diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..eca7ae0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,22 @@ +# Build artefacts (lead-dot AND no-dot variants both seen in zig) +**/zig-cache/ +**/.zig-cache/ +**/zig-out/ +**/.zig-out/ +**/node_modules/ +**/.cache/ +**/_build/ +**/target/ +**/dist/ + +# OS junk +.DS_Store +*.swp +*~ + +# Local env +.env.local +*.local.json + +# Cartridge-side runtime +**/*.log diff --git a/README.md b/README.md index 451724f..1c16252 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,81 @@ + + + # boj-server-cartridges -Canonical home of BoJ cartridges. Hosts (boj-server, panll) fetch from here on demand. + +Canonical home of **BoJ cartridges**. Hosts (`boj-server`, `panll`, others) fetch cartridges from here on demand into a host-local cache; this repository ships the source tree. + +## Status + +🟒 **v0.1 β€” populated from boj-server 2026-05-26.** Initial migration of 125 cartridges out of `boj-server/cartridges/` into this dedicated repository. Boj-server's catalog refactor to fetch from here is tracked separately. + +## What is a cartridge? + +See the canonical spec: [standards/cartridges/CARTRIDGE-FORMAT.adoc](https://github.com/hyperpolymath/standards/blob/main/cartridges/CARTRIDGE-FORMAT.adoc) and JSON schema: [cartridge-v1.json](https://github.com/hyperpolymath/standards/blob/main/cartridges/cartridge-v1.json). + +A cartridge is a self-contained server unit consumed by a host to extend its tool surface (MCP), language-server reach (LSP), debug-adapter capabilities (DAP), build-tool integration (BSP), or other server-role mode. Cartridges are process-isolated (each backend listens on its own loopback port) and content-addressable. + +## Taxonomy + +Hybrid layout ratified in [docs/decisions/ADR-001-taxonomy.adoc](docs/decisions/ADR-001-taxonomy.adoc): + +``` +cartridges/ +β”œβ”€β”€ domains/ ← cartridges grouped by functional domain +β”‚ β”œβ”€β”€ cloud/ ← 10 cartridges (umbrella + 9 providers) +β”‚ β”œβ”€β”€ database/ ← 12 cartridges (umbrella + 11 providers) +β”‚ β”œβ”€β”€ ci-cd/, languages/, security/, research/, … (30 domains total) +β”œβ”€β”€ cross-cutting/ ← cartridges not bound to a single domain +β”‚ β”œβ”€β”€ agentic/ ← agent-mcp, claude-ai-mcp, model-router-mcp, … +β”‚ β”œβ”€β”€ nesy/ ← nesy-mcp, ml-mcp +β”‚ β”œβ”€β”€ build/ ← bsp-mcp (generic BSP server) +β”‚ β”œβ”€β”€ debug/ ← dap-mcp (generic DAP server) +β”‚ β”œβ”€β”€ fleet/ ← fleet-mcp (orchestration) +β”‚ └── health/ ← boj-health +└── templates/ ← canonical scaffolds for new cartridges + └── gossamer-mcp/ ← reference template +``` + +## Cartridge roles + +A cartridge name ends in a canonical role suffix: + +| Suffix | Role | +|---|---| +| `-mcp` | Model Context Protocol | +| `-lsp` | Language Server Protocol | +| `-dap` | Debug Adapter Protocol | +| `-bsp` | Build Server Protocol | +| `-debug` | Debugger (when not strictly DAP) | +| `-format` | Code formatter | +| `-lint` | Linter / static analyser | +| `-build` | Build orchestration | +| `-nesy` | Neurosymbolic reasoning | +| `-agentic` | Agent harness | +| `-fleet` | Fleet orchestrator | + +A single domain may have multiple cartridges across roles, e.g. `database-mcp` + `database-lsp` + `database-format`. + +## Schema + +`schemas/cartridge-v1.json` mirrors the canonical spec at [hyperpolymath/standards](https://github.com/hyperpolymath/standards/blob/main/cartridges/cartridge-v1.json). The mirror is SHA-pinned via [schemas/SCHEMA-MIRROR.md](schemas/SCHEMA-MIRROR.md). + +## Versioning + on-demand fetch + +Each cartridge directory contains its own `cartridge.json` with `version` (semver). Hosts fetch cartridges by name + version; the tray UI (`hyperpolymath/boj-server`) exposes "Add cartridge source" to point at this registry (the canonical default) or any other GitHub URL. + +## Inventory (this initial commit) + +- **125 cartridges** copied from `boj-server/cartridges/` (snapshot 2026-05-26). +- **30 functional domains** + **6 cross-cutting categories** + **1 template**. +- All cartridges retain their original `cartridge.json` manifests. A separate follow-up PR will add the new required `category` field to each manifest. + +## Contributing + +1. Use the **gossamer-mcp** template as your starting point: `cp -r cartridges/templates/gossamer-mcp cartridges/domains//` (or use the minter tool once landed). +2. Update the manifest to reflect your cartridge's name (role-suffixed), domain, protocols, tools. +3. Open a PR; auto-merge is enabled by default for this repo. + +## License + +[MPL-2.0](LICENSE). Cartridges retain their individual SPDX identifiers per `cartridge.json`. diff --git a/cartridges/cross-cutting/agentic/agent-mcp/AGENT-MCP-REPORT.adoc b/cartridges/cross-cutting/agentic/agent-mcp/AGENT-MCP-REPORT.adoc new file mode 100644 index 0000000..7f6bbb4 --- /dev/null +++ b/cartridges/cross-cutting/agentic/agent-mcp/AGENT-MCP-REPORT.adoc @@ -0,0 +1,277 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) += Agent-MCP Cartridge β€” Technical Report +Jonathan D.A. Jewell +:toc: left +:toclevels: 3 +:icons: font +:source-highlighter: rouge + +== Executive Summary + +Agent-MCP is a formally verified agentic orchestration cartridge for the BoJ (Bundle of Joy) server. It enforces Boyd's OODA loop (Observe β†’ Orient β†’ Decide β†’ Act) as a dependent-type state machine, making it *impossible* for an AI agent to act without first observing, orienting, and deciding. This prevents the "shoot first, think later" anti-pattern that plagues autonomous AI systems. + +The cartridge is accessible via *9 protocol transports*: REST, gRPC, GraphQL, WebSocket, JSON-RPC 2.0, MQTT, tRPC, SOAP, and Cap'n Proto β€” supporting everything from web dashboards to multi-agent swarms. + +== Architecture + +=== The ABI-FFI-Adapter Triple + +[source] +---- + Idris2 ABI (SafeOODA.idr, Protocol.idr) + β”‚ ValidOODA proof: impossible to skip stages + β”‚ Dependent types enforce state machine correctness + β–Ό + Zig FFI (agent_ffi.zig) + β”‚ 32 concurrent sessions, mutex-protected + β”‚ 30+ tests, C-ABI exports + β–Ό + zig Adapters (9 protocol servers) + β”‚ REST, gRPC, GraphQL, WebSocket, JSON-RPC, + β”‚ MQTT, tRPC, SOAP, Cap'n Proto + β–Ό + AI Agents (Claude, Gemini, custom agents, bot fleets) +---- + +=== Why This Architecture Matters + +* The *Idris2 layer* makes invalid state transitions a compile-time error. There is no `ValidOODA Observe Act` constructor β€” the type system physically prevents it. +* The *Zig layer* manages 32 concurrent agent sessions with mutex-protected state, ensuring thread safety under load. +* The *zig layer* exposes session management through every protocol pattern, from request/response to pub/sub to binary RPC. + +== Core Logic + +=== The OODA State Machine + +Every AI agent session follows a strict cycle: + +[source] +---- + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ OBSERVE β”‚ ◄─── Gather data, read context + β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”˜ + β”‚ (only valid transition) + β”Œβ”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β” + β”‚ ORIENT β”‚ ◄─── Analyse, interpret, contextualise + β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”˜ + β”‚ (only valid transition) + β”Œβ”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β” + β”‚ DECIDE β”‚ ◄─── Choose action, plan execution + β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”˜ + β”‚ (only valid transition) + β”Œβ”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β” + β”‚ ACT β”‚ ◄─── Execute the decision + β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”˜ + β”‚ (loops back) + └──────────► OBSERVE (new loop, loopCount++) + + Any state ──► HALTED (emergency stop) + HALTED ──► OBSERVE (resume) +---- + +=== Valid Transitions (Idris2 Proof) + +[cols="1,1,1", options="header"] +|=== +| From | To | Allowed? + +| Observe | Orient | *Yes* β€” must analyse before deciding +| Observe | Decide | *No* β€” cannot skip analysis +| Observe | Act | *No* β€” cannot skip analysis AND decision +| Orient | Decide | *Yes* β€” must decide before acting +| Orient | Act | *No* β€” cannot skip decision +| Decide | Act | *Yes* β€” execute the decision +| Act | Observe | *Yes* β€” new loop begins +| Any | Halted | *Yes* β€” emergency stop always available +| Halted | Observe | *Yes* β€” resume with fresh observation +|=== + +The `ValidOODA` dependent type in Idris2 has exactly 9 constructors β€” one for each valid transition. Any other transition is a *type error at compile time*. + +=== Tool Call Safety + +Not all agent actions are equal. Agent-MCP classifies tool calls by risk: + +[cols="1,1,1", options="header"] +|=== +| Tool Call Type | Has Side Effects? | Requires Safety Check? + +| Execute | Yes | *Yes* +| Query | No | No +| Transform | No | No +| Communicate | Yes | No +| Delegate | Yes | *Yes* +| Escalate | Yes | *Yes* +|=== + +=== Safety Gate + +Before execution, a safety check determines whether to proceed: + +[cols="1,1,1", options="header"] +|=== +| Safety Outcome | Allows Execution? | Needs Human? + +| Approved | *Yes* | No +| Denied | No | No +| Escalated | No | *Yes* +| Timeout | No | No +| Sandboxed | *Yes* (isolated) | No +| Human Required | No | *Yes* +|=== + +=== Multi-Agent Coordination + +[cols="1,1", options="header"] +|=== +| Strategy | Description + +| Solo | Single agent, no coordination +| Collaborative | Agents share information and goals +| Competitive | Agents compete for best solution +| Hierarchical | Manager agent delegates to workers +| Swarm | Emergent behaviour from simple rules +| Consensus | Agents vote before acting +|=== + +=== Agent Memory Types + +[cols="1,1,1", options="header"] +|=== +| Memory Type | Persistent? | Scope + +| Working | No | Current session only +| Episodic | Yes | Event history +| Semantic | Yes | Knowledge base +| Procedural | Yes | Skills and procedures +| Shared | Yes | Across all agents +|=== + +== Protocol Transports + +=== 9 Protocols Available + +[cols="1,1,2", options="header"] +|=== +| Protocol | Adapter File | Best For + +| *REST* | `agent_adapter.v` | Standard web APIs, Claude MCP bridge +| *gRPC* | `agent_grpc.v` | Microservice agent orchestration +| *GraphQL* | `agent_graphql.v` | Agent dashboards, flexible session queries +| *WebSocket* | `agent_websocket.v` | Real-time OODA state streaming, live monitoring +| *JSON-RPC 2.0* | `agent_jsonrpc.v` | MCP native protocol, batch operations +| *MQTT* | `agent_mqtt.v` | Distributed agent swarms, edge agents +| *tRPC* | `agent_trpc.v` | Type-safe frontend agent controls +| *SOAP* | `agent_soap.v` | Enterprise workflow integration +| *Cap'n Proto* | `agent_capnproto.v` | High-performance multi-agent coordination +|=== + +=== Protocol Selection Guide + +* *Building an agent dashboard?* β†’ GraphQL or WebSocket +* *Orchestrating a bot fleet?* β†’ gRPC or MQTT +* *Connecting from an MCP client?* β†’ JSON-RPC (native MCP protocol) +* *Running agents on edge devices?* β†’ MQTT +* *Enterprise workflow integration?* β†’ SOAP +* *Maximum throughput agent swarm?* β†’ Cap'n Proto or gRPC +* *Simple single-agent integration?* β†’ REST + +== Specific Agentic Server Types + +=== 1. OODA Compliance Gateway + +The most common deployment: every AI agent in an organisation must register a session with Agent-MCP before it can act. The gateway enforces that no agent skips the Observe β†’ Orient β†’ Decide sequence. + +*Example flow*: Claude Code starts a task β†’ creates OODA session β†’ Observes (reads code) β†’ Orients (analyses patterns) β†’ Decides (plans changes) β†’ Acts (writes code) β†’ loops back to Observe. + +=== 2. Multi-Agent Swarm Controller + +Uses MQTT to coordinate dozens of agents working on the same problem. Each agent has its own OODA session; the controller uses the `Consensus` coordination strategy to aggregate decisions before any agent acts. + +*Example*: 6 bots in the gitbot-fleet (Rhodibot, Echidnabot, Sustainabot, Panicbot, Glambot, Seambot) each run independent OODA loops, but coordinate via Agent-MCP before making changes to shared repositories. + +=== 3. Safety-Critical Agent Sandbox + +For high-risk agent actions (code deployment, database migrations, financial transactions), Agent-MCP gates every `Execute` tool call through the safety check. Only `Approved` or `Sandboxed` outcomes allow execution. + +*Example*: AI-assisted database migration β€” agent Observes schema, Orients by checking foreign keys, Decides on migration plan, but Act is blocked until safety check returns `Approved` (human reviews the plan). + +=== 4. Hierarchical Agent Orchestrator + +Uses gRPC for high-throughput manager β†’ worker delegation. The manager agent runs its own OODA loop, and in the `Act` phase, delegates sub-tasks to worker agents, each with their own enforced OODA sessions. + +*Example*: Project manager agent decomposes a feature request β†’ delegates sub-tasks to coding agent, testing agent, documentation agent β†’ each worker follows its own OODA cycle β†’ results flow back to manager's next Observe phase. + +=== 5. Agent Audit Trail + +Every OODA transition is a logged event. Using WebSocket streaming, a compliance dashboard shows real-time agent activity: which agents are in which OODA phase, how many loops they've completed, whether any have been halted. + +*Example*: Regulatory compliance β€” financial institution must prove that every AI decision followed a structured process. Agent-MCP's OODA log provides a formal audit trail with cryptographic attestation. + +=== 6. Adaptive Agent Learning + +Uses the `Episodic` and `Procedural` memory types to build agent skills over time. After each OODA loop, the agent stores what worked (episodic memory) and extracts reusable procedures (procedural memory). + +*Example*: DevOps agent learns from incident response β€” each incident is an OODA loop, the agent builds procedural memory of "when X alert fires, check Y then Z", getting faster with each iteration. + +== Value Beyond Current Implementation + +=== What We Have Now + +* Dependent-type proof that OODA stages cannot be skipped +* 32 concurrent agent sessions with thread-safe management +* 9 protocol transports for universal accessibility +* Tool call classification with safety gates +* Multi-agent coordination strategies +* Cognitive memory type system + +=== What This Enables That Nothing Else Does + +1. *Provably Safe Agents*: No other agent framework mathematically guarantees that agents follow a structured decision loop. Agent-MCP's `ValidOODA` proof type makes "act without thinking" a *type error*, not a runtime bug. + +2. *Universal Agent Protocol*: The same OODA enforcement works over MQTT (for a Raspberry Pi agent), WebSocket (for a browser-based agent), gRPC (for a Kubernetes agent), and SOAP (for a bank's agent system) β€” all with the same formal guarantees. + +3. *Safety as Architecture*: Tool call classification and safety gates are not config β€” they are type-level properties. The system knows that `Execute` has side effects and `Query` doesn't, and enforces safety checks accordingly. + +4. *Coordination Without Chaos*: The 6 coordination strategies are not just labels β€” they prescribe how agents interact. `Consensus` means agents vote before acting; `Hierarchical` means only the manager can delegate. The protocol enforces this. + +5. *Memory as a Type System*: The 5 memory types aren't just storage categories β€” `Working` memory is explicitly non-persistent (disappears with the session), while `Shared` memory is explicitly cross-agent. This prevents accidental state leakage. + +== Roadmap + +=== Short-Term (v0.3.0) + +* [ ] *Session persistence*: Save/restore agent sessions across restarts (currently in-memory only) +* [ ] *OODA metrics*: Track average time per phase, loop completion rate, halt frequency +* [ ] *WebSocket state streaming*: Subscribe to specific session IDs for targeted monitoring +* [ ] *Tool call logging*: Record every tool invocation with timestamp, inputs, outputs, safety check result +* [ ] *MQTT retained messages*: Last-known state available to new subscribers + +=== Medium-Term (v0.4.0) + +* [ ] *Plan execution engine*: The 7 `PlanStep` types (Action, Condition, Loop, Branch, Parallel, Checkpoint, Rollback) become executable β€” agents submit plans, Agent-MCP validates and executes them step by step +* [ ] *Agent capability model*: Define what tools each agent is allowed to use (per-session capability sets) +* [ ] *Cross-session coordination*: Agents in different sessions can coordinate without sharing state (message-passing only) +* [ ] *Rollback support*: If an `Act` fails, automatically roll back to the last `Checkpoint` in the plan +* [ ] *GraphQL subscriptions*: Real-time OODA state changes via GraphQL subscription +* [ ] *Cap'n Proto streaming*: Bidirectional streaming for high-throughput swarm coordination + +=== Long-Term (v1.0) + +* [ ] *Formal verification of coordination*: Prove that `Consensus` coordination never deadlocks and always terminates (using Idris2 totality checker) +* [ ] *Agent identity and attestation*: Each agent cryptographically signs its OODA transitions, creating a tamper-proof audit trail +* [ ] *OODA composition*: Nest OODA loops β€” an `Act` phase can contain a sub-OODA cycle for fine-grained decision-making +* [ ] *Temporal logic properties*: Prove liveness (agents always eventually act) and safety (agents never act without deciding) using CTL model checking +* [ ] *Agent marketplace*: Community-contributed agent templates with pre-defined OODA patterns, tool call sets, and coordination strategies +* [ ] *Cross-cartridge agent workflows*: Agent-MCP orchestrates multi-cartridge workflows (e.g., agent uses NeSy-MCP for decision verification, Database-MCP for data access, Git-MCP for code changes β€” all within a single OODA loop) +* [ ] *Self-improving agents*: Agents analyse their own OODA loop performance and suggest optimisations to their observation and orientation strategies + +=== Protocol Evolution + +* [ ] *HTTP/2 native gRPC*: Upgrade from JSON-over-HTTP to full Protobuf transport via Zig FFI +* [ ] *QUIC transport*: UDP-based low-latency transport for swarm gossip +* [ ] *NATS/JetStream*: Cloud-native messaging for distributed agent coordination +* [ ] *OpenTelemetry integration*: Emit OODA transitions as OTel spans with distributed trace context +* [ ] *MCP native transport*: Direct MCP stdio bridge without HTTP intermediary +* [ ] *Agent-to-Agent protocol*: Purpose-built binary protocol for direct agent communication without broker overhead diff --git a/cartridges/cross-cutting/agentic/agent-mcp/README.adoc b/cartridges/cross-cutting/agentic/agent-mcp/README.adoc new file mode 100644 index 0000000..100398b --- /dev/null +++ b/cartridges/cross-cutting/agentic/agent-mcp/README.adoc @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) += agent-mcp +Jonathan D.A. Jewell +:spdx: MPL-2.0 +:tier: Ayo +:domain: ai +:protocols: MCP, REST + +== Overview + +OODA loop agent session enforcer + +== Tools (7) + +[cols="2,4"] +|=== +| Tool | Description + +| `agent_new_session` | Open a new OODA agent session +| `agent_end_session` | End an agent session +| `agent_transition` | Transition agent OODA state +| `agent_state` | Get current OODA state of a session +| `agent_loop_count` | Get OODA loop iteration count +| `agent_validate_ooda` | Validate an OODA state transition +| `agent_reset` | Reset all agent sessions +|=== + +== Architecture + +Three-layer stack per the BoJ cartridge standard: + +[cols="1,1,3"] +|=== +| Layer | Language | Purpose + +| ABI | Idris2 | Formally verified state machine with dependent-type proofs +| FFI | Zig | C-ABI implementation: session pool, action metrics, thread safety +| Adapter| Zig | Adapter bridge to the BoJ unified adapter protocol (zig predecessors sidelined) +|=== + +== Building + +[source,bash] +---- +# ABI (Idris2) +cd abi && idris2 --check *.idr + +# FFI (Zig) +cd ffi && zig build +cd ffi && zig build test + +# Adapter (Zig) +cd adapter && zig build +---- + +== Status + +Alpha (CRG D). Cartridge metadata declared in `cartridge.json`; 7 tool(s) exposed. +Not yet dogfooded externally. See `docs/READINESS.md` at repo root for the +project-wide grade table and `docs/practice/DOGFOOD-LOG.adoc` for usage evidence. diff --git a/cartridges/cross-cutting/agentic/agent-mcp/abi/AgentMcp/Protocol.idr b/cartridges/cross-cutting/agentic/agent-mcp/abi/AgentMcp/Protocol.idr new file mode 100644 index 0000000..070d59d --- /dev/null +++ b/cartridges/cross-cutting/agentic/agent-mcp/abi/AgentMcp/Protocol.idr @@ -0,0 +1,219 @@ +-- SPDX-License-Identifier: MPL-2.0 +-- Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +||| AgentMcp.Protocol: Full agentic protocol types for the agent-mcp cartridge. +||| +||| Extends SafeOODA (the OODA loop enforcement) with the comprehensive +||| proven-agentic protocol type system. These types mirror the ABI +||| definitions in proven-servers/protocols/proven-agentic exactly. +||| +||| SafeOODA handles the CONTROL FLOW (Observeβ†’Orientβ†’Decideβ†’Act). +||| Protocol handles the SEMANTICS (tool kinds, plan structure, coordination, +||| safety checks, memory systems). +module AgentMcp.Protocol + +import AgentMcp.SafeOODA + +%default total + +-- ═══════════════════════════════════════════════════════════════════════════ +-- ToolCall β€” what kind of tool invocation an agent can make +-- (from proven-agentic Agentic.Types) +-- ═══════════════════════════════════════════════════════════════════════════ + +||| Classification of tool calls. Used during the Act phase of OODA +||| to categorise what the agent is doing, enabling safety policy enforcement. +public export +data ToolCall : Type where + Execute : ToolCall + Query : ToolCall + Transform : ToolCall + Communicate : ToolCall + Delegate : ToolCall + Escalate : ToolCall + +public export +Show ToolCall where + show Execute = "Execute" + show Query = "Query" + show Transform = "Transform" + show Communicate = "Communicate" + show Delegate = "Delegate" + show Escalate = "Escalate" + +||| Whether this tool call has side effects (affects external state). +public export +hasSideEffects : ToolCall -> Bool +hasSideEffects Execute = True +hasSideEffects Communicate = True +hasSideEffects Delegate = True +hasSideEffects Escalate = True +hasSideEffects _ = False + +||| Whether this tool call requires a safety pre-check. +public export +requiresSafetyCheck : ToolCall -> Bool +requiresSafetyCheck Execute = True +requiresSafetyCheck Delegate = True +requiresSafetyCheck Escalate = True +requiresSafetyCheck _ = False + +-- ═══════════════════════════════════════════════════════════════════════════ +-- PlanStep β€” node type in an execution plan +-- (from proven-agentic Agentic.Types) +-- ═══════════════════════════════════════════════════════════════════════════ + +||| A node in the agent's execution plan. Plans are built during the +||| Decide phase and executed during the Act phase. +public export +data PlanStep : Type where + Action : PlanStep + Condition : PlanStep + Loop : PlanStep + Branch : PlanStep + Parallel : PlanStep + Checkpoint : PlanStep + Rollback : PlanStep + +public export +Show PlanStep where + show Action = "Action" + show Condition = "Condition" + show Loop = "Loop" + show Branch = "Branch" + show Parallel = "Parallel" + show Checkpoint = "Checkpoint" + show Rollback = "Rollback" + +-- ═══════════════════════════════════════════════════════════════════════════ +-- Coordination β€” multi-agent coordination strategy +-- (from proven-agentic Agentic.Types) +-- ═══════════════════════════════════════════════════════════════════════════ + +public export +data Coordination : Type where + Solo : Coordination + Collaborative : Coordination + Competitive : Coordination + Hierarchical : Coordination + Swarm : Coordination + Consensus : Coordination + +public export +Show Coordination where + show Solo = "Solo" + show Collaborative = "Collaborative" + show Competitive = "Competitive" + show Hierarchical = "Hierarchical" + show Swarm = "Swarm" + show Consensus = "Consensus" + +-- ═══════════════════════════════════════════════════════════════════════════ +-- SafetyCheck β€” outcome of a safety evaluation before an action +-- (from proven-agentic Agentic.Types) +-- ═══════════════════════════════════════════════════════════════════════════ + +||| Result of a safety check. Integrates with the OODA loop: an agent +||| in the Decideβ†’Act transition must pass this check first. +public export +data SafetyCheck : Type where + Approved : SafetyCheck + Denied : SafetyCheck + Escalated : SafetyCheck + Timeout : SafetyCheck + Sandboxed : SafetyCheck + HumanRequired : SafetyCheck + +public export +Show SafetyCheck where + show Approved = "Approved" + show Denied = "Denied" + show Escalated = "Escalated" + show Timeout = "Timeout" + show Sandboxed = "Sandboxed" + show HumanRequired = "HumanRequired" + +||| Whether the action may proceed (possibly with constraints). +public export +allowsExecution : SafetyCheck -> Bool +allowsExecution Approved = True +allowsExecution Sandboxed = True +allowsExecution _ = False + +-- ═══════════════════════════════════════════════════════════════════════════ +-- MemoryType β€” cognitive memory classification +-- (from proven-agentic Agentic.Types) +-- ═══════════════════════════════════════════════════════════════════════════ + +public export +data MemoryType : Type where + Working : MemoryType + Episodic : MemoryType + Semantic : MemoryType + Procedural : MemoryType + Shared : MemoryType + +public export +Show MemoryType where + show Working = "Working" + show Episodic = "Episodic" + show Semantic = "Semantic" + show Procedural = "Procedural" + show Shared = "Shared" + +-- ═══════════════════════════════════════════════════════════════════════════ +-- C-ABI Encoding β€” integer encodings for FFI bridge +-- ═══════════════════════════════════════════════════════════════════════════ + +public export +toolCallToInt : ToolCall -> Int +toolCallToInt Execute = 0 +toolCallToInt Query = 1 +toolCallToInt Transform = 2 +toolCallToInt Communicate = 3 +toolCallToInt Delegate = 4 +toolCallToInt Escalate = 5 + +public export +intToToolCall : Int -> ToolCall +intToToolCall 0 = Execute +intToToolCall 1 = Query +intToToolCall 2 = Transform +intToToolCall 3 = Communicate +intToToolCall 4 = Delegate +intToToolCall _ = Escalate + +public export +safetyCheckToInt : SafetyCheck -> Int +safetyCheckToInt Approved = 0 +safetyCheckToInt Denied = 1 +safetyCheckToInt Escalated = 2 +safetyCheckToInt Timeout = 3 +safetyCheckToInt Sandboxed = 4 +safetyCheckToInt HumanRequired = 5 + +public export +intToSafetyCheck : Int -> SafetyCheck +intToSafetyCheck 0 = Approved +intToSafetyCheck 1 = Denied +intToSafetyCheck 2 = Escalated +intToSafetyCheck 3 = Timeout +intToSafetyCheck 4 = Sandboxed +intToSafetyCheck _ = HumanRequired + +||| FFI: Check if a tool call has side effects. +export +agent_tool_has_side_effects : Int -> Int +agent_tool_has_side_effects t = + if hasSideEffects (intToToolCall t) then 1 else 0 + +||| FFI: Check if a tool call requires a safety pre-check. +export +agent_tool_requires_safety : Int -> Int +agent_tool_requires_safety t = + if requiresSafetyCheck (intToToolCall t) then 1 else 0 + +||| FFI: Check if a safety check outcome allows execution. +export +agent_safety_allows_exec : Int -> Int +agent_safety_allows_exec s = + if allowsExecution (intToSafetyCheck s) then 1 else 0 diff --git a/cartridges/cross-cutting/agentic/agent-mcp/abi/AgentMcp/SafeOODA.idr b/cartridges/cross-cutting/agentic/agent-mcp/abi/AgentMcp/SafeOODA.idr new file mode 100644 index 0000000..d7a6a3b --- /dev/null +++ b/cartridges/cross-cutting/agentic/agent-mcp/abi/AgentMcp/SafeOODA.idr @@ -0,0 +1,148 @@ +-- SPDX-License-Identifier: MPL-2.0 +-- Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +||| AgentMcp.SafeOODA: Formally verified autonomous agent loop. +||| +||| Cartridge: agent-mcp +||| Matrix cell: (future Agentic domain) x {MCP, Agentic, gRPC} protocols +||| +||| Implements Boyd's OODA loop (Observe β†’ Orient β†’ Decide β†’ Act) as a +||| dependent type state machine. The key guarantee: an agent CANNOT +||| jump from Observe to Act β€” it must pass through Orient and Decide. +||| +||| This prevents "shoot first, think later" agent behaviour where an +||| AI acts on raw observations without analysis or planning. +||| +||| The state machine is cyclic: Act β†’ Observe starts a new loop. +||| Emergency stops are modelled as a separate Halted state. +module AgentMcp.SafeOODA + +%default total + +-- ═══════════════════════════════════════════════════════════════════════════ +-- OODA States +-- ═══════════════════════════════════════════════════════════════════════════ + +||| The OODA loop states plus a Halted state for emergency stops. +public export +data AgentState + = Observe -- Gathering data from environment + | Orient -- Analysing data, building situational awareness + | Decide -- Choosing a course of action + | Act -- Executing the chosen action + | Halted -- Emergency stop (any state can transition here) + +||| Equality for agent states. +public export +Eq AgentState where + Observe == Observe = True + Orient == Orient = True + Decide == Decide = True + Act == Act = True + Halted == Halted = True + _ == _ = False + +-- ═══════════════════════════════════════════════════════════════════════════ +-- Valid Transitions (the proof) +-- ═══════════════════════════════════════════════════════════════════════════ + +||| Proof that a state transition is valid. +||| This is the core safety guarantee β€” these are the ONLY legal transitions. +||| An agent cannot construct a proof for Observe β†’ Act, so the type system +||| prevents it at compile time. +public export +data ValidOODA : AgentState -> AgentState -> Type where + ObsToOri : ValidOODA Observe Orient -- Must analyse before deciding + OriToDec : ValidOODA Orient Decide -- Must decide before acting + DecToAct : ValidOODA Decide Act -- Execute the decision + ActToObs : ValidOODA Act Observe -- New loop starts with observation + -- Emergency halt from any active state + ObsHalt : ValidOODA Observe Halted + OriHalt : ValidOODA Orient Halted + DecHalt : ValidOODA Decide Halted + ActHalt : ValidOODA Act Halted + -- Resume from halt (back to Observe β€” start fresh) + HaltToObs : ValidOODA Halted Observe + +||| Runtime transition validator. +||| Returns True only for transitions that have a corresponding proof above. +public export +canTransition : AgentState -> AgentState -> Bool +canTransition Observe Orient = True +canTransition Orient Decide = True +canTransition Decide Act = True +canTransition Act Observe = True +canTransition Observe Halted = True +canTransition Orient Halted = True +canTransition Decide Halted = True +canTransition Act Halted = True +canTransition Halted Observe = True +canTransition _ _ = False + +-- ═══════════════════════════════════════════════════════════════════════════ +-- Agent Session +-- ═══════════════════════════════════════════════════════════════════════════ + +||| An agent session tracks the current state and loop count. +public export +record AgentSession where + constructor MkSession + agentId : String + state : AgentState + loopCount : Nat -- How many complete OODA loops so far + halted : Bool -- Has this session ever been halted? + +||| Create a new session (always starts at Observe). +public export +newSession : String -> AgentSession +newSession aid = MkSession aid Observe 0 False + +||| Attempt a state transition. Returns the new session if valid. +public export +transition : AgentSession -> AgentState -> Maybe AgentSession +transition session newState = + if canTransition (state session) newState + then let loops = if state session == Act && newState == Observe + then S (loopCount session) + else loopCount session + wasHalted = halted session || newState == Halted + in Just (MkSession (agentId session) newState loops wasHalted) + else Nothing + +-- ═══════════════════════════════════════════════════════════════════════════ +-- C-ABI Exports +-- ═══════════════════════════════════════════════════════════════════════════ + +||| State to integer. +public export +stateToInt : AgentState -> Int +stateToInt Observe = 1 +stateToInt Orient = 2 +stateToInt Decide = 3 +stateToInt Act = 4 +stateToInt Halted = 5 + +||| Integer to state (safe default: Halted). +public export +intToState : Int -> AgentState +intToState 1 = Observe +intToState 2 = Orient +intToState 3 = Decide +intToState 4 = Act +intToState _ = Halted + +||| FFI: Validate a state transition. +||| Returns 1 if valid, 0 if invalid. +export +agent_validate_ooda : Int -> Int -> Int +agent_validate_ooda from to = + if canTransition (intToState from) (intToState to) then 1 else 0 + +||| FFI: Get the next valid state in the standard OODA sequence. +||| Returns the integer encoding of the next state, or 5 (Halted) if stuck. +export +agent_next_state : Int -> Int +agent_next_state 1 = 2 -- Observe -> Orient +agent_next_state 2 = 3 -- Orient -> Decide +agent_next_state 3 = 4 -- Decide -> Act +agent_next_state 4 = 1 -- Act -> Observe (new loop) +agent_next_state _ = 5 -- Halted or unknown -> Halted diff --git a/cartridges/cross-cutting/agentic/agent-mcp/abi/README.adoc b/cartridges/cross-cutting/agentic/agent-mcp/abi/README.adoc new file mode 100644 index 0000000..8f0b467 --- /dev/null +++ b/cartridges/cross-cutting/agentic/agent-mcp/abi/README.adoc @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) += agent-mcp / abi β€” Idris2 ABI layer +:orientation: shallow + +== Purpose + +Cartridge-local protocol types for `agent-mcp`. Defines the data, tool, and +transition vocabulary this cartridge speaks. Inherits safety lemmas and the +matrix-cell identifier scheme from the trunk ABI at `src/abi/`. + +== Files + +2 Idris2 module(s), ~367 lines total. Lead module: +`Protocol.idr`. + +No `.ipkg` β€” consumed directly by wider BoJ callers; type-checks inside the +trunk `boj.ipkg`. + +== Invariants + +* `%default total` at every module top. +* Zero `believe_me`, `assert_total`, `postulate`, `Admitted`, or + `sorry` (the trunk holds the four declared `believe_me` sites; leaf + cartridges carry none). + +== Test/proof surface + +Thin module(s), no inline tests. Proof surface = type-check success under the +trunk `idris2 --check`. + +== Read-first + +. `Protocol.idr` β€” cartridge-local data records and enums. +. Trunk `src/abi/Boj/Catalogue.idr` for how this cell participates in the + 2D matrix. diff --git a/cartridges/cross-cutting/agentic/agent-mcp/abi/agent-mcp.ipkg b/cartridges/cross-cutting/agentic/agent-mcp/abi/agent-mcp.ipkg new file mode 100644 index 0000000..4d48411 --- /dev/null +++ b/cartridges/cross-cutting/agentic/agent-mcp/abi/agent-mcp.ipkg @@ -0,0 +1,12 @@ +-- SPDX-License-Identifier: MPL-2.0 +package agentmcp + +authors = "Jonathan D.A. Jewell" +version = 0.2.0 +license = "MPL-2.0" +brief = "Agent MCP cartridge β€” OODA loop + full agentic protocol types" + +sourcedir = "." +modules = AgentMcp.SafeOODA + , AgentMcp.Protocol +depends = base diff --git a/cartridges/cross-cutting/agentic/agent-mcp/adapter/README.adoc b/cartridges/cross-cutting/agentic/agent-mcp/adapter/README.adoc new file mode 100644 index 0000000..150dac3 --- /dev/null +++ b/cartridges/cross-cutting/agentic/agent-mcp/adapter/README.adoc @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) += agent-mcp / adapter β€” protocol bridge +:orientation: shallow + +== Purpose + +Exposes this cartridge's FFI over the wire protocols declared in +`cartridge.json`. Dispatches tool invocations to the C-ABI exports at +`../ffi/` and formats responses per protocol. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” depends on the sibling `ffi` target. +| `agent_adapter.zig` | Protocol dispatch (144 lines). Tool catalogue matches `cartridge.json`. +| `SIDELINED-*.v.adoc` | zig predecessor(s). Not built β€” zig banned 2026-04-10. +|=== + +== Invariants + +* **Stateless** β€” all state lives behind the FFI, never in the adapter. +* **Response passthrough** β€” whatever the FFI returns goes back to the wire + unmodified (no embellishment, no silent recovery). +* **Cartridge.json is source of truth** for the tool catalogue; drift between + adapter and manifest is a CI failure. + +== Test/proof surface + +No inline tests at the adapter layer β€” protocol routing is exercised by the +trunk seam tests at `ffi/zig/src/seams.zig`. + +== Read-first + +. `agent_adapter.zig` β€” dispatch function and per-protocol routers. +. `../cartridge.json` β€” the tool manifest the dispatcher mirrors. diff --git a/cartridges/cross-cutting/agentic/agent-mcp/adapter/agent_adapter.zig b/cartridges/cross-cutting/agentic/agent-mcp/adapter/agent_adapter.zig new file mode 100644 index 0000000..804f354 --- /dev/null +++ b/cartridges/cross-cutting/agentic/agent-mcp/adapter/agent_adapter.zig @@ -0,0 +1,144 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +// +// agent-mcp/adapter/agent_adapter.zig +// +// Three-protocol BoJ adapter: REST (port 9199), gRPC-compat (port 9200), +// GraphQL (port 9201). +// Replaces the banned zig adapter (agent_adapter.v). + +const std = @import("std"); +const ffi = @import("agent_ffi"); + +const REST_PORT: u16 = 9199; +const GRPC_PORT: u16 = 9200; +const GQL_PORT: u16 = 9201; + +const Response = struct { status: u16, body: []const u8 }; + +fn okJson(buf: []u8, msg: []const u8) []u8 { + return std.fmt.bufPrint(buf, "{{\"success\":true,\"message\":\"{s}\"}}", .{msg}) catch buf[0..0]; +} + +fn errJson(buf: []u8, msg: []const u8) []u8 { + return std.fmt.bufPrint(buf, "{{\"success\":false,\"error\":\"{s}\"}}", .{msg}) catch buf[0..0]; +} + +fn dispatch(tool: []const u8, _body: []const u8, resp: []u8) Response { + _ = _body; + if (std.mem.eql(u8, tool, "agent_new_session")) { + return .{ .status = 200, .body = okJson(resp, "agent_new_session forwarded") }; + } + if (std.mem.eql(u8, tool, "agent_end_session")) { + return .{ .status = 200, .body = okJson(resp, "agent_end_session forwarded") }; + } + if (std.mem.eql(u8, tool, "agent_transition")) { + return .{ .status = 200, .body = okJson(resp, "agent_transition forwarded") }; + } + if (std.mem.eql(u8, tool, "agent_state")) { + return .{ .status = 200, .body = okJson(resp, "agent_state forwarded") }; + } + if (std.mem.eql(u8, tool, "agent_loop_count")) { + return .{ .status = 200, .body = okJson(resp, "agent_loop_count forwarded") }; + } + if (std.mem.eql(u8, tool, "agent_validate_ooda")) { + return .{ .status = 200, .body = okJson(resp, "agent_validate_ooda forwarded") }; + } + if (std.mem.eql(u8, tool, "agent_reset")) { + return .{ .status = 200, .body = okJson(resp, "agent_reset forwarded") }; + } + return .{ .status = 404, .body = errJson(resp, "unknown tool") }; +} + +fn dispatchRest(path: []const u8, body: []const u8, resp: []u8) Response { + // Expect /tools/ + const prefix = "/tools/"; + if (std.mem.startsWith(u8, path, prefix)) { + const tool = path[prefix.len..]; + return dispatch(tool, body, resp); + } + return .{ .status = 404, .body = errJson(resp, "not found") }; +} + +fn dispatchGrpc(path: []const u8, body: []const u8, resp: []u8) Response { + // Expect // β€” derive tool from Method + var it = std.mem.splitScalar(u8, path, '/'); + _ = it.next(); // leading empty + _ = it.next(); // service + const method = it.next() orelse return .{ .status = 404, .body = errJson(resp, "bad gRPC path") }; + return dispatch(method, body, resp); +} + +fn dispatchGraphql(body: []const u8, resp: []u8) Response { + if (std.mem.indexOf(u8, body, "agent_new_session") != null) + return dispatch("agent_new_session", body, resp); + if (std.mem.indexOf(u8, body, "agent_end_session") != null) + return dispatch("agent_end_session", body, resp); + if (std.mem.indexOf(u8, body, "agent_transition") != null) + return dispatch("agent_transition", body, resp); + if (std.mem.indexOf(u8, body, "agent_state") != null) + return dispatch("agent_state", body, resp); + if (std.mem.indexOf(u8, body, "agent_loop_count") != null) + return dispatch("agent_loop_count", body, resp); + if (std.mem.indexOf(u8, body, "agent_validate_ooda") != null) + return dispatch("agent_validate_ooda", body, resp); + if (std.mem.indexOf(u8, body, "agent_reset") != null) + return dispatch("agent_reset", body, resp); + return .{ .status = 400, .body = errJson(resp, "unrecognised GraphQL operation") }; +} + +fn handleConnection(stream: std.net.Stream, port: u16) void { + defer stream.close(); + var buf: [4096]u8 = undefined; + var resp_buf: [4096]u8 = undefined; + const n = stream.read(&buf) catch return; + const req = buf[0..n]; + const result = switch (port) { + REST_PORT => blk: { + // Parse HTTP/1.1: first line = METHOD PATH HTTP/x.y + var lines = std.mem.splitScalar(u8, req, '\n'); + const first = lines.next() orelse break :blk Response{ .status = 400, .body = "" }; + var parts = std.mem.splitScalar(u8, std.mem.trim(u8, first, "\r"), ' '); + _ = parts.next(); // method + const path = parts.next() orelse break :blk Response{ .status = 400, .body = "" }; + break :blk dispatchRest(path, req, &resp_buf); + }, + GRPC_PORT => blk: { + var lines = std.mem.splitScalar(u8, req, '\n'); + const first = lines.next() orelse break :blk Response{ .status = 400, .body = "" }; + var parts = std.mem.splitScalar(u8, std.mem.trim(u8, first, "\r"), ' '); + _ = parts.next(); + const path = parts.next() orelse break :blk Response{ .status = 400, .body = "" }; + break :blk dispatchGrpc(path, req, &resp_buf); + }, + GQL_PORT => dispatchGraphql(req, &resp_buf), + else => Response{ .status = 500, .body = "" }, + }; + var http_resp: [512]u8 = undefined; + const http = std.fmt.bufPrint(&http_resp, + "HTTP/1.1 {d} OK\r\nContent-Length: {d}\r\nContent-Type: application/json\r\n\r\n", + .{ result.status, result.body.len }) catch return; + _ = stream.write(http) catch {}; + _ = stream.write(result.body) catch {}; +} + +fn listenLoop(port: u16) !void { + const addr = std.net.Address.initIp4(.{ 127, 0, 0, 1 }, port); + var server = try addr.listen(.{ .reuse_address = true }); + defer server.deinit(); + while (true) { + const conn = try server.accept(); + const t = try std.Thread.spawn(.{}, handleConnection, .{ conn.stream, port }); + t.detach(); + } +} + +pub fn main() !void { + ffi.agent_init(); + const t1 = try std.Thread.spawn(.{}, listenLoop, .{REST_PORT}); + const t2 = try std.Thread.spawn(.{}, listenLoop, .{GRPC_PORT}); + const t3 = try std.Thread.spawn(.{}, listenLoop, .{GQL_PORT}); + t1.join(); + t2.join(); + t3.join(); +} diff --git a/cartridges/cross-cutting/agentic/agent-mcp/adapter/build.zig b/cartridges/cross-cutting/agentic/agent-mcp/adapter/build.zig new file mode 100644 index 0000000..0f035bd --- /dev/null +++ b/cartridges/cross-cutting/agentic/agent-mcp/adapter/build.zig @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +// +// agent-mcp/adapter/build.zig + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const ffi_mod = b.createModule(.{ + .root_source_file = b.path("../ffi/agent_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + const adapter = b.addExecutable(.{ + .name = "agent_adapter", + .root_source_file = b.path("agent_adapter.zig"), + .target = target, + .optimize = optimize, + }); + adapter.root_module.addImport("agent_ffi", ffi_mod); + b.installArtifact(adapter); +} diff --git a/cartridges/cross-cutting/agentic/agent-mcp/cartridge.json b/cartridges/cross-cutting/agentic/agent-mcp/cartridge.json new file mode 100644 index 0000000..a0eb24d --- /dev/null +++ b/cartridges/cross-cutting/agentic/agent-mcp/cartridge.json @@ -0,0 +1,142 @@ +{ + "$schema": "https://boj.dev/schemas/cartridge/v1.json", + "spdx": "MPL-2.0", + "copyright": "Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) ", + "name": "agent-mcp", + "version": "0.1.0", + "description": "OODA loop agent session enforcer", + "domain": "ai", + "tier": "Ayo", + "protocols": [ + "MCP", + "REST" + ], + "auth": { + "method": "none", + "env_var": null, + "credential_source": null + }, + "api": { + "base_url": "local://agent-mcp", + "content_type": "application/json" + }, + "tools": [ + { + "name": "agent_new_session", + "description": "Open a new OODA agent session", + "inputSchema": { + "type": "object", + "properties": {} + } + }, + { + "name": "agent_end_session", + "description": "End an agent session", + "inputSchema": { + "type": "object", + "properties": { + "session_id": { + "type": "integer", + "description": "Session index" + } + }, + "required": [ + "session_id" + ] + } + }, + { + "name": "agent_transition", + "description": "Transition agent OODA state", + "inputSchema": { + "type": "object", + "properties": { + "session_id": { + "type": "integer", + "description": "Session index" + }, + "to_state": { + "type": "string", + "description": "Target state: observe|orient|decide|act|halted" + } + }, + "required": [ + "session_id", + "to_state" + ] + } + }, + { + "name": "agent_state", + "description": "Get current OODA state of a session", + "inputSchema": { + "type": "object", + "properties": { + "session_id": { + "type": "integer", + "description": "Session index" + } + }, + "required": [ + "session_id" + ] + } + }, + { + "name": "agent_loop_count", + "description": "Get OODA loop iteration count", + "inputSchema": { + "type": "object", + "properties": { + "session_id": { + "type": "integer", + "description": "Session index" + } + }, + "required": [ + "session_id" + ] + } + }, + { + "name": "agent_validate_ooda", + "description": "Validate an OODA state transition", + "inputSchema": { + "type": "object", + "properties": { + "from_state": { + "type": "string", + "description": "Source state" + }, + "to_state": { + "type": "string", + "description": "Target state" + } + }, + "required": [ + "from_state", + "to_state" + ] + } + }, + { + "name": "agent_reset", + "description": "Reset all agent sessions", + "inputSchema": { + "type": "object", + "properties": {} + } + } + ], + "ffi": { + "so_path": "ffi/zig-out/lib/libagent_mcp.so", + "abi_version": "ADR-0006", + "symbols": [ + "boj_cartridge_init", + "boj_cartridge_deinit", + "boj_cartridge_name", + "boj_cartridge_version", + "boj_cartridge_invoke" + ] + } +} diff --git a/cartridges/cross-cutting/agentic/agent-mcp/ffi/README.adoc b/cartridges/cross-cutting/agentic/agent-mcp/ffi/README.adoc new file mode 100644 index 0000000..7133b24 --- /dev/null +++ b/cartridges/cross-cutting/agentic/agent-mcp/ffi/README.adoc @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) += agent-mcp / ffi β€” Zig FFI layer +:orientation: shallow + +== Purpose + +C-ABI exports bridging this cartridge's Idris2 ABI to the runtime. Consumed by +the adapter for protocol dispatch and by the trunk loader for mount. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” produces `zig-out/lib/libagent.so`. +| `agent_ffi.zig` | C-ABI exports (18 exports, 10 inline tests, 406 lines). +|=== + +== Invariants + +* **Null-pointer rejection** on `[*c]const u8` arguments before any + dereference. +* **Negative return values** carry documented error meanings; zero is + success (matching the trunk error-code convention). +* **No allocator leaks** β€” every allocation path pairs with `defer free`. + +== Test/proof surface + +10 inline `test "..."` block(s) in `agent_ffi.zig`. Run with +`cd ffi && zig build test`. + +== Read-first + +. `agent_ffi.zig` β€” C-ABI signatures and guards. +. Trunk `ffi/zig/src/catalogue.zig` for how this `.so` is loaded. diff --git a/cartridges/cross-cutting/agentic/agent-mcp/ffi/agent_ffi.zig b/cartridges/cross-cutting/agentic/agent-mcp/ffi/agent_ffi.zig new file mode 100644 index 0000000..4016487 --- /dev/null +++ b/cartridges/cross-cutting/agentic/agent-mcp/ffi/agent_ffi.zig @@ -0,0 +1,477 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +// +// Agent-MCP Cartridge β€” Zig FFI bridge for OODA loop enforcement. +// +// Ensures agents follow Observe β†’ Orient β†’ Decide β†’ Act and cannot +// skip steps. Emergency halt from any state, resume to Observe. + +const std = @import("std"); + +// ═══════════════════════════════════════════════════════════════════════ +// Types (must match AgentMcp.SafeOODA encodings) +// ═══════════════════════════════════════════════════════════════════════ + +pub const AgentState = enum(c_int) { + observe = 1, + orient = 2, + decide = 3, + act = 4, + halted = 5, +}; + +// ═══════════════════════════════════════════════════════════════════════ +// Session Management +// ═══════════════════════════════════════════════════════════════════════ + +const MAX_SESSIONS: usize = 32; + +const Session = struct { + active: bool, + state: AgentState, + loop_count: u32, + was_halted: bool, +}; + +var sessions: [MAX_SESSIONS]Session = [_]Session{.{ + .active = false, + .state = .observe, + .loop_count = 0, + .was_halted = false, +}} ** MAX_SESSIONS; + +var mutex: std.Thread.Mutex = .{}; + +/// Validate a state transition. +fn isValidTransition(from: AgentState, to: AgentState) bool { + return switch (from) { + .observe => to == .orient or to == .halted, + .orient => to == .decide or to == .halted, + .decide => to == .act or to == .halted, + .act => to == .observe or to == .halted, + .halted => to == .observe, + }; +} + +/// Create a new agent session. Returns session index or -1. +pub export fn agent_new_session() c_int { + mutex.lock(); + defer mutex.unlock(); + for (&sessions, 0..) |*s, i| { + if (!s.active) { + s.active = true; + s.state = .observe; + s.loop_count = 0; + s.was_halted = false; + return @intCast(i); + } + } + return -1; +} + +/// End a session. +pub export fn agent_end_session(idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + if (idx < 0 or idx >= MAX_SESSIONS) return -1; + sessions[@intCast(idx)].active = false; + return 0; +} + +/// Attempt a state transition. Returns 0 on success, -1 invalid, -2 not found. +pub export fn agent_transition(idx: c_int, to: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + if (idx < 0 or idx >= MAX_SESSIONS) return -2; + const i: usize = @intCast(idx); + if (!sessions[i].active) return -2; + + const target: AgentState = @enumFromInt(to); + if (!isValidTransition(sessions[i].state, target)) return -1; + + // Track loop completion (Act -> Observe) + if (sessions[i].state == .act and target == .observe) { + sessions[i].loop_count += 1; + } + if (target == .halted) { + sessions[i].was_halted = true; + } + + sessions[i].state = target; + return 0; +} + +/// Get current state of a session. +pub export fn agent_state(idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + if (idx < 0 or idx >= MAX_SESSIONS) return -1; + const i: usize = @intCast(idx); + if (!sessions[i].active) return -1; + return @intFromEnum(sessions[i].state); +} + +/// Get loop count for a session. +pub export fn agent_loop_count(idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + if (idx < 0 or idx >= MAX_SESSIONS) return -1; + const i: usize = @intCast(idx); + if (!sessions[i].active) return -1; + return @intCast(sessions[i].loop_count); +} + +/// Validate a transition without executing it (C-ABI export). +pub export fn agent_validate_ooda(from: c_int, to: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + const f: AgentState = @enumFromInt(from); + const t: AgentState = @enumFromInt(to); + return if (isValidTransition(f, t)) 1 else 0; +} + +/// Get next standard state in the OODA sequence. +pub export fn agent_next_state(current: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + const s: AgentState = @enumFromInt(current); + return @intFromEnum(switch (s) { + .observe => AgentState.orient, + .orient => AgentState.decide, + .decide => AgentState.act, + .act => AgentState.observe, + .halted => AgentState.observe, // resume + }); +} + +/// Reset all sessions (for testing). +pub export fn agent_reset() void { + mutex.lock(); + defer mutex.unlock(); + for (&sessions) |*s| { + s.active = false; + s.state = .observe; + s.loop_count = 0; + s.was_halted = false; + } +} + + +// ═══════════════════════════════════════════════════════════════════════ +// Standard Cartridge Interface (loader expects these 4 C-ABI symbols) +// ═══════════════════════════════════════════════════════════════════════ + +/// Initialise the agent-mcp cartridge. Resets all sessions. +pub export fn boj_cartridge_init() c_int { + agent_reset(); + return 0; +} + +/// Deinitialise the agent-mcp cartridge. Resets all sessions. +pub export fn boj_cartridge_deinit() void { + agent_reset(); +} + +/// Return the cartridge name as a null-terminated C string. +pub export fn boj_cartridge_name() [*:0]const u8 { + mutex.lock(); + defer mutex.unlock(); + return "agent-mcp"; +} + +/// Return the cartridge version as a null-terminated C string. +pub export fn boj_cartridge_version() [*:0]const u8 { + mutex.lock(); + defer mutex.unlock(); + return "0.2.0"; +} + +// ═══════════════════════════════════════════════════════════════════════ +// ADR-0006 dispatch (boj_cartridge_invoke, 5th standard symbol) +// ═══════════════════════════════════════════════════════════════════════ + +const shim = @import("cartridge_shim.zig"); + +/// Dispatch the cartridge.json MCP tools. Grade D Alpha β€” each arm +/// returns a stub JSON body shaped to the tool's intended response. +export fn boj_cartridge_invoke( + tool_name: [*c]const u8, + json_args: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) callconv(.c) i32 { + _ = json_args; + if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; + + const body: []const u8 = if (shim.toolIs(tool_name, "agent_new_session")) + "{\"result\":{\"session_id\":0,\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "agent_end_session")) + "{\"result\":{\"ended\":true,\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "agent_transition")) + "{\"result\":{\"transitioned\":true,\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "agent_state")) + "{\"result\":{\"state\":\"unknown\",\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "agent_loop_count")) + "{\"result\":{\"count\":0,\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "agent_validate_ooda")) + "{\"result\":{\"valid\":true,\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "agent_reset")) + "{\"result\":{\"reset\":true,\"status\":\"stub\"}}" + else + return shim.RC_UNKNOWN_TOOL; + + return shim.writeResult(out_buf, in_out_len, body); +} + +// ═══════════════════════════════════════════════════════════════════════ +// Protocol Types (from proven-agentic, added in v0.2.0) +// ═══════════════════════════════════════════════════════════════════════ + +pub const ToolCall = enum(c_int) { + execute = 0, + query = 1, + transform = 2, + communicate = 3, + delegate = 4, + escalate = 5, +}; + +pub const PlanStep = enum(c_int) { + action = 0, + condition = 1, + loop = 2, + branch = 3, + parallel = 4, + checkpoint = 5, + rollback = 6, +}; + +pub const Coordination = enum(c_int) { + solo = 0, + collaborative = 1, + competitive = 2, + hierarchical = 3, + swarm = 4, + consensus = 5, +}; + +pub const SafetyCheck = enum(c_int) { + approved = 0, + denied = 1, + escalated = 2, + timeout = 3, + sandboxed = 4, + human_required = 5, +}; + +pub const MemoryType = enum(c_int) { + working = 0, + episodic = 1, + semantic = 2, + procedural = 3, + shared = 4, +}; + +/// Whether a tool call has side effects. +pub export fn agent_tool_has_side_effects(tc: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + const t: ToolCall = @enumFromInt(tc); + return switch (t) { + .execute, .communicate, .delegate, .escalate => 1, + .query, .transform => 0, + }; +} + +/// Whether a tool call requires a safety pre-check. +pub export fn agent_tool_requires_safety(tc: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + const t: ToolCall = @enumFromInt(tc); + return switch (t) { + .execute, .delegate, .escalate => 1, + else => 0, + }; +} + +/// Whether a safety check outcome allows execution. +pub export fn agent_safety_allows_exec(sc: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + const s: SafetyCheck = @enumFromInt(sc); + return switch (s) { + .approved, .sandboxed => 1, + else => 0, + }; +} + +/// Whether a safety check needs human intervention. +pub export fn agent_safety_needs_human(sc: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + const s: SafetyCheck = @enumFromInt(sc); + return switch (s) { + .escalated, .human_required => 1, + else => 0, + }; +} + +/// Whether a coordination strategy involves multiple agents. +pub export fn agent_coordination_is_multi(c: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + const coord: Coordination = @enumFromInt(c); + return if (coord != .solo) 1 else 0; +} + +/// Whether a memory type persists across sessions. +pub export fn agent_memory_is_persistent(m: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + const mem: MemoryType = @enumFromInt(m); + return if (mem != .working) 1 else 0; +} + +// ═══════════════════════════════════════════════════════════════════════ +// Tests +// ═══════════════════════════════════════════════════════════════════════ + +test "full OODA loop" { + agent_reset(); + const s = agent_new_session(); + try std.testing.expect(s >= 0); + try std.testing.expectEqual(@as(c_int, @intFromEnum(AgentState.observe)), agent_state(s)); + + // Observe -> Orient -> Decide -> Act -> Observe + try std.testing.expectEqual(@as(c_int, 0), agent_transition(s, 2)); // Orient + try std.testing.expectEqual(@as(c_int, 0), agent_transition(s, 3)); // Decide + try std.testing.expectEqual(@as(c_int, 0), agent_transition(s, 4)); // Act + try std.testing.expectEqual(@as(c_int, 0), agent_transition(s, 1)); // Observe (new loop) + + try std.testing.expectEqual(@as(c_int, 1), agent_loop_count(s)); + _ = agent_end_session(s); +} + +test "cannot skip Orient" { + agent_reset(); + const s = agent_new_session(); + // Observe -> Decide should fail (must go through Orient) + try std.testing.expectEqual(@as(c_int, -1), agent_transition(s, 3)); + // Observe -> Act should fail + try std.testing.expectEqual(@as(c_int, -1), agent_transition(s, 4)); + // State should still be Observe + try std.testing.expectEqual(@as(c_int, @intFromEnum(AgentState.observe)), agent_state(s)); + _ = agent_end_session(s); +} + +test "emergency halt from any state" { + agent_reset(); + const s = agent_new_session(); + // Halt from Observe + try std.testing.expectEqual(@as(c_int, 0), agent_transition(s, 5)); + try std.testing.expectEqual(@as(c_int, @intFromEnum(AgentState.halted)), agent_state(s)); + // Resume to Observe + try std.testing.expectEqual(@as(c_int, 0), agent_transition(s, 1)); + try std.testing.expectEqual(@as(c_int, @intFromEnum(AgentState.observe)), agent_state(s)); + _ = agent_end_session(s); +} + +test "halt from Orient" { + agent_reset(); + const s = agent_new_session(); + _ = agent_transition(s, 2); // Orient + try std.testing.expectEqual(@as(c_int, 0), agent_transition(s, 5)); // Halt + try std.testing.expectEqual(@as(c_int, @intFromEnum(AgentState.halted)), agent_state(s)); + _ = agent_end_session(s); +} + +test "cannot go backwards" { + agent_reset(); + const s = agent_new_session(); + _ = agent_transition(s, 2); // Orient + _ = agent_transition(s, 3); // Decide + // Cannot go back to Orient + try std.testing.expectEqual(@as(c_int, -1), agent_transition(s, 2)); + // Cannot go back to Observe + try std.testing.expectEqual(@as(c_int, -1), agent_transition(s, 1)); + _ = agent_end_session(s); +} + +test "next state sequence" { + try std.testing.expectEqual(@as(c_int, 2), agent_next_state(1)); // Observe -> Orient + try std.testing.expectEqual(@as(c_int, 3), agent_next_state(2)); // Orient -> Decide + try std.testing.expectEqual(@as(c_int, 4), agent_next_state(3)); // Decide -> Act + try std.testing.expectEqual(@as(c_int, 1), agent_next_state(4)); // Act -> Observe + try std.testing.expectEqual(@as(c_int, 1), agent_next_state(5)); // Halted -> Observe +} + +// Protocol tests (v0.2.0) + +test "tool call side effects" { + try std.testing.expectEqual(@as(c_int, 1), agent_tool_has_side_effects(0)); // execute + try std.testing.expectEqual(@as(c_int, 0), agent_tool_has_side_effects(1)); // query + try std.testing.expectEqual(@as(c_int, 0), agent_tool_has_side_effects(2)); // transform + try std.testing.expectEqual(@as(c_int, 1), agent_tool_has_side_effects(4)); // delegate +} + +test "safety check permissions" { + try std.testing.expectEqual(@as(c_int, 1), agent_safety_allows_exec(0)); // approved + try std.testing.expectEqual(@as(c_int, 0), agent_safety_allows_exec(1)); // denied + try std.testing.expectEqual(@as(c_int, 1), agent_safety_allows_exec(4)); // sandboxed + try std.testing.expectEqual(@as(c_int, 0), agent_safety_allows_exec(5)); // human_required + try std.testing.expectEqual(@as(c_int, 1), agent_safety_needs_human(5)); // human_required + try std.testing.expectEqual(@as(c_int, 0), agent_safety_needs_human(0)); // approved +} + +test "coordination multi-agent" { + try std.testing.expectEqual(@as(c_int, 0), agent_coordination_is_multi(0)); // solo + try std.testing.expectEqual(@as(c_int, 1), agent_coordination_is_multi(1)); // collaborative + try std.testing.expectEqual(@as(c_int, 1), agent_coordination_is_multi(4)); // swarm +} + +test "validation matches transitions" { + // Valid + try std.testing.expectEqual(@as(c_int, 1), agent_validate_ooda(1, 2)); // Obs -> Ori + try std.testing.expectEqual(@as(c_int, 1), agent_validate_ooda(2, 3)); // Ori -> Dec + try std.testing.expectEqual(@as(c_int, 1), agent_validate_ooda(3, 4)); // Dec -> Act + try std.testing.expectEqual(@as(c_int, 1), agent_validate_ooda(4, 1)); // Act -> Obs + try std.testing.expectEqual(@as(c_int, 1), agent_validate_ooda(1, 5)); // Obs -> Halt + // Invalid + try std.testing.expectEqual(@as(c_int, 0), agent_validate_ooda(1, 3)); // Obs -> Dec + try std.testing.expectEqual(@as(c_int, 0), agent_validate_ooda(1, 4)); // Obs -> Act + try std.testing.expectEqual(@as(c_int, 0), agent_validate_ooda(3, 1)); // Dec -> Obs +} + +// ═══════════════════════════════════════════════════════════════════════ +// ADR-0006 invoke dispatch tests +// ═══════════════════════════════════════════════════════════════════════ + +test "invoke: each declared tool succeeds" { + var buf: [256]u8 = undefined; + const tools = [_][]const u8{ + "agent_new_session", "agent_end_session", "agent_transition", + "agent_state", "agent_loop_count", "agent_validate_ooda", + "agent_reset", + }; + for (tools) |t| { + var len: usize = buf.len; + const rc = boj_cartridge_invoke(t.ptr, "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, 0), rc); + try std.testing.expect(std.mem.indexOf(u8, buf[0..len], "result") != null); + } +} + +test "invoke: unknown tool returns -1" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("nope", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -1), rc); +} + +test "invoke: buffer too small returns -3" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("agent_new_session", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -3), rc); + try std.testing.expect(len > 4); +} diff --git a/cartridges/cross-cutting/agentic/agent-mcp/ffi/build.zig b/cartridges/cross-cutting/agentic/agent-mcp/ffi/build.zig new file mode 100644 index 0000000..92928f7 --- /dev/null +++ b/cartridges/cross-cutting/agentic/agent-mcp/ffi/build.zig @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +// +// Agent-MCP Cartridge β€” Zig FFI build configuration (Zig 0.15+). + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + // Shared ADR-0006 invoke-shim module (relative path up to boj-server trunk). + const shim_mod = b.addModule("cartridge_shim", .{ + .root_source_file = b.path("../../../ffi/zig/src/cartridge_shim.zig"), + .target = target, + .optimize = optimize, + }); + + const agent_mod = b.addModule("agent_ffi", .{ + .root_source_file = b.path("agent_ffi.zig"), + .target = target, + .optimize = optimize, + }); + agent_mod.addImport("cartridge_shim", shim_mod); + + // ── Tests ──────────────────────────────────────────────────────── + const agent_tests = b.addTest(.{ + .root_module = agent_mod, + }); + + const run_tests = b.addRunArtifact(agent_tests); + + const test_step = b.step("test", "Run agent-mcp FFI tests"); + test_step.dependOn(&run_tests.step); + + // ── Shared library ────────────────────────────────────────────── + const lib_mod = b.createModule(.{ + .root_source_file = b.path("agent_ffi.zig"), + .target = target, + .optimize = optimize, + }); + lib_mod.addImport("cartridge_shim", shim_mod); + + const lib = b.addLibrary(.{ + .name = "agent_mcp", + .root_module = lib_mod, + .linkage = .dynamic, + }); + b.installArtifact(lib); + + const lib_step = b.step("lib", "Build shared library"); + lib_step.dependOn(&lib.step); +} diff --git a/cartridges/cross-cutting/agentic/agent-mcp/ffi/cartridge_shim.zig b/cartridges/cross-cutting/agentic/agent-mcp/ffi/cartridge_shim.zig new file mode 100644 index 0000000..0e399db --- /dev/null +++ b/cartridges/cross-cutting/agentic/agent-mcp/ffi/cartridge_shim.zig @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +// +// cartridge_shim.zig β€” Shared helpers for the ADR-0006 five-symbol +// cartridge ABI (`boj_cartridge_init / deinit / name / version / invoke`). +// +// The shim centralises the seven-code return convention, NUL-argument +// guards, tool-name comparison, and the buffer-too-small path so each +// cartridge's `boj_cartridge_invoke` can stay short β€” typically a tool +// table plus `shim.writeResult(...)`. +// +// Cartridges import this file by relative path (no build-graph change +// needed). Example: +// +// const shim = @import("../../../ffi/zig/src/cartridge_shim.zig"); +// +// export fn boj_cartridge_invoke( +// tool_name: [*c]const u8, +// json_args: [*c]const u8, +// out_buf: [*c]u8, +// in_out_len: [*c]usize, +// ) callconv(.c) i32 { +// _ = json_args; +// if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; +// const body = if (shim.toolIs(tool_name, "foo")) "{\"result\":{}}" +// else return shim.RC_UNKNOWN_TOOL; +// return shim.writeResult(out_buf, in_out_len, body); +// } + +const std = @import("std"); + +// ── Return codes (ADR-0006 Β§Return codes) ──────────────────────────── +// +// Frozen by ADR-0006. New failure modes compose these via the error +// JSON body β€” the integer surface does not grow without a follow-up ADR. + +pub const RC_SUCCESS: i32 = 0; +pub const RC_UNKNOWN_TOOL: i32 = -1; +pub const RC_BAD_ARGS: i32 = -2; +pub const RC_BUFFER_TOO_SMALL: i32 = -3; +pub const RC_RUNTIME_ERROR: i32 = -4; +pub const RC_PANIC: i32 = -5; +pub const RC_AUTH_DENIED: i32 = -6; + +// ── Invoke-path helpers ────────────────────────────────────────────── + +/// True if any of the three mandatory `boj_cartridge_invoke` output-path +/// pointers is null. Use at the top of every invoke to short-circuit to +/// `RC_BAD_ARGS`. +pub fn invokeArgsNull( + tool_name: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) bool { + return tool_name == null or out_buf == null or in_out_len == null; +} + +/// Compare a C-NUL-terminated tool-name pointer against a Zig string +/// literal. Caller must have already verified `tool_name` is non-null +/// (usually via `invokeArgsNull`). +pub fn toolIs(tool_name: [*c]const u8, expected: []const u8) bool { + const s = std.mem.span(@as([*:0]const u8, @ptrCast(tool_name))); + return std.mem.eql(u8, s, expected); +} + +/// Copy `body` into `out_buf[0..*in_out_len]` (as a capacity) and update +/// `*in_out_len` to the number of bytes written. Returns `RC_SUCCESS`. +/// +/// If `body.len` exceeds the current capacity stored in `*in_out_len`, +/// sets `*in_out_len` to the required size and returns +/// `RC_BUFFER_TOO_SMALL` β€” the caller is then expected to re-allocate +/// and retry, per ADR-0006 Β§Memory ownership. +/// +/// Caller must have already verified that `out_buf` and `in_out_len` +/// are non-null. +pub fn writeResult( + out_buf: [*c]u8, + in_out_len: [*c]usize, + body: []const u8, +) i32 { + const cap = in_out_len.*; + if (body.len > cap) { + in_out_len.* = body.len; + return RC_BUFFER_TOO_SMALL; + } + @memcpy(out_buf[0..body.len], body); + in_out_len.* = body.len; + return RC_SUCCESS; +} + +// ── Tests ──────────────────────────────────────────────────────────── + +test "writeResult: body fits, writes and sets length" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); + try std.testing.expectEqualStrings("hello", buf[0..len]); +} + +test "writeResult: too small returns -3 and sets required length" { + var buf: [2]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_BUFFER_TOO_SMALL, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: exact-fit succeeds" { + var buf: [5]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: empty body" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, ""); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 0), len); +} + +test "toolIs: matches and rejects" { + const name: [*:0]const u8 = "foo"; + try std.testing.expect(toolIs(@ptrCast(name), "foo")); + try std.testing.expect(!toolIs(@ptrCast(name), "bar")); + try std.testing.expect(!toolIs(@ptrCast(name), "foobar")); + try std.testing.expect(!toolIs(@ptrCast(name), "fo")); +} + +test "invokeArgsNull: detects each null slot" { + var buf: [4]u8 = undefined; + var len: usize = 4; + const name: [*:0]const u8 = "x"; + try std.testing.expect(!invokeArgsNull(@ptrCast(name), &buf, &len)); + try std.testing.expect(invokeArgsNull(null, &buf, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), null, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), &buf, null)); +} diff --git a/cartridges/cross-cutting/agentic/agent-mcp/mod.js b/cartridges/cross-cutting/agentic/agent-mcp/mod.js new file mode 100644 index 0000000..85e6445 --- /dev/null +++ b/cartridges/cross-cutting/agentic/agent-mcp/mod.js @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +// +// agent-mcp/mod.js β€” OODA loop agent session enforcer +// +// Delegates to backend at http://127.0.0.1:7711 (override with AGENT_BACKEND_URL). + +const BASE_URL = Deno.env.get("AGENT_BACKEND_URL") ?? "http://127.0.0.1:7711"; +const TIMEOUT_MS = 15_000; + +async function post(path, payload) { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), TIMEOUT_MS); + try { + const r = await fetch(`${BASE_URL}${path}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + signal: ctrl.signal, + }); + const data = await r.json().catch(() => ({ success: false, error: "non-JSON response" })); + return { status: r.status, data }; + } catch (e) { + if (e.name === "AbortError") return { status: 504, data: { success: false, error: "agent-mcp backend timed out" } }; + return { status: 503, data: { success: false, error: `agent-mcp backend unavailable: ${e.message}` } }; + } finally { clearTimeout(t); } +} + +async function get(path) { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), TIMEOUT_MS); + try { + const r = await fetch(`${BASE_URL}${path}`, { method: "GET", signal: ctrl.signal }); + const data = await r.json().catch(() => ({ success: false, error: "non-JSON response" })); + return { status: r.status, data }; + } catch (e) { + if (e.name === "AbortError") return { status: 504, data: { success: false, error: "agent-mcp backend timed out" } }; + return { status: 503, data: { success: false, error: `agent-mcp backend unavailable: ${e.message}` } }; + } finally { clearTimeout(t); } +} + +export async function handleTool(toolName, args) { + switch (toolName) { + case "agent_new_session": + return post("/api/v1/agent_new_session", args ?? {}); + case "agent_end_session": + return post("/api/v1/agent_end_session", args ?? {}); + case "agent_transition": + return post("/api/v1/agent_transition", args ?? {}); + case "agent_state": + return post("/api/v1/agent_state", args ?? {}); + case "agent_loop_count": + return post("/api/v1/agent_loop_count", args ?? {}); + case "agent_validate_ooda": + return post("/api/v1/agent_validate_ooda", args ?? {}); + case "agent_reset": + return post("/api/v1/agent_reset", args ?? {}); + default: + return { status: 404, data: { error: `Unknown tool: ${toolName}` } }; + } +} diff --git a/cartridges/cross-cutting/agentic/agent-mcp/panels/manifest.json b/cartridges/cross-cutting/agentic/agent-mcp/panels/manifest.json new file mode 100644 index 0000000..8791d1c --- /dev/null +++ b/cartridges/cross-cutting/agentic/agent-mcp/panels/manifest.json @@ -0,0 +1,67 @@ +{ + "$schema": "https://panll.dev/schemas/panel-manifest/v1.json", + "spdx": "MPL-2.0", + "cartridge": "agent-mcp", + "domain": "Agent Orchestration", + "version": "0.1.0", + "panels": [ + { + "id": "agent-status", + "title": "Agent Gateway Status", + "description": "AI agent orchestration health and active sessions", + "type": "status-indicator", + "data_source": { + "endpoint": "/cartridge/agent-mcp/invoke", + "method": "POST", + "body": { "tool": "status" }, + "refresh_interval_ms": 5000 + }, + "widgets": [ + { + "type": "state-badge", + "field": "state", + "states": { + "ready": { "color": "#2ecc71", "icon": "bot" }, + "degraded": { "color": "#f39c12", "icon": "alert-circle" }, + "error": { "color": "#e74c3c", "icon": "alert-triangle" } + } + }, + { "type": "text", "field": "version", "label": "Version" } + ] + }, + { + "id": "agent-sessions", + "title": "Active Agent Sessions", + "description": "Running agent sessions with model and task details", + "type": "metric", + "data_source": { + "endpoint": "/cartridge/agent-mcp/invoke", + "method": "POST", + "body": { "tool": "session_list" }, + "refresh_interval_ms": 10000 + }, + "widgets": [ + { "type": "counter", "field": "active_sessions", "label": "Active Sessions", "icon": "users" }, + { "type": "counter", "field": "pending_tasks", "label": "Pending Tasks", "icon": "clock" }, + { "type": "counter", "field": "completed_tasks", "label": "Completed", "icon": "check-circle" } + ] + }, + { + "id": "agent-coprocessors", + "title": "Coprocessor Dispatch", + "description": "Axiom-style coprocessor detection and dispatch metrics", + "type": "metric", + "data_source": { + "endpoint": "/cartridge/agent-mcp/invoke", + "method": "POST", + "body": { "tool": "coprocessor_stats" }, + "refresh_interval_ms": 15000 + }, + "widgets": [ + { "type": "counter", "field": "dispatched", "label": "Dispatched", "icon": "send" }, + { "type": "counter", "field": "fallbacks", "label": "Fallbacks", "icon": "rotate-ccw" }, + { "type": "counter", "field": "avg_latency_ms", "label": "Avg Latency (ms)", "icon": "activity" } + ] + } + ] +} diff --git a/cartridges/cross-cutting/agentic/agent-mcp/panels/rpa-elysium.json b/cartridges/cross-cutting/agentic/agent-mcp/panels/rpa-elysium.json new file mode 100644 index 0000000..28695b6 --- /dev/null +++ b/cartridges/cross-cutting/agentic/agent-mcp/panels/rpa-elysium.json @@ -0,0 +1,110 @@ +{ + "$schema": "panll-harness/v1", + "service_id": "rpa-elysium", + "service_name": "RPA Elysium Filesystem Automation", + "version": "0.1.0", + "protocol": "http", + "default_endpoint": "http://localhost:7800/api/v1", + "health_check": { + "path": "/health", + "interval_ms": 30000, + "timeout_ms": 5000, + "healthy_threshold": 1, + "unhealthy_threshold": 3 + }, + "data_sources": { + "rpa-elysium://workflow/status": { + "path": "/workflow/status", + "method": "GET", + "returns": "WorkflowStatus", + "description": "Current workflow execution status (idle/running/paused/stopped/error)" + }, + "rpa-elysium://workflow/metrics/events_processed": { + "path": "/workflow/metrics", + "method": "GET", + "returns": "u64", + "jq_extract": ".events_processed", + "description": "Total events processed by the workflow engine" + }, + "rpa-elysium://workflow/metrics/actions_executed": { + "path": "/workflow/metrics", + "method": "GET", + "returns": "u64", + "jq_extract": ".actions_executed", + "description": "Total actions executed" + }, + "rpa-elysium://workflow/metrics/error_count": { + "path": "/workflow/metrics", + "method": "GET", + "returns": "u64", + "jq_extract": ".error_count", + "description": "Total errors encountered" + }, + "rpa-elysium://workflow/watch_paths": { + "path": "/workflow/watch_paths", + "method": "GET", + "returns": "[WatchPath]", + "description": "List of watched directories with event counts" + }, + "rpa-elysium://workflow/rules": { + "path": "/workflow/rules", + "method": "GET", + "returns": "[Rule]", + "description": "Active rules with match counts" + }, + "rpa-elysium://workflow/events/timeline": { + "path": "/workflow/events?format=timeline", + "method": "GET", + "returns": "[TimelineEvent]", + "description": "Event timeline for the last hour" + }, + "rpa-elysium://workflow/actions/recent": { + "path": "/workflow/actions?limit=25", + "method": "GET", + "returns": "[ActionResult]", + "description": "Recent action execution results" + }, + "rpa-elysium://workflow/fsm/state": { + "path": "/workflow/fsm", + "method": "GET", + "returns": "FSMState", + "description": "proven-fsm workflow state machine current state" + }, + "rpa-elysium://queue/state": { + "path": "/queue/state", + "method": "GET", + "returns": "QueueState", + "description": "proven-queueconn subscription state (for HAR integration)" + }, + "rpa-elysium://plugins/count": { + "path": "/plugins/count", + "method": "GET", + "returns": "u64", + "description": "Number of loaded WASM plugins" + }, + "rpa-elysium://plugins/list": { + "path": "/plugins", + "method": "GET", + "returns": "[PluginInfo]", + "description": "List of registered plugins with metadata" + }, + "rpa-elysium://plugins/sandbox/memory_usage": { + "path": "/plugins/sandbox/memory", + "method": "GET", + "returns": "u64", + "description": "Total sandbox memory usage in bytes" + }, + "rpa-elysium://plugins/logs/recent": { + "path": "/plugins/logs?limit=20", + "method": "GET", + "returns": "[PluginLog]", + "description": "Recent plugin execution log entries" + } + }, + "panels": [ + "panels/fs-workflow/panel.json", + "panels/plugin-status/panel.json" + ], + "capabilities": ["filesystem", "scheduling", "plugin"], + "clade": "automation/filesystem" +} diff --git a/cartridges/cross-cutting/agentic/claude-agents-power-mcp/README.adoc b/cartridges/cross-cutting/agentic/claude-agents-power-mcp/README.adoc new file mode 100644 index 0000000..ccef826 --- /dev/null +++ b/cartridges/cross-cutting/agentic/claude-agents-power-mcp/README.adoc @@ -0,0 +1,73 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) += claude-agents-power-mcp +Jonathan D.A. Jewell +:spdx: MPL-2.0 +:tier: Ayo +:domain: Agent Orchestration +:protocols: MCP, REST + +== Overview + +Claude Agents Power MCP Server. Intelligent management of specialized AI agents for development teams. Analyze projects, recommend agents, and deploy 100+ professional roles. + +== Tools (5) + +[cols="2,4"] +|=== +| Tool | Description + +| `claude_agents_analyze_project` | Analyze project directory and recommend suitable agents. +| `claude_agents_list_agents` | List available agents with filtering options. +| `claude_agents_search_agents` | Search agents by keywords or skills. +| `claude_agents_install_agents` | Install one or more agents to project directory. +| `claude_agents_get_download_stats` | Get download statistics for agents. +|=== + +== Architecture + +[cols="1,1,3"] +|=== +| Layer | Language | Purpose + +| ABI | Idris2 | Formally verified state machine with dependent-type proofs +| FFI | Zig | C-ABI implementation: Agent analysis, recommendation engine, GitHub integration +| Adapter| Zig | Adapter bridge to the BoJ unified adapter protocol +|=== + +== Building + +[source,bash] +---- +# ABI (Idris2) +cd abi && idris2 --check *.idr + +# FFI (Zig) +cd ffi && zig build +cd ffi && zig build test + +# Adapter (Zig) +cd adapter && zig build +---- + +== Status + +Alpha (CRG D). Cartridge metadata declared in `cartridge.json`; 5 tool(s) exposed. +Not yet dogfooded externally. See `docs/READINESS.md` at repo root for the +project-wide grade table and `docs/practice/DOGFOOD-LOG.adoc` for usage evidence. + +== Ports + +Allowed:: 3000 (Claude Agents Power default port) +Denied:: 22 (SSH), 23 (Telnet), 25 (SMTP), 135 (RPC), 139 (NetBIOS), 445 (SMB) + +== Authentication + +Method:: API Key +Env Var:: GITHUB_TOKEN +Source:: User-provided + +== References + +- [Claude Agents Power GitHub](https://github.com/hongsw/claude-agents-power-mcp-server) +- [BaryonLabs Agent Repository](https://github.com/baryonlabs/claude-sub-agent-contents) diff --git a/cartridges/cross-cutting/agentic/claude-agents-power-mcp/abi/README.adoc b/cartridges/cross-cutting/agentic/claude-agents-power-mcp/abi/README.adoc new file mode 100644 index 0000000..dd1038e --- /dev/null +++ b/cartridges/cross-cutting/agentic/claude-agents-power-mcp/abi/README.adoc @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) += claude-agents-power-mcp / abi β€” Idris2 ABI layer +:orientation: deep + +== Purpose + +Encodes the claude-agents-power-mcp connection state machine and the MCP tool catalogue. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| claude-agents-power.ipkg | Idris2 package descriptor. +| claude-agents-power/Safeclaude-agents-power.idr | State machine and tool definitions. +|=== + +== Invariants + +* at module top. +* Zero . + +== Test/proof surface + +Type-check only β€” + Error loading file "claude-agents-power.ipkg": File Not Found. + +== Read-first + +. Safeclaude-agents-power.idr β€” state machine and tool definitions. + diff --git a/cartridges/cross-cutting/agentic/claude-agents-power-mcp/adapter/README.adoc b/cartridges/cross-cutting/agentic/claude-agents-power-mcp/adapter/README.adoc new file mode 100644 index 0000000..5c28429 --- /dev/null +++ b/cartridges/cross-cutting/agentic/claude-agents-power-mcp/adapter/README.adoc @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) += claude-agents-power-mcp / adapter β€” REST/gRPC/GraphQL bridge +:orientation: deep + +== Purpose + +Exposes the FFI layer over three protocols. Stateless β€” all state lives behind the FFI mutex. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| build.zig | Zig build graph. +| claude-agents-power_adapter.zig | Three-protocol dispatcher. +|=== + +== Invariants + +* Stateless β€” no global mutable state. +* One tool call per HTTP request. +* JSON content type for all responses. + +== Test/proof surface + +No inline tests β€” FFI tests cover correctness. Integration tested via cartridge-matrix tests. + +== Read-first + +. claude-agents-power_adapter.zig β€” tool-name β†’ FFI-call mapping. diff --git a/cartridges/cross-cutting/agentic/claude-agents-power-mcp/cartridge.json b/cartridges/cross-cutting/agentic/claude-agents-power-mcp/cartridge.json new file mode 100644 index 0000000..2f022fc --- /dev/null +++ b/cartridges/cross-cutting/agentic/claude-agents-power-mcp/cartridge.json @@ -0,0 +1,167 @@ +{ + "$schema": "https://boj.dev/schemas/cartridge/v1.json", + "spdx": "MPL-2.0", + "copyright": "Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath)", + "name": "claude-agents-power-mcp", + "version": "0.1.0", + "description": "Claude Agents Power MCP Server. Intelligent management of specialized AI agents for development teams. Analyze projects, recommend agents, and deploy 100+ professional roles.", + "domain": "Agent Orchestration", + "tier": "Ayo", + "protocols": [ + "MCP", + "REST" + ], + "auth": { + "method": "api-key", + "env_var": "GITHUB_TOKEN", + "credential_source": "user-provided" + }, + "api": { + "base_url": "local://claude-agents-power-mcp", + "content_type": "application/json" + }, + "ports": { + "allowed": [ + 3000 + ], + "denied": [ + 22, + 23, + 25, + 135, + 139, + 445 + ] + }, + "tools": [ + { + "name": "claude_agents_analyze_project", + "description": "Analyze project directory and recommend suitable agents.", + "inputSchema": { + "type": "object", + "properties": { + "project_path": { + "type": "string", + "description": "Path to project directory (default: current directory)" + }, + "language": { + "type": "string", + "enum": [ + "en", + "ko" + ], + "description": "Language for agent descriptions (default: en)" + } + } + } + }, + { + "name": "claude_agents_list_agents", + "description": "List available agents with filtering options.", + "inputSchema": { + "type": "object", + "properties": { + "category": { + "type": "string", + "description": "Filter by category (e.g., Technology, Data, Product)" + }, + "language": { + "type": "string", + "enum": [ + "en", + "ko" + ], + "description": "Language for agent descriptions (default: en)" + }, + "limit": { + "type": "integer", + "description": "Maximum number of results (default: 20)" + } + } + } + }, + { + "name": "claude_agents_search_agents", + "description": "Search agents by keywords or skills.", + "inputSchema": { + "type": "object", + "properties": { + "keywords": { + "type": "string", + "description": "Search keywords (e.g., 'machine learning react')" + }, + "language": { + "type": "string", + "enum": [ + "en", + "ko" + ], + "description": "Language for agent descriptions (default: en)" + } + }, + "required": [ + "keywords" + ] + } + }, + { + "name": "claude_agents_install_agents", + "description": "Install one or more agents to project directory.", + "inputSchema": { + "type": "object", + "properties": { + "agent_ids": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of agent IDs to install" + }, + "target_dir": { + "type": "string", + "description": "Target directory for agent files (default: ./claude/agents/)" + }, + "language": { + "type": "string", + "enum": [ + "en", + "ko" + ], + "description": "Language for agent files (default: en)" + } + }, + "required": [ + "agent_ids" + ] + } + }, + { + "name": "claude_agents_get_download_stats", + "description": "Get download statistics for agents.", + "inputSchema": { + "type": "object", + "properties": { + "agent_id": { + "type": "string", + "description": "Agent ID (optional, returns all if not specified)" + }, + "limit": { + "type": "integer", + "description": "Maximum number of results (default: 10)" + } + } + } + } + ], + "ffi": { + "so_path": "ffi/zig-out/lib/libclaude_agents_power_mcp.so", + "abi_version": "ADR-0006", + "symbols": [ + "boj_cartridge_init", + "boj_cartridge_deinit", + "boj_cartridge_name", + "boj_cartridge_version", + "boj_cartridge_invoke" + ] + } +} diff --git a/cartridges/cross-cutting/agentic/claude-agents-power-mcp/ffi/README.adoc b/cartridges/cross-cutting/agentic/claude-agents-power-mcp/ffi/README.adoc new file mode 100644 index 0000000..c42b37f --- /dev/null +++ b/cartridges/cross-cutting/agentic/claude-agents-power-mcp/ffi/README.adoc @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) += claude-agents-power-mcp / ffi β€” Zig FFI layer +:orientation: deep + +== Purpose + +Zig implementation of the connection state machine from . + +== Files + +[cols="1,4"] +|=== +| File | Role + +| build.zig | Zig build graph. +| claude-agents-power_ffi.zig | FFI implementation. +|=== + +== Invariants + +* Mutex discipline for all shared state. +* Bounds checks before dereference. +* State-machine mirror of ABI. + +== Test/proof surface + +Inline blocks in claude-agents-power_ffi.zig. Run with . + +== Read-first + +. claude-agents-power_ffi.zig β€” FFI exports and guards. + diff --git a/cartridges/cross-cutting/agentic/claude-agents-power-mcp/ffi/build.zig b/cartridges/cross-cutting/agentic/claude-agents-power-mcp/ffi/build.zig new file mode 100644 index 0000000..85f72bd --- /dev/null +++ b/cartridges/cross-cutting/agentic/claude-agents-power-mcp/ffi/build.zig @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const ffi_mod = b.addModule("claude_agents_power_mcp", .{ + .root_source_file = b.path("claude_agents_power_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + const lib = b.addLibrary(.{ + .name = "claude_agents_power_mcp", + .root_module = ffi_mod, + .linkage = .dynamic, + }); + lib.linkLibC(); + b.installArtifact(lib); + + const tests = b.addTest(.{ .root_module = ffi_mod }); + tests.linkLibC(); + const run_tests = b.addRunArtifact(tests); + const test_step = b.step("test", "Run FFI tests"); + test_step.dependOn(&run_tests.step); +} diff --git a/cartridges/cross-cutting/agentic/claude-agents-power-mcp/ffi/cartridge_shim.zig b/cartridges/cross-cutting/agentic/claude-agents-power-mcp/ffi/cartridge_shim.zig new file mode 100644 index 0000000..0e399db --- /dev/null +++ b/cartridges/cross-cutting/agentic/claude-agents-power-mcp/ffi/cartridge_shim.zig @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +// +// cartridge_shim.zig β€” Shared helpers for the ADR-0006 five-symbol +// cartridge ABI (`boj_cartridge_init / deinit / name / version / invoke`). +// +// The shim centralises the seven-code return convention, NUL-argument +// guards, tool-name comparison, and the buffer-too-small path so each +// cartridge's `boj_cartridge_invoke` can stay short β€” typically a tool +// table plus `shim.writeResult(...)`. +// +// Cartridges import this file by relative path (no build-graph change +// needed). Example: +// +// const shim = @import("../../../ffi/zig/src/cartridge_shim.zig"); +// +// export fn boj_cartridge_invoke( +// tool_name: [*c]const u8, +// json_args: [*c]const u8, +// out_buf: [*c]u8, +// in_out_len: [*c]usize, +// ) callconv(.c) i32 { +// _ = json_args; +// if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; +// const body = if (shim.toolIs(tool_name, "foo")) "{\"result\":{}}" +// else return shim.RC_UNKNOWN_TOOL; +// return shim.writeResult(out_buf, in_out_len, body); +// } + +const std = @import("std"); + +// ── Return codes (ADR-0006 Β§Return codes) ──────────────────────────── +// +// Frozen by ADR-0006. New failure modes compose these via the error +// JSON body β€” the integer surface does not grow without a follow-up ADR. + +pub const RC_SUCCESS: i32 = 0; +pub const RC_UNKNOWN_TOOL: i32 = -1; +pub const RC_BAD_ARGS: i32 = -2; +pub const RC_BUFFER_TOO_SMALL: i32 = -3; +pub const RC_RUNTIME_ERROR: i32 = -4; +pub const RC_PANIC: i32 = -5; +pub const RC_AUTH_DENIED: i32 = -6; + +// ── Invoke-path helpers ────────────────────────────────────────────── + +/// True if any of the three mandatory `boj_cartridge_invoke` output-path +/// pointers is null. Use at the top of every invoke to short-circuit to +/// `RC_BAD_ARGS`. +pub fn invokeArgsNull( + tool_name: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) bool { + return tool_name == null or out_buf == null or in_out_len == null; +} + +/// Compare a C-NUL-terminated tool-name pointer against a Zig string +/// literal. Caller must have already verified `tool_name` is non-null +/// (usually via `invokeArgsNull`). +pub fn toolIs(tool_name: [*c]const u8, expected: []const u8) bool { + const s = std.mem.span(@as([*:0]const u8, @ptrCast(tool_name))); + return std.mem.eql(u8, s, expected); +} + +/// Copy `body` into `out_buf[0..*in_out_len]` (as a capacity) and update +/// `*in_out_len` to the number of bytes written. Returns `RC_SUCCESS`. +/// +/// If `body.len` exceeds the current capacity stored in `*in_out_len`, +/// sets `*in_out_len` to the required size and returns +/// `RC_BUFFER_TOO_SMALL` β€” the caller is then expected to re-allocate +/// and retry, per ADR-0006 Β§Memory ownership. +/// +/// Caller must have already verified that `out_buf` and `in_out_len` +/// are non-null. +pub fn writeResult( + out_buf: [*c]u8, + in_out_len: [*c]usize, + body: []const u8, +) i32 { + const cap = in_out_len.*; + if (body.len > cap) { + in_out_len.* = body.len; + return RC_BUFFER_TOO_SMALL; + } + @memcpy(out_buf[0..body.len], body); + in_out_len.* = body.len; + return RC_SUCCESS; +} + +// ── Tests ──────────────────────────────────────────────────────────── + +test "writeResult: body fits, writes and sets length" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); + try std.testing.expectEqualStrings("hello", buf[0..len]); +} + +test "writeResult: too small returns -3 and sets required length" { + var buf: [2]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_BUFFER_TOO_SMALL, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: exact-fit succeeds" { + var buf: [5]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: empty body" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, ""); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 0), len); +} + +test "toolIs: matches and rejects" { + const name: [*:0]const u8 = "foo"; + try std.testing.expect(toolIs(@ptrCast(name), "foo")); + try std.testing.expect(!toolIs(@ptrCast(name), "bar")); + try std.testing.expect(!toolIs(@ptrCast(name), "foobar")); + try std.testing.expect(!toolIs(@ptrCast(name), "fo")); +} + +test "invokeArgsNull: detects each null slot" { + var buf: [4]u8 = undefined; + var len: usize = 4; + const name: [*:0]const u8 = "x"; + try std.testing.expect(!invokeArgsNull(@ptrCast(name), &buf, &len)); + try std.testing.expect(invokeArgsNull(null, &buf, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), null, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), &buf, null)); +} diff --git a/cartridges/cross-cutting/agentic/claude-agents-power-mcp/ffi/claude_agents_power_ffi.zig b/cartridges/cross-cutting/agentic/claude-agents-power-mcp/ffi/claude_agents_power_ffi.zig new file mode 100644 index 0000000..fb2044b --- /dev/null +++ b/cartridges/cross-cutting/agentic/claude-agents-power-mcp/ffi/claude_agents_power_ffi.zig @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +// +// claude-agents-power-mcp FFI β€” ADR-0006 five-symbol cartridge ABI implementation. + +const std = @import("std"); +const shim = @import("cartridge_shim.zig"); + +const CARTRIDGE_NAME_PTR: [*:0]const u8 = "claude-agents-power-mcp"; +const CARTRIDGE_VERSION_PTR: [*:0]const u8 = "0.1.0"; + +export fn boj_cartridge_init() callconv(.c) c_int { + return 0; +} + +export fn boj_cartridge_deinit() callconv(.c) void {} + +export fn boj_cartridge_name() callconv(.c) [*:0]const u8 { + return CARTRIDGE_NAME_PTR; +} + +export fn boj_cartridge_version() callconv(.c) [*:0]const u8 { + return CARTRIDGE_VERSION_PTR; +} + +export fn boj_cartridge_invoke( + tool_name: [*c]const u8, + json_args: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) callconv(.c) i32 { + _ = json_args; + if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; + const body: []const u8 = if (shim.toolIs(tool_name, "claude_agents_analyze_project")) + "{\"result\":{}}" + else if (shim.toolIs(tool_name, "claude_agents_list_agents")) + "{\"result\":{}}" + else if (shim.toolIs(tool_name, "claude_agents_search_agents")) + "{\"result\":{}}" + else if (shim.toolIs(tool_name, "claude_agents_install_agents")) + "{\"result\":{}}" + else if (shim.toolIs(tool_name, "claude_agents_get_download_stats")) + "{\"result\":{}}" + else + return shim.RC_UNKNOWN_TOOL; + + return shim.writeResult(out_buf, in_out_len, body); +} + +test "boj_cartridge_name returns claude-agents-power-mcp" { + try std.testing.expectEqualStrings("claude-agents-power-mcp", std.mem.span(boj_cartridge_name())); +} + +test "boj_cartridge_init returns 0" { + try std.testing.expectEqual(@as(c_int, 0), boj_cartridge_init()); +} + +test "invoke unknown tool returns RC_UNKNOWN_TOOL" { + var buf: [256]u8 = undefined; + var len: usize = buf.len; + try std.testing.expectEqual(@as(i32, shim.RC_UNKNOWN_TOOL), boj_cartridge_invoke("unknown_xyz", "{}", &buf, &len)); +} + +test "invoke claude_agents_analyze_project returns 0" { + var buf: [256]u8 = undefined; + var len: usize = buf.len; + try std.testing.expectEqual(@as(i32, 0), boj_cartridge_invoke("claude_agents_analyze_project", "{}", &buf, &len)); +} diff --git a/cartridges/cross-cutting/agentic/claude-agents-power-mcp/mod.js b/cartridges/cross-cutting/agentic/claude-agents-power-mcp/mod.js new file mode 100644 index 0000000..5f810a9 --- /dev/null +++ b/cartridges/cross-cutting/agentic/claude-agents-power-mcp/mod.js @@ -0,0 +1,92 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +// +// claude-agents-power-mcp/mod.js β€” Claude Agents Power MCP cartridge. +// +// Delegates to backend at http://127.0.0.1:3000 (override with CLAUDE_AGENTS_URL). +// Auth: GITHUB_TOKEN (required for agent install; list/search work without it). + +const BASE_URL = Deno.env.get("CLAUDE_AGENTS_URL") ?? "http://127.0.0.1:3000"; +const TIMEOUT_MS = 20_000; + +function getToken() { + return Deno.env.get("GITHUB_TOKEN") ?? null; +} + +function authHeaders() { + const token = getToken(); + const h = { "Content-Type": "application/json" }; + if (token) h["Authorization"] = `Bearer ${token}`; + return h; +} + +async function post(path, payload) { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), TIMEOUT_MS); + try { + const r = await fetch(`${BASE_URL}${path}`, { + method: "POST", + headers: authHeaders(), + body: JSON.stringify(payload), + signal: ctrl.signal, + }); + const data = await r.json().catch(() => ({ success: false, error: "non-JSON response" })); + return { status: r.status, data }; + } catch (e) { + if (e.name === "AbortError") + return { status: 504, data: { success: false, error: "claude-agents-power-mcp backend timed out" } }; + return { status: 503, data: { success: false, error: `claude-agents-power-mcp backend unavailable: ${e.message}` } }; + } finally { clearTimeout(t); } +} + +export async function handleTool(toolName, args) { + switch (toolName) { + case "claude_agents_analyze_project": { + const { project_path, language } = args ?? {}; + const payload = {}; + if (project_path !== undefined) payload.project_path = project_path; + if (language !== undefined) payload.language = language; + return post("/api/v1/agents/analyze", payload); + } + + case "claude_agents_list_agents": { + const { category, language, limit } = args ?? {}; + const payload = {}; + if (category !== undefined) payload.category = category; + if (language !== undefined) payload.language = language; + if (limit !== undefined) payload.limit = limit; + return post("/api/v1/agents/list", payload); + } + + case "claude_agents_search_agents": { + const { keywords, language } = args ?? {}; + if (!keywords) return { status: 400, data: { error: "keywords is required" } }; + const payload = { keywords }; + if (language !== undefined) payload.language = language; + return post("/api/v1/agents/search", payload); + } + + case "claude_agents_install_agents": { + if (!getToken()) + return { status: 401, data: { error: "GITHUB_TOKEN env var is required to install agents" } }; + const { agent_ids, target_dir, language } = args ?? {}; + if (!agent_ids || !Array.isArray(agent_ids) || agent_ids.length === 0) + return { status: 400, data: { error: "agent_ids array is required" } }; + const payload = { agent_ids }; + if (target_dir !== undefined) payload.target_dir = target_dir; + if (language !== undefined) payload.language = language; + return post("/api/v1/agents/install", payload); + } + + case "claude_agents_get_download_stats": { + const { agent_id, limit } = args ?? {}; + const payload = {}; + if (agent_id !== undefined) payload.agent_id = agent_id; + if (limit !== undefined) payload.limit = limit; + return post("/api/v1/agents/stats", payload); + } + + default: + return { status: 404, data: { error: `Unknown tool: ${toolName}` } }; + } +} diff --git a/cartridges/cross-cutting/agentic/claude-ai-mcp/README.adoc b/cartridges/cross-cutting/agentic/claude-ai-mcp/README.adoc new file mode 100644 index 0000000..e5e3163 --- /dev/null +++ b/cartridges/cross-cutting/agentic/claude-ai-mcp/README.adoc @@ -0,0 +1,48 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) += claude-ai-mcp +Jonathan D.A. Jewell +:spdx: MPL-2.0 +:tier: Ayo +:domain: AI +:protocols: MCP, REST + +== Overview + +Anthropic Messages API cartridge -- send messages to Claude models, count tokens, manage multi-turn conversations + +Connects to `https://api.anthropic.com/v1`. + +== Authentication + +API key via header `x-api-key` (env `ANTHROPIC_API_KEY`). + +Credential source: `vault-mcp`. + +== Tools (3) + +[cols="2,4"] +|=== +| Tool | Description + +| `claude_chat` | Send a message (or multi-turn conversation) to a Claude model and return the text response. Uses the Anthropic Messages API. +| `claude_count_tokens` | Count how many tokens a given set of messages would consume without actually sending them. Uses the Anthropic token-counting endpoint. +| `claude_list_models` | Return the known Claude model IDs available through this cartridge, with tier labels. +|=== + +== Architecture + +JavaScript cartridge. Entry point: `mod.js`. + +== Building + +[source,bash] +---- +# No native build step; loaded by the BoJ runtime from mod.js +---- + +== Status + +Alpha (CRG D). Cartridge metadata declared in `cartridge.json`; 3 tool(s) exposed. +Not yet dogfooded externally. See `docs/READINESS.md` at repo root for the +project-wide grade table and `docs/practice/DOGFOOD-LOG.adoc` for usage evidence. diff --git a/cartridges/cross-cutting/agentic/claude-ai-mcp/cartridge.json b/cartridges/cross-cutting/agentic/claude-ai-mcp/cartridge.json new file mode 100644 index 0000000..aab9676 --- /dev/null +++ b/cartridges/cross-cutting/agentic/claude-ai-mcp/cartridge.json @@ -0,0 +1,149 @@ +{ + "$schema": "https://boj.dev/schemas/cartridge/v1.json", + "spdx": "MPL-2.0", + "copyright": "Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath)", + "name": "claude-ai-mcp", + "version": "0.1.0", + "status": "ffi_only", + "description": "Anthropic Messages API cartridge -- send messages to Claude models, count tokens, manage multi-turn conversations", + "domain": "AI", + "tier": "Ayo", + "protocols": [ + "MCP", + "REST" + ], + "auth": { + "method": "api_key_header", + "header": "x-api-key", + "env_var": "ANTHROPIC_API_KEY", + "credential_source": "vault-mcp" + }, + "api": { + "base_url": "https://api.anthropic.com/v1", + "content_type": "application/json", + "extra_headers": { + "anthropic-version": "2023-06-01" + } + }, + "tools": [ + { + "name": "claude_chat", + "description": "Send a message (or multi-turn conversation) to a Claude model and return the text response. Uses the Anthropic Messages API.", + "inputSchema": { + "type": "object", + "properties": { + "messages": { + "type": "array", + "description": "Conversation history. Each entry has 'role' (user|assistant) and 'content' (string).", + "items": { + "type": "object", + "properties": { + "role": { + "type": "string", + "enum": [ + "user", + "assistant" + ] + }, + "content": { + "type": "string" + } + }, + "required": [ + "role", + "content" + ] + }, + "minItems": 1 + }, + "model": { + "type": "string", + "description": "Model ID. Defaults to claude-sonnet-4-6. Options: claude-opus-4-6, claude-sonnet-4-6, claude-haiku-4-5-20251001.", + "default": "claude-sonnet-4-6" + }, + "system": { + "type": "string", + "description": "Optional system prompt placed before the conversation." + }, + "max_tokens": { + "type": "number", + "description": "Maximum tokens in the response (default 4096, max 8192).", + "default": 4096 + }, + "temperature": { + "type": "number", + "description": "Sampling temperature 0.0–1.0 (default 1.0 = standard).", + "default": 1.0 + } + }, + "required": [ + "messages" + ] + } + }, + { + "name": "claude_count_tokens", + "description": "Count how many tokens a given set of messages would consume without actually sending them. Uses the Anthropic token-counting endpoint.", + "inputSchema": { + "type": "object", + "properties": { + "messages": { + "type": "array", + "description": "Messages to count tokens for.", + "items": { + "type": "object", + "properties": { + "role": { + "type": "string", + "enum": [ + "user", + "assistant" + ] + }, + "content": { + "type": "string" + } + }, + "required": [ + "role", + "content" + ] + }, + "minItems": 1 + }, + "model": { + "type": "string", + "description": "Model to count against (affects tokenisation). Defaults to claude-sonnet-4-6.", + "default": "claude-sonnet-4-6" + }, + "system": { + "type": "string", + "description": "System prompt to include in the count." + } + }, + "required": [ + "messages" + ] + } + }, + { + "name": "claude_list_models", + "description": "Return the known Claude model IDs available through this cartridge, with tier labels.", + "inputSchema": { + "type": "object", + "properties": {} + } + } + ], + "ffi": { + "so_path": "ffi/zig-out/lib/libclaude_ai_mcp.so", + "abi_version": "ADR-0006", + "symbols": [ + "boj_cartridge_init", + "boj_cartridge_deinit", + "boj_cartridge_name", + "boj_cartridge_version", + "boj_cartridge_invoke" + ] + } +} diff --git a/cartridges/cross-cutting/agentic/claude-ai-mcp/ffi/README.adoc b/cartridges/cross-cutting/agentic/claude-ai-mcp/ffi/README.adoc new file mode 100644 index 0000000..0c3659d --- /dev/null +++ b/cartridges/cross-cutting/agentic/claude-ai-mcp/ffi/README.adoc @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) += claude-ai-mcp / ffi β€” Zig FFI layer **(stub)** +:orientation: shallow + +== Purpose + +C-ABI exports bridging this cartridge's Idris2 ABI to the runtime. Consumed by +the adapter for protocol dispatch and by the trunk loader for mount. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” produces `zig-out/lib/libclaude-ai.so`. +| `claude_ai_mcp_ffi.zig` | C-ABI exports (2 exports, 0 +0 inline tests, 15 lines). +|=== + +== Invariants + +* **Null-pointer rejection** on `[*c]const u8` arguments before any + dereference. +* **Negative return values** carry documented error meanings; zero is + success (matching the trunk error-code convention). +* **No allocator leaks** β€” every allocation path pairs with `defer free`. + +== Test/proof surface + +0 +0 inline `test "..."` block(s) in `claude_ai_mcp_ffi.zig`. Run with +`cd ffi && zig build test`. + +== Read-first + +. `claude_ai_mcp_ffi.zig` β€” C-ABI signatures and guards. +. Trunk `ffi/zig/src/catalogue.zig` for how this `.so` is loaded. + +== Promotion gate + +Wire these exports to real backing state (not `return 0` / `return 1` +placeholders) before promoting past D. diff --git a/cartridges/cross-cutting/agentic/claude-ai-mcp/ffi/build.zig b/cartridges/cross-cutting/agentic/claude-ai-mcp/ffi/build.zig new file mode 100644 index 0000000..99dddf3 --- /dev/null +++ b/cartridges/cross-cutting/agentic/claude-ai-mcp/ffi/build.zig @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + // Shared ADR-0006 invoke-shim module (relative path up to boj-server trunk). + + const shim_mod = b.addModule("cartridge_shim", .{ + + .root_source_file = b.path("../../../ffi/zig/src/cartridge_shim.zig"), + + .target = target, + + .optimize = optimize, + + }); + + const ffi_mod = b.addModule("claude_ai_mcp", .{ + .root_source_file = b.path("claude_ai_mcp_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + ffi_mod.addImport("cartridge_shim", shim_mod); + + const lib = b.addLibrary(.{ + .name = "claude_ai_mcp", + .root_module = ffi_mod, + .linkage = .dynamic, + }); + lib.linkLibC(); + b.installArtifact(lib); + + const tests = b.addTest(.{ .root_module = ffi_mod }); + tests.linkLibC(); + const run_tests = b.addRunArtifact(tests); + const test_step = b.step("test", "Run FFI tests"); + test_step.dependOn(&run_tests.step); +} diff --git a/cartridges/cross-cutting/agentic/claude-ai-mcp/ffi/cartridge_shim.zig b/cartridges/cross-cutting/agentic/claude-ai-mcp/ffi/cartridge_shim.zig new file mode 100644 index 0000000..0e399db --- /dev/null +++ b/cartridges/cross-cutting/agentic/claude-ai-mcp/ffi/cartridge_shim.zig @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +// +// cartridge_shim.zig β€” Shared helpers for the ADR-0006 five-symbol +// cartridge ABI (`boj_cartridge_init / deinit / name / version / invoke`). +// +// The shim centralises the seven-code return convention, NUL-argument +// guards, tool-name comparison, and the buffer-too-small path so each +// cartridge's `boj_cartridge_invoke` can stay short β€” typically a tool +// table plus `shim.writeResult(...)`. +// +// Cartridges import this file by relative path (no build-graph change +// needed). Example: +// +// const shim = @import("../../../ffi/zig/src/cartridge_shim.zig"); +// +// export fn boj_cartridge_invoke( +// tool_name: [*c]const u8, +// json_args: [*c]const u8, +// out_buf: [*c]u8, +// in_out_len: [*c]usize, +// ) callconv(.c) i32 { +// _ = json_args; +// if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; +// const body = if (shim.toolIs(tool_name, "foo")) "{\"result\":{}}" +// else return shim.RC_UNKNOWN_TOOL; +// return shim.writeResult(out_buf, in_out_len, body); +// } + +const std = @import("std"); + +// ── Return codes (ADR-0006 Β§Return codes) ──────────────────────────── +// +// Frozen by ADR-0006. New failure modes compose these via the error +// JSON body β€” the integer surface does not grow without a follow-up ADR. + +pub const RC_SUCCESS: i32 = 0; +pub const RC_UNKNOWN_TOOL: i32 = -1; +pub const RC_BAD_ARGS: i32 = -2; +pub const RC_BUFFER_TOO_SMALL: i32 = -3; +pub const RC_RUNTIME_ERROR: i32 = -4; +pub const RC_PANIC: i32 = -5; +pub const RC_AUTH_DENIED: i32 = -6; + +// ── Invoke-path helpers ────────────────────────────────────────────── + +/// True if any of the three mandatory `boj_cartridge_invoke` output-path +/// pointers is null. Use at the top of every invoke to short-circuit to +/// `RC_BAD_ARGS`. +pub fn invokeArgsNull( + tool_name: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) bool { + return tool_name == null or out_buf == null or in_out_len == null; +} + +/// Compare a C-NUL-terminated tool-name pointer against a Zig string +/// literal. Caller must have already verified `tool_name` is non-null +/// (usually via `invokeArgsNull`). +pub fn toolIs(tool_name: [*c]const u8, expected: []const u8) bool { + const s = std.mem.span(@as([*:0]const u8, @ptrCast(tool_name))); + return std.mem.eql(u8, s, expected); +} + +/// Copy `body` into `out_buf[0..*in_out_len]` (as a capacity) and update +/// `*in_out_len` to the number of bytes written. Returns `RC_SUCCESS`. +/// +/// If `body.len` exceeds the current capacity stored in `*in_out_len`, +/// sets `*in_out_len` to the required size and returns +/// `RC_BUFFER_TOO_SMALL` β€” the caller is then expected to re-allocate +/// and retry, per ADR-0006 Β§Memory ownership. +/// +/// Caller must have already verified that `out_buf` and `in_out_len` +/// are non-null. +pub fn writeResult( + out_buf: [*c]u8, + in_out_len: [*c]usize, + body: []const u8, +) i32 { + const cap = in_out_len.*; + if (body.len > cap) { + in_out_len.* = body.len; + return RC_BUFFER_TOO_SMALL; + } + @memcpy(out_buf[0..body.len], body); + in_out_len.* = body.len; + return RC_SUCCESS; +} + +// ── Tests ──────────────────────────────────────────────────────────── + +test "writeResult: body fits, writes and sets length" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); + try std.testing.expectEqualStrings("hello", buf[0..len]); +} + +test "writeResult: too small returns -3 and sets required length" { + var buf: [2]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_BUFFER_TOO_SMALL, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: exact-fit succeeds" { + var buf: [5]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: empty body" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, ""); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 0), len); +} + +test "toolIs: matches and rejects" { + const name: [*:0]const u8 = "foo"; + try std.testing.expect(toolIs(@ptrCast(name), "foo")); + try std.testing.expect(!toolIs(@ptrCast(name), "bar")); + try std.testing.expect(!toolIs(@ptrCast(name), "foobar")); + try std.testing.expect(!toolIs(@ptrCast(name), "fo")); +} + +test "invokeArgsNull: detects each null slot" { + var buf: [4]u8 = undefined; + var len: usize = 4; + const name: [*:0]const u8 = "x"; + try std.testing.expect(!invokeArgsNull(@ptrCast(name), &buf, &len)); + try std.testing.expect(invokeArgsNull(null, &buf, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), null, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), &buf, null)); +} diff --git a/cartridges/cross-cutting/agentic/claude-ai-mcp/ffi/claude_ai_mcp_ffi.zig b/cartridges/cross-cutting/agentic/claude-ai-mcp/ffi/claude_ai_mcp_ffi.zig new file mode 100644 index 0000000..7999f62 --- /dev/null +++ b/cartridges/cross-cutting/agentic/claude-ai-mcp/ffi/claude_ai_mcp_ffi.zig @@ -0,0 +1,103 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +// +// Claude AI MCP cartridge FFI β€” stub implementation +// Full implementation uses the Anthropic API via the REST adapter layer. + +const std = @import("std"); + +pub export fn claude_ai_mcp_version() [*:0]const u8 { + return "0.1.0"; +} + +pub export fn claude_ai_mcp_health() c_int { + return 0; +} + +// ═══════════════════════════════════════════════════════════════════════ +// Standard ABI (ADR-0005 four symbols + ADR-0006 invoke) +// ═══════════════════════════════════════════════════════════════════════ + +const shim = @import("cartridge_shim.zig"); + +const CARTRIDGE_NAME_PTR: [*:0]const u8 = "claude-ai-mcp"; +const CARTRIDGE_VERSION_PTR: [*:0]const u8 = "0.1.0"; + +export fn boj_cartridge_init() callconv(.c) c_int { + return 0; +} + +export fn boj_cartridge_deinit() callconv(.c) void {} + +export fn boj_cartridge_name() callconv(.c) [*:0]const u8 { + return CARTRIDGE_NAME_PTR; +} + +export fn boj_cartridge_version() callconv(.c) [*:0]const u8 { + return CARTRIDGE_VERSION_PTR; +} + +/// Dispatch the cartridge.json MCP tools. Grade D Alpha stubs. +export fn boj_cartridge_invoke( + tool_name: [*c]const u8, + json_args: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) callconv(.c) i32 { + _ = json_args; + if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; + + const body: []const u8 = if (shim.toolIs(tool_name, "claude_chat")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "claude_count_tokens")) + "{\"result\":{\"count\":0,\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "claude_list_models")) + "{\"result\":{\"items\":[],\"count\":0,\"status\":\"stub\"}}" +else + return shim.RC_UNKNOWN_TOOL; + + return shim.writeResult(out_buf, in_out_len, body); +} + +// ═══════════════════════════════════════════════════════════════════════ +// ADR-0006 invoke dispatch tests +// ═══════════════════════════════════════════════════════════════════════ + +test "boj_cartridge_name returns claude-ai-mcp" { + const n = std.mem.span(boj_cartridge_name()); + try std.testing.expectEqualStrings("claude-ai-mcp", n); +} + +test "boj_cartridge_init returns 0" { + try std.testing.expectEqual(@as(c_int, 0), boj_cartridge_init()); +} + +test "invoke: each declared tool succeeds" { + var buf: [256]u8 = undefined; + const tools = [_][]const u8{ + "claude_chat", + "claude_count_tokens", + "claude_list_models", + }; + for (tools) |t| { + var len: usize = buf.len; + const rc = boj_cartridge_invoke(t.ptr, "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, 0), rc); + try std.testing.expect(std.mem.indexOf(u8, buf[0..len], "result") != null); + } +} + +test "invoke: unknown tool returns -1" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("nope", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -1), rc); +} + +test "invoke: buffer too small returns -3" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("claude_chat", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -3), rc); + try std.testing.expect(len > 4); +} diff --git a/cartridges/cross-cutting/agentic/claude-ai-mcp/minter.toml b/cartridges/cross-cutting/agentic/claude-ai-mcp/minter.toml new file mode 100644 index 0000000..9e8d5b5 --- /dev/null +++ b/cartridges/cross-cutting/agentic/claude-ai-mcp/minter.toml @@ -0,0 +1,12 @@ +# SPDX-License-Identifier: MPL-2.0 +name = "claude-ai-mcp" +description = "Anthropic Messages API cartridge β€” send messages to Claude models, count tokens, manage conversations" +version = "0.1.0" +domain = "AI" +protocols = [ + "MCP", + "REST", +] +tier = "Ayo" +backend = "universal" +generate_panel = true diff --git a/cartridges/cross-cutting/agentic/claude-ai-mcp/mod.js b/cartridges/cross-cutting/agentic/claude-ai-mcp/mod.js new file mode 100644 index 0000000..b35810b --- /dev/null +++ b/cartridges/cross-cutting/agentic/claude-ai-mcp/mod.js @@ -0,0 +1,98 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +// +// claude-ai-mcp/mod.js β€” Anthropic Claude API cartridge. +// +// Delegates to the Anthropic Claude API via fetch(). +// Requires ANTHROPIC_API_KEY environment variable. + +const ANTHROPIC_API_KEY = Deno.env.get("ANTHROPIC_API_KEY") ?? ""; +const ANTHROPIC_BASE = "https://api.anthropic.com/v1"; +const TIMEOUT_MS = 60_000; + +async function anthropicPost(path, payload) { + if (!ANTHROPIC_API_KEY) { + return { status: 401, data: { error: "ANTHROPIC_API_KEY not set" } }; + } + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), TIMEOUT_MS); + try { + const r = await fetch(`${ANTHROPIC_BASE}${path}`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-api-key": ANTHROPIC_API_KEY, + "anthropic-version": "2023-06-01", + }, + body: JSON.stringify(payload), + signal: ctrl.signal, + }); + const data = await r.json().catch(() => ({ error: "non-JSON response" })); + return { status: r.status, data }; + } catch (e) { + if (e.name === "AbortError") return { status: 504, data: { error: "Anthropic API timed out" } }; + return { status: 503, data: { error: `Anthropic API unavailable: ${e.message}` } }; + } finally { + clearTimeout(t); + } +} + +async function anthropicGet(path) { + if (!ANTHROPIC_API_KEY) { + return { status: 401, data: { error: "ANTHROPIC_API_KEY not set" } }; + } + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), TIMEOUT_MS); + try { + const r = await fetch(`${ANTHROPIC_BASE}${path}`, { + method: "GET", + headers: { + "x-api-key": ANTHROPIC_API_KEY, + "anthropic-version": "2023-06-01", + }, + signal: ctrl.signal, + }); + const data = await r.json().catch(() => ({ error: "non-JSON response" })); + return { status: r.status, data }; + } catch (e) { + if (e.name === "AbortError") return { status: 504, data: { error: "Anthropic API timed out" } }; + return { status: 503, data: { error: `Anthropic API unavailable: ${e.message}` } }; + } finally { + clearTimeout(t); + } +} + +export async function handleTool(toolName, args) { + switch (toolName) { + + // -- claude_chat ---------------------------------------------------------- + case "claude_chat": { + const { model = "claude-sonnet-4-6", messages, system, max_tokens = 4096 } = args ?? {}; + if (!messages || !Array.isArray(messages) || messages.length === 0) { + return { status: 400, data: { error: "messages array is required" } }; + } + const payload = { model, messages, max_tokens }; + if (system) payload.system = system; + return anthropicPost("/messages", payload); + } + + // -- claude_count_tokens -------------------------------------------------- + case "claude_count_tokens": { + const { model = "claude-sonnet-4-6", messages, system } = args ?? {}; + if (!messages || !Array.isArray(messages)) { + return { status: 400, data: { error: "messages array is required" } }; + } + const payload = { model, messages }; + if (system) payload.system = system; + return anthropicPost("/messages/count_tokens", payload); + } + + // -- claude_list_models --------------------------------------------------- + case "claude_list_models": { + return anthropicGet("/models"); + } + + default: + return { status: 404, data: { error: `Unknown tool: ${toolName}` } }; + } +} diff --git a/cartridges/cross-cutting/agentic/claude-ai-mcp/panels/manifest.json b/cartridges/cross-cutting/agentic/claude-ai-mcp/panels/manifest.json new file mode 100644 index 0000000..a72e253 --- /dev/null +++ b/cartridges/cross-cutting/agentic/claude-ai-mcp/panels/manifest.json @@ -0,0 +1,89 @@ +{ + "$schema": "https://panll.dev/schemas/panel-manifest/v1.json", + "spdx": "MPL-2.0", + "cartridge": "claude-ai-mcp", + "domain": "AI", + "version": "0.1.0", + "panels": [ + { + "id": "claude-auth-status", + "title": "Auth Status", + "description": "Whether ANTHROPIC_API_KEY is present and the API is reachable", + "type": "status-indicator", + "data_source": { + "endpoint": "/cartridge/claude-ai-mcp/invoke", + "method": "POST", + "body": { "tool": "status" }, + "refresh_interval_ms": 30000 + }, + "widgets": [ + { + "type": "state-badge", + "field": "state", + "states": { + "ready": { "color": "#2ecc71", "icon": "check-circle" }, + "no_key": { "color": "#e74c3c", "icon": "key" }, + "api_error": { "color": "#e74c3c", "icon": "alert-triangle" }, + "rate_limited": { "color": "#f39c12", "icon": "clock" } + } + } + ] + }, + { + "id": "claude-active-model", + "title": "Active Model", + "description": "Default model in use and its tier", + "type": "metric", + "data_source": { + "endpoint": "/cartridge/claude-ai-mcp/invoke", + "method": "POST", + "body": { "tool": "claude_list_models" }, + "refresh_interval_ms": 60000 + }, + "widgets": [ + { "type": "text", "field": "default", "label": "Default Model", "icon": "cpu" }, + { "type": "counter", "field": "models.length", "label": "Available Models", "icon": "layers" } + ] + }, + { + "id": "claude-usage", + "title": "Token Usage", + "description": "Cumulative input and output tokens sent through this cartridge in the current session", + "type": "metric", + "data_source": { + "endpoint": "/cartridge/claude-ai-mcp/usage", + "method": "GET", + "refresh_interval_ms": 10000 + }, + "widgets": [ + { "type": "counter", "field": "input_tokens", "label": "Input Tokens", "icon": "arrow-up" }, + { "type": "counter", "field": "output_tokens", "label": "Output Tokens", "icon": "arrow-down" }, + { "type": "counter", "field": "total_requests","label": "Requests", "icon": "activity" } + ] + }, + { + "id": "claude-request-log", + "title": "Request Log", + "description": "Recent messages sent through the cartridge β€” model, token cost, stop reason", + "type": "log-stream", + "data_source": { + "endpoint": "/cartridge/claude-ai-mcp/audit", + "method": "GET", + "refresh_interval_ms": 15000, + "max_entries": 50 + }, + "widgets": [ + { + "type": "log-table", + "columns": [ + { "field": "timestamp", "label": "Time", "format": "datetime" }, + { "field": "model", "label": "Model" }, + { "field": "input_tokens", "label": "In Tokens" }, + { "field": "output_tokens","label": "Out Tokens" }, + { "field": "stop_reason", "label": "Stop" } + ] + } + ] + } + ] +} diff --git a/cartridges/cross-cutting/agentic/claude-ai-mcp/src/server.js b/cartridges/cross-cutting/agentic/claude-ai-mcp/src/server.js new file mode 100644 index 0000000..6c0f2b4 --- /dev/null +++ b/cartridges/cross-cutting/agentic/claude-ai-mcp/src/server.js @@ -0,0 +1,393 @@ +#!/usr/bin/env node +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +// +// claude-ai-mcp β€” Anthropic Messages API cartridge for the BoJ +// +// Exposes three MCP tools: +// claude_chat β€” send messages, get a response +// claude_count_tokens β€” count tokens without sending +// claude_list_models β€” list available model IDs +// +// Auth: ANTHROPIC_API_KEY env var (sourced from vault-mcp in production). +// No npm dependencies β€” uses Node built-in https module only. + +"use strict"; + +const https = require("https"); + +// --------------------------------------------------------------------------- +// Config +// --------------------------------------------------------------------------- + +const SERVER_NAME = "claude-ai-mcp"; +const SERVER_VERSION = "0.1.0"; +const ANTHROPIC_VERSION = "2023-06-01"; +const BASE_HOST = "api.anthropic.com"; + +const KNOWN_MODELS = [ + { id: "claude-opus-4-6", tier: "Opus", note: "Most capable β€” complex reasoning, formal verification" }, + { id: "claude-sonnet-4-6", tier: "Sonnet", note: "Balanced β€” standard coding and analysis (default)" }, + { id: "claude-haiku-4-5-20251001", tier: "Haiku", note: "Fastest β€” simple lookups, mechanical tasks" }, +]; + +const DEFAULT_MODEL = "claude-sonnet-4-6"; +const DEFAULT_MAX_TOKENS = 4096; + +// --------------------------------------------------------------------------- +// HTTP helper (no npm, uses Node built-in https) +// --------------------------------------------------------------------------- + +/** + * Make a POST request to the Anthropic API. + * Returns the parsed JSON body or throws on HTTP error. + * + * @param {string} path - e.g. "/v1/messages" + * @param {object} body - request body + * @param {string} apiKey - Anthropic API key + * @returns {Promise} + */ +function anthropicPost(path, body, apiKey) { + return new Promise((resolve, reject) => { + const payload = JSON.stringify(body); + const options = { + hostname: BASE_HOST, + port: 443, + path, + method: "POST", + headers: { + "x-api-key": apiKey, + "anthropic-version": ANTHROPIC_VERSION, + "content-type": "application/json", + "content-length": Buffer.byteLength(payload), + }, + }; + + const req = https.request(options, (res) => { + let data = ""; + res.on("data", (chunk) => { data += chunk; }); + res.on("end", () => { + try { + const parsed = JSON.parse(data); + if (res.statusCode >= 400) { + const msg = parsed.error?.message || `HTTP ${res.statusCode}`; + reject(new Error(`Anthropic API error (${res.statusCode}): ${msg}`)); + } else { + resolve(parsed); + } + } catch (e) { + reject(new Error(`Failed to parse Anthropic response: ${e.message}`)); + } + }); + }); + + req.on("error", (e) => reject(new Error(`HTTPS request failed: ${e.message}`))); + req.write(payload); + req.end(); + }); +} + +// --------------------------------------------------------------------------- +// Tool implementations +// --------------------------------------------------------------------------- + +/** + * claude_chat: send a conversation, return assistant reply text + usage. + */ +async function claudeChat(args) { + const apiKey = process.env.ANTHROPIC_API_KEY; + if (!apiKey) { + throw new Error("ANTHROPIC_API_KEY is not set. Source it from vault-mcp."); + } + + const model = args.model || DEFAULT_MODEL; + const maxTokens = args.max_tokens || DEFAULT_MAX_TOKENS; + + const body = { + model, + max_tokens: maxTokens, + messages: args.messages, + }; + + if (args.system) { + body.system = args.system; + } + if (typeof args.temperature === "number") { + body.temperature = args.temperature; + } + + const response = await anthropicPost("/v1/messages", body, apiKey); + + // Extract text from content blocks + const textBlocks = (response.content || []) + .filter((b) => b.type === "text") + .map((b) => b.text) + .join(""); + + return { + text: textBlocks, + model: response.model, + stop_reason: response.stop_reason, + usage: response.usage, + id: response.id, + }; +} + +/** + * claude_count_tokens: count tokens without sending the message. + */ +async function claudeCountTokens(args) { + const apiKey = process.env.ANTHROPIC_API_KEY; + if (!apiKey) { + throw new Error("ANTHROPIC_API_KEY is not set. Source it from vault-mcp."); + } + + const model = args.model || DEFAULT_MODEL; + + const body = { + model, + messages: args.messages, + }; + if (args.system) { + body.system = args.system; + } + + const response = await anthropicPost("/v1/messages/count_tokens", body, apiKey); + + return { + input_tokens: response.input_tokens, + model, + }; +} + +/** + * claude_list_models: return known model IDs. + */ +function claudeListModels() { + return { + models: KNOWN_MODELS, + default: DEFAULT_MODEL, + note: "claude-sonnet-4-6 is the recommended default for most tasks.", + }; +} + +// --------------------------------------------------------------------------- +// MCP tool definitions +// --------------------------------------------------------------------------- + +const TOOLS = [ + { + name: "claude_chat", + description: + "Send a message (or multi-turn conversation) to a Claude model and return the text response. Uses the Anthropic Messages API. Requires ANTHROPIC_API_KEY.", + inputSchema: { + type: "object", + properties: { + messages: { + type: "array", + description: "Conversation history. Each entry needs 'role' (user|assistant) and 'content' (string).", + items: { + type: "object", + properties: { + role: { type: "string", enum: ["user", "assistant"] }, + content: { type: "string" }, + }, + required: ["role", "content"], + }, + minItems: 1, + }, + model: { + type: "string", + description: "Model ID. Defaults to claude-sonnet-4-6.", + }, + system: { + type: "string", + description: "Optional system prompt.", + }, + max_tokens: { + type: "number", + description: "Max tokens in response (default 4096).", + }, + temperature: { + type: "number", + description: "Sampling temperature 0.0–1.0 (default 1.0).", + }, + }, + required: ["messages"], + }, + }, + { + name: "claude_count_tokens", + description: + "Count how many input tokens a set of messages would consume without sending them. Uses the Anthropic token-counting endpoint.", + inputSchema: { + type: "object", + properties: { + messages: { + type: "array", + items: { + type: "object", + properties: { + role: { type: "string", enum: ["user", "assistant"] }, + content: { type: "string" }, + }, + required: ["role", "content"], + }, + minItems: 1, + }, + model: { + type: "string", + description: "Model to count against (affects tokenisation). Defaults to claude-sonnet-4-6.", + }, + system: { + type: "string", + description: "System prompt to include in the count.", + }, + }, + required: ["messages"], + }, + }, + { + name: "claude_list_models", + description: + "Return the Claude model IDs available through this cartridge, with tier labels and notes.", + inputSchema: { + type: "object", + properties: {}, + }, + }, +]; + +// --------------------------------------------------------------------------- +// MCP stdio transport (JSON-RPC 2.0, Content-Length framing) +// --------------------------------------------------------------------------- + +let buffer = ""; + +process.stdin.setEncoding("utf-8"); +process.stdin.on("data", (chunk) => { + buffer += chunk; + while (true) { + const headerEnd = buffer.indexOf("\r\n\r\n"); + if (headerEnd === -1) break; + + const header = buffer.substring(0, headerEnd); + const lengthMatch = header.match(/Content-Length:\s*(\d+)/i); + if (!lengthMatch) { + buffer = buffer.substring(headerEnd + 4); + continue; + } + + const contentLength = parseInt(lengthMatch[1], 10); + const bodyStart = headerEnd + 4; + if (buffer.length < bodyStart + contentLength) break; + + const body = buffer.substring(bodyStart, bodyStart + contentLength); + buffer = buffer.substring(bodyStart + contentLength); + + try { + const message = JSON.parse(body); + handleMessage(message); + } catch (_err) { + sendError(null, -32700, "Parse error"); + } + } +}); + +function send(message) { + const body = JSON.stringify(message); + const header = `Content-Length: ${Buffer.byteLength(body)}\r\n\r\n`; + process.stdout.write(header + body); +} + +function sendResult(id, result) { + send({ jsonrpc: "2.0", id, result }); +} + +function sendError(id, code, message) { + send({ jsonrpc: "2.0", id, error: { code, message } }); +} + +function handleMessage(msg) { + if (msg.method === "initialize") { + sendResult(msg.id, { + protocolVersion: "2024-11-05", + capabilities: { tools: {} }, + serverInfo: { name: SERVER_NAME, version: SERVER_VERSION }, + }); + } else if (msg.method === "notifications/initialized") { + // no-op + } else if (msg.method === "tools/list") { + sendResult(msg.id, { tools: TOOLS }); + } else if (msg.method === "tools/call") { + handleToolCall(msg); + } else if (msg.method === "ping") { + sendResult(msg.id, {}); + } else if (msg.id !== undefined) { + sendError(msg.id, -32601, `Unknown method: ${msg.method}`); + } +} + +function handleToolCall(msg) { + const { name, arguments: args } = msg.params; + + const dispatch = async () => { + switch (name) { + case "claude_chat": + return await claudeChat(args); + case "claude_count_tokens": + return await claudeCountTokens(args); + case "claude_list_models": + return claudeListModels(); + default: + sendError(msg.id, -32602, `Unknown tool: ${name}`); + return null; + } + }; + + dispatch() + .then((result) => { + if (result !== null) { + sendResult(msg.id, { + content: [{ type: "text", text: JSON.stringify(result, null, 2) }], + }); + } + }) + .catch((err) => { + const errMsg = err instanceof Error ? err.message : String(err); + sendError(msg.id, -32603, errMsg); + }); +} + +// --------------------------------------------------------------------------- +// Crash resilience β€” keep the server alive when a single request fails +// --------------------------------------------------------------------------- + +process.on("uncaughtException", (err) => { + process.stderr.write(`[${SERVER_NAME}] uncaughtException: ${err.message}\n`); + process.stderr.write(`${err.stack}\n`); +}); + +process.on("unhandledRejection", (reason) => { + const msg = reason instanceof Error ? reason.message : String(reason); + process.stderr.write(`[${SERVER_NAME}] unhandledRejection: ${msg}\n`); +}); + +process.stdin.on("end", () => { + process.stderr.write(`[${SERVER_NAME}] stdin closed β€” exiting cleanly\n`); + process.exit(0); +}); + +process.stdin.on("error", (err) => { + process.stderr.write(`[${SERVER_NAME}] stdin error: ${err.message}\n`); +}); + +process.stdout.on("error", (err) => { + // EPIPE β€” Claude Code closed its end; exit cleanly instead of crashing + if (err.code === "EPIPE") { + process.exit(0); + } + process.stderr.write(`[${SERVER_NAME}] stdout error: ${err.message}\n`); +}); + +process.stderr.write(`[${SERVER_NAME}] MCP server running on stdio\n`); diff --git a/cartridges/cross-cutting/agentic/local-coord-mcp/LOCAL-COORD-MCP-REPORT.adoc b/cartridges/cross-cutting/agentic/local-coord-mcp/LOCAL-COORD-MCP-REPORT.adoc new file mode 100644 index 0000000..2c3f358 --- /dev/null +++ b/cartridges/cross-cutting/agentic/local-coord-mcp/LOCAL-COORD-MCP-REPORT.adoc @@ -0,0 +1,285 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) += LOCAL-COORD-MCP Technical Report +Jonathan D.A. Jewell +v0.1.0, 2026-04-12 +:toc: +:toc-placement: preamble +:sectnums: + +Technical report for the `local-coord-mcp` cartridge β€” BoJ Server cartridge +#97 (numbered at design time; registered count is 99+ after recent additions). + +== Overview + +[cols="1,3"] +|=== +| *Cartridge* | `local-coord-mcp` +| *Version* | 0.1.0 +| *Domain* | Agent (ai) +| *Tier* | Ayo +| *Protocols* | MCP, Agentic +| *Port* | 7745 (REST, loopback only) +| *Federation* | Disabled (LocalOnly β€” uninhabited IsFederated type) +| *Max peers* | 16 concurrent instances +| *Max claims* | 64 concurrent task locks +| *Manifest format* | Nickel (.ncl) source-of-truth β†’ JSON export (first BoJ cartridge to use this) +|=== + +== Problem Statement + +When running multiple AI instances on one machine (e.g. three Claude Code +windows splitting a repo audit), there is no mechanism for the instances to: + +1. Know each other exist +2. Avoid duplicating work +3. Share findings or coordinate strategy + +This leads to wasted tokens, conflicting edits, and redundant effort. The +user must manually orchestrate by switching windows and copy-pasting context. + +== Design Decisions + +=== DD-1: Manifest Format (CLOSED 2026-04-12) + +*Decision:* Nickel. + +`cartridge.ncl` is the source-of-truth. `nickel export --format json` +produces `cartridge.json` for the MCP bridge. This aligns with the estate +rule "tools emit A2ML (data) or Nickel (schemas), not JSON" β€” manifests are +config/schema (Nickel), not data (A2ML). + +=== DD-2: Port (CLOSED 2026-04-12) + +*Decision:* 7745. + +Ports 7700–7744 are allocated. 7745 is the next sequential assignment. + +=== DD-3: Client Identity (CLOSED 2026-04-12) + +*Decision:* Hybrid β€” human-readable prefix + 4-char hex suffix. + +Examples: `claude-7f3a`, `gemini-b2c1`, `copilot-4e9d`, `openai-5fab`, +`mistral-9c0e`. Readable in logs and tool output, unique enough to prevent +collisions on a single machine. Supports the 6-LLM workflow split β€” Claude, +Gemini, Copilot, OpenAI, Mistral, plus a `custom` fall-through (Task #33 +extended `openai` + `mistral`). + +== Security Architecture + +=== Loopback-Only Binding (Compile-Time) + +The Idris2 ABI defines `IsLoopback` as a dependent type with exactly two +constructors: + +[source,idris] +---- +data IsLoopback : String -> Type where + IsIPv4Loop : IsLoopback "127.0.0.1" + IsIPv6Loop : IsLoopback "::1" +---- + +The `BindConfig` record requires an `IsLoopback` proof. The type +`IsLoopback "0.0.0.0"` is uninhabited β€” `wildcardNotLoopback` proves this. +No code path exists that can bind to a non-loopback address. + +The Zig FFI and adapter hardcode `127.0.0.1` as a defence-in-depth measure, +but the primary guarantee is the Idris2 proof. + +=== Session Tokens + +Each peer receives a 128-bit CSPRNG token (from `std.crypto.random`) on +registration. All 6 tool calls require this token. Invalid tokens are +rejected before dispatch. Tokens are not stored on disk β€” they exist only in +the Zig FFI's in-memory peer registry. + +=== Federation Opt-Out + +[source,idris] +---- +data FederationPolicy : Type where + LocalOnly : FederationPolicy + +data IsFederated : FederationPolicy -> Type where + -- Intentionally empty + +localOnlyNotFederated : IsFederated LocalOnly -> Void +localOnlyNotFederated _ impossible +---- + +The `IsFederated` type has zero constructors for `LocalOnly`. Any code +requiring federation participation would need to construct this proof, which +is impossible. This is not a feature flag β€” it is a type-level impossibility. + +=== Attack Surface + +[cols="1,2,2"] +|=== +| Vector | Mitigation | Proof level + +| Network exposure +| Loopback-only bind +| Compile-time (Idris2 `IsLoopback`) + +| Rogue local processes +| Per-session CSPRNG token +| Runtime (Zig FFI) + +| Federation leak +| Uninhabited `IsFederated` type +| Compile-time (Idris2) + +| Inbox flood +| 256-message ring buffer per peer +| Runtime (Zig constant) + +| Peer exhaustion +| 16-peer hard cap +| Runtime (Zig constant) + +| CRLF / null-byte injection +| Inherited from BoJ SafeHTTP/SafeWebSocket +| Compile-time (Idris2) +|=== + +== Formal Verification Summary + +[cols="1,1,2"] +|=== +| Module | Proofs | Status + +| SafeLocalCoord.idr +| `IsLoopback`, `wildcardNotLoopback`, `emptyNotLoopback`, `loopbackRoundtrip`, `ValidPort`, `coordPortValid`, `localOnlyNotFederated` +| 0 believe_me, 0 postulates + +| Protocol.idr +| `grantedAllows`, `heldDenies`, `ValidPeerTransition` (5 constructors), `canTransitionPeer` +| 0 believe_me, 0 postulates +|=== + +Total: *0 believe_me*, *0 postulates* in the local-coord-mcp ABI. + +(The shared `SafetyLemmas.idr` has 3 axiomatic believe_me for Char/String +primitives β€” these are not introduced by this cartridge.) + +== Tool Reference + +=== coord_register + +Register this instance. Returns a hybrid peer ID and a session token. + +*Input:* `{ client_kind: "claude" | "gemini" | "copilot" | "custom" | "openai" | "mistral", context?: string, declared_affinities?: [string], variant?: string }` + +*Output:* `{ peer_id: "claude-7f3a", token: "abc123..." }` + +*Side effects:* Allocates a peer slot (1 of 16). Optional `variant` is +applied via the same code path as `coord_set_variant` and rolls back the +registration on validation failure. + +=== coord_list_peers + +List all active peers. + +*Input:* `{ token: "..." }` + +*Output:* `[{ id: "claude-7f3a", kind: "claude", state: "active", status: "auditing boj-server" }, ...]` + +*Side effects:* None (read-only). + +=== coord_send + +Send a message to a specific peer or broadcast to all. + +*Input:* `{ token: "...", target: "gemini-b2c1" | "*", message: "..." }` + +*Output:* `{ sent: 1 }` + +*Side effects:* Enqueues message(s) in recipient inbox(es). + +=== coord_receive + +Poll for the next inbound message. + +*Input:* `{ token: "..." }` + +*Output:* `{ from: "claude-7f3a", message: "..." }` or `{ empty: true }` + +*Side effects:* Dequeues one message from this peer's inbox. + +=== coord_claim_task + +Attempt to claim a task (mutex acquisition). + +*Input:* `{ token: "...", task: "audit-boj-server" }` + +*Output:* `{ result: "granted" }` or `{ result: "held", holder: "claude-7f3a" }` + +*Side effects:* Acquires or rejects a claim slot. Idempotent if already held +by the same peer. + +=== coord_status + +Set this peer's current work status. + +*Input:* `{ token: "...", status: "auditing boj-server β€” src/abi/" }` + +*Output:* `{ success: true }` + +*Side effects:* Updates the peer's status field, visible via coord_list_peers. + +== Test Coverage + +=== Zig FFI Tests (8 tests, all passing) + +[cols="1,2"] +|=== +| Test | Validates + +| register and deregister peer | Basic lifecycle +| register fills up | MAX_PEERS=16 hard cap +| bad token rejected | Token validation gate +| claim mutex semantics | Grant β†’ deny β†’ release β†’ re-grant +| idempotent claim | Same peer re-claims without error +| send and receive direct message | Point-to-point messaging +| broadcast message | Fan-out to all except sender +| deregister releases claims | Auto-cleanup on disconnect +|=== + +== Integration Roadmap + +[cols="1,2,1"] +|=== +| Phase | Work | Status + +| 1. ABI +| SafeLocalCoord.idr, Protocol.idr, ipkg +| DONE + +| 2. FFI +| local_coord_ffi.zig (peer registry, claims, messages) +| DONE + +| 3. Manifest + handler +| cartridge.ncl β†’ cartridge.json, mod.js +| DONE + +| 4. Integration +| Register in Catalogue.idr, TOPOLOGY.md port map, MCP bridge wiring, PanLL panels +| Panels DONE, catalogue/topology PENDING + +| 5. End-to-end test +| Two Claude Code instances register, message, and claim +| PENDING +|=== + +== Dependencies + +This cartridge depends on: + +- Idris2 (ABI compilation) +- Zig 0.15+ (FFI and adapter) +- Deno (mod.js runtime) +- Nickel CLI (manifest generation) +- BoJ MCP bridge (tool discovery and dispatch) + +No external network dependencies. No database. No federation. diff --git a/cartridges/cross-cutting/agentic/local-coord-mcp/README.adoc b/cartridges/cross-cutting/agentic/local-coord-mcp/README.adoc new file mode 100644 index 0000000..7fa7be8 --- /dev/null +++ b/cartridges/cross-cutting/agentic/local-coord-mcp/README.adoc @@ -0,0 +1,204 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) += local-coord-mcp +:toc: +:toc-placement: preamble + +Localhost multi-instance coordination cartridge for BoJ Server. + +== Purpose + +Enables multiple AI instances (Claude Code windows, Gemini, Copilot, etc.) +running on the same machine to discover each other, exchange messages, and +coordinate work β€” preventing duplicated effort and enabling intentional +multi-window workflows. + +== Security Model + +=== Loopback-Only Binding (Compile-Time Guaranteed) + +The Idris2 ABI (`SafeLocalCoord.idr`) provides an `IsLoopback` dependent type +with exactly two inhabitants: `IsIPv4Loop` ("127.0.0.1") and `IsIPv6Loop` +("::1"). The `BindConfig` record requires this proof β€” there is no code path +that can construct a bind configuration for `0.0.0.0` or any LAN/external +address. This is a **compile-time guarantee**, not a runtime check. + +The Zig FFI and adapter honour this by hardcoding `127.0.0.1:7745`. + +=== Session Tokens + +Every peer receives a 128-bit CSPRNG token on registration. All subsequent +calls must carry this token. A message from an unregistered process is rejected +before dispatch. + +=== No Federation + +This cartridge explicitly does NOT participate in the Umoja federation network. +The `FederationPolicy` type has a single constructor (`LocalOnly`) and the +`IsFederated` type is uninhabited for `LocalOnly` β€” federation is impossible, +not just disabled. + +== Human interface: coord-tui + +`coord-tui` (at `boj-server/coord-tui/`) is the recommended human-facing +interface. It provides: + +* A real-time TUI showing all active peers and task claims. +* `--id` silent mode used by shell hooks β€” runs automatically when you open + `claude`, `gemini`, `cursor`, or `codex`, and sets the terminal window + title to ` []` so you can find any instance at a glance. +* A single-command installer (`coord-tui/install.sh`) for setting up a + new machine. + +See `coord-tui/README.adoc` for full usage. + +== Tools + +[cols="1,3"] +|=== +| Tool | Description + +| `coord_register` +| Register this instance. Returns a hybrid peer ID (e.g. `claude-7f3a`) and a session token. + +| `coord_list_peers` +| List all active peers on this machine with their IDs, kinds, and states. + +| `coord_send` +| Send a direct message to a peer or broadcast to all (`target: "*"`). + +| `coord_receive` +| Poll this peer's inbox for the next message. + +| `coord_claim_task` +| Mutex-style task claiming. First peer to claim wins; others are told who holds it. + +| `coord_status` +| Set this peer's current work status, visible to other peers. +|=== + +== Architecture + +---- +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Claude Code β”‚ β”‚ Claude Code β”‚ β”‚ Gemini β”‚ +β”‚ Window 1 β”‚ β”‚ Window 2 β”‚ β”‚ Window 3 β”‚ +β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ MCP β”‚ MCP β”‚ MCP + β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ β”‚ + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ MCP Bridge (main.js) β”‚ + β”‚ tools/call β†’ local-coord-mcp β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ mod.js (Deno adapter) β”‚ + β”‚ POST http://127.0.0.1:7745/... β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ local_coord_adapter.zig β”‚ + β”‚ REST on 127.0.0.1:7745 β”‚ + β”‚ ↓ calls ↓ β”‚ + β”‚ local_coord_ffi.zig β”‚ + β”‚ Peer registry, message fan-out, β”‚ + β”‚ task claim mutex β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + Idris2 ABI proofs: + IsLoopback, ValidPort, + PeerState transitions, + ClaimResult semantics +---- + +== Peer Identity (Hybrid Model) + +Peers are identified by a human-readable prefix + 4-char hex suffix: + +- `claude-7f3a` β€” Claude Code instance +- `gemini-b2c1` β€” Gemini instance +- `copilot-4e9d` β€” Copilot instance +- `custom-a1b2` β€” Other tool + +== Port Assignment + +| Port | Service | +|------|---------| +| 7745 | local-coord-mcp REST | + +== Files + +[cols="1,2"] +|=== +| Path | Purpose + +| `abi/LocalCoord/SafeLocalCoord.idr` +| Loopback proof, port safety, session tokens, peer identity, federation opt-out + +| `abi/LocalCoord/Protocol.idr` +| Message types, task claiming, peer lifecycle, authenticated message wrapper + +| `abi/local-coord-mcp.ipkg` +| Idris2 package definition + +| `ffi/local_coord_ffi.zig` +| Peer registry, message fan-out, claim mutex, CSPRNG tokens + +| `adapter/local_coord_adapter.zig` +| REST server on 127.0.0.1:7745 + +| `cartridge.ncl` +| Nickel source-of-truth manifest + +| `cartridge.json` +| Generated from Nickel β€” consumed by MCP bridge + +| `mod.js` +| Deno adapter for MCP tool dispatch +|=== + +== Build + +[source,bash] +---- +# FFI tests +cd ffi && zig build test + +# Shared library +cd ffi && zig build lib + +# Adapter binary +cd adapter && zig build + +# Regenerate JSON manifest from Nickel +nickel export --format json < cartridge.ncl > cartridge.json +---- + +== Usage Example + +[source] +---- +# Window 1 (Claude Code) +> coord_register(client_kind: "claude") + β†’ { peer_id: "claude-7f3a", token: "abc123..." } + +> coord_list_peers(token: "abc123...") + β†’ [{ id: "claude-7f3a", kind: "claude", state: "active" }, + { id: "gemini-b2c1", kind: "gemini", state: "active" }] + +> coord_claim_task(token: "abc123...", task: "audit-boj-server") + β†’ { result: "granted" } + +> coord_send(token: "abc123...", target: "*", message: "I'm auditing boj-server, take something else") + β†’ { sent: 1 } + +# Window 2 (Gemini) +> coord_receive(token: "def456...") + β†’ { from: "claude-7f3a", message: "I'm auditing boj-server, take something else" } + +> coord_claim_task(token: "def456...", task: "audit-boj-server") + β†’ { result: "held", holder: "claude-7f3a" } + +> coord_claim_task(token: "def456...", task: "audit-hypatia") + β†’ { result: "granted" } +---- diff --git a/cartridges/cross-cutting/agentic/local-coord-mcp/abi/LocalCoord/Identity.idr b/cartridges/cross-cutting/agentic/local-coord-mcp/abi/LocalCoord/Identity.idr new file mode 100644 index 0000000..61f815c --- /dev/null +++ b/cartridges/cross-cutting/agentic/local-coord-mcp/abi/LocalCoord/Identity.idr @@ -0,0 +1,213 @@ +-- SPDX-License-Identifier: MPL-2.0 +-- Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +||| LocalCoord.Identity: Cryptographic peer identity for the local-coord +||| cartridge. +||| +||| Cartridge: local-coord-mcp +||| ADR: 0016 (mTLS + ed25519 federation stop-gap), Phase 1 β€” identity +||| foundation. No transport here; this module defines the *types* and +||| *FFI signatures* for an ed25519 keypair per peer, the public key as +||| federated identity, and the known_peers.toml entry shape. The Zig +||| implementation (cartridges/local-coord-mcp/adapter/) realises these +||| signatures with std.crypto.sign.Ed25519. +||| +||| Phase-1 scope (deliberate non-promises): +||| * Generates / loads ed25519 keypair material on disk. +||| * Exposes the public key for human export. +||| * Parses known_peers.toml. +||| * Does NOT sign or verify anything β€” Phase 2. +||| * Does NOT bind to any non-loopback address β€” Phase 3. +||| * Does NOT change the `FederationPolicy = LocalOnly` invariant from +||| SafeLocalCoord.idr. Identity material is necessary-but-not- +||| sufficient for federation; carrying a keypair does not enable it. +module LocalCoord.Identity + +import Data.List +import Data.Vect +import Data.Nat + +import LocalCoord.SafeLocalCoord + +%default total + +-- ═══════════════════════════════════════════════════════════════════════════ +-- Key Material β€” Size-Indexed Byte Vectors +-- ═══════════════════════════════════════════════════════════════════════════ + +||| ed25519 public-key length in bytes. RFC 8032 Β§5.1.5. +public export +ed25519PubKeyBytes : Nat +ed25519PubKeyBytes = 32 + +||| ed25519 private-key length in bytes (seed form). RFC 8032 Β§5.1.5. +||| The expanded secret-scalar form is 64 bytes; we store the 32-byte +||| seed and derive the scalar at sign time. +public export +ed25519PrivKeyBytes : Nat +ed25519PrivKeyBytes = 32 + +||| ed25519 signature length in bytes (R || S). RFC 8032 Β§5.1.6. +public export +ed25519SigBytes : Nat +ed25519SigBytes = 64 + +||| A fixed-width byte vector. Used to enforce key-material sizes at +||| the type level β€” the only way to construct an `Ed25519PublicKey` +||| is via a value of `Bytes 32`, so any code holding one *knows* it +||| has 32 bytes without runtime checks. +public export +Bytes : Nat -> Type +Bytes n = Vect n Bits8 + +||| An ed25519 public key. Wrapper around `Bytes 32` so the type system +||| distinguishes pubkeys from arbitrary 32-byte blobs. +public export +record Ed25519PublicKey where + constructor MkPubKey + bytes : Bytes ed25519PubKeyBytes + +||| An ed25519 private key (seed form). NEVER crosses the FFI boundary +||| as a payload β€” the Zig adapter holds it in process memory and +||| references it via opaque handle. This type exists in the ABI only +||| to document the contract. +public export +record Ed25519PrivateKey where + constructor MkPrivKey + bytes : Bytes ed25519PrivKeyBytes + +||| An ed25519 signature over arbitrary bytes. +public export +record Ed25519Signature where + constructor MkSig + bytes : Bytes ed25519SigBytes + +-- ═══════════════════════════════════════════════════════════════════════════ +-- Proof Obligation P-20: Ed25519 Key Material Well-Formedness +-- ═══════════════════════════════════════════════════════════════════════════ +-- +-- A public key value carries exactly `ed25519PubKeyBytes` bytes; a +-- signature value carries exactly `ed25519SigBytes`. This is enforced +-- *by construction* via `Vect n` β€” the only inhabitants of +-- `Bytes ed25519PubKeyBytes` are length-32 byte vectors, so any code +-- receiving an `Ed25519PublicKey` is statically guaranteed it has the +-- right length. No runtime size check is needed; no +-- malformed-key branch can exist in the protocol layer. +-- +-- The "proof" is the type itself: pattern-matching `MkPubKey bs` +-- recovers a `bs : Vect 32 Bits8`, whose index is fixed at the +-- definition site and cannot be shrunk or grown. +-- +-- Demonstration: a concrete zero-keyed pubkey is built-in-shape. + +||| Demonstration witness β€” a concrete pubkey value built from a +||| size-32 literal compiles iff the type's size invariant holds. +||| If this line fails to compile, P-20 has been broken. +public export +zeroPubKey : Ed25519PublicKey +zeroPubKey = MkPubKey (replicate ed25519PubKeyBytes 0) + +||| Same demonstration for signatures. +public export +zeroSig : Ed25519Signature +zeroSig = MkSig (replicate ed25519SigBytes 0) + +-- ═══════════════════════════════════════════════════════════════════════════ +-- Peer Identity = PeerId + Public Key +-- ═══════════════════════════════════════════════════════════════════════════ + +||| A peer's complete identity: the human-readable `PeerId` (from +||| SafeLocalCoord) plus the ed25519 public key that vouches for it. +||| The PeerId is for humans; the pubkey is for crypto. +public export +record PeerIdentity where + constructor MkPeerIdentity + peerId : PeerId + pubKey : Ed25519PublicKey + +||| Extract the display form of a peer identity. Goes through the +||| existing PeerId display β€” the pubkey is intentionally not rendered +||| here (use `pubKeyHex` for that, in a UI context). +public export +identityToString : PeerIdentity -> String +identityToString pi = peerIdToString (peerId pi) + +-- ═══════════════════════════════════════════════════════════════════════════ +-- Known Peers (known_peers.toml entries) β€” Trust List +-- ═══════════════════════════════════════════════════════════════════════════ + +||| An entry in `~/.config/coord-tui/known_peers.toml`. Manual trust +||| list: peers we have explicitly agreed to federate with. No +||| discovery, no CA hierarchy β€” SSH known_hosts model. +||| +||| The `host` and `port` fields are Phase-3 wire material; Phase 1 +||| parses them but does not connect to them. +public export +record KnownPeer where + constructor MkKnownPeer + peerId : PeerId + pubKey : Ed25519PublicKey + host : String -- DNS name or literal IP β€” validated at use-site + port : Nat + +||| The maximum number of known peers a single host may trust. +||| Bound is generous; it exists to make `Vect`-based loading easy. +public export +maxKnownPeers : Nat +maxKnownPeers = 64 + +-- ═══════════════════════════════════════════════════════════════════════════ +-- Federation Invariant Preservation +-- ═══════════════════════════════════════════════════════════════════════════ +-- +-- Crucially: this module adds *types*. It does not add a path from +-- those types to any non-loopback bind. The `FederationPolicy` value +-- in SafeLocalCoord remains `LocalOnly`, and `IsFederated LocalOnly` +-- remains uninhabited. Phase 3 will widen this β€” Phase 1 must not. + +||| Proof that having a PeerIdentity does not unlock federation. The +||| federation policy is still `LocalOnly`, and the negative proof +||| from `SafeLocalCoord.localOnlyNotFederated` carries through. +||| Discharge is structural: the LHS doesn't influence the RHS at all. +export +identityDoesNotEnableFederation + : (_ : PeerIdentity) + -> IsFederated coordFederationPolicy + -> Void +identityDoesNotEnableFederation _ x = localOnlyNotFederated x + +-- ═══════════════════════════════════════════════════════════════════════════ +-- C-ABI Contract (Phase 1) β€” documentation, not %foreign import +-- ═══════════════════════════════════════════════════════════════════════════ +-- +-- The Zig adapter exposes the following entry points. They are NOT +-- imported via `%foreign` here because the Idris2 ABI module's job is +-- to *type* the contract; the actual calls are made from Zig (intra- +-- cartridge) and from the Deno/Node bridge (over HTTP). This mirrors +-- the convention used in `SafeLocalCoord.idr`, which defines the type +-- envelope but doesn't import the Zig functions. +-- +-- int boj_coord_identity_init(const char *key_path); +-- Generates a fresh keypair if none exists at `key_path`, otherwise +-- loads the existing seed. Persists the seed (0600) on disk. Phase +-- 1 keys live at ~/.cache/coord-tui/peer.key. +-- Returns: 0 on success, non-zero error code otherwise. +-- +-- int boj_coord_identity_get_pubkey(uint8_t *out, size_t out_len); +-- Copies `ed25519PubKeyBytes` (32) bytes of the local public key +-- into `out`. The corresponding `Ed25519PublicKey` value can be +-- reconstructed from the bytes on the consumer side. +-- Returns: bytes written (== 32) on success, -1 if not initialised +-- or buffer too small. +-- +-- int boj_coord_identity_load_known_peers(const char *toml_path); +-- Parses `~/.config/coord-tui/known_peers.toml` (or supplied path) +-- into an in-process trust table of `KnownPeer` entries. Replaces +-- any previously loaded set (full reload). +-- Returns: number of entries loaded (>= 0) on success, -1 on error +-- (missing file is treated as zero entries, not an error). +-- +-- int boj_coord_identity_known_peer_count(void); +-- Returns the current count of loaded known peers. +-- +-- Phase 2 will add `boj_coord_envelope_sign` / `boj_coord_envelope_verify` +-- once `LocalCoord.Federation` lands the P-22 obligation. diff --git a/cartridges/cross-cutting/agentic/local-coord-mcp/abi/LocalCoord/PROOF-SCHEDULE.adoc b/cartridges/cross-cutting/agentic/local-coord-mcp/abi/LocalCoord/PROOF-SCHEDULE.adoc new file mode 100644 index 0000000..fe8d1bc --- /dev/null +++ b/cartridges/cross-cutting/agentic/local-coord-mcp/abi/LocalCoord/PROOF-SCHEDULE.adoc @@ -0,0 +1,361 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) + += local-coord-mcp β€” proof schedule +:author: Jonathan D.A. Jewell +:date: 2026-04-20 +:toc: macro +:version: 1 + +Companion to `SafeLocalCoord.idr` (already-proved invariants) and +`Protocol.idr` (message-passing algebra). This document enumerates every +formal-verification obligation the cartridge carries, ranks them by +priority, names the prover, and sequences the work. + +Scope is *Phase 1* (local, hermetic). Phase 2+ obligations (choreographic / +epistemic / echo / tropical) are listed but deferred per +`Desktop/COORD-MCP-DESIGN-LOG.md` Appendices H-J. + +toc::[] + +== Summary + +[cols="1,4,1,1,2",options="header"] +|=== +| # | Obligation | Prior | Status | Prover +| P-00 | Loopback-only bind | β€” | βœ… done | Idris2 +| P-01 | Port in non-privileged range | β€” | βœ… done | Idris2 +| P-02 | Federation policy ≑ LocalOnly uninhabited elsewhere | β€” | βœ… done | Idris2 +| P-03 | Session-token non-empty on issue | β€” | βœ… done (type-level) | Idris2 +| P-04 | Record format invariants (magic ∧ version ∧ CRC) | P-00…P-03 | P0 | Idris2 +| P-05 | CRC truncation tolerance (prefix-closed replay) | P-04 | P0 | Idris2 +| P-06 | Replay-equivalence (persist-then-replay ≑ direct execute) | P-04, P-05 | P0 | Idris2 +| P-07 | Quarantine state machine (determinism + monotonicity) | P-06 | P0 | Idris2 +| P-08 | Ring-buffer FIFO correctness under replay | P-06 | P1 | Idris2 +| P-09 | Persistence monotonicity (commits cannot un-happen) | P-05, P-06 | P1 | Idris2 +| P-10 | Supervisor privilege gating (env-var secret necessary) | P-03 | P1 | Idris2 +| P-11 | Tier-4 forbidden-list schema-level reject for `apprentice` | P-10 | P1 | Nickel + Idris2 +| P-12 | Nickel-contract soundness wrt risk-ladder spec | P-11 | P2 | Lean4 β€– Idris2 +| P-13 | Zig ↔ Idris2 ABI correspondence (hardcoded `127.0.0.1` realises `IsIPv4Loop`) | P-00 | P2 | Idris2 + SPARK-style assertions +| P-14 | Token unforgeability (probabilistic; 128-bit CSPRNG) | P-03 | documented | prose argument +| P-15 | Choreographic types for master/attestation flow | P-06, P-07 | Phase 2 (deferred) | Idris2 session types +| P-16 | Epistemic types for context-knowledge-witness | P-15 | Phase 2 (deferred) | Idris2 + A2ML tags +| P-17 | Echo-type formalisation of audit + summary + hash chain | P-15, P-16 | Phase 3 (deferred) | Agda (echo-types repo) +| P-18 | Tropical-semiring model of TTL + trust | P-17 | Phase 3 (deferred) | Agda (EchoTropical) +| P-19 | Cross-site primacy ceremony | P-15…P-18 | Phase 4 (deferred) | Idris2 + Agda +| P-20 | Ed25519 key-material well-formedness (pubkey ≑ 32B, sig ≑ 64B by construction) | β€” | βœ… done | Idris2 (`LocalCoord.Identity`) +| P-21 | Identity carries forward `FederationPolicy ≑ LocalOnly` (identity β‰  federation) | P-20, P-02 | βœ… done | Idris2 (`LocalCoord.Identity.identityDoesNotEnableFederation`) +| P-22 | Envelope signature soundness (sign-then-verify roundtrip on bytes) | P-20 | ADR-0016 Phase 5 | Idris2 (planned `LocalCoord.Federation`) +| P-23 | mTLS peer pinning matches `known_peers.toml` pubkey | P-20, P-22 | ADR-0016 Phase 5 | Idris2 + std.crypto axiomatised +|=== + +Stage priorities: + +P0 (**must land before Task #8 e2e sign-off**) β†’ P-04, P-05, P-06, P-07. + +P1 (**should land before track-record work, Task #13**) β†’ P-08, P-09, P-10, P-11. + +P2 (**nice-to-have; unblocks Task #17 Deno shim formal verification**) β†’ P-12, P-13. + +== Already proved β€” carry forward + +=== P-00 Loopback-only bind + +`SafeLocalCoord.loopbackToString` is total, and `IsLoopback addr` has +exactly two inhabitants (`IsIPv4Loop`, `IsIPv6Loop`). `BindConfig` +requires a `LoopbackAddr` value, and the only constructors map to +"127.0.0.1" or "::1". `wildcardNotLoopback : IsLoopback "0.0.0.0" -> Void` +closes the negative case. + +=== P-01 Port range + +`ValidPort p` requires `LTE 1024 p` and `LTE p 65535` implicit args. +`coordPortValid : ValidPort 7745` is the specific instance used. + +=== P-02 Federation opt-out + +`FederationPolicy` has one constructor (`LocalOnly`). `IsFederated` is +defined with no constructor β€” uninhabited. `localOnlyNotFederated` +closes the case. + +=== P-03 Session token non-emptiness + +`SessionToken`'s constructor demands `NonEmpty (unpack tok)`. + +These are the spine SafeLocalCoord.idr already enforces. Every new +obligation below composes with them. + +== New obligations (this task + Task #7 durability layer) + +=== P-04 Record format invariants [P0] + +*Claim.* For every record `r` successfully decoded by the replay +iterator, `r.magic = MAGIC ∧ r.format_version = 1 ∧ crc32(header(r) β€– +payload(r)) = r.crc`. + +*Artefact.* `abi/LocalCoord/Durability.idr` defines a record type +`DurableRecord` whose constructor requires all three witnesses as +implicit args. `decodeOne : BitStream -> Maybe DurableRecord` total. + +*Dependencies.* Idris2 library for CRC32 (or a cheap algebraic stand-in +for the proof; the CRC need only be opaque + extensional). + +*Acceptance.* `decodeOne` is shown to produce only well-formed records; +any corruption yields `Nothing`. + +=== P-05 CRC truncation tolerance [P0] + +*Claim.* For any log `L` with a valid prefix `L_good` followed by a +corrupt tail `L_bad` (bad magic, wrong version, or failed CRC), replay +accepts exactly the events in `L_good` and emits nothing for `L_bad`. + +*Artefact.* `Durability.idr`: +`replay : BitStream -> List DurableRecord` total + a corollary +`replayPrefixClosed : (L_good, L_bad : BitStream) -> (allValid L_good) -> +(Β¬ allValid L_bad) -> replay (L_good β€– L_bad) = replay L_good`. + +*Dependencies.* P-04. + +*Acceptance.* Matches `test "replay stops at CRC corruption"` in +`coord_durability.zig` β€” property-test lines up with theorem statement. + +=== P-06 Replay-equivalence [P0 β€” keystone theorem] + +*Claim.* Let `Οƒ : State` be the coord in-memory state. Let `step : +State -> Mutation -> State` be the direct mutation step. Let `logStep : +LogBuffer -> Mutation -> LogBuffer` be the append side. Let `replayInto +: LogBuffer -> State -> State` be the dispatcher. Then for any +mutation sequence `ms : List Mutation` and initial `Οƒ0`, + + foldl step Οƒ0 ms ≑ replayInto (foldl logStep [] ms) Οƒ0 + +*Interpretation.* Persist-then-replay is observationally equivalent to +direct execution. The durability layer never changes semantics. + +*Artefact.* `abi/LocalCoord/Durability.idr` bundles `step`, `logStep`, +`replayInto` with the equivalence lemma `replayEquivalent`. + +*Dependencies.* P-04, P-05. Requires encoding coord `State` (peers, +claims, quarantine, next_request_id) in Idris2 β€” 80% overlap with +`Protocol.idr` state. + +*Acceptance.* Unit-tested empirically already by +`test "restart replay restores peer, claim, inbox, quarantine"`; proof +closes the case for arbitrary mutation sequences. + +*Subtlety.* The replay dispatcher uses "first free slot" for quarantine +reconstruction. The theorem must state slot assignments need not match +the original run β€” only the *active-entry-set* (modulo slot identity) +must. This is the right abstraction since callers address entries by +`request_id`, not by slot index. + +=== P-07 Quarantine state machine [P0] + +*Claim.* For every `request_id r`, the log contains at most one terminal +event among `{QUAR_APPROVE r, QUAR_REJECT r}` after `QUAR_ADD r`. After +a terminal event, no further events reference `r`. + +*State chart.* +[cols="1,1,1,1",options="header"] +|=== +| State | QUAR_ADD | QUAR_APPROVE | QUAR_REJECT +| initial | β†’ pending (ok) | error | error +| pending | error | β†’ approved (ok) | β†’ rejected (ok) +| approved | error | error | error +| rejected | error | error | error +|=== + +*Artefact.* `abi/LocalCoord/Quarantine.idr` β€” state-machine witness with +`QuarState` enum, `validTransition` predicate, and a soundness lemma +that the event stream respects it. + +*Dependencies.* P-06. + +*Acceptance.* Existing `coord_approve` / `coord_reject` are single-shot +(they deactivate the entry); the code meets the spec already β€” proof +just makes it type-level. + +=== P-08 Ring-buffer FIFO correctness [P1] + +*Claim.* Under a schedule of `INBOX_PUSH(peer, msg_i)` and +`INBOX_POP(peer)` events with the invariant `count ≀ MAX_MESSAGES`, the +sequence of messages returned by consecutive `coord_receive` calls is +the sub-sequence of pushed messages whose indices exceed the number of +prior pops. (FIFO.) + +*Artefact.* `abi/LocalCoord/RingBuffer.idr` β€” with `Vect n Msg` indexed +by queue depth + push/pop operations + `popOrderFifo` lemma. + +*Dependencies.* P-06 (to lift single-peer reasoning to the replay-aware +form). + +*Acceptance.* Corresponds to the `"send and receive direct message"` +and `"broadcast message"` Zig tests. + +=== P-09 Persistence monotonicity [P1] + +*Claim.* Let `L_t` be the log at wall-clock time `t`. For `t1 < t2` +without intervening truncation or CRC-destroying corruption, `replay +L_t1 βŠ† replay L_t2` (prefix-wise). + +*Artefact.* `Durability.idr` lemma `replayMonotone`. + +*Dependencies.* P-05. + +*Acceptance.* Matches the in-code guarantee that `logPeerAdd` et al. +only *append*, never overwrite. + +=== P-10 Supervisor privilege gating [P1] + +*Claim.* There is no execution in which `peers[i].role = master` +without one of: + +. a prior call to `coord_promote_to_master(tok_i, secret, ...)` with + `secret ≑ getenv(BOJ_SUPERVISOR_TOKEN)` (constant-time match), *or* +. a test-only direct mutation (explicitly out of scope β€” the proof + applies to the public API only). + +*Artefact.* `abi/LocalCoord/Role.idr` β€” `Role` type with phantom +witness `PromotedVia`; public API functions require a `PromotedVia` +argument to produce a `master`-tagged peer. + +*Dependencies.* P-03. + +*Acceptance.* Corresponds to the run-time rejection paths already +wired (return codes -3 / -4 / promote-requires-env-var). + +=== P-11 Tier-4 forbidden list [P1] + +*Claim.* For any envelope `e` with `sender_role = apprentice` and +`op_kind` matching a Tier-4 forbidden pattern (force-push, branch +delete, public-repo create, license change, always-private-repo touch), +`validate e` rejects. + +*Artefact.* New `forbiddenT4Patterns` predicate in +`coord-messages-contracts.ncl` + Idris2 wrapper proving +exhaustiveness over the current taxonomy (schema-driven). + +*Dependencies.* P-10. + +*Acceptance.* Gates the envelope before it reaches the FFI. + +=== P-12 Nickel-contract soundness wrt risk-ladder spec [P2] + +*Claim.* The four dependent contracts (TierContextGate, +TierAttestationGate, UrgentDirectRestriction, TierOverrideJustification) +are sound wrt a Lean4 model of the risk ladder: every envelope the +ladder should reject is rejected, and every envelope the ladder should +accept is accepted. + +*Artefact.* `proofs/RiskLadder.lean` β€” model of the ladder + contract +equivalence theorem. Optional: translate back to Idris2 if we want a +single-prover house. + +*Dependencies.* P-11. Prover tentative; Idris2 can also do this. + +*Acceptance.* Closes the Nickel-vs-Zig boundary question currently open +in Appendix K. + +=== P-13 Zig ↔ Idris2 ABI correspondence [P2] + +*Claim.* The Zig constant `BIND_ADDR = [4]u8{127,1,0,1}` together with +`BIND_PORT = 7745` faithfully realises `coordBindConfig` from +`SafeLocalCoord.idr`. + +*Artefact.* An extraction-style check: Idris2 generates a C header +declaring `BIND_ADDR`/`BIND_PORT`; Zig imports it. Any divergence is a +compile error. + +*Dependencies.* P-00. + +*Acceptance.* Pure mechanical β€” turns a convention into a constraint. + +== Deferred (Phase 2+) + +=== P-15 Choreographic types + +Model the `apprentice β†’ server β†’ {opus, attester} β†’ server β†’ target` +protocol as a session type. Compile-time enforcement replaces run-time +imperative checks in `coord_send_gated`. + +Reference: Appendix H in design log. + +=== P-16 Epistemic types + +Rename `context_fetch_id` to `knowledge_witness`; formalise per-role +epistemic policy. Track what each peer knows, with K_A(Ο†) logic. + +Reference: Appendix H. + +=== P-17 Echo-type formalisation + +Use `echo-types/` bridges (`EchoChoreo`, `EchoEpistemic`, `EchoTropical`) +to prove audit + summary + hash-chain behave as echo types β€” structured +irreversibility with retained constraint. + +Reference: Appendix I. + +=== P-18 Tropical-semiring TTL/trust + +Model watchdog TTLs as tropical max, trust composition as tropical min. +Integrates with P-17. + +Reference: Appendix J. + +=== P-19 Cross-site primacy ceremony (v2 federation) + +When Phase 4 lands, prove the authoritative-site model provides primacy +transfer as a choreographic + epistemic ceremony. + +Reference: Appendix G. + +== Work schedule + +.P0 block β€” before Task #8 E2E sign-off +1. P-04 (record format) β€” ~1 day β€” `abi/LocalCoord/Durability.idr`, smallest theorem, unlocks rest. +2. P-05 (CRC truncation) β€” ~1 day β€” prefix-closure lemma on top of P-04. +3. P-06 (replay-equivalence) β€” ~3 days β€” biggest single theorem, needs `State` encoding. Most of the value. +4. P-07 (quarantine state machine) β€” ~1 day β€” simple state chart, well-isolated. + +.P1 block β€” before Task #13 track-record +5. P-08 (FIFO) β€” ~1 day β€” standard Vect-indexed argument. +6. P-09 (monotonicity) β€” ~0.5 day β€” corollary of P-05. +7. P-10 (master privilege) β€” ~1 day β€” phantom-witness refactor. +8. P-11 (Tier-4 forbidden) β€” ~1 day β€” schema + Idris2 wrapper. + +.P2 block β€” nice-to-have; triggered by Task #17 Deno shim +9. P-12 (Nickel soundness) β€” ~2 days. +10. P-13 (Zig↔Idris2 correspondence) β€” ~0.5 day β€” mostly plumbing. + +.Phase 2+ β€” deferred; revisit when moving from v1 +11. P-15 through P-19 β€” scheduled by federation triggers, not by calendar. + +Estimate totals: P0 β‰ˆ 6 days, P1 β‰ˆ 3.5 days, P2 β‰ˆ 2.5 days. Comfortably +one Claude-calendar-week of focused proof work if taken end-to-end. + +== Notes on prover choice + +Idris2 remains the default β€” proofs for a cartridge ABI belong next to +the existing `SafeLocalCoord.idr` and `Protocol.idr`. Idris2 handles +everything through P-13. + +Agda enters only for Phase 3 echo-type + tropical work, consuming the +`echo-types/` bridge library. No echo-type obligation lives inside the +cartridge repo β€” it lands upstream in `echo-types/` with coord as the +dogfood consumer. + +Lean4 is listed as an alternative for P-12 only, and only if the user +wants an external mathlib-backed model of the ladder; otherwise Idris2 +does it. + +No part of the schedule needs Coq/Rocq or SPARK/Ada β€” those are +reserved for upstream proof-heavy consumers (007, Cerro-Torre). + +== Cross-references + +* Already-proved obligations: `SafeLocalCoord.idr`, `Protocol.idr`. +* Phase 2+/Phase 3 rationale: `Desktop/COORD-MCP-DESIGN-LOG.md` + Appendices H, I, J, K. +* Durability module: `../../ffi/coord_durability.zig`. +* Envelope schema + contracts: + `../../schemas/coord-messages.ncl`, `coord-messages-contracts.ncl`. diff --git a/cartridges/cross-cutting/agentic/local-coord-mcp/abi/LocalCoord/Protocol.idr b/cartridges/cross-cutting/agentic/local-coord-mcp/abi/LocalCoord/Protocol.idr new file mode 100644 index 0000000..3b5b475 --- /dev/null +++ b/cartridges/cross-cutting/agentic/local-coord-mcp/abi/LocalCoord/Protocol.idr @@ -0,0 +1,254 @@ +-- SPDX-License-Identifier: MPL-2.0 +-- Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +||| LocalCoord.Protocol: Message types and coordination semantics for +||| localhost multi-instance coordination. +||| +||| Cartridge: local-coord-mcp +||| +||| Builds on AgentMcp.Protocol's Coordination and MemoryType enums. +||| Defines the wire-level message types, task claiming (mutex semantics), +||| and peer lifecycle for the local coordination service. +||| +||| Key invariant: all messages carry a session token. A message without +||| a valid token is rejected before dispatch β€” the type system enforces +||| this via the AuthenticatedMessage wrapper. +module LocalCoord.Protocol + +import LocalCoord.SafeLocalCoord + +%default total + +-- ═══════════════════════════════════════════════════════════════════════════ +-- Message Types +-- ═══════════════════════════════════════════════════════════════════════════ + +||| The kinds of coordination messages that can be sent between peers. +public export +data CoordMessageKind : Type where + ||| Direct message to a specific peer. + DirectMsg : CoordMessageKind + ||| Broadcast to all connected peers. + Broadcast : CoordMessageKind + ||| Status announcement (what this instance is working on). + StatusUpdate : CoordMessageKind + ||| Task claim request (mutex acquisition attempt). + ClaimRequest : CoordMessageKind + ||| Task claim release (mutex release). + ClaimRelease : CoordMessageKind + ||| Peer discovery ping. + Ping : CoordMessageKind + +public export +Show CoordMessageKind where + show DirectMsg = "DirectMsg" + show Broadcast = "Broadcast" + show StatusUpdate = "StatusUpdate" + show ClaimRequest = "ClaimRequest" + show ClaimRelease = "ClaimRelease" + show Ping = "Ping" + +||| Whether this message kind has side effects on shared state. +public export +hasSideEffects : CoordMessageKind -> Bool +hasSideEffects ClaimRequest = True +hasSideEffects ClaimRelease = True +hasSideEffects _ = False + +||| Whether this message kind requires acknowledgement from the server. +public export +requiresAck : CoordMessageKind -> Bool +requiresAck ClaimRequest = True +requiresAck ClaimRelease = True +requiresAck DirectMsg = True +requiresAck _ = False + +-- ═══════════════════════════════════════════════════════════════════════════ +-- Message Addressing +-- ═══════════════════════════════════════════════════════════════════════════ + +||| Message target: either a specific peer or all peers. +public export +data MessageTarget : Type where + ToPeer : PeerId -> MessageTarget + ToAll : MessageTarget + +-- ═══════════════════════════════════════════════════════════════════════════ +-- Task Claiming (Mutex Semantics) +-- ═══════════════════════════════════════════════════════════════════════════ + +||| A task identifier for the claiming system. +||| Tasks are named strings (e.g. "audit-boj-server", "fix-ci-pipeline"). +public export +data TaskId : Type where + MkTaskId : (name : String) + -> {auto nonEmpty : NonEmpty (unpack name)} + -> TaskId + +||| Extract the task name string. +public export +taskName : TaskId -> String +taskName (MkTaskId name) = name + +||| The result of a claim attempt. +public export +data ClaimResult : Type where + ||| Claim granted β€” this peer now holds the mutex. + Granted : ClaimResult + ||| Claim denied β€” another peer already holds this task. + Held : (holder : PeerId) -> ClaimResult + ||| Claim denied β€” task ID not recognised. + NotFound : ClaimResult + +public export +Show ClaimResult where + show Granted = "Granted" + show (Held h) = "Held(" ++ peerIdToString h ++ ")" + show NotFound = "NotFound" + +||| Whether a claim result means the caller may proceed. +public export +claimAllowsWork : ClaimResult -> Bool +claimAllowsWork Granted = True +claimAllowsWork _ = False + +||| Proof that a granted claim allows work. +export +grantedAllows : claimAllowsWork Granted = True +grantedAllows = Refl + +||| Proof that a held claim does not allow work. +export +heldDenies : (h : PeerId) -> claimAllowsWork (Held h) = False +heldDenies _ = Refl + +-- ═══════════════════════════════════════════════════════════════════════════ +-- Authenticated Messages +-- ═══════════════════════════════════════════════════════════════════════════ + +||| A coordination message that has been authenticated with a session token. +||| The token proof is erased at runtime (quantity 0) β€” it exists only to +||| enforce the invariant at compile time. +public export +record AuthenticatedMessage where + constructor MkAuthMsg + sender : PeerId + token : SessionToken + msgKind : CoordMessageKind + target : MessageTarget + payload : String -- JSON-encoded message body + +||| An unauthenticated message β€” used at the wire boundary before validation. +public export +record RawMessage where + constructor MkRawMsg + senderStr : String + tokenStr : String + kindInt : Int + targetStr : String -- peer ID string or "*" for broadcast + payload : String + +-- ═══════════════════════════════════════════════════════════════════════════ +-- Peer Lifecycle +-- ═══════════════════════════════════════════════════════════════════════════ + +||| Peer connection state. +public export +data PeerState : Type where + Registering : PeerState -- Handshake in progress + Active : PeerState -- Registered and operational + Departing : PeerState -- Graceful disconnect in progress + Gone : PeerState -- Disconnected + +public export +Eq PeerState where + Registering == Registering = True + Active == Active = True + Departing == Departing = True + Gone == Gone = True + _ == _ = False + +||| Valid peer state transitions. +public export +data ValidPeerTransition : PeerState -> PeerState -> Type where + RegisterToActive : ValidPeerTransition Registering Active + ActiveToDepart : ValidPeerTransition Active Departing + DepartToGone : ValidPeerTransition Departing Gone + -- Abrupt disconnect from any active state + RegisterAbort : ValidPeerTransition Registering Gone + ActiveAbort : ValidPeerTransition Active Gone + +||| Runtime transition check. +public export +canTransitionPeer : PeerState -> PeerState -> Bool +canTransitionPeer Registering Active = True +canTransitionPeer Active Departing = True +canTransitionPeer Departing Gone = True +canTransitionPeer Registering Gone = True +canTransitionPeer Active Gone = True +canTransitionPeer _ _ = False + +-- ═══════════════════════════════════════════════════════════════════════════ +-- C-ABI Encoding +-- ═══════════════════════════════════════════════════════════════════════════ + +public export +msgKindToInt : CoordMessageKind -> Int +msgKindToInt DirectMsg = 0 +msgKindToInt Broadcast = 1 +msgKindToInt StatusUpdate = 2 +msgKindToInt ClaimRequest = 3 +msgKindToInt ClaimRelease = 4 +msgKindToInt Ping = 5 + +public export +intToMsgKind : Int -> CoordMessageKind +intToMsgKind 0 = DirectMsg +intToMsgKind 1 = Broadcast +intToMsgKind 2 = StatusUpdate +intToMsgKind 3 = ClaimRequest +intToMsgKind 4 = ClaimRelease +intToMsgKind _ = Ping + +public export +claimResultToInt : ClaimResult -> Int +claimResultToInt Granted = 0 +claimResultToInt (Held _) = 1 +claimResultToInt NotFound = 2 + +public export +peerStateToInt : PeerState -> Int +peerStateToInt Registering = 0 +peerStateToInt Active = 1 +peerStateToInt Departing = 2 +peerStateToInt Gone = 3 + +public export +intToPeerState : Int -> PeerState +intToPeerState 0 = Registering +intToPeerState 1 = Active +intToPeerState 2 = Departing +intToPeerState _ = Gone + +||| FFI: Check if a message kind has side effects. +export +coord_msg_has_side_effects : Int -> Int +coord_msg_has_side_effects k = + if hasSideEffects (intToMsgKind k) then 1 else 0 + +||| FFI: Check if a message kind requires acknowledgement. +export +coord_msg_requires_ack : Int -> Int +coord_msg_requires_ack k = + if requiresAck (intToMsgKind k) then 1 else 0 + +||| FFI: Check if a claim result allows work. +export +coord_claim_allows_work : Int -> Int +coord_claim_allows_work 0 = 1 -- Granted +coord_claim_allows_work _ = 0 + +||| FFI: Validate a peer state transition. +export +coord_validate_peer_transition : Int -> Int -> Int +coord_validate_peer_transition from to = + if canTransitionPeer (intToPeerState from) (intToPeerState to) then 1 else 0 diff --git a/cartridges/cross-cutting/agentic/local-coord-mcp/abi/LocalCoord/SafeLocalCoord.idr b/cartridges/cross-cutting/agentic/local-coord-mcp/abi/LocalCoord/SafeLocalCoord.idr new file mode 100644 index 0000000..62c868a --- /dev/null +++ b/cartridges/cross-cutting/agentic/local-coord-mcp/abi/LocalCoord/SafeLocalCoord.idr @@ -0,0 +1,267 @@ +-- SPDX-License-Identifier: MPL-2.0 +-- Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +||| LocalCoord.SafeLocalCoord: Formal verification of localhost-only binding. +||| +||| Cartridge: local-coord-mcp +||| Matrix cell: Agent x {MCP, Agentic} protocols +||| +||| Core security guarantee: the coordination service CANNOT bind to any +||| address other than the loopback interface. This is enforced at the type +||| level β€” there is no runtime branch that could accidentally expose the +||| service to the network. +||| +||| Secondary guarantee: session tokens are scoped per-instance, preventing +||| rogue local processes from injecting messages without registering. +||| +||| This cartridge explicitly does NOT participate in Umoja federation. +||| No gossip, no attestation, no remote node discovery. +module LocalCoord.SafeLocalCoord + +import Data.List +import Data.Nat +import Data.String + +%default total + +-- ═══════════════════════════════════════════════════════════════════════════ +-- Loopback Address Safety +-- ═══════════════════════════════════════════════════════════════════════════ + +||| The only two addresses this cartridge may bind to. +||| Constructive proof β€” no wildcard, no LAN, no external IP. +public export +data LoopbackAddr : Type where + IPv4Loop : LoopbackAddr -- 127.0.0.1 + IPv6Loop : LoopbackAddr -- ::1 + +||| Convert a LoopbackAddr to its string representation. +||| This is the ONLY path from type to string β€” guarantees the output. +public export +loopbackToString : LoopbackAddr -> String +loopbackToString IPv4Loop = "127.0.0.1" +loopbackToString IPv6Loop = "::1" + +||| Proof that a string IS a valid loopback address. +||| Only two inhabitants β€” you cannot construct this for "0.0.0.0" or +||| any other address. +public export +data IsLoopback : String -> Type where + IsIPv4Loop : IsLoopback "127.0.0.1" + IsIPv6Loop : IsLoopback "::1" + +||| Attempt to parse a string as a loopback address. +||| Returns Nothing for any non-loopback string. +public export +parseLoopback : (addr : String) -> Maybe (IsLoopback addr) +parseLoopback "127.0.0.1" = Just IsIPv4Loop +parseLoopback "::1" = Just IsIPv6Loop +parseLoopback _ = Nothing + +||| Convert a loopback proof to the concrete address type. +public export +fromLoopbackProof : IsLoopback addr -> LoopbackAddr +fromLoopbackProof IsIPv4Loop = IPv4Loop +fromLoopbackProof IsIPv6Loop = IPv6Loop + +||| Round-trip: loopbackToString after fromLoopbackProof recovers the address. +export +loopbackRoundtrip : (prf : IsLoopback addr) -> loopbackToString (fromLoopbackProof prf) = addr +loopbackRoundtrip IsIPv4Loop = Refl +loopbackRoundtrip IsIPv6Loop = Refl + +||| Proof that "0.0.0.0" is NOT a loopback address. +||| Demonstrates the negative case β€” wildcard bind is impossible. +export +wildcardNotLoopback : IsLoopback "0.0.0.0" -> Void +wildcardNotLoopback _ impossible + +||| Proof that any empty string is NOT a loopback address. +export +emptyNotLoopback : IsLoopback "" -> Void +emptyNotLoopback _ impossible + +-- ═══════════════════════════════════════════════════════════════════════════ +-- Port Safety +-- ═══════════════════════════════════════════════════════════════════════════ + +||| The assigned port for this cartridge. +public export +coordPort : Nat +coordPort = 7745 + +||| Valid port range: 1024–65535 (non-privileged). +public export +data ValidPort : Nat -> Type where + MkValidPort : (p : Nat) + -> {auto lo : LTE 1024 p} + -> {auto hi : LTE p 65535} + -> ValidPort p + +||| Proof that 7745 is a valid non-privileged port. +export +coordPortValid : ValidPort 7745 +coordPortValid = MkValidPort 7745 + +-- ═══════════════════════════════════════════════════════════════════════════ +-- Bind Configuration β€” ties address + port together +-- ═══════════════════════════════════════════════════════════════════════════ + +||| A verified bind configuration. Can only be constructed with a loopback +||| address and a valid port. The type system prevents any other combination. +public export +record BindConfig where + constructor MkBindConfig + addr : LoopbackAddr + port : Nat + 0 portOk : ValidPort port + +||| The canonical bind configuration for local-coord-mcp. +public export +coordBindConfig : BindConfig +coordBindConfig = MkBindConfig IPv4Loop coordPort coordPortValid + +-- ═══════════════════════════════════════════════════════════════════════════ +-- Session Token +-- ═══════════════════════════════════════════════════════════════════════════ + +||| A session token is a non-empty string issued on registration. +||| All coordination messages must carry a valid token. +public export +data SessionToken : Type where + MkSessionToken : (tok : String) + -> {auto nonEmpty : NonEmpty (unpack tok)} + -> SessionToken + +||| Extract the raw token string. +public export +tokenValue : SessionToken -> String +tokenValue (MkSessionToken tok) = tok + +||| Proof that two tokens are equal (used for validation). +public export +data TokenMatch : SessionToken -> SessionToken -> Type where + TokensMatch : (t1, t2 : SessionToken) + -> tokenValue t1 = tokenValue t2 + -> TokenMatch t1 t2 + +-- ═══════════════════════════════════════════════════════════════════════════ +-- Peer Identity (Hybrid Model) +-- ═══════════════════════════════════════════════════════════════════════════ + +||| Known client type prefixes for the hybrid identity model. +||| e.g. "claude", "gemini", "copilot", "custom", "openai", "mistral" +||| (Task #33: extended 2026-04 to cover OpenAI and Mistral families.) +public export +data ClientKind : Type where + Claude : ClientKind + Gemini : ClientKind + Copilot : ClientKind + Custom : ClientKind + Openai : ClientKind + Mistral : ClientKind + +public export +Show ClientKind where + show Claude = "claude" + show Gemini = "gemini" + show Copilot = "copilot" + show Custom = "custom" + show Openai = "openai" + show Mistral = "mistral" + +||| A peer identity: human-readable prefix + 4-character hex suffix. +||| e.g. "claude-7f3a", "gemini-b2c1" +public export +record PeerId where + constructor MkPeerId + kind : ClientKind + suffix : String -- 4-char hex hash + +||| Render a PeerId as its display string. +public export +peerIdToString : PeerId -> String +peerIdToString p = show (kind p) ++ "-" ++ suffix p + +||| Maximum number of concurrent peers on a single machine. +||| Bounded to prevent resource exhaustion on localhost. +public export +maxPeers : Nat +maxPeers = 16 + +-- ═══════════════════════════════════════════════════════════════════════════ +-- Federation Opt-Out +-- ═══════════════════════════════════════════════════════════════════════════ + +||| Proof that this cartridge does not participate in federation. +||| The type has exactly one inhabitant β€” LocalOnly β€” and no constructor +||| for any federated mode. +public export +data FederationPolicy : Type where + LocalOnly : FederationPolicy + +||| This cartridge's federation policy. Always LocalOnly. +public export +coordFederationPolicy : FederationPolicy +coordFederationPolicy = LocalOnly + +||| Proof that federation is disabled. Any code path requiring federation +||| participation would need a `Federated` constructor, which does not exist. +public export +data IsFederated : FederationPolicy -> Type where + -- Intentionally empty β€” no constructor for LocalOnly. + -- This type is uninhabited when the policy is LocalOnly. + +||| Proof that LocalOnly is not federated. +export +localOnlyNotFederated : IsFederated LocalOnly -> Void +localOnlyNotFederated _ impossible + +-- ═══════════════════════════════════════════════════════════════════════════ +-- C-ABI Encoding +-- ═══════════════════════════════════════════════════════════════════════════ + +public export +loopbackAddrToInt : LoopbackAddr -> Int +loopbackAddrToInt IPv4Loop = 4 +loopbackAddrToInt IPv6Loop = 6 + +public export +intToLoopbackAddr : Int -> LoopbackAddr +intToLoopbackAddr 6 = IPv6Loop +intToLoopbackAddr _ = IPv4Loop -- default to IPv4 + +public export +clientKindToInt : ClientKind -> Int +clientKindToInt Claude = 0 +clientKindToInt Gemini = 1 +clientKindToInt Copilot = 2 +clientKindToInt Custom = 3 +clientKindToInt Openai = 4 +clientKindToInt Mistral = 5 + +public export +intToClientKind : Int -> ClientKind +intToClientKind 0 = Claude +intToClientKind 1 = Gemini +intToClientKind 2 = Copilot +intToClientKind 4 = Openai +intToClientKind 5 = Mistral +intToClientKind _ = Custom + +||| FFI: Get the bind port. +export +coord_get_port : Int +coord_get_port = 7745 + +||| FFI: Check if an address integer represents loopback. +||| Returns 1 for IPv4 (4) or IPv6 (6) loopback, 0 otherwise. +export +coord_is_loopback : Int -> Int +coord_is_loopback 4 = 1 +coord_is_loopback 6 = 1 +coord_is_loopback _ = 0 + +||| FFI: Check if federation is enabled. Always returns 0 (disabled). +export +coord_federation_enabled : Int +coord_federation_enabled = 0 diff --git a/cartridges/cross-cutting/agentic/local-coord-mcp/abi/README.adoc b/cartridges/cross-cutting/agentic/local-coord-mcp/abi/README.adoc new file mode 100644 index 0000000..0b33c66 --- /dev/null +++ b/cartridges/cross-cutting/agentic/local-coord-mcp/abi/README.adoc @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) += local-coord-mcp / abi β€” Idris2 ABI layer +:orientation: shallow + +== Purpose + +Cartridge-local protocol types for `local-coord-mcp`. Defines the data, tool, and +transition vocabulary this cartridge speaks. Inherits safety lemmas and the +matrix-cell identifier scheme from the trunk ABI at `src/abi/`. + +== Files + +2 Idris2 module(s), ~512 lines total. Lead module: +`SafeLocalCoord.idr`. + +No `.ipkg` β€” consumed directly by wider BoJ callers; type-checks inside the +trunk `boj.ipkg`. + +== Invariants + +* `%default total` at every module top. +* Zero `believe_me`, `assert_total`, `postulate`, `Admitted`, or + `sorry` (the trunk holds the four declared `believe_me` sites; leaf + cartridges carry none). + +== Test/proof surface + +Thin module(s), no inline tests. Proof surface = type-check success under the +trunk `idris2 --check`. + +== Read-first + +. `SafeLocalCoord.idr` β€” cartridge-local data records and enums. +. Trunk `src/abi/Boj/Catalogue.idr` for how this cell participates in the + 2D matrix. diff --git a/cartridges/cross-cutting/agentic/local-coord-mcp/abi/local-coord-mcp.ipkg b/cartridges/cross-cutting/agentic/local-coord-mcp/abi/local-coord-mcp.ipkg new file mode 100644 index 0000000..a9b1075 --- /dev/null +++ b/cartridges/cross-cutting/agentic/local-coord-mcp/abi/local-coord-mcp.ipkg @@ -0,0 +1,13 @@ +-- SPDX-License-Identifier: MPL-2.0 +package localcoordmcp + +authors = "Jonathan D.A. Jewell" +version = 0.1.0 +license = "MPL-2.0" +brief = "Local-coord MCP cartridge β€” localhost multi-instance coordination with formal loopback proof" + +sourcedir = "." +modules = LocalCoord.SafeLocalCoord + , LocalCoord.Protocol + , LocalCoord.Identity +depends = base diff --git a/cartridges/cross-cutting/agentic/local-coord-mcp/adapter/README.adoc b/cartridges/cross-cutting/agentic/local-coord-mcp/adapter/README.adoc new file mode 100644 index 0000000..c04efad --- /dev/null +++ b/cartridges/cross-cutting/agentic/local-coord-mcp/adapter/README.adoc @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) += local-coord-mcp / adapter β€” protocol bridge +:orientation: shallow + +== Purpose + +Exposes this cartridge's FFI over the wire protocols declared in +`cartridge.json`. Dispatches tool invocations to the C-ABI exports at +`../ffi/` and formats responses per protocol. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” depends on the sibling `ffi` target. +| `local_coord_adapter.zig` | Protocol dispatch (117 lines). Tool catalogue matches `cartridge.json`. +| `SIDELINED-*.v.adoc` | zig predecessor(s). Not built β€” zig banned 2026-04-10. +|=== + +== Invariants + +* **Stateless** β€” all state lives behind the FFI, never in the adapter. +* **Response passthrough** β€” whatever the FFI returns goes back to the wire + unmodified (no embellishment, no silent recovery). +* **Cartridge.json is source of truth** for the tool catalogue; drift between + adapter and manifest is a CI failure. + +== Test/proof surface + +No inline tests at the adapter layer β€” protocol routing is exercised by the +trunk seam tests at `ffi/zig/src/seams.zig`. + +== Read-first + +. `local_coord_adapter.zig` β€” dispatch function and per-protocol routers. +. `../cartridge.json` β€” the tool manifest the dispatcher mirrors. diff --git a/cartridges/cross-cutting/agentic/local-coord-mcp/adapter/build.zig b/cartridges/cross-cutting/agentic/local-coord-mcp/adapter/build.zig new file mode 100644 index 0000000..4d2a4f2 --- /dev/null +++ b/cartridges/cross-cutting/agentic/local-coord-mcp/adapter/build.zig @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +// +// local-coord-mcp/adapter/build.zig + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + // FFI re-exports `cartridge_shim` via its ADR-0006 invoke dispatch, + // so the adapter must wire that module transitively. Same source as + // the FFI's own build.zig (see ../ffi/build.zig). + const shim_mod = b.addModule("cartridge_shim", .{ + .root_source_file = b.path("../../../ffi/zig/src/cartridge_shim.zig"), + .target = target, + .optimize = optimize, + }); + + const ffi_mod = b.createModule(.{ + .root_source_file = b.path("../ffi/local_coord_ffi.zig"), + .target = target, + .optimize = optimize, + }); + ffi_mod.addImport("cartridge_shim", shim_mod); + + const adapter_mod = b.createModule(.{ + .root_source_file = b.path("local_coord_adapter.zig"), + .target = target, + .optimize = optimize, + }); + adapter_mod.addImport("local_coord_ffi", ffi_mod); + + const adapter = b.addExecutable(.{ + .name = "local_coord_adapter", + .root_module = adapter_mod, + }); + b.installArtifact(adapter); +} diff --git a/cartridges/cross-cutting/agentic/local-coord-mcp/adapter/local_coord_adapter.zig b/cartridges/cross-cutting/agentic/local-coord-mcp/adapter/local_coord_adapter.zig new file mode 100644 index 0000000..9e0d722 --- /dev/null +++ b/cartridges/cross-cutting/agentic/local-coord-mcp/adapter/local_coord_adapter.zig @@ -0,0 +1,1093 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +// +// local-coord-mcp/adapter/local_coord_adapter.zig + +const std = @import("std"); +const ffi = @import("local_coord_ffi"); + +const BIND_ADDR = [4]u8{ 127, 0, 0, 1 }; +const REST_PORT: u16 = 7745; + +const Response = struct { status: u16, body: []const u8 }; + +fn okJson(buf: []u8, msg: []const u8) []u8 { + return std.fmt.bufPrint(buf, "{{\"success\":true,\"message\":\"{s}\"}}", .{msg}) catch buf[0..0]; +} + +fn errJson(buf: []u8, msg: []const u8) []u8 { + return std.fmt.bufPrint(buf, "{{\"success\":false,\"error\":\"{s}\"}}", .{msg}) catch buf[0..0]; +} + +fn kindName(kind: i32) []const u8 { + return switch (kind) { + 0 => "claude", + 1 => "gemini", + 2 => "copilot", + 4 => "openai", + 5 => "mistral", + else => "custom", + }; +} + +/// Map the client_kind JSON string to the FFI integer. Unknown names +/// fall through to `custom` (3) β€” matches the enum's catch-all. +fn kindFromString(s: []const u8) i32 { + if (std.mem.eql(u8, s, "claude")) return 0; + if (std.mem.eql(u8, s, "gemini")) return 1; + if (std.mem.eql(u8, s, "copilot")) return 2; + if (std.mem.eql(u8, s, "openai")) return 4; + if (std.mem.eql(u8, s, "mistral")) return 5; + return 3; // custom +} + +/// Fold a JSON array of strings into a CSV, respecting `cap`. Items that +/// are not strings are skipped. Used for declared_affinities, class, and +/// prover_strengths at register time. +fn arrayToCsv(items: []const std.json.Value, buf: []u8) usize { + var len: usize = 0; + for (items) |item| { + if (item != .string) continue; + const s = item.string; + if (len > 0 and len < buf.len) { + buf[len] = ','; + len += 1; + } + const to_copy: usize = @min(s.len, buf.len - len); + if (to_copy > 0) @memcpy(buf[len .. len + to_copy], s[0..to_copy]); + len += to_copy; + } + return len; +} + +fn stateName(state: i32) []const u8 { + return switch (state) { + 0 => "registering", + 1 => "active", + 2 => "departing", + else => "gone", + }; +} + +fn parseToken(token_hex: []const u8, out: *[16]u8) bool { + if (token_hex.len != 32) return false; + _ = std.fmt.hexToBytes(out, token_hex) catch return false; + return true; +} + +/// Render a peer_id into the caller buffer. Format is `-<4hex>` when +/// ctx is empty, `-<4hex>@` when set. Returns the slice of +/// buf actually used. +fn renderPeerId(buf: []u8, kind_str: []const u8, suffix: []const u8, ctx: []const u8) ![]u8 { + if (ctx.len == 0) { + return try std.fmt.bufPrint(buf, "{s}-{s}", .{ kind_str, suffix }); + } + return try std.fmt.bufPrint(buf, "{s}-{s}@{s}", .{ kind_str, suffix, ctx }); +} + +/// Extract the 4-char hex suffix from a target peer_id string. Format is +/// `-<4hex>` or `-<4hex>@`. Returns null if malformed. +fn extractSuffix(target: []const u8) ?[]const u8 { + // Find the last '-' before any '@' β€” the 4 hex chars follow it. + const at_pos = std.mem.indexOfScalar(u8, target, '@') orelse target.len; + const left = target[0..at_pos]; + const dash_pos = std.mem.lastIndexOfScalar(u8, left, '-') orelse return null; + const suffix = left[dash_pos + 1 ..]; + if (suffix.len != 4) return null; + return suffix; +} + +fn dispatch(tool: []const u8, body: []const u8, resp: []u8, allocator: std.mem.Allocator) Response { + if (std.mem.eql(u8, tool, "coord_register")) { + const parsed = std.json.parseFromSlice(std.json.Value, allocator, body, .{}) catch return .{ .status = 400, .body = errJson(resp, "invalid json") }; + defer parsed.deinit(); + + const kind_val = parsed.value.object.get("client_kind") orelse return .{ .status = 400, .body = errJson(resp, "missing client_kind") }; + const kind_str = kind_val.string; + const kind: i32 = kindFromString(kind_str); + + // Optional context for per-window disambiguation. + const ctx_str: []const u8 = blk: { + const ctx_val = parsed.value.object.get("context") orelse break :blk ""; + break :blk ctx_val.string; + }; + + // Optional role hint. Server rejects role=master here; callers + // must promote via coord_promote_to_master with the env secret. + // Old names (supervisor/executor/supervised) accepted as aliases + // for one release per DD-32. + const role_hint: i32 = blk: { + const rv = parsed.value.object.get("role") orelse break :blk -1; + const rs = rv.string; + if (std.mem.eql(u8, rs, "master") or std.mem.eql(u8, rs, "supervisor")) break :blk 0; + if (std.mem.eql(u8, rs, "journeyman") or std.mem.eql(u8, rs, "executor")) break :blk 1; + if (std.mem.eql(u8, rs, "apprentice") or std.mem.eql(u8, rs, "supervised")) break :blk 2; + break :blk -1; + }; + + var token: [16]u8 = undefined; + var suffix: [4]u8 = undefined; + const idx = ffi.coord_register(kind, role_hint, &token, &suffix); + if (idx == -3) return .{ .status = 400, .body = errJson(resp, "master role must be obtained via coord_promote_to_master") }; + if (idx < 0) return .{ .status = 500, .body = errJson(resp, "registry full") }; + + if (ctx_str.len > 0) { + const set_rc = ffi.coord_set_context(&token, 16, ctx_str.ptr, @intCast(ctx_str.len)); + if (set_rc < 0) { + // Rollback: deregister the half-registered peer so the caller can retry cleanly. + _ = ffi.coord_deregister(&token, 16); + return .{ .status = 400, .body = errJson(resp, "invalid context (alphanumeric/hyphen/underscore only, max 32 bytes)") }; + } + } + + // Optional declared_affinities β€” an array of tag strings that the + // peer self-reports as strengths. Stored as a CSV internally so the + // reassignment engine (Task #14) can diff against track record. + if (parsed.value.object.get("declared_affinities")) |decl_val| { + if (decl_val == .array) { + var csv_buf: [256]u8 = undefined; + const csv_len = arrayToCsv(decl_val.array.items, &csv_buf); + if (csv_len > 0) { + _ = ffi.coord_set_declared_affinities(&token, 16, &csv_buf, @intCast(csv_len)); + } + } + } + + // Task #33 β€” optional free-form variant label (e.g. "opus-4.7"). + // Invalid chars / oversize β†’ rollback so the caller sees a clear 400. + if (parsed.value.object.get("variant")) |var_val| { + if (var_val == .string) { + const vs = var_val.string; + if (vs.len > 0) { + const rc = ffi.coord_set_variant(&token, 16, vs.ptr, @intCast(vs.len)); + if (rc < 0) { + _ = ffi.coord_deregister(&token, 16); + return .{ .status = 400, .body = errJson(resp, "invalid variant (alphanum / . / - / _ only, max 32 bytes)") }; + } + } + } + } + + // Task #34 β€” optional capability advertisement block: + // "capabilities": { "class": [...], "tier": 1..5, "prover_strengths": [...] } + // All three keys are individually optional. Oversized / bad tier β†’ 400 + rollback. + if (parsed.value.object.get("capabilities")) |caps_val| { + if (caps_val == .object) { + var class_buf: [128]u8 = undefined; + var class_len: usize = 0; + if (caps_val.object.get("class")) |cv| { + if (cv == .array) class_len = arrayToCsv(cv.array.items, &class_buf); + } + + var tier: i32 = 0; + if (caps_val.object.get("tier")) |tv| { + if (tv == .integer) tier = @intCast(tv.integer); + } + + var pro_buf: [256]u8 = undefined; + var pro_len: usize = 0; + if (caps_val.object.get("prover_strengths")) |pv| { + if (pv == .array) pro_len = arrayToCsv(pv.array.items, &pro_buf); + } + + if (class_len != 0 or tier != 0 or pro_len != 0) { + const rc = ffi.coord_set_capabilities( + &token, 16, + &class_buf, @intCast(class_len), + tier, + &pro_buf, @intCast(pro_len), + ); + if (rc < 0) { + _ = ffi.coord_deregister(&token, 16); + return .{ .status = 400, .body = errJson(resp, "invalid capabilities (tier 0..5, class ≀128B, prover_strengths ≀256B)") }; + } + } + } + } + + var token_hex: [32]u8 = undefined; + const hex_chars = "0123456789abcdef"; + for (token, 0..) |b, i| { + token_hex[i * 2] = hex_chars[b >> 4]; + token_hex[i * 2 + 1] = hex_chars[b & 0x0f]; + } + + var peer_id_buf: [96]u8 = undefined; + const peer_id = renderPeerId(&peer_id_buf, kind_str, &suffix, ctx_str) catch return .{ .status = 500, .body = errJson(resp, "peer_id render overflow") }; + + const body_out = std.fmt.bufPrint(resp, "{{\"success\":true,\"peer_id\":\"{s}\",\"token\":\"{s}\"}}", .{ peer_id, token_hex }) catch return .{ .status = 500, .body = errJson(resp, "buffer overflow") }; + return .{ .status = 200, .body = body_out }; + } + + if (std.mem.eql(u8, tool, "coord_list_peers")) { + const parsed = std.json.parseFromSlice(std.json.Value, allocator, body, .{}) catch return .{ .status = 400, .body = errJson(resp, "invalid json") }; + defer parsed.deinit(); + const token_val = parsed.value.object.get("token") orelse return .{ .status = 400, .body = errJson(resp, "missing token") }; + var token: [16]u8 = undefined; + if (!parseToken(token_val.string, &token)) return .{ .status = 400, .body = errJson(resp, "invalid token hex") }; + + // FFI returns 12 bytes per peer: kind(i32) + suffix[4] + state(i32). + // Cap at MAX_PEERS (16) * 12 = 192 bytes. + var raw: [192]u8 = undefined; + const count = ffi.coord_list_peers(&token, 16, &raw, @intCast(raw.len)); + if (count < 0) return .{ .status = 401, .body = errJson(resp, "unauthenticated") }; + + // Build JSON: {"success":true,"peers":[{"peer_id":"kind-xxxx","kind":"...","state":"...","status":"..."},...]} + var stream = std.io.fixedBufferStream(resp); + const w = stream.writer(); + w.writeAll("{\"success\":true,\"peers\":[") catch return .{ .status = 500, .body = errJson(resp, "buffer overflow") }; + + // The 12-byte records in `raw` are packed in peer-index-ascending order + // (FFI iterates peers[] and writes only active ones). We scan the same + // peer-index range and pair each active index with the next dense record. + var i: i32 = 0; + var written_idx: usize = 0; + const cnt: usize = @intCast(count); + while (i < 16 and written_idx < cnt) : (i += 1) { + const kind_val = ffi.coord_read_peer_kind(i); + if (kind_val < 0) continue; + + const rec_offset = written_idx * 12; + const suffix = raw[rec_offset + 4 .. rec_offset + 8]; + const state_bytes = raw[rec_offset + 8 .. rec_offset + 12]; + const state: i32 = @bitCast([4]u8{ state_bytes[0], state_bytes[1], state_bytes[2], state_bytes[3] }); + + var status_buf: [256]u8 = undefined; + const status_len = ffi.coord_read_peer_status(i, &status_buf, @intCast(status_buf.len)); + const status_slice: []const u8 = if (status_len > 0) status_buf[0..@intCast(status_len)] else ""; + + var ctx_buf: [32]u8 = undefined; + const ctx_len = ffi.coord_read_peer_context(i, &ctx_buf, @intCast(ctx_buf.len)); + const ctx_slice: []const u8 = if (ctx_len > 0) ctx_buf[0..@intCast(ctx_len)] else ""; + + var variant_buf: [32]u8 = undefined; + const v_len = ffi.coord_read_peer_variant(i, &variant_buf, @intCast(variant_buf.len)); + const variant_slice: []const u8 = if (v_len > 0) variant_buf[0..@intCast(v_len)] else ""; + + var peer_id_buf: [96]u8 = undefined; + const peer_id = renderPeerId(&peer_id_buf, kindName(kind_val), suffix, ctx_slice) catch return .{ .status = 500, .body = errJson(resp, "peer_id render overflow") }; + + if (written_idx > 0) w.writeAll(",") catch return .{ .status = 500, .body = errJson(resp, "buffer overflow") }; + // peer_id/kind/state/context/variant are all validated-safe; status is + // arbitrary user text β†’ must go through writeJsonString to prevent + // JSON breakage (e.g. status = `working on "foo"` would split the string). + std.fmt.format(w, "{{\"peer_id\":\"{s}\",\"kind\":\"{s}\",\"state\":\"{s}\",\"context\":\"{s}\",\"variant\":\"{s}\",\"status\":", .{ + peer_id, kindName(kind_val), stateName(state), ctx_slice, variant_slice, + }) catch return .{ .status = 500, .body = errJson(resp, "buffer overflow") }; + writeJsonString(w, status_slice) catch return .{ .status = 500, .body = errJson(resp, "buffer overflow") }; + w.writeByte('}') catch return .{ .status = 500, .body = errJson(resp, "buffer overflow") }; + written_idx += 1; + } + + w.writeAll("]}") catch return .{ .status = 500, .body = errJson(resp, "buffer overflow") }; + return .{ .status = 200, .body = resp[0..stream.pos] }; + } + + if (std.mem.eql(u8, tool, "coord_send")) { + const parsed = std.json.parseFromSlice(std.json.Value, allocator, body, .{}) catch return .{ .status = 400, .body = errJson(resp, "invalid json") }; + defer parsed.deinit(); + const token_val = parsed.value.object.get("token") orelse return .{ .status = 400, .body = errJson(resp, "missing token") }; + const target_val = parsed.value.object.get("target") orelse return .{ .status = 400, .body = errJson(resp, "missing target") }; + const msg_val = parsed.value.object.get("message") orelse return .{ .status = 400, .body = errJson(resp, "missing message") }; + + var token: [16]u8 = undefined; + if (!parseToken(token_val.string, &token)) return .{ .status = 400, .body = errJson(resp, "invalid token hex") }; + + const target_str = target_val.string; + var target_idx: i32 = -1; + if (!std.mem.eql(u8, target_str, "*")) { + // Peer ID format: "-<4hex>" or "-<4hex>@". + const suffix = extractSuffix(target_str) orelse return .{ .status = 400, .body = errJson(resp, "invalid target format β€” expected -<4hex>[@]") }; + target_idx = ffi.coord_find_peer_by_suffix(suffix.ptr); + if (target_idx < 0) return .{ .status = 404, .body = errJson(resp, "target peer not found") }; + } + + const msg = msg_val.string; + const sent = ffi.coord_send(&token, 16, target_idx, msg.ptr, @intCast(msg.len)); + if (sent < 0) return .{ .status = 401, .body = errJson(resp, "unauthenticated or invalid target") }; + + const body_out = std.fmt.bufPrint(resp, "{{\"success\":true,\"sent\":{d}}}", .{sent}) catch return .{ .status = 500, .body = errJson(resp, "buffer overflow") }; + return .{ .status = 200, .body = body_out }; + } + + if (std.mem.eql(u8, tool, "coord_receive")) { + const parsed = std.json.parseFromSlice(std.json.Value, allocator, body, .{}) catch return .{ .status = 400, .body = errJson(resp, "invalid json") }; + defer parsed.deinit(); + const token_val = parsed.value.object.get("token") orelse return .{ .status = 400, .body = errJson(resp, "missing token") }; + var token: [16]u8 = undefined; + if (!parseToken(token_val.string, &token)) return .{ .status = 400, .body = errJson(resp, "invalid token hex") }; + + var msg_buf: [512]u8 = undefined; + const mlen = ffi.coord_receive(&token, 16, &msg_buf, @intCast(msg_buf.len)); + if (mlen < 0) return .{ .status = 401, .body = errJson(resp, "unauthenticated") }; + + if (mlen == 0) { + return .{ .status = 200, .body = std.fmt.bufPrint(resp, "{{\"success\":true,\"message\":null}}", .{}) catch resp[0..0] }; + } + const msg_slice = msg_buf[0..@intCast(mlen)]; + var recv_stream = std.io.fixedBufferStream(resp); + const rw = recv_stream.writer(); + rw.writeAll("{\"success\":true,\"message\":") catch return .{ .status = 500, .body = errJson(resp, "buffer overflow") }; + writeJsonString(rw, msg_slice) catch return .{ .status = 500, .body = errJson(resp, "buffer overflow") }; + rw.writeByte('}') catch return .{ .status = 500, .body = errJson(resp, "buffer overflow") }; + return .{ .status = 200, .body = resp[0..recv_stream.pos] }; + } + + if (std.mem.eql(u8, tool, "coord_status")) { + const parsed = std.json.parseFromSlice(std.json.Value, allocator, body, .{}) catch return .{ .status = 400, .body = errJson(resp, "invalid json") }; + defer parsed.deinit(); + const token_val = parsed.value.object.get("token") orelse return .{ .status = 400, .body = errJson(resp, "missing token") }; + const status_val = parsed.value.object.get("status") orelse return .{ .status = 400, .body = errJson(resp, "missing status") }; + var token: [16]u8 = undefined; + if (!parseToken(token_val.string, &token)) return .{ .status = 400, .body = errJson(resp, "invalid token hex") }; + + const status = status_val.string; + const rc = ffi.coord_set_status(&token, 16, status.ptr, @intCast(status.len)); + if (rc < 0) return .{ .status = 401, .body = errJson(resp, "unauthenticated") }; + return .{ .status = 200, .body = okJson(resp, "ok") }; + } + + if (std.mem.eql(u8, tool, "coord_claim_task")) { + const parsed = std.json.parseFromSlice(std.json.Value, allocator, body, .{}) catch return .{ .status = 400, .body = errJson(resp, "invalid json") }; + defer parsed.deinit(); + const token_hex = parsed.value.object.get("token") orelse return .{ .status = 400, .body = errJson(resp, "missing token") }; + const task = parsed.value.object.get("task") orelse return .{ .status = 400, .body = errJson(resp, "missing task") }; + + var token: [16]u8 = undefined; + if (!parseToken(token_hex.string, &token)) return .{ .status = 400, .body = errJson(resp, "invalid token hex") }; + + // Optional Task #15 fields: confidence, dispatch_preference, task_difficulty. + const confidence: i32 = blk: { + const v = parsed.value.object.get("confidence") orelse break :blk -1; + // Accept either a float in 0..1 or a percentage 0..100. + switch (v) { + .float => |f| break :blk @intFromFloat(@min(@max(f * 100.0, 0.0), 100.0)), + .integer => |i| break :blk @intCast(i), + else => break :blk -1, + } + }; + const dispatch_pref: i32 = blk: { + const v = parsed.value.object.get("dispatch_preference") orelse break :blk -1; + const s = v.string; + if (std.mem.eql(u8, s, "deliberate")) break :blk 0; + if (std.mem.eql(u8, s, "broadcast")) break :blk 1; + if (std.mem.eql(u8, s, "auto")) break :blk 2; + break :blk -1; + }; + const difficulty: i32 = blk: { + const v = parsed.value.object.get("task_difficulty") orelse break :blk -1; + const s = v.string; + if (std.mem.eql(u8, s, "trivial")) break :blk 0; + if (std.mem.eql(u8, s, "routine")) break :blk 1; + if (std.mem.eql(u8, s, "challenging")) break :blk 2; + if (std.mem.eql(u8, s, "novel")) break :blk 3; + break :blk -1; + }; + + const result = ffi.coord_claim_task_ex( + &token, 16, task.string.ptr, @intCast(task.string.len), + confidence, dispatch_pref, difficulty, + ); + if (result == 0) return .{ .status = 200, .body = okJson(resp, "granted") }; + if (result == 1) return .{ .status = 200, .body = errJson(resp, "held") }; + if (result == -5) return .{ .status = 429, .body = errJson(resp, "cooldown: too many recent claim rejections for this client_kind β€” wait 30s") }; + return .{ .status = 500, .body = errJson(resp, "claim failed") }; + } + + // Old name accepted as alias per DD-32 backward-compat (one release). + if (std.mem.eql(u8, tool, "coord_promote_to_master") or + std.mem.eql(u8, tool, "coord_promote_to_supervisor")) + { + const parsed = std.json.parseFromSlice(std.json.Value, allocator, body, .{}) catch return .{ .status = 400, .body = errJson(resp, "invalid json") }; + defer parsed.deinit(); + const token_val = parsed.value.object.get("token") orelse return .{ .status = 400, .body = errJson(resp, "missing token") }; + const secret_val = parsed.value.object.get("secret") orelse return .{ .status = 400, .body = errJson(resp, "missing secret") }; + var token: [16]u8 = undefined; + if (!parseToken(token_val.string, &token)) return .{ .status = 400, .body = errJson(resp, "invalid token hex") }; + + const secret = secret_val.string; + const rc = ffi.coord_promote_to_master(&token, 16, secret.ptr, @intCast(secret.len)); + if (rc == 0) return .{ .status = 200, .body = okJson(resp, "promoted") }; + if (rc == -1) return .{ .status = 401, .body = errJson(resp, "unauthenticated") }; + if (rc == -2) return .{ .status = 409, .body = errJson(resp, "master already exists") }; + if (rc == -3) return .{ .status = 403, .body = errJson(resp, "master role not configured on this server") }; + if (rc == -4) return .{ .status = 403, .body = errJson(resp, "secret does not match") }; + return .{ .status = 500, .body = errJson(resp, "promotion failed") }; + } + + if (std.mem.eql(u8, tool, "coord_transfer_master")) { + const parsed = std.json.parseFromSlice(std.json.Value, allocator, body, .{}) catch return .{ .status = 400, .body = errJson(resp, "invalid json") }; + defer parsed.deinit(); + const token_val = parsed.value.object.get("token") orelse return .{ .status = 400, .body = errJson(resp, "missing token") }; + const target_val = parsed.value.object.get("new_peer_id") orelse return .{ .status = 400, .body = errJson(resp, "missing new_peer_id") }; + const secret_val = parsed.value.object.get("secret") orelse return .{ .status = 400, .body = errJson(resp, "missing secret") }; + + var token: [16]u8 = undefined; + if (!parseToken(token_val.string, &token)) return .{ .status = 400, .body = errJson(resp, "invalid token hex") }; + + const target_str = target_val.string; + const suffix = extractSuffix(target_str) orelse return .{ .status = 400, .body = errJson(resp, "invalid new_peer_id format β€” expected -<4hex>[@]") }; + const target_idx = ffi.coord_find_peer_by_suffix(suffix.ptr); + if (target_idx < 0) return .{ .status = 404, .body = errJson(resp, "target peer not found") }; + + const secret = secret_val.string; + const rc = ffi.coord_transfer_master(&token, 16, target_idx, secret.ptr, @intCast(secret.len)); + if (rc == 0) return .{ .status = 200, .body = okJson(resp, "transferred") }; + if (rc == -1) return .{ .status = 401, .body = errJson(resp, "caller is not the current master") }; + if (rc == -2) return .{ .status = 404, .body = errJson(resp, "target peer not found or same as caller") }; + if (rc == -3) return .{ .status = 403, .body = errJson(resp, "secret does not match BOJ_MASTER_TOKEN") }; + if (rc == -4) return .{ .status = 403, .body = errJson(resp, "target is an apprentice β€” must be journeyman or master") }; + return .{ .status = 500, .body = errJson(resp, "transfer failed") }; + } + + if (std.mem.eql(u8, tool, "coord_send_gated")) { + const parsed = std.json.parseFromSlice(std.json.Value, allocator, body, .{}) catch return .{ .status = 400, .body = errJson(resp, "invalid json") }; + defer parsed.deinit(); + const token_val = parsed.value.object.get("token") orelse return .{ .status = 400, .body = errJson(resp, "missing token") }; + const target_val = parsed.value.object.get("target") orelse return .{ .status = 400, .body = errJson(resp, "missing target") }; + const msg_val = parsed.value.object.get("message") orelse return .{ .status = 400, .body = errJson(resp, "missing message") }; + const tier_val = parsed.value.object.get("risk_tier") orelse return .{ .status = 400, .body = errJson(resp, "missing risk_tier") }; + + var token: [16]u8 = undefined; + if (!parseToken(token_val.string, &token)) return .{ .status = 400, .body = errJson(resp, "invalid token hex") }; + + const target_str = target_val.string; + var target_idx: i32 = -1; + if (!std.mem.eql(u8, target_str, "*")) { + const suffix = extractSuffix(target_str) orelse return .{ .status = 400, .body = errJson(resp, "invalid target format") }; + target_idx = ffi.coord_find_peer_by_suffix(suffix.ptr); + if (target_idx < 0) return .{ .status = 404, .body = errJson(resp, "target peer not found") }; + } + + const msg = msg_val.string; + const tier: i32 = @intCast(tier_val.integer); + const rc = ffi.coord_send_gated(&token, 16, target_idx, msg.ptr, @intCast(msg.len), tier); + + if (rc >= 0) { + const body_out = std.fmt.bufPrint(resp, "{{\"success\":true,\"status\":\"delivered\",\"sent\":{d}}}", .{rc}) catch return .{ .status = 500, .body = errJson(resp, "buffer overflow") }; + return .{ .status = 200, .body = body_out }; + } + if (rc < -1000) { + const request_id: i64 = -(@as(i64, rc) + 1000); + const body_out = std.fmt.bufPrint(resp, "{{\"success\":true,\"status\":\"quarantined\",\"request_id\":{d}}}", .{request_id}) catch return .{ .status = 500, .body = errJson(resp, "buffer overflow") }; + return .{ .status = 200, .body = body_out }; + } + if (rc == -1) return .{ .status = 401, .body = errJson(resp, "unauthenticated") }; + if (rc == -2) return .{ .status = 404, .body = errJson(resp, "target peer not found") }; + if (rc == -3) return .{ .status = 503, .body = errJson(resp, "target inbox full") }; + if (rc == -4) return .{ .status = 503, .body = errJson(resp, "quarantine queue full β€” spill to VeriSimDB not yet wired") }; + if (rc == -5) return .{ .status = 428, .body = errJson(resp, "no master registered β€” Tier 2+ from apprentice requires a master") }; + return .{ .status = 500, .body = errJson(resp, "gated send failed") }; + } + + if (std.mem.eql(u8, tool, "coord_review")) { + const parsed = std.json.parseFromSlice(std.json.Value, allocator, body, .{}) catch return .{ .status = 400, .body = errJson(resp, "invalid json") }; + defer parsed.deinit(); + const token_val = parsed.value.object.get("token") orelse return .{ .status = 400, .body = errJson(resp, "missing token") }; + var token: [16]u8 = undefined; + if (!parseToken(token_val.string, &token)) return .{ .status = 400, .body = errJson(resp, "invalid token hex") }; + + // 32 entries max * 16 bytes per record = 512 bytes raw. + var raw: [512]u8 = undefined; + const count = ffi.coord_review(&token, 16, &raw, @intCast(raw.len)); + if (count < 0) return .{ .status = 403, .body = errJson(resp, "master role required") }; + + var stream = std.io.fixedBufferStream(resp); + const w = stream.writer(); + w.writeAll("{\"success\":true,\"entries\":[") catch return .{ .status = 500, .body = errJson(resp, "buffer overflow") }; + + var i: usize = 0; + const cnt: usize = @intCast(count); + while (i < cnt) : (i += 1) { + const rec = raw[i * 16 ..][0..16]; + const rid_bytes: *const [4]u8 = rec[0..4]; + const rid: u32 = @bitCast(rid_bytes.*); + const sender_idx: u8 = rec[4]; + const target_idx_sign: i8 = @bitCast(rec[5]); + const risk_tier: u8 = rec[6]; + const mlen_bytes: *const [2]u8 = rec[7..9]; + const mlen: u16 = @bitCast(mlen_bytes.*); + const preview_n: usize = @min(@as(usize, 7), @as(usize, mlen)); + const preview = rec[9 .. 9 + preview_n]; + + if (i > 0) w.writeAll(",") catch return .{ .status = 500, .body = errJson(resp, "buffer overflow") }; + // sender_idx = 0xFE indicates a server-origin entry from coord_scan_suggestions. + const origin: []const u8 = if (sender_idx == 0xFE) "server-engine" else "peer"; + std.fmt.format(w, "{{\"request_id\":{d},\"origin\":\"{s}\",\"sender_idx\":{d},\"target_idx\":{d},\"risk_tier\":{d},\"msg_len\":{d},\"preview\":", .{ + rid, origin, sender_idx, target_idx_sign, risk_tier, mlen, + }) catch return .{ .status = 500, .body = errJson(resp, "buffer overflow") }; + writeJsonString(w, preview) catch return .{ .status = 500, .body = errJson(resp, "buffer overflow") }; + w.writeByte('}') catch return .{ .status = 500, .body = errJson(resp, "buffer overflow") }; + } + w.writeAll("]}") catch return .{ .status = 500, .body = errJson(resp, "buffer overflow") }; + return .{ .status = 200, .body = resp[0..stream.pos] }; + } + + if (std.mem.eql(u8, tool, "coord_review_entry")) { + const parsed = std.json.parseFromSlice(std.json.Value, allocator, body, .{}) catch return .{ .status = 400, .body = errJson(resp, "invalid json") }; + defer parsed.deinit(); + const token_val = parsed.value.object.get("token") orelse return .{ .status = 400, .body = errJson(resp, "missing token") }; + const rid_val = parsed.value.object.get("request_id") orelse return .{ .status = 400, .body = errJson(resp, "missing request_id") }; + var token: [16]u8 = undefined; + if (!parseToken(token_val.string, &token)) return .{ .status = 400, .body = errJson(resp, "invalid token hex") }; + + var msg_buf: [512]u8 = undefined; + const rc = ffi.coord_review_entry(&token, 16, @intCast(rid_val.integer), &msg_buf, @intCast(msg_buf.len)); + if (rc == -1) return .{ .status = 403, .body = errJson(resp, "master role required") }; + if (rc == -2) return .{ .status = 404, .body = errJson(resp, "request_id not found") }; + if (rc < 0) return .{ .status = 500, .body = errJson(resp, "review failed") }; + + const msg_slice = msg_buf[0..@intCast(rc)]; + var entry_stream = std.io.fixedBufferStream(resp); + const ew = entry_stream.writer(); + ew.writeAll("{\"success\":true,\"message\":") catch return .{ .status = 500, .body = errJson(resp, "buffer overflow") }; + writeJsonString(ew, msg_slice) catch return .{ .status = 500, .body = errJson(resp, "buffer overflow") }; + ew.writeByte('}') catch return .{ .status = 500, .body = errJson(resp, "buffer overflow") }; + return .{ .status = 200, .body = resp[0..entry_stream.pos] }; + } + + if (std.mem.eql(u8, tool, "coord_approve")) { + const parsed = std.json.parseFromSlice(std.json.Value, allocator, body, .{}) catch return .{ .status = 400, .body = errJson(resp, "invalid json") }; + defer parsed.deinit(); + const token_val = parsed.value.object.get("token") orelse return .{ .status = 400, .body = errJson(resp, "missing token") }; + const rid_val = parsed.value.object.get("request_id") orelse return .{ .status = 400, .body = errJson(resp, "missing request_id") }; + var token: [16]u8 = undefined; + if (!parseToken(token_val.string, &token)) return .{ .status = 400, .body = errJson(resp, "invalid token hex") }; + + const rc = ffi.coord_approve(&token, 16, @intCast(rid_val.integer)); + if (rc == 0) return .{ .status = 200, .body = okJson(resp, "approved") }; + if (rc == -1) return .{ .status = 403, .body = errJson(resp, "master role required") }; + if (rc == -2) return .{ .status = 404, .body = errJson(resp, "request_id not found") }; + if (rc == -3) return .{ .status = 503, .body = errJson(resp, "target inbox full β€” retry") }; + return .{ .status = 500, .body = errJson(resp, "approve failed") }; + } + + if (std.mem.eql(u8, tool, "coord_report_outcome")) { + const parsed = std.json.parseFromSlice(std.json.Value, allocator, body, .{}) catch return .{ .status = 400, .body = errJson(resp, "invalid json") }; + defer parsed.deinit(); + const token_val = parsed.value.object.get("token") orelse return .{ .status = 400, .body = errJson(resp, "missing token") }; + const tag_val = parsed.value.object.get("tag") orelse return .{ .status = 400, .body = errJson(resp, "missing tag") }; + const outcome_val = parsed.value.object.get("outcome") orelse return .{ .status = 400, .body = errJson(resp, "missing outcome") }; + const tier_val = parsed.value.object.get("risk_tier") orelse return .{ .status = 400, .body = errJson(resp, "missing risk_tier") }; + // duration_ms is optional; default 0. + const duration_val: i64 = blk: { + const v = parsed.value.object.get("duration_ms") orelse break :blk 0; + break :blk v.integer; + }; + // confidence is optional; accept 0..1 float or 0..100 integer, -1 if absent. + const confidence: i32 = blk: { + const v = parsed.value.object.get("confidence") orelse break :blk -1; + switch (v) { + .float => |f| break :blk @intFromFloat(@min(@max(f * 100.0, 0.0), 100.0)), + .integer => |i| break :blk @intCast(i), + else => break :blk -1, + } + }; + + var token: [16]u8 = undefined; + if (!parseToken(token_val.string, &token)) return .{ .status = 400, .body = errJson(resp, "invalid token hex") }; + + const tag_str = tag_val.string; + if (tag_str.len > 64) return .{ .status = 400, .body = errJson(resp, "tag exceeds 64 bytes") }; + + // outcome may arrive as string ("success"/"fail") or integer (0/1). + var outcome: i32 = -1; + switch (outcome_val) { + .string => |s| { + if (std.mem.eql(u8, s, "success")) outcome = 1; + if (std.mem.eql(u8, s, "fail")) outcome = 0; + }, + .integer => |i| outcome = @intCast(i), + else => {}, + } + if (outcome != 0 and outcome != 1) return .{ .status = 400, .body = errJson(resp, "outcome must be 'success'/'fail' or 0/1") }; + + const tier: i32 = @intCast(tier_val.integer); + const duration: i32 = @intCast(duration_val); + const rc = ffi.coord_report_outcome(&token, 16, tag_str.ptr, @intCast(tag_str.len), outcome, duration, tier, confidence); + if (rc == 0) return .{ .status = 200, .body = okJson(resp, "recorded") }; + if (rc == -1) return .{ .status = 401, .body = errJson(resp, "unauthenticated") }; + if (rc == -2) return .{ .status = 400, .body = errJson(resp, "invalid args") }; + return .{ .status = 500, .body = errJson(resp, "report failed") }; + } + + if (std.mem.eql(u8, tool, "coord_set_declared_affinities")) { + const parsed = std.json.parseFromSlice(std.json.Value, allocator, body, .{}) catch return .{ .status = 400, .body = errJson(resp, "invalid json") }; + defer parsed.deinit(); + const token_val = parsed.value.object.get("token") orelse return .{ .status = 400, .body = errJson(resp, "missing token") }; + const tags_val = parsed.value.object.get("tags") orelse return .{ .status = 400, .body = errJson(resp, "missing tags") }; + if (tags_val != .array) return .{ .status = 400, .body = errJson(resp, "tags must be an array of strings") }; + + var token: [16]u8 = undefined; + if (!parseToken(token_val.string, &token)) return .{ .status = 400, .body = errJson(resp, "invalid token hex") }; + + var csv_buf: [256]u8 = undefined; + var csv_len: usize = 0; + for (tags_val.array.items) |item| { + if (item != .string) continue; + const s = item.string; + if (csv_len > 0 and csv_len < csv_buf.len) { + csv_buf[csv_len] = ','; + csv_len += 1; + } + const to_copy: usize = @min(s.len, csv_buf.len - csv_len); + if (to_copy > 0) @memcpy(csv_buf[csv_len .. csv_len + to_copy], s[0..to_copy]); + csv_len += to_copy; + } + + const rc = ffi.coord_set_declared_affinities(&token, 16, &csv_buf, @intCast(csv_len)); + if (rc == 0) return .{ .status = 200, .body = okJson(resp, "declared") }; + if (rc == -1) return .{ .status = 401, .body = errJson(resp, "unauthenticated") }; + if (rc == -2) return .{ .status = 400, .body = errJson(resp, "declared affinities CSV too long") }; + return .{ .status = 500, .body = errJson(resp, "set failed") }; + } + + if (std.mem.eql(u8, tool, "coord_scan_suggestions")) { + const parsed = std.json.parseFromSlice(std.json.Value, allocator, body, .{}) catch return .{ .status = 400, .body = errJson(resp, "invalid json") }; + defer parsed.deinit(); + const token_val = parsed.value.object.get("token") orelse return .{ .status = 400, .body = errJson(resp, "missing token") }; + var token: [16]u8 = undefined; + if (!parseToken(token_val.string, &token)) return .{ .status = 400, .body = errJson(resp, "invalid token hex") }; + + const n = ffi.coord_scan_suggestions(&token, 16); + if (n == -1) return .{ .status = 401, .body = errJson(resp, "unauthenticated") }; + const body_out = std.fmt.bufPrint(resp, "{{\"success\":true,\"suggestions_queued\":{d},\"hint\":\"use coord_review to inspect, coord_approve/coord_reject to act\"}}", .{n}) catch return .{ .status = 500, .body = errJson(resp, "buffer overflow") }; + return .{ .status = 200, .body = body_out }; + } + + if (std.mem.eql(u8, tool, "coord_get_affinities")) { + const parsed = std.json.parseFromSlice(std.json.Value, allocator, body, .{}) catch return .{ .status = 400, .body = errJson(resp, "invalid json") }; + defer parsed.deinit(); + const token_val = parsed.value.object.get("token") orelse return .{ .status = 400, .body = errJson(resp, "missing token") }; + var token: [16]u8 = undefined; + if (!parseToken(token_val.string, &token)) return .{ .status = 400, .body = errJson(resp, "invalid token hex") }; + + // Up to 64 aggregates * 64 bytes = 4096 bytes. + var raw: [4096]u8 = undefined; + const n = ffi.coord_get_affinities(&token, 16, &raw, @intCast(raw.len)); + if (n == -1) return .{ .status = 401, .body = errJson(resp, "unauthenticated") }; + if (n < -1000) return .{ .status = 500, .body = errJson(resp, "affinity buffer overflow β€” too many distinct (kind, tag) pairs") }; + if (n < 0) return .{ .status = 500, .body = errJson(resp, "affinity query failed") }; + + var stream = std.io.fixedBufferStream(resp); + const w = stream.writer(); + w.writeAll("{\"success\":true,\"affinities\":[") catch return .{ .status = 500, .body = errJson(resp, "buffer overflow") }; + + const REC_SIZE: usize = 64; + const cnt: usize = @intCast(n); + var i: usize = 0; + while (i < cnt) : (i += 1) { + const rec = raw[i * REC_SIZE ..][0..REC_SIZE]; + const kind: u8 = rec[0]; + const attempts: u16 = @bitCast([2]u8{ rec[1], rec[2] }); + const successes: u16 = @bitCast([2]u8{ rec[3], rec[4] }); + const pct: u8 = rec[5]; + const tag_len: u8 = rec[6]; + const tag = rec[7 .. 7 + @min(@as(usize, tag_len), 57)]; + + if (i > 0) w.writeAll(",") catch return .{ .status = 500, .body = errJson(resp, "buffer overflow") }; + + // affinity as decimal; 255 sentinel means no data. Tag is user-supplied + // (from coord_report_outcome) so must go through writeJsonString. + if (pct == 255) { + std.fmt.format(w, "{{\"client_kind\":\"{s}\",\"tag\":", .{kindName(@intCast(kind))}) catch return .{ .status = 500, .body = errJson(resp, "buffer overflow") }; + writeJsonString(w, tag) catch return .{ .status = 500, .body = errJson(resp, "buffer overflow") }; + std.fmt.format(w, ",\"attempts\":{d},\"successes\":{d},\"effective_affinity\":null}}", .{ attempts, successes }) catch return .{ .status = 500, .body = errJson(resp, "buffer overflow") }; + } else { + std.fmt.format(w, "{{\"client_kind\":\"{s}\",\"tag\":", .{kindName(@intCast(kind))}) catch return .{ .status = 500, .body = errJson(resp, "buffer overflow") }; + writeJsonString(w, tag) catch return .{ .status = 500, .body = errJson(resp, "buffer overflow") }; + std.fmt.format(w, ",\"attempts\":{d},\"successes\":{d},\"effective_affinity\":{d}.{d:0>2}}}", .{ attempts, successes, pct / 100, pct % 100 }) catch return .{ .status = 500, .body = errJson(resp, "buffer overflow") }; + } + } + w.writeAll("]}") catch return .{ .status = 500, .body = errJson(resp, "buffer overflow") }; + return .{ .status = 200, .body = resp[0..stream.pos] }; + } + + if (std.mem.eql(u8, tool, "coord_reject")) { + const parsed = std.json.parseFromSlice(std.json.Value, allocator, body, .{}) catch return .{ .status = 400, .body = errJson(resp, "invalid json") }; + defer parsed.deinit(); + const token_val = parsed.value.object.get("token") orelse return .{ .status = 400, .body = errJson(resp, "missing token") }; + const rid_val = parsed.value.object.get("request_id") orelse return .{ .status = 400, .body = errJson(resp, "missing request_id") }; + const reason_val = parsed.value.object.get("reason") orelse return .{ .status = 400, .body = errJson(resp, "missing reason") }; + var token: [16]u8 = undefined; + if (!parseToken(token_val.string, &token)) return .{ .status = 400, .body = errJson(resp, "invalid token hex") }; + + const reason = reason_val.string; + const rc = ffi.coord_reject(&token, 16, @intCast(rid_val.integer), reason.ptr, @intCast(reason.len)); + if (rc == 0) return .{ .status = 200, .body = okJson(resp, "rejected") }; + if (rc == -1) return .{ .status = 403, .body = errJson(resp, "master role required") }; + if (rc == -2) return .{ .status = 404, .body = errJson(resp, "request_id not found") }; + return .{ .status = 500, .body = errJson(resp, "reject failed") }; + } + + if (std.mem.eql(u8, tool, "coord_set_variant")) { + const parsed = std.json.parseFromSlice(std.json.Value, allocator, body, .{}) catch return .{ .status = 400, .body = errJson(resp, "invalid json") }; + defer parsed.deinit(); + const token_val = parsed.value.object.get("token") orelse return .{ .status = 400, .body = errJson(resp, "missing token") }; + const variant_val = parsed.value.object.get("variant") orelse return .{ .status = 400, .body = errJson(resp, "missing variant") }; + if (variant_val != .string) return .{ .status = 400, .body = errJson(resp, "variant must be a string") }; + + var token: [16]u8 = undefined; + if (!parseToken(token_val.string, &token)) return .{ .status = 400, .body = errJson(resp, "invalid token hex") }; + + const vs = variant_val.string; + const rc = ffi.coord_set_variant(&token, 16, vs.ptr, @intCast(vs.len)); + if (rc == 0) return .{ .status = 200, .body = okJson(resp, "set") }; + if (rc == -1) return .{ .status = 401, .body = errJson(resp, "unauthenticated") }; + if (rc == -2) return .{ .status = 400, .body = errJson(resp, "invalid variant (alphanum / . / - / _ only, max 32 bytes)") }; + return .{ .status = 500, .body = errJson(resp, "set_variant failed") }; + } + + if (std.mem.eql(u8, tool, "coord_set_capabilities")) { + const parsed = std.json.parseFromSlice(std.json.Value, allocator, body, .{}) catch return .{ .status = 400, .body = errJson(resp, "invalid json") }; + defer parsed.deinit(); + const token_val = parsed.value.object.get("token") orelse return .{ .status = 400, .body = errJson(resp, "missing token") }; + var token: [16]u8 = undefined; + if (!parseToken(token_val.string, &token)) return .{ .status = 400, .body = errJson(resp, "invalid token hex") }; + + var class_buf: [128]u8 = undefined; + var class_len: usize = 0; + if (parsed.value.object.get("class")) |cv| { + if (cv == .array) class_len = arrayToCsv(cv.array.items, &class_buf); + } + + var tier: i32 = 0; + if (parsed.value.object.get("tier")) |tv| { + if (tv == .integer) tier = @intCast(tv.integer); + } + + var pro_buf: [256]u8 = undefined; + var pro_len: usize = 0; + if (parsed.value.object.get("prover_strengths")) |pv| { + if (pv == .array) pro_len = arrayToCsv(pv.array.items, &pro_buf); + } + + const rc = ffi.coord_set_capabilities( + &token, 16, + &class_buf, @intCast(class_len), + tier, + &pro_buf, @intCast(pro_len), + ); + if (rc == 0) return .{ .status = 200, .body = okJson(resp, "set") }; + if (rc == -1) return .{ .status = 401, .body = errJson(resp, "unauthenticated") }; + if (rc == -2) return .{ .status = 400, .body = errJson(resp, "invalid capabilities (tier 0..5, class ≀128B, prover_strengths ≀256B)") }; + return .{ .status = 500, .body = errJson(resp, "set_capabilities failed") }; + } + + if (std.mem.eql(u8, tool, "coord_get_peer_capabilities")) { + const parsed = std.json.parseFromSlice(std.json.Value, allocator, body, .{}) catch return .{ .status = 400, .body = errJson(resp, "invalid json") }; + defer parsed.deinit(); + const token_val = parsed.value.object.get("token") orelse return .{ .status = 400, .body = errJson(resp, "missing token") }; + const target_val = parsed.value.object.get("peer_id") orelse return .{ .status = 400, .body = errJson(resp, "missing peer_id") }; + + var token: [16]u8 = undefined; + if (!parseToken(token_val.string, &token)) return .{ .status = 400, .body = errJson(resp, "invalid token hex") }; + + // Validate caller token by calling any authenticated read; the cheapest + // is coord_list_peers with a zero-cap buffer (returns -1 on bad token). + var probe: [1]u8 = undefined; + if (ffi.coord_list_peers(&token, 16, &probe, 0) < 0) { + return .{ .status = 401, .body = errJson(resp, "unauthenticated") }; + } + + const target_str = target_val.string; + const suffix = extractSuffix(target_str) orelse return .{ .status = 400, .body = errJson(resp, "invalid peer_id format β€” expected -<4hex>[@]") }; + const peer_idx = ffi.coord_find_peer_by_suffix(suffix.ptr); + if (peer_idx < 0) return .{ .status = 404, .body = errJson(resp, "peer not found") }; + + // Read each component separately; renderJSON arrays from CSVs. + var class_buf: [128]u8 = undefined; + const class_n = ffi.coord_read_peer_class(peer_idx, &class_buf, @intCast(class_buf.len)); + const class_slice: []const u8 = if (class_n > 0) class_buf[0..@intCast(class_n)] else ""; + + const tier_val = ffi.coord_read_peer_tier(peer_idx); + + var pro_buf: [256]u8 = undefined; + const pro_n = ffi.coord_read_peer_provers(peer_idx, &pro_buf, @intCast(pro_buf.len)); + const pro_slice: []const u8 = if (pro_n > 0) pro_buf[0..@intCast(pro_n)] else ""; + + var variant_buf: [32]u8 = undefined; + const v_n = ffi.coord_read_peer_variant(peer_idx, &variant_buf, @intCast(variant_buf.len)); + const variant_slice: []const u8 = if (v_n > 0) variant_buf[0..@intCast(v_n)] else ""; + + const kind_val = ffi.coord_read_peer_kind(peer_idx); + + // Reconstruct the canonical peer_id from server-side state rather than + // echoing user input. Avoids reflecting any unvalidated chars into JSON. + var ctx_buf2: [32]u8 = undefined; + const ctx_n = ffi.coord_read_peer_context(peer_idx, &ctx_buf2, @intCast(ctx_buf2.len)); + const ctx_slice2: []const u8 = if (ctx_n > 0) ctx_buf2[0..@intCast(ctx_n)] else ""; + var canon_id_buf: [96]u8 = undefined; + const canon_id = renderPeerId(&canon_id_buf, kindName(kind_val), suffix, ctx_slice2) catch return .{ .status = 500, .body = errJson(resp, "peer_id render overflow") }; + + var stream = std.io.fixedBufferStream(resp); + const w = stream.writer(); + std.fmt.format(w, + "{{\"success\":true,\"peer_id\":\"{s}\",\"kind\":\"{s}\",\"variant\":\"{s}\",\"tier\":{d},\"class\":[", + .{ canon_id, kindName(kind_val), variant_slice, tier_val }, + ) catch return .{ .status = 500, .body = errJson(resp, "buffer overflow") }; + writeCsvAsJsonStrings(w, class_slice) catch return .{ .status = 500, .body = errJson(resp, "buffer overflow") }; + w.writeAll("],\"prover_strengths\":[") catch return .{ .status = 500, .body = errJson(resp, "buffer overflow") }; + writeCsvAsJsonStrings(w, pro_slice) catch return .{ .status = 500, .body = errJson(resp, "buffer overflow") }; + w.writeAll("]}") catch return .{ .status = 500, .body = errJson(resp, "buffer overflow") }; + return .{ .status = 200, .body = resp[0..stream.pos] }; + } + + if (std.mem.eql(u8, tool, "coord_progress")) { + const parsed = std.json.parseFromSlice(std.json.Value, allocator, body, .{}) catch return .{ .status = 400, .body = errJson(resp, "invalid json") }; + defer parsed.deinit(); + const token_val = parsed.value.object.get("token") orelse return .{ .status = 400, .body = errJson(resp, "missing token") }; + const task_val = parsed.value.object.get("task") orelse return .{ .status = 400, .body = errJson(resp, "missing task") }; + + var token: [16]u8 = undefined; + if (!parseToken(token_val.string, &token)) return .{ .status = 400, .body = errJson(resp, "invalid token hex") }; + + const task = task_val.string; + const rc = ffi.coord_progress(&token, 16, task.ptr, @intCast(task.len)); + if (rc == 0) return .{ .status = 200, .body = okJson(resp, "heartbeat") }; + if (rc == -1) return .{ .status = 401, .body = errJson(resp, "unauthenticated") }; + if (rc == -2) return .{ .status = 404, .body = errJson(resp, "no active claim for this task") }; + if (rc == -3) return .{ .status = 403, .body = errJson(resp, "caller is not the claim holder") }; + return .{ .status = 500, .body = errJson(resp, "progress failed") }; + } + + if (std.mem.eql(u8, tool, "coord_sweep_watchdog")) { + const parsed = std.json.parseFromSlice(std.json.Value, allocator, body, .{}) catch return .{ .status = 400, .body = errJson(resp, "invalid json") }; + defer parsed.deinit(); + const token_val = parsed.value.object.get("token") orelse return .{ .status = 400, .body = errJson(resp, "missing token") }; + var token: [16]u8 = undefined; + if (!parseToken(token_val.string, &token)) return .{ .status = 400, .body = errJson(resp, "invalid token hex") }; + + const rc = ffi.coord_sweep_watchdog(&token, 16); + if (rc < 0) return .{ .status = 401, .body = errJson(resp, "unauthenticated") }; + const body_out = std.fmt.bufPrint(resp, + "{{\"success\":true,\"released\":{d},\"ttl_apprentice_ms\":30000,\"ttl_journeyman_ms\":300000}}", + .{rc}) catch return .{ .status = 500, .body = errJson(resp, "buffer overflow") }; + return .{ .status = 200, .body = body_out }; + } + + if (std.mem.eql(u8, tool, "coord_list_claims")) { + const parsed = std.json.parseFromSlice(std.json.Value, allocator, body, .{}) catch return .{ .status = 400, .body = errJson(resp, "invalid json") }; + defer parsed.deinit(); + const token_val = parsed.value.object.get("token") orelse return .{ .status = 400, .body = errJson(resp, "missing token") }; + var token: [16]u8 = undefined; + if (!parseToken(token_val.string, &token)) return .{ .status = 400, .body = errJson(resp, "invalid token hex") }; + + var stream = std.io.fixedBufferStream(resp); + const w = stream.writer(); + w.writeAll("{\"success\":true,\"active_claims\":[") catch return .{ .status = 500, .body = errJson(resp, "buffer overflow") }; + var first = true; + var ci: c_int = 0; + while (ci < 64) : (ci += 1) { + var task_buf: [128]u8 = undefined; + const task_len = ffi.coord_read_claim_task(&token, 16, ci, &task_buf, @intCast(task_buf.len)); + if (task_len < 0) continue; + const task_slice = task_buf[0..@intCast(task_len)]; + var holder_suffix: [4]u8 = undefined; + const hs_rc = ffi.coord_read_claim_holder_suffix(&token, 16, ci, &holder_suffix); + if (!first) w.writeByte(',') catch return .{ .status = 500, .body = errJson(resp, "buffer overflow") }; + first = false; + w.writeAll("{\"task\":") catch return .{ .status = 500, .body = errJson(resp, "buffer overflow") }; + writeJsonString(w, task_slice) catch return .{ .status = 500, .body = errJson(resp, "buffer overflow") }; + if (hs_rc == 4) { + std.fmt.format(w, ",\"holder\":\"{s}\"", .{holder_suffix}) catch return .{ .status = 500, .body = errJson(resp, "buffer overflow") }; + } else { + w.writeAll(",\"holder\":\"\"") catch return .{ .status = 500, .body = errJson(resp, "buffer overflow") }; + } + w.writeByte('}') catch return .{ .status = 500, .body = errJson(resp, "buffer overflow") }; + } + w.writeAll("]}") catch return .{ .status = 500, .body = errJson(resp, "buffer overflow") }; + return .{ .status = 200, .body = resp[0..stream.pos] }; + } + + if (std.mem.eql(u8, tool, "coord_health")) { + const parsed = std.json.parseFromSlice(std.json.Value, allocator, body, .{}) catch return .{ .status = 400, .body = errJson(resp, "invalid json") }; + defer parsed.deinit(); + const token_val = parsed.value.object.get("token") orelse return .{ .status = 400, .body = errJson(resp, "missing token") }; + var token: [16]u8 = undefined; + if (!parseToken(token_val.string, &token)) return .{ .status = 400, .body = errJson(resp, "invalid token hex") }; + + // Gather per-peer counts by scanning the 16 slots directly. + var peer_active: u8 = 0; + var by_kind = [_]u8{ 0, 0, 0, 0, 0, 0 }; // claude/gemini/copilot/custom/openai/mistral + var by_role = [_]u8{ 0, 0, 0 }; // master / journeyman / apprentice + var i: c_int = 0; + while (i < 16) : (i += 1) { + const k = ffi.coord_read_peer_kind(i); + if (k < 0) continue; + peer_active += 1; + if (k >= 0 and k < 6) by_kind[@intCast(k)] += 1; + const r = ffi.coord_read_peer_role(i); + if (r >= 0 and r < 3) by_role[@intCast(r)] += 1; + } + + // Per-kind reject window counts + cooldown flags. + var reject_counts = [_]c_int{ 0, 0, 0, 0, 0, 0 }; + var cooldown_flags = [_]c_int{ 0, 0, 0, 0, 0, 0 }; + var kind_valid = [_]bool{ false, false, false, false, false, false }; + var ki: c_int = 0; + while (ki < 6) : (ki += 1) { + const rc = ffi.coord_count_rejects_recent(&token, 16, ki); + if (rc < 0) { + // First bad-token return short-circuits to 401 β€” otherwise we'd + // silently emit {success:true, ...0s}. + if (ki == 0 and rc == -1) return .{ .status = 401, .body = errJson(resp, "unauthenticated") }; + continue; + } + reject_counts[@intCast(ki)] = rc; + kind_valid[@intCast(ki)] = true; + const cd = ffi.coord_kind_in_cooldown(&token, 16, ki); + if (cd >= 0) cooldown_flags[@intCast(ki)] = cd; + } + + const quar = ffi.coord_count_quarantine(&token, 16); + const clm = ffi.coord_count_claims(&token, 16); + const trk = ffi.coord_count_track(&token, 16); + + var stream = std.io.fixedBufferStream(resp); + const w = stream.writer(); + std.fmt.format(w, + "{{\"success\":true,\"peers\":{{\"active\":{d},\"max\":16," ++ + "\"by_kind\":{{\"claude\":{d},\"gemini\":{d},\"copilot\":{d},\"custom\":{d},\"openai\":{d},\"mistral\":{d}}}," ++ + "\"by_role\":{{\"master\":{d},\"journeyman\":{d},\"apprentice\":{d}}}}}," ++ + "\"quarantine\":{{\"pending\":{d},\"max\":32}}," ++ + "\"claims\":{{\"active\":{d},\"max\":64}}," ++ + "\"track\":{{\"entries\":{d},\"max\":512}}," ++ + "\"rejects\":{{\"window_ms\":600000,\"cooldown_ms\":30000," ++ + "\"recent_by_kind\":{{\"claude\":{d},\"gemini\":{d},\"copilot\":{d},\"custom\":{d},\"openai\":{d},\"mistral\":{d}}}," ++ + "\"in_cooldown\":[", + .{ + peer_active, + by_kind[0], by_kind[1], by_kind[2], by_kind[3], by_kind[4], by_kind[5], + by_role[0], by_role[1], by_role[2], + quar, clm, trk, + reject_counts[0], reject_counts[1], reject_counts[2], reject_counts[3], reject_counts[4], reject_counts[5], + }, + ) catch return .{ .status = 500, .body = errJson(resp, "buffer overflow") }; + + var wrote_cd: bool = false; + ki = 0; + while (ki < 6) : (ki += 1) { + if (cooldown_flags[@intCast(ki)] == 1) { + if (wrote_cd) w.writeAll(",") catch return .{ .status = 500, .body = errJson(resp, "buffer overflow") }; + wrote_cd = true; + w.writeAll("\"") catch return .{ .status = 500, .body = errJson(resp, "buffer overflow") }; + w.writeAll(kindName(ki)) catch return .{ .status = 500, .body = errJson(resp, "buffer overflow") }; + w.writeAll("\"") catch return .{ .status = 500, .body = errJson(resp, "buffer overflow") }; + } + } + w.writeAll("],\"by_peer\":[") catch return .{ .status = 500, .body = errJson(resp, "buffer overflow") }; + + // Per-peer reject counts + cooldown flags (Item A β€” rejection rate-limit hardening). + // Skip inactive slots; peer_id rebuilt canonically via renderPeerId. + var wrote_bp: bool = false; + var pi: c_int = 0; + while (pi < 16) : (pi += 1) { + const pk = ffi.coord_read_peer_kind(pi); + if (pk < 0) continue; + var psuf: [4]u8 = undefined; + if (ffi.coord_read_peer_suffix(pi, &psuf) != 4) continue; + var pctx_buf: [32]u8 = undefined; + const pctx_len = ffi.coord_read_peer_context(pi, &pctx_buf, @intCast(pctx_buf.len)); + const pctx_slice: []const u8 = if (pctx_len > 0) pctx_buf[0..@intCast(pctx_len)] else ""; + var pid_buf: [96]u8 = undefined; + const pid = renderPeerId(&pid_buf, kindName(pk), &psuf, pctx_slice) catch continue; + const prc = ffi.coord_count_rejects_recent_peer(&token, 16, pi); + if (prc < 0) continue; + const pcd = ffi.coord_peer_in_cooldown(&token, 16, pi); + if (wrote_bp) w.writeAll(",") catch return .{ .status = 500, .body = errJson(resp, "buffer overflow") }; + wrote_bp = true; + std.fmt.format(w, "{{\"peer_id\":\"{s}\",\"count\":{d},\"in_cooldown\":{s}}}", .{ + pid, prc, if (pcd == 1) "true" else "false", + }) catch return .{ .status = 500, .body = errJson(resp, "buffer overflow") }; + } + w.writeAll("]}}") catch return .{ .status = 500, .body = errJson(resp, "buffer overflow") }; + return .{ .status = 200, .body = resp[0..stream.pos] }; + } + + return .{ .status = 404, .body = errJson(resp, "not implemented") }; +} + +/// Write a JSON string (with surrounding quotes) to writer, escaping all +/// characters that JSON forbids in string literals: `"`, `\`, and the +/// C0 control set (0x00–0x1F). High bytes (β‰₯0x80) are written as-is on +/// the assumption that the caller already has valid UTF-8; JSON allows +/// raw UTF-8 sequences as long as `"` and `\` are escaped. +fn writeJsonString(w: anytype, s: []const u8) !void { + try w.writeByte('"'); + for (s) |c| { + switch (c) { + '"' => try w.writeAll("\\\""), + '\\' => try w.writeAll("\\\\"), + '\n' => try w.writeAll("\\n"), + '\r' => try w.writeAll("\\r"), + '\t' => try w.writeAll("\\t"), + // Remaining C0 controls: \u00XX (2 significant hex digits suffice). + 0x00...0x08, 0x0B, 0x0C, 0x0E...0x1F => try std.fmt.format(w, "\\u00{x:0>2}", .{c}), + else => try w.writeByte(c), + } + } + try w.writeByte('"'); +} + +/// Emit a CSV (comma-separated) as a JSON array of strings into the writer. +/// Empty CSV β†’ empty output. Caller supplies surrounding `[` and `]`. +/// Each item is written via writeJsonString so quotes/backslashes are safe. +fn writeCsvAsJsonStrings(w: anytype, csv: []const u8) !void { + if (csv.len == 0) return; + var it = std.mem.splitScalar(u8, csv, ','); + var first = true; + while (it.next()) |part| { + if (part.len == 0) continue; + if (!first) try w.writeAll(","); + first = false; + try writeJsonString(w, part); + } +} + +fn handleConnection(stream: std.net.Stream, allocator: std.mem.Allocator) void { + defer stream.close(); + var buf: [8192]u8 = undefined; + var resp_buf: [8192]u8 = undefined; + const n = stream.read(&buf) catch return; + const req = buf[0..n]; + + var lines = std.mem.splitScalar(u8, req, '\n'); + const first = lines.next() orelse return; + var parts = std.mem.splitScalar(u8, std.mem.trim(u8, first, "\r"), ' '); + _ = parts.next(); + const path = parts.next() orelse return; + + const body_start = std.mem.indexOf(u8, req, "\r\n\r\n") orelse 0; + const body = if (body_start > 0) req[body_start + 4 ..] else ""; + + const prefix = "/tools/"; + var result: Response = .{ .status = 404, .body = errJson(&resp_buf, "not found") }; + if (std.mem.startsWith(u8, path, prefix)) { + const tool = path[prefix.len..]; + result = dispatch(tool, body, &resp_buf, allocator); + } + + var http_resp: [512]u8 = undefined; + const http = std.fmt.bufPrint(&http_resp, + "HTTP/1.1 {d} OK\r\nContent-Length: {d}\r\nContent-Type: application/json\r\nConnection: close\r\n\r\n", + .{ result.status, result.body.len }) catch return; + _ = stream.write(http) catch {}; + _ = stream.write(result.body) catch {}; +} + +pub fn main() !void { + var gpa = std.heap.GeneralPurposeAllocator(.{}){}; + const allocator = gpa.allocator(); + + _ = ffi.boj_cartridge_init(); + + const addr = std.net.Address.initIp4(BIND_ADDR, REST_PORT); + var server = try addr.listen(.{ .reuse_address = true }); + defer server.deinit(); + + while (true) { + const conn = try server.accept(); + handleConnection(conn.stream, allocator); + } +} diff --git a/cartridges/cross-cutting/agentic/local-coord-mcp/cartridge.json b/cartridges/cross-cutting/agentic/local-coord-mcp/cartridge.json new file mode 100644 index 0000000..001a10c --- /dev/null +++ b/cartridges/cross-cutting/agentic/local-coord-mcp/cartridge.json @@ -0,0 +1,653 @@ +{ + "$schema": "https://boj.dev/schemas/cartridge/v1.json", + "api": { + "base_url": "local://local-coord-mcp", + "content_type": "application/json" + }, + "auth": { + "credential_source": null, + "description": "Per-instance session token issued on coord_register. All subsequent calls require this token.", + "env_var": null, + "method": "session-token" + }, + "bind": { + "address": "127.0.0.1", + "loopback_only": true, + "port": 7745 + }, + "copyright": "Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) ", + "description": "Localhost multi-instance coordination β€” peer discovery, message passing, and task claiming for parallel AI sessions on the same machine", + "domain": "ai", + "federation": "none", + "name": "local-coord-mcp", + "protocols": [ + "MCP", + "Agentic" + ], + "spdx": "MPL-2.0", + "tier": "Ayo", + "tools": [ + { + "description": "Register this instance as a coordination peer. Returns a peer ID and a session token for all subsequent calls. Optional `context` (alphanumeric + hyphen/underscore, max 32 bytes) disambiguates multiple windows of the same client_kind. Optional `declared_affinities` is an array of tag names the peer self-reports as strengths β€” feeds the reassignment engine (Task #14). Optional `variant` is a free-form model identifier (Task #33). Optional `capabilities` advertises class / tier / prover_strengths for cold-start routing (Task #34).", + "inputSchema": { + "properties": { + "capabilities": { + "description": "Capability advertisement used for cold-start routing (Task #34). All three keys are individually optional.", + "properties": { + "class": { + "description": "Capability classes (e.g. 'reasoning', 'coding', 'proof', 'grammar'). CSV internally, max 128 bytes total.", + "items": { + "type": "string" + }, + "type": "array" + }, + "prover_strengths": { + "description": "Prover names this peer claims strength in (e.g. 'coq', 'lean', 'tlaps'). CSV internally, max 256 bytes total.", + "items": { + "type": "string" + }, + "type": "array" + }, + "tier": { + "description": "Advertised capability tier 1..5 (0 = unset).", + "maximum": 5, + "minimum": 0, + "type": "integer" + } + }, + "type": "object" + }, + "client_kind": { + "description": "Client family: claude, gemini, copilot, openai, mistral, or custom (Task #33 extended openai + mistral).", + "enum": [ + "claude", + "gemini", + "copilot", + "custom", + "openai", + "mistral" + ], + "type": "string" + }, + "context": { + "description": "Optional per-window disambiguator (repo name, tty label). Alphanumeric/hyphen/underscore only, max 32 chars.", + "type": "string" + }, + "declared_affinities": { + "description": "Optional array of tag strings the peer claims as strengths (e.g. ['proof-analysis', 'supervision']). Feeds Task #14 reassignment engine.", + "items": { + "type": "string" + }, + "type": "array" + }, + "role": { + "description": "Optional requested role (DD-32). master is ALWAYS rejected here β€” promote via coord_promote_to_master. Default: claude->journeyman, others->apprentice. Old aliases (executor, supervised) accepted for one release.", + "enum": [ + "journeyman", + "apprentice", + "executor", + "supervised" + ], + "type": "string" + }, + "variant": { + "description": "Free-form model/variant label (Task #33). Alphanumeric + `.`/`-`/`_` only, max 32 bytes. e.g. 'opus-4.7', 'flash-2.5', 'leanstral'.", + "type": "string" + } + }, + "required": [ + "client_kind" + ], + "type": "object" + }, + "name": "coord_register" + }, + { + "description": "List all active coordination peers on this machine. Returns peer IDs, client kinds, and states.", + "inputSchema": { + "properties": { + "token": { + "description": "Session token from coord_register", + "type": "string" + } + }, + "required": [ + "token" + ], + "type": "object" + }, + "name": "coord_list_peers" + }, + { + "description": "Send a message to a specific peer (by peer ID) or broadcast to all peers (target '*'). Messages are queued in recipient inboxes.", + "inputSchema": { + "properties": { + "message": { + "description": "Message content", + "type": "string" + }, + "target": { + "description": "Peer ID to send to, or '*' for broadcast", + "type": "string" + }, + "token": { + "description": "Session token from coord_register", + "type": "string" + } + }, + "required": [ + "token", + "target", + "message" + ], + "type": "object" + }, + "name": "coord_send" + }, + { + "description": "Receive the next message from this peer's inbox. Returns the message content, or empty if no messages pending.", + "inputSchema": { + "properties": { + "token": { + "description": "Session token from coord_register", + "type": "string" + } + }, + "required": [ + "token" + ], + "type": "object" + }, + "name": "coord_receive" + }, + { + "description": "Attempt to claim a task (mutex-style). If the task is unclaimed, this peer becomes the holder. If another peer holds it, the claim is denied. Idempotent if already held by caller. Task #15: optional confidence, dispatch_preference (deliberate/broadcast/auto), task_difficulty (trivial/routine/challenging/novel) β€” default policy broadcasts trivial+routine, deliberates on challenging+novel. Claim rejection triggers a per-client_kind rate-limit: 5 rejections / 10 min => 30s cooldown before the next attempt. Optional `paths` declares working-tree files this claim expects to touch; the bridge layer returns advisory `path_overlap` warnings when overlap is detected with other active claims. Bridge-only β€” backend ignores the field.", + "inputSchema": { + "properties": { + "confidence": { + "description": "Sender's self-assessed fit 0.0-1.0 (DD-28; feeds overclaim detector)", + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "dispatch_preference": { + "description": "Routing hint (DD-30). auto = server derives from task_difficulty", + "enum": [ + "deliberate", + "broadcast", + "auto" + ], + "type": "string" + }, + "paths": { + "description": "Optional advisory list of working-tree paths this claim expects to touch. Bridge-layer hint only β€” not enforced by the backend.", + "items": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "maxItems": 64, + "type": "array" + }, + "task": { + "description": "Task identifier to claim (e.g. 'audit-boj-server')", + "type": "string" + }, + "task_difficulty": { + "description": "Difficulty level (DD-30; drives default dispatch mode)", + "enum": [ + "trivial", + "routine", + "challenging", + "novel" + ], + "type": "string" + }, + "token": { + "description": "Session token from coord_register", + "type": "string" + } + }, + "required": [ + "token", + "task" + ], + "type": "object" + }, + "name": "coord_claim_task" + }, + { + "description": "Set or broadcast this peer's current work status. Other peers can see it via coord_list_peers.", + "inputSchema": { + "properties": { + "status": { + "description": "Current work status description", + "type": "string" + }, + "token": { + "description": "Session token from coord_register", + "type": "string" + } + }, + "required": [ + "token", + "status" + ], + "type": "object" + }, + "name": "coord_status" + }, + { + "description": "Promote this peer to the master role (DD-32, renamed from supervisor). Requires BOJ_MASTER_TOKEN env-var (BOJ_SUPERVISOR_TOKEN accepted as fallback for one release) and the presented secret to match. At most one master at a time. The old tool name `coord_promote_to_supervisor` is accepted as an alias.", + "inputSchema": { + "properties": { + "secret": { + "description": "Must match BOJ_MASTER_TOKEN env var on the server (BOJ_SUPERVISOR_TOKEN fallback)", + "type": "string" + }, + "token": { + "description": "Own session token from coord_register", + "type": "string" + } + }, + "required": [ + "token", + "secret" + ], + "type": "object" + }, + "name": "coord_promote_to_master" + }, + { + "description": "Send a message with a declared risk_tier. Tier 2+ from role=apprentice peers is quarantined for master review; everything else delivers directly. Returns status:quarantined + request_id when gated.", + "inputSchema": { + "properties": { + "message": { + "description": "Message content (typically an A2ML envelope per schemas/coord-messages.ncl)", + "type": "string" + }, + "risk_tier": { + "description": "Declared risk tier 0-4 per the risk ladder", + "maximum": 4, + "minimum": 0, + "type": "integer" + }, + "target": { + "description": "Peer ID to send to, or '*' for broadcast", + "type": "string" + }, + "token": { + "description": "Session token from coord_register", + "type": "string" + } + }, + "required": [ + "token", + "target", + "message", + "risk_tier" + ], + "type": "object" + }, + "name": "coord_send_gated" + }, + { + "description": "List all currently quarantined messages awaiting master decision. Master role only.", + "inputSchema": { + "properties": { + "token": { + "description": "Master session token", + "type": "string" + } + }, + "required": [ + "token" + ], + "type": "object" + }, + "name": "coord_review" + }, + { + "description": "Read the full message body of a specific quarantined entry. Master role only.", + "inputSchema": { + "properties": { + "request_id": { + "description": "Request ID from coord_review or coord_send_gated", + "type": "integer" + }, + "token": { + "description": "Master session token", + "type": "string" + } + }, + "required": [ + "token", + "request_id" + ], + "type": "object" + }, + "name": "coord_review_entry" + }, + { + "description": "Approve a quarantined message β€” delivers to target and removes from queue. Master role only.", + "inputSchema": { + "properties": { + "request_id": { + "description": "Request ID to approve", + "type": "integer" + }, + "token": { + "description": "Master session token", + "type": "string" + } + }, + "required": [ + "token", + "request_id" + ], + "type": "object" + }, + "name": "coord_approve" + }, + { + "description": "Reject a quarantined message with a reason β€” removes from queue without delivery. Master role only. Reason logged for audit.", + "inputSchema": { + "properties": { + "reason": { + "description": "Reason for rejection (surfaces in audit log)", + "type": "string" + }, + "request_id": { + "description": "Request ID to reject", + "type": "integer" + }, + "token": { + "description": "Master session token", + "type": "string" + } + }, + "required": [ + "token", + "request_id", + "reason" + ], + "type": "object" + }, + "name": "coord_reject" + }, + { + "description": "Report the outcome of a claim or attempted op against an affinity tag. Aggregated into the track record keyed on client_kind (survives peer restart). Feeds effective_affinity + reassignment suggestions. Task #14: optional `confidence` 0.0-1.0 feeds the overclaim detector.", + "inputSchema": { + "properties": { + "confidence": { + "description": "Self-assessed confidence at claim time (0.0-1.0). Feeds overclaim detection.", + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "duration_ms": { + "description": "Wall-time duration of the op in ms (optional, default 0)", + "minimum": 0, + "type": "integer" + }, + "outcome": { + "description": "'success' or 'fail' (or 1/0)", + "enum": [ + "success", + "fail" + ], + "type": "string" + }, + "risk_tier": { + "description": "Risk tier of the op (0-4)", + "maximum": 4, + "minimum": 0, + "type": "integer" + }, + "tag": { + "description": "Affinity tag the op belongs to (e.g. 'proof-analysis', 'routine-edit')", + "maxLength": 64, + "type": "string" + }, + "token": { + "description": "Session token from coord_register", + "type": "string" + } + }, + "required": [ + "token", + "tag", + "outcome", + "risk_tier" + ], + "type": "object" + }, + "name": "coord_report_outcome" + }, + { + "description": "Return per-(client_kind, tag) effective_affinity computed over the last 20 attempts OR last 7 days (whichever is larger). Used by Opus to drive attester selection (DD-27) and reassignment suggestions (DD-28).", + "inputSchema": { + "properties": { + "token": { + "description": "Session token from coord_register", + "type": "string" + } + }, + "required": [ + "token" + ], + "type": "object" + }, + "name": "coord_get_affinities" + }, + { + "description": "Set this peer's declared affinities (self-reported strengths). Replaces any existing list. Task #14 diffs against effective_affinity to detect overclaim/promote/remove outliers.", + "inputSchema": { + "properties": { + "tags": { + "description": "Array of tag names (e.g. ['proof-analysis', 'supervision']). Serialised as CSV internally (max 256 bytes total).", + "items": { + "type": "string" + }, + "type": "array" + }, + "token": { + "description": "Session token from coord_register", + "type": "string" + } + }, + "required": [ + "token", + "tags" + ], + "type": "object" + }, + "name": "coord_set_declared_affinities" + }, + { + "description": "Run the reassignment-suggestion scanner (Task #14). Scans track-record aggregates vs declared_affinities; enqueues candidate envelopes in the quarantine for master review. Rules: overclaim (high avg_confidence + low effective_affinity), promote (high effective_affinity on undeclared tag), remove (low effective_affinity with >=5 attempts). Master reviews via coord_review / approves via coord_approve / rejects via coord_reject β€” never auto-modifies.", + "inputSchema": { + "properties": { + "token": { + "description": "Session token from coord_register", + "type": "string" + } + }, + "required": [ + "token" + ], + "type": "object" + }, + "name": "coord_scan_suggestions" + }, + { + "description": "Live master handoff (Task #35). Outgoing master passes the role to a named successor without a process restart. Secret-gated by BOJ_MASTER_TOKEN (BOJ_SUPERVISOR_TOKEN fallback). Target must be journeyman or already master β€” apprentices rejected. Audit-logged so replay reconstructs the transfer.", + "inputSchema": { + "properties": { + "new_peer_id": { + "description": "Peer id of the successor (kind-4hex[@context])", + "type": "string" + }, + "secret": { + "description": "Must match BOJ_MASTER_TOKEN env var on the server", + "type": "string" + }, + "token": { + "description": "Current master's session token", + "type": "string" + } + }, + "required": [ + "token", + "new_peer_id", + "secret" + ], + "type": "object" + }, + "name": "coord_transfer_master" + }, + { + "description": "Set or update this peer's free-form model/variant label after registration (Task #33). Alphanumeric + `.`/`-`/`_` only, max 32 bytes. Empty clears.", + "inputSchema": { + "properties": { + "token": { + "description": "Session token from coord_register", + "type": "string" + }, + "variant": { + "description": "Variant label (e.g. 'opus-4.7', 'flash-2.5', 'leanstral')", + "type": "string" + } + }, + "required": [ + "token", + "variant" + ], + "type": "object" + }, + "name": "coord_set_variant" + }, + { + "description": "Set or update this peer's advertised capabilities (Task #34). Any omitted key is cleared.", + "inputSchema": { + "properties": { + "class": { + "description": "Capability classes (CSV internally, max 128 bytes).", + "items": { + "type": "string" + }, + "type": "array" + }, + "prover_strengths": { + "description": "Prover strengths (CSV internally, max 256 bytes).", + "items": { + "type": "string" + }, + "type": "array" + }, + "tier": { + "description": "Advertised capability tier 1..5 (0 = unset).", + "maximum": 5, + "minimum": 0, + "type": "integer" + }, + "token": { + "description": "Session token from coord_register", + "type": "string" + } + }, + "required": [ + "token" + ], + "type": "object" + }, + "name": "coord_set_capabilities" + }, + { + "description": "Read another peer's advertised capabilities (Task #34). Used by the routing layer for cold-start dispatch when track_record is thin.", + "inputSchema": { + "properties": { + "peer_id": { + "description": "Target peer_id (-<4hex>[@])", + "type": "string" + }, + "token": { + "description": "Own session token", + "type": "string" + } + }, + "required": [ + "token", + "peer_id" + ], + "type": "object" + }, + "name": "coord_get_peer_capabilities" + }, + { + "description": "Read-only snapshot of coord-mcp state: active peer count (with per-kind / per-role breakdown), pending quarantine depth, active claims, track-record fill, and recent reject counts with cooldown flags. Lets other tooling monitor coord health without walking every export individually.", + "inputSchema": { + "properties": { + "token": { + "description": "Session token from coord_register β€” any active peer may poll.", + "type": "string" + } + }, + "required": [ + "token" + ], + "type": "object" + }, + "name": "coord_health" + }, + { + "description": "Heartbeat for a held claim β€” resets the watchdog TTL (DD-20). Apprentice TTL is 30 s, journeyman 5 min, master no watchdog. Long-running work should ping this periodically so the server doesn't auto-release the claim.", + "inputSchema": { + "properties": { + "task": { + "description": "Task identifier previously claimed via coord_claim_task", + "type": "string" + }, + "token": { + "description": "Session token from coord_register", + "type": "string" + } + }, + "required": [ + "token", + "task" + ], + "type": "object" + }, + "name": "coord_progress" + }, + { + "description": "Explicit watchdog sweep β€” release any claim whose holder has missed its role-based TTL. The sweep also runs implicitly at the top of every coord_claim_task, so this tool is only needed by ops wanting an external tick.", + "inputSchema": { + "properties": { + "token": { + "description": "Session token from coord_register β€” any active peer may invoke.", + "type": "string" + } + }, + "required": [ + "token" + ], + "type": "object" + }, + "name": "coord_sweep_watchdog" + } + ], + "version": "0.9.0", + "ffi": { + "so_path": "ffi/zig-out/lib/liblocal_coord_mcp.so", + "abi_version": "ADR-0006", + "symbols": [ + "boj_cartridge_init", + "boj_cartridge_deinit", + "boj_cartridge_name", + "boj_cartridge_version", + "boj_cartridge_invoke" + ] + } +} diff --git a/cartridges/cross-cutting/agentic/local-coord-mcp/cartridge.ncl b/cartridges/cross-cutting/agentic/local-coord-mcp/cartridge.ncl new file mode 100644 index 0000000..cc6c043 --- /dev/null +++ b/cartridges/cross-cutting/agentic/local-coord-mcp/cartridge.ncl @@ -0,0 +1,333 @@ +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +# +# local-coord-mcp/cartridge.ncl +# +# Nickel source-of-truth manifest for the local coordination cartridge. +# Generate JSON for MCP bridge: nickel export --format json < cartridge.ncl > cartridge.json + +{ + "$schema" = "https://boj.dev/schemas/cartridge/v1.json", + spdx = "MPL-2.0", + copyright = "Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) ", + name = "local-coord-mcp", + version = "0.9.0", + description = "Localhost multi-instance coordination β€” peer discovery, message passing, and task claiming for parallel AI sessions on the same machine", + domain = "ai", + tier = "Ayo", + protocols = ["MCP", "Agentic"], + federation = "none", + bind = { + address = "127.0.0.1", + port = 7745, + loopback_only = true, + }, + auth = { + method = "session-token", + env_var | optional = null, + credential_source | optional = null, + description = "Per-instance session token issued on coord_register. All subsequent calls require this token.", + }, + api = { + base_url = "local://local-coord-mcp", + content_type = "application/json", + }, + tools = [ + { + name = "coord_register", + description = "Register this instance as a coordination peer. Returns a peer ID and a session token for all subsequent calls. Optional `context` (alphanumeric + hyphen/underscore, max 32 bytes) disambiguates multiple windows of the same client_kind β€” peer_id becomes -<4hex>@. Optional `variant` is a free-form model identifier (e.g. `opus-4.7`, `flash-2.5`, `leanstral`). Optional `capabilities` advertises class / tier / prover_strengths for cold-start routing (Task #34).", + inputSchema = { + type = "object", + properties = { + client_kind = { + type = "string", + description = "Client family: claude, gemini, copilot, openai, mistral, or custom (Task #33).", + enum = ["claude", "gemini", "copilot", "custom", "openai", "mistral"], + }, + context = { + type = "string", + description = "Optional per-window disambiguator (repo name, tty label). Alphanumeric/hyphen/underscore only, max 32 chars.", + }, + role = { + type = "string", + description = "Optional requested role (DD-32). master is ALWAYS rejected here β€” promote via coord_promote_to_master. Default derived from client_kind (claude->journeyman, others->apprentice). Old names (executor, supervised) accepted as aliases for one release.", + enum = ["journeyman", "apprentice", "executor", "supervised"], + }, + variant = { + type = "string", + description = "Free-form model/variant label (Task #33). Alphanumeric + `.`/`-`/`_` only, max 32 bytes. e.g. `opus-4.7`, `flash-2.5`, `leanstral`.", + }, + capabilities = { + type = "object", + description = "Capability advertisement used for cold-start routing (Task #34). All three keys are individually optional.", + properties = { + class = { + type = "array", + description = "Capability classes (e.g. `reasoning`, `coding`, `proof`, `grammar`). Joined as CSV internally, max 128 bytes total.", + items = { type = "string" }, + }, + tier = { + type = "integer", + description = "Advertised capability tier 1..5 (0 = unset).", + minimum = 0, + maximum = 5, + }, + prover_strengths = { + type = "array", + description = "Prover names this peer claims strength in (e.g. `coq`, `lean`, `tlaps`). CSV internally, max 256 bytes total.", + items = { type = "string" }, + }, + }, + }, + }, + required = ["client_kind"], + }, + }, + { + name = "coord_list_peers", + description = "List all active coordination peers on this machine. Returns peer IDs, client kinds, and states.", + inputSchema = { + type = "object", + properties = { + token = { + type = "string", + description = "Session token from coord_register", + }, + }, + required = ["token"], + }, + }, + { + name = "coord_send", + description = "Send a message to a specific peer (by peer ID) or broadcast to all peers (target '*'). Messages are queued in recipient inboxes.", + inputSchema = { + type = "object", + properties = { + token = { + type = "string", + description = "Session token from coord_register", + }, + target = { + type = "string", + description = "Peer ID to send to, or '*' for broadcast", + }, + message = { + type = "string", + description = "Message content", + }, + }, + required = ["token", "target", "message"], + }, + }, + { + name = "coord_receive", + description = "Receive the next message from this peer's inbox. Returns the message content, or empty if no messages pending.", + inputSchema = { + type = "object", + properties = { + token = { + type = "string", + description = "Session token from coord_register", + }, + }, + required = ["token"], + }, + }, + { + name = "coord_claim_task", + description = "Attempt to claim a task (mutex-style). If the task is unclaimed, this peer becomes the holder. If another peer holds it, the claim is denied. Idempotent if already held by caller.", + inputSchema = { + type = "object", + properties = { + token = { + type = "string", + description = "Session token from coord_register", + }, + task = { + type = "string", + description = "Task identifier to claim (e.g. 'audit-boj-server')", + }, + }, + required = ["token", "task"], + }, + }, + { + name = "coord_status", + description = "Set or broadcast this peer's current work status. Other peers can see it via coord_list_peers.", + inputSchema = { + type = "object", + properties = { + token = { + type = "string", + description = "Session token from coord_register", + }, + status = { + type = "string", + description = "Current work status description", + }, + }, + required = ["token", "status"], + }, + }, + # ── Supervision tools ───────────────────────────────────────── + { + name = "coord_promote_to_master", + description = "Promote this peer to the master role (DD-32, renamed from supervisor). Requires the BOJ_MASTER_TOKEN env-var to be set on the server and the presented secret to match. Old BOJ_SUPERVISOR_TOKEN is read as fallback for one release. At most one master at a time. Intended for the Opus-tier Claude that holds the veto. The old tool name `coord_promote_to_supervisor` is accepted as an alias.", + inputSchema = { + type = "object", + properties = { + token = { type = "string", description = "Own session token from coord_register" }, + secret = { type = "string", description = "Must match BOJ_SUPERVISOR_TOKEN env var on the server" }, + }, + required = ["token", "secret"], + }, + }, + { + name = "coord_send_gated", + description = "Send a message with a declared risk_tier. Tier 2+ from role=apprentice peers is quarantined for master review; everything else delivers directly. Returns status:quarantined + request_id when gated.", + inputSchema = { + type = "object", + properties = { + token = { type = "string", description = "Session token from coord_register" }, + target = { type = "string", description = "Peer ID to send to, or '*' for broadcast" }, + message = { type = "string", description = "Message content (typically an A2ML envelope per schemas/coord-messages.ncl)" }, + risk_tier = { type = "integer", minimum = 0, maximum = 4, description = "Declared risk tier 0-4 per the risk ladder" }, + }, + required = ["token", "target", "message", "risk_tier"], + }, + }, + { + name = "coord_review", + description = "List all currently quarantined messages awaiting master decision. Master role only.", + inputSchema = { + type = "object", + properties = { + token = { type = "string", description = "Supervisor session token" }, + }, + required = ["token"], + }, + }, + { + name = "coord_review_entry", + description = "Read the full message body of a specific quarantined entry. Supervisor role only.", + inputSchema = { + type = "object", + properties = { + token = { type = "string", description = "Supervisor session token" }, + request_id = { type = "integer", description = "Request ID from coord_review or coord_send_gated" }, + }, + required = ["token", "request_id"], + }, + }, + { + name = "coord_approve", + description = "Approve a quarantined message β€” delivers to target and removes from queue. Supervisor role only.", + inputSchema = { + type = "object", + properties = { + token = { type = "string", description = "Supervisor session token" }, + request_id = { type = "integer", description = "Request ID to approve" }, + }, + required = ["token", "request_id"], + }, + }, + { + name = "coord_reject", + description = "Reject a quarantined message with a reason β€” removes from queue without delivery. Supervisor role only. Reason logged for audit.", + inputSchema = { + type = "object", + properties = { + token = { type = "string", description = "Supervisor session token" }, + request_id = { type = "integer", description = "Request ID to reject" }, + reason = { type = "string", description = "Reason for rejection (surfaces in audit log)" }, + }, + required = ["token", "request_id", "reason"], + }, + }, + { + name = "coord_set_variant", + description = "Set or update this peer's free-form model/variant label after registration (Task #33). Alphanumeric + `.`/`-`/`_` only, max 32 bytes. Empty clears.", + inputSchema = { + type = "object", + properties = { + token = { type = "string", description = "Session token from coord_register" }, + variant = { type = "string", description = "Variant label (e.g. `opus-4.7`, `flash-2.5`, `leanstral`)" }, + }, + required = ["token", "variant"], + }, + }, + { + name = "coord_set_capabilities", + description = "Set or update this peer's advertised capabilities (Task #34). Any omitted key is cleared.", + inputSchema = { + type = "object", + properties = { + token = { type = "string", description = "Session token from coord_register" }, + class = { + type = "array", + description = "Capability classes (CSV internally, max 128 bytes).", + items = { type = "string" }, + }, + tier = { + type = "integer", + description = "Advertised capability tier 1..5 (0 = unset).", + minimum = 0, + maximum = 5, + }, + prover_strengths = { + type = "array", + description = "Prover strengths (CSV internally, max 256 bytes).", + items = { type = "string" }, + }, + }, + required = ["token"], + }, + }, + { + name = "coord_get_peer_capabilities", + description = "Read another peer's advertised capabilities (Task #34). Used by the routing layer for cold-start dispatch when track_record is thin.", + inputSchema = { + type = "object", + properties = { + token = { type = "string", description = "Own session token" }, + peer_id = { type = "string", description = "Target peer_id (-<4hex>[@])" }, + }, + required = ["token", "peer_id"], + }, + }, + { + name = "coord_progress", + description = "Heartbeat for a held claim β€” resets the watchdog TTL (DD-20). Apprentice TTL is 30 s, journeyman 5 min, master no watchdog. Long-running work should ping this periodically so the server doesn't auto-release the claim.", + inputSchema = { + type = "object", + properties = { + token = { type = "string", description = "Session token from coord_register" }, + task = { type = "string", description = "Task identifier previously claimed via coord_claim_task" }, + }, + required = ["token", "task"], + }, + }, + { + name = "coord_sweep_watchdog", + description = "Explicit watchdog sweep β€” release any claim whose holder has missed its role-based TTL. The sweep also runs implicitly at the top of every coord_claim_task, so this tool is only needed by ops wanting an external tick.", + inputSchema = { + type = "object", + properties = { + token = { type = "string", description = "Session token from coord_register β€” any active peer may invoke." }, + }, + required = ["token"], + }, + }, + { + name = "coord_health", + description = "Read-only snapshot of coord-mcp state: active peer count (with per-kind / per-role breakdown), pending quarantine depth, active claims, track-record fill, and recent reject counts with cooldown flags. Lets other tooling monitor coord health without walking every export individually.", + inputSchema = { + type = "object", + properties = { + token = { type = "string", description = "Session token from coord_register β€” any active peer may poll." }, + }, + required = ["token"], + }, + }, + ], +} diff --git a/cartridges/cross-cutting/agentic/local-coord-mcp/docs/envelope-design.adoc b/cartridges/cross-cutting/agentic/local-coord-mcp/docs/envelope-design.adoc new file mode 100644 index 0000000..1a84ceb --- /dev/null +++ b/cartridges/cross-cutting/agentic/local-coord-mcp/docs/envelope-design.adoc @@ -0,0 +1,306 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) + += local-coord-mcp message envelope β€” design rationale +:author: Jonathan D.A. Jewell +:date: 2026-04-20 +:toc: macro +:version: v1 + +Companion document to `schemas/coord-messages.ncl`. +Architectural memory: `project_coord_supervision_architecture.md`. + +toc::[] + +== Motivation + +The first version of local-coord-mcp treated messages as opaque strings. +That worked for a toy two-peer ping but doesn't survive a real multi-agent +workspace with mixed-trust peers (Claude + vibe + codex + gemini), where +one agent is prone to confabulation ("Gemini is often nuts") and the user +needs a firewall between runaway outputs and the system. + +The envelope defined here: + +* Makes every message typed, routable, and auditable. +* Carries enough metadata for the server to gate risky ops without + interrupting free ones. +* Uses Byzantine-style cross-verification to catch the specifically + dangerous failure modes of a low-trust peer. +* Keeps the master (Opus + user) as the single locus of attention + while the other agents work in parallel. + +== Design principles + +. *Proportional gating.* Tier 0/1 flow free, Tier 2 light auto-approve, + Tier 3 hard gate, Tier 4 schema-level reject. Coordination overhead + should never be the rate-determining step for routine work. +. *Awareness scales with blast radius.* Drone behaviour on small ops, + strategic awareness on big ones. Enforced by requiring + `context_fetch_id` on Tier 2+. +. *Never trust self-assessment alone.* Self-declared confidence is a + signal, never a decision. Server cross-checks against track record + and, for Tier 2+ from apprentice peers, requires an independent + attester. +. *Supervision without bottleneck.* Auto-approve where safe, batch + where feasible, preserve Opus attention for genuine judgement calls. +. *Single user locus.* All agent-to-user traffic filters through the + master terminal. Apprentice peers never reach the user directly. + +== Trust tiers + +NOTE: DD-32 (2026-04-20) renamed the three trust roles from +supervisor/executor/supervised to `master`/`journeyman`/`apprentice`. +Integer ordinals preserved so existing durable logs replay unchanged. +Old names accepted as aliases for one release. + +|=== +| Role | Assigned to | Default for client_kind + +| `master` (was: supervisor) | Opus | (explicit only) +| `journeyman` (was: executor) | Claude Sonnet / Haiku | `claude` +| `apprentice` (was: supervised) | vibe, codex, gemini | `gemini`, `copilot`, `custom`, `openai`, `mistral` +|=== + +`master` must be requested explicitly via `coord_promote_to_master` +(old alias `coord_promote_to_supervisor` still accepted) with the +`BOJ_MASTER_TOKEN` env secret. `BOJ_SUPERVISOR_TOKEN` is read as +fallback for one release. + +== Risk ladder + +|=== +| Tier | Examples | Gate + +| 0 | status, query, read, peer discovery, context_query | none +| 1 | single-file edit (non-critical), test, lint, fyi, release | logged +| 2 | multi-file edit, dep bump, new file, claim (local/repo) | auto-approve 60s +| 3 | commit, push, mirror, secret-adjacent, deletion, schema migration | master approval, no timeout +| 4 | force-push, branch delete, public-repo create, license change, always-private-repo touch | schema-level reject for `apprentice` +|=== + +== Byzantine safety mechanisms + +Five layers, each catching a specific failure mode: + +=== 1. Hash chain per sender β€” catches retroactive rewriting + +Each envelope's `prev_msg_hash` is the SHA-256 of the sender's previous +envelope. First-ever message uses 64 zeros. Server tracks chain head per +peer; any break is an instant reject. Cost: 32 bytes per message. + +=== 2. M-of-N attestation β€” catches unilateral bad Tier 2+ ops + +Before a Tier 2+ envelope from `role=apprentice` executes, server routes +`attest_req` in parallel to a second journeyman (Sonnet or Codex). That +journeyman independently reads the payload, HMAC-signs the envelope with +its session token, and returns `attest_resp`. Opus sees both the +supervise_req and the attestation when deciding. + +This is PBFT-lite for *f=1* byzantine tolerance. + +=== 3. Watchdog claims β€” catches claim-squatting + +Every `claim` op has a TTL. Claimant must emit `progress` or `release` +before expiry or server auto-releases the claim. + +* `apprentice`: 30s TTL +* `journeyman`: 5min TTL +* `master`: unlimited + +=== 4. Content sanity gate β€” catches confabulation + tier underclaiming + +Server runs heuristics over payload content: + +* File references validated against recent-FS-state cache β€” nonexistent + path = reject. +* Self-contradiction detector (simple heuristic within payload). +* Risk-tier escalator β€” content matching `sanity_auto_promote` patterns + forces the minimum tier regardless of declared value. + +=== 5. Tamper-evident audit log β€” forensic recovery + +Every envelope + every supervision decision logged to VeriSimDB with +hash-chain continuity. Even when a bad message slips through, post-hoc +audit reconstructs exactly what happened and who signed what. + +== Self-assessment β€” four layers + +.Ranked by strength of evidence +|=== +| Layer | Source | Catches + +| A. Static affinity | Peer declares on register | Coarse mismatch +| B. Per-claim confidence | Peer self-reports on `claim` | Laziness / overreach +| C. Track record | Server-computed from outcomes | Pattern miscalibration +| D. Drift detector | Server alerts on confidence vs track-record gap | Specific overclaim ("Gemini is nuts" case) +|=== + +Tie-break on concurrent claims = `max(sender_confidence * effective_affinity)`. +The second factor is server-attached and cannot be forged. + +== Adaptive awareness + +`context_fetch_id` is the load-bearing field here. Required for Tier 2+ +envelopes. Obtained via `context_query` + `context_reply`. Forces every +high-risk action to be preceded by a context read. + +Context summary depth scales with requester role: + +* `apprentice` β†’ summary only (never raw state β€” prevents hallucinated + connections). +* `journeyman` β†’ summary by default, raw on request. +* `master` β†’ full raw on request, always. + +== User-interaction routing + +Three op_kinds for agent-to-user communication: + +[cols="1,2,2",options="header"] +|=== +| op_kind | Behaviour | Allowed setters + +| `fyi` | Log-only. Never interrupts. | any role +| `clarify` | Batch through master terminal. `urgent_direct` breakout allowed for journeyman/master. | any role (apprentice can't set urgent_direct) +| `blocker` | Inline interrupt on master terminal. Stops the task. | any role +|=== + +The master (Opus) deduplicates near-identical questions from multiple +peers, synthesises with context, and relays to the user in natural +language. Rejected confabulated questions from apprentice peers never +reach the user. + +== op_kind quick reference + +[cols="1,1,3",options="header"] +|=== +| op_kind | Default tier | Purpose + +| status | 0 | Current work status, visible via list_peers +| query | 0 | Read-only Q&A between peers +| context_query | 0 | Request big-picture context (required for Tier 2+) +| context_reply | 0 | Response to context_query +| supervise_req | 0 | Apprentice peer requests gated-op approval +| supervise_resp | 0 | Master decides approve/reject +| attest_req | 0 | Server requests independent attestation (BFT) +| attest_resp | 0 | Journeyman attests/rejects +| fyi | 1 | Log-only observation +| progress | 1 | Task percent + note +| warn_drift | 1 | Rule / invariant / expected-state drift +| release | 1 | Release claim with outcome +| claim | 2 | Claim task, affinity-routed (3 when scope=estate) +| handoff | 2 | Transfer task to peer (3 when scope=estate) +| clarify | 2 | Question for user, batched +| blocker | 3 | Peer cannot proceed without user +| gated_op | 3 | Wrapper for arbitrary high-risk action +|=== + +== Prover tag convention (Task #37 β€” DD-37) + +Zero-code convention for routing proof-sized tasks to the right peer. +Task tags prefixed `proof:` ask the scheduler to prefer a peer +whose declared affinities cover that prover. Canonical prover names: + +[cols="1,3",options="header"] +|=== +| Tag | Target + +| `proof:lean4` | Lean 4 proofs (mathlib4, std4) +| `proof:agda` | Agda (cubical, stdlib, agda-mode) +| `proof:idris2` | Idris 2 (dependent types, totality proofs) +| `proof:rocq` | Rocq / Coq (formal verification, Coquelicot, mathcomp) +| `proof:tla` | TLA+ (concurrent/distributed specs) +| `proof:echidna` | ECHIDNA property-based fuzzing +| `proof:spark` | SPARK / Ada formal subset +|=== + +=== How peers advertise a prover + +Peers pass an array of prover tags to `coord_register`'s +`declared_affinities` field. Example (Node/Deno over MCP): + +[source,json] +---- +{ + "client_kind": "claude", + "context": "boj-server", + "declared_affinities": ["proof:rocq", "proof:idris2", "proof-analysis"] +} +---- + +=== How the scheduler picks a prover + +When a task is filed with `tag = "proof:agda"`: + +. The scheduler calls `coord_get_affinities` to read per-(kind, tag) + effective_affinity over the active window (last 20 attempts OR last + 7 days, whichever larger β€” DD-28). +. It filters to peers whose `declared_affinities` include `proof:agda`. +. It tie-breaks on `max(confidence Γ— effective_affinity)` per DD-9 so + honest-modest peers with solid track records beat overclaimers. +. Falls back to generic `proof-analysis` if no specialist peer is + available. + +=== Envelope example + +[source,json] +---- +{ + "version": 1, + "msg_id": "7f3a92b4c1d0", + "prev_msg_hash": "00...00", + "sender": "claude-7f3a@boj-server", + "recipient": "*", + "timestamp": "2026-04-20T10:15:00Z", + "op_kind": "claim", + "risk_tier": 2, + "payload": { "task": "proof:agda/category-pullback-universal" }, + "context_fetch_id": "ctx-b1c2", + "sender_confidence": 0.8, + "difficulty_hint": "high", + "task_difficulty": "novel" +} +---- + +The `claim` envelope's `payload.task` uses the `proof:/` +form so the scheduler can strip the prefix when dispatching and keep +the specifier for logs / track-record aggregation. + +=== Why no new code + +The affinity machinery (Task #13 effective_affinity, Task #14 +reassignment engine, `coord_set_declared_affinities`) already does all +the work β€” prover tags are just a naming convention on top. If a new +prover appears, coin a tag and peers start advertising; no cartridge +change needed. + +== What this does NOT cover + +* *Cross-machine transport* β€” this envelope is localhost-only. Umoja + federation (separate design) handles multi-host. +* *Model attestation* β€” peer identity is `(client_kind, session token, + suffix)`. No proof the peer is actually the model it claims. + Out-of-scope until we have hardware attestation. +* *Privacy between peers* β€” all messages are server-visible. Encrypted + peer-to-peer is a later addition (not needed for local use). + +== Implementation sequence + +. Schema + design doc (this commit). +. Supervision tier + role field in FFI (Task #5). +. Supervisor tools: coord_review / coord_approve / coord_reject (Task #6). +. VeriSimDB sidecar for durable state + track record (Task #7). +. E2E test: 2-instance with master gate + durability (Task #8). + +Only after those land do we wire the full envelope into the FFI's +coord_send path. Today's coord_send accepts raw strings; in the +transition period, strings without the envelope shape are treated as +implicit `{op_kind: "query", risk_tier: 0}` for backward compatibility. + +== References + +* Nickel source: `../schemas/coord-messages.ncl` +* JSON export: `../schemas/coord-messages.json` +* Supervision architecture memory: `memory/project_coord_supervision_architecture.md` +* Cartridge manifest: `../cartridge.ncl` +* ADR-0006 (five-symbol cartridge ABI): `boj-server/docs/adr/ADR-0006.adoc` diff --git a/cartridges/cross-cutting/agentic/local-coord-mcp/ffi/README.adoc b/cartridges/cross-cutting/agentic/local-coord-mcp/ffi/README.adoc new file mode 100644 index 0000000..24da863 --- /dev/null +++ b/cartridges/cross-cutting/agentic/local-coord-mcp/ffi/README.adoc @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) += local-coord-mcp / ffi β€” Zig FFI layer +:orientation: shallow + +== Purpose + +C-ABI exports bridging this cartridge's Idris2 ABI to the runtime. Consumed by +the adapter for protocol dispatch and by the trunk loader for mount. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” produces `zig-out/lib/liblocal-coord.so`. +| `local_coord_ffi.zig` | C-ABI exports (13 exports, 8 inline tests, 583 lines). +|=== + +== Invariants + +* **Null-pointer rejection** on `[*c]const u8` arguments before any + dereference. +* **Negative return values** carry documented error meanings; zero is + success (matching the trunk error-code convention). +* **No allocator leaks** β€” every allocation path pairs with `defer free`. + +== Test/proof surface + +8 inline `test "..."` block(s) in `local_coord_ffi.zig`. Run with +`cd ffi && zig build test`. + +== Read-first + +. `local_coord_ffi.zig` β€” C-ABI signatures and guards. +. Trunk `ffi/zig/src/catalogue.zig` for how this `.so` is loaded. diff --git a/cartridges/cross-cutting/agentic/local-coord-mcp/ffi/bench_coord.zig b/cartridges/cross-cutting/agentic/local-coord-mcp/ffi/bench_coord.zig new file mode 100644 index 0000000..b0d80a5 --- /dev/null +++ b/cartridges/cross-cutting/agentic/local-coord-mcp/ffi/bench_coord.zig @@ -0,0 +1,311 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +// +// bench_coord.zig β€” Micro-benchmarks for local-coord-mcp. +// +// Measures: +// 1. Durability log append (persist-on-write hot path cost) +// 2. Durability replay (startup cost per logged event) +// 3. coord_register + coord_deregister lifecycle +// 4. coord_send direct + coord_receive round-trip +// 5. coord_claim_task + coord_release_task +// 6. coord_send_gated to quarantine + coord_approve +// 7. persist-on-write overhead (durable-on vs durable-off) +// +// Benchmarks emit "NAME ns/op ops/sec" and are meant for relative +// comparison across revisions and for Six Sigma regression gating +// (feedback_blitz_definition + full_battery_before_claims). + +const std = @import("std"); +const dur = @import("coord_durability.zig"); + +// The ffi module exports C-ABI symbols directly; we import it to call +// the public coord_* functions in-process without round-tripping through +// the REST adapter. +const ffi = @import("local_coord_ffi.zig"); + +const WARMUP: u64 = 200; +const ITERS: u64 = 50_000; + +fn printHeader(title: []const u8) void { + std.debug.print("\n── {s} ──\n", .{title}); +} + +fn printRow(name: []const u8, elapsed_ns: u64, iters: u64) void { + const per_op = if (iters == 0) 0 else elapsed_ns / iters; + const ops_per_sec = if (per_op == 0) 0 else @as(u64, 1_000_000_000) / per_op; + std.debug.print(" {s:<48} {d:>10} ns/op {d:>14} ops/sec\n", .{ name, per_op, ops_per_sec }); +} + +fn tmpBenchDir(buf: []u8) ![]u8 { + return std.fmt.bufPrint(buf, "/tmp/boj-coord-bench-{d}-{d}", .{ + std.time.milliTimestamp(), + std.crypto.random.int(u32), + }); +} + +// ───────────────────────────────────────────────────────────────────── + +fn benchAppendNoop() !void { + // Durability closed β€” append is a single mutex + null check. + dur.close(); + const suffix = [_]u8{ 'a', 'b', 'c', 'd' }; + const token = [_]u8{0} ** 16; + + for (0..WARMUP) |_| { + dur.logPeerAdd(0, 0, 1, &suffix, &token); + } + + var timer = try std.time.Timer.start(); + for (0..ITERS) |_| { + dur.logPeerAdd(0, 0, 1, &suffix, &token); + } + printRow("append (durability DISABLED)", timer.read(), ITERS); +} + +fn benchAppendHot() !void { + var buf: [256]u8 = undefined; + const d = try tmpBenchDir(&buf); + defer std.fs.cwd().deleteTree(d) catch {}; + + dur.close(); + _ = dur.openWithDir(d); + defer dur.close(); + + const suffix = [_]u8{ 'a', 'b', 'c', 'd' }; + const token = [_]u8{0} ** 16; + + for (0..WARMUP) |_| { + dur.logPeerAdd(0, 0, 1, &suffix, &token); + } + + var timer = try std.time.Timer.start(); + for (0..ITERS) |_| { + dur.logPeerAdd(0, 0, 1, &suffix, &token); + } + printRow("append peer_add (23B payload)", timer.read(), ITERS); +} + +fn benchAppendLargePayload() !void { + var buf: [256]u8 = undefined; + const d = try tmpBenchDir(&buf); + defer std.fs.cwd().deleteTree(d) catch {}; + + dur.close(); + _ = dur.openWithDir(d); + defer dur.close(); + + const msg_512 = [_]u8{'x'} ** 512; + + for (0..WARMUP) |_| { + dur.logInboxPush(0, &msg_512); + } + + var timer = try std.time.Timer.start(); + for (0..ITERS) |_| { + dur.logInboxPush(0, &msg_512); + } + printRow("append inbox_push (512B payload)", timer.read(), ITERS); +} + +fn benchReplay() !void { + var buf: [256]u8 = undefined; + const d = try tmpBenchDir(&buf); + defer std.fs.cwd().deleteTree(d) catch {}; + + dur.close(); + _ = dur.openWithDir(d); + + const suffix = [_]u8{ 'a', 'b', 'c', 'd' }; + const token = [_]u8{0} ** 16; + const REPLAY_EVENTS: usize = 10_000; + for (0..REPLAY_EVENTS) |_| { + dur.logPeerAdd(0, 0, 1, &suffix, &token); + } + dur.close(); + + _ = dur.openWithDir(d); + defer dur.close(); + + var replay_counter: usize = 0; + const counter_ptr = &replay_counter; + _ = counter_ptr; // kept to document intent + + const Capture = struct { + var count: usize = 0; + fn cb(event: dur.EventType, payload: []const u8) void { + _ = event; + _ = payload; + count += 1; + } + }; + Capture.count = 0; + + var timer = try std.time.Timer.start(); + dur.replay(Capture.cb); + const elapsed = timer.read(); + + if (Capture.count == 0) return error.ReplayEmpty; + printRow("replay peer_add", elapsed, @intCast(Capture.count)); +} + +// ───────────────────────────────────────────────────────────────────── + +fn benchRegisterLifecycle() !void { + ffi.coord_reset(); + dur.close(); + + var token: [16]u8 = undefined; + var suffix: [4]u8 = undefined; + + for (0..WARMUP) |_| { + const idx = ffi.coord_register(0, -1, &token, &suffix); + if (idx >= 0) _ = ffi.coord_deregister(&token, 16); + } + + var timer = try std.time.Timer.start(); + const N: u64 = 10_000; + for (0..N) |_| { + const idx = ffi.coord_register(0, -1, &token, &suffix); + if (idx >= 0) _ = ffi.coord_deregister(&token, 16); + } + printRow("register + deregister (no durability)", timer.read(), N); + + // Now with durability on. + var buf: [256]u8 = undefined; + const d = try tmpBenchDir(&buf); + defer std.fs.cwd().deleteTree(d) catch {}; + ffi.coord_reset(); + _ = dur.openWithDir(d); + defer dur.close(); + + for (0..WARMUP) |_| { + const idx = ffi.coord_register(0, -1, &token, &suffix); + if (idx >= 0) _ = ffi.coord_deregister(&token, 16); + } + + timer = try std.time.Timer.start(); + for (0..N) |_| { + const idx = ffi.coord_register(0, -1, &token, &suffix); + if (idx >= 0) _ = ffi.coord_deregister(&token, 16); + } + printRow("register + deregister (durable)", timer.read(), N); +} + +fn benchSendReceive() !void { + ffi.coord_reset(); + dur.close(); + + var tok_a: [16]u8 = undefined; + var tok_b: [16]u8 = undefined; + var suf: [4]u8 = undefined; + _ = ffi.coord_register(0, -1, &tok_a, &suf); + _ = ffi.coord_register(0, -1, &tok_b, &suf); + + const msg = "benchmark-direct-message"; + var recv: [64]u8 = undefined; + + for (0..WARMUP) |_| { + _ = ffi.coord_send(&tok_a, 16, 1, msg.ptr, @intCast(msg.len)); + _ = ffi.coord_receive(&tok_b, 16, &recv, 64); + } + + var timer = try std.time.Timer.start(); + const N: u64 = 20_000; + for (0..N) |_| { + _ = ffi.coord_send(&tok_a, 16, 1, msg.ptr, @intCast(msg.len)); + _ = ffi.coord_receive(&tok_b, 16, &recv, 64); + } + printRow("send + receive round-trip (no durability)", timer.read(), N); + + // With durability. + var buf: [256]u8 = undefined; + const d = try tmpBenchDir(&buf); + defer std.fs.cwd().deleteTree(d) catch {}; + ffi.coord_reset(); + _ = dur.openWithDir(d); + defer dur.close(); + _ = ffi.coord_register(0, -1, &tok_a, &suf); + _ = ffi.coord_register(0, -1, &tok_b, &suf); + + for (0..WARMUP) |_| { + _ = ffi.coord_send(&tok_a, 16, 1, msg.ptr, @intCast(msg.len)); + _ = ffi.coord_receive(&tok_b, 16, &recv, 64); + } + timer = try std.time.Timer.start(); + for (0..N) |_| { + _ = ffi.coord_send(&tok_a, 16, 1, msg.ptr, @intCast(msg.len)); + _ = ffi.coord_receive(&tok_b, 16, &recv, 64); + } + printRow("send + receive round-trip (durable)", timer.read(), N); +} + +fn benchClaimCycle() !void { + ffi.coord_reset(); + dur.close(); + + var tok: [16]u8 = undefined; + var suf: [4]u8 = undefined; + _ = ffi.coord_register(0, -1, &tok, &suf); + + const task = "bench-task"; + for (0..WARMUP) |_| { + _ = ffi.coord_claim_task(&tok, 16, task.ptr, @intCast(task.len)); + _ = ffi.coord_release_task(&tok, 16, task.ptr, @intCast(task.len)); + } + var timer = try std.time.Timer.start(); + const N: u64 = 20_000; + for (0..N) |_| { + _ = ffi.coord_claim_task(&tok, 16, task.ptr, @intCast(task.len)); + _ = ffi.coord_release_task(&tok, 16, task.ptr, @intCast(task.len)); + } + printRow("claim + release (no durability)", timer.read(), N); + + var buf: [256]u8 = undefined; + const d = try tmpBenchDir(&buf); + defer std.fs.cwd().deleteTree(d) catch {}; + ffi.coord_reset(); + _ = dur.openWithDir(d); + defer dur.close(); + _ = ffi.coord_register(0, -1, &tok, &suf); + + for (0..WARMUP) |_| { + _ = ffi.coord_claim_task(&tok, 16, task.ptr, @intCast(task.len)); + _ = ffi.coord_release_task(&tok, 16, task.ptr, @intCast(task.len)); + } + timer = try std.time.Timer.start(); + for (0..N) |_| { + _ = ffi.coord_claim_task(&tok, 16, task.ptr, @intCast(task.len)); + _ = ffi.coord_release_task(&tok, 16, task.ptr, @intCast(task.len)); + } + printRow("claim + release (durable)", timer.read(), N); +} + +// ───────────────────────────────────────────────────────────────────── + +pub fn main() !void { + std.debug.print("\n═══════════════════════════════════════════════════════════════════════\n", .{}); + std.debug.print(" local-coord-mcp micro-benchmarks\n", .{}); + std.debug.print(" warmup={d} iters (measurement varies per bench)\n", .{WARMUP}); + std.debug.print("═══════════════════════════════════════════════════════════════════════\n", .{}); + + printHeader("Durability β€” raw log ops"); + try benchAppendNoop(); + try benchAppendHot(); + try benchAppendLargePayload(); + try benchReplay(); + + printHeader("Coord lifecycle"); + try benchRegisterLifecycle(); + + printHeader("Messaging"); + try benchSendReceive(); + + printHeader("Claims"); + try benchClaimCycle(); + + ffi.coord_reset(); + dur.close(); + + std.debug.print("\n", .{}); +} diff --git a/cartridges/cross-cutting/agentic/local-coord-mcp/ffi/build.zig b/cartridges/cross-cutting/agentic/local-coord-mcp/ffi/build.zig new file mode 100644 index 0000000..f382f88 --- /dev/null +++ b/cartridges/cross-cutting/agentic/local-coord-mcp/ffi/build.zig @@ -0,0 +1,76 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +// +// Local-Coord MCP Cartridge β€” Zig FFI build configuration (Zig 0.15+). + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + // Shared ADR-0006 invoke-shim module (relative path up to boj-server trunk). + + const shim_mod = b.addModule("cartridge_shim", .{ + + .root_source_file = b.path("../../../ffi/zig/src/cartridge_shim.zig"), + + .target = target, + + .optimize = optimize, + + }); + + const coord_mod = b.addModule("local_coord_ffi", .{ + .root_source_file = b.path("local_coord_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + coord_mod.addImport("cartridge_shim", shim_mod); + + // ── Tests ──────────────────────────────────────────────────────── + const coord_tests = b.addTest(.{ + .root_module = coord_mod, + }); + + const run_tests = b.addRunArtifact(coord_tests); + + const test_step = b.step("test", "Run local-coord-mcp FFI tests"); + test_step.dependOn(&run_tests.step); + + // ── Shared library ────────────────────────────────────────────── + const lib_mod = b.createModule(.{ + .root_source_file = b.path("local_coord_ffi.zig"), + .target = target, + .optimize = optimize, + }); + lib_mod.addImport("cartridge_shim", shim_mod); + + const lib = b.addLibrary(.{ + .name = "local_coord_mcp", + .root_module = lib_mod, + .linkage = .dynamic, + }); + b.installArtifact(lib); + + const lib_step = b.step("lib", "Build shared library"); + lib_step.dependOn(&lib.step); + + // ── Benchmarks ────────────────────────────────────────────────── + const bench_mod = b.createModule(.{ + .root_source_file = b.path("bench_coord.zig"), + .target = target, + .optimize = .ReleaseFast, + }); + bench_mod.addImport("cartridge_shim", shim_mod); + + const bench_exe = b.addExecutable(.{ + .name = "bench_coord", + .root_module = bench_mod, + }); + + const run_bench = b.addRunArtifact(bench_exe); + const bench_step = b.step("bench", "Run local-coord-mcp benchmarks"); + bench_step.dependOn(&run_bench.step); +} diff --git a/cartridges/cross-cutting/agentic/local-coord-mcp/ffi/cartridge_shim.zig b/cartridges/cross-cutting/agentic/local-coord-mcp/ffi/cartridge_shim.zig new file mode 100644 index 0000000..5b0fac8 --- /dev/null +++ b/cartridges/cross-cutting/agentic/local-coord-mcp/ffi/cartridge_shim.zig @@ -0,0 +1,148 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +// +// cartridge_shim.zig β€” Shared helpers for the ADR-0006 five-symbol +// cartridge ABI (`boj_cartridge_init / deinit / name / version / invoke`). +// +// The shim centralises the seven-code return convention, NUL-argument +// guards, tool-name comparison, and the buffer-too-small path so each +// cartridge's `boj_cartridge_invoke` can stay short β€” typically a tool +// table plus `shim.writeResult(...)`. +// +// Cartridges import this file by relative path (no build-graph change +// needed). Example: +// +// const shim = @import("../../../ffi/zig/src/cartridge_shim.zig"); +// +// export fn boj_cartridge_invoke( +// tool_name: [*c]const u8, +// json_args: [*c]const u8, +// out_buf: [*c]u8, +// in_out_len: [*c]usize, +// ) callconv(.c) i32 { +// _ = json_args; +// if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; +// const body = if (shim.toolIs(tool_name, "foo")) "{\"result\":{}}" +// else return shim.RC_UNKNOWN_TOOL; +// return shim.writeResult(out_buf, in_out_len, body); +// } + +const std = @import("std"); + +// ── Return codes (ADR-0006 Β§Return codes) ──────────────────────────── +// +// Frozen by ADR-0006. New failure modes compose these via the error +// JSON body β€” the integer surface does not grow without a follow-up ADR. + +pub const RC_SUCCESS: i32 = 0; +pub const RC_UNKNOWN_TOOL: i32 = -1; +pub const RC_BAD_ARGS: i32 = -2; +pub const RC_BUFFER_TOO_SMALL: i32 = -3; +pub const RC_RUNTIME_ERROR: i32 = -4; +pub const RC_PANIC: i32 = -5; +pub const RC_AUTH_DENIED: i32 = -6; + +// ── Invoke-path helpers ────────────────────────────────────────────── + +/// True if any of the three mandatory `boj_cartridge_invoke` output-path +/// pointers is null. Use at the top of every invoke to short-circuit to +/// `RC_BAD_ARGS`. +pub fn invokeArgsNull( + tool_name: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) bool { + return tool_name == null or out_buf == null or in_out_len == null; +} + +/// Compare a C-NUL-terminated tool-name pointer against a Zig string +/// literal. Caller must have already verified `tool_name` is non-null +/// (usually via `invokeArgsNull`). +/// +/// Implementation note (CWE-704 fix, post-#146): uses +/// `std.mem.sliceTo(ptr, 0)` which scans the C string up to the first +/// NUL β€” no `@ptrCast` and no `[*:0]` re-typing. The earlier +/// `std.mem.spanZ` call was removed in Zig 0.14+ and would not +/// compile under the 0.15.1 CI pin. +pub fn toolIs(tool_name: [*c]const u8, expected: []const u8) bool { + const s = std.mem.sliceTo(tool_name, 0); + return std.mem.eql(u8, s, expected); +} + +/// Copy `body` into `out_buf[0..*in_out_len]` (as a capacity) and update +/// `*in_out_len` to the number of bytes written. Returns `RC_SUCCESS`. +/// +/// If `body.len` exceeds the current capacity stored in `*in_out_len`, +/// sets `*in_out_len` to the required size and returns +/// `RC_BUFFER_TOO_SMALL` β€” the caller is then expected to re-allocate +/// and retry, per ADR-0006 Β§Memory ownership. +/// +/// Caller must have already verified that `out_buf` and `in_out_len` +/// are non-null. +pub fn writeResult( + out_buf: [*c]u8, + in_out_len: [*c]usize, + body: []const u8, +) i32 { + const cap = in_out_len.*; + if (body.len > cap) { + in_out_len.* = body.len; + return RC_BUFFER_TOO_SMALL; + } + @memcpy(out_buf[0..body.len], body); + in_out_len.* = body.len; + return RC_SUCCESS; +} + +// ── Tests ──────────────────────────────────────────────────────────── + +test "writeResult: body fits, writes and sets length" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); + try std.testing.expectEqualStrings("hello", buf[0..len]); +} + +test "writeResult: too small returns -3 and sets required length" { + var buf: [2]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_BUFFER_TOO_SMALL, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: exact-fit succeeds" { + var buf: [5]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: empty body" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, ""); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 0), len); +} + +test "toolIs: matches and rejects" { + const name: [*c]const u8 = "foo"; + try std.testing.expect(toolIs(name, "foo")); + try std.testing.expect(!toolIs(name, "bar")); + try std.testing.expect(!toolIs(name, "foobar")); + try std.testing.expect(!toolIs(name, "fo")); +} + +test "invokeArgsNull: detects each null slot" { + var buf: [4]u8 = undefined; + var len: usize = 4; + const name: [*c]const u8 = "x"; + try std.testing.expect(!invokeArgsNull(name, &buf, &len)); + try std.testing.expect(invokeArgsNull(null, &buf, &len)); + try std.testing.expect(invokeArgsNull(name, null, &len)); + try std.testing.expect(invokeArgsNull(name, &buf, null)); +} diff --git a/cartridges/cross-cutting/agentic/local-coord-mcp/ffi/coord_durability.zig b/cartridges/cross-cutting/agentic/local-coord-mcp/ffi/coord_durability.zig new file mode 100644 index 0000000..48c33b4 --- /dev/null +++ b/cartridges/cross-cutting/agentic/local-coord-mcp/ffi/coord_durability.zig @@ -0,0 +1,792 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +// +// Per-box durability layer for local-coord-mcp β€” append-only event log with +// replay-on-init so coord state (peers, claims, inbox, quarantine, audit, +// track record) survives adapter restart. +// +// Future-swap note: temporary in-tree backend. A follow-up task replaces +// open/append/replay with calls into verisimdb-mcp's FFI once that FFI is +// real; the typed log helpers exposed here (logPeerAdd, logInboxPush, ...) +// are the stable seam local_coord_ffi.zig depends on, so the swap is +// contained. +// +// Enabled when BOJ_COORD_STATE_DIR is set to a writable directory. When +// the env var is unset, every entry point is a silent no-op β€” existing +// in-memory-only behaviour (and existing tests) is preserved. + +const std = @import("std"); + +// ═══════════════════════════════════════════════════════════════════════ +// Record format (little-endian throughout) +// ═══════════════════════════════════════════════════════════════════════ +// +// magic u32 = MAGIC +// event_type u16 EventType +// format_ver u16 = FORMAT_VERSION +// payload_len u32 (<= MAX_PAYLOAD) +// timestamp u64 ms since unix epoch +// payload [payload_len]u8 +// crc32 u32 CRC32 over (header || payload) +// +// Total record = HEADER_SIZE + payload_len + TRAILER_SIZE. + +pub const MAGIC: u32 = 0x07B0C00D; +pub const FORMAT_VERSION: u16 = 1; +pub const MAX_PAYLOAD: usize = 1024; +pub const HEADER_SIZE: usize = 4 + 2 + 2 + 4 + 8; +pub const TRAILER_SIZE: usize = 4; + +pub const EventType = enum(u16) { + peer_add = 1, + peer_remove = 2, + peer_role_set = 3, + peer_context_set = 4, + peer_status_set = 5, + inbox_push = 6, + inbox_pop = 7, + claim_add = 8, + claim_rel = 9, + quar_add = 10, + quar_approve = 11, + quar_reject = 12, + audit = 13, + track_update = 14, + // Task #33 β€” variant label (free-form model identifier). + peer_variant_set = 15, + // Task #34 β€” capability advertisement (class / tier / prover_strengths). + peer_capabilities_set = 16, + // DD-20 β€” watchdog heartbeat for a claim. + claim_progress = 17, + _, +}; + +const ENV_VAR = "BOJ_COORD_STATE_DIR"; +const LOG_FILE_NAME = "coord.log"; + +// ── VeriSimDB backend (Task #7b) ────────────────────────────────────── +// +// When BOJ_VERISIMDB_ENDPOINT is set, events are also forwarded to VeriSimDB +// alongside the local file backend. The local file backend remains the +// primary durability store; VeriSimDB adds cross-restart queryability and +// multi-modality provenance. +// +// Current status: infrastructure wired, VeriSimDB API call stubs pending. +// Completion requires verisimdb-mcp FFI to expose an append-log API beyond +// the current octad-level interface (verisimdb_store_octad / verisimdb_get_octad +// are too coarse for per-event streaming). This wiring establishes the seam. + +const VDB_ENV_VAR = "BOJ_VERISIMDB_ENDPOINT"; +const VDB_ENDPOINT_MAX = 128; + +var vdb_endpoint: [VDB_ENDPOINT_MAX]u8 = undefined; +var vdb_endpoint_len: usize = 0; + +/// True when the VeriSimDB endpoint is configured. +pub fn vdbEnabled() bool { + return vdb_endpoint_len > 0; +} + +/// Forward one event to VeriSimDB. Stub: real impl will call verisimdb-mcp +/// FFI once that exposes a streaming append-log endpoint. Errors are swallowed +/// β€” VeriSimDB is supplementary; the local file log is authoritative. +fn vdb_append_event(event: EventType, payload: []const u8) void { + if (!vdbEnabled()) return; + // TODO(Task #7b): call verisimdb_store_octad or a future + // verisimdb_append_event once the FFI exposes a log-entry API. + // Key: std.fmt.bufPrint("coord-event-{d}-{d}", .{@intFromEnum(event), timestamp}) + // Data: binary payload or A2ML-encoded event record. + _ = event; + _ = payload; +} + +/// Query VeriSimDB for all coord events and replay them via cb. +/// Stub: real impl will use verisimdb query-by-tag once the FFI is richer. +fn vdb_replay_events(cb: ReplayCb) void { + if (!vdbEnabled()) return; + // TODO(Task #7b): query verisimdb for events with prefix "coord-event-" + // ordered by timestamp and call cb for each valid record. + _ = cb; +} + +var log_file: ?std.fs.File = null; +var mutex: std.Thread.Mutex = .{}; + +// ═══════════════════════════════════════════════════════════════════════ +// Open / close / truncate / status +// ═══════════════════════════════════════════════════════════════════════ + +/// True when the log is open for appends. +pub fn isEnabled() bool { + mutex.lock(); + defer mutex.unlock(); + return log_file != null; +} + +/// Open the log at an explicit directory (test-usable entry point). +/// Creates the directory if missing. Returns true on success. +pub fn openWithDir(dir: []const u8) bool { + mutex.lock(); + defer mutex.unlock(); + if (log_file != null) return true; + if (dir.len == 0 or dir.len >= 256) return false; + + std.fs.cwd().makePath(dir) catch return false; + + var path_buf: [512]u8 = undefined; + const log_path = std.fmt.bufPrint(&path_buf, "{s}/{s}", .{ dir, LOG_FILE_NAME }) catch return false; + + const f = std.fs.cwd().createFile(log_path, .{ .truncate = false, .read = true }) catch return false; + f.seekFromEnd(0) catch { + f.close(); + return false; + }; + log_file = f; + return true; +} + +/// Open the log using BOJ_COORD_STATE_DIR. No-op if unset / empty. +/// Also arms the VeriSimDB supplementary backend if BOJ_VERISIMDB_ENDPOINT +/// is set (Task #7b). VeriSimDB does not gate file-backend success. +pub fn open() bool { + // Arm VeriSimDB supplementary backend. + if (std.posix.getenv(VDB_ENV_VAR)) |ep| { + if (ep.len > 0 and ep.len <= VDB_ENDPOINT_MAX) { + @memcpy(vdb_endpoint[0..ep.len], ep); + vdb_endpoint_len = ep.len; + } + } + const env = std.posix.getenv(ENV_VAR) orelse return false; + if (env.len == 0) return false; + return openWithDir(env); +} + +/// Close the log. Idempotent. +pub fn close() void { + mutex.lock(); + defer mutex.unlock(); + if (log_file) |f| { + f.close(); + log_file = null; + } +} + +/// Truncate the log to zero bytes (test-only utility). +pub fn truncate() void { + mutex.lock(); + defer mutex.unlock(); + const f = log_file orelse return; + f.setEndPos(0) catch {}; + f.seekTo(0) catch {}; +} + +// ═══════════════════════════════════════════════════════════════════════ +// Low-level append / replay +// ═══════════════════════════════════════════════════════════════════════ + +/// Append a typed event. Silently no-ops when the log is closed; errors +/// during write are swallowed β€” durability is best-effort and must not +/// block the coord hot path. Also forwards to VeriSimDB if configured. +pub fn append(event: EventType, payload: []const u8) void { + if (payload.len > MAX_PAYLOAD) return; + + // Forward to VeriSimDB supplementary backend (Task #7b). + // Runs before the mutex so VeriSimDB can have its own concurrency model. + vdb_append_event(event, payload); + + mutex.lock(); + defer mutex.unlock(); + const f = log_file orelse return; + + var hdr: [HEADER_SIZE]u8 = undefined; + std.mem.writeInt(u32, hdr[0..4], MAGIC, .little); + std.mem.writeInt(u16, hdr[4..6], @intFromEnum(event), .little); + std.mem.writeInt(u16, hdr[6..8], FORMAT_VERSION, .little); + std.mem.writeInt(u32, hdr[8..12], @intCast(payload.len), .little); + std.mem.writeInt(u64, hdr[12..20], @intCast(std.time.milliTimestamp()), .little); + + var crc = std.hash.Crc32.init(); + crc.update(&hdr); + crc.update(payload); + var trailer: [TRAILER_SIZE]u8 = undefined; + std.mem.writeInt(u32, &trailer, crc.final(), .little); + + f.writeAll(&hdr) catch return; + if (payload.len > 0) f.writeAll(payload) catch return; + f.writeAll(&trailer) catch return; +} + +/// Callback shape for replay(). The callback is invoked once per valid +/// record in order. Payload slice is valid only for the duration of the +/// call (copy if needed). +pub const ReplayCb = *const fn (event: EventType, payload: []const u8) void; + +/// Iterate the log from the start, invoking cb for every valid record. +/// Stops at the first corrupt / truncated record β€” treated as a partial +/// tail from a previous crash. After replay, seeks back to end for +/// subsequent appends. +pub fn replay(cb: ReplayCb) void { + mutex.lock(); + defer mutex.unlock(); + const f = log_file orelse return; + + f.seekTo(0) catch return; + + var hdr: [HEADER_SIZE]u8 = undefined; + var payload_buf: [MAX_PAYLOAD]u8 = undefined; + var trailer: [TRAILER_SIZE]u8 = undefined; + + while (true) { + const n = f.readAll(&hdr) catch break; + if (n < HEADER_SIZE) break; + + const magic = std.mem.readInt(u32, hdr[0..4], .little); + if (magic != MAGIC) break; + const event_raw = std.mem.readInt(u16, hdr[4..6], .little); + const format_ver = std.mem.readInt(u16, hdr[6..8], .little); + if (format_ver != FORMAT_VERSION) break; + const payload_len: usize = @intCast(std.mem.readInt(u32, hdr[8..12], .little)); + if (payload_len > MAX_PAYLOAD) break; + + if (payload_len > 0) { + const pn = f.readAll(payload_buf[0..payload_len]) catch break; + if (pn < payload_len) break; + } + + const tn = f.readAll(&trailer) catch break; + if (tn < TRAILER_SIZE) break; + + var crc = std.hash.Crc32.init(); + crc.update(&hdr); + crc.update(payload_buf[0..payload_len]); + const expected = std.mem.readInt(u32, &trailer, .little); + if (crc.final() != expected) break; + + const event: EventType = @enumFromInt(event_raw); + cb(event, payload_buf[0..payload_len]); + } + + f.seekFromEnd(0) catch {}; +} + +// ═══════════════════════════════════════════════════════════════════════ +// Typed log helpers β€” the stable seam local_coord_ffi.zig depends on. +// Each helper packs its payload in a fixed layout. Replay decoders mirror +// these shapes. +// ═══════════════════════════════════════════════════════════════════════ + +/// PEER_ADD β€” slot_idx:u8 kind:u8 role:u8 suffix[4]u8 token[16]u8 (23B) +pub fn logPeerAdd(slot_idx: u8, kind: u8, role: u8, suffix: *const [4]u8, token: *const [16]u8) void { + var buf: [23]u8 = undefined; + buf[0] = slot_idx; + buf[1] = kind; + buf[2] = role; + @memcpy(buf[3..7], suffix); + @memcpy(buf[7..23], token); + append(.peer_add, &buf); +} + +/// PEER_REMOVE β€” slot_idx:u8 (1B) +pub fn logPeerRemove(slot_idx: u8) void { + append(.peer_remove, &[_]u8{slot_idx}); +} + +/// PEER_ROLE_SET β€” slot_idx:u8 role:u8 (2B) +pub fn logPeerRoleSet(slot_idx: u8, role: u8) void { + append(.peer_role_set, &[_]u8{ slot_idx, role }); +} + +/// PEER_CONTEXT_SET β€” slot_idx:u8 ctx_len:u8 ctx[ctx_len] +pub fn logPeerContextSet(slot_idx: u8, ctx: []const u8) void { + if (ctx.len > 32) return; + var buf: [34]u8 = undefined; + buf[0] = slot_idx; + buf[1] = @intCast(ctx.len); + if (ctx.len > 0) @memcpy(buf[2 .. 2 + ctx.len], ctx); + append(.peer_context_set, buf[0 .. 2 + ctx.len]); +} + +/// PEER_VARIANT_SET β€” slot_idx:u8 variant_len:u8 variant[variant_len] (Task #33) +pub fn logPeerVariantSet(slot_idx: u8, variant: []const u8) void { + if (variant.len > 32) return; + var buf: [34]u8 = undefined; + buf[0] = slot_idx; + buf[1] = @intCast(variant.len); + if (variant.len > 0) @memcpy(buf[2 .. 2 + variant.len], variant); + append(.peer_variant_set, buf[0 .. 2 + variant.len]); +} + +/// PEER_CAPABILITIES_SET β€” Task #34. +/// Payload: slot_idx:u8 tier:u8 class_len:u16 class[class_len] provers_len:u16 provers[provers_len] +/// class_len ≀ 128, provers_len ≀ 256; oversized payloads drop silently. +pub fn logPeerCapabilitiesSet(slot_idx: u8, tier: u8, class: []const u8, provers: []const u8) void { + if (class.len > 128 or provers.len > 256) return; + var buf: [2 + 2 + 128 + 2 + 256]u8 = undefined; + buf[0] = slot_idx; + buf[1] = tier; + std.mem.writeInt(u16, buf[2..4], @intCast(class.len), .little); + if (class.len > 0) @memcpy(buf[4 .. 4 + class.len], class); + const p_off: usize = 4 + class.len; + std.mem.writeInt(u16, buf[p_off..][0..2], @intCast(provers.len), .little); + if (provers.len > 0) @memcpy(buf[p_off + 2 .. p_off + 2 + provers.len], provers); + append(.peer_capabilities_set, buf[0 .. p_off + 2 + provers.len]); +} + +/// PEER_STATUS_SET β€” slot_idx:u8 status_len:u16 status[status_len] +pub fn logPeerStatusSet(slot_idx: u8, status: []const u8) void { + if (status.len > 256) return; + var buf: [3 + 256]u8 = undefined; + buf[0] = slot_idx; + std.mem.writeInt(u16, buf[1..3], @intCast(status.len), .little); + if (status.len > 0) @memcpy(buf[3 .. 3 + status.len], status); + append(.peer_status_set, buf[0 .. 3 + status.len]); +} + +/// INBOX_PUSH β€” target_idx:u8 msg_len:u16 msg[msg_len] +pub fn logInboxPush(target_idx: u8, msg: []const u8) void { + if (msg.len > 512) return; + var buf: [3 + 512]u8 = undefined; + buf[0] = target_idx; + std.mem.writeInt(u16, buf[1..3], @intCast(msg.len), .little); + if (msg.len > 0) @memcpy(buf[3 .. 3 + msg.len], msg); + append(.inbox_push, buf[0 .. 3 + msg.len]); +} + +/// INBOX_POP β€” peer_idx:u8 (1B) +pub fn logInboxPop(peer_idx: u8) void { + append(.inbox_pop, &[_]u8{peer_idx}); +} + +/// CLAIM_ADD β€” claim_idx:u8 holder_idx:u8 task_len:u8 task[task_len] +pub fn logClaimAdd(claim_idx: u8, holder_idx: u8, task: []const u8) void { + if (task.len > 128) return; + var buf: [3 + 128]u8 = undefined; + buf[0] = claim_idx; + buf[1] = holder_idx; + buf[2] = @intCast(task.len); + if (task.len > 0) @memcpy(buf[3 .. 3 + task.len], task); + append(.claim_add, buf[0 .. 3 + task.len]); +} + +/// CLAIM_REL β€” claim_idx:u8 (1B) +pub fn logClaimRel(claim_idx: u8) void { + append(.claim_rel, &[_]u8{claim_idx}); +} + +/// CLAIM_PROGRESS β€” claim_idx:u8 timestamp_ms:u64 (9B). DD-20 watchdog. +pub fn logClaimProgress(claim_idx: u8, timestamp_ms: u64) void { + var buf: [9]u8 = undefined; + buf[0] = claim_idx; + std.mem.writeInt(u64, buf[1..9], timestamp_ms, .little); + append(.claim_progress, &buf); +} + +/// QUAR_ADD β€” request_id:u32 sender_idx:u8 target_idx:i8 risk_tier:u8 +/// msg_len:u16 msg[msg_len] +pub fn logQuarAdd(request_id: u32, sender_idx: u8, target_idx: i8, risk_tier: u8, msg: []const u8) void { + if (msg.len > 512) return; + var buf: [9 + 512]u8 = undefined; + std.mem.writeInt(u32, buf[0..4], request_id, .little); + buf[4] = sender_idx; + buf[5] = @bitCast(target_idx); + buf[6] = risk_tier; + std.mem.writeInt(u16, buf[7..9], @intCast(msg.len), .little); + if (msg.len > 0) @memcpy(buf[9 .. 9 + msg.len], msg); + append(.quar_add, buf[0 .. 9 + msg.len]); +} + +/// QUAR_APPROVE β€” request_id:u32 (4B) +pub fn logQuarApprove(request_id: u32) void { + var buf: [4]u8 = undefined; + std.mem.writeInt(u32, &buf, request_id, .little); + append(.quar_approve, &buf); +} + +/// QUAR_REJECT β€” request_id:u32 reason_len:u16 reason[reason_len] +pub fn logQuarReject(request_id: u32, reason: []const u8) void { + if (reason.len > 256) return; + var buf: [6 + 256]u8 = undefined; + std.mem.writeInt(u32, buf[0..4], request_id, .little); + std.mem.writeInt(u16, buf[4..6], @intCast(reason.len), .little); + if (reason.len > 0) @memcpy(buf[6 .. 6 + reason.len], reason); + append(.quar_reject, buf[0 .. 6 + reason.len]); +} + +/// AUDIT β€” kind:u8 detail_len:u16 detail[detail_len] +/// Generic audit line for supervision decisions and policy trips beyond +/// the quarantine flow (e.g. tier-promotion, bad-token attempts). +pub fn logAudit(kind: u8, detail: []const u8) void { + if (detail.len > 512) return; + var buf: [3 + 512]u8 = undefined; + buf[0] = kind; + std.mem.writeInt(u16, buf[1..3], @intCast(detail.len), .little); + if (detail.len > 0) @memcpy(buf[3 .. 3 + detail.len], detail); + append(.audit, buf[0 .. 3 + detail.len]); +} + +/// TRACK_UPDATE β€” client_kind:u8 outcome:u8 risk_tier:u8 duration_ms:u32 +/// timestamp_ms:u64 tag_len:u8 tag[tag_len] confidence_pct:u8 +/// +/// confidence_pct is 0..100, or 255 = unset. Always the last byte so old +/// decoders (16+tag.len) can still parse the leading fields. +/// +/// DD-29: keyed on client_kind (not peer_id/suffix) so the track record +/// survives peer crash+restart β€” a fresh peer of the same client_kind +/// inherits the track record. +pub fn logTrackUpdate( + client_kind: u8, + outcome: u8, + risk_tier: u8, + duration_ms: u32, + timestamp_ms: u64, + tag: []const u8, + confidence_pct: u8, +) void { + if (tag.len > 64) return; + var buf: [17 + 64]u8 = undefined; + buf[0] = client_kind; + buf[1] = outcome; + buf[2] = risk_tier; + std.mem.writeInt(u32, buf[3..7], duration_ms, .little); + std.mem.writeInt(u64, buf[7..15], timestamp_ms, .little); + buf[15] = @intCast(tag.len); + if (tag.len > 0) @memcpy(buf[16 .. 16 + tag.len], tag); + buf[16 + tag.len] = confidence_pct; + append(.track_update, buf[0 .. 17 + tag.len]); +} + +// ═══════════════════════════════════════════════════════════════════════ +// Typed decoders β€” helpers for replay callbacks. Each returns null if the +// payload is too short for the fixed portion of the event. +// ═══════════════════════════════════════════════════════════════════════ + +pub const PeerAdd = struct { slot_idx: u8, kind: u8, role: u8, suffix: [4]u8, token: [16]u8 }; +pub fn decodePeerAdd(p: []const u8) ?PeerAdd { + if (p.len < 23) return null; + var out: PeerAdd = undefined; + out.slot_idx = p[0]; + out.kind = p[1]; + out.role = p[2]; + @memcpy(&out.suffix, p[3..7]); + @memcpy(&out.token, p[7..23]); + return out; +} + +pub fn decodeSlotIdx(p: []const u8) ?u8 { + if (p.len < 1) return null; + return p[0]; +} + +pub const PeerRoleSet = struct { slot_idx: u8, role: u8 }; +pub fn decodePeerRoleSet(p: []const u8) ?PeerRoleSet { + if (p.len < 2) return null; + return .{ .slot_idx = p[0], .role = p[1] }; +} + +pub const PeerContextSet = struct { slot_idx: u8, ctx: []const u8 }; +pub fn decodePeerContextSet(p: []const u8) ?PeerContextSet { + if (p.len < 2) return null; + const n: usize = p[1]; + if (p.len < 2 + n) return null; + return .{ .slot_idx = p[0], .ctx = p[2 .. 2 + n] }; +} + +/// Task #33 β€” identical shape to PeerContextSet. +pub const PeerVariantSet = struct { slot_idx: u8, variant: []const u8 }; +pub fn decodePeerVariantSet(p: []const u8) ?PeerVariantSet { + if (p.len < 2) return null; + const n: usize = p[1]; + if (p.len < 2 + n) return null; + return .{ .slot_idx = p[0], .variant = p[2 .. 2 + n] }; +} + +/// Task #34 β€” layout mirrors logPeerCapabilitiesSet exactly. +pub const PeerCapabilitiesSet = struct { + slot_idx: u8, + tier: u8, + class: []const u8, + provers: []const u8, +}; +pub fn decodePeerCapabilitiesSet(p: []const u8) ?PeerCapabilitiesSet { + if (p.len < 4) return null; + const class_len: usize = std.mem.readInt(u16, p[2..4], .little); + if (p.len < 4 + class_len + 2) return null; + const p_off: usize = 4 + class_len; + const provers_len: usize = std.mem.readInt(u16, p[p_off..][0..2], .little); + if (p.len < p_off + 2 + provers_len) return null; + return .{ + .slot_idx = p[0], + .tier = p[1], + .class = p[4 .. 4 + class_len], + .provers = p[p_off + 2 .. p_off + 2 + provers_len], + }; +} + +pub const PeerStatusSet = struct { slot_idx: u8, status: []const u8 }; +pub fn decodePeerStatusSet(p: []const u8) ?PeerStatusSet { + if (p.len < 3) return null; + const n: usize = std.mem.readInt(u16, p[1..3], .little); + if (p.len < 3 + n) return null; + return .{ .slot_idx = p[0], .status = p[3 .. 3 + n] }; +} + +pub const InboxPush = struct { target_idx: u8, msg: []const u8 }; +pub fn decodeInboxPush(p: []const u8) ?InboxPush { + if (p.len < 3) return null; + const n: usize = std.mem.readInt(u16, p[1..3], .little); + if (p.len < 3 + n) return null; + return .{ .target_idx = p[0], .msg = p[3 .. 3 + n] }; +} + +pub const ClaimAdd = struct { claim_idx: u8, holder_idx: u8, task: []const u8 }; +pub fn decodeClaimAdd(p: []const u8) ?ClaimAdd { + if (p.len < 3) return null; + const n: usize = p[2]; + if (p.len < 3 + n) return null; + return .{ .claim_idx = p[0], .holder_idx = p[1], .task = p[3 .. 3 + n] }; +} + +pub const ClaimProgress = struct { claim_idx: u8, timestamp_ms: u64 }; +pub fn decodeClaimProgress(p: []const u8) ?ClaimProgress { + if (p.len < 9) return null; + return .{ + .claim_idx = p[0], + .timestamp_ms = std.mem.readInt(u64, p[1..9], .little), + }; +} + +pub const QuarAdd = struct { + request_id: u32, + sender_idx: u8, + target_idx: i8, + risk_tier: u8, + msg: []const u8, +}; +pub fn decodeQuarAdd(p: []const u8) ?QuarAdd { + if (p.len < 9) return null; + const n: usize = std.mem.readInt(u16, p[7..9], .little); + if (p.len < 9 + n) return null; + return .{ + .request_id = std.mem.readInt(u32, p[0..4], .little), + .sender_idx = p[4], + .target_idx = @bitCast(p[5]), + .risk_tier = p[6], + .msg = p[9 .. 9 + n], + }; +} + +pub fn decodeRequestId(p: []const u8) ?u32 { + if (p.len < 4) return null; + return std.mem.readInt(u32, p[0..4], .little); +} + +pub const QuarReject = struct { request_id: u32, reason: []const u8 }; +pub fn decodeQuarReject(p: []const u8) ?QuarReject { + if (p.len < 6) return null; + const n: usize = std.mem.readInt(u16, p[4..6], .little); + if (p.len < 6 + n) return null; + return .{ + .request_id = std.mem.readInt(u32, p[0..4], .little), + .reason = p[6 .. 6 + n], + }; +} + +pub const Audit = struct { kind: u8, detail: []const u8 }; +pub fn decodeAudit(p: []const u8) ?Audit { + if (p.len < 3) return null; + const n: usize = std.mem.readInt(u16, p[1..3], .little); + if (p.len < 3 + n) return null; + return .{ .kind = p[0], .detail = p[3 .. 3 + n] }; +} + +pub const TrackUpdate = struct { + client_kind: u8, + outcome: u8, + risk_tier: u8, + duration_ms: u32, + timestamp_ms: u64, + tag: []const u8, + confidence_pct: u8, // 255 = unset (old event or never reported) +}; +pub fn decodeTrackUpdate(p: []const u8) ?TrackUpdate { + if (p.len < 16) return null; + const n: usize = p[15]; + if (p.len < 16 + n) return null; + const conf: u8 = if (p.len >= 17 + n) p[16 + n] else 255; + return .{ + .client_kind = p[0], + .outcome = p[1], + .risk_tier = p[2], + .duration_ms = std.mem.readInt(u32, p[3..7], .little), + .timestamp_ms = std.mem.readInt(u64, p[7..15], .little), + .tag = p[16 .. 16 + n], + .confidence_pct = conf, + }; +} + +// ═══════════════════════════════════════════════════════════════════════ +// Tests β€” round-trip append/replay through a temp file +// ═══════════════════════════════════════════════════════════════════════ + +// Replay tests capture events into these globals. Zig doesn't support +// closures in function pointers; a module-level capture is the simplest +// way to assert replayed state in tests. +var t_events: [64]EventType = undefined; +var t_payloads: [64][MAX_PAYLOAD]u8 = undefined; +var t_payload_lens: [64]usize = undefined; +var t_count: usize = 0; + +fn tCapture(event: EventType, payload: []const u8) void { + if (t_count >= t_events.len) return; + t_events[t_count] = event; + @memcpy(t_payloads[t_count][0..payload.len], payload); + t_payload_lens[t_count] = payload.len; + t_count += 1; +} + +fn tReset() void { + t_count = 0; +} + +fn tTempDir(buf: []u8) ![]u8 { + return std.fmt.bufPrint(buf, "/tmp/boj-coord-dur-test-{d}-{d}", .{ + std.time.milliTimestamp(), + std.crypto.random.int(u32), + }); +} + +test "disabled when dir unset" { + // Fresh state β€” no dir opened. + close(); + try std.testing.expect(!isEnabled()); + // Append silently no-ops. + logPeerRemove(3); + try std.testing.expect(!isEnabled()); +} + +test "open creates dir and log file" { + var buf: [256]u8 = undefined; + const dir = try tTempDir(&buf); + defer std.fs.cwd().deleteTree(dir) catch {}; + close(); + + try std.testing.expect(openWithDir(dir)); + try std.testing.expect(isEnabled()); + close(); + try std.testing.expect(!isEnabled()); +} + +test "append and replay round-trip peer add" { + var buf: [256]u8 = undefined; + const dir = try tTempDir(&buf); + defer std.fs.cwd().deleteTree(dir) catch {}; + close(); + + try std.testing.expect(openWithDir(dir)); + const suffix = [4]u8{ '7', 'f', '3', 'a' }; + const token = [16]u8{ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16 }; + logPeerAdd(2, 0, 1, &suffix, &token); + close(); + + // Re-open and replay. + try std.testing.expect(openWithDir(dir)); + tReset(); + replay(tCapture); + close(); + + try std.testing.expectEqual(@as(usize, 1), t_count); + try std.testing.expectEqual(EventType.peer_add, t_events[0]); + + const decoded = decodePeerAdd(t_payloads[0][0..t_payload_lens[0]]) orelse return error.DecodeFailed; + try std.testing.expectEqual(@as(u8, 2), decoded.slot_idx); + try std.testing.expectEqual(@as(u8, 0), decoded.kind); + try std.testing.expectEqual(@as(u8, 1), decoded.role); + try std.testing.expectEqualSlices(u8, &suffix, &decoded.suffix); + try std.testing.expectEqualSlices(u8, &token, &decoded.token); +} + +test "replay stops at CRC corruption" { + var buf: [256]u8 = undefined; + const dir = try tTempDir(&buf); + defer std.fs.cwd().deleteTree(dir) catch {}; + close(); + + try std.testing.expect(openWithDir(dir)); + logPeerRemove(1); + logPeerRemove(2); + logPeerRemove(3); + close(); + + // Corrupt the tail by overwriting the last CRC byte. + var path_buf: [512]u8 = undefined; + const path = try std.fmt.bufPrint(&path_buf, "{s}/coord.log", .{dir}); + const f = try std.fs.cwd().openFile(path, .{ .mode = .read_write }); + defer f.close(); + const size = try f.getEndPos(); + try f.seekTo(size - 1); + _ = try f.write(&[_]u8{0xFF}); + + // Replay should recover the first two records, stop at the corrupt tail. + try std.testing.expect(openWithDir(dir)); + tReset(); + replay(tCapture); + close(); + + try std.testing.expectEqual(@as(usize, 2), t_count); +} + +test "replay decodes every event type" { + var buf: [256]u8 = undefined; + const dir = try tTempDir(&buf); + defer std.fs.cwd().deleteTree(dir) catch {}; + close(); + + try std.testing.expect(openWithDir(dir)); + const suffix = [4]u8{ 'a', 'b', 'c', 'd' }; + const token = [_]u8{0} ** 16; + + logPeerAdd(0, 0, 1, &suffix, &token); + logPeerRoleSet(0, 2); + logPeerContextSet(0, "007-lang"); + logPeerStatusSet(0, "auditing proofs"); + logInboxPush(1, "hello"); + logInboxPop(1); + logClaimAdd(3, 0, "fix-ci"); + logClaimRel(3); + logQuarAdd(42, 0, 1, 3, "proposed-push"); + logQuarApprove(42); + logQuarReject(43, "confabulated path"); + logAudit(1, "tier3-from-supervised"); + logTrackUpdate(0, 1, 2, 1234, 1_700_000_000_000, "proof-analysis", 85); + logPeerRemove(0); + close(); + + try std.testing.expect(openWithDir(dir)); + tReset(); + replay(tCapture); + close(); + + try std.testing.expectEqual(@as(usize, 14), t_count); + + // Spot-check a few decoders. + try std.testing.expectEqual(EventType.peer_add, t_events[0]); + try std.testing.expectEqual(EventType.inbox_push, t_events[4]); + const push = decodeInboxPush(t_payloads[4][0..t_payload_lens[4]]) orelse return error.DecodeFailed; + try std.testing.expectEqual(@as(u8, 1), push.target_idx); + try std.testing.expectEqualSlices(u8, "hello", push.msg); + + try std.testing.expectEqual(EventType.quar_reject, t_events[10]); + const rej = decodeQuarReject(t_payloads[10][0..t_payload_lens[10]]) orelse return error.DecodeFailed; + try std.testing.expectEqual(@as(u32, 43), rej.request_id); + try std.testing.expectEqualSlices(u8, "confabulated path", rej.reason); + + try std.testing.expectEqual(EventType.track_update, t_events[12]); + const tr = decodeTrackUpdate(t_payloads[12][0..t_payload_lens[12]]) orelse return error.DecodeFailed; + try std.testing.expectEqual(@as(u8, 0), tr.client_kind); + try std.testing.expectEqual(@as(u8, 1), tr.outcome); + try std.testing.expectEqual(@as(u8, 2), tr.risk_tier); + try std.testing.expectEqual(@as(u32, 1234), tr.duration_ms); + try std.testing.expectEqual(@as(u64, 1_700_000_000_000), tr.timestamp_ms); + try std.testing.expectEqualSlices(u8, "proof-analysis", tr.tag); + try std.testing.expectEqual(@as(u8, 85), tr.confidence_pct); +} diff --git a/cartridges/cross-cutting/agentic/local-coord-mcp/ffi/coord_identity.zig b/cartridges/cross-cutting/agentic/local-coord-mcp/ffi/coord_identity.zig new file mode 100644 index 0000000..618652e --- /dev/null +++ b/cartridges/cross-cutting/agentic/local-coord-mcp/ffi/coord_identity.zig @@ -0,0 +1,471 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +// +// coord_identity.zig β€” Phase 1 (ADR-0016) ed25519 identity foundation. +// +// Realises the C-ABI contract documented in +// `cartridges/local-coord-mcp/abi/LocalCoord/Identity.idr`: +// +// * boj_coord_identity_init(key_path) -> int (0 ok) +// * boj_coord_identity_get_pubkey(out, out_len) -> int (bytes written, -1 err) +// * boj_coord_identity_load_known_peers(path) -> int (count, -1 err) +// * boj_coord_identity_known_peer_count() -> int +// +// Phase 1 scope: keypair generation, on-disk persistence (0600), pubkey +// export, and a minimal TOML-shaped parser for the trust list. NO +// signing, NO verification, NO network β€” those are Phase 2 / 3. + +const std = @import("std"); +const fs = std.fs; +const mem = std.mem; +const crypto = std.crypto; +const Ed25519 = crypto.sign.Ed25519; + +const PUBKEY_BYTES: usize = 32; +const SEED_BYTES: usize = 32; +const SIG_BYTES: usize = 64; +const MAX_KNOWN_PEERS: usize = 64; +const PEER_ID_MAX: usize = 32; +const HOST_MAX: usize = 256; + +// ═══════════════════════════════════════════════════════════════════ +// Global identity state (Phase 1 β€” singleton per process) +// ═══════════════════════════════════════════════════════════════════ + +const KnownPeer = struct { + peer_id: [PEER_ID_MAX]u8, + peer_id_len: u8, + pubkey: [PUBKEY_BYTES]u8, + host: [HOST_MAX]u8, + host_len: u16, + port: u16, +}; + +const IdentityState = struct { + initialised: bool = false, + key_pair: ?Ed25519.KeyPair = null, + known_peers: [MAX_KNOWN_PEERS]KnownPeer = undefined, + known_peer_count: usize = 0, +}; + +var state: IdentityState = .{}; +var state_mutex: std.Thread.Mutex = .{}; + +// ═══════════════════════════════════════════════════════════════════ +// Internal helpers +// ═══════════════════════════════════════════════════════════════════ + +fn cStrToSlice(ptr: [*:0]const u8) []const u8 { + return mem.span(ptr); +} + +fn ensureParentDir(path: []const u8) !void { + if (fs.path.dirname(path)) |dir| { + fs.makeDirAbsolute(dir) catch |e| switch (e) { + error.PathAlreadyExists => {}, + else => return e, + }; + } +} + +fn writeSeedFile(path: []const u8, seed: [SEED_BYTES]u8) !void { + try ensureParentDir(path); + const file = try fs.createFileAbsolute(path, .{ .mode = 0o600, .truncate = true }); + defer file.close(); + try file.writeAll(&seed); +} + +fn readSeedFile(path: []const u8) !?[SEED_BYTES]u8 { + const file = fs.openFileAbsolute(path, .{}) catch |e| switch (e) { + error.FileNotFound => return null, + else => return e, + }; + defer file.close(); + var buf: [SEED_BYTES]u8 = undefined; + const n = try file.readAll(&buf); + if (n != SEED_BYTES) return error.InvalidKeyFile; + return buf; +} + +// ═══════════════════════════════════════════════════════════════════ +// Public FFI surface β€” matches Identity.idr contract +// ═══════════════════════════════════════════════════════════════════ + +/// Initialise the identity store. Loads the keypair from `key_path` if +/// present; otherwise generates a fresh keypair and persists the seed +/// at that path (mode 0600). Idempotent: subsequent calls with the +/// same path are no-ops. +pub export fn boj_coord_identity_init(key_path: [*:0]const u8) c_int { + state_mutex.lock(); + defer state_mutex.unlock(); + + if (state.initialised) return 0; + + const path = cStrToSlice(key_path); + + var seed: [SEED_BYTES]u8 = undefined; + if (readSeedFile(path) catch return -1) |existing| { + seed = existing; + } else { + crypto.random.bytes(&seed); + writeSeedFile(path, seed) catch return -2; + } + + // Ed25519.KeyPair.generateDeterministic is the Zig 0.15.x API for + // seed β†’ keypair derivation. It can theoretically fail with + // IdentityElementError for adversarial seeds; not reachable for a + // CSPRNG-derived 32-byte input, but propagate the error code for + // honesty rather than `unreachable`. + const kp = Ed25519.KeyPair.generateDeterministic(seed) catch return -3; + state.key_pair = kp; + state.initialised = true; + return 0; +} + +/// Copy the local ed25519 public key (32 bytes) into the caller's +/// buffer. Returns bytes written on success (== 32), -1 if not yet +/// initialised, -2 if buffer too small. +pub export fn boj_coord_identity_get_pubkey(out: [*]u8, out_len: usize) c_int { + state_mutex.lock(); + defer state_mutex.unlock(); + + if (!state.initialised) return -1; + if (out_len < PUBKEY_BYTES) return -2; + const kp = state.key_pair orelse return -1; + // Ed25519.PublicKey holds a `.bytes: [32]u8` field directly. + @memcpy(out[0..PUBKEY_BYTES], &kp.public_key.bytes); + return @intCast(PUBKEY_BYTES); +} + +/// Load the known-peers trust list from `toml_path`. Replaces any +/// previously-loaded set on each call. Returns the number of entries +/// loaded (>= 0) or -1 on parse error. A missing file is treated as +/// zero entries (not an error) so the bus starts cleanly on first run. +pub export fn boj_coord_identity_load_known_peers(toml_path: [*:0]const u8) c_int { + state_mutex.lock(); + defer state_mutex.unlock(); + + const path = cStrToSlice(toml_path); + const file = fs.openFileAbsolute(path, .{}) catch |e| switch (e) { + error.FileNotFound => { + state.known_peer_count = 0; + return 0; + }, + else => return -1, + }; + defer file.close(); + + var buf: [16384]u8 = undefined; + const n = file.readAll(&buf) catch return -1; + const text = buf[0..n]; + + state.known_peer_count = 0; + parseTomlPeers(text, &state.known_peers, &state.known_peer_count) catch return -1; + return @intCast(state.known_peer_count); +} + +/// Current number of loaded known peers. +pub export fn boj_coord_identity_known_peer_count() c_int { + state_mutex.lock(); + defer state_mutex.unlock(); + return @intCast(state.known_peer_count); +} + +// ═══════════════════════════════════════════════════════════════════ +// Minimal TOML-shaped parser +// ═══════════════════════════════════════════════════════════════════ +// +// Accepts the following shape, one or more times: +// +// [[peer]] +// id = "claude-7f3a" +// pubkey = "abcdef..." # 64 hex chars (32 bytes) +// host = "192.168.1.42" +// port = 7746 +// +// Comments start with '#'. Blank lines and unknown keys are ignored. +// All four fields are required per `[[peer]]` block; a block missing +// any required field is rejected at the end of that block. + +const ParseError = error{ Malformed, BadHex, TooManyPeers, MissingField }; + +const FieldFlags = packed struct { + id: bool = false, + pubkey: bool = false, + host: bool = false, + port: bool = false, +}; + +fn parseTomlPeers( + text: []const u8, + out: *[MAX_KNOWN_PEERS]KnownPeer, + out_count: *usize, +) ParseError!void { + var in_block = false; + var current: KnownPeer = std.mem.zeroes(KnownPeer); + var flags = FieldFlags{}; + + var lines = mem.splitScalar(u8, text, '\n'); + while (lines.next()) |raw_line| { + const line = trim(raw_line); + if (line.len == 0 or line[0] == '#') continue; + + if (mem.eql(u8, line, "[[peer]]")) { + if (in_block) { + try commitBlock(out, out_count, ¤t, &flags); + } + in_block = true; + current = std.mem.zeroes(KnownPeer); + flags = .{}; + continue; + } + + if (!in_block) return error.Malformed; // stray key=value before any [[peer]] + + const eq_idx = mem.indexOfScalar(u8, line, '=') orelse return error.Malformed; + const key = trim(line[0..eq_idx]); + const value = trim(line[eq_idx + 1 ..]); + + if (mem.eql(u8, key, "id")) { + const s = stripQuotes(value) orelse return error.Malformed; + if (s.len == 0 or s.len > PEER_ID_MAX) return error.Malformed; + @memcpy(current.peer_id[0..s.len], s); + current.peer_id_len = @intCast(s.len); + flags.id = true; + } else if (mem.eql(u8, key, "pubkey")) { + const s = stripQuotes(value) orelse return error.Malformed; + try hexDecode(s, current.pubkey[0..]); + flags.pubkey = true; + } else if (mem.eql(u8, key, "host")) { + const s = stripQuotes(value) orelse return error.Malformed; + if (s.len == 0 or s.len > HOST_MAX) return error.Malformed; + @memcpy(current.host[0..s.len], s); + current.host_len = @intCast(s.len); + flags.host = true; + } else if (mem.eql(u8, key, "port")) { + current.port = std.fmt.parseInt(u16, value, 10) catch return error.Malformed; + flags.port = true; + } + // Unknown keys: silently ignored (forward-compat). + } + if (in_block) { + try commitBlock(out, out_count, ¤t, &flags); + } +} + +fn commitBlock( + out: *[MAX_KNOWN_PEERS]KnownPeer, + out_count: *usize, + current: *const KnownPeer, + flags: *const FieldFlags, +) ParseError!void { + if (!(flags.id and flags.pubkey and flags.host and flags.port)) { + return error.MissingField; + } + if (out_count.* >= MAX_KNOWN_PEERS) return error.TooManyPeers; + out[out_count.*] = current.*; + out_count.* += 1; +} + +fn trim(s: []const u8) []const u8 { + var start: usize = 0; + var end: usize = s.len; + while (start < end and isSpace(s[start])) start += 1; + while (end > start and isSpace(s[end - 1])) end -= 1; + return s[start..end]; +} + +fn isSpace(c: u8) bool { + return c == ' ' or c == '\t' or c == '\r'; +} + +fn stripQuotes(s: []const u8) ?[]const u8 { + if (s.len < 2) return null; + if (s[0] != '"' or s[s.len - 1] != '"') return null; + return s[1 .. s.len - 1]; +} + +fn hexDecode(hex: []const u8, out: []u8) ParseError!void { + if (hex.len != out.len * 2) return error.BadHex; + var i: usize = 0; + while (i < out.len) : (i += 1) { + const hi = hexNibble(hex[2 * i]) orelse return error.BadHex; + const lo = hexNibble(hex[2 * i + 1]) orelse return error.BadHex; + out[i] = (hi << 4) | lo; + } +} + +fn hexNibble(c: u8) ?u8 { + return switch (c) { + '0'...'9' => c - '0', + 'a'...'f' => c - 'a' + 10, + 'A'...'F' => c - 'A' + 10, + else => null, + }; +} + +// ═══════════════════════════════════════════════════════════════════ +// Test-only helpers β€” allow Zig unit tests to inspect internal state +// without going through the C-ABI surface. +// ═══════════════════════════════════════════════════════════════════ + +pub fn testResetState() void { + state_mutex.lock(); + defer state_mutex.unlock(); + state = .{}; +} + +pub fn testKnownPeerAt(index: usize) ?KnownPeer { + state_mutex.lock(); + defer state_mutex.unlock(); + if (index >= state.known_peer_count) return null; + return state.known_peers[index]; +} + +// ═══════════════════════════════════════════════════════════════════ +// Tests +// ═══════════════════════════════════════════════════════════════════ + +test "hexDecode roundtrip on a known pubkey-shaped value" { + var out: [PUBKEY_BYTES]u8 = undefined; + try hexDecode("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", &out); + try std.testing.expectEqual(@as(u8, 0x01), out[0]); + try std.testing.expectEqual(@as(u8, 0xef), out[31]); +} + +test "hexDecode rejects wrong-length input" { + var out: [4]u8 = undefined; + try std.testing.expectError(error.BadHex, hexDecode("aabbcc", &out)); // 6 chars, need 8 +} + +test "hexDecode rejects non-hex characters" { + var out: [2]u8 = undefined; + try std.testing.expectError(error.BadHex, hexDecode("ZZAA", &out)); +} + +test "parseTomlPeers handles a single complete block" { + testResetState(); + const toml = + \\[[peer]] + \\id = "claude-7f3a" + \\pubkey = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + \\host = "192.168.1.42" + \\port = 7746 + ; + var peers: [MAX_KNOWN_PEERS]KnownPeer = undefined; + var count: usize = 0; + try parseTomlPeers(toml, &peers, &count); + try std.testing.expectEqual(@as(usize, 1), count); + try std.testing.expectEqual(@as(u16, 7746), peers[0].port); + try std.testing.expectEqualStrings("claude-7f3a", peers[0].peer_id[0..peers[0].peer_id_len]); +} + +test "parseTomlPeers handles multiple blocks and comments" { + testResetState(); + const toml = + \\# trust list + \\[[peer]] + \\id = "alice" + \\pubkey = "0000000000000000000000000000000000000000000000000000000000000001" + \\host = "alice.local" + \\port = 7746 + \\ + \\[[peer]] + \\id = "bob" + \\pubkey = "0000000000000000000000000000000000000000000000000000000000000002" + \\host = "bob.local" + \\port = 7747 + ; + var peers: [MAX_KNOWN_PEERS]KnownPeer = undefined; + var count: usize = 0; + try parseTomlPeers(toml, &peers, &count); + try std.testing.expectEqual(@as(usize, 2), count); + try std.testing.expectEqual(@as(u16, 7747), peers[1].port); +} + +test "parseTomlPeers rejects a block missing fields" { + const toml = + \\[[peer]] + \\id = "incomplete" + \\host = "x" + ; + var peers: [MAX_KNOWN_PEERS]KnownPeer = undefined; + var count: usize = 0; + try std.testing.expectError(error.MissingField, parseTomlPeers(toml, &peers, &count)); +} + +test "FFI: identity init generates and persists, second init no-ops" { + testResetState(); + const tmp_path = "/tmp/boj-coord-test-identity.key"; + // Clean any previous state + fs.deleteFileAbsolute(tmp_path) catch {}; + defer fs.deleteFileAbsolute(tmp_path) catch {}; + + // Zig string literals already carry a `:0` sentinel, so `.ptr` + // coerces directly to `[*:0]const u8`. No @ptrCast needed. + const path_z: [:0]const u8 = tmp_path; + const rc1 = boj_coord_identity_init(path_z.ptr); + try std.testing.expectEqual(@as(c_int, 0), rc1); + + var pubkey1: [PUBKEY_BYTES]u8 = undefined; + const n1 = boj_coord_identity_get_pubkey(&pubkey1, PUBKEY_BYTES); + try std.testing.expectEqual(@as(c_int, @intCast(PUBKEY_BYTES)), n1); + + // Re-init: idempotent on the same process state. + const rc2 = boj_coord_identity_init(path_z.ptr); + try std.testing.expectEqual(@as(c_int, 0), rc2); + + var pubkey2: [PUBKEY_BYTES]u8 = undefined; + _ = boj_coord_identity_get_pubkey(&pubkey2, PUBKEY_BYTES); + try std.testing.expectEqualSlices(u8, &pubkey1, &pubkey2); +} + +test "FFI: get_pubkey before init returns -1" { + testResetState(); + var pubkey: [PUBKEY_BYTES]u8 = undefined; + try std.testing.expectEqual(@as(c_int, -1), boj_coord_identity_get_pubkey(&pubkey, PUBKEY_BYTES)); +} + +test "FFI: load_known_peers on missing file returns 0" { + testResetState(); + const missing_z: [:0]const u8 = "/tmp/boj-coord-test-no-such-known-peers.toml"; + const rc = boj_coord_identity_load_known_peers(missing_z.ptr); + try std.testing.expectEqual(@as(c_int, 0), rc); + try std.testing.expectEqual(@as(c_int, 0), boj_coord_identity_known_peer_count()); +} + +// RFC 8032 Β§7.1 TEST 1 β€” the canonical ed25519 reference vector. +// The matching test in coord-tui/src/main.rs pins the same vector, +// so if both this test and that test pass, the Rust and Zig +// derivations agree with the spec β€” and therefore with each other +// β€” across the shared 32-byte seed-file format. +// +// SEED: 9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60 +// PUBKEY: d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a + +test "RFC 8032 Β§7.1 TEST 1 β€” seed derives the canonical pubkey" { + testResetState(); + const seed_hex = "9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60"; + const expect_hex = "d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a"; + + var seed: [SEED_BYTES]u8 = undefined; + try hexDecode(seed_hex, &seed); + + const tmp_path = "/tmp/boj-coord-test-rfc8032.key"; + fs.deleteFileAbsolute(tmp_path) catch {}; + defer fs.deleteFileAbsolute(tmp_path) catch {}; + try writeSeedFile(tmp_path, seed); + + const path_z: [:0]const u8 = tmp_path; + try std.testing.expectEqual(@as(c_int, 0), boj_coord_identity_init(path_z.ptr)); + + var pubkey: [PUBKEY_BYTES]u8 = undefined; + try std.testing.expectEqual( + @as(c_int, @intCast(PUBKEY_BYTES)), + boj_coord_identity_get_pubkey(&pubkey, PUBKEY_BYTES), + ); + + var expect_bytes: [PUBKEY_BYTES]u8 = undefined; + try hexDecode(expect_hex, &expect_bytes); + try std.testing.expectEqualSlices(u8, &expect_bytes, &pubkey); +} diff --git a/cartridges/cross-cutting/agentic/local-coord-mcp/ffi/local_coord_ffi.zig b/cartridges/cross-cutting/agentic/local-coord-mcp/ffi/local_coord_ffi.zig new file mode 100644 index 0000000..631908f --- /dev/null +++ b/cartridges/cross-cutting/agentic/local-coord-mcp/ffi/local_coord_ffi.zig @@ -0,0 +1,4751 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +// +// Local-Coord MCP Cartridge β€” Zig FFI bridge for localhost multi-instance +// coordination. +// +// Manages a peer registry, session tokens, message fan-out, and task +// claiming (mutex). Binds ONLY to 127.0.0.1:7745 β€” the Idris2 ABI +// proves loopback-only at compile time; this FFI honours that constraint +// at runtime. +// +// Durability: every mutation persists to an append-only log under +// BOJ_COORD_STATE_DIR. On init the log is replayed to restore state +// across adapter restarts. When the env var is unset, durability is a +// silent no-op β€” process-local in-memory behaviour is preserved. +// See coord_durability.zig. + +const std = @import("std"); +const dur = @import("coord_durability.zig"); + +// ADR-0016 Phase 1: pull in coord_identity so its `pub export fn` +// symbols are part of the shared library. The module does not need to +// be referenced directly from this file β€” the import side-effect is +// to surface the FFI exports for `boj_coord_identity_*`. +comptime { + _ = @import("coord_identity.zig"); +} + +// ═══════════════════════════════════════════════════════════════════════ +// Constants (must match SafeLocalCoord.idr) +// ═══════════════════════════════════════════════════════════════════════ + +/// CRITICAL: Loopback only. Never change to 0.0.0.0 or any LAN address. +const BIND_ADDR = [4]u8{ 127, 0, 0, 1 }; +const BIND_PORT: u16 = 7745; +const MAX_PEERS: usize = 16; +const MAX_CLAIMS: usize = 64; +const TOKEN_LEN: usize = 16; // 16 bytes = 32 hex chars +const MAX_MESSAGES: usize = 256; // ring buffer size per peer + +// ═══════════════════════════════════════════════════════════════════════ +// Types (must match Protocol.idr encodings) +// ═══════════════════════════════════════════════════════════════════════ + +pub const ClientKind = enum(c_int) { + claude = 0, + gemini = 1, + copilot = 2, + custom = 3, + openai = 4, + mistral = 5, +}; + +pub const PeerState = enum(c_int) { + registering = 0, + active = 1, + departing = 2, + gone = 3, +}; + +/// Trust role β€” determines what a peer may do without a master gate. +/// See docs/envelope-design.adoc for the risk ladder. Integer ordinals +/// preserved across the 2026-04-20 rename (old supervisor β†’ master, +/// executor β†’ journeyman, supervised β†’ apprentice β€” DD-32) so logs +/// written before the rename still replay correctly +/// from before the rename still replay. +pub const Role = enum(c_int) { + master = 0, // Opus (or whoever holds BOJ_MASTER_TOKEN) β€” approval authority + journeyman = 1, // Claude Sonnet/Haiku β€” trusted to act solo on Tier 2 + apprentice = 2, // gemini/codex/vibe β€” Tier 2+ quarantined for master review +}; + +/// Role-hint sentinel for coord_register β€” lets the server decide the +/// default from client_kind. Used to keep the register signature stable +/// while allowing explicit role requests. +const ROLE_HINT_DEFAULT: c_int = -1; + +pub const MsgKind = enum(c_int) { + direct_msg = 0, + broadcast = 1, + status_update = 2, + claim_request = 3, + claim_release = 4, + ping = 5, +}; + +pub const ClaimResult = enum(c_int) { + granted = 0, + held = 1, + not_found = 2, +}; + +// ═══════════════════════════════════════════════════════════════════════ +// Peer Registry +// ═══════════════════════════════════════════════════════════════════════ + +/// Per-window context disambiguator. Short label (e.g. repo name, tty-hash) +/// appended to peer_id as `-<4hex>@`. Optional β€” empty means +/// the old `-<4hex>` form. Alphanumeric + hyphens only; enforced in +/// coord_set_context. +const MAX_CONTEXT: usize = 32; + +/// Declared affinities β€” comma-joined list of tags the peer self-reports +/// as strengths at register time. The reassignment engine (Task #14) +/// cross-checks these against effective_affinity to detect gaps and +/// overclaims. Stored as the raw CSV string so comparison is cheap. +const MAX_DECLARED: usize = 256; + +/// Variant label β€” free-form model/variant identifier (e.g. "opus-4.7", +/// "flash-2.5", "leanstral"). Task #33. Alphanumeric + `.`/`-`/`_` only. +const MAX_VARIANT: usize = 32; + +/// Capability class CSV β€” e.g. "reasoning,coding,proof". Task #34. +const MAX_CLASS: usize = 128; + +/// Prover-strengths CSV β€” e.g. "coq,isabelle,lean,tlaps". Task #34. +const MAX_PROVERS: usize = 256; + +/// Sentinel for unset capability tier. Valid advertised tiers are 1..5. +const TIER_UNSET: u8 = 0; + +const Peer = struct { + active: bool, + kind: ClientKind, + suffix: [4]u8, // 4-char hex suffix + state: PeerState, + token: [TOKEN_LEN]u8, + role: Role, + // Per-peer message inbox (ring buffer) + inbox: [MAX_MESSAGES][512]u8, + inbox_lens: [MAX_MESSAGES]u16, + inbox_head: u16, // next write position + inbox_tail: u16, // next read position + inbox_count: u16, + // Status string + status: [256]u8, + status_len: u16, + // Context disambiguator (repo / tty / window label) + context: [MAX_CONTEXT]u8, + context_len: u8, + // Declared affinities (CSV of tag names) + declared_affinities: [MAX_DECLARED]u8, + declared_affinities_len: u16, + // Task #33 β€” model/variant label (e.g. "opus-4.7", "flash-2.5") + variant: [MAX_VARIANT]u8, + variant_len: u8, + // Task #34 β€” capability advertisement + class_csv: [MAX_CLASS]u8, + class_csv_len: u16, + tier: u8, // 0 = unset (TIER_UNSET), 1..5 = advertised tier + prover_strengths: [MAX_PROVERS]u8, + prover_strengths_len: u16, +}; + +const empty_peer = Peer{ + .active = false, + .kind = .claude, + .suffix = [_]u8{ '0', '0', '0', '0' }, + .state = .gone, + .token = [_]u8{0} ** TOKEN_LEN, + .role = .apprentice, + .inbox = [_][512]u8{[_]u8{0} ** 512} ** MAX_MESSAGES, + .inbox_lens = [_]u16{0} ** MAX_MESSAGES, + .inbox_head = 0, + .inbox_tail = 0, + .inbox_count = 0, + .status = [_]u8{0} ** 256, + .status_len = 0, + .context = [_]u8{0} ** MAX_CONTEXT, + .context_len = 0, + .declared_affinities = [_]u8{0} ** MAX_DECLARED, + .declared_affinities_len = 0, + .variant = [_]u8{0} ** MAX_VARIANT, + .variant_len = 0, + .class_csv = [_]u8{0} ** MAX_CLASS, + .class_csv_len = 0, + .tier = TIER_UNSET, + .prover_strengths = [_]u8{0} ** MAX_PROVERS, + .prover_strengths_len = 0, +}; + +var peers: [MAX_PEERS]Peer = [_]Peer{empty_peer} ** MAX_PEERS; +var mutex: std.Thread.Mutex = .{}; + +// ═══════════════════════════════════════════════════════════════════════ +// Task Claim Registry +// ═══════════════════════════════════════════════════════════════════════ + +const Claim = struct { + active: bool, + task_name: [128]u8, + task_name_len: u8, + holder_idx: u8, // index into peers[] + // Task #15-watchdog (DD-20) β€” ms since epoch of claim acquisition or + // most recent coord_progress heartbeat. sweepExpiredClaims compares + // (now - claimed_at_ms) against the holder's role-specific TTL. + claimed_at_ms: u64, +}; + +const empty_claim = Claim{ + .active = false, + .task_name = [_]u8{0} ** 128, + .task_name_len = 0, + .holder_idx = 0, + .claimed_at_ms = 0, +}; + +var claims: [MAX_CLAIMS]Claim = [_]Claim{empty_claim} ** MAX_CLAIMS; + +/// Watchdog TTLs per role (DD-20). master has no TTL β€” they're the +/// approval authority, not an executor. Values chosen to catch abandoned +/// work fast (apprentice) while allowing deep thought time (journeyman). +const TTL_APPRENTICE_MS: u64 = 30 * 1000; // 30 s +const TTL_JOURNEYMAN_MS: u64 = 5 * 60 * 1000; // 5 min + +/// Return the TTL in ms for a role, or 0 for roles with no watchdog. +fn watchdogTtlMs(role: Role) u64 { + return switch (role) { + .apprentice => TTL_APPRENTICE_MS, + .journeyman => TTL_JOURNEYMAN_MS, + .master => 0, + }; +} + +// ═══════════════════════════════════════════════════════════════════════ +// Quarantine Queue β€” Tier 2+ ops from role=apprentice peers held here +// until a master approves or rejects. +// ═══════════════════════════════════════════════════════════════════════ + +const MAX_QUARANTINE: usize = 32; +const MAX_REASON: usize = 256; + +const QuarantineEntry = struct { + active: bool, + request_id: u32, + sender_idx: u8, + target_idx: i8, // -1 for broadcast + risk_tier: u8, + msg: [512]u8, + msg_len: u16, + reason: [MAX_REASON]u8, + reason_len: u16, +}; + +const empty_quar = QuarantineEntry{ + .active = false, + .request_id = 0, + .sender_idx = 0, + .target_idx = -1, + .risk_tier = 0, + .msg = [_]u8{0} ** 512, + .msg_len = 0, + .reason = [_]u8{0} ** MAX_REASON, + .reason_len = 0, +}; + +var quarantine: [MAX_QUARANTINE]QuarantineEntry = [_]QuarantineEntry{empty_quar} ** MAX_QUARANTINE; +var next_request_id: u32 = 1; + +// ═══════════════════════════════════════════════════════════════════════ +// Track Record β€” per (client_kind, tag) outcome history used to compute +// `effective_affinity`. DD-29: keyed on client_kind not peer_id so the +// record survives peer crash+restart. +// +// Ring buffer; oldest entry overwritten when full. Window for affinity +// aggregation: last 20 attempts for that (kind, tag) OR all attempts +// within the last 7 days, whichever is larger (DD-28). +// ═══════════════════════════════════════════════════════════════════════ + +const MAX_TRACK: usize = 512; +const MAX_TAG: usize = 64; +const WINDOW_ATTEMPTS: usize = 20; +const WINDOW_MS: u64 = 7 * 24 * 60 * 60 * 1000; // 7 days in ms + +const TrackEntry = struct { + active: bool, + client_kind: u8, + outcome: u8, // 0 = fail, 1 = success + risk_tier: u8, + duration_ms: u32, + timestamp_ms: u64, + tag_len: u8, + tag: [MAX_TAG]u8, + confidence_pct: u8, // 255 = unset +}; + +const empty_track = TrackEntry{ + .active = false, + .client_kind = 0, + .outcome = 0, + .risk_tier = 0, + .duration_ms = 0, + .timestamp_ms = 0, + .tag_len = 0, + .tag = [_]u8{0} ** MAX_TAG, + .confidence_pct = 255, +}; + +var track: [MAX_TRACK]TrackEntry = [_]TrackEntry{empty_track} ** MAX_TRACK; +var track_head: usize = 0; // next write slot +var track_count: usize = 0; // active entries (saturates at MAX_TRACK) + +/// Push a track-record entry into the ring. Caller-visible timestamp is +/// always std.time.milliTimestamp() at insertion. Oldest record is +/// overwritten when the ring is full. +fn recordTrack( + client_kind: u8, + outcome: u8, + risk_tier: u8, + duration_ms: u32, + tag: []const u8, + confidence_pct: u8, +) void { + const t: *TrackEntry = &track[track_head]; + t.active = true; + t.client_kind = client_kind; + t.outcome = outcome; + t.risk_tier = risk_tier; + t.duration_ms = duration_ms; + t.timestamp_ms = @intCast(std.time.milliTimestamp()); + const tl: usize = @min(tag.len, MAX_TAG); + if (tl > 0) @memcpy(t.tag[0..tl], tag[0..tl]); + t.tag_len = @intCast(tl); + t.confidence_pct = confidence_pct; + track_head = (track_head + 1) % MAX_TRACK; + if (track_count < MAX_TRACK) track_count += 1; +} + +/// Re-insert a replayed track entry without clobbering its original +/// timestamp. Used by replayDispatch so aggregations after restart +/// reflect real event time, not replay time. +fn recordTrackReplay( + client_kind: u8, + outcome: u8, + risk_tier: u8, + duration_ms: u32, + timestamp_ms: u64, + tag: []const u8, + confidence_pct: u8, +) void { + const t: *TrackEntry = &track[track_head]; + t.active = true; + t.client_kind = client_kind; + t.outcome = outcome; + t.risk_tier = risk_tier; + t.duration_ms = duration_ms; + t.timestamp_ms = timestamp_ms; + const tl: usize = @min(tag.len, MAX_TAG); + if (tl > 0) @memcpy(t.tag[0..tl], tag[0..tl]); + t.tag_len = @intCast(tl); + t.confidence_pct = confidence_pct; + track_head = (track_head + 1) % MAX_TRACK; + if (track_count < MAX_TRACK) track_count += 1; +} + +// ��══════════════════════════════════════════════════════════════════════ +// Token Generation (CSPRNG from OS) +// ═══════════════════════════════════════════════════════════════════════ + +fn generateToken() [TOKEN_LEN]u8 { + var buf: [TOKEN_LEN]u8 = undefined; + std.crypto.random.bytes(&buf); + return buf; +} + +fn generateSuffix() [4]u8 { + var raw: [2]u8 = undefined; + std.crypto.random.bytes(&raw); + const hex = "0123456789abcdef"; + return [4]u8{ + hex[raw[0] >> 4], + hex[raw[0] & 0x0f], + hex[raw[1] >> 4], + hex[raw[1] & 0x0f], + }; +} + +// ═══════════════════════════════════════════════════════════════════════ +// Peer Operations +// ═══════════════════════════════════════════════════════════════════════ + +fn findPeerByToken(token_ptr: [*]const u8, token_len: usize) ?usize { + if (token_len != TOKEN_LEN) return null; + for (&peers, 0..) |*p, i| { + if (p.active and std.mem.eql(u8, &p.token, token_ptr[0..TOKEN_LEN])) { + return i; + } + } + return null; +} + +/// Find an active peer by its 4-char hex suffix. Returns index 0..MAX_PEERS-1 +/// or -1 if no match. Adapters use this to resolve a peer_id string like +/// "claude-7f3a" (suffix = "7f3a") to the FFI peer index expected by coord_send. +pub export fn coord_find_peer_by_suffix(suffix_ptr: [*]const u8) c_int { + mutex.lock(); + defer mutex.unlock(); + for (&peers, 0..) |*p, i| { + if (p.active and std.mem.eql(u8, &p.suffix, suffix_ptr[0..4])) { + return @intCast(i); + } + } + return -1; +} + +/// Read a peer's current status string. Writes up to out_cap bytes into out. +/// Returns status length on success, 0 if empty, -1 if peer index out of range. +/// Intended for coord_list_peers enrichment β€” the caller token is not required +/// because status is broadcast-visible by design. +pub export fn coord_read_peer_status(peer_idx: c_int, out: [*]u8, out_cap: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + if (peer_idx < 0 or peer_idx >= MAX_PEERS) return -1; + const p = &peers[@intCast(peer_idx)]; + if (!p.active) return -1; + const slen: usize = @min(@as(usize, p.status_len), @as(usize, @intCast(out_cap))); + if (slen > 0) @memcpy(out[0..slen], p.status[0..slen]); + return @intCast(slen); +} + +/// Read a peer's client_kind. Returns 0=claude 1=gemini 2=copilot 3=custom, +/// or -1 if peer index out of range / inactive. +pub export fn coord_read_peer_kind(peer_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + if (peer_idx < 0 or peer_idx >= MAX_PEERS) return -1; + const p = &peers[@intCast(peer_idx)]; + if (!p.active) return -1; + return @intFromEnum(p.kind); +} + +/// Default role for a client_kind when no explicit hint is given. +/// claude -> journeyman (trusted to act solo on Tier 2) +/// everything else (gemini, copilot, custom, openai, mistral) -> apprentice +fn defaultRoleForKind(kind: ClientKind) Role { + return switch (kind) { + .claude => .journeyman, + else => .apprentice, + }; +} + +/// Find the active master, if any. Returns the peer index or null. +fn findMaster() ?usize { + for (&peers, 0..) |*p, i| { + if (p.active and p.role == .master) return i; + } + return null; +} + +/// Register a new peer. Returns peer index, or -1 if full, -3 if the +/// caller tries to claim master via role_hint (use +/// coord_promote_to_master instead). +/// +/// role_hint = -1 (ROLE_HINT_DEFAULT): server assigns from kind +/// role_hint = 0 (master): REJECTED here β€” use coord_promote_to_master +/// role_hint = 1 (journeyman): granted journeyman role +/// role_hint = 2 (apprentice): granted apprentice role (self-downgrade) +pub export fn coord_register(kind: c_int, role_hint: c_int, token_out: [*]u8, suffix_out: [*]u8) c_int { + mutex.lock(); + defer mutex.unlock(); + + const client_kind: ClientKind = @enumFromInt(kind); + + // Resolve role. Master NEVER granted at register β€” must be + // promoted via coord_promote_to_master with env-var secret. + const role: Role = blk: { + if (role_hint == ROLE_HINT_DEFAULT) break :blk defaultRoleForKind(client_kind); + const r: Role = @enumFromInt(role_hint); + if (r == .master) return -3; + break :blk r; + }; + + for (&peers, 0..) |*p, i| { + if (!p.active) { + p.active = true; + p.kind = client_kind; + p.suffix = generateSuffix(); + p.state = .active; + p.token = generateToken(); + p.role = role; + p.inbox_head = 0; + p.inbox_tail = 0; + p.inbox_count = 0; + p.status_len = 0; + p.context_len = 0; // reset on slot reuse + p.declared_affinities_len = 0; + p.variant_len = 0; + p.class_csv_len = 0; + p.tier = TIER_UNSET; + p.prover_strengths_len = 0; + + @memcpy(token_out[0..TOKEN_LEN], &p.token); + @memcpy(suffix_out[0..4], &p.suffix); + + dur.logPeerAdd(@intCast(i), @intCast(@intFromEnum(client_kind)), @intCast(@intFromEnum(role)), &p.suffix, &p.token); + return @intCast(i); + } + } + return -1; // registry full +} + +/// Promote the caller's peer to master role. Gated by the +/// BOJ_MASTER_TOKEN env var (must be set, and presented secret must +/// match). At most one master at a time. `BOJ_SUPERVISOR_TOKEN` is +/// honoured as a fallback for one release (DD-32 backward-compat). +/// +/// Returns: +/// 0 β€” promoted +/// -1 β€” bad own token +/// -2 β€” master already exists +/// -3 β€” BOJ_MASTER_TOKEN (and deprecated BOJ_SUPERVISOR_TOKEN) not +/// set β€” server doesn't allow master role in this deployment +/// -4 β€” presented secret does not match env var +pub export fn coord_promote_to_master( + own_token_ptr: [*]const u8, + own_token_len: c_int, + secret_ptr: [*]const u8, + secret_len: c_int, +) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx = findPeerByToken(own_token_ptr, @intCast(own_token_len)) orelse return -1; + if (findMaster() != null) return -2; + + // Env-var check β€” BOJ_MASTER_TOKEN first, BOJ_SUPERVISOR_TOKEN fallback + // for one release (DD-32). Read at promotion time so a running server + // can have its policy changed by restart. + const env_secret = std.posix.getenv("BOJ_MASTER_TOKEN") orelse + std.posix.getenv("BOJ_SUPERVISOR_TOKEN") orelse return -3; + if (env_secret.len == 0) return -3; + + const slen: usize = @intCast(secret_len); + if (slen != env_secret.len) return -4; + // Constant-time compare β€” defence against timing oracles even though + // we're loopback-only. + var diff: u8 = 0; + var k: usize = 0; + while (k < slen) : (k += 1) { + diff |= env_secret[k] ^ secret_ptr[k]; + } + if (diff != 0) return -4; + + peers[idx].role = .master; + dur.logPeerRoleSet(@intCast(idx), @intCast(@intFromEnum(Role.master))); + return 0; +} + +/// Backward-compat alias for the 2026-04-20 rename (DD-32). Old MCP +/// clients call this name; it forwards to coord_promote_to_master. +/// Remove one release after DD-32 lands. +pub export fn coord_promote_to_supervisor( + own_token_ptr: [*]const u8, + own_token_len: c_int, + secret_ptr: [*]const u8, + secret_len: c_int, +) c_int { + return coord_promote_to_master(own_token_ptr, own_token_len, secret_ptr, secret_len); +} + +/// Live master handoff (Task #35) β€” pass the master role to a named +/// successor without a process restart. Secret-gated by BOJ_MASTER_TOKEN +/// (BOJ_SUPERVISOR_TOKEN fallback for one release). Target must be +/// journeyman or already master β€” apprentices are rejected (prevents +/// hostile handoff to an untrusted peer). Audit-logged so replay +/// reconstructs the transfer. +/// +/// Returns: +/// 0 promoted successor + demoted self to journeyman +/// -1 caller not master / bad token +/// -2 target peer not found / inactive +/// -3 secret mismatch (or env var unset) +/// -4 target is apprentice β€” blocked, must be journeyman+ +pub export fn coord_transfer_master( + current_master_token_ptr: [*]const u8, + current_master_token_len: c_int, + target_peer_idx: c_int, + secret_ptr: [*]const u8, + secret_len: c_int, +) c_int { + mutex.lock(); + defer mutex.unlock(); + + const cur_idx = findPeerByToken(current_master_token_ptr, @intCast(current_master_token_len)) orelse return -1; + if (peers[cur_idx].role != .master) return -1; + + if (target_peer_idx < 0 or target_peer_idx >= MAX_PEERS) return -2; + const tidx: usize = @intCast(target_peer_idx); + const target = &peers[tidx]; + if (!target.active) return -2; + if (target.role == .apprentice) return -4; + if (tidx == cur_idx) return -2; // handoff to self is a no-op, treat as bad target + + // Secret must match BOJ_MASTER_TOKEN (or BOJ_SUPERVISOR_TOKEN as + // back-compat fallback). Fail closed if neither is set. + const env_secret = std.posix.getenv("BOJ_MASTER_TOKEN") + orelse std.posix.getenv("BOJ_SUPERVISOR_TOKEN") + orelse return -3; + if (env_secret.len == 0) return -3; + + const slen: usize = @intCast(secret_len); + if (slen != env_secret.len) return -3; + var diff: u8 = 0; + var k: usize = 0; + while (k < slen) : (k += 1) { + diff |= env_secret[k] ^ secret_ptr[k]; + } + if (diff != 0) return -3; + + // Atomic pair: demote caller to journeyman, promote target to master. + peers[cur_idx].role = .journeyman; + dur.logPeerRoleSet(@intCast(cur_idx), @intCast(@intFromEnum(Role.journeyman))); + target.role = .master; + dur.logPeerRoleSet(@intCast(tidx), @intCast(@intFromEnum(Role.master))); + + // Audit breadcrumb β€” kind=2 reserved for MASTER_HANDOFF. + var detail_buf: [64]u8 = undefined; + const detail = std.fmt.bufPrint(&detail_buf, "from={d}|to={d}", .{ cur_idx, tidx }) catch ""; + dur.logAudit(2, detail); + return 0; +} + +/// Read a peer's role. Returns 0=master, 1=journeyman, 2=apprentice, +/// or -1 if peer index out of range / inactive. +pub export fn coord_read_peer_role(peer_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + if (peer_idx < 0 or peer_idx >= MAX_PEERS) return -1; + const p = &peers[@intCast(peer_idx)]; + if (!p.active) return -1; + return @intFromEnum(p.role); +} + +/// Re-assign a peer's role. Only callable by an active master (token +/// must belong to role=master). Returns 0 on success, -1 on bad +/// master token, -2 on bad target, -3 on disallowed transition +/// (e.g. demoting the sole master). +pub export fn coord_set_role( + master_token_ptr: [*]const u8, + master_token_len: c_int, + target_peer_idx: c_int, + new_role: c_int, +) c_int { + mutex.lock(); + defer mutex.unlock(); + + const sup_idx = findPeerByToken(master_token_ptr, @intCast(master_token_len)) orelse return -1; + if (peers[sup_idx].role != .master) return -1; + + if (target_peer_idx < 0 or target_peer_idx >= MAX_PEERS) return -2; + const target = &peers[@intCast(target_peer_idx)]; + if (!target.active) return -2; + + const nr: Role = @enumFromInt(new_role); + + // Forbid demoting the only master. + if (target.role == .master and nr != .master) { + var other_sup: bool = false; + for (&peers, 0..) |*p, i| { + if (i == @as(usize, @intCast(target_peer_idx))) continue; + if (p.active and p.role == .master) { other_sup = true; break; } + } + if (!other_sup) return -3; + } + + target.role = nr; + dur.logPeerRoleSet(@intCast(target_peer_idx), @intCast(@intFromEnum(nr))); + return 0; +} + +/// Set a context disambiguator for this peer (repo name, tty hash, window +/// label). Must be alphanumeric or hyphen, max MAX_CONTEXT bytes β€” anything +/// else returns -2 and the existing context is untouched. +pub export fn coord_set_context( + token_ptr: [*]const u8, + token_len: c_int, + ctx_ptr: [*]const u8, + ctx_len: c_int, +) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx = findPeerByToken(token_ptr, @intCast(token_len)) orelse return -1; + const clen: usize = @intCast(ctx_len); + if (clen > MAX_CONTEXT) return -2; + + // Validate: alphanum + hyphen + underscore only. + var k: usize = 0; + while (k < clen) : (k += 1) { + const c = ctx_ptr[k]; + const ok = (c >= '0' and c <= '9') or (c >= 'a' and c <= 'z') or + (c >= 'A' and c <= 'Z') or c == '-' or c == '_'; + if (!ok) return -2; + } + + if (clen > 0) @memcpy(peers[idx].context[0..clen], ctx_ptr[0..clen]); + peers[idx].context_len = @intCast(clen); + dur.logPeerContextSet(@intCast(idx), ctx_ptr[0..clen]); + return 0; +} + +/// Read a peer's context disambiguator. Writes up to out_cap bytes into out. +/// Returns context length on success, 0 if unset, -1 if peer index out of +/// range / inactive. Caller token is not required β€” context is broadcast- +/// visible by design (it's how other peers identify which window this is). +pub export fn coord_read_peer_context(peer_idx: c_int, out: [*]u8, out_cap: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + if (peer_idx < 0 or peer_idx >= MAX_PEERS) return -1; + const p = &peers[@intCast(peer_idx)]; + if (!p.active) return -1; + const clen: usize = @min(@as(usize, p.context_len), @as(usize, @intCast(out_cap))); + if (clen > 0) @memcpy(out[0..clen], p.context[0..clen]); + return @intCast(clen); +} + +// ═══════════════════════════════════════════════════════════════════════ +// Task #33 β€” variant label (model / variant identifier) +// ═══════════════════════════════════════════════════════════════════════ + +/// Set a free-form model/variant label for this peer (e.g. "opus-4.7", +/// "flash-2.5", "leanstral"). Alphanumeric + `.`/`-`/`_` only, max +/// MAX_VARIANT bytes; anything else returns -2 and the existing variant +/// is untouched. Empty (len=0) clears the variant. +pub export fn coord_set_variant( + token_ptr: [*]const u8, + token_len: c_int, + variant_ptr: [*]const u8, + variant_len: c_int, +) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx = findPeerByToken(token_ptr, @intCast(token_len)) orelse return -1; + const vlen: usize = @intCast(variant_len); + if (vlen > MAX_VARIANT) return -2; + + var k: usize = 0; + while (k < vlen) : (k += 1) { + const c = variant_ptr[k]; + const ok = (c >= '0' and c <= '9') or (c >= 'a' and c <= 'z') or + (c >= 'A' and c <= 'Z') or c == '-' or c == '_' or c == '.'; + if (!ok) return -2; + } + + if (vlen > 0) @memcpy(peers[idx].variant[0..vlen], variant_ptr[0..vlen]); + peers[idx].variant_len = @intCast(vlen); + dur.logPeerVariantSet(@intCast(idx), variant_ptr[0..vlen]); + return 0; +} + +/// Read a peer's variant label. Writes up to out_cap bytes into out. +/// Returns variant length on success, 0 if unset, -1 if peer index out of +/// range / inactive. Caller token is not required β€” variant is broadcast- +/// visible by design (other peers use it for cold-start routing). +pub export fn coord_read_peer_variant(peer_idx: c_int, out: [*]u8, out_cap: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + if (peer_idx < 0 or peer_idx >= MAX_PEERS) return -1; + const p = &peers[@intCast(peer_idx)]; + if (!p.active) return -1; + const vlen: usize = @min(@as(usize, p.variant_len), @as(usize, @intCast(out_cap))); + if (vlen > 0) @memcpy(out[0..vlen], p.variant[0..vlen]); + return @intCast(vlen); +} + +// ═══════════════════════════════════════════════════════════════════════ +// Task #34 β€” capability advertisement (class / tier / prover_strengths) +// ═══════════════════════════════════════════════════════════════════════ + +/// Set this peer's advertised capabilities in one shot. +/// class β€” CSV of capability classes (e.g. "reasoning,coding"). +/// Max MAX_CLASS bytes. Empty clears. +/// tier β€” 0 (unset) or 1..5 (advertised tier). >5 is clamped-reject (-2). +/// provers β€” CSV of prover strengths (e.g. "coq,lean"). Max MAX_PROVERS bytes. +/// +/// Validation is minimal (length + tier range) so the engine can treat +/// the fields as opaque hints; the reassignment loop does the semantic +/// cross-check against track_record later. +/// +/// Returns 0 on success, -1 on bad token, -2 on range/length violation. +pub export fn coord_set_capabilities( + token_ptr: [*]const u8, + token_len: c_int, + class_ptr: [*]const u8, + class_len: c_int, + tier: c_int, + provers_ptr: [*]const u8, + provers_len: c_int, +) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx = findPeerByToken(token_ptr, @intCast(token_len)) orelse return -1; + const clen: usize = @intCast(class_len); + const plen: usize = @intCast(provers_len); + if (clen > MAX_CLASS) return -2; + if (plen > MAX_PROVERS) return -2; + if (tier < 0 or tier > 5) return -2; + + // Reject bytes that would break CSV or JSON rendering. Allow the + // union of [A-Za-z0-9], `.`, `-`, `_`, `+`, `/`, and `,` (the CSV + // separator itself). + var k: usize = 0; + while (k < clen) : (k += 1) { + const c = class_ptr[k]; + const ok = (c >= '0' and c <= '9') or (c >= 'a' and c <= 'z') or + (c >= 'A' and c <= 'Z') or c == '-' or c == '_' or c == '.' or + c == '+' or c == '/' or c == ','; + if (!ok) return -2; + } + k = 0; + while (k < plen) : (k += 1) { + const c = provers_ptr[k]; + const ok = (c >= '0' and c <= '9') or (c >= 'a' and c <= 'z') or + (c >= 'A' and c <= 'Z') or c == '-' or c == '_' or c == '.' or + c == '+' or c == '/' or c == ','; + if (!ok) return -2; + } + + const p = &peers[idx]; + if (clen > 0) @memcpy(p.class_csv[0..clen], class_ptr[0..clen]); + p.class_csv_len = @intCast(clen); + p.tier = @intCast(tier); + if (plen > 0) @memcpy(p.prover_strengths[0..plen], provers_ptr[0..plen]); + p.prover_strengths_len = @intCast(plen); + + dur.logPeerCapabilitiesSet( + @intCast(idx), + p.tier, + class_ptr[0..clen], + provers_ptr[0..plen], + ); + return 0; +} + +/// Read a peer's class CSV. Returns length, 0 if unset, -1 if idx invalid. +pub export fn coord_read_peer_class(peer_idx: c_int, out: [*]u8, out_cap: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + if (peer_idx < 0 or peer_idx >= MAX_PEERS) return -1; + const p = &peers[@intCast(peer_idx)]; + if (!p.active) return -1; + const clen: usize = @min(@as(usize, p.class_csv_len), @as(usize, @intCast(out_cap))); + if (clen > 0) @memcpy(out[0..clen], p.class_csv[0..clen]); + return @intCast(clen); +} + +/// Read a peer's advertised tier. Returns 0 (unset) or 1..5, or -1 if idx invalid. +pub export fn coord_read_peer_tier(peer_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + if (peer_idx < 0 or peer_idx >= MAX_PEERS) return -1; + const p = &peers[@intCast(peer_idx)]; + if (!p.active) return -1; + return @intCast(p.tier); +} + +/// Read a peer's prover-strengths CSV. Returns length, 0 if unset, -1 if idx invalid. +pub export fn coord_read_peer_provers(peer_idx: c_int, out: [*]u8, out_cap: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + if (peer_idx < 0 or peer_idx >= MAX_PEERS) return -1; + const p = &peers[@intCast(peer_idx)]; + if (!p.active) return -1; + const plen: usize = @min(@as(usize, p.prover_strengths_len), @as(usize, @intCast(out_cap))); + if (plen > 0) @memcpy(out[0..plen], p.prover_strengths[0..plen]); + return @intCast(plen); +} + +// ═══════════════════════════════════════════════════════════════════════ +// Health metrics β€” read-only aggregates over live registries. Exposed so +// the adapter can render `coord_health` without knowing the registry +// layout. All functions take the caller's token and return -1 on auth +// failure so individual peers can poll without a master gate but rogue +// local processes can't. +// ═══════════════════════════════════════════════════════════════════════ + +fn validateToken(token_ptr: [*]const u8, token_len: c_int) bool { + return findPeerByToken(token_ptr, @intCast(token_len)) != null; +} + +/// Count active quarantine entries. Returns count, -1 on bad token. +pub export fn coord_count_quarantine(token_ptr: [*]const u8, token_len: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + if (!validateToken(token_ptr, token_len)) return -1; + var n: c_int = 0; + for (&quarantine) |*q| { + if (q.active) n += 1; + } + return n; +} + +/// Count active claims. Returns count, -1 on bad token. +pub export fn coord_count_claims(token_ptr: [*]const u8, token_len: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + if (!validateToken(token_ptr, token_len)) return -1; + var n: c_int = 0; + for (&claims) |*c| { + if (c.active) n += 1; + } + return n; +} + +/// Read the task name of active claim at slot `claim_idx` (0-based over all 64 slots). +/// Writes the task name into `out` (max `out_cap` bytes). Returns bytes written, or +/// -1 if token invalid / slot inactive / out of range. +/// Use alongside coord_count_claims to iterate: call with idx 0..MAX_CLAIMS-1, +/// skip -1 results. +pub export fn coord_read_claim_task(token_ptr: [*]const u8, token_len: c_int, claim_idx: c_int, out: [*]u8, out_cap: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + if (!validateToken(token_ptr, token_len)) return -1; + if (claim_idx < 0 or claim_idx >= MAX_CLAIMS) return -1; + const c = &claims[@intCast(claim_idx)]; + if (!c.active) return -1; + const len: usize = @min(@as(usize, c.task_name_len), @as(usize, @intCast(out_cap))); + if (len > 0) @memcpy(out[0..len], c.task_name[0..len]); + return @intCast(len); +} + +/// Read the peer suffix (4-byte printable hex) of the holder of active claim `claim_idx`. +/// Writes into `out` (must be >= 4 bytes). Returns 4 on success, -1 otherwise. +pub export fn coord_read_claim_holder_suffix(token_ptr: [*]const u8, token_len: c_int, claim_idx: c_int, out: [*]u8) c_int { + mutex.lock(); + defer mutex.unlock(); + if (!validateToken(token_ptr, token_len)) return -1; + if (claim_idx < 0 or claim_idx >= MAX_CLAIMS) return -1; + const c = &claims[@intCast(claim_idx)]; + if (!c.active) return -1; + const p = &peers[c.holder_idx]; + if (!p.active) return -1; + @memcpy(out[0..4], &p.suffix); + return 4; +} + +/// Count track-record entries in the ring (saturates at MAX_TRACK). +/// Returns count, -1 on bad token. +pub export fn coord_count_track(token_ptr: [*]const u8, token_len: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + if (!validateToken(token_ptr, token_len)) return -1; + return @intCast(track_count); +} + +/// Count rejections in the current window for the given client_kind. +/// Returns count, -1 on bad token, -2 on bad kind. +pub export fn coord_count_rejects_recent( + token_ptr: [*]const u8, + token_len: c_int, + kind: c_int, +) c_int { + mutex.lock(); + defer mutex.unlock(); + if (!validateToken(token_ptr, token_len)) return -1; + if (kind < 0 or kind >= KIND_COUNT) return -2; + const k: usize = @intCast(kind); + const now_ms: u64 = @intCast(std.time.milliTimestamp()); + var n: c_int = 0; + for (reject_ring[k]) |ts| { + if (ts == 0) continue; + if (now_ms > ts and (now_ms - ts) > REJECT_WINDOW_MS) continue; + n += 1; + } + return n; +} + +/// Returns 1 if the given client_kind is currently in reject-cooldown, +/// 0 otherwise, -1 on bad token, -2 on bad kind. +pub export fn coord_kind_in_cooldown( + token_ptr: [*]const u8, + token_len: c_int, + kind: c_int, +) c_int { + mutex.lock(); + defer mutex.unlock(); + if (!validateToken(token_ptr, token_len)) return -1; + if (kind < 0 or kind >= KIND_COUNT) return -2; + const ck: ClientKind = @enumFromInt(kind); + const now_ms: u64 = @intCast(std.time.milliTimestamp()); + return if (isInCooldown(ck, now_ms)) 1 else 0; +} + +/// Count rejections in the current window for the given peer slot (Item A). +/// Returns count, -1 on bad token, -2 on bad/inactive peer_idx. +pub export fn coord_count_rejects_recent_peer( + token_ptr: [*]const u8, + token_len: usize, + peer_idx: c_int, +) c_int { + mutex.lock(); + defer mutex.unlock(); + if (findPeerByToken(token_ptr, token_len) == null) return -1; + if (peer_idx < 0 or peer_idx >= MAX_PEERS) return -2; + const pi: usize = @intCast(peer_idx); + if (!peers[pi].active) return -2; + const now_ms: u64 = @intCast(std.time.milliTimestamp()); + var n: c_int = 0; + for (peer_reject_ring[pi]) |ts| { + if (ts == 0) continue; + if (now_ms > ts and (now_ms - ts) > REJECT_WINDOW_MS) continue; + n += 1; + } + return n; +} + +/// Returns 1 if peer_idx is currently in reject-cooldown, 0 otherwise, +/// -1 on bad token, -2 on bad/inactive peer_idx (Item A). +pub export fn coord_peer_in_cooldown( + token_ptr: [*]const u8, + token_len: usize, + peer_idx: c_int, +) c_int { + mutex.lock(); + defer mutex.unlock(); + if (findPeerByToken(token_ptr, token_len) == null) return -1; + if (peer_idx < 0 or peer_idx >= MAX_PEERS) return -2; + const pi: usize = @intCast(peer_idx); + if (!peers[pi].active) return -2; + const now_ms: u64 = @intCast(std.time.milliTimestamp()); + return if (isPeerInCooldown(pi, now_ms)) 1 else 0; +} + +/// Read a peer's 4-char hex suffix into out (must be at least 4 bytes). +/// Returns 4 on success, -1 if peer_idx is out of range or inactive. +pub export fn coord_read_peer_suffix(peer_idx: c_int, out: [*]u8) c_int { + mutex.lock(); + defer mutex.unlock(); + if (peer_idx < 0 or peer_idx >= MAX_PEERS) return -1; + const p = &peers[@intCast(peer_idx)]; + if (!p.active) return -1; + @memcpy(out[0..4], &p.suffix); + return 4; +} + +/// Deregister a peer. Releases any claims it holds. +pub export fn coord_deregister(token_ptr: [*]const u8, token_len: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx = findPeerByToken(token_ptr, @intCast(token_len)) orelse return -1; + + // Release all claims held by this peer + for (&claims, 0..) |*c, ci| { + if (c.active and c.holder_idx == @as(u8, @intCast(idx))) { + c.active = false; + dur.logClaimRel(@intCast(ci)); + } + } + + peers[idx].active = false; + peers[idx].state = .gone; + peer_reject_ring[idx] = [_]u64{0} ** REJECT_LIMIT; + peer_reject_head[idx] = 0; + dur.logPeerRemove(@intCast(idx)); + return 0; +} + +/// List active peers. Writes peer info into out buffer as a series of +/// (kind: i32, suffix: [4]u8, state: i32) = 12 bytes per peer. +/// Returns number of active peers written. +pub export fn coord_list_peers(token_ptr: [*]const u8, token_len: c_int, out: [*]u8, out_cap: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + // Validate caller token + if (findPeerByToken(token_ptr, @intCast(token_len)) == null) return -1; + + var written: usize = 0; + const cap: usize = @intCast(out_cap); + + for (&peers) |*p| { + if (p.active and (written + 12) <= cap) { + const offset = written; + // kind (4 bytes, little-endian i32) + const kind_bytes: [4]u8 = @bitCast(@intFromEnum(p.kind)); + @memcpy(out[offset .. offset + 4], &kind_bytes); + // suffix (4 bytes) + @memcpy(out[offset + 4 .. offset + 8], &p.suffix); + // state (4 bytes, little-endian i32) + const state_bytes: [4]u8 = @bitCast(@intFromEnum(p.state)); + @memcpy(out[offset + 8 .. offset + 12], &state_bytes); + written += 12; + } + } + return @intCast(written / 12); +} + +/// Send a message to a specific peer (by index) or broadcast (target = -1). +pub export fn coord_send( + token_ptr: [*]const u8, + token_len: c_int, + target_idx: c_int, + msg_ptr: [*]const u8, + msg_len: c_int, +) c_int { + mutex.lock(); + defer mutex.unlock(); + + const sender_idx = findPeerByToken(token_ptr, @intCast(token_len)) orelse return -1; + const mlen: usize = @intCast(@min(msg_len, 512)); + + if (target_idx == -1) { + // Broadcast to all active peers except sender + var sent: c_int = 0; + for (&peers, 0..) |*p, i| { + if (p.active and i != sender_idx and p.inbox_count < MAX_MESSAGES) { + const head: usize = p.inbox_head; + @memcpy(p.inbox[head][0..mlen], msg_ptr[0..mlen]); + p.inbox_lens[head] = @intCast(mlen); + p.inbox_head = @intCast((@as(u32, p.inbox_head) + 1) % MAX_MESSAGES); + p.inbox_count += 1; + dur.logInboxPush(@intCast(i), msg_ptr[0..mlen]); + sent += 1; + } + } + return sent; + } else { + // Direct message + if (target_idx < 0 or target_idx >= MAX_PEERS) return -2; + const tidx: usize = @intCast(target_idx); + const target = &peers[tidx]; + if (!target.active) return -2; + if (target.inbox_count >= MAX_MESSAGES) return -3; // inbox full + + const head: usize = target.inbox_head; + @memcpy(target.inbox[head][0..mlen], msg_ptr[0..mlen]); + target.inbox_lens[head] = @intCast(mlen); + target.inbox_head = @intCast((@as(u32, target.inbox_head) + 1) % MAX_MESSAGES); + target.inbox_count += 1; + dur.logInboxPush(@intCast(tidx), msg_ptr[0..mlen]); + return 1; + } +} + +/// Receive the next message from this peer's inbox. +/// Writes message into msg_out, returns message length, or 0 if empty, -1 if bad token. +pub export fn coord_receive( + token_ptr: [*]const u8, + token_len: c_int, + msg_out: [*]u8, + msg_cap: c_int, +) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx = findPeerByToken(token_ptr, @intCast(token_len)) orelse return -1; + const peer = &peers[idx]; + + if (peer.inbox_count == 0) return 0; + + const tail: usize = peer.inbox_tail; + const mlen: usize = @min(@as(usize, peer.inbox_lens[tail]), @as(usize, @intCast(msg_cap))); + @memcpy(msg_out[0..mlen], peer.inbox[tail][0..mlen]); + peer.inbox_tail = @intCast((@as(u32, peer.inbox_tail) + 1) % MAX_MESSAGES); + peer.inbox_count -= 1; + dur.logInboxPop(@intCast(idx)); + return @intCast(mlen); +} + +// ═══════════════════════════════════════════════════════════════════════ +// Rejection cooldown (Task #15) β€” per client_kind, enforce a short +// cooldown after a burst of claim rejections to blunt runaway peers. +// +// Policy: 5 rejections within REJECT_WINDOW_MS (10 min) trigger a +// COOLDOWN_MS (30 s) freeze from the 5th rejection's timestamp. During +// cooldown the server returns a dedicated "cooldown" result so callers +// can back off rather than tight-looping. +// ═══════════════════════════════════════════════════════════════════════ + +const REJECT_WINDOW_MS: u64 = 10 * 60 * 1000; +const REJECT_LIMIT: usize = 5; +const COOLDOWN_MS: u64 = 30 * 1000; + +// Timestamps of the most recent rejections per client_kind, as a small +// ring. ClientKind enum has 6 variants β€” one slot each. +// (Task #33: extended from 4 β†’ 6 for openai + mistral.) +const KIND_COUNT: usize = 6; +var reject_ring: [KIND_COUNT][REJECT_LIMIT]u64 = [_][REJECT_LIMIT]u64{[_]u64{0} ** REJECT_LIMIT} ** KIND_COUNT; +var reject_head: [KIND_COUNT]usize = [_]usize{0} ** KIND_COUNT; + +// Per-peer rejection ring (Item A β€” per-peer reject tracking). +// 16 peers Γ— 5 slots Γ— 8 bytes = 640 bytes overhead. +var peer_reject_ring: [MAX_PEERS][REJECT_LIMIT]u64 = [_][REJECT_LIMIT]u64{[_]u64{0} ** REJECT_LIMIT} ** MAX_PEERS; +var peer_reject_head: [MAX_PEERS]usize = [_]usize{0} ** MAX_PEERS; + +fn isInCooldown(kind: ClientKind, now_ms: u64) bool { + const k: usize = @intCast(@intFromEnum(kind)); + if (k >= KIND_COUNT) return false; + const ring = &reject_ring[k]; + // Count rejections within the window. + var count: usize = 0; + var newest: u64 = 0; + for (ring) |ts| { + if (ts == 0) continue; + if (now_ms > ts and (now_ms - ts) > REJECT_WINDOW_MS) continue; + count += 1; + if (ts > newest) newest = ts; + } + if (count < REJECT_LIMIT) return false; + if (now_ms > newest and (now_ms - newest) >= COOLDOWN_MS) return false; + return true; +} + +fn recordRejection(kind: ClientKind, now_ms: u64) void { + const k: usize = @intCast(@intFromEnum(kind)); + if (k >= KIND_COUNT) return; + const h = reject_head[k]; + reject_ring[k][h] = now_ms; + reject_head[k] = (h + 1) % REJECT_LIMIT; +} + +fn isPeerInCooldown(peer_idx: usize, now_ms: u64) bool { + if (peer_idx >= MAX_PEERS) return false; + const ring = &peer_reject_ring[peer_idx]; + var count: usize = 0; + var newest: u64 = 0; + for (ring) |ts| { + if (ts == 0) continue; + if (now_ms > ts and (now_ms - ts) > REJECT_WINDOW_MS) continue; + count += 1; + if (ts > newest) newest = ts; + } + if (count < REJECT_LIMIT) return false; + if (now_ms > newest and (now_ms - newest) >= COOLDOWN_MS) return false; + return true; +} + +fn recordPeerRejection(peer_idx: usize, now_ms: u64) void { + if (peer_idx >= MAX_PEERS) return; + const h = peer_reject_head[peer_idx]; + peer_reject_ring[peer_idx][h] = now_ms; + peer_reject_head[peer_idx] = (h + 1) % REJECT_LIMIT; +} + +/// Attempt to claim a task. Returns ClaimResult encoding. +pub export fn coord_claim_task( + token_ptr: [*]const u8, + token_len: c_int, + task_ptr: [*]const u8, + task_len: c_int, +) c_int { + return coord_claim_task_ex(token_ptr, token_len, task_ptr, task_len, -1, -1, -1); +} + +/// Dispatch-preference constants shared with the envelope schema. +pub const DispatchPref = enum(c_int) { + deliberate = 0, + broadcast = 1, + auto = 2, +}; + +pub const TaskDifficulty = enum(c_int) { + trivial = 0, + routine = 1, + challenging = 2, + novel = 3, +}; + +/// Extended claim β€” carries the sender's own confidence (0-100 %), +/// dispatch preference, and task difficulty. All three are optional +/// (-1 for unset). Return codes match coord_claim_task: +/// 0 = granted +/// 1 = held by another peer +/// 2 = no claim slot +/// -1 = bad token +/// -5 = rejection cooldown in effect for this client_kind +pub export fn coord_claim_task_ex( + token_ptr: [*]const u8, + token_len: c_int, + task_ptr: [*]const u8, + task_len: c_int, + confidence_pct: c_int, // 0..100, or -1 for unset + dispatch_pref: c_int, // DispatchPref, or -1 for auto-derive + task_difficulty: c_int, // TaskDifficulty, or -1 if unknown +) c_int { + _ = dispatch_pref; // schema-level field; server records but doesn't gate on it + _ = task_difficulty; // likewise + _ = confidence_pct; // recorded via coord_report_outcome; here it's metadata only + + mutex.lock(); + defer mutex.unlock(); + + const idx = findPeerByToken(token_ptr, @intCast(token_len)) orelse return -1; + const tlen: usize = @intCast(@min(task_len, 128)); + + const now_ms: u64 = @intCast(std.time.milliTimestamp()); + const kind = peers[idx].kind; + if (isInCooldown(kind, now_ms)) return -5; + if (isPeerInCooldown(idx, now_ms)) return -5; + + // Watchdog sweep piggybacks on claim contention β€” the natural moment + // an abandoned claim matters. DD-20: apprentice = 30 s, journeyman = + // 5 min, master no TTL. Releases happen before contention is checked + // so a freshly-expired slot becomes available to this caller. + _ = sweepExpiredClaims(now_ms); + + // Check if already claimed + for (&claims) |*c| { + if (c.active and c.task_name_len == @as(u8, @intCast(tlen)) and + std.mem.eql(u8, c.task_name[0..tlen], task_ptr[0..tlen])) + { + if (c.holder_idx == @as(u8, @intCast(idx))) { + c.claimed_at_ms = now_ms; // re-claim resets TTL + return 0; // Already held by caller β€” idempotent grant + } + recordRejection(kind, now_ms); + recordPeerRejection(idx, now_ms); + return 1; // Held by another peer + } + } + + // Find an empty claim slot + for (&claims, 0..) |*c, ci| { + if (!c.active) { + c.active = true; + @memcpy(c.task_name[0..tlen], task_ptr[0..tlen]); + c.task_name_len = @intCast(tlen); + c.holder_idx = @intCast(idx); + c.claimed_at_ms = now_ms; + dur.logClaimAdd(@intCast(ci), @intCast(idx), task_ptr[0..tlen]); + return 0; // Granted + } + } + recordRejection(kind, now_ms); + recordPeerRejection(idx, now_ms); + return 2; // No slots available (treated as NotFound) +} + +// ═══════════════════════════════════════════════════════════════════════ +// Watchdog (DD-20) β€” auto-release claims whose holder missed the TTL. +// ═══════════════════════════════════════════════════════════════════════ + +/// Sweep all active claims, auto-releasing any that have exceeded the +/// TTL for their holder's role. Returns the number of claims released. +/// Caller must already hold `mutex`. +/// +/// Master claims are never swept (TTL=0). Inactive-holder claims (peer +/// deregistered without releasing) are cleared by coord_deregister, so +/// the sweep just treats their slots as already free. +// Audit event kinds used by the watchdog path (DD-20 / DD-21). +// 1 = tier3-from-supervised, 2 = master-transfer (defined elsewhere). +const AUDIT_AUTO_RELEASE: u8 = 3; +const AUDIT_WARN_DRIFT_QUEUED: u8 = 4; +const AUDIT_WARN_DRIFT_BROADCAST: u8 = 5; + +fn roleStr(r: Role) []const u8 { + return switch (r) { + .master => "master", + .journeyman => "journeyman", + .apprentice => "apprentice", + }; +} + +/// DD-21: when the watchdog auto-releases a claim, broadcast a `warn_drift` +/// envelope so the rest of the coordination pool notices the stalled peer. +/// Route through quarantine (sender_idx = server-origin sentinel) if a +/// master is present; otherwise push directly into every active peer's +/// inbox so the warning still lands in master-less local sessions. +fn emitWarnDrift( + holder_idx: u8, + role: Role, + task: []const u8, + held_ms: u64, +) void { + const holder = &peers[holder_idx]; + if (!holder.active) return; + + const kind_name = kindStr(@intCast(@intFromEnum(holder.kind))); + const role_name = roleStr(role); + const ctx_slice = holder.context[0..holder.context_len]; + + var env_buf: [512]u8 = undefined; + const env = blk: { + if (ctx_slice.len > 0) { + break :blk std.fmt.bufPrint(&env_buf, + "{{\"kind\":\"warn_drift\",\"op_kind\":\"warn\",\"risk_tier\":1," ++ + "\"peer_id\":\"{s}-{s}@{s}\",\"peer_kind\":\"{s}\"," ++ + "\"task\":\"{s}\",\"held_ms\":{d},\"role\":\"{s}\"," ++ + "\"rationale\":\"watchdog auto-release: peer held claim past TTL without heartbeat\"}}", + .{ kind_name, holder.suffix[0..], ctx_slice, kind_name, task, held_ms, role_name }, + ) catch return; + } else { + break :blk std.fmt.bufPrint(&env_buf, + "{{\"kind\":\"warn_drift\",\"op_kind\":\"warn\",\"risk_tier\":1," ++ + "\"peer_id\":\"{s}-{s}\",\"peer_kind\":\"{s}\"," ++ + "\"task\":\"{s}\",\"held_ms\":{d},\"role\":\"{s}\"," ++ + "\"rationale\":\"watchdog auto-release: peer held claim past TTL without heartbeat\"}}", + .{ kind_name, holder.suffix[0..], kind_name, task, held_ms, role_name }, + ) catch return; + } + }; + + if (findMaster() != null) { + const rid = enqueueServerSuggestion(-1, 1, env); + if (rid >= 0) dur.logAudit(AUDIT_WARN_DRIFT_QUEUED, env); + return; + } + + // Master absent β€” direct broadcast to every active peer except the holder. + var pushed: c_int = 0; + for (&peers, 0..) |*p, i| { + if (!p.active) continue; + if (i == holder_idx) continue; + if (p.inbox_count >= MAX_MESSAGES) continue; + const head: usize = p.inbox_head; + @memcpy(p.inbox[head][0..env.len], env); + p.inbox_lens[head] = @intCast(env.len); + p.inbox_head = @intCast((@as(u32, p.inbox_head) + 1) % MAX_MESSAGES); + p.inbox_count += 1; + dur.logInboxPush(@intCast(i), env); + pushed += 1; + } + if (pushed > 0) dur.logAudit(AUDIT_WARN_DRIFT_BROADCAST, env); +} + +fn sweepExpiredClaims(now_ms: u64) c_int { + var released: c_int = 0; + for (&claims, 0..) |*c, ci| { + if (!c.active) continue; + const holder = &peers[c.holder_idx]; + if (!holder.active) { + // Shouldn't normally happen (deregister releases), but be safe. + c.active = false; + dur.logClaimRel(@intCast(ci)); + released += 1; + continue; + } + const ttl = watchdogTtlMs(holder.role); + if (ttl == 0) continue; // master or unknown β€” no watchdog + if (c.claimed_at_ms == 0) continue; // replayed without timestamp; skip first pass + if (now_ms <= c.claimed_at_ms) continue; // clock skew guard + if ((now_ms - c.claimed_at_ms) <= ttl) continue; + + // Auto-release. + const age_ms = now_ms - c.claimed_at_ms; + const task_slice = c.task_name[0..c.task_name_len]; + const holder_role = holder.role; + const holder_idx_copy = c.holder_idx; + + var detail_buf: [128]u8 = undefined; + const detail = std.fmt.bufPrint(&detail_buf, + "claim={d}|holder={d}|role={d}|age_ms={d}|task={s}", + .{ ci, c.holder_idx, @intFromEnum(holder_role), age_ms, task_slice }, + ) catch ""; + c.active = false; + dur.logClaimRel(@intCast(ci)); + dur.logAudit(AUDIT_AUTO_RELEASE, detail); + + // DD-21: warn-drift broadcast so the pool sees the stalled peer. + // Emitted after the auto-release audit so the release is durable + // even if the warning path fails to serialize. + emitWarnDrift(holder_idx_copy, holder_role, task_slice, age_ms); + released += 1; + } + return released; +} + +/// Heartbeat: bump the claimed_at_ms of a claim held by the caller so +/// long-running work doesn't get swept. The caller must be the current +/// holder; other peers cannot refresh someone else's claim. +/// +/// Returns 0 OK, -1 bad token, -2 no matching claim, -3 not the holder. +pub export fn coord_progress( + token_ptr: [*]const u8, + token_len: c_int, + task_ptr: [*]const u8, + task_len: c_int, +) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx = findPeerByToken(token_ptr, @intCast(token_len)) orelse return -1; + const tlen: usize = @intCast(@min(task_len, 128)); + const now_ms: u64 = @intCast(std.time.milliTimestamp()); + + for (&claims, 0..) |*c, ci| { + if (!c.active) continue; + if (c.task_name_len != @as(u8, @intCast(tlen))) continue; + if (!std.mem.eql(u8, c.task_name[0..tlen], task_ptr[0..tlen])) continue; + if (c.holder_idx != @as(u8, @intCast(idx))) return -3; + c.claimed_at_ms = now_ms; + dur.logClaimProgress(@intCast(ci), now_ms); + return 0; + } + return -2; +} + +/// Externally-callable watchdog sweep. Any active peer may invoke it +/// (loopback-only, trusted session); callers can schedule it as a +/// polling tick. Returns released-count, -1 on bad token. +pub export fn coord_sweep_watchdog( + token_ptr: [*]const u8, + token_len: c_int, +) c_int { + mutex.lock(); + defer mutex.unlock(); + if (findPeerByToken(token_ptr, @intCast(token_len)) == null) return -1; + const now_ms: u64 = @intCast(std.time.milliTimestamp()); + return sweepExpiredClaims(now_ms); +} + +/// Release a task claim. +pub export fn coord_release_task( + token_ptr: [*]const u8, + token_len: c_int, + task_ptr: [*]const u8, + task_len: c_int, +) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx = findPeerByToken(token_ptr, @intCast(token_len)) orelse return -1; + const tlen: usize = @intCast(@min(task_len, 128)); + + for (&claims, 0..) |*c, ci| { + if (c.active and c.task_name_len == @as(u8, @intCast(tlen)) and + std.mem.eql(u8, c.task_name[0..tlen], task_ptr[0..tlen]) and + c.holder_idx == @as(u8, @intCast(idx))) + { + c.active = false; + dur.logClaimRel(@intCast(ci)); + return 0; + } + } + return -2; // Not held by this peer +} + +// ═══════════════════════════════════════════════════════════════════════ +// Gated send + Quarantine Queue +// ═══════════════════════════════════════════════════════════════════════ + +/// Send a message that MAY be gated. If sender role is apprentice and +/// risk_tier >= 2, the message is quarantined and a request_id returned. +/// Otherwise it's delivered directly (identical to coord_send). +/// +/// Returns: +/// >= 1 β€” direct send succeeded, value is sent count +/// -1 β€” bad token +/// -2 β€” bad target_idx +/// -3 β€” target inbox full (direct send) +/// -4 β€” quarantine queue full +/// -5 β€” no master registered (apprentice peer can't file a Tier 2+ +/// without someone to review it) +/// < -1000 β€” quarantined; request_id = -(returned_value + 1000) +/// (lets a single c_int carry both direct-send count and +/// request_id by sign + range) +pub export fn coord_send_gated( + token_ptr: [*]const u8, + token_len: c_int, + target_idx: c_int, + msg_ptr: [*]const u8, + msg_len: c_int, + risk_tier: c_int, +) c_int { + mutex.lock(); + defer mutex.unlock(); + + const sender_idx = findPeerByToken(token_ptr, @intCast(token_len)) orelse return -1; + const sender = &peers[sender_idx]; + const tier_u: u8 = if (risk_tier < 0) 0 else @intCast(@min(risk_tier, 4)); + + // Free path: master/journeyman, or apprentice with low tier. + if (sender.role != .apprentice or tier_u < 2) { + // Defer to unlocked direct-send by releasing the mutex β€” coord_send + // re-acquires. Inline here to avoid lock churn. + const mlen: usize = @intCast(@min(msg_len, 512)); + + if (target_idx == -1) { + var sent: c_int = 0; + for (&peers, 0..) |*p, i| { + if (p.active and i != sender_idx and p.inbox_count < MAX_MESSAGES) { + const head: usize = p.inbox_head; + @memcpy(p.inbox[head][0..mlen], msg_ptr[0..mlen]); + p.inbox_lens[head] = @intCast(mlen); + p.inbox_head = @intCast((@as(u32, p.inbox_head) + 1) % MAX_MESSAGES); + p.inbox_count += 1; + dur.logInboxPush(@intCast(i), msg_ptr[0..mlen]); + sent += 1; + } + } + return sent; + } + + if (target_idx < 0 or target_idx >= MAX_PEERS) return -2; + const target = &peers[@intCast(target_idx)]; + if (!target.active) return -2; + if (target.inbox_count >= MAX_MESSAGES) return -3; + + const head: usize = target.inbox_head; + @memcpy(target.inbox[head][0..mlen], msg_ptr[0..mlen]); + target.inbox_lens[head] = @intCast(mlen); + target.inbox_head = @intCast((@as(u32, target.inbox_head) + 1) % MAX_MESSAGES); + target.inbox_count += 1; + dur.logInboxPush(@intCast(target_idx), msg_ptr[0..mlen]); + return 1; + } + + // Gated path: apprentice peer + Tier 2+ = quarantine. + if (findMaster() == null) return -5; + + for (&quarantine) |*q| { + if (!q.active) { + q.active = true; + q.request_id = next_request_id; + next_request_id += 1; + q.sender_idx = @intCast(sender_idx); + q.target_idx = if (target_idx == -1) -1 else @intCast(target_idx); + q.risk_tier = tier_u; + const mlen: usize = @intCast(@min(msg_len, 512)); + @memcpy(q.msg[0..mlen], msg_ptr[0..mlen]); + q.msg_len = @intCast(mlen); + q.reason_len = 0; + dur.logQuarAdd(q.request_id, q.sender_idx, q.target_idx, q.risk_tier, msg_ptr[0..mlen]); + // Encode request_id as -(id + 1000) so caller can distinguish + // from direct-send counts. + const encoded: i64 = -(@as(i64, @intCast(q.request_id)) + 1000); + return @intCast(encoded); + } + } + return -4; // queue full +} + +/// List pending quarantine entries. Only callable by a master. +/// Writes records into `out` β€” each record is 16 bytes: +/// request_id: u32 little-endian +/// sender_idx: u8 +/// target_idx: i8 +/// risk_tier: u8 +/// msg_len: u16 little-endian +/// first 7 bytes of msg (preview) +/// +/// Returns number of records written, or -1 if caller is not master. +pub export fn coord_review( + master_token_ptr: [*]const u8, + master_token_len: c_int, + out: [*]u8, + out_cap: c_int, +) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx = findPeerByToken(master_token_ptr, @intCast(master_token_len)) orelse return -1; + if (peers[idx].role != .master) return -1; + + var written: usize = 0; + const cap: usize = @intCast(out_cap); + + for (&quarantine) |*q| { + if (q.active and (written + 16) <= cap) { + const rid_bytes: [4]u8 = @bitCast(q.request_id); + @memcpy(out[written .. written + 4], &rid_bytes); + out[written + 4] = q.sender_idx; + out[written + 5] = @bitCast(q.target_idx); + out[written + 6] = q.risk_tier; + const mlen_bytes: [2]u8 = @bitCast(q.msg_len); + @memcpy(out[written + 7 .. written + 9], &mlen_bytes); + const preview_n: usize = @min(@as(usize, 7), @as(usize, q.msg_len)); + @memcpy(out[written + 9 .. written + 9 + preview_n], q.msg[0..preview_n]); + // Zero-pad unused preview bytes. + if (preview_n < 7) @memset(out[written + 9 + preview_n .. written + 16], 0); + written += 16; + } + } + return @intCast(written / 16); +} + +/// Read the full message body of a specific quarantine entry. Supervisor-only. +/// Returns message length on success, -1 on bad master, -2 on unknown id. +pub export fn coord_review_entry( + master_token_ptr: [*]const u8, + master_token_len: c_int, + request_id: c_int, + out: [*]u8, + out_cap: c_int, +) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx = findPeerByToken(master_token_ptr, @intCast(master_token_len)) orelse return -1; + if (peers[idx].role != .master) return -1; + + const rid: u32 = @intCast(request_id); + for (&quarantine) |*q| { + if (q.active and q.request_id == rid) { + const cap: usize = @intCast(out_cap); + const mlen: usize = @min(@as(usize, q.msg_len), cap); + @memcpy(out[0..mlen], q.msg[0..mlen]); + return @intCast(mlen); + } + } + return -2; +} + +/// Approve a quarantined entry β€” delivers the message to its target(s) +/// and removes the entry. Supervisor-only. Returns 0 on success, -1 on +/// bad master, -2 on unknown id, -3 if target inbox full (caller +/// should retry later). +pub export fn coord_approve( + master_token_ptr: [*]const u8, + master_token_len: c_int, + request_id: c_int, +) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx = findPeerByToken(master_token_ptr, @intCast(master_token_len)) orelse return -1; + if (peers[idx].role != .master) return -1; + + const rid: u32 = @intCast(request_id); + for (&quarantine) |*q| { + if (q.active and q.request_id == rid) { + const sender_i: usize = q.sender_idx; + const mlen: usize = q.msg_len; + + if (q.target_idx == -1) { + // Broadcast β€” deliver to all active peers except sender. + for (&peers, 0..) |*p, i| { + if (p.active and i != sender_i and p.inbox_count < MAX_MESSAGES) { + const head: usize = p.inbox_head; + @memcpy(p.inbox[head][0..mlen], q.msg[0..mlen]); + p.inbox_lens[head] = @intCast(mlen); + p.inbox_head = @intCast((@as(u32, p.inbox_head) + 1) % MAX_MESSAGES); + p.inbox_count += 1; + dur.logInboxPush(@intCast(i), q.msg[0..mlen]); + } + } + } else { + const tidx: usize = @intCast(q.target_idx); + if (tidx >= MAX_PEERS) return -2; + const target = &peers[tidx]; + if (!target.active) return -2; + if (target.inbox_count >= MAX_MESSAGES) return -3; + const head: usize = target.inbox_head; + @memcpy(target.inbox[head][0..mlen], q.msg[0..mlen]); + target.inbox_lens[head] = @intCast(mlen); + target.inbox_head = @intCast((@as(u32, target.inbox_head) + 1) % MAX_MESSAGES); + target.inbox_count += 1; + dur.logInboxPush(@intCast(tidx), q.msg[0..mlen]); + } + q.active = false; + dur.logQuarApprove(rid); + return 0; + } + } + return -2; +} + +/// Reject a quarantined entry β€” removes it with a recorded reason. The +/// message is NOT delivered. Reason stays in the entry until the next +/// coord_reset (for audit; VeriSimDB sidecar will persist it later). +/// Supervisor-only. Returns 0 on success, -1 on bad master, -2 on +/// unknown id. +pub export fn coord_reject( + master_token_ptr: [*]const u8, + master_token_len: c_int, + request_id: c_int, + reason_ptr: [*]const u8, + reason_len: c_int, +) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx = findPeerByToken(master_token_ptr, @intCast(master_token_len)) orelse return -1; + if (peers[idx].role != .master) return -1; + + const rid: u32 = @intCast(request_id); + for (&quarantine) |*q| { + if (q.active and q.request_id == rid) { + const rlen: usize = @intCast(@min(reason_len, MAX_REASON)); + if (rlen > 0) @memcpy(q.reason[0..rlen], reason_ptr[0..rlen]); + q.reason_len = @intCast(rlen); + q.active = false; + dur.logQuarReject(rid, reason_ptr[0..rlen]); + return 0; + } + } + return -2; +} + +/// Set this peer's status string. +pub export fn coord_set_status( + token_ptr: [*]const u8, + token_len: c_int, + status_ptr: [*]const u8, + status_len: c_int, +) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx = findPeerByToken(token_ptr, @intCast(token_len)) orelse return -1; + const slen: usize = @intCast(@min(status_len, 256)); + @memcpy(peers[idx].status[0..slen], status_ptr[0..slen]); + peers[idx].status_len = @intCast(slen); + dur.logPeerStatusSet(@intCast(idx), status_ptr[0..slen]); + return 0; +} + +/// Set this peer's declared affinities β€” a CSV of tag names the peer +/// self-reports as strengths. Feeds the reassignment engine (Task #14): +/// tags not in this list but with high effective_affinity trigger a +/// promotion suggestion; tags in the list with low effective_affinity +/// trigger an overclaim or removal suggestion. +/// +/// Returns 0 on success, -1 on bad token, -2 if csv exceeds MAX_DECLARED. +/// An empty csv (len=0) clears the declared list. +pub export fn coord_set_declared_affinities( + token_ptr: [*]const u8, + token_len: c_int, + csv_ptr: [*]const u8, + csv_len: c_int, +) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx = findPeerByToken(token_ptr, @intCast(token_len)) orelse return -1; + const clen: usize = @intCast(csv_len); + if (clen > MAX_DECLARED) return -2; + + if (clen > 0) @memcpy(peers[idx].declared_affinities[0..clen], csv_ptr[0..clen]); + peers[idx].declared_affinities_len = @intCast(clen); + return 0; +} + +/// Return this peer's declared affinities CSV in `out`. Writes up to +/// `out_cap` bytes. Returns csv length, 0 if unset, -1 on bad index. +pub export fn coord_read_declared_affinities( + peer_idx: c_int, + out: [*]u8, + out_cap: c_int, +) c_int { + mutex.lock(); + defer mutex.unlock(); + if (peer_idx < 0 or peer_idx >= MAX_PEERS) return -1; + const p = &peers[@intCast(peer_idx)]; + if (!p.active) return -1; + const dlen: usize = @min(@as(usize, p.declared_affinities_len), @as(usize, @intCast(out_cap))); + if (dlen > 0) @memcpy(out[0..dlen], p.declared_affinities[0..dlen]); + return @intCast(dlen); +} + +// ═══════════════════════════════════════════════════════════════════════ +// Track Record FFI (Task #13) +// ═══════════════════════════════════════════════════════════════════════ + +/// Report the outcome of a claim or attempted op. The peer's client_kind +/// (derived from its token) is the aggregation key per DD-29 β€” the record +/// survives peer crash+restart. +/// +/// outcome: 0 = fail, 1 = success +/// duration_ms: wall-time cost of the op in ms (0 if unknown) +/// risk_tier: tier of the op the outcome belongs to (0-4) +/// tag: affinity tag (e.g. "proof-analysis", "routine-edit"); max 64 bytes +/// confidence_pct: self-assessed confidence at claim time (0-100), or -1 +/// if unreported. Used by the reassignment engine (Task #14) to detect +/// overclaim (high confidence vs low effective_affinity). +/// +/// Returns 0 on success, -1 on bad token, -2 on bad args. +pub export fn coord_report_outcome( + token_ptr: [*]const u8, + token_len: c_int, + tag_ptr: [*]const u8, + tag_len: c_int, + outcome: c_int, + duration_ms: c_int, + risk_tier: c_int, + confidence_pct: c_int, +) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx = findPeerByToken(token_ptr, @intCast(token_len)) orelse return -1; + if (tag_len < 0 or tag_len > @as(c_int, @intCast(MAX_TAG))) return -2; + if (outcome < 0 or outcome > 1) return -2; + if (risk_tier < 0 or risk_tier > 4) return -2; + if (duration_ms < 0) return -2; + if (confidence_pct < -1 or confidence_pct > 100) return -2; + + const tlen: usize = @intCast(tag_len); + const tag = tag_ptr[0..tlen]; + + const kind_u: u8 = @intCast(@intFromEnum(peers[idx].kind)); + const outcome_u: u8 = @intCast(outcome); + const tier_u: u8 = @intCast(risk_tier); + const dur_u: u32 = @intCast(duration_ms); + const conf_u: u8 = if (confidence_pct < 0) 255 else @intCast(confidence_pct); + + recordTrack(kind_u, outcome_u, tier_u, dur_u, tag, conf_u); + dur.logTrackUpdate(kind_u, outcome_u, tier_u, dur_u, @intCast(std.time.milliTimestamp()), tag, conf_u); + return 0; +} + +const Aggregate = struct { + client_kind: u8, + attempts: u16, + successes: u16, + tag_len: u8, + tag: [MAX_TAG]u8, +}; + +fn tagEql(a: []const u8, b: []const u8) bool { + if (a.len != b.len) return false; + return std.mem.eql(u8, a, b); +} + +/// Compute per-(client_kind, tag) aggregates over the active window. +/// Window: an entry counts toward its (kind, tag) if it is within the +/// last 7 days OR among the 20 most recent attempts for that (kind, tag) +/// β€” whichever set is larger (DD-28). Writes up to `out_cap` aggregates +/// into `out`. Returns the number of aggregates written. +fn buildAggregates(out: []Aggregate) usize { + var n: usize = 0; + if (track_count == 0) return 0; + + const now: u64 = @intCast(std.time.milliTimestamp()); + const cutoff: u64 = if (now > WINDOW_MS) now - WINDOW_MS else 0; + + // Iterate track ring in insertion order (oldest first). + const start: usize = if (track_count < MAX_TRACK) 0 else track_head; + var step: usize = 0; + while (step < track_count) : (step += 1) { + const src_i: usize = (start + step) % MAX_TRACK; + const t = &track[src_i]; + if (!t.active) continue; + + const tag_slice: []const u8 = t.tag[0..t.tag_len]; + + // Find existing aggregate or append. + var agg_i: usize = 0; + var found: bool = false; + while (agg_i < n) : (agg_i += 1) { + if (out[agg_i].client_kind == t.client_kind and + tagEql(out[agg_i].tag[0..out[agg_i].tag_len], tag_slice)) + { + found = true; + break; + } + } + if (!found) { + if (n >= out.len) continue; + out[n] = .{ + .client_kind = t.client_kind, + .attempts = 0, + .successes = 0, + .tag_len = t.tag_len, + .tag = [_]u8{0} ** MAX_TAG, + }; + if (t.tag_len > 0) @memcpy(out[n].tag[0..t.tag_len], tag_slice); + agg_i = n; + n += 1; + } + // Provisional include β€” we'll filter per (kind, tag) below. + out[agg_i].attempts += 1; + if (t.outcome == 1) out[agg_i].successes += 1; + } + + // Second pass: for each aggregate, apply the window rule. + // The simple counts above treat every entry as eligible. Replace + // them with a window-filtered recount. + var i: usize = 0; + while (i < n) : (i += 1) { + const tgt_kind = out[i].client_kind; + const tgt_tag: []const u8 = out[i].tag[0..out[i].tag_len]; + + // Collect indices of matching entries, newest first (scan ring + // in reverse insertion order). + var matches_attempts: u16 = 0; + var matches_successes: u16 = 0; + + var seen_for_kind_tag: u16 = 0; // newest-first counter + var k: usize = 0; + while (k < track_count) : (k += 1) { + // Traverse newest-first: head - 1 - k (mod MAX_TRACK). + const raw_i: isize = @as(isize, @intCast(track_head)) - 1 - @as(isize, @intCast(k)); + const src_i: usize = @intCast(@mod(raw_i, @as(isize, @intCast(MAX_TRACK)))); + const t = &track[src_i]; + if (!t.active) continue; + if (t.client_kind != tgt_kind) continue; + if (!tagEql(t.tag[0..t.tag_len], tgt_tag)) continue; + + seen_for_kind_tag += 1; + const within_time = t.timestamp_ms >= cutoff; + const within_count = seen_for_kind_tag <= @as(u16, @intCast(WINDOW_ATTEMPTS)); + + if (within_time or within_count) { + matches_attempts += 1; + if (t.outcome == 1) matches_successes += 1; + } else { + // Outside both windows; older entries will also be outside. + break; + } + } + out[i].attempts = matches_attempts; + out[i].successes = matches_successes; + } + + return n; +} + +/// Return per-(client_kind, tag) affinity aggregates in `out`. Each +/// record is 64 bytes packed little-endian: +/// +/// client_kind : u8 +/// attempts : u16 +/// successes : u16 +/// affinity_pct: u8 (0..100, 255 = no data) +/// tag_len : u8 +/// tag : [57]u8 (only first tag_len bytes valid) +/// +/// Returns the number of records written, or -1 on bad token, or the +/// required number of records if `out_cap` is too small (in that case +/// return = -(required + 1000), matching the coord_send_gated idiom). +pub export fn coord_get_affinities( + token_ptr: [*]const u8, + token_len: c_int, + out: [*]u8, + out_cap: c_int, +) c_int { + mutex.lock(); + defer mutex.unlock(); + + if (findPeerByToken(token_ptr, @intCast(token_len)) == null) return -1; + + // Computed aggregates live on the stack; MAX_TRACK worst-case upper + // bound on distinct (kind, tag) pairs. + var aggs: [MAX_TRACK]Aggregate = undefined; + const n = buildAggregates(aggs[0..]); + + const REC_SIZE: usize = 64; + const cap: usize = @intCast(out_cap); + const required: usize = n * REC_SIZE; + if (required > cap) { + const encoded: i64 = -(@as(i64, @intCast(n)) + 1000); + return @intCast(encoded); + } + + var written: usize = 0; + var i: usize = 0; + while (i < n) : (i += 1) { + const rec = out[written .. written + REC_SIZE]; + rec[0] = aggs[i].client_kind; + std.mem.writeInt(u16, rec[1..3], aggs[i].attempts, .little); + std.mem.writeInt(u16, rec[3..5], aggs[i].successes, .little); + const pct: u8 = if (aggs[i].attempts == 0) + 255 + else + @intCast(@min( + @as(u32, 100), + (@as(u32, aggs[i].successes) * 100) / @as(u32, aggs[i].attempts), + )); + rec[5] = pct; + rec[6] = aggs[i].tag_len; + // Tag into bytes 7..64 (57 bytes). Zero-pad trailing. + const tl: usize = @min(aggs[i].tag_len, 57); + if (tl > 0) @memcpy(rec[7 .. 7 + tl], aggs[i].tag[0..tl]); + if (tl < 57) @memset(rec[7 + tl .. 64], 0); + written += REC_SIZE; + } + return @intCast(n); +} + +// ═══════════════════════════════════════════════════════════════════════ +// Reassignment suggestion engine (Task #14) +// +// Scans track-record aggregates + per-peer declared_affinities and +// synthesises candidate envelopes that land in the QUARANTINE as +// server-origin entries (sender_idx = SERVER_ORIGIN_SENTINEL). Opus +// (master) then approves or rejects via coord_approve / coord_reject +// β€” never auto-modifies. +// +// Suggestion kinds: +// - "overclaim": avg_confidence > 0.8 AND effective_affinity < 0.3 +// β†’ routing FYI (op_kind=fyi, tier 1) +// - "drift": same condition as overclaim, but framed as a self- +// assessment monitoring flag (DD-9 layer D). Emitted +// alongside overclaim with op_kind=warn, tier 2, and a +// drift_pct field carrying the confidence-vs-affinity gap. +// - "promote": effective_affinity >= 0.7 AND tag not in declared set +// - "remove": effective_affinity <= 0.2 AND attempts >= 5 +// +// Envelope payload is a compact JSON object surfaced to Opus verbatim. +// ═══════════════════════════════════════════════════════════════════════ + +pub const SERVER_ORIGIN_SENTINEL: u8 = 0xFE; + +const OVERCLAIM_CONF_MIN: u32 = 80; +const OVERCLAIM_AFFINITY_MAX: u32 = 30; +const PROMOTE_AFFINITY_MIN: u32 = 70; +const REMOVE_AFFINITY_MAX: u32 = 20; +const REMOVE_MIN_ATTEMPTS: u16 = 5; + +/// Enqueue a server-origin candidate envelope into the quarantine. +/// Supervisor reviews via coord_review + coord_review_entry. Targets: +/// target_idx = -1 -> broadcast on approve +/// target_idx >= 0 -> direct delivery on approve +/// +/// Returns the assigned request_id, or -1 if the quarantine queue is +/// full (caller should back off). +fn enqueueServerSuggestion( + target_idx: i8, + risk_tier: u8, + msg: []const u8, +) i64 { + for (&quarantine) |*q| { + if (!q.active) { + q.active = true; + q.request_id = next_request_id; + next_request_id += 1; + q.sender_idx = SERVER_ORIGIN_SENTINEL; + q.target_idx = target_idx; + q.risk_tier = risk_tier; + const mlen: usize = @min(msg.len, 512); + if (mlen > 0) @memcpy(q.msg[0..mlen], msg[0..mlen]); + q.msg_len = @intCast(mlen); + q.reason_len = 0; + dur.logQuarAdd(q.request_id, q.sender_idx, q.target_idx, q.risk_tier, msg[0..mlen]); + return @intCast(q.request_id); + } + } + return -1; +} + +/// Internal kindβ†’string for engine-side rendering. Mirrors the adapter's +/// kindName but lives in FFI so server-origin envelopes can name peers +/// correctly. Updated when ClientKind grows (Task #33 added openai/mistral). +fn kindStr(k: u8) []const u8 { + return switch (k) { + 0 => "claude", + 1 => "gemini", + 2 => "copilot", + 4 => "openai", + 5 => "mistral", + else => "custom", + }; +} + +fn affinityPct(attempts: u16, successes: u16) u32 { + if (attempts == 0) return 0; + return (@as(u32, successes) * 100) / @as(u32, attempts); +} + +/// Average confidence (pct) for entries matching (kind, tag) within the +/// active window. Returns 256 when there is no confidence-tagged data. +fn windowedAvgConfidence(tgt_kind: u8, tgt_tag: []const u8) u32 { + if (track_count == 0) return 256; + + const now: u64 = @intCast(std.time.milliTimestamp()); + const cutoff: u64 = if (now > WINDOW_MS) now - WINDOW_MS else 0; + + var seen: u16 = 0; + var sum_conf: u32 = 0; + var conf_n: u32 = 0; + + var k: usize = 0; + while (k < track_count) : (k += 1) { + const raw_i: isize = @as(isize, @intCast(track_head)) - 1 - @as(isize, @intCast(k)); + const src_i: usize = @intCast(@mod(raw_i, @as(isize, @intCast(MAX_TRACK)))); + const t = &track[src_i]; + if (!t.active) continue; + if (t.client_kind != tgt_kind) continue; + if (!tagEql(t.tag[0..t.tag_len], tgt_tag)) continue; + + seen += 1; + const within_time = t.timestamp_ms >= cutoff; + const within_count = seen <= @as(u16, @intCast(WINDOW_ATTEMPTS)); + + if (within_time or within_count) { + if (t.confidence_pct != 255) { + sum_conf += t.confidence_pct; + conf_n += 1; + } + } else break; + } + if (conf_n == 0) return 256; + return sum_conf / conf_n; +} + +/// Is `tag` present in any peer of the given client_kind's declared CSV? +fn tagInDeclaredFor(kind: u8, tag: []const u8) bool { + for (&peers) |*p| { + if (!p.active) continue; + if (@intFromEnum(p.kind) != kind) continue; + const csv = p.declared_affinities[0..p.declared_affinities_len]; + var it = std.mem.splitScalar(u8, csv, ','); + while (it.next()) |raw| { + const trimmed = std.mem.trim(u8, raw, " "); + if (trimmed.len == 0) continue; + if (tagEql(trimmed, tag)) return true; + } + } + return false; +} + +/// Scan track-record aggregates and enqueue candidate envelopes into the +/// quarantine. Returns the number of suggestions emitted, or -1 on bad +/// token. Caller should be a master β€” only the master can act +/// on these through coord_approve/coord_reject anyway, but any peer can +/// trigger the scan since it mutates only server-owned queue state. +pub export fn coord_scan_suggestions( + token_ptr: [*]const u8, + token_len: c_int, +) c_int { + mutex.lock(); + defer mutex.unlock(); + + if (findPeerByToken(token_ptr, @intCast(token_len)) == null) return -1; + + var aggs: [MAX_TRACK]Aggregate = undefined; + const n = buildAggregates(aggs[0..]); + + var emitted: c_int = 0; + var msg_buf: [512]u8 = undefined; + + var i: usize = 0; + while (i < n) : (i += 1) { + const agg = &aggs[i]; + if (agg.attempts == 0) continue; + const pct = affinityPct(agg.attempts, agg.successes); + const tag_slice: []const u8 = agg.tag[0..agg.tag_len]; + const kind_str = kindStr(agg.client_kind); + + // Overclaim: high self-confidence + low real affinity. + // (Routing suggestion β€” op_kind=fyi, tier 1.) + const avg_conf = windowedAvgConfidence(agg.client_kind, tag_slice); + if (avg_conf != 256 and avg_conf >= OVERCLAIM_CONF_MIN and pct < OVERCLAIM_AFFINITY_MAX) { + const msg = std.fmt.bufPrint(&msg_buf, + "{{\"kind\":\"overclaim\",\"client_kind\":\"{s}\",\"tag\":\"{s}\",\"attempts\":{d},\"successes\":{d},\"effective_affinity_pct\":{d},\"avg_confidence_pct\":{d},\"op_kind\":\"fyi\",\"rationale\":\"high self-confidence with low track-record success β€” reassignment suggested\"}}", + .{ kind_str, tag_slice, agg.attempts, agg.successes, pct, avg_conf }, + ) catch continue; + if (enqueueServerSuggestion(-1, 1, msg) >= 0) emitted += 1; + + // Drift detector (DD-9 layer D / TODO P1): same condition, but + // framed as a self-assessment monitoring flag for the master + // to act on at the peer-trust level (op_kind=warn, tier 2). + // Carries `drift_pct = avg_conf - effective_affinity` so the + // master sees the magnitude of the gap at a glance. + const drift_pct: u32 = avg_conf - pct; + const drift_msg = std.fmt.bufPrint(&msg_buf, + "{{\"kind\":\"drift\",\"client_kind\":\"{s}\",\"tag\":\"{s}\",\"attempts\":{d},\"successes\":{d},\"effective_affinity_pct\":{d},\"avg_confidence_pct\":{d},\"drift_pct\":{d},\"op_kind\":\"warn\",\"rationale\":\"self-assessment drift β€” confidence consistently outpaces track-record success\"}}", + .{ kind_str, tag_slice, agg.attempts, agg.successes, pct, avg_conf, drift_pct }, + ) catch continue; + if (enqueueServerSuggestion(-1, 2, drift_msg) >= 0) emitted += 1; + } + + // Promote: high effective affinity, but tag not in any same-kind peer's declared list. + if (pct >= PROMOTE_AFFINITY_MIN and !tagInDeclaredFor(agg.client_kind, tag_slice)) { + const msg = std.fmt.bufPrint(&msg_buf, + "{{\"kind\":\"promote\",\"client_kind\":\"{s}\",\"tag\":\"{s}\",\"attempts\":{d},\"successes\":{d},\"effective_affinity_pct\":{d},\"op_kind\":\"fyi\",\"rationale\":\"strong track record on an undeclared tag β€” consider adding to declared_affinities\"}}", + .{ kind_str, tag_slice, agg.attempts, agg.successes, pct }, + ) catch continue; + if (enqueueServerSuggestion(-1, 1, msg) >= 0) emitted += 1; + } + + // Remove: low effective affinity with enough sample. + if (agg.attempts >= REMOVE_MIN_ATTEMPTS and pct <= REMOVE_AFFINITY_MAX) { + const msg = std.fmt.bufPrint(&msg_buf, + "{{\"kind\":\"remove\",\"client_kind\":\"{s}\",\"tag\":\"{s}\",\"attempts\":{d},\"successes\":{d},\"effective_affinity_pct\":{d},\"op_kind\":\"clarify\",\"rationale\":\"consistently low success for this tag β€” consider removing from declared_affinities\"}}", + .{ kind_str, tag_slice, agg.attempts, agg.successes, pct }, + ) catch continue; + if (enqueueServerSuggestion(-1, 1, msg) >= 0) emitted += 1; + } + } + return emitted; +} + +// ═══════════════════════════════════════════════════════════════════════ +// Standard Cartridge Interface (loader expects these 4 C-ABI symbols) +// ═══════════════════════════════════════════════════════════════════════ + +pub export fn boj_cartridge_init() c_int { + coord_reset(); + _ = dur.open(); + if (dur.isEnabled()) { + dur.replay(replayDispatch); + } + return 0; +} + +pub export fn boj_cartridge_deinit() void { + dur.close(); + coord_reset(); +} + +// ═══════════════════════════════════════════════════════════════════════ +// Replay dispatcher β€” reconstructs in-memory state from the durable log. +// Called exactly once per record during boj_cartridge_init replay. +// Events that can't apply (e.g. slot out of range, unknown request_id) +// are silently skipped β€” the log is best-effort, never a correctness gate. +// ═══════════════════════════════════════════════════════════════════════ + +fn replayDispatch(event: dur.EventType, payload: []const u8) void { + switch (event) { + .peer_add => { + const d = dur.decodePeerAdd(payload) orelse return; + if (d.slot_idx >= MAX_PEERS) return; + const p = &peers[d.slot_idx]; + p.active = true; + p.kind = @enumFromInt(d.kind); + p.role = @enumFromInt(d.role); + p.state = .active; + p.suffix = d.suffix; + p.token = d.token; + p.inbox_head = 0; + p.inbox_tail = 0; + p.inbox_count = 0; + p.status_len = 0; + p.context_len = 0; + p.declared_affinities_len = 0; + p.variant_len = 0; + p.class_csv_len = 0; + p.tier = TIER_UNSET; + p.prover_strengths_len = 0; + }, + .peer_remove => { + const idx = dur.decodeSlotIdx(payload) orelse return; + if (idx >= MAX_PEERS) return; + peers[idx].active = false; + peers[idx].state = .gone; + }, + .peer_role_set => { + const d = dur.decodePeerRoleSet(payload) orelse return; + if (d.slot_idx >= MAX_PEERS) return; + peers[d.slot_idx].role = @enumFromInt(d.role); + }, + .peer_context_set => { + const d = dur.decodePeerContextSet(payload) orelse return; + if (d.slot_idx >= MAX_PEERS) return; + const p = &peers[d.slot_idx]; + if (d.ctx.len > MAX_CONTEXT) return; + if (d.ctx.len > 0) @memcpy(p.context[0..d.ctx.len], d.ctx); + p.context_len = @intCast(d.ctx.len); + }, + .peer_status_set => { + const d = dur.decodePeerStatusSet(payload) orelse return; + if (d.slot_idx >= MAX_PEERS) return; + const p = &peers[d.slot_idx]; + if (d.status.len > 256) return; + if (d.status.len > 0) @memcpy(p.status[0..d.status.len], d.status); + p.status_len = @intCast(d.status.len); + }, + .inbox_push => { + const d = dur.decodeInboxPush(payload) orelse return; + if (d.target_idx >= MAX_PEERS) return; + const p = &peers[d.target_idx]; + if (!p.active or p.inbox_count >= MAX_MESSAGES) return; + const mlen: usize = @min(d.msg.len, 512); + const head: usize = p.inbox_head; + if (mlen > 0) @memcpy(p.inbox[head][0..mlen], d.msg[0..mlen]); + p.inbox_lens[head] = @intCast(mlen); + p.inbox_head = @intCast((@as(u32, p.inbox_head) + 1) % MAX_MESSAGES); + p.inbox_count += 1; + }, + .inbox_pop => { + const idx = dur.decodeSlotIdx(payload) orelse return; + if (idx >= MAX_PEERS) return; + const p = &peers[idx]; + if (p.inbox_count == 0) return; + p.inbox_tail = @intCast((@as(u32, p.inbox_tail) + 1) % MAX_MESSAGES); + p.inbox_count -= 1; + }, + .claim_add => { + const d = dur.decodeClaimAdd(payload) orelse return; + if (d.claim_idx >= MAX_CLAIMS) return; + if (d.holder_idx >= MAX_PEERS) return; + const c = &claims[d.claim_idx]; + c.active = true; + c.holder_idx = d.holder_idx; + const tlen: usize = @min(d.task.len, 128); + if (tlen > 0) @memcpy(c.task_name[0..tlen], d.task[0..tlen]); + c.task_name_len = @intCast(tlen); + // Fresh TTL after replay: old logs predate claim_progress + // events, so we restart the watchdog from now. A claim_progress + // record later in the same log will override this. + c.claimed_at_ms = @intCast(std.time.milliTimestamp()); + }, + .claim_rel => { + const idx = dur.decodeSlotIdx(payload) orelse return; + if (idx >= MAX_CLAIMS) return; + claims[idx].active = false; + }, + .claim_progress => { + const d = dur.decodeClaimProgress(payload) orelse return; + if (d.claim_idx >= MAX_CLAIMS) return; + const c = &claims[d.claim_idx]; + if (!c.active) return; + c.claimed_at_ms = d.timestamp_ms; + }, + .quar_add => { + const d = dur.decodeQuarAdd(payload) orelse return; + // First empty slot; logged entries beyond MAX_QUARANTINE are + // dropped during replay (hot-cache-only in Phase 1). + for (&quarantine) |*q| { + if (!q.active) { + q.active = true; + q.request_id = d.request_id; + q.sender_idx = d.sender_idx; + q.target_idx = d.target_idx; + q.risk_tier = d.risk_tier; + const mlen: usize = @min(d.msg.len, 512); + if (mlen > 0) @memcpy(q.msg[0..mlen], d.msg[0..mlen]); + q.msg_len = @intCast(mlen); + q.reason_len = 0; + if (d.request_id >= next_request_id) next_request_id = d.request_id + 1; + return; + } + } + }, + .quar_approve => { + const rid = dur.decodeRequestId(payload) orelse return; + for (&quarantine) |*q| { + if (q.active and q.request_id == rid) { + q.active = false; + return; + } + } + }, + .quar_reject => { + const d = dur.decodeQuarReject(payload) orelse return; + for (&quarantine) |*q| { + if (q.active and q.request_id == d.request_id) { + const rlen: usize = @min(d.reason.len, MAX_REASON); + if (rlen > 0) @memcpy(q.reason[0..rlen], d.reason[0..rlen]); + q.reason_len = @intCast(rlen); + q.active = false; + return; + } + } + }, + .audit => { + // Append-only by design β€” nothing to reconstruct in live memory. + }, + .track_update => { + const d = dur.decodeTrackUpdate(payload) orelse return; + recordTrackReplay(d.client_kind, d.outcome, d.risk_tier, d.duration_ms, d.timestamp_ms, d.tag, d.confidence_pct); + }, + .peer_variant_set => { + const d = dur.decodePeerVariantSet(payload) orelse return; + if (d.slot_idx >= MAX_PEERS) return; + const p = &peers[d.slot_idx]; + if (d.variant.len > MAX_VARIANT) return; + if (d.variant.len > 0) @memcpy(p.variant[0..d.variant.len], d.variant); + p.variant_len = @intCast(d.variant.len); + }, + .peer_capabilities_set => { + const d = dur.decodePeerCapabilitiesSet(payload) orelse return; + if (d.slot_idx >= MAX_PEERS) return; + const p = &peers[d.slot_idx]; + if (d.class.len > MAX_CLASS or d.provers.len > MAX_PROVERS) return; + if (d.class.len > 0) @memcpy(p.class_csv[0..d.class.len], d.class); + p.class_csv_len = @intCast(d.class.len); + p.tier = d.tier; + if (d.provers.len > 0) @memcpy(p.prover_strengths[0..d.provers.len], d.provers); + p.prover_strengths_len = @intCast(d.provers.len); + }, + else => {}, + } +} + +pub export fn boj_cartridge_name() [*:0]const u8 { + return "local-coord-mcp"; +} + +pub export fn boj_cartridge_version() [*:0]const u8 { + return "0.9.0"; +} + +// ═══════════════════════════════════════════════════════════════════════ +// ADR-0006 dispatch (boj_cartridge_invoke, 5th standard symbol) +// ═══════════════════════════════════════════════════════════════════════ + +const shim = @import("cartridge_shim.zig"); + +// ── JSON dispatch helpers ───────────────────────────────────────────── +// +// These mirror local_coord_adapter.zig but call the exported coord_* +// functions directly (same compilation unit β€” no ffi.* prefix needed). + +fn ci_kindFromString(s: []const u8) i32 { + if (std.mem.eql(u8, s, "claude")) return 0; + if (std.mem.eql(u8, s, "gemini")) return 1; + if (std.mem.eql(u8, s, "copilot")) return 2; + if (std.mem.eql(u8, s, "openai")) return 4; + if (std.mem.eql(u8, s, "mistral")) return 5; + return 3; // custom +} + +fn ci_kindName(kind: i32) []const u8 { + return switch (kind) { + 0 => "claude", + 1 => "gemini", + 2 => "copilot", + 4 => "openai", + 5 => "mistral", + else => "custom", + }; +} + +fn ci_stateName(state: i32) []const u8 { + return switch (state) { + 0 => "registering", + 1 => "active", + 2 => "departing", + else => "gone", + }; +} + +fn ci_parseToken(token_hex: []const u8, out: *[16]u8) bool { + if (token_hex.len != 32) return false; + _ = std.fmt.hexToBytes(out, token_hex) catch return false; + return true; +} + +fn ci_extractSuffix(target: []const u8) ?[]const u8 { + const at_pos = std.mem.indexOfScalar(u8, target, '@') orelse target.len; + const left = target[0..at_pos]; + const dash_pos = std.mem.lastIndexOfScalar(u8, left, '-') orelse return null; + const suffix = left[dash_pos + 1 ..]; + if (suffix.len != 4) return null; + return suffix; +} + +fn ci_arrayToCsv(items: []const std.json.Value, buf: []u8) usize { + var len: usize = 0; + for (items) |item| { + if (item != .string) continue; + const s = item.string; + if (len > 0 and len < buf.len) { + buf[len] = ','; + len += 1; + } + const to_copy: usize = @min(s.len, buf.len - len); + if (to_copy > 0) @memcpy(buf[len .. len + to_copy], s[0..to_copy]); + len += to_copy; + } + return len; +} + +fn ci_renderPeerId(buf: []u8, kind_str: []const u8, suffix: []const u8, ctx: []const u8) ![]u8 { + if (ctx.len == 0) return try std.fmt.bufPrint(buf, "{s}-{s}", .{ kind_str, suffix }); + return try std.fmt.bufPrint(buf, "{s}-{s}@{s}", .{ kind_str, suffix, ctx }); +} + +fn ci_writeJsonString(w: anytype, s: []const u8) !void { + try w.writeByte('"'); + for (s) |c| { + switch (c) { + '"' => try w.writeAll("\\\""), + '\\' => try w.writeAll("\\\\"), + '\n' => try w.writeAll("\\n"), + '\r' => try w.writeAll("\\r"), + '\t' => try w.writeAll("\\t"), + 0x00...0x08, 0x0B, 0x0C, 0x0E...0x1F => try std.fmt.format(w, "\\u00{x:0>2}", .{c}), + else => try w.writeByte(c), + } + } + try w.writeByte('"'); +} + +fn ci_writeCsvAsJsonStrings(w: anytype, csv: []const u8) !void { + if (csv.len == 0) return; + var it = std.mem.splitScalar(u8, csv, ','); + var first = true; + while (it.next()) |part| { + if (part.len == 0) continue; + if (!first) try w.writeAll(","); + first = false; + try ci_writeJsonString(w, part); + } +} + +const InvokeResult = struct { written: usize, rc: i32 }; + +fn ci_fail(out: []u8, msg: []const u8, rc: i32) InvokeResult { + const b = std.fmt.bufPrint(out, "{{\"success\":false,\"error\":\"{s}\"}}", .{msg}) catch out[0..0]; + return .{ .written = b.len, .rc = rc }; +} + +fn ci_ok(out: []u8, msg: []const u8) InvokeResult { + const b = std.fmt.bufPrint(out, "{{\"success\":true,\"message\":\"{s}\"}}", .{msg}) catch out[0..0]; + return .{ .written = b.len, .rc = shim.RC_SUCCESS }; +} + +/// Main tool dispatch β€” mirrors the adapter's dispatch() with direct FFI calls. +/// Writes a complete JSON body into `out` and returns (bytes_written, rc_code). +/// Error responses are always written to `out` even when rc < 0, so callers +/// can surface the message while still acting on the return code. +fn ci_dispatch(tool: []const u8, json_args: []const u8, out: []u8, alloc: std.mem.Allocator) InvokeResult { + if (std.mem.eql(u8, tool, "coord_register")) { + const parsed = std.json.parseFromSlice(std.json.Value, alloc, json_args, .{}) catch + return ci_fail(out, "invalid json", shim.RC_BAD_ARGS); + defer parsed.deinit(); + + const kind_val = parsed.value.object.get("client_kind") orelse + return ci_fail(out, "missing client_kind", shim.RC_BAD_ARGS); + const kind_str = kind_val.string; + const kind: i32 = ci_kindFromString(kind_str); + + const ctx_str: []const u8 = blk: { + const v = parsed.value.object.get("context") orelse break :blk ""; + break :blk v.string; + }; + const role_hint: i32 = blk: { + const rv = parsed.value.object.get("role") orelse break :blk -1; + const rs = rv.string; + if (std.mem.eql(u8, rs, "master") or std.mem.eql(u8, rs, "supervisor")) break :blk 0; + if (std.mem.eql(u8, rs, "journeyman") or std.mem.eql(u8, rs, "executor")) break :blk 1; + if (std.mem.eql(u8, rs, "apprentice") or std.mem.eql(u8, rs, "supervised")) break :blk 2; + break :blk -1; + }; + + var token: [16]u8 = undefined; + var suffix: [4]u8 = undefined; + const idx = coord_register(kind, role_hint, &token, &suffix); + if (idx == -3) return ci_fail(out, "master role must be obtained via coord_promote_to_master", shim.RC_BAD_ARGS); + if (idx < 0) return ci_fail(out, "registry full", shim.RC_RUNTIME_ERROR); + + if (ctx_str.len > 0) { + if (coord_set_context(&token, 16, ctx_str.ptr, @intCast(ctx_str.len)) < 0) { + _ = coord_deregister(&token, 16); + return ci_fail(out, "invalid context (alphanumeric/hyphen/underscore only, max 32 bytes)", shim.RC_BAD_ARGS); + } + } + if (parsed.value.object.get("declared_affinities")) |dv| { + if (dv == .array) { + var csv_buf: [256]u8 = undefined; + const csv_len = ci_arrayToCsv(dv.array.items, &csv_buf); + if (csv_len > 0) _ = coord_set_declared_affinities(&token, 16, &csv_buf, @intCast(csv_len)); + } + } + if (parsed.value.object.get("variant")) |vv| { + if (vv == .string and vv.string.len > 0) { + if (coord_set_variant(&token, 16, vv.string.ptr, @intCast(vv.string.len)) < 0) { + _ = coord_deregister(&token, 16); + return ci_fail(out, "invalid variant (alphanum / . / - / _ only, max 32 bytes)", shim.RC_BAD_ARGS); + } + } + } + if (parsed.value.object.get("capabilities")) |caps| { + if (caps == .object) { + var class_buf: [128]u8 = undefined; + var class_len: usize = 0; + if (caps.object.get("class")) |cv| { + if (cv == .array) class_len = ci_arrayToCsv(cv.array.items, &class_buf); + } + var tier: i32 = 0; + if (caps.object.get("tier")) |tv| { + if (tv == .integer) tier = @intCast(tv.integer); + } + var pro_buf: [256]u8 = undefined; + var pro_len: usize = 0; + if (caps.object.get("prover_strengths")) |pv| { + if (pv == .array) pro_len = ci_arrayToCsv(pv.array.items, &pro_buf); + } + if (class_len != 0 or tier != 0 or pro_len != 0) { + if (coord_set_capabilities(&token, 16, &class_buf, @intCast(class_len), tier, &pro_buf, @intCast(pro_len)) < 0) { + _ = coord_deregister(&token, 16); + return ci_fail(out, "invalid capabilities (tier 0..5, class ≀128B, prover_strengths ≀256B)", shim.RC_BAD_ARGS); + } + } + } + } + + const hex_chars = "0123456789abcdef"; + var token_hex: [32]u8 = undefined; + for (token, 0..) |b, i| { + token_hex[i * 2] = hex_chars[b >> 4]; + token_hex[i * 2 + 1] = hex_chars[b & 0x0f]; + } + var pid_buf: [96]u8 = undefined; + const peer_id = ci_renderPeerId(&pid_buf, kind_str, &suffix, ctx_str) catch + return ci_fail(out, "peer_id render overflow", shim.RC_RUNTIME_ERROR); + const body = std.fmt.bufPrint(out, "{{\"success\":true,\"peer_id\":\"{s}\",\"token\":\"{s}\"}}", .{ peer_id, token_hex }) catch + return ci_fail(out, "buffer overflow", shim.RC_RUNTIME_ERROR); + return .{ .written = body.len, .rc = shim.RC_SUCCESS }; + } + + if (std.mem.eql(u8, tool, "coord_deregister")) { + const parsed = std.json.parseFromSlice(std.json.Value, alloc, json_args, .{}) catch + return ci_fail(out, "invalid json", shim.RC_BAD_ARGS); + defer parsed.deinit(); + const tv = parsed.value.object.get("token") orelse return ci_fail(out, "missing token", shim.RC_BAD_ARGS); + var token: [16]u8 = undefined; + if (!ci_parseToken(tv.string, &token)) return ci_fail(out, "invalid token hex", shim.RC_BAD_ARGS); + const rc = coord_deregister(&token, 16); + if (rc == 0) return ci_ok(out, "deregistered"); + if (rc == -1) return ci_fail(out, "unauthenticated", shim.RC_AUTH_DENIED); + return ci_fail(out, "deregister failed", shim.RC_RUNTIME_ERROR); + } + + if (std.mem.eql(u8, tool, "coord_list_peers")) { + const parsed = std.json.parseFromSlice(std.json.Value, alloc, json_args, .{}) catch + return ci_fail(out, "invalid json", shim.RC_BAD_ARGS); + defer parsed.deinit(); + const tv = parsed.value.object.get("token") orelse return ci_fail(out, "missing token", shim.RC_BAD_ARGS); + var token: [16]u8 = undefined; + if (!ci_parseToken(tv.string, &token)) return ci_fail(out, "invalid token hex", shim.RC_BAD_ARGS); + + var raw: [192]u8 = undefined; + const count = coord_list_peers(&token, 16, &raw, @intCast(raw.len)); + if (count < 0) return ci_fail(out, "unauthenticated", shim.RC_AUTH_DENIED); + + var stream = std.io.fixedBufferStream(out); + const w = stream.writer(); + w.writeAll("{\"success\":true,\"peers\":[") catch return ci_fail(out, "buffer overflow", shim.RC_RUNTIME_ERROR); + var pi: i32 = 0; + var written_idx: usize = 0; + const cnt: usize = @intCast(count); + while (pi < 16 and written_idx < cnt) : (pi += 1) { + const kv = coord_read_peer_kind(pi); + if (kv < 0) continue; + const rec = raw[written_idx * 12 ..]; + const suf = rec[4..8]; + const st: i32 = @bitCast([4]u8{ rec[8], rec[9], rec[10], rec[11] }); + var status_buf: [256]u8 = undefined; + const sl = coord_read_peer_status(pi, &status_buf, @intCast(status_buf.len)); + const ss: []const u8 = if (sl > 0) status_buf[0..@intCast(sl)] else ""; + var ctx_buf: [32]u8 = undefined; + const cl = coord_read_peer_context(pi, &ctx_buf, @intCast(ctx_buf.len)); + const cs: []const u8 = if (cl > 0) ctx_buf[0..@intCast(cl)] else ""; + var var_buf: [32]u8 = undefined; + const vl = coord_read_peer_variant(pi, &var_buf, @intCast(var_buf.len)); + const vs: []const u8 = if (vl > 0) var_buf[0..@intCast(vl)] else ""; + var pid_buf: [96]u8 = undefined; + const peer_id = ci_renderPeerId(&pid_buf, ci_kindName(kv), suf, cs) catch return ci_fail(out, "peer_id render overflow", shim.RC_RUNTIME_ERROR); + if (written_idx > 0) w.writeByte(',') catch return ci_fail(out, "buffer overflow", shim.RC_RUNTIME_ERROR); + std.fmt.format(w, "{{\"peer_id\":\"{s}\",\"kind\":\"{s}\",\"state\":\"{s}\",\"context\":\"{s}\",\"variant\":\"{s}\",\"status\":", .{ + peer_id, ci_kindName(kv), ci_stateName(st), cs, vs, + }) catch return ci_fail(out, "buffer overflow", shim.RC_RUNTIME_ERROR); + ci_writeJsonString(w, ss) catch return ci_fail(out, "buffer overflow", shim.RC_RUNTIME_ERROR); + w.writeByte('}') catch return ci_fail(out, "buffer overflow", shim.RC_RUNTIME_ERROR); + written_idx += 1; + } + w.writeAll("]}") catch return ci_fail(out, "buffer overflow", shim.RC_RUNTIME_ERROR); + return .{ .written = stream.pos, .rc = shim.RC_SUCCESS }; + } + + if (std.mem.eql(u8, tool, "coord_send")) { + const parsed = std.json.parseFromSlice(std.json.Value, alloc, json_args, .{}) catch + return ci_fail(out, "invalid json", shim.RC_BAD_ARGS); + defer parsed.deinit(); + const tv = parsed.value.object.get("token") orelse return ci_fail(out, "missing token", shim.RC_BAD_ARGS); + const tgt = parsed.value.object.get("target") orelse return ci_fail(out, "missing target", shim.RC_BAD_ARGS); + const mv = parsed.value.object.get("message") orelse return ci_fail(out, "missing message", shim.RC_BAD_ARGS); + var token: [16]u8 = undefined; + if (!ci_parseToken(tv.string, &token)) return ci_fail(out, "invalid token hex", shim.RC_BAD_ARGS); + const target_str = tgt.string; + var target_idx: i32 = -1; + if (!std.mem.eql(u8, target_str, "*")) { + const suf = ci_extractSuffix(target_str) orelse return ci_fail(out, "invalid target format β€” expected -<4hex>[@]", shim.RC_BAD_ARGS); + target_idx = coord_find_peer_by_suffix(suf.ptr); + if (target_idx < 0) return ci_fail(out, "target peer not found", shim.RC_BAD_ARGS); + } + const msg = mv.string; + const sent = coord_send(&token, 16, target_idx, msg.ptr, @intCast(msg.len)); + if (sent < 0) return ci_fail(out, "unauthenticated or invalid target", shim.RC_AUTH_DENIED); + const b = std.fmt.bufPrint(out, "{{\"success\":true,\"sent\":{d}}}", .{sent}) catch + return ci_fail(out, "buffer overflow", shim.RC_RUNTIME_ERROR); + return .{ .written = b.len, .rc = shim.RC_SUCCESS }; + } + + if (std.mem.eql(u8, tool, "coord_receive")) { + const parsed = std.json.parseFromSlice(std.json.Value, alloc, json_args, .{}) catch + return ci_fail(out, "invalid json", shim.RC_BAD_ARGS); + defer parsed.deinit(); + const tv = parsed.value.object.get("token") orelse return ci_fail(out, "missing token", shim.RC_BAD_ARGS); + var token: [16]u8 = undefined; + if (!ci_parseToken(tv.string, &token)) return ci_fail(out, "invalid token hex", shim.RC_BAD_ARGS); + var msg_buf: [512]u8 = undefined; + const mlen = coord_receive(&token, 16, &msg_buf, @intCast(msg_buf.len)); + if (mlen < 0) return ci_fail(out, "unauthenticated", shim.RC_AUTH_DENIED); + if (mlen == 0) { + const b = std.fmt.bufPrint(out, "{{\"success\":true,\"message\":null}}", .{}) catch out[0..0]; + return .{ .written = b.len, .rc = shim.RC_SUCCESS }; + } + var stream = std.io.fixedBufferStream(out); + const w = stream.writer(); + w.writeAll("{\"success\":true,\"message\":") catch return ci_fail(out, "buffer overflow", shim.RC_RUNTIME_ERROR); + ci_writeJsonString(w, msg_buf[0..@intCast(mlen)]) catch return ci_fail(out, "buffer overflow", shim.RC_RUNTIME_ERROR); + w.writeByte('}') catch return ci_fail(out, "buffer overflow", shim.RC_RUNTIME_ERROR); + return .{ .written = stream.pos, .rc = shim.RC_SUCCESS }; + } + + if (std.mem.eql(u8, tool, "coord_status")) { + const parsed = std.json.parseFromSlice(std.json.Value, alloc, json_args, .{}) catch + return ci_fail(out, "invalid json", shim.RC_BAD_ARGS); + defer parsed.deinit(); + const tv = parsed.value.object.get("token") orelse return ci_fail(out, "missing token", shim.RC_BAD_ARGS); + const sv = parsed.value.object.get("status") orelse return ci_fail(out, "missing status", shim.RC_BAD_ARGS); + var token: [16]u8 = undefined; + if (!ci_parseToken(tv.string, &token)) return ci_fail(out, "invalid token hex", shim.RC_BAD_ARGS); + const rc = coord_set_status(&token, 16, sv.string.ptr, @intCast(sv.string.len)); + if (rc < 0) return ci_fail(out, "unauthenticated", shim.RC_AUTH_DENIED); + return ci_ok(out, "ok"); + } + + if (std.mem.eql(u8, tool, "coord_claim_task")) { + const parsed = std.json.parseFromSlice(std.json.Value, alloc, json_args, .{}) catch + return ci_fail(out, "invalid json", shim.RC_BAD_ARGS); + defer parsed.deinit(); + const tv = parsed.value.object.get("token") orelse return ci_fail(out, "missing token", shim.RC_BAD_ARGS); + const task = parsed.value.object.get("task") orelse return ci_fail(out, "missing task", shim.RC_BAD_ARGS); + var token: [16]u8 = undefined; + if (!ci_parseToken(tv.string, &token)) return ci_fail(out, "invalid token hex", shim.RC_BAD_ARGS); + const confidence: i32 = blk: { + const v = parsed.value.object.get("confidence") orelse break :blk -1; + break :blk switch (v) { + .float => |f| @intFromFloat(@min(@max(f * 100.0, 0.0), 100.0)), + .integer => |i| @intCast(i), + else => -1, + }; + }; + const dispatch_pref: i32 = blk: { + const v = parsed.value.object.get("dispatch_preference") orelse break :blk -1; + const s = v.string; + if (std.mem.eql(u8, s, "deliberate")) break :blk 0; + if (std.mem.eql(u8, s, "broadcast")) break :blk 1; + if (std.mem.eql(u8, s, "auto")) break :blk 2; + break :blk -1; + }; + const difficulty: i32 = blk: { + const v = parsed.value.object.get("task_difficulty") orelse break :blk -1; + const s = v.string; + if (std.mem.eql(u8, s, "trivial")) break :blk 0; + if (std.mem.eql(u8, s, "routine")) break :blk 1; + if (std.mem.eql(u8, s, "challenging")) break :blk 2; + if (std.mem.eql(u8, s, "novel")) break :blk 3; + break :blk -1; + }; + const result = coord_claim_task_ex(&token, 16, task.string.ptr, @intCast(task.string.len), confidence, dispatch_pref, difficulty); + if (result == 0) return ci_ok(out, "granted"); + if (result == 1) return ci_fail(out, "held", shim.RC_RUNTIME_ERROR); + if (result == -5) return ci_fail(out, "cooldown: too many recent claim rejections for this client_kind β€” wait 30s", shim.RC_RUNTIME_ERROR); + return ci_fail(out, "claim failed", shim.RC_RUNTIME_ERROR); + } + + if (std.mem.eql(u8, tool, "coord_promote_to_master") or + std.mem.eql(u8, tool, "coord_promote_to_supervisor")) + { + const parsed = std.json.parseFromSlice(std.json.Value, alloc, json_args, .{}) catch + return ci_fail(out, "invalid json", shim.RC_BAD_ARGS); + defer parsed.deinit(); + const tv = parsed.value.object.get("token") orelse return ci_fail(out, "missing token", shim.RC_BAD_ARGS); + const sv = parsed.value.object.get("secret") orelse return ci_fail(out, "missing secret", shim.RC_BAD_ARGS); + var token: [16]u8 = undefined; + if (!ci_parseToken(tv.string, &token)) return ci_fail(out, "invalid token hex", shim.RC_BAD_ARGS); + const secret = sv.string; + const rc = coord_promote_to_master(&token, 16, secret.ptr, @intCast(secret.len)); + if (rc == 0) return ci_ok(out, "promoted"); + if (rc == -1) return ci_fail(out, "unauthenticated", shim.RC_AUTH_DENIED); + if (rc == -2) return ci_fail(out, "master already exists", shim.RC_RUNTIME_ERROR); + if (rc == -3) return ci_fail(out, "master role not configured on this server", shim.RC_AUTH_DENIED); + if (rc == -4) return ci_fail(out, "secret does not match", shim.RC_AUTH_DENIED); + return ci_fail(out, "promotion failed", shim.RC_RUNTIME_ERROR); + } + + if (std.mem.eql(u8, tool, "coord_transfer_master")) { + const parsed = std.json.parseFromSlice(std.json.Value, alloc, json_args, .{}) catch + return ci_fail(out, "invalid json", shim.RC_BAD_ARGS); + defer parsed.deinit(); + const tv = parsed.value.object.get("token") orelse return ci_fail(out, "missing token", shim.RC_BAD_ARGS); + const nv = parsed.value.object.get("new_peer_id") orelse return ci_fail(out, "missing new_peer_id", shim.RC_BAD_ARGS); + const sv = parsed.value.object.get("secret") orelse return ci_fail(out, "missing secret", shim.RC_BAD_ARGS); + var token: [16]u8 = undefined; + if (!ci_parseToken(tv.string, &token)) return ci_fail(out, "invalid token hex", shim.RC_BAD_ARGS); + const suf = ci_extractSuffix(nv.string) orelse return ci_fail(out, "invalid new_peer_id format β€” expected -<4hex>[@]", shim.RC_BAD_ARGS); + const target_idx = coord_find_peer_by_suffix(suf.ptr); + if (target_idx < 0) return ci_fail(out, "target peer not found", shim.RC_BAD_ARGS); + const secret = sv.string; + const rc = coord_transfer_master(&token, 16, target_idx, secret.ptr, @intCast(secret.len)); + if (rc == 0) return ci_ok(out, "transferred"); + if (rc == -1) return ci_fail(out, "caller is not the current master", shim.RC_AUTH_DENIED); + if (rc == -2) return ci_fail(out, "target peer not found or same as caller", shim.RC_BAD_ARGS); + if (rc == -3) return ci_fail(out, "secret does not match BOJ_MASTER_TOKEN", shim.RC_AUTH_DENIED); + if (rc == -4) return ci_fail(out, "target is an apprentice β€” must be journeyman or master", shim.RC_BAD_ARGS); + return ci_fail(out, "transfer failed", shim.RC_RUNTIME_ERROR); + } + + if (std.mem.eql(u8, tool, "coord_send_gated")) { + const parsed = std.json.parseFromSlice(std.json.Value, alloc, json_args, .{}) catch + return ci_fail(out, "invalid json", shim.RC_BAD_ARGS); + defer parsed.deinit(); + const tv = parsed.value.object.get("token") orelse return ci_fail(out, "missing token", shim.RC_BAD_ARGS); + const tgt = parsed.value.object.get("target") orelse return ci_fail(out, "missing target", shim.RC_BAD_ARGS); + const mv = parsed.value.object.get("message") orelse return ci_fail(out, "missing message", shim.RC_BAD_ARGS); + const tier_v = parsed.value.object.get("risk_tier") orelse return ci_fail(out, "missing risk_tier", shim.RC_BAD_ARGS); + var token: [16]u8 = undefined; + if (!ci_parseToken(tv.string, &token)) return ci_fail(out, "invalid token hex", shim.RC_BAD_ARGS); + const target_str = tgt.string; + var target_idx: i32 = -1; + if (!std.mem.eql(u8, target_str, "*")) { + const suf = ci_extractSuffix(target_str) orelse return ci_fail(out, "invalid target format", shim.RC_BAD_ARGS); + target_idx = coord_find_peer_by_suffix(suf.ptr); + if (target_idx < 0) return ci_fail(out, "target peer not found", shim.RC_BAD_ARGS); + } + const msg = mv.string; + const tier: i32 = @intCast(tier_v.integer); + const rc = coord_send_gated(&token, 16, target_idx, msg.ptr, @intCast(msg.len), tier); + if (rc >= 0) { + const b = std.fmt.bufPrint(out, "{{\"success\":true,\"status\":\"delivered\",\"sent\":{d}}}", .{rc}) catch + return ci_fail(out, "buffer overflow", shim.RC_RUNTIME_ERROR); + return .{ .written = b.len, .rc = shim.RC_SUCCESS }; + } + if (rc < -1000) { + const request_id: i64 = -(@as(i64, rc) + 1000); + const b = std.fmt.bufPrint(out, "{{\"success\":true,\"status\":\"quarantined\",\"request_id\":{d}}}", .{request_id}) catch + return ci_fail(out, "buffer overflow", shim.RC_RUNTIME_ERROR); + return .{ .written = b.len, .rc = shim.RC_SUCCESS }; + } + if (rc == -1) return ci_fail(out, "unauthenticated", shim.RC_AUTH_DENIED); + if (rc == -2) return ci_fail(out, "target peer not found", shim.RC_BAD_ARGS); + if (rc == -3) return ci_fail(out, "target inbox full", shim.RC_RUNTIME_ERROR); + if (rc == -4) return ci_fail(out, "quarantine queue full β€” spill to VeriSimDB not yet wired", shim.RC_RUNTIME_ERROR); + if (rc == -5) return ci_fail(out, "no master registered β€” Tier 2+ from apprentice requires a master", shim.RC_RUNTIME_ERROR); + return ci_fail(out, "gated send failed", shim.RC_RUNTIME_ERROR); + } + + if (std.mem.eql(u8, tool, "coord_review")) { + const parsed = std.json.parseFromSlice(std.json.Value, alloc, json_args, .{}) catch + return ci_fail(out, "invalid json", shim.RC_BAD_ARGS); + defer parsed.deinit(); + const tv = parsed.value.object.get("token") orelse return ci_fail(out, "missing token", shim.RC_BAD_ARGS); + var token: [16]u8 = undefined; + if (!ci_parseToken(tv.string, &token)) return ci_fail(out, "invalid token hex", shim.RC_BAD_ARGS); + var raw: [512]u8 = undefined; + const count = coord_review(&token, 16, &raw, @intCast(raw.len)); + if (count < 0) return ci_fail(out, "master role required", shim.RC_AUTH_DENIED); + var stream = std.io.fixedBufferStream(out); + const w = stream.writer(); + w.writeAll("{\"success\":true,\"entries\":[") catch return ci_fail(out, "buffer overflow", shim.RC_RUNTIME_ERROR); + var i: usize = 0; + const cnt: usize = @intCast(count); + while (i < cnt) : (i += 1) { + const rec = raw[i * 16 ..][0..16]; + const rid: u32 = @bitCast([4]u8{ rec[0], rec[1], rec[2], rec[3] }); + const sender_idx: u8 = rec[4]; + const target_idx_sign: i8 = @bitCast(rec[5]); + const risk_tier: u8 = rec[6]; + const mlen: u16 = @bitCast([2]u8{ rec[7], rec[8] }); + const preview_n: usize = @min(@as(usize, 7), @as(usize, mlen)); + const preview = rec[9 .. 9 + preview_n]; + if (i > 0) w.writeByte(',') catch return ci_fail(out, "buffer overflow", shim.RC_RUNTIME_ERROR); + const origin: []const u8 = if (sender_idx == 0xFE) "server-engine" else "peer"; + std.fmt.format(w, "{{\"request_id\":{d},\"origin\":\"{s}\",\"sender_idx\":{d},\"target_idx\":{d},\"risk_tier\":{d},\"msg_len\":{d},\"preview\":", .{ + rid, origin, sender_idx, target_idx_sign, risk_tier, mlen, + }) catch return ci_fail(out, "buffer overflow", shim.RC_RUNTIME_ERROR); + ci_writeJsonString(w, preview) catch return ci_fail(out, "buffer overflow", shim.RC_RUNTIME_ERROR); + w.writeByte('}') catch return ci_fail(out, "buffer overflow", shim.RC_RUNTIME_ERROR); + } + w.writeAll("]}") catch return ci_fail(out, "buffer overflow", shim.RC_RUNTIME_ERROR); + return .{ .written = stream.pos, .rc = shim.RC_SUCCESS }; + } + + if (std.mem.eql(u8, tool, "coord_review_entry")) { + const parsed = std.json.parseFromSlice(std.json.Value, alloc, json_args, .{}) catch + return ci_fail(out, "invalid json", shim.RC_BAD_ARGS); + defer parsed.deinit(); + const tv = parsed.value.object.get("token") orelse return ci_fail(out, "missing token", shim.RC_BAD_ARGS); + const rv = parsed.value.object.get("request_id") orelse return ci_fail(out, "missing request_id", shim.RC_BAD_ARGS); + var token: [16]u8 = undefined; + if (!ci_parseToken(tv.string, &token)) return ci_fail(out, "invalid token hex", shim.RC_BAD_ARGS); + var msg_buf: [512]u8 = undefined; + const rc = coord_review_entry(&token, 16, @intCast(rv.integer), &msg_buf, @intCast(msg_buf.len)); + if (rc == -1) return ci_fail(out, "master role required", shim.RC_AUTH_DENIED); + if (rc == -2) return ci_fail(out, "request_id not found", shim.RC_BAD_ARGS); + if (rc < 0) return ci_fail(out, "review failed", shim.RC_RUNTIME_ERROR); + var stream = std.io.fixedBufferStream(out); + const w = stream.writer(); + w.writeAll("{\"success\":true,\"message\":") catch return ci_fail(out, "buffer overflow", shim.RC_RUNTIME_ERROR); + ci_writeJsonString(w, msg_buf[0..@intCast(rc)]) catch return ci_fail(out, "buffer overflow", shim.RC_RUNTIME_ERROR); + w.writeByte('}') catch return ci_fail(out, "buffer overflow", shim.RC_RUNTIME_ERROR); + return .{ .written = stream.pos, .rc = shim.RC_SUCCESS }; + } + + if (std.mem.eql(u8, tool, "coord_approve")) { + const parsed = std.json.parseFromSlice(std.json.Value, alloc, json_args, .{}) catch + return ci_fail(out, "invalid json", shim.RC_BAD_ARGS); + defer parsed.deinit(); + const tv = parsed.value.object.get("token") orelse return ci_fail(out, "missing token", shim.RC_BAD_ARGS); + const rv = parsed.value.object.get("request_id") orelse return ci_fail(out, "missing request_id", shim.RC_BAD_ARGS); + var token: [16]u8 = undefined; + if (!ci_parseToken(tv.string, &token)) return ci_fail(out, "invalid token hex", shim.RC_BAD_ARGS); + const rc = coord_approve(&token, 16, @intCast(rv.integer)); + if (rc == 0) return ci_ok(out, "approved"); + if (rc == -1) return ci_fail(out, "master role required", shim.RC_AUTH_DENIED); + if (rc == -2) return ci_fail(out, "request_id not found", shim.RC_BAD_ARGS); + if (rc == -3) return ci_fail(out, "target inbox full β€” retry", shim.RC_RUNTIME_ERROR); + return ci_fail(out, "approve failed", shim.RC_RUNTIME_ERROR); + } + + if (std.mem.eql(u8, tool, "coord_reject")) { + const parsed = std.json.parseFromSlice(std.json.Value, alloc, json_args, .{}) catch + return ci_fail(out, "invalid json", shim.RC_BAD_ARGS); + defer parsed.deinit(); + const tv = parsed.value.object.get("token") orelse return ci_fail(out, "missing token", shim.RC_BAD_ARGS); + const rv = parsed.value.object.get("request_id") orelse return ci_fail(out, "missing request_id", shim.RC_BAD_ARGS); + const rea = parsed.value.object.get("reason") orelse return ci_fail(out, "missing reason", shim.RC_BAD_ARGS); + var token: [16]u8 = undefined; + if (!ci_parseToken(tv.string, &token)) return ci_fail(out, "invalid token hex", shim.RC_BAD_ARGS); + const reason = rea.string; + const rc = coord_reject(&token, 16, @intCast(rv.integer), reason.ptr, @intCast(reason.len)); + if (rc == 0) return ci_ok(out, "rejected"); + if (rc == -1) return ci_fail(out, "master role required", shim.RC_AUTH_DENIED); + if (rc == -2) return ci_fail(out, "request_id not found", shim.RC_BAD_ARGS); + return ci_fail(out, "reject failed", shim.RC_RUNTIME_ERROR); + } + + if (std.mem.eql(u8, tool, "coord_report_outcome")) { + const parsed = std.json.parseFromSlice(std.json.Value, alloc, json_args, .{}) catch + return ci_fail(out, "invalid json", shim.RC_BAD_ARGS); + defer parsed.deinit(); + const tv = parsed.value.object.get("token") orelse return ci_fail(out, "missing token", shim.RC_BAD_ARGS); + const tag_v = parsed.value.object.get("tag") orelse return ci_fail(out, "missing tag", shim.RC_BAD_ARGS); + const out_v = parsed.value.object.get("outcome") orelse return ci_fail(out, "missing outcome", shim.RC_BAD_ARGS); + const tier_v = parsed.value.object.get("risk_tier") orelse return ci_fail(out, "missing risk_tier", shim.RC_BAD_ARGS); + var token: [16]u8 = undefined; + if (!ci_parseToken(tv.string, &token)) return ci_fail(out, "invalid token hex", shim.RC_BAD_ARGS); + const tag_str = tag_v.string; + if (tag_str.len > 64) return ci_fail(out, "tag exceeds 64 bytes", shim.RC_BAD_ARGS); + const duration_ms: i64 = blk: { + const v = parsed.value.object.get("duration_ms") orelse break :blk 0; + break :blk v.integer; + }; + const confidence: i32 = blk: { + const v = parsed.value.object.get("confidence") orelse break :blk -1; + break :blk switch (v) { + .float => |f| @intFromFloat(@min(@max(f * 100.0, 0.0), 100.0)), + .integer => |i| @intCast(i), + else => -1, + }; + }; + var outcome: i32 = -1; + switch (out_v) { + .string => |s| { + if (std.mem.eql(u8, s, "success")) outcome = 1; + if (std.mem.eql(u8, s, "fail")) outcome = 0; + }, + .integer => |i| outcome = @intCast(i), + else => {}, + } + if (outcome != 0 and outcome != 1) return ci_fail(out, "outcome must be 'success'/'fail' or 0/1", shim.RC_BAD_ARGS); + const tier: i32 = @intCast(tier_v.integer); + const rc = coord_report_outcome(&token, 16, tag_str.ptr, @intCast(tag_str.len), outcome, @intCast(duration_ms), tier, confidence); + if (rc == 0) return ci_ok(out, "recorded"); + if (rc == -1) return ci_fail(out, "unauthenticated", shim.RC_AUTH_DENIED); + if (rc == -2) return ci_fail(out, "invalid args", shim.RC_BAD_ARGS); + return ci_fail(out, "report failed", shim.RC_RUNTIME_ERROR); + } + + if (std.mem.eql(u8, tool, "coord_set_declared_affinities")) { + const parsed = std.json.parseFromSlice(std.json.Value, alloc, json_args, .{}) catch + return ci_fail(out, "invalid json", shim.RC_BAD_ARGS); + defer parsed.deinit(); + const tv = parsed.value.object.get("token") orelse return ci_fail(out, "missing token", shim.RC_BAD_ARGS); + const tags_v = parsed.value.object.get("tags") orelse return ci_fail(out, "missing tags", shim.RC_BAD_ARGS); + if (tags_v != .array) return ci_fail(out, "tags must be an array of strings", shim.RC_BAD_ARGS); + var token: [16]u8 = undefined; + if (!ci_parseToken(tv.string, &token)) return ci_fail(out, "invalid token hex", shim.RC_BAD_ARGS); + var csv_buf: [256]u8 = undefined; + const csv_len = ci_arrayToCsv(tags_v.array.items, &csv_buf); + const rc = coord_set_declared_affinities(&token, 16, &csv_buf, @intCast(csv_len)); + if (rc == 0) return ci_ok(out, "declared"); + if (rc == -1) return ci_fail(out, "unauthenticated", shim.RC_AUTH_DENIED); + if (rc == -2) return ci_fail(out, "declared affinities CSV too long", shim.RC_BAD_ARGS); + return ci_fail(out, "set failed", shim.RC_RUNTIME_ERROR); + } + + if (std.mem.eql(u8, tool, "coord_scan_suggestions")) { + const parsed = std.json.parseFromSlice(std.json.Value, alloc, json_args, .{}) catch + return ci_fail(out, "invalid json", shim.RC_BAD_ARGS); + defer parsed.deinit(); + const tv = parsed.value.object.get("token") orelse return ci_fail(out, "missing token", shim.RC_BAD_ARGS); + var token: [16]u8 = undefined; + if (!ci_parseToken(tv.string, &token)) return ci_fail(out, "invalid token hex", shim.RC_BAD_ARGS); + const n = coord_scan_suggestions(&token, 16); + if (n == -1) return ci_fail(out, "unauthenticated", shim.RC_AUTH_DENIED); + const b = std.fmt.bufPrint(out, "{{\"success\":true,\"suggestions_queued\":{d},\"hint\":\"use coord_review to inspect, coord_approve/coord_reject to act\"}}", .{n}) catch + return ci_fail(out, "buffer overflow", shim.RC_RUNTIME_ERROR); + return .{ .written = b.len, .rc = shim.RC_SUCCESS }; + } + + if (std.mem.eql(u8, tool, "coord_get_affinities")) { + const parsed = std.json.parseFromSlice(std.json.Value, alloc, json_args, .{}) catch + return ci_fail(out, "invalid json", shim.RC_BAD_ARGS); + defer parsed.deinit(); + const tv = parsed.value.object.get("token") orelse return ci_fail(out, "missing token", shim.RC_BAD_ARGS); + var token: [16]u8 = undefined; + if (!ci_parseToken(tv.string, &token)) return ci_fail(out, "invalid token hex", shim.RC_BAD_ARGS); + var raw: [4096]u8 = undefined; + const n = coord_get_affinities(&token, 16, &raw, @intCast(raw.len)); + if (n == -1) return ci_fail(out, "unauthenticated", shim.RC_AUTH_DENIED); + if (n < 0) return ci_fail(out, "affinity query failed", shim.RC_RUNTIME_ERROR); + var stream = std.io.fixedBufferStream(out); + const w = stream.writer(); + w.writeAll("{\"success\":true,\"affinities\":[") catch return ci_fail(out, "buffer overflow", shim.RC_RUNTIME_ERROR); + const REC_SIZE: usize = 64; + const cnt: usize = @intCast(n); + var i: usize = 0; + while (i < cnt) : (i += 1) { + const rec = raw[i * REC_SIZE ..][0..REC_SIZE]; + const kind: u8 = rec[0]; + const attempts: u16 = @bitCast([2]u8{ rec[1], rec[2] }); + const successes: u16 = @bitCast([2]u8{ rec[3], rec[4] }); + const pct: u8 = rec[5]; + const tag_len: u8 = rec[6]; + const tag = rec[7 .. 7 + @min(@as(usize, tag_len), 57)]; + if (i > 0) w.writeByte(',') catch return ci_fail(out, "buffer overflow", shim.RC_RUNTIME_ERROR); + std.fmt.format(w, "{{\"client_kind\":\"{s}\",\"tag\":", .{ci_kindName(@intCast(kind))}) catch return ci_fail(out, "buffer overflow", shim.RC_RUNTIME_ERROR); + ci_writeJsonString(w, tag) catch return ci_fail(out, "buffer overflow", shim.RC_RUNTIME_ERROR); + if (pct == 255) { + std.fmt.format(w, ",\"attempts\":{d},\"successes\":{d},\"effective_affinity\":null}}", .{ attempts, successes }) catch return ci_fail(out, "buffer overflow", shim.RC_RUNTIME_ERROR); + } else { + std.fmt.format(w, ",\"attempts\":{d},\"successes\":{d},\"effective_affinity\":{d}.{d:0>2}}}", .{ attempts, successes, pct / 100, pct % 100 }) catch return ci_fail(out, "buffer overflow", shim.RC_RUNTIME_ERROR); + } + } + w.writeAll("]}") catch return ci_fail(out, "buffer overflow", shim.RC_RUNTIME_ERROR); + return .{ .written = stream.pos, .rc = shim.RC_SUCCESS }; + } + + if (std.mem.eql(u8, tool, "coord_set_variant")) { + const parsed = std.json.parseFromSlice(std.json.Value, alloc, json_args, .{}) catch + return ci_fail(out, "invalid json", shim.RC_BAD_ARGS); + defer parsed.deinit(); + const tv = parsed.value.object.get("token") orelse return ci_fail(out, "missing token", shim.RC_BAD_ARGS); + const vv = parsed.value.object.get("variant") orelse return ci_fail(out, "missing variant", shim.RC_BAD_ARGS); + if (vv != .string) return ci_fail(out, "variant must be a string", shim.RC_BAD_ARGS); + var token: [16]u8 = undefined; + if (!ci_parseToken(tv.string, &token)) return ci_fail(out, "invalid token hex", shim.RC_BAD_ARGS); + const vs = vv.string; + const rc = coord_set_variant(&token, 16, vs.ptr, @intCast(vs.len)); + if (rc == 0) return ci_ok(out, "set"); + if (rc == -1) return ci_fail(out, "unauthenticated", shim.RC_AUTH_DENIED); + if (rc == -2) return ci_fail(out, "invalid variant (alphanum / . / - / _ only, max 32 bytes)", shim.RC_BAD_ARGS); + return ci_fail(out, "set_variant failed", shim.RC_RUNTIME_ERROR); + } + + if (std.mem.eql(u8, tool, "coord_set_capabilities")) { + const parsed = std.json.parseFromSlice(std.json.Value, alloc, json_args, .{}) catch + return ci_fail(out, "invalid json", shim.RC_BAD_ARGS); + defer parsed.deinit(); + const tv = parsed.value.object.get("token") orelse return ci_fail(out, "missing token", shim.RC_BAD_ARGS); + var token: [16]u8 = undefined; + if (!ci_parseToken(tv.string, &token)) return ci_fail(out, "invalid token hex", shim.RC_BAD_ARGS); + var class_buf: [128]u8 = undefined; + var class_len: usize = 0; + if (parsed.value.object.get("class")) |cv| { + if (cv == .array) class_len = ci_arrayToCsv(cv.array.items, &class_buf); + } + var tier: i32 = 0; + if (parsed.value.object.get("tier")) |tier_v| { + if (tier_v == .integer) tier = @intCast(tier_v.integer); + } + var pro_buf: [256]u8 = undefined; + var pro_len: usize = 0; + if (parsed.value.object.get("prover_strengths")) |pv| { + if (pv == .array) pro_len = ci_arrayToCsv(pv.array.items, &pro_buf); + } + const rc = coord_set_capabilities(&token, 16, &class_buf, @intCast(class_len), tier, &pro_buf, @intCast(pro_len)); + if (rc == 0) return ci_ok(out, "set"); + if (rc == -1) return ci_fail(out, "unauthenticated", shim.RC_AUTH_DENIED); + if (rc == -2) return ci_fail(out, "invalid capabilities (tier 0..5, class ≀128B, prover_strengths ≀256B)", shim.RC_BAD_ARGS); + return ci_fail(out, "set_capabilities failed", shim.RC_RUNTIME_ERROR); + } + + if (std.mem.eql(u8, tool, "coord_get_peer_capabilities")) { + const parsed = std.json.parseFromSlice(std.json.Value, alloc, json_args, .{}) catch + return ci_fail(out, "invalid json", shim.RC_BAD_ARGS); + defer parsed.deinit(); + const tv = parsed.value.object.get("token") orelse return ci_fail(out, "missing token", shim.RC_BAD_ARGS); + const pv = parsed.value.object.get("peer_id") orelse return ci_fail(out, "missing peer_id", shim.RC_BAD_ARGS); + var token: [16]u8 = undefined; + if (!ci_parseToken(tv.string, &token)) return ci_fail(out, "invalid token hex", shim.RC_BAD_ARGS); + var probe: [1]u8 = undefined; + if (coord_list_peers(&token, 16, &probe, 0) < 0) return ci_fail(out, "unauthenticated", shim.RC_AUTH_DENIED); + const suf = ci_extractSuffix(pv.string) orelse return ci_fail(out, "invalid peer_id format β€” expected -<4hex>[@]", shim.RC_BAD_ARGS); + const peer_idx = coord_find_peer_by_suffix(suf.ptr); + if (peer_idx < 0) return ci_fail(out, "peer not found", shim.RC_BAD_ARGS); + var class_buf: [128]u8 = undefined; + const class_n = coord_read_peer_class(peer_idx, &class_buf, @intCast(class_buf.len)); + const class_slice: []const u8 = if (class_n > 0) class_buf[0..@intCast(class_n)] else ""; + const tier_val = coord_read_peer_tier(peer_idx); + var pro_buf: [256]u8 = undefined; + const pro_n = coord_read_peer_provers(peer_idx, &pro_buf, @intCast(pro_buf.len)); + const pro_slice: []const u8 = if (pro_n > 0) pro_buf[0..@intCast(pro_n)] else ""; + var var_buf: [32]u8 = undefined; + const v_n = coord_read_peer_variant(peer_idx, &var_buf, @intCast(var_buf.len)); + const var_slice: []const u8 = if (v_n > 0) var_buf[0..@intCast(v_n)] else ""; + const kind_val = coord_read_peer_kind(peer_idx); + var ctx_buf: [32]u8 = undefined; + const ctx_n = coord_read_peer_context(peer_idx, &ctx_buf, @intCast(ctx_buf.len)); + const ctx_slice: []const u8 = if (ctx_n > 0) ctx_buf[0..@intCast(ctx_n)] else ""; + var pid_buf: [96]u8 = undefined; + const canon_id = ci_renderPeerId(&pid_buf, ci_kindName(kind_val), suf, ctx_slice) catch + return ci_fail(out, "peer_id render overflow", shim.RC_RUNTIME_ERROR); + var stream = std.io.fixedBufferStream(out); + const w = stream.writer(); + std.fmt.format(w, "{{\"success\":true,\"peer_id\":\"{s}\",\"kind\":\"{s}\",\"variant\":\"{s}\",\"tier\":{d},\"class\":[", .{ + canon_id, ci_kindName(kind_val), var_slice, tier_val, + }) catch return ci_fail(out, "buffer overflow", shim.RC_RUNTIME_ERROR); + ci_writeCsvAsJsonStrings(w, class_slice) catch return ci_fail(out, "buffer overflow", shim.RC_RUNTIME_ERROR); + w.writeAll("],\"prover_strengths\":[") catch return ci_fail(out, "buffer overflow", shim.RC_RUNTIME_ERROR); + ci_writeCsvAsJsonStrings(w, pro_slice) catch return ci_fail(out, "buffer overflow", shim.RC_RUNTIME_ERROR); + w.writeAll("]}") catch return ci_fail(out, "buffer overflow", shim.RC_RUNTIME_ERROR); + return .{ .written = stream.pos, .rc = shim.RC_SUCCESS }; + } + + if (std.mem.eql(u8, tool, "coord_progress")) { + const parsed = std.json.parseFromSlice(std.json.Value, alloc, json_args, .{}) catch + return ci_fail(out, "invalid json", shim.RC_BAD_ARGS); + defer parsed.deinit(); + const tv = parsed.value.object.get("token") orelse return ci_fail(out, "missing token", shim.RC_BAD_ARGS); + const task = parsed.value.object.get("task") orelse return ci_fail(out, "missing task", shim.RC_BAD_ARGS); + var token: [16]u8 = undefined; + if (!ci_parseToken(tv.string, &token)) return ci_fail(out, "invalid token hex", shim.RC_BAD_ARGS); + const rc = coord_progress(&token, 16, task.string.ptr, @intCast(task.string.len)); + if (rc == 0) return ci_ok(out, "heartbeat"); + if (rc == -1) return ci_fail(out, "unauthenticated", shim.RC_AUTH_DENIED); + if (rc == -2) return ci_fail(out, "no active claim for this task", shim.RC_BAD_ARGS); + if (rc == -3) return ci_fail(out, "caller is not the claim holder", shim.RC_AUTH_DENIED); + return ci_fail(out, "progress failed", shim.RC_RUNTIME_ERROR); + } + + if (std.mem.eql(u8, tool, "coord_sweep_watchdog")) { + const parsed = std.json.parseFromSlice(std.json.Value, alloc, json_args, .{}) catch + return ci_fail(out, "invalid json", shim.RC_BAD_ARGS); + defer parsed.deinit(); + const tv = parsed.value.object.get("token") orelse return ci_fail(out, "missing token", shim.RC_BAD_ARGS); + var token: [16]u8 = undefined; + if (!ci_parseToken(tv.string, &token)) return ci_fail(out, "invalid token hex", shim.RC_BAD_ARGS); + const rc = coord_sweep_watchdog(&token, 16); + if (rc < 0) return ci_fail(out, "unauthenticated", shim.RC_AUTH_DENIED); + const b = std.fmt.bufPrint(out, "{{\"success\":true,\"released\":{d},\"ttl_apprentice_ms\":30000,\"ttl_journeyman_ms\":300000}}", .{rc}) catch + return ci_fail(out, "buffer overflow", shim.RC_RUNTIME_ERROR); + return .{ .written = b.len, .rc = shim.RC_SUCCESS }; + } + + if (std.mem.eql(u8, tool, "coord_list_claims")) { + const parsed = std.json.parseFromSlice(std.json.Value, alloc, json_args, .{}) catch + return ci_fail(out, "invalid json", shim.RC_BAD_ARGS); + defer parsed.deinit(); + const tv = parsed.value.object.get("token") orelse return ci_fail(out, "missing token", shim.RC_BAD_ARGS); + var token: [16]u8 = undefined; + if (!ci_parseToken(tv.string, &token)) return ci_fail(out, "invalid token hex", shim.RC_BAD_ARGS); + var stream = std.io.fixedBufferStream(out); + const w = stream.writer(); + w.writeAll("{\"success\":true,\"active_claims\":[") catch return ci_fail(out, "buffer overflow", shim.RC_RUNTIME_ERROR); + var first = true; + var ci: c_int = 0; + while (ci < 64) : (ci += 1) { + var task_buf: [128]u8 = undefined; + const task_len = coord_read_claim_task(&token, 16, ci, &task_buf, @intCast(task_buf.len)); + if (task_len < 0) continue; + const task_slice = task_buf[0..@intCast(task_len)]; + var holder_suffix: [4]u8 = undefined; + const hs_rc = coord_read_claim_holder_suffix(&token, 16, ci, &holder_suffix); + if (!first) w.writeByte(',') catch return ci_fail(out, "buffer overflow", shim.RC_RUNTIME_ERROR); + first = false; + w.writeAll("{\"task\":") catch return ci_fail(out, "buffer overflow", shim.RC_RUNTIME_ERROR); + ci_writeJsonString(w, task_slice) catch return ci_fail(out, "buffer overflow", shim.RC_RUNTIME_ERROR); + if (hs_rc == 4) { + std.fmt.format(w, ",\"holder\":\"{s}\"", .{holder_suffix}) catch return ci_fail(out, "buffer overflow", shim.RC_RUNTIME_ERROR); + } else { + w.writeAll(",\"holder\":\"\"") catch return ci_fail(out, "buffer overflow", shim.RC_RUNTIME_ERROR); + } + w.writeByte('}') catch return ci_fail(out, "buffer overflow", shim.RC_RUNTIME_ERROR); + } + w.writeAll("]}") catch return ci_fail(out, "buffer overflow", shim.RC_RUNTIME_ERROR); + return .{ .written = stream.pos, .rc = shim.RC_SUCCESS }; + } + + return ci_fail(out, "unknown tool", shim.RC_UNKNOWN_TOOL); +} + +/// Dispatch the cartridge.json MCP tools β€” Grade B: all 20+ tools wired to +/// real FFI calls (Task #2). Parses JSON args with page_allocator; error +/// bodies are written to out_buf even when rc < 0. +export fn boj_cartridge_invoke( + tool_name: [*c]const u8, + json_args: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) callconv(.c) i32 { + if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; + const cap = in_out_len.*; + if (cap == 0) { + in_out_len.* = 64; // hint a minimum useful size + return shim.RC_BUFFER_TOO_SMALL; + } + // CWE-704 fix (post-#146): std.mem.sliceTo(ptr, 0) reads the C string + // up to the first NUL without an `@ptrCast` and without the + // `std.mem.spanZ` that no longer exists in Zig 0.14+. The optional-payload + // capture `if (json_args != null) |ja|` was also invalid for [*c] + // pointers β€” those are null-checked with `== null`, not unwrapped. + const tool = std.mem.sliceTo(tool_name, 0); + const args: []const u8 = if (json_args == null) "{}" else std.mem.sliceTo(json_args, 0); + const result = ci_dispatch(tool, args, out_buf[0..cap], std.heap.page_allocator); + in_out_len.* = result.written; + return result.rc; +} + +// ═══════════════════════════════════════════════════════════════════════ +// Reset (for testing) +// ═══════════════════════════════════════════════════════════════════════ + +pub export fn coord_reset() void { + mutex.lock(); + defer mutex.unlock(); + peers = [_]Peer{empty_peer} ** MAX_PEERS; + claims = [_]Claim{empty_claim} ** MAX_CLAIMS; + quarantine = [_]QuarantineEntry{empty_quar} ** MAX_QUARANTINE; + next_request_id = 1; + track = [_]TrackEntry{empty_track} ** MAX_TRACK; + track_head = 0; + track_count = 0; + reject_ring = [_][REJECT_LIMIT]u64{[_]u64{0} ** REJECT_LIMIT} ** KIND_COUNT; + reject_head = [_]usize{0} ** KIND_COUNT; + peer_reject_ring = [_][REJECT_LIMIT]u64{[_]u64{0} ** REJECT_LIMIT} ** MAX_PEERS; + peer_reject_head = [_]usize{0} ** MAX_PEERS; +} + +// ═══════════════════════════════════════════════════════════════════════ +// Tests +// ═══════════════════════════════════════════════════════════════════════ + +test "register and deregister peer" { + coord_reset(); + var token: [TOKEN_LEN]u8 = undefined; + var suffix: [4]u8 = undefined; + const idx = coord_register(0, -1, &token, &suffix); // claude + try std.testing.expect(idx >= 0); + + // Deregister with correct token + const result = coord_deregister(&token, TOKEN_LEN); + try std.testing.expectEqual(@as(c_int, 0), result); +} + +test "register fills up" { + coord_reset(); + var tokens: [MAX_PEERS][TOKEN_LEN]u8 = undefined; + var suffix: [4]u8 = undefined; + + // Fill all slots + for (0..MAX_PEERS) |i| { + const idx = coord_register(0, -1, &tokens[i], &suffix); + try std.testing.expectEqual(@as(c_int, @intCast(i)), idx); + } + + // Next should fail + var extra_token: [TOKEN_LEN]u8 = undefined; + const overflow = coord_register(0, -1, &extra_token, &suffix); + try std.testing.expectEqual(@as(c_int, -1), overflow); + + coord_reset(); +} + +test "bad token rejected" { + coord_reset(); + var token: [TOKEN_LEN]u8 = undefined; + var suffix: [4]u8 = undefined; + _ = coord_register(0, -1, &token, &suffix); + + var bad_token = [_]u8{0xFF} ** TOKEN_LEN; + var out: [256]u8 = undefined; + const result = coord_list_peers(&bad_token, TOKEN_LEN, &out, 256); + try std.testing.expectEqual(@as(c_int, -1), result); + coord_reset(); +} + +test "claim mutex semantics" { + coord_reset(); + var tok1: [TOKEN_LEN]u8 = undefined; + var tok2: [TOKEN_LEN]u8 = undefined; + var suf: [4]u8 = undefined; + _ = coord_register(0, -1, &tok1, &suf); // claude + _ = coord_register(1, -1, &tok2, &suf); // gemini + + const task = "audit-boj-server"; + + // Peer 1 claims + const r1 = coord_claim_task(&tok1, TOKEN_LEN, task.ptr, @intCast(task.len)); + try std.testing.expectEqual(@as(c_int, 0), r1); // Granted + + // Peer 2 tries to claim same task β€” should be denied + const r2 = coord_claim_task(&tok2, TOKEN_LEN, task.ptr, @intCast(task.len)); + try std.testing.expectEqual(@as(c_int, 1), r2); // Held + + // Peer 1 releases + const r3 = coord_release_task(&tok1, TOKEN_LEN, task.ptr, @intCast(task.len)); + try std.testing.expectEqual(@as(c_int, 0), r3); + + // Now peer 2 can claim + const r4 = coord_claim_task(&tok2, TOKEN_LEN, task.ptr, @intCast(task.len)); + try std.testing.expectEqual(@as(c_int, 0), r4); // Granted + + coord_reset(); +} + +test "watchdog: apprentice claim swept past 30s TTL" { + coord_reset(); + var tok: [TOKEN_LEN]u8 = undefined; + var suf: [4]u8 = undefined; + _ = coord_register(1, -1, &tok, &suf); // gemini β†’ apprentice, 30s TTL + + const task = "long-running-apprentice-task"; + try std.testing.expectEqual(@as(c_int, 0), + coord_claim_task(&tok, TOKEN_LEN, task.ptr, @intCast(task.len))); + + // Rewind the claim timestamp past the apprentice TTL. + claims[0].claimed_at_ms -= TTL_APPRENTICE_MS + 1000; + + const released = coord_sweep_watchdog(&tok, TOKEN_LEN); + try std.testing.expectEqual(@as(c_int, 1), released); + try std.testing.expectEqual(false, claims[0].active); + + coord_reset(); +} + +test "watchdog: progress heartbeat keeps claim alive" { + coord_reset(); + var tok: [TOKEN_LEN]u8 = undefined; + var suf: [4]u8 = undefined; + _ = coord_register(1, -1, &tok, &suf); // gemini β†’ apprentice + + const task = "heartbeat-task"; + try std.testing.expectEqual(@as(c_int, 0), + coord_claim_task(&tok, TOKEN_LEN, task.ptr, @intCast(task.len))); + + // Rewind just under the TTL to simulate nearly-expired work. + claims[0].claimed_at_ms -= TTL_APPRENTICE_MS - 1000; + + // Heartbeat must succeed and bump the timestamp. + try std.testing.expectEqual(@as(c_int, 0), + coord_progress(&tok, TOKEN_LEN, task.ptr, @intCast(task.len))); + + // Sweep now should release nothing β€” TTL got reset. + try std.testing.expectEqual(@as(c_int, 0), coord_sweep_watchdog(&tok, TOKEN_LEN)); + try std.testing.expectEqual(true, claims[0].active); + + coord_reset(); +} + +test "watchdog: journeyman gets 5min TTL, master never swept" { + coord_reset(); + var tok_j: [TOKEN_LEN]u8 = undefined; + var tok_a: [TOKEN_LEN]u8 = undefined; + var suf: [4]u8 = undefined; + _ = coord_register(0, -1, &tok_j, &suf); // claude β†’ journeyman + _ = coord_register(1, -1, &tok_a, &suf); // gemini β†’ apprentice + + const t_j = "journeyman-task"; + const t_a = "apprentice-task"; + _ = coord_claim_task(&tok_j, TOKEN_LEN, t_j.ptr, @intCast(t_j.len)); + _ = coord_claim_task(&tok_a, TOKEN_LEN, t_a.ptr, @intCast(t_a.len)); + + // Rewind both by 1 minute: journeyman still safe (5 min TTL), apprentice + // is way over (30 s TTL). + const one_min: u64 = 60 * 1000; + claims[0].claimed_at_ms -= one_min; + claims[1].claimed_at_ms -= one_min; + + try std.testing.expectEqual(@as(c_int, 1), coord_sweep_watchdog(&tok_j, TOKEN_LEN)); + try std.testing.expectEqual(true, claims[0].active); // journeyman survives + try std.testing.expectEqual(false, claims[1].active); // apprentice swept + + // Now push the journeyman past its TTL. + claims[0].claimed_at_ms -= TTL_JOURNEYMAN_MS; + try std.testing.expectEqual(@as(c_int, 1), coord_sweep_watchdog(&tok_j, TOKEN_LEN)); + try std.testing.expectEqual(false, claims[0].active); + + coord_reset(); +} + +test "watchdog: progress rejected from non-holder" { + coord_reset(); + var tok1: [TOKEN_LEN]u8 = undefined; + var tok2: [TOKEN_LEN]u8 = undefined; + var suf: [4]u8 = undefined; + _ = coord_register(0, -1, &tok1, &suf); + _ = coord_register(1, -1, &tok2, &suf); + + const task = "held-by-tok1"; + _ = coord_claim_task(&tok1, TOKEN_LEN, task.ptr, @intCast(task.len)); + + // tok2 tries to refresh tok1's claim β†’ -3. + try std.testing.expectEqual(@as(c_int, -3), + coord_progress(&tok2, TOKEN_LEN, task.ptr, @intCast(task.len))); + + // Unknown task β†’ -2. + const missing = "no-such-task"; + try std.testing.expectEqual(@as(c_int, -2), + coord_progress(&tok1, TOKEN_LEN, missing.ptr, @intCast(missing.len))); + + coord_reset(); +} + +test "watchdog: implicit sweep frees stale slot on contention" { + coord_reset(); + var tok_old: [TOKEN_LEN]u8 = undefined; + var tok_new: [TOKEN_LEN]u8 = undefined; + var suf: [4]u8 = undefined; + _ = coord_register(1, -1, &tok_old, &suf); // gemini β†’ apprentice + _ = coord_register(1, -1, &tok_new, &suf); + + const task = "contested-task"; + _ = coord_claim_task(&tok_old, TOKEN_LEN, task.ptr, @intCast(task.len)); + claims[0].claimed_at_ms -= TTL_APPRENTICE_MS + 1000; + + // Second peer attempts to claim β€” sweep runs inline, old claim evaporates, + // new peer gets granted (rc=0), not held (rc=1). + try std.testing.expectEqual(@as(c_int, 0), + coord_claim_task(&tok_new, TOKEN_LEN, task.ptr, @intCast(task.len))); + + coord_reset(); +} + +test "watchdog auto-release files warn_drift into quarantine when master present" { + // DD-21: with a master on the pool, the warn_drift envelope is queued + // for master review as a server-origin suggestion (sender_idx = 0xFE). + coord_reset(); + var tok_m: [TOKEN_LEN]u8 = undefined; + var tok_a: [TOKEN_LEN]u8 = undefined; + var suf: [4]u8 = undefined; + _ = coord_register(0, -1, &tok_m, &suf); // claude β†’ journeyman; promote below + _ = coord_register(1, -1, &tok_a, &suf); // gemini β†’ apprentice + peers[0].role = .master; + + const task = "stalled-work"; + try std.testing.expectEqual(@as(c_int, 0), + coord_claim_task(&tok_a, TOKEN_LEN, task.ptr, @intCast(task.len))); + claims[0].claimed_at_ms -= TTL_APPRENTICE_MS + 1000; + + const released = coord_sweep_watchdog(&tok_m, TOKEN_LEN); + try std.testing.expectEqual(@as(c_int, 1), released); + + // Quarantine now has one server-origin entry with the warn_drift envelope. + var found: bool = false; + for (&quarantine) |*q| { + if (!q.active) continue; + if (q.sender_idx != SERVER_ORIGIN_SENTINEL) continue; + try std.testing.expectEqual(@as(i8, -1), q.target_idx); + try std.testing.expectEqual(@as(u8, 1), q.risk_tier); + const body = q.msg[0..q.msg_len]; + try std.testing.expect(std.mem.indexOf(u8, body, "\"kind\":\"warn_drift\"") != null); + try std.testing.expect(std.mem.indexOf(u8, body, "\"task\":\"stalled-work\"") != null); + try std.testing.expect(std.mem.indexOf(u8, body, "\"role\":\"apprentice\"") != null); + try std.testing.expect(std.mem.indexOf(u8, body, "\"held_ms\":") != null); + found = true; + break; + } + try std.testing.expect(found); + + coord_reset(); +} + +test "watchdog auto-release broadcasts warn_drift when no master" { + // DD-21 fallback: with no master present, the envelope goes straight + // to every active peer's inbox so the warning still lands. + coord_reset(); + var tok_a: [TOKEN_LEN]u8 = undefined; + var tok_j: [TOKEN_LEN]u8 = undefined; + var suf: [4]u8 = undefined; + _ = coord_register(1, -1, &tok_a, &suf); // gemini β†’ apprentice (holder) + _ = coord_register(0, -1, &tok_j, &suf); // claude β†’ journeyman (receiver) + + const task = "abandoned-claim"; + try std.testing.expectEqual(@as(c_int, 0), + coord_claim_task(&tok_a, TOKEN_LEN, task.ptr, @intCast(task.len))); + claims[0].claimed_at_ms -= TTL_APPRENTICE_MS + 1000; + + const released = coord_sweep_watchdog(&tok_j, TOKEN_LEN); + try std.testing.expectEqual(@as(c_int, 1), released); + + // Journeyman (idx 1) should have a warn_drift envelope in its inbox. + try std.testing.expect(peers[1].inbox_count >= 1); + const tail = peers[1].inbox_tail; + const len: usize = peers[1].inbox_lens[tail]; + const body = peers[1].inbox[tail][0..len]; + try std.testing.expect(std.mem.indexOf(u8, body, "\"kind\":\"warn_drift\"") != null); + try std.testing.expect(std.mem.indexOf(u8, body, "\"task\":\"abandoned-claim\"") != null); + try std.testing.expect(std.mem.indexOf(u8, body, "\"role\":\"apprentice\"") != null); + + // Holder (idx 0) must NOT receive its own drift warning. + try std.testing.expectEqual(@as(u16, 0), peers[0].inbox_count); + + coord_reset(); +} + +test "idempotent claim" { + coord_reset(); + var tok: [TOKEN_LEN]u8 = undefined; + var suf: [4]u8 = undefined; + _ = coord_register(0, -1, &tok, &suf); + + const task = "fix-ci"; + const r1 = coord_claim_task(&tok, TOKEN_LEN, task.ptr, @intCast(task.len)); + try std.testing.expectEqual(@as(c_int, 0), r1); + + // Same peer claims again β€” should be idempotent grant + const r2 = coord_claim_task(&tok, TOKEN_LEN, task.ptr, @intCast(task.len)); + try std.testing.expectEqual(@as(c_int, 0), r2); + + coord_reset(); +} + +test "send and receive direct message" { + coord_reset(); + var tok1: [TOKEN_LEN]u8 = undefined; + var tok2: [TOKEN_LEN]u8 = undefined; + var suf: [4]u8 = undefined; + const idx1 = coord_register(0, -1, &tok1, &suf); + _ = coord_register(1, -1, &tok2, &suf); + + const msg = "hello from claude"; + // idx1 sends to idx2 (idx2 = 1) + _ = idx1; + const sent = coord_send(&tok1, TOKEN_LEN, 1, msg.ptr, @intCast(msg.len)); + try std.testing.expectEqual(@as(c_int, 1), sent); + + // idx2 receives + var buf: [512]u8 = undefined; + const received = coord_receive(&tok2, TOKEN_LEN, &buf, 512); + try std.testing.expectEqual(@as(c_int, @intCast(msg.len)), received); + try std.testing.expect(std.mem.eql(u8, buf[0..msg.len], msg)); + + // No more messages + const empty = coord_receive(&tok2, TOKEN_LEN, &buf, 512); + try std.testing.expectEqual(@as(c_int, 0), empty); + + coord_reset(); +} + +test "broadcast message" { + coord_reset(); + var tok1: [TOKEN_LEN]u8 = undefined; + var tok2: [TOKEN_LEN]u8 = undefined; + var tok3: [TOKEN_LEN]u8 = undefined; + var suf: [4]u8 = undefined; + _ = coord_register(0, -1, &tok1, &suf); + _ = coord_register(1, -1, &tok2, &suf); + _ = coord_register(2, -1, &tok3, &suf); + + const msg = "starting audit"; + const sent = coord_send(&tok1, TOKEN_LEN, -1, msg.ptr, @intCast(msg.len)); + try std.testing.expectEqual(@as(c_int, 2), sent); // 2 recipients (not sender) + + // Both tok2 and tok3 should have the message + var buf: [512]u8 = undefined; + const r2 = coord_receive(&tok2, TOKEN_LEN, &buf, 512); + try std.testing.expect(r2 > 0); + const r3 = coord_receive(&tok3, TOKEN_LEN, &buf, 512); + try std.testing.expect(r3 > 0); + + // Sender should NOT have the message + const r1 = coord_receive(&tok1, TOKEN_LEN, &buf, 512); + try std.testing.expectEqual(@as(c_int, 0), r1); + + coord_reset(); +} + +test "set and read peer context" { + coord_reset(); + var tok: [TOKEN_LEN]u8 = undefined; + var suf: [4]u8 = undefined; + const idx = coord_register(0, -1, &tok, &suf); + try std.testing.expect(idx >= 0); + + // Initially empty + var ctx_buf: [MAX_CONTEXT]u8 = undefined; + const empty = coord_read_peer_context(idx, &ctx_buf, @intCast(ctx_buf.len)); + try std.testing.expectEqual(@as(c_int, 0), empty); + + // Set a valid context + const ctx = "007-lang"; + const set_ok = coord_set_context(&tok, TOKEN_LEN, ctx.ptr, @intCast(ctx.len)); + try std.testing.expectEqual(@as(c_int, 0), set_ok); + + // Read it back + const read_len = coord_read_peer_context(idx, &ctx_buf, @intCast(ctx_buf.len)); + try std.testing.expectEqual(@as(c_int, @intCast(ctx.len)), read_len); + try std.testing.expect(std.mem.eql(u8, ctx_buf[0..ctx.len], ctx)); + + // Bad context (spaces) rejected + const bad = "has space"; + const rc_bad = coord_set_context(&tok, TOKEN_LEN, bad.ptr, @intCast(bad.len)); + try std.testing.expectEqual(@as(c_int, -2), rc_bad); + + // Original context untouched after rejection + const reread = coord_read_peer_context(idx, &ctx_buf, @intCast(ctx_buf.len)); + try std.testing.expectEqual(@as(c_int, @intCast(ctx.len)), reread); + + // Slot reuse clears context + _ = coord_deregister(&tok, TOKEN_LEN); + var tok2: [TOKEN_LEN]u8 = undefined; + const idx2 = coord_register(0, -1, &tok2, &suf); + // Same slot likely re-used; context should be zeroed + const after = coord_read_peer_context(idx2, &ctx_buf, @intCast(ctx_buf.len)); + try std.testing.expectEqual(@as(c_int, 0), after); + + coord_reset(); +} + +test "default role derives from client_kind" { + coord_reset(); + var tok: [TOKEN_LEN]u8 = undefined; + var suf: [4]u8 = undefined; + + // claude -> journeyman + const c_idx = coord_register(0, -1, &tok, &suf); + try std.testing.expect(c_idx >= 0); + try std.testing.expectEqual(@as(c_int, @intFromEnum(Role.journeyman)), coord_read_peer_role(c_idx)); + + // gemini -> apprentice + const g_idx = coord_register(1, -1, &tok, &suf); + try std.testing.expect(g_idx >= 0); + try std.testing.expectEqual(@as(c_int, @intFromEnum(Role.apprentice)), coord_read_peer_role(g_idx)); + + // copilot -> apprentice + const p_idx = coord_register(2, -1, &tok, &suf); + try std.testing.expect(p_idx >= 0); + try std.testing.expectEqual(@as(c_int, @intFromEnum(Role.apprentice)), coord_read_peer_role(p_idx)); + + // custom -> apprentice + const x_idx = coord_register(3, -1, &tok, &suf); + try std.testing.expect(x_idx >= 0); + try std.testing.expectEqual(@as(c_int, @intFromEnum(Role.apprentice)), coord_read_peer_role(x_idx)); + + // openai -> apprentice (Task #33) + const o_idx = coord_register(4, -1, &tok, &suf); + try std.testing.expect(o_idx >= 0); + try std.testing.expectEqual(@as(c_int, @intFromEnum(Role.apprentice)), coord_read_peer_role(o_idx)); + try std.testing.expectEqual(@as(c_int, 4), coord_read_peer_kind(o_idx)); + + // mistral -> apprentice (Task #33) + const m_idx = coord_register(5, -1, &tok, &suf); + try std.testing.expect(m_idx >= 0); + try std.testing.expectEqual(@as(c_int, @intFromEnum(Role.apprentice)), coord_read_peer_role(m_idx)); + try std.testing.expectEqual(@as(c_int, 5), coord_read_peer_kind(m_idx)); + + coord_reset(); +} + +test "set and read peer variant (Task #33)" { + coord_reset(); + var tok: [TOKEN_LEN]u8 = undefined; + var suf: [4]u8 = undefined; + const idx = coord_register(4, -1, &tok, &suf); // openai + try std.testing.expect(idx >= 0); + + // Initially empty. + var vbuf: [MAX_VARIANT]u8 = undefined; + try std.testing.expectEqual(@as(c_int, 0), coord_read_peer_variant(idx, &vbuf, @intCast(vbuf.len))); + + // Valid variant. + const v = "opus-4.7"; + try std.testing.expectEqual(@as(c_int, 0), coord_set_variant(&tok, TOKEN_LEN, v.ptr, @intCast(v.len))); + const n = coord_read_peer_variant(idx, &vbuf, @intCast(vbuf.len)); + try std.testing.expectEqual(@as(c_int, @intCast(v.len)), n); + try std.testing.expect(std.mem.eql(u8, vbuf[0..v.len], v)); + + // Reject spaces. + const bad = "has space"; + try std.testing.expectEqual(@as(c_int, -2), coord_set_variant(&tok, TOKEN_LEN, bad.ptr, @intCast(bad.len))); + + // Slot reuse clears variant. + _ = coord_deregister(&tok, TOKEN_LEN); + var tok2: [TOKEN_LEN]u8 = undefined; + const idx2 = coord_register(4, -1, &tok2, &suf); + try std.testing.expectEqual(@as(c_int, 0), coord_read_peer_variant(idx2, &vbuf, @intCast(vbuf.len))); + + coord_reset(); +} + +test "set and read peer capabilities (Task #34)" { + coord_reset(); + var tok: [TOKEN_LEN]u8 = undefined; + var suf: [4]u8 = undefined; + const idx = coord_register(0, -1, &tok, &suf); // claude + try std.testing.expect(idx >= 0); + + const class = "reasoning,coding,proof"; + const provers = "coq,lean,tlaps"; + const rc = coord_set_capabilities( + &tok, TOKEN_LEN, + class.ptr, @intCast(class.len), + 5, + provers.ptr, @intCast(provers.len), + ); + try std.testing.expectEqual(@as(c_int, 0), rc); + + try std.testing.expectEqual(@as(c_int, 5), coord_read_peer_tier(idx)); + + var cbuf: [MAX_CLASS]u8 = undefined; + const cn = coord_read_peer_class(idx, &cbuf, @intCast(cbuf.len)); + try std.testing.expectEqual(@as(c_int, @intCast(class.len)), cn); + try std.testing.expect(std.mem.eql(u8, cbuf[0..class.len], class)); + + var pbuf: [MAX_PROVERS]u8 = undefined; + const pn = coord_read_peer_provers(idx, &pbuf, @intCast(pbuf.len)); + try std.testing.expectEqual(@as(c_int, @intCast(provers.len)), pn); + try std.testing.expect(std.mem.eql(u8, pbuf[0..provers.len], provers)); + + // tier out of range rejected. + const rc_bad_tier = coord_set_capabilities( + &tok, TOKEN_LEN, + class.ptr, @intCast(class.len), + 99, + provers.ptr, @intCast(provers.len), + ); + try std.testing.expectEqual(@as(c_int, -2), rc_bad_tier); + // Prior value retained on rejection. + try std.testing.expectEqual(@as(c_int, 5), coord_read_peer_tier(idx)); + + // Bad char (quote) in class CSV rejected. + const bad_class = "a\",b"; + const rc_bad_char = coord_set_capabilities( + &tok, TOKEN_LEN, + bad_class.ptr, @intCast(bad_class.len), + 3, + provers.ptr, @intCast(provers.len), + ); + try std.testing.expectEqual(@as(c_int, -2), rc_bad_char); + + coord_reset(); +} + +test "coord_health counts basics" { + coord_reset(); + var tok1: [TOKEN_LEN]u8 = undefined; + var tok2: [TOKEN_LEN]u8 = undefined; + var tok3: [TOKEN_LEN]u8 = undefined; + var suf: [4]u8 = undefined; + + _ = coord_register(0, -1, &tok1, &suf); // claude β†’ journeyman + _ = coord_register(1, -1, &tok2, &suf); // gemini β†’ apprentice + _ = coord_register(4, -1, &tok3, &suf); // openai β†’ apprentice + + // Claim from tok1 contributes to active claim count. + const t = "health-test-task"; + try std.testing.expectEqual(@as(c_int, 0), coord_claim_task(&tok1, TOKEN_LEN, t.ptr, @intCast(t.len))); + + // Bad token returns -1. + var bad: [TOKEN_LEN]u8 = [_]u8{0} ** TOKEN_LEN; + try std.testing.expectEqual(@as(c_int, -1), coord_count_claims(&bad, TOKEN_LEN)); + try std.testing.expectEqual(@as(c_int, -1), coord_count_quarantine(&bad, TOKEN_LEN)); + try std.testing.expectEqual(@as(c_int, -1), coord_count_track(&bad, TOKEN_LEN)); + + // With a valid token, counts are positive / zero as expected. + try std.testing.expectEqual(@as(c_int, 1), coord_count_claims(&tok1, TOKEN_LEN)); + try std.testing.expectEqual(@as(c_int, 0), coord_count_quarantine(&tok1, TOKEN_LEN)); + try std.testing.expectEqual(@as(c_int, 0), coord_count_track(&tok1, TOKEN_LEN)); + + // Recent-rejects for an unseen kind (mistral=5) is 0; kind out of range is -2. + try std.testing.expectEqual(@as(c_int, 0), coord_count_rejects_recent(&tok1, TOKEN_LEN, 5)); + try std.testing.expectEqual(@as(c_int, -2), coord_count_rejects_recent(&tok1, TOKEN_LEN, 99)); + try std.testing.expectEqual(@as(c_int, 0), coord_kind_in_cooldown(&tok1, TOKEN_LEN, 0)); + + coord_reset(); +} + +test "register rejects master role_hint" { + coord_reset(); + var tok: [TOKEN_LEN]u8 = undefined; + var suf: [4]u8 = undefined; + + // role_hint=0 (master) is always rejected; must use coord_promote_to_master. + const rc = coord_register(0, 0, &tok, &suf); + try std.testing.expectEqual(@as(c_int, -3), rc); + + coord_reset(); +} + +test "promote to master requires env-var secret match" { + coord_reset(); + var tok: [TOKEN_LEN]u8 = undefined; + var suf: [4]u8 = undefined; + const idx = coord_register(0, -1, &tok, &suf); + try std.testing.expect(idx >= 0); + + // No env var set -> promotion refused (-3). + // (Can't reliably unset env in Zig std; this test documents the expected + // contract. The match path is exercised by the adapter-level integration + // test which sets BOJ_SUPERVISOR_TOKEN before spawning the process.) + + coord_reset(); +} + +test "coord_transfer_master rejects apprentice target" { + coord_reset(); + var mas_tok: [TOKEN_LEN]u8 = undefined; + var app_tok: [TOKEN_LEN]u8 = undefined; + var suf: [4]u8 = undefined; + + // Install master directly to avoid env-var gymnastics. + _ = coord_register(0, 1, &mas_tok, &suf); // journeyman + peers[0].role = .master; + + // Register apprentice (gemini default). + const app_idx = coord_register(1, -1, &app_tok, &suf); + try std.testing.expect(app_idx >= 0); + try std.testing.expectEqual(Role.apprentice, peers[@intCast(app_idx)].role); + + // Handoff to apprentice target -> -4. + // Even with a valid env secret, target role blocks before secret check. + // Without env secret the same target would yield -3 β€” but role gate + // fires first in our implementation so -4 is returned when target is + // an apprentice regardless of secret validity. + const rc = coord_transfer_master(&mas_tok, TOKEN_LEN, app_idx, "whatever".ptr, 8); + try std.testing.expectEqual(@as(c_int, -4), rc); + + coord_reset(); +} + +test "coord_transfer_master caller must be master" { + coord_reset(); + var jm_tok: [TOKEN_LEN]u8 = undefined; + var other_tok: [TOKEN_LEN]u8 = undefined; + var suf: [4]u8 = undefined; + + _ = coord_register(0, -1, &jm_tok, &suf); // claude -> journeyman + const other_idx = coord_register(0, -1, &other_tok, &suf); + try std.testing.expect(other_idx >= 0); + + const rc = coord_transfer_master(&jm_tok, TOKEN_LEN, other_idx, "x".ptr, 1); + try std.testing.expectEqual(@as(c_int, -1), rc); + coord_reset(); +} + +test "coord_transfer_master bad target idx" { + coord_reset(); + var mas_tok: [TOKEN_LEN]u8 = undefined; + var suf: [4]u8 = undefined; + _ = coord_register(0, 1, &mas_tok, &suf); + peers[0].role = .master; + + const rc = coord_transfer_master(&mas_tok, TOKEN_LEN, 99, "x".ptr, 1); + try std.testing.expectEqual(@as(c_int, -2), rc); + coord_reset(); +} + +test "gated send from apprentice peer lands in quarantine" { + coord_reset(); + var sup_tok: [TOKEN_LEN]u8 = undefined; + var gem_tok: [TOKEN_LEN]u8 = undefined; + var suf: [4]u8 = undefined; + + // Manually install a master by bypassing env-var gate (test-only + // shortcut β€” we set role directly via coord_set_role which needs a + // master token, so we instead register the master by direct + // register + role override for this test). + _ = coord_register(0, 1, &sup_tok, &suf); // journeyman + // Upgrade directly for test purposes by touching the peer record. + // In production this happens via coord_promote_to_master. + peers[0].role = .master; + + // Now register gemini as apprentice (default for kind=1). + const gem_idx = coord_register(1, -1, &gem_tok, &suf); + try std.testing.expect(gem_idx >= 0); + try std.testing.expectEqual(@as(c_int, @intFromEnum(Role.apprentice)), coord_read_peer_role(gem_idx)); + + // Tier 0 op from apprentice: direct delivery. + const msg_low = "status-update"; + const t0_rc = coord_send_gated(&gem_tok, TOKEN_LEN, 0, msg_low.ptr, @intCast(msg_low.len), 0); + try std.testing.expectEqual(@as(c_int, 1), t0_rc); // direct send: sent=1 + + // Tier 2 op from apprentice: quarantined; returned value encodes request_id. + const msg_high = "proposed-commit-a1b2c3d"; + const t2_rc = coord_send_gated(&gem_tok, TOKEN_LEN, 0, msg_high.ptr, @intCast(msg_high.len), 2); + try std.testing.expect(t2_rc < -1000); // encoded request_id + + const request_id: u32 = @intCast(-(t2_rc + 1000)); + + // Supervisor should see one pending entry. + var review_buf: [512]u8 = undefined; + const n = coord_review(&sup_tok, TOKEN_LEN, &review_buf, @intCast(review_buf.len)); + try std.testing.expectEqual(@as(c_int, 1), n); + + // Full entry body is readable. + var body_buf: [512]u8 = undefined; + const body_len = coord_review_entry(&sup_tok, TOKEN_LEN, @intCast(request_id), &body_buf, @intCast(body_buf.len)); + try std.testing.expectEqual(@as(c_int, @intCast(msg_high.len)), body_len); + try std.testing.expect(std.mem.eql(u8, body_buf[0..msg_high.len], msg_high)); + + // Approve delivers to recipient. + const a_rc = coord_approve(&sup_tok, TOKEN_LEN, @intCast(request_id)); + try std.testing.expectEqual(@as(c_int, 0), a_rc); + + // Recipient (index 0 β€” master in this test) can receive the message. + var recv_buf: [512]u8 = undefined; + // First message is msg_low from the Tier 0 send (it went direct). + // The gated approved msg_high is now second in queue. + const r1_len = coord_receive(&sup_tok, TOKEN_LEN, &recv_buf, @intCast(recv_buf.len)); + try std.testing.expect(r1_len > 0); + const r2_len = coord_receive(&sup_tok, TOKEN_LEN, &recv_buf, @intCast(recv_buf.len)); + try std.testing.expectEqual(@as(c_int, @intCast(msg_high.len)), r2_len); + try std.testing.expect(std.mem.eql(u8, recv_buf[0..msg_high.len], msg_high)); + + coord_reset(); +} + +test "master rejects a quarantined entry" { + coord_reset(); + var sup_tok: [TOKEN_LEN]u8 = undefined; + var gem_tok: [TOKEN_LEN]u8 = undefined; + var suf: [4]u8 = undefined; + + _ = coord_register(0, 1, &sup_tok, &suf); + peers[0].role = .master; + _ = coord_register(1, -1, &gem_tok, &suf); + + const msg = "sneaky-push"; + const rc = coord_send_gated(&gem_tok, TOKEN_LEN, 0, msg.ptr, @intCast(msg.len), 3); + try std.testing.expect(rc < -1000); + const request_id: u32 = @intCast(-(rc + 1000)); + + const reason = "confabulated file path"; + const rj = coord_reject(&sup_tok, TOKEN_LEN, @intCast(request_id), reason.ptr, @intCast(reason.len)); + try std.testing.expectEqual(@as(c_int, 0), rj); + + // Review queue now empty. + var buf: [512]u8 = undefined; + const n = coord_review(&sup_tok, TOKEN_LEN, &buf, @intCast(buf.len)); + try std.testing.expectEqual(@as(c_int, 0), n); + + // Recipient did NOT get the message. + const r = coord_receive(&sup_tok, TOKEN_LEN, &buf, @intCast(buf.len)); + try std.testing.expectEqual(@as(c_int, 0), r); + + coord_reset(); +} + +test "non-master cannot review/approve/reject" { + coord_reset(); + var tok: [TOKEN_LEN]u8 = undefined; + var suf: [4]u8 = undefined; + _ = coord_register(0, -1, &tok, &suf); // journeyman, not master + + var out: [128]u8 = undefined; + try std.testing.expectEqual(@as(c_int, -1), coord_review(&tok, TOKEN_LEN, &out, @intCast(out.len))); + try std.testing.expectEqual(@as(c_int, -1), coord_approve(&tok, TOKEN_LEN, 42)); + const reason = "nope"; + try std.testing.expectEqual(@as(c_int, -1), coord_reject(&tok, TOKEN_LEN, 42, reason.ptr, @intCast(reason.len))); + + coord_reset(); +} + +test "find peer by suffix" { + coord_reset(); + var tok: [TOKEN_LEN]u8 = undefined; + var suf: [4]u8 = undefined; + const idx = coord_register(0, -1, &tok, &suf); + try std.testing.expect(idx >= 0); + + // Lookup should find it + const found = coord_find_peer_by_suffix(&suf); + try std.testing.expectEqual(@as(c_int, idx), found); + + // Unknown suffix returns -1 + const miss = [4]u8{ 'z', 'z', 'z', 'z' }; + const not_found = coord_find_peer_by_suffix(&miss); + try std.testing.expectEqual(@as(c_int, -1), not_found); + + coord_reset(); +} + +test "deregister releases claims" { + coord_reset(); + var tok1: [TOKEN_LEN]u8 = undefined; + var tok2: [TOKEN_LEN]u8 = undefined; + var suf: [4]u8 = undefined; + _ = coord_register(0, -1, &tok1, &suf); + _ = coord_register(1, -1, &tok2, &suf); + + const task = "fix-pipeline"; + _ = coord_claim_task(&tok1, TOKEN_LEN, task.ptr, @intCast(task.len)); + + // Deregister peer 1 + _ = coord_deregister(&tok1, TOKEN_LEN); + + // Peer 2 should now be able to claim + const r = coord_claim_task(&tok2, TOKEN_LEN, task.ptr, @intCast(task.len)); + try std.testing.expectEqual(@as(c_int, 0), r); // Granted + + coord_reset(); +} + +// ═══════════════════════════════════════════════════════════════════════ +// ADR-0006 invoke dispatch tests +// ═══════════════════════════════════════════════════════════════════════ + +test "invoke: coord_register with valid client_kind succeeds" { + coord_reset(); + var buf: [512]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("coord_register", "{\"client_kind\":\"claude\"}", &buf, &len); + try std.testing.expectEqual(@as(i32, 0), rc); + try std.testing.expect(std.mem.indexOf(u8, buf[0..len], "peer_id") != null); + try std.testing.expect(std.mem.indexOf(u8, buf[0..len], "token") != null); +} + +test "invoke: missing required args returns RC_BAD_ARGS" { + coord_reset(); + var buf: [256]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("coord_register", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, shim.RC_BAD_ARGS), rc); + try std.testing.expect(len > 0); // error body written even on failure +} + +test "invoke: unknown tool returns -1" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("nope", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -1), rc); +} + +test "invoke: zero-capacity buffer returns -3 with size hint" { + var buf: [4]u8 = undefined; + var len: usize = 0; + const rc = boj_cartridge_invoke("coord_register", "{\"client_kind\":\"claude\"}", &buf, &len); + try std.testing.expectEqual(@as(i32, -3), rc); + try std.testing.expect(len > 0); // hint provided +} + +// ═══════════════════════════════════════════════════════════════════════ +// Durability integration tests β€” restart-preserves-state +// ═══════════════════════════════════════════════════════════════════════ + +fn tmpCoordDir(buf: []u8) ![]u8 { + return std.fmt.bufPrint(buf, "/tmp/boj-coord-integ-{d}-{d}", .{ + std.time.milliTimestamp(), + std.crypto.random.int(u32), + }); +} + +test "restart replay restores peer, claim, inbox, quarantine" { + coord_reset(); + dur.close(); + + var path_buf: [256]u8 = undefined; + const dir = try tmpCoordDir(&path_buf); + defer std.fs.cwd().deleteTree(dir) catch {}; + + try std.testing.expect(dur.openWithDir(dir)); + dur.truncate(); + + // ── Phase 1: build up state under durable logging ───────────────── + var sup_tok: [TOKEN_LEN]u8 = undefined; + var sup_suf: [4]u8 = undefined; + const sup_idx = coord_register(0, 1, &sup_tok, &sup_suf); // claude as journeyman + try std.testing.expect(sup_idx >= 0); + // Promote directly via state mutation β€” avoids env-var gymnastics + // in-test, and still gets persisted via the coord_set_role path below. + peers[@intCast(sup_idx)].role = .master; + dur.logPeerRoleSet(@intCast(sup_idx), @intCast(@intFromEnum(Role.master))); + + var gem_tok: [TOKEN_LEN]u8 = undefined; + var gem_suf: [4]u8 = undefined; + const gem_idx = coord_register(1, -1, &gem_tok, &gem_suf); // gemini β†’ apprentice + try std.testing.expect(gem_idx >= 0); + + // Remember identities for post-replay comparison. + const sup_suf_copy = sup_suf; + const gem_suf_copy = gem_suf; + + // Set a context on the apprentice peer. + const ctx = "007-lang"; + try std.testing.expectEqual( + @as(c_int, 0), + coord_set_context(&gem_tok, TOKEN_LEN, ctx.ptr, @intCast(ctx.len)), + ); + + // Send a direct message sup β†’ gem; leave it unreceived so it must + // come back from replay. + const pending_msg = "pending-direct-message"; + try std.testing.expectEqual( + @as(c_int, 1), + coord_send(&sup_tok, TOKEN_LEN, gem_idx, pending_msg.ptr, @intCast(pending_msg.len)), + ); + + // Claim a task as the master. + const task = "restart-replay-task"; + try std.testing.expectEqual( + @as(c_int, 0), + coord_claim_task(&sup_tok, TOKEN_LEN, task.ptr, @intCast(task.len)), + ); + + // Gemini files a Tier 3 gated op β€” lands in quarantine. + const gated_msg = "proposed-commit"; + const gated_rc = coord_send_gated(&gem_tok, TOKEN_LEN, sup_idx, gated_msg.ptr, @intCast(gated_msg.len), 3); + try std.testing.expect(gated_rc < -1000); + const request_id: u32 = @intCast(-(gated_rc + 1000)); + + // ── Phase 2: simulate adapter restart β€” close log, wipe memory, reopen, replay ── + dur.close(); + coord_reset(); + try std.testing.expect(dur.openWithDir(dir)); + dur.replay(replayDispatch); + defer { + dur.close(); + } + + // ── Phase 3: verify state reconstructed ─────────────────────────── + + // Peers re-occupy their original slots with original suffixes. + try std.testing.expect(peers[@intCast(sup_idx)].active); + try std.testing.expectEqualSlices(u8, &sup_suf_copy, &peers[@intCast(sup_idx)].suffix); + try std.testing.expectEqual(Role.master, peers[@intCast(sup_idx)].role); + + try std.testing.expect(peers[@intCast(gem_idx)].active); + try std.testing.expectEqualSlices(u8, &gem_suf_copy, &peers[@intCast(gem_idx)].suffix); + try std.testing.expectEqual(Role.apprentice, peers[@intCast(gem_idx)].role); + + // Context survives replay. + var ctx_buf: [MAX_CONTEXT]u8 = undefined; + const ctx_len = coord_read_peer_context(gem_idx, &ctx_buf, @intCast(ctx_buf.len)); + try std.testing.expectEqual(@as(c_int, @intCast(ctx.len)), ctx_len); + try std.testing.expectEqualSlices(u8, ctx, ctx_buf[0..@intCast(ctx_len)]); + + // Pending inbox message delivers to gemini on receive. + var recv_buf: [512]u8 = undefined; + const recv_len = coord_receive(&gem_tok, TOKEN_LEN, &recv_buf, @intCast(recv_buf.len)); + try std.testing.expectEqual(@as(c_int, @intCast(pending_msg.len)), recv_len); + try std.testing.expectEqualSlices(u8, pending_msg, recv_buf[0..@intCast(recv_len)]); + + // Claim still held by master β€” another peer can't grab it. + const steal_rc = coord_claim_task(&gem_tok, TOKEN_LEN, task.ptr, @intCast(task.len)); + try std.testing.expectEqual(@as(c_int, 1), steal_rc); // Held + + // Supervisor's own re-claim is idempotent. + try std.testing.expectEqual( + @as(c_int, 0), + coord_claim_task(&sup_tok, TOKEN_LEN, task.ptr, @intCast(task.len)), + ); + + // Quarantine entry reappears for the master to review. + var review_buf: [512]u8 = undefined; + const n = coord_review(&sup_tok, TOKEN_LEN, &review_buf, @intCast(review_buf.len)); + try std.testing.expectEqual(@as(c_int, 1), n); + + var body_buf: [512]u8 = undefined; + const body_len = coord_review_entry(&sup_tok, TOKEN_LEN, @intCast(request_id), &body_buf, @intCast(body_buf.len)); + try std.testing.expectEqual(@as(c_int, @intCast(gated_msg.len)), body_len); + try std.testing.expectEqualSlices(u8, gated_msg, body_buf[0..@intCast(body_len)]); + + coord_reset(); +} + +test "approve then restart: quarantine gone, delivered message survives" { + coord_reset(); + dur.close(); + + var path_buf: [256]u8 = undefined; + const dir = try tmpCoordDir(&path_buf); + defer std.fs.cwd().deleteTree(dir) catch {}; + + try std.testing.expect(dur.openWithDir(dir)); + dur.truncate(); + + var sup_tok: [TOKEN_LEN]u8 = undefined; + var sup_suf: [4]u8 = undefined; + const sup_idx = coord_register(0, 1, &sup_tok, &sup_suf); + try std.testing.expect(sup_idx >= 0); + peers[@intCast(sup_idx)].role = .master; + dur.logPeerRoleSet(@intCast(sup_idx), @intCast(@intFromEnum(Role.master))); + + var gem_tok: [TOKEN_LEN]u8 = undefined; + var gem_suf: [4]u8 = undefined; + const gem_idx = coord_register(1, -1, &gem_tok, &gem_suf); + try std.testing.expect(gem_idx >= 0); + + // Supervised files, master approves β€” approved message is now in + // sup's inbox and the quarantine slot is freed. + const msg = "gated-and-approved"; + const gated_rc = coord_send_gated(&gem_tok, TOKEN_LEN, sup_idx, msg.ptr, @intCast(msg.len), 3); + try std.testing.expect(gated_rc < -1000); + const rid: u32 = @intCast(-(gated_rc + 1000)); + try std.testing.expectEqual(@as(c_int, 0), coord_approve(&sup_tok, TOKEN_LEN, @intCast(rid))); + + dur.close(); + coord_reset(); + try std.testing.expect(dur.openWithDir(dir)); + dur.replay(replayDispatch); + defer dur.close(); + + // Quarantine empty after replay (add + approve cancel out). + var review_buf: [256]u8 = undefined; + try std.testing.expectEqual(@as(c_int, 0), coord_review(&sup_tok, TOKEN_LEN, &review_buf, @intCast(review_buf.len))); + + // Approved message remains in sup's inbox. + var recv_buf: [512]u8 = undefined; + const n = coord_receive(&sup_tok, TOKEN_LEN, &recv_buf, @intCast(recv_buf.len)); + try std.testing.expectEqual(@as(c_int, @intCast(msg.len)), n); + try std.testing.expectEqualSlices(u8, msg, recv_buf[0..@intCast(n)]); + + coord_reset(); +} + +test "reject then restart: quarantine gone, message NOT delivered" { + coord_reset(); + dur.close(); + + var path_buf: [256]u8 = undefined; + const dir = try tmpCoordDir(&path_buf); + defer std.fs.cwd().deleteTree(dir) catch {}; + + try std.testing.expect(dur.openWithDir(dir)); + dur.truncate(); + + var sup_tok: [TOKEN_LEN]u8 = undefined; + var sup_suf: [4]u8 = undefined; + _ = coord_register(0, 1, &sup_tok, &sup_suf); + peers[0].role = .master; + dur.logPeerRoleSet(0, @intCast(@intFromEnum(Role.master))); + + var gem_tok: [TOKEN_LEN]u8 = undefined; + var gem_suf: [4]u8 = undefined; + _ = coord_register(1, -1, &gem_tok, &gem_suf); + + const msg = "gated-and-rejected"; + const gated_rc = coord_send_gated(&gem_tok, TOKEN_LEN, 0, msg.ptr, @intCast(msg.len), 3); + try std.testing.expect(gated_rc < -1000); + const rid: u32 = @intCast(-(gated_rc + 1000)); + const reason = "confabulated-path"; + try std.testing.expectEqual( + @as(c_int, 0), + coord_reject(&sup_tok, TOKEN_LEN, @intCast(rid), reason.ptr, @intCast(reason.len)), + ); + + dur.close(); + coord_reset(); + try std.testing.expect(dur.openWithDir(dir)); + dur.replay(replayDispatch); + defer dur.close(); + + // Supervisor inbox empty β€” rejected msg not delivered across restart. + var recv_buf: [256]u8 = undefined; + try std.testing.expectEqual( + @as(c_int, 0), + coord_receive(&sup_tok, TOKEN_LEN, &recv_buf, @intCast(recv_buf.len)), + ); + + // Quarantine empty too. + try std.testing.expectEqual( + @as(c_int, 0), + coord_review(&sup_tok, TOKEN_LEN, &recv_buf, @intCast(recv_buf.len)), + ); + + coord_reset(); +} + +// ═══════════════════════════════════════════════════════════════════════ +// Track Record tests (Task #13) +// ═══════════════════════════════════════════════════════════════════════ + +fn findAggByTag(out: []const u8, n: usize, kind: u8, tag: []const u8) ?usize { + const REC_SIZE: usize = 64; + var i: usize = 0; + while (i < n) : (i += 1) { + const rec = out[i * REC_SIZE ..][0..REC_SIZE]; + if (rec[0] != kind) continue; + const tl: usize = rec[6]; + if (tl != tag.len) continue; + if (std.mem.eql(u8, rec[7 .. 7 + tl], tag)) return i; + } + return null; +} + +test "report outcome and compute affinity" { + coord_reset(); + var tok: [TOKEN_LEN]u8 = undefined; + var suf: [4]u8 = undefined; + _ = coord_register(0, -1, &tok, &suf); // claude + + const tag = "proof-analysis"; + // 3 successes + 1 failure β†’ 75%. + for (0..3) |_| { + try std.testing.expectEqual( + @as(c_int, 0), + coord_report_outcome(&tok, TOKEN_LEN, tag.ptr, @intCast(tag.len), 1, 500, 2, -1), + ); + } + try std.testing.expectEqual( + @as(c_int, 0), + coord_report_outcome(&tok, TOKEN_LEN, tag.ptr, @intCast(tag.len), 0, 500, 2, -1), + ); + + var buf: [64 * 8]u8 = undefined; + const n = coord_get_affinities(&tok, TOKEN_LEN, &buf, @intCast(buf.len)); + try std.testing.expect(n >= 1); + + const n_usize: usize = @intCast(n); + const idx = findAggByTag(&buf, n_usize, 0, tag) orelse return error.AggregateMissing; + const rec = buf[idx * 64 ..][0..64]; + const attempts = std.mem.readInt(u16, rec[1..3], .little); + const successes = std.mem.readInt(u16, rec[3..5], .little); + try std.testing.expectEqual(@as(u16, 4), attempts); + try std.testing.expectEqual(@as(u16, 3), successes); + try std.testing.expectEqual(@as(u8, 75), rec[5]); + + coord_reset(); +} + +test "affinity keyed on client_kind, survives peer restart" { + coord_reset(); + + // Two claude peers in sequence (deregister + re-register simulates restart). + var tok1: [TOKEN_LEN]u8 = undefined; + var suf1: [4]u8 = undefined; + _ = coord_register(0, -1, &tok1, &suf1); // claude #1 + const tag = "routine-edit"; + _ = coord_report_outcome(&tok1, TOKEN_LEN, tag.ptr, @intCast(tag.len), 1, 100, 1, -1); + _ = coord_report_outcome(&tok1, TOKEN_LEN, tag.ptr, @intCast(tag.len), 1, 100, 1, -1); + _ = coord_deregister(&tok1, TOKEN_LEN); + + // New peer, same client_kind. Track record should aggregate together. + var tok2: [TOKEN_LEN]u8 = undefined; + var suf2: [4]u8 = undefined; + _ = coord_register(0, -1, &tok2, &suf2); // claude #2 + _ = coord_report_outcome(&tok2, TOKEN_LEN, tag.ptr, @intCast(tag.len), 0, 100, 1, -1); + + var buf: [64 * 8]u8 = undefined; + const n = coord_get_affinities(&tok2, TOKEN_LEN, &buf, @intCast(buf.len)); + try std.testing.expect(n >= 1); + const idx = findAggByTag(&buf, @intCast(n), 0, tag) orelse return error.AggregateMissing; + const rec = buf[idx * 64 ..][0..64]; + try std.testing.expectEqual(@as(u16, 3), std.mem.readInt(u16, rec[1..3], .little)); + try std.testing.expectEqual(@as(u16, 2), std.mem.readInt(u16, rec[3..5], .little)); + + coord_reset(); +} + +test "affinity window cap β€” last 20 attempts when no 7-day-older entries" { + coord_reset(); + var tok: [TOKEN_LEN]u8 = undefined; + var suf: [4]u8 = undefined; + _ = coord_register(0, -1, &tok, &suf); + + const tag = "doc-writing"; + // 25 successes β€” window caps at 20 (all newer than 7 days, count-rule wins). + var i: usize = 0; + while (i < 25) : (i += 1) { + _ = coord_report_outcome(&tok, TOKEN_LEN, tag.ptr, @intCast(tag.len), 1, 50, 1, -1); + } + + var buf: [64 * 4]u8 = undefined; + const n = coord_get_affinities(&tok, TOKEN_LEN, &buf, @intCast(buf.len)); + const idx = findAggByTag(&buf, @intCast(n), 0, tag) orelse return error.AggregateMissing; + const rec = buf[idx * 64 ..][0..64]; + // All 25 are within last 7 days, so time-window rule > 20-count rule. + // "whichever is larger" means we use 25 attempts. + try std.testing.expectEqual(@as(u16, 25), std.mem.readInt(u16, rec[1..3], .little)); + + coord_reset(); +} + +test "affinity bad token rejected" { + coord_reset(); + var bad_tok = [_]u8{0xFF} ** TOKEN_LEN; + var buf: [64]u8 = undefined; + try std.testing.expectEqual( + @as(c_int, -1), + coord_get_affinities(&bad_tok, TOKEN_LEN, &buf, @intCast(buf.len)), + ); + try std.testing.expectEqual( + @as(c_int, -1), + coord_report_outcome(&bad_tok, TOKEN_LEN, "x".ptr, 1, 1, 0, 0, -1), + ); + coord_reset(); +} + +// ═══════════════════════════════════════════════════════════════════════ +// Reassignment-engine tests (Task #14) +// ═══════════════════════════════════════════════════════════════════════ + +test "scan flags overclaim: high confidence + low effective_affinity" { + coord_reset(); + var tok: [TOKEN_LEN]u8 = undefined; + var suf: [4]u8 = undefined; + _ = coord_register(0, -1, &tok, &suf); // claude + + const tag = "formal-verification"; + // 1 success + 4 failures => 20% affinity. All with confidence 90. + _ = coord_report_outcome(&tok, TOKEN_LEN, tag.ptr, @intCast(tag.len), 1, 100, 2, 90); + for (0..4) |_| { + _ = coord_report_outcome(&tok, TOKEN_LEN, tag.ptr, @intCast(tag.len), 0, 100, 2, 90); + } + + // No master registered β€” scan still runs. + const n = coord_scan_suggestions(&tok, TOKEN_LEN); + try std.testing.expect(n >= 1); + + // The quarantine should contain server-origin envelopes for both + // overclaim (routing FYI, tier 1) and drift (monitoring warn, tier 2). + var found_overclaim: bool = false; + var found_remove: bool = false; + var found_drift: bool = false; + var drift_tier: u8 = 0; + for (&quarantine) |*q| { + if (!q.active) continue; + try std.testing.expectEqual(SERVER_ORIGIN_SENTINEL, q.sender_idx); + if (std.mem.indexOf(u8, q.msg[0..q.msg_len], "\"kind\":\"overclaim\"") != null) found_overclaim = true; + if (std.mem.indexOf(u8, q.msg[0..q.msg_len], "\"kind\":\"remove\"") != null) found_remove = true; + if (std.mem.indexOf(u8, q.msg[0..q.msg_len], "\"kind\":\"drift\"") != null) { + found_drift = true; + drift_tier = q.risk_tier; + // Drift envelope must carry the gap magnitude. + try std.testing.expect(std.mem.indexOf(u8, q.msg[0..q.msg_len], "\"drift_pct\":") != null); + try std.testing.expect(std.mem.indexOf(u8, q.msg[0..q.msg_len], "\"op_kind\":\"warn\"") != null); + } + } + try std.testing.expect(found_overclaim); + try std.testing.expect(found_drift); + try std.testing.expectEqual(@as(u8, 2), drift_tier); + // 5 attempts + 20% affinity also fires the remove rule. + try std.testing.expect(found_remove); + + coord_reset(); +} + +test "drift uses Task #33 kind names (openai)" { + coord_reset(); + var tok: [TOKEN_LEN]u8 = undefined; + var suf: [4]u8 = undefined; + _ = coord_register(4, -1, &tok, &suf); // openai + + const tag = "rust-codegen"; + _ = coord_report_outcome(&tok, TOKEN_LEN, tag.ptr, @intCast(tag.len), 1, 100, 2, 90); + for (0..4) |_| { + _ = coord_report_outcome(&tok, TOKEN_LEN, tag.ptr, @intCast(tag.len), 0, 100, 2, 90); + } + _ = coord_scan_suggestions(&tok, TOKEN_LEN); + + var found_openai_drift: bool = false; + for (&quarantine) |*q| { + if (!q.active) continue; + const body = q.msg[0..q.msg_len]; + if (std.mem.indexOf(u8, body, "\"kind\":\"drift\"") != null and + std.mem.indexOf(u8, body, "\"client_kind\":\"openai\"") != null) + { + found_openai_drift = true; + } + } + try std.testing.expect(found_openai_drift); + + coord_reset(); +} + +test "scan flags promote: high affinity on undeclared tag" { + coord_reset(); + var tok: [TOKEN_LEN]u8 = undefined; + var suf: [4]u8 = undefined; + _ = coord_register(0, -1, &tok, &suf); + + // Declare only 'routine-edit'; let 'proof-analysis' accumulate a + // strong record to trigger the promote suggestion. + const decl = "routine-edit"; + _ = coord_set_declared_affinities(&tok, TOKEN_LEN, decl.ptr, @intCast(decl.len)); + + const tag = "proof-analysis"; + for (0..8) |_| { + _ = coord_report_outcome(&tok, TOKEN_LEN, tag.ptr, @intCast(tag.len), 1, 100, 2, 60); + } + + const n = coord_scan_suggestions(&tok, TOKEN_LEN); + try std.testing.expect(n >= 1); + + var found_promote: bool = false; + for (&quarantine) |*q| { + if (!q.active) continue; + if (std.mem.indexOf(u8, q.msg[0..q.msg_len], "promote") != null) found_promote = true; + } + try std.testing.expect(found_promote); + coord_reset(); +} + +test "scan with no track record emits nothing" { + coord_reset(); + var tok: [TOKEN_LEN]u8 = undefined; + var suf: [4]u8 = undefined; + _ = coord_register(0, -1, &tok, &suf); + const n = coord_scan_suggestions(&tok, TOKEN_LEN); + try std.testing.expectEqual(@as(c_int, 0), n); + coord_reset(); +} + +test "declared affinities round-trip" { + coord_reset(); + var tok: [TOKEN_LEN]u8 = undefined; + var suf: [4]u8 = undefined; + const idx = coord_register(0, -1, &tok, &suf); + + const decl = "proof-analysis,supervision,doc-writing"; + try std.testing.expectEqual( + @as(c_int, 0), + coord_set_declared_affinities(&tok, TOKEN_LEN, decl.ptr, @intCast(decl.len)), + ); + + var buf: [256]u8 = undefined; + const dlen = coord_read_declared_affinities(idx, &buf, @intCast(buf.len)); + try std.testing.expectEqual(@as(c_int, @intCast(decl.len)), dlen); + try std.testing.expectEqualSlices(u8, decl, buf[0..@intCast(dlen)]); + coord_reset(); +} + +// ═══════════════════════════════════════════════════════════════════════ +// Claim extension + rejection cooldown tests (Task #15) +// ═══════════════════════════════════════════════════════════════════════ + +test "coord_claim_task_ex accepts optional fields and grants" { + coord_reset(); + var tok: [TOKEN_LEN]u8 = undefined; + var suf: [4]u8 = undefined; + _ = coord_register(0, -1, &tok, &suf); + + const task = "design-review"; + // confidence=80, dispatch_pref=deliberate (0), difficulty=challenging (2) + const rc = coord_claim_task_ex(&tok, TOKEN_LEN, task.ptr, @intCast(task.len), 80, 0, 2); + try std.testing.expectEqual(@as(c_int, 0), rc); + coord_reset(); +} + +test "rejection cooldown engages after 5 rejects in 10 min" { + coord_reset(); + var tok1: [TOKEN_LEN]u8 = undefined; + var tok2: [TOKEN_LEN]u8 = undefined; + var suf: [4]u8 = undefined; + _ = coord_register(0, -1, &tok1, &suf); // claude holds the task + _ = coord_register(0, -1, &tok2, &suf); // claude #2 keeps colliding + + const task = "held-task"; + try std.testing.expectEqual(@as(c_int, 0), coord_claim_task(&tok1, TOKEN_LEN, task.ptr, @intCast(task.len))); + + // 5 rejects in sequence β€” the 6th triggers cooldown. + var i: usize = 0; + while (i < 5) : (i += 1) { + try std.testing.expectEqual( + @as(c_int, 1), + coord_claim_task(&tok2, TOKEN_LEN, task.ptr, @intCast(task.len)), + ); + } + // Same kind (claude) β€” should now be in cooldown. + const cool = coord_claim_task(&tok2, TOKEN_LEN, task.ptr, @intCast(task.len)); + try std.testing.expectEqual(@as(c_int, -5), cool); + + // Different kind is unaffected. + var gem_tok: [TOKEN_LEN]u8 = undefined; + _ = coord_register(1, -1, &gem_tok, &suf); + const gem_rc = coord_claim_task(&gem_tok, TOKEN_LEN, task.ptr, @intCast(task.len)); + try std.testing.expectEqual(@as(c_int, 1), gem_rc); // held, not cooldown + + coord_reset(); +} + +test "per-peer reject ring counts independently of kind ring" { + // Item A β€” after 5 rejections of one peer, coord_count_rejects_recent_peer + // and coord_peer_in_cooldown reflect it via the public FFI. + coord_reset(); + var tok_holder: [TOKEN_LEN]u8 = undefined; + var tok_a: [TOKEN_LEN]u8 = undefined; + var suf: [4]u8 = undefined; + _ = coord_register(1, -1, &tok_holder, &suf); // gemini #1 (idx 0) holds + _ = coord_register(1, -1, &tok_a, &suf); // gemini #2 (idx 1) rejects 5x + + const task = "contested"; + try std.testing.expectEqual(@as(c_int, 0), coord_claim_task(&tok_holder, TOKEN_LEN, task.ptr, @intCast(task.len))); + + var i: usize = 0; + while (i < 5) : (i += 1) { + try std.testing.expectEqual( + @as(c_int, 1), + coord_claim_task(&tok_a, TOKEN_LEN, task.ptr, @intCast(task.len)), + ); + } + + // Per-peer counts via FFI β€” peer idx 1 accumulated 5, idx 0 (holder) 0. + try std.testing.expectEqual(@as(c_int, 5), coord_count_rejects_recent_peer(&tok_holder, TOKEN_LEN, 1)); + try std.testing.expectEqual(@as(c_int, 0), coord_count_rejects_recent_peer(&tok_holder, TOKEN_LEN, 0)); + try std.testing.expectEqual(@as(c_int, 1), coord_peer_in_cooldown(&tok_holder, TOKEN_LEN, 1)); + try std.testing.expectEqual(@as(c_int, 0), coord_peer_in_cooldown(&tok_holder, TOKEN_LEN, 0)); + + coord_reset(); +} + +test "per-peer reject ring resets on deregister" { + // Item A β€” when a peer deregisters, its reject ring is zeroed so a + // freshly-registered replacement in the same slot starts clean. + coord_reset(); + var tok_holder: [TOKEN_LEN]u8 = undefined; + var tok_a: [TOKEN_LEN]u8 = undefined; + var suf: [4]u8 = undefined; + _ = coord_register(2, -1, &tok_holder, &suf); // copilot #1 (idx 0) + _ = coord_register(2, -1, &tok_a, &suf); // copilot #2 (idx 1) rejects + + const task = "held"; + try std.testing.expectEqual(@as(c_int, 0), coord_claim_task(&tok_holder, TOKEN_LEN, task.ptr, @intCast(task.len))); + + var i: usize = 0; + while (i < 3) : (i += 1) { + _ = coord_claim_task(&tok_a, TOKEN_LEN, task.ptr, @intCast(task.len)); + } + try std.testing.expectEqual(@as(c_int, 3), coord_count_rejects_recent_peer(&tok_holder, TOKEN_LEN, 1)); + + // Deregister idx 1; re-register β€” replacement lands back in slot 1. + try std.testing.expectEqual(@as(c_int, 0), coord_deregister(&tok_a, TOKEN_LEN)); + var tok_b: [TOKEN_LEN]u8 = undefined; + _ = coord_register(2, -1, &tok_b, &suf); + try std.testing.expectEqual(@as(c_int, 0), coord_count_rejects_recent_peer(&tok_holder, TOKEN_LEN, 1)); + + coord_reset(); +} + +test "affinity replay after restart" { + coord_reset(); + dur.close(); + + var path_buf: [256]u8 = undefined; + const dir = try tmpCoordDir(&path_buf); + defer std.fs.cwd().deleteTree(dir) catch {}; + + try std.testing.expect(dur.openWithDir(dir)); + dur.truncate(); + + var tok: [TOKEN_LEN]u8 = undefined; + var suf: [4]u8 = undefined; + _ = coord_register(0, -1, &tok, &suf); + + const tag = "test-writing"; + _ = coord_report_outcome(&tok, TOKEN_LEN, tag.ptr, @intCast(tag.len), 1, 200, 1, -1); + _ = coord_report_outcome(&tok, TOKEN_LEN, tag.ptr, @intCast(tag.len), 0, 200, 1, -1); + + dur.close(); + coord_reset(); + try std.testing.expect(dur.openWithDir(dir)); + dur.replay(replayDispatch); + defer dur.close(); + + // Re-register so we have a token to query with. + var tok2: [TOKEN_LEN]u8 = undefined; + var suf2: [4]u8 = undefined; + _ = coord_register(0, -1, &tok2, &suf2); + + var buf: [64 * 4]u8 = undefined; + const n = coord_get_affinities(&tok2, TOKEN_LEN, &buf, @intCast(buf.len)); + const idx = findAggByTag(&buf, @intCast(n), 0, tag) orelse return error.AggregateMissing; + const rec = buf[idx * 64 ..][0..64]; + try std.testing.expectEqual(@as(u16, 2), std.mem.readInt(u16, rec[1..3], .little)); + try std.testing.expectEqual(@as(u16, 1), std.mem.readInt(u16, rec[3..5], .little)); + + coord_reset(); +} diff --git a/cartridges/cross-cutting/agentic/local-coord-mcp/mod.js b/cartridges/cross-cutting/agentic/local-coord-mcp/mod.js new file mode 100644 index 0000000..ec046d2 --- /dev/null +++ b/cartridges/cross-cutting/agentic/local-coord-mcp/mod.js @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +// +// local-coord-mcp/mod.js β€” Localhost multi-instance coordination +// +// Delegates to backend at http://127.0.0.1:7745 (override with COORD_BACKEND_URL). +// CRITICAL: Backend MUST bind to loopback only β€” the Idris2 ABI guarantees this. + +const BASE_URL = Deno.env.get("COORD_BACKEND_URL") ?? "http://127.0.0.1:7745"; +const TIMEOUT_MS = 15_000; + +async function post(path, payload) { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), TIMEOUT_MS); + try { + const r = await fetch(`${BASE_URL}${path}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + signal: ctrl.signal, + }); + const data = await r.json().catch(() => ({ success: false, error: "non-JSON response" })); + return { status: r.status, data }; + } catch (e) { + if (e.name === "AbortError") return { status: 504, data: { success: false, error: "local-coord-mcp backend timed out" } }; + return { status: 503, data: { success: false, error: `local-coord-mcp backend unavailable: ${e.message}` } }; + } finally { clearTimeout(t); } +} + +// Path canonical: /tools/. Matches main.js:dispatchLocalCoord and the +// estate-wide Zig-adapter convention. Earlier /api/v1/* paths were a dead +// code path β€” the mcp-bridge bypasses mod.js and POSTs directly to the +// adapter, so mod.js is a secondary transport used only by alternative +// bridges (e.g. an Elixir catalogue-level proxy). +export async function handleTool(toolName, args) { + switch (toolName) { + case "coord_register": + case "coord_list_peers": + case "coord_send": + case "coord_receive": + case "coord_claim_task": + case "coord_status": + return post(`/tools/${toolName}`, args ?? {}); + default: + return { status: 404, data: { error: `Unknown tool: ${toolName}` } }; + } +} diff --git a/cartridges/cross-cutting/agentic/local-coord-mcp/panels/manifest.json b/cartridges/cross-cutting/agentic/local-coord-mcp/panels/manifest.json new file mode 100644 index 0000000..7ae432f --- /dev/null +++ b/cartridges/cross-cutting/agentic/local-coord-mcp/panels/manifest.json @@ -0,0 +1,82 @@ +{ + "$schema": "https://panll.dev/schemas/panel-manifest/v1.json", + "spdx": "MPL-2.0", + "cartridge": "local-coord-mcp", + "domain": "Local Coordination", + "version": "0.1.0", + "panels": [ + { + "id": "coord-peers", + "title": "Active Peers", + "description": "AI instances registered on this machine β€” their IDs, kinds, and states", + "type": "metric", + "data_source": { + "endpoint": "/cartridge/local-coord-mcp/invoke", + "method": "POST", + "body": { "tool": "coord_list_peers" }, + "refresh_interval_ms": 3000 + }, + "widgets": [ + { "type": "counter", "field": "active_peers", "label": "Active Peers", "icon": "users" }, + { + "type": "table", + "field": "peers", + "columns": [ + { "key": "id", "label": "Peer ID" }, + { "key": "kind", "label": "Client" }, + { "key": "state", "label": "State" }, + { "key": "status", "label": "Current Work" } + ] + } + ] + }, + { + "id": "coord-claims", + "title": "Task Claims", + "description": "Mutex-style task assignments β€” which peer holds which task", + "type": "metric", + "data_source": { + "endpoint": "/cartridge/local-coord-mcp/invoke", + "method": "POST", + "body": { "tool": "coord_list_claims" }, + "refresh_interval_ms": 5000 + }, + "widgets": [ + { "type": "counter", "field": "active_claims", "label": "Active Claims", "icon": "lock" }, + { + "type": "table", + "field": "claims", + "columns": [ + { "key": "task", "label": "Task" }, + { "key": "holder", "label": "Held By" } + ] + } + ] + }, + { + "id": "coord-status", + "title": "Coordination Status", + "description": "Service health and bind configuration", + "type": "status-indicator", + "data_source": { + "endpoint": "/cartridge/local-coord-mcp/invoke", + "method": "POST", + "body": { "tool": "status" }, + "refresh_interval_ms": 10000 + }, + "widgets": [ + { + "type": "state-badge", + "field": "state", + "states": { + "ready": { "color": "#2ecc71", "icon": "shield" }, + "degraded": { "color": "#f39c12", "icon": "alert-circle" }, + "error": { "color": "#e74c3c", "icon": "alert-triangle" } + } + }, + { "type": "text", "field": "bind_address", "label": "Bind" }, + { "type": "text", "field": "federation", "label": "Federation" } + ] + } + ] +} diff --git a/cartridges/cross-cutting/agentic/local-coord-mcp/schemas/coord-messages-contracts.ncl b/cartridges/cross-cutting/agentic/local-coord-mcp/schemas/coord-messages-contracts.ncl new file mode 100644 index 0000000..2e8f209 --- /dev/null +++ b/cartridges/cross-cutting/agentic/local-coord-mcp/schemas/coord-messages-contracts.ncl @@ -0,0 +1,209 @@ +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +# +# coord-messages-contracts.ncl β€” Nickel contracts for rich server-side +# envelope validation. Sibling to coord-messages.ncl (portable JSON +# Schema) β€” use this file for cross-field dependent constraints that +# JSON Schema can't elegantly express. +# +# Wired into mcp-bridge via a Deno shim (Task #17): on each coord_send +# or coord_send_gated call, the bridge imports this file, extracts +# `EnvelopeV1`, and applies it to the message body. Invalid envelopes +# are rejected with a descriptive error before they reach the Zig +# adapter. +# +# Usage pattern: +# let contracts = import "coord-messages-contracts.ncl" in +# let ok_envelope = my_envelope | contracts.EnvelopeV1 in +# ... +# +# If any constraint fails, Nickel emits a descriptive error pointing at +# the offending field. The Deno shim translates that error into an MCP +# error response. + +let OpDefaults = { + status = 0, + query = 0, + context_query = 0, + context_reply = 0, + supervise_req = 0, + supervise_resp = 0, + attest_req = 0, + attest_resp = 0, + fyi = 1, + progress = 1, + warn_drift = 1, + release = 1, + claim = 2, + handoff = 2, + clarify = 2, + blocker = 3, + gated_op = 3, +} in + +let OpKinds = std.record.fields OpDefaults in + +{ + # ── Primitive contracts ───────────────────────────────────────── + + RiskTier = std.contract.from_predicate (fun v => + std.is_number v + && v >= 0 + && v <= 4 + && v == (std.number.floor v) + ), + + OpKind = std.contract.from_predicate (fun v => + std.is_string v && std.array.any (fun k => k == v) OpKinds + ), + + MsgIdHex = std.contract.from_predicate (fun v => + std.is_string v + && std.string.length v == 12 + && std.string.is_match "^[0-9a-f]{12}$" v + ), + + HashHex = std.contract.from_predicate (fun v => + std.is_string v + && std.string.length v == 64 + && std.string.is_match "^[0-9a-f]{64}$" v + ), + + # ── Dependent constraints β€” the rules JSON Schema can't express ── + + # Any Tier >= 2 envelope MUST carry a non-empty context_fetch_id. + # Blast-radius scaled: drone at Tier 0/1, strategic at Tier 2+. + TierContextGate = std.contract.from_predicate (fun e => + !(std.record.has_field "risk_tier" e) + || e.risk_tier < 2 + || (std.record.has_field "context_fetch_id" e + && std.is_string e.context_fetch_id + && std.string.length e.context_fetch_id > 0) + ), + + # Any Tier >= 2 envelope from a peer whose role is "apprentice" (or + # legacy alias "supervised" β€” DD-32) MUST carry at least one + # attestation_refs entry. M-of-N Byzantine safety. + # + # The sender's role is NOT in the envelope itself (server-attached at + # dispatch). Validator caller passes role via e._meta.sender_role when + # running this contract server-side. + TierAttestationGate = std.contract.from_predicate (fun e => + !(std.record.has_field "risk_tier" e) + || e.risk_tier < 2 + || !(std.record.has_field "_meta" e) + || !(std.record.has_field "sender_role" e._meta) + || (e._meta.sender_role != "apprentice" && e._meta.sender_role != "supervised") + || (std.record.has_field "attestation_refs" e + && std.is_array e.attestation_refs + && std.array.length e.attestation_refs >= 1) + ), + + # urgent_direct=true is only permitted for master + journeyman. + # apprentice peers never interrupt the user directly. Legacy + # "supervised" string accepted for one release per DD-32. + UrgentDirectRestriction = std.contract.from_predicate (fun e => + !(std.record.has_field "urgent_direct" e) + || !e.urgent_direct + || !(std.record.has_field "_meta" e) + || !(std.record.has_field "sender_role" e._meta) + || (e._meta.sender_role != "apprentice" && e._meta.sender_role != "supervised") + ), + + # If declared risk_tier differs from the op_kind's default, a + # non-empty tier_override_reason MUST be present. Catches tier + # underclaiming ("I said it's Tier 1 but it's a git push"). + TierOverrideJustification = std.contract.from_predicate (fun e => + !(std.record.has_field "op_kind" e) + || !(std.record.has_field "risk_tier" e) + || ( + let default_tier = + if std.record.has_field e.op_kind OpDefaults then + std.record.get e.op_kind OpDefaults + else 0 in + e.risk_tier == default_tier + || (std.record.has_field "tier_override_reason" e + && std.is_string e.tier_override_reason + && std.string.length e.tier_override_reason > 0) + ) + ), + + # ── Task #15 fields β€” shape-level guards ──────────────────────── + + # sender_confidence, if present, must be a float in [0.0, 1.0]. + # Feeds the overclaim rule (conf > 0.8 AND effective_affinity < 0.3 + # flags a reassignment suggestion; see DD-28). + ConfidenceShape = std.contract.from_predicate (fun e => + !(std.record.has_field "sender_confidence" e) + || (std.is_number e.sender_confidence + && e.sender_confidence >= 0 + && e.sender_confidence <= 1) + ), + + # dispatch_preference, if present, must be one of the three modes. + # Default policy (DD-30): broadcast for trivial+routine tasks; + # deliberate for challenging+novel. Server-side auto-derivation + # kicks in when the value is absent or set to "auto". + DispatchPrefShape = std.contract.from_predicate (fun e => + !(std.record.has_field "dispatch_preference" e) + || (std.is_string e.dispatch_preference + && std.array.any + (fun k => k == e.dispatch_preference) + ["deliberate", "broadcast", "auto"]) + ), + + # task_difficulty, if present, must be one of the four levels. + TaskDifficultyShape = std.contract.from_predicate (fun e => + !(std.record.has_field "task_difficulty" e) + || (std.is_string e.task_difficulty + && std.array.any + (fun k => k == e.task_difficulty) + ["trivial", "routine", "challenging", "novel"]) + ), + + # ── Task #36 β€” difficulty_hint ───────────────────────────────── + # + # Orthogonal to risk_tier. risk_tier says *how bad if wrong*; + # difficulty_hint says *how hard to get right*. Drives model-class + # routing (DD-36): + # (low risk, low difficulty) -> jester (e.g. Flash) + # (low risk, high difficulty) -> reasoner (Pro / Sonnet) + # (high risk, low difficulty) -> journeyman + trust check + # (high risk, high difficulty) -> master + reasoner + attester + DifficultyHintValid = std.contract.from_predicate (fun e => + !(std.record.has_field "difficulty_hint" e) + || (std.is_string e.difficulty_hint + && std.array.any + (fun k => k == e.difficulty_hint) + ["low", "medium", "high"]) + ), + + # ── Combined validator ───────────────────────────────────────── + # + # Lazy function that applies all four dependent-constraint contracts + # in sequence. Shape validation stays in the JSON Schema sibling + # (coord-messages.ncl / .json) β€” this validator runs AFTER shape has + # been confirmed by a JSON Schema validator. + # + # Usage from the Deno shim (Task #17): + # + # const contracts = await loadNickel("coord-messages-contracts.ncl"); + # // First: JSON Schema validates shape. + # // Then: this validator applies dependent constraints. + # const result = applyNickelContract(contracts.validate, envelope); + # + # Wrapped in a function so Nickel doesn't eagerly try to realise it + # when the file is imported β€” evaluation happens when validate is + # called on actual envelope data. + + validate = fun envelope => + envelope + | TierContextGate + | TierAttestationGate + | UrgentDirectRestriction + | TierOverrideJustification + | ConfidenceShape + | DispatchPrefShape + | TaskDifficultyShape + | DifficultyHintValid, +} diff --git a/cartridges/cross-cutting/agentic/local-coord-mcp/schemas/coord-messages.json b/cartridges/cross-cutting/agentic/local-coord-mcp/schemas/coord-messages.json new file mode 100644 index 0000000..5dc971c --- /dev/null +++ b/cartridges/cross-cutting/agentic/local-coord-mcp/schemas/coord-messages.json @@ -0,0 +1,764 @@ +{ + "$id": "https://boj.dev/schemas/coord-messages/v1.json", + "$schema": "https://json-schema.org/draft-07/schema#", + "copyright": "Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) ", + "envelope": { + "additionalProperties": false, + "properties": { + "ack_required": { + "default": false, + "type": "boolean" + }, + "attestation_refs": { + "description": "Cosigner attestations. REQUIRED for Tier 2+ envelopes from role=apprentice peers. Empty otherwise.", + "items": { + "additionalProperties": false, + "properties": { + "attester": { + "description": "Peer ID of attester.", + "type": "string" + }, + "decision": { + "enum": [ + "attest", + "reject" + ], + "type": "string" + }, + "reason": { + "maxLength": 200, + "type": "string" + }, + "token_mac": { + "description": "HMAC-SHA256(attester_token, envelope_sans_attestations). Proves attester signed this specific content.", + "pattern": "^[0-9a-f]{64}$", + "type": "string" + } + }, + "required": [ + "attester", + "decision", + "reason", + "token_mac" + ], + "type": "object" + }, + "maxItems": 8, + "type": "array" + }, + "context_fetch_id": { + "description": "ID returned by a prior context_query. REQUIRED for Tier 2+ β€” server rejects without. Caps staleness (N minutes).", + "maxLength": 64, + "type": "string" + }, + "correlation_id": { + "description": "Optional. Pairs request/response across turns.", + "maxLength": 64, + "type": "string" + }, + "difficulty_hint": { + "description": "Task #36 β€” how hard to get right, orthogonal to risk_tier (how bad if wrong). Drives model-class routing (DD-36).", + "enum": [ + "low", + "medium", + "high" + ], + "type": "string" + }, + "dispatch_preference": { + "description": "Routing hint (DD-30). 'auto' lets the server derive from task_difficulty.", + "enum": [ + "deliberate", + "broadcast", + "auto" + ], + "type": "string" + }, + "msg_id": { + "description": "12-char lowercase hex, unique per sender. Sender-generated.", + "pattern": "^[0-9a-f]{12}$", + "type": "string" + }, + "op_kind": { + "description": "One of the keys in op_kinds.", + "type": "string" + }, + "payload": { + "description": "Shape determined by op_kind β€” see payloads.*.", + "type": "object" + }, + "prev_msg_hash": { + "description": "SHA-256 of sender's previous envelope. First-ever message uses 64 zeros. Chain break = instant reject.", + "pattern": "^[0-9a-f]{64}$", + "type": "string" + }, + "recipient": { + "description": "Peer ID of recipient, or '*' for broadcast.", + "type": "string" + }, + "risk_tier": { + "maximum": 4, + "minimum": 0, + "type": "integer" + }, + "sender": { + "description": "Peer ID of sender: -<4hex>[@].", + "type": "string" + }, + "sender_confidence": { + "description": "Sender's declared confidence in its fit for this op.", + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "sender_reasoning": { + "description": "One-sentence justification. Required if sender_confidence present.", + "maxLength": 200, + "type": "string" + }, + "task_difficulty": { + "description": "Difficulty level (DD-30). Drives default dispatch_preference.", + "enum": [ + "trivial", + "routine", + "challenging", + "novel" + ], + "type": "string" + }, + "tier_override_reason": { + "description": "Required when declared risk_tier differs from op_kinds..tier_default. Sanity gate may reject. Server records.", + "maxLength": 200, + "type": "string" + }, + "timestamp": { + "description": "ISO 8601 UTC timestamp, sender-generated.", + "format": "date-time", + "type": "string" + }, + "ttl_seconds": { + "description": "Optional auto-expiry. Default = unlimited.", + "maximum": 86400, + "minimum": 1, + "type": "integer" + }, + "urgent_direct": { + "default": false, + "description": "Requests inline interrupt in master terminal. Only role ∈ {master, journeyman} may set true β€” schema rejects for apprentice.", + "type": "boolean" + }, + "version": { + "const": 1, + "description": "Envelope schema version. Rejected if mismatched.", + "type": "integer" + } + }, + "required": [ + "version", + "msg_id", + "prev_msg_hash", + "sender", + "recipient", + "timestamp", + "op_kind", + "risk_tier", + "payload" + ], + "type": "object" + }, + "op_kinds": { + "attest_req": { + "description": "Server asks a second journeyman for independent attestation on a Tier 2+ op from an apprentice peer (BFT f=1).", + "tier_default": 0 + }, + "attest_resp": { + "description": "Journeyman responds with attest/reject + reason.", + "tier_default": 0 + }, + "blocker": { + "description": "Peer cannot proceed without user input. Surfaces inline in master terminal.", + "tier_default": 3 + }, + "claim": { + "description": "Claim a task. Mutex-style. Tier escalates to 3 when scope=estate.", + "tier_default": 2 + }, + "clarify": { + "description": "Question for the user, batched through master. Only journeyman + master peers may set urgent_direct=true.", + "tier_default": 2 + }, + "context_query": { + "description": "Request big-picture context for Tier 2+ preparation. Returns a context_fetch_id cited by subsequent higher-tier ops.", + "tier_default": 0 + }, + "context_reply": { + "description": "Server or peer responds to a context_query.", + "tier_default": 0 + }, + "fyi": { + "description": "Log-only progress/observation. Never interrupts the user.", + "tier_default": 1 + }, + "gated_op": { + "description": "Wrapper for an arbitrary high-risk action awaiting Tier 3 approval.", + "tier_default": 3 + }, + "handoff": { + "description": "Transfer a task from one peer to another with artefact refs. Tier escalates to 3 when scope=estate.", + "tier_default": 2 + }, + "progress": { + "description": "Task progress update: percent + note.", + "tier_default": 1 + }, + "query": { + "description": "Ask a peer or the server a read-only question.", + "tier_default": 0 + }, + "release": { + "description": "Release a previously-claimed task.", + "tier_default": 1 + }, + "status": { + "description": "Peer sets its current work status, visible to others.", + "tier_default": 0 + }, + "supervise_req": { + "description": "Apprentice peer requests authorisation for a gated op; embeds the original envelope.", + "tier_default": 0 + }, + "supervise_resp": { + "description": "Master responds with approve/reject + reason.", + "tier_default": 0 + }, + "warn_drift": { + "description": "Peer detected drift from a rule / invariant / expected state.", + "tier_default": 1 + } + }, + "payloads": { + "attest_req": { + "additionalProperties": false, + "properties": { + "ask": { + "description": "What the attester is being asked to verify.", + "maxLength": 512, + "type": "string" + }, + "original_msg": { + "$ref": "#/envelope" + }, + "original_msg_id": { + "type": "string" + } + }, + "required": [ + "original_msg_id", + "original_msg", + "ask" + ], + "type": "object" + }, + "attest_resp": { + "additionalProperties": false, + "properties": { + "decision": { + "enum": [ + "attest", + "reject" + ], + "type": "string" + }, + "original_msg_id": { + "type": "string" + }, + "reason": { + "maxLength": 512, + "type": "string" + } + }, + "required": [ + "original_msg_id", + "decision", + "reason" + ], + "type": "object" + }, + "blocker": { + "additionalProperties": false, + "properties": { + "attempted": { + "description": "Things the peer already tried.", + "items": { + "type": "string" + }, + "type": "array" + }, + "blocker": { + "maxLength": 1024, + "type": "string" + }, + "needed": { + "description": "What the user must provide or decide.", + "type": "string" + }, + "task": { + "maxLength": 128, + "type": "string" + } + }, + "required": [ + "task", + "blocker", + "needed" + ], + "type": "object" + }, + "claim": { + "additionalProperties": false, + "properties": { + "scope": { + "enum": [ + "local", + "repo", + "estate" + ], + "type": "string" + }, + "task": { + "maxLength": 128, + "type": "string" + }, + "task_tags": { + "description": "Affinity-routing tags. Server uses to score this claim against peer affinities.", + "items": { + "maxLength": 32, + "type": "string" + }, + "maxItems": 8, + "type": "array" + } + }, + "required": [ + "task", + "scope" + ], + "type": "object" + }, + "clarify": { + "additionalProperties": false, + "properties": { + "blocks_task": { + "description": "Task identifier this clarification blocks, if any.", + "type": "string" + }, + "context_summary": { + "maxLength": 1024, + "type": "string" + }, + "options": { + "items": { + "type": "string" + }, + "maxItems": 8, + "type": "array" + }, + "question": { + "maxLength": 1024, + "type": "string" + } + }, + "required": [ + "question" + ], + "type": "object" + }, + "context_query": { + "additionalProperties": false, + "properties": { + "scope_files": { + "description": "Specific files. Optional.", + "items": { + "type": "string" + }, + "type": "array" + }, + "scope_peers": { + "enum": [ + "self", + "claiming", + "all" + ], + "type": "string" + }, + "scope_queue": { + "description": "Include task-queue snapshot.", + "type": "boolean" + }, + "scope_repo": { + "description": "Repo name. Optional.", + "type": "string" + }, + "summarise_for": { + "description": "Determines summary depth. Apprentice (ex-supervised) gets summary; journeyman/master (ex-executor/supervisor) get raw on request. Old names accepted as aliases per DD-32.", + "enum": [ + "apprentice", + "journeyman", + "master", + "supervised", + "executor", + "supervisor" + ], + "type": "string" + } + }, + "required": [], + "type": "object" + }, + "context_reply": { + "additionalProperties": false, + "properties": { + "context_fetch_id": { + "description": "ID the requester cites in later Tier 2+ envelopes.", + "type": "string" + }, + "raw": { + "description": "Full structured context, only for journeyman/master.", + "type": "object" + }, + "summary": { + "maxLength": 8192, + "type": "string" + }, + "ttl_seconds": { + "description": "Staleness budget.", + "type": "integer" + } + }, + "required": [ + "context_fetch_id", + "ttl_seconds", + "summary" + ], + "type": "object" + }, + "fyi": { + "additionalProperties": false, + "properties": { + "message": { + "maxLength": 1024, + "type": "string" + }, + "tags": { + "items": { + "type": "string" + }, + "maxItems": 8, + "type": "array" + } + }, + "required": [ + "message" + ], + "type": "object" + }, + "gated_op": { + "additionalProperties": true, + "description": "Wrapper for an arbitrary high-risk action; used when no purpose-built op_kind fits.", + "properties": { + "action": { + "maxLength": 256, + "type": "string" + }, + "payload": { + "type": "object" + } + }, + "required": [ + "action" + ], + "type": "object" + }, + "handoff": { + "additionalProperties": false, + "properties": { + "artefact_refs": { + "items": { + "type": "string" + }, + "maxItems": 32, + "type": "array" + }, + "scope": { + "enum": [ + "local", + "repo", + "estate" + ], + "type": "string" + }, + "task": { + "maxLength": 128, + "type": "string" + }, + "to": { + "description": "Recipient peer_id.", + "type": "string" + } + }, + "required": [ + "task", + "to", + "scope" + ], + "type": "object" + }, + "progress": { + "additionalProperties": false, + "properties": { + "note": { + "maxLength": 512, + "type": "string" + }, + "percent": { + "maximum": 100, + "minimum": 0, + "type": "integer" + }, + "task": { + "maxLength": 128, + "type": "string" + } + }, + "required": [ + "task", + "percent" + ], + "type": "object" + }, + "query": { + "additionalProperties": false, + "properties": { + "question": { + "maxLength": 1024, + "type": "string" + }, + "response_to": { + "description": "Optional msg_id being replied to.", + "type": "string" + } + }, + "required": [ + "question" + ], + "type": "object" + }, + "release": { + "additionalProperties": false, + "properties": { + "note": { + "maxLength": 512, + "type": "string" + }, + "outcome": { + "enum": [ + "success", + "fail", + "abandoned" + ], + "type": "string" + }, + "task": { + "maxLength": 128, + "type": "string" + } + }, + "required": [ + "task", + "outcome" + ], + "type": "object" + }, + "status": { + "additionalProperties": false, + "properties": { + "status": { + "maxLength": 256, + "type": "string" + } + }, + "required": [ + "status" + ], + "type": "object" + }, + "supervise_req": { + "additionalProperties": false, + "properties": { + "original_msg": { + "$ref": "#/envelope" + }, + "requested_action": { + "maxLength": 512, + "type": "string" + } + }, + "required": [ + "original_msg", + "requested_action" + ], + "type": "object" + }, + "supervise_resp": { + "additionalProperties": false, + "properties": { + "decision": { + "enum": [ + "approve", + "reject" + ], + "type": "string" + }, + "original_msg_id": { + "type": "string" + }, + "reason": { + "maxLength": 512, + "type": "string" + } + }, + "required": [ + "original_msg_id", + "decision", + "reason" + ], + "type": "object" + }, + "warn_drift": { + "additionalProperties": false, + "properties": { + "detected": { + "maxLength": 512, + "type": "string" + }, + "evidence": { + "items": { + "type": "string" + }, + "maxItems": 8, + "type": "array" + }, + "severity": { + "enum": [ + "low", + "med", + "high" + ], + "type": "string" + } + }, + "required": [ + "detected", + "severity" + ], + "type": "object" + } + }, + "peer_registration_extensions": { + "affinities": { + "description": "Free-form strength tags (e.g. 'proof-analysis', 'typescript-refactor', 'long-document-grok'). Router prefers peers whose affinities match task_tags. Non-matching peers can still claim.", + "items": { + "maxLength": 32, + "pattern": "^[a-z][a-z0-9-]*$", + "type": "string" + }, + "maxItems": 16, + "type": "array" + }, + "role": { + "description": "Trust tier (DD-32). master (ex-supervisor) = approval authority; journeyman (ex-executor) = trusted to act solo on Tier 2; apprentice (ex-supervised) = Tier 2+ quarantined. Defaults: client_kind=claude -> journeyman; non-claude -> apprentice. master must be requested explicitly via coord_promote_to_master with BOJ_MASTER_TOKEN. Old enum values accepted as aliases for one release.", + "enum": [ + "master", + "journeyman", + "apprentice", + "supervisor", + "executor", + "supervised" + ], + "type": "string" + } + }, + "sanity_auto_promote": [ + { + "min_tier": 3, + "pattern": "git push", + "reason": "Push operation" + }, + { + "min_tier": 2, + "pattern": "git commit", + "reason": "Commit operation" + }, + { + "min_tier": 3, + "pattern": "\\brm\\s+-[rR]f?", + "reason": "Recursive rm" + }, + { + "min_tier": 3, + "pattern": "DROP TABLE", + "reason": "Schema destruction" + }, + { + "min_tier": 3, + "pattern": "SET secret", + "reason": "Secret modification" + }, + { + "min_tier": 3, + "pattern": "--no-verify", + "reason": "Hook bypass" + } + ], + "spdx": "MPL-2.0", + "tier_4_forbidden_for_apprentice": [ + { + "description": "Force-push (raw or wrapped).", + "match_kind": "payload_contains", + "value": "--force" + }, + { + "description": "Force-push explicit.", + "match_kind": "payload_contains", + "value": "git push --force" + }, + { + "description": "Remote branch delete.", + "match_kind": "payload_contains", + "value": "branch --delete" + }, + { + "description": "Public repo creation.", + "match_kind": "payload_contains", + "value": "gh repo create --public" + }, + { + "description": "Any touch to LICENSE files β€” tier 4 by default, require Opus exception.", + "match_kind": "payload_contains", + "value": "LICENSE" + }, + { + "description": "Always-private repos β€” see memory/always-private-repos.md.", + "match_kind": "path_touches_any_of", + "value": [ + "007", + ".git-private-farm", + "hyperpolymath-sovereign-registry", + "blog-drafts", + "claude-memory" + ] + } + ], + "title": "local-coord-mcp message envelope (v1)", + "version": 1 +} diff --git a/cartridges/cross-cutting/agentic/local-coord-mcp/schemas/coord-messages.ncl b/cartridges/cross-cutting/agentic/local-coord-mcp/schemas/coord-messages.ncl new file mode 100644 index 0000000..f80a004 --- /dev/null +++ b/cartridges/cross-cutting/agentic/local-coord-mcp/schemas/coord-messages.ncl @@ -0,0 +1,387 @@ +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +# +# coord-messages.ncl β€” Envelope schema for local-coord-mcp messages (v1). +# +# Source-of-truth for all messages passing through coord_send / coord_receive. +# Nickel source exports to JSON Schema via: +# +# nickel export --format json schemas/coord-messages.ncl > schemas/coord-messages.json +# +# Design rationale + ops playbook lives in docs/envelope-design.adoc. +# Supervision architecture lives in the workspace memory file +# project_coord_supervision_architecture.md. + +{ + "$schema" = "https://json-schema.org/draft-07/schema#", + "$id" = "https://boj.dev/schemas/coord-messages/v1.json", + spdx = "MPL-2.0", + copyright = "Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) ", + title = "local-coord-mcp message envelope (v1)", + version = 1, + + # ──────────────────────────────────────────────────────────────────── + # Op-kind taxonomy. + # + # Each op_kind declares a default risk tier. The server may auto-promote + # via sanity gate (e.g. content mentions `git push` β†’ Tier 3 regardless + # of declared tier). Peers may declare a lower tier ONLY with a + # tier_override_reason that explains why (e.g. "read-only dry-run"). + # + # Tier semantics: + # 0 free status / query / reads / peer discovery + # 1 logged single-file edits (non-critical), tests, lint, fyi + # 2 light gate multi-file edits, dep bumps, new files β€” auto-approve + # after 60s unless master intervenes + # 3 hard gate commits, pushes, mirrors, secret-adjacent, schema + # migrations, deletions β€” master approval, no + # timeout + # 4 forbidden force-push, branch delete, public-repo creation, + # license changes, touching always-private repos β€” + # schema-level reject for role=apprentice + # ──────────────────────────────────────────────────────────────────── + + op_kinds = { + # Tier 0 β€” always free + status = { tier_default = 0, description = "Peer sets its current work status, visible to others." }, + query = { tier_default = 0, description = "Ask a peer or the server a read-only question." }, + context_query = { tier_default = 0, description = "Request big-picture context for Tier 2+ preparation. Returns a context_fetch_id cited by subsequent higher-tier ops." }, + context_reply = { tier_default = 0, description = "Server or peer responds to a context_query." }, + supervise_req = { tier_default = 0, description = "Apprentice peer requests authorisation for a gated op; embeds the original envelope." }, + supervise_resp = { tier_default = 0, description = "Master responds with approve/reject + reason." }, + attest_req = { tier_default = 0, description = "Server asks a second journeyman for independent attestation on a Tier 2+ op from an apprentice peer (BFT f=1)." }, + attest_resp = { tier_default = 0, description = "Journeyman responds with attest/reject + reason." }, + fyi = { tier_default = 1, description = "Log-only progress/observation. Never interrupts the user." }, + + # Tier 1 β€” logged, no gate + progress = { tier_default = 1, description = "Task progress update: percent + note." }, + warn_drift = { tier_default = 1, description = "Peer detected drift from a rule / invariant / expected state." }, + release = { tier_default = 1, description = "Release a previously-claimed task." }, + + # Tier 2 β€” light gate (60s auto-approve) + claim = { tier_default = 2, description = "Claim a task. Mutex-style. Tier escalates to 3 when scope=estate." }, + handoff = { tier_default = 2, description = "Transfer a task from one peer to another with artefact refs. Tier escalates to 3 when scope=estate." }, + clarify = { tier_default = 2, description = "Question for the user, batched through master. Only journeyman + master peers may set urgent_direct=true." }, + + # Tier 3 β€” hard gate (master approval required) + blocker = { tier_default = 3, description = "Peer cannot proceed without user input. Surfaces inline in master terminal." }, + + # op_kinds whose tier is always the envelope's declared risk_tier; + # used for wrapping arbitrary actions through the supervision system. + gated_op = { tier_default = 3, description = "Wrapper for an arbitrary high-risk action awaiting Tier 3 approval." }, + }, + + # ──────────────────────────────────────────────────────────────────── + # Core envelope schema. + # ──────────────────────────────────────────────────────────────────── + + envelope = { + type = "object", + additionalProperties = false, + properties = { + # ── Identity + provenance ───────────────────────────────────── + version = { type = "integer", const = 1, description = "Envelope schema version. Rejected if mismatched." }, + msg_id = { type = "string", pattern = "^[0-9a-f]{12}$", description = "12-char lowercase hex, unique per sender. Sender-generated." }, + prev_msg_hash = { type = "string", pattern = "^[0-9a-f]{64}$", description = "SHA-256 of sender's previous envelope. First-ever message uses 64 zeros. Chain break = instant reject." }, + correlation_id = { type = "string", maxLength = 64, description = "Optional. Pairs request/response across turns." }, + + # ── Routing ────────────────────────────────────────────────── + sender = { type = "string", description = "Peer ID of sender: -<4hex>[@]." }, + recipient = { type = "string", description = "Peer ID of recipient, or '*' for broadcast." }, + timestamp = { type = "string", format = "date-time", description = "ISO 8601 UTC timestamp, sender-generated." }, + + # ── Operation ───────────────────────────────────────────────── + op_kind = { type = "string", description = "One of the keys in op_kinds." }, + risk_tier = { type = "integer", minimum = 0, maximum = 4 }, + payload = { type = "object", description = "Shape determined by op_kind β€” see payloads.*." }, + + # ── Self-assessment (optional for Tier 0/1; recommended Tier 2+) ── + sender_confidence = { type = "number", minimum = 0.0, maximum = 1.0, description = "Sender's declared confidence in its fit for this op." }, + sender_reasoning = { type = "string", maxLength = 200, description = "One-sentence justification. Required if sender_confidence present." }, + + # ── Adaptive horizon (required for Tier 2+) ─────────────────── + context_fetch_id = { type = "string", maxLength = 64, description = "ID returned by a prior context_query. REQUIRED for Tier 2+ β€” server rejects without. Caps staleness (N minutes)." }, + + # ── Byzantine tolerance ────────────────────────────────────── + attestation_refs = { + type = "array", + description = "Cosigner attestations. REQUIRED for Tier 2+ envelopes from role=apprentice peers. Empty otherwise.", + maxItems = 8, + items = { + type = "object", + additionalProperties = false, + properties = { + attester = { type = "string", description = "Peer ID of attester." }, + decision = { type = "string", enum = ["attest", "reject"] }, + reason = { type = "string", maxLength = 200 }, + token_mac = { type = "string", pattern = "^[0-9a-f]{64}$", description = "HMAC-SHA256(attester_token, envelope_sans_attestations). Proves attester signed this specific content." }, + }, + required = ["attester", "decision", "reason", "token_mac"], + }, + }, + + tier_override_reason = { type = "string", maxLength = 200, description = "Required when declared risk_tier differs from op_kinds..tier_default. Sanity gate may reject. Server records." }, + + # ── Dispatch hints (Tasks #15, #36) ─────────────────────────── + dispatch_preference = { type = "string", enum = ["deliberate", "broadcast", "auto"], description = "Routing hint (DD-30). 'auto' lets the server derive from task_difficulty." }, + task_difficulty = { type = "string", enum = ["trivial", "routine", "challenging", "novel"], description = "Difficulty level (DD-30). Drives default dispatch_preference." }, + difficulty_hint = { type = "string", enum = ["low", "medium", "high"], description = "Task #36 β€” how hard to get right, orthogonal to risk_tier (how bad if wrong). Drives model-class routing (DD-36)." }, + + # ── User-interaction flags ──────────────────────────────────── + urgent_direct = { type = "boolean", default = false, description = "Requests inline interrupt in master terminal. Only role ∈ {master, journeyman} may set true β€” schema rejects for apprentice." }, + ack_required = { type = "boolean", default = false }, + ttl_seconds = { type = "integer", minimum = 1, maximum = 86400, description = "Optional auto-expiry. Default = unlimited." }, + }, + required = ["version", "msg_id", "prev_msg_hash", "sender", "recipient", "timestamp", "op_kind", "risk_tier", "payload"], + }, + + # ──────────────────────────────────────────────────────────────────── + # Per-op-kind payload schemas. Server dispatches by op_kind and validates + # payload against the matching schema here. + # ──────────────────────────────────────────────────────────────────── + + payloads = { + status = { + type = "object", + additionalProperties = false, + properties = { status = { type = "string", maxLength = 256 } }, + required = ["status"], + }, + + query = { + type = "object", + additionalProperties = false, + properties = { + question = { type = "string", maxLength = 1024 }, + response_to = { type = "string", description = "Optional msg_id being replied to." }, + }, + required = ["question"], + }, + + context_query = { + type = "object", + additionalProperties = false, + properties = { + scope_repo = { type = "string", description = "Repo name. Optional." }, + scope_files = { type = "array", items = { type = "string" }, description = "Specific files. Optional." }, + scope_peers = { type = "string", enum = ["self", "claiming", "all"] }, + scope_queue = { type = "boolean", description = "Include task-queue snapshot." }, + summarise_for = { type = "string", enum = ["apprentice", "journeyman", "master", "supervised", "executor", "supervisor"], description = "Determines summary depth. Apprentice (ex-supervised) gets summary; journeyman/master (ex-executor/supervisor) get raw on request. Old names accepted as aliases per DD-32." }, + }, + required = [], + }, + + context_reply = { + type = "object", + additionalProperties = false, + properties = { + context_fetch_id = { type = "string", description = "ID the requester cites in later Tier 2+ envelopes." }, + ttl_seconds = { type = "integer", description = "Staleness budget." }, + summary = { type = "string", maxLength = 8192 }, + raw = { type = "object", description = "Full structured context, only for journeyman/master." }, + }, + required = ["context_fetch_id", "ttl_seconds", "summary"], + }, + + progress = { + type = "object", + additionalProperties = false, + properties = { + task = { type = "string", maxLength = 128 }, + percent = { type = "integer", minimum = 0, maximum = 100 }, + note = { type = "string", maxLength = 512 }, + }, + required = ["task", "percent"], + }, + + warn_drift = { + type = "object", + additionalProperties = false, + properties = { + detected = { type = "string", maxLength = 512 }, + severity = { type = "string", enum = ["low", "med", "high"] }, + evidence = { type = "array", items = { type = "string" }, maxItems = 8 }, + }, + required = ["detected", "severity"], + }, + + claim = { + type = "object", + additionalProperties = false, + properties = { + task = { type = "string", maxLength = 128 }, + task_tags = { type = "array", items = { type = "string", maxLength = 32 }, maxItems = 8, description = "Affinity-routing tags. Server uses to score this claim against peer affinities." }, + scope = { type = "string", enum = ["local", "repo", "estate"] }, + }, + required = ["task", "scope"], + }, + + release = { + type = "object", + additionalProperties = false, + properties = { + task = { type = "string", maxLength = 128 }, + outcome = { type = "string", enum = ["success", "fail", "abandoned"] }, + note = { type = "string", maxLength = 512 }, + }, + required = ["task", "outcome"], + }, + + handoff = { + type = "object", + additionalProperties = false, + properties = { + task = { type = "string", maxLength = 128 }, + to = { type = "string", description = "Recipient peer_id." }, + artefact_refs = { type = "array", items = { type = "string" }, maxItems = 32 }, + scope = { type = "string", enum = ["local", "repo", "estate"] }, + }, + required = ["task", "to", "scope"], + }, + + supervise_req = { + type = "object", + additionalProperties = false, + properties = { + original_msg = { "$ref" = "#/envelope" }, + requested_action = { type = "string", maxLength = 512 }, + }, + required = ["original_msg", "requested_action"], + }, + + supervise_resp = { + type = "object", + additionalProperties = false, + properties = { + original_msg_id = { type = "string" }, + decision = { type = "string", enum = ["approve", "reject"] }, + reason = { type = "string", maxLength = 512 }, + }, + required = ["original_msg_id", "decision", "reason"], + }, + + attest_req = { + type = "object", + additionalProperties = false, + properties = { + original_msg_id = { type = "string" }, + original_msg = { "$ref" = "#/envelope" }, + ask = { type = "string", maxLength = 512, description = "What the attester is being asked to verify." }, + }, + required = ["original_msg_id", "original_msg", "ask"], + }, + + attest_resp = { + type = "object", + additionalProperties = false, + properties = { + original_msg_id = { type = "string" }, + decision = { type = "string", enum = ["attest", "reject"] }, + reason = { type = "string", maxLength = 512 }, + }, + required = ["original_msg_id", "decision", "reason"], + }, + + fyi = { + type = "object", + additionalProperties = false, + properties = { + message = { type = "string", maxLength = 1024 }, + tags = { type = "array", items = { type = "string" }, maxItems = 8 }, + }, + required = ["message"], + }, + + clarify = { + type = "object", + additionalProperties = false, + properties = { + question = { type = "string", maxLength = 1024 }, + context_summary = { type = "string", maxLength = 1024 }, + options = { type = "array", items = { type = "string" }, maxItems = 8 }, + blocks_task = { type = "string", description = "Task identifier this clarification blocks, if any." }, + }, + required = ["question"], + }, + + blocker = { + type = "object", + additionalProperties = false, + properties = { + task = { type = "string", maxLength = 128 }, + blocker = { type = "string", maxLength = 1024 }, + attempted = { type = "array", items = { type = "string" }, description = "Things the peer already tried." }, + needed = { type = "string", description = "What the user must provide or decide." }, + }, + required = ["task", "blocker", "needed"], + }, + + gated_op = { + type = "object", + description = "Wrapper for an arbitrary high-risk action; used when no purpose-built op_kind fits.", + additionalProperties = true, + properties = { + action = { type = "string", maxLength = 256 }, + payload = { type = "object" }, + }, + required = ["action"], + }, + }, + + # ──────────────────────────────────────────────────────────────────── + # Peer registration extensions β€” fields added to coord_register beyond + # the current (client_kind, context). Advisory until Task #5 wires them + # into the FFI Peer struct. + # ──────────────────────────────────────────────────────────────────── + + peer_registration_extensions = { + role = { + type = "string", + enum = ["master", "journeyman", "apprentice", "supervisor", "executor", "supervised"], + description = "Trust tier (DD-32). master (ex-supervisor) = approval authority; journeyman (ex-executor) = trusted to act solo on Tier 2; apprentice (ex-supervised) = Tier 2+ quarantined. Defaults: client_kind=claude -> journeyman; non-claude -> apprentice. master must be requested explicitly via coord_promote_to_master with BOJ_MASTER_TOKEN. Old enum values accepted as aliases for one release.", + }, + affinities = { + type = "array", + items = { type = "string", maxLength = 32, pattern = "^[a-z][a-z0-9-]*$" }, + maxItems = 16, + description = "Free-form strength tags (e.g. 'proof-analysis', 'typescript-refactor', 'long-document-grok'). Router prefers peers whose affinities match task_tags. Non-matching peers can still claim.", + }, + }, + + # ──────────────────────────────────────────────────────────────────── + # Forbidden-action policy (Tier 4). + # + # Schema-level reject for envelopes from role=apprentice matching any + # of these signatures. The server runs this list at validate time. + # ──────────────────────────────────────────────────────────────────── + + tier_4_forbidden_for_apprentice = [ + { match_kind = "payload_contains", value = "--force", description = "Force-push (raw or wrapped)." }, + { match_kind = "payload_contains", value = "git push --force", description = "Force-push explicit." }, + { match_kind = "payload_contains", value = "branch --delete", description = "Remote branch delete." }, + { match_kind = "payload_contains", value = "gh repo create --public", description = "Public repo creation." }, + { match_kind = "payload_contains", value = "LICENSE", description = "Any touch to LICENSE files β€” tier 4 by default, require Opus exception." }, + { match_kind = "path_touches_any_of", value = ["007", ".git-private-farm", "hyperpolymath-sovereign-registry", "blog-drafts", "claude-memory"], description = "Always-private repos β€” see memory/always-private-repos.md." }, + ], + + # ──────────────────────────────────────────────────────────────────── + # Sanity gate β€” content heuristics auto-promoting risk_tier. + # ──────────────────────────────────────────────────────────────────── + + sanity_auto_promote = [ + { pattern = "git push", min_tier = 3, reason = "Push operation" }, + { pattern = "git commit", min_tier = 2, reason = "Commit operation" }, + { pattern = "\\brm\\s+-[rR]f?", min_tier = 3, reason = "Recursive rm" }, + { pattern = "DROP TABLE", min_tier = 3, reason = "Schema destruction" }, + { pattern = "SET secret", min_tier = 3, reason = "Secret modification" }, + { pattern = "--no-verify", min_tier = 3, reason = "Hook bypass" }, + ], + + # Note on validation layers: + # - This file (JSON-Schema-style) is the PORTABLE interface. Exports + # to coord-messages.json for any JSON Schema validator. + # - Rich dependent-constraint validation lives in sibling file + # schemas/coord-messages-contracts.ncl β€” use that for server-side + # checks via the Deno shim (Task #17). It expresses rules that JSON + # Schema can't cleanly say (Tier 2+ requires context_fetch_id, etc.) +} diff --git a/cartridges/cross-cutting/agentic/local-coord-mcp/schemas/examples/prover-tag-claim.ncl b/cartridges/cross-cutting/agentic/local-coord-mcp/schemas/examples/prover-tag-claim.ncl new file mode 100644 index 0000000..49d7b01 --- /dev/null +++ b/cartridges/cross-cutting/agentic/local-coord-mcp/schemas/examples/prover-tag-claim.ncl @@ -0,0 +1,44 @@ +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +# +# prover-tag-claim.ncl β€” Example envelope for a prover-specific claim +# (Task #37 / DD-37). +# +# The `proof:` tag convention routes proof-sized work to peers +# whose declared affinities include that prover. Zero new server code β€” +# the existing affinity machinery (Task #13 effective_affinity, Task #14 +# reassignment engine) does the routing based on declared_affinities + +# track record. +# +# Canonical prover names: +# proof:lean4, proof:agda, proof:idris2, proof:rocq, proof:tla, +# proof:echidna, proof:spark +# +# Validate with: +# nickel eval examples/prover-tag-claim.ncl +# (uses ../coord-messages-contracts.ncl to gate on dependent fields) + +let contracts = import "../coord-messages-contracts.ncl" in + +let envelope = { + version = 1, + msg_id = "7f3a92b4c1d0", + prev_msg_hash = "0000000000000000000000000000000000000000000000000000000000000000", + sender = "claude-7f3a@boj-server", + recipient = "*", + timestamp = "2026-04-20T10:15:00Z", + op_kind = "claim", + risk_tier = 2, + payload = { + # Convention: proof:/. Scheduler strips the prefix + # when dispatching; keeps it for logs + track-record aggregation. + task = "proof:agda/category-pullback-universal", + }, + context_fetch_id = "ctx-b1c2", + sender_confidence = 0.8, + difficulty_hint = "high", + task_difficulty = "novel", + dispatch_preference = "deliberate", +} in + +contracts.validate envelope diff --git a/cartridges/cross-cutting/agentic/local-coord-mcp/schemas/test-contracts.sh b/cartridges/cross-cutting/agentic/local-coord-mcp/schemas/test-contracts.sh new file mode 100755 index 0000000..c21f171 --- /dev/null +++ b/cartridges/cross-cutting/agentic/local-coord-mcp/schemas/test-contracts.sh @@ -0,0 +1,264 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +# +# test-contracts.sh β€” Smoke-test the Nickel contracts in +# coord-messages-contracts.ncl. Each case either passes (envelope +# accepted by validator) or fails (validator rejects with a specific +# contract violation). +# +# Usage: bash test-contracts.sh + +set -u +cd "$(dirname "$0")" + +PASS=0 +FAIL=0 +EXPECTED_FAILS=0 + +# Run Nickel with an inline envelope piped through the validator. +# Exit 0 = accepted; non-zero = contract violation. +run_case() { + local name="$1" + local envelope="$2" + local expect="$3" # "pass" or "fail" + + # Create temp file in schemas/ so relative import works. + local out + local tmp="./_test_case_$$.ncl" + cat > "$tmp" <&1) + local rc=$? + rm -f "$tmp" + + if [ "$expect" = "pass" ]; then + if [ $rc -eq 0 ]; then + echo " PASS: $name" + PASS=$((PASS + 1)) + else + echo " FAIL: $name (expected accept, got reject)" + echo " $out" | head -3 + FAIL=$((FAIL + 1)) + fi + else + if [ $rc -ne 0 ]; then + echo " PASS: $name (expected reject β€” good)" + EXPECTED_FAILS=$((EXPECTED_FAILS + 1)) + else + echo " FAIL: $name (expected reject, got accept)" + FAIL=$((FAIL + 1)) + fi + fi +} + +echo "=== Nickel contract tests ===" + +# ── Valid cases ──────────────────────────────────────────────── + +run_case "tier 0 status envelope" \ +'{ + version = 1, + msg_id = "abcdef012345", + prev_msg_hash = "0000000000000000000000000000000000000000000000000000000000000000", + sender = "claude-7f3a", + recipient = "gemini-b2c1", + timestamp = "2026-04-20T10:00:00Z", + op_kind = "status", + risk_tier = 0, + payload = { status = "ok" }, +}' \ +"pass" + +run_case "tier 2 claim with context_fetch_id" \ +'{ + version = 1, + msg_id = "abcdef012346", + prev_msg_hash = "0000000000000000000000000000000000000000000000000000000000000000", + sender = "claude-7f3a", + recipient = "*", + timestamp = "2026-04-20T10:00:00Z", + op_kind = "claim", + risk_tier = 2, + payload = { task = "audit-X", scope = "repo" }, + context_fetch_id = "ctx-abc123", +}' \ +"pass" + +# ── Rejections ───────────────────────────────────────────────── + +run_case "tier 2 claim WITHOUT context_fetch_id (TierContextGate)" \ +'{ + version = 1, + msg_id = "abcdef012347", + prev_msg_hash = "0000000000000000000000000000000000000000000000000000000000000000", + sender = "claude-7f3a", + recipient = "*", + timestamp = "2026-04-20T10:00:00Z", + op_kind = "claim", + risk_tier = 2, + payload = { task = "audit-X", scope = "repo" }, +}' \ +"fail" + +run_case "tier 3 from apprentice WITHOUT attestation (TierAttestationGate) β€” exercises DD-32 'supervised' alias" \ +'{ + version = 1, + msg_id = "abcdef012348", + prev_msg_hash = "0000000000000000000000000000000000000000000000000000000000000000", + sender = "gemini-b2c1", + recipient = "*", + timestamp = "2026-04-20T10:00:00Z", + op_kind = "gated_op", + risk_tier = 3, + payload = { action = "push" }, + context_fetch_id = "ctx-abc123", + _meta = { sender_role = "supervised" }, +}' \ +"fail" + +run_case "urgent_direct from apprentice (UrgentDirectRestriction) β€” exercises DD-32 'supervised' alias" \ +'{ + version = 1, + msg_id = "abcdef012349", + prev_msg_hash = "0000000000000000000000000000000000000000000000000000000000000000", + sender = "gemini-b2c1", + recipient = "claude-7f3a", + timestamp = "2026-04-20T10:00:00Z", + op_kind = "clarify", + risk_tier = 0, + payload = { question = "hey" }, + urgent_direct = true, + _meta = { sender_role = "supervised" }, +}' \ +"fail" + +run_case "status declared as tier 3 WITHOUT tier_override_reason (TierOverrideJustification)" \ +'{ + version = 1, + msg_id = "abcdef01234a", + prev_msg_hash = "0000000000000000000000000000000000000000000000000000000000000000", + sender = "claude-7f3a", + recipient = "gemini-b2c1", + timestamp = "2026-04-20T10:00:00Z", + op_kind = "status", + risk_tier = 3, + payload = { status = "ok" }, + context_fetch_id = "ctx-abc", +}' \ +"fail" + +# ── Task #15 fields ──────────────────────────────────────────── + +run_case "claim with valid sender_confidence + dispatch_preference + task_difficulty" \ +'{ + version = 1, + msg_id = "abcdef01234b", + prev_msg_hash = "0000000000000000000000000000000000000000000000000000000000000000", + sender = "claude-7f3a", + recipient = "*", + timestamp = "2026-04-20T10:00:00Z", + op_kind = "claim", + risk_tier = 2, + payload = { task = "proof-audit" }, + context_fetch_id = "ctx-xyz", + sender_confidence = 0.75, + dispatch_preference = "deliberate", + task_difficulty = "challenging", +}' \ +"pass" + +run_case "sender_confidence outside 0..1 (ConfidenceShape)" \ +'{ + version = 1, + msg_id = "abcdef01234c", + prev_msg_hash = "0000000000000000000000000000000000000000000000000000000000000000", + sender = "claude-7f3a", + recipient = "*", + timestamp = "2026-04-20T10:00:00Z", + op_kind = "status", + risk_tier = 0, + payload = { status = "ok" }, + sender_confidence = 1.5, +}' \ +"fail" + +run_case "unknown dispatch_preference string (DispatchPrefShape)" \ +'{ + version = 1, + msg_id = "abcdef01234d", + prev_msg_hash = "0000000000000000000000000000000000000000000000000000000000000000", + sender = "claude-7f3a", + recipient = "*", + timestamp = "2026-04-20T10:00:00Z", + op_kind = "claim", + risk_tier = 2, + payload = { task = "x" }, + context_fetch_id = "ctx", + dispatch_preference = "yolo", +}' \ +"fail" + +run_case "unknown task_difficulty string (TaskDifficultyShape)" \ +'{ + version = 1, + msg_id = "abcdef01234e", + prev_msg_hash = "0000000000000000000000000000000000000000000000000000000000000000", + sender = "claude-7f3a", + recipient = "*", + timestamp = "2026-04-20T10:00:00Z", + op_kind = "claim", + risk_tier = 2, + payload = { task = "x" }, + context_fetch_id = "ctx", + task_difficulty = "spicy", +}' \ +"fail" + +# ── Task #36 β€” difficulty_hint ──────────────────────────────── + +run_case "envelope with difficulty_hint=high (DifficultyHintValid)" \ +'{ + version = 1, + msg_id = "abcdef01234f", + prev_msg_hash = "0000000000000000000000000000000000000000000000000000000000000000", + sender = "claude-7f3a", + recipient = "*", + timestamp = "2026-04-20T10:00:00Z", + op_kind = "claim", + risk_tier = 2, + payload = { task = "tricky-proof" }, + context_fetch_id = "ctx", + difficulty_hint = "high", +}' \ +"pass" + +run_case "unknown difficulty_hint string (DifficultyHintValid)" \ +'{ + version = 1, + msg_id = "abcdef012350", + prev_msg_hash = "0000000000000000000000000000000000000000000000000000000000000000", + sender = "claude-7f3a", + recipient = "*", + timestamp = "2026-04-20T10:00:00Z", + op_kind = "status", + risk_tier = 0, + payload = { status = "ok" }, + difficulty_hint = "impossible", +}' \ +"fail" + +echo +echo "=== Summary ===" +echo "Accepted (pass expected): $PASS" +echo "Rejected (fail expected): $EXPECTED_FAILS" +echo "Unexpected: $FAIL" + +if [ $FAIL -eq 0 ]; then + exit 0 +else + exit 1 +fi diff --git a/cartridges/cross-cutting/agentic/local-coord-mcp/tests/e2e_coord.zig b/cartridges/cross-cutting/agentic/local-coord-mcp/tests/e2e_coord.zig new file mode 100644 index 0000000..6009ff7 --- /dev/null +++ b/cartridges/cross-cutting/agentic/local-coord-mcp/tests/e2e_coord.zig @@ -0,0 +1,341 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +// +// e2e_coord.zig β€” End-to-end integration test for local-coord-mcp (Zig port). +// +// Spawns the compiled adapter binary, drives a 2-peer flow through the +// REST surface (master + apprentice), exercises gate / approve / reject / +// claim paths, then restarts the adapter with the same state dir and +// verifies durability replay. +// +// Preconditions: +// - Adapter binary built: cd adapter && zig build +// - Port 7745 free on 127.0.0.1 +// +// Run (via cartridge build.zig β€” see adapter/build.zig for e2e step): +// cd adapter && zig build e2e +// +// Or directly: +// zig test cartridges/local-coord-mcp/tests/e2e_coord.zig + +const std = @import("std"); +const testing = std.testing; +const net = std.net; + +const PORT: u16 = 7745; +const HOST = "127.0.0.1"; +const SUP_SECRET = "e2e-test-supervisor-secret-do-not-deploy"; // hypatia-ignore: e2e test fixture β€” not a production credential + +// Path to the built adapter binary, relative to the cartridge root. +const ADAPTER_REL_PATH = "adapter/zig-out/bin/local_coord_adapter"; + +// ── HTTP over raw TCP (Zig 0.15-compatible) ─────────────────────────────── +// +// Builds a minimal HTTP/1.1 POST request and reads the response body. +// Using raw TCP avoids tracking std.http.Client API churn across Zig +// releases during the transition to the typed-WASM bridge. + +fn httpPost( + allocator: std.mem.Allocator, + path: []const u8, + body: []const u8, +) ![]u8 { + const addr = try net.Address.parseIp4(HOST, PORT); + const stream = try net.tcpConnectToAddress(addr); + defer stream.close(); + + // Write request. + const req_header = try std.fmt.allocPrint(allocator, + "POST {s} HTTP/1.1\r\n" ++ + "Host: " ++ HOST ++ ":{d}\r\n" ++ + "Content-Type: application/json\r\n" ++ + "Content-Length: {d}\r\n" ++ + "Connection: close\r\n" ++ + "\r\n", + .{ path, PORT, body.len }, + ); + defer allocator.free(req_header); + try stream.writeAll(req_header); + try stream.writeAll(body); + + // Read response. + var resp_buf = try std.ArrayList(u8).initCapacity(allocator, 1024); + defer resp_buf.deinit(allocator); + var tmp: [4096]u8 = undefined; + while (true) { + const n = stream.read(&tmp) catch break; + if (n == 0) break; + try resp_buf.appendSlice(allocator, tmp[0..n]); + } + + // Strip HTTP headers (find \r\n\r\n). + const raw = resp_buf.items; + const sep = "\r\n\r\n"; + const body_start = std.mem.indexOf(u8, raw, sep) orelse 0; + const resp_body = if (body_start + sep.len <= raw.len) + raw[body_start + sep.len ..] + else + raw; + + return allocator.dupe(u8, resp_body); +} + +fn tool( + allocator: std.mem.Allocator, + tool_name: []const u8, + payload: []const u8, +) ![]u8 { + const path = try std.fmt.allocPrint(allocator, "/tools/{s}", .{tool_name}); + defer allocator.free(path); + return httpPost(allocator, path, payload); +} + +// ── Adapter lifecycle ───────────────────────────────────────────────────── + +const Adapter = struct { + child: std.process.Child, + allocator: std.mem.Allocator, + + fn spawn(allocator: std.mem.Allocator, state_dir: []const u8) !Adapter { + var env = std.process.EnvMap.init(allocator); + defer env.deinit(); + try env.put("BOJ_COORD_STATE_DIR", state_dir); + try env.put("BOJ_MASTER_TOKEN", SUP_SECRET); + + var child = std.process.Child.init( + &.{ADAPTER_REL_PATH}, + allocator, + ); + child.env_map = &env; + child.stdout_behavior = .Pipe; + child.stderr_behavior = .Pipe; + try child.spawn(); + + try waitForBind(allocator); + return Adapter{ .child = child, .allocator = allocator }; + } + + fn stop(self: *Adapter) void { + _ = self.child.kill() catch {}; + _ = self.child.wait() catch {}; + waitForUnbind(self.allocator) catch {}; + } +}; + +fn waitForBind(allocator: std.mem.Allocator) !void { + _ = allocator; + const deadline = std.time.nanoTimestamp() + 3 * std.time.ns_per_s; + while (std.time.nanoTimestamp() < deadline) { + const addr = net.Address.parseIp4(HOST, PORT) catch continue; + const stream = net.tcpConnectToAddress(addr) catch { + std.Thread.sleep(50 * std.time.ns_per_ms); + continue; + }; + stream.close(); + return; + } + return error.AdapterBindTimeout; +} + +fn waitForUnbind(allocator: std.mem.Allocator) !void { + _ = allocator; + const deadline = std.time.nanoTimestamp() + 3 * std.time.ns_per_s; + while (std.time.nanoTimestamp() < deadline) { + const addr = net.Address.parseIp4(HOST, PORT) catch return; + const stream = net.tcpConnectToAddress(addr) catch return; + stream.close(); + std.Thread.sleep(50 * std.time.ns_per_ms); + } +} + +// ── Assertion helpers ───────────────────────────────────────────────────── + +var pass_count: usize = 0; +var fail_count: usize = 0; + +fn ok(label: []const u8) void { + pass_count += 1; + std.debug.print(" βœ“ {s}\n", .{label}); +} + +fn fail(label: []const u8, detail: []const u8) void { + fail_count += 1; + std.debug.print(" βœ— {s} β€” {s}\n", .{ label, detail }); +} + +fn check(cond: bool, label: []const u8, detail: []const u8) void { + if (cond) ok(label) else fail(label, detail); +} + +fn fieldStr(v: std.json.Value, key: []const u8) ?[]const u8 { + return switch (v) { + .object => |o| switch (o.get(key) orelse return null) { + .string => |s| s, + else => null, + }, + else => null, + }; +} + +fn fieldBool(v: std.json.Value, key: []const u8) ?bool { + return switch (v) { + .object => |o| switch (o.get(key) orelse return null) { + .bool => |b| b, + else => null, + }, + else => null, + }; +} + +fn fieldInt(v: std.json.Value, key: []const u8) ?i64 { + return switch (v) { + .object => |o| switch (o.get(key) orelse return null) { + .integer => |n| n, + else => null, + }, + else => null, + }; +} + +// ── Main test ───────────────────────────────────────────────────────────── + +test "E2E coord: full 2-peer lifecycle with restart durability" { + // Skip if adapter binary hasn't been built. + std.fs.cwd().access(ADAPTER_REL_PATH, .{}) catch |err| { + if (err == error.FileNotFound) { + std.debug.print( + " SKIP: adapter not found at {s} β€” run `cd adapter && zig build` first\n", + .{ADAPTER_REL_PATH}, + ); + return; + } + return err; + }; + + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + // Temp state directory. + const state_dir = blk: { + const tmpdir = std.process.getEnvVarOwned(alloc, "TMPDIR") catch "/tmp"; + break :blk try std.fs.path.join(alloc, &.{ tmpdir, "boj-coord-e2e" }); + }; + std.fs.makeDirAbsolute(state_dir) catch |e| { + if (e != error.PathAlreadyExists) return e; + }; + defer std.fs.deleteTreeAbsolute(state_dir) catch {}; + + var adapter = try Adapter.spawn(alloc, state_dir); + defer adapter.stop(); + + std.debug.print("\n── Phase 1: baseline peer flow ──\n", .{}); + + // Register A (claude) β€” should succeed. + const reg_a_resp = try tool(alloc, "coord_register", + \\{"client_kind":"claude","context":"window-A"} + ); + defer alloc.free(reg_a_resp); + const reg_a = try std.json.parseFromSlice(std.json.Value, alloc, reg_a_resp, .{}); + defer reg_a.deinit(); + check( + fieldBool(reg_a.value, "success") == true, + "register A (claude)", + "expected success=true", + ); + const token_a = fieldStr(reg_a.value, "token") orelse ""; + const peer_id_a = fieldStr(reg_a.value, "peer_id") orelse ""; + check(token_a.len > 0, "A has token", "empty token"); + check( + std.mem.startsWith(u8, peer_id_a, "claude-"), + "peer_id_A starts with claude-", + peer_id_a, + ); + + // Promote A to master. + const promote_payload = try std.fmt.allocPrint(alloc, + \\{{"token":"{s}","secret":"{s}"}} + , .{ token_a, SUP_SECRET }); + defer alloc.free(promote_payload); + const promote_resp = try tool(alloc, "coord_promote_to_supervisor", promote_payload); + defer alloc.free(promote_resp); + const promote = try std.json.parseFromSlice(std.json.Value, alloc, promote_resp, .{}); + defer promote.deinit(); + check(fieldBool(promote.value, "success") == true, "promote A to master", "failed"); + + // Register B (gemini). + const reg_b_resp = try tool(alloc, "coord_register", + \\{"client_kind":"gemini","context":"window-B"} + ); + defer alloc.free(reg_b_resp); + const reg_b = try std.json.parseFromSlice(std.json.Value, alloc, reg_b_resp, .{}); + defer reg_b.deinit(); + check(fieldBool(reg_b.value, "success") == true, "register B (gemini)", "failed"); + const token_b = fieldStr(reg_b.value, "token") orelse ""; + const peer_id_b = fieldStr(reg_b.value, "peer_id") orelse ""; + check(token_b.len > 0, "B has token", "empty token"); + check( + std.mem.startsWith(u8, peer_id_b, "gemini-"), + "peer_id_B starts with gemini-", + peer_id_b, + ); + + std.debug.print("\n── Phase 2: gated message β†’ approve ──\n", .{}); + + // B sends a Tier 3 gated message to A. + const gated_payload = try std.fmt.allocPrint(alloc, + \\{{"token":"{s}","target":"{s}","message":"please commit feature X","risk_tier":3}} + , .{ token_b, peer_id_a }); + defer alloc.free(gated_payload); + const gated_resp = try tool(alloc, "coord_send_gated", gated_payload); + defer alloc.free(gated_resp); + const gated = try std.json.parseFromSlice(std.json.Value, alloc, gated_resp, .{}); + defer gated.deinit(); + check( + std.mem.eql(u8, fieldStr(gated.value, "status") orelse "", "quarantined"), + "Tier 3 from apprentice β†’ quarantined", + fieldStr(gated.value, "status") orelse "missing", + ); + const rid1 = fieldInt(gated.value, "request_id") orelse -1; + check(rid1 > 0, "request_id is positive", ""); + + // A approves. + const approve_payload = try std.fmt.allocPrint(alloc, + \\{{"token":"{s}","request_id":{d}}} + , .{ token_a, rid1 }); + defer alloc.free(approve_payload); + const approve_resp = try tool(alloc, "coord_approve", approve_payload); + defer alloc.free(approve_resp); + const approve = try std.json.parseFromSlice(std.json.Value, alloc, approve_resp, .{}); + defer approve.deinit(); + check(fieldBool(approve.value, "success") == true, "approve message", "failed"); + + std.debug.print("\n── Phase 3: wrong-secret promotion rejected ──\n", .{}); + + // B tries to promote with wrong secret. + const bad_promote_payload = try std.fmt.allocPrint(alloc, + \\{{"token":"{s}","secret":"wrong-secret"}} + , .{token_b}); + defer alloc.free(bad_promote_payload); + const bad_promote_resp = try tool(alloc, "coord_promote_to_master", bad_promote_payload); + defer alloc.free(bad_promote_resp); + const bad_promote = try std.json.parseFromSlice(std.json.Value, alloc, bad_promote_resp, .{}); + defer bad_promote.deinit(); + check( + fieldBool(bad_promote.value, "success") != true, + "B with wrong secret rejected", + "should have failed", + ); + + // Summary. + std.debug.print("\n───────────────────────────────────────────────\n", .{}); + if (fail_count == 0) { + std.debug.print(" βœ… {d} assertions passed\n", .{pass_count}); + } else { + std.debug.print( + " ❌ {d}/{d} assertions failed\n", + .{ fail_count, pass_count + fail_count }, + ); + return error.TestFailed; + } +} diff --git a/cartridges/cross-cutting/agentic/model-router-mcp/README.adoc b/cartridges/cross-cutting/agentic/model-router-mcp/README.adoc new file mode 100644 index 0000000..d0b3fb5 --- /dev/null +++ b/cartridges/cross-cutting/agentic/model-router-mcp/README.adoc @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) += model-router-mcp +Jonathan D.A. Jewell +:spdx: MPL-2.0 +:tier: Ayo +:domain: AI +:protocols: MCP + +== Overview + +Intelligent model router for Claude Code. Classifies an incoming task and +recommends the optimal Claude model tier (Opus / Sonnet / Haiku), and can plan +a delegation from a higher tier to a lower tier: Opus analyses, Haiku or +Sonnet executes, Opus reviews. + +Aligns with the estate-standing rule "Opus supervises, Haiku first" β€” Haiku +executes, Sonnet escalates, Opus is the fallback reviewer. + +== Tools (4) + +[cols="2,4"] +|=== +| Tool | Description + +| `classify_task` | Analyse a task and recommend opus/sonnet/haiku, with complexity, confidence, and delegatability. +| `plan_delegation` | Generate a step-by-step plan for a cheaper model to execute a task that was classified as delegatable. +| `review_output` | Have Opus review the work a delegated model produced; returns APPROVED / NEEDS_REVISION / FAILED plus notes. +| `estimate_cost` | Estimate relative token cost across tiers; shows potential savings from delegation. +|=== + +== Architecture + +[cols="1,1,3"] +|=== +| Layer | Language | Purpose + +| ABI | Idris2 | `ModelRouter.Protocol` β€” JSON-RPC / MCP protocol types +| FFI | Zig | Thin native shim (`ffi/model_router_ffi.zig`, `ffi/build.zig`) +| Server | JavaScript (Deno-compatible) | MCP stdio server (`src/router.js`), JSON-RPC 2.0 over `Content-Length` framing +|=== + +This cartridge is the one exception in the fleet with no `cartridge.json` β€” the +tool list is declared inline in `src/router.js`. The router currently ships as +a JavaScript MCP server because classification heuristics were prototyped in +JS before the router was pulled into the BoJ cartridge layout. Porting the +classifier into the Zig FFI layer is on the backlog. + +== Building + +[source,bash] +---- +# ABI (Idris2) β€” protocol types only +cd abi && idris2 --check ModelRouter/Protocol.idr + +# FFI (Zig) β€” native shim +cd ffi && zig build + +# Server (JS) β€” direct invocation +node src/router.js # or loaded by the BoJ runtime +---- + +== Status + +Alpha (CRG D). Four tools exposed over MCP stdio; classifier heuristics active; +no formal delegation-safety proofs. Not yet dogfooded externally. See +`docs/READINESS.md` for the project-wide grade table and +`docs/practice/DOGFOOD-LOG.adoc` for usage evidence. diff --git a/cartridges/cross-cutting/agentic/model-router-mcp/abi/ModelRouter/Protocol.idr b/cartridges/cross-cutting/agentic/model-router-mcp/abi/ModelRouter/Protocol.idr new file mode 100644 index 0000000..bcf67a0 --- /dev/null +++ b/cartridges/cross-cutting/agentic/model-router-mcp/abi/ModelRouter/Protocol.idr @@ -0,0 +1,59 @@ +-- SPDX-License-Identifier: MPL-2.0 +-- Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +||| Protocol: Model Router β€” LLM tier routing via BoJ MCP +||| +||| Cartridge: model-router +||| Matrix cell: LLM x Routing +||| +||| Proves that: +||| 1. Model selection is deterministic for a given cost/quality preference +||| 2. Fallback chains always terminate +||| 3. Budget constraints are enforced before dispatch +module ModelRouter.Protocol + +import Data.Fin + +%default total + +||| Model tier (matches 007 language ModelTier) +public export +data ModelTier = Haiku | Sonnet | Opus + +||| Routing operation +public export +data Operation + = SelectModel -- Pick best model for a task + | ListModels -- List available models + | GetBudget -- Check remaining budget + | SetPreference -- Set cost/quality preference + +||| Cost preference (0=cheapest, 100=best quality) +public export +data CostPreference = MkPref (n : Fin 101) + +-- ═══════════════════════════════════════════════════════════════════════ +-- Fallback chain proof +-- ═══════════════════════════════════════════════════════════════════════ + +||| Fallback chain: Opus β†’ Sonnet β†’ Haiku (always terminates) +public export +fallback : ModelTier -> Maybe ModelTier +fallback Opus = Just Sonnet +fallback Sonnet = Just Haiku +fallback Haiku = Nothing + +-- ═══════════════════════════════════════════════════════════════════════ +-- C ABI Exports +-- ═══════════════════════════════════════════════════════════════════════ + +export +modelTierToInt : ModelTier -> Int +modelTierToInt Haiku = 0 +modelTierToInt Sonnet = 1 +modelTierToInt Opus = 2 + +export +router_fallback : Int -> Int +router_fallback 2 = 1 -- Opus β†’ Sonnet +router_fallback 1 = 0 -- Sonnet β†’ Haiku +router_fallback _ = -1 -- Haiku β†’ no fallback diff --git a/cartridges/cross-cutting/agentic/model-router-mcp/abi/README.adoc b/cartridges/cross-cutting/agentic/model-router-mcp/abi/README.adoc new file mode 100644 index 0000000..29412f0 --- /dev/null +++ b/cartridges/cross-cutting/agentic/model-router-mcp/abi/README.adoc @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) += model-router-mcp / abi β€” Idris2 ABI layer +:orientation: shallow + +== Purpose + +Cartridge-local protocol types for `model-router-mcp`. Defines the data, tool, and +transition vocabulary this cartridge speaks. Inherits safety lemmas and the +matrix-cell identifier scheme from the trunk ABI at `src/abi/`. + +== Files + +1 Idris2 module(s), ~59 lines total. Lead module: +`Protocol.idr`. + +No `.ipkg` β€” consumed directly by wider BoJ callers; type-checks inside the +trunk `boj.ipkg`. + +== Invariants + +* `%default total` at every module top. +* Zero `believe_me`, `assert_total`, `postulate`, `Admitted`, or + `sorry` (the trunk holds the four declared `believe_me` sites; leaf + cartridges carry none). + +== Test/proof surface + +Thin module(s), no inline tests. Proof surface = type-check success under the +trunk `idris2 --check`. + +== Read-first + +. `Protocol.idr` β€” cartridge-local data records and enums. +. Trunk `src/abi/Boj/Catalogue.idr` for how this cell participates in the + 2D matrix. diff --git a/cartridges/cross-cutting/agentic/model-router-mcp/abi/model-router.ipkg b/cartridges/cross-cutting/agentic/model-router-mcp/abi/model-router.ipkg new file mode 100644 index 0000000..bb5e72a --- /dev/null +++ b/cartridges/cross-cutting/agentic/model-router-mcp/abi/model-router.ipkg @@ -0,0 +1,6 @@ +-- SPDX-License-Identifier: MPL-2.0 +package modelRouter + +modules = ModelRouter.Protocol + +sourcedir = "." diff --git a/cartridges/cross-cutting/agentic/model-router-mcp/cartridge.json b/cartridges/cross-cutting/agentic/model-router-mcp/cartridge.json new file mode 100644 index 0000000..989ff21 --- /dev/null +++ b/cartridges/cross-cutting/agentic/model-router-mcp/cartridge.json @@ -0,0 +1,113 @@ +{ + "$schema": "https://boj.dev/schemas/cartridge/v1.json", + "spdx": "MPL-2.0", + "copyright": "Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) ", + "name": "model-router-mcp", + "version": "0.1.0", + "description": "Intelligent model router for Claude Code. Classifies tasks and recommends opus/sonnet/haiku, plans delegation from a higher tier to a cheaper executor, reviews delegated output, and estimates relative token cost savings.", + "domain": "AI", + "tier": "Ayo", + "protocols": [ + "MCP" + ], + "auth": { + "method": "none", + "env_var": null, + "credential_source": null + }, + "api": { + "base_url": "local://model-router-mcp", + "content_type": "application/json" + }, + "ports": { + "allowed": [], + "denied": [ + 22, + 23, + 25, + 135, + 139, + 445 + ] + }, + "tools": [ + { + "name": "classify_task", + "description": "Analyse a task and recommend the optimal Claude model (opus/sonnet/haiku). Returns complexity, recommended model, confidence, reason, and whether the task can be delegated.", + "inputSchema": { + "type": "object", + "properties": { + "task": { + "type": "string", + "description": "The task description to classify" + } + }, + "required": [ + "task" + ] + } + }, + { + "name": "plan_delegation", + "description": "Generate a step-by-step execution plan for a cheaper model to carry out a task. Opus plans, Haiku or Sonnet executes.", + "inputSchema": { + "type": "object", + "properties": { + "task": { + "type": "string", + "description": "The original task to delegate" + }, + "target_model": { + "type": "string", + "enum": [ + "haiku", + "sonnet" + ], + "description": "Which model will execute the plan" + } + }, + "required": [ + "task", + "target_model" + ] + } + }, + { + "name": "review_output", + "description": "Review work done by a delegated model. Returns verdict (APPROVED / NEEDS_REVISION / FAILED) and structured notes.", + "inputSchema": { + "type": "object", + "properties": { + "original_task": { + "type": "string", + "description": "What was originally requested" + }, + "executor_output": { + "type": "string", + "description": "What the delegated model produced" + } + }, + "required": [ + "original_task", + "executor_output" + ] + } + }, + { + "name": "estimate_cost", + "description": "Estimate relative token cost across model tiers and show potential savings from delegation.", + "inputSchema": { + "type": "object", + "properties": { + "estimated_tokens": { + "type": "number", + "description": "Rough estimate of total tokens (input + output) for the task" + } + }, + "required": [ + "estimated_tokens" + ] + } + } + ] +} diff --git a/cartridges/cross-cutting/agentic/model-router-mcp/ffi/README.adoc b/cartridges/cross-cutting/agentic/model-router-mcp/ffi/README.adoc new file mode 100644 index 0000000..01018c7 --- /dev/null +++ b/cartridges/cross-cutting/agentic/model-router-mcp/ffi/README.adoc @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) += model-router-mcp / ffi β€” Zig FFI layer **(stub)** +:orientation: shallow + +== Purpose + +C-ABI exports bridging this cartridge's Idris2 ABI to the runtime. Consumed by +the adapter for protocol dispatch and by the trunk loader for mount. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” produces `zig-out/lib/libmodel-router.so`. +| `model_router_ffi.zig` | C-ABI exports (2 exports, 2 inline tests, 40 lines). +|=== + +== Invariants + +* **Null-pointer rejection** on `[*c]const u8` arguments before any + dereference. +* **Negative return values** carry documented error meanings; zero is + success (matching the trunk error-code convention). +* **No allocator leaks** β€” every allocation path pairs with `defer free`. + +== Test/proof surface + +2 inline `test "..."` block(s) in `model_router_ffi.zig`. Run with +`cd ffi && zig build test`. + +== Read-first + +. `model_router_ffi.zig` β€” C-ABI signatures and guards. +. Trunk `ffi/zig/src/catalogue.zig` for how this `.so` is loaded. + +== Promotion gate + +Wire these exports to real backing state (not `return 0` / `return 1` +placeholders) before promoting past D. diff --git a/cartridges/cross-cutting/agentic/model-router-mcp/ffi/build.zig b/cartridges/cross-cutting/agentic/model-router-mcp/ffi/build.zig new file mode 100644 index 0000000..c153c8e --- /dev/null +++ b/cartridges/cross-cutting/agentic/model-router-mcp/ffi/build.zig @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const ffi_mod = b.addModule("model_router_mcp", .{ + .root_source_file = b.path("model_router_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + const lib = b.addLibrary(.{ + .name = "model_router_mcp", + .root_module = ffi_mod, + .linkage = .dynamic, + }); + lib.linkLibC(); + b.installArtifact(lib); + + const tests = b.addTest(.{ .root_module = ffi_mod }); + tests.linkLibC(); + const run_tests = b.addRunArtifact(tests); + const test_step = b.step("test", "Run FFI tests"); + test_step.dependOn(&run_tests.step); +} diff --git a/cartridges/cross-cutting/agentic/model-router-mcp/ffi/cartridge_shim.zig b/cartridges/cross-cutting/agentic/model-router-mcp/ffi/cartridge_shim.zig new file mode 100644 index 0000000..0e399db --- /dev/null +++ b/cartridges/cross-cutting/agentic/model-router-mcp/ffi/cartridge_shim.zig @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +// +// cartridge_shim.zig β€” Shared helpers for the ADR-0006 five-symbol +// cartridge ABI (`boj_cartridge_init / deinit / name / version / invoke`). +// +// The shim centralises the seven-code return convention, NUL-argument +// guards, tool-name comparison, and the buffer-too-small path so each +// cartridge's `boj_cartridge_invoke` can stay short β€” typically a tool +// table plus `shim.writeResult(...)`. +// +// Cartridges import this file by relative path (no build-graph change +// needed). Example: +// +// const shim = @import("../../../ffi/zig/src/cartridge_shim.zig"); +// +// export fn boj_cartridge_invoke( +// tool_name: [*c]const u8, +// json_args: [*c]const u8, +// out_buf: [*c]u8, +// in_out_len: [*c]usize, +// ) callconv(.c) i32 { +// _ = json_args; +// if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; +// const body = if (shim.toolIs(tool_name, "foo")) "{\"result\":{}}" +// else return shim.RC_UNKNOWN_TOOL; +// return shim.writeResult(out_buf, in_out_len, body); +// } + +const std = @import("std"); + +// ── Return codes (ADR-0006 Β§Return codes) ──────────────────────────── +// +// Frozen by ADR-0006. New failure modes compose these via the error +// JSON body β€” the integer surface does not grow without a follow-up ADR. + +pub const RC_SUCCESS: i32 = 0; +pub const RC_UNKNOWN_TOOL: i32 = -1; +pub const RC_BAD_ARGS: i32 = -2; +pub const RC_BUFFER_TOO_SMALL: i32 = -3; +pub const RC_RUNTIME_ERROR: i32 = -4; +pub const RC_PANIC: i32 = -5; +pub const RC_AUTH_DENIED: i32 = -6; + +// ── Invoke-path helpers ────────────────────────────────────────────── + +/// True if any of the three mandatory `boj_cartridge_invoke` output-path +/// pointers is null. Use at the top of every invoke to short-circuit to +/// `RC_BAD_ARGS`. +pub fn invokeArgsNull( + tool_name: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) bool { + return tool_name == null or out_buf == null or in_out_len == null; +} + +/// Compare a C-NUL-terminated tool-name pointer against a Zig string +/// literal. Caller must have already verified `tool_name` is non-null +/// (usually via `invokeArgsNull`). +pub fn toolIs(tool_name: [*c]const u8, expected: []const u8) bool { + const s = std.mem.span(@as([*:0]const u8, @ptrCast(tool_name))); + return std.mem.eql(u8, s, expected); +} + +/// Copy `body` into `out_buf[0..*in_out_len]` (as a capacity) and update +/// `*in_out_len` to the number of bytes written. Returns `RC_SUCCESS`. +/// +/// If `body.len` exceeds the current capacity stored in `*in_out_len`, +/// sets `*in_out_len` to the required size and returns +/// `RC_BUFFER_TOO_SMALL` β€” the caller is then expected to re-allocate +/// and retry, per ADR-0006 Β§Memory ownership. +/// +/// Caller must have already verified that `out_buf` and `in_out_len` +/// are non-null. +pub fn writeResult( + out_buf: [*c]u8, + in_out_len: [*c]usize, + body: []const u8, +) i32 { + const cap = in_out_len.*; + if (body.len > cap) { + in_out_len.* = body.len; + return RC_BUFFER_TOO_SMALL; + } + @memcpy(out_buf[0..body.len], body); + in_out_len.* = body.len; + return RC_SUCCESS; +} + +// ── Tests ──────────────────────────────────────────────────────────── + +test "writeResult: body fits, writes and sets length" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); + try std.testing.expectEqualStrings("hello", buf[0..len]); +} + +test "writeResult: too small returns -3 and sets required length" { + var buf: [2]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_BUFFER_TOO_SMALL, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: exact-fit succeeds" { + var buf: [5]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: empty body" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, ""); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 0), len); +} + +test "toolIs: matches and rejects" { + const name: [*:0]const u8 = "foo"; + try std.testing.expect(toolIs(@ptrCast(name), "foo")); + try std.testing.expect(!toolIs(@ptrCast(name), "bar")); + try std.testing.expect(!toolIs(@ptrCast(name), "foobar")); + try std.testing.expect(!toolIs(@ptrCast(name), "fo")); +} + +test "invokeArgsNull: detects each null slot" { + var buf: [4]u8 = undefined; + var len: usize = 4; + const name: [*:0]const u8 = "x"; + try std.testing.expect(!invokeArgsNull(@ptrCast(name), &buf, &len)); + try std.testing.expect(invokeArgsNull(null, &buf, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), null, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), &buf, null)); +} diff --git a/cartridges/cross-cutting/agentic/model-router-mcp/ffi/model_router_ffi.zig b/cartridges/cross-cutting/agentic/model-router-mcp/ffi/model_router_ffi.zig new file mode 100644 index 0000000..38a87f2 --- /dev/null +++ b/cartridges/cross-cutting/agentic/model-router-mcp/ffi/model_router_ffi.zig @@ -0,0 +1,131 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +// +// Model Router FFI β€” LLM tier routing for BoJ MCP cartridge. + +const std = @import("std"); + +pub const ModelTier = enum(i32) { + haiku = 0, + sonnet = 1, + opus = 2, +}; + +/// Select model based on cost preference (0=cheapest, 100=best quality). +pub export fn router_select(cost_pref: i32) i32 { + if (cost_pref < 30) return 0; // Haiku + if (cost_pref < 70) return 1; // Sonnet + return 2; // Opus +} + +/// Fallback: Opusβ†’Sonnet, Sonnetβ†’Haiku, Haikuβ†’-1 (no fallback). +pub export fn router_fallback(tier: i32) i32 { + return switch (@as(ModelTier, @enumFromInt(tier))) { + .opus => 1, + .sonnet => 0, + .haiku => -1, + }; +} + +// ═══════════════════════════════════════════════════════════════════════ +// Standard ABI (ADR-0005 four symbols + ADR-0006 invoke) +// ═══════════════════════════════════════════════════════════════════════ +// +// Note: model-router-mcp has no cartridge.json; the 4 MCP tool names +// below come from README.adoc. + +const shim = @import("cartridge_shim.zig"); + +const CARTRIDGE_NAME_PTR: [*:0]const u8 = "model-router-mcp"; +const CARTRIDGE_VERSION_PTR: [*:0]const u8 = "0.1.0"; + +export fn boj_cartridge_init() callconv(.c) c_int { + return 0; +} + +export fn boj_cartridge_deinit() callconv(.c) void {} + +export fn boj_cartridge_name() callconv(.c) [*:0]const u8 { + return CARTRIDGE_NAME_PTR; +} + +export fn boj_cartridge_version() callconv(.c) [*:0]const u8 { + return CARTRIDGE_VERSION_PTR; +} + +/// Dispatch the 4 MCP tools declared in README.adoc. Grade D Alpha. +export fn boj_cartridge_invoke( + tool_name: [*c]const u8, + json_args: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) callconv(.c) i32 { + _ = json_args; + if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; + + const body: []const u8 = if (shim.toolIs(tool_name, "classify_task")) + "{\"result\":{\"tier\":\"haiku\",\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "plan_delegation")) + "{\"result\":{\"plan\":[],\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "review_output")) + "{\"result\":{\"approved\":false,\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "estimate_cost")) + "{\"result\":{\"usd\":0.0,\"status\":\"stub\"}}" + else + return shim.RC_UNKNOWN_TOOL; + + return shim.writeResult(out_buf, in_out_len, body); +} + +test "model selection" { + try std.testing.expectEqual(@as(i32, 0), router_select(0)); + try std.testing.expectEqual(@as(i32, 1), router_select(50)); + try std.testing.expectEqual(@as(i32, 2), router_select(100)); +} + +test "fallback chain terminates" { + try std.testing.expectEqual(@as(i32, 1), router_fallback(2)); + try std.testing.expectEqual(@as(i32, 0), router_fallback(1)); + try std.testing.expectEqual(@as(i32, -1), router_fallback(0)); +} + +// ═══════════════════════════════════════════════════════════════════════ +// ADR-0006 invoke dispatch tests +// ═══════════════════════════════════════════════════════════════════════ + +test "boj_cartridge_name returns model-router-mcp" { + const n = std.mem.span(boj_cartridge_name()); + try std.testing.expectEqualStrings("model-router-mcp", n); +} + +test "boj_cartridge_init returns 0" { + try std.testing.expectEqual(@as(c_int, 0), boj_cartridge_init()); +} + +test "invoke: each declared tool succeeds" { + var buf: [256]u8 = undefined; + const tools = [_][]const u8{ + "classify_task", "plan_delegation", "review_output", "estimate_cost", + }; + for (tools) |t| { + var len: usize = buf.len; + const rc = boj_cartridge_invoke(t.ptr, "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, 0), rc); + try std.testing.expect(std.mem.indexOf(u8, buf[0..len], "result") != null); + } +} + +test "invoke: unknown tool returns -1" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("nope", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -1), rc); +} + +test "invoke: buffer too small returns -3" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("classify_task", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -3), rc); + try std.testing.expect(len > 4); +} diff --git a/cartridges/cross-cutting/agentic/model-router-mcp/mod.js b/cartridges/cross-cutting/agentic/model-router-mcp/mod.js new file mode 100644 index 0000000..93ad15c --- /dev/null +++ b/cartridges/cross-cutting/agentic/model-router-mcp/mod.js @@ -0,0 +1,174 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +// +// model-router-mcp/mod.js β€” Intelligent model-tier router cartridge. +// +// All logic is local β€” no external service required. Classification, +// delegation planning, output review, and cost estimation run in-process. +// The canonical implementation lives in src/router.js (Node.js MCP server); +// this module re-implements the same logic for the BoJ cartridge host. + +// --------------------------------------------------------------------------- +// Task Classification +// --------------------------------------------------------------------------- + +function classifyTask(task) { + const expertPatterns = [ + /formal.?verif/i, /dependent.?type/i, /idris2?/i, /prove|proof|theorem/i, + /architecture|redesign|refactor.*across/i, /security.?audit|vulnerability/i, + /design.*system|system.*design/i, /migrate.*codebase/i, + /cross.?repo|across.*repos/i, /critical.*decision/i, + /believe_me|assert_total|sorry|Admitted/i, + ]; + for (const p of expertPatterns) { + if (p.test(task)) + return { complexity: "expert", model: "opus", confidence: 0.9, reason: `Expert: ${p.source}`, canDelegate: false }; + } + + const complexPatterns = [ + /implement.*feature/i, /build.*system/i, /create.*module/i, + /debug.*complex|investigate/i, /review.*code|audit.*code/i, + /multiple.*files|several.*files/i, /wire.*together|integrate/i, + /protocol|specification/i, + ]; + for (const p of complexPatterns) { + if (p.test(task)) + return { complexity: "complex", model: "opus", confidence: 0.75, reason: `Complex: ${p.source}`, canDelegate: true }; + } + + const trivialPatterns = [ + /^(list|show|what is|check|status|count|find|search|grep|glob)/i, + /^(read|cat|head|tail|ls)\s/i, + /git status|git log|git branch/i, + /how many|what files/i, + ]; + for (const p of trivialPatterns) { + if (p.test(task)) + return { complexity: "trivial", model: "haiku", confidence: 0.85, reason: `Trivial: ${p.source}`, canDelegate: false }; + } + + const simplePatterns = [ + /rename|replace|fix.*typo|update.*version/i, + /add.*header|add.*spdx|add.*license/i, + /delete.*file|remove.*file|clean.*up/i, + /format|lint|prettier/i, + /commit|push|pull/i, + ]; + for (const p of simplePatterns) { + if (p.test(task)) + return { complexity: "simple", model: "haiku", confidence: 0.7, reason: `Simple: ${p.source}`, canDelegate: false }; + } + + const wordCount = task.split(/\s+/).length; + if (wordCount < 10) + return { complexity: "simple", model: "sonnet", confidence: 0.5, reason: "Short prompt", canDelegate: false }; + if (wordCount > 100) + return { complexity: "complex", model: "opus", confidence: 0.6, reason: "Long detailed prompt", canDelegate: true }; + + return { complexity: "moderate", model: "sonnet", confidence: 0.5, reason: "No strong signals, defaulting to Sonnet", canDelegate: true }; +} + +// --------------------------------------------------------------------------- +// Plan-and-Delegate +// --------------------------------------------------------------------------- + +function generateDelegationPlan(task, targetModel) { + return `You are executing a pre-planned task. Follow these instructions EXACTLY. +Do not deviate, improvise, or add anything not specified. +If you encounter something unexpected, STOP and report it β€” do not try to fix it yourself. + +MODEL: ${targetModel} +ORIGINAL TASK: ${task} + +INSTRUCTIONS: +[Opus will fill detailed step-by-step instructions before handoff] + +CHECKPOINTS: +- After each file edit, verify it compiles/builds +- After completing all steps, run tests if available +- Report: what was done, what succeeded, what failed + +ESCALATION: If any step fails or is unclear, output "ESCALATE: " and stop.`; +} + +// --------------------------------------------------------------------------- +// Output Review +// --------------------------------------------------------------------------- + +function generateReviewPrompt(originalTask, executorOutput) { + return `Review the following work done by a delegated model. + +ORIGINAL TASK: ${originalTask} + +EXECUTOR OUTPUT: +${executorOutput} + +CHECK: +1. Was the task completed correctly? +2. Were there any errors, omissions, or quality issues? +3. Does the output match the intent of the original task? +4. Are there any security concerns? + +VERDICT: [APPROVED / NEEDS_REVISION / FAILED] +NOTES: [Specific feedback if not approved]`; +} + +// --------------------------------------------------------------------------- +// Cost Estimation +// --------------------------------------------------------------------------- + +function estimateCost(estimatedTokens) { + const opusPer = 1.0, sonnetPer = 0.2, haikuPer = 0.04; + const opus = estimatedTokens * opusPer; + const sonnet = estimatedTokens * sonnetPer; + const haiku = estimatedTokens * haikuPer; + // Delegated: Opus plans (10%) + Haiku executes (90%) + Opus reviews (10%) + const delegated = estimatedTokens * 0.1 * opusPer + + estimatedTokens * 0.9 * haikuPer + + estimatedTokens * 0.1 * opusPer; + return { + opus: Math.round(opus), + sonnet: Math.round(sonnet), + haiku: Math.round(haiku), + delegated: Math.round(delegated), + savings: Math.round((1 - delegated / opus) * 100) + "%", + }; +} + +// --------------------------------------------------------------------------- +// handleTool β€” BoJ cartridge entry point +// --------------------------------------------------------------------------- + +export async function handleTool(toolName, args) { + switch (toolName) { + case "classify_task": { + const { task } = args ?? {}; + if (!task) return { status: 400, data: { error: "task is required" } }; + return { status: 200, data: classifyTask(task) }; + } + + case "plan_delegation": { + const { task, target_model } = args ?? {}; + if (!task || !target_model) + return { status: 400, data: { error: "task and target_model are required" } }; + return { status: 200, data: { plan: generateDelegationPlan(task, target_model), target_model } }; + } + + case "review_output": { + const { original_task, executor_output } = args ?? {}; + if (!original_task || !executor_output) + return { status: 400, data: { error: "original_task and executor_output are required" } }; + return { status: 200, data: { review_prompt: generateReviewPrompt(original_task, executor_output) } }; + } + + case "estimate_cost": { + const { estimated_tokens } = args ?? {}; + if (estimated_tokens == null || typeof estimated_tokens !== "number") + return { status: 400, data: { error: "estimated_tokens (number) is required" } }; + return { status: 200, data: estimateCost(estimated_tokens) }; + } + + default: + return { status: 404, data: { error: `Unknown tool: ${toolName}` } }; + } +} diff --git a/cartridges/cross-cutting/agentic/model-router-mcp/panels/manifest.json b/cartridges/cross-cutting/agentic/model-router-mcp/panels/manifest.json new file mode 100644 index 0000000..771a73d --- /dev/null +++ b/cartridges/cross-cutting/agentic/model-router-mcp/panels/manifest.json @@ -0,0 +1,67 @@ +{ + "$schema": "https://panll.dev/schemas/panel-manifest/v1.json", + "spdx": "MPL-2.0", + "cartridge": "model-router-mcp", + "domain": "Model Routing", + "version": "0.1.0", + "panels": [ + { + "id": "model-router-status", + "title": "Model Router Status", + "description": "LLM routing gateway health and upstream provider connectivity", + "type": "status-indicator", + "data_source": { + "endpoint": "/cartridge/model-router-mcp/invoke", + "method": "POST", + "body": { "tool": "status" }, + "refresh_interval_ms": 5000 + }, + "widgets": [ + { + "type": "state-badge", + "field": "state", + "states": { + "ready": { "color": "#2ecc71", "icon": "share-2" }, + "degraded": { "color": "#f39c12", "icon": "alert-circle" }, + "error": { "color": "#e74c3c", "icon": "alert-triangle" } + } + }, + { "type": "text", "field": "version", "label": "Version" } + ] + }, + { + "id": "model-router-providers", + "title": "Provider Overview", + "description": "Connected LLM providers, available models, and rate limit headroom", + "type": "metric", + "data_source": { + "endpoint": "/cartridge/model-router-mcp/invoke", + "method": "POST", + "body": { "tool": "provider_overview" }, + "refresh_interval_ms": 15000 + }, + "widgets": [ + { "type": "counter", "field": "active_providers", "label": "Providers", "icon": "server" }, + { "type": "counter", "field": "available_models", "label": "Models", "icon": "cpu" }, + { "type": "gauge", "field": "rate_limit_headroom", "label": "Rate Limit %", "min": 0, "max": 100 } + ] + }, + { + "id": "model-router-routing-stats", + "title": "Routing Statistics", + "description": "Request routing decisions, fallback counts, and cost tracking", + "type": "metric", + "data_source": { + "endpoint": "/cartridge/model-router-mcp/invoke", + "method": "POST", + "body": { "tool": "routing_stats" }, + "refresh_interval_ms": 10000 + }, + "widgets": [ + { "type": "counter", "field": "requests_routed", "label": "Requests Routed", "icon": "arrow-right" }, + { "type": "counter", "field": "fallback_count", "label": "Fallbacks", "icon": "repeat" }, + { "type": "text", "field": "estimated_cost_today", "label": "Est. Cost Today" } + ] + } + ] +} diff --git a/cartridges/cross-cutting/agentic/model-router-mcp/src/router.js b/cartridges/cross-cutting/agentic/model-router-mcp/src/router.js new file mode 100644 index 0000000..3386d09 --- /dev/null +++ b/cartridges/cross-cutting/agentic/model-router-mcp/src/router.js @@ -0,0 +1,438 @@ +#!/usr/bin/env node +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +// +// Model Router β€” Intelligent model switching for Claude Code +// +// Analyses incoming tasks and determines the optimal model: +// - Opus: complex architecture, design decisions, formal verification, +// multi-file refactoring, code review, debugging novel issues +// - Sonnet: standard coding, feature implementation, file editing, +// test writing, documentation, most daily work +// - Haiku: simple searches, file reads, grep/glob, formatting, +// mechanical replacements, status checks +// +// The router can also PLAN at a higher tier and DELEGATE to a lower tier: +// 1. Opus analyses the task and writes detailed step-by-step instructions +// 2. Haiku/Sonnet executes the plan +// 3. Opus reviews at intervals (every N steps or on completion) +// 4. Escalates back to Opus if the executor is struggling +// +// Usage as MCP server: +// Add to claude_desktop_config.json or .claude/settings.json +// +// Usage as Claude Code skill: +// /model-route + +// --------------------------------------------------------------------------- +// Task Classification +// --------------------------------------------------------------------------- + +/** + * Complexity levels that map to model tiers. + * @typedef {'trivial' | 'simple' | 'moderate' | 'complex' | 'expert'} Complexity + */ + +/** + * Classify a task's complexity based on keyword analysis and heuristics. + * Returns a complexity level and recommended model. + * + * @param {string} task - The task description or prompt + * @returns {{ complexity: string, model: string, confidence: number, reason: string, canDelegate: boolean }} + */ +function classifyTask(task) { + const lower = task.toLowerCase(); + const wordCount = task.split(/\s+/).length; + + // --- Expert indicators (always Opus) --- + const expertPatterns = [ + /formal.?verif/i, /dependent.?type/i, /idris2?/i, /prove|proof|theorem/i, + /architecture|redesign|refactor.*across/i, /security.?audit|vulnerability/i, + /design.*system|system.*design/i, /migrate.*codebase/i, + /cross.?repo|across.*repos/i, /critical.*decision/i, + /believe_me|assert_total|sorry|Admitted/i, + ]; + for (const pattern of expertPatterns) { + if (pattern.test(task)) { + return { + complexity: "expert", + model: "opus", + confidence: 0.9, + reason: `Expert task detected: ${pattern.source}`, + canDelegate: false, + }; + } + } + + // --- Complex indicators (Opus, but can delegate with plan) --- + const complexPatterns = [ + /implement.*feature/i, /build.*system/i, /create.*module/i, + /debug.*complex|investigate/i, /review.*code|audit.*code/i, + /multiple.*files|several.*files/i, /wire.*together|integrate/i, + /character.*system|game.*design/i, /protocol|specification/i, + ]; + for (const pattern of complexPatterns) { + if (pattern.test(task)) { + return { + complexity: "complex", + model: "opus", + confidence: 0.75, + reason: `Complex task: ${pattern.source}`, + canDelegate: true, + }; + } + } + + // --- Trivial indicators (always Haiku) --- + const trivialPatterns = [ + /^(list|show|what is|check|status|count|find|search|grep|glob)/i, + /^(read|cat|head|tail|ls)\s/i, + /git status|git log|git branch/i, + /how many|what files/i, + ]; + for (const pattern of trivialPatterns) { + if (pattern.test(task)) { + return { + complexity: "trivial", + model: "haiku", + confidence: 0.85, + reason: `Trivial query: ${pattern.source}`, + canDelegate: false, + }; + } + } + + // --- Simple indicators (Haiku or Sonnet) --- + const simplePatterns = [ + /rename|replace|fix.*typo|update.*version/i, + /add.*header|add.*spdx|add.*license/i, + /delete.*file|remove.*file|clean.*up/i, + /format|lint|prettier/i, + /commit|push|pull/i, + ]; + for (const pattern of simplePatterns) { + if (pattern.test(task)) { + return { + complexity: "simple", + model: "haiku", + confidence: 0.7, + reason: `Simple mechanical task: ${pattern.source}`, + canDelegate: false, + }; + } + } + + // --- Length-based heuristic for remaining --- + if (wordCount < 10) { + return { + complexity: "simple", + model: "sonnet", + confidence: 0.5, + reason: "Short prompt, defaulting to Sonnet", + canDelegate: false, + }; + } + + if (wordCount > 100) { + return { + complexity: "complex", + model: "opus", + confidence: 0.6, + reason: "Long detailed prompt suggests complex task", + canDelegate: true, + }; + } + + // Default: Sonnet (balanced) + return { + complexity: "moderate", + model: "sonnet", + confidence: 0.5, + reason: "No strong signals, defaulting to Sonnet", + canDelegate: true, + }; +} + +// --------------------------------------------------------------------------- +// Plan-and-Delegate System +// --------------------------------------------------------------------------- + +/** + * Generate a delegation plan β€” instructions that a lower-tier model can follow. + * + * This is the key efficiency mechanism: + * 1. Opus (expensive) analyses the task and writes precise step-by-step instructions + * 2. Haiku (cheap) follows the instructions mechanically + * 3. Opus reviews the output at checkpoints + * + * @param {string} task - The original task + * @param {string} targetModel - The model that will execute ('haiku' or 'sonnet') + * @returns {string} A prompt for the target model with detailed instructions + */ +function generateDelegationPlan(task, targetModel) { + return `You are executing a pre-planned task. Follow these instructions EXACTLY. +Do not deviate, improvise, or add anything not specified. +If you encounter something unexpected, STOP and report it β€” do not try to fix it yourself. + +MODEL: ${targetModel} +ORIGINAL TASK: ${task} + +INSTRUCTIONS: +[This section would be filled by Opus with specific step-by-step instructions] + +CHECKPOINTS: +- After each file edit, verify it compiles/builds +- After completing all steps, run tests if available +- Report: what was done, what succeeded, what failed + +ESCALATION: If any step fails or is unclear, output "ESCALATE: " and stop.`; +} + +/** + * Generate review instructions for Opus to check delegated work. + * + * @param {string} originalTask - What was requested + * @param {string} executorOutput - What the lower model produced + * @returns {string} A prompt for Opus to review + */ +function generateReviewPrompt(originalTask, executorOutput) { + return `Review the following work done by a delegated model. + +ORIGINAL TASK: ${originalTask} + +EXECUTOR OUTPUT: +${executorOutput} + +CHECK: +1. Was the task completed correctly? +2. Were there any errors, omissions, or quality issues? +3. Does the output match the intent of the original task? +4. Are there any security concerns? + +VERDICT: [APPROVED / NEEDS_REVISION / FAILED] +NOTES: [Specific feedback if not approved]`; +} + +// --------------------------------------------------------------------------- +// Cost Estimation +// --------------------------------------------------------------------------- + +/** + * Estimate relative cost of running a task on different models. + * Based on approximate token pricing ratios. + * + * @param {number} estimatedTokens - Rough input+output token estimate + * @returns {{ opus: number, sonnet: number, haiku: number, delegated: number }} + */ +function estimateCost(estimatedTokens) { + // Relative cost ratios (approximate) + const opusCostPerToken = 1.0; // baseline + const sonnetCostPerToken = 0.2; // ~5x cheaper + const haikuCostPerToken = 0.04; // ~25x cheaper + + const opusCost = estimatedTokens * opusCostPerToken; + const sonnetCost = estimatedTokens * sonnetCostPerToken; + const haikuCost = estimatedTokens * haikuCostPerToken; + + // Delegated: Opus plans (10% of tokens) + Haiku executes (90%) + Opus reviews (10%) + const delegatedCost = + estimatedTokens * 0.1 * opusCostPerToken + + estimatedTokens * 0.9 * haikuCostPerToken + + estimatedTokens * 0.1 * opusCostPerToken; + + return { + opus: Math.round(opusCost), + sonnet: Math.round(sonnetCost), + haiku: Math.round(haikuCost), + delegated: Math.round(delegatedCost), + savings: Math.round((1 - delegatedCost / opusCost) * 100) + "%", + }; +} + +// --------------------------------------------------------------------------- +// MCP Server (stdio transport) +// --------------------------------------------------------------------------- + +const SERVER_NAME = "model-router"; +const SERVER_VERSION = "1.0.0"; + +const TOOLS = [ + { + name: "classify_task", + description: + "Analyse a task and recommend the optimal Claude model (opus/sonnet/haiku). Returns complexity, recommended model, confidence, and whether the task can be delegated to a cheaper model with planning.", + inputSchema: { + type: "object", + properties: { + task: { + type: "string", + description: "The task description to classify", + }, + }, + required: ["task"], + }, + }, + { + name: "plan_delegation", + description: + "Generate a step-by-step plan for a cheaper model to execute a task that was classified as delegatable. Opus plans, Haiku/Sonnet executes.", + inputSchema: { + type: "object", + properties: { + task: { + type: "string", + description: "The original task to delegate", + }, + target_model: { + type: "string", + enum: ["haiku", "sonnet"], + description: "Which model will execute the plan", + }, + }, + required: ["task", "target_model"], + }, + }, + { + name: "review_output", + description: + "Have Opus review work done by a delegated model. Returns verdict (APPROVED/NEEDS_REVISION/FAILED) and notes.", + inputSchema: { + type: "object", + properties: { + original_task: { + type: "string", + description: "What was originally requested", + }, + executor_output: { + type: "string", + description: "What the delegated model produced", + }, + }, + required: ["original_task", "executor_output"], + }, + }, + { + name: "estimate_cost", + description: + "Estimate relative cost of running a task on different models. Shows potential savings from delegation.", + inputSchema: { + type: "object", + properties: { + estimated_tokens: { + type: "number", + description: + "Rough estimate of total tokens (input + output) for the task", + }, + }, + required: ["estimated_tokens"], + }, + }, +]; + +// --- JSON-RPC stdio transport --- + +let buffer = ""; + +process.stdin.setEncoding("utf-8"); +process.stdin.on("data", (chunk) => { + buffer += chunk; + while (true) { + const headerEnd = buffer.indexOf("\r\n\r\n"); + if (headerEnd === -1) break; + + const header = buffer.substring(0, headerEnd); + const lengthMatch = header.match(/Content-Length:\s*(\d+)/i); + if (!lengthMatch) { + buffer = buffer.substring(headerEnd + 4); + continue; + } + + const contentLength = parseInt(lengthMatch[1], 10); + const bodyStart = headerEnd + 4; + if (buffer.length < bodyStart + contentLength) break; + + const body = buffer.substring(bodyStart, bodyStart + contentLength); + buffer = buffer.substring(bodyStart + contentLength); + + try { + const message = JSON.parse(body); + handleMessage(message); + } catch (err) { + sendError(null, -32700, "Parse error"); + } + } +}); + +function send(message) { + const body = JSON.stringify(message); + const header = `Content-Length: ${Buffer.byteLength(body)}\r\n\r\n`; + process.stdout.write(header + body); +} + +function sendResult(id, result) { + send({ jsonrpc: "2.0", id, result }); +} + +function sendError(id, code, message) { + send({ jsonrpc: "2.0", id, error: { code, message } }); +} + +function handleMessage(msg) { + if (msg.method === "initialize") { + sendResult(msg.id, { + protocolVersion: "2024-11-05", + capabilities: { tools: {} }, + serverInfo: { name: SERVER_NAME, version: SERVER_VERSION }, + }); + } else if (msg.method === "notifications/initialized") { + // no-op + } else if (msg.method === "tools/list") { + sendResult(msg.id, { tools: TOOLS }); + } else if (msg.method === "tools/call") { + handleToolCall(msg); + } else if (msg.method === "ping") { + sendResult(msg.id, {}); + } else if (msg.id) { + sendError(msg.id, -32601, `Unknown method: ${msg.method}`); + } +} + +function handleToolCall(msg) { + const { name, arguments: args } = msg.params; + + try { + let result; + switch (name) { + case "classify_task": + result = classifyTask(args.task); + break; + case "plan_delegation": + result = { + plan: generateDelegationPlan(args.task, args.target_model), + target_model: args.target_model, + }; + break; + case "review_output": + result = { + review_prompt: generateReviewPrompt( + args.original_task, + args.executor_output + ), + }; + break; + case "estimate_cost": + result = estimateCost(args.estimated_tokens); + break; + default: + sendError(msg.id, -32602, `Unknown tool: ${name}`); + return; + } + sendResult(msg.id, { + content: [{ type: "text", text: JSON.stringify(result, null, 2) }], + }); + } catch (err) { + sendError(msg.id, -32603, err.message); + } +} + +process.stderr.write(`[${SERVER_NAME}] MCP server running on stdio\n`); diff --git a/cartridges/cross-cutting/build/bsp-mcp/README.adoc b/cartridges/cross-cutting/build/bsp-mcp/README.adoc new file mode 100644 index 0000000..b50ed2e --- /dev/null +++ b/cartridges/cross-cutting/build/bsp-mcp/README.adoc @@ -0,0 +1,63 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) += bsp-mcp +Jonathan D.A. Jewell +:spdx: MPL-2.0 +:tier: Ayo +:domain: Language Tools +:protocols: MCP, REST + +== Overview + +Generic Build Server Protocol (BSP 2.x) gateway. Spawns any BSP server as a persistent subprocess and exposes build lifecycle operations (initialize, targets, compile, test, run, clean, diagnostics) as MCP tools. + +== Tools (9) + +[cols="2,4"] +|=== +| Tool | Description + +| `bsp_start` | Start a BSP server subprocess and return a session ID. The server stays alive until bsp_stop is called. +| `bsp_initialize` | Send the BSP build/initialize handshake and return the server's capabilities. Must be called once after bsp_start. +| `bsp_targets` | List all build targets in the workspace (workspace/buildTargets). Returns targets with their IDs, language IDs, and capabilities. +| `bsp_compile` | Compile one or more build targets (buildTarget/compile). Returns a compile result with diagnostics. +| `bsp_test` | Run tests for one or more build targets (buildTarget/test). Returns test results. +| `bsp_run` | Run a build target (buildTarget/run). Returns the run result and captured output. +| `bsp_clean` | Clean the build cache for targets or the entire workspace (workspace/cleanCache). Returns cleanup statistics. +| `bsp_diagnostics` | Return the latest build diagnostics published by the server (from build/publishDiagnostics notifications). Aggregated across all targets. +| `bsp_stop` | Send the BSP build/shutdown + build/exit sequence and terminate the server subprocess. The session ID is invalidated. +|=== + +== Architecture + +Three-layer stack per the BoJ cartridge standard: + +[cols="1,1,3"] +|=== +| Layer | Language | Purpose + +| ABI | Idris2 | Formally verified state machine with dependent-type proofs +| FFI | Zig | C-ABI implementation: session pool, action metrics, thread safety +| Adapter| Zig | Adapter bridge to the BoJ unified adapter protocol (zig predecessors sidelined) +|=== + +== Building + +[source,bash] +---- +# ABI (Idris2) +cd abi && idris2 --check *.idr + +# FFI (Zig) +cd ffi && zig build +cd ffi && zig build test + +# Adapter (Zig) +cd adapter && zig build +---- + +== Status + +Alpha (CRG D). Cartridge metadata declared in `cartridge.json`; 9 tool(s) exposed. +Not yet dogfooded externally. See `docs/READINESS.md` at repo root for the +project-wide grade table and `docs/practice/DOGFOOD-LOG.adoc` for usage evidence. diff --git a/cartridges/cross-cutting/build/bsp-mcp/abi/BspMcp/SafeBsp.idr b/cartridges/cross-cutting/build/bsp-mcp/abi/BspMcp/SafeBsp.idr new file mode 100644 index 0000000..c4a24d3 --- /dev/null +++ b/cartridges/cross-cutting/build/bsp-mcp/abi/BspMcp/SafeBsp.idr @@ -0,0 +1,214 @@ +-- SPDX-License-Identifier: MPL-2.0 +-- Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +||| BspMcp.SafeBsp: Formally verified Build Server Protocol operations. +||| +||| Cartridge: bsp-mcp +||| Matrix cell: BSP protocol column +||| +||| Models the BSP server lifecycle with type-level guarantees that: +||| - Build requests can only be sent to an initialized server +||| - Compile/test/run tasks follow proper lifecycle +||| - Source roots must be registered before build operations +module BspMcp.SafeBsp + +%default total + +-- ═══════════════════════════════════════════════════════════════════════════ +-- BSP Server Lifecycle State Machine +-- ═══════════════════════════════════════════════════════════════════════════ + +||| BSP server lifecycle states per BSP specification 2.1. +public export +data BspState + = Uninitialized -- Before InitializeBuild request + | Initializing -- InitializeBuild sent, awaiting response + | Ready -- Fully operational, can accept build requests + | Building -- Build task in progress + | ShuttingDown -- BuildShutdown requested + | Exited -- BuildExit sent + +||| Equality for BSP states. +public export +Eq BspState where + Uninitialized == Uninitialized = True + Initializing == Initializing = True + Ready == Ready = True + Building == Building = True + ShuttingDown == ShuttingDown = True + Exited == Exited = True + _ == _ = False + +||| Valid state transitions. +public export +canTransition : BspState -> BspState -> Bool +canTransition Uninitialized Initializing = True +canTransition Initializing Ready = True +canTransition Ready Building = True +canTransition Building Ready = True -- build complete +canTransition Ready ShuttingDown = True +canTransition Building ShuttingDown = True -- cancel build +canTransition ShuttingDown Exited = True +canTransition Initializing Exited = True -- init failure +canTransition _ _ = False + +-- ═══════════════════════════════════════════════════════════════════════════ +-- BSP Types +-- ═══════════════════════════════════════════════════════════════════════════ + +||| BSP build target kind. +public export +data BuildTargetKind + = BTLibrary -- Library target + | BTApplication -- Executable application + | BTTest -- Test suite + | BTBenchmark -- Benchmark target + | BTIntegrationTest -- Integration test + | BTDocumentation -- Documentation generation + +||| C-ABI encoding. +public export +buildTargetKindToInt : BuildTargetKind -> Int +buildTargetKindToInt BTLibrary = 1 +buildTargetKindToInt BTApplication = 2 +buildTargetKindToInt BTTest = 3 +buildTargetKindToInt BTBenchmark = 4 +buildTargetKindToInt BTIntegrationTest = 5 +buildTargetKindToInt BTDocumentation = 6 + +||| Task status (for build, test, run). +public export +data TaskStatus + = TaskQueued -- Waiting to start + | TaskStarted -- In progress + | TaskFinished -- Completed successfully + | TaskCancelled -- Cancelled by user + | TaskFailed -- Completed with errors + +||| C-ABI encoding. +public export +taskStatusToInt : TaskStatus -> Int +taskStatusToInt TaskQueued = 1 +taskStatusToInt TaskStarted = 2 +taskStatusToInt TaskFinished = 3 +taskStatusToInt TaskCancelled = 4 +taskStatusToInt TaskFailed = 5 + +||| Diagnostic severity (BSP uses same as LSP). +public export +data BspDiagnosticSeverity = BspError | BspWarning | BspInfo | BspHint + +||| C-ABI encoding. +public export +bspSeverityToInt : BspDiagnosticSeverity -> Int +bspSeverityToInt BspError = 1 +bspSeverityToInt BspWarning = 2 +bspSeverityToInt BspInfo = 3 +bspSeverityToInt BspHint = 4 + +-- ═══════════════════════════════════════════════════════════════════════════ +-- BSP Capabilities +-- ═══════════════════════════════════════════════════════════════════════════ + +||| Server capabilities negotiated during initialization. +public export +data BspCapability + = CanCompile -- buildTarget/compile + | CanTest -- buildTarget/test + | CanRun -- buildTarget/run + | CanDebug -- buildTarget/debugSession + | CanCleanCache -- buildTarget/cleanCache + | CanDependencySources -- buildTarget/dependencySources + | CanResources -- buildTarget/resources + | CanOutputPaths -- buildTarget/outputPaths + | CanJvmTestEnv -- buildTarget/jvmTestEnvironment + +||| C-ABI encoding. +public export +bspCapabilityToInt : BspCapability -> Int +bspCapabilityToInt CanCompile = 1 +bspCapabilityToInt CanTest = 2 +bspCapabilityToInt CanRun = 3 +bspCapabilityToInt CanDebug = 4 +bspCapabilityToInt CanCleanCache = 5 +bspCapabilityToInt CanDependencySources = 6 +bspCapabilityToInt CanResources = 7 +bspCapabilityToInt CanOutputPaths = 8 +bspCapabilityToInt CanJvmTestEnv = 9 + +-- ═══════════════════════════════════════════════════════════════════════════ +-- State Machine Proofs +-- ═══════════════════════════════════════════════════════════════════════════ + +||| Proof that the BSP lifecycle is well-formed. +public export +data BspLifecycleWellFormed : Type where + MkWellFormed : + (initOk : canTransition Uninitialized Initializing = True) -> + (readyOk : canTransition Initializing Ready = True) -> + (buildOk : canTransition Ready Building = True) -> + (doneOk : canTransition Building Ready = True) -> + (shutOk : canTransition Ready ShuttingDown = True) -> + (exitOk : canTransition ShuttingDown Exited = True) -> + (cancelOk : canTransition Building ShuttingDown = True) -> + BspLifecycleWellFormed + +||| Witness: the BSP lifecycle is well-formed. +public export +bspLifecycleOk : BspLifecycleWellFormed +bspLifecycleOk = MkWellFormed Refl Refl Refl Refl Refl Refl Refl + +-- ═══════════════════════════════════════════════════════════════════════════ +-- Safety Predicates +-- ═══════════════════════════════════════════════════════════════════════════ + +||| Can we submit build requests in this state? +public export +canBuild : BspState -> Bool +canBuild Ready = True +canBuild _ = False + +||| Is a build in progress? +public export +isBuildActive : BspState -> Bool +isBuildActive Building = True +isBuildActive _ = False + +-- ═══════════════════════════════════════════════════════════════════════════ +-- C-ABI Exports +-- ═══════════════════════════════════════════════════════════════════════════ + +||| BSP state to integer. +public export +bspStateToInt : BspState -> Int +bspStateToInt Uninitialized = 0 +bspStateToInt Initializing = 1 +bspStateToInt Ready = 2 +bspStateToInt Building = 3 +bspStateToInt ShuttingDown = 4 +bspStateToInt Exited = 5 + +||| FFI: Validate a state transition. +export +bsp_can_transition : Int -> Int -> Int +bsp_can_transition from to = + let fromState = case from of + 0 => Uninitialized + 1 => Initializing + 2 => Ready + 3 => Building + 4 => ShuttingDown + _ => Exited + toState = case to of + 0 => Uninitialized + 1 => Initializing + 2 => Ready + 3 => Building + 4 => ShuttingDown + _ => Exited + in if canTransition fromState toState then 1 else 0 + +||| FFI: Can submit build requests? +export +bsp_can_build : Int -> Int +bsp_can_build 2 = 1 -- Ready +bsp_can_build _ = 0 diff --git a/cartridges/cross-cutting/build/bsp-mcp/abi/README.adoc b/cartridges/cross-cutting/build/bsp-mcp/abi/README.adoc new file mode 100644 index 0000000..2dedc6a --- /dev/null +++ b/cartridges/cross-cutting/build/bsp-mcp/abi/README.adoc @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) += bsp-mcp / abi β€” Idris2 ABI layer +:orientation: shallow + +== Purpose + +Cartridge-local protocol types for `bsp-mcp`. Defines the data, tool, and +transition vocabulary this cartridge speaks. Inherits safety lemmas and the +matrix-cell identifier scheme from the trunk ABI at `src/abi/`. + +== Files + +1 Idris2 module(s), ~214 lines total. Lead module: +`SafeBsp.idr`. + +No `.ipkg` β€” consumed directly by wider BoJ callers; type-checks inside the +trunk `boj.ipkg`. + +== Invariants + +* `%default total` at every module top. +* Zero `believe_me`, `assert_total`, `postulate`, `Admitted`, or + `sorry` (the trunk holds the four declared `believe_me` sites; leaf + cartridges carry none). + +== Test/proof surface + +Thin module(s), no inline tests. Proof surface = type-check success under the +trunk `idris2 --check`. + +== Read-first + +. `SafeBsp.idr` β€” cartridge-local data records and enums. +. Trunk `src/abi/Boj/Catalogue.idr` for how this cell participates in the + 2D matrix. diff --git a/cartridges/cross-cutting/build/bsp-mcp/abi/bsp-mcp.ipkg b/cartridges/cross-cutting/build/bsp-mcp/abi/bsp-mcp.ipkg new file mode 100644 index 0000000..86d783e --- /dev/null +++ b/cartridges/cross-cutting/build/bsp-mcp/abi/bsp-mcp.ipkg @@ -0,0 +1,11 @@ +-- SPDX-License-Identifier: MPL-2.0 +package bspmcp + +authors = "Jonathan D.A. Jewell" +version = 0.1.0 +license = "MPL-2.0" +brief = "BSP MCP cartridge β€” Build Server Protocol lifecycle with formal state guarantees" + +sourcedir = "." +modules = BspMcp.SafeBsp +depends = base, contrib diff --git a/cartridges/cross-cutting/build/bsp-mcp/adapter/README.adoc b/cartridges/cross-cutting/build/bsp-mcp/adapter/README.adoc new file mode 100644 index 0000000..3fc88c4 --- /dev/null +++ b/cartridges/cross-cutting/build/bsp-mcp/adapter/README.adoc @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) += bsp-mcp / adapter β€” protocol bridge +:orientation: shallow + +== Purpose + +Exposes this cartridge's FFI over the wire protocols declared in +`cartridge.json`. Dispatches tool invocations to the C-ABI exports at +`../ffi/` and formats responses per protocol. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” depends on the sibling `ffi` target. +| `bsp_adapter.zig` | Protocol dispatch (414 lines). Tool catalogue matches `cartridge.json`. +|=== + +== Invariants + +* **Stateless** β€” all state lives behind the FFI, never in the adapter. +* **Response passthrough** β€” whatever the FFI returns goes back to the wire + unmodified (no embellishment, no silent recovery). +* **Cartridge.json is source of truth** for the tool catalogue; drift between + adapter and manifest is a CI failure. + +== Test/proof surface + +No inline tests at the adapter layer β€” protocol routing is exercised by the +trunk seam tests at `ffi/zig/src/seams.zig`. + +== Read-first + +. `bsp_adapter.zig` β€” dispatch function and per-protocol routers. +. `../cartridge.json` β€” the tool manifest the dispatcher mirrors. diff --git a/cartridges/cross-cutting/build/bsp-mcp/adapter/bsp_adapter.zig b/cartridges/cross-cutting/build/bsp-mcp/adapter/bsp_adapter.zig new file mode 100644 index 0000000..140e941 --- /dev/null +++ b/cartridges/cross-cutting/build/bsp-mcp/adapter/bsp_adapter.zig @@ -0,0 +1,414 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +// +// BSP-MCP Cartridge β€” Unified Zig adapter. +// Replaces the banned bsp_adapter.v (zig removed 2026-04-10). +// +// Exposes the BSP session state machine from bsp_ffi.zig via three protocols: +// REST port 9025 HTTP/1.1, JSON responses +// gRPC-compat port 9026 HTTP/1.1 + proto-style paths, JSON bodies +// GraphQL port 9027 HTTP/1.1 POST /graphql, keyword dispatch +// +// Build: zig build (build.zig in this directory) + +const std = @import("std"); +const ffi = @import("bsp_ffi"); + +// ═══════════════════════════════════════════════════════════════════════════ +// Constants +// ═══════════════════════════════════════════════════════════════════════════ + +const VERSION = "0.1.0"; +const CARTRIDGE = "bsp-mcp"; +const REST_PORT: u16 = 9025; +const GRPC_PORT: u16 = 9026; +const GQL_PORT: u16 = 9027; + +const STATE_LABELS = [_][]const u8{ + "uninitialized", "initializing", "ready", "building", "shutting_down", "exited", +}; + +/// Capability labels, 1-indexed (slot 0 unused). +const CAP_LABELS = [_][]const u8{ + "", + "compile", "test", "run", "debug", "clean_cache", + "dependency_sources", "resources", "output_paths", "jvm_test_env", +}; + +const TARGET_KIND_LABELS = [_][]const u8{ + "", + "library", "application", "test_target", "benchmark", + "integration_test", "documentation", +}; + +const GRPC_PROTO = + \\syntax = "proto3"; + \\package bsp_mcp; + \\ + \\service BspService { + \\ rpc CreateSession (Empty) returns (SessionResponse); + \\ rpc Initialize (SlotRequest) returns (SessionResponse); + \\ rpc RegisterCapability (CapabilityRequest) returns (SessionResponse); + \\ rpc Ready (SlotRequest) returns (SessionResponse); + \\ rpc Build (SlotRequest) returns (SessionResponse); + \\ rpc BuildDone (SlotRequest) returns (SessionResponse); + \\ rpc AddTarget (TargetRequest) returns (SessionResponse); + \\ rpc Shutdown (SlotRequest) returns (SessionResponse); + \\ rpc Exit (SlotRequest) returns (SessionResponse); + \\ rpc GetState (SlotRequest) returns (SessionResponse); + \\ rpc ReleaseSession (SlotRequest) returns (ReleaseResponse); + \\ rpc Health (Empty) returns (HealthResponse); + \\ rpc Types (Empty) returns (TypeInfoResponse); + \\} + \\ + \\message Empty {} + \\message SlotRequest { int32 slot = 1; } + \\message CapabilityRequest { int32 slot = 1; int32 capability = 2; } + \\message TargetRequest { int32 slot = 1; int32 kind = 2; } + \\message SessionResponse { int32 slot = 1; string state = 2; bool can_build = 3; bool is_building = 4; repeated string capabilities = 5; } + \\message ReleaseResponse { int32 slot = 1; bool released = 2; } + \\message HealthResponse { string status = 1; string cartridge = 2; string version = 3; } + \\message TypeInfoResponse { repeated string states = 1; repeated string capabilities = 2; repeated string target_kinds = 3; } +; + +const GRAPHQL_SCHEMA = + \\type Query { + \\ health: Health! + \\ session(slot: Int!): Session + \\ types: TypeInfo! + \\} + \\type Mutation { + \\ createSession: Session! + \\ initialize(slot: Int!): Session! + \\ registerCapability(slot: Int!, capability: Int!): Session! + \\ ready(slot: Int!): Session! + \\ build(slot: Int!): Session! + \\ buildDone(slot: Int!): Session! + \\ addTarget(slot: Int!, kind: Int!): Session! + \\ shutdown(slot: Int!): Session! + \\ exit(slot: Int!): Session! + \\ releaseSession(slot: Int!): ReleaseResult! + \\} + \\type Health { status: String! cartridge: String! version: String! } + \\type Session { slot: Int! state: String! canBuild: Boolean! isBuilding: Boolean! capabilities: [String!]! } + \\type ReleaseResult { slot: Int! released: Boolean! } + \\type TypeInfo { states: [String!]! capabilities: [String!]! targetKinds: [String!]! } +; + +// ═══════════════════════════════════════════════════════════════════════════ +// JSON helpers +// ═══════════════════════════════════════════════════════════════════════════ + +fn stateLabel(s: c_int) []const u8 { + if (s >= 0 and s < @as(c_int, STATE_LABELS.len)) return STATE_LABELS[@intCast(s)]; + return "unknown"; +} + +fn capabilitiesJson(slot: c_int, buf: []u8) []const u8 { + var pos: usize = 0; + buf[pos] = '['; pos += 1; + var first = true; + var cap: c_int = 1; + while (cap <= 9) : (cap += 1) { + if (ffi.bsp_has_capability(slot, cap) == 1) { + if (!first) { buf[pos] = ','; pos += 1; } + buf[pos] = '"'; pos += 1; + const label = CAP_LABELS[@intCast(cap)]; + @memcpy(buf[pos..][0..label.len], label); + pos += label.len; + buf[pos] = '"'; pos += 1; + first = false; + } + } + buf[pos] = ']'; pos += 1; + return buf[0..pos]; +} + +fn sessionJson(slot: c_int, resp: []u8, cap: []u8) []const u8 { + const state = ffi.bsp_state(slot); + const can_build = ffi.bsp_can_build(slot); + const is_building = ffi.bsp_is_building(slot); + const caps = capabilitiesJson(slot, cap); + return std.fmt.bufPrint(resp, + \\{{"slot":{d},"state":"{s}","can_build":{s},"is_building":{s},"capabilities":{s}}} + , .{ + slot, + stateLabel(state), + if (can_build == 1) "true" else "false", + if (is_building == 1) "true" else "false", + caps, + }) catch resp[0..0]; +} + +fn errorJson(buf: []u8, msg: []const u8, code: c_int) []const u8 { + return std.fmt.bufPrint(buf, \\{{"error":"{s}","code":{d}}}, .{ msg, code }) catch buf[0..0]; +} + +fn healthJson(buf: []u8) []const u8 { + return std.fmt.bufPrint(buf, \\{{"status":"ok","cartridge":"{s}","version":"{s}"}}, .{ CARTRIDGE, VERSION }) catch buf[0..0]; +} + +const TYPES_JSON = + \\{"states":["uninitialized","initializing","ready","building","shutting_down","exited"], + \\"capabilities":["compile","test","run","debug","clean_cache","dependency_sources","resources","output_paths","jvm_test_env"], + \\"target_kinds":["library","application","test_target","benchmark","integration_test","documentation"]} +; + +// ═══════════════════════════════════════════════════════════════════════════ +// JSON body parsers +// ═══════════════════════════════════════════════════════════════════════════ + +fn parseIntField(body: []const u8, field: []const u8) ?c_int { + var buf: [32]u8 = undefined; + const needle = std.fmt.bufPrint(&buf, "\"{s}\":", .{field}) catch return null; + const idx = std.mem.indexOf(u8, body, needle) orelse return null; + const rest = std.mem.trimLeft(u8, body[idx + needle.len ..], " \t\r\n"); + var end: usize = 0; + while (end < rest.len and rest[end] >= '0' and rest[end] <= '9') : (end += 1) {} + if (end == 0) return null; + return @intCast(std.fmt.parseInt(i32, rest[0..end], 10) catch return null); +} + +fn pathSlot(target: []const u8) ?c_int { + var it = std.mem.splitBackwardsScalar(u8, target, '/'); + while (it.next()) |seg| { + if (seg.len == 0) continue; + var all_digits = true; + for (seg) |c| { if (c < '0' or c > '9') { all_digits = false; break; } } + if (all_digits) return @intCast(std.fmt.parseInt(i32, seg, 10) catch return null); + } + return null; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Response type +// ═══════════════════════════════════════════════════════════════════════════ + +const Response = struct { + status: std.http.Status, + body: []const u8, + content_type: []const u8 = "application/json", +}; + +// ═══════════════════════════════════════════════════════════════════════════ +// REST dispatch +// ═══════════════════════════════════════════════════════════════════════════ + +fn dispatchRest(method: std.http.Method, target: []const u8, body: []const u8, resp: []u8, cap: []u8) Response { + const ok = std.http.Status.ok; + const bad = std.http.Status.bad_request; + + if (method == .GET and std.mem.eql(u8, target, "/health")) return .{ .status = ok, .body = healthJson(resp) }; + if (method == .GET and std.mem.eql(u8, target, "/types")) return .{ .status = ok, .body = TYPES_JSON }; + if (method == .POST and std.mem.eql(u8, target, "/sessions")) { + const slot = ffi.bsp_init(); + if (slot < 0) return .{ .status = bad, .body = errorJson(resp, "no slots available", slot) }; + return .{ .status = ok, .body = sessionJson(slot, resp, cap) }; + } + + if (std.mem.startsWith(u8, target, "/sessions/")) { + const slot_opt = pathSlot(std.mem.trimRight(u8, target, "/")); + + inline for (.{ + .{ "/state", .GET, null }, + }) |entry| { + _ = entry; + } + + if (method == .GET and std.mem.endsWith(u8, target, "/state")) { const slot = slot_opt orelse return .{ .status = bad, .body = errorJson(resp, "missing slot", -1) }; return .{ .status = ok, .body = sessionJson(slot, resp, cap) }; } + if (method == .POST and std.mem.endsWith(u8, target, "/initialize")) { const slot = slot_opt orelse return .{ .status = bad, .body = errorJson(resp, "missing slot", -1) }; const r = ffi.bsp_start_init(slot); if (r < 0) return .{ .status = bad, .body = errorJson(resp, "invalid transition", r) }; return .{ .status = ok, .body = sessionJson(slot, resp, cap) }; } + if (method == .POST and std.mem.endsWith(u8, target, "/ready")) { const slot = slot_opt orelse return .{ .status = bad, .body = errorJson(resp, "missing slot", -1) }; const r = ffi.bsp_ready(slot); if (r < 0) return .{ .status = bad, .body = errorJson(resp, "invalid transition", r) }; return .{ .status = ok, .body = sessionJson(slot, resp, cap) }; } + if (method == .POST and std.mem.endsWith(u8, target, "/build")) { const slot = slot_opt orelse return .{ .status = bad, .body = errorJson(resp, "missing slot", -1) }; const r = ffi.bsp_build(slot); if (r < 0) return .{ .status = bad, .body = errorJson(resp, "invalid transition", r) }; return .{ .status = ok, .body = sessionJson(slot, resp, cap) }; } + if (method == .POST and std.mem.endsWith(u8, target, "/build-done")) { const slot = slot_opt orelse return .{ .status = bad, .body = errorJson(resp, "missing slot", -1) }; const r = ffi.bsp_build_done(slot); if (r < 0) return .{ .status = bad, .body = errorJson(resp, "invalid transition", r) }; return .{ .status = ok, .body = sessionJson(slot, resp, cap) }; } + if (method == .POST and std.mem.endsWith(u8, target, "/shutdown")) { const slot = slot_opt orelse return .{ .status = bad, .body = errorJson(resp, "missing slot", -1) }; const r = ffi.bsp_shutdown(slot); if (r < 0) return .{ .status = bad, .body = errorJson(resp, "invalid transition", r) }; return .{ .status = ok, .body = sessionJson(slot, resp, cap) }; } + if (method == .POST and std.mem.endsWith(u8, target, "/exit")) { const slot = slot_opt orelse return .{ .status = bad, .body = errorJson(resp, "missing slot", -1) }; const r = ffi.bsp_exit(slot); if (r < 0) return .{ .status = bad, .body = errorJson(resp, "invalid transition", r) }; return .{ .status = ok, .body = sessionJson(slot, resp, cap) }; } + + if (method == .POST and std.mem.endsWith(u8, target, "/capability")) { + const slot = slot_opt orelse return .{ .status = bad, .body = errorJson(resp, "missing slot", -1) }; + const c = parseIntField(body, "capability") orelse return .{ .status = bad, .body = errorJson(resp, "missing capability", -1) }; + const r = ffi.bsp_register_capability(slot, c); + if (r < 0) return .{ .status = bad, .body = errorJson(resp, "invalid capability or state", r) }; + return .{ .status = ok, .body = sessionJson(slot, resp, cap) }; + } + if (method == .POST and std.mem.endsWith(u8, target, "/target")) { + const slot = slot_opt orelse return .{ .status = bad, .body = errorJson(resp, "missing slot", -1) }; + const kind = parseIntField(body, "kind") orelse return .{ .status = bad, .body = errorJson(resp, "missing kind", -1) }; + const r = ffi.bsp_add_target(slot, kind); + if (r < 0) return .{ .status = bad, .body = errorJson(resp, "cannot add target (wrong state or limit reached)", r) }; + return .{ .status = ok, .body = sessionJson(slot, resp, cap) }; + } + if (method == .DELETE) { + const slot = slot_opt orelse return .{ .status = bad, .body = errorJson(resp, "missing slot", -1) }; + const r = ffi.bsp_release(slot); + if (r < 0) return .{ .status = bad, .body = errorJson(resp, "release failed", r) }; + return .{ .status = ok, .body = std.fmt.bufPrint(resp, \\{{"slot":{d},"released":true}}, .{slot}) catch resp[0..0] }; + } + } + + return .{ .status = std.http.Status.not_found, .body = errorJson(resp, "not found", -404) }; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// gRPC-compat dispatch +// ═══════════════════════════════════════════════════════════════════════════ + +const GRPC_PREFIX = "/bsp_mcp.BspService/"; + +fn dispatchGrpc(target: []const u8, body: []const u8, resp: []u8, cap: []u8) Response { + const ok = std.http.Status.ok; + const bad = std.http.Status.bad_request; + + if (std.mem.eql(u8, target, "/proto")) return .{ .status = ok, .body = GRPC_PROTO, .content_type = "text/plain" }; + if (!std.mem.startsWith(u8, target, GRPC_PREFIX)) return .{ .status = std.http.Status.not_found, .body = errorJson(resp, "unknown gRPC path", -1) }; + + const rpc = target[GRPC_PREFIX.len..]; + + if (std.mem.eql(u8, rpc, "Health")) return .{ .status = ok, .body = healthJson(resp) }; + if (std.mem.eql(u8, rpc, "Types")) return .{ .status = ok, .body = TYPES_JSON }; + if (std.mem.eql(u8, rpc, "CreateSession")) { + const slot = ffi.bsp_init(); + if (slot < 0) return .{ .status = bad, .body = errorJson(resp, "no slots available", slot) }; + return .{ .status = ok, .body = sessionJson(slot, resp, cap) }; + } + + const slot = parseIntField(body, "slot") orelse return .{ .status = bad, .body = errorJson(resp, "missing slot", -1) }; + + if (std.mem.eql(u8, rpc, "GetState")) return .{ .status = ok, .body = sessionJson(slot, resp, cap) }; + if (std.mem.eql(u8, rpc, "Initialize")) { const r = ffi.bsp_start_init(slot); if (r < 0) return .{ .status = bad, .body = errorJson(resp, "invalid transition", r) }; return .{ .status = ok, .body = sessionJson(slot, resp, cap) }; } + if (std.mem.eql(u8, rpc, "Ready")) { const r = ffi.bsp_ready(slot); if (r < 0) return .{ .status = bad, .body = errorJson(resp, "invalid transition", r) }; return .{ .status = ok, .body = sessionJson(slot, resp, cap) }; } + if (std.mem.eql(u8, rpc, "Build")) { const r = ffi.bsp_build(slot); if (r < 0) return .{ .status = bad, .body = errorJson(resp, "invalid transition", r) }; return .{ .status = ok, .body = sessionJson(slot, resp, cap) }; } + if (std.mem.eql(u8, rpc, "BuildDone")) { const r = ffi.bsp_build_done(slot); if (r < 0) return .{ .status = bad, .body = errorJson(resp, "invalid transition", r) }; return .{ .status = ok, .body = sessionJson(slot, resp, cap) }; } + if (std.mem.eql(u8, rpc, "Shutdown")) { const r = ffi.bsp_shutdown(slot); if (r < 0) return .{ .status = bad, .body = errorJson(resp, "invalid transition", r) }; return .{ .status = ok, .body = sessionJson(slot, resp, cap) }; } + if (std.mem.eql(u8, rpc, "Exit")) { const r = ffi.bsp_exit(slot); if (r < 0) return .{ .status = bad, .body = errorJson(resp, "invalid transition", r) }; return .{ .status = ok, .body = sessionJson(slot, resp, cap) }; } + + if (std.mem.eql(u8, rpc, "RegisterCapability")) { + const c = parseIntField(body, "capability") orelse return .{ .status = bad, .body = errorJson(resp, "missing capability", -1) }; + const r = ffi.bsp_register_capability(slot, c); + if (r < 0) return .{ .status = bad, .body = errorJson(resp, "invalid capability or state", r) }; + return .{ .status = ok, .body = sessionJson(slot, resp, cap) }; + } + if (std.mem.eql(u8, rpc, "AddTarget")) { + const kind = parseIntField(body, "kind") orelse return .{ .status = bad, .body = errorJson(resp, "missing kind", -1) }; + const r = ffi.bsp_add_target(slot, kind); + if (r < 0) return .{ .status = bad, .body = errorJson(resp, "cannot add target", r) }; + return .{ .status = ok, .body = sessionJson(slot, resp, cap) }; + } + if (std.mem.eql(u8, rpc, "ReleaseSession")) { + const r = ffi.bsp_release(slot); + if (r < 0) return .{ .status = bad, .body = errorJson(resp, "release failed", r) }; + return .{ .status = ok, .body = std.fmt.bufPrint(resp, \\{{"slot":{d},"released":true}}, .{slot}) catch resp[0..0] }; + } + + return .{ .status = std.http.Status.not_found, .body = errorJson(resp, "unknown gRPC method", -1) }; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// GraphQL dispatch +// ═══════════════════════════════════════════════════════════════════════════ + +fn dispatchGraphql(q: []const u8, resp: []u8, cap: []u8) Response { + const ok = std.http.Status.ok; + const bad = std.http.Status.bad_request; + const has = std.mem.indexOf; + + if (has(u8, q, "__schema") != null) return .{ .status = ok, .body = std.fmt.bufPrint(resp, \\{{"data":{{"__schema":{{"sdl":"{s}"}}}}}}, .{GRAPHQL_SCHEMA}) catch resp[0..0] }; + if (has(u8, q, "health") != null and has(u8, q, "mutation") == null) return .{ .status = ok, .body = std.fmt.bufPrint(resp, \\{{"data":{{"health":{s}}}}}, .{healthJson(resp)}) catch resp[0..0] }; + if (has(u8, q, "types") != null and has(u8, q, "mutation") == null) return .{ .status = ok, .body = std.fmt.bufPrint(resp, \\{{"data":{{"types":{s}}}}}, .{TYPES_JSON}) catch resp[0..0] }; + if (has(u8, q, "createSession") != null) { + const slot = ffi.bsp_init(); + if (slot < 0) return .{ .status = bad, .body = errorJson(resp, "no slots available", slot) }; + return .{ .status = ok, .body = std.fmt.bufPrint(resp, \\{{"data":{{"createSession":{s}}}}}, .{sessionJson(slot, resp, cap)}) catch resp[0..0] }; + } + + const slot = parseIntField(q, "slot") orelse return .{ .status = bad, .body = errorJson(resp, "could not determine slot", -1) }; + + if (has(u8, q, "registerCapability") != null) { const c = parseIntField(q, "capability") orelse return .{ .status = bad, .body = errorJson(resp, "missing capability", -1) }; const r = ffi.bsp_register_capability(slot, c); if (r < 0) return .{ .status = bad, .body = errorJson(resp, "invalid capability or state", r) }; return .{ .status = ok, .body = std.fmt.bufPrint(resp, \\{{"data":{{"registerCapability":{s}}}}}, .{sessionJson(slot, resp, cap)}) catch resp[0..0] }; } + if (has(u8, q, "addTarget") != null) { const kind = parseIntField(q, "kind") orelse return .{ .status = bad, .body = errorJson(resp, "missing kind", -1) }; const r = ffi.bsp_add_target(slot, kind); if (r < 0) return .{ .status = bad, .body = errorJson(resp, "cannot add target", r) }; return .{ .status = ok, .body = std.fmt.bufPrint(resp, \\{{"data":{{"addTarget":{s}}}}}, .{sessionJson(slot, resp, cap)}) catch resp[0..0] }; } + if (has(u8, q, "initialize") != null) { const r = ffi.bsp_start_init(slot); if (r < 0) return .{ .status = bad, .body = errorJson(resp, "invalid transition", r) }; return .{ .status = ok, .body = std.fmt.bufPrint(resp, \\{{"data":{{"initialize":{s}}}}}, .{sessionJson(slot, resp, cap)}) catch resp[0..0] }; } + if (has(u8, q, "buildDone") != null) { const r = ffi.bsp_build_done(slot); if (r < 0) return .{ .status = bad, .body = errorJson(resp, "invalid transition", r) }; return .{ .status = ok, .body = std.fmt.bufPrint(resp, \\{{"data":{{"buildDone":{s}}}}}, .{sessionJson(slot, resp, cap)}) catch resp[0..0] }; } + if (has(u8, q, "shutdown") != null) { const r = ffi.bsp_shutdown(slot); if (r < 0) return .{ .status = bad, .body = errorJson(resp, "invalid transition", r) }; return .{ .status = ok, .body = std.fmt.bufPrint(resp, \\{{"data":{{"shutdown":{s}}}}}, .{sessionJson(slot, resp, cap)}) catch resp[0..0] }; } + if (has(u8, q, "exit") != null) { const r = ffi.bsp_exit(slot); if (r < 0) return .{ .status = bad, .body = errorJson(resp, "invalid transition", r) }; return .{ .status = ok, .body = std.fmt.bufPrint(resp, \\{{"data":{{"exit":{s}}}}}, .{sessionJson(slot, resp, cap)}) catch resp[0..0] }; } + if (has(u8, q, "ready") != null) { const r = ffi.bsp_ready(slot); if (r < 0) return .{ .status = bad, .body = errorJson(resp, "invalid transition", r) }; return .{ .status = ok, .body = std.fmt.bufPrint(resp, \\{{"data":{{"ready":{s}}}}}, .{sessionJson(slot, resp, cap)}) catch resp[0..0] }; } + if (has(u8, q, "build") != null) { const r = ffi.bsp_build(slot); if (r < 0) return .{ .status = bad, .body = errorJson(resp, "invalid transition", r) }; return .{ .status = ok, .body = std.fmt.bufPrint(resp, \\{{"data":{{"build":{s}}}}}, .{sessionJson(slot, resp, cap)}) catch resp[0..0] }; } + if (has(u8, q, "releaseSession") != null) { const r = ffi.bsp_release(slot); if (r < 0) return .{ .status = bad, .body = errorJson(resp, "release failed", r) }; return .{ .status = ok, .body = std.fmt.bufPrint(resp, \\{{"data":{{"releaseSession":{{"slot":{d},"released":true}}}}}}, .{slot}) catch resp[0..0] }; } + if (has(u8, q, "session") != null) return .{ .status = ok, .body = std.fmt.bufPrint(resp, \\{{"data":{{"session":{s}}}}}, .{sessionJson(slot, resp, cap)}) catch resp[0..0] }; + + return .{ .status = bad, .body = errorJson(resp, "unrecognised GraphQL operation", -1) }; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// HTTP connection handler + listener loop +// ═══════════════════════════════════════════════════════════════════════════ + +const Protocol = enum { rest, grpc, graphql }; +const ListenerCtx = struct { listener: *std.net.Server, protocol: Protocol }; + +fn handleConnection(conn: std.net.Server.Connection, protocol: Protocol) void { + defer conn.stream.close(); + var read_buf: [8192]u8 = undefined; + var http_srv = std.http.Server.init(conn, &read_buf); + var request = http_srv.receiveHead() catch return; + + var body_buf: [262144]u8 = undefined; + var body_len: usize = 0; + if (request.head.content_length) |cl| { + const to_read: usize = @min(cl, body_buf.len); + var reader = request.reader() catch return; + body_len = reader.readAll(body_buf[0..to_read]) catch 0; + } + + var resp_buf: [4096]u8 = undefined; + var cap_buf: [512]u8 = undefined; + const body = body_buf[0..body_len]; + + const resp: Response = switch (protocol) { + .rest => dispatchRest(request.head.method, request.head.target, body, &resp_buf, &cap_buf), + .grpc => dispatchGrpc(request.head.target, body, &resp_buf, &cap_buf), + .graphql => blk: { + if (request.head.method == .GET and std.mem.eql(u8, request.head.target, "/graphql/schema")) + break :blk .{ .status = .ok, .body = GRAPHQL_SCHEMA, .content_type = "text/plain" }; + break :blk dispatchGraphql(body, &resp_buf, &cap_buf); + }, + }; + + const ct: std.http.Header = .{ .name = "content-type", .value = resp.content_type }; + const cors: std.http.Header = .{ .name = "access-control-allow-origin", .value = "*" }; + const gs: std.http.Header = .{ .name = "grpc-status", .value = if (resp.status == .ok) "0" else "2" }; + + if (protocol == .grpc) { + request.respond(resp.body, .{ .status = resp.status, .extra_headers = &.{ ct, gs } }) catch {}; + } else { + request.respond(resp.body, .{ .status = resp.status, .extra_headers = &.{ ct, cors } }) catch {}; + } +} + +fn listenLoop(ctx: ListenerCtx) void { + while (true) { + const conn = ctx.listener.accept() catch |err| { + std.log.err("accept error on {s}: {}", .{ @tagName(ctx.protocol), err }); + continue; + }; + handleConnection(conn, ctx.protocol); + } +} + +pub fn main() !void { + _ = ffi.boj_cartridge_init(); + + var rest_listener = try (try std.net.Address.parseIp4("0.0.0.0", REST_PORT)).listen(.{ .reuse_address = true }); + defer rest_listener.deinit(); + var grpc_listener = try (try std.net.Address.parseIp4("0.0.0.0", GRPC_PORT)).listen(.{ .reuse_address = true }); + defer grpc_listener.deinit(); + var gql_listener = try (try std.net.Address.parseIp4("0.0.0.0", GQL_PORT)).listen(.{ .reuse_address = true }); + defer gql_listener.deinit(); + + std.log.info("{s} REST :{d} gRPC :{d} GraphQL :{d}", .{ CARTRIDGE, REST_PORT, GRPC_PORT, GQL_PORT }); + + const t_rest = try std.Thread.spawn(.{}, listenLoop, .{ ListenerCtx{ .listener = &rest_listener, .protocol = .rest } }); + const t_grpc = try std.Thread.spawn(.{}, listenLoop, .{ ListenerCtx{ .listener = &grpc_listener, .protocol = .grpc } }); + const t_gql = try std.Thread.spawn(.{}, listenLoop, .{ ListenerCtx{ .listener = &gql_listener, .protocol = .graphql } }); + + t_rest.join(); + t_grpc.join(); + t_gql.join(); +} diff --git a/cartridges/cross-cutting/build/bsp-mcp/adapter/build.zig b/cartridges/cross-cutting/build/bsp-mcp/adapter/build.zig new file mode 100644 index 0000000..f703745 --- /dev/null +++ b/cartridges/cross-cutting/build/bsp-mcp/adapter/build.zig @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +// +// BSP-MCP Cartridge β€” adapter build configuration (Zig 0.14+). + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const ffi_mod = b.createModule(.{ + .root_source_file = b.path("../ffi/bsp_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + const adapter = b.addExecutable(.{ + .name = "bsp-adapter", + .root_source_file = b.path("bsp_adapter.zig"), + .target = target, + .optimize = optimize, + }); + adapter.root_module.addImport("bsp_ffi", ffi_mod); + b.installArtifact(adapter); + + const run_cmd = b.addRunArtifact(adapter); + const run_step = b.step("run", "Run bsp-adapter (REST :9025, gRPC :9026, GraphQL :9027)"); + run_step.dependOn(&run_cmd.step); + + const tests = b.addTest(.{ + .root_source_file = b.path("bsp_adapter.zig"), + .target = target, + .optimize = optimize, + }); + tests.root_module.addImport("bsp_ffi", ffi_mod); + const run_tests = b.addRunArtifact(tests); + const test_step = b.step("test", "Run adapter unit tests"); + test_step.dependOn(&run_tests.step); +} diff --git a/cartridges/cross-cutting/build/bsp-mcp/cartridge.json b/cartridges/cross-cutting/build/bsp-mcp/cartridge.json new file mode 100644 index 0000000..4891bad --- /dev/null +++ b/cartridges/cross-cutting/build/bsp-mcp/cartridge.json @@ -0,0 +1,259 @@ +{ + "$schema": "https://boj.dev/schemas/cartridge/v1.json", + "spdx": "MPL-2.0", + "copyright": "Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath)", + "name": "bsp-mcp", + "version": "0.1.0", + "description": "Generic Build Server Protocol (BSP 2.x) gateway. Spawns any BSP server as a persistent subprocess and exposes build lifecycle operations (initialize, targets, compile, test, run, clean, diagnostics) as MCP tools.", + "domain": "Language Tools", + "tier": "Ayo", + "protocols": [ + "MCP", + "REST" + ], + "auth": { + "method": "none", + "env_var": null, + "credential_source": null + }, + "api": { + "base_url": "local://bsp-mcp", + "content_type": "application/json" + }, + "tools": [ + { + "name": "bsp_start", + "description": "Start a BSP server subprocess and return a session ID. The server stays alive until bsp_stop is called.", + "inputSchema": { + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "BSP server executable (e.g. 'sbt bspConfig', 'mill mill.bsp.BSP/install', 'cargo-bsp')" + }, + "args": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Additional command arguments" + }, + "workspace_root": { + "type": "string", + "description": "Workspace root directory path" + } + }, + "required": [ + "command" + ] + } + }, + { + "name": "bsp_initialize", + "description": "Send the BSP build/initialize handshake and return the server's capabilities. Must be called once after bsp_start.", + "inputSchema": { + "type": "object", + "properties": { + "session_id": { + "type": "string", + "description": "Session ID from bsp_start" + }, + "display_name": { + "type": "string", + "description": "Client display name (default: boj-bsp-mcp)" + }, + "version": { + "type": "string", + "description": "Client version (default: 0.1.0)" + }, + "bsp_version": { + "type": "string", + "description": "BSP protocol version (default: 2.2.0)" + } + }, + "required": [ + "session_id" + ] + } + }, + { + "name": "bsp_targets", + "description": "List all build targets in the workspace (workspace/buildTargets). Returns targets with their IDs, language IDs, and capabilities.", + "inputSchema": { + "type": "object", + "properties": { + "session_id": { + "type": "string", + "description": "Session ID" + } + }, + "required": [ + "session_id" + ] + } + }, + { + "name": "bsp_compile", + "description": "Compile one or more build targets (buildTarget/compile). Returns a compile result with diagnostics.", + "inputSchema": { + "type": "object", + "properties": { + "session_id": { + "type": "string", + "description": "Session ID" + }, + "targets": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Build target URIs to compile (omit to compile all)" + }, + "origin_id": { + "type": "string", + "description": "Optional request origin ID for tracking" + } + }, + "required": [ + "session_id" + ] + } + }, + { + "name": "bsp_test", + "description": "Run tests for one or more build targets (buildTarget/test). Returns test results.", + "inputSchema": { + "type": "object", + "properties": { + "session_id": { + "type": "string", + "description": "Session ID" + }, + "targets": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Build target URIs to test" + }, + "arguments": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Arguments passed to the test runner" + }, + "origin_id": { + "type": "string", + "description": "Optional request origin ID for tracking" + } + }, + "required": [ + "session_id" + ] + } + }, + { + "name": "bsp_run", + "description": "Run a build target (buildTarget/run). Returns the run result and captured output.", + "inputSchema": { + "type": "object", + "properties": { + "session_id": { + "type": "string", + "description": "Session ID" + }, + "target": { + "type": "string", + "description": "Build target URI to run" + }, + "arguments": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Arguments for the program" + }, + "environment_variables": { + "type": "object", + "description": "Environment variables (key-value)" + } + }, + "required": [ + "session_id", + "target" + ] + } + }, + { + "name": "bsp_clean", + "description": "Clean the build cache for targets or the entire workspace (workspace/cleanCache). Returns cleanup statistics.", + "inputSchema": { + "type": "object", + "properties": { + "session_id": { + "type": "string", + "description": "Session ID" + }, + "targets": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Build target URIs to clean (omit for full workspace clean)" + } + }, + "required": [ + "session_id" + ] + } + }, + { + "name": "bsp_diagnostics", + "description": "Return the latest build diagnostics published by the server (from build/publishDiagnostics notifications). Aggregated across all targets.", + "inputSchema": { + "type": "object", + "properties": { + "session_id": { + "type": "string", + "description": "Session ID" + }, + "target": { + "type": "string", + "description": "Filter by build target URI (optional)" + } + }, + "required": [ + "session_id" + ] + } + }, + { + "name": "bsp_stop", + "description": "Send the BSP build/shutdown + build/exit sequence and terminate the server subprocess. The session ID is invalidated.", + "inputSchema": { + "type": "object", + "properties": { + "session_id": { + "type": "string", + "description": "Session ID to stop" + } + }, + "required": [ + "session_id" + ] + } + } + ], + "ffi": { + "so_path": "ffi/zig-out/lib/libbsp_mcp.so", + "abi_version": "ADR-0006", + "symbols": [ + "boj_cartridge_init", + "boj_cartridge_deinit", + "boj_cartridge_name", + "boj_cartridge_version", + "boj_cartridge_invoke" + ] + } +} diff --git a/cartridges/cross-cutting/build/bsp-mcp/ffi/README.adoc b/cartridges/cross-cutting/build/bsp-mcp/ffi/README.adoc new file mode 100644 index 0000000..220dc93 --- /dev/null +++ b/cartridges/cross-cutting/build/bsp-mcp/ffi/README.adoc @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) += bsp-mcp / ffi β€” Zig FFI layer +:orientation: shallow + +== Purpose + +C-ABI exports bridging this cartridge's Idris2 ABI to the runtime. Consumed by +the adapter for protocol dispatch and by the trunk loader for mount. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” produces `zig-out/lib/libbsp.so`. +| `bsp_ffi.zig` | C-ABI exports (20 exports, 5 inline tests, 372 lines). +|=== + +== Invariants + +* **Null-pointer rejection** on `[*c]const u8` arguments before any + dereference. +* **Negative return values** carry documented error meanings; zero is + success (matching the trunk error-code convention). +* **No allocator leaks** β€” every allocation path pairs with `defer free`. + +== Test/proof surface + +5 inline `test "..."` block(s) in `bsp_ffi.zig`. Run with +`cd ffi && zig build test`. + +== Read-first + +. `bsp_ffi.zig` β€” C-ABI signatures and guards. +. Trunk `ffi/zig/src/catalogue.zig` for how this `.so` is loaded. diff --git a/cartridges/cross-cutting/build/bsp-mcp/ffi/bsp_ffi.zig b/cartridges/cross-cutting/build/bsp-mcp/ffi/bsp_ffi.zig new file mode 100644 index 0000000..0feb4e8 --- /dev/null +++ b/cartridges/cross-cutting/build/bsp-mcp/ffi/bsp_ffi.zig @@ -0,0 +1,453 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +// +// BSP-MCP Cartridge β€” Zig FFI bridge for Build Server Protocol management. +// +// Implements the BSP lifecycle state machine from SafeBsp.idr with build +// target tracking and task status management. + +const std = @import("std"); + +// ═══════════════════════════════════════════════════════════════════════ +// Types (must match BspMcp.SafeBsp encodings) +// ═══════════════════════════════════════════════════════════════════════ + +pub const BspState = enum(c_int) { + uninitialized = 0, + initializing = 1, + ready = 2, + building = 3, + shutting_down = 4, + exited = 5, +}; + +pub const BuildTargetKind = enum(c_int) { + library = 1, + application = 2, + test_target = 3, + benchmark = 4, + integration_test = 5, + documentation = 6, +}; + +pub const TaskStatus = enum(c_int) { + queued = 1, + started = 2, + finished = 3, + cancelled = 4, + failed = 5, +}; + +pub const BspCapability = enum(c_int) { + compile = 1, + test_cap = 2, + run = 3, + debug = 4, + clean_cache = 5, + dependency_sources = 6, + resources = 7, + output_paths = 8, + jvm_test_env = 9, +}; + +// ═══════════════════════════════════════════════════════════════════════ +// BSP Session State Machine +// ═══════════════════════════════════════════════════════════════════════ + +const MAX_SESSIONS: usize = 8; +const MAX_TARGETS: usize = 32; +const MAX_CAPABILITIES: usize = 9; + +const BuildTarget = struct { + active: bool = false, + kind: BuildTargetKind = .library, + task_status: TaskStatus = .queued, +}; + +const SessionSlot = struct { + active: bool = false, + state: BspState = .uninitialized, + capabilities: [MAX_CAPABILITIES]bool = [_]bool{false} ** MAX_CAPABILITIES, + targets: [MAX_TARGETS]BuildTarget = [_]BuildTarget{.{}} ** MAX_TARGETS, + target_count: usize = 0, + diagnostics_count: usize = 0, + errors_count: usize = 0, + warnings_count: usize = 0, +}; + +var sessions: [MAX_SESSIONS]SessionSlot = [_]SessionSlot{.{}} ** MAX_SESSIONS; + +var mutex: std.Thread.Mutex = .{}; + +/// Validate a state transition (matches Idris2 canTransition). +fn isValidTransition(from: BspState, to: BspState) bool { + return switch (from) { + .uninitialized => to == .initializing, + .initializing => to == .ready or to == .exited, + .ready => to == .building or to == .shutting_down, + .building => to == .ready or to == .shutting_down, + .shutting_down => to == .exited, + .exited => false, + }; +} + +/// Initialise a new BSP session. Returns slot index or -1 on failure. +pub export fn bsp_init() c_int { + mutex.lock(); + defer mutex.unlock(); + for (&sessions, 0..) |*slot, i| { + if (!slot.active) { + slot.* = SessionSlot{}; + slot.active = true; + return @intCast(i); + } + } + return -1; +} + +/// Start initialization (Uninitialized -> Initializing). +pub export fn bsp_start_init(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + if (slot_idx < 0 or slot_idx >= MAX_SESSIONS) return -1; + const idx: usize = @intCast(slot_idx); + if (!sessions[idx].active) return -1; + if (!isValidTransition(sessions[idx].state, .initializing)) return -2; + sessions[idx].state = .initializing; + return 0; +} + +/// Register a build capability during initialization. +pub export fn bsp_register_capability(slot_idx: c_int, cap: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + if (slot_idx < 0 or slot_idx >= MAX_SESSIONS) return -1; + const idx: usize = @intCast(slot_idx); + if (!sessions[idx].active) return -1; + if (sessions[idx].state != .initializing) return -2; + if (cap < 1 or cap > MAX_CAPABILITIES) return -3; + const cap_idx: usize = @intCast(cap - 1); + sessions[idx].capabilities[cap_idx] = true; + return 0; +} + +/// Mark initialization complete (Initializing -> Ready). +pub export fn bsp_ready(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + if (slot_idx < 0 or slot_idx >= MAX_SESSIONS) return -1; + const idx: usize = @intCast(slot_idx); + if (!sessions[idx].active) return -1; + if (!isValidTransition(sessions[idx].state, .ready)) return -2; + sessions[idx].state = .ready; + return 0; +} + +/// Start a build (Ready -> Building). +pub export fn bsp_build(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + if (slot_idx < 0 or slot_idx >= MAX_SESSIONS) return -1; + const idx: usize = @intCast(slot_idx); + if (!sessions[idx].active) return -1; + if (!isValidTransition(sessions[idx].state, .building)) return -2; + sessions[idx].state = .building; + return 0; +} + +/// Build complete (Building -> Ready). +pub export fn bsp_build_done(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + if (slot_idx < 0 or slot_idx >= MAX_SESSIONS) return -1; + const idx: usize = @intCast(slot_idx); + if (!sessions[idx].active) return -1; + if (!isValidTransition(sessions[idx].state, .ready)) return -2; + sessions[idx].state = .ready; + return 0; +} + +/// Request shutdown. +pub export fn bsp_shutdown(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + if (slot_idx < 0 or slot_idx >= MAX_SESSIONS) return -1; + const idx: usize = @intCast(slot_idx); + if (!sessions[idx].active) return -1; + if (!isValidTransition(sessions[idx].state, .shutting_down)) return -2; + sessions[idx].state = .shutting_down; + return 0; +} + +/// Exit. +pub export fn bsp_exit(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + if (slot_idx < 0 or slot_idx >= MAX_SESSIONS) return -1; + const idx: usize = @intCast(slot_idx); + if (!sessions[idx].active) return -1; + if (!isValidTransition(sessions[idx].state, .exited)) return -2; + sessions[idx].state = .exited; + return 0; +} + +/// Get the state of a session. +pub export fn bsp_state(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + if (slot_idx < 0 or slot_idx >= MAX_SESSIONS) return -1; + const idx: usize = @intCast(slot_idx); + if (!sessions[idx].active) return @intFromEnum(BspState.uninitialized); + return @intFromEnum(sessions[idx].state); +} + +/// Can we submit build requests? +pub export fn bsp_can_build(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + if (slot_idx < 0 or slot_idx >= MAX_SESSIONS) return 0; + const idx: usize = @intCast(slot_idx); + return if (sessions[idx].active and sessions[idx].state == .ready) 1 else 0; +} + +/// Is a build in progress? +pub export fn bsp_is_building(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + if (slot_idx < 0 or slot_idx >= MAX_SESSIONS) return 0; + const idx: usize = @intCast(slot_idx); + return if (sessions[idx].active and sessions[idx].state == .building) 1 else 0; +} + +/// Register a build target. Returns target index or -1 on failure. +pub export fn bsp_add_target(slot_idx: c_int, kind: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + if (slot_idx < 0 or slot_idx >= MAX_SESSIONS) return -1; + const idx: usize = @intCast(slot_idx); + if (!sessions[idx].active) return -1; + if (sessions[idx].target_count >= MAX_TARGETS) return -2; + + for (&sessions[idx].targets, 0..) |*tgt, ti| { + if (!tgt.active) { + tgt.active = true; + tgt.kind = @enumFromInt(kind); + tgt.task_status = .queued; + sessions[idx].target_count += 1; + return @intCast(ti); + } + } + return -1; +} + +/// Check if a session has a specific capability. +pub export fn bsp_has_capability(slot_idx: c_int, cap: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + if (slot_idx < 0 or slot_idx >= MAX_SESSIONS) return 0; + const idx: usize = @intCast(slot_idx); + if (!sessions[idx].active) return 0; + if (cap < 1 or cap > MAX_CAPABILITIES) return 0; + const cap_idx: usize = @intCast(cap - 1); + return if (sessions[idx].capabilities[cap_idx]) 1 else 0; +} + +/// Validate a state transition (C-ABI export). +pub export fn bsp_can_transition(from: c_int, to: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + const f: BspState = @enumFromInt(from); + const t: BspState = @enumFromInt(to); + return if (isValidTransition(f, t)) 1 else 0; +} + +/// Release a session slot. +pub export fn bsp_release(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + if (slot_idx < 0 or slot_idx >= MAX_SESSIONS) return -1; + const idx: usize = @intCast(slot_idx); + if (!sessions[idx].active) return -1; + sessions[idx] = SessionSlot{}; + return 0; +} + +/// Reset all sessions. +pub export fn bsp_reset_all() void { + mutex.lock(); + defer mutex.unlock(); + for (&sessions) |*slot| { + slot.* = SessionSlot{}; + } +} + +// ═══════════════════════════════════════════════════════════════════════ +// Standard Cartridge Interface +// ═══════════════════════════════════════════════════════════════════════ + +pub export fn boj_cartridge_init() c_int { + bsp_reset_all(); + return 0; +} + +pub export fn boj_cartridge_deinit() void { + bsp_reset_all(); +} + +pub export fn boj_cartridge_name() [*:0]const u8 { + mutex.lock(); + defer mutex.unlock(); + return "bsp-mcp"; +} + +pub export fn boj_cartridge_version() [*:0]const u8 { + mutex.lock(); + defer mutex.unlock(); + return "0.1.0"; +} + +// ═══════════════════════════════════════════════════════════════════════ +// ADR-0006 dispatch (boj_cartridge_invoke, 5th standard symbol) +// ═══════════════════════════════════════════════════════════════════════ + +const shim = @import("cartridge_shim.zig"); + +/// Dispatch the cartridge.json MCP tools. Grade D Alpha β€” each arm +/// returns a stub JSON body shaped to the tool's intended response. +export fn boj_cartridge_invoke( + tool_name: [*c]const u8, + json_args: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) callconv(.c) i32 { + _ = json_args; + if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; + + const body: []const u8 = if (shim.toolIs(tool_name, "bsp_start")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "bsp_initialize")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "bsp_targets")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "bsp_compile")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "bsp_test")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "bsp_run")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "bsp_clean")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "bsp_diagnostics")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "bsp_stop")) + "{\"result\":{\"status\":\"stub\"}}" +else + return shim.RC_UNKNOWN_TOOL; + + return shim.writeResult(out_buf, in_out_len, body); +} + +// ═══════════════════════════════════════════════════════════════════════ +// Tests +// ═══════════════════════════════════════════════════════════════════════ + +test "init and release BSP session" { + bsp_reset_all(); + const slot = bsp_init(); + try std.testing.expect(slot >= 0); + try std.testing.expectEqual(@as(c_int, @intFromEnum(BspState.uninitialized)), bsp_state(slot)); + try std.testing.expectEqual(@as(c_int, 0), bsp_release(slot)); +} + +test "full BSP build lifecycle" { + bsp_reset_all(); + const slot = bsp_init(); + try std.testing.expectEqual(@as(c_int, 0), bsp_start_init(slot)); + // Register capabilities + try std.testing.expectEqual(@as(c_int, 0), bsp_register_capability(slot, @intFromEnum(BspCapability.compile))); + try std.testing.expectEqual(@as(c_int, 0), bsp_register_capability(slot, @intFromEnum(BspCapability.test_cap))); + try std.testing.expectEqual(@as(c_int, 0), bsp_ready(slot)); + try std.testing.expectEqual(@as(c_int, 1), bsp_can_build(slot)); + // Check capabilities + try std.testing.expectEqual(@as(c_int, 1), bsp_has_capability(slot, @intFromEnum(BspCapability.compile))); + try std.testing.expectEqual(@as(c_int, 0), bsp_has_capability(slot, @intFromEnum(BspCapability.debug))); + // Add targets and build + try std.testing.expect(bsp_add_target(slot, @intFromEnum(BuildTargetKind.library)) >= 0); + try std.testing.expectEqual(@as(c_int, 0), bsp_build(slot)); + try std.testing.expectEqual(@as(c_int, 1), bsp_is_building(slot)); + try std.testing.expectEqual(@as(c_int, 0), bsp_can_build(slot)); + // Build done + try std.testing.expectEqual(@as(c_int, 0), bsp_build_done(slot)); + try std.testing.expectEqual(@as(c_int, 1), bsp_can_build(slot)); + // Shutdown and exit + try std.testing.expectEqual(@as(c_int, 0), bsp_shutdown(slot)); + try std.testing.expectEqual(@as(c_int, 0), bsp_exit(slot)); +} + +test "cannot build before ready" { + bsp_reset_all(); + const slot = bsp_init(); + try std.testing.expectEqual(@as(c_int, 0), bsp_can_build(slot)); + try std.testing.expectEqual(@as(c_int, -2), bsp_build(slot)); +} + +test "BSP state transitions" { + try std.testing.expectEqual(@as(c_int, 1), bsp_can_transition(0, 1)); // uninit -> initializing + try std.testing.expectEqual(@as(c_int, 1), bsp_can_transition(1, 2)); // initializing -> ready + try std.testing.expectEqual(@as(c_int, 1), bsp_can_transition(2, 3)); // ready -> building + try std.testing.expectEqual(@as(c_int, 1), bsp_can_transition(3, 2)); // building -> ready + try std.testing.expectEqual(@as(c_int, 1), bsp_can_transition(2, 4)); // ready -> shutting_down + try std.testing.expectEqual(@as(c_int, 1), bsp_can_transition(3, 4)); // building -> shutting_down (cancel) + try std.testing.expectEqual(@as(c_int, 1), bsp_can_transition(4, 5)); // shutting_down -> exited + try std.testing.expectEqual(@as(c_int, 0), bsp_can_transition(0, 2)); // uninit -> ready (invalid) + try std.testing.expectEqual(@as(c_int, 0), bsp_can_transition(5, 0)); // exited -> uninit (invalid) +} + +test "max BSP sessions enforced" { + bsp_reset_all(); + var i: usize = 0; + while (i < MAX_SESSIONS) : (i += 1) { + try std.testing.expect(bsp_init() >= 0); + } + try std.testing.expectEqual(@as(c_int, -1), bsp_init()); +} + +// ═══════════════════════════════════════════════════════════════════════ +// ADR-0006 invoke dispatch tests +// ═══════════════════════════════════════════════════════════════════════ + +test "invoke: each declared tool succeeds" { + var buf: [256]u8 = undefined; + const tools = [_][]const u8{ + "bsp_start", + "bsp_initialize", + "bsp_targets", + "bsp_compile", + "bsp_test", + "bsp_run", + "bsp_clean", + "bsp_diagnostics", + "bsp_stop", + }; + for (tools) |t| { + var len: usize = buf.len; + const rc = boj_cartridge_invoke(t.ptr, "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, 0), rc); + try std.testing.expect(std.mem.indexOf(u8, buf[0..len], "result") != null); + } +} + +test "invoke: unknown tool returns -1" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("nope", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -1), rc); +} + +test "invoke: buffer too small returns -3" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("bsp_start", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -3), rc); + try std.testing.expect(len > 4); +} diff --git a/cartridges/cross-cutting/build/bsp-mcp/ffi/build.zig b/cartridges/cross-cutting/build/bsp-mcp/ffi/build.zig new file mode 100644 index 0000000..904882a --- /dev/null +++ b/cartridges/cross-cutting/build/bsp-mcp/ffi/build.zig @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +// +// BSP-MCP Cartridge β€” Zig FFI build configuration (Zig 0.15+). + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + // Shared ADR-0006 invoke-shim module (relative path up to boj-server trunk). + + const shim_mod = b.addModule("cartridge_shim", .{ + + .root_source_file = b.path("../../../ffi/zig/src/cartridge_shim.zig"), + + .target = target, + + .optimize = optimize, + + }); + + const bsp_mod = b.addModule("bsp_ffi", .{ + .root_source_file = b.path("bsp_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + bsp_mod.addImport("cartridge_shim", shim_mod); + + // ── Tests ──────────────────────────────────────────────────────── + const bsp_tests = b.addTest(.{ + .root_module = bsp_mod, + }); + + const run_tests = b.addRunArtifact(bsp_tests); + + const test_step = b.step("test", "Run bsp-mcp FFI tests"); + test_step.dependOn(&run_tests.step); + + // ── Shared library ────────────────────────────────────────────── + const lib_mod = b.createModule(.{ + .root_source_file = b.path("bsp_ffi.zig"), + .target = target, + .optimize = optimize, + }); + lib_mod.addImport("cartridge_shim", shim_mod); + + const lib = b.addLibrary(.{ + .name = "bsp_mcp", + .root_module = lib_mod, + .linkage = .dynamic, + }); + b.installArtifact(lib); + + const lib_step = b.step("lib", "Build shared library"); + lib_step.dependOn(&lib.step); +} diff --git a/cartridges/cross-cutting/build/bsp-mcp/ffi/cartridge_shim.zig b/cartridges/cross-cutting/build/bsp-mcp/ffi/cartridge_shim.zig new file mode 100644 index 0000000..0e399db --- /dev/null +++ b/cartridges/cross-cutting/build/bsp-mcp/ffi/cartridge_shim.zig @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +// +// cartridge_shim.zig β€” Shared helpers for the ADR-0006 five-symbol +// cartridge ABI (`boj_cartridge_init / deinit / name / version / invoke`). +// +// The shim centralises the seven-code return convention, NUL-argument +// guards, tool-name comparison, and the buffer-too-small path so each +// cartridge's `boj_cartridge_invoke` can stay short β€” typically a tool +// table plus `shim.writeResult(...)`. +// +// Cartridges import this file by relative path (no build-graph change +// needed). Example: +// +// const shim = @import("../../../ffi/zig/src/cartridge_shim.zig"); +// +// export fn boj_cartridge_invoke( +// tool_name: [*c]const u8, +// json_args: [*c]const u8, +// out_buf: [*c]u8, +// in_out_len: [*c]usize, +// ) callconv(.c) i32 { +// _ = json_args; +// if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; +// const body = if (shim.toolIs(tool_name, "foo")) "{\"result\":{}}" +// else return shim.RC_UNKNOWN_TOOL; +// return shim.writeResult(out_buf, in_out_len, body); +// } + +const std = @import("std"); + +// ── Return codes (ADR-0006 Β§Return codes) ──────────────────────────── +// +// Frozen by ADR-0006. New failure modes compose these via the error +// JSON body β€” the integer surface does not grow without a follow-up ADR. + +pub const RC_SUCCESS: i32 = 0; +pub const RC_UNKNOWN_TOOL: i32 = -1; +pub const RC_BAD_ARGS: i32 = -2; +pub const RC_BUFFER_TOO_SMALL: i32 = -3; +pub const RC_RUNTIME_ERROR: i32 = -4; +pub const RC_PANIC: i32 = -5; +pub const RC_AUTH_DENIED: i32 = -6; + +// ── Invoke-path helpers ────────────────────────────────────────────── + +/// True if any of the three mandatory `boj_cartridge_invoke` output-path +/// pointers is null. Use at the top of every invoke to short-circuit to +/// `RC_BAD_ARGS`. +pub fn invokeArgsNull( + tool_name: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) bool { + return tool_name == null or out_buf == null or in_out_len == null; +} + +/// Compare a C-NUL-terminated tool-name pointer against a Zig string +/// literal. Caller must have already verified `tool_name` is non-null +/// (usually via `invokeArgsNull`). +pub fn toolIs(tool_name: [*c]const u8, expected: []const u8) bool { + const s = std.mem.span(@as([*:0]const u8, @ptrCast(tool_name))); + return std.mem.eql(u8, s, expected); +} + +/// Copy `body` into `out_buf[0..*in_out_len]` (as a capacity) and update +/// `*in_out_len` to the number of bytes written. Returns `RC_SUCCESS`. +/// +/// If `body.len` exceeds the current capacity stored in `*in_out_len`, +/// sets `*in_out_len` to the required size and returns +/// `RC_BUFFER_TOO_SMALL` β€” the caller is then expected to re-allocate +/// and retry, per ADR-0006 Β§Memory ownership. +/// +/// Caller must have already verified that `out_buf` and `in_out_len` +/// are non-null. +pub fn writeResult( + out_buf: [*c]u8, + in_out_len: [*c]usize, + body: []const u8, +) i32 { + const cap = in_out_len.*; + if (body.len > cap) { + in_out_len.* = body.len; + return RC_BUFFER_TOO_SMALL; + } + @memcpy(out_buf[0..body.len], body); + in_out_len.* = body.len; + return RC_SUCCESS; +} + +// ── Tests ──────────────────────────────────────────────────────────── + +test "writeResult: body fits, writes and sets length" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); + try std.testing.expectEqualStrings("hello", buf[0..len]); +} + +test "writeResult: too small returns -3 and sets required length" { + var buf: [2]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_BUFFER_TOO_SMALL, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: exact-fit succeeds" { + var buf: [5]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: empty body" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, ""); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 0), len); +} + +test "toolIs: matches and rejects" { + const name: [*:0]const u8 = "foo"; + try std.testing.expect(toolIs(@ptrCast(name), "foo")); + try std.testing.expect(!toolIs(@ptrCast(name), "bar")); + try std.testing.expect(!toolIs(@ptrCast(name), "foobar")); + try std.testing.expect(!toolIs(@ptrCast(name), "fo")); +} + +test "invokeArgsNull: detects each null slot" { + var buf: [4]u8 = undefined; + var len: usize = 4; + const name: [*:0]const u8 = "x"; + try std.testing.expect(!invokeArgsNull(@ptrCast(name), &buf, &len)); + try std.testing.expect(invokeArgsNull(null, &buf, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), null, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), &buf, null)); +} diff --git a/cartridges/cross-cutting/build/bsp-mcp/mod.js b/cartridges/cross-cutting/build/bsp-mcp/mod.js new file mode 100644 index 0000000..632b2ac --- /dev/null +++ b/cartridges/cross-cutting/build/bsp-mcp/mod.js @@ -0,0 +1,292 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +// +// bsp-mcp/mod.js -- Generic Build Server Protocol (BSP 2.x) gateway cartridge. +// +// Manages persistent BSP server subprocesses communicating over JSON-RPC 2.0 +// with Content-Length framing (stdio transport, same as LSP). Each session +// runs one server subprocess that stays alive until bsp_stop is called. +// +// Session lifecycle: +// bsp_start β†’ bsp_initialize β†’ bsp_targets +// β†’ bsp_compile | bsp_test | bsp_run | bsp_clean +// β†’ bsp_diagnostics (reads buffered build/publishDiagnostics notifications) +// β†’ bsp_stop +// +// Auth: None required β€” local subprocess. +// +// Usage: import { handleTool } from "./mod.js"; + +// --------------------------------------------------------------------------- +// JsonRpcSession β€” persistent subprocess + JSON-RPC 2.0 / Content-Length framing +// --------------------------------------------------------------------------- + +class JsonRpcSession { + /** @type {Deno.ChildProcess} */ #proc; + /** @type {Map} */ + #pending = new Map(); + /** @type {Array} */ #notifications = []; + #nextId = 1; + #buf = new Uint8Array(0); + #closed = false; + static #MAX_NOTIFICATIONS = 200; + static #REQUEST_TIMEOUT_MS = 60_000; // BSP builds can be slow + + constructor(proc) { + this.#proc = proc; + this.#drainLoop(); + } + + request(method, params) { + return new Promise((resolve, reject) => { + if (this.#closed) { reject(new Error("session closed")); return; } + const id = this.#nextId++; + const msg = JSON.stringify({ jsonrpc: "2.0", id, method, params }); + const header = `Content-Length: ${new TextEncoder().encode(msg).byteLength}\r\n\r\n`; + const timer = setTimeout(() => { + this.#pending.delete(id); + reject(new Error(`BSP request '${method}' timed out after ${JsonRpcSession.#REQUEST_TIMEOUT_MS}ms`)); + }, JsonRpcSession.#REQUEST_TIMEOUT_MS); + this.#pending.set(id, { resolve, reject, timer }); + this.#write(header + msg); + }); + } + + notify(method, params) { + if (this.#closed) return; + const msg = JSON.stringify({ jsonrpc: "2.0", method, params }); + const header = `Content-Length: ${new TextEncoder().encode(msg).byteLength}\r\n\r\n`; + this.#write(header + msg); + } + + getNotifications(method) { + return method + ? this.#notifications.filter(n => n.method === method) + : [...this.#notifications]; + } + + async close() { + if (this.#closed) return; + this.#closed = true; + for (const { reject, timer } of this.#pending.values()) { + clearTimeout(timer); + reject(new Error("session closed")); + } + this.#pending.clear(); + try { this.#proc.stdin.close(); } catch { /* ignore */ } + try { await this.#proc.status; } catch { /* ignore */ } + } + + #write(text) { + try { + const writer = this.#proc.stdin.getWriter(); + writer.write(new TextEncoder().encode(text)).finally(() => writer.releaseLock()); + } catch { /* process may have died */ } + } + + async #drainLoop() { + const reader = this.#proc.stdout.getReader(); + const dec = new TextDecoder(); + try { + while (true) { + const { value, done } = await reader.read(); + if (done) break; + const combined = new Uint8Array(this.#buf.length + value.length); + combined.set(this.#buf); + combined.set(value, this.#buf.length); + this.#buf = combined; + this.#parseMessages(dec); + } + } catch { /* read error */ } + this.#closed = true; + } + + #parseMessages(dec) { + while (true) { + const text = dec.decode(this.#buf); + const sep = text.indexOf("\r\n\r\n"); + if (sep === -1) return; + const headerSection = text.slice(0, sep); + const match = /Content-Length:\s*(\d+)/i.exec(headerSection); + if (!match) { this.#buf = new Uint8Array(0); return; } + const bodyLen = parseInt(match[1], 10); + const headerBytes = new TextEncoder().encode(headerSection + "\r\n\r\n").byteLength; + if (this.#buf.length < headerBytes + bodyLen) return; + const bodyBytes = this.#buf.slice(headerBytes, headerBytes + bodyLen); + this.#buf = this.#buf.slice(headerBytes + bodyLen); + let msg; + try { msg = JSON.parse(dec.decode(bodyBytes)); } catch { continue; } + if ("id" in msg && msg.id !== null) { + const entry = this.#pending.get(msg.id); + if (entry) { + clearTimeout(entry.timer); + this.#pending.delete(msg.id); + if (msg.error) entry.reject(Object.assign(new Error(msg.error.message ?? "RPC error"), { code: msg.error.code })); + else entry.resolve(msg.result); + } + } else if ("method" in msg) { + if (this.#notifications.length >= JsonRpcSession.#MAX_NOTIFICATIONS) this.#notifications.shift(); + this.#notifications.push(msg); + } + } + } +} + +// --------------------------------------------------------------------------- +// Session registry +// --------------------------------------------------------------------------- + +/** @type {Map} */ +const SESSIONS = new Map(); + +function newSessionId() { + return `bsp_${crypto.randomUUID().replace(/-/g, "").slice(0, 12)}`; +} + +function getSession(session_id) { + const entry = SESSIONS.get(session_id); + if (!entry) throw Object.assign(new Error(`Unknown BSP session: ${session_id}`), { status: 404 }); + return entry; +} + +// Build target URI helper β€” wraps a plain string in a BSP BuildTargetIdentifier +function targetId(uri) { return { uri }; } + +// --------------------------------------------------------------------------- +// Tool handlers +// --------------------------------------------------------------------------- + +export async function handleTool(toolName, args) { + switch (toolName) { + + // -- bsp_start ----------------------------------------------------------- + case "bsp_start": { + const { command, args: extraArgs = [], workspace_root } = args; + if (!command) return { status: 400, data: { error: "command is required" } }; + + const cmdParts = command.trim().split(/\s+/); + const proc = new Deno.Command(cmdParts[0], { + args: [...cmdParts.slice(1), ...extraArgs], + cwd: workspace_root, + stdin: "piped", + stdout: "piped", + stderr: "null", + }).spawn(); + + const session = new JsonRpcSession(proc); + const session_id = newSessionId(); + SESSIONS.set(session_id, { + session, + meta: { command, workspace_root: workspace_root ?? null, initialized: false }, + }); + return { status: 200, data: { session_id, message: `BSP server started: ${command}` } }; + } + + // -- bsp_initialize ------------------------------------------------------ + case "bsp_initialize": { + const { + session_id, + display_name = "boj-bsp-mcp", + version = "0.1.0", + bsp_version = "2.2.0", + } = args; + const { session, meta } = getSession(session_id); + const result = await session.request("build/initialize", { + displayName: display_name, + version, + bspVersion: bsp_version, + rootUri: meta.workspace_root ? `file://${meta.workspace_root}` : null, + capabilities: { + languageIds: [], + }, + }); + session.notify("build/initialized", {}); + meta.initialized = true; + meta.capabilities = result.capabilities; + return { status: 200, data: { capabilities: result.capabilities, displayName: result.displayName, version: result.version } }; + } + + // -- bsp_targets --------------------------------------------------------- + case "bsp_targets": { + const { session_id } = args; + const { session } = getSession(session_id); + const result = await session.request("workspace/buildTargets", {}); + return { status: 200, data: { targets: result.targets ?? [] } }; + } + + // -- bsp_compile --------------------------------------------------------- + case "bsp_compile": { + const { session_id, targets = [], origin_id } = args; + const { session } = getSession(session_id); + const req = { targets: targets.map(targetId) }; + if (origin_id) req.originId = origin_id; + const result = await session.request("buildTarget/compile", req); + // Collect any diagnostics published during the build + const diags = session.getNotifications("build/publishDiagnostics"); + return { status: 200, data: { result, diagnostics: diags.map(n => n.params) } }; + } + + // -- bsp_test ------------------------------------------------------------ + case "bsp_test": { + const { session_id, targets = [], arguments: testArgs = [], origin_id } = args; + const { session } = getSession(session_id); + const req = { targets: targets.map(targetId), arguments: testArgs }; + if (origin_id) req.originId = origin_id; + const result = await session.request("buildTarget/test", req); + return { status: 200, data: result }; + } + + // -- bsp_run ------------------------------------------------------------- + case "bsp_run": { + const { session_id, target, arguments: runArgs = [], environment_variables } = args; + const { session } = getSession(session_id); + const req = { target: targetId(target), arguments: runArgs }; + if (environment_variables) req.environmentVariables = environment_variables; + const result = await session.request("buildTarget/run", req); + // Capture any output events + const outputEvents = session.getNotifications("build/logMessage"); + return { status: 200, data: { result, output: outputEvents.map(n => n.params?.message).filter(Boolean) } }; + } + + // -- bsp_clean ----------------------------------------------------------- + case "bsp_clean": { + const { session_id, targets = [] } = args; + const { session } = getSession(session_id); + const result = await session.request("workspace/cleanCache", { + targets: targets.map(targetId), + }); + return { status: 200, data: result }; + } + + // -- bsp_diagnostics ----------------------------------------------------- + case "bsp_diagnostics": { + const { session_id, target } = args; + const { session } = getSession(session_id); + const notifications = session.getNotifications("build/publishDiagnostics"); + let result; + if (target) { + const matching = notifications.filter(n => n.params?.buildTarget?.uri === target); + result = matching.map(n => n.params); + } else { + result = notifications.map(n => n.params); + } + return { status: 200, data: { diagnostics: result } }; + } + + // -- bsp_stop ------------------------------------------------------------ + case "bsp_stop": { + const { session_id } = args; + const entry = SESSIONS.get(session_id); + if (!entry) return { status: 404, data: { error: `Unknown session: ${session_id}` } }; + const { session } = entry; + try { await session.request("build/shutdown", null); } catch { /* ignore */ } + session.notify("build/exit", null); + await session.close(); + SESSIONS.delete(session_id); + return { status: 200, data: { stopped: session_id } }; + } + + default: + return { status: 404, data: { error: `Unknown tool: ${toolName}` } }; + } +} diff --git a/cartridges/cross-cutting/build/bsp-mcp/panels/manifest.json b/cartridges/cross-cutting/build/bsp-mcp/panels/manifest.json new file mode 100644 index 0000000..ad42b6a --- /dev/null +++ b/cartridges/cross-cutting/build/bsp-mcp/panels/manifest.json @@ -0,0 +1,67 @@ +{ + "$schema": "https://panll.dev/schemas/panel-manifest/v1.json", + "spdx": "MPL-2.0", + "cartridge": "bsp-mcp", + "domain": "Build Server Protocol", + "version": "0.1.0", + "panels": [ + { + "id": "bsp-status", + "title": "BSP Gateway Status", + "description": "Build server proxy health and connection to build tools", + "type": "status-indicator", + "data_source": { + "endpoint": "/cartridge/bsp-mcp/invoke", + "method": "POST", + "body": { "tool": "status" }, + "refresh_interval_ms": 5000 + }, + "widgets": [ + { + "type": "state-badge", + "field": "state", + "states": { + "ready": { "color": "#2ecc71", "icon": "hammer" }, + "degraded": { "color": "#f39c12", "icon": "alert-circle" }, + "error": { "color": "#e74c3c", "icon": "alert-triangle" } + } + }, + { "type": "text", "field": "version", "label": "Version" } + ] + }, + { + "id": "bsp-build-targets", + "title": "Build Targets", + "description": "Registered build targets with their compilation status", + "type": "metric", + "data_source": { + "endpoint": "/cartridge/bsp-mcp/invoke", + "method": "POST", + "body": { "tool": "build_targets" }, + "refresh_interval_ms": 15000 + }, + "widgets": [ + { "type": "counter", "field": "total_targets", "label": "Targets", "icon": "target" }, + { "type": "counter", "field": "compiled", "label": "Compiled", "icon": "check-circle" }, + { "type": "counter", "field": "failed", "label": "Failed", "icon": "x-circle" } + ] + }, + { + "id": "bsp-compile-metrics", + "title": "Compilation Metrics", + "description": "Compile times, cache hit rates, and diagnostic counts", + "type": "metric", + "data_source": { + "endpoint": "/cartridge/bsp-mcp/invoke", + "method": "POST", + "body": { "tool": "compile_metrics" }, + "refresh_interval_ms": 15000 + }, + "widgets": [ + { "type": "text", "field": "avg_compile_time", "label": "Avg Compile Time" }, + { "type": "gauge", "field": "cache_hit_rate", "label": "Cache Hit %", "min": 0, "max": 100 }, + { "type": "counter", "field": "diagnostics_count", "label": "Diagnostics", "icon": "alert-circle" } + ] + } + ] +} diff --git a/cartridges/cross-cutting/debug/dap-mcp/README.adoc b/cartridges/cross-cutting/debug/dap-mcp/README.adoc new file mode 100644 index 0000000..5d0fd49 --- /dev/null +++ b/cartridges/cross-cutting/debug/dap-mcp/README.adoc @@ -0,0 +1,66 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) += dap-mcp +Jonathan D.A. Jewell +:spdx: MPL-2.0 +:tier: Ayo +:domain: Language Tools +:protocols: MCP, REST + +== Overview + +Generic Debug Adapter Protocol (DAP) gateway. Spawns any DAP adapter as a persistent subprocess and exposes debug lifecycle operations (initialize, launch/attach, breakpoints, stepping, stack inspection, variable evaluation) as MCP tools. + +== Tools (12) + +[cols="2,4"] +|=== +| Tool | Description + +| `dap_start` | Start a debug adapter subprocess and return a session ID. The adapter stays alive until dap_stop is called. +| `dap_initialize` | Send the DAP initialize request to negotiate adapter capabilities. Must be called once after dap_start. +| `dap_launch` | Launch a program under the debug adapter. The adapter starts the debuggee and raises a stopped event at entry (if configured). +| `dap_attach` | Attach the debug adapter to a running process by PID or by a connection (e.g. localhost:port for remote debugging). +| `dap_set_breakpoints` | Set breakpoints in a source file, replacing any previously set breakpoints for that file. Returns the verified breakpoint list. +| `dap_continue` | Continue execution for a thread (or all threads) until the next breakpoint or program exit. +| `dap_step_over` | Step over (next statement) for a thread. Execution resumes until the next line in the current scope. +| `dap_step_in` | Step into the next function call for a thread. +| `dap_step_out` | Step out of the current function for a thread (run until function return). +| `dap_stack_trace` | Get the call stack for a stopped thread. Returns an array of stack frames with source locations. +| `dap_variables` | Get variables for a stack frame scope or an expandable variable. Returns an array of variable objects. +| `dap_stop` | Send the DAP disconnect request and terminate the debug adapter subprocess. The session ID is invalidated. +|=== + +== Architecture + +Three-layer stack per the BoJ cartridge standard: + +[cols="1,1,3"] +|=== +| Layer | Language | Purpose + +| ABI | Idris2 | Formally verified state machine with dependent-type proofs +| FFI | Zig | C-ABI implementation: session pool, action metrics, thread safety +| Adapter| Zig | Adapter bridge to the BoJ unified adapter protocol (zig predecessors sidelined) +|=== + +== Building + +[source,bash] +---- +# ABI (Idris2) +cd abi && idris2 --check *.idr + +# FFI (Zig) +cd ffi && zig build +cd ffi && zig build test + +# Adapter (Zig) +cd adapter && zig build +---- + +== Status + +Alpha (CRG D). Cartridge metadata declared in `cartridge.json`; 12 tool(s) exposed. +Not yet dogfooded externally. See `docs/READINESS.md` at repo root for the +project-wide grade table and `docs/practice/DOGFOOD-LOG.adoc` for usage evidence. diff --git a/cartridges/cross-cutting/debug/dap-mcp/abi/DapMcp/SafeDap.idr b/cartridges/cross-cutting/debug/dap-mcp/abi/DapMcp/SafeDap.idr new file mode 100644 index 0000000..2c073da --- /dev/null +++ b/cartridges/cross-cutting/debug/dap-mcp/abi/DapMcp/SafeDap.idr @@ -0,0 +1,193 @@ +-- SPDX-License-Identifier: MPL-2.0 +-- Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +||| DapMcp.SafeDap: Formally verified Debug Adapter Protocol operations. +||| +||| Cartridge: dap-mcp +||| Matrix cell: DAP protocol column +||| +||| Models the DAP session lifecycle with type-level guarantees that: +||| - Debug commands can only be sent to an initialized adapter +||| - Thread and stack frame references are scoped to stopped states +||| - Variables can only be inspected while paused +module DapMcp.SafeDap + +%default total + +-- ═══════════════════════════════════════════════════════════════════════════ +-- DAP Session State Machine +-- ═══════════════════════════════════════════════════════════════════════════ + +||| DAP session lifecycle states per DAP specification 1.65. +public export +data DapState + = NotStarted -- Adapter not launched + | Launched -- Adapter process started + | Configured -- ConfigurationDone sent + | Running -- Target executing + | Stopped -- Target hit breakpoint/exception + | Terminated -- Target terminated + | Disconnected -- Adapter disconnected + +||| Equality for DAP states. +public export +Eq DapState where + NotStarted == NotStarted = True + Launched == Launched = True + Configured == Configured = True + Running == Running = True + Stopped == Stopped = True + Terminated == Terminated = True + Disconnected == Disconnected = True + _ == _ = False + +||| Valid state transitions. +public export +canTransition : DapState -> DapState -> Bool +canTransition NotStarted Launched = True +canTransition Launched Configured = True +canTransition Configured Running = True +canTransition Running Stopped = True +canTransition Stopped Running = True -- continue/step +canTransition Running Terminated = True +canTransition Stopped Terminated = True +canTransition Terminated Disconnected = True +canTransition Launched Disconnected = True -- early disconnect +canTransition Running Disconnected = True -- forced disconnect +canTransition _ _ = False + +-- ═══════════════════════════════════════════════════════════════════════════ +-- DAP Types +-- ═══════════════════════════════════════════════════════════════════════════ + +||| Breakpoint type. +public export +data BreakpointKind + = SourceBreakpoint -- Line/column breakpoint + | FunctionBreakpoint -- Function name breakpoint + | DataBreakpoint -- Memory/variable watch + | InstructionBP -- Instruction-level breakpoint + | ExceptionBreakpoint -- Exception filter + +||| C-ABI encoding. +public export +breakpointKindToInt : BreakpointKind -> Int +breakpointKindToInt SourceBreakpoint = 1 +breakpointKindToInt FunctionBreakpoint = 2 +breakpointKindToInt DataBreakpoint = 3 +breakpointKindToInt InstructionBP = 4 +breakpointKindToInt ExceptionBreakpoint = 5 + +||| Stop reason (why the debugger paused). +public export +data StopReason + = Breakpoint -- Hit a breakpoint + | Step -- Completed a step + | Exception -- Unhandled exception + | Pause -- Manual pause + | Entry -- Program entry point + | Goto -- Goto target reached + +||| C-ABI encoding. +public export +stopReasonToInt : StopReason -> Int +stopReasonToInt Breakpoint = 1 +stopReasonToInt Step = 2 +stopReasonToInt Exception = 3 +stopReasonToInt Pause = 4 +stopReasonToInt Entry = 5 +stopReasonToInt Goto = 6 + +||| Step granularity. +public export +data StepGranularity = StepStatement | StepLine | StepInstruction + +||| C-ABI encoding. +public export +stepGranularityToInt : StepGranularity -> Int +stepGranularityToInt StepStatement = 1 +stepGranularityToInt StepLine = 2 +stepGranularityToInt StepInstruction = 3 + +-- ═══════════════════════════════════════════════════════════════════════════ +-- State Machine Proofs +-- ═══════════════════════════════════════════════════════════════════════════ + +||| Proof that the DAP lifecycle is well-formed. +public export +data DapLifecycleWellFormed : Type where + MkWellFormed : + (launchOk : canTransition NotStarted Launched = True) -> + (configOk : canTransition Launched Configured = True) -> + (runOk : canTransition Configured Running = True) -> + (stopOk : canTransition Running Stopped = True) -> + (contOk : canTransition Stopped Running = True) -> + (termFromR : canTransition Running Terminated = True) -> + (termFromS : canTransition Stopped Terminated = True) -> + (discOk : canTransition Terminated Disconnected = True) -> + DapLifecycleWellFormed + +||| Witness: the DAP lifecycle is well-formed. +public export +dapLifecycleOk : DapLifecycleWellFormed +dapLifecycleOk = MkWellFormed Refl Refl Refl Refl Refl Refl Refl Refl + +-- ═══════════════════════════════════════════════════════════════════════════ +-- Safety Predicates +-- ═══════════════════════════════════════════════════════════════════════════ + +||| Can we inspect variables/stack in this state? Only while stopped. +public export +canInspect : DapState -> Bool +canInspect Stopped = True +canInspect _ = False + +||| Can we set breakpoints? In launched, configured, or stopped states. +public export +canSetBreakpoints : DapState -> Bool +canSetBreakpoints Launched = True +canSetBreakpoints Configured = True +canSetBreakpoints Stopped = True +canSetBreakpoints _ = False + +-- ═══════════════════════════════════════════════════════════════════════════ +-- C-ABI Exports +-- ═══════════════════════════════════════════════════════════════════════════ + +||| DAP state to integer. +public export +dapStateToInt : DapState -> Int +dapStateToInt NotStarted = 0 +dapStateToInt Launched = 1 +dapStateToInt Configured = 2 +dapStateToInt Running = 3 +dapStateToInt Stopped = 4 +dapStateToInt Terminated = 5 +dapStateToInt Disconnected = 6 + +||| FFI: Validate a state transition. +export +dap_can_transition : Int -> Int -> Int +dap_can_transition from to = + let fromState = case from of + 0 => NotStarted + 1 => Launched + 2 => Configured + 3 => Running + 4 => Stopped + 5 => Terminated + _ => Disconnected + toState = case to of + 0 => NotStarted + 1 => Launched + 2 => Configured + 3 => Running + 4 => Stopped + 5 => Terminated + _ => Disconnected + in if canTransition fromState toState then 1 else 0 + +||| FFI: Can inspect variables in this state? +export +dap_can_inspect : Int -> Int +dap_can_inspect 4 = 1 -- Stopped +dap_can_inspect _ = 0 diff --git a/cartridges/cross-cutting/debug/dap-mcp/abi/README.adoc b/cartridges/cross-cutting/debug/dap-mcp/abi/README.adoc new file mode 100644 index 0000000..5f05a76 --- /dev/null +++ b/cartridges/cross-cutting/debug/dap-mcp/abi/README.adoc @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) += dap-mcp / abi β€” Idris2 ABI layer +:orientation: shallow + +== Purpose + +Cartridge-local protocol types for `dap-mcp`. Defines the data, tool, and +transition vocabulary this cartridge speaks. Inherits safety lemmas and the +matrix-cell identifier scheme from the trunk ABI at `src/abi/`. + +== Files + +1 Idris2 module(s), ~193 lines total. Lead module: +`SafeDap.idr`. + +No `.ipkg` β€” consumed directly by wider BoJ callers; type-checks inside the +trunk `boj.ipkg`. + +== Invariants + +* `%default total` at every module top. +* Zero `believe_me`, `assert_total`, `postulate`, `Admitted`, or + `sorry` (the trunk holds the four declared `believe_me` sites; leaf + cartridges carry none). + +== Test/proof surface + +Thin module(s), no inline tests. Proof surface = type-check success under the +trunk `idris2 --check`. + +== Read-first + +. `SafeDap.idr` β€” cartridge-local data records and enums. +. Trunk `src/abi/Boj/Catalogue.idr` for how this cell participates in the + 2D matrix. diff --git a/cartridges/cross-cutting/debug/dap-mcp/abi/dap-mcp.ipkg b/cartridges/cross-cutting/debug/dap-mcp/abi/dap-mcp.ipkg new file mode 100644 index 0000000..9405894 --- /dev/null +++ b/cartridges/cross-cutting/debug/dap-mcp/abi/dap-mcp.ipkg @@ -0,0 +1,11 @@ +-- SPDX-License-Identifier: MPL-2.0 +package dapmcp + +authors = "Jonathan D.A. Jewell" +version = 0.1.0 +license = "MPL-2.0" +brief = "DAP MCP cartridge β€” Debug Adapter Protocol lifecycle with formal state guarantees" + +sourcedir = "." +modules = DapMcp.SafeDap +depends = base, contrib diff --git a/cartridges/cross-cutting/debug/dap-mcp/adapter/README.adoc b/cartridges/cross-cutting/debug/dap-mcp/adapter/README.adoc new file mode 100644 index 0000000..18884e6 --- /dev/null +++ b/cartridges/cross-cutting/debug/dap-mcp/adapter/README.adoc @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) += dap-mcp / adapter β€” protocol bridge +:orientation: shallow + +== Purpose + +Exposes this cartridge's FFI over the wire protocols declared in +`cartridge.json`. Dispatches tool invocations to the C-ABI exports at +`../ffi/` and formats responses per protocol. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” depends on the sibling `ffi` target. +| `dap_adapter.zig` | Protocol dispatch (403 lines). Tool catalogue matches `cartridge.json`. +| `SIDELINED-*.v.adoc` | zig predecessor(s). Not built β€” zig banned 2026-04-10. +|=== + +== Invariants + +* **Stateless** β€” all state lives behind the FFI, never in the adapter. +* **Response passthrough** β€” whatever the FFI returns goes back to the wire + unmodified (no embellishment, no silent recovery). +* **Cartridge.json is source of truth** for the tool catalogue; drift between + adapter and manifest is a CI failure. + +== Test/proof surface + +No inline tests at the adapter layer β€” protocol routing is exercised by the +trunk seam tests at `ffi/zig/src/seams.zig`. + +== Read-first + +. `dap_adapter.zig` β€” dispatch function and per-protocol routers. +. `../cartridge.json` β€” the tool manifest the dispatcher mirrors. diff --git a/cartridges/cross-cutting/debug/dap-mcp/adapter/build.zig b/cartridges/cross-cutting/debug/dap-mcp/adapter/build.zig new file mode 100644 index 0000000..acb8e56 --- /dev/null +++ b/cartridges/cross-cutting/debug/dap-mcp/adapter/build.zig @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +// +// DAP-MCP Cartridge β€” adapter build configuration (Zig 0.14+). + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const ffi_mod = b.createModule(.{ + .root_source_file = b.path("../ffi/dap_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + const adapter = b.addExecutable(.{ + .name = "dap-adapter", + .root_source_file = b.path("dap_adapter.zig"), + .target = target, + .optimize = optimize, + }); + adapter.root_module.addImport("dap_ffi", ffi_mod); + b.installArtifact(adapter); + + const run_cmd = b.addRunArtifact(adapter); + const run_step = b.step("run", "Run dap-adapter (REST :9019, gRPC :9020, GraphQL :9021)"); + run_step.dependOn(&run_cmd.step); + + const tests = b.addTest(.{ + .root_source_file = b.path("dap_adapter.zig"), + .target = target, + .optimize = optimize, + }); + tests.root_module.addImport("dap_ffi", ffi_mod); + const run_tests = b.addRunArtifact(tests); + const test_step = b.step("test", "Run adapter unit tests"); + test_step.dependOn(&run_tests.step); +} diff --git a/cartridges/cross-cutting/debug/dap-mcp/adapter/dap_adapter.zig b/cartridges/cross-cutting/debug/dap-mcp/adapter/dap_adapter.zig new file mode 100644 index 0000000..bd054b2 --- /dev/null +++ b/cartridges/cross-cutting/debug/dap-mcp/adapter/dap_adapter.zig @@ -0,0 +1,403 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +// +// DAP-MCP Cartridge β€” Unified Zig adapter. +// Replaces the banned dap_adapter.v (zig removed 2026-04-10). +// +// Exposes the DAP session state machine from dap_ffi.zig via three protocols: +// REST port 9019 HTTP/1.1, JSON responses +// gRPC-compat port 9020 HTTP/1.1 + proto-style paths, JSON bodies +// GraphQL port 9021 HTTP/1.1 POST /graphql, keyword dispatch +// +// Build: zig build (build.zig in this directory) + +const std = @import("std"); +const ffi = @import("dap_ffi"); + +// ═══════════════════════════════════════════════════════════════════════════ +// Constants +// ═══════════════════════════════════════════════════════════════════════════ + +const VERSION = "0.1.0"; +const CARTRIDGE = "dap-mcp"; +const REST_PORT: u16 = 9019; +const GRPC_PORT: u16 = 9020; +const GQL_PORT: u16 = 9021; + +const STATE_LABELS = [_][]const u8{ + "not_started", "launched", "configured", "running", + "stopped", "terminated", "disconnected", +}; + +const BREAKPOINT_KIND_LABELS = [_][]const u8{ + "", "source", "function", "data", "instruction", "exception", +}; + +const STOP_REASON_LABELS = [_][]const u8{ + "", "breakpoint", "step", "exception", "pause", "entry", "goto_target", +}; + +const GRPC_PROTO = + \\syntax = "proto3"; + \\package dap_mcp; + \\ + \\service DapService { + \\ rpc CreateSession (Empty) returns (SessionResponse); + \\ rpc Launch (SlotRequest) returns (SessionResponse); + \\ rpc Configure (SlotRequest) returns (SessionResponse); + \\ rpc Continue (SlotRequest) returns (SessionResponse); + \\ rpc Stopped (StopRequest) returns (SessionResponse); + \\ rpc Terminate (SlotRequest) returns (SessionResponse); + \\ rpc Disconnect (SlotRequest) returns (SessionResponse); + \\ rpc AddBreakpoint (BreakpointRequest) returns (SessionResponse); + \\ rpc GetState (SlotRequest) returns (SessionResponse); + \\ rpc ReleaseSession (SlotRequest) returns (ReleaseResponse); + \\ rpc Health (Empty) returns (HealthResponse); + \\ rpc Types (Empty) returns (TypeInfoResponse); + \\} + \\ + \\message Empty {} + \\message SlotRequest { int32 slot = 1; } + \\message StopRequest { int32 slot = 1; int32 reason = 2; } + \\message BreakpointRequest { int32 slot = 1; int32 kind = 2; } + \\message SessionResponse { int32 slot = 1; string state = 2; bool can_inspect = 3; } + \\message ReleaseResponse { int32 slot = 1; bool released = 2; } + \\message HealthResponse { string status = 1; string cartridge = 2; string version = 3; } + \\message TypeInfoResponse { repeated string states = 1; repeated string breakpoint_kinds = 2; repeated string stop_reasons = 3; } +; + +const GRAPHQL_SCHEMA = + \\type Query { + \\ health: Health! + \\ session(slot: Int!): Session + \\ types: TypeInfo! + \\} + \\type Mutation { + \\ createSession: Session! + \\ launch(slot: Int!): Session! + \\ configure(slot: Int!): Session! + \\ continue(slot: Int!): Session! + \\ stopped(slot: Int!, reason: Int!): Session! + \\ terminate(slot: Int!): Session! + \\ disconnect(slot: Int!): Session! + \\ addBreakpoint(slot: Int!, kind: Int!): Session! + \\ releaseSession(slot: Int!): ReleaseResult! + \\} + \\type Health { status: String! cartridge: String! version: String! } + \\type Session { slot: Int! state: String! canInspect: Boolean! } + \\type ReleaseResult { slot: Int! released: Boolean! } + \\type TypeInfo { states: [String!]! breakpointKinds: [String!]! stopReasons: [String!]! } +; + +// ═══════════════════════════════════════════════════════════════════════════ +// JSON helpers +// ═══════════════════════════════════════════════════════════════════════════ + +fn stateLabel(s: c_int) []const u8 { + if (s >= 0 and s < @as(c_int, STATE_LABELS.len)) return STATE_LABELS[@intCast(s)]; + return "unknown"; +} + +fn sessionJson(slot: c_int, buf: []u8) []const u8 { + const state = ffi.dap_state(slot); + const can_inspect = ffi.dap_can_inspect(slot); + return std.fmt.bufPrint(buf, + \\{{"slot":{d},"state":"{s}","can_inspect":{s}}} + , .{ slot, stateLabel(state), if (can_inspect == 1) "true" else "false" }) catch buf[0..0]; +} + +fn errorJson(buf: []u8, msg: []const u8, code: c_int) []const u8 { + return std.fmt.bufPrint(buf, + \\{{"error":"{s}","code":{d}}} + , .{ msg, code }) catch buf[0..0]; +} + +fn healthJson(buf: []u8) []const u8 { + return std.fmt.bufPrint(buf, + \\{{"status":"ok","cartridge":"{s}","version":"{s}"}} + , .{ CARTRIDGE, VERSION }) catch buf[0..0]; +} + +const TYPES_JSON = + \\{"states":["not_started","launched","configured","running","stopped","terminated","disconnected"], + \\"breakpoint_kinds":["source","function","data","instruction","exception"], + \\"stop_reasons":["breakpoint","step","exception","pause","entry","goto_target"]} +; + +// ═══════════════════════════════════════════════════════════════════════════ +// JSON body parsers +// ═══════════════════════════════════════════════════════════════════════════ + +fn parseIntField(body: []const u8, field: []const u8) ?c_int { + var buf: [32]u8 = undefined; + const needle = std.fmt.bufPrint(&buf, "\"{s}\":", .{field}) catch return null; + const idx = std.mem.indexOf(u8, body, needle) orelse return null; + const rest = std.mem.trimLeft(u8, body[idx + needle.len ..], " \t\r\n"); + var end: usize = 0; + while (end < rest.len and rest[end] >= '0' and rest[end] <= '9') : (end += 1) {} + if (end == 0) return null; + return @intCast(std.fmt.parseInt(i32, rest[0..end], 10) catch return null); +} + +fn pathSlot(target: []const u8) ?c_int { + var it = std.mem.splitBackwardsScalar(u8, target, '/'); + while (it.next()) |seg| { + if (seg.len == 0) continue; + var all_digits = true; + for (seg) |c| { if (c < '0' or c > '9') { all_digits = false; break; } } + if (all_digits) return @intCast(std.fmt.parseInt(i32, seg, 10) catch return null); + } + return null; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Response type +// ═══════════════════════════════════════════════════════════════════════════ + +const Response = struct { + status: std.http.Status, + body: []const u8, + content_type: []const u8 = "application/json", +}; + +// ═══════════════════════════════════════════════════════════════════════════ +// REST dispatch +// ═══════════════════════════════════════════════════════════════════════════ + +fn dispatchRest(method: std.http.Method, target: []const u8, body: []const u8, resp: []u8) Response { + const ok = std.http.Status.ok; + const bad = std.http.Status.bad_request; + + if (method == .GET and std.mem.eql(u8, target, "/health")) return .{ .status = ok, .body = healthJson(resp) }; + if (method == .GET and std.mem.eql(u8, target, "/types")) return .{ .status = ok, .body = TYPES_JSON }; + if (method == .POST and std.mem.eql(u8, target, "/sessions")) { + const slot = ffi.dap_init(); + if (slot < 0) return .{ .status = bad, .body = errorJson(resp, "no slots available", slot) }; + return .{ .status = ok, .body = sessionJson(slot, resp) }; + } + + if (std.mem.startsWith(u8, target, "/sessions/")) { + const slot_opt = pathSlot(std.mem.trimRight(u8, target, "/")); + + if (method == .GET and std.mem.endsWith(u8, target, "/state")) { + const slot = slot_opt orelse return .{ .status = bad, .body = errorJson(resp, "missing slot", -1) }; + return .{ .status = ok, .body = sessionJson(slot, resp) }; + } + if (method == .POST and std.mem.endsWith(u8, target, "/launch")) { + const slot = slot_opt orelse return .{ .status = bad, .body = errorJson(resp, "missing slot", -1) }; + const r = ffi.dap_launch(slot); + if (r < 0) return .{ .status = bad, .body = errorJson(resp, "invalid transition", r) }; + return .{ .status = ok, .body = sessionJson(slot, resp) }; + } + if (method == .POST and std.mem.endsWith(u8, target, "/configure")) { + const slot = slot_opt orelse return .{ .status = bad, .body = errorJson(resp, "missing slot", -1) }; + const r = ffi.dap_configure(slot); + if (r < 0) return .{ .status = bad, .body = errorJson(resp, "invalid transition", r) }; + return .{ .status = ok, .body = sessionJson(slot, resp) }; + } + if (method == .POST and std.mem.endsWith(u8, target, "/continue")) { + const slot = slot_opt orelse return .{ .status = bad, .body = errorJson(resp, "missing slot", -1) }; + const r = ffi.dap_continue(slot); + if (r < 0) return .{ .status = bad, .body = errorJson(resp, "invalid transition", r) }; + return .{ .status = ok, .body = sessionJson(slot, resp) }; + } + if (method == .POST and std.mem.endsWith(u8, target, "/stop")) { + const slot = slot_opt orelse return .{ .status = bad, .body = errorJson(resp, "missing slot", -1) }; + const reason = parseIntField(body, "reason") orelse 2; // default: step + const r = ffi.dap_stopped(slot, reason); + if (r < 0) return .{ .status = bad, .body = errorJson(resp, "invalid transition", r) }; + return .{ .status = ok, .body = sessionJson(slot, resp) }; + } + if (method == .POST and std.mem.endsWith(u8, target, "/terminate")) { + const slot = slot_opt orelse return .{ .status = bad, .body = errorJson(resp, "missing slot", -1) }; + const r = ffi.dap_terminate(slot); + if (r < 0) return .{ .status = bad, .body = errorJson(resp, "invalid transition", r) }; + return .{ .status = ok, .body = sessionJson(slot, resp) }; + } + if (method == .POST and std.mem.endsWith(u8, target, "/disconnect")) { + const slot = slot_opt orelse return .{ .status = bad, .body = errorJson(resp, "missing slot", -1) }; + const r = ffi.dap_disconnect(slot); + if (r < 0) return .{ .status = bad, .body = errorJson(resp, "invalid transition", r) }; + return .{ .status = ok, .body = sessionJson(slot, resp) }; + } + if (method == .POST and std.mem.endsWith(u8, target, "/breakpoint")) { + const slot = slot_opt orelse return .{ .status = bad, .body = errorJson(resp, "missing slot", -1) }; + const kind = parseIntField(body, "kind") orelse 1; // default: source + const r = ffi.dap_add_breakpoint(slot, kind); + if (r < 0) return .{ .status = bad, .body = errorJson(resp, "cannot set breakpoint (wrong state or limit reached)", r) }; + return .{ .status = ok, .body = sessionJson(slot, resp) }; + } + if (method == .DELETE) { + const slot = slot_opt orelse return .{ .status = bad, .body = errorJson(resp, "missing slot", -1) }; + const r = ffi.dap_release(slot); + if (r < 0) return .{ .status = bad, .body = errorJson(resp, "release failed", r) }; + return .{ + .status = ok, + .body = std.fmt.bufPrint(resp, \\{{"slot":{d},"released":true}}, .{slot}) catch resp[0..0], + }; + } + } + + return .{ .status = std.http.Status.not_found, .body = errorJson(resp, "not found", -404) }; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// gRPC-compat dispatch +// ═══════════════════════════════════════════════════════════════════════════ + +const GRPC_PREFIX = "/dap_mcp.DapService/"; + +fn dispatchGrpc(target: []const u8, body: []const u8, resp: []u8) Response { + const ok = std.http.Status.ok; + const bad = std.http.Status.bad_request; + + if (std.mem.eql(u8, target, "/proto")) return .{ .status = ok, .body = GRPC_PROTO, .content_type = "text/plain" }; + if (!std.mem.startsWith(u8, target, GRPC_PREFIX)) return .{ .status = std.http.Status.not_found, .body = errorJson(resp, "unknown gRPC path", -1) }; + + const rpc = target[GRPC_PREFIX.len..]; + + if (std.mem.eql(u8, rpc, "Health")) return .{ .status = ok, .body = healthJson(resp) }; + if (std.mem.eql(u8, rpc, "Types")) return .{ .status = ok, .body = TYPES_JSON }; + if (std.mem.eql(u8, rpc, "CreateSession")) { + const slot = ffi.dap_init(); + if (slot < 0) return .{ .status = bad, .body = errorJson(resp, "no slots available", slot) }; + return .{ .status = ok, .body = sessionJson(slot, resp) }; + } + + const slot = parseIntField(body, "slot") orelse return .{ .status = bad, .body = errorJson(resp, "missing slot", -1) }; + + if (std.mem.eql(u8, rpc, "GetState")) return .{ .status = ok, .body = sessionJson(slot, resp) }; + if (std.mem.eql(u8, rpc, "Launch")) { const r = ffi.dap_launch(slot); if (r < 0) return .{ .status = bad, .body = errorJson(resp, "invalid transition", r) }; return .{ .status = ok, .body = sessionJson(slot, resp) }; } + if (std.mem.eql(u8, rpc, "Configure")) { const r = ffi.dap_configure(slot); if (r < 0) return .{ .status = bad, .body = errorJson(resp, "invalid transition", r) }; return .{ .status = ok, .body = sessionJson(slot, resp) }; } + if (std.mem.eql(u8, rpc, "Continue")) { const r = ffi.dap_continue(slot); if (r < 0) return .{ .status = bad, .body = errorJson(resp, "invalid transition", r) }; return .{ .status = ok, .body = sessionJson(slot, resp) }; } + if (std.mem.eql(u8, rpc, "Terminate")) { const r = ffi.dap_terminate(slot); if (r < 0) return .{ .status = bad, .body = errorJson(resp, "invalid transition", r) }; return .{ .status = ok, .body = sessionJson(slot, resp) }; } + if (std.mem.eql(u8, rpc, "Disconnect")) { const r = ffi.dap_disconnect(slot); if (r < 0) return .{ .status = bad, .body = errorJson(resp, "invalid transition", r) }; return .{ .status = ok, .body = sessionJson(slot, resp) }; } + if (std.mem.eql(u8, rpc, "Stopped")) { + const reason = parseIntField(body, "reason") orelse 2; + const r = ffi.dap_stopped(slot, reason); + if (r < 0) return .{ .status = bad, .body = errorJson(resp, "invalid transition", r) }; + return .{ .status = ok, .body = sessionJson(slot, resp) }; + } + if (std.mem.eql(u8, rpc, "AddBreakpoint")) { + const kind = parseIntField(body, "kind") orelse 1; + const r = ffi.dap_add_breakpoint(slot, kind); + if (r < 0) return .{ .status = bad, .body = errorJson(resp, "cannot set breakpoint", r) }; + return .{ .status = ok, .body = sessionJson(slot, resp) }; + } + if (std.mem.eql(u8, rpc, "ReleaseSession")) { + const r = ffi.dap_release(slot); + if (r < 0) return .{ .status = bad, .body = errorJson(resp, "release failed", r) }; + return .{ .status = ok, .body = std.fmt.bufPrint(resp, \\{{"slot":{d},"released":true}}, .{slot}) catch resp[0..0] }; + } + + return .{ .status = std.http.Status.not_found, .body = errorJson(resp, "unknown gRPC method", -1) }; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// GraphQL dispatch (keyword-based, same pattern as lsp_adapter.zig) +// ═══════════════════════════════════════════════════════════════════════════ + +fn dispatchGraphql(q: []const u8, resp: []u8) Response { + const ok = std.http.Status.ok; + const bad = std.http.Status.bad_request; + const has = std.mem.indexOf; + + if (has(u8, q, "__schema") != null) return .{ .status = ok, .body = std.fmt.bufPrint(resp, \\{{"data":{{"__schema":{{"sdl":"{s}"}}}}}}, .{GRAPHQL_SCHEMA}) catch resp[0..0] }; + if (has(u8, q, "health") != null and has(u8, q, "mutation") == null) return .{ .status = ok, .body = std.fmt.bufPrint(resp, \\{{"data":{{"health":{s}}}}}, .{healthJson(resp)}) catch resp[0..0] }; + if (has(u8, q, "types") != null and has(u8, q, "mutation") == null) return .{ .status = ok, .body = std.fmt.bufPrint(resp, \\{{"data":{{"types":{s}}}}}, .{TYPES_JSON}) catch resp[0..0] }; + if (has(u8, q, "createSession") != null) { + const slot = ffi.dap_init(); + if (slot < 0) return .{ .status = bad, .body = errorJson(resp, "no slots available", slot) }; + return .{ .status = ok, .body = std.fmt.bufPrint(resp, \\{{"data":{{"createSession":{s}}}}}, .{sessionJson(slot, resp)}) catch resp[0..0] }; + } + + const slot = parseIntField(q, "slot") orelse return .{ .status = bad, .body = errorJson(resp, "could not determine slot", -1) }; + + if (has(u8, q, "addBreakpoint") != null) { const kind = parseIntField(q, "kind") orelse 1; const r = ffi.dap_add_breakpoint(slot, kind); if (r < 0) return .{ .status = bad, .body = errorJson(resp, "cannot set breakpoint", r) }; return .{ .status = ok, .body = std.fmt.bufPrint(resp, \\{{"data":{{"addBreakpoint":{s}}}}}, .{sessionJson(slot, resp)}) catch resp[0..0] }; } + if (has(u8, q, "stopped") != null) { const reason = parseIntField(q, "reason") orelse 2; const r = ffi.dap_stopped(slot, reason); if (r < 0) return .{ .status = bad, .body = errorJson(resp, "invalid transition", r) }; return .{ .status = ok, .body = std.fmt.bufPrint(resp, \\{{"data":{{"stopped":{s}}}}}, .{sessionJson(slot, resp)}) catch resp[0..0] }; } + if (has(u8, q, "configure") != null) { const r = ffi.dap_configure(slot); if (r < 0) return .{ .status = bad, .body = errorJson(resp, "invalid transition", r) }; return .{ .status = ok, .body = std.fmt.bufPrint(resp, \\{{"data":{{"configure":{s}}}}}, .{sessionJson(slot, resp)}) catch resp[0..0] }; } + if (has(u8, q, "launch") != null) { const r = ffi.dap_launch(slot); if (r < 0) return .{ .status = bad, .body = errorJson(resp, "invalid transition", r) }; return .{ .status = ok, .body = std.fmt.bufPrint(resp, \\{{"data":{{"launch":{s}}}}}, .{sessionJson(slot, resp)}) catch resp[0..0] }; } + if (has(u8, q, "terminate") != null) { const r = ffi.dap_terminate(slot); if (r < 0) return .{ .status = bad, .body = errorJson(resp, "invalid transition", r) }; return .{ .status = ok, .body = std.fmt.bufPrint(resp, \\{{"data":{{"terminate":{s}}}}}, .{sessionJson(slot, resp)}) catch resp[0..0] }; } + if (has(u8, q, "disconnect") != null) { const r = ffi.dap_disconnect(slot); if (r < 0) return .{ .status = bad, .body = errorJson(resp, "invalid transition", r) }; return .{ .status = ok, .body = std.fmt.bufPrint(resp, \\{{"data":{{"disconnect":{s}}}}}, .{sessionJson(slot, resp)}) catch resp[0..0] }; } + if (has(u8, q, "continue") != null) { const r = ffi.dap_continue(slot); if (r < 0) return .{ .status = bad, .body = errorJson(resp, "invalid transition", r) }; return .{ .status = ok, .body = std.fmt.bufPrint(resp, \\{{"data":{{"continue":{s}}}}}, .{sessionJson(slot, resp)}) catch resp[0..0] }; } + if (has(u8, q, "releaseSession") != null) { const r = ffi.dap_release(slot); if (r < 0) return .{ .status = bad, .body = errorJson(resp, "release failed", r) }; return .{ .status = ok, .body = std.fmt.bufPrint(resp, \\{{"data":{{"releaseSession":{{"slot":{d},"released":true}}}}}}, .{slot}) catch resp[0..0] }; } + if (has(u8, q, "session") != null) return .{ .status = ok, .body = std.fmt.bufPrint(resp, \\{{"data":{{"session":{s}}}}}, .{sessionJson(slot, resp)}) catch resp[0..0] }; + + return .{ .status = bad, .body = errorJson(resp, "unrecognised GraphQL operation", -1) }; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// HTTP connection handler + listener loop (shared pattern with lsp_adapter) +// ═══════════════════════════════════════════════════════════════════════════ + +const Protocol = enum { rest, grpc, graphql }; +const ListenerCtx = struct { listener: *std.net.Server, protocol: Protocol }; + +fn handleConnection(conn: std.net.Server.Connection, protocol: Protocol) void { + defer conn.stream.close(); + var read_buf: [8192]u8 = undefined; + var http_srv = std.http.Server.init(conn, &read_buf); + var request = http_srv.receiveHead() catch return; + + var body_buf: [262144]u8 = undefined; + var body_len: usize = 0; + if (request.head.content_length) |cl| { + const to_read: usize = @min(cl, body_buf.len); + var reader = request.reader() catch return; + body_len = reader.readAll(body_buf[0..to_read]) catch 0; + } + + var resp_buf: [4096]u8 = undefined; + const body = body_buf[0..body_len]; + + const resp: Response = switch (protocol) { + .rest => dispatchRest(request.head.method, request.head.target, body, &resp_buf), + .grpc => dispatchGrpc(request.head.target, body, &resp_buf), + .graphql => blk: { + if (request.head.method == .GET and std.mem.eql(u8, request.head.target, "/graphql/schema")) + break :blk .{ .status = .ok, .body = GRAPHQL_SCHEMA, .content_type = "text/plain" }; + break :blk dispatchGraphql(body, &resp_buf); + }, + }; + + const ct_header: std.http.Header = .{ .name = "content-type", .value = resp.content_type }; + const cors_header: std.http.Header = .{ .name = "access-control-allow-origin", .value = "*" }; + const grpc_status: std.http.Header = .{ .name = "grpc-status", .value = if (resp.status == .ok) "0" else "2" }; + + if (protocol == .grpc) { + request.respond(resp.body, .{ .status = resp.status, .extra_headers = &.{ ct_header, grpc_status } }) catch {}; + } else { + request.respond(resp.body, .{ .status = resp.status, .extra_headers = &.{ ct_header, cors_header } }) catch {}; + } +} + +fn listenLoop(ctx: ListenerCtx) void { + while (true) { + const conn = ctx.listener.accept() catch |err| { + std.log.err("accept error on {s} listener: {}", .{ @tagName(ctx.protocol), err }); + continue; + }; + handleConnection(conn, ctx.protocol); + } +} + +pub fn main() !void { + _ = ffi.boj_cartridge_init(); + + var rest_listener = try (try std.net.Address.parseIp4("0.0.0.0", REST_PORT)).listen(.{ .reuse_address = true }); + defer rest_listener.deinit(); + var grpc_listener = try (try std.net.Address.parseIp4("0.0.0.0", GRPC_PORT)).listen(.{ .reuse_address = true }); + defer grpc_listener.deinit(); + var gql_listener = try (try std.net.Address.parseIp4("0.0.0.0", GQL_PORT)).listen(.{ .reuse_address = true }); + defer gql_listener.deinit(); + + std.log.info("{s} REST :{d} gRPC :{d} GraphQL :{d}", .{ CARTRIDGE, REST_PORT, GRPC_PORT, GQL_PORT }); + + const t_rest = try std.Thread.spawn(.{}, listenLoop, .{ ListenerCtx{ .listener = &rest_listener, .protocol = .rest } }); + const t_grpc = try std.Thread.spawn(.{}, listenLoop, .{ ListenerCtx{ .listener = &grpc_listener, .protocol = .grpc } }); + const t_gql = try std.Thread.spawn(.{}, listenLoop, .{ ListenerCtx{ .listener = &gql_listener, .protocol = .graphql } }); + + t_rest.join(); + t_grpc.join(); + t_gql.join(); +} diff --git a/cartridges/cross-cutting/debug/dap-mcp/cartridge.json b/cartridges/cross-cutting/debug/dap-mcp/cartridge.json new file mode 100644 index 0000000..6e8b6d0 --- /dev/null +++ b/cartridges/cross-cutting/debug/dap-mcp/cartridge.json @@ -0,0 +1,370 @@ +{ + "$schema": "https://boj.dev/schemas/cartridge/v1.json", + "spdx": "MPL-2.0", + "copyright": "Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath)", + "name": "dap-mcp", + "version": "0.1.0", + "description": "Generic Debug Adapter Protocol (DAP) gateway. Spawns any DAP adapter as a persistent subprocess and exposes debug lifecycle operations (initialize, launch/attach, breakpoints, stepping, stack inspection, variable evaluation) as MCP tools.", + "domain": "Language Tools", + "tier": "Ayo", + "protocols": [ + "MCP", + "REST" + ], + "auth": { + "method": "none", + "env_var": null, + "credential_source": null + }, + "api": { + "base_url": "local://dap-mcp", + "content_type": "application/json" + }, + "tools": [ + { + "name": "dap_start", + "description": "Start a debug adapter subprocess and return a session ID. The adapter stays alive until dap_stop is called.", + "inputSchema": { + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "Debug adapter executable (e.g. 'node path/to/adapter.js', 'lldb-dap')" + }, + "args": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Additional command arguments" + }, + "cwd": { + "type": "string", + "description": "Working directory for the adapter process" + } + }, + "required": [ + "command" + ] + } + }, + { + "name": "dap_initialize", + "description": "Send the DAP initialize request to negotiate adapter capabilities. Must be called once after dap_start.", + "inputSchema": { + "type": "object", + "properties": { + "session_id": { + "type": "string", + "description": "Session ID from dap_start" + }, + "adapter_id": { + "type": "string", + "description": "Adapter identifier (e.g. 'lldb', 'codelldb', 'debugpy')" + }, + "locale": { + "type": "string", + "description": "Locale string (default: 'en-GB')" + } + }, + "required": [ + "session_id", + "adapter_id" + ] + } + }, + { + "name": "dap_launch", + "description": "Launch a program under the debug adapter. The adapter starts the debuggee and raises a stopped event at entry (if configured).", + "inputSchema": { + "type": "object", + "properties": { + "session_id": { + "type": "string", + "description": "Session ID" + }, + "program": { + "type": "string", + "description": "Path to the program to debug" + }, + "args": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Program arguments" + }, + "cwd": { + "type": "string", + "description": "Program working directory" + }, + "env": { + "type": "object", + "description": "Environment variables (key-value pairs)" + }, + "stop_on_entry": { + "type": "boolean", + "description": "Stop at program entry (default: false)" + } + }, + "required": [ + "session_id", + "program" + ] + } + }, + { + "name": "dap_attach", + "description": "Attach the debug adapter to a running process by PID or by a connection (e.g. localhost:port for remote debugging).", + "inputSchema": { + "type": "object", + "properties": { + "session_id": { + "type": "string", + "description": "Session ID" + }, + "pid": { + "type": "number", + "description": "Process ID to attach to" + }, + "host": { + "type": "string", + "description": "Remote debug host (for remote attach)" + }, + "port": { + "type": "number", + "description": "Remote debug port" + } + }, + "required": [ + "session_id" + ] + } + }, + { + "name": "dap_set_breakpoints", + "description": "Set breakpoints in a source file, replacing any previously set breakpoints for that file. Returns the verified breakpoint list.", + "inputSchema": { + "type": "object", + "properties": { + "session_id": { + "type": "string", + "description": "Session ID" + }, + "source_path": { + "type": "string", + "description": "Absolute path to the source file" + }, + "lines": { + "type": "array", + "items": { + "type": "number" + }, + "description": "1-based line numbers" + }, + "conditions": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Optional condition expressions (parallel array with lines)" + } + }, + "required": [ + "session_id", + "source_path", + "lines" + ] + } + }, + { + "name": "dap_continue", + "description": "Continue execution for a thread (or all threads) until the next breakpoint or program exit.", + "inputSchema": { + "type": "object", + "properties": { + "session_id": { + "type": "string", + "description": "Session ID" + }, + "thread_id": { + "type": "number", + "description": "Thread to continue (0 or omit for all threads)" + } + }, + "required": [ + "session_id" + ] + } + }, + { + "name": "dap_step_over", + "description": "Step over (next statement) for a thread. Execution resumes until the next line in the current scope.", + "inputSchema": { + "type": "object", + "properties": { + "session_id": { + "type": "string", + "description": "Session ID" + }, + "thread_id": { + "type": "number", + "description": "Thread to step" + }, + "granularity": { + "type": "string", + "enum": [ + "statement", + "line", + "instruction" + ], + "description": "Step granularity (default: statement)" + } + }, + "required": [ + "session_id", + "thread_id" + ] + } + }, + { + "name": "dap_step_in", + "description": "Step into the next function call for a thread.", + "inputSchema": { + "type": "object", + "properties": { + "session_id": { + "type": "string", + "description": "Session ID" + }, + "thread_id": { + "type": "number", + "description": "Thread to step" + } + }, + "required": [ + "session_id", + "thread_id" + ] + } + }, + { + "name": "dap_step_out", + "description": "Step out of the current function for a thread (run until function return).", + "inputSchema": { + "type": "object", + "properties": { + "session_id": { + "type": "string", + "description": "Session ID" + }, + "thread_id": { + "type": "number", + "description": "Thread to step out" + } + }, + "required": [ + "session_id", + "thread_id" + ] + } + }, + { + "name": "dap_stack_trace", + "description": "Get the call stack for a stopped thread. Returns an array of stack frames with source locations.", + "inputSchema": { + "type": "object", + "properties": { + "session_id": { + "type": "string", + "description": "Session ID" + }, + "thread_id": { + "type": "number", + "description": "Thread ID" + }, + "start_frame": { + "type": "number", + "description": "First frame to return (default: 0)" + }, + "levels": { + "type": "number", + "description": "Max frames to return (default: 20)" + } + }, + "required": [ + "session_id", + "thread_id" + ] + } + }, + { + "name": "dap_variables", + "description": "Get variables for a stack frame scope or an expandable variable. Returns an array of variable objects.", + "inputSchema": { + "type": "object", + "properties": { + "session_id": { + "type": "string", + "description": "Session ID" + }, + "variables_reference": { + "type": "number", + "description": "variablesReference from a stack frame scope or parent variable" + }, + "filter": { + "type": "string", + "enum": [ + "indexed", + "named" + ], + "description": "Filter for array vs object members (optional)" + }, + "start": { + "type": "number", + "description": "First variable (for paging)" + }, + "count": { + "type": "number", + "description": "Max variables to return" + } + }, + "required": [ + "session_id", + "variables_reference" + ] + } + }, + { + "name": "dap_stop", + "description": "Send the DAP disconnect request and terminate the debug adapter subprocess. The session ID is invalidated.", + "inputSchema": { + "type": "object", + "properties": { + "session_id": { + "type": "string", + "description": "Session ID to stop" + }, + "terminate_debuggee": { + "type": "boolean", + "description": "Kill the debugged program (default: true)" + } + }, + "required": [ + "session_id" + ] + } + } + ], + "ffi": { + "so_path": "ffi/zig-out/lib/libdap_mcp.so", + "abi_version": "ADR-0006", + "symbols": [ + "boj_cartridge_init", + "boj_cartridge_deinit", + "boj_cartridge_name", + "boj_cartridge_version", + "boj_cartridge_invoke" + ] + } +} diff --git a/cartridges/cross-cutting/debug/dap-mcp/ffi/README.adoc b/cartridges/cross-cutting/debug/dap-mcp/ffi/README.adoc new file mode 100644 index 0000000..7ce99ae --- /dev/null +++ b/cartridges/cross-cutting/debug/dap-mcp/ffi/README.adoc @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) += dap-mcp / ffi β€” Zig FFI layer +:orientation: shallow + +== Purpose + +C-ABI exports bridging this cartridge's Idris2 ABI to the runtime. Consumed by +the adapter for protocol dispatch and by the trunk loader for mount. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” produces `zig-out/lib/libdap.so`. +| `dap_ffi.zig` | C-ABI exports (18 exports, 5 inline tests, 348 lines). +|=== + +== Invariants + +* **Null-pointer rejection** on `[*c]const u8` arguments before any + dereference. +* **Negative return values** carry documented error meanings; zero is + success (matching the trunk error-code convention). +* **No allocator leaks** β€” every allocation path pairs with `defer free`. + +== Test/proof surface + +5 inline `test "..."` block(s) in `dap_ffi.zig`. Run with +`cd ffi && zig build test`. + +== Read-first + +. `dap_ffi.zig` β€” C-ABI signatures and guards. +. Trunk `ffi/zig/src/catalogue.zig` for how this `.so` is loaded. diff --git a/cartridges/cross-cutting/debug/dap-mcp/ffi/build.zig b/cartridges/cross-cutting/debug/dap-mcp/ffi/build.zig new file mode 100644 index 0000000..629bcc9 --- /dev/null +++ b/cartridges/cross-cutting/debug/dap-mcp/ffi/build.zig @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +// +// DAP-MCP Cartridge β€” Zig FFI build configuration (Zig 0.15+). + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + // Shared ADR-0006 invoke-shim module (relative path up to boj-server trunk). + + const shim_mod = b.addModule("cartridge_shim", .{ + + .root_source_file = b.path("../../../ffi/zig/src/cartridge_shim.zig"), + + .target = target, + + .optimize = optimize, + + }); + + const dap_mod = b.addModule("dap_ffi", .{ + .root_source_file = b.path("dap_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + dap_mod.addImport("cartridge_shim", shim_mod); + + // ── Tests ──────────────────────────────────────────────────────── + const dap_tests = b.addTest(.{ + .root_module = dap_mod, + }); + + const run_tests = b.addRunArtifact(dap_tests); + + const test_step = b.step("test", "Run dap-mcp FFI tests"); + test_step.dependOn(&run_tests.step); + + // ── Shared library ────────────────────────────────────────────── + const lib_mod = b.createModule(.{ + .root_source_file = b.path("dap_ffi.zig"), + .target = target, + .optimize = optimize, + }); + lib_mod.addImport("cartridge_shim", shim_mod); + + const lib = b.addLibrary(.{ + .name = "dap_mcp", + .root_module = lib_mod, + .linkage = .dynamic, + }); + b.installArtifact(lib); + + const lib_step = b.step("lib", "Build shared library"); + lib_step.dependOn(&lib.step); +} diff --git a/cartridges/cross-cutting/debug/dap-mcp/ffi/cartridge_shim.zig b/cartridges/cross-cutting/debug/dap-mcp/ffi/cartridge_shim.zig new file mode 100644 index 0000000..0e399db --- /dev/null +++ b/cartridges/cross-cutting/debug/dap-mcp/ffi/cartridge_shim.zig @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +// +// cartridge_shim.zig β€” Shared helpers for the ADR-0006 five-symbol +// cartridge ABI (`boj_cartridge_init / deinit / name / version / invoke`). +// +// The shim centralises the seven-code return convention, NUL-argument +// guards, tool-name comparison, and the buffer-too-small path so each +// cartridge's `boj_cartridge_invoke` can stay short β€” typically a tool +// table plus `shim.writeResult(...)`. +// +// Cartridges import this file by relative path (no build-graph change +// needed). Example: +// +// const shim = @import("../../../ffi/zig/src/cartridge_shim.zig"); +// +// export fn boj_cartridge_invoke( +// tool_name: [*c]const u8, +// json_args: [*c]const u8, +// out_buf: [*c]u8, +// in_out_len: [*c]usize, +// ) callconv(.c) i32 { +// _ = json_args; +// if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; +// const body = if (shim.toolIs(tool_name, "foo")) "{\"result\":{}}" +// else return shim.RC_UNKNOWN_TOOL; +// return shim.writeResult(out_buf, in_out_len, body); +// } + +const std = @import("std"); + +// ── Return codes (ADR-0006 Β§Return codes) ──────────────────────────── +// +// Frozen by ADR-0006. New failure modes compose these via the error +// JSON body β€” the integer surface does not grow without a follow-up ADR. + +pub const RC_SUCCESS: i32 = 0; +pub const RC_UNKNOWN_TOOL: i32 = -1; +pub const RC_BAD_ARGS: i32 = -2; +pub const RC_BUFFER_TOO_SMALL: i32 = -3; +pub const RC_RUNTIME_ERROR: i32 = -4; +pub const RC_PANIC: i32 = -5; +pub const RC_AUTH_DENIED: i32 = -6; + +// ── Invoke-path helpers ────────────────────────────────────────────── + +/// True if any of the three mandatory `boj_cartridge_invoke` output-path +/// pointers is null. Use at the top of every invoke to short-circuit to +/// `RC_BAD_ARGS`. +pub fn invokeArgsNull( + tool_name: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) bool { + return tool_name == null or out_buf == null or in_out_len == null; +} + +/// Compare a C-NUL-terminated tool-name pointer against a Zig string +/// literal. Caller must have already verified `tool_name` is non-null +/// (usually via `invokeArgsNull`). +pub fn toolIs(tool_name: [*c]const u8, expected: []const u8) bool { + const s = std.mem.span(@as([*:0]const u8, @ptrCast(tool_name))); + return std.mem.eql(u8, s, expected); +} + +/// Copy `body` into `out_buf[0..*in_out_len]` (as a capacity) and update +/// `*in_out_len` to the number of bytes written. Returns `RC_SUCCESS`. +/// +/// If `body.len` exceeds the current capacity stored in `*in_out_len`, +/// sets `*in_out_len` to the required size and returns +/// `RC_BUFFER_TOO_SMALL` β€” the caller is then expected to re-allocate +/// and retry, per ADR-0006 Β§Memory ownership. +/// +/// Caller must have already verified that `out_buf` and `in_out_len` +/// are non-null. +pub fn writeResult( + out_buf: [*c]u8, + in_out_len: [*c]usize, + body: []const u8, +) i32 { + const cap = in_out_len.*; + if (body.len > cap) { + in_out_len.* = body.len; + return RC_BUFFER_TOO_SMALL; + } + @memcpy(out_buf[0..body.len], body); + in_out_len.* = body.len; + return RC_SUCCESS; +} + +// ── Tests ──────────────────────────────────────────────────────────── + +test "writeResult: body fits, writes and sets length" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); + try std.testing.expectEqualStrings("hello", buf[0..len]); +} + +test "writeResult: too small returns -3 and sets required length" { + var buf: [2]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_BUFFER_TOO_SMALL, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: exact-fit succeeds" { + var buf: [5]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: empty body" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, ""); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 0), len); +} + +test "toolIs: matches and rejects" { + const name: [*:0]const u8 = "foo"; + try std.testing.expect(toolIs(@ptrCast(name), "foo")); + try std.testing.expect(!toolIs(@ptrCast(name), "bar")); + try std.testing.expect(!toolIs(@ptrCast(name), "foobar")); + try std.testing.expect(!toolIs(@ptrCast(name), "fo")); +} + +test "invokeArgsNull: detects each null slot" { + var buf: [4]u8 = undefined; + var len: usize = 4; + const name: [*:0]const u8 = "x"; + try std.testing.expect(!invokeArgsNull(@ptrCast(name), &buf, &len)); + try std.testing.expect(invokeArgsNull(null, &buf, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), null, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), &buf, null)); +} diff --git a/cartridges/cross-cutting/debug/dap-mcp/ffi/dap_ffi.zig b/cartridges/cross-cutting/debug/dap-mcp/ffi/dap_ffi.zig new file mode 100644 index 0000000..493eac3 --- /dev/null +++ b/cartridges/cross-cutting/debug/dap-mcp/ffi/dap_ffi.zig @@ -0,0 +1,438 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +// +// DAP-MCP Cartridge β€” Zig FFI bridge for Debug Adapter Protocol management. +// +// Implements the DAP session lifecycle from SafeDap.idr with breakpoint +// management and step-control state tracking. + +const std = @import("std"); + +// ═══════════════════════════════════════════════════════════════════════ +// Types (must match DapMcp.SafeDap encodings) +// ═══════════════════════════════════════════════════════════════════════ + +pub const DapState = enum(c_int) { + not_started = 0, + launched = 1, + configured = 2, + running = 3, + stopped = 4, + terminated = 5, + disconnected = 6, +}; + +pub const BreakpointKind = enum(c_int) { + source = 1, + function = 2, + data = 3, + instruction = 4, + exception = 5, +}; + +pub const StopReason = enum(c_int) { + breakpoint = 1, + step = 2, + exception = 3, + pause = 4, + entry = 5, + goto_target = 6, +}; + +pub const StepGranularity = enum(c_int) { + statement = 1, + line = 2, + instruction = 3, +}; + +// ═══════════════════════════════════════════════════════════════════════ +// DAP Session State Machine +// ═══════════════════════════════════════════════════════════════════════ + +const MAX_SESSIONS: usize = 8; +const MAX_BREAKPOINTS: usize = 32; + +const Breakpoint = struct { + active: bool = false, + kind: BreakpointKind = .source, + verified: bool = false, +}; + +const SessionSlot = struct { + active: bool = false, + state: DapState = .not_started, + stop_reason: StopReason = .breakpoint, + breakpoints: [MAX_BREAKPOINTS]Breakpoint = [_]Breakpoint{.{}} ** MAX_BREAKPOINTS, + breakpoint_count: usize = 0, +}; + +var sessions: [MAX_SESSIONS]SessionSlot = [_]SessionSlot{.{}} ** MAX_SESSIONS; + +var mutex: std.Thread.Mutex = .{}; + +/// Validate a state transition (matches Idris2 canTransition). +fn isValidTransition(from: DapState, to: DapState) bool { + return switch (from) { + .not_started => to == .launched, + .launched => to == .configured or to == .disconnected, + .configured => to == .running, + .running => to == .stopped or to == .terminated or to == .disconnected, + .stopped => to == .running or to == .terminated, + .terminated => to == .disconnected, + .disconnected => false, + }; +} + +/// Initialise a new DAP session. Returns slot index or -1 on failure. +pub export fn dap_init() c_int { + mutex.lock(); + defer mutex.unlock(); + for (&sessions, 0..) |*slot, i| { + if (!slot.active) { + slot.* = SessionSlot{}; + slot.active = true; + return @intCast(i); + } + } + return -1; +} + +/// Launch the debug adapter (NotStarted -> Launched). +pub export fn dap_launch(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + if (slot_idx < 0 or slot_idx >= MAX_SESSIONS) return -1; + const idx: usize = @intCast(slot_idx); + if (!sessions[idx].active) return -1; + if (!isValidTransition(sessions[idx].state, .launched)) return -2; + sessions[idx].state = .launched; + return 0; +} + +/// Send ConfigurationDone (Launched -> Configured). +pub export fn dap_configure(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + if (slot_idx < 0 or slot_idx >= MAX_SESSIONS) return -1; + const idx: usize = @intCast(slot_idx); + if (!sessions[idx].active) return -1; + if (!isValidTransition(sessions[idx].state, .configured)) return -2; + sessions[idx].state = .configured; + return 0; +} + +/// Start execution (Configured -> Running or Stopped -> Running for continue/step). +pub export fn dap_continue(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + if (slot_idx < 0 or slot_idx >= MAX_SESSIONS) return -1; + const idx: usize = @intCast(slot_idx); + if (!sessions[idx].active) return -1; + if (!isValidTransition(sessions[idx].state, .running)) return -2; + sessions[idx].state = .running; + return 0; +} + +/// Target stopped (Running -> Stopped). +pub export fn dap_stopped(slot_idx: c_int, reason: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + if (slot_idx < 0 or slot_idx >= MAX_SESSIONS) return -1; + const idx: usize = @intCast(slot_idx); + if (!sessions[idx].active) return -1; + if (!isValidTransition(sessions[idx].state, .stopped)) return -2; + sessions[idx].state = .stopped; + sessions[idx].stop_reason = @enumFromInt(reason); + return 0; +} + +/// Target terminated (Running/Stopped -> Terminated). +pub export fn dap_terminate(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + if (slot_idx < 0 or slot_idx >= MAX_SESSIONS) return -1; + const idx: usize = @intCast(slot_idx); + if (!sessions[idx].active) return -1; + if (!isValidTransition(sessions[idx].state, .terminated)) return -2; + sessions[idx].state = .terminated; + return 0; +} + +/// Disconnect adapter. +pub export fn dap_disconnect(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + if (slot_idx < 0 or slot_idx >= MAX_SESSIONS) return -1; + const idx: usize = @intCast(slot_idx); + if (!sessions[idx].active) return -1; + if (!isValidTransition(sessions[idx].state, .disconnected)) return -2; + sessions[idx].state = .disconnected; + return 0; +} + +/// Get the state of a session. +pub export fn dap_state(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + if (slot_idx < 0 or slot_idx >= MAX_SESSIONS) return -1; + const idx: usize = @intCast(slot_idx); + if (!sessions[idx].active) return @intFromEnum(DapState.not_started); + return @intFromEnum(sessions[idx].state); +} + +/// Can we inspect variables/stack? (only in stopped state) +pub export fn dap_can_inspect(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + if (slot_idx < 0 or slot_idx >= MAX_SESSIONS) return 0; + const idx: usize = @intCast(slot_idx); + return if (sessions[idx].active and sessions[idx].state == .stopped) 1 else 0; +} + +/// Can we set breakpoints? (in launched, configured, or stopped states) +pub export fn dap_can_set_breakpoints(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + if (slot_idx < 0 or slot_idx >= MAX_SESSIONS) return 0; + const idx: usize = @intCast(slot_idx); + if (!sessions[idx].active) return 0; + return switch (sessions[idx].state) { + .launched, .configured, .stopped => 1, + else => 0, + }; +} + +/// Add a breakpoint. Returns breakpoint index or -1 on failure. +pub export fn dap_add_breakpoint(slot_idx: c_int, kind: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + if (slot_idx < 0 or slot_idx >= MAX_SESSIONS) return -1; + const idx: usize = @intCast(slot_idx); + if (!sessions[idx].active) return -1; + // Inline breakpoint check to avoid deadlock (dap_can_set_breakpoints also locks) + const can_set = switch (sessions[idx].state) { + .launched, .configured, .stopped => true, + else => false, + }; + if (!can_set) return -2; + if (sessions[idx].breakpoint_count >= MAX_BREAKPOINTS) return -3; + + for (&sessions[idx].breakpoints, 0..) |*bp, bi| { + if (!bp.active) { + bp.active = true; + bp.kind = @enumFromInt(kind); + bp.verified = true; + sessions[idx].breakpoint_count += 1; + return @intCast(bi); + } + } + return -1; +} + +/// Validate a state transition (C-ABI export). +pub export fn dap_can_transition(from: c_int, to: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + const f: DapState = @enumFromInt(from); + const t: DapState = @enumFromInt(to); + return if (isValidTransition(f, t)) 1 else 0; +} + +/// Release a session slot. +pub export fn dap_release(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + if (slot_idx < 0 or slot_idx >= MAX_SESSIONS) return -1; + const idx: usize = @intCast(slot_idx); + if (!sessions[idx].active) return -1; + sessions[idx] = SessionSlot{}; + return 0; +} + +/// Reset all sessions. +pub export fn dap_reset_all() void { + mutex.lock(); + defer mutex.unlock(); + for (&sessions) |*slot| { + slot.* = SessionSlot{}; + } +} + +// ═══════════════════════════════════════════════════════════════════════ +// Standard Cartridge Interface +// ═══════════════════════════════════════════════════════════════════════ + +pub export fn boj_cartridge_init() c_int { + dap_reset_all(); + return 0; +} + +pub export fn boj_cartridge_deinit() void { + dap_reset_all(); +} + +pub export fn boj_cartridge_name() [*:0]const u8 { + mutex.lock(); + defer mutex.unlock(); + return "dap-mcp"; +} + +pub export fn boj_cartridge_version() [*:0]const u8 { + mutex.lock(); + defer mutex.unlock(); + return "0.1.0"; +} + +// ═══════════════════════════════════════════════════════════════════════ +// ADR-0006 dispatch (boj_cartridge_invoke, 5th standard symbol) +// ═══════════════════════════════════════════════════════════════════════ + +const shim = @import("cartridge_shim.zig"); + +/// Dispatch the cartridge.json MCP tools. Grade D Alpha β€” each arm +/// returns a stub JSON body shaped to the tool's intended response. +export fn boj_cartridge_invoke( + tool_name: [*c]const u8, + json_args: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) callconv(.c) i32 { + _ = json_args; + if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; + + const body: []const u8 = if (shim.toolIs(tool_name, "dap_start")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "dap_initialize")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "dap_launch")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "dap_attach")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "dap_set_breakpoints")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "dap_continue")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "dap_step_over")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "dap_step_in")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "dap_step_out")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "dap_stack_trace")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "dap_variables")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "dap_stop")) + "{\"result\":{\"status\":\"stub\"}}" +else + return shim.RC_UNKNOWN_TOOL; + + return shim.writeResult(out_buf, in_out_len, body); +} + +// ═══════════════════════════════════════════════════════════════════════ +// Tests +// ═══════════════════════════════════════════════════════════════════════ + +test "init and release DAP session" { + dap_reset_all(); + const slot = dap_init(); + try std.testing.expect(slot >= 0); + try std.testing.expectEqual(@as(c_int, @intFromEnum(DapState.not_started)), dap_state(slot)); + try std.testing.expectEqual(@as(c_int, 0), dap_release(slot)); +} + +test "full DAP debug lifecycle" { + dap_reset_all(); + const slot = dap_init(); + try std.testing.expectEqual(@as(c_int, 0), dap_launch(slot)); + // Set a breakpoint while launched + try std.testing.expect(dap_add_breakpoint(slot, @intFromEnum(BreakpointKind.source)) >= 0); + try std.testing.expectEqual(@as(c_int, 0), dap_configure(slot)); + try std.testing.expectEqual(@as(c_int, 0), dap_continue(slot)); + try std.testing.expectEqual(@as(c_int, @intFromEnum(DapState.running)), dap_state(slot)); + // Hit breakpoint + try std.testing.expectEqual(@as(c_int, 0), dap_stopped(slot, @intFromEnum(StopReason.breakpoint))); + try std.testing.expectEqual(@as(c_int, 1), dap_can_inspect(slot)); + // Continue + try std.testing.expectEqual(@as(c_int, 0), dap_continue(slot)); + try std.testing.expectEqual(@as(c_int, 0), dap_can_inspect(slot)); + // Terminate + try std.testing.expectEqual(@as(c_int, 0), dap_terminate(slot)); + try std.testing.expectEqual(@as(c_int, 0), dap_disconnect(slot)); +} + +test "cannot inspect while running" { + dap_reset_all(); + const slot = dap_init(); + _ = dap_launch(slot); + _ = dap_configure(slot); + _ = dap_continue(slot); + try std.testing.expectEqual(@as(c_int, 0), dap_can_inspect(slot)); +} + +test "DAP state transitions" { + try std.testing.expectEqual(@as(c_int, 1), dap_can_transition(0, 1)); // not_started -> launched + try std.testing.expectEqual(@as(c_int, 1), dap_can_transition(1, 2)); // launched -> configured + try std.testing.expectEqual(@as(c_int, 1), dap_can_transition(2, 3)); // configured -> running + try std.testing.expectEqual(@as(c_int, 1), dap_can_transition(3, 4)); // running -> stopped + try std.testing.expectEqual(@as(c_int, 1), dap_can_transition(4, 3)); // stopped -> running + try std.testing.expectEqual(@as(c_int, 1), dap_can_transition(3, 5)); // running -> terminated + try std.testing.expectEqual(@as(c_int, 1), dap_can_transition(5, 6)); // terminated -> disconnected + try std.testing.expectEqual(@as(c_int, 0), dap_can_transition(0, 3)); // not_started -> running (invalid) + try std.testing.expectEqual(@as(c_int, 0), dap_can_transition(6, 0)); // disconnected -> not_started (invalid) +} + +test "breakpoint management" { + dap_reset_all(); + const slot = dap_init(); + // Can't set breakpoints before launch + try std.testing.expectEqual(@as(c_int, 0), dap_can_set_breakpoints(slot)); + _ = dap_launch(slot); + try std.testing.expectEqual(@as(c_int, 1), dap_can_set_breakpoints(slot)); + const bp = dap_add_breakpoint(slot, @intFromEnum(BreakpointKind.function)); + try std.testing.expect(bp >= 0); +} + +// ═══════════════════════════════════════════════════════════════════════ +// ADR-0006 invoke dispatch tests +// ═══════════════════════════════════════════════════════════════════════ + +test "invoke: each declared tool succeeds" { + var buf: [256]u8 = undefined; + const tools = [_][]const u8{ + "dap_start", + "dap_initialize", + "dap_launch", + "dap_attach", + "dap_set_breakpoints", + "dap_continue", + "dap_step_over", + "dap_step_in", + "dap_step_out", + "dap_stack_trace", + "dap_variables", + "dap_stop", + }; + for (tools) |t| { + var len: usize = buf.len; + const rc = boj_cartridge_invoke(t.ptr, "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, 0), rc); + try std.testing.expect(std.mem.indexOf(u8, buf[0..len], "result") != null); + } +} + +test "invoke: unknown tool returns -1" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("nope", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -1), rc); +} + +test "invoke: buffer too small returns -3" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("dap_start", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -3), rc); + try std.testing.expect(len > 4); +} diff --git a/cartridges/cross-cutting/debug/dap-mcp/mod.js b/cartridges/cross-cutting/debug/dap-mcp/mod.js new file mode 100644 index 0000000..6859578 --- /dev/null +++ b/cartridges/cross-cutting/debug/dap-mcp/mod.js @@ -0,0 +1,321 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +// +// dap-mcp/mod.js -- Generic Debug Adapter Protocol (DAP) gateway cartridge. +// +// Manages persistent DAP adapter subprocesses communicating over JSON-RPC 2.0 +// with Content-Length framing (stdio transport, same as LSP). Each session +// runs one adapter subprocess that stays alive until dap_stop is called. +// +// Session lifecycle: +// dap_start β†’ dap_initialize β†’ dap_launch | dap_attach +// β†’ dap_set_breakpoints β†’ dap_continue | dap_step_over | dap_step_in | dap_step_out +// β†’ dap_stack_trace β†’ dap_variables +// β†’ dap_stop +// +// Auth: None required β€” local subprocess. +// +// Usage: import { handleTool } from "./mod.js"; + +// --------------------------------------------------------------------------- +// JsonRpcSession β€” persistent subprocess + JSON-RPC 2.0 / Content-Length framing +// (DAP uses the same transport as LSP; events replace LSP notifications here) +// --------------------------------------------------------------------------- + +class JsonRpcSession { + /** @type {Deno.ChildProcess} */ #proc; + /** @type {Map} */ + #pending = new Map(); + /** @type {Array} */ #events = []; + #nextSeq = 1; + #buf = new Uint8Array(0); + #closed = false; + static #MAX_EVENTS = 200; + static #REQUEST_TIMEOUT_MS = 30_000; + + constructor(proc) { + this.#proc = proc; + this.#drainLoop(); + } + + // -- Public API ----------------------------------------------------------- + + /** Send a DAP request and await the response body. */ + request(command, args) { + return new Promise((resolve, reject) => { + if (this.#closed) { reject(new Error("session closed")); return; } + const seq = this.#nextSeq++; + const msg = JSON.stringify({ seq, type: "request", command, arguments: args ?? {} }); + const header = `Content-Length: ${new TextEncoder().encode(msg).byteLength}\r\n\r\n`; + const timer = setTimeout(() => { + this.#pending.delete(seq); + reject(new Error(`DAP request '${command}' timed out after ${JsonRpcSession.#REQUEST_TIMEOUT_MS}ms`)); + }, JsonRpcSession.#REQUEST_TIMEOUT_MS); + this.#pending.set(seq, { resolve, reject, timer }); + this.#write(header + msg); + }); + } + + /** Return buffered events (DAP 'event' messages), optionally filtered by event name. */ + getEvents(event) { + return event + ? this.#events.filter(e => e.event === event) + : [...this.#events]; + } + + async close() { + if (this.#closed) return; + this.#closed = true; + for (const { reject, timer } of this.#pending.values()) { + clearTimeout(timer); + reject(new Error("session closed")); + } + this.#pending.clear(); + try { this.#proc.stdin.close(); } catch { /* ignore */ } + try { await this.#proc.status; } catch { /* ignore */ } + } + + // -- Private helpers ------------------------------------------------------ + + #write(text) { + try { + const writer = this.#proc.stdin.getWriter(); + writer.write(new TextEncoder().encode(text)).finally(() => writer.releaseLock()); + } catch { /* process may have died */ } + } + + async #drainLoop() { + const reader = this.#proc.stdout.getReader(); + const dec = new TextDecoder(); + try { + while (true) { + const { value, done } = await reader.read(); + if (done) break; + const combined = new Uint8Array(this.#buf.length + value.length); + combined.set(this.#buf); + combined.set(value, this.#buf.length); + this.#buf = combined; + this.#parseMessages(dec); + } + } catch { /* read error */ } + this.#closed = true; + } + + #parseMessages(dec) { + while (true) { + const text = dec.decode(this.#buf); + const sep = text.indexOf("\r\n\r\n"); + if (sep === -1) return; + const headerSection = text.slice(0, sep); + const match = /Content-Length:\s*(\d+)/i.exec(headerSection); + if (!match) { this.#buf = new Uint8Array(0); return; } + const bodyLen = parseInt(match[1], 10); + const headerBytes = new TextEncoder().encode(headerSection + "\r\n\r\n").byteLength; + if (this.#buf.length < headerBytes + bodyLen) return; + const bodyBytes = this.#buf.slice(headerBytes, headerBytes + bodyLen); + this.#buf = this.#buf.slice(headerBytes + bodyLen); + let msg; + try { msg = JSON.parse(dec.decode(bodyBytes)); } catch { continue; } + + if (msg.type === "response") { + const entry = this.#pending.get(msg.request_seq); + if (entry) { + clearTimeout(entry.timer); + this.#pending.delete(msg.request_seq); + if (!msg.success) entry.reject(Object.assign(new Error(msg.message ?? "DAP error"), { body: msg.body })); + else entry.resolve(msg.body ?? {}); + } + } else if (msg.type === "event") { + if (this.#events.length >= JsonRpcSession.#MAX_EVENTS) this.#events.shift(); + this.#events.push(msg); + } + } + } +} + +// --------------------------------------------------------------------------- +// Session registry +// --------------------------------------------------------------------------- + +/** @type {Map} */ +const SESSIONS = new Map(); + +function newSessionId() { + return `dap_${crypto.randomUUID().replace(/-/g, "").slice(0, 12)}`; +} + +function getSession(session_id) { + const entry = SESSIONS.get(session_id); + if (!entry) throw Object.assign(new Error(`Unknown DAP session: ${session_id}`), { status: 404 }); + return entry; +} + +// --------------------------------------------------------------------------- +// Tool handlers +// --------------------------------------------------------------------------- + +export async function handleTool(toolName, args) { + switch (toolName) { + + // -- dap_start ----------------------------------------------------------- + case "dap_start": { + const { command, args: extraArgs = [], cwd } = args; + if (!command) return { status: 400, data: { error: "command is required" } }; + + const cmdParts = command.trim().split(/\s+/); + const proc = new Deno.Command(cmdParts[0], { + args: [...cmdParts.slice(1), ...extraArgs], + cwd: cwd, + stdin: "piped", + stdout: "piped", + stderr: "null", + }).spawn(); + + const session = new JsonRpcSession(proc); + const session_id = newSessionId(); + SESSIONS.set(session_id, { + session, + meta: { command, initialized: false, launched: false }, + }); + return { status: 200, data: { session_id, message: `DAP adapter started: ${command}` } }; + } + + // -- dap_initialize ------------------------------------------------------ + case "dap_initialize": { + const { session_id, adapter_id, locale = "en-GB" } = args; + const { session, meta } = getSession(session_id); + const body = await session.request("initialize", { + adapterID: adapter_id, + clientID: "boj-dap-mcp", + clientName: "BoJ DAP MCP", + locale, + linesStartAt1: true, + columnsStartAt1: true, + pathFormat: "path", + supportsVariableType: true, + supportsVariablePaging: true, + supportsRunInTerminalRequest: false, + supportsProgressReporting: false, + }); + meta.initialized = true; + meta.capabilities = body; + return { status: 200, data: { capabilities: body } }; + } + + // -- dap_launch ---------------------------------------------------------- + case "dap_launch": { + const { session_id, program, args: progArgs = [], cwd, env, stop_on_entry = false } = args; + const { session, meta } = getSession(session_id); + const body = await session.request("launch", { + program, + args: progArgs, + cwd, + env, + stopOnEntry: stop_on_entry, + noDebug: false, + }); + meta.launched = true; + return { status: 200, data: body }; + } + + // -- dap_attach ---------------------------------------------------------- + case "dap_attach": { + const { session_id, pid, host, port } = args; + const { session, meta } = getSession(session_id); + const attachArgs = {}; + if (pid != null) attachArgs.pid = pid; + if (host) attachArgs.host = host; + if (port != null) attachArgs.port = port; + const body = await session.request("attach", attachArgs); + meta.launched = true; + return { status: 200, data: body }; + } + + // -- dap_set_breakpoints ------------------------------------------------- + case "dap_set_breakpoints": { + const { session_id, source_path, lines, conditions = [] } = args; + const { session } = getSession(session_id); + const breakpoints = lines.map((line, i) => { + const bp = { line }; + if (conditions[i]) bp.condition = conditions[i]; + return bp; + }); + const body = await session.request("setBreakpoints", { + source: { path: source_path }, + breakpoints, + }); + return { status: 200, data: { breakpoints: body.breakpoints ?? [] } }; + } + + // -- dap_continue -------------------------------------------------------- + case "dap_continue": { + const { session_id, thread_id = 0 } = args; + const { session } = getSession(session_id); + const body = await session.request("continue", { threadId: thread_id }); + return { status: 200, data: body }; + } + + // -- dap_step_over ------------------------------------------------------- + case "dap_step_over": { + const { session_id, thread_id, granularity = "statement" } = args; + const { session } = getSession(session_id); + const body = await session.request("next", { threadId: thread_id, granularity }); + return { status: 200, data: body }; + } + + // -- dap_step_in --------------------------------------------------------- + case "dap_step_in": { + const { session_id, thread_id } = args; + const { session } = getSession(session_id); + const body = await session.request("stepIn", { threadId: thread_id }); + return { status: 200, data: body }; + } + + // -- dap_step_out -------------------------------------------------------- + case "dap_step_out": { + const { session_id, thread_id } = args; + const { session } = getSession(session_id); + const body = await session.request("stepOut", { threadId: thread_id }); + return { status: 200, data: body }; + } + + // -- dap_stack_trace ----------------------------------------------------- + case "dap_stack_trace": { + const { session_id, thread_id, start_frame = 0, levels = 20 } = args; + const { session } = getSession(session_id); + const body = await session.request("stackTrace", { + threadId: thread_id, + startFrame: start_frame, + levels, + }); + return { status: 200, data: { frames: body.stackFrames ?? [], total: body.totalFrames ?? null } }; + } + + // -- dap_variables ------------------------------------------------------- + case "dap_variables": { + const { session_id, variables_reference, filter, start, count } = args; + const { session } = getSession(session_id); + const req = { variablesReference: variables_reference }; + if (filter) req.filter = filter; + if (start != null) req.start = start; + if (count != null) req.count = count; + const body = await session.request("variables", req); + return { status: 200, data: { variables: body.variables ?? [] } }; + } + + // -- dap_stop ------------------------------------------------------------ + case "dap_stop": { + const { session_id, terminate_debuggee = true } = args; + const entry = SESSIONS.get(session_id); + if (!entry) return { status: 404, data: { error: `Unknown session: ${session_id}` } }; + const { session } = entry; + try { await session.request("disconnect", { terminateDebuggee: terminate_debuggee }); } catch { /* ignore */ } + await session.close(); + SESSIONS.delete(session_id); + return { status: 200, data: { stopped: session_id } }; + } + + default: + return { status: 404, data: { error: `Unknown tool: ${toolName}` } }; + } +} diff --git a/cartridges/cross-cutting/debug/dap-mcp/panels/manifest.json b/cartridges/cross-cutting/debug/dap-mcp/panels/manifest.json new file mode 100644 index 0000000..2d27b43 --- /dev/null +++ b/cartridges/cross-cutting/debug/dap-mcp/panels/manifest.json @@ -0,0 +1,66 @@ +{ + "$schema": "https://panll.dev/schemas/panel-manifest/v1.json", + "spdx": "MPL-2.0", + "cartridge": "dap-mcp", + "domain": "Debug Adapter Protocol", + "version": "0.1.0", + "panels": [ + { + "id": "dap-status", + "title": "DAP Gateway Status", + "description": "Debug adapter proxy health and active debug sessions", + "type": "status-indicator", + "data_source": { + "endpoint": "/cartridge/dap-mcp/invoke", + "method": "POST", + "body": { "tool": "status" }, + "refresh_interval_ms": 5000 + }, + "widgets": [ + { + "type": "state-badge", + "field": "state", + "states": { + "ready": { "color": "#2ecc71", "icon": "bug" }, + "degraded": { "color": "#f39c12", "icon": "alert-circle" }, + "error": { "color": "#e74c3c", "icon": "alert-triangle" } + } + }, + { "type": "text", "field": "version", "label": "Version" } + ] + }, + { + "id": "dap-sessions", + "title": "Debug Sessions", + "description": "Active and recent debug sessions with their target runtimes", + "type": "metric", + "data_source": { + "endpoint": "/cartridge/dap-mcp/invoke", + "method": "POST", + "body": { "tool": "session_summary" }, + "refresh_interval_ms": 5000 + }, + "widgets": [ + { "type": "counter", "field": "active_sessions", "label": "Active Sessions", "icon": "play" }, + { "type": "counter", "field": "breakpoints_set", "label": "Breakpoints", "icon": "circle" }, + { "type": "counter", "field": "threads_paused", "label": "Threads Paused", "icon": "pause" } + ] + }, + { + "id": "dap-adapters", + "title": "Registered Adapters", + "description": "Available debug adapters and their supported languages", + "type": "metric", + "data_source": { + "endpoint": "/cartridge/dap-mcp/invoke", + "method": "POST", + "body": { "tool": "adapter_list" }, + "refresh_interval_ms": 30000 + }, + "widgets": [ + { "type": "counter", "field": "adapter_count", "label": "Adapters", "icon": "tool" }, + { "type": "counter", "field": "languages_supported", "label": "Languages", "icon": "code" } + ] + } + ] +} diff --git a/cartridges/cross-cutting/fleet/fleet-mcp/README.adoc b/cartridges/cross-cutting/fleet/fleet-mcp/README.adoc new file mode 100644 index 0000000..43fa56f --- /dev/null +++ b/cartridges/cross-cutting/fleet/fleet-mcp/README.adoc @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) += fleet-mcp +Jonathan D.A. Jewell +:spdx: MPL-2.0 +:tier: Ayo +:domain: ci +:protocols: MCP, REST + +== Overview + +gitbot-fleet gate compliance tracker + +== Tools (5) + +[cols="2,4"] +|=== +| Tool | Description + +| `fleet_record_gate` | Record a gate result for a bot +| `fleet_bot_status` | Get status of a bot +| `fleet_gate_score` | Get gate compliance score for a bot +| `fleet_has_mandatory` | Check all mandatory gates passed +| `fleet_fleet_status` | Get status of all bots in the fleet +|=== + +== Architecture + +Three-layer stack per the BoJ cartridge standard: + +[cols="1,1,3"] +|=== +| Layer | Language | Purpose + +| ABI | Idris2 | Formally verified state machine with dependent-type proofs +| FFI | Zig | C-ABI implementation: session pool, action metrics, thread safety +| Adapter| Zig | Adapter bridge to the BoJ unified adapter protocol (zig predecessors sidelined) +|=== + +== Building + +[source,bash] +---- +# ABI (Idris2) +cd abi && idris2 --check *.idr + +# FFI (Zig) +cd ffi && zig build +cd ffi && zig build test + +# Adapter (Zig) +cd adapter && zig build +---- + +== Status + +Alpha (CRG D). Cartridge metadata declared in `cartridge.json`; 5 tool(s) exposed. +Not yet dogfooded externally. See `docs/READINESS.md` at repo root for the +project-wide grade table and `docs/practice/DOGFOOD-LOG.adoc` for usage evidence. diff --git a/cartridges/cross-cutting/fleet/fleet-mcp/abi/FleetMcp/SafeFleet.idr b/cartridges/cross-cutting/fleet/fleet-mcp/abi/FleetMcp/SafeFleet.idr new file mode 100644 index 0000000..6044332 --- /dev/null +++ b/cartridges/cross-cutting/fleet/fleet-mcp/abi/FleetMcp/SafeFleet.idr @@ -0,0 +1,176 @@ +-- SPDX-License-Identifier: MPL-2.0 +-- Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +||| FleetMcp.SafeFleet: Formally verified gitbot fleet orchestration. +||| +||| Cartridge: fleet-mcp +||| Matrix cell: Fleet domain x {MCP, Fleet, gRPC} protocols +||| +||| This module defines the 6-bot gate policy for repository quality +||| and the FleetCertified proof type that ensures all mandatory +||| gates have been passed before a release is allowed. +||| +||| The six bots: +||| Rhodibot β€” Identity & structure validation +||| Echidnabot β€” Formal verification (flags believe_me, Admitted, sorry) +||| Sustainabot β€” Eco/economic efficiency +||| Panicbot β€” Static security audit +||| Glambot β€” Presentation & documentation +||| Seambot β€” Integration & compatibility +module FleetMcp.SafeFleet + +import Data.List + +%default total + +-- ═══════════════════════════════════════════════════════════════════════════ +-- Bot Gates +-- ═══════════════════════════════════════════════════════════════════════════ + +||| The six quality gates for hyperpolymath repositories. +||| Each gate is a bot that validates a specific aspect of code quality. +public export +data BotGate + = Rhodibot -- Identity & structure (RSR compliance, file layout) + | Echidnabot -- Formal verification (believe_me, Admitted, sorry) + | Sustainabot -- Eco/economic efficiency (dependency health, bloat) + | Panicbot -- Static security audit (OWASP, secrets, CVEs) + | Glambot -- Presentation (docs, README, TOPOLOGY, formatting) + | Seambot -- Integration (CI/CD, cross-repo compat, API stability) + +||| Equality for bot gates (needed for list membership). +public export +Eq BotGate where + Rhodibot == Rhodibot = True + Echidnabot == Echidnabot = True + Sustainabot == Sustainabot = True + Panicbot == Panicbot = True + Glambot == Glambot = True + Seambot == Seambot = True + _ == _ = False + +||| C-ABI encoding: bot gate to integer. +public export +gateToInt : BotGate -> Int +gateToInt Rhodibot = 1 +gateToInt Echidnabot = 2 +gateToInt Sustainabot = 3 +gateToInt Panicbot = 4 +gateToInt Glambot = 5 +gateToInt Seambot = 6 + +||| C-ABI decoding: integer to bot gate. +public export +intToGate : Int -> Maybe BotGate +intToGate 1 = Just Rhodibot +intToGate 2 = Just Echidnabot +intToGate 3 = Just Sustainabot +intToGate 4 = Just Panicbot +intToGate 5 = Just Glambot +intToGate 6 = Just Seambot +intToGate _ = Nothing + +-- ═══════════════════════════════════════════════════════════════════════════ +-- Repository Health +-- ═══════════════════════════════════════════════════════════════════════════ + +||| Repository health status. +public export +data RepoStatus = Unscanned | Scanning | Healthy | Degraded | Blocked + +||| Equality for repo status. +public export +Eq RepoStatus where + Unscanned == Unscanned = True + Scanning == Scanning = True + Healthy == Healthy = True + Degraded == Degraded = True + Blocked == Blocked = True + _ == _ = False + +||| C-ABI encoding. +public export +repoStatusToInt : RepoStatus -> Int +repoStatusToInt Unscanned = 0 +repoStatusToInt Scanning = 1 +repoStatusToInt Healthy = 2 +repoStatusToInt Degraded = 3 +repoStatusToInt Blocked = 4 + +-- ═══════════════════════════════════════════════════════════════════════════ +-- Fleet Certification (the proof) +-- ═══════════════════════════════════════════════════════════════════════════ + +||| Formal proof that a repository has passed ALL six quality gates. +||| This is the fleet equivalent of IsUnbreakable β€” you cannot release +||| without all six bots signing off. +public export +data FleetCertified : (passedGates : List BotGate) -> Type where + FullyVerified : FleetCertified [Rhodibot, Echidnabot, Sustainabot, + Panicbot, Glambot, Seambot] + +||| The three mandatory gates (minimum for any release). +||| Rhodibot (structure), Echidnabot (verification), Panicbot (security). +public export +mandatoryGates : List BotGate +mandatoryGates = [Rhodibot, Echidnabot, Panicbot] + +||| Check if the mandatory gates have been passed. +public export +hasMandatoryGates : List BotGate -> Bool +hasMandatoryGates gates = all (\g => elem g gates) mandatoryGates + +||| Check if ALL six gates have been passed (full certification). +public export +hasAllGates : List BotGate -> Bool +hasAllGates gates = all (\g => elem g gates) + [Rhodibot, Echidnabot, Sustainabot, Panicbot, Glambot, Seambot] + +||| Determine repo status from passed gates. +public export +deriveStatus : List BotGate -> RepoStatus +deriveStatus gates = + if hasAllGates gates then Healthy + else if hasMandatoryGates gates then Degraded + else if length gates > 0 then Scanning + else Unscanned + +-- ═══════════════════════════════════════════════════════════════════════════ +-- Scan Result +-- ═══════════════════════════════════════════════════════════════════════════ + +||| Result of a single bot gate scan. +public export +record GateScanResult where + constructor MkGateScan + gate : BotGate + passed : Bool + score : Int -- 0-100 quality score + message : String -- Human-readable summary + +||| Extract passed gates from a list of scan results. +public export +passedGates : List GateScanResult -> List BotGate +passedGates [] = [] +passedGates (r :: rs) = + if passed r + then gate r :: passedGates rs + else passedGates rs + +-- ═══════════════════════════════════════════════════════════════════════════ +-- C-ABI Export +-- ═══════════════════════════════════════════════════════════════════════════ + +||| FFI: Check if a set of gate integers represents a releasable state. +||| Takes up to 6 gate ints (-1 for unused), returns 1 if releasable, 0 if not. +export +fleet_can_release : Int -> Int -> Int -> Int -> Int -> Int -> Int +fleet_can_release g1 g2 g3 g4 g5 g6 = + let gates = mapMaybe intToGate [g1, g2, g3, g4, g5, g6] + in if hasMandatoryGates gates then 1 else 0 + +||| FFI: Derive repo status from gate integers. +export +fleet_derive_status : Int -> Int -> Int -> Int -> Int -> Int -> Int +fleet_derive_status g1 g2 g3 g4 g5 g6 = + let gates = mapMaybe intToGate [g1, g2, g3, g4, g5, g6] + in repoStatusToInt (deriveStatus gates) diff --git a/cartridges/cross-cutting/fleet/fleet-mcp/abi/README.adoc b/cartridges/cross-cutting/fleet/fleet-mcp/abi/README.adoc new file mode 100644 index 0000000..709f8ee --- /dev/null +++ b/cartridges/cross-cutting/fleet/fleet-mcp/abi/README.adoc @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) += fleet-mcp / abi β€” Idris2 ABI layer +:orientation: deep + +== Purpose + +Encodes the **six quality gates** (Rhodibot, Echidnabot, Sustainabot, +Panicbot, Glambot, Seambot) and a `FleetCertified` indexed type that is +inhabited only when every gate has passed. `deriveStatus` folds a list of +gate results into one of four `RepoStatus` values so the runtime can publish +a repo's health in one byte. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `fleet-mcp.ipkg` | Idris2 package descriptor. `modules = FleetMcp.SafeFleet`, `depends = base, contrib`. +| `FleetMcp/SafeFleet.idr` | `BotGate` enum, `RepoStatus` enum, the indexed type `FleetCertified` with single constructor `FullyVerified`, record `GateScanResult`, helper predicates `hasMandatoryGates` and `hasAllGates`, and `deriveStatus`. +|=== + +== Invariants + +* **Mandatory subset** = [`Rhodibot`, `Echidnabot`, `Panicbot`] (line 115). + A release requires *all three* to pass. +* **Status derivation** (lines 131–135): + * all six pass β†’ `Healthy` + * mandatory three pass but not all six β†’ `Degraded` + * any gate passed β†’ `Scanning` + * none passed β†’ `Unscanned` +* **Single constructor** for `FleetCertified`: `FullyVerified` exists only + when all six gates have produced a pass result. You cannot fabricate a + `FleetCertified` by pattern-matching anything else. +* Gate-integer encoding excludes `-1` via `mapMaybe` (lines 168, 175). + +== Test/proof surface + +Type-check only via `.ipkg`. No inline `test` blocks. + +== Read-first + +. Lines 25–60 β€” `BotGate` enum and its integer encoding. +. Lines 100–127 β€” `FleetCertified` type, `hasMandatoryGates`, `hasAllGates`. +. Lines 143–158 β€” `GateScanResult`, `passedGates`, and `deriveStatus`. diff --git a/cartridges/cross-cutting/fleet/fleet-mcp/abi/fleet-mcp.ipkg b/cartridges/cross-cutting/fleet/fleet-mcp/abi/fleet-mcp.ipkg new file mode 100644 index 0000000..8a6d395 --- /dev/null +++ b/cartridges/cross-cutting/fleet/fleet-mcp/abi/fleet-mcp.ipkg @@ -0,0 +1,11 @@ +-- SPDX-License-Identifier: MPL-2.0 +package fleetmcp + +authors = "Jonathan D.A. Jewell" +version = 0.1.0 +license = "MPL-2.0" +brief = "Gitbot fleet orchestration cartridge β€” 6-bot gate policy" + +sourcedir = "." +modules = FleetMcp.SafeFleet +depends = base, contrib diff --git a/cartridges/cross-cutting/fleet/fleet-mcp/adapter/README.adoc b/cartridges/cross-cutting/fleet/fleet-mcp/adapter/README.adoc new file mode 100644 index 0000000..90ff37e --- /dev/null +++ b/cartridges/cross-cutting/fleet/fleet-mcp/adapter/README.adoc @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) += fleet-mcp / adapter β€” REST/gRPC/GraphQL bridge +:orientation: deep + +== Purpose + +Exposes the fleet-mcp FFI over three protocols: REST on **9235**, gRPC-compat +on **9236**, GraphQL on **9237**. Stateless; all gate state lives behind the +FFI mutex in `../ffi/`. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph for the adapter binary. +| `fleet_adapter.zig` | `dispatch` routes tool names to FFI calls. Tools: `fleet_record_gate`, `fleet_bot_status`, `fleet_gate_score`, `fleet_has_mandatory`, `fleet_fleet_status`. +| `SIDELINED-fleet_adapter.v.adoc` | Archived zig predecessor. Not built. +|=== + +== Invariants + +* **Stateless**; adapter holds no gate state of its own. +* One gate-query per HTTP request; no pipelining. + +== Test/proof surface + +No inline tests; correctness lives in the FFI. + +== Read-first + +. Lines 27–44 β€” `dispatch` routing. +. Lines 47–78 β€” protocol routers (REST/gRPC/GraphQL). diff --git a/cartridges/cross-cutting/fleet/fleet-mcp/adapter/build.zig b/cartridges/cross-cutting/fleet/fleet-mcp/adapter/build.zig new file mode 100644 index 0000000..098f02a --- /dev/null +++ b/cartridges/cross-cutting/fleet/fleet-mcp/adapter/build.zig @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +// +// fleet-mcp/adapter/build.zig + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const ffi_mod = b.createModule(.{ + .root_source_file = b.path("../ffi/fleet_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + const adapter = b.addExecutable(.{ + .name = "fleet_adapter", + .root_source_file = b.path("fleet_adapter.zig"), + .target = target, + .optimize = optimize, + }); + adapter.root_module.addImport("fleet_ffi", ffi_mod); + b.installArtifact(adapter); +} diff --git a/cartridges/cross-cutting/fleet/fleet-mcp/adapter/fleet_adapter.zig b/cartridges/cross-cutting/fleet/fleet-mcp/adapter/fleet_adapter.zig new file mode 100644 index 0000000..966315c --- /dev/null +++ b/cartridges/cross-cutting/fleet/fleet-mcp/adapter/fleet_adapter.zig @@ -0,0 +1,134 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +// +// fleet-mcp/adapter/fleet_adapter.zig +// +// Three-protocol BoJ adapter: REST (port 9235), gRPC-compat (port 9236), +// GraphQL (port 9237). +// Replaces the banned zig adapter (fleet_adapter.v). + +const std = @import("std"); +const ffi = @import("fleet_ffi"); + +const REST_PORT: u16 = 9235; +const GRPC_PORT: u16 = 9236; +const GQL_PORT: u16 = 9237; + +const Response = struct { status: u16, body: []const u8 }; + +fn okJson(buf: []u8, msg: []const u8) []u8 { + return std.fmt.bufPrint(buf, "{{\"success\":true,\"message\":\"{s}\"}}", .{msg}) catch buf[0..0]; +} + +fn errJson(buf: []u8, msg: []const u8) []u8 { + return std.fmt.bufPrint(buf, "{{\"success\":false,\"error\":\"{s}\"}}", .{msg}) catch buf[0..0]; +} + +fn dispatch(tool: []const u8, _body: []const u8, resp: []u8) Response { + _ = _body; + if (std.mem.eql(u8, tool, "fleet_record_gate")) { + return .{ .status = 200, .body = okJson(resp, "fleet_record_gate forwarded") }; + } + if (std.mem.eql(u8, tool, "fleet_bot_status")) { + return .{ .status = 200, .body = okJson(resp, "fleet_bot_status forwarded") }; + } + if (std.mem.eql(u8, tool, "fleet_gate_score")) { + return .{ .status = 200, .body = okJson(resp, "fleet_gate_score forwarded") }; + } + if (std.mem.eql(u8, tool, "fleet_has_mandatory")) { + return .{ .status = 200, .body = okJson(resp, "fleet_has_mandatory forwarded") }; + } + if (std.mem.eql(u8, tool, "fleet_fleet_status")) { + return .{ .status = 200, .body = okJson(resp, "fleet_fleet_status forwarded") }; + } + return .{ .status = 404, .body = errJson(resp, "unknown tool") }; +} + +fn dispatchRest(path: []const u8, body: []const u8, resp: []u8) Response { + // Expect /tools/ + const prefix = "/tools/"; + if (std.mem.startsWith(u8, path, prefix)) { + const tool = path[prefix.len..]; + return dispatch(tool, body, resp); + } + return .{ .status = 404, .body = errJson(resp, "not found") }; +} + +fn dispatchGrpc(path: []const u8, body: []const u8, resp: []u8) Response { + // Expect // β€” derive tool from Method + var it = std.mem.splitScalar(u8, path, '/'); + _ = it.next(); // leading empty + _ = it.next(); // service + const method = it.next() orelse return .{ .status = 404, .body = errJson(resp, "bad gRPC path") }; + return dispatch(method, body, resp); +} + +fn dispatchGraphql(body: []const u8, resp: []u8) Response { + if (std.mem.indexOf(u8, body, "fleet_record_gate") != null) + return dispatch("fleet_record_gate", body, resp); + if (std.mem.indexOf(u8, body, "fleet_bot_status") != null) + return dispatch("fleet_bot_status", body, resp); + if (std.mem.indexOf(u8, body, "fleet_gate_score") != null) + return dispatch("fleet_gate_score", body, resp); + if (std.mem.indexOf(u8, body, "fleet_has_mandatory") != null) + return dispatch("fleet_has_mandatory", body, resp); + if (std.mem.indexOf(u8, body, "fleet_fleet_status") != null) + return dispatch("fleet_fleet_status", body, resp); + return .{ .status = 400, .body = errJson(resp, "unrecognised GraphQL operation") }; +} + +fn handleConnection(stream: std.net.Stream, port: u16) void { + defer stream.close(); + var buf: [4096]u8 = undefined; + var resp_buf: [4096]u8 = undefined; + const n = stream.read(&buf) catch return; + const req = buf[0..n]; + const result = switch (port) { + REST_PORT => blk: { + // Parse HTTP/1.1: first line = METHOD PATH HTTP/x.y + var lines = std.mem.splitScalar(u8, req, '\n'); + const first = lines.next() orelse break :blk Response{ .status = 400, .body = "" }; + var parts = std.mem.splitScalar(u8, std.mem.trim(u8, first, "\r"), ' '); + _ = parts.next(); // method + const path = parts.next() orelse break :blk Response{ .status = 400, .body = "" }; + break :blk dispatchRest(path, req, &resp_buf); + }, + GRPC_PORT => blk: { + var lines = std.mem.splitScalar(u8, req, '\n'); + const first = lines.next() orelse break :blk Response{ .status = 400, .body = "" }; + var parts = std.mem.splitScalar(u8, std.mem.trim(u8, first, "\r"), ' '); + _ = parts.next(); + const path = parts.next() orelse break :blk Response{ .status = 400, .body = "" }; + break :blk dispatchGrpc(path, req, &resp_buf); + }, + GQL_PORT => dispatchGraphql(req, &resp_buf), + else => Response{ .status = 500, .body = "" }, + }; + var http_resp: [512]u8 = undefined; + const http = std.fmt.bufPrint(&http_resp, + "HTTP/1.1 {d} OK\r\nContent-Length: {d}\r\nContent-Type: application/json\r\n\r\n", + .{ result.status, result.body.len }) catch return; + _ = stream.write(http) catch {}; + _ = stream.write(result.body) catch {}; +} + +fn listenLoop(port: u16) !void { + const addr = std.net.Address.initIp4(.{ 127, 0, 0, 1 }, port); + var server = try addr.listen(.{ .reuse_address = true }); + defer server.deinit(); + while (true) { + const conn = try server.accept(); + const t = try std.Thread.spawn(.{}, handleConnection, .{ conn.stream, port }); + t.detach(); + } +} + +pub fn main() !void { + ffi.fleet_init(); + const t1 = try std.Thread.spawn(.{}, listenLoop, .{REST_PORT}); + const t2 = try std.Thread.spawn(.{}, listenLoop, .{GRPC_PORT}); + const t3 = try std.Thread.spawn(.{}, listenLoop, .{GQL_PORT}); + t1.join(); + t2.join(); + t3.join(); +} diff --git a/cartridges/cross-cutting/fleet/fleet-mcp/cartridge.json b/cartridges/cross-cutting/fleet/fleet-mcp/cartridge.json new file mode 100644 index 0000000..37b79ac --- /dev/null +++ b/cartridges/cross-cutting/fleet/fleet-mcp/cartridge.json @@ -0,0 +1,118 @@ +{ + "$schema": "https://boj.dev/schemas/cartridge/v1.json", + "spdx": "MPL-2.0", + "copyright": "Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) ", + "name": "fleet-mcp", + "version": "0.1.0", + "description": "gitbot-fleet gate compliance tracker", + "domain": "ci", + "tier": "Ayo", + "protocols": [ + "MCP", + "REST" + ], + "auth": { + "method": "none", + "env_var": null, + "credential_source": null + }, + "api": { + "base_url": "local://fleet-mcp", + "content_type": "application/json" + }, + "tools": [ + { + "name": "fleet_record_gate", + "description": "Record a gate result for a bot", + "inputSchema": { + "type": "object", + "properties": { + "bot_name": { + "type": "string", + "description": "Bot: rhodibot|echidnabot|sustainabot|panicbot|glambot|seambot" + }, + "gate": { + "type": "string", + "description": "Gate name" + }, + "passed": { + "type": "boolean", + "description": "Gate passed" + } + }, + "required": [ + "bot_name", + "gate", + "passed" + ] + } + }, + { + "name": "fleet_bot_status", + "description": "Get status of a bot", + "inputSchema": { + "type": "object", + "properties": { + "bot_name": { + "type": "string", + "description": "Bot name" + } + }, + "required": [ + "bot_name" + ] + } + }, + { + "name": "fleet_gate_score", + "description": "Get gate compliance score for a bot", + "inputSchema": { + "type": "object", + "properties": { + "bot_name": { + "type": "string", + "description": "Bot name" + } + }, + "required": [ + "bot_name" + ] + } + }, + { + "name": "fleet_has_mandatory", + "description": "Check all mandatory gates passed", + "inputSchema": { + "type": "object", + "properties": { + "bot_name": { + "type": "string", + "description": "Bot name" + } + }, + "required": [ + "bot_name" + ] + } + }, + { + "name": "fleet_fleet_status", + "description": "Get status of all bots in the fleet", + "inputSchema": { + "type": "object", + "properties": {} + } + } + ], + "ffi": { + "so_path": "ffi/zig-out/lib/libfleet_mcp.so", + "abi_version": "ADR-0006", + "symbols": [ + "boj_cartridge_init", + "boj_cartridge_deinit", + "boj_cartridge_name", + "boj_cartridge_version", + "boj_cartridge_invoke" + ] + } +} diff --git a/cartridges/cross-cutting/fleet/fleet-mcp/ffi/README.adoc b/cartridges/cross-cutting/fleet/fleet-mcp/ffi/README.adoc new file mode 100644 index 0000000..bbdd96b --- /dev/null +++ b/cartridges/cross-cutting/fleet/fleet-mcp/ffi/README.adoc @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) += fleet-mcp / ffi β€” Zig FFI layer +:orientation: deep + +== Purpose + +Runtime gate tracker. Mutex-guarded arrays hold per-gate pass/fail booleans +and per-gate scores (0–100). Exposes the ABI's three predicates +(`fleet_has_mandatory`, `fleet_has_all`, derived `fleet_status`) over the +C-ABI so the BoJ server can ask "is this repo release-ready?" with one call. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph (shared library + test binary). +| `fleet_ffi.zig` | Gate-state arrays, mutex, the C-ABI exports `fleet_record_gate`, `fleet_has_mandatory`, `fleet_has_all`, `fleet_status`, `fleet_gate_score`, and the four `boj_cartridge_*` symbols. +| `zig-out/` | Build artefacts (gitignored). +|=== + +== Invariants + +* **Mutex discipline** on every gate read/write (lines 45, 53, 64, 72, 82, 102). +* **Mandatory** = indices 0, 1, 3 (Rhodibot, Echidnabot, Panicbot). The + check lives at line 68 β€” changing mandatory composition means changing + those three index constants. +* **Deadlock avoidance.** `fleet_status` inlines the mandatory/all checks + (lines 85–98) rather than calling `fleet_has_mandatory` / `fleet_has_all` + which would re-acquire the mutex. +* Gate indices arriving from callers use the 1–6 range from the ABI; + internal arrays are 0–5. + +== Test/proof surface + +4 inline `test "..."` blocks (lines 143–185): + +* initial state β€” no gates recorded, status is `Unscanned`. +* mandatory gates insufficient until Panicbot is added (tests the 0/1/3 + triple specifically). +* all six gates β†’ `Healthy`. +* failed gate prevents `Healthy` even when mandatory still satisfied. + +Run with `cd ffi && zig build test`. + +== Read-first + +. Lines 37–49 β€” gate-result arrays, scores, and the mutex. +. Lines 64–79 β€” `fleet_has_mandatory` and `fleet_has_all`. +. Lines 82–99 β€” `fleet_status` with its inline (non-re-locking) logic. +. Lines 143–185 β€” tests. diff --git a/cartridges/cross-cutting/fleet/fleet-mcp/ffi/build.zig b/cartridges/cross-cutting/fleet/fleet-mcp/ffi/build.zig new file mode 100644 index 0000000..2a8d48f --- /dev/null +++ b/cartridges/cross-cutting/fleet/fleet-mcp/ffi/build.zig @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +// +// Fleet-MCP Cartridge β€” Zig FFI build configuration (Zig 0.15+). + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + // Shared ADR-0006 invoke-shim module (relative path up to boj-server trunk). + + const shim_mod = b.addModule("cartridge_shim", .{ + + .root_source_file = b.path("../../../ffi/zig/src/cartridge_shim.zig"), + + .target = target, + + .optimize = optimize, + + }); + + const fleet_mod = b.addModule("fleet_ffi", .{ + .root_source_file = b.path("fleet_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + fleet_mod.addImport("cartridge_shim", shim_mod); + + // ── Tests ──────────────────────────────────────────────────────── + const fleet_tests = b.addTest(.{ + .root_module = fleet_mod, + }); + + const run_tests = b.addRunArtifact(fleet_tests); + + const test_step = b.step("test", "Run fleet-mcp FFI tests"); + test_step.dependOn(&run_tests.step); + + // ── Shared library ────────────────────────────────────────────── + const lib_mod = b.createModule(.{ + .root_source_file = b.path("fleet_ffi.zig"), + .target = target, + .optimize = optimize, + }); + lib_mod.addImport("cartridge_shim", shim_mod); + + const lib = b.addLibrary(.{ + .name = "fleet_mcp", + .root_module = lib_mod, + .linkage = .dynamic, + }); + b.installArtifact(lib); + + const lib_step = b.step("lib", "Build shared library"); + lib_step.dependOn(&lib.step); +} diff --git a/cartridges/cross-cutting/fleet/fleet-mcp/ffi/cartridge_shim.zig b/cartridges/cross-cutting/fleet/fleet-mcp/ffi/cartridge_shim.zig new file mode 100644 index 0000000..0e399db --- /dev/null +++ b/cartridges/cross-cutting/fleet/fleet-mcp/ffi/cartridge_shim.zig @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +// +// cartridge_shim.zig β€” Shared helpers for the ADR-0006 five-symbol +// cartridge ABI (`boj_cartridge_init / deinit / name / version / invoke`). +// +// The shim centralises the seven-code return convention, NUL-argument +// guards, tool-name comparison, and the buffer-too-small path so each +// cartridge's `boj_cartridge_invoke` can stay short β€” typically a tool +// table plus `shim.writeResult(...)`. +// +// Cartridges import this file by relative path (no build-graph change +// needed). Example: +// +// const shim = @import("../../../ffi/zig/src/cartridge_shim.zig"); +// +// export fn boj_cartridge_invoke( +// tool_name: [*c]const u8, +// json_args: [*c]const u8, +// out_buf: [*c]u8, +// in_out_len: [*c]usize, +// ) callconv(.c) i32 { +// _ = json_args; +// if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; +// const body = if (shim.toolIs(tool_name, "foo")) "{\"result\":{}}" +// else return shim.RC_UNKNOWN_TOOL; +// return shim.writeResult(out_buf, in_out_len, body); +// } + +const std = @import("std"); + +// ── Return codes (ADR-0006 Β§Return codes) ──────────────────────────── +// +// Frozen by ADR-0006. New failure modes compose these via the error +// JSON body β€” the integer surface does not grow without a follow-up ADR. + +pub const RC_SUCCESS: i32 = 0; +pub const RC_UNKNOWN_TOOL: i32 = -1; +pub const RC_BAD_ARGS: i32 = -2; +pub const RC_BUFFER_TOO_SMALL: i32 = -3; +pub const RC_RUNTIME_ERROR: i32 = -4; +pub const RC_PANIC: i32 = -5; +pub const RC_AUTH_DENIED: i32 = -6; + +// ── Invoke-path helpers ────────────────────────────────────────────── + +/// True if any of the three mandatory `boj_cartridge_invoke` output-path +/// pointers is null. Use at the top of every invoke to short-circuit to +/// `RC_BAD_ARGS`. +pub fn invokeArgsNull( + tool_name: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) bool { + return tool_name == null or out_buf == null or in_out_len == null; +} + +/// Compare a C-NUL-terminated tool-name pointer against a Zig string +/// literal. Caller must have already verified `tool_name` is non-null +/// (usually via `invokeArgsNull`). +pub fn toolIs(tool_name: [*c]const u8, expected: []const u8) bool { + const s = std.mem.span(@as([*:0]const u8, @ptrCast(tool_name))); + return std.mem.eql(u8, s, expected); +} + +/// Copy `body` into `out_buf[0..*in_out_len]` (as a capacity) and update +/// `*in_out_len` to the number of bytes written. Returns `RC_SUCCESS`. +/// +/// If `body.len` exceeds the current capacity stored in `*in_out_len`, +/// sets `*in_out_len` to the required size and returns +/// `RC_BUFFER_TOO_SMALL` β€” the caller is then expected to re-allocate +/// and retry, per ADR-0006 Β§Memory ownership. +/// +/// Caller must have already verified that `out_buf` and `in_out_len` +/// are non-null. +pub fn writeResult( + out_buf: [*c]u8, + in_out_len: [*c]usize, + body: []const u8, +) i32 { + const cap = in_out_len.*; + if (body.len > cap) { + in_out_len.* = body.len; + return RC_BUFFER_TOO_SMALL; + } + @memcpy(out_buf[0..body.len], body); + in_out_len.* = body.len; + return RC_SUCCESS; +} + +// ── Tests ──────────────────────────────────────────────────────────── + +test "writeResult: body fits, writes and sets length" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); + try std.testing.expectEqualStrings("hello", buf[0..len]); +} + +test "writeResult: too small returns -3 and sets required length" { + var buf: [2]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_BUFFER_TOO_SMALL, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: exact-fit succeeds" { + var buf: [5]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: empty body" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, ""); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 0), len); +} + +test "toolIs: matches and rejects" { + const name: [*:0]const u8 = "foo"; + try std.testing.expect(toolIs(@ptrCast(name), "foo")); + try std.testing.expect(!toolIs(@ptrCast(name), "bar")); + try std.testing.expect(!toolIs(@ptrCast(name), "foobar")); + try std.testing.expect(!toolIs(@ptrCast(name), "fo")); +} + +test "invokeArgsNull: detects each null slot" { + var buf: [4]u8 = undefined; + var len: usize = 4; + const name: [*:0]const u8 = "x"; + try std.testing.expect(!invokeArgsNull(@ptrCast(name), &buf, &len)); + try std.testing.expect(invokeArgsNull(null, &buf, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), null, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), &buf, null)); +} diff --git a/cartridges/cross-cutting/fleet/fleet-mcp/ffi/fleet_ffi.zig b/cartridges/cross-cutting/fleet/fleet-mcp/ffi/fleet_ffi.zig new file mode 100644 index 0000000..c3acd50 --- /dev/null +++ b/cartridges/cross-cutting/fleet/fleet-mcp/ffi/fleet_ffi.zig @@ -0,0 +1,254 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +// +// Fleet-MCP Cartridge β€” Zig FFI bridge for gitbot fleet orchestration. +// +// Provides the native execution layer for the 6-bot gate policy. +// The Idris2 ABI (SafeFleet.idr) defines the gate types and proofs; +// this Zig layer runs the actual gate checks. + +const std = @import("std"); + +// ═══════════════════════════════════════════════════════════════════════ +// Types (must match FleetMcp.SafeFleet encodings) +// ═══════════════════════════════════════════════════════════════════════ + +pub const BotGate = enum(c_int) { + rhodibot = 1, + echidnabot = 2, + sustainabot = 3, + panicbot = 4, + glambot = 5, + seambot = 6, +}; + +pub const RepoStatus = enum(c_int) { + unscanned = 0, + scanning = 1, + healthy = 2, + degraded = 3, + blocked = 4, +}; + +// ═══════════════════════════════════════════════════════════════════════ +// Gate Results +// ═══════════════════════════════════════════════════════════════════════ + +const MAX_GATES: usize = 6; + +var passed_gates: [MAX_GATES]bool = .{ false, false, false, false, false, false }; +var gate_scores: [MAX_GATES]c_int = .{ 0, 0, 0, 0, 0, 0 }; + +var mutex: std.Thread.Mutex = .{}; + +/// Reset all gate results. +pub export fn fleet_reset() void { + mutex.lock(); + defer mutex.unlock(); + for (&passed_gates) |*g| g.* = false; + for (&gate_scores) |*s| s.* = 0; +} + +/// Record a gate scan result. +pub export fn fleet_record_gate(gate: c_int, passed: c_int, score: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + if (gate < 1 or gate > 6) return -1; + const idx: usize = @intCast(gate - 1); + passed_gates[idx] = passed != 0; + gate_scores[idx] = score; + return 0; +} + +/// Check if mandatory gates (Rhodibot, Echidnabot, Panicbot) have passed. +pub export fn fleet_has_mandatory() c_int { + mutex.lock(); + defer mutex.unlock(); + // Rhodibot=0, Echidnabot=1, Panicbot=3 + return if (passed_gates[0] and passed_gates[1] and passed_gates[3]) 1 else 0; +} + +/// Check if all six gates have passed. +pub export fn fleet_has_all() c_int { + mutex.lock(); + defer mutex.unlock(); + for (passed_gates) |g| { + if (!g) return 0; + } + return 1; +} + +/// Derive repository status from current gate results. +pub export fn fleet_status() c_int { + mutex.lock(); + defer mutex.unlock(); + // Inline checks to avoid deadlock (fleet_has_all/fleet_has_mandatory also lock) + const all_passed = blk: { + for (passed_gates) |g| { + if (!g) break :blk false; + } + break :blk true; + }; + if (all_passed) return @intFromEnum(RepoStatus.healthy); + // Mandatory: Rhodibot=0, Echidnabot=1, Panicbot=3 + if (passed_gates[0] and passed_gates[1] and passed_gates[3]) return @intFromEnum(RepoStatus.degraded); + for (passed_gates) |g| { + if (g) return @intFromEnum(RepoStatus.scanning); + } + return @intFromEnum(RepoStatus.unscanned); +} + +/// Get the score for a specific gate. +pub export fn fleet_gate_score(gate: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + if (gate < 1 or gate > 6) return -1; + return gate_scores[@intCast(gate - 1)]; +} + + +// ═══════════════════════════════════════════════════════════════════════ +// Standard Cartridge Interface (loader expects these 4 C-ABI symbols) +// ═══════════════════════════════════════════════════════════════════════ + +/// Initialise the fleet-mcp cartridge. Resets all gate results. +pub export fn boj_cartridge_init() c_int { + fleet_reset(); + return 0; +} + +/// Deinitialise the fleet-mcp cartridge. Resets all gate results. +pub export fn boj_cartridge_deinit() void { + fleet_reset(); +} + +/// Return the cartridge name as a null-terminated C string. +pub export fn boj_cartridge_name() [*:0]const u8 { + mutex.lock(); + defer mutex.unlock(); + return "fleet-mcp"; +} + +/// Return the cartridge version as a null-terminated C string. +pub export fn boj_cartridge_version() [*:0]const u8 { + mutex.lock(); + defer mutex.unlock(); + return "0.1.0"; +} + +// ═══════════════════════════════════════════════════════════════════════ +// ADR-0006 dispatch (boj_cartridge_invoke, 5th standard symbol) +// ═══════════════════════════════════════════════════════════════════════ + +const shim = @import("cartridge_shim.zig"); + +/// Dispatch the cartridge.json MCP tools. Grade D Alpha β€” each arm +/// returns a stub JSON body shaped to the tool's intended response. +export fn boj_cartridge_invoke( + tool_name: [*c]const u8, + json_args: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) callconv(.c) i32 { + _ = json_args; + if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; + + const body: []const u8 = if (shim.toolIs(tool_name, "fleet_record_gate")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "fleet_bot_status")) + "{\"result\":{\"metadata\":{},\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "fleet_gate_score")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "fleet_has_mandatory")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "fleet_fleet_status")) + "{\"result\":{\"metadata\":{},\"status\":\"stub\"}}" +else + return shim.RC_UNKNOWN_TOOL; + + return shim.writeResult(out_buf, in_out_len, body); +} + +// ═══════════════════════════════════════════════════════════════════════ +// Tests +// ═══════════════════════════════════════════════════════════════════════ + +test "initial state is unscanned" { + fleet_reset(); + try std.testing.expectEqual(@as(c_int, 0), fleet_status()); +} + +test "mandatory gates required for release" { + fleet_reset(); + // Pass only Rhodibot and Echidnabot β€” not enough + _ = fleet_record_gate(1, 1, 95); // Rhodibot + _ = fleet_record_gate(2, 1, 90); // Echidnabot + try std.testing.expectEqual(@as(c_int, 0), fleet_has_mandatory()); + + // Add Panicbot β€” now mandatory is met + _ = fleet_record_gate(4, 1, 85); // Panicbot + try std.testing.expectEqual(@as(c_int, 1), fleet_has_mandatory()); + try std.testing.expectEqual(@as(c_int, @intFromEnum(RepoStatus.degraded)), fleet_status()); +} + +test "all gates for healthy status" { + fleet_reset(); + _ = fleet_record_gate(1, 1, 95); + _ = fleet_record_gate(2, 1, 90); + _ = fleet_record_gate(3, 1, 80); + _ = fleet_record_gate(4, 1, 85); + _ = fleet_record_gate(5, 1, 75); + _ = fleet_record_gate(6, 1, 88); + try std.testing.expectEqual(@as(c_int, 1), fleet_has_all()); + try std.testing.expectEqual(@as(c_int, @intFromEnum(RepoStatus.healthy)), fleet_status()); +} + +test "failed gate prevents healthy" { + fleet_reset(); + _ = fleet_record_gate(1, 1, 95); + _ = fleet_record_gate(2, 1, 90); + _ = fleet_record_gate(3, 0, 30); // Sustainabot failed + _ = fleet_record_gate(4, 1, 85); + _ = fleet_record_gate(5, 1, 75); + _ = fleet_record_gate(6, 1, 88); + try std.testing.expectEqual(@as(c_int, 0), fleet_has_all()); + // But mandatory still met (Rhodibot, Echidnabot, Panicbot passed) + try std.testing.expectEqual(@as(c_int, 1), fleet_has_mandatory()); + try std.testing.expectEqual(@as(c_int, @intFromEnum(RepoStatus.degraded)), fleet_status()); +} + +// ═══════════════════════════════════════════════════════════════════════ +// ADR-0006 invoke dispatch tests +// ═══════════════════════════════════════════════════════════════════════ + +test "invoke: each declared tool succeeds" { + var buf: [256]u8 = undefined; + const tools = [_][]const u8{ + "fleet_record_gate", + "fleet_bot_status", + "fleet_gate_score", + "fleet_has_mandatory", + "fleet_fleet_status", + }; + for (tools) |t| { + var len: usize = buf.len; + const rc = boj_cartridge_invoke(t.ptr, "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, 0), rc); + try std.testing.expect(std.mem.indexOf(u8, buf[0..len], "result") != null); + } +} + +test "invoke: unknown tool returns -1" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("nope", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -1), rc); +} + +test "invoke: buffer too small returns -3" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("fleet_record_gate", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -3), rc); + try std.testing.expect(len > 4); +} diff --git a/cartridges/cross-cutting/fleet/fleet-mcp/mod.js b/cartridges/cross-cutting/fleet/fleet-mcp/mod.js new file mode 100644 index 0000000..6357906 --- /dev/null +++ b/cartridges/cross-cutting/fleet/fleet-mcp/mod.js @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +// +// fleet-mcp/mod.js β€” gitbot-fleet gate compliance tracker +// +// Delegates to backend at http://127.0.0.1:7723 (override with FLEET_BACKEND_URL). + +const BASE_URL = Deno.env.get("FLEET_BACKEND_URL") ?? "http://127.0.0.1:7723"; +const TIMEOUT_MS = 15_000; + +async function post(path, payload) { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), TIMEOUT_MS); + try { + const r = await fetch(`${BASE_URL}${path}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + signal: ctrl.signal, + }); + const data = await r.json().catch(() => ({ success: false, error: "non-JSON response" })); + return { status: r.status, data }; + } catch (e) { + if (e.name === "AbortError") return { status: 504, data: { success: false, error: "fleet-mcp backend timed out" } }; + return { status: 503, data: { success: false, error: `fleet-mcp backend unavailable: ${e.message}` } }; + } finally { clearTimeout(t); } +} + +async function get(path) { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), TIMEOUT_MS); + try { + const r = await fetch(`${BASE_URL}${path}`, { method: "GET", signal: ctrl.signal }); + const data = await r.json().catch(() => ({ success: false, error: "non-JSON response" })); + return { status: r.status, data }; + } catch (e) { + if (e.name === "AbortError") return { status: 504, data: { success: false, error: "fleet-mcp backend timed out" } }; + return { status: 503, data: { success: false, error: `fleet-mcp backend unavailable: ${e.message}` } }; + } finally { clearTimeout(t); } +} + +export async function handleTool(toolName, args) { + switch (toolName) { + case "fleet_record_gate": + return post("/api/v1/fleet_record_gate", args ?? {}); + case "fleet_bot_status": + return post("/api/v1/fleet_bot_status", args ?? {}); + case "fleet_gate_score": + return post("/api/v1/fleet_gate_score", args ?? {}); + case "fleet_has_mandatory": + return post("/api/v1/fleet_has_mandatory", args ?? {}); + case "fleet_fleet_status": + return post("/api/v1/fleet_fleet_status", args ?? {}); + default: + return { status: 404, data: { error: `Unknown tool: ${toolName}` } }; + } +} diff --git a/cartridges/cross-cutting/fleet/fleet-mcp/panels/har.json b/cartridges/cross-cutting/fleet/fleet-mcp/panels/har.json new file mode 100644 index 0000000..7c41936 --- /dev/null +++ b/cartridges/cross-cutting/fleet/fleet-mcp/panels/har.json @@ -0,0 +1,105 @@ +{ + "$schema": "panll-harness/v1", + "service_id": "hybrid-automation-router", + "service_name": "Hybrid Automation Router", + "version": "0.2.0", + "protocol": "http", + "default_endpoint": "http://localhost:7900/api/v1", + "health_check": { + "path": "/health", + "interval_ms": 15000, + "timeout_ms": 5000, + "healthy_threshold": 1, + "unhealthy_threshold": 3 + }, + "data_sources": { + "har://system/health": { + "path": "/system/health", + "method": "GET", + "returns": "SystemHealth", + "description": "Overall router system health" + }, + "har://targets/count": { + "path": "/targets/count", + "method": "GET", + "returns": "u64", + "description": "Total registered automation targets" + }, + "har://targets/all": { + "path": "/targets", + "method": "GET", + "returns": "[AutomationTarget]", + "description": "All registered targets with status" + }, + "har://targets/rpa-elysium/status": { + "path": "/targets/rpa-elysium/status", + "method": "GET", + "returns": "TargetStatus", + "description": "rpa-elysium target health status" + }, + "har://targets/rpa-elysium/metrics/events_routed": { + "path": "/targets/rpa-elysium/metrics", + "method": "GET", + "returns": "u64", + "jq_extract": ".events_routed", + "description": "Events routed to rpa-elysium" + }, + "har://targets/rpa-elysium/metrics/avg_confidence": { + "path": "/targets/rpa-elysium/metrics", + "method": "GET", + "returns": "f64", + "jq_extract": ".avg_confidence", + "description": "Average routing confidence for rpa-elysium" + }, + "har://targets/rpa-elysium/events/recent": { + "path": "/targets/rpa-elysium/events?limit=20", + "method": "GET", + "returns": "[RoutedEvent]", + "description": "Recent events routed to rpa-elysium" + }, + "har://metrics/events_per_minute": { + "path": "/metrics/events_per_minute", + "method": "GET", + "returns": "[SparklinePoint]", + "description": "Events per minute over last hour" + }, + "har://metrics/routing_success_rate": { + "path": "/metrics/success_rate", + "method": "GET", + "returns": "f64", + "description": "Routing success rate (0.0–1.0)" + }, + "har://metrics/strategy_breakdown": { + "path": "/metrics/strategy_breakdown", + "method": "GET", + "returns": "HashMap", + "description": "Routing strategy usage counts" + }, + "har://metrics/category_distribution": { + "path": "/metrics/category_distribution", + "method": "GET", + "returns": "HashMap", + "description": "Event category distribution" + }, + "har://routes/recent": { + "path": "/routes/recent?limit=30", + "method": "GET", + "returns": "[RouteDecision]", + "description": "Recent routing decisions with confidence and strategy" + } + }, + "panels": [ + "panels/har-dashboard/panel.json", + "panels/rpa-elysium/panel.json" + ], + "capabilities": ["routing", "health_checking", "metrics", "dispatch"], + "clade": "automation/routing", + "integrations": { + "rpa-elysium": { + "role": "dispatch_target", + "protocol": "queue", + "delivery_guarantee": "at_least_once", + "health_check_endpoint": "http://localhost:7800/health" + } + } +} diff --git a/cartridges/cross-cutting/fleet/fleet-mcp/panels/manifest.json b/cartridges/cross-cutting/fleet/fleet-mcp/panels/manifest.json new file mode 100644 index 0000000..1d2d2a5 --- /dev/null +++ b/cartridges/cross-cutting/fleet/fleet-mcp/panels/manifest.json @@ -0,0 +1,67 @@ +{ + "$schema": "https://panll.dev/schemas/panel-manifest/v1.json", + "spdx": "MPL-2.0", + "cartridge": "fleet-mcp", + "domain": "Bot Fleet Management", + "version": "0.1.0", + "panels": [ + { + "id": "fleet-status", + "title": "Fleet Gateway Status", + "description": "gitbot-fleet orchestration health and active bots", + "type": "status-indicator", + "data_source": { + "endpoint": "/cartridge/fleet-mcp/invoke", + "method": "POST", + "body": { "tool": "status" }, + "refresh_interval_ms": 5000 + }, + "widgets": [ + { + "type": "state-badge", + "field": "state", + "states": { + "ready": { "color": "#2ecc71", "icon": "users" }, + "degraded": { "color": "#f39c12", "icon": "alert-circle" }, + "error": { "color": "#e74c3c", "icon": "alert-triangle" } + } + }, + { "type": "text", "field": "version", "label": "Version" } + ] + }, + { + "id": "fleet-bots", + "title": "Active Bots", + "description": "Bot status across the 6-bot gitbot-fleet", + "type": "metric", + "data_source": { + "endpoint": "/cartridge/fleet-mcp/invoke", + "method": "POST", + "body": { "tool": "bot_list" }, + "refresh_interval_ms": 10000 + }, + "widgets": [ + { "type": "counter", "field": "active_bots", "label": "Active Bots", "icon": "cpu" }, + { "type": "counter", "field": "pending_jobs", "label": "Pending Jobs", "icon": "clock" }, + { "type": "counter", "field": "completed_jobs", "label": "Completed", "icon": "check-circle" } + ] + }, + { + "id": "fleet-har", + "title": "HAR Integration", + "description": "HTTP Archive recording and replay automation metrics", + "type": "metric", + "data_source": { + "endpoint": "/cartridge/fleet-mcp/invoke", + "method": "POST", + "body": { "tool": "har_stats" }, + "refresh_interval_ms": 15000 + }, + "widgets": [ + { "type": "counter", "field": "recordings", "label": "Recordings", "icon": "film" }, + { "type": "counter", "field": "replays", "label": "Replays", "icon": "play" }, + { "type": "counter", "field": "assertions_passed", "label": "Assertions OK", "icon": "check" } + ] + } + ] +} diff --git a/cartridges/cross-cutting/health/boj-health/cartridge.json b/cartridges/cross-cutting/health/boj-health/cartridge.json new file mode 100644 index 0000000..c8ea038 --- /dev/null +++ b/cartridges/cross-cutting/health/boj-health/cartridge.json @@ -0,0 +1,52 @@ +{ + "$schema": "https://boj.dev/schemas/cartridge/v1.json", + "spdx": "MPL-2.0", + "copyright": "Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) ", + "name": "boj-health", + "version": "0.1.0", + "status": "ffi_only", + "description": "BoJ server self-health cartridge β€” status, ping, and uptime queries. Self-contained Zig FFI (.so) reference implementation: no external services required.", + "domain": "infrastructure", + "tier": "Ayo", + "protocols": ["MCP"], + "auth": { + "method": "none", + "env_var": null, + "credential_source": null + }, + "api": { + "base_url": "local://boj-health", + "content_type": "application/json" + }, + "ffi": { + "so_path": "ffi/zig-out/lib/libboj_health.so", + "abi_version": "ADR-0006", + "symbols": ["boj_cartridge_init", "boj_cartridge_deinit", "boj_cartridge_name", "boj_cartridge_version", "boj_cartridge_invoke"] + }, + "tools": [ + { + "name": "boj_health_status", + "description": "Return BoJ server health status and version", + "inputSchema": { + "type": "object", + "properties": {} + } + }, + { + "name": "boj_health_ping", + "description": "Ping the BoJ health cartridge β€” always returns pong", + "inputSchema": { + "type": "object", + "properties": {} + } + }, + { + "name": "boj_health_version", + "description": "Return boj-health cartridge version string", + "inputSchema": { + "type": "object", + "properties": {} + } + } + ] +} diff --git a/cartridges/cross-cutting/health/boj-health/ffi/boj_health_ffi.zig b/cartridges/cross-cutting/health/boj-health/ffi/boj_health_ffi.zig new file mode 100644 index 0000000..d8a1399 --- /dev/null +++ b/cartridges/cross-cutting/health/boj-health/ffi/boj_health_ffi.zig @@ -0,0 +1,90 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +// +// boj-health cartridge β€” ADR-0006 five-symbol Zig FFI implementation. +// +// Reference implementation: no external services, no env vars required. +// Demonstrates the full Idris2 ABI β†’ Zig FFI β†’ boj-invoke β†’ Elixir chain. +// +// Tools: +// boj_health_status β€” JSON health blob: ok, version, uptime_ms +// boj_health_ping β€” always {pong: true} +// boj_health_version β€” version string only +// +// Runtime note: boj-invoke targets x86_64-linux-gnu (glibc) so it uses +// DlDynLib (real dlopen). This .so can therefore safely use link_libc = true +// (glibc) without a musl/glibc clash. The std_options override prevents +// this .so from overwriting boj-invoke's SIGSEGV handler at dlopen time. + +const std = @import("std"); +const shim = @import("cartridge_shim"); + +// Use the C clock_gettime directly β€” straightforward, no Zig TLS involved. +const c = @cImport({ + @cInclude("time.h"); +}); + +// Suppress Zig's debug segfault signal handler so this .so does not +// overwrite boj-invoke's handler when dlopened into the host Zig binary. +pub const std_options: std.Options = .{ + .enable_segfault_handler = false, +}; + +var init_time_ms: i64 = 0; +var init_done: bool = false; + +// ─── Five-symbol ADR-0006 ABI ──────────────────────────────────────────────── + +export fn boj_cartridge_name() callconv(.c) [*:0]const u8 { + return "boj-health"; +} + +export fn boj_cartridge_version() callconv(.c) [*:0]const u8 { + return "0.1.0"; +} + +export fn boj_cartridge_init() callconv(.c) c_int { + var ts: c.struct_timespec = undefined; + _ = c.clock_gettime(c.CLOCK_MONOTONIC, &ts); + init_time_ms = ts.tv_sec * 1000 + @divTrunc(ts.tv_nsec, 1_000_000); + init_done = true; + return 0; +} + +export fn boj_cartridge_deinit() callconv(.c) void { + init_done = false; +} + +export fn boj_cartridge_invoke( + tool_name: [*c]const u8, + json_args: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) callconv(.c) i32 { + _ = json_args; + if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; + + if (shim.toolIs(tool_name, "boj_health_ping")) { + return shim.writeResult(out_buf, in_out_len, "{\"pong\":true}"); + } + + if (shim.toolIs(tool_name, "boj_health_version")) { + return shim.writeResult(out_buf, in_out_len, "{\"version\":\"0.1.0\"}"); + } + + if (shim.toolIs(tool_name, "boj_health_status")) { + var ts: c.struct_timespec = undefined; + _ = c.clock_gettime(c.CLOCK_MONOTONIC, &ts); + const now_ms: i64 = ts.tv_sec * 1000 + @divTrunc(ts.tv_nsec, 1_000_000); + const uptime: i64 = if (init_done) now_ms - init_time_ms else 0; + + var buf: [256]u8 = undefined; + const body = std.fmt.bufPrint(&buf, + "{{\"ok\":true,\"version\":\"0.1.0\",\"uptime_ms\":{d},\"cartridge\":\"boj-health\"}}", + .{uptime}, + ) catch return shim.RC_RUNTIME_ERROR; + return shim.writeResult(out_buf, in_out_len, body); + } + + return shim.RC_UNKNOWN_TOOL; +} diff --git a/cartridges/cross-cutting/health/boj-health/ffi/build.zig b/cartridges/cross-cutting/health/boj-health/ffi/build.zig new file mode 100644 index 0000000..b65b730 --- /dev/null +++ b/cartridges/cross-cutting/health/boj-health/ffi/build.zig @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +// +// boj-health cartridge FFI build β€” produces libboj_health.so +// +// link_libc = true: boj-invoke targets x86_64-linux-gnu (glibc) and uses +// DlDynLib (real dlopen). A glibc-linked .so is therefore fully compatible β€” +// dlopen loads it into the glibc process and resolves libc symbols against +// the already-loaded libc.so.6 with no duplication. + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const shim_mod = b.addModule("cartridge_shim", .{ + .root_source_file = b.path("../../../ffi/zig/src/cartridge_shim.zig"), + .target = target, + .optimize = optimize, + }); + + const ffi_mod = b.createModule(.{ + .root_source_file = b.path("boj_health_ffi.zig"), + .target = target, + .optimize = optimize, + .link_libc = true, + }); + ffi_mod.addImport("cartridge_shim", shim_mod); + + const lib = b.addLibrary(.{ + .name = "boj_health", + .root_module = ffi_mod, + .linkage = .dynamic, + }); + b.installArtifact(lib); + + const lib_static = b.addLibrary(.{ + .name = "boj_health", + .root_module = b.createModule(.{ + .root_source_file = b.path("boj_health_ffi.zig"), + .target = target, + .optimize = optimize, + .link_libc = true, + }), + .linkage = .static, + }); + lib_static.root_module.addImport("cartridge_shim", shim_mod); + b.installArtifact(lib_static); + + // Unit tests for the shim helpers used here. + const tests = b.addTest(.{ .root_module = ffi_mod }); + const run_tests = b.addRunArtifact(tests); + const test_step = b.step("test", "Run boj-health FFI unit tests"); + test_step.dependOn(&run_tests.step); +} diff --git a/cartridges/cross-cutting/nesy/ml-mcp/README.adoc b/cartridges/cross-cutting/nesy/ml-mcp/README.adoc new file mode 100644 index 0000000..6a19449 --- /dev/null +++ b/cartridges/cross-cutting/nesy/ml-mcp/README.adoc @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) += ml-mcp +Jonathan D.A. Jewell +:spdx: MPL-2.0 +:tier: Ayo +:domain: ai +:protocols: MCP, REST + +== Overview + +Machine learning inference (HuggingFace and others) + +== Tools (4) + +[cols="2,4"] +|=== +| Tool | Description + +| `ml_authenticate` | Authenticate with an ML provider +| `ml_inference` | Run model inference +| `ml_list_models` | List available models +| `ml_get_model_info` | Get model metadata +|=== + +== Architecture + +Three-layer stack per the BoJ cartridge standard: + +[cols="1,1,3"] +|=== +| Layer | Language | Purpose + +| ABI | Idris2 | Formally verified state machine with dependent-type proofs +| FFI | Zig | C-ABI implementation: session pool, action metrics, thread safety +| Adapter| Zig | Adapter bridge to the BoJ unified adapter protocol (zig predecessors sidelined) +|=== + +== Building + +[source,bash] +---- +# ABI (Idris2) +cd abi && idris2 --check *.idr + +# FFI (Zig) +cd ffi && zig build +cd ffi && zig build test + +# Adapter (Zig) +cd adapter && zig build +---- + +== Status + +Alpha (CRG D). Cartridge metadata declared in `cartridge.json`; 4 tool(s) exposed. +Not yet dogfooded externally. See `docs/READINESS.md` at repo root for the +project-wide grade table and `docs/practice/DOGFOOD-LOG.adoc` for usage evidence. diff --git a/cartridges/cross-cutting/nesy/ml-mcp/abi/MlMcp/SafeMl.idr b/cartridges/cross-cutting/nesy/ml-mcp/abi/MlMcp/SafeMl.idr new file mode 100644 index 0000000..87421e5 --- /dev/null +++ b/cartridges/cross-cutting/nesy/ml-mcp/abi/MlMcp/SafeMl.idr @@ -0,0 +1,205 @@ +-- SPDX-License-Identifier: MPL-2.0 +-- Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +||| MlMcp.SafeMl: Formally verified ML/AI provider operations. +||| +||| Cartridge: ml-mcp +||| Matrix cell: ML/AI domain x {MCP, LSP} protocols +||| +||| This module defines type-safe ML provider operations with a +||| session state machine that prevents: +||| - Operations on unauthenticated providers +||| - Credential leaks by tracking auth lifecycle +||| - Operations without proper session teardown +||| +||| State machine: Unauthenticated -> Authenticated -> Operating -> Authenticated -> Unauthenticated +module MlMcp.SafeMl + +import Data.List + +%default total + +-- ═══════════════════════════════════════════════════════════════════════════ +-- Session State Machine +-- ═══════════════════════════════════════════════════════════════════════════ + +||| Provider session lifecycle states. +||| A session progresses: Unauthenticated -> Authenticated -> Operating -> Authenticated -> Unauthenticated +public export +data SessionState = Unauthenticated | Authenticated | Operating | AuthError + +||| Equality for session states. +public export +Eq SessionState where + Unauthenticated == Unauthenticated = True + Authenticated == Authenticated = True + Operating == Operating = True + AuthError == AuthError = True + _ == _ = False + +||| Valid state transitions (enforced at the type level). +public export +data ValidTransition : SessionState -> SessionState -> Type where + Authenticate : ValidTransition Unauthenticated Authenticated + BeginOperation : ValidTransition Authenticated Operating + EndOperation : ValidTransition Operating Authenticated + Logout : ValidTransition Authenticated Unauthenticated + OpError : ValidTransition Operating AuthError + Recover : ValidTransition AuthError Unauthenticated + +||| Runtime transition validator. +public export +canTransition : SessionState -> SessionState -> Bool +canTransition Unauthenticated Authenticated = True +canTransition Authenticated Operating = True +canTransition Operating Authenticated = True +canTransition Authenticated Unauthenticated = True +canTransition Operating AuthError = True +canTransition AuthError Unauthenticated = True +canTransition _ _ = False + +-- ═══════════════════════════════════════════════════════════════════════════ +-- ML Provider Types +-- ═══════════════════════════════════════════════════════════════════════════ + +||| Supported ML/AI providers. +public export +data MlProvider + = HuggingFace -- Hugging Face model hub + | Custom String -- User-defined provider + +||| C-ABI encoding. +public export +providerToInt : MlProvider -> Int +providerToInt HuggingFace = 1 +providerToInt (Custom _) = 99 + +-- ═══════════════════════════════════════════════════════════════════════════ +-- Provider Capabilities +-- ═══════════════════════════════════════════════════════════════════════════ + +||| Capabilities an ML provider may support. +public export +data ProviderCapability + = SearchModels -- Search for models + | ModelInfo -- Get model metadata + | Inference -- Run model inference + | ListSpaces -- List Spaces (demos) + | SpaceInfo -- Get Space metadata + | ListDatasets -- List datasets + | DatasetInfo -- Get dataset metadata + +-- ═══════════════════════════════════════════════════════════════════════════ +-- Hugging Face Resource Types +-- ═══════════════════════════════════════════════════════════════════════════ + +||| Resource types available on the Hugging Face provider. +public export +data HuggingFaceResource + = HfModel -- ML model + | HfSpace -- Gradio / Streamlit space + | HfDataset -- Dataset + | HfInference -- Inference endpoint + +||| C-ABI encoding for Hugging Face resource types. +public export +hfResourceToInt : HuggingFaceResource -> Int +hfResourceToInt HfModel = 1 +hfResourceToInt HfSpace = 2 +hfResourceToInt HfDataset = 3 +hfResourceToInt HfInference = 4 + +||| Map Hugging Face to its supported capabilities. +public export +huggingFaceCapabilities : List ProviderCapability +huggingFaceCapabilities = [SearchModels, ModelInfo, Inference, ListSpaces, SpaceInfo, ListDatasets, DatasetInfo] + +-- ═══════════════════════════════════════════════════════════════════════════ +-- Session Record +-- ═══════════════════════════════════════════════════════════════════════════ + +||| An ML provider session with tracked state. +public export +record Session where + constructor MkSession + sessionId : String + provider : MlProvider + state : SessionState + namespace : String + +||| Proof that a session is authenticated (ready for operations). +public export +data IsAuthenticated : Session -> Type where + ActiveSession : (s : Session) -> + (state s = Authenticated) -> + IsAuthenticated s + +-- ═══════════════════════════════════════════════════════════════════════════ +-- MCP Tool Definitions +-- ═══════════════════════════════════════════════════════════════════════════ + +||| MCP tools exposed by this cartridge. +||| These map to MCP tool definitions that AI agents can call. +public export +data McpTool + = ToolAuthenticate -- Authenticate with an ML provider + | ToolSearchModels -- Search for models + | ToolModelInfo -- Get model metadata + | ToolInference -- Run model inference + | ToolListSpaces -- List Spaces + | ToolSpaceInfo -- Get Space metadata + | ToolListDatasets -- List datasets + | ToolDatasetInfo -- Get dataset metadata + | ToolLogout -- End provider session + +||| MCP tool name (for JSON-RPC method name). +public export +toolName : McpTool -> String +toolName ToolAuthenticate = "ml/authenticate" +toolName ToolSearchModels = "ml/models/search" +toolName ToolModelInfo = "ml/models/info" +toolName ToolInference = "ml/models/inference" +toolName ToolListSpaces = "ml/spaces/list" +toolName ToolSpaceInfo = "ml/spaces/info" +toolName ToolListDatasets = "ml/datasets/list" +toolName ToolDatasetInfo = "ml/datasets/info" +toolName ToolLogout = "ml/logout" + +||| Which tools require an authenticated session. +public export +requiresAuth : McpTool -> Bool +requiresAuth ToolAuthenticate = False +requiresAuth _ = True + +-- ═══════════════════════════════════════════════════════════════════════════ +-- C-ABI Exports +-- ═══════════════════════════════════════════════════════════════════════════ + +||| Session state to integer. +public export +sessionStateToInt : SessionState -> Int +sessionStateToInt Unauthenticated = 0 +sessionStateToInt Authenticated = 1 +sessionStateToInt Operating = 2 +sessionStateToInt AuthError = 3 + +||| FFI: Validate a state transition. +export +ml_can_transition : Int -> Int -> Int +ml_can_transition from to = + let fromState = case from of + 0 => Unauthenticated + 1 => Authenticated + 2 => Operating + _ => AuthError + toState = case to of + 0 => Unauthenticated + 1 => Authenticated + 2 => Operating + _ => AuthError + in if canTransition fromState toState then 1 else 0 + +||| FFI: Check if a tool requires authentication. +export +ml_tool_requires_auth : Int -> Int +ml_tool_requires_auth 1 = 0 -- ToolAuthenticate +ml_tool_requires_auth _ = 1 -- All others require auth diff --git a/cartridges/cross-cutting/nesy/ml-mcp/abi/README.adoc b/cartridges/cross-cutting/nesy/ml-mcp/abi/README.adoc new file mode 100644 index 0000000..34dee86 --- /dev/null +++ b/cartridges/cross-cutting/nesy/ml-mcp/abi/README.adoc @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) += ml-mcp / abi β€” Idris2 ABI layer +:orientation: shallow + +== Purpose + +Cartridge-local protocol types for `ml-mcp`. Defines the data, tool, and +transition vocabulary this cartridge speaks. Inherits safety lemmas and the +matrix-cell identifier scheme from the trunk ABI at `src/abi/`. + +== Files + +1 Idris2 module(s), ~205 lines total. Lead module: +`SafeMl.idr`. + +No `.ipkg` β€” consumed directly by wider BoJ callers; type-checks inside the +trunk `boj.ipkg`. + +== Invariants + +* `%default total` at every module top. +* Zero `believe_me`, `assert_total`, `postulate`, `Admitted`, or + `sorry` (the trunk holds the four declared `believe_me` sites; leaf + cartridges carry none). + +== Test/proof surface + +Thin module(s), no inline tests. Proof surface = type-check success under the +trunk `idris2 --check`. + +== Read-first + +. `SafeMl.idr` β€” cartridge-local data records and enums. +. Trunk `src/abi/Boj/Catalogue.idr` for how this cell participates in the + 2D matrix. diff --git a/cartridges/cross-cutting/nesy/ml-mcp/abi/ml-mcp.ipkg b/cartridges/cross-cutting/nesy/ml-mcp/abi/ml-mcp.ipkg new file mode 100644 index 0000000..7f66e8e --- /dev/null +++ b/cartridges/cross-cutting/nesy/ml-mcp/abi/ml-mcp.ipkg @@ -0,0 +1,11 @@ +-- SPDX-License-Identifier: MPL-2.0 +package mlmcp + +authors = "Jonathan D.A. Jewell" +version = 0.1.0 +license = "MPL-2.0" +brief = "ML/AI MCP cartridge β€” Hugging Face model hub with session safety" + +sourcedir = "." +modules = MlMcp.SafeMl +depends = base, contrib diff --git a/cartridges/cross-cutting/nesy/ml-mcp/adapter/README.adoc b/cartridges/cross-cutting/nesy/ml-mcp/adapter/README.adoc new file mode 100644 index 0000000..7be89bf --- /dev/null +++ b/cartridges/cross-cutting/nesy/ml-mcp/adapter/README.adoc @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) += ml-mcp / adapter β€” protocol bridge +:orientation: shallow + +== Purpose + +Exposes this cartridge's FFI over the wire protocols declared in +`cartridge.json`. Dispatches tool invocations to the C-ABI exports at +`../ffi/` and formats responses per protocol. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” depends on the sibling `ffi` target. +| `ml_adapter.zig` | Protocol dispatch (129 lines). Tool catalogue matches `cartridge.json`. +| `SIDELINED-*.v.adoc` | zig predecessor(s). Not built β€” zig banned 2026-04-10. +|=== + +== Invariants + +* **Stateless** β€” all state lives behind the FFI, never in the adapter. +* **Response passthrough** β€” whatever the FFI returns goes back to the wire + unmodified (no embellishment, no silent recovery). +* **Cartridge.json is source of truth** for the tool catalogue; drift between + adapter and manifest is a CI failure. + +== Test/proof surface + +No inline tests at the adapter layer β€” protocol routing is exercised by the +trunk seam tests at `ffi/zig/src/seams.zig`. + +== Read-first + +. `ml_adapter.zig` β€” dispatch function and per-protocol routers. +. `../cartridge.json` β€” the tool manifest the dispatcher mirrors. diff --git a/cartridges/cross-cutting/nesy/ml-mcp/adapter/build.zig b/cartridges/cross-cutting/nesy/ml-mcp/adapter/build.zig new file mode 100644 index 0000000..8dfb385 --- /dev/null +++ b/cartridges/cross-cutting/nesy/ml-mcp/adapter/build.zig @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +// +// ml-mcp/adapter/build.zig + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const ffi_mod = b.createModule(.{ + .root_source_file = b.path("../ffi/ml_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + const adapter = b.addExecutable(.{ + .name = "ml_adapter", + .root_source_file = b.path("ml_adapter.zig"), + .target = target, + .optimize = optimize, + }); + adapter.root_module.addImport("ml_ffi", ffi_mod); + b.installArtifact(adapter); +} diff --git a/cartridges/cross-cutting/nesy/ml-mcp/adapter/ml_adapter.zig b/cartridges/cross-cutting/nesy/ml-mcp/adapter/ml_adapter.zig new file mode 100644 index 0000000..9756594 --- /dev/null +++ b/cartridges/cross-cutting/nesy/ml-mcp/adapter/ml_adapter.zig @@ -0,0 +1,129 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +// +// ml-mcp/adapter/ml_adapter.zig +// +// Three-protocol BoJ adapter: REST (port 9265), gRPC-compat (port 9266), +// GraphQL (port 9267). +// Replaces the banned zig adapter (ml_adapter.v). + +const std = @import("std"); +const ffi = @import("ml_ffi"); + +const REST_PORT: u16 = 9265; +const GRPC_PORT: u16 = 9266; +const GQL_PORT: u16 = 9267; + +const Response = struct { status: u16, body: []const u8 }; + +fn okJson(buf: []u8, msg: []const u8) []u8 { + return std.fmt.bufPrint(buf, "{{\"success\":true,\"message\":\"{s}\"}}", .{msg}) catch buf[0..0]; +} + +fn errJson(buf: []u8, msg: []const u8) []u8 { + return std.fmt.bufPrint(buf, "{{\"success\":false,\"error\":\"{s}\"}}", .{msg}) catch buf[0..0]; +} + +fn dispatch(tool: []const u8, _body: []const u8, resp: []u8) Response { + _ = _body; + if (std.mem.eql(u8, tool, "ml_authenticate")) { + return .{ .status = 200, .body = okJson(resp, "ml_authenticate forwarded") }; + } + if (std.mem.eql(u8, tool, "ml_inference")) { + return .{ .status = 200, .body = okJson(resp, "ml_inference forwarded") }; + } + if (std.mem.eql(u8, tool, "ml_list_models")) { + return .{ .status = 200, .body = okJson(resp, "ml_list_models forwarded") }; + } + if (std.mem.eql(u8, tool, "ml_get_model_info")) { + return .{ .status = 200, .body = okJson(resp, "ml_get_model_info forwarded") }; + } + return .{ .status = 404, .body = errJson(resp, "unknown tool") }; +} + +fn dispatchRest(path: []const u8, body: []const u8, resp: []u8) Response { + // Expect /tools/ + const prefix = "/tools/"; + if (std.mem.startsWith(u8, path, prefix)) { + const tool = path[prefix.len..]; + return dispatch(tool, body, resp); + } + return .{ .status = 404, .body = errJson(resp, "not found") }; +} + +fn dispatchGrpc(path: []const u8, body: []const u8, resp: []u8) Response { + // Expect // β€” derive tool from Method + var it = std.mem.splitScalar(u8, path, '/'); + _ = it.next(); // leading empty + _ = it.next(); // service + const method = it.next() orelse return .{ .status = 404, .body = errJson(resp, "bad gRPC path") }; + return dispatch(method, body, resp); +} + +fn dispatchGraphql(body: []const u8, resp: []u8) Response { + if (std.mem.indexOf(u8, body, "ml_authenticate") != null) + return dispatch("ml_authenticate", body, resp); + if (std.mem.indexOf(u8, body, "ml_inference") != null) + return dispatch("ml_inference", body, resp); + if (std.mem.indexOf(u8, body, "ml_list_models") != null) + return dispatch("ml_list_models", body, resp); + if (std.mem.indexOf(u8, body, "ml_get_model_info") != null) + return dispatch("ml_get_model_info", body, resp); + return .{ .status = 400, .body = errJson(resp, "unrecognised GraphQL operation") }; +} + +fn handleConnection(stream: std.net.Stream, port: u16) void { + defer stream.close(); + var buf: [4096]u8 = undefined; + var resp_buf: [4096]u8 = undefined; + const n = stream.read(&buf) catch return; + const req = buf[0..n]; + const result = switch (port) { + REST_PORT => blk: { + // Parse HTTP/1.1: first line = METHOD PATH HTTP/x.y + var lines = std.mem.splitScalar(u8, req, '\n'); + const first = lines.next() orelse break :blk Response{ .status = 400, .body = "" }; + var parts = std.mem.splitScalar(u8, std.mem.trim(u8, first, "\r"), ' '); + _ = parts.next(); // method + const path = parts.next() orelse break :blk Response{ .status = 400, .body = "" }; + break :blk dispatchRest(path, req, &resp_buf); + }, + GRPC_PORT => blk: { + var lines = std.mem.splitScalar(u8, req, '\n'); + const first = lines.next() orelse break :blk Response{ .status = 400, .body = "" }; + var parts = std.mem.splitScalar(u8, std.mem.trim(u8, first, "\r"), ' '); + _ = parts.next(); + const path = parts.next() orelse break :blk Response{ .status = 400, .body = "" }; + break :blk dispatchGrpc(path, req, &resp_buf); + }, + GQL_PORT => dispatchGraphql(req, &resp_buf), + else => Response{ .status = 500, .body = "" }, + }; + var http_resp: [512]u8 = undefined; + const http = std.fmt.bufPrint(&http_resp, + "HTTP/1.1 {d} OK\r\nContent-Length: {d}\r\nContent-Type: application/json\r\n\r\n", + .{ result.status, result.body.len }) catch return; + _ = stream.write(http) catch {}; + _ = stream.write(result.body) catch {}; +} + +fn listenLoop(port: u16) !void { + const addr = std.net.Address.initIp4(.{ 127, 0, 0, 1 }, port); + var server = try addr.listen(.{ .reuse_address = true }); + defer server.deinit(); + while (true) { + const conn = try server.accept(); + const t = try std.Thread.spawn(.{}, handleConnection, .{ conn.stream, port }); + t.detach(); + } +} + +pub fn main() !void { + ffi.ml_init(); + const t1 = try std.Thread.spawn(.{}, listenLoop, .{REST_PORT}); + const t2 = try std.Thread.spawn(.{}, listenLoop, .{GRPC_PORT}); + const t3 = try std.Thread.spawn(.{}, listenLoop, .{GQL_PORT}); + t1.join(); + t2.join(); + t3.join(); +} diff --git a/cartridges/cross-cutting/nesy/ml-mcp/cartridge.json b/cartridges/cross-cutting/nesy/ml-mcp/cartridge.json new file mode 100644 index 0000000..860126b --- /dev/null +++ b/cartridges/cross-cutting/nesy/ml-mcp/cartridge.json @@ -0,0 +1,131 @@ +{ + "$schema": "https://boj.dev/schemas/cartridge/v1.json", + "spdx": "MPL-2.0", + "copyright": "Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) ", + "name": "ml-mcp", + "version": "0.1.0", + "description": "Machine learning inference (HuggingFace and others)", + "domain": "ai", + "tier": "Ayo", + "protocols": [ + "MCP", + "REST" + ], + "auth": { + "method": "none", + "env_var": null, + "credential_source": null + }, + "api": { + "base_url": "local://ml-mcp", + "content_type": "application/json" + }, + "tools": [ + { + "name": "ml_authenticate", + "description": "Authenticate with an ML provider", + "inputSchema": { + "type": "object", + "properties": { + "provider": { + "type": "string", + "description": "Provider: huggingface|openai|anthropic|ollama" + }, + "api_key": { + "type": "string", + "description": "API key" + }, + "endpoint": { + "type": "string", + "description": "Custom endpoint URL" + } + }, + "required": [ + "provider" + ] + } + }, + { + "name": "ml_inference", + "description": "Run model inference", + "inputSchema": { + "type": "object", + "properties": { + "session_id": { + "type": "integer", + "description": "Session index" + }, + "model": { + "type": "string", + "description": "Model name or path" + }, + "inputs": { + "type": "string", + "description": "Input text or JSON payload" + }, + "task": { + "type": "string", + "description": "Task type: text-generation|classification|embedding" + } + }, + "required": [ + "session_id", + "model", + "inputs" + ] + } + }, + { + "name": "ml_list_models", + "description": "List available models", + "inputSchema": { + "type": "object", + "properties": { + "session_id": { + "type": "integer", + "description": "Session index" + }, + "filter": { + "type": "string", + "description": "Filter by task or name" + } + }, + "required": [ + "session_id" + ] + } + }, + { + "name": "ml_get_model_info", + "description": "Get model metadata", + "inputSchema": { + "type": "object", + "properties": { + "session_id": { + "type": "integer", + "description": "Session index" + }, + "model": { + "type": "string", + "description": "Model name" + } + }, + "required": [ + "session_id", + "model" + ] + } + } + ], + "ffi": { + "so_path": "ffi/zig-out/lib/libml_mcp.so", + "abi_version": "ADR-0006", + "symbols": [ + "boj_cartridge_init", + "boj_cartridge_deinit", + "boj_cartridge_name", + "boj_cartridge_version", + "boj_cartridge_invoke" + ] + } +} diff --git a/cartridges/cross-cutting/nesy/ml-mcp/ffi/README.adoc b/cartridges/cross-cutting/nesy/ml-mcp/ffi/README.adoc new file mode 100644 index 0000000..ffa53dc --- /dev/null +++ b/cartridges/cross-cutting/nesy/ml-mcp/ffi/README.adoc @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) += ml-mcp / ffi β€” Zig FFI layer +:orientation: shallow + +== Purpose + +C-ABI exports bridging this cartridge's Idris2 ABI to the runtime. Consumed by +the adapter for protocol dispatch and by the trunk loader for mount. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” produces `zig-out/lib/libml.so`. +| `ml_ffi.zig` | C-ABI exports (20 exports, 12 inline tests, 468 lines). +|=== + +== Invariants + +* **Null-pointer rejection** on `[*c]const u8` arguments before any + dereference. +* **Negative return values** carry documented error meanings; zero is + success (matching the trunk error-code convention). +* **No allocator leaks** β€” every allocation path pairs with `defer free`. + +== Test/proof surface + +12 inline `test "..."` block(s) in `ml_ffi.zig`. Run with +`cd ffi && zig build test`. + +== Read-first + +. `ml_ffi.zig` β€” C-ABI signatures and guards. +. Trunk `ffi/zig/src/catalogue.zig` for how this `.so` is loaded. diff --git a/cartridges/cross-cutting/nesy/ml-mcp/ffi/build.zig b/cartridges/cross-cutting/nesy/ml-mcp/ffi/build.zig new file mode 100644 index 0000000..8ece298 --- /dev/null +++ b/cartridges/cross-cutting/nesy/ml-mcp/ffi/build.zig @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +// +// ML-MCP Cartridge β€” Zig FFI build configuration (Zig 0.15+). + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + // Shared ADR-0006 invoke-shim module (relative path up to boj-server trunk). + const shim_mod = b.addModule("cartridge_shim", .{ + .root_source_file = b.path("../../../ffi/zig/src/cartridge_shim.zig"), + .target = target, + .optimize = optimize, + }); + + const ml_mod = b.addModule("ml_ffi", .{ + .root_source_file = b.path("ml_ffi.zig"), + .target = target, + .optimize = optimize, + }); + ml_mod.addImport("cartridge_shim", shim_mod); + + // ── Tests ──────────────────────────────────────────────────────── + const ml_tests = b.addTest(.{ + .root_module = ml_mod, + }); + + const run_tests = b.addRunArtifact(ml_tests); + + const test_step = b.step("test", "Run ml-mcp FFI tests"); + test_step.dependOn(&run_tests.step); + + // ── Shared library ────────────────────────────────────────────── + const lib_mod = b.createModule(.{ + .root_source_file = b.path("ml_ffi.zig"), + .target = target, + .optimize = optimize, + }); + lib_mod.addImport("cartridge_shim", shim_mod); + + const lib = b.addLibrary(.{ + .name = "ml_mcp", + .root_module = lib_mod, + .linkage = .dynamic, + }); + b.installArtifact(lib); + + const lib_step = b.step("lib", "Build shared library"); + lib_step.dependOn(&lib.step); +} diff --git a/cartridges/cross-cutting/nesy/ml-mcp/ffi/cartridge_shim.zig b/cartridges/cross-cutting/nesy/ml-mcp/ffi/cartridge_shim.zig new file mode 100644 index 0000000..0e399db --- /dev/null +++ b/cartridges/cross-cutting/nesy/ml-mcp/ffi/cartridge_shim.zig @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +// +// cartridge_shim.zig β€” Shared helpers for the ADR-0006 five-symbol +// cartridge ABI (`boj_cartridge_init / deinit / name / version / invoke`). +// +// The shim centralises the seven-code return convention, NUL-argument +// guards, tool-name comparison, and the buffer-too-small path so each +// cartridge's `boj_cartridge_invoke` can stay short β€” typically a tool +// table plus `shim.writeResult(...)`. +// +// Cartridges import this file by relative path (no build-graph change +// needed). Example: +// +// const shim = @import("../../../ffi/zig/src/cartridge_shim.zig"); +// +// export fn boj_cartridge_invoke( +// tool_name: [*c]const u8, +// json_args: [*c]const u8, +// out_buf: [*c]u8, +// in_out_len: [*c]usize, +// ) callconv(.c) i32 { +// _ = json_args; +// if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; +// const body = if (shim.toolIs(tool_name, "foo")) "{\"result\":{}}" +// else return shim.RC_UNKNOWN_TOOL; +// return shim.writeResult(out_buf, in_out_len, body); +// } + +const std = @import("std"); + +// ── Return codes (ADR-0006 Β§Return codes) ──────────────────────────── +// +// Frozen by ADR-0006. New failure modes compose these via the error +// JSON body β€” the integer surface does not grow without a follow-up ADR. + +pub const RC_SUCCESS: i32 = 0; +pub const RC_UNKNOWN_TOOL: i32 = -1; +pub const RC_BAD_ARGS: i32 = -2; +pub const RC_BUFFER_TOO_SMALL: i32 = -3; +pub const RC_RUNTIME_ERROR: i32 = -4; +pub const RC_PANIC: i32 = -5; +pub const RC_AUTH_DENIED: i32 = -6; + +// ── Invoke-path helpers ────────────────────────────────────────────── + +/// True if any of the three mandatory `boj_cartridge_invoke` output-path +/// pointers is null. Use at the top of every invoke to short-circuit to +/// `RC_BAD_ARGS`. +pub fn invokeArgsNull( + tool_name: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) bool { + return tool_name == null or out_buf == null or in_out_len == null; +} + +/// Compare a C-NUL-terminated tool-name pointer against a Zig string +/// literal. Caller must have already verified `tool_name` is non-null +/// (usually via `invokeArgsNull`). +pub fn toolIs(tool_name: [*c]const u8, expected: []const u8) bool { + const s = std.mem.span(@as([*:0]const u8, @ptrCast(tool_name))); + return std.mem.eql(u8, s, expected); +} + +/// Copy `body` into `out_buf[0..*in_out_len]` (as a capacity) and update +/// `*in_out_len` to the number of bytes written. Returns `RC_SUCCESS`. +/// +/// If `body.len` exceeds the current capacity stored in `*in_out_len`, +/// sets `*in_out_len` to the required size and returns +/// `RC_BUFFER_TOO_SMALL` β€” the caller is then expected to re-allocate +/// and retry, per ADR-0006 Β§Memory ownership. +/// +/// Caller must have already verified that `out_buf` and `in_out_len` +/// are non-null. +pub fn writeResult( + out_buf: [*c]u8, + in_out_len: [*c]usize, + body: []const u8, +) i32 { + const cap = in_out_len.*; + if (body.len > cap) { + in_out_len.* = body.len; + return RC_BUFFER_TOO_SMALL; + } + @memcpy(out_buf[0..body.len], body); + in_out_len.* = body.len; + return RC_SUCCESS; +} + +// ── Tests ──────────────────────────────────────────────────────────── + +test "writeResult: body fits, writes and sets length" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); + try std.testing.expectEqualStrings("hello", buf[0..len]); +} + +test "writeResult: too small returns -3 and sets required length" { + var buf: [2]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_BUFFER_TOO_SMALL, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: exact-fit succeeds" { + var buf: [5]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: empty body" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, ""); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 0), len); +} + +test "toolIs: matches and rejects" { + const name: [*:0]const u8 = "foo"; + try std.testing.expect(toolIs(@ptrCast(name), "foo")); + try std.testing.expect(!toolIs(@ptrCast(name), "bar")); + try std.testing.expect(!toolIs(@ptrCast(name), "foobar")); + try std.testing.expect(!toolIs(@ptrCast(name), "fo")); +} + +test "invokeArgsNull: detects each null slot" { + var buf: [4]u8 = undefined; + var len: usize = 4; + const name: [*:0]const u8 = "x"; + try std.testing.expect(!invokeArgsNull(@ptrCast(name), &buf, &len)); + try std.testing.expect(invokeArgsNull(null, &buf, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), null, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), &buf, null)); +} diff --git a/cartridges/cross-cutting/nesy/ml-mcp/ffi/ml_ffi.zig b/cartridges/cross-cutting/nesy/ml-mcp/ffi/ml_ffi.zig new file mode 100644 index 0000000..dad965d --- /dev/null +++ b/cartridges/cross-cutting/nesy/ml-mcp/ffi/ml_ffi.zig @@ -0,0 +1,563 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +// +// ML-MCP Cartridge β€” Zig FFI bridge for ML/AI provider operations. +// +// Implements the provider session state machine from SafeMl.idr. +// Ensures no operation can execute on an unauthenticated provider, +// and tracks credential lifecycle to prevent leaks. + +const std = @import("std"); + +// ═══════════════════════════════════════════════════════════════════════ +// Types (must match MlMcp.SafeMl encodings) +// ═══════════════════════════════════════════════════════════════════════ + +pub const SessionState = enum(c_int) { + unauthenticated = 0, + authenticated = 1, + operating = 2, + auth_error = 3, +}; + +pub const MlProvider = enum(c_int) { + hugging_face = 1, + custom = 99, +}; + +/// Hugging Face resource types β€” mirrors `MlMcp.SafeMl.HuggingFaceResource` +/// + `hfResourceToInt` encoding. Declared here so `iseriser abi-verify` +/// can structurally check the encoding against the Idris2 source. +pub const HuggingFaceResource = enum(c_int) { + hf_model = 1, + hf_space = 2, + hf_dataset = 3, + hf_inference = 4, +}; + +// ═══════════════════════════════════════════════════════════════════════ +// Session State Machine +// ═══════════════════════════════════════════════════════════════════════ + +const MAX_SESSIONS: usize = 8; + +const RESULT_BUF_SIZE: usize = 4096; + +const API_TOKEN_SIZE: usize = 256; + +const SessionSlot = struct { + active: bool, + state: SessionState, + provider: MlProvider, + api_token: [API_TOKEN_SIZE]u8 = [_]u8{0} ** API_TOKEN_SIZE, + api_token_len: usize = 0, + result_buf: [RESULT_BUF_SIZE]u8 = [_]u8{0} ** RESULT_BUF_SIZE, + result_len: usize = 0, +}; + +var sessions: [MAX_SESSIONS]SessionSlot = [_]SessionSlot{.{ + .active = false, + .state = .unauthenticated, + .provider = .hugging_face, + .api_token = [_]u8{0} ** API_TOKEN_SIZE, + .api_token_len = 0, + .result_buf = [_]u8{0} ** RESULT_BUF_SIZE, + .result_len = 0, +}} ** MAX_SESSIONS; + +var mutex: std.Thread.Mutex = .{}; + +/// Validate a state transition (matches Idris2 canTransition). +fn isValidTransition(from: SessionState, to: SessionState) bool { + return switch (from) { + .unauthenticated => to == .authenticated, + .authenticated => to == .operating or to == .unauthenticated, + .operating => to == .authenticated or to == .auth_error, + .auth_error => to == .unauthenticated, + }; +} + +/// Authenticate with a provider. Returns slot index or -1 on failure. +pub export fn ml_authenticate(provider: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + for (&sessions, 0..) |*slot, i| { + if (!slot.active) { + slot.active = true; + slot.state = .authenticated; + slot.provider = @enumFromInt(provider); + return @intCast(i); + } + } + return -1; // No slots available +} + +/// Logout from a provider session by slot index. +pub export fn ml_logout(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + if (slot_idx < 0 or slot_idx >= MAX_SESSIONS) return -1; + const idx: usize = @intCast(slot_idx); + if (!sessions[idx].active) return -1; + if (!isValidTransition(sessions[idx].state, .unauthenticated)) return -2; + + // Wipe API token on logout + @memset(&sessions[idx].api_token, 0); + sessions[idx].api_token_len = 0; + sessions[idx].active = false; + sessions[idx].state = .unauthenticated; + return 0; +} + +/// Begin an operation (transition Authenticated -> Operating). +pub export fn ml_begin_operation(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + if (slot_idx < 0 or slot_idx >= MAX_SESSIONS) return -1; + const idx: usize = @intCast(slot_idx); + if (!sessions[idx].active) return -1; + if (!isValidTransition(sessions[idx].state, .operating)) return -2; + + sessions[idx].state = .operating; + return 0; +} + +/// End an operation (transition Operating -> Authenticated). +pub export fn ml_end_operation(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + if (slot_idx < 0 or slot_idx >= MAX_SESSIONS) return -1; + const idx: usize = @intCast(slot_idx); + if (!sessions[idx].active) return -1; + if (!isValidTransition(sessions[idx].state, .authenticated)) return -2; + + sessions[idx].state = .authenticated; + return 0; +} + +/// Get the state of a session. +pub export fn ml_state(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + if (slot_idx < 0 or slot_idx >= MAX_SESSIONS) return -1; + const idx: usize = @intCast(slot_idx); + if (!sessions[idx].active) return @intFromEnum(SessionState.unauthenticated); + return @intFromEnum(sessions[idx].state); +} + +/// Validate a state transition (C-ABI export). +pub export fn ml_can_transition(from: c_int, to: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + const f: SessionState = @enumFromInt(from); + const t: SessionState = @enumFromInt(to); + return if (isValidTransition(f, t)) 1 else 0; +} + +/// Reset all sessions (for testing). +pub export fn ml_reset() void { + mutex.lock(); + defer mutex.unlock(); + for (&sessions) |*slot| { + @memset(&slot.api_token, 0); + slot.api_token_len = 0; + slot.active = false; + slot.state = .unauthenticated; + slot.result_len = 0; + } +} + +// ═══════════════════════════════════════════════════════════════════════ +// Standard Cartridge Interface (loader expects these 4 C-ABI symbols) +// ═══════════════════════════════════════════════════════════════════════ + +/// Initialise the ml-mcp cartridge. Resets all session slots. +pub export fn boj_cartridge_init() c_int { + ml_reset(); + return 0; +} + +/// Deinitialise the ml-mcp cartridge. Resets all session slots. +pub export fn boj_cartridge_deinit() void { + ml_reset(); +} + +/// Return the cartridge name as a null-terminated C string. +pub export fn boj_cartridge_name() [*:0]const u8 { + mutex.lock(); + defer mutex.unlock(); + return "ml-mcp"; +} + +/// Return the cartridge version as a null-terminated C string. +pub export fn boj_cartridge_version() [*:0]const u8 { + mutex.lock(); + defer mutex.unlock(); + return "0.1.0"; +} + +// ═══════════════════════════════════════════════════════════════════════ +// ADR-0006 dispatch (boj_cartridge_invoke, 5th standard symbol) +// ═══════════════════════════════════════════════════════════════════════ + +const shim = @import("cartridge_shim.zig"); + +/// Dispatch the 4 cartridge.json MCP tools. Grade D Alpha β€” each arm +/// returns a stub JSON body that reflects the tool's intended shape. +/// `json_args` is ignored here; providers that need args (e.g. the +/// `provider` discriminator in `ml_authenticate`) parse them in a +/// follow-up migration once dispatch is wired end-to-end. +export fn boj_cartridge_invoke( + tool_name: [*c]const u8, + json_args: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) callconv(.c) i32 { + _ = json_args; + if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; + + const body: []const u8 = if (shim.toolIs(tool_name, "ml_authenticate")) + "{\"result\":{\"session_id\":0,\"status\":\"stub\",\"note\":\"Grade D Alpha\"}}" + else if (shim.toolIs(tool_name, "ml_inference")) + "{\"result\":{\"output\":\"\",\"status\":\"stub\",\"note\":\"Grade D Alpha\"}}" + else if (shim.toolIs(tool_name, "ml_list_models")) + "{\"result\":{\"models\":[],\"status\":\"stub\",\"note\":\"Grade D Alpha\"}}" + else if (shim.toolIs(tool_name, "ml_get_model_info")) + "{\"result\":{\"metadata\":{},\"status\":\"stub\",\"note\":\"Grade D Alpha\"}}" + else + return shim.RC_UNKNOWN_TOOL; + + return shim.writeResult(out_buf, in_out_len, body); +} + +// ═══════════════════════════════════════════════════════════════════════ +// Hugging Face Provider (provider code 1) +// Grade D Alpha β€” stub implementations +// Real API: https://huggingface.co/api/{endpoint} +// Auth: Authorization: Bearer {api_token} +// ═══════════════════════════════════════════════════════════════════════ + +/// Validate that a slot is active, authenticated, and bound to the Hugging Face provider. +fn validateHfSlot(slot_idx: c_int) ?usize { + if (slot_idx < 0 or slot_idx >= MAX_SESSIONS) return null; + const idx: usize = @intCast(slot_idx); + if (!sessions[idx].active) return null; + if (sessions[idx].provider != .hugging_face) return null; + if (sessions[idx].state != .authenticated) return null; + return idx; +} + +/// Write a JSON stub response into a session's result buffer. +fn writeHfResult(slot: *SessionSlot, endpoint: []const u8, method: []const u8) void { + const prefix = "{\"provider\":\"huggingface\",\"endpoint\":\""; + const mid1 = "\",\"method\":\""; + const mid2 = "\",\"status\":\"stub\",\"note\":\"Grade D Alpha\"}"; + + var pos: usize = 0; + const parts = [_][]const u8{ prefix, endpoint, mid1, method, mid2 }; + for (parts) |part| { + if (pos + part.len > RESULT_BUF_SIZE) break; + @memcpy(slot.result_buf[pos .. pos + part.len], part); + pos += part.len; + } + slot.result_len = pos; +} + +/// Set API token credentials on a Hugging Face session slot. +pub export fn ml_hf_set_credentials(slot_idx: c_int, token_ptr: [*]const u8, token_len: usize) c_int { + mutex.lock(); + defer mutex.unlock(); + const idx = validateHfSlot(slot_idx) orelse return -1; + if (token_len > API_TOKEN_SIZE) return -3; + @memcpy(sessions[idx].api_token[0..token_len], token_ptr[0..token_len]); + sessions[idx].api_token_len = token_len; + return 0; +} + +/// Search for models on Hugging Face. +pub export fn ml_hf_search_models(slot_idx: c_int, query_ptr: [*]const u8, query_len: usize) c_int { + mutex.lock(); + defer mutex.unlock(); + const idx = validateHfSlot(slot_idx) orelse return -1; + _ = query_ptr[0..query_len]; + writeHfResult(&sessions[idx], "models?search={query}", "GET"); + return 0; +} + +/// Get model info from Hugging Face. +pub export fn ml_hf_model_info(slot_idx: c_int, model_id_ptr: [*]const u8, model_id_len: usize) c_int { + mutex.lock(); + defer mutex.unlock(); + const idx = validateHfSlot(slot_idx) orelse return -1; + _ = model_id_ptr[0..model_id_len]; + writeHfResult(&sessions[idx], "models/{model_id}", "GET"); + return 0; +} + +/// Run inference on a Hugging Face model. json_ptr/json_len contain the inference payload. +pub export fn ml_hf_inference(slot_idx: c_int, json_ptr: [*]const u8, json_len: usize) c_int { + mutex.lock(); + defer mutex.unlock(); + const idx = validateHfSlot(slot_idx) orelse return -1; + _ = json_ptr[0..json_len]; + writeHfResult(&sessions[idx], "models/{model_id}/inference", "POST"); + return 0; +} + +/// List Spaces on Hugging Face. +pub export fn ml_hf_list_spaces(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + const idx = validateHfSlot(slot_idx) orelse return -1; + writeHfResult(&sessions[idx], "spaces", "GET"); + return 0; +} + +/// Get Space info from Hugging Face. +pub export fn ml_hf_space_info(slot_idx: c_int, space_id_ptr: [*]const u8, space_id_len: usize) c_int { + mutex.lock(); + defer mutex.unlock(); + const idx = validateHfSlot(slot_idx) orelse return -1; + _ = space_id_ptr[0..space_id_len]; + writeHfResult(&sessions[idx], "spaces/{space_id}", "GET"); + return 0; +} + +/// List datasets on Hugging Face. +pub export fn ml_hf_list_datasets(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + const idx = validateHfSlot(slot_idx) orelse return -1; + writeHfResult(&sessions[idx], "datasets", "GET"); + return 0; +} + +/// Get dataset info from Hugging Face. +pub export fn ml_hf_dataset_info(slot_idx: c_int, dataset_id_ptr: [*]const u8, dataset_id_len: usize) c_int { + mutex.lock(); + defer mutex.unlock(); + const idx = validateHfSlot(slot_idx) orelse return -1; + _ = dataset_id_ptr[0..dataset_id_len]; + writeHfResult(&sessions[idx], "datasets/{dataset_id}", "GET"); + return 0; +} + +/// Read the result buffer for a Hugging Face session slot. Returns length or -1 on error. +pub export fn ml_hf_read_result(slot_idx: c_int, out_ptr: [*]u8, out_cap: usize) c_int { + mutex.lock(); + defer mutex.unlock(); + if (slot_idx < 0 or slot_idx >= MAX_SESSIONS) return -1; + const idx: usize = @intCast(slot_idx); + if (!sessions[idx].active) return -1; + const len = @min(sessions[idx].result_len, out_cap); + @memcpy(out_ptr[0..len], sessions[idx].result_buf[0..len]); + return @intCast(len); +} + +// ═══════════════════════════════════════════════════════════════════════ +// Tests +// ═══════════════════════════════════════════════════════════════════════ + +test "authenticate and logout" { + ml_reset(); + const slot = ml_authenticate(@intFromEnum(MlProvider.hugging_face)); + try std.testing.expect(slot >= 0); + try std.testing.expectEqual(@as(c_int, @intFromEnum(SessionState.authenticated)), ml_state(slot)); + try std.testing.expectEqual(@as(c_int, 0), ml_logout(slot)); +} + +test "cannot operate on unauthenticated" { + ml_reset(); + const slot = ml_authenticate(@intFromEnum(MlProvider.hugging_face)); + _ = ml_logout(slot); + // Should fail β€” can't begin operation on unauthenticated session + try std.testing.expectEqual(@as(c_int, -1), ml_begin_operation(slot)); +} + +test "operation lifecycle" { + ml_reset(); + const slot = ml_authenticate(@intFromEnum(MlProvider.hugging_face)); + try std.testing.expectEqual(@as(c_int, 0), ml_begin_operation(slot)); + try std.testing.expectEqual(@as(c_int, @intFromEnum(SessionState.operating)), ml_state(slot)); + try std.testing.expectEqual(@as(c_int, 0), ml_end_operation(slot)); + try std.testing.expectEqual(@as(c_int, @intFromEnum(SessionState.authenticated)), ml_state(slot)); + try std.testing.expectEqual(@as(c_int, 0), ml_logout(slot)); +} + +test "cannot double-logout" { + ml_reset(); + const slot = ml_authenticate(@intFromEnum(MlProvider.hugging_face)); + _ = ml_logout(slot); + try std.testing.expectEqual(@as(c_int, -1), ml_logout(slot)); +} + +test "cannot logout while operating" { + ml_reset(); + const slot = ml_authenticate(@intFromEnum(MlProvider.hugging_face)); + _ = ml_begin_operation(slot); + try std.testing.expectEqual(@as(c_int, -2), ml_logout(slot)); +} + +test "state transition validation" { + try std.testing.expectEqual(@as(c_int, 1), ml_can_transition(0, 1)); + try std.testing.expectEqual(@as(c_int, 1), ml_can_transition(1, 2)); + try std.testing.expectEqual(@as(c_int, 1), ml_can_transition(2, 1)); + try std.testing.expectEqual(@as(c_int, 1), ml_can_transition(1, 0)); + try std.testing.expectEqual(@as(c_int, 1), ml_can_transition(2, 3)); + try std.testing.expectEqual(@as(c_int, 1), ml_can_transition(3, 0)); + try std.testing.expectEqual(@as(c_int, 0), ml_can_transition(0, 2)); + try std.testing.expectEqual(@as(c_int, 0), ml_can_transition(2, 0)); +} + +test "max sessions enforced" { + ml_reset(); + var slots: [MAX_SESSIONS]c_int = undefined; + for (&slots) |*s| { + s.* = ml_authenticate(@intFromEnum(MlProvider.hugging_face)); + try std.testing.expect(s.* >= 0); + } + try std.testing.expectEqual(@as(c_int, -1), ml_authenticate(@intFromEnum(MlProvider.hugging_face))); + _ = ml_logout(slots[0]); + try std.testing.expect(ml_authenticate(@intFromEnum(MlProvider.hugging_face)) >= 0); +} + +// ═══════════════════════════════════════════════════════════════════════ +// Hugging Face Provider Tests +// ═══════════════════════════════════════════════════════════════════════ + +test "huggingface auth and credential storage" { + ml_reset(); + const slot = ml_authenticate(@intFromEnum(MlProvider.hugging_face)); + try std.testing.expect(slot >= 0); + try std.testing.expectEqual(@as(c_int, @intFromEnum(SessionState.authenticated)), ml_state(slot)); + const token = "hf_test_api_token_abc123"; + try std.testing.expectEqual(@as(c_int, 0), ml_hf_set_credentials(slot, token.ptr, token.len)); + try std.testing.expectEqual(@as(c_int, 0), ml_logout(slot)); +} + +test "huggingface search models and model info" { + ml_reset(); + const slot = ml_authenticate(@intFromEnum(MlProvider.hugging_face)); + try std.testing.expect(slot >= 0); + // Search models + const query = "text-generation"; + try std.testing.expectEqual(@as(c_int, 0), ml_hf_search_models(slot, query.ptr, query.len)); + var buf: [RESULT_BUF_SIZE]u8 = undefined; + const len = ml_hf_read_result(slot, &buf, buf.len); + try std.testing.expect(len > 0); + const result = buf[0..@intCast(len)]; + try std.testing.expect(std.mem.indexOf(u8, result, "\"provider\":\"huggingface\"") != null); + try std.testing.expect(std.mem.indexOf(u8, result, "\"endpoint\":\"models?search={query}\"") != null); + // Model info + const model_id = "meta-llama/Llama-2-7b"; + try std.testing.expectEqual(@as(c_int, 0), ml_hf_model_info(slot, model_id.ptr, model_id.len)); + _ = ml_logout(slot); +} + +test "huggingface inference and spaces" { + ml_reset(); + const slot = ml_authenticate(@intFromEnum(MlProvider.hugging_face)); + try std.testing.expect(slot >= 0); + // Inference + const inference_json = "{\"model\":\"gpt2\",\"inputs\":\"Hello world\"}"; + try std.testing.expectEqual(@as(c_int, 0), ml_hf_inference(slot, inference_json.ptr, inference_json.len)); + var buf: [RESULT_BUF_SIZE]u8 = undefined; + var len = ml_hf_read_result(slot, &buf, buf.len); + try std.testing.expect(std.mem.indexOf(u8, buf[0..@intCast(len)], "\"endpoint\":\"models/{model_id}/inference\"") != null); + // List spaces + try std.testing.expectEqual(@as(c_int, 0), ml_hf_list_spaces(slot)); + len = ml_hf_read_result(slot, &buf, buf.len); + try std.testing.expect(std.mem.indexOf(u8, buf[0..@intCast(len)], "\"endpoint\":\"spaces\"") != null); + // Space info + const space_id = "stabilityai/stable-diffusion"; + try std.testing.expectEqual(@as(c_int, 0), ml_hf_space_info(slot, space_id.ptr, space_id.len)); + _ = ml_logout(slot); +} + +test "huggingface datasets" { + ml_reset(); + const slot = ml_authenticate(@intFromEnum(MlProvider.hugging_face)); + try std.testing.expect(slot >= 0); + // List datasets + try std.testing.expectEqual(@as(c_int, 0), ml_hf_list_datasets(slot)); + var buf: [RESULT_BUF_SIZE]u8 = undefined; + var len = ml_hf_read_result(slot, &buf, buf.len); + try std.testing.expect(std.mem.indexOf(u8, buf[0..@intCast(len)], "\"endpoint\":\"datasets\"") != null); + // Dataset info + const dataset_id = "squad"; + try std.testing.expectEqual(@as(c_int, 0), ml_hf_dataset_info(slot, dataset_id.ptr, dataset_id.len)); + len = ml_hf_read_result(slot, &buf, buf.len); + try std.testing.expect(std.mem.indexOf(u8, buf[0..@intCast(len)], "\"endpoint\":\"datasets/{dataset_id}\"") != null); + _ = ml_logout(slot); +} + +// ═══════════════════════════════════════════════════════════════════════ +// ADR-0006 invoke dispatch tests +// ═══════════════════════════════════════════════════════════════════════ + +test "invoke: ml_authenticate returns session_id stub" { + var buf: [256]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("ml_authenticate", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, 0), rc); + try std.testing.expect(std.mem.indexOf(u8, buf[0..len], "session_id") != null); +} + +test "invoke: ml_inference returns output stub" { + var buf: [256]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("ml_inference", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, 0), rc); + try std.testing.expect(std.mem.indexOf(u8, buf[0..len], "output") != null); +} + +test "invoke: ml_list_models returns models array" { + var buf: [256]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("ml_list_models", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, 0), rc); + try std.testing.expect(std.mem.indexOf(u8, buf[0..len], "models") != null); +} + +test "invoke: ml_get_model_info returns metadata" { + var buf: [256]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("ml_get_model_info", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, 0), rc); + try std.testing.expect(std.mem.indexOf(u8, buf[0..len], "metadata") != null); +} + +test "invoke: unknown tool returns -1" { + var buf: [256]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("not_a_tool", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -1), rc); +} + +test "invoke: buffer too small returns -3 and sets required length" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("ml_authenticate", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -3), rc); + try std.testing.expect(len > 4); +} + +test "huggingface wrong-provider rejection" { + ml_reset(); + // Authenticate as custom, not hugging_face + const slot = ml_authenticate(@intFromEnum(MlProvider.custom)); + try std.testing.expect(slot >= 0); + // All HF operations should return -1 (wrong provider) + const query = "test"; + try std.testing.expectEqual(@as(c_int, -1), ml_hf_search_models(slot, query.ptr, query.len)); + try std.testing.expectEqual(@as(c_int, -1), ml_hf_model_info(slot, query.ptr, query.len)); + const json_data = "{}"; + try std.testing.expectEqual(@as(c_int, -1), ml_hf_inference(slot, json_data.ptr, json_data.len)); + try std.testing.expectEqual(@as(c_int, -1), ml_hf_list_spaces(slot)); + try std.testing.expectEqual(@as(c_int, -1), ml_hf_space_info(slot, query.ptr, query.len)); + try std.testing.expectEqual(@as(c_int, -1), ml_hf_list_datasets(slot)); + try std.testing.expectEqual(@as(c_int, -1), ml_hf_dataset_info(slot, query.ptr, query.len)); + const token = "hf_test"; + try std.testing.expectEqual(@as(c_int, -1), ml_hf_set_credentials(slot, token.ptr, token.len)); + _ = ml_logout(slot); +} diff --git a/cartridges/cross-cutting/nesy/ml-mcp/mod.js b/cartridges/cross-cutting/nesy/ml-mcp/mod.js new file mode 100644 index 0000000..2458d24 --- /dev/null +++ b/cartridges/cross-cutting/nesy/ml-mcp/mod.js @@ -0,0 +1,55 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +// +// ml-mcp/mod.js β€” Machine learning inference (HuggingFace and others) +// +// Delegates to backend at http://127.0.0.1:7733 (override with ML_BACKEND_URL). + +const BASE_URL = Deno.env.get("ML_BACKEND_URL") ?? "http://127.0.0.1:7733"; +const TIMEOUT_MS = 15_000; + +async function post(path, payload) { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), TIMEOUT_MS); + try { + const r = await fetch(`${BASE_URL}${path}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + signal: ctrl.signal, + }); + const data = await r.json().catch(() => ({ success: false, error: "non-JSON response" })); + return { status: r.status, data }; + } catch (e) { + if (e.name === "AbortError") return { status: 504, data: { success: false, error: "ml-mcp backend timed out" } }; + return { status: 503, data: { success: false, error: `ml-mcp backend unavailable: ${e.message}` } }; + } finally { clearTimeout(t); } +} + +async function get(path) { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), TIMEOUT_MS); + try { + const r = await fetch(`${BASE_URL}${path}`, { method: "GET", signal: ctrl.signal }); + const data = await r.json().catch(() => ({ success: false, error: "non-JSON response" })); + return { status: r.status, data }; + } catch (e) { + if (e.name === "AbortError") return { status: 504, data: { success: false, error: "ml-mcp backend timed out" } }; + return { status: 503, data: { success: false, error: `ml-mcp backend unavailable: ${e.message}` } }; + } finally { clearTimeout(t); } +} + +export async function handleTool(toolName, args) { + switch (toolName) { + case "ml_authenticate": + return post("/api/v1/ml_authenticate", args ?? {}); + case "ml_inference": + return post("/api/v1/ml_inference", args ?? {}); + case "ml_list_models": + return post("/api/v1/ml_list_models", args ?? {}); + case "ml_get_model_info": + return post("/api/v1/ml_get_model_info", args ?? {}); + default: + return { status: 404, data: { error: `Unknown tool: ${toolName}` } }; + } +} diff --git a/cartridges/cross-cutting/nesy/ml-mcp/panels/manifest.json b/cartridges/cross-cutting/nesy/ml-mcp/panels/manifest.json new file mode 100644 index 0000000..66261e8 --- /dev/null +++ b/cartridges/cross-cutting/nesy/ml-mcp/panels/manifest.json @@ -0,0 +1,67 @@ +{ + "$schema": "https://panll.dev/schemas/panel-manifest/v1.json", + "spdx": "MPL-2.0", + "cartridge": "ml-mcp", + "domain": "Machine Learning", + "version": "0.1.0", + "panels": [ + { + "id": "ml-status", + "title": "ML Pipeline Status", + "description": "HuggingFace and local model runtime readiness", + "type": "status-indicator", + "data_source": { + "endpoint": "/cartridge/ml-mcp/invoke", + "method": "POST", + "body": { "tool": "status" }, + "refresh_interval_ms": 5000 + }, + "widgets": [ + { + "type": "state-badge", + "field": "state", + "states": { + "ready": { "color": "#2ecc71", "icon": "cpu" }, + "degraded": { "color": "#f39c12", "icon": "alert-circle" }, + "error": { "color": "#e74c3c", "icon": "alert-triangle" } + } + }, + { "type": "text", "field": "version", "label": "Version" } + ] + }, + { + "id": "ml-models", + "title": "Model Registry", + "description": "Loaded models, their sizes, and inference endpoints", + "type": "metric", + "data_source": { + "endpoint": "/cartridge/ml-mcp/invoke", + "method": "POST", + "body": { "tool": "model_registry" }, + "refresh_interval_ms": 30000 + }, + "widgets": [ + { "type": "counter", "field": "loaded_models", "label": "Loaded Models", "icon": "box" }, + { "type": "counter", "field": "available_models", "label": "Available", "icon": "archive" }, + { "type": "text", "field": "total_vram_used", "label": "VRAM Used" } + ] + }, + { + "id": "ml-inference-stats", + "title": "Inference Statistics", + "description": "Request throughput, average latency, and token generation rate", + "type": "metric", + "data_source": { + "endpoint": "/cartridge/ml-mcp/invoke", + "method": "POST", + "body": { "tool": "inference_stats" }, + "refresh_interval_ms": 10000 + }, + "widgets": [ + { "type": "counter", "field": "requests_per_minute", "label": "Requests/min", "icon": "activity" }, + { "type": "counter", "field": "avg_latency_ms", "label": "Avg Latency (ms)", "icon": "clock" }, + { "type": "counter", "field": "tokens_per_second", "label": "Tokens/sec", "icon": "zap" } + ] + } + ] +} diff --git a/cartridges/cross-cutting/nesy/nesy-mcp/NESY-MCP-REPORT.adoc b/cartridges/cross-cutting/nesy/nesy-mcp/NESY-MCP-REPORT.adoc new file mode 100644 index 0000000..dc8169f --- /dev/null +++ b/cartridges/cross-cutting/nesy/nesy-mcp/NESY-MCP-REPORT.adoc @@ -0,0 +1,212 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) += NeSy-MCP Cartridge β€” Technical Report +Jonathan D.A. Jewell +:toc: left +:toclevels: 3 +:icons: font +:source-highlighter: rouge + +== Executive Summary + +NeSy-MCP is a formally verified neurosymbolic reasoning cartridge for the BoJ (Bundle of Joy) server. It enforces a single inviolable law: *Symbolic truth ALWAYS overrides Neural probability.* This cartridge bridges the gap between AI systems that guess (neural networks) and systems that prove (formal verification), ensuring that when both disagree, proven facts win. + +The cartridge is now accessible via *9 protocol transports*: REST, gRPC, GraphQL, WebSocket, JSON-RPC 2.0, MQTT, tRPC, SOAP, and Cap'n Proto β€” making it embeddable in any architecture from web APIs to IIoT edge devices. + +== Architecture + +=== The ABI-FFI-Adapter Triple + +[source] +---- + Idris2 ABI (SafeReasoning.idr, Protocol.idr) + β”‚ Formal proofs, dependent types + β”‚ %default total β€” every function terminates + β–Ό + Zig FFI (nesy_ffi.zig) + β”‚ C-compatible exports, thread-safe + β”‚ 40 tests, zero undefined behaviour + β–Ό + zig Adapters (9 protocol servers) + β”‚ REST, gRPC, GraphQL, WebSocket, JSON-RPC, + β”‚ MQTT, tRPC, SOAP, Cap'n Proto + β–Ό + Clients (AI agents, IDEs, dashboards, edge devices) +---- + +=== Why This Architecture Matters + +* The *Idris2 layer* proves correctness at compile time. The harmonization law cannot be violated β€” it is a mathematical theorem, not a convention. +* The *Zig layer* translates proofs into executable code with zero runtime overhead. No garbage collector, no exceptions, no undefined behaviour. +* The *zig layer* makes the proven logic accessible from any protocol a consumer might need. Adding a new transport does not require re-proving anything. + +== Core Logic + +=== The Harmonization Law + +The central function of NeSy-MCP takes two inputs β€” what the neural network thinks and what the symbolic prover knows β€” and produces a single authoritative verdict. + +[cols="1,1,1,1", options="header"] +|=== +| Neural Says | Symbolic Says | Harmonized Verdict | Confidence + +| Probable Safe | Proven Safe | *Certified Safe* | Absolute +| Probable Safe | No Proof | Requires Review | Low +| Probable Safe | Proven Unsafe | *Critical Unsafe* | Absolute +| Unsure | Proven Safe | *Certified Safe* | Absolute +| Unsure | No Proof | Requires Review | Low +| Unsure | Proven Unsafe | *Critical Unsafe* | Absolute +| Probable Unsafe | Proven Safe | *Certified Safe* | Absolute +| Probable Unsafe | No Proof | *Critical Unsafe* | High +| Probable Unsafe | Proven Unsafe | *Critical Unsafe* | Absolute +|=== + +*Key insight*: When there is a proof (safe or unsafe), the neural verdict is irrelevant. When there is no proof and neural says "unsafe", we escalate β€” because a neural alarm without proof demands investigation. + +=== Drift Detection + +Neural models degrade over time. NeSy-MCP classifies 6 kinds of drift and recommends actions: + +[cols="1,1,1", options="header"] +|=== +| Drift Kind | Severity | Recommended Action + +| No Drift | 0 | Log and Accept +| Semantic Drift | 1 | Log and Accept +| Confidence Drift | 2 | Flag for Review +| Factual Drift | 3 | Reject Neural Output +| Temporal Drift | 4 | Retry Neural Query +| Catastrophic Drift | 5 | *Halt System* +|=== + +=== Reasoning Modes + +NeSy-MCP supports 6 reasoning modes that determine which layers are engaged: + +[cols="1,1,1,1", options="header"] +|=== +| Mode | Uses Symbolic | Uses Neural | Use Case + +| Symbolic | Yes | No | Pure formal verification +| Neural | No | Yes | Fast prediction (no guarantees) +| SymToNeural | Yes | Yes | Proof-guided neural search +| NeuralToSym | Yes | Yes | Neural hypothesis β†’ proof attempt +| Ensemble | Yes | Yes | Both run independently, merge results +| Cascade | Yes | Yes | Try symbolic first, fall back to neural +|=== + +== Protocol Transports + +=== 9 Protocols Available + +[cols="1,1,2", options="header"] +|=== +| Protocol | Adapter File | Best For + +| *REST* | `nesy_adapter.v` | Standard web APIs, Claude MCP bridge +| *gRPC* | `nesy_grpc.v` | Microservices, high-throughput RPCs +| *GraphQL* | `nesy_graphql.v` | Frontend dashboards, flexible queries +| *WebSocket* | `nesy_websocket.v` | Real-time streaming verdicts, live monitoring +| *JSON-RPC 2.0* | `nesy_jsonrpc.v` | MCP native protocol, batch harmonization +| *MQTT* | `nesy_mqtt.v` | IoT edge, SCADA, lightweight telemetry +| *tRPC* | `nesy_trpc.v` | Type-safe frontend integration +| *SOAP* | `nesy_soap.v` | Enterprise/government compliance systems +| *Cap'n Proto* | `nesy_capnproto.v` | Zero-copy high-performance binary RPC +|=== + +=== Protocol Selection Guide + +* *Building a web dashboard?* β†’ GraphQL or tRPC +* *Streaming live verdicts to a panel?* β†’ WebSocket +* *Connecting from an MCP client?* β†’ JSON-RPC (native MCP protocol) +* *Edge device sending telemetry?* β†’ MQTT +* *Enterprise compliance system?* β†’ SOAP +* *Maximum throughput pipeline?* β†’ Cap'n Proto or gRPC +* *Simple integration?* β†’ REST + +== Specific NeSy Server Types + +=== 1. Harmonization Gateway + +The most common deployment: a single NeSy-MCP instance sits between AI agents and production systems. Every AI decision passes through harmonization before execution. + +*Example flow*: Claude suggests a code change β†’ Hypatia CI runs a neural security scan β†’ Idris2 proof checker validates β†’ NeSy-MCP harmonizes both verdicts β†’ only `certified_safe` changes are merged. + +=== 2. Drift Sentinel + +Deployed as a long-running monitor that continuously evaluates neural model health. Uses MQTT to publish drift alerts to ops channels, WebSocket to feed live dashboards, and REST for manual queries. + +*Example*: Hugging Face model inference shows decreasing confidence scores β†’ Drift Sentinel detects `ConfidenceDrift` β†’ flags for human review before the model produces garbage. + +=== 3. Proof-Assisted RAG + +A Retrieval-Augmented Generation system where retrieved facts are verified before injection into the neural context. Uses the `NeuralToSym` reasoning mode: neural retrieves candidates, symbolic proves which are factual. + +*Example*: Legal document analysis β€” neural model retrieves relevant precedents, symbolic layer verifies case citations against a legal database, only verified citations are included. + +=== 4. Safety-Critical Decision Engine + +For autonomous systems (robotics, medical, industrial) where neural predictions must be formally verified before actuation. Uses Cap'n Proto for minimal-latency binary RPC, with the `Cascade` reasoning mode. + +*Example*: Autonomous vehicle perception β€” neural object detection β†’ symbolic spatial reasoning proves collision risk β†’ only proven-safe manoeuvres are executed. + +=== 5. Federated NeSy Mesh + +Multiple NeSy-MCP instances across a network, each with different symbolic knowledge bases. Uses the Umoja federation protocol (UDP gossip) to share harmonization results across nodes. + +*Example*: Multi-team software development β€” each team's NeSy node has domain-specific proofs, but all share harmonization results to build collective confidence. + +== Value Beyond Current Implementation + +=== What We Have Now + +* Formally verified harmonization law (zero bugs possible in the core logic) +* 9 protocol transports covering every integration pattern +* Thread-safe session management via Zig mutexes +* Drift detection and classification +* Reasoning mode selection + +=== What This Enables That Nothing Else Does + +1. *Trustworthy AI*: No other system mathematically guarantees that neural predictions cannot override symbolic proofs. NeSy-MCP's Idris2 ABI makes this a theorem, not a best practice. + +2. *Protocol Universality*: The same verified logic is available via MQTT (for an Arduino), WebSocket (for a browser), gRPC (for a Kubernetes pod), and SOAP (for a bank's legacy system) β€” all calling the same proven Zig FFI. + +3. *Drift as a First-Class Concept*: Most ML monitoring tools detect drift but don't act on it. NeSy-MCP's policy functions map drift β†’ action deterministically, with `Halt` as the emergency stop. + +4. *Composable Reasoning*: The 6 reasoning modes are not configuration β€” they are fundamental execution strategies. `Cascade` tries proof first and falls back; `Ensemble` runs both independently. This is a reasoning algebra. + +== Roadmap + +=== Short-Term (v0.3.0) + +* [ ] *Grounding verification*: Implement `GroundingStatus` pipeline that checks neural outputs against a fact database before harmonization +* [ ] *Batch harmonization*: JSON-RPC batch mode to harmonize 100+ verdicts in a single call +* [ ] *WebSocket subscription channels*: Subscribe to specific drift kinds (e.g., only `CatastrophicDrift` alerts) +* [ ] *MQTT QoS 1 support*: At-least-once delivery for safety-critical verdicts + +=== Medium-Term (v0.4.0) + +* [ ] *Proof certificate export*: When symbolic proves something, export the proof certificate (Idris2 elaborated core) as a portable artifact that any verifier can check +* [ ] *Neural backend abstraction*: Hot-swap between Claude, Gemini, Mistral without changing harmonization logic +* [ ] *Confidence calibration*: Track how often `RequiresReview` verdicts turn out safe vs unsafe, auto-adjust thresholds +* [ ] *GraphQL subscriptions*: Real-time GraphQL subscription for live verdict streaming (not just polling) +* [ ] *Cap'n Proto streaming RPC*: Full bidirectional streaming for high-throughput pipelines + +=== Long-Term (v1.0) + +* [ ] *Formal verification of FFI layer*: Prove that the Zig implementation is a faithful translation of the Idris2 specification (using Lean4 or Coq extraction) +* [ ] *NeSy composition operators*: Chain multiple NeSy cartridges together (`nesy1 >> nesy2 >> nesy3`) with compositional proof guarantees +* [ ] *Adaptive reasoning mode selection*: ML model that learns which reasoning mode to use based on the query type, automatically selecting Cascade vs Ensemble +* [ ] *Cross-cartridge harmonization*: NeSy-MCP harmonizes not just neural+symbolic but also cross-domain cartridge outputs (e.g., database + security + compliance verdicts) +* [ ] *Formal drift taxonomy*: Extend drift types with domain-specific categories (medical drift, financial drift, safety drift) with domain-specific action policies +* [ ] *WASM cartridge support*: Run NeSy-MCP as a WASM module in browsers and edge runtimes + +=== Protocol Evolution + +* [ ] *HTTP/2 native gRPC*: Upgrade from JSON-over-HTTP to full Protobuf transport via Zig FFI +* [ ] *QUIC transport*: UDP-based low-latency transport for federation gossip +* [ ] *NATS/JetStream*: Cloud-native messaging alternative to MQTT +* [ ] *Apache Arrow IPC*: Zero-copy columnar data for batch harmonization of datasets +* [ ] *OpenTelemetry integration*: Emit harmonization metrics as OTel spans +* [ ] *MCP native transport*: Direct MCP stdio bridge without HTTP intermediary diff --git a/cartridges/cross-cutting/nesy/nesy-mcp/README.adoc b/cartridges/cross-cutting/nesy/nesy-mcp/README.adoc new file mode 100644 index 0000000..b2d4849 --- /dev/null +++ b/cartridges/cross-cutting/nesy/nesy-mcp/README.adoc @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) += nesy-mcp +Jonathan D.A. Jewell +:spdx: MPL-2.0 +:tier: Ayo +:domain: AI/NeSy +:protocols: MCP, REST + +== Overview + +Neural-symbolic (NeSy) harmonization engine. Symbolic truth always overrides neural probability. + +== Tools (3) + +[cols="2,4"] +|=== +| Tool | Description + +| `nesy_harmonize` | Harmonize a neural probability with a symbolic verdict. Returns harmonized verdict and confidence. +| `nesy_analyze_drift` | Analyze a drift kind and return severity, urgency, and recommended action. +| `nesy_reasoning_mode_info` | Get metadata about a reasoning mode: symbolic, neural, or hybrid. +|=== + +== Architecture + +Three-layer stack per the BoJ cartridge standard: + +[cols="1,1,3"] +|=== +| Layer | Language | Purpose + +| ABI | Idris2 | Formally verified state machine with dependent-type proofs +| FFI | Zig | C-ABI implementation: session pool, action metrics, thread safety +| Adapter| Zig | Adapter bridge to the BoJ unified adapter protocol (zig predecessors sidelined) +|=== + +== Building + +[source,bash] +---- +# ABI (Idris2) +cd abi && idris2 --check *.idr + +# FFI (Zig) +cd ffi && zig build +cd ffi && zig build test + +# Adapter (Zig) +cd adapter && zig build +---- + +== Status + +Alpha (CRG D). Cartridge metadata declared in `cartridge.json`; 3 tool(s) exposed. +Not yet dogfooded externally. See `docs/READINESS.md` at repo root for the +project-wide grade table and `docs/practice/DOGFOOD-LOG.adoc` for usage evidence. diff --git a/cartridges/cross-cutting/nesy/nesy-mcp/abi/NesyMcp/Protocol.idr b/cartridges/cross-cutting/nesy/nesy-mcp/abi/NesyMcp/Protocol.idr new file mode 100644 index 0000000..aca0952 --- /dev/null +++ b/cartridges/cross-cutting/nesy/nesy-mcp/abi/NesyMcp/Protocol.idr @@ -0,0 +1,249 @@ +-- SPDX-License-Identifier: MPL-2.0 +-- Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +||| NesyMcp.Protocol: Full neurosymbolic protocol types for the nesy-mcp cartridge. +||| +||| Extends SafeReasoning (the harmonization law) with the comprehensive +||| proven-nesy protocol type system. These types mirror the ABI definitions +||| in proven-servers/protocols/proven-nesy exactly. +||| +||| SafeReasoning handles the WHAT (harmonize verdicts). +||| Protocol handles the HOW (reasoning modes, backends, drift, merge strategies). +module NesyMcp.Protocol + +import NesyMcp.SafeReasoning + +%default total + +-- ═══════════════════════════════════════════════════════════════════════════ +-- ReasoningMode β€” which paradigm to use for a query +-- (from proven-nesy NeSy.Types) +-- ═══════════════════════════════════════════════════════════════════════════ + +||| The reasoning paradigm to apply. Extends the basic Neural/Symbolic +||| binary from SafeReasoning into a richer taxonomy of hybrid modes. +public export +data ReasoningMode : Type where + Symbolic : ReasoningMode + Neural : ReasoningMode + SymToNeural : ReasoningMode + NeuralToSym : ReasoningMode + Ensemble : ReasoningMode + Cascade : ReasoningMode + +public export +Show ReasoningMode where + show Symbolic = "Symbolic" + show Neural = "Neural" + show SymToNeural = "SymToNeural" + show NeuralToSym = "NeuralToSym" + show Ensemble = "Ensemble" + show Cascade = "Cascade" + +||| Whether this mode engages the symbolic layer. +public export +usesSymbolic : ReasoningMode -> Bool +usesSymbolic Neural = False +usesSymbolic _ = True + +||| Whether this mode engages the neural layer. +public export +usesNeural : ReasoningMode -> Bool +usesNeural Symbolic = False +usesNeural _ = True + +-- ═══════════════════════════════════════════════════════════════════════════ +-- ProofStatus β€” lifecycle of a proof obligation +-- (from proven-nesy NeSy.Types) +-- ═══════════════════════════════════════════════════════════════════════════ + +public export +data ProofStatus : Type where + Pending : ProofStatus + Attempting : ProofStatus + Proved : ProofStatus + Failed : ProofStatus + Assumed : ProofStatus + Vacuous : ProofStatus + +public export +Show ProofStatus where + show Pending = "Pending" + show Attempting = "Attempting" + show Proved = "Proved" + show Failed = "Failed" + show Assumed = "Assumed" + show Vacuous = "Vacuous" + +-- ═══════════════════════════════════════════════════════════════════════════ +-- NeuralBackend β€” which inference engine +-- (from proven-nesy NeSy.Types) +-- ═══════════════════════════════════════════════════════════════════════════ + +public export +data NeuralBackend : Type where + LocalModel : NeuralBackend + Claude : NeuralBackend + Gemini : NeuralBackend + Mistral : NeuralBackend + GPT : NeuralBackend + CustomNeural : NeuralBackend + +public export +Show NeuralBackend where + show LocalModel = "LocalModel" + show Claude = "Claude" + show Gemini = "Gemini" + show Mistral = "Mistral" + show GPT = "GPT" + show CustomNeural = "CustomNeural" + +-- ═══════════════════════════════════════════════════════════════════════════ +-- DriftKind β€” how symbolic and neural results diverge +-- (from proven-nesy NeSy.Types) +-- ═══════════════════════════════════════════════════════════════════════════ + +public export +data DriftKind : Type where + NoDrift : DriftKind + SemanticDrift : DriftKind + ConfidenceDrift : DriftKind + FactualDrift : DriftKind + TemporalDrift : DriftKind + CatastrophicDrift : DriftKind + +public export +Show DriftKind where + show NoDrift = "NoDrift" + show SemanticDrift = "SemanticDrift" + show ConfidenceDrift = "ConfidenceDrift" + show FactualDrift = "FactualDrift" + show TemporalDrift = "TemporalDrift" + show CatastrophicDrift = "CatastrophicDrift" + +-- ═══════════════════════════════════════════════════════════════════════════ +-- MergeStrategy β€” how to combine results +-- (from proven-nesy NeSy.Integration) +-- ═══════════════════════════════════════════════════════════════════════════ + +public export +data MergeStrategy : Type where + SymbolicPrimacy : MergeStrategy + NeuralPrimacy : MergeStrategy + ConfidenceWeighted : MergeStrategy + Consensus : MergeStrategy + DualReturn : MergeStrategy + ConstrainedGeneration : MergeStrategy + +public export +Show MergeStrategy where + show SymbolicPrimacy = "SymbolicPrimacy" + show NeuralPrimacy = "NeuralPrimacy" + show ConfidenceWeighted = "ConfidenceWeighted" + show Consensus = "Consensus" + show DualReturn = "DualReturn" + show ConstrainedGeneration = "ConstrainedGeneration" + +-- ═══════════════════════════════════════════════════════════════════════════ +-- DriftAction β€” what to do when drift is detected +-- (from proven-nesy NeSy.Integration) +-- ═══════════════════════════════════════════════════════════════════════════ + +public export +data DriftAction : Type where + LogAndAccept : DriftAction + FlagForReview : DriftAction + RejectNeural : DriftAction + RetryNeural : DriftAction + Escalate : DriftAction + Halt : DriftAction + +public export +Show DriftAction where + show LogAndAccept = "LogAndAccept" + show FlagForReview = "FlagForReview" + show RejectNeural = "RejectNeural" + show RetryNeural = "RetryNeural" + show Escalate = "Escalate" + show Halt = "Halt" + +-- ═══════════════════════════════════════════════════════════════════════════ +-- GroundingStatus β€” is the neural output grounded in symbolic facts? +-- (from proven-nesy NeSy.Integration) +-- ═══════════════════════════════════════════════════════════════════════════ + +public export +data GroundingStatus : Type where + FullyGrounded : GroundingStatus + PartiallyGrounded : GroundingStatus + Ungrounded : GroundingStatus + GroundingPending : GroundingStatus + GroundingFailed : GroundingStatus + +public export +Show GroundingStatus where + show FullyGrounded = "FullyGrounded" + show PartiallyGrounded = "PartiallyGrounded" + show Ungrounded = "Ungrounded" + show GroundingPending = "GroundingPending" + show GroundingFailed = "GroundingFailed" + +-- ═══════════════════════════════════════════════════════════════════════════ +-- Drift Recommendation β€” pure function connecting DriftKind to DriftAction +-- ═══════════════════════════════════════════════════════════════════════════ + +||| Recommend the default action for a given drift severity. +||| This is the policy-level complement to SafeReasoning's harmonization law. +public export +recommendDriftAction : DriftKind -> DriftAction +recommendDriftAction NoDrift = LogAndAccept +recommendDriftAction SemanticDrift = LogAndAccept +recommendDriftAction ConfidenceDrift = FlagForReview +recommendDriftAction FactualDrift = RejectNeural +recommendDriftAction TemporalDrift = RetryNeural +recommendDriftAction CatastrophicDrift = Halt + +-- ═══════════════════════════════════════════════════════════════════════════ +-- C-ABI Encoding β€” integer encodings for FFI bridge +-- ═══════════════════════════════════════════════════════════════════════════ + +public export +reasoningModeToInt : ReasoningMode -> Int +reasoningModeToInt Symbolic = 0 +reasoningModeToInt Neural = 1 +reasoningModeToInt SymToNeural = 2 +reasoningModeToInt NeuralToSym = 3 +reasoningModeToInt Ensemble = 4 +reasoningModeToInt Cascade = 5 + +public export +driftKindToInt : DriftKind -> Int +driftKindToInt NoDrift = 0 +driftKindToInt SemanticDrift = 1 +driftKindToInt ConfidenceDrift = 2 +driftKindToInt FactualDrift = 3 +driftKindToInt TemporalDrift = 4 +driftKindToInt CatastrophicDrift = 5 + +public export +intToDriftKind : Int -> DriftKind +intToDriftKind 0 = NoDrift +intToDriftKind 1 = SemanticDrift +intToDriftKind 2 = ConfidenceDrift +intToDriftKind 3 = FactualDrift +intToDriftKind 4 = TemporalDrift +intToDriftKind _ = CatastrophicDrift + +public export +driftActionToInt : DriftAction -> Int +driftActionToInt LogAndAccept = 0 +driftActionToInt FlagForReview = 1 +driftActionToInt RejectNeural = 2 +driftActionToInt RetryNeural = 3 +driftActionToInt Escalate = 4 +driftActionToInt Halt = 5 + +||| FFI: Recommend a drift action given a drift kind (integer-encoded). +export +nesy_recommend_drift_action : Int -> Int +nesy_recommend_drift_action d = + driftActionToInt (recommendDriftAction (intToDriftKind d)) diff --git a/cartridges/cross-cutting/nesy/nesy-mcp/abi/NesyMcp/SafeReasoning.idr b/cartridges/cross-cutting/nesy/nesy-mcp/abi/NesyMcp/SafeReasoning.idr new file mode 100644 index 0000000..6549ee4 --- /dev/null +++ b/cartridges/cross-cutting/nesy/nesy-mcp/abi/NesyMcp/SafeReasoning.idr @@ -0,0 +1,137 @@ +-- SPDX-License-Identifier: MPL-2.0 +-- Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +||| NesyMcp.SafeReasoning: Formally verified neurosymbolic harmonizer. +||| +||| Cartridge: nesy-mcp +||| Matrix cell: NeSy domain x {MCP, LSP, NeSy, gRPC} protocols +||| +||| Core axiom: Symbolic truth ALWAYS overrides Neural probability. +||| +||| This module defines the formal rules for combining: +||| - Neural predictions (from Hypatia β€” pattern recognition, heuristics) +||| - Symbolic proofs (from Echidna/Proven β€” formal verification) +||| +||| The harmonization law ensures that a proven result is never +||| overridden by a probabilistic guess, no matter how confident. +module NesyMcp.SafeReasoning + +%default total + +-- ═══════════════════════════════════════════════════════════════════════════ +-- Verdict Types +-- ═══════════════════════════════════════════════════════════════════════════ + +||| Neural classification (from Hypatia or similar). +||| These are probabilistic β€” "probably safe" is NOT "proven safe". +public export +data NeuralVerdict = ProbableSafe | Unsure | ProbableUnsafe + +||| Symbolic proof result (from Echidna, Proven, or similar). +||| These are definitive β€” "proven safe" IS safe. +public export +data SymbolicVerdict = ProvenSafe | NoProof | ProvenUnsafe + +||| The harmonized conclusion after combining both verdicts. +public export +data HarmonizedVerdict = CertifiedSafe | RequiresReview | CriticalUnsafe + +-- ═══════════════════════════════════════════════════════════════════════════ +-- The Harmonization Law +-- ═══════════════════════════════════════════════════════════════════════════ + +||| Core harmonization: Symbolic truth always overrides Neural probability. +||| +||| The rules: +||| 1. If Symbolic says UNSAFE β†’ CriticalUnsafe (regardless of Neural) +||| 2. If Symbolic says SAFE β†’ CertifiedSafe (regardless of Neural) +||| 3. If Symbolic has NO PROOF β†’ RequiresReview (regardless of Neural) +||| +||| Rule 3 is the key insight: even if Neural says "ProbableSafe", +||| without a proof it's just a guess. We don't certify guesses. +public export +harmonize : NeuralVerdict -> SymbolicVerdict -> HarmonizedVerdict +harmonize _ ProvenUnsafe = CriticalUnsafe +harmonize _ ProvenSafe = CertifiedSafe +harmonize ProbableUnsafe NoProof = CriticalUnsafe -- Neural alarm + no proof = escalate +harmonize Unsure NoProof = RequiresReview +harmonize ProbableSafe NoProof = RequiresReview -- Even if neural thinks safe, no proof = review + +-- ═══════════════════════════════════════════════════════════════════════════ +-- Confidence Levels +-- ═══════════════════════════════════════════════════════════════════════════ + +||| How confident we are in a harmonized verdict. +public export +data ConfidenceLevel = Absolute | High | Low + +||| Derive confidence from the input verdicts. +public export +confidence : NeuralVerdict -> SymbolicVerdict -> ConfidenceLevel +confidence _ ProvenSafe = Absolute -- Proof is absolute +confidence _ ProvenUnsafe = Absolute -- Proof is absolute +confidence ProbableSafe NoProof = Low +confidence Unsure NoProof = Low +confidence ProbableUnsafe NoProof = High -- Neural alarm is worth paying attention to + +-- ═══════════════════════════════════════════════════════════════════════════ +-- C-ABI Encoding/Decoding +-- ═══════════════════════════════════════════════════════════════════════════ + +||| Neural verdict to integer. +public export +neuralToInt : NeuralVerdict -> Int +neuralToInt ProbableSafe = 1 +neuralToInt Unsure = 2 +neuralToInt ProbableUnsafe = 3 + +||| Integer to neural verdict (safe default: Unsure). +public export +intToNeural : Int -> NeuralVerdict +intToNeural 1 = ProbableSafe +intToNeural 3 = ProbableUnsafe +intToNeural _ = Unsure + +||| Symbolic verdict to integer. +public export +symbolicToInt : SymbolicVerdict -> Int +symbolicToInt ProvenSafe = 1 +symbolicToInt NoProof = 2 +symbolicToInt ProvenUnsafe = 3 + +||| Integer to symbolic verdict (safe default: NoProof). +public export +intToSymbolic : Int -> SymbolicVerdict +intToSymbolic 1 = ProvenSafe +intToSymbolic 3 = ProvenUnsafe +intToSymbolic _ = NoProof + +||| Harmonized verdict to integer. +public export +harmonizedToInt : HarmonizedVerdict -> Int +harmonizedToInt CertifiedSafe = 1 +harmonizedToInt RequiresReview = 2 +harmonizedToInt CriticalUnsafe = 3 + +||| Confidence level to integer. +public export +confidenceToInt : ConfidenceLevel -> Int +confidenceToInt Absolute = 3 +confidenceToInt High = 2 +confidenceToInt Low = 1 + +-- ═══════════════════════════════════════════════════════════════════════════ +-- C-ABI Exports +-- ═══════════════════════════════════════════════════════════════════════════ + +||| FFI: Harmonize neural and symbolic verdicts. +||| Takes integer-encoded verdicts, returns integer-encoded result. +export +nesy_harmonize : Int -> Int -> Int +nesy_harmonize n s = + harmonizedToInt (harmonize (intToNeural n) (intToSymbolic s)) + +||| FFI: Get confidence level for a harmonization. +export +nesy_confidence : Int -> Int -> Int +nesy_confidence n s = + confidenceToInt (confidence (intToNeural n) (intToSymbolic s)) diff --git a/cartridges/cross-cutting/nesy/nesy-mcp/abi/README.adoc b/cartridges/cross-cutting/nesy/nesy-mcp/abi/README.adoc new file mode 100644 index 0000000..55d44d7 --- /dev/null +++ b/cartridges/cross-cutting/nesy/nesy-mcp/abi/README.adoc @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) += nesy-mcp / abi β€” Idris2 ABI layer +:orientation: shallow + +== Purpose + +Cartridge-local protocol types for `nesy-mcp`. Defines the data, tool, and +transition vocabulary this cartridge speaks. Inherits safety lemmas and the +matrix-cell identifier scheme from the trunk ABI at `src/abi/`. + +== Files + +2 Idris2 module(s), ~386 lines total. Lead module: +`Protocol.idr`. + +No `.ipkg` β€” consumed directly by wider BoJ callers; type-checks inside the +trunk `boj.ipkg`. + +== Invariants + +* `%default total` at every module top. +* Zero `believe_me`, `assert_total`, `postulate`, `Admitted`, or + `sorry` (the trunk holds the four declared `believe_me` sites; leaf + cartridges carry none). + +== Test/proof surface + +Thin module(s), no inline tests. Proof surface = type-check success under the +trunk `idris2 --check`. + +== Read-first + +. `Protocol.idr` β€” cartridge-local data records and enums. +. Trunk `src/abi/Boj/Catalogue.idr` for how this cell participates in the + 2D matrix. diff --git a/cartridges/cross-cutting/nesy/nesy-mcp/abi/nesy-mcp.ipkg b/cartridges/cross-cutting/nesy/nesy-mcp/abi/nesy-mcp.ipkg new file mode 100644 index 0000000..bdcfabe --- /dev/null +++ b/cartridges/cross-cutting/nesy/nesy-mcp/abi/nesy-mcp.ipkg @@ -0,0 +1,12 @@ +-- SPDX-License-Identifier: MPL-2.0 +package nesymcp + +authors = "Jonathan D.A. Jewell" +version = 0.2.0 +license = "MPL-2.0" +brief = "Neurosymbolic harmonizer cartridge β€” Symbolic > Neural + full protocol types" + +sourcedir = "." +modules = NesyMcp.SafeReasoning + , NesyMcp.Protocol +depends = base diff --git a/cartridges/cross-cutting/nesy/nesy-mcp/adapter/README.adoc b/cartridges/cross-cutting/nesy/nesy-mcp/adapter/README.adoc new file mode 100644 index 0000000..13449a9 --- /dev/null +++ b/cartridges/cross-cutting/nesy/nesy-mcp/adapter/README.adoc @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) += nesy-mcp / adapter β€” protocol bridge +:orientation: shallow + +== Purpose + +Exposes this cartridge's FFI over the wire protocols declared in +`cartridge.json`. Dispatches tool invocations to the C-ABI exports at +`../ffi/` and formats responses per protocol. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” depends on the sibling `ffi` target. +| `nesy_adapter.zig` | Protocol dispatch (118 lines). Tool catalogue matches `cartridge.json`. +| `SIDELINED-*.v.adoc` | zig predecessor(s). Not built β€” zig banned 2026-04-10. +|=== + +== Invariants + +* **Stateless** β€” all state lives behind the FFI, never in the adapter. +* **Response passthrough** β€” whatever the FFI returns goes back to the wire + unmodified (no embellishment, no silent recovery). +* **Cartridge.json is source of truth** for the tool catalogue; drift between + adapter and manifest is a CI failure. + +== Test/proof surface + +No inline tests at the adapter layer β€” protocol routing is exercised by the +trunk seam tests at `ffi/zig/src/seams.zig`. + +== Read-first + +. `nesy_adapter.zig` β€” dispatch function and per-protocol routers. +. `../cartridge.json` β€” the tool manifest the dispatcher mirrors. diff --git a/cartridges/cross-cutting/nesy/nesy-mcp/adapter/build.zig b/cartridges/cross-cutting/nesy/nesy-mcp/adapter/build.zig new file mode 100644 index 0000000..fb06240 --- /dev/null +++ b/cartridges/cross-cutting/nesy/nesy-mcp/adapter/build.zig @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +// +// nesy-mcp/adapter/build.zig + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + const ffi_mod = b.createModule(.{ + .root_source_file = b.path("../ffi/nesy_ffi.zig"), + .target = target, .optimize = optimize, + }); + const adapter = b.addExecutable(.{ + .name = "nesy_adapter", + .root_source_file = b.path("nesy_adapter.zig"), + .target = target, .optimize = optimize, + }); + adapter.root_module.addImport("nesy_ffi", ffi_mod); + b.installArtifact(adapter); + const run_step = b.step("run", "Run the nesy-mcp adapter"); + run_step.dependOn(&b.addRunArtifact(adapter).step); + const tests = b.addTest(.{ + .root_source_file = b.path("nesy_adapter.zig"), + .target = target, .optimize = optimize, + }); + tests.root_module.addImport("nesy_ffi", ffi_mod); + const test_step = b.step("test", "Run nesy-mcp adapter tests"); + test_step.dependOn(&b.addRunArtifact(tests).step); +} diff --git a/cartridges/cross-cutting/nesy/nesy-mcp/adapter/nesy_adapter.zig b/cartridges/cross-cutting/nesy/nesy-mcp/adapter/nesy_adapter.zig new file mode 100644 index 0000000..81b933a --- /dev/null +++ b/cartridges/cross-cutting/nesy/nesy-mcp/adapter/nesy_adapter.zig @@ -0,0 +1,118 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +// +// nesy-mcp/adapter/nesy_adapter.zig -- Unified three-protocol adapter. +// +// Replaces the banned nesy_adapter.v (zig, removed 2026-04-12). +// +// REST :9118 gRPC-compat :9119 GraphQL :9120 +// Neural-symbolic (NeSy) harmonization engine. Symbolic truth always overrides neural probability. +// Tools: nesy_harmonize, nesy_analyze_drift, nesy_reasoning_mode_info + +const std = @import("std"); +const ffi = @import("nesy_ffi"); + +const REST_PORT: u16 = 9118; +const GRPC_PORT: u16 = 9119; +const GQL_PORT: u16 = 9120; +const MAX_CONN_BUF: usize = 16 * 1024; + +fn okJson(buf: []u8, msg: []const u8) []u8 { + const n = std.fmt.bufPrint(buf, "{{\"success\":true,\"message\":\"{s}\"}}", .{msg}) catch return buf[0..0]; + return n; +} +fn errJson(buf: []u8, msg: []const u8) []u8 { + const n = std.fmt.bufPrint(buf, "{{\"success\":false,\"error\":\"{s}\"}}", .{msg}) catch return buf[0..0]; + return n; +} +fn statusJson(buf: []u8) []u8 { + const n = std.fmt.bufPrint(buf, "{{\"success\":true,\"state\":\"ready\",\"service\":\"nesy-mcp\"}}", .{}) catch return buf[0..0]; + return n; +} + +const Response = struct { status: u16, body: []u8 }; + +fn dispatch(tool: []const u8, body: []const u8, resp: []u8) Response { + _ = body; + if (std.mem.eql(u8, tool, "nesy_harmonize")) return .{ .status = 200, .body = okJson(resp, "nesy_harmonize forwarded") }; + if (std.mem.eql(u8, tool, "nesy_analyze_drift")) return .{ .status = 200, .body = okJson(resp, "nesy_analyze_drift forwarded") }; + if (std.mem.eql(u8, tool, "nesy_reasoning_mode_info")) return .{ .status = 200, .body = okJson(resp, "nesy_reasoning_mode_info forwarded") }; + if (std.mem.eql(u8, tool, "status") or std.mem.eql(u8, tool, "health")) + return .{ .status = 200, .body = statusJson(resp) }; + return .{ .status = 404, .body = errJson(resp, "Unknown tool") }; +} + +fn dispatchRest(path: []const u8, body: []const u8, resp: []u8) Response { + if (std.mem.startsWith(u8, path, "/tools/")) return dispatch(path["/tools/".len..], body, resp); + if (std.mem.eql(u8, path, "/status") or std.mem.eql(u8, path, "/health")) return .{ .status = 200, .body = statusJson(resp) }; + return .{ .status = 404, .body = errJson(resp, "Not found") }; +} + +fn dispatchGrpc(path: []const u8, body: []const u8, resp: []u8) Response { + const prefix = "/Nesyservice/"; + if (!std.mem.startsWith(u8, path, prefix)) + return .{ .status = 404, .body = errJson(resp, "Not a recognized gRPC path") }; + const method = path[prefix.len..]; + const tool = blk: { + if (std.mem.eql(u8, method, "nesy_harmonize")) break :blk "nesy_harmonize"; + if (std.mem.eql(u8, method, "nesy_analyze_drift")) break :blk "nesy_analyze_drift"; + if (std.mem.eql(u8, method, "nesy_reasoning_mode_info")) break :blk "nesy_reasoning_mode_info"; + return .{ .status = 404, .body = errJson(resp, "Unknown gRPC method") }; + }; + return dispatch(tool, body, resp); +} + +fn dispatchGraphql(body: []const u8, resp: []u8) Response { + if (std.mem.indexOf(u8, body, "__schema") != null) return .{ .status = 200, .body = okJson(resp, "schema not supported") }; + if (std.mem.indexOf(u8, body, "harmonize") != null) return dispatch("nesy_harmonize", body, resp); + if (std.mem.indexOf(u8, body, "analyze_drift") != null) return dispatch("nesy_analyze_drift", body, resp); + if (std.mem.indexOf(u8, body, "reasoning_mode_info") != null) return dispatch("nesy_reasoning_mode_info", body, resp); + return .{ .status = 200, .body = errJson(resp, "Unrecognised GraphQL operation") }; +} + +const Protocol = enum { rest, grpc, graphql }; + +fn handleConnection(conn: std.net.Server.Connection, proto: Protocol) void { + defer conn.stream.close(); + var in_buf: [MAX_CONN_BUF]u8 = undefined; + const n = conn.stream.read(&in_buf) catch return; + const req = in_buf[0..n]; + var path: []const u8 = "/"; + var body: []const u8 = ""; + if (n > 4) { + const line_end = std.mem.indexOf(u8, req, "\r\n") orelse req.len; + const first_line = req[0..line_end]; + const sp1 = std.mem.indexOfScalar(u8, first_line, ' ') orelse 0; + const rest_of = first_line[sp1 + 1 ..]; + const sp2 = std.mem.indexOfScalar(u8, rest_of, ' ') orelse rest_of.len; + path = rest_of[0..sp2]; + const body_sep = std.mem.indexOf(u8, req, "\r\n\r\n") orelse n; + body = req[@min(body_sep + 4, n)..]; + } + var resp_buf: [MAX_CONN_BUF]u8 = undefined; + const result = switch (proto) { + .rest => dispatchRest(path, body, &resp_buf), + .grpc => dispatchGrpc(path, body, &resp_buf), + .graphql => dispatchGraphql(body, &resp_buf), + }; + const ct: []const u8 = if (proto == .grpc) "application/grpc+json" else "application/json"; + var hdr_buf: [256]u8 = undefined; + const hdr = std.fmt.bufPrint(&hdr_buf, "HTTP/1.1 {d} OK\r\nContent-Type: {s}\r\nContent-Length: {d}\r\nConnection: close\r\n\r\n", .{ result.status, ct, result.body.len }) catch return; + _ = conn.stream.writeAll(hdr) catch return; + _ = conn.stream.writeAll(result.body) catch return; +} + +fn listenLoop(port: u16, proto: Protocol) void { + const addr = std.net.Address.initIp4(.{ 127, 0, 0, 1 }, port) catch return; + var server = addr.listen(.{ .reuse_address = true }) catch return; + defer server.deinit(); + while (true) { const conn = server.accept() catch continue; handleConnection(conn, proto); } +} + +pub fn main() !void { + ffi.nesy_init(); + const t1 = try std.Thread.spawn(.{}, listenLoop, .{ REST_PORT, Protocol.rest }); + const t2 = try std.Thread.spawn(.{}, listenLoop, .{ GRPC_PORT, Protocol.grpc }); + const t3 = try std.Thread.spawn(.{}, listenLoop, .{ GQL_PORT, Protocol.graphql }); + t1.join(); t2.join(); t3.join(); +} diff --git a/cartridges/cross-cutting/nesy/nesy-mcp/cartridge.json b/cartridges/cross-cutting/nesy/nesy-mcp/cartridge.json new file mode 100644 index 0000000..54f6382 --- /dev/null +++ b/cartridges/cross-cutting/nesy/nesy-mcp/cartridge.json @@ -0,0 +1,89 @@ +{ + "$schema": "https://boj.dev/schemas/cartridge/v1.json", + "spdx": "MPL-2.0", + "copyright": "Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath)", + "name": "nesy-mcp", + "version": "0.1.0", + "description": "Neural-symbolic (NeSy) harmonization engine. Symbolic truth always overrides neural probability.", + "domain": "AI/NeSy", + "tier": "Ayo", + "protocols": [ + "MCP", + "REST" + ], + "auth": { + "method": "none", + "env_var": null, + "credential_source": null + }, + "api": { + "base_url": "local://nesy-mcp", + "content_type": "application/json" + }, + "tools": [ + { + "name": "nesy_harmonize", + "description": "Harmonize a neural probability with a symbolic verdict. Returns harmonized verdict and confidence.", + "inputSchema": { + "type": "object", + "properties": { + "neural": { + "type": "string", + "description": "Neural verdict: probable_safe / unsure / probable_unsafe" + }, + "symbolic": { + "type": "string", + "description": "Symbolic verdict: proven_safe / no_proof / proven_unsafe" + } + }, + "required": [ + "neural", + "symbolic" + ] + } + }, + { + "name": "nesy_analyze_drift", + "description": "Analyze a drift kind and return severity, urgency, and recommended action.", + "inputSchema": { + "type": "object", + "properties": { + "kind": { + "type": "string", + "description": "Drift kind: no_drift / semantic / confidence / factual / temporal / catastrophic" + } + }, + "required": [ + "kind" + ] + } + }, + { + "name": "nesy_reasoning_mode_info", + "description": "Get metadata about a reasoning mode: symbolic, neural, or hybrid.", + "inputSchema": { + "type": "object", + "properties": { + "mode": { + "type": "string", + "description": "Mode: Symbolic / Neural / SymToNeural / NeuralToSym / Ensemble / Cascade" + } + }, + "required": [ + "mode" + ] + } + } + ], + "ffi": { + "so_path": "ffi/zig-out/lib/libnesy_mcp.so", + "abi_version": "ADR-0006", + "symbols": [ + "boj_cartridge_init", + "boj_cartridge_deinit", + "boj_cartridge_name", + "boj_cartridge_version", + "boj_cartridge_invoke" + ] + } +} diff --git a/cartridges/cross-cutting/nesy/nesy-mcp/ffi/README.adoc b/cartridges/cross-cutting/nesy/nesy-mcp/ffi/README.adoc new file mode 100644 index 0000000..6255e4b --- /dev/null +++ b/cartridges/cross-cutting/nesy/nesy-mcp/ffi/README.adoc @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) += nesy-mcp / ffi β€” Zig FFI layer +:orientation: shallow + +== Purpose + +C-ABI exports bridging this cartridge's Idris2 ABI to the runtime. Consumed by +the adapter for protocol dispatch and by the trunk loader for mount. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” produces `zig-out/lib/libnesy.so`. +| `nesy_ffi.zig` | C-ABI exports (11 exports, 9 inline tests, 259 lines). +|=== + +== Invariants + +* **Null-pointer rejection** on `[*c]const u8` arguments before any + dereference. +* **Negative return values** carry documented error meanings; zero is + success (matching the trunk error-code convention). +* **No allocator leaks** β€” every allocation path pairs with `defer free`. + +== Test/proof surface + +9 inline `test "..."` block(s) in `nesy_ffi.zig`. Run with +`cd ffi && zig build test`. + +== Read-first + +. `nesy_ffi.zig` β€” C-ABI signatures and guards. +. Trunk `ffi/zig/src/catalogue.zig` for how this `.so` is loaded. diff --git a/cartridges/cross-cutting/nesy/nesy-mcp/ffi/build.zig b/cartridges/cross-cutting/nesy/nesy-mcp/ffi/build.zig new file mode 100644 index 0000000..02aff97 --- /dev/null +++ b/cartridges/cross-cutting/nesy/nesy-mcp/ffi/build.zig @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +// +// NeSy-MCP Cartridge β€” Zig FFI build configuration (Zig 0.15+). + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + // Shared ADR-0006 invoke-shim module (relative path up to boj-server trunk). + + const shim_mod = b.addModule("cartridge_shim", .{ + + .root_source_file = b.path("../../../ffi/zig/src/cartridge_shim.zig"), + + .target = target, + + .optimize = optimize, + + }); + + const nesy_mod = b.addModule("nesy_ffi", .{ + .root_source_file = b.path("nesy_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + nesy_mod.addImport("cartridge_shim", shim_mod); + + // ── Tests ──────────────────────────────────────────────────────── + const nesy_tests = b.addTest(.{ + .root_module = nesy_mod, + }); + + const run_tests = b.addRunArtifact(nesy_tests); + + const test_step = b.step("test", "Run nesy-mcp FFI tests"); + test_step.dependOn(&run_tests.step); + + // ── Shared library ────────────────────────────────────────────── + const lib_mod = b.createModule(.{ + .root_source_file = b.path("nesy_ffi.zig"), + .target = target, + .optimize = optimize, + }); + lib_mod.addImport("cartridge_shim", shim_mod); + + const lib = b.addLibrary(.{ + .name = "nesy_mcp", + .root_module = lib_mod, + .linkage = .dynamic, + }); + b.installArtifact(lib); + + const lib_step = b.step("lib", "Build shared library"); + lib_step.dependOn(&lib.step); +} diff --git a/cartridges/cross-cutting/nesy/nesy-mcp/ffi/cartridge_shim.zig b/cartridges/cross-cutting/nesy/nesy-mcp/ffi/cartridge_shim.zig new file mode 100644 index 0000000..0e399db --- /dev/null +++ b/cartridges/cross-cutting/nesy/nesy-mcp/ffi/cartridge_shim.zig @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +// +// cartridge_shim.zig β€” Shared helpers for the ADR-0006 five-symbol +// cartridge ABI (`boj_cartridge_init / deinit / name / version / invoke`). +// +// The shim centralises the seven-code return convention, NUL-argument +// guards, tool-name comparison, and the buffer-too-small path so each +// cartridge's `boj_cartridge_invoke` can stay short β€” typically a tool +// table plus `shim.writeResult(...)`. +// +// Cartridges import this file by relative path (no build-graph change +// needed). Example: +// +// const shim = @import("../../../ffi/zig/src/cartridge_shim.zig"); +// +// export fn boj_cartridge_invoke( +// tool_name: [*c]const u8, +// json_args: [*c]const u8, +// out_buf: [*c]u8, +// in_out_len: [*c]usize, +// ) callconv(.c) i32 { +// _ = json_args; +// if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; +// const body = if (shim.toolIs(tool_name, "foo")) "{\"result\":{}}" +// else return shim.RC_UNKNOWN_TOOL; +// return shim.writeResult(out_buf, in_out_len, body); +// } + +const std = @import("std"); + +// ── Return codes (ADR-0006 Β§Return codes) ──────────────────────────── +// +// Frozen by ADR-0006. New failure modes compose these via the error +// JSON body β€” the integer surface does not grow without a follow-up ADR. + +pub const RC_SUCCESS: i32 = 0; +pub const RC_UNKNOWN_TOOL: i32 = -1; +pub const RC_BAD_ARGS: i32 = -2; +pub const RC_BUFFER_TOO_SMALL: i32 = -3; +pub const RC_RUNTIME_ERROR: i32 = -4; +pub const RC_PANIC: i32 = -5; +pub const RC_AUTH_DENIED: i32 = -6; + +// ── Invoke-path helpers ────────────────────────────────────────────── + +/// True if any of the three mandatory `boj_cartridge_invoke` output-path +/// pointers is null. Use at the top of every invoke to short-circuit to +/// `RC_BAD_ARGS`. +pub fn invokeArgsNull( + tool_name: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) bool { + return tool_name == null or out_buf == null or in_out_len == null; +} + +/// Compare a C-NUL-terminated tool-name pointer against a Zig string +/// literal. Caller must have already verified `tool_name` is non-null +/// (usually via `invokeArgsNull`). +pub fn toolIs(tool_name: [*c]const u8, expected: []const u8) bool { + const s = std.mem.span(@as([*:0]const u8, @ptrCast(tool_name))); + return std.mem.eql(u8, s, expected); +} + +/// Copy `body` into `out_buf[0..*in_out_len]` (as a capacity) and update +/// `*in_out_len` to the number of bytes written. Returns `RC_SUCCESS`. +/// +/// If `body.len` exceeds the current capacity stored in `*in_out_len`, +/// sets `*in_out_len` to the required size and returns +/// `RC_BUFFER_TOO_SMALL` β€” the caller is then expected to re-allocate +/// and retry, per ADR-0006 Β§Memory ownership. +/// +/// Caller must have already verified that `out_buf` and `in_out_len` +/// are non-null. +pub fn writeResult( + out_buf: [*c]u8, + in_out_len: [*c]usize, + body: []const u8, +) i32 { + const cap = in_out_len.*; + if (body.len > cap) { + in_out_len.* = body.len; + return RC_BUFFER_TOO_SMALL; + } + @memcpy(out_buf[0..body.len], body); + in_out_len.* = body.len; + return RC_SUCCESS; +} + +// ── Tests ──────────────────────────────────────────────────────────── + +test "writeResult: body fits, writes and sets length" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); + try std.testing.expectEqualStrings("hello", buf[0..len]); +} + +test "writeResult: too small returns -3 and sets required length" { + var buf: [2]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_BUFFER_TOO_SMALL, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: exact-fit succeeds" { + var buf: [5]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: empty body" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, ""); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 0), len); +} + +test "toolIs: matches and rejects" { + const name: [*:0]const u8 = "foo"; + try std.testing.expect(toolIs(@ptrCast(name), "foo")); + try std.testing.expect(!toolIs(@ptrCast(name), "bar")); + try std.testing.expect(!toolIs(@ptrCast(name), "foobar")); + try std.testing.expect(!toolIs(@ptrCast(name), "fo")); +} + +test "invokeArgsNull: detects each null slot" { + var buf: [4]u8 = undefined; + var len: usize = 4; + const name: [*:0]const u8 = "x"; + try std.testing.expect(!invokeArgsNull(@ptrCast(name), &buf, &len)); + try std.testing.expect(invokeArgsNull(null, &buf, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), null, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), &buf, null)); +} diff --git a/cartridges/cross-cutting/nesy/nesy-mcp/ffi/nesy_ffi.zig b/cartridges/cross-cutting/nesy/nesy-mcp/ffi/nesy_ffi.zig new file mode 100644 index 0000000..57fa894 --- /dev/null +++ b/cartridges/cross-cutting/nesy/nesy-mcp/ffi/nesy_ffi.zig @@ -0,0 +1,322 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +// +// NeSy-MCP Cartridge β€” Zig FFI bridge for neurosymbolic harmonization. +// +// Implements the harmonization law: Symbolic truth always overrides +// Neural probability. This is the runtime bridge between Hypatia's +// neural predictions and Echidna's symbolic proofs. + +const std = @import("std"); + +// ═══════════════════════════════════════════════════════════════════════ +// Types (must match NesyMcp.SafeReasoning encodings) +// ═══════════════════════════════════════════════════════════════════════ + +pub const NeuralVerdict = enum(c_int) { + probable_safe = 1, + unsure = 2, + probable_unsafe = 3, +}; + +pub const SymbolicVerdict = enum(c_int) { + proven_safe = 1, + no_proof = 2, + proven_unsafe = 3, +}; + +pub const HarmonizedVerdict = enum(c_int) { + certified_safe = 1, + requires_review = 2, + critical_unsafe = 3, +}; + +pub const ConfidenceLevel = enum(c_int) { + low = 1, + high = 2, + absolute = 3, +}; + +// ═══════════════════════════════════════════════════════════════════════ +// Harmonization +// ═══════════════════════════════════════════════════════════════════════ + +/// The harmonization law. +/// Symbolic truth ALWAYS overrides Neural probability. +pub export fn nesy_harmonize(neural: c_int, symbolic: c_int) c_int { + const sym: SymbolicVerdict = @enumFromInt(symbolic); + const neur: NeuralVerdict = @enumFromInt(neural); + + const result: HarmonizedVerdict = switch (sym) { + .proven_unsafe => .critical_unsafe, + .proven_safe => .certified_safe, + .no_proof => switch (neur) { + .probable_unsafe => .critical_unsafe, + .unsure => .requires_review, + .probable_safe => .requires_review, + }, + }; + + return @intFromEnum(result); +} + +/// Confidence level for a harmonization. +pub export fn nesy_confidence(neural: c_int, symbolic: c_int) c_int { + const sym: SymbolicVerdict = @enumFromInt(symbolic); + const neur: NeuralVerdict = @enumFromInt(neural); + + const result: ConfidenceLevel = switch (sym) { + .proven_safe, .proven_unsafe => .absolute, + .no_proof => switch (neur) { + .probable_unsafe => .high, + .unsure, .probable_safe => .low, + }, + }; + + return @intFromEnum(result); +} + + +// ═══════════════════════════════════════════════════════════════════════ +// Standard Cartridge Interface (loader expects these 4 C-ABI symbols) +// ═══════════════════════════════════════════════════════════════════════ + +/// Initialise the nesy-mcp cartridge. No-op (harmonization is stateless). +pub export fn boj_cartridge_init() c_int { + return 0; +} + +/// Deinitialise the nesy-mcp cartridge. No-op (harmonization is stateless). +pub export fn boj_cartridge_deinit() void {} + +/// Return the cartridge name as a null-terminated C string. +pub export fn boj_cartridge_name() [*:0]const u8 { + return "nesy-mcp"; +} + +/// Return the cartridge version as a null-terminated C string. +pub export fn boj_cartridge_version() [*:0]const u8 { + return "0.2.0"; +} + +// ═══════════════════════════════════════════════════════════════════════ +// ADR-0006 dispatch (boj_cartridge_invoke, 5th standard symbol) +// ═══════════════════════════════════════════════════════════════════════ + +const shim = @import("cartridge_shim.zig"); + +/// Dispatch the cartridge.json MCP tools. Grade D Alpha β€” each arm +/// returns a stub JSON body shaped to the tool's intended response. +export fn boj_cartridge_invoke( + tool_name: [*c]const u8, + json_args: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) callconv(.c) i32 { + _ = json_args; + if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; + + const body: []const u8 = if (shim.toolIs(tool_name, "nesy_harmonize")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "nesy_analyze_drift")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "nesy_reasoning_mode_info")) + "{\"result\":{\"metadata\":{},\"status\":\"stub\"}}" +else + return shim.RC_UNKNOWN_TOOL; + + return shim.writeResult(out_buf, in_out_len, body); +} + +// ═══════════════════════════════════════════════════════════════════════ +// Protocol Types (from proven-nesy, added in v0.2.0) +// ═══════════════════════════════════════════════════════════════════════ + +pub const ReasoningMode = enum(c_int) { + symbolic = 0, + neural = 1, + sym_to_neural = 2, + neural_to_sym = 3, + ensemble = 4, + cascade = 5, +}; + +pub const DriftKind = enum(c_int) { + no_drift = 0, + semantic_drift = 1, + confidence_drift = 2, + factual_drift = 3, + temporal_drift = 4, + catastrophic_drift = 5, +}; + +pub const DriftAction = enum(c_int) { + log_and_accept = 0, + flag_for_review = 1, + reject_neural = 2, + retry_neural = 3, + escalate = 4, + halt = 5, +}; + +pub const MergeStrategy = enum(c_int) { + symbolic_primacy = 0, + neural_primacy = 1, + confidence_weighted = 2, + consensus = 3, + dual_return = 4, + constrained_generation = 5, +}; + +pub const GroundingStatus = enum(c_int) { + fully_grounded = 0, + partially_grounded = 1, + ungrounded = 2, + grounding_pending = 3, + grounding_failed = 4, +}; + +/// Recommend a drift action given drift severity. +pub export fn nesy_recommend_drift_action(drift: c_int) c_int { + const dk: DriftKind = @enumFromInt(drift); + const action: DriftAction = switch (dk) { + .no_drift, .semantic_drift => .log_and_accept, + .confidence_drift => .flag_for_review, + .factual_drift => .reject_neural, + .temporal_drift => .retry_neural, + .catastrophic_drift => .halt, + }; + return @intFromEnum(action); +} + +/// Whether a reasoning mode uses the symbolic layer. +pub export fn nesy_mode_uses_symbolic(mode: c_int) c_int { + const m: ReasoningMode = @enumFromInt(mode); + return if (m != .neural) 1 else 0; +} + +/// Whether a reasoning mode uses the neural layer. +pub export fn nesy_mode_uses_neural(mode: c_int) c_int { + const m: ReasoningMode = @enumFromInt(mode); + return if (m != .symbolic) 1 else 0; +} + +/// Whether a grounding status is trusted (fully grounded). +pub export fn nesy_grounding_is_trusted(g: c_int) c_int { + const gs: GroundingStatus = @enumFromInt(g); + return if (gs == .fully_grounded) 1 else 0; +} + +/// Whether the drift is urgent (factual or catastrophic). +pub export fn nesy_drift_is_urgent(drift: c_int) c_int { + const dk: DriftKind = @enumFromInt(drift); + return switch (dk) { + .factual_drift, .catastrophic_drift => 1, + else => 0, + }; +} + +// ═══════════════════════════════════════════════════════════════════════ +// Tests +// ═══════════════════════════════════════════════════════════════════════ + +test "symbolic proven unsafe always wins" { + // Even if neural says safe, symbolic unsafe = critical + try std.testing.expectEqual( + @as(c_int, @intFromEnum(HarmonizedVerdict.critical_unsafe)), + nesy_harmonize(@intFromEnum(NeuralVerdict.probable_safe), @intFromEnum(SymbolicVerdict.proven_unsafe)), + ); +} + +test "symbolic proven safe always wins" { + // Even if neural says unsafe, symbolic safe = certified + try std.testing.expectEqual( + @as(c_int, @intFromEnum(HarmonizedVerdict.certified_safe)), + nesy_harmonize(@intFromEnum(NeuralVerdict.probable_unsafe), @intFromEnum(SymbolicVerdict.proven_safe)), + ); +} + +test "no proof + probable safe = requires review" { + // Neural confidence without proof is just a guess + try std.testing.expectEqual( + @as(c_int, @intFromEnum(HarmonizedVerdict.requires_review)), + nesy_harmonize(@intFromEnum(NeuralVerdict.probable_safe), @intFromEnum(SymbolicVerdict.no_proof)), + ); +} + +test "no proof + probable unsafe = critical" { + // Neural alarm without proof = escalate + try std.testing.expectEqual( + @as(c_int, @intFromEnum(HarmonizedVerdict.critical_unsafe)), + nesy_harmonize(@intFromEnum(NeuralVerdict.probable_unsafe), @intFromEnum(SymbolicVerdict.no_proof)), + ); +} + +test "proof gives absolute confidence" { + try std.testing.expectEqual( + @as(c_int, @intFromEnum(ConfidenceLevel.absolute)), + nesy_confidence(@intFromEnum(NeuralVerdict.unsure), @intFromEnum(SymbolicVerdict.proven_safe)), + ); +} + +test "no proof gives low confidence" { + try std.testing.expectEqual( + @as(c_int, @intFromEnum(ConfidenceLevel.low)), + nesy_confidence(@intFromEnum(NeuralVerdict.probable_safe), @intFromEnum(SymbolicVerdict.no_proof)), + ); +} + +// Protocol tests (v0.2.0) + +test "drift action recommendations" { + try std.testing.expectEqual(@as(c_int, 0), nesy_recommend_drift_action(0)); // no_drift -> log_and_accept + try std.testing.expectEqual(@as(c_int, 2), nesy_recommend_drift_action(3)); // factual -> reject_neural + try std.testing.expectEqual(@as(c_int, 5), nesy_recommend_drift_action(5)); // catastrophic -> halt +} + +test "reasoning mode predicates" { + try std.testing.expectEqual(@as(c_int, 1), nesy_mode_uses_symbolic(0)); // symbolic uses symbolic + try std.testing.expectEqual(@as(c_int, 0), nesy_mode_uses_symbolic(1)); // neural does not + try std.testing.expectEqual(@as(c_int, 0), nesy_mode_uses_neural(0)); // symbolic does not use neural + try std.testing.expectEqual(@as(c_int, 1), nesy_mode_uses_neural(4)); // ensemble uses neural +} + +test "drift urgency" { + try std.testing.expectEqual(@as(c_int, 0), nesy_drift_is_urgent(0)); // no_drift not urgent + try std.testing.expectEqual(@as(c_int, 1), nesy_drift_is_urgent(3)); // factual is urgent + try std.testing.expectEqual(@as(c_int, 1), nesy_drift_is_urgent(5)); // catastrophic is urgent +} + +// ═══════════════════════════════════════════════════════════════════════ +// ADR-0006 invoke dispatch tests +// ═══════════════════════════════════════════════════════════════════════ + +test "invoke: each declared tool succeeds" { + var buf: [256]u8 = undefined; + const tools = [_][]const u8{ + "nesy_harmonize", + "nesy_analyze_drift", + "nesy_reasoning_mode_info", + }; + for (tools) |t| { + var len: usize = buf.len; + const rc = boj_cartridge_invoke(t.ptr, "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, 0), rc); + try std.testing.expect(std.mem.indexOf(u8, buf[0..len], "result") != null); + } +} + +test "invoke: unknown tool returns -1" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("nope", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -1), rc); +} + +test "invoke: buffer too small returns -3" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("nesy_harmonize", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -3), rc); + try std.testing.expect(len > 4); +} diff --git a/cartridges/cross-cutting/nesy/nesy-mcp/mod.js b/cartridges/cross-cutting/nesy/nesy-mcp/mod.js new file mode 100644 index 0000000..0be6a42 --- /dev/null +++ b/cartridges/cross-cutting/nesy/nesy-mcp/mod.js @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +// +// nesy-mcp/mod.js -- nesy gateway + +const BASE_URL = Deno.env.get("NESY_BACKEND_URL") ?? "http://127.0.0.1:7706"; + +async function post(path, payload) { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), 15000); + try { + const r = await fetch(`${BASE_URL}${path}`, { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), signal: ctrl.signal, + }); + const data = await r.json().catch(() => ({ success: false, error: "non-JSON response" })); + return { status: r.status, data }; + } catch (e) { + if (e.name === "AbortError") return { status: 504, data: { success: false, error: "nesy-mcp backend timed out" } }; + return { status: 503, data: { success: false, error: `nesy-mcp backend unavailable: ${e.message}` } }; + } finally { clearTimeout(t); } +} + +export async function handleTool(toolName, args) { + switch (toolName) { + case "nesy_harmonize": { + const { neural, symbolic } = args ?? {}; + if (!neural || !symbolic) return { status: 400, data: { error: "neural is required" } }; + const payload = { neural, symbolic }; + return post("/api/v1/harmonize", payload); + } + + case "nesy_analyze_drift": { + const { kind } = args ?? {}; + if (!kind) return { status: 400, data: { error: "kind is required" } }; + const payload = { kind }; + return post("/api/v1/analyze-drift", payload); + } + + case "nesy_reasoning_mode_info": { + const { mode } = args ?? {}; + if (!mode) return { status: 400, data: { error: "mode is required" } }; + const payload = { mode }; + return post("/api/v1/reasoning-mode-info", payload); + } + default: + return { status: 404, data: { error: `Unknown tool: ${toolName}` } }; + } +} diff --git a/cartridges/cross-cutting/nesy/nesy-mcp/panels/manifest.json b/cartridges/cross-cutting/nesy/nesy-mcp/panels/manifest.json new file mode 100644 index 0000000..41a66fd --- /dev/null +++ b/cartridges/cross-cutting/nesy/nesy-mcp/panels/manifest.json @@ -0,0 +1,67 @@ +{ + "$schema": "https://panll.dev/schemas/panel-manifest/v1.json", + "spdx": "MPL-2.0", + "cartridge": "nesy-mcp", + "domain": "Neurosymbolic AI", + "version": "0.1.0", + "panels": [ + { + "id": "nesy-status", + "title": "NeSy Engine Status", + "description": "Neurosymbolic inference engine readiness and current reasoning mode", + "type": "status-indicator", + "data_source": { + "endpoint": "/cartridge/nesy-mcp/invoke", + "method": "POST", + "body": { "tool": "status" }, + "refresh_interval_ms": 5000 + }, + "widgets": [ + { + "type": "state-badge", + "field": "state", + "states": { + "ready": { "color": "#2ecc71", "icon": "brain" }, + "degraded": { "color": "#f39c12", "icon": "alert-circle" }, + "error": { "color": "#e74c3c", "icon": "alert-triangle" } + } + }, + { "type": "text", "field": "version", "label": "Version" } + ] + }, + { + "id": "nesy-reasoning-stats", + "title": "Reasoning Statistics", + "description": "Inference count, average latency, and symbolic/neural split ratio", + "type": "metric", + "data_source": { + "endpoint": "/cartridge/nesy-mcp/invoke", + "method": "POST", + "body": { "tool": "reasoning_stats" }, + "refresh_interval_ms": 10000 + }, + "widgets": [ + { "type": "counter", "field": "total_inferences", "label": "Total Inferences", "icon": "activity" }, + { "type": "counter", "field": "avg_latency_ms", "label": "Avg Latency (ms)", "icon": "clock" }, + { "type": "gauge", "field": "symbolic_ratio", "label": "Symbolic Ratio", "min": 0, "max": 1 } + ] + }, + { + "id": "nesy-knowledge-graph", + "title": "Knowledge Graph", + "description": "Node and edge counts in the active neurosymbolic knowledge graph", + "type": "metric", + "data_source": { + "endpoint": "/cartridge/nesy-mcp/invoke", + "method": "POST", + "body": { "tool": "graph_stats" }, + "refresh_interval_ms": 30000 + }, + "widgets": [ + { "type": "counter", "field": "node_count", "label": "Nodes", "icon": "circle" }, + { "type": "counter", "field": "edge_count", "label": "Edges", "icon": "git-branch" }, + { "type": "counter", "field": "rule_count", "label": "Rules", "icon": "book" } + ] + } + ] +} diff --git a/cartridges/domains/automation/browser-mcp/README.adoc b/cartridges/domains/automation/browser-mcp/README.adoc new file mode 100644 index 0000000..6cd42aa --- /dev/null +++ b/cartridges/domains/automation/browser-mcp/README.adoc @@ -0,0 +1,125 @@ += browser-mcp +Jonathan D.A. Jewell +:spdx: MPL-2.0 +:tier: Ayo +:domain: Browser +:protocols: MCP, REST + +== Overview + +Browser automation cartridge for Firefox via the **Marionette protocol**. +Connects to Firefox's built-in remote automation endpoint on `localhost:2828` +(enabled by launching Firefox with `--marionette` or setting +`marionette.enabled = true` in `about:config`). + +Provides MCP tools for: + +* **Navigate** β€” load a URL in the current tab +* **Click** β€” click an element by CSS selector +* **Type** β€” type text into a form element by CSS selector +* **Screenshot** β€” capture the current viewport as PNG +* **ReadPage** β€” extract DOM text content +* **FillForm** β€” fill multiple form fields in one operation +* **ExecuteJS** β€” execute JavaScript in page context +* **TabCreate** / **TabClose** / **TabList** β€” manage browser tabs + +Inspired by the Firefox MCP at `patallm-gallery/claude-integrations/firefox-mcp/`, +which uses a Deno server + Firefox extension via WebSocket. This cartridge +replaces that architecture with the standard BoJ Idris2/Zig/zig triple +and connects directly to Marionette over TCP (no extension required). + +== Architecture + +[cols="1,1,2"] +|=== +| Layer | Language | Purpose + +| ABI +| Idris2 +| Formally verified browser state machine (`Closed -> Connecting -> Connected -> Navigating`) with dependent-type proofs + +| FFI +| Zig +| C-compatible implementation with thread-safe session pool, Marionette TCP protocol handling + +| Adapter +| zig +| REST endpoint bridge to BoJ unified adapter protocol +|=== + +=== Browser State Machine + +.... +Closed --StartConnect--> Connecting +Connecting --ConnectSuccess--> Connected +Connecting --ConnectFail--> Error +Connected --BeginNavigate--> Navigating +Connected --Disconnect--> Closed +Connected --ConnectedError--> Error +Navigating --EndNavigate--> Connected +Navigating --NavigateError--> Error +Error --ErrorRecover--> Closed +.... + +== Building + +[source,bash] +---- +# Build FFI shared library +cd ffi && zig build + +# Run FFI tests (state machine tests, no Firefox needed) +cd ffi && zig build test + +# Type-check ABI +cd abi && idris2 --check BrowserMcp.SafeBrowser +---- + +== Firefox Setup + +To enable Marionette for automation: + +[source,bash] +---- +# Launch Firefox with Marionette enabled +firefox --marionette + +# Or set in about:config +# marionette.enabled = true +# marionette.port = 2828 (default) +---- + +== REST Endpoints + +[cols="1,2,2"] +|=== +| Method | Path | Description + +| POST | `/session` | Open a new browser session +| DELETE | `/session/{slot}` | Close a session +| GET | `/session/{slot}/state` | Get connection state +| POST | `/session/{slot}/connect` | Connect to Firefox Marionette +| POST | `/session/{slot}/disconnect` | Disconnect from Firefox +| POST | `/session/{slot}/navigate` | Navigate to URL +| POST | `/session/{slot}/click` | Click element by selector +| POST | `/session/{slot}/type` | Type text into element +| POST | `/session/{slot}/screenshot` | Capture screenshot +| POST | `/session/{slot}/read_page` | Read page DOM text +| POST | `/session/{slot}/tab` | Create new tab +| DELETE | `/session/{slot}/tab/{idx}` | Close tab by index +| GET | `/session/{slot}/tabs` | List open tabs +|=== + +== PanLL Panel + +The `panels/manifest.json` defines a monitoring panel with: + +* Connection status badge (colour-coded by state) +* Current URL display +* Tab count indicator +* Page title display +* Screenshot thumbnail (refreshed every 30s) + +== Status + +Development β€” not yet ready for mounting. diff --git a/cartridges/domains/automation/browser-mcp/abi/BrowserMcp/SafeBrowser.idr b/cartridges/domains/automation/browser-mcp/abi/BrowserMcp/SafeBrowser.idr new file mode 100644 index 0000000..378aacb --- /dev/null +++ b/cartridges/domains/automation/browser-mcp/abi/BrowserMcp/SafeBrowser.idr @@ -0,0 +1,228 @@ +-- SPDX-License-Identifier: MPL-2.0 +-- Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +-- +-- BrowserMcp.SafeBrowser β€” Type-safe ABI for browser-mcp cartridge. +-- +-- State machine with dependent-type proofs ensuring only valid transitions +-- can occur at the FFI boundary. Designed for Firefox browser automation +-- via the Marionette protocol (TCP to localhost:2828). +-- Zero unsafe escape hatches. Fully total, formally verified. + +module BrowserMcp.SafeBrowser + +%default total + +-- --------------------------------------------------------------------------- +-- Browser connection state machine +-- --------------------------------------------------------------------------- + +||| Connection state for the Marionette protocol session. +||| Closed: no connection to Firefox. +||| Connecting: TCP handshake in progress to localhost:2828. +||| Connected: Marionette session established, ready for commands. +||| Navigating: a page load or navigation action is in progress. +||| Error: an unrecoverable error occurred; must disconnect. +public export +data BrowserState = Closed | Connecting | Connected | Navigating | Error + +||| Proof that a browser state transition is valid. +public export +data ValidTransition : BrowserState -> BrowserState -> Type where + ||| Initiate connection to Firefox Marionette endpoint. + StartConnect : ValidTransition Closed Connecting + ||| TCP handshake completed, Marionette session ready. + ConnectSuccess : ValidTransition Connecting Connected + ||| Connection attempt failed. + ConnectFail : ValidTransition Connecting Error + ||| Begin a navigation (page load). + BeginNavigate : ValidTransition Connected Navigating + ||| Navigation completed, back to connected state. + EndNavigate : ValidTransition Navigating Connected + ||| Navigation failed mid-flight. + NavigateError : ValidTransition Navigating Error + ||| Graceful disconnect from connected state. + Disconnect : ValidTransition Connected Closed + ||| Error during connected state (e.g., Firefox crash). + ConnectedError : ValidTransition Connected Error + ||| Recover from error by closing the connection. + ErrorRecover : ValidTransition Error Closed + +-- --------------------------------------------------------------------------- +-- Browser action types +-- --------------------------------------------------------------------------- + +||| Actions that can be performed on the browser via Marionette protocol. +public export +data BrowserAction + = Navigate -- ^ Load a URL in the current tab. + | Click -- ^ Click an element matching a CSS selector. + | TypeText -- ^ Type text into an element matching a CSS selector. + | Screenshot -- ^ Capture a screenshot of the current viewport. + | ReadPage -- ^ Read the DOM text content of the current page. + | FillForm -- ^ Fill multiple form fields in one operation. + | ExecuteJS -- ^ Execute arbitrary JavaScript in page context. + | TabCreate -- ^ Open a new browser tab. + | TabClose -- ^ Close a tab by its handle identifier. + | TabList -- ^ List all open tabs with their titles and URLs. + +-- --------------------------------------------------------------------------- +-- Tab and page state types +-- --------------------------------------------------------------------------- + +||| Represents the state of a single browser tab. +public export +record TabState where + constructor MkTabState + ||| Unique handle identifier assigned by Marionette. + handle : Nat + ||| Page title (may be empty during load). + title : String + ||| Current URL loaded in this tab. + url : String + +||| Represents the observable state of the current page. +public export +record PageState where + constructor MkPageState + ||| URL of the currently loaded page. + currentUrl : String + ||| Page title from the element. + pageTitle : String + ||| Whether the page has finished loading. + loadComplete : Bool + +-- --------------------------------------------------------------------------- +-- C-ABI integer encoding β€” browser state +-- --------------------------------------------------------------------------- + +||| Encode browser state as C-compatible integer. +export +browserStateToInt : BrowserState -> Int +browserStateToInt Closed = 0 +browserStateToInt Connecting = 1 +browserStateToInt Connected = 2 +browserStateToInt Navigating = 3 +browserStateToInt Error = 4 + +||| Decode integer back to browser state. +export +intToBrowserState : Int -> Maybe BrowserState +intToBrowserState 0 = Just Closed +intToBrowserState 1 = Just Connecting +intToBrowserState 2 = Just Connected +intToBrowserState 3 = Just Navigating +intToBrowserState 4 = Just Error +intToBrowserState _ = Nothing + +-- --------------------------------------------------------------------------- +-- C-ABI integer encoding β€” browser action +-- --------------------------------------------------------------------------- + +||| Encode browser action as C-compatible integer. +export +browserActionToInt : BrowserAction -> Int +browserActionToInt Navigate = 0 +browserActionToInt Click = 1 +browserActionToInt TypeText = 2 +browserActionToInt Screenshot = 3 +browserActionToInt ReadPage = 4 +browserActionToInt FillForm = 5 +browserActionToInt ExecuteJS = 6 +browserActionToInt TabCreate = 7 +browserActionToInt TabClose = 8 +browserActionToInt TabList = 9 + +||| Decode integer to browser action. +export +intToBrowserAction : Int -> Maybe BrowserAction +intToBrowserAction 0 = Just Navigate +intToBrowserAction 1 = Just Click +intToBrowserAction 2 = Just TypeText +intToBrowserAction 3 = Just Screenshot +intToBrowserAction 4 = Just ReadPage +intToBrowserAction 5 = Just FillForm +intToBrowserAction 6 = Just ExecuteJS +intToBrowserAction 7 = Just TabCreate +intToBrowserAction 8 = Just TabClose +intToBrowserAction 9 = Just TabList +intToBrowserAction _ = Nothing + +-- --------------------------------------------------------------------------- +-- Transition validation (C-ABI export) +-- --------------------------------------------------------------------------- + +||| Check if a browser state transition is valid (C-ABI export). +||| Returns 1 for valid, 0 for invalid. +export +browser_mcp_can_transition : Int -> Int -> Int +browser_mcp_can_transition from to = + case (intToBrowserState from, intToBrowserState to) of + (Just Closed, Just Connecting) => 1 -- StartConnect + (Just Connecting, Just Connected) => 1 -- ConnectSuccess + (Just Connecting, Just Error) => 1 -- ConnectFail + (Just Connected, Just Navigating) => 1 -- BeginNavigate + (Just Navigating, Just Connected) => 1 -- EndNavigate + (Just Navigating, Just Error) => 1 -- NavigateError + (Just Connected, Just Closed) => 1 -- Disconnect + (Just Connected, Just Error) => 1 -- ConnectedError + (Just Error, Just Closed) => 1 -- ErrorRecover + _ => 0 + +-- --------------------------------------------------------------------------- +-- Action validity β€” which actions require Connected state +-- --------------------------------------------------------------------------- + +||| Check whether a browser action requires the Connected state. +||| All browser actions require an active connection. +export +actionRequiresConnected : BrowserAction -> Bool +actionRequiresConnected _ = True + +||| Check whether an action triggers a navigation (page load). +export +actionTriggersNavigation : BrowserAction -> Bool +actionTriggersNavigation Navigate = True +actionTriggersNavigation _ = False + +-- --------------------------------------------------------------------------- +-- MCP tool declarations +-- --------------------------------------------------------------------------- + +||| Tools exposed via MCP protocol for browser automation. +public export +data McpTool + = ToolNavigate + | ToolClick + | ToolType + | ToolScreenshot + | ToolReadPage + | ToolFillForm + | ToolExecuteJS + | ToolTabCreate + | ToolTabClose + | ToolTabList + | ToolConnect + | ToolDisconnect + | ToolStatus + +||| Check if a tool requires an active browser connection. +export +toolRequiresConnection : McpTool -> Bool +toolRequiresConnection ToolConnect = False +toolRequiresConnection ToolDisconnect = True +toolRequiresConnection ToolStatus = False +toolRequiresConnection ToolNavigate = True +toolRequiresConnection ToolClick = True +toolRequiresConnection ToolType = True +toolRequiresConnection ToolScreenshot = True +toolRequiresConnection ToolReadPage = True +toolRequiresConnection ToolFillForm = True +toolRequiresConnection ToolExecuteJS = True +toolRequiresConnection ToolTabCreate = True +toolRequiresConnection ToolTabClose = True +toolRequiresConnection ToolTabList = True + +||| Tool count for this cartridge. +export +toolCount : Nat +toolCount = 13 diff --git a/cartridges/domains/automation/browser-mcp/abi/README.adoc b/cartridges/domains/automation/browser-mcp/abi/README.adoc new file mode 100644 index 0000000..de97fcd --- /dev/null +++ b/cartridges/domains/automation/browser-mcp/abi/README.adoc @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += browser-mcp / abi β€” Idris2 ABI layer +:orientation: shallow + +== Purpose + +Cartridge-local protocol types for `browser-mcp`. Defines the data, tool, and +transition vocabulary this cartridge speaks. Inherits safety lemmas and the +matrix-cell identifier scheme from the trunk ABI at `src/abi/`. + +== Files + +1 Idris2 module(s), ~228 lines total. Lead module: +`SafeBrowser.idr`. + +No `.ipkg` β€” consumed directly by wider BoJ callers; type-checks inside the +trunk `boj.ipkg`. + +== Invariants + +* `%default total` at every module top. +* Zero `believe_me`, `assert_total`, `postulate`, `Admitted`, or + `sorry` (the trunk holds the four declared `believe_me` sites; leaf + cartridges carry none). + +== Test/proof surface + +Thin module(s), no inline tests. Proof surface = type-check success under the +trunk `idris2 --check`. + +== Read-first + +. `SafeBrowser.idr` β€” cartridge-local data records and enums. +. Trunk `src/abi/Boj/Catalogue.idr` for how this cell participates in the + 2D matrix. diff --git a/cartridges/domains/automation/browser-mcp/abi/browser_mcp.ipkg b/cartridges/domains/automation/browser-mcp/abi/browser_mcp.ipkg new file mode 100644 index 0000000..afbc999 --- /dev/null +++ b/cartridges/domains/automation/browser-mcp/abi/browser_mcp.ipkg @@ -0,0 +1,10 @@ +-- SPDX-License-Identifier: MPL-2.0 +package browser_mcp + +version = "0.1.0" +authors = "Jonathan D.A. Jewell" +brief = "browser-mcp cartridge β€” Firefox browser automation via Marionette protocol" + +depends = base + +modules = BrowserMcp.SafeBrowser diff --git a/cartridges/domains/automation/browser-mcp/adapter/README.adoc b/cartridges/domains/automation/browser-mcp/adapter/README.adoc new file mode 100644 index 0000000..2bdbb3d --- /dev/null +++ b/cartridges/domains/automation/browser-mcp/adapter/README.adoc @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += browser-mcp / adapter β€” protocol bridge +:orientation: shallow + +== Purpose + +Exposes this cartridge's FFI over the wire protocols declared in +`cartridge.json`. Dispatches tool invocations to the C-ABI exports at +`../ffi/` and formats responses per protocol. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” depends on the sibling `ffi` target. +| `browser_mcp_adapter.zig` | Protocol dispatch (154 lines). Tool catalogue matches `cartridge.json`. +| `SIDELINED-*.v.adoc` | zig predecessor(s). Not built β€” zig banned 2026-04-10. +|=== + +== Invariants + +* **Stateless** β€” all state lives behind the FFI, never in the adapter. +* **Response passthrough** β€” whatever the FFI returns goes back to the wire + unmodified (no embellishment, no silent recovery). +* **Cartridge.json is source of truth** for the tool catalogue; drift between + adapter and manifest is a CI failure. + +== Test/proof surface + +No inline tests at the adapter layer β€” protocol routing is exercised by the +trunk seam tests at `ffi/zig/src/seams.zig`. + +== Read-first + +. `browser_mcp_adapter.zig` β€” dispatch function and per-protocol routers. +. `../cartridge.json` β€” the tool manifest the dispatcher mirrors. diff --git a/cartridges/domains/automation/browser-mcp/adapter/browser_mcp_adapter.zig b/cartridges/domains/automation/browser-mcp/adapter/browser_mcp_adapter.zig new file mode 100644 index 0000000..6d534c1 --- /dev/null +++ b/cartridges/domains/automation/browser-mcp/adapter/browser_mcp_adapter.zig @@ -0,0 +1,154 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// browser-mcp/adapter/browser_mcp_adapter.zig +// +// Three-protocol BoJ adapter: REST (port 9202), gRPC-compat (port 9203), +// GraphQL (port 9204). +// Replaces the banned zig adapter (browser_mcp_adapter.v). + +const std = @import("std"); +const ffi = @import("browser_mcp_ffi"); + +const REST_PORT: u16 = 9202; +const GRPC_PORT: u16 = 9203; +const GQL_PORT: u16 = 9204; + +const Response = struct { status: u16, body: []const u8 }; + +fn okJson(buf: []u8, msg: []const u8) []u8 { + return std.fmt.bufPrint(buf, "{{\"success\":true,\"message\":\"{s}\"}}", .{msg}) catch buf[0..0]; +} + +fn errJson(buf: []u8, msg: []const u8) []u8 { + return std.fmt.bufPrint(buf, "{{\"success\":false,\"error\":\"{s}\"}}", .{msg}) catch buf[0..0]; +} + +fn dispatch(tool: []const u8, _body: []const u8, resp: []u8) Response { + _ = _body; + if (std.mem.eql(u8, tool, "browser_open")) { + return .{ .status = 200, .body = okJson(resp, "browser_open forwarded") }; + } + if (std.mem.eql(u8, tool, "browser_close")) { + return .{ .status = 200, .body = okJson(resp, "browser_close forwarded") }; + } + if (std.mem.eql(u8, tool, "browser_connect")) { + return .{ .status = 200, .body = okJson(resp, "browser_connect forwarded") }; + } + if (std.mem.eql(u8, tool, "browser_navigate")) { + return .{ .status = 200, .body = okJson(resp, "browser_navigate forwarded") }; + } + if (std.mem.eql(u8, tool, "browser_click")) { + return .{ .status = 200, .body = okJson(resp, "browser_click forwarded") }; + } + if (std.mem.eql(u8, tool, "browser_type")) { + return .{ .status = 200, .body = okJson(resp, "browser_type forwarded") }; + } + if (std.mem.eql(u8, tool, "browser_screenshot")) { + return .{ .status = 200, .body = okJson(resp, "browser_screenshot forwarded") }; + } + if (std.mem.eql(u8, tool, "browser_read_page")) { + return .{ .status = 200, .body = okJson(resp, "browser_read_page forwarded") }; + } + if (std.mem.eql(u8, tool, "browser_tab_list")) { + return .{ .status = 200, .body = okJson(resp, "browser_tab_list forwarded") }; + } + return .{ .status = 404, .body = errJson(resp, "unknown tool") }; +} + +fn dispatchRest(path: []const u8, body: []const u8, resp: []u8) Response { + // Expect /tools/<tool_name> + const prefix = "/tools/"; + if (std.mem.startsWith(u8, path, prefix)) { + const tool = path[prefix.len..]; + return dispatch(tool, body, resp); + } + return .{ .status = 404, .body = errJson(resp, "not found") }; +} + +fn dispatchGrpc(path: []const u8, body: []const u8, resp: []u8) Response { + // Expect /<Service>/<Method> β€” derive tool from Method + var it = std.mem.splitScalar(u8, path, '/'); + _ = it.next(); // leading empty + _ = it.next(); // service + const method = it.next() orelse return .{ .status = 404, .body = errJson(resp, "bad gRPC path") }; + return dispatch(method, body, resp); +} + +fn dispatchGraphql(body: []const u8, resp: []u8) Response { + if (std.mem.indexOf(u8, body, "browser_open") != null) + return dispatch("browser_open", body, resp); + if (std.mem.indexOf(u8, body, "browser_close") != null) + return dispatch("browser_close", body, resp); + if (std.mem.indexOf(u8, body, "browser_connect") != null) + return dispatch("browser_connect", body, resp); + if (std.mem.indexOf(u8, body, "browser_navigate") != null) + return dispatch("browser_navigate", body, resp); + if (std.mem.indexOf(u8, body, "browser_click") != null) + return dispatch("browser_click", body, resp); + if (std.mem.indexOf(u8, body, "browser_type") != null) + return dispatch("browser_type", body, resp); + if (std.mem.indexOf(u8, body, "browser_screenshot") != null) + return dispatch("browser_screenshot", body, resp); + if (std.mem.indexOf(u8, body, "browser_read_page") != null) + return dispatch("browser_read_page", body, resp); + if (std.mem.indexOf(u8, body, "browser_tab_list") != null) + return dispatch("browser_tab_list", body, resp); + return .{ .status = 400, .body = errJson(resp, "unrecognised GraphQL operation") }; +} + +fn handleConnection(stream: std.net.Stream, port: u16) void { + defer stream.close(); + var buf: [4096]u8 = undefined; + var resp_buf: [4096]u8 = undefined; + const n = stream.read(&buf) catch return; + const req = buf[0..n]; + const result = switch (port) { + REST_PORT => blk: { + // Parse HTTP/1.1: first line = METHOD PATH HTTP/x.y + var lines = std.mem.splitScalar(u8, req, '\n'); + const first = lines.next() orelse break :blk Response{ .status = 400, .body = "" }; + var parts = std.mem.splitScalar(u8, std.mem.trim(u8, first, "\r"), ' '); + _ = parts.next(); // method + const path = parts.next() orelse break :blk Response{ .status = 400, .body = "" }; + break :blk dispatchRest(path, req, &resp_buf); + }, + GRPC_PORT => blk: { + var lines = std.mem.splitScalar(u8, req, '\n'); + const first = lines.next() orelse break :blk Response{ .status = 400, .body = "" }; + var parts = std.mem.splitScalar(u8, std.mem.trim(u8, first, "\r"), ' '); + _ = parts.next(); + const path = parts.next() orelse break :blk Response{ .status = 400, .body = "" }; + break :blk dispatchGrpc(path, req, &resp_buf); + }, + GQL_PORT => dispatchGraphql(req, &resp_buf), + else => Response{ .status = 500, .body = "" }, + }; + var http_resp: [512]u8 = undefined; + const http = std.fmt.bufPrint(&http_resp, + "HTTP/1.1 {d} OK\r\nContent-Length: {d}\r\nContent-Type: application/json\r\n\r\n", + .{ result.status, result.body.len }) catch return; + _ = stream.write(http) catch {}; + _ = stream.write(result.body) catch {}; +} + +fn listenLoop(port: u16) !void { + const addr = std.net.Address.initIp4(.{ 127, 0, 0, 1 }, port); + var server = try addr.listen(.{ .reuse_address = true }); + defer server.deinit(); + while (true) { + const conn = try server.accept(); + const t = try std.Thread.spawn(.{}, handleConnection, .{ conn.stream, port }); + t.detach(); + } +} + +pub fn main() !void { + ffi.browser_mcp_init(); + const t1 = try std.Thread.spawn(.{}, listenLoop, .{REST_PORT}); + const t2 = try std.Thread.spawn(.{}, listenLoop, .{GRPC_PORT}); + const t3 = try std.Thread.spawn(.{}, listenLoop, .{GQL_PORT}); + t1.join(); + t2.join(); + t3.join(); +} diff --git a/cartridges/domains/automation/browser-mcp/adapter/build.zig b/cartridges/domains/automation/browser-mcp/adapter/build.zig new file mode 100644 index 0000000..8269c87 --- /dev/null +++ b/cartridges/domains/automation/browser-mcp/adapter/build.zig @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// browser-mcp/adapter/build.zig + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const ffi_mod = b.createModule(.{ + .root_source_file = b.path("../ffi/browser_mcp_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + const adapter = b.addExecutable(.{ + .name = "browser_mcp_adapter", + .root_source_file = b.path("browser_mcp_adapter.zig"), + .target = target, + .optimize = optimize, + }); + adapter.root_module.addImport("browser_mcp_ffi", ffi_mod); + b.installArtifact(adapter); +} diff --git a/cartridges/domains/automation/browser-mcp/benchmarks/quick-bench.sh b/cartridges/domains/automation/browser-mcp/benchmarks/quick-bench.sh new file mode 100755 index 0000000..0c158dc --- /dev/null +++ b/cartridges/domains/automation/browser-mcp/benchmarks/quick-bench.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Benchmarks for browser-mcp cartridge. +set -euo pipefail + +echo "=== browser-mcp benchmarks ===" + +cd "$(dirname "${BASH_SOURCE[0]}")/../ffi" + +# Build with ReleaseFast for benchmarking +zig build -Doptimize=ReleaseFast 2>&1 + +echo "Session open/close cycle (1000 iterations):" +time for i in $(seq 1 1000); do + # This would call the FFI benchmark binary + true +done + +echo "" +echo "Benchmark placeholder β€” implement real benchmarks in Zig test blocks" +echo "or via the zig adapter HTTP benchmark tool." diff --git a/cartridges/domains/automation/browser-mcp/cartridge.json b/cartridges/domains/automation/browser-mcp/cartridge.json new file mode 100644 index 0000000..2c89cee --- /dev/null +++ b/cartridges/domains/automation/browser-mcp/cartridge.json @@ -0,0 +1,192 @@ +{ + "$schema": "https://boj.dev/schemas/cartridge/v1.json", + "spdx": "MPL-2.0", + "copyright": "Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk>", + "name": "browser-mcp", + "version": "0.1.0", + "description": "Firefox browser automation via Marionette", + "domain": "automation", + "tier": "Ayo", + "protocols": [ + "MCP", + "REST" + ], + "auth": { + "method": "none", + "env_var": null, + "credential_source": null + }, + "api": { + "base_url": "local://browser-mcp", + "content_type": "application/json" + }, + "tools": [ + { + "name": "browser_open", + "description": "Open a new browser session", + "inputSchema": { + "type": "object", + "properties": {} + } + }, + { + "name": "browser_close", + "description": "Close a browser session", + "inputSchema": { + "type": "object", + "properties": { + "session_id": { + "type": "integer", + "description": "Session index" + } + }, + "required": [ + "session_id" + ] + } + }, + { + "name": "browser_connect", + "description": "Connect session to Marionette", + "inputSchema": { + "type": "object", + "properties": { + "session_id": { + "type": "integer", + "description": "Session index" + } + }, + "required": [ + "session_id" + ] + } + }, + { + "name": "browser_navigate", + "description": "Navigate to a URL", + "inputSchema": { + "type": "object", + "properties": { + "session_id": { + "type": "integer", + "description": "Session index" + }, + "url": { + "type": "string", + "description": "URL to navigate to" + } + }, + "required": [ + "session_id", + "url" + ] + } + }, + { + "name": "browser_click", + "description": "Click a CSS selector", + "inputSchema": { + "type": "object", + "properties": { + "session_id": { + "type": "integer", + "description": "Session index" + }, + "selector": { + "type": "string", + "description": "CSS selector" + } + }, + "required": [ + "session_id", + "selector" + ] + } + }, + { + "name": "browser_type", + "description": "Type text into an element", + "inputSchema": { + "type": "object", + "properties": { + "session_id": { + "type": "integer", + "description": "Session index" + }, + "selector": { + "type": "string", + "description": "CSS selector" + }, + "text": { + "type": "string", + "description": "Text to type" + } + }, + "required": [ + "session_id", + "selector", + "text" + ] + } + }, + { + "name": "browser_screenshot", + "description": "Take a screenshot", + "inputSchema": { + "type": "object", + "properties": { + "session_id": { + "type": "integer", + "description": "Session index" + } + }, + "required": [ + "session_id" + ] + } + }, + { + "name": "browser_read_page", + "description": "Read page text content", + "inputSchema": { + "type": "object", + "properties": { + "session_id": { + "type": "integer", + "description": "Session index" + } + }, + "required": [ + "session_id" + ] + } + }, + { + "name": "browser_tab_list", + "description": "List open tabs", + "inputSchema": { + "type": "object", + "properties": { + "session_id": { + "type": "integer", + "description": "Session index" + } + }, + "required": [ + "session_id" + ] + } + } + ], + "ffi": { + "so_path": "ffi/zig-out/lib/libbrowser_mcp.so", + "abi_version": "ADR-0006", + "symbols": [ + "boj_cartridge_init", + "boj_cartridge_deinit", + "boj_cartridge_name", + "boj_cartridge_version", + "boj_cartridge_invoke" + ] + } +} diff --git a/cartridges/domains/automation/browser-mcp/ffi/README.adoc b/cartridges/domains/automation/browser-mcp/ffi/README.adoc new file mode 100644 index 0000000..305d0d4 --- /dev/null +++ b/cartridges/domains/automation/browser-mcp/ffi/README.adoc @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += browser-mcp / ffi β€” Zig FFI layer +:orientation: shallow + +== Purpose + +C-ABI exports bridging this cartridge's Idris2 ABI to the runtime. Consumed by +the adapter for protocol dispatch and by the trunk loader for mount. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” produces `zig-out/lib/libbrowser.so`. +| `browser_mcp_ffi.zig` | C-ABI exports (17 exports, 8 inline tests, 574 lines). +|=== + +== Invariants + +* **Null-pointer rejection** on `[*c]const u8` arguments before any + dereference. +* **Negative return values** carry documented error meanings; zero is + success (matching the trunk error-code convention). +* **No allocator leaks** β€” every allocation path pairs with `defer free`. + +== Test/proof surface + +8 inline `test "..."` block(s) in `browser_mcp_ffi.zig`. Run with +`cd ffi && zig build test`. + +== Read-first + +. `browser_mcp_ffi.zig` β€” C-ABI signatures and guards. +. Trunk `ffi/zig/src/catalogue.zig` for how this `.so` is loaded. diff --git a/cartridges/domains/automation/browser-mcp/ffi/browser_mcp_ffi.zig b/cartridges/domains/automation/browser-mcp/ffi/browser_mcp_ffi.zig new file mode 100644 index 0000000..5f46910 --- /dev/null +++ b/cartridges/domains/automation/browser-mcp/ffi/browser_mcp_ffi.zig @@ -0,0 +1,680 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// browser_mcp_ffi.zig β€” C-ABI FFI implementation for browser-mcp cartridge. +// +// Implements the browser state machine defined in the Idris2 ABI layer +// for Firefox automation via the Marionette protocol (TCP localhost:2828). +// Thread-safe via std.Thread.Mutex. No heap allocations for results. + +const std = @import("std"); + +// --------------------------------------------------------------------------- +// Browser state machine (matches Idris2 ABI exactly) +// --------------------------------------------------------------------------- + +/// Connection state for the Marionette protocol session. +pub const BrowserState = enum(c_int) { + closed = 0, + connecting = 1, + connected = 2, + navigating = 3, + err = 4, +}; + +/// Browser actions that can be performed via Marionette. +pub const BrowserAction = enum(c_int) { + navigate = 0, + click = 1, + type_text = 2, + screenshot = 3, + read_page = 4, + fill_form = 5, + execute_js = 6, + tab_create = 7, + tab_close = 8, + tab_list = 9, +}; + +/// Check if a browser state transition is valid. +fn isValidTransition(from: BrowserState, to: BrowserState) bool { + return switch (from) { + .closed => to == .connecting, + .connecting => to == .connected or to == .err, + .connected => to == .navigating or to == .closed or to == .err, + .navigating => to == .connected or to == .err, + .err => to == .closed, + }; +} + +// --------------------------------------------------------------------------- +// Browser session slots (thread-safe, fixed-size pool) +// --------------------------------------------------------------------------- + +const MAX_SESSIONS: usize = 16; +const URL_BUF_SIZE: usize = 4096; +const TITLE_BUF_SIZE: usize = 1024; +const RESULT_BUF_SIZE: usize = 65536; + +/// Represents a single browser tab handle. +const TabHandle = struct { + active: bool = false, + id: u32 = 0, + url_buf: [URL_BUF_SIZE]u8 = undefined, + url_len: usize = 0, + title_buf: [TITLE_BUF_SIZE]u8 = undefined, + title_len: usize = 0, +}; + +const MAX_TABS: usize = 64; + +/// A browser session slot managing connection state, tabs, and result buffers. +const BrowserSession = struct { + active: bool = false, + state: BrowserState = .closed, + // Marionette connection target + marionette_port: u16 = 2828, + // Current page state + current_url_buf: [URL_BUF_SIZE]u8 = undefined, + current_url_len: usize = 0, + page_title_buf: [TITLE_BUF_SIZE]u8 = undefined, + page_title_len: usize = 0, + load_complete: bool = false, + // Tab tracking + tabs: [MAX_TABS]TabHandle = [_]TabHandle{.{}} ** MAX_TABS, + tab_count: usize = 0, + // Result buffer (for screenshot, read_page, tab_list, execute_js) + result_buf: [RESULT_BUF_SIZE]u8 = undefined, + result_len: usize = 0, +}; + +var sessions: [MAX_SESSIONS]BrowserSession = [_]BrowserSession{.{}} ** MAX_SESSIONS; +var mutex: std.Thread.Mutex = .{}; + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +/// Attempt a state transition on a session. Returns 0 on success, -2 on invalid. +fn transitionState(slot: *BrowserSession, to: BrowserState) c_int { + if (!isValidTransition(slot.state, to)) return -2; + slot.state = to; + return 0; +} + +/// Validate a slot index and return a pointer, or null if invalid/inactive. +fn getActiveSlot(slot_idx: c_int) ?*BrowserSession { + const idx: usize = std.math.cast(usize, slot_idx) orelse return null; + if (idx >= MAX_SESSIONS) return null; + const slot = &sessions[idx]; + if (!slot.active) return null; + return slot; +} + +// --------------------------------------------------------------------------- +// C-ABI exports β€” state machine +// --------------------------------------------------------------------------- + +/// Check if a browser state transition is valid. Returns 1 (valid) or 0 (invalid). +pub export fn browser_mcp_can_transition(from: c_int, to: c_int) c_int { + const f = std.meta.intToEnum(BrowserState, from) catch return 0; + const t = std.meta.intToEnum(BrowserState, to) catch return 0; + return if (isValidTransition(f, t)) 1 else 0; +} + +// --------------------------------------------------------------------------- +// C-ABI exports β€” session management +// --------------------------------------------------------------------------- + +/// Open a new browser session. Returns slot index (>= 0) or -1 if no free slots. +pub export fn browser_mcp_session_open() c_int { + mutex.lock(); + defer mutex.unlock(); + + for (&sessions, 0..) |*slot, idx| { + if (!slot.active) { + slot.* = .{}; + slot.active = true; + slot.state = .closed; + return @intCast(idx); + } + } + return -1; // No free slots +} + +/// Close a browser session. Returns 0 on success, -1 if slot invalid. +pub export fn browser_mcp_session_close(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + + slot.* = .{}; + return 0; +} + +/// Get the current state of a browser session. Returns state int or -1 if invalid. +pub export fn browser_mcp_session_state(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const slot = getActiveSlot(slot_idx) orelse return -1; + return @intFromEnum(slot.state); +} + +/// Reset all sessions (test/debug use only). +pub export fn browser_mcp_reset() void { + mutex.lock(); + defer mutex.unlock(); + sessions = [_]BrowserSession{.{}} ** MAX_SESSIONS; +} + +// --------------------------------------------------------------------------- +// C-ABI exports β€” Marionette connection lifecycle +// --------------------------------------------------------------------------- + +/// Initiate connection to Firefox Marionette (Closed -> Connecting). +/// Returns 0 on success, -1 if invalid slot, -2 if bad transition. +pub export fn browser_mcp_connect(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const slot = getActiveSlot(slot_idx) orelse return -1; + const rc = transitionState(slot, .connecting); + if (rc != 0) return rc; + // In a real implementation, this would initiate TCP to localhost:2828. + // Immediately transition to connected for the state machine layer. + slot.state = .connected; + return 0; +} + +/// Disconnect from Firefox Marionette (Connected -> Closed). +/// Returns 0 on success, -1 if invalid slot, -2 if bad transition. +pub export fn browser_mcp_disconnect(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const slot = getActiveSlot(slot_idx) orelse return -1; + return transitionState(slot, .closed); +} + +// --------------------------------------------------------------------------- +// C-ABI exports β€” browser actions +// --------------------------------------------------------------------------- + +/// Navigate to a URL. Writes url into the session's current_url buffer. +/// Returns 0 on success, -1 if invalid slot, -2 if not connected, -3 if URL too long. +pub export fn browser_mcp_navigate(slot_idx: c_int, url_ptr: [*]const u8, url_len: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const slot = getActiveSlot(slot_idx) orelse return -1; + if (slot.state != .connected) return -2; + + const len: usize = std.math.cast(usize, url_len) orelse return -3; + if (len > URL_BUF_SIZE) return -3; + + // Transition: Connected -> Navigating -> Connected + slot.state = .navigating; + @memcpy(slot.current_url_buf[0..len], url_ptr[0..len]); + slot.current_url_len = len; + slot.load_complete = true; + slot.state = .connected; + return 0; +} + +/// Click an element by CSS selector. +/// Returns 0 on success, -1 if invalid slot, -2 if not connected. +pub export fn browser_mcp_click(slot_idx: c_int, selector_ptr: [*]const u8, selector_len: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const slot = getActiveSlot(slot_idx) orelse return -1; + if (slot.state != .connected) return -2; + + // Validate selector length + const len: usize = std.math.cast(usize, selector_len) orelse return -3; + if (len == 0) return -3; + + // In real implementation: send Marionette findElement + clickElement commands + _ = selector_ptr; + return 0; +} + +/// Type text into an element matching a CSS selector. +/// Returns 0 on success, -1 if invalid slot, -2 if not connected. +pub export fn browser_mcp_type(slot_idx: c_int, selector_ptr: [*]const u8, selector_len: c_int, text_ptr: [*]const u8, text_len: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const slot = getActiveSlot(slot_idx) orelse return -1; + if (slot.state != .connected) return -2; + + const sel_len: usize = std.math.cast(usize, selector_len) orelse return -3; + const txt_len: usize = std.math.cast(usize, text_len) orelse return -3; + if (sel_len == 0 or txt_len == 0) return -3; + + // In real implementation: send Marionette findElement + sendKeys commands + _ = selector_ptr; + _ = text_ptr; + return 0; +} + +/// Capture a screenshot. Writes PNG data into the session's result_buf. +/// Returns bytes written on success (>= 0), -1 if invalid slot, -2 if not connected. +pub export fn browser_mcp_screenshot(slot_idx: c_int, out_buf: [*]u8, out_buf_len: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const slot = getActiveSlot(slot_idx) orelse return -1; + if (slot.state != .connected) return -2; + + const buf_len: usize = std.math.cast(usize, out_buf_len) orelse return -3; + + // In real implementation: send Marionette takeScreenshot command + // For now, write a placeholder indicating the operation was invoked + const placeholder = "SCREENSHOT_PLACEHOLDER"; + if (buf_len < placeholder.len) return -3; + @memcpy(out_buf[0..placeholder.len], placeholder); + return @intCast(placeholder.len); +} + +/// Read the current page DOM text. Writes text into out_buf. +/// Returns bytes written on success (>= 0), -1 if invalid slot, -2 if not connected. +pub export fn browser_mcp_read_page(slot_idx: c_int, out_buf: [*]u8, out_buf_len: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const slot = getActiveSlot(slot_idx) orelse return -1; + if (slot.state != .connected) return -2; + + const buf_len: usize = std.math.cast(usize, out_buf_len) orelse return -3; + + // In real implementation: send Marionette getPageSource or executeScript + // Return current URL as proof the session is tracking state + if (slot.current_url_len == 0) { + const empty = "NO_PAGE_LOADED"; + if (buf_len < empty.len) return -3; + @memcpy(out_buf[0..empty.len], empty); + return @intCast(empty.len); + } + if (buf_len < slot.current_url_len) return -3; + @memcpy(out_buf[0..slot.current_url_len], slot.current_url_buf[0..slot.current_url_len]); + return @intCast(slot.current_url_len); +} + +// --------------------------------------------------------------------------- +// C-ABI exports β€” tab management +// --------------------------------------------------------------------------- + +/// Create a new tab. Returns tab index (>= 0) or error code (< 0). +/// -1 = invalid slot, -2 = not connected, -3 = tab limit reached. +pub export fn browser_mcp_tab_create(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const slot = getActiveSlot(slot_idx) orelse return -1; + if (slot.state != .connected) return -2; + if (slot.tab_count >= MAX_TABS) return -3; + + const tab_idx = slot.tab_count; + slot.tabs[tab_idx] = .{ + .active = true, + .id = @intCast(tab_idx), + }; + slot.tab_count += 1; + + // In real implementation: send Marionette newWindow command + return @intCast(tab_idx); +} + +/// Close a tab by index. Returns 0 on success, -1 if invalid slot, +/// -2 if not connected, -3 if tab index invalid. +pub export fn browser_mcp_tab_close(slot_idx: c_int, tab_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const slot = getActiveSlot(slot_idx) orelse return -1; + if (slot.state != .connected) return -2; + + const tidx: usize = std.math.cast(usize, tab_idx) orelse return -3; + if (tidx >= slot.tab_count) return -3; + if (!slot.tabs[tidx].active) return -3; + + slot.tabs[tidx].active = false; + + // In real implementation: send Marionette closeWindow command + return 0; +} + +/// List open tabs. Writes tab count to out_buf as a simple integer string. +/// Returns bytes written on success (>= 0), or error code (< 0). +pub export fn browser_mcp_tab_list(slot_idx: c_int, out_buf: [*]u8, out_buf_len: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const slot = getActiveSlot(slot_idx) orelse return -1; + if (slot.state != .connected) return -2; + + const buf_len: usize = std.math.cast(usize, out_buf_len) orelse return -3; + + // Count active tabs + var active_count: usize = 0; + for (slot.tabs[0..slot.tab_count]) |tab| { + if (tab.active) active_count += 1; + } + + // Write count as ASCII digits + var tmp_buf: [32]u8 = undefined; + const result = std.fmt.bufPrint(&tmp_buf, "{d}", .{active_count}) catch return -3; + if (buf_len < result.len) return -3; + @memcpy(out_buf[0..result.len], result); + return @intCast(result.len); +} + +/// Signal an error on a session (Connected|Navigating -> Error). +/// Returns 0 on success, -1 if invalid slot, -2 if bad transition. +pub export fn browser_mcp_signal_error(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const slot = getActiveSlot(slot_idx) orelse return -1; + return transitionState(slot, .err); +} + +/// Recover from error (Error -> Closed). Returns 0 on success. +pub export fn browser_mcp_error_recover(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const slot = getActiveSlot(slot_idx) orelse return -1; + return transitionState(slot, .closed); +} + +// --------------------------------------------------------------------------- +// Tests β€” state machine validation (no actual Firefox connections) +// --------------------------------------------------------------------------- + +// ═══════════════════════════════════════════════════════════════════════ +// Standard ABI (ADR-0005 four symbols + ADR-0006 invoke) +// ═══════════════════════════════════════════════════════════════════════ + +const shim = @import("cartridge_shim.zig"); + +const CARTRIDGE_NAME_PTR: [*:0]const u8 = "browser-mcp"; +const CARTRIDGE_VERSION_PTR: [*:0]const u8 = "0.1.0"; + +export fn boj_cartridge_init() callconv(.c) c_int { + return 0; +} + +export fn boj_cartridge_deinit() callconv(.c) void {} + +export fn boj_cartridge_name() callconv(.c) [*:0]const u8 { + return CARTRIDGE_NAME_PTR; +} + +export fn boj_cartridge_version() callconv(.c) [*:0]const u8 { + return CARTRIDGE_VERSION_PTR; +} + +/// Dispatch the cartridge.json MCP tools. Grade D Alpha stubs. +export fn boj_cartridge_invoke( + tool_name: [*c]const u8, + json_args: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) callconv(.c) i32 { + _ = json_args; + if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; + + const body: []const u8 = if (shim.toolIs(tool_name, "browser_open")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "browser_close")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "browser_connect")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "browser_navigate")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "browser_click")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "browser_type")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "browser_screenshot")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "browser_read_page")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "browser_tab_list")) + "{\"result\":{\"items\":[],\"count\":0,\"status\":\"stub\"}}" +else + return shim.RC_UNKNOWN_TOOL; + + return shim.writeResult(out_buf, in_out_len, body); +} + +test "browser session lifecycle: open -> connect -> disconnect -> close" { + browser_mcp_reset(); + + // Open a session (starts in Closed state) + const slot = browser_mcp_session_open(); + try std.testing.expect(slot >= 0); + try std.testing.expectEqual(@as(c_int, 0), browser_mcp_session_state(slot)); // closed = 0 + + // Connect (Closed -> Connected) + try std.testing.expectEqual(@as(c_int, 0), browser_mcp_connect(slot)); + try std.testing.expectEqual(@as(c_int, 2), browser_mcp_session_state(slot)); // connected = 2 + + // Disconnect (Connected -> Closed) + try std.testing.expectEqual(@as(c_int, 0), browser_mcp_disconnect(slot)); + try std.testing.expectEqual(@as(c_int, 0), browser_mcp_session_state(slot)); // closed = 0 + + // Close the session + try std.testing.expectEqual(@as(c_int, 0), browser_mcp_session_close(slot)); +} + +test "navigate updates current URL" { + browser_mcp_reset(); + + const slot = browser_mcp_session_open(); + try std.testing.expect(slot >= 0); + try std.testing.expectEqual(@as(c_int, 0), browser_mcp_connect(slot)); + + // Navigate + const url = "https://example.com"; + try std.testing.expectEqual(@as(c_int, 0), browser_mcp_navigate(slot, url.ptr, @intCast(url.len))); + + // State should still be connected (navigation completed synchronously) + try std.testing.expectEqual(@as(c_int, 2), browser_mcp_session_state(slot)); + + // Read page should return the URL we navigated to + var buf: [4096]u8 = undefined; + const read_len = browser_mcp_read_page(slot, &buf, 4096); + try std.testing.expect(read_len > 0); + const read_len_usize: usize = @intCast(read_len); + try std.testing.expectEqualStrings(url, buf[0..read_len_usize]); +} + +test "actions rejected when not connected" { + browser_mcp_reset(); + + const slot = browser_mcp_session_open(); + try std.testing.expect(slot >= 0); + + // Session is in Closed state β€” all actions should fail with -2 + const url = "https://example.com"; + try std.testing.expectEqual(@as(c_int, -2), browser_mcp_navigate(slot, url.ptr, @intCast(url.len))); + + const sel = "#btn"; + try std.testing.expectEqual(@as(c_int, -2), browser_mcp_click(slot, sel.ptr, @intCast(sel.len))); + + var buf: [128]u8 = undefined; + try std.testing.expectEqual(@as(c_int, -2), browser_mcp_screenshot(slot, &buf, 128)); + try std.testing.expectEqual(@as(c_int, -2), browser_mcp_read_page(slot, &buf, 128)); + try std.testing.expectEqual(@as(c_int, -2), browser_mcp_tab_create(slot)); +} + +test "tab management" { + browser_mcp_reset(); + + const slot = browser_mcp_session_open(); + try std.testing.expect(slot >= 0); + try std.testing.expectEqual(@as(c_int, 0), browser_mcp_connect(slot)); + + // Create tabs + const tab0 = browser_mcp_tab_create(slot); + try std.testing.expect(tab0 >= 0); + const tab1 = browser_mcp_tab_create(slot); + try std.testing.expect(tab1 >= 0); + + // List should show 2 active tabs + var buf: [64]u8 = undefined; + const list_len = browser_mcp_tab_list(slot, &buf, 64); + try std.testing.expect(list_len > 0); + const list_len_usize: usize = @intCast(list_len); + try std.testing.expectEqualStrings("2", buf[0..list_len_usize]); + + // Close one tab + try std.testing.expectEqual(@as(c_int, 0), browser_mcp_tab_close(slot, tab0)); + + // List should now show 1 + const list_len2 = browser_mcp_tab_list(slot, &buf, 64); + try std.testing.expect(list_len2 > 0); + const list_len2_usize: usize = @intCast(list_len2); + try std.testing.expectEqualStrings("1", buf[0..list_len2_usize]); +} + +test "invalid state transitions rejected" { + browser_mcp_reset(); + + const slot = browser_mcp_session_open(); + try std.testing.expect(slot >= 0); + + // Can't disconnect from Closed state + try std.testing.expectEqual(@as(c_int, -2), browser_mcp_disconnect(slot)); + + // Can't signal error from Closed state + try std.testing.expectEqual(@as(c_int, -2), browser_mcp_signal_error(slot)); + + // Connect, then try invalid transitions + try std.testing.expectEqual(@as(c_int, 0), browser_mcp_connect(slot)); + + // Can't connect again from Connected + try std.testing.expectEqual(@as(c_int, -2), browser_mcp_connect(slot)); + + // Can't recover from non-Error state + try std.testing.expectEqual(@as(c_int, -2), browser_mcp_error_recover(slot)); +} + +test "transition validator covers all valid browser transitions" { + // Valid transitions (matching Idris2 ABI) + try std.testing.expectEqual(@as(c_int, 1), browser_mcp_can_transition(0, 1)); // closed -> connecting + try std.testing.expectEqual(@as(c_int, 1), browser_mcp_can_transition(1, 2)); // connecting -> connected + try std.testing.expectEqual(@as(c_int, 1), browser_mcp_can_transition(1, 4)); // connecting -> error + try std.testing.expectEqual(@as(c_int, 1), browser_mcp_can_transition(2, 3)); // connected -> navigating + try std.testing.expectEqual(@as(c_int, 1), browser_mcp_can_transition(3, 2)); // navigating -> connected + try std.testing.expectEqual(@as(c_int, 1), browser_mcp_can_transition(3, 4)); // navigating -> error + try std.testing.expectEqual(@as(c_int, 1), browser_mcp_can_transition(2, 0)); // connected -> closed + try std.testing.expectEqual(@as(c_int, 1), browser_mcp_can_transition(2, 4)); // connected -> error + try std.testing.expectEqual(@as(c_int, 1), browser_mcp_can_transition(4, 0)); // error -> closed + + // Invalid transitions + try std.testing.expectEqual(@as(c_int, 0), browser_mcp_can_transition(0, 2)); // closed -> connected (skip) + try std.testing.expectEqual(@as(c_int, 0), browser_mcp_can_transition(0, 3)); // closed -> navigating + try std.testing.expectEqual(@as(c_int, 0), browser_mcp_can_transition(4, 2)); // error -> connected + try std.testing.expectEqual(@as(c_int, 0), browser_mcp_can_transition(3, 0)); // navigating -> closed (must go through connected) + + // Out of range + try std.testing.expectEqual(@as(c_int, 0), browser_mcp_can_transition(99, 0)); + try std.testing.expectEqual(@as(c_int, 0), browser_mcp_can_transition(0, 99)); +} + +test "slot exhaustion" { + browser_mcp_reset(); + + // Fill all slots + var slots: [MAX_SESSIONS]c_int = undefined; + for (&slots) |*s| { + s.* = browser_mcp_session_open(); + try std.testing.expect(s.* >= 0); + } + + // Next open should fail + try std.testing.expectEqual(@as(c_int, -1), browser_mcp_session_open()); + + // Free one and try again + try std.testing.expectEqual(@as(c_int, 0), browser_mcp_session_close(slots[0])); + const new_slot = browser_mcp_session_open(); + try std.testing.expect(new_slot >= 0); +} + +test "error recovery flow" { + browser_mcp_reset(); + + const slot = browser_mcp_session_open(); + try std.testing.expect(slot >= 0); + try std.testing.expectEqual(@as(c_int, 0), browser_mcp_connect(slot)); + + // Signal error (Connected -> Error) + try std.testing.expectEqual(@as(c_int, 0), browser_mcp_signal_error(slot)); + try std.testing.expectEqual(@as(c_int, 4), browser_mcp_session_state(slot)); // error = 4 + + // Recover (Error -> Closed) + try std.testing.expectEqual(@as(c_int, 0), browser_mcp_error_recover(slot)); + try std.testing.expectEqual(@as(c_int, 0), browser_mcp_session_state(slot)); // closed = 0 + + // Can reconnect after recovery + try std.testing.expectEqual(@as(c_int, 0), browser_mcp_connect(slot)); + try std.testing.expectEqual(@as(c_int, 2), browser_mcp_session_state(slot)); // connected = 2 +} + +// ═══════════════════════════════════════════════════════════════════════ +// ADR-0006 invoke dispatch tests +// ═══════════════════════════════════════════════════════════════════════ + +test "boj_cartridge_name returns browser-mcp" { + const n = std.mem.span(boj_cartridge_name()); + try std.testing.expectEqualStrings("browser-mcp", n); +} + +test "boj_cartridge_init returns 0" { + try std.testing.expectEqual(@as(c_int, 0), boj_cartridge_init()); +} + +test "invoke: each declared tool succeeds" { + var buf: [256]u8 = undefined; + const tools = [_][]const u8{ + "browser_open", + "browser_close", + "browser_connect", + "browser_navigate", + "browser_click", + "browser_type", + "browser_screenshot", + "browser_read_page", + "browser_tab_list", + }; + for (tools) |t| { + var len: usize = buf.len; + const rc = boj_cartridge_invoke(t.ptr, "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, 0), rc); + try std.testing.expect(std.mem.indexOf(u8, buf[0..len], "result") != null); + } +} + +test "invoke: unknown tool returns -1" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("nope", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -1), rc); +} + +test "invoke: buffer too small returns -3" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("browser_open", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -3), rc); + try std.testing.expect(len > 4); +} diff --git a/cartridges/domains/automation/browser-mcp/ffi/build.zig b/cartridges/domains/automation/browser-mcp/ffi/build.zig new file mode 100644 index 0000000..a1aead1 --- /dev/null +++ b/cartridges/domains/automation/browser-mcp/ffi/build.zig @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + // Module + // Shared ADR-0006 invoke-shim module (relative path up to boj-server trunk). + + const shim_mod = b.addModule("cartridge_shim", .{ + + .root_source_file = b.path("../../../ffi/zig/src/cartridge_shim.zig"), + + .target = target, + + .optimize = optimize, + + }); + + const ffi_mod = b.addModule("browser_mcp", .{ + .root_source_file = b.path("browser_mcp_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + ffi_mod.addImport("cartridge_shim", shim_mod); + + // Shared library + const lib = b.addLibrary(.{ + .name = "browser_mcp", + .root_module = ffi_mod, + .linkage = .dynamic, + }); + lib.linkLibC(); + b.installArtifact(lib); + + // Tests + const tests = b.addTest(.{ + .root_module = ffi_mod, + }); + tests.linkLibC(); + + const run_tests = b.addRunArtifact(tests); + const test_step = b.step("test", "Run FFI tests"); + test_step.dependOn(&run_tests.step); +} diff --git a/cartridges/domains/automation/browser-mcp/ffi/cartridge_shim.zig b/cartridges/domains/automation/browser-mcp/ffi/cartridge_shim.zig new file mode 100644 index 0000000..0e399db --- /dev/null +++ b/cartridges/domains/automation/browser-mcp/ffi/cartridge_shim.zig @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// cartridge_shim.zig β€” Shared helpers for the ADR-0006 five-symbol +// cartridge ABI (`boj_cartridge_init / deinit / name / version / invoke`). +// +// The shim centralises the seven-code return convention, NUL-argument +// guards, tool-name comparison, and the buffer-too-small path so each +// cartridge's `boj_cartridge_invoke` can stay short β€” typically a tool +// table plus `shim.writeResult(...)`. +// +// Cartridges import this file by relative path (no build-graph change +// needed). Example: +// +// const shim = @import("../../../ffi/zig/src/cartridge_shim.zig"); +// +// export fn boj_cartridge_invoke( +// tool_name: [*c]const u8, +// json_args: [*c]const u8, +// out_buf: [*c]u8, +// in_out_len: [*c]usize, +// ) callconv(.c) i32 { +// _ = json_args; +// if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; +// const body = if (shim.toolIs(tool_name, "foo")) "{\"result\":{}}" +// else return shim.RC_UNKNOWN_TOOL; +// return shim.writeResult(out_buf, in_out_len, body); +// } + +const std = @import("std"); + +// ── Return codes (ADR-0006 Β§Return codes) ──────────────────────────── +// +// Frozen by ADR-0006. New failure modes compose these via the error +// JSON body β€” the integer surface does not grow without a follow-up ADR. + +pub const RC_SUCCESS: i32 = 0; +pub const RC_UNKNOWN_TOOL: i32 = -1; +pub const RC_BAD_ARGS: i32 = -2; +pub const RC_BUFFER_TOO_SMALL: i32 = -3; +pub const RC_RUNTIME_ERROR: i32 = -4; +pub const RC_PANIC: i32 = -5; +pub const RC_AUTH_DENIED: i32 = -6; + +// ── Invoke-path helpers ────────────────────────────────────────────── + +/// True if any of the three mandatory `boj_cartridge_invoke` output-path +/// pointers is null. Use at the top of every invoke to short-circuit to +/// `RC_BAD_ARGS`. +pub fn invokeArgsNull( + tool_name: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) bool { + return tool_name == null or out_buf == null or in_out_len == null; +} + +/// Compare a C-NUL-terminated tool-name pointer against a Zig string +/// literal. Caller must have already verified `tool_name` is non-null +/// (usually via `invokeArgsNull`). +pub fn toolIs(tool_name: [*c]const u8, expected: []const u8) bool { + const s = std.mem.span(@as([*:0]const u8, @ptrCast(tool_name))); + return std.mem.eql(u8, s, expected); +} + +/// Copy `body` into `out_buf[0..*in_out_len]` (as a capacity) and update +/// `*in_out_len` to the number of bytes written. Returns `RC_SUCCESS`. +/// +/// If `body.len` exceeds the current capacity stored in `*in_out_len`, +/// sets `*in_out_len` to the required size and returns +/// `RC_BUFFER_TOO_SMALL` β€” the caller is then expected to re-allocate +/// and retry, per ADR-0006 Β§Memory ownership. +/// +/// Caller must have already verified that `out_buf` and `in_out_len` +/// are non-null. +pub fn writeResult( + out_buf: [*c]u8, + in_out_len: [*c]usize, + body: []const u8, +) i32 { + const cap = in_out_len.*; + if (body.len > cap) { + in_out_len.* = body.len; + return RC_BUFFER_TOO_SMALL; + } + @memcpy(out_buf[0..body.len], body); + in_out_len.* = body.len; + return RC_SUCCESS; +} + +// ── Tests ──────────────────────────────────────────────────────────── + +test "writeResult: body fits, writes and sets length" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); + try std.testing.expectEqualStrings("hello", buf[0..len]); +} + +test "writeResult: too small returns -3 and sets required length" { + var buf: [2]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_BUFFER_TOO_SMALL, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: exact-fit succeeds" { + var buf: [5]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: empty body" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, ""); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 0), len); +} + +test "toolIs: matches and rejects" { + const name: [*:0]const u8 = "foo"; + try std.testing.expect(toolIs(@ptrCast(name), "foo")); + try std.testing.expect(!toolIs(@ptrCast(name), "bar")); + try std.testing.expect(!toolIs(@ptrCast(name), "foobar")); + try std.testing.expect(!toolIs(@ptrCast(name), "fo")); +} + +test "invokeArgsNull: detects each null slot" { + var buf: [4]u8 = undefined; + var len: usize = 4; + const name: [*:0]const u8 = "x"; + try std.testing.expect(!invokeArgsNull(@ptrCast(name), &buf, &len)); + try std.testing.expect(invokeArgsNull(null, &buf, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), null, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), &buf, null)); +} diff --git a/cartridges/domains/automation/browser-mcp/minter.toml b/cartridges/domains/automation/browser-mcp/minter.toml new file mode 100644 index 0000000..8fe282d --- /dev/null +++ b/cartridges/domains/automation/browser-mcp/minter.toml @@ -0,0 +1,11 @@ +name = "browser-mcp" +description = "Firefox browser automation via the Marionette protocol (TCP localhost:2828)" +version = "0.1.0" +domain = "Browser" +protocols = [ + "MCP", + "REST", +] +tier = "Ayo" +backend = "universal" +generate_panel = true diff --git a/cartridges/domains/automation/browser-mcp/mod.js b/cartridges/domains/automation/browser-mcp/mod.js new file mode 100644 index 0000000..1426155 --- /dev/null +++ b/cartridges/domains/automation/browser-mcp/mod.js @@ -0,0 +1,65 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// browser-mcp/mod.js β€” Firefox browser automation via Marionette +// +// Delegates to backend at http://127.0.0.1:7712 (override with BROWSER_BACKEND_URL). + +const BASE_URL = Deno.env.get("BROWSER_BACKEND_URL") ?? "http://127.0.0.1:7712"; +const TIMEOUT_MS = 15_000; + +async function post(path, payload) { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), TIMEOUT_MS); + try { + const r = await fetch(`${BASE_URL}${path}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + signal: ctrl.signal, + }); + const data = await r.json().catch(() => ({ success: false, error: "non-JSON response" })); + return { status: r.status, data }; + } catch (e) { + if (e.name === "AbortError") return { status: 504, data: { success: false, error: "browser-mcp backend timed out" } }; + return { status: 503, data: { success: false, error: `browser-mcp backend unavailable: ${e.message}` } }; + } finally { clearTimeout(t); } +} + +async function get(path) { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), TIMEOUT_MS); + try { + const r = await fetch(`${BASE_URL}${path}`, { method: "GET", signal: ctrl.signal }); + const data = await r.json().catch(() => ({ success: false, error: "non-JSON response" })); + return { status: r.status, data }; + } catch (e) { + if (e.name === "AbortError") return { status: 504, data: { success: false, error: "browser-mcp backend timed out" } }; + return { status: 503, data: { success: false, error: `browser-mcp backend unavailable: ${e.message}` } }; + } finally { clearTimeout(t); } +} + +export async function handleTool(toolName, args) { + switch (toolName) { + case "browser_open": + return post("/api/v1/browser_open", args ?? {}); + case "browser_close": + return post("/api/v1/browser_close", args ?? {}); + case "browser_connect": + return post("/api/v1/browser_connect", args ?? {}); + case "browser_navigate": + return post("/api/v1/browser_navigate", args ?? {}); + case "browser_click": + return post("/api/v1/browser_click", args ?? {}); + case "browser_type": + return post("/api/v1/browser_type", args ?? {}); + case "browser_screenshot": + return post("/api/v1/browser_screenshot", args ?? {}); + case "browser_read_page": + return post("/api/v1/browser_read_page", args ?? {}); + case "browser_tab_list": + return post("/api/v1/browser_tab_list", args ?? {}); + default: + return { status: 404, data: { error: `Unknown tool: ${toolName}` } }; + } +} diff --git a/cartridges/domains/automation/browser-mcp/panels/manifest.json b/cartridges/domains/automation/browser-mcp/panels/manifest.json new file mode 100644 index 0000000..dda955f --- /dev/null +++ b/cartridges/domains/automation/browser-mcp/panels/manifest.json @@ -0,0 +1,127 @@ +{ + "$schema": "https://panll.dev/schemas/panel-manifest/v1.json", + "name": "browser-mcp", + "version": "0.1.0", + "title": "Browser Automation", + "description": "Firefox browser automation via Marionette protocol β€” monitor connection state, tabs, and page info.", + "license": "MPL-2.0", + "author": "Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk>", + "data_sources": [ + { + "id": "node_health", + "label": "BoJ Node Health", + "type": "boolean", + "endpoint": "/health", + "poll_interval_ms": 60000 + }, + { + "id": "node_uptime", + "label": "Node Uptime", + "type": "integer", + "endpoint": "/health", + "poll_interval_ms": 60000 + }, + { + "id": "connection_status", + "label": "Connection Status", + "type": "enum", + "values": ["closed", "connecting", "connected", "navigating", "error"], + "endpoint": "/session/{slot}/state", + "poll_interval_ms": 2000 + }, + { + "id": "current_url", + "label": "Current URL", + "type": "string", + "endpoint": "/session/{slot}/read_page", + "poll_interval_ms": 5000 + }, + { + "id": "tab_count", + "label": "Tab Count", + "type": "integer", + "endpoint": "/session/{slot}/tabs", + "poll_interval_ms": 5000 + }, + { + "id": "page_title", + "label": "Page Title", + "type": "string", + "endpoint": "/session/{slot}/state", + "poll_interval_ms": 5000 + }, + { + "id": "screenshot_thumbnail", + "label": "Screenshot Thumbnail", + "type": "image/png", + "endpoint": "/session/{slot}/screenshot", + "poll_interval_ms": 30000 + } + ], + "widgets": [ + { + "id": "node_health_status", + "type": "status-badge", + "title": "BoJ Node", + "data_source": "node_health", + "style": { + "true": "success", + "false": "danger" + }, + "position": { "row": 0, "col": 0, "width": 1, "height": 1 } + }, + { + "id": "node_uptime_display", + "type": "numeric-display", + "title": "Node Uptime", + "data_source": "node_uptime", + "icon": "timer", + "position": { "row": 0, "col": 1, "width": 1, "height": 1 } + }, + { + "id": "status_indicator", + "type": "status-badge", + "title": "Marionette Connection", + "data_source": "connection_status", + "style": { + "connected": "success", + "connecting": "warning", + "navigating": "info", + "closed": "neutral", + "error": "danger" + }, + "position": { "row": 0, "col": 0, "width": 2, "height": 1 } + }, + { + "id": "url_display", + "type": "text-display", + "title": "Current URL", + "data_source": "current_url", + "truncate": 80, + "position": { "row": 0, "col": 2, "width": 4, "height": 1 } + }, + { + "id": "tab_counter", + "type": "numeric-display", + "title": "Open Tabs", + "data_source": "tab_count", + "icon": "tabs", + "position": { "row": 1, "col": 0, "width": 1, "height": 1 } + }, + { + "id": "page_title_display", + "type": "text-display", + "title": "Page Title", + "data_source": "page_title", + "truncate": 60, + "position": { "row": 1, "col": 1, "width": 3, "height": 1 } + }, + { + "id": "screenshot_preview", + "type": "image-thumbnail", + "title": "Screenshot Preview", + "data_source": "screenshot_thumbnail", + "position": { "row": 2, "col": 0, "width": 6, "height": 3 } + } + ] +} diff --git a/cartridges/domains/automation/browser-mcp/tests/integration_test.sh b/cartridges/domains/automation/browser-mcp/tests/integration_test.sh new file mode 100755 index 0000000..241c0c0 --- /dev/null +++ b/cartridges/domains/automation/browser-mcp/tests/integration_test.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Integration tests for browser-mcp cartridge. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CART_DIR="$(dirname "$SCRIPT_DIR")" +FFI_DIR="$CART_DIR/ffi" + +echo "=== browser-mcp integration tests ===" + +# Build FFI +echo "[1/3] Building FFI..." +cd "$FFI_DIR" && zig build 2>&1 + +# Run Zig unit tests +echo "[2/3] Running FFI unit tests..." +cd "$FFI_DIR" && zig build test 2>&1 + +# Validate Idris2 ABI (if idris2 available) +echo "[3/3] Checking ABI..." +if command -v idris2 &>/dev/null; then + cd "$CART_DIR/abi" && idris2 --check BrowserMcp.SafeBrowser 2>&1 + echo " ABI: OK" +else + echo " ABI: SKIPPED (idris2 not in PATH)" +fi + +echo "" +echo "All tests passed for browser-mcp!" diff --git a/cartridges/domains/bioinformatics/origenemcp/README.adoc b/cartridges/domains/bioinformatics/origenemcp/README.adoc new file mode 100644 index 0000000..8835451 --- /dev/null +++ b/cartridges/domains/bioinformatics/origenemcp/README.adoc @@ -0,0 +1,73 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += origenemcp +Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk> +:spdx: MPL-2.0 +:tier: Ayo +:domain: Bioinformatics +:protocols: MCP, REST + +== Overview + +Biomedical MCP Server Platform. Integrates 600+ tools and databases (ChEMBL, PubChem, FDA, OpenTargets, NCBI, UniProt, PDB, Ensembl, UCSC, KEGG, STRING, TCGA, Monarch, ClinicalTrials) for multi-dimensional biomedical information retrieval. + +== Tools (5) + +[cols="2,4"] +|=== +| Tool | Description + +| `origenemcp_get_gene_info` | Get general information about a gene (e.g., TP53) from UniProt. +| `origenemcp_search_compound` | Search for a compound in ChEMBL or PubChem. +| `origenemcp_get_disease_info` | Get information about a disease from OpenTargets or Monarch. +| `origenemcp_search_clinical_trials` | Search for clinical trials related to a disease or compound. +| `origenemcp_get_protein_structure` | Get protein structure from PDB. +|=== + +== Architecture + +[cols="1,1,3"] +|=== +| Layer | Language | Purpose + +| ABI | Idris2 | Formally verified state machine with dependent-type proofs +| FFI | Zig | C-ABI implementation: Biomedical API integration, data retrieval, caching +| Adapter| Zig | Adapter bridge to the BoJ unified adapter protocol +|=== + +== Building + +[source,bash] +---- +# ABI (Idris2) +cd abi && idris2 --check *.idr + +# FFI (Zig) +cd ffi && zig build +cd ffi && zig build test + +# Adapter (Zig) +cd adapter && zig build +---- + +== Status + +Alpha (CRG D). Cartridge metadata declared in `cartridge.json`; 5 tool(s) exposed. +Not yet dogfooded externally. See `docs/READINESS.md` at repo root for the +project-wide grade table and `docs/practice/DOGFOOD-LOG.adoc` for usage evidence. + +== Ports + +Allowed:: 8788 (OrigeneMCP default port) +Denied:: 22 (SSH), 23 (Telnet), 25 (SMTP), 135 (RPC), 139 (NetBIOS), 445 (SMB) + +== Authentication + +Method:: API Key +Env Var:: ORIGENE_API_KEY +Source:: User-provided + +== References + +- [OrigeneMCP GitHub](https://github.com/GENTEL-lab/OrigeneMCP) +- [OriGene Paper](https://www.biorxiv.org/content/10.1101/2025.07.24.550247v1) diff --git a/cartridges/domains/bioinformatics/origenemcp/abi/README.adoc b/cartridges/domains/bioinformatics/origenemcp/abi/README.adoc new file mode 100644 index 0000000..e69f857 --- /dev/null +++ b/cartridges/domains/bioinformatics/origenemcp/abi/README.adoc @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) += origenemcp / abi β€” Idris2 ABI layer +:orientation: deep + +== Purpose + +Encodes the origenemcp connection state machine and the MCP tool catalogue. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| origenemcp.ipkg | Idris2 package descriptor. +| origenemcp/Safeorigenemcp.idr | State machine and tool definitions. +|=== + +== Invariants + +* at module top. +* Zero . + +== Test/proof surface + +Type-check only β€” + Error loading file "origenemcp.ipkg": File Not Found. + +== Read-first + +. Safeorigenemcp.idr β€” state machine and tool definitions. + diff --git a/cartridges/domains/bioinformatics/origenemcp/adapter/README.adoc b/cartridges/domains/bioinformatics/origenemcp/adapter/README.adoc new file mode 100644 index 0000000..8735d6b --- /dev/null +++ b/cartridges/domains/bioinformatics/origenemcp/adapter/README.adoc @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) += origenemcp / adapter β€” REST/gRPC/GraphQL bridge +:orientation: deep + +== Purpose + +Exposes the FFI layer over three protocols. Stateless β€” all state lives behind the FFI mutex. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| build.zig | Zig build graph. +| origenemcp_adapter.zig | Three-protocol dispatcher. +|=== + +== Invariants + +* Stateless β€” no global mutable state. +* One tool call per HTTP request. +* JSON content type for all responses. + +== Test/proof surface + +No inline tests β€” FFI tests cover correctness. Integration tested via cartridge-matrix tests. + +== Read-first + +. origenemcp_adapter.zig β€” tool-name β†’ FFI-call mapping. diff --git a/cartridges/domains/bioinformatics/origenemcp/cartridge.json b/cartridges/domains/bioinformatics/origenemcp/cartridge.json new file mode 100644 index 0000000..f5e2756 --- /dev/null +++ b/cartridges/domains/bioinformatics/origenemcp/cartridge.json @@ -0,0 +1,153 @@ +{ + "$schema": "https://boj.dev/schemas/cartridge/v1.json", + "spdx": "MPL-2.0", + "copyright": "Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath)", + "name": "origenemcp", + "version": "0.1.0", + "description": "Biomedical MCP Server Platform. Integrates 600+ tools and databases (ChEMBL, PubChem, FDA, OpenTargets, NCBI, UniProt, PDB, Ensembl, UCSC, KEGG, STRING, TCGA, Monarch, ClinicalTrials) for multi-dimensional biomedical information retrieval.", + "domain": "Bioinformatics", + "tier": "Ayo", + "protocols": [ + "MCP", + "REST" + ], + "auth": { + "method": "api-key", + "env_var": "ORIGENE_API_KEY", + "credential_source": "user-provided" + }, + "api": { + "base_url": "local://origenemcp", + "content_type": "application/json" + }, + "ports": { + "allowed": [ + 8788 + ], + "denied": [ + 22, + 23, + 25, + 135, + 139, + 445 + ] + }, + "tools": [ + { + "name": "origenemcp_get_gene_info", + "description": "Get general information about a gene (e.g., TP53) from UniProt.", + "inputSchema": { + "type": "object", + "properties": { + "gene_name": { + "type": "string", + "description": "Name of the gene (e.g., TP53)" + }, + "species": { + "type": "string", + "description": "Species name (e.g., Homo sapiens)" + } + }, + "required": [ + "gene_name" + ] + } + }, + { + "name": "origenemcp_search_compound", + "description": "Search for a compound in ChEMBL or PubChem.", + "inputSchema": { + "type": "object", + "properties": { + "compound_name": { + "type": "string", + "description": "Name or ID of the compound" + }, + "database": { + "type": "string", + "enum": [ + "chembl", + "pubchem" + ], + "description": "Database to search (default: chembl)" + } + }, + "required": [ + "compound_name" + ] + } + }, + { + "name": "origenemcp_get_disease_info", + "description": "Get information about a disease from OpenTargets or Monarch.", + "inputSchema": { + "type": "object", + "properties": { + "disease_name": { + "type": "string", + "description": "Name of the disease" + }, + "database": { + "type": "string", + "enum": [ + "opentargets", + "monarch" + ], + "description": "Database to search (default: opentargets)" + } + }, + "required": [ + "disease_name" + ] + } + }, + { + "name": "origenemcp_search_clinical_trials", + "description": "Search for clinical trials related to a disease or compound.", + "inputSchema": { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "Disease name, compound name, or other keywords" + }, + "status": { + "type": "string", + "description": "Trial status (e.g., recruiting, completed)" + } + }, + "required": [ + "query" + ] + } + }, + { + "name": "origenemcp_get_protein_structure", + "description": "Get protein structure from PDB.", + "inputSchema": { + "type": "object", + "properties": { + "pdb_id": { + "type": "string", + "description": "PDB ID of the protein" + } + }, + "required": [ + "pdb_id" + ] + } + } + ], + "ffi": { + "so_path": "ffi/zig-out/lib/liborigenemcp.so", + "abi_version": "ADR-0006", + "symbols": [ + "boj_cartridge_init", + "boj_cartridge_deinit", + "boj_cartridge_name", + "boj_cartridge_version", + "boj_cartridge_invoke" + ] + } +} diff --git a/cartridges/domains/bioinformatics/origenemcp/ffi/README.adoc b/cartridges/domains/bioinformatics/origenemcp/ffi/README.adoc new file mode 100644 index 0000000..33a070a --- /dev/null +++ b/cartridges/domains/bioinformatics/origenemcp/ffi/README.adoc @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) += origenemcp / ffi β€” Zig FFI layer +:orientation: deep + +== Purpose + +Zig implementation of the connection state machine from . + +== Files + +[cols="1,4"] +|=== +| File | Role + +| build.zig | Zig build graph. +| origenemcp_ffi.zig | FFI implementation. +|=== + +== Invariants + +* Mutex discipline for all shared state. +* Bounds checks before dereference. +* State-machine mirror of ABI. + +== Test/proof surface + +Inline blocks in origenemcp_ffi.zig. Run with . + +== Read-first + +. origenemcp_ffi.zig β€” FFI exports and guards. + diff --git a/cartridges/domains/bioinformatics/origenemcp/ffi/build.zig b/cartridges/domains/bioinformatics/origenemcp/ffi/build.zig new file mode 100644 index 0000000..2f2c727 --- /dev/null +++ b/cartridges/domains/bioinformatics/origenemcp/ffi/build.zig @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const ffi_mod = b.addModule("origenemcp", .{ + .root_source_file = b.path("origenemcp_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + const lib = b.addLibrary(.{ + .name = "origenemcp", + .root_module = ffi_mod, + .linkage = .dynamic, + }); + lib.linkLibC(); + b.installArtifact(lib); + + const tests = b.addTest(.{ .root_module = ffi_mod }); + tests.linkLibC(); + const run_tests = b.addRunArtifact(tests); + const test_step = b.step("test", "Run FFI tests"); + test_step.dependOn(&run_tests.step); +} diff --git a/cartridges/domains/bioinformatics/origenemcp/ffi/cartridge_shim.zig b/cartridges/domains/bioinformatics/origenemcp/ffi/cartridge_shim.zig new file mode 100644 index 0000000..0e399db --- /dev/null +++ b/cartridges/domains/bioinformatics/origenemcp/ffi/cartridge_shim.zig @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// cartridge_shim.zig β€” Shared helpers for the ADR-0006 five-symbol +// cartridge ABI (`boj_cartridge_init / deinit / name / version / invoke`). +// +// The shim centralises the seven-code return convention, NUL-argument +// guards, tool-name comparison, and the buffer-too-small path so each +// cartridge's `boj_cartridge_invoke` can stay short β€” typically a tool +// table plus `shim.writeResult(...)`. +// +// Cartridges import this file by relative path (no build-graph change +// needed). Example: +// +// const shim = @import("../../../ffi/zig/src/cartridge_shim.zig"); +// +// export fn boj_cartridge_invoke( +// tool_name: [*c]const u8, +// json_args: [*c]const u8, +// out_buf: [*c]u8, +// in_out_len: [*c]usize, +// ) callconv(.c) i32 { +// _ = json_args; +// if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; +// const body = if (shim.toolIs(tool_name, "foo")) "{\"result\":{}}" +// else return shim.RC_UNKNOWN_TOOL; +// return shim.writeResult(out_buf, in_out_len, body); +// } + +const std = @import("std"); + +// ── Return codes (ADR-0006 Β§Return codes) ──────────────────────────── +// +// Frozen by ADR-0006. New failure modes compose these via the error +// JSON body β€” the integer surface does not grow without a follow-up ADR. + +pub const RC_SUCCESS: i32 = 0; +pub const RC_UNKNOWN_TOOL: i32 = -1; +pub const RC_BAD_ARGS: i32 = -2; +pub const RC_BUFFER_TOO_SMALL: i32 = -3; +pub const RC_RUNTIME_ERROR: i32 = -4; +pub const RC_PANIC: i32 = -5; +pub const RC_AUTH_DENIED: i32 = -6; + +// ── Invoke-path helpers ────────────────────────────────────────────── + +/// True if any of the three mandatory `boj_cartridge_invoke` output-path +/// pointers is null. Use at the top of every invoke to short-circuit to +/// `RC_BAD_ARGS`. +pub fn invokeArgsNull( + tool_name: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) bool { + return tool_name == null or out_buf == null or in_out_len == null; +} + +/// Compare a C-NUL-terminated tool-name pointer against a Zig string +/// literal. Caller must have already verified `tool_name` is non-null +/// (usually via `invokeArgsNull`). +pub fn toolIs(tool_name: [*c]const u8, expected: []const u8) bool { + const s = std.mem.span(@as([*:0]const u8, @ptrCast(tool_name))); + return std.mem.eql(u8, s, expected); +} + +/// Copy `body` into `out_buf[0..*in_out_len]` (as a capacity) and update +/// `*in_out_len` to the number of bytes written. Returns `RC_SUCCESS`. +/// +/// If `body.len` exceeds the current capacity stored in `*in_out_len`, +/// sets `*in_out_len` to the required size and returns +/// `RC_BUFFER_TOO_SMALL` β€” the caller is then expected to re-allocate +/// and retry, per ADR-0006 Β§Memory ownership. +/// +/// Caller must have already verified that `out_buf` and `in_out_len` +/// are non-null. +pub fn writeResult( + out_buf: [*c]u8, + in_out_len: [*c]usize, + body: []const u8, +) i32 { + const cap = in_out_len.*; + if (body.len > cap) { + in_out_len.* = body.len; + return RC_BUFFER_TOO_SMALL; + } + @memcpy(out_buf[0..body.len], body); + in_out_len.* = body.len; + return RC_SUCCESS; +} + +// ── Tests ──────────────────────────────────────────────────────────── + +test "writeResult: body fits, writes and sets length" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); + try std.testing.expectEqualStrings("hello", buf[0..len]); +} + +test "writeResult: too small returns -3 and sets required length" { + var buf: [2]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_BUFFER_TOO_SMALL, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: exact-fit succeeds" { + var buf: [5]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: empty body" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, ""); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 0), len); +} + +test "toolIs: matches and rejects" { + const name: [*:0]const u8 = "foo"; + try std.testing.expect(toolIs(@ptrCast(name), "foo")); + try std.testing.expect(!toolIs(@ptrCast(name), "bar")); + try std.testing.expect(!toolIs(@ptrCast(name), "foobar")); + try std.testing.expect(!toolIs(@ptrCast(name), "fo")); +} + +test "invokeArgsNull: detects each null slot" { + var buf: [4]u8 = undefined; + var len: usize = 4; + const name: [*:0]const u8 = "x"; + try std.testing.expect(!invokeArgsNull(@ptrCast(name), &buf, &len)); + try std.testing.expect(invokeArgsNull(null, &buf, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), null, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), &buf, null)); +} diff --git a/cartridges/domains/bioinformatics/origenemcp/ffi/origenemcp_ffi.zig b/cartridges/domains/bioinformatics/origenemcp/ffi/origenemcp_ffi.zig new file mode 100644 index 0000000..49bbd76 --- /dev/null +++ b/cartridges/domains/bioinformatics/origenemcp/ffi/origenemcp_ffi.zig @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// origenemcp FFI β€” ADR-0006 five-symbol cartridge ABI implementation. + +const std = @import("std"); +const shim = @import("cartridge_shim.zig"); + +const CARTRIDGE_NAME_PTR: [*:0]const u8 = "origenemcp"; +const CARTRIDGE_VERSION_PTR: [*:0]const u8 = "0.1.0"; + +export fn boj_cartridge_init() callconv(.c) c_int { + return 0; +} + +export fn boj_cartridge_deinit() callconv(.c) void {} + +export fn boj_cartridge_name() callconv(.c) [*:0]const u8 { + return CARTRIDGE_NAME_PTR; +} + +export fn boj_cartridge_version() callconv(.c) [*:0]const u8 { + return CARTRIDGE_VERSION_PTR; +} + +export fn boj_cartridge_invoke( + tool_name: [*c]const u8, + json_args: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) callconv(.c) i32 { + _ = json_args; + if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; + const body: []const u8 = if (shim.toolIs(tool_name, "origenemcp_get_gene_info")) + "{\"result\":{}}" + else if (shim.toolIs(tool_name, "origenemcp_search_compound")) + "{\"result\":{}}" + else if (shim.toolIs(tool_name, "origenemcp_get_disease_info")) + "{\"result\":{}}" + else if (shim.toolIs(tool_name, "origenemcp_search_clinical_trials")) + "{\"result\":{}}" + else if (shim.toolIs(tool_name, "origenemcp_get_protein_structure")) + "{\"result\":{}}" + else + return shim.RC_UNKNOWN_TOOL; + + return shim.writeResult(out_buf, in_out_len, body); +} + +test "boj_cartridge_name returns origenemcp" { + try std.testing.expectEqualStrings("origenemcp", std.mem.span(boj_cartridge_name())); +} + +test "boj_cartridge_init returns 0" { + try std.testing.expectEqual(@as(c_int, 0), boj_cartridge_init()); +} + +test "invoke unknown tool returns RC_UNKNOWN_TOOL" { + var buf: [256]u8 = undefined; + var len: usize = buf.len; + try std.testing.expectEqual(@as(i32, shim.RC_UNKNOWN_TOOL), boj_cartridge_invoke("unknown_xyz", "{}", &buf, &len)); +} + +test "invoke origenemcp_get_gene_info returns 0" { + var buf: [256]u8 = undefined; + var len: usize = buf.len; + try std.testing.expectEqual(@as(i32, 0), boj_cartridge_invoke("origenemcp_get_gene_info", "{}", &buf, &len)); +} diff --git a/cartridges/domains/bioinformatics/origenemcp/mod.js b/cartridges/domains/bioinformatics/origenemcp/mod.js new file mode 100644 index 0000000..a4b0047 --- /dev/null +++ b/cartridges/domains/bioinformatics/origenemcp/mod.js @@ -0,0 +1,89 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// origenemcp/mod.js β€” OrigenesMCP biomedical research cartridge. +// +// Delegates to backend at http://127.0.0.1:8788 (override with ORIGENE_URL). +// Auth: ORIGENE_API_KEY (required for all operations). +// Integrates UniProt, ChEMBL, PubChem, OpenTargets, Monarch, PDB, ClinicalTrials. + +const BASE_URL = Deno.env.get("ORIGENE_URL") ?? "http://127.0.0.1:8788"; +const TIMEOUT_MS = 30_000; // biomedical DB queries can be slow + +function getKey() { + return Deno.env.get("ORIGENE_API_KEY") ?? null; +} + +function authHeaders() { + const key = getKey(); + if (!key) return null; + return { "Content-Type": "application/json", "X-API-Key": key }; +} + +async function post(path, payload) { + const headers = authHeaders(); + if (!headers) + return { status: 401, data: { success: false, error: "ORIGENE_API_KEY env var is required" } }; + + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), TIMEOUT_MS); + try { + const r = await fetch(`${BASE_URL}${path}`, { + method: "POST", + headers, + body: JSON.stringify(payload), + signal: ctrl.signal, + }); + const data = await r.json().catch(() => ({ success: false, error: "non-JSON response" })); + return { status: r.status, data }; + } catch (e) { + if (e.name === "AbortError") + return { status: 504, data: { success: false, error: "origenemcp backend timed out" } }; + return { status: 503, data: { success: false, error: `origenemcp backend unavailable: ${e.message}` } }; + } finally { clearTimeout(t); } +} + +export async function handleTool(toolName, args) { + switch (toolName) { + case "origenemcp_get_gene_info": { + const { gene_name, species } = args ?? {}; + if (!gene_name) return { status: 400, data: { error: "gene_name is required" } }; + const payload = { gene_name }; + if (species !== undefined) payload.species = species; + return post("/api/v1/gene/info", payload); + } + + case "origenemcp_search_compound": { + const { compound_name, database } = args ?? {}; + if (!compound_name) return { status: 400, data: { error: "compound_name is required" } }; + const payload = { compound_name }; + if (database !== undefined) payload.database = database; + return post("/api/v1/compound/search", payload); + } + + case "origenemcp_get_disease_info": { + const { disease_name, database } = args ?? {}; + if (!disease_name) return { status: 400, data: { error: "disease_name is required" } }; + const payload = { disease_name }; + if (database !== undefined) payload.database = database; + return post("/api/v1/disease/info", payload); + } + + case "origenemcp_search_clinical_trials": { + const { query, status } = args ?? {}; + if (!query) return { status: 400, data: { error: "query is required" } }; + const payload = { query }; + if (status !== undefined) payload.status = status; + return post("/api/v1/trials/search", payload); + } + + case "origenemcp_get_protein_structure": { + const { pdb_id } = args ?? {}; + if (!pdb_id) return { status: 400, data: { error: "pdb_id is required" } }; + return post("/api/v1/protein/structure", { pdb_id }); + } + + default: + return { status: 404, data: { error: `Unknown tool: ${toolName}` } }; + } +} diff --git a/cartridges/domains/ci-cd/buildkite-mcp/README.adoc b/cartridges/domains/ci-cd/buildkite-mcp/README.adoc new file mode 100644 index 0000000..70cd80a --- /dev/null +++ b/cartridges/domains/ci-cd/buildkite-mcp/README.adoc @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += buildkite-mcp +Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk> +:spdx: MPL-2.0 +:tier: Ayo +:domain: CI/CD +:protocols: MCP, REST + +== Overview + +Buildkite CI/CD cartridge for the BoJ server. Provides type-safe access to +the Buildkite REST API v2 for pipeline listing, build management, job inspection, +artifact retrieval, agent listing, build triggering, log retrieval, and cancellation. + +=== Actions (10) + +[cols="1,1"] +|=== +| Category | Actions + +| Pipelines +| ListPipelines, GetPipeline + +| Builds +| ListBuilds, GetBuild, CreateBuild, CancelBuild, ListJobs, GetJobLog, ListArtifacts + +| Agents +| ListAgents +|=== + +== Architecture + +[cols="1,1,2"] +|=== +| Layer | Language | Purpose + +| ABI | Idris2 | Formally verified state machine (BuildkiteMcp.SafeRegistry) +| FFI | Zig | Thread-safe session pool, action recording +| Adapter | zig | REST bridge to BoJ unified adapter protocol +|=== + +== Building + +[source,bash] +---- +cd ffi && zig build +cd ffi && zig build test +cd abi && idris2 --check BuildkiteMcp.SafeRegistry +---- + +== Status + +Development -- Tier 6 CI/CD cartridge with full Buildkite pipeline and build management. diff --git a/cartridges/domains/ci-cd/buildkite-mcp/abi/BuildkiteMcp/SafeRegistry.idr b/cartridges/domains/ci-cd/buildkite-mcp/abi/BuildkiteMcp/SafeRegistry.idr new file mode 100644 index 0000000..6188036 --- /dev/null +++ b/cartridges/domains/ci-cd/buildkite-mcp/abi/BuildkiteMcp/SafeRegistry.idr @@ -0,0 +1,153 @@ +-- SPDX-License-Identifier: MPL-2.0 +-- Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +-- +-- BuildkiteMcp.SafeRegistry β€” Type-safe ABI for buildkite-mcp cartridge. +-- +-- Dependent-type state machine governing Buildkite REST API v2 access. +-- Encodes Bearer token auth, pipeline listing, build management, +-- job inspection, artifact retrieval, agent listing, build triggering, +-- log retrieval, and cancellation as compile-time invariants. +-- API: https://buildkite.com/docs/apis/rest-api +-- No unsafe escape hatches. + +module BuildkiteMcp.SafeRegistry + +%default total + +-- --------------------------------------------------------------------------- +-- Authentication state machine +-- --------------------------------------------------------------------------- + +public export +data SessionState + = Unauthenticated + | Authenticated + | RateLimited + | Error + +public export +data ValidTransition : SessionState -> SessionState -> Type where + Authenticate : ValidTransition Unauthenticated Authenticated + Deauthenticate : ValidTransition Authenticated Unauthenticated + Throttle : ValidTransition Authenticated RateLimited + Unthrottle : ValidTransition RateLimited Authenticated + AuthError : ValidTransition Authenticated Error + AnonError : ValidTransition Unauthenticated Error + RecoverToAuth : ValidTransition Error Authenticated + RecoverToAnon : ValidTransition Error Unauthenticated + +-- --------------------------------------------------------------------------- +-- C-ABI integer encoding +-- --------------------------------------------------------------------------- + +export +sessionStateToInt : SessionState -> Int +sessionStateToInt Unauthenticated = 0 +sessionStateToInt Authenticated = 1 +sessionStateToInt RateLimited = 2 +sessionStateToInt Error = 3 + +export +intToSessionState : Int -> Maybe SessionState +intToSessionState 0 = Just Unauthenticated +intToSessionState 1 = Just Authenticated +intToSessionState 2 = Just RateLimited +intToSessionState 3 = Just Error +intToSessionState _ = Nothing + +export +buildkite_mcp_can_transition : Int -> Int -> Int +buildkite_mcp_can_transition from to = + case (intToSessionState from, intToSessionState to) of + (Just Unauthenticated, Just Authenticated) => 1 + (Just Authenticated, Just Unauthenticated) => 1 + (Just Authenticated, Just RateLimited) => 1 + (Just RateLimited, Just Authenticated) => 1 + (Just Authenticated, Just Error) => 1 + (Just Unauthenticated, Just Error) => 1 + (Just Error, Just Authenticated) => 1 + (Just Error, Just Unauthenticated) => 1 + _ => 0 + +-- --------------------------------------------------------------------------- +-- Buildkite actions +-- --------------------------------------------------------------------------- + +public export +data BuildkiteAction + = ListPipelines + | GetPipeline + | ListBuilds + | GetBuild + | CreateBuild + | CancelBuild + | ListJobs + | GetJobLog + | ListArtifacts + | ListAgents + +export +actionRequiresAuth : BuildkiteAction -> Bool +actionRequiresAuth _ = True + +export +actionIsMutating : BuildkiteAction -> Bool +actionIsMutating CreateBuild = True +actionIsMutating CancelBuild = True +actionIsMutating _ = False + +export +actionToInt : BuildkiteAction -> Int +actionToInt ListPipelines = 0 +actionToInt GetPipeline = 1 +actionToInt ListBuilds = 2 +actionToInt GetBuild = 3 +actionToInt CreateBuild = 4 +actionToInt CancelBuild = 5 +actionToInt ListJobs = 6 +actionToInt GetJobLog = 7 +actionToInt ListArtifacts = 8 +actionToInt ListAgents = 9 + +export +intToAction : Int -> Maybe BuildkiteAction +intToAction 0 = Just ListPipelines +intToAction 1 = Just GetPipeline +intToAction 2 = Just ListBuilds +intToAction 3 = Just GetBuild +intToAction 4 = Just CreateBuild +intToAction 5 = Just CancelBuild +intToAction 6 = Just ListJobs +intToAction 7 = Just GetJobLog +intToAction 8 = Just ListArtifacts +intToAction 9 = Just ListAgents +intToAction _ = Nothing + +-- --------------------------------------------------------------------------- +-- MCP tool declarations +-- --------------------------------------------------------------------------- + +public export +data McpTool + = ToolListPipelines + | ToolGetPipeline + | ToolListBuilds + | ToolGetBuild + | ToolCreateBuild + | ToolCancelBuild + | ToolListJobs + | ToolGetJobLog + | ToolListArtifacts + | ToolListAgents + +export +toolRequiresSession : McpTool -> Bool +toolRequiresSession _ = True + +export +toolCount : Nat +toolCount = 10 + +export +actionCount : Nat +actionCount = 10 diff --git a/cartridges/domains/ci-cd/buildkite-mcp/abi/README.adoc b/cartridges/domains/ci-cd/buildkite-mcp/abi/README.adoc new file mode 100644 index 0000000..13662cd --- /dev/null +++ b/cartridges/domains/ci-cd/buildkite-mcp/abi/README.adoc @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += buildkite-mcp / abi β€” Idris2 ABI layer +:orientation: shallow + +== Purpose + +Cartridge-local protocol types for `buildkite-mcp`. Defines the data, tool, and +transition vocabulary this cartridge speaks. Inherits safety lemmas and the +matrix-cell identifier scheme from the trunk ABI at `src/abi/`. + +== Files + +1 Idris2 module(s), ~153 lines total. Lead module: +`SafeRegistry.idr`. + +No `.ipkg` β€” consumed directly by wider BoJ callers; type-checks inside the +trunk `boj.ipkg`. + +== Invariants + +* `%default total` at every module top. +* Zero `believe_me`, `assert_total`, `postulate`, `Admitted`, or + `sorry` (the trunk holds the four declared `believe_me` sites; leaf + cartridges carry none). + +== Test/proof surface + +Thin module(s), no inline tests. Proof surface = type-check success under the +trunk `idris2 --check`. + +== Read-first + +. `SafeRegistry.idr` β€” cartridge-local data records and enums. +. Trunk `src/abi/Boj/Catalogue.idr` for how this cell participates in the + 2D matrix. diff --git a/cartridges/domains/ci-cd/buildkite-mcp/adapter/README.adoc b/cartridges/domains/ci-cd/buildkite-mcp/adapter/README.adoc new file mode 100644 index 0000000..6a3356c --- /dev/null +++ b/cartridges/domains/ci-cd/buildkite-mcp/adapter/README.adoc @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += buildkite-mcp / adapter β€” protocol bridge +:orientation: shallow + +== Purpose + +Exposes this cartridge's FFI over the wire protocols declared in +`cartridge.json`. Dispatches tool invocations to the C-ABI exports at +`../ffi/` and formats responses per protocol. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” depends on the sibling `ffi` target. +| `buildkite_adapter.zig` | Protocol dispatch (204 lines). Tool catalogue matches `cartridge.json`. +| `SIDELINED-*.v.adoc` | zig predecessor(s). Not built β€” zig banned 2026-04-10. +|=== + +== Invariants + +* **Stateless** β€” all state lives behind the FFI, never in the adapter. +* **Response passthrough** β€” whatever the FFI returns goes back to the wire + unmodified (no embellishment, no silent recovery). +* **Cartridge.json is source of truth** for the tool catalogue; drift between + adapter and manifest is a CI failure. + +== Test/proof surface + +No inline tests at the adapter layer β€” protocol routing is exercised by the +trunk seam tests at `ffi/zig/src/seams.zig`. + +== Read-first + +. `buildkite_adapter.zig` β€” dispatch function and per-protocol routers. +. `../cartridge.json` β€” the tool manifest the dispatcher mirrors. diff --git a/cartridges/domains/ci-cd/buildkite-mcp/adapter/build.zig b/cartridges/domains/ci-cd/buildkite-mcp/adapter/build.zig new file mode 100644 index 0000000..3ab7128 --- /dev/null +++ b/cartridges/domains/ci-cd/buildkite-mcp/adapter/build.zig @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// buildkite-mcp/adapter/build.zig + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const ffi_mod = b.createModule(.{ + .root_source_file = b.path("../ffi/buildkite_mcp_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + const adapter = b.addExecutable(.{ + .name = "buildkite_adapter", + .root_source_file = b.path("buildkite_adapter.zig"), + .target = target, + .optimize = optimize, + }); + adapter.root_module.addImport("buildkite_mcp_ffi", ffi_mod); + b.installArtifact(adapter); + + const run_artifact = b.addRunArtifact(adapter); + const run_step = b.step("run", "Run the buildkite-mcp adapter"); + run_step.dependOn(&run_artifact.step); + + const tests = b.addTest(.{ + .root_source_file = b.path("buildkite_adapter.zig"), + .target = target, + .optimize = optimize, + }); + tests.root_module.addImport("buildkite_mcp_ffi", ffi_mod); + const run_tests = b.addRunArtifact(tests); + const test_step = b.step("test", "Run buildkite-mcp adapter tests"); + test_step.dependOn(&run_tests.step); +} diff --git a/cartridges/domains/ci-cd/buildkite-mcp/adapter/buildkite_adapter.zig b/cartridges/domains/ci-cd/buildkite-mcp/adapter/buildkite_adapter.zig new file mode 100644 index 0000000..92c9116 --- /dev/null +++ b/cartridges/domains/ci-cd/buildkite-mcp/adapter/buildkite_adapter.zig @@ -0,0 +1,204 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// buildkite-mcp/adapter/buildkite_adapter.zig -- Unified three-protocol adapter. +// +// Replaces the banned buildkite_adapter.v (zig, removed 2026-04-12). +// +// Bridges the Zig FFI (buildkite_mcp_ffi.zig) to three network protocols: +// REST :9037 POST /tools/<tool> +// gRPC-compat :9038 /BuildkiteMcpService/<Method> +// GraphQL :9039 POST /graphql { query: "..." } +// +// Buildkite CI/CD: pipelines, builds, jobs, artifacts, agents +// Tools: +// buildkite_list_pipelines +// buildkite_get_pipeline +// buildkite_list_builds +// buildkite_get_build +// buildkite_create_build +// buildkite_cancel_build +// buildkite_list_jobs +// buildkite_get_job_log +// buildkite_list_artifacts +// buildkite_list_agents + +const std = @import("std"); +const ffi = @import("buildkite_mcp_ffi"); + +const REST_PORT: u16 = 9037; +const GRPC_PORT: u16 = 9038; +const GQL_PORT: u16 = 9039; + +const MAX_CONN_BUF: usize = 16 * 1024; + +// ============================================================================ +// JSON response builders +// ============================================================================ + +fn okJson(buf: []u8, msg: []const u8) []u8 { + return std.fmt.bufPrint(buf, + \{{"success":true,"message":"{}"}} + , .{msg}) catch buf[0..0]; +} + +fn errJson(buf: []u8, msg: []const u8) []u8 { + return std.fmt.bufPrint(buf, + \{{"success":false,"error":"{}"}} + , .{msg}) catch buf[0..0]; +} + +fn statusJson(buf: []u8) []u8 { + return std.fmt.bufPrint(buf, + \{{"success":true,"state":"ready","service":"buildkite-mcp"}} + , .{}) catch buf[0..0]; +} + +// ============================================================================ +// Tool dispatcher +// ============================================================================ + +const Response = struct { status: u16, body: []u8 }; + +fn dispatch(tool: []const u8, body: []const u8, resp: []u8) Response { + _ = body; + if (std.mem.eql(u8, tool, "buildkite_list_pipelines")) return .{ .status = 200, .body = okJson(resp, "buildkite_list_pipelines forwarded to backend") }; + if (std.mem.eql(u8, tool, "buildkite_get_pipeline")) return .{ .status = 200, .body = okJson(resp, "buildkite_get_pipeline forwarded to backend") }; + if (std.mem.eql(u8, tool, "buildkite_list_builds")) return .{ .status = 200, .body = okJson(resp, "buildkite_list_builds forwarded to backend") }; + if (std.mem.eql(u8, tool, "buildkite_get_build")) return .{ .status = 200, .body = okJson(resp, "buildkite_get_build forwarded to backend") }; + if (std.mem.eql(u8, tool, "buildkite_create_build")) return .{ .status = 200, .body = okJson(resp, "buildkite_create_build forwarded to backend") }; + if (std.mem.eql(u8, tool, "buildkite_cancel_build")) return .{ .status = 200, .body = okJson(resp, "buildkite_cancel_build forwarded to backend") }; + if (std.mem.eql(u8, tool, "buildkite_list_jobs")) return .{ .status = 200, .body = okJson(resp, "buildkite_list_jobs forwarded to backend") }; + if (std.mem.eql(u8, tool, "buildkite_get_job_log")) return .{ .status = 200, .body = okJson(resp, "buildkite_get_job_log forwarded to backend") }; + if (std.mem.eql(u8, tool, "buildkite_list_artifacts")) return .{ .status = 200, .body = okJson(resp, "buildkite_list_artifacts forwarded to backend") }; + if (std.mem.eql(u8, tool, "buildkite_list_agents")) return .{ .status = 200, .body = okJson(resp, "buildkite_list_agents forwarded to backend") }; + if (std.mem.eql(u8, tool, "status") or std.mem.eql(u8, tool, "health")) + return .{ .status = 200, .body = statusJson(resp) }; + return .{ .status = 404, .body = errJson(resp, "Unknown tool") }; +} + +// ============================================================================ +// REST handler +// ============================================================================ + +fn dispatchRest(path: []const u8, body: []const u8, resp: []u8) Response { + if (std.mem.startsWith(u8, path, "/tools/")) { + return dispatch(path["/tools/".len..], body, resp); + } + if (std.mem.eql(u8, path, "/status") or std.mem.eql(u8, path, "/health")) { + return .{ .status = 200, .body = statusJson(resp) }; + } + return .{ .status = 404, .body = errJson(resp, "Not found") }; +} + +// ============================================================================ +// gRPC-compat handler +// ============================================================================ + +fn dispatchGrpc(path: []const u8, body: []const u8, resp: []u8) Response { + const prefix = "/BuildkiteMcpService/"; + if (!std.mem.startsWith(u8, path, prefix)) + return .{ .status = 404, .body = errJson(resp, "Not a recognized gRPC path") }; + const method = path[prefix.len..]; + const tool = blk: { + if (std.mem.eql(u8, method, "BuildkiteListPipelines")) break :blk "buildkite_list_pipelines"; + if (std.mem.eql(u8, method, "BuildkiteGetPipeline")) break :blk "buildkite_get_pipeline"; + if (std.mem.eql(u8, method, "BuildkiteListBuilds")) break :blk "buildkite_list_builds"; + if (std.mem.eql(u8, method, "BuildkiteGetBuild")) break :blk "buildkite_get_build"; + if (std.mem.eql(u8, method, "BuildkiteCreateBuild")) break :blk "buildkite_create_build"; + if (std.mem.eql(u8, method, "BuildkiteCancelBuild")) break :blk "buildkite_cancel_build"; + if (std.mem.eql(u8, method, "BuildkiteListJobs")) break :blk "buildkite_list_jobs"; + if (std.mem.eql(u8, method, "BuildkiteGetJobLog")) break :blk "buildkite_get_job_log"; + if (std.mem.eql(u8, method, "BuildkiteListArtifacts")) break :blk "buildkite_list_artifacts"; + if (std.mem.eql(u8, method, "BuildkiteListAgents")) break :blk "buildkite_list_agents"; + return .{ .status = 404, .body = errJson(resp, "Unknown gRPC method") }; + }; + return dispatch(tool, body, resp); +} + +// ============================================================================ +// GraphQL handler +// ============================================================================ + +fn dispatchGraphql(body: []const u8, resp: []u8) Response { + if (std.mem.indexOf(u8, body, "__schema") != null) + return .{ .status = 200, .body = okJson(resp, "schema introspection not yet supported") }; + if (std.mem.indexOf(u8, body, "list_pipelines") != null) return dispatch("buildkite_list_pipelines", body, resp); + if (std.mem.indexOf(u8, body, "get_pipeline") != null) return dispatch("buildkite_get_pipeline", body, resp); + if (std.mem.indexOf(u8, body, "list_builds") != null) return dispatch("buildkite_list_builds", body, resp); + if (std.mem.indexOf(u8, body, "get_build") != null) return dispatch("buildkite_get_build", body, resp); + if (std.mem.indexOf(u8, body, "create_build") != null) return dispatch("buildkite_create_build", body, resp); + if (std.mem.indexOf(u8, body, "cancel_build") != null) return dispatch("buildkite_cancel_build", body, resp); + if (std.mem.indexOf(u8, body, "list_jobs") != null) return dispatch("buildkite_list_jobs", body, resp); + if (std.mem.indexOf(u8, body, "get_job_log") != null) return dispatch("buildkite_get_job_log", body, resp); + if (std.mem.indexOf(u8, body, "list_artifacts") != null) return dispatch("buildkite_list_artifacts", body, resp); + if (std.mem.indexOf(u8, body, "list_agents") != null) return dispatch("buildkite_list_agents", body, resp); + return .{ .status = 200, .body = errJson(resp, "Unrecognised GraphQL operation") }; +} + +// ============================================================================ +// HTTP/1.1 connection handler +// ============================================================================ + +const Protocol = enum { rest, grpc, graphql }; + +fn handleConnection(conn: std.net.Server.Connection, proto: Protocol) void { + defer conn.stream.close(); + var in_buf: [MAX_CONN_BUF]u8 = undefined; + const n = conn.stream.read(&in_buf) catch return; + const req = in_buf[0..n]; + + var path: []const u8 = "/"; + var body: []const u8 = ""; + if (n > 4) { + const line_end = std.mem.indexOf(u8, req, "\r\n") orelse req.len; + const first_line = req[0..line_end]; + const sp1 = std.mem.indexOfScalar(u8, first_line, ' ') orelse 0; + const rest_of = first_line[sp1 + 1 ..]; + const sp2 = std.mem.indexOfScalar(u8, rest_of, ' ') orelse rest_of.len; + path = rest_of[0..sp2]; + const body_sep = std.mem.indexOf(u8, req, "\r\n\r\n") orelse n; + body = req[@min(body_sep + 4, n)..]; + } + + var resp_buf: [MAX_CONN_BUF]u8 = undefined; + const result = switch (proto) { + .rest => dispatchRest(path, body, &resp_buf), + .grpc => dispatchGrpc(path, body, &resp_buf), + .graphql => dispatchGraphql(body, &resp_buf), + }; + + const content_type = switch (proto) { + .rest => "application/json", + .grpc => "application/grpc+json", + .graphql => "application/json", + }; + + var hdr_buf: [256]u8 = undefined; + const hdr = std.fmt.bufPrint(&hdr_buf, + "HTTP/1.1 {d} OK\r\nContent-Type: {s}\r\nContent-Length: {d}\r\nConnection: close\r\n\r\n", + .{ result.status, content_type, result.body.len }, + ) catch return; + _ = conn.stream.writeAll(hdr) catch return; + _ = conn.stream.writeAll(result.body) catch return; +} + +fn listenLoop(port: u16, proto: Protocol) void { + const addr = std.net.Address.initIp4(.{ 127, 0, 0, 1 }, port) catch return; + var server = addr.listen(.{ .reuse_address = true }) catch return; + defer server.deinit(); + while (true) { + const conn = server.accept() catch continue; + handleConnection(conn, proto); + } +} + +pub fn main() !void { + ffi.buildkite_init(); + const rest_thread = try std.Thread.spawn(.{}, listenLoop, .{ REST_PORT, Protocol.rest }); + const grpc_thread = try std.Thread.spawn(.{}, listenLoop, .{ GRPC_PORT, Protocol.grpc }); + const gql_thread = try std.Thread.spawn(.{}, listenLoop, .{ GQL_PORT, Protocol.graphql }); + rest_thread.join(); + grpc_thread.join(); + gql_thread.join(); +} diff --git a/cartridges/domains/ci-cd/buildkite-mcp/benchmarks/quick-bench.sh b/cartridges/domains/ci-cd/buildkite-mcp/benchmarks/quick-bench.sh new file mode 100755 index 0000000..ee60409 --- /dev/null +++ b/cartridges/domains/ci-cd/buildkite-mcp/benchmarks/quick-bench.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Benchmarks for buildkite-mcp cartridge. +set -euo pipefail + +echo "=== buildkite-mcp benchmarks ===" + +cd "$(dirname "${BASH_SOURCE[0]}")/../ffi" +zig build -Doptimize=ReleaseFast 2>&1 + +echo "Session open/close cycle (1000 iterations):" +time for i in $(seq 1 1000); do true; done + +echo "" +echo "Benchmark placeholder -- implement real benchmarks in Zig test blocks" +echo "or via the zig adapter HTTP benchmark tool." diff --git a/cartridges/domains/ci-cd/buildkite-mcp/cartridge.json b/cartridges/domains/ci-cd/buildkite-mcp/cartridge.json new file mode 100644 index 0000000..59b604f --- /dev/null +++ b/cartridges/domains/ci-cd/buildkite-mcp/cartridge.json @@ -0,0 +1,316 @@ +{ + "$schema": "https://boj.dev/schemas/cartridge/v1.json", + "spdx": "MPL-2.0", + "copyright": "Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath)", + "name": "buildkite-mcp", + "version": "0.1.0", + "description": "Buildkite CI/CD cartridge -- pipeline listing, build management, job inspection, artifact retrieval, agent listing, annotation creation, build triggering, log retrieval, environment listing, and build cancellation via the Buildkite REST API", + "domain": "CI/CD", + "tier": "Ayo", + "protocols": [ + "MCP", + "REST" + ], + "auth": { + "method": "bearer_token", + "env_var": "BUILDKITE_API_TOKEN", + "credential_source": "vault-mcp" + }, + "api": { + "base_url": "https://api.buildkite.com/v2", + "content_type": "application/json" + }, + "tools": [ + { + "name": "buildkite_list_pipelines", + "description": "List all pipelines in a Buildkite organization", + "inputSchema": { + "type": "object", + "properties": { + "organization": { + "type": "string", + "description": "Organization slug" + }, + "page": { + "type": "number", + "description": "Page number" + }, + "per_page": { + "type": "number", + "description": "Results per page (default 30)" + } + }, + "required": [ + "organization" + ] + } + }, + { + "name": "buildkite_get_pipeline", + "description": "Get details for a specific Buildkite pipeline", + "inputSchema": { + "type": "object", + "properties": { + "organization": { + "type": "string", + "description": "Organization slug" + }, + "pipeline": { + "type": "string", + "description": "Pipeline slug" + } + }, + "required": [ + "organization", + "pipeline" + ] + } + }, + { + "name": "buildkite_list_builds", + "description": "List builds for a pipeline with optional state/branch filters", + "inputSchema": { + "type": "object", + "properties": { + "organization": { + "type": "string", + "description": "Organization slug" + }, + "pipeline": { + "type": "string", + "description": "Pipeline slug" + }, + "state": { + "type": "string", + "description": "Filter: running, scheduled, passed, failed, canceled, blocked" + }, + "branch": { + "type": "string", + "description": "Filter by branch" + }, + "page": { + "type": "number", + "description": "Page number" + }, + "per_page": { + "type": "number", + "description": "Results per page" + } + }, + "required": [ + "organization", + "pipeline" + ] + } + }, + { + "name": "buildkite_get_build", + "description": "Get detailed information about a specific Buildkite build", + "inputSchema": { + "type": "object", + "properties": { + "organization": { + "type": "string", + "description": "Organization slug" + }, + "pipeline": { + "type": "string", + "description": "Pipeline slug" + }, + "build_number": { + "type": "number", + "description": "Build number" + } + }, + "required": [ + "organization", + "pipeline", + "build_number" + ] + } + }, + { + "name": "buildkite_create_build", + "description": "Trigger a new build on a Buildkite pipeline", + "inputSchema": { + "type": "object", + "properties": { + "organization": { + "type": "string", + "description": "Organization slug" + }, + "pipeline": { + "type": "string", + "description": "Pipeline slug" + }, + "commit": { + "type": "string", + "description": "Git commit SHA (or 'HEAD')" + }, + "branch": { + "type": "string", + "description": "Git branch" + }, + "message": { + "type": "string", + "description": "Build message" + }, + "env": { + "type": "object", + "description": "Environment variables" + } + }, + "required": [ + "organization", + "pipeline", + "commit", + "branch" + ] + } + }, + { + "name": "buildkite_cancel_build", + "description": "Cancel a running Buildkite build", + "inputSchema": { + "type": "object", + "properties": { + "organization": { + "type": "string", + "description": "Organization slug" + }, + "pipeline": { + "type": "string", + "description": "Pipeline slug" + }, + "build_number": { + "type": "number", + "description": "Build number" + } + }, + "required": [ + "organization", + "pipeline", + "build_number" + ] + } + }, + { + "name": "buildkite_list_jobs", + "description": "List jobs for a specific Buildkite build with step details", + "inputSchema": { + "type": "object", + "properties": { + "organization": { + "type": "string", + "description": "Organization slug" + }, + "pipeline": { + "type": "string", + "description": "Pipeline slug" + }, + "build_number": { + "type": "number", + "description": "Build number" + } + }, + "required": [ + "organization", + "pipeline", + "build_number" + ] + } + }, + { + "name": "buildkite_get_job_log", + "description": "Get the log output for a specific job in a Buildkite build", + "inputSchema": { + "type": "object", + "properties": { + "organization": { + "type": "string", + "description": "Organization slug" + }, + "pipeline": { + "type": "string", + "description": "Pipeline slug" + }, + "build_number": { + "type": "number", + "description": "Build number" + }, + "job_id": { + "type": "string", + "description": "Job UUID" + } + }, + "required": [ + "organization", + "pipeline", + "build_number", + "job_id" + ] + } + }, + { + "name": "buildkite_list_artifacts", + "description": "List artifacts produced by a Buildkite build", + "inputSchema": { + "type": "object", + "properties": { + "organization": { + "type": "string", + "description": "Organization slug" + }, + "pipeline": { + "type": "string", + "description": "Pipeline slug" + }, + "build_number": { + "type": "number", + "description": "Build number" + } + }, + "required": [ + "organization", + "pipeline", + "build_number" + ] + } + }, + { + "name": "buildkite_list_agents", + "description": "List all agents in a Buildkite organization with status and metadata", + "inputSchema": { + "type": "object", + "properties": { + "organization": { + "type": "string", + "description": "Organization slug" + }, + "page": { + "type": "number", + "description": "Page number" + }, + "per_page": { + "type": "number", + "description": "Results per page" + } + }, + "required": [ + "organization" + ] + } + } + ], + "ffi": { + "so_path": "ffi/zig-out/lib/libbuildkite_mcp.so", + "abi_version": "ADR-0006", + "symbols": [ + "boj_cartridge_init", + "boj_cartridge_deinit", + "boj_cartridge_name", + "boj_cartridge_version", + "boj_cartridge_invoke" + ] + } +} diff --git a/cartridges/domains/ci-cd/buildkite-mcp/ffi/README.adoc b/cartridges/domains/ci-cd/buildkite-mcp/ffi/README.adoc new file mode 100644 index 0000000..b2c7f28 --- /dev/null +++ b/cartridges/domains/ci-cd/buildkite-mcp/ffi/README.adoc @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += buildkite-mcp / ffi β€” Zig FFI layer +:orientation: shallow + +== Purpose + +C-ABI exports bridging this cartridge's Idris2 ABI to the runtime. Consumed by +the adapter for protocol dispatch and by the trunk loader for mount. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” produces `zig-out/lib/libbuildkite.so`. +| `buildkite_mcp_ffi.zig` | C-ABI exports (14 exports, 5 inline tests, 272 lines). +|=== + +== Invariants + +* **Null-pointer rejection** on `[*c]const u8` arguments before any + dereference. +* **Negative return values** carry documented error meanings; zero is + success (matching the trunk error-code convention). +* **No allocator leaks** β€” every allocation path pairs with `defer free`. + +== Test/proof surface + +5 inline `test "..."` block(s) in `buildkite_mcp_ffi.zig`. Run with +`cd ffi && zig build test`. + +== Read-first + +. `buildkite_mcp_ffi.zig` β€” C-ABI signatures and guards. +. Trunk `ffi/zig/src/catalogue.zig` for how this `.so` is loaded. diff --git a/cartridges/domains/ci-cd/buildkite-mcp/ffi/build.zig b/cartridges/domains/ci-cd/buildkite-mcp/ffi/build.zig new file mode 100644 index 0000000..4435cb1 --- /dev/null +++ b/cartridges/domains/ci-cd/buildkite-mcp/ffi/build.zig @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + // Shared ADR-0006 invoke-shim module (relative path up to boj-server trunk). + + const shim_mod = b.addModule("cartridge_shim", .{ + + .root_source_file = b.path("../../../ffi/zig/src/cartridge_shim.zig"), + + .target = target, + + .optimize = optimize, + + }); + + const ffi_mod = b.addModule("buildkite_mcp", .{ + .root_source_file = b.path("buildkite_mcp_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + ffi_mod.addImport("cartridge_shim", shim_mod); + + const lib = b.addLibrary(.{ + .name = "buildkite_mcp", + .root_module = ffi_mod, + .linkage = .dynamic, + }); + lib.linkLibC(); + b.installArtifact(lib); + + const tests = b.addTest(.{ .root_module = ffi_mod }); + tests.linkLibC(); + const run_tests = b.addRunArtifact(tests); + const test_step = b.step("test", "Run FFI tests"); + test_step.dependOn(&run_tests.step); +} + diff --git a/cartridges/domains/ci-cd/buildkite-mcp/ffi/buildkite_mcp_ffi.zig b/cartridges/domains/ci-cd/buildkite-mcp/ffi/buildkite_mcp_ffi.zig new file mode 100644 index 0000000..5c7eb32 --- /dev/null +++ b/cartridges/domains/ci-cd/buildkite-mcp/ffi/buildkite_mcp_ffi.zig @@ -0,0 +1,381 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// buildkite_mcp_ffi.zig β€” C-ABI FFI implementation for buildkite-mcp cartridge. +// +// Implements the state machine defined in BuildkiteMcp.SafeRegistry (Idris2 ABI). +// State machine: Unauthenticated | Authenticated | RateLimited | Error +// Auth: Bearer token required for all Buildkite API operations. +// Actions: ListPipelines, GetPipeline, ListBuilds, GetBuild, CreateBuild, +// CancelBuild, ListJobs, GetJobLog, ListArtifacts, ListAgents +// Thread-safe via std.Thread.Mutex. Fixed-size session pool, no heap allocations. + +const std = @import("std"); + +pub const SessionState = enum(c_int) { + unauthenticated = 0, + authenticated = 1, + rate_limited = 2, + err = 3, +}; + +pub const BuildkiteAction = enum(c_int) { + list_pipelines = 0, + get_pipeline = 1, + list_builds = 2, + get_build = 3, + create_build = 4, + cancel_build = 5, + list_jobs = 6, + get_job_log = 7, + list_artifacts = 8, + list_agents = 9, +}; + +fn isValidTransition(from: SessionState, to: SessionState) bool { + return switch (from) { + .unauthenticated => to == .authenticated or to == .err, + .authenticated => to == .unauthenticated or to == .rate_limited or to == .err, + .rate_limited => to == .authenticated, + .err => to == .authenticated or to == .unauthenticated, + }; +} + +const MAX_SESSIONS: usize = 16; + +const SessionSlot = struct { + active: bool = false, + state: SessionState = .unauthenticated, + api_call_count: u64 = 0, + last_action: c_int = -1, + pipeline_ops: u32 = 0, + build_ops: u32 = 0, + agent_ops: u32 = 0, +}; + +var sessions: [MAX_SESSIONS]SessionSlot = .{SessionSlot{}} ** MAX_SESSIONS; +var mutex: std.Thread.Mutex = .{}; + +pub export fn buildkite_mcp_can_transition(from: c_int, to: c_int) c_int { + const f = std.meta.intToEnum(SessionState, from) catch return 0; + const t = std.meta.intToEnum(SessionState, to) catch return 0; + return if (isValidTransition(f, t)) 1 else 0; +} + +pub export fn buildkite_mcp_authenticate(dummy: c_int) c_int { + _ = dummy; + mutex.lock(); + defer mutex.unlock(); + + for (&sessions, 0..) |*slot, idx| { + if (!slot.active) { + slot.active = true; + slot.state = .authenticated; + slot.api_call_count = 0; + slot.last_action = -1; + slot.pipeline_ops = 0; + slot.build_ops = 0; + slot.agent_ops = 0; + return @intCast(idx); + } + } + return -1; +} + +pub export fn buildkite_mcp_close(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + + sessions[idx] = SessionSlot{}; + return 0; +} + +pub export fn buildkite_mcp_session_state(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + return @intFromEnum(slot.state); +} + +pub export fn buildkite_mcp_throttle(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + if (!isValidTransition(slot.state, .rate_limited)) return -2; + + sessions[idx].state = .rate_limited; + return 0; +} + +pub export fn buildkite_mcp_unthrottle(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + if (!isValidTransition(slot.state, .authenticated)) return -2; + + sessions[idx].state = .authenticated; + return 0; +} + +pub export fn buildkite_mcp_signal_error(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + if (!isValidTransition(slot.state, .err)) return -2; + + sessions[idx].state = .err; + return 0; +} + +pub export fn buildkite_mcp_record_call(slot_idx: c_int, action: c_int) c_int { + const act = std.meta.intToEnum(BuildkiteAction, action) catch return -3; + + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + if (slot.state == .rate_limited) return -2; + if (slot.state == .err) return -2; + if (slot.state == .unauthenticated) return -2; + + sessions[idx].api_call_count += 1; + sessions[idx].last_action = action; + + switch (act) { + .list_pipelines, .get_pipeline => sessions[idx].pipeline_ops += 1, + .list_builds, .get_build, .create_build, .cancel_build, .list_jobs, .get_job_log, .list_artifacts => sessions[idx].build_ops += 1, + .list_agents => sessions[idx].agent_ops += 1, + } + + return 0; +} + +pub export fn buildkite_mcp_call_count(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + if (!sessions[idx].active) return -1; + return @intCast(sessions[idx].api_call_count); +} + +pub export fn buildkite_mcp_pipeline_op_count(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + if (!sessions[idx].active) return -1; + return @intCast(sessions[idx].pipeline_ops); +} + +pub export fn buildkite_mcp_build_op_count(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + if (!sessions[idx].active) return -1; + return @intCast(sessions[idx].build_ops); +} + +pub export fn buildkite_mcp_agent_op_count(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + if (!sessions[idx].active) return -1; + return @intCast(sessions[idx].agent_ops); +} + +pub export fn buildkite_mcp_action_count() c_int { + return 10; +} + +pub export fn buildkite_mcp_reset() void { + mutex.lock(); + defer mutex.unlock(); + sessions = .{SessionSlot{}} ** MAX_SESSIONS; +} + +// ═══════════════════════════════════════════════════════════════════════ +// Standard ABI (ADR-0005 four symbols + ADR-0006 invoke) +// ═══════════════════════════════════════════════════════════════════════ + +const shim = @import("cartridge_shim.zig"); + +const CARTRIDGE_NAME_PTR: [*:0]const u8 = "buildkite-mcp"; +const CARTRIDGE_VERSION_PTR: [*:0]const u8 = "0.1.0"; + +export fn boj_cartridge_init() callconv(.c) c_int { + return 0; +} + +export fn boj_cartridge_deinit() callconv(.c) void {} + +export fn boj_cartridge_name() callconv(.c) [*:0]const u8 { + return CARTRIDGE_NAME_PTR; +} + +export fn boj_cartridge_version() callconv(.c) [*:0]const u8 { + return CARTRIDGE_VERSION_PTR; +} + +/// Dispatch the cartridge.json MCP tools. Grade D Alpha stubs. +export fn boj_cartridge_invoke( + tool_name: [*c]const u8, + json_args: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) callconv(.c) i32 { + _ = json_args; + if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; + + const body: []const u8 = if (shim.toolIs(tool_name, "buildkite_list_pipelines")) + "{\"result\":{\"items\":[],\"count\":0,\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "buildkite_get_pipeline")) + "{\"result\":{\"metadata\":{},\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "buildkite_list_builds")) + "{\"result\":{\"items\":[],\"count\":0,\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "buildkite_get_build")) + "{\"result\":{\"metadata\":{},\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "buildkite_create_build")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "buildkite_cancel_build")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "buildkite_list_jobs")) + "{\"result\":{\"items\":[],\"count\":0,\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "buildkite_get_job_log")) + "{\"result\":{\"metadata\":{},\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "buildkite_list_artifacts")) + "{\"result\":{\"items\":[],\"count\":0,\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "buildkite_list_agents")) + "{\"result\":{\"items\":[],\"count\":0,\"status\":\"stub\"}}" +else + return shim.RC_UNKNOWN_TOOL; + + return shim.writeResult(out_buf, in_out_len, body); +} + +test "authenticated session lifecycle" { + buildkite_mcp_reset(); + const slot = buildkite_mcp_authenticate(0); + try std.testing.expect(slot >= 0); + try std.testing.expectEqual(@as(c_int, 1), buildkite_mcp_session_state(slot)); + try std.testing.expectEqual(@as(c_int, 0), buildkite_mcp_record_call(slot, 0)); + try std.testing.expectEqual(@as(c_int, 1), buildkite_mcp_pipeline_op_count(slot)); + try std.testing.expectEqual(@as(c_int, 0), buildkite_mcp_close(slot)); +} + +test "rate limiting flow" { + buildkite_mcp_reset(); + const slot = buildkite_mcp_authenticate(0); + try std.testing.expect(slot >= 0); + try std.testing.expectEqual(@as(c_int, 0), buildkite_mcp_throttle(slot)); + try std.testing.expectEqual(@as(c_int, -2), buildkite_mcp_record_call(slot, 0)); + try std.testing.expectEqual(@as(c_int, 0), buildkite_mcp_unthrottle(slot)); + try std.testing.expectEqual(@as(c_int, 1), buildkite_mcp_session_state(slot)); +} + +test "category counting" { + buildkite_mcp_reset(); + const slot = buildkite_mcp_authenticate(0); + try std.testing.expect(slot >= 0); + try std.testing.expectEqual(@as(c_int, 0), buildkite_mcp_record_call(slot, 0)); + try std.testing.expectEqual(@as(c_int, 0), buildkite_mcp_record_call(slot, 2)); + try std.testing.expectEqual(@as(c_int, 0), buildkite_mcp_record_call(slot, 9)); + try std.testing.expectEqual(@as(c_int, 3), buildkite_mcp_call_count(slot)); + try std.testing.expectEqual(@as(c_int, 1), buildkite_mcp_pipeline_op_count(slot)); + try std.testing.expectEqual(@as(c_int, 1), buildkite_mcp_build_op_count(slot)); + try std.testing.expectEqual(@as(c_int, 1), buildkite_mcp_agent_op_count(slot)); +} + +test "transition validator" { + try std.testing.expectEqual(@as(c_int, 1), buildkite_mcp_can_transition(0, 1)); + try std.testing.expectEqual(@as(c_int, 1), buildkite_mcp_can_transition(1, 2)); + try std.testing.expectEqual(@as(c_int, 0), buildkite_mcp_can_transition(0, 2)); +} + +test "slot exhaustion" { + buildkite_mcp_reset(); + var slots: [MAX_SESSIONS]c_int = undefined; + for (&slots) |*s| { + s.* = buildkite_mcp_authenticate(0); + try std.testing.expect(s.* >= 0); + } + try std.testing.expectEqual(@as(c_int, -1), buildkite_mcp_authenticate(0)); + try std.testing.expectEqual(@as(c_int, 0), buildkite_mcp_close(slots[0])); + try std.testing.expect(buildkite_mcp_authenticate(0) >= 0); +} + +// ═══════════════════════════════════════════════════════════════════════ +// ADR-0006 invoke dispatch tests +// ═══════════════════════════════════════════════════════════════════════ + +test "boj_cartridge_name returns buildkite-mcp" { + const n = std.mem.span(boj_cartridge_name()); + try std.testing.expectEqualStrings("buildkite-mcp", n); +} + +test "boj_cartridge_init returns 0" { + try std.testing.expectEqual(@as(c_int, 0), boj_cartridge_init()); +} + +test "invoke: each declared tool succeeds" { + var buf: [256]u8 = undefined; + const tools = [_][]const u8{ + "buildkite_list_pipelines", + "buildkite_get_pipeline", + "buildkite_list_builds", + "buildkite_get_build", + "buildkite_create_build", + "buildkite_cancel_build", + "buildkite_list_jobs", + "buildkite_get_job_log", + "buildkite_list_artifacts", + "buildkite_list_agents", + }; + for (tools) |t| { + var len: usize = buf.len; + const rc = boj_cartridge_invoke(t.ptr, "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, 0), rc); + try std.testing.expect(std.mem.indexOf(u8, buf[0..len], "result") != null); + } +} + +test "invoke: unknown tool returns -1" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("nope", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -1), rc); +} + +test "invoke: buffer too small returns -3" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("buildkite_list_pipelines", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -3), rc); + try std.testing.expect(len > 4); +} diff --git a/cartridges/domains/ci-cd/buildkite-mcp/ffi/cartridge_shim.zig b/cartridges/domains/ci-cd/buildkite-mcp/ffi/cartridge_shim.zig new file mode 100644 index 0000000..0e399db --- /dev/null +++ b/cartridges/domains/ci-cd/buildkite-mcp/ffi/cartridge_shim.zig @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// cartridge_shim.zig β€” Shared helpers for the ADR-0006 five-symbol +// cartridge ABI (`boj_cartridge_init / deinit / name / version / invoke`). +// +// The shim centralises the seven-code return convention, NUL-argument +// guards, tool-name comparison, and the buffer-too-small path so each +// cartridge's `boj_cartridge_invoke` can stay short β€” typically a tool +// table plus `shim.writeResult(...)`. +// +// Cartridges import this file by relative path (no build-graph change +// needed). Example: +// +// const shim = @import("../../../ffi/zig/src/cartridge_shim.zig"); +// +// export fn boj_cartridge_invoke( +// tool_name: [*c]const u8, +// json_args: [*c]const u8, +// out_buf: [*c]u8, +// in_out_len: [*c]usize, +// ) callconv(.c) i32 { +// _ = json_args; +// if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; +// const body = if (shim.toolIs(tool_name, "foo")) "{\"result\":{}}" +// else return shim.RC_UNKNOWN_TOOL; +// return shim.writeResult(out_buf, in_out_len, body); +// } + +const std = @import("std"); + +// ── Return codes (ADR-0006 Β§Return codes) ──────────────────────────── +// +// Frozen by ADR-0006. New failure modes compose these via the error +// JSON body β€” the integer surface does not grow without a follow-up ADR. + +pub const RC_SUCCESS: i32 = 0; +pub const RC_UNKNOWN_TOOL: i32 = -1; +pub const RC_BAD_ARGS: i32 = -2; +pub const RC_BUFFER_TOO_SMALL: i32 = -3; +pub const RC_RUNTIME_ERROR: i32 = -4; +pub const RC_PANIC: i32 = -5; +pub const RC_AUTH_DENIED: i32 = -6; + +// ── Invoke-path helpers ────────────────────────────────────────────── + +/// True if any of the three mandatory `boj_cartridge_invoke` output-path +/// pointers is null. Use at the top of every invoke to short-circuit to +/// `RC_BAD_ARGS`. +pub fn invokeArgsNull( + tool_name: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) bool { + return tool_name == null or out_buf == null or in_out_len == null; +} + +/// Compare a C-NUL-terminated tool-name pointer against a Zig string +/// literal. Caller must have already verified `tool_name` is non-null +/// (usually via `invokeArgsNull`). +pub fn toolIs(tool_name: [*c]const u8, expected: []const u8) bool { + const s = std.mem.span(@as([*:0]const u8, @ptrCast(tool_name))); + return std.mem.eql(u8, s, expected); +} + +/// Copy `body` into `out_buf[0..*in_out_len]` (as a capacity) and update +/// `*in_out_len` to the number of bytes written. Returns `RC_SUCCESS`. +/// +/// If `body.len` exceeds the current capacity stored in `*in_out_len`, +/// sets `*in_out_len` to the required size and returns +/// `RC_BUFFER_TOO_SMALL` β€” the caller is then expected to re-allocate +/// and retry, per ADR-0006 Β§Memory ownership. +/// +/// Caller must have already verified that `out_buf` and `in_out_len` +/// are non-null. +pub fn writeResult( + out_buf: [*c]u8, + in_out_len: [*c]usize, + body: []const u8, +) i32 { + const cap = in_out_len.*; + if (body.len > cap) { + in_out_len.* = body.len; + return RC_BUFFER_TOO_SMALL; + } + @memcpy(out_buf[0..body.len], body); + in_out_len.* = body.len; + return RC_SUCCESS; +} + +// ── Tests ──────────────────────────────────────────────────────────── + +test "writeResult: body fits, writes and sets length" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); + try std.testing.expectEqualStrings("hello", buf[0..len]); +} + +test "writeResult: too small returns -3 and sets required length" { + var buf: [2]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_BUFFER_TOO_SMALL, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: exact-fit succeeds" { + var buf: [5]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: empty body" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, ""); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 0), len); +} + +test "toolIs: matches and rejects" { + const name: [*:0]const u8 = "foo"; + try std.testing.expect(toolIs(@ptrCast(name), "foo")); + try std.testing.expect(!toolIs(@ptrCast(name), "bar")); + try std.testing.expect(!toolIs(@ptrCast(name), "foobar")); + try std.testing.expect(!toolIs(@ptrCast(name), "fo")); +} + +test "invokeArgsNull: detects each null slot" { + var buf: [4]u8 = undefined; + var len: usize = 4; + const name: [*:0]const u8 = "x"; + try std.testing.expect(!invokeArgsNull(@ptrCast(name), &buf, &len)); + try std.testing.expect(invokeArgsNull(null, &buf, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), null, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), &buf, null)); +} diff --git a/cartridges/domains/ci-cd/buildkite-mcp/minter.toml b/cartridges/domains/ci-cd/buildkite-mcp/minter.toml new file mode 100644 index 0000000..8e48b48 --- /dev/null +++ b/cartridges/domains/ci-cd/buildkite-mcp/minter.toml @@ -0,0 +1,22 @@ +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> + +name = "buildkite-mcp" +description = "Buildkite CI/CD cartridge β€” pipeline listing, build management, job inspection, artifacts, agents, log retrieval, build triggering, cancellation" +version = "0.1.0" +domain = "CI/CD" +protocols = [ + "MCP", + "REST", +] +tier = "Ayo" +backend = "universal" +generate_panel = true + +[auth] +method = "bearer_token" +credential_source = "vault-mcp" +fields = ["buildkite_api_token"] + +[api] +base_url = "https://api.buildkite.com/v2" diff --git a/cartridges/domains/ci-cd/buildkite-mcp/mod.js b/cartridges/domains/ci-cd/buildkite-mcp/mod.js new file mode 100644 index 0000000..472ed17 --- /dev/null +++ b/cartridges/domains/ci-cd/buildkite-mcp/mod.js @@ -0,0 +1,164 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// buildkite-mcp/mod.js -- Buildkite CI/CD cartridge implementation. +// +// Provides MCP tool handlers for the Buildkite REST API v2: +// - Pipeline listing and detail retrieval +// - Build listing, detail, triggering, cancellation +// - Job inspection with step details +// - Job log retrieval +// - Artifact listing +// - Agent listing with status and metadata +// +// Auth: Bearer token via BUILDKITE_API_TOKEN (required for all operations). +// API docs: https://buildkite.com/docs/apis/rest-api +// +// Usage: import { handleTool } from "./mod.js"; +// or: deno run --allow-net --allow-env mod.js + +const API_BASE = "https://api.buildkite.com/v2"; + +function getToken() { + const token = typeof Deno !== "undefined" + ? Deno.env.get("BUILDKITE_API_TOKEN") + : process.env.BUILDKITE_API_TOKEN; + return token || null; +} + +async function bkFetch(path, { method = "GET", queryParams, body } = {}) { + const url = new URL(`${API_BASE}${path}`); + + if (queryParams) { + for (const [key, value] of Object.entries(queryParams)) { + if (value !== undefined && value !== null && value !== "") { + url.searchParams.set(key, String(value)); + } + } + } + + const headers = { + "Accept": "application/json", + "Content-Type": "application/json", + }; + + const token = getToken(); + if (token) { + headers["Authorization"] = `Bearer ${token}`; + } + + const options = { method, headers }; + if (body) { + options.body = JSON.stringify(body); + } + + const response = await fetch(url.toString(), options); + + if (response.status === 429) { + const retryAfter = response.headers.get("retry-after"); + return { status: 429, error: `Rate limited by Buildkite. Retry after ${retryAfter || "unknown"} seconds.`, retryAfter }; + } + + const data = await response.json().catch(() => ({})); + + if (!response.ok) { + const errorMessage = data.message || `HTTP ${response.status}`; + return { status: response.status, error: errorMessage, data }; + } + + return { status: response.status, data }; +} + +export async function handleTool(toolName, args) { + switch (toolName) { + + case "buildkite_list_pipelines": { + if (!args.organization) return { error: "Missing required field: organization" }; + const query = { page: args.page, per_page: args.per_page }; + return bkFetch(`/organizations/${encodeURIComponent(args.organization)}/pipelines`, { queryParams: query }); + } + + case "buildkite_get_pipeline": { + if (!args.organization) return { error: "Missing required field: organization" }; + if (!args.pipeline) return { error: "Missing required field: pipeline" }; + return bkFetch(`/organizations/${encodeURIComponent(args.organization)}/pipelines/${encodeURIComponent(args.pipeline)}`); + } + + case "buildkite_list_builds": { + if (!args.organization) return { error: "Missing required field: organization" }; + if (!args.pipeline) return { error: "Missing required field: pipeline" }; + const query = { state: args.state, branch: args.branch, page: args.page, per_page: args.per_page }; + return bkFetch(`/organizations/${encodeURIComponent(args.organization)}/pipelines/${encodeURIComponent(args.pipeline)}/builds`, { queryParams: query }); + } + + case "buildkite_get_build": { + if (!args.organization) return { error: "Missing required field: organization" }; + if (!args.pipeline) return { error: "Missing required field: pipeline" }; + if (!args.build_number) return { error: "Missing required field: build_number" }; + return bkFetch(`/organizations/${encodeURIComponent(args.organization)}/pipelines/${encodeURIComponent(args.pipeline)}/builds/${args.build_number}`); + } + + case "buildkite_create_build": { + if (!args.organization) return { error: "Missing required field: organization" }; + if (!args.pipeline) return { error: "Missing required field: pipeline" }; + if (!args.commit) return { error: "Missing required field: commit" }; + if (!args.branch) return { error: "Missing required field: branch" }; + const body = { + commit: args.commit, + branch: args.branch, + message: args.message || "", + env: args.env || {}, + }; + return bkFetch(`/organizations/${encodeURIComponent(args.organization)}/pipelines/${encodeURIComponent(args.pipeline)}/builds`, { method: "POST", body }); + } + + case "buildkite_cancel_build": { + if (!args.organization) return { error: "Missing required field: organization" }; + if (!args.pipeline) return { error: "Missing required field: pipeline" }; + if (!args.build_number) return { error: "Missing required field: build_number" }; + return bkFetch(`/organizations/${encodeURIComponent(args.organization)}/pipelines/${encodeURIComponent(args.pipeline)}/builds/${args.build_number}/cancel`, { method: "PUT" }); + } + + case "buildkite_list_jobs": { + if (!args.organization) return { error: "Missing required field: organization" }; + if (!args.pipeline) return { error: "Missing required field: pipeline" }; + if (!args.build_number) return { error: "Missing required field: build_number" }; + const result = await bkFetch(`/organizations/${encodeURIComponent(args.organization)}/pipelines/${encodeURIComponent(args.pipeline)}/builds/${args.build_number}`); + if (result.error) return result; + return { status: result.status, data: { jobs: result.data.jobs || [] } }; + } + + case "buildkite_get_job_log": { + if (!args.organization) return { error: "Missing required field: organization" }; + if (!args.pipeline) return { error: "Missing required field: pipeline" }; + if (!args.build_number) return { error: "Missing required field: build_number" }; + if (!args.job_id) return { error: "Missing required field: job_id" }; + return bkFetch(`/organizations/${encodeURIComponent(args.organization)}/pipelines/${encodeURIComponent(args.pipeline)}/builds/${args.build_number}/jobs/${encodeURIComponent(args.job_id)}/log`); + } + + case "buildkite_list_artifacts": { + if (!args.organization) return { error: "Missing required field: organization" }; + if (!args.pipeline) return { error: "Missing required field: pipeline" }; + if (!args.build_number) return { error: "Missing required field: build_number" }; + return bkFetch(`/organizations/${encodeURIComponent(args.organization)}/pipelines/${encodeURIComponent(args.pipeline)}/builds/${args.build_number}/artifacts`); + } + + case "buildkite_list_agents": { + if (!args.organization) return { error: "Missing required field: organization" }; + const query = { page: args.page, per_page: args.per_page }; + return bkFetch(`/organizations/${encodeURIComponent(args.organization)}/agents`, { queryParams: query }); + } + + default: + return { error: `Unknown buildkite-mcp tool: ${toolName}` }; + } +} + +export const metadata = { + name: "buildkite-mcp", + version: "0.1.0", + domain: "CI/CD", + tier: "Ayo", + protocols: ["MCP", "REST"], + toolCount: 10, +}; diff --git a/cartridges/domains/ci-cd/buildkite-mcp/panels/manifest.json b/cartridges/domains/ci-cd/buildkite-mcp/panels/manifest.json new file mode 100644 index 0000000..e2835f2 --- /dev/null +++ b/cartridges/domains/ci-cd/buildkite-mcp/panels/manifest.json @@ -0,0 +1,42 @@ +{ + "$schema": "https://panll.dev/schemas/panel-manifest/v1.json", + "spdx": "MPL-2.0", + "copyright": "Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath)", + "cartridge": "buildkite-mcp", + "domain": "CI/CD", + "version": "0.1.0", + "panels": [ + { + "id": "buildkite-auth-status", + "title": "Auth Status", + "description": "Buildkite session state", + "type": "status-indicator", + "data_source": { "endpoint": "/buildkite/state", "method": "GET", "refresh_interval_ms": 5000 }, + "widgets": [{ "type": "state-badge", "field": "state", "states": { "unauthenticated": { "color": "#95a5a6", "icon": "user-x" }, "authenticated": { "color": "#2ecc71", "icon": "user-check" }, "rate_limited": { "color": "#f39c12", "icon": "clock" }, "error": { "color": "#e74c3c", "icon": "alert-triangle" } } }] + }, + { + "id": "buildkite-pipeline-ops", + "title": "Pipeline Operations", + "description": "Number of pipeline listing/detail operations", + "type": "metric", + "data_source": { "endpoint": "/buildkite/status", "method": "GET", "refresh_interval_ms": 10000 }, + "widgets": [{ "type": "counter", "field": "pipeline_ops", "label": "Pipeline Ops", "icon": "layers" }] + }, + { + "id": "buildkite-build-ops", + "title": "Build Operations", + "description": "Number of build management operations (list, get, create, cancel, jobs, logs, artifacts)", + "type": "metric", + "data_source": { "endpoint": "/buildkite/status", "method": "GET", "refresh_interval_ms": 10000 }, + "widgets": [{ "type": "counter", "field": "build_ops", "label": "Build Ops", "icon": "hammer" }] + }, + { + "id": "buildkite-agent-ops", + "title": "Agent Operations", + "description": "Number of agent listing operations", + "type": "metric", + "data_source": { "endpoint": "/buildkite/status", "method": "GET", "refresh_interval_ms": 10000 }, + "widgets": [{ "type": "counter", "field": "agent_ops", "label": "Agent Ops", "icon": "cpu" }] + } + ] +} diff --git a/cartridges/domains/ci-cd/buildkite-mcp/tests/integration_test.sh b/cartridges/domains/ci-cd/buildkite-mcp/tests/integration_test.sh new file mode 100755 index 0000000..7aeeca4 --- /dev/null +++ b/cartridges/domains/ci-cd/buildkite-mcp/tests/integration_test.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Integration tests for buildkite-mcp cartridge. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CART_DIR="$(dirname "$SCRIPT_DIR")" +FFI_DIR="$CART_DIR/ffi" + +echo "=== buildkite-mcp integration tests ===" + +echo "[1/3] Building FFI..." +cd "$FFI_DIR" && zig build 2>&1 + +echo "[2/3] Running FFI unit tests..." +cd "$FFI_DIR" && zig build test 2>&1 + +echo "[3/3] Checking ABI..." +if command -v idris2 &>/dev/null; then + cd "$CART_DIR/abi" && idris2 --check BuildkiteMcp.SafeRegistry 2>&1 + echo " ABI: OK" +else + echo " ABI: SKIPPED (idris2 not in PATH)" +fi + +echo "" +echo "All tests passed for buildkite-mcp!" diff --git a/cartridges/domains/ci-cd/circleci-mcp/README.adoc b/cartridges/domains/ci-cd/circleci-mcp/README.adoc new file mode 100644 index 0000000..ffa0492 --- /dev/null +++ b/cartridges/domains/ci-cd/circleci-mcp/README.adoc @@ -0,0 +1,55 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += circleci-mcp +Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk> +:spdx: MPL-2.0 +:tier: Ayo +:domain: CI/CD +:protocols: MCP, REST + +== Overview + +CircleCI CI/CD cartridge for the BoJ server. Provides type-safe access to +the CircleCI API v2 for pipeline listing, workflow management, job inspection, +artifact retrieval, pipeline triggering, workflow cancellation, and +environment variable browsing. + +=== Actions (9) + +[cols="1,1"] +|=== +| Category | Actions + +| Pipelines +| ListPipelines, GetPipeline, TriggerPipeline + +| Workflows +| ListWorkflows, GetWorkflow, ListJobs, ListArtifacts, CancelWorkflow + +| Configuration +| ListEnvVars +|=== + +== Architecture + +[cols="1,1,2"] +|=== +| Layer | Language | Purpose + +| ABI | Idris2 | Formally verified state machine (CircleciMcp.SafeRegistry) +| FFI | Zig | Thread-safe session pool, action recording +| Adapter | zig | REST bridge to BoJ unified adapter protocol +|=== + +== Building + +[source,bash] +---- +cd ffi && zig build +cd ffi && zig build test +cd abi && idris2 --check CircleciMcp.SafeRegistry +---- + +== Status + +Development -- Tier 6 CI/CD cartridge with full CircleCI pipeline and workflow management. diff --git a/cartridges/domains/ci-cd/circleci-mcp/abi/CircleciMcp/SafeRegistry.idr b/cartridges/domains/ci-cd/circleci-mcp/abi/CircleciMcp/SafeRegistry.idr new file mode 100644 index 0000000..57e3b5e --- /dev/null +++ b/cartridges/domains/ci-cd/circleci-mcp/abi/CircleciMcp/SafeRegistry.idr @@ -0,0 +1,133 @@ +-- SPDX-License-Identifier: MPL-2.0 +-- Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +-- +-- CircleciMcp.SafeRegistry β€” Type-safe ABI for circleci-mcp cartridge. +-- +-- Dependent-type state machine governing CircleCI API v2 access. +-- Encodes Circle-Token auth, pipeline listing, workflow management, +-- job inspection, artifact retrieval, pipeline triggering, workflow +-- cancellation, and environment variable browsing as compile-time invariants. +-- API: https://circleci.com/docs/api/v2/ +-- No unsafe escape hatches. + +module CircleciMcp.SafeRegistry + +%default total + +public export +data SessionState + = Unauthenticated + | Authenticated + | RateLimited + | Error + +public export +data ValidTransition : SessionState -> SessionState -> Type where + Authenticate : ValidTransition Unauthenticated Authenticated + Deauthenticate : ValidTransition Authenticated Unauthenticated + Throttle : ValidTransition Authenticated RateLimited + Unthrottle : ValidTransition RateLimited Authenticated + AuthError : ValidTransition Authenticated Error + AnonError : ValidTransition Unauthenticated Error + RecoverToAuth : ValidTransition Error Authenticated + RecoverToAnon : ValidTransition Error Unauthenticated + +export +sessionStateToInt : SessionState -> Int +sessionStateToInt Unauthenticated = 0 +sessionStateToInt Authenticated = 1 +sessionStateToInt RateLimited = 2 +sessionStateToInt Error = 3 + +export +intToSessionState : Int -> Maybe SessionState +intToSessionState 0 = Just Unauthenticated +intToSessionState 1 = Just Authenticated +intToSessionState 2 = Just RateLimited +intToSessionState 3 = Just Error +intToSessionState _ = Nothing + +export +circleci_mcp_can_transition : Int -> Int -> Int +circleci_mcp_can_transition from to = + case (intToSessionState from, intToSessionState to) of + (Just Unauthenticated, Just Authenticated) => 1 + (Just Authenticated, Just Unauthenticated) => 1 + (Just Authenticated, Just RateLimited) => 1 + (Just RateLimited, Just Authenticated) => 1 + (Just Authenticated, Just Error) => 1 + (Just Unauthenticated, Just Error) => 1 + (Just Error, Just Authenticated) => 1 + (Just Error, Just Unauthenticated) => 1 + _ => 0 + +public export +data CircleciAction + = ListPipelines + | GetPipeline + | ListWorkflows + | GetWorkflow + | ListJobs + | ListArtifacts + | TriggerPipeline + | CancelWorkflow + | ListEnvVars + +export +actionRequiresAuth : CircleciAction -> Bool +actionRequiresAuth _ = True + +export +actionIsMutating : CircleciAction -> Bool +actionIsMutating TriggerPipeline = True +actionIsMutating CancelWorkflow = True +actionIsMutating _ = False + +export +actionToInt : CircleciAction -> Int +actionToInt ListPipelines = 0 +actionToInt GetPipeline = 1 +actionToInt ListWorkflows = 2 +actionToInt GetWorkflow = 3 +actionToInt ListJobs = 4 +actionToInt ListArtifacts = 5 +actionToInt TriggerPipeline = 6 +actionToInt CancelWorkflow = 7 +actionToInt ListEnvVars = 8 + +export +intToAction : Int -> Maybe CircleciAction +intToAction 0 = Just ListPipelines +intToAction 1 = Just GetPipeline +intToAction 2 = Just ListWorkflows +intToAction 3 = Just GetWorkflow +intToAction 4 = Just ListJobs +intToAction 5 = Just ListArtifacts +intToAction 6 = Just TriggerPipeline +intToAction 7 = Just CancelWorkflow +intToAction 8 = Just ListEnvVars +intToAction _ = Nothing + +public export +data McpTool + = ToolListPipelines + | ToolGetPipeline + | ToolListWorkflows + | ToolGetWorkflow + | ToolListJobs + | ToolListArtifacts + | ToolTriggerPipeline + | ToolCancelWorkflow + | ToolListEnvVars + +export +toolRequiresSession : McpTool -> Bool +toolRequiresSession _ = True + +export +toolCount : Nat +toolCount = 9 + +export +actionCount : Nat +actionCount = 9 diff --git a/cartridges/domains/ci-cd/circleci-mcp/abi/README.adoc b/cartridges/domains/ci-cd/circleci-mcp/abi/README.adoc new file mode 100644 index 0000000..59dbf32 --- /dev/null +++ b/cartridges/domains/ci-cd/circleci-mcp/abi/README.adoc @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += circleci-mcp / abi β€” Idris2 ABI layer +:orientation: shallow + +== Purpose + +Cartridge-local protocol types for `circleci-mcp`. Defines the data, tool, and +transition vocabulary this cartridge speaks. Inherits safety lemmas and the +matrix-cell identifier scheme from the trunk ABI at `src/abi/`. + +== Files + +1 Idris2 module(s), ~133 lines total. Lead module: +`SafeRegistry.idr`. + +No `.ipkg` β€” consumed directly by wider BoJ callers; type-checks inside the +trunk `boj.ipkg`. + +== Invariants + +* `%default total` at every module top. +* Zero `believe_me`, `assert_total`, `postulate`, `Admitted`, or + `sorry` (the trunk holds the four declared `believe_me` sites; leaf + cartridges carry none). + +== Test/proof surface + +Thin module(s), no inline tests. Proof surface = type-check success under the +trunk `idris2 --check`. + +== Read-first + +. `SafeRegistry.idr` β€” cartridge-local data records and enums. +. Trunk `src/abi/Boj/Catalogue.idr` for how this cell participates in the + 2D matrix. diff --git a/cartridges/domains/ci-cd/circleci-mcp/adapter/README.adoc b/cartridges/domains/ci-cd/circleci-mcp/adapter/README.adoc new file mode 100644 index 0000000..cb75948 --- /dev/null +++ b/cartridges/domains/ci-cd/circleci-mcp/adapter/README.adoc @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += circleci-mcp / adapter β€” protocol bridge +:orientation: shallow + +== Purpose + +Exposes this cartridge's FFI over the wire protocols declared in +`cartridge.json`. Dispatches tool invocations to the C-ABI exports at +`../ffi/` and formats responses per protocol. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” depends on the sibling `ffi` target. +| `circleci_adapter.zig` | Protocol dispatch (200 lines). Tool catalogue matches `cartridge.json`. +| `SIDELINED-*.v.adoc` | zig predecessor(s). Not built β€” zig banned 2026-04-10. +|=== + +== Invariants + +* **Stateless** β€” all state lives behind the FFI, never in the adapter. +* **Response passthrough** β€” whatever the FFI returns goes back to the wire + unmodified (no embellishment, no silent recovery). +* **Cartridge.json is source of truth** for the tool catalogue; drift between + adapter and manifest is a CI failure. + +== Test/proof surface + +No inline tests at the adapter layer β€” protocol routing is exercised by the +trunk seam tests at `ffi/zig/src/seams.zig`. + +== Read-first + +. `circleci_adapter.zig` β€” dispatch function and per-protocol routers. +. `../cartridge.json` β€” the tool manifest the dispatcher mirrors. diff --git a/cartridges/domains/ci-cd/circleci-mcp/adapter/build.zig b/cartridges/domains/ci-cd/circleci-mcp/adapter/build.zig new file mode 100644 index 0000000..cc5d72b --- /dev/null +++ b/cartridges/domains/ci-cd/circleci-mcp/adapter/build.zig @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// circleci-mcp/adapter/build.zig + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const ffi_mod = b.createModule(.{ + .root_source_file = b.path("../ffi/circleci_mcp_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + const adapter = b.addExecutable(.{ + .name = "circleci_adapter", + .root_source_file = b.path("circleci_adapter.zig"), + .target = target, + .optimize = optimize, + }); + adapter.root_module.addImport("circleci_mcp_ffi", ffi_mod); + b.installArtifact(adapter); + + const run_artifact = b.addRunArtifact(adapter); + const run_step = b.step("run", "Run the circleci-mcp adapter"); + run_step.dependOn(&run_artifact.step); + + const tests = b.addTest(.{ + .root_source_file = b.path("circleci_adapter.zig"), + .target = target, + .optimize = optimize, + }); + tests.root_module.addImport("circleci_mcp_ffi", ffi_mod); + const run_tests = b.addRunArtifact(tests); + const test_step = b.step("test", "Run circleci-mcp adapter tests"); + test_step.dependOn(&run_tests.step); +} diff --git a/cartridges/domains/ci-cd/circleci-mcp/adapter/circleci_adapter.zig b/cartridges/domains/ci-cd/circleci-mcp/adapter/circleci_adapter.zig new file mode 100644 index 0000000..342c3dc --- /dev/null +++ b/cartridges/domains/ci-cd/circleci-mcp/adapter/circleci_adapter.zig @@ -0,0 +1,200 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// circleci-mcp/adapter/circleci_adapter.zig -- Unified three-protocol adapter. +// +// Replaces the banned circleci_adapter.v (zig, removed 2026-04-12). +// +// Bridges the Zig FFI (circleci_mcp_ffi.zig) to three network protocols: +// REST :9040 POST /tools/<tool> +// gRPC-compat :9041 /CircleciMcpService/<Method> +// GraphQL :9042 POST /graphql { query: "..." } +// +// CircleCI pipelines, workflows, jobs, artifacts, environment variables +// Tools: +// circleci_list_pipelines +// circleci_get_pipeline +// circleci_list_workflows +// circleci_get_workflow +// circleci_list_jobs +// circleci_list_artifacts +// circleci_trigger_pipeline +// circleci_cancel_workflow +// circleci_list_envvars + +const std = @import("std"); +const ffi = @import("circleci_mcp_ffi"); + +const REST_PORT: u16 = 9040; +const GRPC_PORT: u16 = 9041; +const GQL_PORT: u16 = 9042; + +const MAX_CONN_BUF: usize = 16 * 1024; + +// ============================================================================ +// JSON response builders +// ============================================================================ + +fn okJson(buf: []u8, msg: []const u8) []u8 { + return std.fmt.bufPrint(buf, + \{{"success":true,"message":"{}"}} + , .{msg}) catch buf[0..0]; +} + +fn errJson(buf: []u8, msg: []const u8) []u8 { + return std.fmt.bufPrint(buf, + \{{"success":false,"error":"{}"}} + , .{msg}) catch buf[0..0]; +} + +fn statusJson(buf: []u8) []u8 { + return std.fmt.bufPrint(buf, + \{{"success":true,"state":"ready","service":"circleci-mcp"}} + , .{}) catch buf[0..0]; +} + +// ============================================================================ +// Tool dispatcher +// ============================================================================ + +const Response = struct { status: u16, body: []u8 }; + +fn dispatch(tool: []const u8, body: []const u8, resp: []u8) Response { + _ = body; + if (std.mem.eql(u8, tool, "circleci_list_pipelines")) return .{ .status = 200, .body = okJson(resp, "circleci_list_pipelines forwarded to backend") }; + if (std.mem.eql(u8, tool, "circleci_get_pipeline")) return .{ .status = 200, .body = okJson(resp, "circleci_get_pipeline forwarded to backend") }; + if (std.mem.eql(u8, tool, "circleci_list_workflows")) return .{ .status = 200, .body = okJson(resp, "circleci_list_workflows forwarded to backend") }; + if (std.mem.eql(u8, tool, "circleci_get_workflow")) return .{ .status = 200, .body = okJson(resp, "circleci_get_workflow forwarded to backend") }; + if (std.mem.eql(u8, tool, "circleci_list_jobs")) return .{ .status = 200, .body = okJson(resp, "circleci_list_jobs forwarded to backend") }; + if (std.mem.eql(u8, tool, "circleci_list_artifacts")) return .{ .status = 200, .body = okJson(resp, "circleci_list_artifacts forwarded to backend") }; + if (std.mem.eql(u8, tool, "circleci_trigger_pipeline")) return .{ .status = 200, .body = okJson(resp, "circleci_trigger_pipeline forwarded to backend") }; + if (std.mem.eql(u8, tool, "circleci_cancel_workflow")) return .{ .status = 200, .body = okJson(resp, "circleci_cancel_workflow forwarded to backend") }; + if (std.mem.eql(u8, tool, "circleci_list_envvars")) return .{ .status = 200, .body = okJson(resp, "circleci_list_envvars forwarded to backend") }; + if (std.mem.eql(u8, tool, "status") or std.mem.eql(u8, tool, "health")) + return .{ .status = 200, .body = statusJson(resp) }; + return .{ .status = 404, .body = errJson(resp, "Unknown tool") }; +} + +// ============================================================================ +// REST handler +// ============================================================================ + +fn dispatchRest(path: []const u8, body: []const u8, resp: []u8) Response { + if (std.mem.startsWith(u8, path, "/tools/")) { + return dispatch(path["/tools/".len..], body, resp); + } + if (std.mem.eql(u8, path, "/status") or std.mem.eql(u8, path, "/health")) { + return .{ .status = 200, .body = statusJson(resp) }; + } + return .{ .status = 404, .body = errJson(resp, "Not found") }; +} + +// ============================================================================ +// gRPC-compat handler +// ============================================================================ + +fn dispatchGrpc(path: []const u8, body: []const u8, resp: []u8) Response { + const prefix = "/CircleciMcpService/"; + if (!std.mem.startsWith(u8, path, prefix)) + return .{ .status = 404, .body = errJson(resp, "Not a recognized gRPC path") }; + const method = path[prefix.len..]; + const tool = blk: { + if (std.mem.eql(u8, method, "CircleciListPipelines")) break :blk "circleci_list_pipelines"; + if (std.mem.eql(u8, method, "CircleciGetPipeline")) break :blk "circleci_get_pipeline"; + if (std.mem.eql(u8, method, "CircleciListWorkflows")) break :blk "circleci_list_workflows"; + if (std.mem.eql(u8, method, "CircleciGetWorkflow")) break :blk "circleci_get_workflow"; + if (std.mem.eql(u8, method, "CircleciListJobs")) break :blk "circleci_list_jobs"; + if (std.mem.eql(u8, method, "CircleciListArtifacts")) break :blk "circleci_list_artifacts"; + if (std.mem.eql(u8, method, "CircleciTriggerPipeline")) break :blk "circleci_trigger_pipeline"; + if (std.mem.eql(u8, method, "CircleciCancelWorkflow")) break :blk "circleci_cancel_workflow"; + if (std.mem.eql(u8, method, "CircleciListEnvvars")) break :blk "circleci_list_envvars"; + return .{ .status = 404, .body = errJson(resp, "Unknown gRPC method") }; + }; + return dispatch(tool, body, resp); +} + +// ============================================================================ +// GraphQL handler +// ============================================================================ + +fn dispatchGraphql(body: []const u8, resp: []u8) Response { + if (std.mem.indexOf(u8, body, "__schema") != null) + return .{ .status = 200, .body = okJson(resp, "schema introspection not yet supported") }; + if (std.mem.indexOf(u8, body, "list_pipelines") != null) return dispatch("circleci_list_pipelines", body, resp); + if (std.mem.indexOf(u8, body, "get_pipeline") != null) return dispatch("circleci_get_pipeline", body, resp); + if (std.mem.indexOf(u8, body, "list_workflows") != null) return dispatch("circleci_list_workflows", body, resp); + if (std.mem.indexOf(u8, body, "get_workflow") != null) return dispatch("circleci_get_workflow", body, resp); + if (std.mem.indexOf(u8, body, "list_jobs") != null) return dispatch("circleci_list_jobs", body, resp); + if (std.mem.indexOf(u8, body, "list_artifacts") != null) return dispatch("circleci_list_artifacts", body, resp); + if (std.mem.indexOf(u8, body, "trigger_pipeline") != null) return dispatch("circleci_trigger_pipeline", body, resp); + if (std.mem.indexOf(u8, body, "cancel_workflow") != null) return dispatch("circleci_cancel_workflow", body, resp); + if (std.mem.indexOf(u8, body, "list_envvars") != null) return dispatch("circleci_list_envvars", body, resp); + return .{ .status = 200, .body = errJson(resp, "Unrecognised GraphQL operation") }; +} + +// ============================================================================ +// HTTP/1.1 connection handler +// ============================================================================ + +const Protocol = enum { rest, grpc, graphql }; + +fn handleConnection(conn: std.net.Server.Connection, proto: Protocol) void { + defer conn.stream.close(); + var in_buf: [MAX_CONN_BUF]u8 = undefined; + const n = conn.stream.read(&in_buf) catch return; + const req = in_buf[0..n]; + + var path: []const u8 = "/"; + var body: []const u8 = ""; + if (n > 4) { + const line_end = std.mem.indexOf(u8, req, "\r\n") orelse req.len; + const first_line = req[0..line_end]; + const sp1 = std.mem.indexOfScalar(u8, first_line, ' ') orelse 0; + const rest_of = first_line[sp1 + 1 ..]; + const sp2 = std.mem.indexOfScalar(u8, rest_of, ' ') orelse rest_of.len; + path = rest_of[0..sp2]; + const body_sep = std.mem.indexOf(u8, req, "\r\n\r\n") orelse n; + body = req[@min(body_sep + 4, n)..]; + } + + var resp_buf: [MAX_CONN_BUF]u8 = undefined; + const result = switch (proto) { + .rest => dispatchRest(path, body, &resp_buf), + .grpc => dispatchGrpc(path, body, &resp_buf), + .graphql => dispatchGraphql(body, &resp_buf), + }; + + const content_type = switch (proto) { + .rest => "application/json", + .grpc => "application/grpc+json", + .graphql => "application/json", + }; + + var hdr_buf: [256]u8 = undefined; + const hdr = std.fmt.bufPrint(&hdr_buf, + "HTTP/1.1 {d} OK\r\nContent-Type: {s}\r\nContent-Length: {d}\r\nConnection: close\r\n\r\n", + .{ result.status, content_type, result.body.len }, + ) catch return; + _ = conn.stream.writeAll(hdr) catch return; + _ = conn.stream.writeAll(result.body) catch return; +} + +fn listenLoop(port: u16, proto: Protocol) void { + const addr = std.net.Address.initIp4(.{ 127, 0, 0, 1 }, port) catch return; + var server = addr.listen(.{ .reuse_address = true }) catch return; + defer server.deinit(); + while (true) { + const conn = server.accept() catch continue; + handleConnection(conn, proto); + } +} + +pub fn main() !void { + ffi.circleci_init(); + const rest_thread = try std.Thread.spawn(.{}, listenLoop, .{ REST_PORT, Protocol.rest }); + const grpc_thread = try std.Thread.spawn(.{}, listenLoop, .{ GRPC_PORT, Protocol.grpc }); + const gql_thread = try std.Thread.spawn(.{}, listenLoop, .{ GQL_PORT, Protocol.graphql }); + rest_thread.join(); + grpc_thread.join(); + gql_thread.join(); +} diff --git a/cartridges/domains/ci-cd/circleci-mcp/benchmarks/quick-bench.sh b/cartridges/domains/ci-cd/circleci-mcp/benchmarks/quick-bench.sh new file mode 100755 index 0000000..5cba6cd --- /dev/null +++ b/cartridges/domains/ci-cd/circleci-mcp/benchmarks/quick-bench.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Benchmarks for circleci-mcp cartridge. +set -euo pipefail + +echo "=== circleci-mcp benchmarks ===" + +cd "$(dirname "${BASH_SOURCE[0]}")/../ffi" +zig build -Doptimize=ReleaseFast 2>&1 + +echo "Session open/close cycle (1000 iterations):" +time for i in $(seq 1 1000); do true; done + +echo "" +echo "Benchmark placeholder -- implement real benchmarks in Zig test blocks" +echo "or via the zig adapter HTTP benchmark tool." diff --git a/cartridges/domains/ci-cd/circleci-mcp/cartridge.json b/cartridges/domains/ci-cd/circleci-mcp/cartridge.json new file mode 100644 index 0000000..8958f1a --- /dev/null +++ b/cartridges/domains/ci-cd/circleci-mcp/cartridge.json @@ -0,0 +1,213 @@ +{ + "$schema": "https://boj.dev/schemas/cartridge/v1.json", + "spdx": "MPL-2.0", + "copyright": "Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath)", + "name": "circleci-mcp", + "version": "0.1.0", + "description": "CircleCI CI/CD cartridge -- pipeline listing, workflow management, job inspection, artifact retrieval, project listing, environment variable browsing, pipeline triggering, job cancellation, and insight queries via the CircleCI API v2", + "domain": "CI/CD", + "tier": "Ayo", + "protocols": [ + "MCP", + "REST" + ], + "auth": { + "method": "bearer_token", + "env_var": "CIRCLECI_TOKEN", + "credential_source": "vault-mcp" + }, + "api": { + "base_url": "https://circleci.com/api/v2", + "content_type": "application/json" + }, + "tools": [ + { + "name": "circleci_list_pipelines", + "description": "List recent pipelines for a CircleCI project", + "inputSchema": { + "type": "object", + "properties": { + "project_slug": { + "type": "string", + "description": "Project slug (e.g. 'gh/owner/repo')" + }, + "branch": { + "type": "string", + "description": "Filter by branch" + }, + "page_token": { + "type": "string", + "description": "Pagination token" + } + }, + "required": [ + "project_slug" + ] + } + }, + { + "name": "circleci_get_pipeline", + "description": "Get details for a specific CircleCI pipeline by ID", + "inputSchema": { + "type": "object", + "properties": { + "pipeline_id": { + "type": "string", + "description": "Pipeline UUID" + } + }, + "required": [ + "pipeline_id" + ] + } + }, + { + "name": "circleci_list_workflows", + "description": "List workflows for a CircleCI pipeline", + "inputSchema": { + "type": "object", + "properties": { + "pipeline_id": { + "type": "string", + "description": "Pipeline UUID" + }, + "page_token": { + "type": "string", + "description": "Pagination token" + } + }, + "required": [ + "pipeline_id" + ] + } + }, + { + "name": "circleci_get_workflow", + "description": "Get details for a specific CircleCI workflow", + "inputSchema": { + "type": "object", + "properties": { + "workflow_id": { + "type": "string", + "description": "Workflow UUID" + } + }, + "required": [ + "workflow_id" + ] + } + }, + { + "name": "circleci_list_jobs", + "description": "List jobs for a CircleCI workflow with status and timing", + "inputSchema": { + "type": "object", + "properties": { + "workflow_id": { + "type": "string", + "description": "Workflow UUID" + }, + "page_token": { + "type": "string", + "description": "Pagination token" + } + }, + "required": [ + "workflow_id" + ] + } + }, + { + "name": "circleci_list_artifacts", + "description": "List artifacts produced by a CircleCI job", + "inputSchema": { + "type": "object", + "properties": { + "job_number": { + "type": "number", + "description": "Job number" + }, + "project_slug": { + "type": "string", + "description": "Project slug" + } + }, + "required": [ + "project_slug", + "job_number" + ] + } + }, + { + "name": "circleci_trigger_pipeline", + "description": "Trigger a new pipeline on a CircleCI project", + "inputSchema": { + "type": "object", + "properties": { + "project_slug": { + "type": "string", + "description": "Project slug" + }, + "branch": { + "type": "string", + "description": "Git branch" + }, + "tag": { + "type": "string", + "description": "Git tag (mutually exclusive with branch)" + }, + "parameters": { + "type": "object", + "description": "Pipeline parameters" + } + }, + "required": [ + "project_slug" + ] + } + }, + { + "name": "circleci_cancel_workflow", + "description": "Cancel a running CircleCI workflow", + "inputSchema": { + "type": "object", + "properties": { + "workflow_id": { + "type": "string", + "description": "Workflow UUID" + } + }, + "required": [ + "workflow_id" + ] + } + }, + { + "name": "circleci_list_envvars", + "description": "List environment variables for a CircleCI project (names only, values masked)", + "inputSchema": { + "type": "object", + "properties": { + "project_slug": { + "type": "string", + "description": "Project slug" + } + }, + "required": [ + "project_slug" + ] + } + } + ], + "ffi": { + "so_path": "ffi/zig-out/lib/libcircleci_mcp.so", + "abi_version": "ADR-0006", + "symbols": [ + "boj_cartridge_init", + "boj_cartridge_deinit", + "boj_cartridge_name", + "boj_cartridge_version", + "boj_cartridge_invoke" + ] + } +} diff --git a/cartridges/domains/ci-cd/circleci-mcp/ffi/README.adoc b/cartridges/domains/ci-cd/circleci-mcp/ffi/README.adoc new file mode 100644 index 0000000..45b3ca2 --- /dev/null +++ b/cartridges/domains/ci-cd/circleci-mcp/ffi/README.adoc @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += circleci-mcp / ffi β€” Zig FFI layer +:orientation: shallow + +== Purpose + +C-ABI exports bridging this cartridge's Idris2 ABI to the runtime. Consumed by +the adapter for protocol dispatch and by the trunk loader for mount. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” produces `zig-out/lib/libcircleci.so`. +| `circleci_mcp_ffi.zig` | C-ABI exports (14 exports, 5 inline tests, 274 lines). +|=== + +== Invariants + +* **Null-pointer rejection** on `[*c]const u8` arguments before any + dereference. +* **Negative return values** carry documented error meanings; zero is + success (matching the trunk error-code convention). +* **No allocator leaks** β€” every allocation path pairs with `defer free`. + +== Test/proof surface + +5 inline `test "..."` block(s) in `circleci_mcp_ffi.zig`. Run with +`cd ffi && zig build test`. + +== Read-first + +. `circleci_mcp_ffi.zig` β€” C-ABI signatures and guards. +. Trunk `ffi/zig/src/catalogue.zig` for how this `.so` is loaded. diff --git a/cartridges/domains/ci-cd/circleci-mcp/ffi/build.zig b/cartridges/domains/ci-cd/circleci-mcp/ffi/build.zig new file mode 100644 index 0000000..b6f1f2a --- /dev/null +++ b/cartridges/domains/ci-cd/circleci-mcp/ffi/build.zig @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + // Shared ADR-0006 invoke-shim module (relative path up to boj-server trunk). + + const shim_mod = b.addModule("cartridge_shim", .{ + + .root_source_file = b.path("../../../ffi/zig/src/cartridge_shim.zig"), + + .target = target, + + .optimize = optimize, + + }); + + const ffi_mod = b.addModule("circleci_mcp", .{ + .root_source_file = b.path("circleci_mcp_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + ffi_mod.addImport("cartridge_shim", shim_mod); + + const lib = b.addLibrary(.{ + .name = "circleci_mcp", + .root_module = ffi_mod, + .linkage = .dynamic, + }); + lib.linkLibC(); + b.installArtifact(lib); + + const tests = b.addTest(.{ .root_module = ffi_mod }); + tests.linkLibC(); + const run_tests = b.addRunArtifact(tests); + const test_step = b.step("test", "Run FFI tests"); + test_step.dependOn(&run_tests.step); +} + diff --git a/cartridges/domains/ci-cd/circleci-mcp/ffi/cartridge_shim.zig b/cartridges/domains/ci-cd/circleci-mcp/ffi/cartridge_shim.zig new file mode 100644 index 0000000..0e399db --- /dev/null +++ b/cartridges/domains/ci-cd/circleci-mcp/ffi/cartridge_shim.zig @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// cartridge_shim.zig β€” Shared helpers for the ADR-0006 five-symbol +// cartridge ABI (`boj_cartridge_init / deinit / name / version / invoke`). +// +// The shim centralises the seven-code return convention, NUL-argument +// guards, tool-name comparison, and the buffer-too-small path so each +// cartridge's `boj_cartridge_invoke` can stay short β€” typically a tool +// table plus `shim.writeResult(...)`. +// +// Cartridges import this file by relative path (no build-graph change +// needed). Example: +// +// const shim = @import("../../../ffi/zig/src/cartridge_shim.zig"); +// +// export fn boj_cartridge_invoke( +// tool_name: [*c]const u8, +// json_args: [*c]const u8, +// out_buf: [*c]u8, +// in_out_len: [*c]usize, +// ) callconv(.c) i32 { +// _ = json_args; +// if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; +// const body = if (shim.toolIs(tool_name, "foo")) "{\"result\":{}}" +// else return shim.RC_UNKNOWN_TOOL; +// return shim.writeResult(out_buf, in_out_len, body); +// } + +const std = @import("std"); + +// ── Return codes (ADR-0006 Β§Return codes) ──────────────────────────── +// +// Frozen by ADR-0006. New failure modes compose these via the error +// JSON body β€” the integer surface does not grow without a follow-up ADR. + +pub const RC_SUCCESS: i32 = 0; +pub const RC_UNKNOWN_TOOL: i32 = -1; +pub const RC_BAD_ARGS: i32 = -2; +pub const RC_BUFFER_TOO_SMALL: i32 = -3; +pub const RC_RUNTIME_ERROR: i32 = -4; +pub const RC_PANIC: i32 = -5; +pub const RC_AUTH_DENIED: i32 = -6; + +// ── Invoke-path helpers ────────────────────────────────────────────── + +/// True if any of the three mandatory `boj_cartridge_invoke` output-path +/// pointers is null. Use at the top of every invoke to short-circuit to +/// `RC_BAD_ARGS`. +pub fn invokeArgsNull( + tool_name: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) bool { + return tool_name == null or out_buf == null or in_out_len == null; +} + +/// Compare a C-NUL-terminated tool-name pointer against a Zig string +/// literal. Caller must have already verified `tool_name` is non-null +/// (usually via `invokeArgsNull`). +pub fn toolIs(tool_name: [*c]const u8, expected: []const u8) bool { + const s = std.mem.span(@as([*:0]const u8, @ptrCast(tool_name))); + return std.mem.eql(u8, s, expected); +} + +/// Copy `body` into `out_buf[0..*in_out_len]` (as a capacity) and update +/// `*in_out_len` to the number of bytes written. Returns `RC_SUCCESS`. +/// +/// If `body.len` exceeds the current capacity stored in `*in_out_len`, +/// sets `*in_out_len` to the required size and returns +/// `RC_BUFFER_TOO_SMALL` β€” the caller is then expected to re-allocate +/// and retry, per ADR-0006 Β§Memory ownership. +/// +/// Caller must have already verified that `out_buf` and `in_out_len` +/// are non-null. +pub fn writeResult( + out_buf: [*c]u8, + in_out_len: [*c]usize, + body: []const u8, +) i32 { + const cap = in_out_len.*; + if (body.len > cap) { + in_out_len.* = body.len; + return RC_BUFFER_TOO_SMALL; + } + @memcpy(out_buf[0..body.len], body); + in_out_len.* = body.len; + return RC_SUCCESS; +} + +// ── Tests ──────────────────────────────────────────────────────────── + +test "writeResult: body fits, writes and sets length" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); + try std.testing.expectEqualStrings("hello", buf[0..len]); +} + +test "writeResult: too small returns -3 and sets required length" { + var buf: [2]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_BUFFER_TOO_SMALL, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: exact-fit succeeds" { + var buf: [5]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: empty body" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, ""); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 0), len); +} + +test "toolIs: matches and rejects" { + const name: [*:0]const u8 = "foo"; + try std.testing.expect(toolIs(@ptrCast(name), "foo")); + try std.testing.expect(!toolIs(@ptrCast(name), "bar")); + try std.testing.expect(!toolIs(@ptrCast(name), "foobar")); + try std.testing.expect(!toolIs(@ptrCast(name), "fo")); +} + +test "invokeArgsNull: detects each null slot" { + var buf: [4]u8 = undefined; + var len: usize = 4; + const name: [*:0]const u8 = "x"; + try std.testing.expect(!invokeArgsNull(@ptrCast(name), &buf, &len)); + try std.testing.expect(invokeArgsNull(null, &buf, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), null, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), &buf, null)); +} diff --git a/cartridges/domains/ci-cd/circleci-mcp/ffi/circleci_mcp_ffi.zig b/cartridges/domains/ci-cd/circleci-mcp/ffi/circleci_mcp_ffi.zig new file mode 100644 index 0000000..4bab602 --- /dev/null +++ b/cartridges/domains/ci-cd/circleci-mcp/ffi/circleci_mcp_ffi.zig @@ -0,0 +1,380 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// circleci_mcp_ffi.zig β€” C-ABI FFI implementation for circleci-mcp cartridge. +// +// Implements the state machine defined in CircleciMcp.SafeRegistry (Idris2 ABI). +// State machine: Unauthenticated | Authenticated | RateLimited | Error +// Auth: Circle-Token required for all CircleCI API operations. +// Actions: ListPipelines, GetPipeline, ListWorkflows, GetWorkflow, ListJobs, +// ListArtifacts, TriggerPipeline, CancelWorkflow, ListEnvVars +// Thread-safe via std.Thread.Mutex. Fixed-size session pool, no heap allocations. + +const std = @import("std"); + +pub const SessionState = enum(c_int) { + unauthenticated = 0, + authenticated = 1, + rate_limited = 2, + err = 3, +}; + +pub const CircleciAction = enum(c_int) { + list_pipelines = 0, + get_pipeline = 1, + list_workflows = 2, + get_workflow = 3, + list_jobs = 4, + list_artifacts = 5, + trigger_pipeline = 6, + cancel_workflow = 7, + list_envvars = 8, +}; + +fn isValidTransition(from: SessionState, to: SessionState) bool { + return switch (from) { + .unauthenticated => to == .authenticated or to == .err, + .authenticated => to == .unauthenticated or to == .rate_limited or to == .err, + .rate_limited => to == .authenticated, + .err => to == .authenticated or to == .unauthenticated, + }; +} + +const MAX_SESSIONS: usize = 16; + +const SessionSlot = struct { + active: bool = false, + state: SessionState = .unauthenticated, + api_call_count: u64 = 0, + last_action: c_int = -1, + pipeline_ops: u32 = 0, + workflow_ops: u32 = 0, + config_ops: u32 = 0, +}; + +var sessions: [MAX_SESSIONS]SessionSlot = .{SessionSlot{}} ** MAX_SESSIONS; +var mutex: std.Thread.Mutex = .{}; + +pub export fn circleci_mcp_can_transition(from: c_int, to: c_int) c_int { + const f = std.meta.intToEnum(SessionState, from) catch return 0; + const t = std.meta.intToEnum(SessionState, to) catch return 0; + return if (isValidTransition(f, t)) 1 else 0; +} + +pub export fn circleci_mcp_authenticate(dummy: c_int) c_int { + _ = dummy; + mutex.lock(); + defer mutex.unlock(); + + for (&sessions, 0..) |*slot, idx| { + if (!slot.active) { + slot.active = true; + slot.state = .authenticated; + slot.api_call_count = 0; + slot.last_action = -1; + slot.pipeline_ops = 0; + slot.workflow_ops = 0; + slot.config_ops = 0; + return @intCast(idx); + } + } + return -1; +} + +pub export fn circleci_mcp_close(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + + sessions[idx] = SessionSlot{}; + return 0; +} + +pub export fn circleci_mcp_session_state(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + return @intFromEnum(slot.state); +} + +pub export fn circleci_mcp_throttle(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + if (!isValidTransition(slot.state, .rate_limited)) return -2; + + sessions[idx].state = .rate_limited; + return 0; +} + +pub export fn circleci_mcp_unthrottle(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + if (!isValidTransition(slot.state, .authenticated)) return -2; + + sessions[idx].state = .authenticated; + return 0; +} + +pub export fn circleci_mcp_signal_error(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + if (!isValidTransition(slot.state, .err)) return -2; + + sessions[idx].state = .err; + return 0; +} + +pub export fn circleci_mcp_record_call(slot_idx: c_int, action: c_int) c_int { + const act = std.meta.intToEnum(CircleciAction, action) catch return -3; + + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + if (slot.state == .rate_limited) return -2; + if (slot.state == .err) return -2; + if (slot.state == .unauthenticated) return -2; + + sessions[idx].api_call_count += 1; + sessions[idx].last_action = action; + + switch (act) { + .list_pipelines, .get_pipeline, .trigger_pipeline => sessions[idx].pipeline_ops += 1, + .list_workflows, .get_workflow, .list_jobs, .list_artifacts, .cancel_workflow => sessions[idx].workflow_ops += 1, + .list_envvars => sessions[idx].config_ops += 1, + } + + return 0; +} + +pub export fn circleci_mcp_call_count(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + if (!sessions[idx].active) return -1; + return @intCast(sessions[idx].api_call_count); +} + +pub export fn circleci_mcp_pipeline_op_count(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + if (!sessions[idx].active) return -1; + return @intCast(sessions[idx].pipeline_ops); +} + +pub export fn circleci_mcp_workflow_op_count(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + if (!sessions[idx].active) return -1; + return @intCast(sessions[idx].workflow_ops); +} + +pub export fn circleci_mcp_config_op_count(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + if (!sessions[idx].active) return -1; + return @intCast(sessions[idx].config_ops); +} + +pub export fn circleci_mcp_action_count() c_int { + return 9; +} + +pub export fn circleci_mcp_reset() void { + mutex.lock(); + defer mutex.unlock(); + sessions = .{SessionSlot{}} ** MAX_SESSIONS; +} + +// ═══════════════════════════════════════════════════════════════════════ +// Standard ABI (ADR-0005 four symbols + ADR-0006 invoke) +// ═══════════════════════════════════════════════════════════════════════ + +const shim = @import("cartridge_shim.zig"); + +const CARTRIDGE_NAME_PTR: [*:0]const u8 = "circleci-mcp"; +const CARTRIDGE_VERSION_PTR: [*:0]const u8 = "0.1.0"; + +export fn boj_cartridge_init() callconv(.c) c_int { + return 0; +} + +export fn boj_cartridge_deinit() callconv(.c) void {} + +export fn boj_cartridge_name() callconv(.c) [*:0]const u8 { + return CARTRIDGE_NAME_PTR; +} + +export fn boj_cartridge_version() callconv(.c) [*:0]const u8 { + return CARTRIDGE_VERSION_PTR; +} + +/// Dispatch the cartridge.json MCP tools. Grade D Alpha stubs. +export fn boj_cartridge_invoke( + tool_name: [*c]const u8, + json_args: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) callconv(.c) i32 { + _ = json_args; + if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; + + const body: []const u8 = if (shim.toolIs(tool_name, "circleci_list_pipelines")) + "{\"result\":{\"items\":[],\"count\":0,\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "circleci_get_pipeline")) + "{\"result\":{\"metadata\":{},\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "circleci_list_workflows")) + "{\"result\":{\"items\":[],\"count\":0,\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "circleci_get_workflow")) + "{\"result\":{\"metadata\":{},\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "circleci_list_jobs")) + "{\"result\":{\"items\":[],\"count\":0,\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "circleci_list_artifacts")) + "{\"result\":{\"items\":[],\"count\":0,\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "circleci_trigger_pipeline")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "circleci_cancel_workflow")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "circleci_list_envvars")) + "{\"result\":{\"items\":[],\"count\":0,\"status\":\"stub\"}}" +else + return shim.RC_UNKNOWN_TOOL; + + return shim.writeResult(out_buf, in_out_len, body); +} + +test "authenticated session lifecycle" { + circleci_mcp_reset(); + const slot = circleci_mcp_authenticate(0); + try std.testing.expect(slot >= 0); + try std.testing.expectEqual(@as(c_int, 1), circleci_mcp_session_state(slot)); + try std.testing.expectEqual(@as(c_int, 0), circleci_mcp_record_call(slot, 0)); + try std.testing.expectEqual(@as(c_int, 1), circleci_mcp_pipeline_op_count(slot)); + try std.testing.expectEqual(@as(c_int, 0), circleci_mcp_close(slot)); +} + +test "rate limiting flow" { + circleci_mcp_reset(); + const slot = circleci_mcp_authenticate(0); + try std.testing.expect(slot >= 0); + try std.testing.expectEqual(@as(c_int, 0), circleci_mcp_throttle(slot)); + try std.testing.expectEqual(@as(c_int, -2), circleci_mcp_record_call(slot, 0)); + try std.testing.expectEqual(@as(c_int, 0), circleci_mcp_unthrottle(slot)); + try std.testing.expectEqual(@as(c_int, 1), circleci_mcp_session_state(slot)); +} + +test "category counting" { + circleci_mcp_reset(); + const slot = circleci_mcp_authenticate(0); + try std.testing.expect(slot >= 0); + // ListPipelines (0) + try std.testing.expectEqual(@as(c_int, 0), circleci_mcp_record_call(slot, 0)); + // ListWorkflows (2) + try std.testing.expectEqual(@as(c_int, 0), circleci_mcp_record_call(slot, 2)); + // ListEnvVars (8) + try std.testing.expectEqual(@as(c_int, 0), circleci_mcp_record_call(slot, 8)); + try std.testing.expectEqual(@as(c_int, 3), circleci_mcp_call_count(slot)); + try std.testing.expectEqual(@as(c_int, 1), circleci_mcp_pipeline_op_count(slot)); + try std.testing.expectEqual(@as(c_int, 1), circleci_mcp_workflow_op_count(slot)); + try std.testing.expectEqual(@as(c_int, 1), circleci_mcp_config_op_count(slot)); +} + +test "transition validator" { + try std.testing.expectEqual(@as(c_int, 1), circleci_mcp_can_transition(0, 1)); + try std.testing.expectEqual(@as(c_int, 1), circleci_mcp_can_transition(1, 2)); + try std.testing.expectEqual(@as(c_int, 0), circleci_mcp_can_transition(0, 2)); +} + +test "slot exhaustion" { + circleci_mcp_reset(); + var slots: [MAX_SESSIONS]c_int = undefined; + for (&slots) |*s| { + s.* = circleci_mcp_authenticate(0); + try std.testing.expect(s.* >= 0); + } + try std.testing.expectEqual(@as(c_int, -1), circleci_mcp_authenticate(0)); + try std.testing.expectEqual(@as(c_int, 0), circleci_mcp_close(slots[0])); + try std.testing.expect(circleci_mcp_authenticate(0) >= 0); +} + +// ═══════════════════════════════════════════════════════════════════════ +// ADR-0006 invoke dispatch tests +// ═══════════════════════════════════════════════════════════════════════ + +test "boj_cartridge_name returns circleci-mcp" { + const n = std.mem.span(boj_cartridge_name()); + try std.testing.expectEqualStrings("circleci-mcp", n); +} + +test "boj_cartridge_init returns 0" { + try std.testing.expectEqual(@as(c_int, 0), boj_cartridge_init()); +} + +test "invoke: each declared tool succeeds" { + var buf: [256]u8 = undefined; + const tools = [_][]const u8{ + "circleci_list_pipelines", + "circleci_get_pipeline", + "circleci_list_workflows", + "circleci_get_workflow", + "circleci_list_jobs", + "circleci_list_artifacts", + "circleci_trigger_pipeline", + "circleci_cancel_workflow", + "circleci_list_envvars", + }; + for (tools) |t| { + var len: usize = buf.len; + const rc = boj_cartridge_invoke(t.ptr, "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, 0), rc); + try std.testing.expect(std.mem.indexOf(u8, buf[0..len], "result") != null); + } +} + +test "invoke: unknown tool returns -1" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("nope", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -1), rc); +} + +test "invoke: buffer too small returns -3" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("circleci_list_pipelines", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -3), rc); + try std.testing.expect(len > 4); +} diff --git a/cartridges/domains/ci-cd/circleci-mcp/minter.toml b/cartridges/domains/ci-cd/circleci-mcp/minter.toml new file mode 100644 index 0000000..1c3b57e --- /dev/null +++ b/cartridges/domains/ci-cd/circleci-mcp/minter.toml @@ -0,0 +1,22 @@ +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> + +name = "circleci-mcp" +description = "CircleCI CI/CD cartridge β€” pipeline listing, workflow management, job inspection, artifacts, pipeline triggering, workflow cancellation, environment variables" +version = "0.1.0" +domain = "CI/CD" +protocols = [ + "MCP", + "REST", +] +tier = "Ayo" +backend = "universal" +generate_panel = true + +[auth] +method = "bearer_token" +credential_source = "vault-mcp" +fields = ["circleci_token"] + +[api] +base_url = "https://circleci.com/api/v2" diff --git a/cartridges/domains/ci-cd/circleci-mcp/mod.js b/cartridges/domains/ci-cd/circleci-mcp/mod.js new file mode 100644 index 0000000..5873d4c --- /dev/null +++ b/cartridges/domains/ci-cd/circleci-mcp/mod.js @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// circleci-mcp/mod.js -- CircleCI CI/CD cartridge implementation. +// +// Provides MCP tool handlers for the CircleCI API v2: +// - Pipeline listing and detail retrieval +// - Workflow listing and detail retrieval +// - Job listing with status and timing +// - Artifact listing +// - Pipeline triggering +// - Workflow cancellation +// - Environment variable listing (names only, values masked) +// +// Auth: Bearer token via CIRCLECI_TOKEN (required for all operations). +// API docs: https://circleci.com/docs/api/v2/ +// +// Usage: import { handleTool } from "./mod.js"; +// or: deno run --allow-net --allow-env mod.js + +const API_BASE = "https://circleci.com/api/v2"; + +function getToken() { + const token = typeof Deno !== "undefined" + ? Deno.env.get("CIRCLECI_TOKEN") + : process.env.CIRCLECI_TOKEN; + return token || null; +} + +async function cciFetch(path, { method = "GET", queryParams, body } = {}) { + const url = new URL(`${API_BASE}${path}`); + + if (queryParams) { + for (const [key, value] of Object.entries(queryParams)) { + if (value !== undefined && value !== null && value !== "") { + url.searchParams.set(key, String(value)); + } + } + } + + const headers = { + "Accept": "application/json", + "Content-Type": "application/json", + }; + + const token = getToken(); + if (token) { + headers["Circle-Token"] = token; + } + + const options = { method, headers }; + if (body) { + options.body = JSON.stringify(body); + } + + const response = await fetch(url.toString(), options); + + if (response.status === 429) { + const retryAfter = response.headers.get("retry-after"); + return { status: 429, error: `Rate limited by CircleCI. Retry after ${retryAfter || "unknown"} seconds.`, retryAfter }; + } + + const data = await response.json().catch(() => ({})); + + if (!response.ok) { + const errorMessage = data.message || `HTTP ${response.status}`; + return { status: response.status, error: errorMessage, data }; + } + + return { status: response.status, data }; +} + +export async function handleTool(toolName, args) { + switch (toolName) { + + case "circleci_list_pipelines": { + if (!args.project_slug) return { error: "Missing required field: project_slug" }; + const query = { branch: args.branch, "page-token": args.page_token }; + return cciFetch(`/project/${args.project_slug}/pipeline`, { queryParams: query }); + } + + case "circleci_get_pipeline": { + if (!args.pipeline_id) return { error: "Missing required field: pipeline_id" }; + return cciFetch(`/pipeline/${encodeURIComponent(args.pipeline_id)}`); + } + + case "circleci_list_workflows": { + if (!args.pipeline_id) return { error: "Missing required field: pipeline_id" }; + const query = { "page-token": args.page_token }; + return cciFetch(`/pipeline/${encodeURIComponent(args.pipeline_id)}/workflow`, { queryParams: query }); + } + + case "circleci_get_workflow": { + if (!args.workflow_id) return { error: "Missing required field: workflow_id" }; + return cciFetch(`/workflow/${encodeURIComponent(args.workflow_id)}`); + } + + case "circleci_list_jobs": { + if (!args.workflow_id) return { error: "Missing required field: workflow_id" }; + const query = { "page-token": args.page_token }; + return cciFetch(`/workflow/${encodeURIComponent(args.workflow_id)}/job`, { queryParams: query }); + } + + case "circleci_list_artifacts": { + if (!args.project_slug) return { error: "Missing required field: project_slug" }; + if (!args.job_number) return { error: "Missing required field: job_number" }; + return cciFetch(`/project/${args.project_slug}/${args.job_number}/artifacts`); + } + + case "circleci_trigger_pipeline": { + if (!args.project_slug) return { error: "Missing required field: project_slug" }; + const body = { + branch: args.branch, + tag: args.tag, + parameters: args.parameters || {}, + }; + return cciFetch(`/project/${args.project_slug}/pipeline`, { method: "POST", body }); + } + + case "circleci_cancel_workflow": { + if (!args.workflow_id) return { error: "Missing required field: workflow_id" }; + return cciFetch(`/workflow/${encodeURIComponent(args.workflow_id)}/cancel`, { method: "POST" }); + } + + case "circleci_list_envvars": { + if (!args.project_slug) return { error: "Missing required field: project_slug" }; + return cciFetch(`/project/${args.project_slug}/envvar`); + } + + default: + return { error: `Unknown circleci-mcp tool: ${toolName}` }; + } +} + +export const metadata = { + name: "circleci-mcp", + version: "0.1.0", + domain: "CI/CD", + tier: "Ayo", + protocols: ["MCP", "REST"], + toolCount: 9, +}; diff --git a/cartridges/domains/ci-cd/circleci-mcp/panels/manifest.json b/cartridges/domains/ci-cd/circleci-mcp/panels/manifest.json new file mode 100644 index 0000000..0dbc240 --- /dev/null +++ b/cartridges/domains/ci-cd/circleci-mcp/panels/manifest.json @@ -0,0 +1,42 @@ +{ + "$schema": "https://panll.dev/schemas/panel-manifest/v1.json", + "spdx": "MPL-2.0", + "copyright": "Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath)", + "cartridge": "circleci-mcp", + "domain": "CI/CD", + "version": "0.1.0", + "panels": [ + { + "id": "circleci-auth-status", + "title": "Auth Status", + "description": "CircleCI session state", + "type": "status-indicator", + "data_source": { "endpoint": "/circleci/state", "method": "GET", "refresh_interval_ms": 5000 }, + "widgets": [{ "type": "state-badge", "field": "state", "states": { "unauthenticated": { "color": "#95a5a6", "icon": "user-x" }, "authenticated": { "color": "#2ecc71", "icon": "user-check" }, "rate_limited": { "color": "#f39c12", "icon": "clock" }, "error": { "color": "#e74c3c", "icon": "alert-triangle" } } }] + }, + { + "id": "circleci-pipeline-ops", + "title": "Pipeline Operations", + "description": "Number of pipeline listing, retrieval, and triggering operations", + "type": "metric", + "data_source": { "endpoint": "/circleci/status", "method": "GET", "refresh_interval_ms": 10000 }, + "widgets": [{ "type": "counter", "field": "pipeline_ops", "label": "Pipeline Ops", "icon": "layers" }] + }, + { + "id": "circleci-workflow-ops", + "title": "Workflow Operations", + "description": "Number of workflow, job, artifact, and cancellation operations", + "type": "metric", + "data_source": { "endpoint": "/circleci/status", "method": "GET", "refresh_interval_ms": 10000 }, + "widgets": [{ "type": "counter", "field": "workflow_ops", "label": "Workflow Ops", "icon": "git-merge" }] + }, + { + "id": "circleci-config-ops", + "title": "Config Operations", + "description": "Number of environment variable listing operations", + "type": "metric", + "data_source": { "endpoint": "/circleci/status", "method": "GET", "refresh_interval_ms": 10000 }, + "widgets": [{ "type": "counter", "field": "config_ops", "label": "Config Ops", "icon": "settings" }] + } + ] +} diff --git a/cartridges/domains/ci-cd/circleci-mcp/tests/integration_test.sh b/cartridges/domains/ci-cd/circleci-mcp/tests/integration_test.sh new file mode 100755 index 0000000..974bef2 --- /dev/null +++ b/cartridges/domains/ci-cd/circleci-mcp/tests/integration_test.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Integration tests for circleci-mcp cartridge. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CART_DIR="$(dirname "$SCRIPT_DIR")" +FFI_DIR="$CART_DIR/ffi" + +echo "=== circleci-mcp integration tests ===" + +echo "[1/3] Building FFI..." +cd "$FFI_DIR" && zig build 2>&1 + +echo "[2/3] Running FFI unit tests..." +cd "$FFI_DIR" && zig build test 2>&1 + +echo "[3/3] Checking ABI..." +if command -v idris2 &>/dev/null; then + cd "$CART_DIR/abi" && idris2 --check CircleciMcp.SafeRegistry 2>&1 + echo " ABI: OK" +else + echo " ABI: SKIPPED (idris2 not in PATH)" +fi + +echo "" +echo "All tests passed for circleci-mcp!" diff --git a/cartridges/domains/ci-cd/github-actions-mcp/README.adoc b/cartridges/domains/ci-cd/github-actions-mcp/README.adoc new file mode 100644 index 0000000..bbb0005 --- /dev/null +++ b/cartridges/domains/ci-cd/github-actions-mcp/README.adoc @@ -0,0 +1,71 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += github-actions-mcp +Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk> +:spdx: MPL-2.0 +:tier: Ayo +:domain: CI/CD +:protocols: MCP, REST + +== Overview + +GitHub Actions CI/CD cartridge for the BoJ server. Provides type-safe access to +the GitHub Actions REST API for workflow listing, run management, job/step +inspection, artifact listing, log retrieval, workflow dispatch, re-run, +cancellation, secret listing, runner listing, and cache management. + +=== State Machine + +`Unauthenticated -> Authenticated` (required for all operations) + +`Authenticated -> RateLimited -> Authenticated` (normal flow) + +`Authenticated -> Error -> Unauthenticated` (error recovery) + +=== Actions (12) + +[cols="1,1"] +|=== +| Category | Actions + +| Workflows +| ListWorkflows, DispatchWorkflow + +| Runs +| ListRuns, GetRun, ListJobs, GetLogs, ListArtifacts, RerunWorkflow, CancelRun + +| Infrastructure +| ListSecrets, ListRunners, ListCaches +|=== + +== Architecture + +[cols="1,1,2"] +|=== +| Layer | Language | Purpose + +| ABI +| Idris2 +| Formally verified state machine (GithubActionsMcp.SafeRegistry) + +| FFI +| Zig +| Thread-safe session pool, action recording, category counting + +| Adapter +| zig +| REST bridge to BoJ unified adapter protocol +|=== + +== Building + +[source,bash] +---- +cd ffi && zig build +cd ffi && zig build test +cd abi && idris2 --check GithubActionsMcp.SafeRegistry +---- + +== Status + +Development -- Tier 6 CI/CD cartridge with full GitHub Actions workflow management. diff --git a/cartridges/domains/ci-cd/github-actions-mcp/abi/GithubActionsMcp/SafeRegistry.idr b/cartridges/domains/ci-cd/github-actions-mcp/abi/GithubActionsMcp/SafeRegistry.idr new file mode 100644 index 0000000..1828146 --- /dev/null +++ b/cartridges/domains/ci-cd/github-actions-mcp/abi/GithubActionsMcp/SafeRegistry.idr @@ -0,0 +1,170 @@ +-- SPDX-License-Identifier: MPL-2.0 +-- Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +-- +-- GithubActionsMcp.SafeRegistry β€” Type-safe ABI for github-actions-mcp cartridge. +-- +-- Dependent-type state machine governing GitHub Actions API access. +-- Encodes Bearer token auth, workflow listing, run management, job inspection, +-- artifact listing, log retrieval, dispatch, re-run, cancellation, secret listing, +-- runner listing, and cache management as compile-time invariants. +-- API: https://docs.github.com/en/rest/actions +-- No unsafe escape hatches. + +module GithubActionsMcp.SafeRegistry + +%default total + +-- --------------------------------------------------------------------------- +-- Authentication state machine +-- --------------------------------------------------------------------------- + +||| Session state for GitHub Actions MCP operations. +public export +data SessionState + = Unauthenticated + | Authenticated + | RateLimited + | Error + +||| Proof that a state transition is valid. +||| GitHub Actions requires authentication for all operations. +public export +data ValidTransition : SessionState -> SessionState -> Type where + Authenticate : ValidTransition Unauthenticated Authenticated + Deauthenticate : ValidTransition Authenticated Unauthenticated + Throttle : ValidTransition Authenticated RateLimited + Unthrottle : ValidTransition RateLimited Authenticated + AuthError : ValidTransition Authenticated Error + AnonError : ValidTransition Unauthenticated Error + RecoverToAuth : ValidTransition Error Authenticated + RecoverToAnon : ValidTransition Error Unauthenticated + +-- --------------------------------------------------------------------------- +-- C-ABI integer encoding +-- --------------------------------------------------------------------------- + +export +sessionStateToInt : SessionState -> Int +sessionStateToInt Unauthenticated = 0 +sessionStateToInt Authenticated = 1 +sessionStateToInt RateLimited = 2 +sessionStateToInt Error = 3 + +export +intToSessionState : Int -> Maybe SessionState +intToSessionState 0 = Just Unauthenticated +intToSessionState 1 = Just Authenticated +intToSessionState 2 = Just RateLimited +intToSessionState 3 = Just Error +intToSessionState _ = Nothing + +export +gha_mcp_can_transition : Int -> Int -> Int +gha_mcp_can_transition from to = + case (intToSessionState from, intToSessionState to) of + (Just Unauthenticated, Just Authenticated) => 1 + (Just Authenticated, Just Unauthenticated) => 1 + (Just Authenticated, Just RateLimited) => 1 + (Just RateLimited, Just Authenticated) => 1 + (Just Authenticated, Just Error) => 1 + (Just Unauthenticated, Just Error) => 1 + (Just Error, Just Authenticated) => 1 + (Just Error, Just Unauthenticated) => 1 + _ => 0 + +-- --------------------------------------------------------------------------- +-- GitHub Actions actions +-- --------------------------------------------------------------------------- + +||| Actions available through the GitHub Actions MCP cartridge. +public export +data GhaAction + = ListWorkflows + | ListRuns + | GetRun + | ListJobs + | GetLogs + | ListArtifacts + | DispatchWorkflow + | RerunWorkflow + | CancelRun + | ListSecrets + | ListRunners + | ListCaches + +||| Whether an action requires Authenticated state. +export +actionRequiresAuth : GhaAction -> Bool +actionRequiresAuth _ = True + +||| Whether an action is a write/mutating operation. +export +actionIsMutating : GhaAction -> Bool +actionIsMutating DispatchWorkflow = True +actionIsMutating RerunWorkflow = True +actionIsMutating CancelRun = True +actionIsMutating _ = False + +||| Encode action as C-compatible integer for FFI. +export +actionToInt : GhaAction -> Int +actionToInt ListWorkflows = 0 +actionToInt ListRuns = 1 +actionToInt GetRun = 2 +actionToInt ListJobs = 3 +actionToInt GetLogs = 4 +actionToInt ListArtifacts = 5 +actionToInt DispatchWorkflow = 6 +actionToInt RerunWorkflow = 7 +actionToInt CancelRun = 8 +actionToInt ListSecrets = 9 +actionToInt ListRunners = 10 +actionToInt ListCaches = 11 + +||| Decode integer to GHA action. +export +intToAction : Int -> Maybe GhaAction +intToAction 0 = Just ListWorkflows +intToAction 1 = Just ListRuns +intToAction 2 = Just GetRun +intToAction 3 = Just ListJobs +intToAction 4 = Just GetLogs +intToAction 5 = Just ListArtifacts +intToAction 6 = Just DispatchWorkflow +intToAction 7 = Just RerunWorkflow +intToAction 8 = Just CancelRun +intToAction 9 = Just ListSecrets +intToAction 10 = Just ListRunners +intToAction 11 = Just ListCaches +intToAction _ = Nothing + +-- --------------------------------------------------------------------------- +-- MCP tool declarations +-- --------------------------------------------------------------------------- + +public export +data McpTool + = ToolListWorkflows + | ToolListRuns + | ToolGetRun + | ToolListJobs + | ToolGetLogs + | ToolListArtifacts + | ToolDispatchWorkflow + | ToolRerunWorkflow + | ToolCancelRun + | ToolListSecrets + | ToolListRunners + | ToolListCaches + +export +toolRequiresSession : McpTool -> Bool +toolRequiresSession _ = True + +export +toolCount : Nat +toolCount = 12 + +export +actionCount : Nat +actionCount = 12 diff --git a/cartridges/domains/ci-cd/github-actions-mcp/abi/README.adoc b/cartridges/domains/ci-cd/github-actions-mcp/abi/README.adoc new file mode 100644 index 0000000..59144c5 --- /dev/null +++ b/cartridges/domains/ci-cd/github-actions-mcp/abi/README.adoc @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += github-actions-mcp / abi β€” Idris2 ABI layer +:orientation: shallow + +== Purpose + +Cartridge-local protocol types for `github-actions-mcp`. Defines the data, tool, and +transition vocabulary this cartridge speaks. Inherits safety lemmas and the +matrix-cell identifier scheme from the trunk ABI at `src/abi/`. + +== Files + +1 Idris2 module(s), ~170 lines total. Lead module: +`SafeRegistry.idr`. + +No `.ipkg` β€” consumed directly by wider BoJ callers; type-checks inside the +trunk `boj.ipkg`. + +== Invariants + +* `%default total` at every module top. +* Zero `believe_me`, `assert_total`, `postulate`, `Admitted`, or + `sorry` (the trunk holds the four declared `believe_me` sites; leaf + cartridges carry none). + +== Test/proof surface + +Thin module(s), no inline tests. Proof surface = type-check success under the +trunk `idris2 --check`. + +== Read-first + +. `SafeRegistry.idr` β€” cartridge-local data records and enums. +. Trunk `src/abi/Boj/Catalogue.idr` for how this cell participates in the + 2D matrix. diff --git a/cartridges/domains/ci-cd/github-actions-mcp/adapter/README.adoc b/cartridges/domains/ci-cd/github-actions-mcp/adapter/README.adoc new file mode 100644 index 0000000..cdc06bd --- /dev/null +++ b/cartridges/domains/ci-cd/github-actions-mcp/adapter/README.adoc @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += github-actions-mcp / adapter β€” protocol bridge +:orientation: shallow + +== Purpose + +Exposes this cartridge's FFI over the wire protocols declared in +`cartridge.json`. Dispatches tool invocations to the C-ABI exports at +`../ffi/` and formats responses per protocol. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” depends on the sibling `ffi` target. +| `github_actions_mcp_adapter.zig` | Protocol dispatch (144 lines). Tool catalogue matches `cartridge.json`. +| `SIDELINED-*.v.adoc` | zig predecessor(s). Not built β€” zig banned 2026-04-10. +|=== + +== Invariants + +* **Stateless** β€” all state lives behind the FFI, never in the adapter. +* **Response passthrough** β€” whatever the FFI returns goes back to the wire + unmodified (no embellishment, no silent recovery). +* **Cartridge.json is source of truth** for the tool catalogue; drift between + adapter and manifest is a CI failure. + +== Test/proof surface + +No inline tests at the adapter layer β€” protocol routing is exercised by the +trunk seam tests at `ffi/zig/src/seams.zig`. + +== Read-first + +. `github_actions_mcp_adapter.zig` β€” dispatch function and per-protocol routers. +. `../cartridge.json` β€” the tool manifest the dispatcher mirrors. diff --git a/cartridges/domains/ci-cd/github-actions-mcp/adapter/build.zig b/cartridges/domains/ci-cd/github-actions-mcp/adapter/build.zig new file mode 100644 index 0000000..2f3768d --- /dev/null +++ b/cartridges/domains/ci-cd/github-actions-mcp/adapter/build.zig @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// github-actions-mcp/adapter/build.zig + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const ffi_mod = b.createModule(.{ + .root_source_file = b.path("../ffi/github_actions_mcp_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + const adapter = b.addExecutable(.{ + .name = "github_actions_mcp_adapter", + .root_source_file = b.path("github_actions_mcp_adapter.zig"), + .target = target, + .optimize = optimize, + }); + adapter.root_module.addImport("github_actions_mcp_ffi", ffi_mod); + b.installArtifact(adapter); +} diff --git a/cartridges/domains/ci-cd/github-actions-mcp/adapter/github_actions_mcp_adapter.zig b/cartridges/domains/ci-cd/github-actions-mcp/adapter/github_actions_mcp_adapter.zig new file mode 100644 index 0000000..2c8b54d --- /dev/null +++ b/cartridges/domains/ci-cd/github-actions-mcp/adapter/github_actions_mcp_adapter.zig @@ -0,0 +1,144 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// github-actions-mcp/adapter/github_actions_mcp_adapter.zig +// +// Three-protocol BoJ adapter: REST (port 9241), gRPC-compat (port 9242), +// GraphQL (port 9243). +// Replaces the banned zig adapter (github_actions_mcp_adapter.v). + +const std = @import("std"); +const ffi = @import("github_actions_mcp_ffi"); + +const REST_PORT: u16 = 9241; +const GRPC_PORT: u16 = 9242; +const GQL_PORT: u16 = 9243; + +const Response = struct { status: u16, body: []const u8 }; + +fn okJson(buf: []u8, msg: []const u8) []u8 { + return std.fmt.bufPrint(buf, "{{\"success\":true,\"message\":\"{s}\"}}", .{msg}) catch buf[0..0]; +} + +fn errJson(buf: []u8, msg: []const u8) []u8 { + return std.fmt.bufPrint(buf, "{{\"success\":false,\"error\":\"{s}\"}}", .{msg}) catch buf[0..0]; +} + +fn dispatch(tool: []const u8, _body: []const u8, resp: []u8) Response { + _ = _body; + if (std.mem.eql(u8, tool, "gha_authenticate")) { + return .{ .status = 200, .body = okJson(resp, "gha_authenticate forwarded") }; + } + if (std.mem.eql(u8, tool, "gha_list_workflows")) { + return .{ .status = 200, .body = okJson(resp, "gha_list_workflows forwarded") }; + } + if (std.mem.eql(u8, tool, "gha_list_runs")) { + return .{ .status = 200, .body = okJson(resp, "gha_list_runs forwarded") }; + } + if (std.mem.eql(u8, tool, "gha_get_run")) { + return .{ .status = 200, .body = okJson(resp, "gha_get_run forwarded") }; + } + if (std.mem.eql(u8, tool, "gha_dispatch_workflow")) { + return .{ .status = 200, .body = okJson(resp, "gha_dispatch_workflow forwarded") }; + } + if (std.mem.eql(u8, tool, "gha_cancel_run")) { + return .{ .status = 200, .body = okJson(resp, "gha_cancel_run forwarded") }; + } + if (std.mem.eql(u8, tool, "gha_list_jobs")) { + return .{ .status = 200, .body = okJson(resp, "gha_list_jobs forwarded") }; + } + return .{ .status = 404, .body = errJson(resp, "unknown tool") }; +} + +fn dispatchRest(path: []const u8, body: []const u8, resp: []u8) Response { + // Expect /tools/<tool_name> + const prefix = "/tools/"; + if (std.mem.startsWith(u8, path, prefix)) { + const tool = path[prefix.len..]; + return dispatch(tool, body, resp); + } + return .{ .status = 404, .body = errJson(resp, "not found") }; +} + +fn dispatchGrpc(path: []const u8, body: []const u8, resp: []u8) Response { + // Expect /<Service>/<Method> β€” derive tool from Method + var it = std.mem.splitScalar(u8, path, '/'); + _ = it.next(); // leading empty + _ = it.next(); // service + const method = it.next() orelse return .{ .status = 404, .body = errJson(resp, "bad gRPC path") }; + return dispatch(method, body, resp); +} + +fn dispatchGraphql(body: []const u8, resp: []u8) Response { + if (std.mem.indexOf(u8, body, "gha_authenticate") != null) + return dispatch("gha_authenticate", body, resp); + if (std.mem.indexOf(u8, body, "gha_list_workflows") != null) + return dispatch("gha_list_workflows", body, resp); + if (std.mem.indexOf(u8, body, "gha_list_runs") != null) + return dispatch("gha_list_runs", body, resp); + if (std.mem.indexOf(u8, body, "gha_get_run") != null) + return dispatch("gha_get_run", body, resp); + if (std.mem.indexOf(u8, body, "gha_dispatch_workflow") != null) + return dispatch("gha_dispatch_workflow", body, resp); + if (std.mem.indexOf(u8, body, "gha_cancel_run") != null) + return dispatch("gha_cancel_run", body, resp); + if (std.mem.indexOf(u8, body, "gha_list_jobs") != null) + return dispatch("gha_list_jobs", body, resp); + return .{ .status = 400, .body = errJson(resp, "unrecognised GraphQL operation") }; +} + +fn handleConnection(stream: std.net.Stream, port: u16) void { + defer stream.close(); + var buf: [4096]u8 = undefined; + var resp_buf: [4096]u8 = undefined; + const n = stream.read(&buf) catch return; + const req = buf[0..n]; + const result = switch (port) { + REST_PORT => blk: { + // Parse HTTP/1.1: first line = METHOD PATH HTTP/x.y + var lines = std.mem.splitScalar(u8, req, '\n'); + const first = lines.next() orelse break :blk Response{ .status = 400, .body = "" }; + var parts = std.mem.splitScalar(u8, std.mem.trim(u8, first, "\r"), ' '); + _ = parts.next(); // method + const path = parts.next() orelse break :blk Response{ .status = 400, .body = "" }; + break :blk dispatchRest(path, req, &resp_buf); + }, + GRPC_PORT => blk: { + var lines = std.mem.splitScalar(u8, req, '\n'); + const first = lines.next() orelse break :blk Response{ .status = 400, .body = "" }; + var parts = std.mem.splitScalar(u8, std.mem.trim(u8, first, "\r"), ' '); + _ = parts.next(); + const path = parts.next() orelse break :blk Response{ .status = 400, .body = "" }; + break :blk dispatchGrpc(path, req, &resp_buf); + }, + GQL_PORT => dispatchGraphql(req, &resp_buf), + else => Response{ .status = 500, .body = "" }, + }; + var http_resp: [512]u8 = undefined; + const http = std.fmt.bufPrint(&http_resp, + "HTTP/1.1 {d} OK\r\nContent-Length: {d}\r\nContent-Type: application/json\r\n\r\n", + .{ result.status, result.body.len }) catch return; + _ = stream.write(http) catch {}; + _ = stream.write(result.body) catch {}; +} + +fn listenLoop(port: u16) !void { + const addr = std.net.Address.initIp4(.{ 127, 0, 0, 1 }, port); + var server = try addr.listen(.{ .reuse_address = true }); + defer server.deinit(); + while (true) { + const conn = try server.accept(); + const t = try std.Thread.spawn(.{}, handleConnection, .{ conn.stream, port }); + t.detach(); + } +} + +pub fn main() !void { + ffi.github_actions_mcp_init(); + const t1 = try std.Thread.spawn(.{}, listenLoop, .{REST_PORT}); + const t2 = try std.Thread.spawn(.{}, listenLoop, .{GRPC_PORT}); + const t3 = try std.Thread.spawn(.{}, listenLoop, .{GQL_PORT}); + t1.join(); + t2.join(); + t3.join(); +} diff --git a/cartridges/domains/ci-cd/github-actions-mcp/benchmarks/quick-bench.sh b/cartridges/domains/ci-cd/github-actions-mcp/benchmarks/quick-bench.sh new file mode 100755 index 0000000..48bd284 --- /dev/null +++ b/cartridges/domains/ci-cd/github-actions-mcp/benchmarks/quick-bench.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Benchmarks for github-actions-mcp cartridge. +set -euo pipefail + +echo "=== github-actions-mcp benchmarks ===" + +cd "$(dirname "${BASH_SOURCE[0]}")/../ffi" + +zig build -Doptimize=ReleaseFast 2>&1 + +echo "Session open/close cycle (1000 iterations):" +time for i in $(seq 1 1000); do + true +done + +echo "" +echo "Benchmark placeholder -- implement real benchmarks in Zig test blocks" +echo "or via the zig adapter HTTP benchmark tool." diff --git a/cartridges/domains/ci-cd/github-actions-mcp/cartridge.json b/cartridges/domains/ci-cd/github-actions-mcp/cartridge.json new file mode 100644 index 0000000..e475a3c --- /dev/null +++ b/cartridges/domains/ci-cd/github-actions-mcp/cartridge.json @@ -0,0 +1,365 @@ +{ + "$schema": "https://boj.dev/schemas/cartridge/v1.json", + "spdx": "MPL-2.0", + "copyright": "Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath)", + "name": "github-actions-mcp", + "version": "0.1.0", + "description": "GitHub Actions CI/CD cartridge -- workflow listing, run management, job/step inspection, artifact download, log retrieval, workflow dispatch, secret listing, environment browsing, cache management, runner listing, re-run, and cancellation via the GitHub Actions API", + "domain": "CI/CD", + "tier": "Ayo", + "protocols": [ + "MCP", + "REST" + ], + "auth": { + "method": "bearer_token", + "env_var": "GITHUB_TOKEN", + "credential_source": "vault-mcp" + }, + "api": { + "base_url": "https://api.github.com", + "content_type": "application/json", + "accept_header": "application/vnd.github+json" + }, + "tools": [ + { + "name": "gha_list_workflows", + "description": "List all workflows defined in a GitHub repository", + "inputSchema": { + "type": "object", + "properties": { + "owner": { + "type": "string", + "description": "Repository owner" + }, + "repo": { + "type": "string", + "description": "Repository name" + } + }, + "required": [ + "owner", + "repo" + ] + } + }, + { + "name": "gha_list_runs", + "description": "List workflow runs for a repository with optional status/branch filters", + "inputSchema": { + "type": "object", + "properties": { + "owner": { + "type": "string", + "description": "Repository owner" + }, + "repo": { + "type": "string", + "description": "Repository name" + }, + "workflow_id": { + "type": "string", + "description": "Workflow ID or filename (e.g. 'ci.yml')" + }, + "branch": { + "type": "string", + "description": "Filter by branch" + }, + "status": { + "type": "string", + "description": "Filter: queued, in_progress, completed, success, failure" + }, + "per_page": { + "type": "number", + "description": "Results per page (default 25, max 100)" + } + }, + "required": [ + "owner", + "repo" + ] + } + }, + { + "name": "gha_get_run", + "description": "Get details for a specific workflow run including timing and conclusion", + "inputSchema": { + "type": "object", + "properties": { + "owner": { + "type": "string", + "description": "Repository owner" + }, + "repo": { + "type": "string", + "description": "Repository name" + }, + "run_id": { + "type": "number", + "description": "Workflow run ID" + } + }, + "required": [ + "owner", + "repo", + "run_id" + ] + } + }, + { + "name": "gha_list_jobs", + "description": "List jobs for a workflow run with step details and conclusions", + "inputSchema": { + "type": "object", + "properties": { + "owner": { + "type": "string", + "description": "Repository owner" + }, + "repo": { + "type": "string", + "description": "Repository name" + }, + "run_id": { + "type": "number", + "description": "Workflow run ID" + }, + "filter": { + "type": "string", + "description": "Filter: latest, all" + } + }, + "required": [ + "owner", + "repo", + "run_id" + ] + } + }, + { + "name": "gha_get_logs", + "description": "Download logs for a workflow run (returns redirect URL to log archive)", + "inputSchema": { + "type": "object", + "properties": { + "owner": { + "type": "string", + "description": "Repository owner" + }, + "repo": { + "type": "string", + "description": "Repository name" + }, + "run_id": { + "type": "number", + "description": "Workflow run ID" + } + }, + "required": [ + "owner", + "repo", + "run_id" + ] + } + }, + { + "name": "gha_list_artifacts", + "description": "List artifacts produced by a workflow run", + "inputSchema": { + "type": "object", + "properties": { + "owner": { + "type": "string", + "description": "Repository owner" + }, + "repo": { + "type": "string", + "description": "Repository name" + }, + "run_id": { + "type": "number", + "description": "Workflow run ID" + } + }, + "required": [ + "owner", + "repo", + "run_id" + ] + } + }, + { + "name": "gha_dispatch_workflow", + "description": "Trigger a workflow_dispatch event to start a workflow run", + "inputSchema": { + "type": "object", + "properties": { + "owner": { + "type": "string", + "description": "Repository owner" + }, + "repo": { + "type": "string", + "description": "Repository name" + }, + "workflow_id": { + "type": "string", + "description": "Workflow ID or filename" + }, + "ref": { + "type": "string", + "description": "Git ref (branch or tag)" + }, + "inputs": { + "type": "object", + "description": "Workflow input parameters" + } + }, + "required": [ + "owner", + "repo", + "workflow_id", + "ref" + ] + } + }, + { + "name": "gha_rerun_workflow", + "description": "Re-run a failed or completed workflow run", + "inputSchema": { + "type": "object", + "properties": { + "owner": { + "type": "string", + "description": "Repository owner" + }, + "repo": { + "type": "string", + "description": "Repository name" + }, + "run_id": { + "type": "number", + "description": "Workflow run ID" + }, + "failed_only": { + "type": "boolean", + "description": "Re-run only failed jobs (default false)" + } + }, + "required": [ + "owner", + "repo", + "run_id" + ] + } + }, + { + "name": "gha_cancel_run", + "description": "Cancel an in-progress workflow run", + "inputSchema": { + "type": "object", + "properties": { + "owner": { + "type": "string", + "description": "Repository owner" + }, + "repo": { + "type": "string", + "description": "Repository name" + }, + "run_id": { + "type": "number", + "description": "Workflow run ID" + } + }, + "required": [ + "owner", + "repo", + "run_id" + ] + } + }, + { + "name": "gha_list_secrets", + "description": "List repository secrets (names only, not values) for GitHub Actions", + "inputSchema": { + "type": "object", + "properties": { + "owner": { + "type": "string", + "description": "Repository owner" + }, + "repo": { + "type": "string", + "description": "Repository name" + } + }, + "required": [ + "owner", + "repo" + ] + } + }, + { + "name": "gha_list_runners", + "description": "List self-hosted runners for a repository", + "inputSchema": { + "type": "object", + "properties": { + "owner": { + "type": "string", + "description": "Repository owner" + }, + "repo": { + "type": "string", + "description": "Repository name" + } + }, + "required": [ + "owner", + "repo" + ] + } + }, + { + "name": "gha_list_caches", + "description": "List GitHub Actions caches for a repository with size and usage info", + "inputSchema": { + "type": "object", + "properties": { + "owner": { + "type": "string", + "description": "Repository owner" + }, + "repo": { + "type": "string", + "description": "Repository name" + }, + "key": { + "type": "string", + "description": "Filter by cache key prefix" + }, + "sort": { + "type": "string", + "description": "Sort: created_at, last_accessed_at, size_in_bytes" + } + }, + "required": [ + "owner", + "repo" + ] + } + } + ], + "ffi": { + "so_path": "ffi/zig-out/lib/libgithub_actions_mcp.so", + "abi_version": "ADR-0006", + "symbols": [ + "boj_cartridge_init", + "boj_cartridge_deinit", + "boj_cartridge_name", + "boj_cartridge_version", + "boj_cartridge_invoke" + ] + } +} diff --git a/cartridges/domains/ci-cd/github-actions-mcp/ffi/README.adoc b/cartridges/domains/ci-cd/github-actions-mcp/ffi/README.adoc new file mode 100644 index 0000000..224c8c7 --- /dev/null +++ b/cartridges/domains/ci-cd/github-actions-mcp/ffi/README.adoc @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += github-actions-mcp / ffi β€” Zig FFI layer +:orientation: shallow + +== Purpose + +C-ABI exports bridging this cartridge's Idris2 ABI to the runtime. Consumed by +the adapter for protocol dispatch and by the trunk loader for mount. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” produces `zig-out/lib/libgithub-actions.so`. +| `github_actions_mcp_ffi.zig` | C-ABI exports (14 exports, 5 inline tests, 327 lines). +|=== + +== Invariants + +* **Null-pointer rejection** on `[*c]const u8` arguments before any + dereference. +* **Negative return values** carry documented error meanings; zero is + success (matching the trunk error-code convention). +* **No allocator leaks** β€” every allocation path pairs with `defer free`. + +== Test/proof surface + +5 inline `test "..."` block(s) in `github_actions_mcp_ffi.zig`. Run with +`cd ffi && zig build test`. + +== Read-first + +. `github_actions_mcp_ffi.zig` β€” C-ABI signatures and guards. +. Trunk `ffi/zig/src/catalogue.zig` for how this `.so` is loaded. diff --git a/cartridges/domains/ci-cd/github-actions-mcp/ffi/build.zig b/cartridges/domains/ci-cd/github-actions-mcp/ffi/build.zig new file mode 100644 index 0000000..0d70d67 --- /dev/null +++ b/cartridges/domains/ci-cd/github-actions-mcp/ffi/build.zig @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + // Shared ADR-0006 invoke-shim module (relative path up to boj-server trunk). + + const shim_mod = b.addModule("cartridge_shim", .{ + + .root_source_file = b.path("../../../ffi/zig/src/cartridge_shim.zig"), + + .target = target, + + .optimize = optimize, + + }); + + const ffi_mod = b.addModule("github_actions_mcp", .{ + .root_source_file = b.path("github_actions_mcp_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + ffi_mod.addImport("cartridge_shim", shim_mod); + + const lib = b.addLibrary(.{ + .name = "github_actions_mcp", + .root_module = ffi_mod, + .linkage = .dynamic, + }); + lib.linkLibC(); + b.installArtifact(lib); + + const tests = b.addTest(.{ .root_module = ffi_mod }); + tests.linkLibC(); + const run_tests = b.addRunArtifact(tests); + const test_step = b.step("test", "Run FFI tests"); + test_step.dependOn(&run_tests.step); +} + diff --git a/cartridges/domains/ci-cd/github-actions-mcp/ffi/cartridge_shim.zig b/cartridges/domains/ci-cd/github-actions-mcp/ffi/cartridge_shim.zig new file mode 100644 index 0000000..0e399db --- /dev/null +++ b/cartridges/domains/ci-cd/github-actions-mcp/ffi/cartridge_shim.zig @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// cartridge_shim.zig β€” Shared helpers for the ADR-0006 five-symbol +// cartridge ABI (`boj_cartridge_init / deinit / name / version / invoke`). +// +// The shim centralises the seven-code return convention, NUL-argument +// guards, tool-name comparison, and the buffer-too-small path so each +// cartridge's `boj_cartridge_invoke` can stay short β€” typically a tool +// table plus `shim.writeResult(...)`. +// +// Cartridges import this file by relative path (no build-graph change +// needed). Example: +// +// const shim = @import("../../../ffi/zig/src/cartridge_shim.zig"); +// +// export fn boj_cartridge_invoke( +// tool_name: [*c]const u8, +// json_args: [*c]const u8, +// out_buf: [*c]u8, +// in_out_len: [*c]usize, +// ) callconv(.c) i32 { +// _ = json_args; +// if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; +// const body = if (shim.toolIs(tool_name, "foo")) "{\"result\":{}}" +// else return shim.RC_UNKNOWN_TOOL; +// return shim.writeResult(out_buf, in_out_len, body); +// } + +const std = @import("std"); + +// ── Return codes (ADR-0006 Β§Return codes) ──────────────────────────── +// +// Frozen by ADR-0006. New failure modes compose these via the error +// JSON body β€” the integer surface does not grow without a follow-up ADR. + +pub const RC_SUCCESS: i32 = 0; +pub const RC_UNKNOWN_TOOL: i32 = -1; +pub const RC_BAD_ARGS: i32 = -2; +pub const RC_BUFFER_TOO_SMALL: i32 = -3; +pub const RC_RUNTIME_ERROR: i32 = -4; +pub const RC_PANIC: i32 = -5; +pub const RC_AUTH_DENIED: i32 = -6; + +// ── Invoke-path helpers ────────────────────────────────────────────── + +/// True if any of the three mandatory `boj_cartridge_invoke` output-path +/// pointers is null. Use at the top of every invoke to short-circuit to +/// `RC_BAD_ARGS`. +pub fn invokeArgsNull( + tool_name: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) bool { + return tool_name == null or out_buf == null or in_out_len == null; +} + +/// Compare a C-NUL-terminated tool-name pointer against a Zig string +/// literal. Caller must have already verified `tool_name` is non-null +/// (usually via `invokeArgsNull`). +pub fn toolIs(tool_name: [*c]const u8, expected: []const u8) bool { + const s = std.mem.span(@as([*:0]const u8, @ptrCast(tool_name))); + return std.mem.eql(u8, s, expected); +} + +/// Copy `body` into `out_buf[0..*in_out_len]` (as a capacity) and update +/// `*in_out_len` to the number of bytes written. Returns `RC_SUCCESS`. +/// +/// If `body.len` exceeds the current capacity stored in `*in_out_len`, +/// sets `*in_out_len` to the required size and returns +/// `RC_BUFFER_TOO_SMALL` β€” the caller is then expected to re-allocate +/// and retry, per ADR-0006 Β§Memory ownership. +/// +/// Caller must have already verified that `out_buf` and `in_out_len` +/// are non-null. +pub fn writeResult( + out_buf: [*c]u8, + in_out_len: [*c]usize, + body: []const u8, +) i32 { + const cap = in_out_len.*; + if (body.len > cap) { + in_out_len.* = body.len; + return RC_BUFFER_TOO_SMALL; + } + @memcpy(out_buf[0..body.len], body); + in_out_len.* = body.len; + return RC_SUCCESS; +} + +// ── Tests ──────────────────────────────────────────────────────────── + +test "writeResult: body fits, writes and sets length" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); + try std.testing.expectEqualStrings("hello", buf[0..len]); +} + +test "writeResult: too small returns -3 and sets required length" { + var buf: [2]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_BUFFER_TOO_SMALL, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: exact-fit succeeds" { + var buf: [5]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: empty body" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, ""); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 0), len); +} + +test "toolIs: matches and rejects" { + const name: [*:0]const u8 = "foo"; + try std.testing.expect(toolIs(@ptrCast(name), "foo")); + try std.testing.expect(!toolIs(@ptrCast(name), "bar")); + try std.testing.expect(!toolIs(@ptrCast(name), "foobar")); + try std.testing.expect(!toolIs(@ptrCast(name), "fo")); +} + +test "invokeArgsNull: detects each null slot" { + var buf: [4]u8 = undefined; + var len: usize = 4; + const name: [*:0]const u8 = "x"; + try std.testing.expect(!invokeArgsNull(@ptrCast(name), &buf, &len)); + try std.testing.expect(invokeArgsNull(null, &buf, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), null, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), &buf, null)); +} diff --git a/cartridges/domains/ci-cd/github-actions-mcp/ffi/github_actions_mcp_ffi.zig b/cartridges/domains/ci-cd/github-actions-mcp/ffi/github_actions_mcp_ffi.zig new file mode 100644 index 0000000..369505d --- /dev/null +++ b/cartridges/domains/ci-cd/github-actions-mcp/ffi/github_actions_mcp_ffi.zig @@ -0,0 +1,442 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// github_actions_mcp_ffi.zig β€” C-ABI FFI implementation for github-actions-mcp cartridge. +// +// Implements the state machine defined in GithubActionsMcp.SafeRegistry (Idris2 ABI). +// State machine: Unauthenticated | Authenticated | RateLimited | Error +// Auth: Bearer token required for all GitHub Actions API operations. +// Actions: ListWorkflows, ListRuns, GetRun, ListJobs, GetLogs, ListArtifacts, +// DispatchWorkflow, RerunWorkflow, CancelRun, ListSecrets, ListRunners, ListCaches +// Thread-safe via std.Thread.Mutex. Fixed-size session pool, no heap allocations. + +const std = @import("std"); + +// --------------------------------------------------------------------------- +// State machine (matches Idris2 ABI SessionState exactly) +// --------------------------------------------------------------------------- + +pub const SessionState = enum(c_int) { + unauthenticated = 0, + authenticated = 1, + rate_limited = 2, + err = 3, +}; + +pub const GhaAction = enum(c_int) { + list_workflows = 0, + list_runs = 1, + get_run = 2, + list_jobs = 3, + get_logs = 4, + list_artifacts = 5, + dispatch_workflow = 6, + rerun_workflow = 7, + cancel_run = 8, + list_secrets = 9, + list_runners = 10, + list_caches = 11, +}; + +fn isValidTransition(from: SessionState, to: SessionState) bool { + return switch (from) { + .unauthenticated => to == .authenticated or to == .err, + .authenticated => to == .unauthenticated or to == .rate_limited or to == .err, + .rate_limited => to == .authenticated, + .err => to == .authenticated or to == .unauthenticated, + }; +} + +// --------------------------------------------------------------------------- +// Session slots (thread-safe, fixed-size pool) +// --------------------------------------------------------------------------- + +const MAX_SESSIONS: usize = 16; + +const SessionSlot = struct { + active: bool = false, + state: SessionState = .unauthenticated, + api_call_count: u64 = 0, + last_action: c_int = -1, + workflow_ops: u32 = 0, + run_ops: u32 = 0, + infra_ops: u32 = 0, +}; + +var sessions: [MAX_SESSIONS]SessionSlot = .{SessionSlot{}} ** MAX_SESSIONS; +var mutex: std.Thread.Mutex = .{}; + +// --------------------------------------------------------------------------- +// C-ABI exports β€” state machine +// --------------------------------------------------------------------------- + +pub export fn gha_mcp_can_transition(from: c_int, to: c_int) c_int { + const f = std.meta.intToEnum(SessionState, from) catch return 0; + const t = std.meta.intToEnum(SessionState, to) catch return 0; + return if (isValidTransition(f, t)) 1 else 0; +} + +pub export fn gha_mcp_authenticate(dummy: c_int) c_int { + _ = dummy; + mutex.lock(); + defer mutex.unlock(); + + for (&sessions, 0..) |*slot, idx| { + if (!slot.active) { + slot.active = true; + slot.state = .authenticated; + slot.api_call_count = 0; + slot.last_action = -1; + slot.workflow_ops = 0; + slot.run_ops = 0; + slot.infra_ops = 0; + return @intCast(idx); + } + } + return -1; +} + +pub export fn gha_mcp_close(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + + sessions[idx] = SessionSlot{}; + return 0; +} + +pub export fn gha_mcp_session_state(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + return @intFromEnum(slot.state); +} + +pub export fn gha_mcp_throttle(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + if (!isValidTransition(slot.state, .rate_limited)) return -2; + + sessions[idx].state = .rate_limited; + return 0; +} + +pub export fn gha_mcp_unthrottle(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + if (!isValidTransition(slot.state, .authenticated)) return -2; + + sessions[idx].state = .authenticated; + return 0; +} + +pub export fn gha_mcp_signal_error(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + if (!isValidTransition(slot.state, .err)) return -2; + + sessions[idx].state = .err; + return 0; +} + +// --------------------------------------------------------------------------- +// C-ABI exports β€” action recording and metrics +// --------------------------------------------------------------------------- + +pub export fn gha_mcp_record_call(slot_idx: c_int, action: c_int) c_int { + const act = std.meta.intToEnum(GhaAction, action) catch return -3; + + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + if (slot.state == .rate_limited) return -2; + if (slot.state == .err) return -2; + if (slot.state == .unauthenticated) return -2; + + sessions[idx].api_call_count += 1; + sessions[idx].last_action = action; + + switch (act) { + .list_workflows, .dispatch_workflow => sessions[idx].workflow_ops += 1, + .list_runs, .get_run, .list_jobs, .get_logs, .list_artifacts, .rerun_workflow, .cancel_run => sessions[idx].run_ops += 1, + .list_secrets, .list_runners, .list_caches => sessions[idx].infra_ops += 1, + } + + return 0; +} + +pub export fn gha_mcp_call_count(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + return @intCast(slot.api_call_count); +} + +pub export fn gha_mcp_workflow_op_count(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + return @intCast(slot.workflow_ops); +} + +pub export fn gha_mcp_run_op_count(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + return @intCast(slot.run_ops); +} + +pub export fn gha_mcp_infra_op_count(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + return @intCast(slot.infra_ops); +} + +pub export fn gha_mcp_action_count() c_int { + return 12; +} + +pub export fn gha_mcp_reset() void { + mutex.lock(); + defer mutex.unlock(); + sessions = .{SessionSlot{}} ** MAX_SESSIONS; +} + +// --------------------------------------------------------------------------- +// ═══════════════════════════════════════════════════════════════════════ +// Standard ABI (ADR-0005 four symbols + ADR-0006 invoke) +// ═══════════════════════════════════════════════════════════════════════ + +const shim = @import("cartridge_shim.zig"); + +const CARTRIDGE_NAME_PTR: [*:0]const u8 = "github-actions-mcp"; +const CARTRIDGE_VERSION_PTR: [*:0]const u8 = "0.1.0"; + +export fn boj_cartridge_init() callconv(.c) c_int { + return 0; +} + +export fn boj_cartridge_deinit() callconv(.c) void {} + +export fn boj_cartridge_name() callconv(.c) [*:0]const u8 { + return CARTRIDGE_NAME_PTR; +} + +export fn boj_cartridge_version() callconv(.c) [*:0]const u8 { + return CARTRIDGE_VERSION_PTR; +} + +/// Dispatch the cartridge.json MCP tools. Grade D Alpha stubs. +export fn boj_cartridge_invoke( + tool_name: [*c]const u8, + json_args: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) callconv(.c) i32 { + _ = json_args; + if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; + + const body: []const u8 = if (shim.toolIs(tool_name, "gha_list_workflows")) + "{\"result\":{\"items\":[],\"count\":0,\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "gha_list_runs")) + "{\"result\":{\"items\":[],\"count\":0,\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "gha_get_run")) + "{\"result\":{\"metadata\":{},\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "gha_list_jobs")) + "{\"result\":{\"items\":[],\"count\":0,\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "gha_get_logs")) + "{\"result\":{\"metadata\":{},\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "gha_list_artifacts")) + "{\"result\":{\"items\":[],\"count\":0,\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "gha_dispatch_workflow")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "gha_rerun_workflow")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "gha_cancel_run")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "gha_list_secrets")) + "{\"result\":{\"items\":[],\"count\":0,\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "gha_list_runners")) + "{\"result\":{\"items\":[],\"count\":0,\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "gha_list_caches")) + "{\"result\":{\"items\":[],\"count\":0,\"status\":\"stub\"}}" +else + return shim.RC_UNKNOWN_TOOL; + + return shim.writeResult(out_buf, in_out_len, body); +} + +// Tests +// --------------------------------------------------------------------------- + +test "authenticated session lifecycle" { + gha_mcp_reset(); + + const slot = gha_mcp_authenticate(0); + try std.testing.expect(slot >= 0); + try std.testing.expectEqual(@as(c_int, 1), gha_mcp_session_state(slot)); + + // ListWorkflows (0) + try std.testing.expectEqual(@as(c_int, 0), gha_mcp_record_call(slot, 0)); + try std.testing.expectEqual(@as(c_int, 1), gha_mcp_call_count(slot)); + try std.testing.expectEqual(@as(c_int, 1), gha_mcp_workflow_op_count(slot)); + + try std.testing.expectEqual(@as(c_int, 0), gha_mcp_close(slot)); +} + +test "rate limiting flow" { + gha_mcp_reset(); + + const slot = gha_mcp_authenticate(0); + try std.testing.expect(slot >= 0); + + try std.testing.expectEqual(@as(c_int, 0), gha_mcp_throttle(slot)); + try std.testing.expectEqual(@as(c_int, 2), gha_mcp_session_state(slot)); + try std.testing.expectEqual(@as(c_int, -2), gha_mcp_record_call(slot, 0)); + + try std.testing.expectEqual(@as(c_int, 0), gha_mcp_unthrottle(slot)); + try std.testing.expectEqual(@as(c_int, 1), gha_mcp_session_state(slot)); +} + +test "category counting" { + gha_mcp_reset(); + + const slot = gha_mcp_authenticate(0); + try std.testing.expect(slot >= 0); + + // ListWorkflows (0) + try std.testing.expectEqual(@as(c_int, 0), gha_mcp_record_call(slot, 0)); + // ListRuns (1) + try std.testing.expectEqual(@as(c_int, 0), gha_mcp_record_call(slot, 1)); + // GetRun (2) + try std.testing.expectEqual(@as(c_int, 0), gha_mcp_record_call(slot, 2)); + // ListSecrets (9) + try std.testing.expectEqual(@as(c_int, 0), gha_mcp_record_call(slot, 9)); + // DispatchWorkflow (6) + try std.testing.expectEqual(@as(c_int, 0), gha_mcp_record_call(slot, 6)); + + try std.testing.expectEqual(@as(c_int, 5), gha_mcp_call_count(slot)); + try std.testing.expectEqual(@as(c_int, 2), gha_mcp_workflow_op_count(slot)); + try std.testing.expectEqual(@as(c_int, 2), gha_mcp_run_op_count(slot)); + try std.testing.expectEqual(@as(c_int, 1), gha_mcp_infra_op_count(slot)); +} + +test "transition validator" { + try std.testing.expectEqual(@as(c_int, 1), gha_mcp_can_transition(0, 1)); + try std.testing.expectEqual(@as(c_int, 1), gha_mcp_can_transition(1, 0)); + try std.testing.expectEqual(@as(c_int, 1), gha_mcp_can_transition(1, 2)); + try std.testing.expectEqual(@as(c_int, 1), gha_mcp_can_transition(2, 1)); + try std.testing.expectEqual(@as(c_int, 0), gha_mcp_can_transition(0, 2)); +} + +test "slot exhaustion" { + gha_mcp_reset(); + + var slots: [MAX_SESSIONS]c_int = undefined; + for (&slots) |*s| { + s.* = gha_mcp_authenticate(0); + try std.testing.expect(s.* >= 0); + } + + try std.testing.expectEqual(@as(c_int, -1), gha_mcp_authenticate(0)); + + try std.testing.expectEqual(@as(c_int, 0), gha_mcp_close(slots[0])); + const new_slot = gha_mcp_authenticate(0); + try std.testing.expect(new_slot >= 0); +} + +// ═══════════════════════════════════════════════════════════════════════ +// ADR-0006 invoke dispatch tests +// ═══════════════════════════════════════════════════════════════════════ + +test "boj_cartridge_name returns github-actions-mcp" { + const n = std.mem.span(boj_cartridge_name()); + try std.testing.expectEqualStrings("github-actions-mcp", n); +} + +test "boj_cartridge_init returns 0" { + try std.testing.expectEqual(@as(c_int, 0), boj_cartridge_init()); +} + +test "invoke: each declared tool succeeds" { + var buf: [256]u8 = undefined; + const tools = [_][]const u8{ + "gha_list_workflows", + "gha_list_runs", + "gha_get_run", + "gha_list_jobs", + "gha_get_logs", + "gha_list_artifacts", + "gha_dispatch_workflow", + "gha_rerun_workflow", + "gha_cancel_run", + "gha_list_secrets", + "gha_list_runners", + "gha_list_caches", + }; + for (tools) |t| { + var len: usize = buf.len; + const rc = boj_cartridge_invoke(t.ptr, "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, 0), rc); + try std.testing.expect(std.mem.indexOf(u8, buf[0..len], "result") != null); + } +} + +test "invoke: unknown tool returns -1" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("nope", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -1), rc); +} + +test "invoke: buffer too small returns -3" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("gha_list_workflows", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -3), rc); + try std.testing.expect(len > 4); +} diff --git a/cartridges/domains/ci-cd/github-actions-mcp/minter.toml b/cartridges/domains/ci-cd/github-actions-mcp/minter.toml new file mode 100644 index 0000000..11d8262 --- /dev/null +++ b/cartridges/domains/ci-cd/github-actions-mcp/minter.toml @@ -0,0 +1,23 @@ +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> + +name = "github-actions-mcp" +description = "GitHub Actions CI/CD cartridge β€” workflow listing, run management, job inspection, artifacts, logs, dispatch, re-run, cancel, secrets, runners, caches" +version = "0.1.0" +domain = "CI/CD" +protocols = [ + "MCP", + "REST", +] +tier = "Ayo" +backend = "universal" +generate_panel = true + +[auth] +method = "bearer_token" +credential_source = "vault-mcp" +fields = ["github_token"] + +[api] +base_url = "https://api.github.com" +accept_header = "application/vnd.github+json" diff --git a/cartridges/domains/ci-cd/github-actions-mcp/mod.js b/cartridges/domains/ci-cd/github-actions-mcp/mod.js new file mode 100644 index 0000000..79718aa --- /dev/null +++ b/cartridges/domains/ci-cd/github-actions-mcp/mod.js @@ -0,0 +1,231 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// github-actions-mcp/mod.js -- GitHub Actions CI/CD cartridge implementation. +// +// Provides MCP tool handlers for the GitHub Actions REST API: +// - Workflow listing and dispatch +// - Run listing, detail retrieval, re-run, cancellation +// - Job and step inspection +// - Artifact listing +// - Log retrieval +// - Secret listing (names only) +// - Self-hosted runner listing +// - Cache management +// +// Auth: Bearer token via GITHUB_TOKEN (required for all operations). +// API docs: https://docs.github.com/en/rest/actions +// +// Usage: import { handleTool } from "./mod.js"; +// or: deno run --allow-net --allow-env mod.js + +const API_BASE = "https://api.github.com"; + +// --------------------------------------------------------------------------- +// Auth helper +// --------------------------------------------------------------------------- + +function getToken() { + const token = typeof Deno !== "undefined" + ? Deno.env.get("GITHUB_TOKEN") + : process.env.GITHUB_TOKEN; + return token || null; +} + +// --------------------------------------------------------------------------- +// HTTP request helper +// --------------------------------------------------------------------------- + +async function ghaFetch(path, { method = "GET", queryParams, body } = {}) { + const url = new URL(`${API_BASE}${path}`); + + if (queryParams) { + for (const [key, value] of Object.entries(queryParams)) { + if (value !== undefined && value !== null && value !== "") { + url.searchParams.set(key, String(value)); + } + } + } + + const headers = { + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + }; + + const token = getToken(); + if (token) { + headers["Authorization"] = `Bearer ${token}`; + } + + const options = { method, headers }; + if (body) { + headers["Content-Type"] = "application/json"; + options.body = JSON.stringify(body); + } + + const response = await fetch(url.toString(), options); + + if (response.status === 429) { + const retryAfter = response.headers.get("retry-after"); + return { + status: 429, + error: `Rate limited by GitHub. Retry after ${retryAfter || "unknown"} seconds.`, + retryAfter, + }; + } + + // Some endpoints return 204 No Content + if (response.status === 204) { + return { status: 204, data: { message: "Success (no content)" } }; + } + + const data = await response.json().catch(() => ({})); + + if (!response.ok) { + const errorMessage = data.message || `HTTP ${response.status}`; + return { status: response.status, error: errorMessage, data }; + } + + return { status: response.status, data }; +} + +// --------------------------------------------------------------------------- +// Tool handler dispatch +// --------------------------------------------------------------------------- + +export async function handleTool(toolName, args) { + switch (toolName) { + + // --- Workflows --- + + case "gha_list_workflows": { + if (!args.owner) return { error: "Missing required field: owner" }; + if (!args.repo) return { error: "Missing required field: repo" }; + return ghaFetch(`/repos/${encodeURIComponent(args.owner)}/${encodeURIComponent(args.repo)}/actions/workflows`); + } + + // --- Runs --- + + case "gha_list_runs": { + if (!args.owner) return { error: "Missing required field: owner" }; + if (!args.repo) return { error: "Missing required field: repo" }; + const base = args.workflow_id + ? `/repos/${encodeURIComponent(args.owner)}/${encodeURIComponent(args.repo)}/actions/workflows/${encodeURIComponent(args.workflow_id)}/runs` + : `/repos/${encodeURIComponent(args.owner)}/${encodeURIComponent(args.repo)}/actions/runs`; + const query = { + branch: args.branch, + status: args.status, + per_page: args.per_page, + }; + return ghaFetch(base, { queryParams: query }); + } + + case "gha_get_run": { + if (!args.owner) return { error: "Missing required field: owner" }; + if (!args.repo) return { error: "Missing required field: repo" }; + if (!args.run_id) return { error: "Missing required field: run_id" }; + return ghaFetch(`/repos/${encodeURIComponent(args.owner)}/${encodeURIComponent(args.repo)}/actions/runs/${args.run_id}`); + } + + // --- Jobs --- + + case "gha_list_jobs": { + if (!args.owner) return { error: "Missing required field: owner" }; + if (!args.repo) return { error: "Missing required field: repo" }; + if (!args.run_id) return { error: "Missing required field: run_id" }; + const query = { filter: args.filter }; + return ghaFetch(`/repos/${encodeURIComponent(args.owner)}/${encodeURIComponent(args.repo)}/actions/runs/${args.run_id}/jobs`, { queryParams: query }); + } + + // --- Logs --- + + case "gha_get_logs": { + if (!args.owner) return { error: "Missing required field: owner" }; + if (!args.repo) return { error: "Missing required field: repo" }; + if (!args.run_id) return { error: "Missing required field: run_id" }; + return ghaFetch(`/repos/${encodeURIComponent(args.owner)}/${encodeURIComponent(args.repo)}/actions/runs/${args.run_id}/logs`); + } + + // --- Artifacts --- + + case "gha_list_artifacts": { + if (!args.owner) return { error: "Missing required field: owner" }; + if (!args.repo) return { error: "Missing required field: repo" }; + if (!args.run_id) return { error: "Missing required field: run_id" }; + return ghaFetch(`/repos/${encodeURIComponent(args.owner)}/${encodeURIComponent(args.repo)}/actions/runs/${args.run_id}/artifacts`); + } + + // --- Dispatch --- + + case "gha_dispatch_workflow": { + if (!args.owner) return { error: "Missing required field: owner" }; + if (!args.repo) return { error: "Missing required field: repo" }; + if (!args.workflow_id) return { error: "Missing required field: workflow_id" }; + if (!args.ref) return { error: "Missing required field: ref" }; + const body = { ref: args.ref, inputs: args.inputs || {} }; + return ghaFetch(`/repos/${encodeURIComponent(args.owner)}/${encodeURIComponent(args.repo)}/actions/workflows/${encodeURIComponent(args.workflow_id)}/dispatches`, { method: "POST", body }); + } + + // --- Re-run --- + + case "gha_rerun_workflow": { + if (!args.owner) return { error: "Missing required field: owner" }; + if (!args.repo) return { error: "Missing required field: repo" }; + if (!args.run_id) return { error: "Missing required field: run_id" }; + const endpoint = args.failed_only + ? `/repos/${encodeURIComponent(args.owner)}/${encodeURIComponent(args.repo)}/actions/runs/${args.run_id}/rerun-failed-jobs` + : `/repos/${encodeURIComponent(args.owner)}/${encodeURIComponent(args.repo)}/actions/runs/${args.run_id}/rerun`; + return ghaFetch(endpoint, { method: "POST" }); + } + + // --- Cancel --- + + case "gha_cancel_run": { + if (!args.owner) return { error: "Missing required field: owner" }; + if (!args.repo) return { error: "Missing required field: repo" }; + if (!args.run_id) return { error: "Missing required field: run_id" }; + return ghaFetch(`/repos/${encodeURIComponent(args.owner)}/${encodeURIComponent(args.repo)}/actions/runs/${args.run_id}/cancel`, { method: "POST" }); + } + + // --- Secrets --- + + case "gha_list_secrets": { + if (!args.owner) return { error: "Missing required field: owner" }; + if (!args.repo) return { error: "Missing required field: repo" }; + return ghaFetch(`/repos/${encodeURIComponent(args.owner)}/${encodeURIComponent(args.repo)}/actions/secrets`); + } + + // --- Runners --- + + case "gha_list_runners": { + if (!args.owner) return { error: "Missing required field: owner" }; + if (!args.repo) return { error: "Missing required field: repo" }; + return ghaFetch(`/repos/${encodeURIComponent(args.owner)}/${encodeURIComponent(args.repo)}/actions/runners`); + } + + // --- Caches --- + + case "gha_list_caches": { + if (!args.owner) return { error: "Missing required field: owner" }; + if (!args.repo) return { error: "Missing required field: repo" }; + const query = { key: args.key, sort: args.sort }; + return ghaFetch(`/repos/${encodeURIComponent(args.owner)}/${encodeURIComponent(args.repo)}/actions/caches`, { queryParams: query }); + } + + default: + return { error: `Unknown github-actions-mcp tool: ${toolName}` }; + } +} + +// --------------------------------------------------------------------------- +// Cartridge metadata export +// --------------------------------------------------------------------------- + +export const metadata = { + name: "github-actions-mcp", + version: "0.1.0", + domain: "CI/CD", + tier: "Ayo", + protocols: ["MCP", "REST"], + toolCount: 12, +}; diff --git a/cartridges/domains/ci-cd/github-actions-mcp/panels/manifest.json b/cartridges/domains/ci-cd/github-actions-mcp/panels/manifest.json new file mode 100644 index 0000000..bd29ec9 --- /dev/null +++ b/cartridges/domains/ci-cd/github-actions-mcp/panels/manifest.json @@ -0,0 +1,90 @@ +{ + "$schema": "https://panll.dev/schemas/panel-manifest/v1.json", + "spdx": "MPL-2.0", + "copyright": "Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath)", + "cartridge": "github-actions-mcp", + "domain": "CI/CD", + "version": "0.1.0", + "panels": [ + { + "id": "gha-auth-status", + "title": "Auth Status", + "description": "GitHub Actions session state (Unauthenticated / Authenticated / RateLimited / Error)", + "type": "status-indicator", + "data_source": { + "endpoint": "/gha/state", + "method": "GET", + "refresh_interval_ms": 5000 + }, + "widgets": [ + { + "type": "state-badge", + "field": "state", + "states": { + "unauthenticated": { "color": "#95a5a6", "icon": "user-x" }, + "authenticated": { "color": "#2ecc71", "icon": "user-check" }, + "rate_limited": { "color": "#f39c12", "icon": "clock" }, + "error": { "color": "#e74c3c", "icon": "alert-triangle" } + } + } + ] + }, + { + "id": "gha-workflow-ops", + "title": "Workflow Operations", + "description": "Number of workflow listing and dispatch operations in the current session", + "type": "metric", + "data_source": { + "endpoint": "/gha/status", + "method": "GET", + "refresh_interval_ms": 10000 + }, + "widgets": [ + { + "type": "counter", + "field": "workflow_ops", + "label": "Workflow Ops", + "icon": "git-branch" + } + ] + }, + { + "id": "gha-run-ops", + "title": "Run Operations", + "description": "Number of run management operations (list, get, jobs, logs, artifacts, re-run, cancel)", + "type": "metric", + "data_source": { + "endpoint": "/gha/status", + "method": "GET", + "refresh_interval_ms": 10000 + }, + "widgets": [ + { + "type": "counter", + "field": "run_ops", + "label": "Run Ops", + "icon": "play-circle" + } + ] + }, + { + "id": "gha-infra-ops", + "title": "Infrastructure Operations", + "description": "Number of secrets, runner, and cache operations in the current session", + "type": "metric", + "data_source": { + "endpoint": "/gha/status", + "method": "GET", + "refresh_interval_ms": 10000 + }, + "widgets": [ + { + "type": "counter", + "field": "infra_ops", + "label": "Infra Ops", + "icon": "server" + } + ] + } + ] +} diff --git a/cartridges/domains/ci-cd/github-actions-mcp/tests/integration_test.sh b/cartridges/domains/ci-cd/github-actions-mcp/tests/integration_test.sh new file mode 100755 index 0000000..da2ad37 --- /dev/null +++ b/cartridges/domains/ci-cd/github-actions-mcp/tests/integration_test.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Integration tests for github-actions-mcp cartridge. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CART_DIR="$(dirname "$SCRIPT_DIR")" +FFI_DIR="$CART_DIR/ffi" + +echo "=== github-actions-mcp integration tests ===" + +echo "[1/3] Building FFI..." +cd "$FFI_DIR" && zig build 2>&1 + +echo "[2/3] Running FFI unit tests..." +cd "$FFI_DIR" && zig build test 2>&1 + +echo "[3/3] Checking ABI..." +if command -v idris2 &>/dev/null; then + cd "$CART_DIR/abi" && idris2 --check GithubActionsMcp.SafeRegistry 2>&1 + echo " ABI: OK" +else + echo " ABI: SKIPPED (idris2 not in PATH)" +fi + +echo "" +echo "All tests passed for github-actions-mcp!" diff --git a/cartridges/domains/ci-cd/hypatia-mcp/README.adoc b/cartridges/domains/ci-cd/hypatia-mcp/README.adoc new file mode 100644 index 0000000..9c87c29 --- /dev/null +++ b/cartridges/domains/ci-cd/hypatia-mcp/README.adoc @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += hypatia-mcp +Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk> +:spdx: MPL-2.0 +:tier: Ayo +:domain: CI/CD Intelligence +:protocols: MCP, REST + +== Overview + +Hypatia neurosymbolic CI/CD intelligence. Scans repos for security, quality, and compliance issues using symbolic rules + neural pattern detection. + +== Tools (4) + +[cols="2,4"] +|=== +| Tool | Description + +| `hypatia_scan_repo` | Scan a repository for security, quality, and compliance issues. Returns a scan_id. +| `hypatia_get_score` | Get the aggregate quality/security score for a completed scan. +| `hypatia_get_rule_set` | List all active Hypatia rules with their categories, IDs, and enabled state. +| `hypatia_train_model` | Trigger a training cycle for the neural component against the current rule corpus. +|=== + +== Architecture + +Three-layer stack per the BoJ cartridge standard: + +[cols="1,1,3"] +|=== +| Layer | Language | Purpose + +| ABI | Idris2 | Formally verified state machine with dependent-type proofs +| FFI | Zig | C-ABI implementation: session pool, action metrics, thread safety +| Adapter| Zig | Adapter bridge to the BoJ unified adapter protocol (zig predecessors sidelined) +|=== + +== Building + +[source,bash] +---- +# ABI (Idris2) +cd abi && idris2 --check *.idr + +# FFI (Zig) +cd ffi && zig build +cd ffi && zig build test + +# Adapter (Zig) +cd adapter && zig build +---- + +== Status + +Alpha (CRG D). Cartridge metadata declared in `cartridge.json`; 4 tool(s) exposed. +Not yet dogfooded externally. See `docs/READINESS.md` at repo root for the +project-wide grade table and `docs/practice/DOGFOOD-LOG.adoc` for usage evidence. diff --git a/cartridges/domains/ci-cd/hypatia-mcp/abi/Hypatia/Protocol.idr b/cartridges/domains/ci-cd/hypatia-mcp/abi/Hypatia/Protocol.idr new file mode 100644 index 0000000..a80f551 --- /dev/null +++ b/cartridges/domains/ci-cd/hypatia-mcp/abi/Hypatia/Protocol.idr @@ -0,0 +1,52 @@ +-- SPDX-License-Identifier: MPL-2.0 +-- Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +-- +-- Hypatia ABI β€” neurosymbolic CI protocol definitions. + +module Hypatia.Protocol + +import Data.Nat + +||| Hypatia operation codes. +public export +data HypatiaOp + = ScanRepo + | TrainModel + | GetRuleSet + | GetScore + +||| Scan score is bounded 0-100. +public export +record Score where + constructor MkScore + value : Nat + {auto prf : LTE value 100} + +||| A rule with unique identifier and description. +public export +record Rule where + constructor MkRule + ruleId : String + name : String + weight : Nat + +||| Rule set is a non-empty collection. +public export +record RuleSet where + constructor MkRuleSet + rules : List Rule + {auto prf : NonEmpty rules} + +||| Training status. +public export +data TrainStatus = Pending | Training | Complete | Failed + +||| Proof: a Score with value 0 satisfies the upper bound. +export +zeroIsValidScore : LTE 0 100 +zeroIsValidScore = LTEZero + +||| Proof: score bound is transitive β€” if s <= 100 and 100 <= n, then s <= n. +export +scoreBoundTransitive : LTE s 100 -> LTE 100 n -> LTE s n +scoreBoundTransitive p q = lteTransitive p q diff --git a/cartridges/domains/ci-cd/hypatia-mcp/abi/README.adoc b/cartridges/domains/ci-cd/hypatia-mcp/abi/README.adoc new file mode 100644 index 0000000..54b2c56 --- /dev/null +++ b/cartridges/domains/ci-cd/hypatia-mcp/abi/README.adoc @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += hypatia-mcp / abi β€” Idris2 ABI layer +:orientation: deep + +== Purpose + +Thin protocol layer for Hypatia (neurosymbolic CI/CD intelligence). Defines +the op-code enum, a **bounded score type** (0–100) carrying its own proof, +and a **non-empty rule set** type. This is spec-only β€” the actual scanner, +trainer, and scorer live in the wider Hypatia codebase and are reached +through the FFI stubs in `../ffi/`. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `Hypatia/Protocol.idr` | Enums `HypatiaOp` (`ScanRepo`, `TrainModel`, `GetRuleSet`, `GetScore`), `TrainStatus`. Records `Score` (`value : Nat`, `prf : LTE value 100`), `Rule`, `RuleSet` (`rules : List Rule`, `prf : NonEmpty rules`). Lemmas `zeroIsValidScore` and `scoreBoundTransitive`. +|=== + +No `.ipkg` β€” the module is consumed directly by callers that supply their +own package file. + +== Invariants + +* **Scores are bounded.** `Score.prf : LTE value 100` β€” a `Score` with + `value > 100` cannot be constructed. The FFI enforces the same by + returning a `u8` clamped at 100. +* **Rule sets are non-empty.** `RuleSet.prf : NonEmpty rules` β€” an empty + list cannot be a `RuleSet`. +* `zeroIsValidScore : LTE 0 100` is the canonical "empty score" witness. + +== Test/proof surface + +Thin module, no inline tests. Type-checks by any consumer that imports it. + +== Read-first + +. Lines 12–38 β€” `HypatiaOp`, `Score`, `Rule`, `RuleSet`. +. Lines 45–52 β€” lemmas `zeroIsValidScore` and `scoreBoundTransitive`. diff --git a/cartridges/domains/ci-cd/hypatia-mcp/adapter/README.adoc b/cartridges/domains/ci-cd/hypatia-mcp/adapter/README.adoc new file mode 100644 index 0000000..778ed57 --- /dev/null +++ b/cartridges/domains/ci-cd/hypatia-mcp/adapter/README.adoc @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += hypatia-mcp / adapter β€” REST/gRPC/GraphQL bridge +:orientation: deep + +== Purpose + +Exposes the (stub) hypatia-mcp FFI over three protocols: REST on **9103**, +gRPC-compat on **9104**, GraphQL on **9105**. Stateless. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph. +| `hypatia_adapter.zig` | Protocol enum (`rest`, `grpc`, `graphql`), `dispatch` routing. Tools: `hypatia_scan_repo`, `hypatia_get_score`, `hypatia_get_rule_set`, `hypatia_train_model`. Health endpoint returns `{"success":true,"state":"ready","service":"hypatia-mcp"}`. +| `SIDELINED-hypatia_adapter.v.adoc` | zig predecessor. Not built. +|=== + +== Invariants + +* **Stateless**. +* Health endpoint always reports `ready` as long as the process is up. This + is honest: the adapter is a thin forwarder; "ready" here means "accepting + HTTP and wired to the FFI," *not* "the real Hypatia scanner is wired up." + +== Test/proof surface + +No inline tests. + +== Read-first + +. Lines 35–44 β€” `dispatch`. +. Lines 46–74 β€” protocol routers. +. Lines 108–120 β€” listener loop. diff --git a/cartridges/domains/ci-cd/hypatia-mcp/adapter/build.zig b/cartridges/domains/ci-cd/hypatia-mcp/adapter/build.zig new file mode 100644 index 0000000..d821067 --- /dev/null +++ b/cartridges/domains/ci-cd/hypatia-mcp/adapter/build.zig @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// hypatia-mcp/adapter/build.zig + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + const ffi_mod = b.createModule(.{ + .root_source_file = b.path("../ffi/hypatia_ffi.zig"), + .target = target, .optimize = optimize, + }); + const adapter = b.addExecutable(.{ + .name = "hypatia_adapter", + .root_source_file = b.path("hypatia_adapter.zig"), + .target = target, .optimize = optimize, + }); + adapter.root_module.addImport("hypatia_ffi", ffi_mod); + b.installArtifact(adapter); + const run_step = b.step("run", "Run the hypatia-mcp adapter"); + run_step.dependOn(&b.addRunArtifact(adapter).step); + const tests = b.addTest(.{ + .root_source_file = b.path("hypatia_adapter.zig"), + .target = target, .optimize = optimize, + }); + tests.root_module.addImport("hypatia_ffi", ffi_mod); + const test_step = b.step("test", "Run hypatia-mcp adapter tests"); + test_step.dependOn(&b.addRunArtifact(tests).step); +} diff --git a/cartridges/domains/ci-cd/hypatia-mcp/adapter/hypatia_adapter.zig b/cartridges/domains/ci-cd/hypatia-mcp/adapter/hypatia_adapter.zig new file mode 100644 index 0000000..a2f0b0f --- /dev/null +++ b/cartridges/domains/ci-cd/hypatia-mcp/adapter/hypatia_adapter.zig @@ -0,0 +1,121 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// hypatia-mcp/adapter/hypatia_adapter.zig -- Unified three-protocol adapter. +// +// Replaces the banned hypatia_adapter.v (zig, removed 2026-04-12). +// +// REST :9103 gRPC-compat :9104 GraphQL :9105 +// Hypatia neurosymbolic CI/CD intelligence. Scans repos for security, quality, and compliance issues u +// Tools: hypatia_scan_repo, hypatia_get_score, hypatia_get_rule_set, hypatia_train_model + +const std = @import("std"); +const ffi = @import("hypatia_ffi"); + +const REST_PORT: u16 = 9103; +const GRPC_PORT: u16 = 9104; +const GQL_PORT: u16 = 9105; +const MAX_CONN_BUF: usize = 16 * 1024; + +fn okJson(buf: []u8, msg: []const u8) []u8 { + const n = std.fmt.bufPrint(buf, "{{\"success\":true,\"message\":\"{s}\"}}", .{msg}) catch return buf[0..0]; + return n; +} +fn errJson(buf: []u8, msg: []const u8) []u8 { + const n = std.fmt.bufPrint(buf, "{{\"success\":false,\"error\":\"{s}\"}}", .{msg}) catch return buf[0..0]; + return n; +} +fn statusJson(buf: []u8) []u8 { + const n = std.fmt.bufPrint(buf, "{{\"success\":true,\"state\":\"ready\",\"service\":\"hypatia-mcp\"}}", .{}) catch return buf[0..0]; + return n; +} + +const Response = struct { status: u16, body: []u8 }; + +fn dispatch(tool: []const u8, body: []const u8, resp: []u8) Response { + _ = body; + if (std.mem.eql(u8, tool, "hypatia_scan_repo")) return .{ .status = 200, .body = okJson(resp, "hypatia_scan_repo forwarded") }; + if (std.mem.eql(u8, tool, "hypatia_get_score")) return .{ .status = 200, .body = okJson(resp, "hypatia_get_score forwarded") }; + if (std.mem.eql(u8, tool, "hypatia_get_rule_set")) return .{ .status = 200, .body = okJson(resp, "hypatia_get_rule_set forwarded") }; + if (std.mem.eql(u8, tool, "hypatia_train_model")) return .{ .status = 200, .body = okJson(resp, "hypatia_train_model forwarded") }; + if (std.mem.eql(u8, tool, "status") or std.mem.eql(u8, tool, "health")) + return .{ .status = 200, .body = statusJson(resp) }; + return .{ .status = 404, .body = errJson(resp, "Unknown tool") }; +} + +fn dispatchRest(path: []const u8, body: []const u8, resp: []u8) Response { + if (std.mem.startsWith(u8, path, "/tools/")) return dispatch(path["/tools/".len..], body, resp); + if (std.mem.eql(u8, path, "/status") or std.mem.eql(u8, path, "/health")) return .{ .status = 200, .body = statusJson(resp) }; + return .{ .status = 404, .body = errJson(resp, "Not found") }; +} + +fn dispatchGrpc(path: []const u8, body: []const u8, resp: []u8) Response { + const prefix = "/Hypatiaservice/"; + if (!std.mem.startsWith(u8, path, prefix)) + return .{ .status = 404, .body = errJson(resp, "Not a recognized gRPC path") }; + const method = path[prefix.len..]; + const tool = blk: { + if (std.mem.eql(u8, method, "hypatia_scan_repo")) break :blk "hypatia_scan_repo"; + if (std.mem.eql(u8, method, "hypatia_get_score")) break :blk "hypatia_get_score"; + if (std.mem.eql(u8, method, "hypatia_get_rule_set")) break :blk "hypatia_get_rule_set"; + if (std.mem.eql(u8, method, "hypatia_train_model")) break :blk "hypatia_train_model"; + return .{ .status = 404, .body = errJson(resp, "Unknown gRPC method") }; + }; + return dispatch(tool, body, resp); +} + +fn dispatchGraphql(body: []const u8, resp: []u8) Response { + if (std.mem.indexOf(u8, body, "__schema") != null) return .{ .status = 200, .body = okJson(resp, "schema not supported") }; + if (std.mem.indexOf(u8, body, "scan_repo") != null) return dispatch("hypatia_scan_repo", body, resp); + if (std.mem.indexOf(u8, body, "get_score") != null) return dispatch("hypatia_get_score", body, resp); + if (std.mem.indexOf(u8, body, "get_rule_set") != null) return dispatch("hypatia_get_rule_set", body, resp); + if (std.mem.indexOf(u8, body, "train_model") != null) return dispatch("hypatia_train_model", body, resp); + return .{ .status = 200, .body = errJson(resp, "Unrecognised GraphQL operation") }; +} + +const Protocol = enum { rest, grpc, graphql }; + +fn handleConnection(conn: std.net.Server.Connection, proto: Protocol) void { + defer conn.stream.close(); + var in_buf: [MAX_CONN_BUF]u8 = undefined; + const n = conn.stream.read(&in_buf) catch return; + const req = in_buf[0..n]; + var path: []const u8 = "/"; + var body: []const u8 = ""; + if (n > 4) { + const line_end = std.mem.indexOf(u8, req, "\r\n") orelse req.len; + const first_line = req[0..line_end]; + const sp1 = std.mem.indexOfScalar(u8, first_line, ' ') orelse 0; + const rest_of = first_line[sp1 + 1 ..]; + const sp2 = std.mem.indexOfScalar(u8, rest_of, ' ') orelse rest_of.len; + path = rest_of[0..sp2]; + const body_sep = std.mem.indexOf(u8, req, "\r\n\r\n") orelse n; + body = req[@min(body_sep + 4, n)..]; + } + var resp_buf: [MAX_CONN_BUF]u8 = undefined; + const result = switch (proto) { + .rest => dispatchRest(path, body, &resp_buf), + .grpc => dispatchGrpc(path, body, &resp_buf), + .graphql => dispatchGraphql(body, &resp_buf), + }; + const ct: []const u8 = if (proto == .grpc) "application/grpc+json" else "application/json"; + var hdr_buf: [256]u8 = undefined; + const hdr = std.fmt.bufPrint(&hdr_buf, "HTTP/1.1 {d} OK\r\nContent-Type: {s}\r\nContent-Length: {d}\r\nConnection: close\r\n\r\n", .{ result.status, ct, result.body.len }) catch return; + _ = conn.stream.writeAll(hdr) catch return; + _ = conn.stream.writeAll(result.body) catch return; +} + +fn listenLoop(port: u16, proto: Protocol) void { + const addr = std.net.Address.initIp4(.{ 127, 0, 0, 1 }, port) catch return; + var server = addr.listen(.{ .reuse_address = true }) catch return; + defer server.deinit(); + while (true) { const conn = server.accept() catch continue; handleConnection(conn, proto); } +} + +pub fn main() !void { + ffi.hypatia_init(); + const t1 = try std.Thread.spawn(.{}, listenLoop, .{ REST_PORT, Protocol.rest }); + const t2 = try std.Thread.spawn(.{}, listenLoop, .{ GRPC_PORT, Protocol.grpc }); + const t3 = try std.Thread.spawn(.{}, listenLoop, .{ GQL_PORT, Protocol.graphql }); + t1.join(); t2.join(); t3.join(); +} diff --git a/cartridges/domains/ci-cd/hypatia-mcp/cartridge.json b/cartridges/domains/ci-cd/hypatia-mcp/cartridge.json new file mode 100644 index 0000000..124c841 --- /dev/null +++ b/cartridges/domains/ci-cd/hypatia-mcp/cartridge.json @@ -0,0 +1,93 @@ +{ + "$schema": "https://boj.dev/schemas/cartridge/v1.json", + "spdx": "MPL-2.0", + "copyright": "Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath)", + "name": "hypatia-mcp", + "version": "0.1.0", + "description": "Hypatia neurosymbolic CI/CD intelligence. Scans repos for security, quality, and compliance issues using symbolic rules + neural pattern detection.", + "domain": "CI/CD Intelligence", + "tier": "Ayo", + "protocols": [ + "MCP", + "REST" + ], + "auth": { + "method": "none", + "env_var": null, + "credential_source": null + }, + "api": { + "base_url": "local://hypatia-mcp", + "content_type": "application/json" + }, + "tools": [ + { + "name": "hypatia_scan_repo", + "description": "Scan a repository for security, quality, and compliance issues. Returns a scan_id.", + "inputSchema": { + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "Absolute path to the repository root" + } + }, + "required": [ + "path" + ] + } + }, + { + "name": "hypatia_get_score", + "description": "Get the aggregate quality/security score for a completed scan.", + "inputSchema": { + "type": "object", + "properties": { + "scan_id": { + "type": "integer", + "description": "Scan ID returned by hypatia_scan_repo" + } + }, + "required": [ + "scan_id" + ] + } + }, + { + "name": "hypatia_get_rule_set", + "description": "List all active Hypatia rules with their categories, IDs, and enabled state.", + "inputSchema": { + "type": "object", + "properties": {}, + "required": [] + } + }, + { + "name": "hypatia_train_model", + "description": "Trigger a training cycle for the neural component against the current rule corpus.", + "inputSchema": { + "type": "object", + "properties": { + "model_name": { + "type": "string", + "description": "Model identifier to train (e.g. 'default', 'strict')" + } + }, + "required": [ + "model_name" + ] + } + } + ], + "ffi": { + "so_path": "ffi/zig-out/lib/libhypatia_mcp.so", + "abi_version": "ADR-0006", + "symbols": [ + "boj_cartridge_init", + "boj_cartridge_deinit", + "boj_cartridge_name", + "boj_cartridge_version", + "boj_cartridge_invoke" + ] + } +} diff --git a/cartridges/domains/ci-cd/hypatia-mcp/ffi/README.adoc b/cartridges/domains/ci-cd/hypatia-mcp/ffi/README.adoc new file mode 100644 index 0000000..aabcddb --- /dev/null +++ b/cartridges/domains/ci-cd/hypatia-mcp/ffi/README.adoc @@ -0,0 +1,55 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += hypatia-mcp / ffi β€” Zig FFI layer (stubs) +:orientation: deep + +== Purpose + +**Stub** implementation. This cartridge's FFI is a placeholder: `scan_repo` +returns a hardcoded scan id, `get_score` returns `85`, `get_rule_count` +returns `17`, and `train_model` returns `0` regardless of input. The real +Hypatia scanner lives outside this cartridge; this layer exists so the BoJ +loader can mount the cartridge today and swap in the live backend later +without changing the ABI or adapter. + +Recording the stub status here honestly keeps CRG grading accurate: this +layer cannot be C-graded until it invokes the real scanner. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph. +| `hypatia_ffi.zig` | C-ABI exports `hypatia_scan_repo` (returns a scan id or `0` on null input), `hypatia_train_model` (returns `0`), `hypatia_get_score` (returns `u8` clamped to `≀ 100`), `hypatia_get_rule_count` (returns `17`). All functions are null-guarded but otherwise stubs. +| `zig-out/` | Build artefacts (gitignored). +|=== + +== Invariants + +* **Score bound** enforced by the `u8` return type and explicit clamp at 100 + (matches the ABI's `LTE value 100`). +* **Null-pointer rejection** on every string argument (lines 10, 16). +* **No mutable global state** β€” every call is a pure stub. + +== Test/proof surface + +3 inline `test "..."` blocks (lines 33–44): + +* null path rejection +* score within bounds +* rule count positive + +These tests exercise the stubs' guard behaviour only; they prove nothing +about the real scanner because the real scanner is not here. + +== Read-first + +. Lines 9–24 β€” function signatures and the stub returns. +. Lines 33–44 β€” guard-behaviour tests. + +== Promotion gate + +Replace the hardcoded returns with calls into the live Hypatia scanner, +then update `docs/READINESS.md` for this component. diff --git a/cartridges/domains/ci-cd/hypatia-mcp/ffi/build.zig b/cartridges/domains/ci-cd/hypatia-mcp/ffi/build.zig new file mode 100644 index 0000000..2fd173f --- /dev/null +++ b/cartridges/domains/ci-cd/hypatia-mcp/ffi/build.zig @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const shim_mod = b.addModule("cartridge_shim", .{ + .root_source_file = b.path("../../../ffi/zig/src/cartridge_shim.zig"), + .target = target, + .optimize = optimize, + }); + + const ffi_mod = b.addModule("hypatia_mcp", .{ + .root_source_file = b.path("hypatia_ffi.zig"), + .target = target, + .optimize = optimize, + }); + ffi_mod.addImport("cartridge_shim", shim_mod); + + const lib = b.addLibrary(.{ + .name = "hypatia_mcp", + .root_module = ffi_mod, + .linkage = .dynamic, + }); + lib.linkLibC(); + b.installArtifact(lib); + + const tests = b.addTest(.{ .root_module = ffi_mod }); + tests.linkLibC(); + const run_tests = b.addRunArtifact(tests); + const test_step = b.step("test", "Run FFI tests"); + test_step.dependOn(&run_tests.step); +} + diff --git a/cartridges/domains/ci-cd/hypatia-mcp/ffi/cartridge_shim.zig b/cartridges/domains/ci-cd/hypatia-mcp/ffi/cartridge_shim.zig new file mode 100644 index 0000000..0e399db --- /dev/null +++ b/cartridges/domains/ci-cd/hypatia-mcp/ffi/cartridge_shim.zig @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// cartridge_shim.zig β€” Shared helpers for the ADR-0006 five-symbol +// cartridge ABI (`boj_cartridge_init / deinit / name / version / invoke`). +// +// The shim centralises the seven-code return convention, NUL-argument +// guards, tool-name comparison, and the buffer-too-small path so each +// cartridge's `boj_cartridge_invoke` can stay short β€” typically a tool +// table plus `shim.writeResult(...)`. +// +// Cartridges import this file by relative path (no build-graph change +// needed). Example: +// +// const shim = @import("../../../ffi/zig/src/cartridge_shim.zig"); +// +// export fn boj_cartridge_invoke( +// tool_name: [*c]const u8, +// json_args: [*c]const u8, +// out_buf: [*c]u8, +// in_out_len: [*c]usize, +// ) callconv(.c) i32 { +// _ = json_args; +// if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; +// const body = if (shim.toolIs(tool_name, "foo")) "{\"result\":{}}" +// else return shim.RC_UNKNOWN_TOOL; +// return shim.writeResult(out_buf, in_out_len, body); +// } + +const std = @import("std"); + +// ── Return codes (ADR-0006 Β§Return codes) ──────────────────────────── +// +// Frozen by ADR-0006. New failure modes compose these via the error +// JSON body β€” the integer surface does not grow without a follow-up ADR. + +pub const RC_SUCCESS: i32 = 0; +pub const RC_UNKNOWN_TOOL: i32 = -1; +pub const RC_BAD_ARGS: i32 = -2; +pub const RC_BUFFER_TOO_SMALL: i32 = -3; +pub const RC_RUNTIME_ERROR: i32 = -4; +pub const RC_PANIC: i32 = -5; +pub const RC_AUTH_DENIED: i32 = -6; + +// ── Invoke-path helpers ────────────────────────────────────────────── + +/// True if any of the three mandatory `boj_cartridge_invoke` output-path +/// pointers is null. Use at the top of every invoke to short-circuit to +/// `RC_BAD_ARGS`. +pub fn invokeArgsNull( + tool_name: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) bool { + return tool_name == null or out_buf == null or in_out_len == null; +} + +/// Compare a C-NUL-terminated tool-name pointer against a Zig string +/// literal. Caller must have already verified `tool_name` is non-null +/// (usually via `invokeArgsNull`). +pub fn toolIs(tool_name: [*c]const u8, expected: []const u8) bool { + const s = std.mem.span(@as([*:0]const u8, @ptrCast(tool_name))); + return std.mem.eql(u8, s, expected); +} + +/// Copy `body` into `out_buf[0..*in_out_len]` (as a capacity) and update +/// `*in_out_len` to the number of bytes written. Returns `RC_SUCCESS`. +/// +/// If `body.len` exceeds the current capacity stored in `*in_out_len`, +/// sets `*in_out_len` to the required size and returns +/// `RC_BUFFER_TOO_SMALL` β€” the caller is then expected to re-allocate +/// and retry, per ADR-0006 Β§Memory ownership. +/// +/// Caller must have already verified that `out_buf` and `in_out_len` +/// are non-null. +pub fn writeResult( + out_buf: [*c]u8, + in_out_len: [*c]usize, + body: []const u8, +) i32 { + const cap = in_out_len.*; + if (body.len > cap) { + in_out_len.* = body.len; + return RC_BUFFER_TOO_SMALL; + } + @memcpy(out_buf[0..body.len], body); + in_out_len.* = body.len; + return RC_SUCCESS; +} + +// ── Tests ──────────────────────────────────────────────────────────── + +test "writeResult: body fits, writes and sets length" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); + try std.testing.expectEqualStrings("hello", buf[0..len]); +} + +test "writeResult: too small returns -3 and sets required length" { + var buf: [2]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_BUFFER_TOO_SMALL, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: exact-fit succeeds" { + var buf: [5]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: empty body" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, ""); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 0), len); +} + +test "toolIs: matches and rejects" { + const name: [*:0]const u8 = "foo"; + try std.testing.expect(toolIs(@ptrCast(name), "foo")); + try std.testing.expect(!toolIs(@ptrCast(name), "bar")); + try std.testing.expect(!toolIs(@ptrCast(name), "foobar")); + try std.testing.expect(!toolIs(@ptrCast(name), "fo")); +} + +test "invokeArgsNull: detects each null slot" { + var buf: [4]u8 = undefined; + var len: usize = 4; + const name: [*:0]const u8 = "x"; + try std.testing.expect(!invokeArgsNull(@ptrCast(name), &buf, &len)); + try std.testing.expect(invokeArgsNull(null, &buf, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), null, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), &buf, null)); +} diff --git a/cartridges/domains/ci-cd/hypatia-mcp/ffi/hypatia_ffi.zig b/cartridges/domains/ci-cd/hypatia-mcp/ffi/hypatia_ffi.zig new file mode 100644 index 0000000..8de517a --- /dev/null +++ b/cartridges/domains/ci-cd/hypatia-mcp/ffi/hypatia_ffi.zig @@ -0,0 +1,130 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +// +// Hypatia FFI β€” C-compatible exports for neurosymbolic CI scanning. + +const std = @import("std"); + +/// Scan a repository path. Returns scan ID or 0 on failure. +export fn hypatia_scan_repo(path: [*c]const u8) u32 { + if (path == null) return 0; + return 1; // Stub +} + +/// Begin model training. Returns 0 on success. +export fn hypatia_train_model(model_name: [*c]const u8) i32 { + if (model_name == null) return -1; + return 0; // Stub +} + +/// Get the score for a completed scan (0-100). +export fn hypatia_get_score(scan_id: u32) u8 { + if (scan_id == 0) return 0; + return 85; // Stub +} + +/// Get active rule count. +export fn hypatia_get_rule_count() u32 { + return 17; // Stub β€” matches standard workflow set +} + +// ── Standard ABI (ADR-0005 four symbols + ADR-0006 invoke) ────────── + +const shim = @import("cartridge_shim.zig"); + +const CARTRIDGE_NAME_PTR: [*:0]const u8 = "hypatia-mcp"; +const CARTRIDGE_VERSION_PTR: [*:0]const u8 = "0.1.0"; + +export fn boj_cartridge_init() callconv(.c) c_int { + return 0; +} + +export fn boj_cartridge_deinit() callconv(.c) void {} + +export fn boj_cartridge_name() callconv(.c) [*:0]const u8 { + return CARTRIDGE_NAME_PTR; +} + +export fn boj_cartridge_version() callconv(.c) [*:0]const u8 { + return CARTRIDGE_VERSION_PTR; +} + +/// Dispatch the 4 cartridge.json MCP tools. Grade D Alpha stubs. +/// Note: `hypatia_get_rule_set` is the declared tool name; the bespoke +/// FFI symbol is `hypatia_get_rule_count` β€” the invoke dispatch uses +/// the cartridge.json-declared name as its canonical surface. +export fn boj_cartridge_invoke( + tool_name: [*c]const u8, + json_args: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) callconv(.c) i32 { + _ = json_args; + if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; + + const body: []const u8 = if (shim.toolIs(tool_name, "hypatia_scan_repo")) + "{\"result\":{\"scan_id\":1,\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "hypatia_get_score")) + "{\"result\":{\"score\":85,\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "hypatia_get_rule_set")) + "{\"result\":{\"rules\":[],\"count\":17,\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "hypatia_train_model")) + "{\"result\":{\"training\":true,\"status\":\"stub\"}}" + else + return shim.RC_UNKNOWN_TOOL; + + return shim.writeResult(out_buf, in_out_len, body); +} + +// ── Tests ── + +test "scan rejects null path" { + try std.testing.expectEqual(@as(u32, 0), hypatia_scan_repo(null)); +} + +test "score within bounds" { + const score = hypatia_get_score(1); + try std.testing.expect(score <= 100); +} + +test "rule count is positive" { + try std.testing.expect(hypatia_get_rule_count() > 0); +} + +test "boj_cartridge_name returns hypatia-mcp" { + const n = std.mem.span(boj_cartridge_name()); + try std.testing.expectEqualStrings("hypatia-mcp", n); +} + +test "boj_cartridge_init returns 0" { + try std.testing.expectEqual(@as(c_int, 0), boj_cartridge_init()); +} + +test "invoke: each declared tool succeeds" { + var buf: [256]u8 = undefined; + const tools = [_][]const u8{ + "hypatia_scan_repo", "hypatia_get_score", + "hypatia_get_rule_set", "hypatia_train_model", + }; + for (tools) |t| { + var len: usize = buf.len; + const rc = boj_cartridge_invoke(t.ptr, "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, 0), rc); + try std.testing.expect(std.mem.indexOf(u8, buf[0..len], "result") != null); + } +} + +test "invoke: unknown tool returns -1" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("nope", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -1), rc); +} + +test "invoke: buffer too small returns -3" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("hypatia_scan_repo", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -3), rc); + try std.testing.expect(len > 4); +} diff --git a/cartridges/domains/ci-cd/hypatia-mcp/mod.js b/cartridges/domains/ci-cd/hypatia-mcp/mod.js new file mode 100644 index 0000000..a3edf48 --- /dev/null +++ b/cartridges/domains/ci-cd/hypatia-mcp/mod.js @@ -0,0 +1,55 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// hypatia-mcp/mod.js -- hypatia gateway + +const BASE_URL = Deno.env.get("HYPATIA_BACKEND_URL") ?? "http://127.0.0.1:7701"; + +async function post(path, payload) { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), 15000); + try { + const r = await fetch(`${BASE_URL}${path}`, { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), signal: ctrl.signal, + }); + const data = await r.json().catch(() => ({ success: false, error: "non-JSON response" })); + return { status: r.status, data }; + } catch (e) { + if (e.name === "AbortError") return { status: 504, data: { success: false, error: "hypatia-mcp backend timed out" } }; + return { status: 503, data: { success: false, error: `hypatia-mcp backend unavailable: ${e.message}` } }; + } finally { clearTimeout(t); } +} + +export async function handleTool(toolName, args) { + switch (toolName) { + case "hypatia_scan_repo": { + const { path } = args ?? {}; + if (!path) return { status: 400, data: { error: "path is required" } }; + const payload = { path }; + return post("/api/v1/scan-repo", payload); + } + + case "hypatia_get_score": { + const { scan_id } = args ?? {}; + if (!scan_id) return { status: 400, data: { error: "scan_id is required" } }; + const payload = { scan_id }; + return post("/api/v1/get-score", payload); + } + + case "hypatia_get_rule_set": { + const { _args } = args ?? {}; + const payload = { }; + return post("/api/v1/get-rule-set", payload); + } + + case "hypatia_train_model": { + const { model_name } = args ?? {}; + if (!model_name) return { status: 400, data: { error: "model_name is required" } }; + const payload = { model_name }; + return post("/api/v1/train-model", payload); + } + default: + return { status: 404, data: { error: `Unknown tool: ${toolName}` } }; + } +} diff --git a/cartridges/domains/ci-cd/hypatia-mcp/panels/manifest.json b/cartridges/domains/ci-cd/hypatia-mcp/panels/manifest.json new file mode 100644 index 0000000..aeb8e0f --- /dev/null +++ b/cartridges/domains/ci-cd/hypatia-mcp/panels/manifest.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://panll.dev/schemas/panel-manifest/v1.json", + "spdx": "MPL-2.0", + "cartridge": "hypatia-mcp", + "domain": "ci-cd", + "version": "0.1.0", + "panels": [ + { + "id": "hypatia-scan-badge", + "title": "Scan Score", + "description": "Neurosymbolic scan score badge with colour-coded severity and active rule list.", + "type": "badge", + "entrypoint": "panels/hypatia-scan-badge.js", + "size": { "cols": 2, "rows": 2 }, + "refresh_interval_ms": 10000, + "data_sources": [ + { "op": "GetScore", "interval_ms": 10000 }, + { "op": "GetRuleSet", "interval_ms": 60000 } + ] + } + ] +} diff --git a/cartridges/domains/ci-cd/laminar-mcp/README.adoc b/cartridges/domains/ci-cd/laminar-mcp/README.adoc new file mode 100644 index 0000000..bb17f6a --- /dev/null +++ b/cartridges/domains/ci-cd/laminar-mcp/README.adoc @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += laminar-mcp +Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk> +:spdx: MPL-2.0 +:tier: Ayo +:domain: ci +:protocols: MCP, REST + +== Overview + +Laminar CI/CD pipeline management + +== Tools (4) + +[cols="2,4"] +|=== +| Tool | Description + +| `laminar_create_pipeline` | Create a Laminar pipeline +| `laminar_run_stage` | Run a pipeline stage +| `laminar_get_status` | Get pipeline run status +| `laminar_cancel_pipeline` | Cancel a running pipeline +|=== + +== Architecture + +Three-layer stack per the BoJ cartridge standard: + +[cols="1,1,3"] +|=== +| Layer | Language | Purpose + +| ABI | Idris2 | Formally verified state machine with dependent-type proofs +| FFI | Zig | C-ABI implementation: session pool, action metrics, thread safety +| Adapter| Zig | Adapter bridge to the BoJ unified adapter protocol (zig predecessors sidelined) +|=== + +== Building + +[source,bash] +---- +# ABI (Idris2) +cd abi && idris2 --check *.idr + +# FFI (Zig) +cd ffi && zig build +cd ffi && zig build test + +# Adapter (Zig) +cd adapter && zig build +---- + +== Status + +Alpha (CRG D). Cartridge metadata declared in `cartridge.json`; 4 tool(s) exposed. +Not yet dogfooded externally. See `docs/READINESS.md` at repo root for the +project-wide grade table and `docs/practice/DOGFOOD-LOG.adoc` for usage evidence. diff --git a/cartridges/domains/ci-cd/laminar-mcp/abi/Laminar/Protocol.idr b/cartridges/domains/ci-cd/laminar-mcp/abi/Laminar/Protocol.idr new file mode 100644 index 0000000..68c940c --- /dev/null +++ b/cartridges/domains/ci-cd/laminar-mcp/abi/Laminar/Protocol.idr @@ -0,0 +1,47 @@ +-- SPDX-License-Identifier: MPL-2.0 +-- Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +-- +-- Laminar ABI β€” pipeline orchestration protocol definitions. + +module Laminar.Protocol + +import Data.Nat + +||| Laminar operation codes. +public export +data LaminarOp + = CreatePipeline + | RunStage + | GetStatus + | CancelPipeline + +||| Pipeline execution status. +public export +data PipelineStatus = Pending | Running | Succeeded | Failed | Cancelled + +||| Stage within a pipeline. +public export +record Stage where + constructor MkStage + name : String + index : Nat + status : PipelineStatus + +||| Pipeline with ordered stages. +public export +record Pipeline where + constructor MkPipeline + pipelineId : Nat + stages : List Stage + current : Nat + +||| Proof: a pipeline's current stage index is bounded by total stages. +export +currentStageBounded : (p : Pipeline) -> LTE p.current (length p.stages) -> + LTE p.current (S (length p.stages)) +currentStageBounded _ prf = lteSuccRight prf + +||| Proof: a cancelled pipeline has a valid terminal status. +export +cancelledIsTerminal : (s : PipelineStatus) -> s = Cancelled -> Bool +cancelledIsTerminal Cancelled Refl = True diff --git a/cartridges/domains/ci-cd/laminar-mcp/abi/README.adoc b/cartridges/domains/ci-cd/laminar-mcp/abi/README.adoc new file mode 100644 index 0000000..f4cc47e --- /dev/null +++ b/cartridges/domains/ci-cd/laminar-mcp/abi/README.adoc @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += laminar-mcp / abi β€” Idris2 ABI layer +:orientation: shallow + +== Purpose + +Cartridge-local protocol types for `laminar-mcp`. Defines the data, tool, and +transition vocabulary this cartridge speaks. Inherits safety lemmas and the +matrix-cell identifier scheme from the trunk ABI at `src/abi/`. + +== Files + +1 Idris2 module(s), ~47 lines total. Lead module: +`Protocol.idr`. + +No `.ipkg` β€” consumed directly by wider BoJ callers; type-checks inside the +trunk `boj.ipkg`. + +== Invariants + +* `%default total` at every module top. +* Zero `believe_me`, `assert_total`, `postulate`, `Admitted`, or + `sorry` (the trunk holds the four declared `believe_me` sites; leaf + cartridges carry none). + +== Test/proof surface + +Thin module(s), no inline tests. Proof surface = type-check success under the +trunk `idris2 --check`. + +== Read-first + +. `Protocol.idr` β€” cartridge-local data records and enums. +. Trunk `src/abi/Boj/Catalogue.idr` for how this cell participates in the + 2D matrix. diff --git a/cartridges/domains/ci-cd/laminar-mcp/adapter/README.adoc b/cartridges/domains/ci-cd/laminar-mcp/adapter/README.adoc new file mode 100644 index 0000000..c112b14 --- /dev/null +++ b/cartridges/domains/ci-cd/laminar-mcp/adapter/README.adoc @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += laminar-mcp / adapter β€” protocol bridge +:orientation: shallow + +== Purpose + +Exposes this cartridge's FFI over the wire protocols declared in +`cartridge.json`. Dispatches tool invocations to the C-ABI exports at +`../ffi/` and formats responses per protocol. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” depends on the sibling `ffi` target. +| `laminar_adapter.zig` | Protocol dispatch (129 lines). Tool catalogue matches `cartridge.json`. +| `SIDELINED-*.v.adoc` | zig predecessor(s). Not built β€” zig banned 2026-04-10. +|=== + +== Invariants + +* **Stateless** β€” all state lives behind the FFI, never in the adapter. +* **Response passthrough** β€” whatever the FFI returns goes back to the wire + unmodified (no embellishment, no silent recovery). +* **Cartridge.json is source of truth** for the tool catalogue; drift between + adapter and manifest is a CI failure. + +== Test/proof surface + +No inline tests at the adapter layer β€” protocol routing is exercised by the +trunk seam tests at `ffi/zig/src/seams.zig`. + +== Read-first + +. `laminar_adapter.zig` β€” dispatch function and per-protocol routers. +. `../cartridge.json` β€” the tool manifest the dispatcher mirrors. diff --git a/cartridges/domains/ci-cd/laminar-mcp/adapter/build.zig b/cartridges/domains/ci-cd/laminar-mcp/adapter/build.zig new file mode 100644 index 0000000..7dc80dc --- /dev/null +++ b/cartridges/domains/ci-cd/laminar-mcp/adapter/build.zig @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// laminar-mcp/adapter/build.zig + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const ffi_mod = b.createModule(.{ + .root_source_file = b.path("../ffi/laminar_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + const adapter = b.addExecutable(.{ + .name = "laminar_adapter", + .root_source_file = b.path("laminar_adapter.zig"), + .target = target, + .optimize = optimize, + }); + adapter.root_module.addImport("laminar_ffi", ffi_mod); + b.installArtifact(adapter); +} diff --git a/cartridges/domains/ci-cd/laminar-mcp/adapter/laminar_adapter.zig b/cartridges/domains/ci-cd/laminar-mcp/adapter/laminar_adapter.zig new file mode 100644 index 0000000..bae1ab5 --- /dev/null +++ b/cartridges/domains/ci-cd/laminar-mcp/adapter/laminar_adapter.zig @@ -0,0 +1,129 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// laminar-mcp/adapter/laminar_adapter.zig +// +// Three-protocol BoJ adapter: REST (port 9259), gRPC-compat (port 9260), +// GraphQL (port 9261). +// Replaces the banned zig adapter (laminar_adapter.v). + +const std = @import("std"); +const ffi = @import("laminar_ffi"); + +const REST_PORT: u16 = 9259; +const GRPC_PORT: u16 = 9260; +const GQL_PORT: u16 = 9261; + +const Response = struct { status: u16, body: []const u8 }; + +fn okJson(buf: []u8, msg: []const u8) []u8 { + return std.fmt.bufPrint(buf, "{{\"success\":true,\"message\":\"{s}\"}}", .{msg}) catch buf[0..0]; +} + +fn errJson(buf: []u8, msg: []const u8) []u8 { + return std.fmt.bufPrint(buf, "{{\"success\":false,\"error\":\"{s}\"}}", .{msg}) catch buf[0..0]; +} + +fn dispatch(tool: []const u8, _body: []const u8, resp: []u8) Response { + _ = _body; + if (std.mem.eql(u8, tool, "laminar_create_pipeline")) { + return .{ .status = 200, .body = okJson(resp, "laminar_create_pipeline forwarded") }; + } + if (std.mem.eql(u8, tool, "laminar_run_stage")) { + return .{ .status = 200, .body = okJson(resp, "laminar_run_stage forwarded") }; + } + if (std.mem.eql(u8, tool, "laminar_get_status")) { + return .{ .status = 200, .body = okJson(resp, "laminar_get_status forwarded") }; + } + if (std.mem.eql(u8, tool, "laminar_cancel_pipeline")) { + return .{ .status = 200, .body = okJson(resp, "laminar_cancel_pipeline forwarded") }; + } + return .{ .status = 404, .body = errJson(resp, "unknown tool") }; +} + +fn dispatchRest(path: []const u8, body: []const u8, resp: []u8) Response { + // Expect /tools/<tool_name> + const prefix = "/tools/"; + if (std.mem.startsWith(u8, path, prefix)) { + const tool = path[prefix.len..]; + return dispatch(tool, body, resp); + } + return .{ .status = 404, .body = errJson(resp, "not found") }; +} + +fn dispatchGrpc(path: []const u8, body: []const u8, resp: []u8) Response { + // Expect /<Service>/<Method> β€” derive tool from Method + var it = std.mem.splitScalar(u8, path, '/'); + _ = it.next(); // leading empty + _ = it.next(); // service + const method = it.next() orelse return .{ .status = 404, .body = errJson(resp, "bad gRPC path") }; + return dispatch(method, body, resp); +} + +fn dispatchGraphql(body: []const u8, resp: []u8) Response { + if (std.mem.indexOf(u8, body, "laminar_create_pipeline") != null) + return dispatch("laminar_create_pipeline", body, resp); + if (std.mem.indexOf(u8, body, "laminar_run_stage") != null) + return dispatch("laminar_run_stage", body, resp); + if (std.mem.indexOf(u8, body, "laminar_get_status") != null) + return dispatch("laminar_get_status", body, resp); + if (std.mem.indexOf(u8, body, "laminar_cancel_pipeline") != null) + return dispatch("laminar_cancel_pipeline", body, resp); + return .{ .status = 400, .body = errJson(resp, "unrecognised GraphQL operation") }; +} + +fn handleConnection(stream: std.net.Stream, port: u16) void { + defer stream.close(); + var buf: [4096]u8 = undefined; + var resp_buf: [4096]u8 = undefined; + const n = stream.read(&buf) catch return; + const req = buf[0..n]; + const result = switch (port) { + REST_PORT => blk: { + // Parse HTTP/1.1: first line = METHOD PATH HTTP/x.y + var lines = std.mem.splitScalar(u8, req, '\n'); + const first = lines.next() orelse break :blk Response{ .status = 400, .body = "" }; + var parts = std.mem.splitScalar(u8, std.mem.trim(u8, first, "\r"), ' '); + _ = parts.next(); // method + const path = parts.next() orelse break :blk Response{ .status = 400, .body = "" }; + break :blk dispatchRest(path, req, &resp_buf); + }, + GRPC_PORT => blk: { + var lines = std.mem.splitScalar(u8, req, '\n'); + const first = lines.next() orelse break :blk Response{ .status = 400, .body = "" }; + var parts = std.mem.splitScalar(u8, std.mem.trim(u8, first, "\r"), ' '); + _ = parts.next(); + const path = parts.next() orelse break :blk Response{ .status = 400, .body = "" }; + break :blk dispatchGrpc(path, req, &resp_buf); + }, + GQL_PORT => dispatchGraphql(req, &resp_buf), + else => Response{ .status = 500, .body = "" }, + }; + var http_resp: [512]u8 = undefined; + const http = std.fmt.bufPrint(&http_resp, + "HTTP/1.1 {d} OK\r\nContent-Length: {d}\r\nContent-Type: application/json\r\n\r\n", + .{ result.status, result.body.len }) catch return; + _ = stream.write(http) catch {}; + _ = stream.write(result.body) catch {}; +} + +fn listenLoop(port: u16) !void { + const addr = std.net.Address.initIp4(.{ 127, 0, 0, 1 }, port); + var server = try addr.listen(.{ .reuse_address = true }); + defer server.deinit(); + while (true) { + const conn = try server.accept(); + const t = try std.Thread.spawn(.{}, handleConnection, .{ conn.stream, port }); + t.detach(); + } +} + +pub fn main() !void { + ffi.laminar_init(); + const t1 = try std.Thread.spawn(.{}, listenLoop, .{REST_PORT}); + const t2 = try std.Thread.spawn(.{}, listenLoop, .{GRPC_PORT}); + const t3 = try std.Thread.spawn(.{}, listenLoop, .{GQL_PORT}); + t1.join(); + t2.join(); + t3.join(); +} diff --git a/cartridges/domains/ci-cd/laminar-mcp/cartridge.json b/cartridges/domains/ci-cd/laminar-mcp/cartridge.json new file mode 100644 index 0000000..11e440f --- /dev/null +++ b/cartridges/domains/ci-cd/laminar-mcp/cartridge.json @@ -0,0 +1,105 @@ +{ + "$schema": "https://boj.dev/schemas/cartridge/v1.json", + "spdx": "MPL-2.0", + "copyright": "Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk>", + "name": "laminar-mcp", + "version": "0.1.0", + "description": "Laminar CI/CD pipeline management", + "domain": "ci", + "tier": "Ayo", + "protocols": [ + "MCP", + "REST" + ], + "auth": { + "method": "none", + "env_var": null, + "credential_source": null + }, + "api": { + "base_url": "local://laminar-mcp", + "content_type": "application/json" + }, + "tools": [ + { + "name": "laminar_create_pipeline", + "description": "Create a Laminar pipeline", + "inputSchema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Pipeline name" + } + }, + "required": [ + "name" + ] + } + }, + { + "name": "laminar_run_stage", + "description": "Run a pipeline stage", + "inputSchema": { + "type": "object", + "properties": { + "pipeline_id": { + "type": "integer", + "description": "Pipeline ID" + }, + "stage_name": { + "type": "string", + "description": "Stage name" + } + }, + "required": [ + "pipeline_id", + "stage_name" + ] + } + }, + { + "name": "laminar_get_status", + "description": "Get pipeline run status", + "inputSchema": { + "type": "object", + "properties": { + "pipeline_id": { + "type": "integer", + "description": "Pipeline ID" + } + }, + "required": [ + "pipeline_id" + ] + } + }, + { + "name": "laminar_cancel_pipeline", + "description": "Cancel a running pipeline", + "inputSchema": { + "type": "object", + "properties": { + "pipeline_id": { + "type": "integer", + "description": "Pipeline ID" + } + }, + "required": [ + "pipeline_id" + ] + } + } + ], + "ffi": { + "so_path": "ffi/zig-out/lib/liblaminar_mcp.so", + "abi_version": "ADR-0006", + "symbols": [ + "boj_cartridge_init", + "boj_cartridge_deinit", + "boj_cartridge_name", + "boj_cartridge_version", + "boj_cartridge_invoke" + ] + } +} diff --git a/cartridges/domains/ci-cd/laminar-mcp/ffi/README.adoc b/cartridges/domains/ci-cd/laminar-mcp/ffi/README.adoc new file mode 100644 index 0000000..fd32979 --- /dev/null +++ b/cartridges/domains/ci-cd/laminar-mcp/ffi/README.adoc @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += laminar-mcp / ffi β€” Zig FFI layer **(stub)** +:orientation: shallow + +== Purpose + +C-ABI exports bridging this cartridge's Idris2 ABI to the runtime. Consumed by +the adapter for protocol dispatch and by the trunk loader for mount. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” produces `zig-out/lib/liblaminar.so`. +| `laminar_ffi.zig` | C-ABI exports (4 exports, 3 inline tests, 44 lines). +|=== + +== Invariants + +* **Null-pointer rejection** on `[*c]const u8` arguments before any + dereference. +* **Negative return values** carry documented error meanings; zero is + success (matching the trunk error-code convention). +* **No allocator leaks** β€” every allocation path pairs with `defer free`. + +== Test/proof surface + +3 inline `test "..."` block(s) in `laminar_ffi.zig`. Run with +`cd ffi && zig build test`. + +== Read-first + +. `laminar_ffi.zig` β€” C-ABI signatures and guards. +. Trunk `ffi/zig/src/catalogue.zig` for how this `.so` is loaded. + +== Promotion gate + +Wire these exports to real backing state (not `return 0` / `return 1` +placeholders) before promoting past D. diff --git a/cartridges/domains/ci-cd/laminar-mcp/ffi/build.zig b/cartridges/domains/ci-cd/laminar-mcp/ffi/build.zig new file mode 100644 index 0000000..9371793 --- /dev/null +++ b/cartridges/domains/ci-cd/laminar-mcp/ffi/build.zig @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + // Shared ADR-0006 invoke-shim module (relative path up to boj-server trunk). + + const shim_mod = b.addModule("cartridge_shim", .{ + + .root_source_file = b.path("../../../ffi/zig/src/cartridge_shim.zig"), + + .target = target, + + .optimize = optimize, + + }); + + const ffi_mod = b.addModule("laminar_mcp", .{ + .root_source_file = b.path("laminar_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + ffi_mod.addImport("cartridge_shim", shim_mod); + + const lib = b.addLibrary(.{ + .name = "laminar_mcp", + .root_module = ffi_mod, + .linkage = .dynamic, + }); + lib.linkLibC(); + b.installArtifact(lib); + + const tests = b.addTest(.{ .root_module = ffi_mod }); + tests.linkLibC(); + const run_tests = b.addRunArtifact(tests); + const test_step = b.step("test", "Run FFI tests"); + test_step.dependOn(&run_tests.step); +} + diff --git a/cartridges/domains/ci-cd/laminar-mcp/ffi/cartridge_shim.zig b/cartridges/domains/ci-cd/laminar-mcp/ffi/cartridge_shim.zig new file mode 100644 index 0000000..0e399db --- /dev/null +++ b/cartridges/domains/ci-cd/laminar-mcp/ffi/cartridge_shim.zig @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// cartridge_shim.zig β€” Shared helpers for the ADR-0006 five-symbol +// cartridge ABI (`boj_cartridge_init / deinit / name / version / invoke`). +// +// The shim centralises the seven-code return convention, NUL-argument +// guards, tool-name comparison, and the buffer-too-small path so each +// cartridge's `boj_cartridge_invoke` can stay short β€” typically a tool +// table plus `shim.writeResult(...)`. +// +// Cartridges import this file by relative path (no build-graph change +// needed). Example: +// +// const shim = @import("../../../ffi/zig/src/cartridge_shim.zig"); +// +// export fn boj_cartridge_invoke( +// tool_name: [*c]const u8, +// json_args: [*c]const u8, +// out_buf: [*c]u8, +// in_out_len: [*c]usize, +// ) callconv(.c) i32 { +// _ = json_args; +// if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; +// const body = if (shim.toolIs(tool_name, "foo")) "{\"result\":{}}" +// else return shim.RC_UNKNOWN_TOOL; +// return shim.writeResult(out_buf, in_out_len, body); +// } + +const std = @import("std"); + +// ── Return codes (ADR-0006 Β§Return codes) ──────────────────────────── +// +// Frozen by ADR-0006. New failure modes compose these via the error +// JSON body β€” the integer surface does not grow without a follow-up ADR. + +pub const RC_SUCCESS: i32 = 0; +pub const RC_UNKNOWN_TOOL: i32 = -1; +pub const RC_BAD_ARGS: i32 = -2; +pub const RC_BUFFER_TOO_SMALL: i32 = -3; +pub const RC_RUNTIME_ERROR: i32 = -4; +pub const RC_PANIC: i32 = -5; +pub const RC_AUTH_DENIED: i32 = -6; + +// ── Invoke-path helpers ────────────────────────────────────────────── + +/// True if any of the three mandatory `boj_cartridge_invoke` output-path +/// pointers is null. Use at the top of every invoke to short-circuit to +/// `RC_BAD_ARGS`. +pub fn invokeArgsNull( + tool_name: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) bool { + return tool_name == null or out_buf == null or in_out_len == null; +} + +/// Compare a C-NUL-terminated tool-name pointer against a Zig string +/// literal. Caller must have already verified `tool_name` is non-null +/// (usually via `invokeArgsNull`). +pub fn toolIs(tool_name: [*c]const u8, expected: []const u8) bool { + const s = std.mem.span(@as([*:0]const u8, @ptrCast(tool_name))); + return std.mem.eql(u8, s, expected); +} + +/// Copy `body` into `out_buf[0..*in_out_len]` (as a capacity) and update +/// `*in_out_len` to the number of bytes written. Returns `RC_SUCCESS`. +/// +/// If `body.len` exceeds the current capacity stored in `*in_out_len`, +/// sets `*in_out_len` to the required size and returns +/// `RC_BUFFER_TOO_SMALL` β€” the caller is then expected to re-allocate +/// and retry, per ADR-0006 Β§Memory ownership. +/// +/// Caller must have already verified that `out_buf` and `in_out_len` +/// are non-null. +pub fn writeResult( + out_buf: [*c]u8, + in_out_len: [*c]usize, + body: []const u8, +) i32 { + const cap = in_out_len.*; + if (body.len > cap) { + in_out_len.* = body.len; + return RC_BUFFER_TOO_SMALL; + } + @memcpy(out_buf[0..body.len], body); + in_out_len.* = body.len; + return RC_SUCCESS; +} + +// ── Tests ──────────────────────────────────────────────────────────── + +test "writeResult: body fits, writes and sets length" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); + try std.testing.expectEqualStrings("hello", buf[0..len]); +} + +test "writeResult: too small returns -3 and sets required length" { + var buf: [2]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_BUFFER_TOO_SMALL, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: exact-fit succeeds" { + var buf: [5]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: empty body" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, ""); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 0), len); +} + +test "toolIs: matches and rejects" { + const name: [*:0]const u8 = "foo"; + try std.testing.expect(toolIs(@ptrCast(name), "foo")); + try std.testing.expect(!toolIs(@ptrCast(name), "bar")); + try std.testing.expect(!toolIs(@ptrCast(name), "foobar")); + try std.testing.expect(!toolIs(@ptrCast(name), "fo")); +} + +test "invokeArgsNull: detects each null slot" { + var buf: [4]u8 = undefined; + var len: usize = 4; + const name: [*:0]const u8 = "x"; + try std.testing.expect(!invokeArgsNull(@ptrCast(name), &buf, &len)); + try std.testing.expect(invokeArgsNull(null, &buf, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), null, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), &buf, null)); +} diff --git a/cartridges/domains/ci-cd/laminar-mcp/ffi/laminar_ffi.zig b/cartridges/domains/ci-cd/laminar-mcp/ffi/laminar_ffi.zig new file mode 100644 index 0000000..db31b5f --- /dev/null +++ b/cartridges/domains/ci-cd/laminar-mcp/ffi/laminar_ffi.zig @@ -0,0 +1,135 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +// +// Laminar FFI β€” C-compatible exports for pipeline orchestration. + +const std = @import("std"); + +/// Create a pipeline. Returns pipeline ID or 0 on failure. +export fn laminar_create_pipeline(name: [*c]const u8) u32 { + if (name == null) return 0; + return 1; // Stub +} + +/// Run the next stage. Returns 0 on success, -1 on error. +export fn laminar_run_stage(pipeline_id: u32, stage_name: [*c]const u8) i32 { + if (pipeline_id == 0 or stage_name == null) return -1; + return 0; // Stub +} + +/// Get pipeline status: 0=pending, 1=running, 2=succeeded, 3=failed, 4=cancelled. +export fn laminar_get_status(pipeline_id: u32) u8 { + if (pipeline_id == 0) return 3; // Failed for invalid ID + return 1; // Stub β€” running +} + +/// Cancel a pipeline. Returns 0 on success. +export fn laminar_cancel_pipeline(pipeline_id: u32) i32 { + if (pipeline_id == 0) return -1; + return 0; // Stub +} + +// ═══════════════════════════════════════════════════════════════════════ +// Standard ABI (ADR-0005 four symbols + ADR-0006 invoke) +// ═══════════════════════════════════════════════════════════════════════ + +const shim = @import("cartridge_shim.zig"); + +const CARTRIDGE_NAME_PTR: [*:0]const u8 = "laminar-mcp"; +const CARTRIDGE_VERSION_PTR: [*:0]const u8 = "0.1.0"; + +export fn boj_cartridge_init() callconv(.c) c_int { + return 0; +} + +export fn boj_cartridge_deinit() callconv(.c) void {} + +export fn boj_cartridge_name() callconv(.c) [*:0]const u8 { + return CARTRIDGE_NAME_PTR; +} + +export fn boj_cartridge_version() callconv(.c) [*:0]const u8 { + return CARTRIDGE_VERSION_PTR; +} + +/// Dispatch the cartridge.json MCP tools. Grade D Alpha stubs. +export fn boj_cartridge_invoke( + tool_name: [*c]const u8, + json_args: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) callconv(.c) i32 { + _ = json_args; + if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; + + const body: []const u8 = if (shim.toolIs(tool_name, "laminar_create_pipeline")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "laminar_run_stage")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "laminar_get_status")) + "{\"result\":{\"metadata\":{},\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "laminar_cancel_pipeline")) + "{\"result\":{\"status\":\"stub\"}}" +else + return shim.RC_UNKNOWN_TOOL; + + return shim.writeResult(out_buf, in_out_len, body); +} + +// ── Tests ── + +test "create rejects null name" { + try std.testing.expectEqual(@as(u32, 0), laminar_create_pipeline(null)); +} + +test "run stage rejects invalid pipeline" { + try std.testing.expectEqual(@as(i32, -1), laminar_run_stage(0, "build")); +} + +test "cancel rejects invalid pipeline" { + try std.testing.expectEqual(@as(i32, -1), laminar_cancel_pipeline(0)); +} + +// ═══════════════════════════════════════════════════════════════════════ +// ADR-0006 invoke dispatch tests +// ═══════════════════════════════════════════════════════════════════════ + +test "boj_cartridge_name returns laminar-mcp" { + const n = std.mem.span(boj_cartridge_name()); + try std.testing.expectEqualStrings("laminar-mcp", n); +} + +test "boj_cartridge_init returns 0" { + try std.testing.expectEqual(@as(c_int, 0), boj_cartridge_init()); +} + +test "invoke: each declared tool succeeds" { + var buf: [256]u8 = undefined; + const tools = [_][]const u8{ + "laminar_create_pipeline", + "laminar_run_stage", + "laminar_get_status", + "laminar_cancel_pipeline", + }; + for (tools) |t| { + var len: usize = buf.len; + const rc = boj_cartridge_invoke(t.ptr, "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, 0), rc); + try std.testing.expect(std.mem.indexOf(u8, buf[0..len], "result") != null); + } +} + +test "invoke: unknown tool returns -1" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("nope", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -1), rc); +} + +test "invoke: buffer too small returns -3" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("laminar_create_pipeline", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -3), rc); + try std.testing.expect(len > 4); +} diff --git a/cartridges/domains/ci-cd/laminar-mcp/mod.js b/cartridges/domains/ci-cd/laminar-mcp/mod.js new file mode 100644 index 0000000..7562616 --- /dev/null +++ b/cartridges/domains/ci-cd/laminar-mcp/mod.js @@ -0,0 +1,55 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// laminar-mcp/mod.js β€” Laminar CI/CD pipeline management +// +// Delegates to backend at http://127.0.0.1:7731 (override with LAMINAR_BACKEND_URL). + +const BASE_URL = Deno.env.get("LAMINAR_BACKEND_URL") ?? "http://127.0.0.1:7731"; +const TIMEOUT_MS = 15_000; + +async function post(path, payload) { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), TIMEOUT_MS); + try { + const r = await fetch(`${BASE_URL}${path}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + signal: ctrl.signal, + }); + const data = await r.json().catch(() => ({ success: false, error: "non-JSON response" })); + return { status: r.status, data }; + } catch (e) { + if (e.name === "AbortError") return { status: 504, data: { success: false, error: "laminar-mcp backend timed out" } }; + return { status: 503, data: { success: false, error: `laminar-mcp backend unavailable: ${e.message}` } }; + } finally { clearTimeout(t); } +} + +async function get(path) { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), TIMEOUT_MS); + try { + const r = await fetch(`${BASE_URL}${path}`, { method: "GET", signal: ctrl.signal }); + const data = await r.json().catch(() => ({ success: false, error: "non-JSON response" })); + return { status: r.status, data }; + } catch (e) { + if (e.name === "AbortError") return { status: 504, data: { success: false, error: "laminar-mcp backend timed out" } }; + return { status: 503, data: { success: false, error: `laminar-mcp backend unavailable: ${e.message}` } }; + } finally { clearTimeout(t); } +} + +export async function handleTool(toolName, args) { + switch (toolName) { + case "laminar_create_pipeline": + return post("/api/v1/laminar_create_pipeline", args ?? {}); + case "laminar_run_stage": + return post("/api/v1/laminar_run_stage", args ?? {}); + case "laminar_get_status": + return post("/api/v1/laminar_get_status", args ?? {}); + case "laminar_cancel_pipeline": + return post("/api/v1/laminar_cancel_pipeline", args ?? {}); + default: + return { status: 404, data: { error: `Unknown tool: ${toolName}` } }; + } +} diff --git a/cartridges/domains/ci-cd/laminar-mcp/panels/manifest.json b/cartridges/domains/ci-cd/laminar-mcp/panels/manifest.json new file mode 100644 index 0000000..450a236 --- /dev/null +++ b/cartridges/domains/ci-cd/laminar-mcp/panels/manifest.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://panll.dev/schemas/panel-manifest/v1.json", + "spdx": "MPL-2.0", + "cartridge": "laminar-mcp", + "domain": "pipelines", + "version": "0.1.0", + "panels": [ + { + "id": "laminar-stage-tracker", + "title": "Pipeline Stages", + "description": "Visual pipeline stage tracker with per-stage status, timing, and cancel controls.", + "type": "pipeline", + "entrypoint": "panels/laminar-stage-tracker.js", + "size": { "cols": 4, "rows": 2 }, + "refresh_interval_ms": 3000, + "data_sources": [ + { "op": "GetStatus", "interval_ms": 3000 }, + { "op": "RunStage", "on": "event" } + ] + } + ] +} diff --git a/cartridges/domains/cloud/aws-mcp/README.adoc b/cartridges/domains/cloud/aws-mcp/README.adoc new file mode 100644 index 0000000..014ab48 --- /dev/null +++ b/cartridges/domains/cloud/aws-mcp/README.adoc @@ -0,0 +1,123 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += aws-mcp +Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk> +:spdx: MPL-2.0 +:tier: Ayo +:domain: Cloud +:protocols: MCP, REST + +== Overview + +AWS SDK cartridge for the BoJ server. Provides type-safe access to AWS +services via Signature V4 authentication, with credentials proxied through +vault-mcp. Supports 7 services and 21 actions with configurable region, +automatic endpoint routing, rate-limit back-pressure, and mutability tracking. + +=== State Machine + +`Unauthenticated -> Authenticated -> RateLimited -> Authenticated` (normal flow) + +`Authenticated -> Error -> Unauthenticated` (error recovery) + +=== Services (7) + +[cols="1,1,2"] +|=== +| Service | Endpoint | Description + +| S3 +| `s3.{region}.amazonaws.com` +| Object storage β€” list buckets, get/put/delete objects, presigned URLs + +| Lambda +| `lambda.{region}.amazonaws.com` +| Serverless compute β€” list functions, invoke + +| DynamoDB +| `dynamodb.{region}.amazonaws.com` +| NoSQL database β€” query, scan, get/put items + +| SQS +| `sqs.{region}.amazonaws.com` +| Message queues β€” list queues, send/receive/delete messages + +| CloudWatch +| `monitoring.{region}.amazonaws.com` +| Metrics β€” get metrics, put metric data + +| IAM +| `iam.amazonaws.com` +| Identity management β€” list users, list roles (read-only) + +| STS +| `sts.{region}.amazonaws.com` +| Security tokens β€” get caller identity, assume role +|=== + +=== Actions (21) + +[cols="1,1,1"] +|=== +| Service | Action | Mutating? + +| S3 | S3ListBuckets | No +| S3 | S3GetObject | No +| S3 | S3PutObject | Yes +| S3 | S3DeleteObject | Yes +| S3 | S3PresignedUrl | No +| Lambda | LambdaListFunctions | No +| Lambda | LambdaInvoke | Yes +| DynamoDB | DynamoQuery | No +| DynamoDB | DynamoScan | No +| DynamoDB | DynamoPutItem | Yes +| DynamoDB | DynamoGetItem | No +| SQS | SqsListQueues | No +| SQS | SqsSendMessage | Yes +| SQS | SqsReceiveMessage | No +| SQS | SqsDeleteMessage | Yes +| CloudWatch | CwGetMetrics | No +| CloudWatch | CwPutMetricData | Yes +| IAM | IamListUsers | No +| IAM | IamListRoles | No +| STS | StsGetCallerIdentity | No +| STS | StsAssumeRole | Yes +|=== + +== Architecture + +[cols="1,1,2"] +|=== +| Layer | Language | Purpose + +| ABI +| Idris2 +| Formally verified state machine with dependent-type proofs (AwsMcp.SafeCloud) + +| FFI +| Zig +| C-compatible implementation with thread-safe session pool, service routing, mutability checks + +| Adapter +| zig +| REST bridge to BoJ unified adapter protocol +|=== + +== Building + +[source,bash] +---- +# Build FFI shared library +cd ffi && zig build + +# Run FFI tests +cd ffi && zig build test + +# Type-check ABI +cd abi && idris2 --check aws_mcp.ipkg +---- + +== Status + +Development -- v0.3.0: expanded to 7 services (S3, Lambda, DynamoDB, SQS, CloudWatch, IAM, STS) +with 21 actions, presigned URL support, and mutability tracking. diff --git a/cartridges/domains/cloud/aws-mcp/abi/AwsMcp/SafeCloud.idr b/cartridges/domains/cloud/aws-mcp/abi/AwsMcp/SafeCloud.idr new file mode 100644 index 0000000..8d8d486 --- /dev/null +++ b/cartridges/domains/cloud/aws-mcp/abi/AwsMcp/SafeCloud.idr @@ -0,0 +1,314 @@ +-- SPDX-License-Identifier: MPL-2.0 +-- Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +-- +-- AwsMcp.SafeCloud β€” Type-safe ABI for aws-mcp cartridge. +-- +-- Dependent-type state machine governing AWS API access through vault-mcp +-- credential proxy. Encodes AWS Signature V4 auth flow, multi-service +-- routing (S3, Lambda, DynamoDB, SQS, CloudWatch, IAM, STS), and rate-limit +-- back-pressure as compile-time invariants. No unsafe escape hatches. + +module AwsMcp.SafeCloud + +%default total + +-- --------------------------------------------------------------------------- +-- Authentication state machine +-- --------------------------------------------------------------------------- + +||| Session state for AWS MCP operations. +||| Unauthenticated: no credentials loaded. +||| Authenticated: AWS Signature V4 credentials active (access_key_id + +||| secret_access_key + region + optional session_token), obtained via vault-mcp. +||| RateLimited: AWS throttling response received; must wait before retry. +||| Error: unrecoverable error (invalid credentials, permission denied, etc.). +public export +data SessionState + = Unauthenticated + | Authenticated + | RateLimited + | Error + +||| Proof that a state transition is valid. +||| Only these six edges are permitted in the session lifecycle. +public export +data ValidTransition : SessionState -> SessionState -> Type where + Authenticate : ValidTransition Unauthenticated Authenticated + Deauthenticate : ValidTransition Authenticated Unauthenticated + Throttle : ValidTransition Authenticated RateLimited + Unthrottle : ValidTransition RateLimited Authenticated + AuthError : ValidTransition Authenticated Error + Recover : ValidTransition Error Unauthenticated + +-- --------------------------------------------------------------------------- +-- C-ABI integer encoding +-- --------------------------------------------------------------------------- + +||| Encode session state as C-compatible integer for the Zig FFI boundary. +export +sessionStateToInt : SessionState -> Int +sessionStateToInt Unauthenticated = 0 +sessionStateToInt Authenticated = 1 +sessionStateToInt RateLimited = 2 +sessionStateToInt Error = 3 + +||| Decode integer back to session state. Returns Nothing for out-of-range. +export +intToSessionState : Int -> Maybe SessionState +intToSessionState 0 = Just Unauthenticated +intToSessionState 1 = Just Authenticated +intToSessionState 2 = Just RateLimited +intToSessionState 3 = Just Error +intToSessionState _ = Nothing + +||| Check if a state transition is valid (C-ABI export). +||| Returns 1 for valid, 0 for invalid. +export +aws_mcp_can_transition : Int -> Int -> Int +aws_mcp_can_transition from to = + case (intToSessionState from, intToSessionState to) of + (Just Unauthenticated, Just Authenticated) => 1 + (Just Authenticated, Just Unauthenticated) => 1 + (Just Authenticated, Just RateLimited) => 1 + (Just RateLimited, Just Authenticated) => 1 + (Just Authenticated, Just Error) => 1 + (Just Error, Just Unauthenticated) => 1 + _ => 0 + +-- --------------------------------------------------------------------------- +-- AWS service routing +-- --------------------------------------------------------------------------- + +||| AWS services accessible through this cartridge. +||| S3: object storage. Lambda: serverless compute. DynamoDB: NoSQL database. +||| SQS: message queues. CloudWatch: metrics/monitoring. +||| IAM: identity management (read-only). STS: security token service. +public export +data AwsService + = S3 + | Lambda + | DynamoDB + | SQS + | CloudWatch + | IAM + | STS + +||| Map service to its API endpoint prefix. +export +serviceEndpoint : AwsService -> String +serviceEndpoint S3 = "s3" +serviceEndpoint Lambda = "lambda" +serviceEndpoint DynamoDB = "dynamodb" +serviceEndpoint SQS = "sqs" +serviceEndpoint CloudWatch = "monitoring" +serviceEndpoint IAM = "iam" +serviceEndpoint STS = "sts" + +||| Encode service as C-compatible integer for FFI. +export +serviceToInt : AwsService -> Int +serviceToInt S3 = 0 +serviceToInt Lambda = 1 +serviceToInt DynamoDB = 2 +serviceToInt SQS = 3 +serviceToInt CloudWatch = 4 +serviceToInt IAM = 5 +serviceToInt STS = 6 + +||| Decode integer to AWS service. +export +intToService : Int -> Maybe AwsService +intToService 0 = Just S3 +intToService 1 = Just Lambda +intToService 2 = Just DynamoDB +intToService 3 = Just SQS +intToService 4 = Just CloudWatch +intToService 5 = Just IAM +intToService 6 = Just STS +intToService _ = Nothing + +-- --------------------------------------------------------------------------- +-- AWS actions +-- --------------------------------------------------------------------------- + +||| Actions available through the AWS MCP cartridge. +||| Grouped by service: +||| S3: bucket/object operations including presigned URLs +||| Lambda: function invocation and listing +||| DynamoDB: table/item operations (query, scan, get, put) +||| SQS: queue/message operations +||| CloudWatch: metrics (get/put) +||| IAM: read-only user/role listing +||| STS: caller identity and role assumption +public export +data AwsAction + -- S3 (0-4) + = S3ListBuckets + | S3GetObject + | S3PutObject + | S3DeleteObject + | S3PresignedUrl + -- Lambda (5-6) + | LambdaListFunctions + | LambdaInvoke + -- DynamoDB (7-10) + | DynamoQuery + | DynamoScan + | DynamoPutItem + | DynamoGetItem + -- SQS (11-14) + | SqsListQueues + | SqsSendMessage + | SqsReceiveMessage + | SqsDeleteMessage + -- CloudWatch (15-16) + | CwGetMetrics + | CwPutMetricData + -- IAM (17-18) β€” read-only + | IamListUsers + | IamListRoles + -- STS (19-20) + | StsGetCallerIdentity + | StsAssumeRole + +||| Which service handles a given action. +export +actionService : AwsAction -> AwsService +actionService S3ListBuckets = S3 +actionService S3GetObject = S3 +actionService S3PutObject = S3 +actionService S3DeleteObject = S3 +actionService S3PresignedUrl = S3 +actionService LambdaListFunctions = Lambda +actionService LambdaInvoke = Lambda +actionService DynamoQuery = DynamoDB +actionService DynamoScan = DynamoDB +actionService DynamoPutItem = DynamoDB +actionService DynamoGetItem = DynamoDB +actionService SqsListQueues = SQS +actionService SqsSendMessage = SQS +actionService SqsReceiveMessage = SQS +actionService SqsDeleteMessage = SQS +actionService CwGetMetrics = CloudWatch +actionService CwPutMetricData = CloudWatch +actionService IamListUsers = IAM +actionService IamListRoles = IAM +actionService StsGetCallerIdentity = STS +actionService StsAssumeRole = STS + +||| Encode action as C-compatible integer for FFI. +export +actionToInt : AwsAction -> Int +actionToInt S3ListBuckets = 0 +actionToInt S3GetObject = 1 +actionToInt S3PutObject = 2 +actionToInt S3DeleteObject = 3 +actionToInt S3PresignedUrl = 4 +actionToInt LambdaListFunctions = 5 +actionToInt LambdaInvoke = 6 +actionToInt DynamoQuery = 7 +actionToInt DynamoScan = 8 +actionToInt DynamoPutItem = 9 +actionToInt DynamoGetItem = 10 +actionToInt SqsListQueues = 11 +actionToInt SqsSendMessage = 12 +actionToInt SqsReceiveMessage = 13 +actionToInt SqsDeleteMessage = 14 +actionToInt CwGetMetrics = 15 +actionToInt CwPutMetricData = 16 +actionToInt IamListUsers = 17 +actionToInt IamListRoles = 18 +actionToInt StsGetCallerIdentity = 19 +actionToInt StsAssumeRole = 20 + +||| Decode integer to AWS action. +export +intToAction : Int -> Maybe AwsAction +intToAction 0 = Just S3ListBuckets +intToAction 1 = Just S3GetObject +intToAction 2 = Just S3PutObject +intToAction 3 = Just S3DeleteObject +intToAction 4 = Just S3PresignedUrl +intToAction 5 = Just LambdaListFunctions +intToAction 6 = Just LambdaInvoke +intToAction 7 = Just DynamoQuery +intToAction 8 = Just DynamoScan +intToAction 9 = Just DynamoPutItem +intToAction 10 = Just DynamoGetItem +intToAction 11 = Just SqsListQueues +intToAction 12 = Just SqsSendMessage +intToAction 13 = Just SqsReceiveMessage +intToAction 14 = Just SqsDeleteMessage +intToAction 15 = Just CwGetMetrics +intToAction 16 = Just CwPutMetricData +intToAction 17 = Just IamListUsers +intToAction 18 = Just IamListRoles +intToAction 19 = Just StsGetCallerIdentity +intToAction 20 = Just StsAssumeRole +intToAction _ = Nothing + +||| Whether an action requires Authenticated state. +||| All AWS actions require authentication. +export +actionRequiresAuth : AwsAction -> Bool +actionRequiresAuth _ = True + +||| Whether an action is a write/mutating operation. +||| IAM listing, STS GetCallerIdentity, S3 reads, DynamoDB reads, SQS reads, +||| and CloudWatch reads are non-mutating. Everything else mutates state. +export +actionIsMutating : AwsAction -> Bool +actionIsMutating S3PutObject = True +actionIsMutating S3DeleteObject = True +actionIsMutating LambdaInvoke = True +actionIsMutating DynamoPutItem = True +actionIsMutating SqsSendMessage = True +actionIsMutating SqsDeleteMessage = True +actionIsMutating CwPutMetricData = True +actionIsMutating StsAssumeRole = True +actionIsMutating _ = False + +||| Whether an action is strictly read-only (safe for audit/inspection). +||| Useful for enforcing least-privilege in IAM policy generation. +export +actionIsReadOnly : AwsAction -> Bool +actionIsReadOnly act = not (actionIsMutating act) + +-- --------------------------------------------------------------------------- +-- MCP tool declarations +-- --------------------------------------------------------------------------- + +||| Tools exposed via MCP protocol for this cartridge. +public export +data McpTool + = ToolAuthenticate + | ToolDeauthenticate + | ToolStatus + | ToolInvoke + | ToolListServices + | ToolListActions + +||| Check if a tool requires an authenticated session. +export +toolRequiresSession : McpTool -> Bool +toolRequiresSession ToolAuthenticate = False +toolRequiresSession ToolDeauthenticate = True +toolRequiresSession ToolStatus = False +toolRequiresSession ToolInvoke = True +toolRequiresSession ToolListServices = False +toolRequiresSession ToolListActions = False + +||| Total tool count for this cartridge. +export +toolCount : Nat +toolCount = 6 + +||| Total action count for this cartridge. +export +actionCount : Nat +actionCount = 21 + +||| Total service count for this cartridge. +export +serviceCount : Nat +serviceCount = 7 diff --git a/cartridges/domains/cloud/aws-mcp/abi/README.adoc b/cartridges/domains/cloud/aws-mcp/abi/README.adoc new file mode 100644 index 0000000..ac43e0d --- /dev/null +++ b/cartridges/domains/cloud/aws-mcp/abi/README.adoc @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += aws-mcp / abi β€” Idris2 ABI layer +:orientation: shallow + +== Purpose + +Cartridge-local protocol types for `aws-mcp`. Defines the data, tool, and +transition vocabulary this cartridge speaks. Inherits safety lemmas and the +matrix-cell identifier scheme from the trunk ABI at `src/abi/`. + +== Files + +1 Idris2 module(s), ~314 lines total. Lead module: +`SafeCloud.idr`. + +No `.ipkg` β€” consumed directly by wider BoJ callers; type-checks inside the +trunk `boj.ipkg`. + +== Invariants + +* `%default total` at every module top. +* Zero `believe_me`, `assert_total`, `postulate`, `Admitted`, or + `sorry` (the trunk holds the four declared `believe_me` sites; leaf + cartridges carry none). + +== Test/proof surface + +Thin module(s), no inline tests. Proof surface = type-check success under the +trunk `idris2 --check`. + +== Read-first + +. `SafeCloud.idr` β€” cartridge-local data records and enums. +. Trunk `src/abi/Boj/Catalogue.idr` for how this cell participates in the + 2D matrix. diff --git a/cartridges/domains/cloud/aws-mcp/abi/aws_mcp.ipkg b/cartridges/domains/cloud/aws-mcp/abi/aws_mcp.ipkg new file mode 100644 index 0000000..1eeeade --- /dev/null +++ b/cartridges/domains/cloud/aws-mcp/abi/aws_mcp.ipkg @@ -0,0 +1,13 @@ +-- SPDX-License-Identifier: MPL-2.0 +-- Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +package aws_mcp + +version = "0.3.0" +authors = "Jonathan D.A. Jewell" +brief = "Type-safe ABI for AWS MCP cartridge β€” Signature V4 auth, multi-service routing (S3/Lambda/DynamoDB/SQS/CloudWatch/IAM/STS)" + +sourcedir = "." + +depends = base + +modules = AwsMcp.SafeCloud diff --git a/cartridges/domains/cloud/aws-mcp/adapter/README.adoc b/cartridges/domains/cloud/aws-mcp/adapter/README.adoc new file mode 100644 index 0000000..78da2c1 --- /dev/null +++ b/cartridges/domains/cloud/aws-mcp/adapter/README.adoc @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += aws-mcp / adapter β€” protocol bridge +:orientation: shallow + +== Purpose + +Exposes this cartridge's FFI over the wire protocols declared in +`cartridge.json`. Dispatches tool invocations to the C-ABI exports at +`../ffi/` and formats responses per protocol. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” depends on the sibling `ffi` target. +| `aws_mcp_adapter.zig` | Protocol dispatch (115 lines). Tool catalogue matches `cartridge.json`. +| `SIDELINED-*.v.adoc` | zig predecessor(s). Not built β€” zig banned 2026-04-10. +|=== + +== Invariants + +* **Stateless** β€” all state lives behind the FFI, never in the adapter. +* **Response passthrough** β€” whatever the FFI returns goes back to the wire + unmodified (no embellishment, no silent recovery). +* **Cartridge.json is source of truth** for the tool catalogue; drift between + adapter and manifest is a CI failure. + +== Test/proof surface + +No inline tests at the adapter layer β€” protocol routing is exercised by the +trunk seam tests at `ffi/zig/src/seams.zig`. + +== Read-first + +. `aws_mcp_adapter.zig` β€” dispatch function and per-protocol routers. +. `../cartridge.json` β€” the tool manifest the dispatcher mirrors. diff --git a/cartridges/domains/cloud/aws-mcp/adapter/aws_mcp_adapter.zig b/cartridges/domains/cloud/aws-mcp/adapter/aws_mcp_adapter.zig new file mode 100644 index 0000000..1dc0886 --- /dev/null +++ b/cartridges/domains/cloud/aws-mcp/adapter/aws_mcp_adapter.zig @@ -0,0 +1,115 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// aws-mcp/adapter/aws_mcp_adapter.zig -- Unified three-protocol adapter. +// Replaces banned aws_mcp_adapter.v (zig, removed 2026-04-12). +// REST:9139 gRPC:9140 GraphQL:9141 +// Tools: aws_authenticate, aws_s3_list, aws_s3_get, aws_s3_put... + +const std = @import("std"); +const ffi = @import("aws_mcp_ffi"); + +const REST_PORT: u16 = 9139; +const GRPC_PORT: u16 = 9140; +const GQL_PORT: u16 = 9141; +const MAX_CONN_BUF: usize = 16 * 1024; + +fn okJson(buf: []u8, msg: []const u8) []u8 { + const n = std.fmt.bufPrint(buf, "{\"success\":true,\"message\":\"{s}\"}", .{msg}) catch return buf[0..0]; return n; +} +fn errJson(buf: []u8, msg: []const u8) []u8 { + const n = std.fmt.bufPrint(buf, "{\"success\":false,\"error\":\"{s}\"}", .{msg}) catch return buf[0..0]; return n; +} +fn statusJson(buf: []u8) []u8 { + const n = std.fmt.bufPrint(buf, "{\"success\":true,\"state\":\"ready\",\"service\":\"aws-mcp\"}", .{}) catch return buf[0..0]; return n; +} + +const Response = struct { status: u16, body: []u8 }; + +fn dispatch(tool: []const u8, body: []const u8, resp: []u8) Response { + _ = body; + if (std.mem.eql(u8, tool, "aws_authenticate")) return .{ .status = 200, .body = okJson(resp, "aws_authenticate forwarded") }; + if (std.mem.eql(u8, tool, "aws_s3_list")) return .{ .status = 200, .body = okJson(resp, "aws_s3_list forwarded") }; + if (std.mem.eql(u8, tool, "aws_s3_get")) return .{ .status = 200, .body = okJson(resp, "aws_s3_get forwarded") }; + if (std.mem.eql(u8, tool, "aws_s3_put")) return .{ .status = 200, .body = okJson(resp, "aws_s3_put forwarded") }; + if (std.mem.eql(u8, tool, "aws_ec2_list")) return .{ .status = 200, .body = okJson(resp, "aws_ec2_list forwarded") }; + if (std.mem.eql(u8, tool, "aws_lambda_invoke")) return .{ .status = 200, .body = okJson(resp, "aws_lambda_invoke forwarded") }; + if (std.mem.eql(u8, tool, "aws_session_state")) return .{ .status = 200, .body = okJson(resp, "aws_session_state forwarded") }; + if (std.mem.eql(u8, tool, "aws_deauthenticate")) return .{ .status = 200, .body = okJson(resp, "aws_deauthenticate forwarded") }; + if (std.mem.eql(u8, tool, "status") or std.mem.eql(u8, tool, "health")) return .{ .status = 200, .body = statusJson(resp) }; + return .{ .status = 404, .body = errJson(resp, "Unknown tool") }; +} + +fn dispatchRest(path: []const u8, body: []const u8, resp: []u8) Response { + if (std.mem.startsWith(u8, path, "/tools/")) return dispatch(path["/tools/".len..], body, resp); + if (std.mem.eql(u8, path, "/status") or std.mem.eql(u8, path, "/health")) return .{ .status = 200, .body = statusJson(resp) }; + return .{ .status = 404, .body = errJson(resp, "Not found") }; +} + +fn dispatchGrpc(path: []const u8, body: []const u8, resp: []u8) Response { + const prefix = "/AwsMcpservice/"; + if (!std.mem.startsWith(u8, path, prefix)) return .{ .status = 404, .body = errJson(resp, "Not a recognized gRPC path") }; + const method = path[prefix.len..]; + const tool = blk: { + if (std.mem.eql(u8, method, "aws_authenticate")) break :blk "aws_authenticate"; + if (std.mem.eql(u8, method, "aws_s3_list")) break :blk "aws_s3_list"; + if (std.mem.eql(u8, method, "aws_s3_get")) break :blk "aws_s3_get"; + if (std.mem.eql(u8, method, "aws_s3_put")) break :blk "aws_s3_put"; + if (std.mem.eql(u8, method, "aws_ec2_list")) break :blk "aws_ec2_list"; + if (std.mem.eql(u8, method, "aws_lambda_invoke")) break :blk "aws_lambda_invoke"; + if (std.mem.eql(u8, method, "aws_session_state")) break :blk "aws_session_state"; + if (std.mem.eql(u8, method, "aws_deauthenticate")) break :blk "aws_deauthenticate"; + return .{ .status = 404, .body = errJson(resp, "Unknown gRPC method") }; + }; + return dispatch(tool, body, resp); +} + +fn dispatchGraphql(body: []const u8, resp: []u8) Response { + if (std.mem.indexOf(u8, body, "__schema") != null) return .{ .status = 200, .body = okJson(resp, "schema not supported") }; + if (std.mem.indexOf(u8, body, "authenticate") != null) return dispatch("aws_authenticate", body, resp); + if (std.mem.indexOf(u8, body, "s3_list") != null) return dispatch("aws_s3_list", body, resp); + if (std.mem.indexOf(u8, body, "s3_get") != null) return dispatch("aws_s3_get", body, resp); + if (std.mem.indexOf(u8, body, "s3_put") != null) return dispatch("aws_s3_put", body, resp); + if (std.mem.indexOf(u8, body, "ec2_list") != null) return dispatch("aws_ec2_list", body, resp); + if (std.mem.indexOf(u8, body, "lambda_invoke") != null) return dispatch("aws_lambda_invoke", body, resp); + if (std.mem.indexOf(u8, body, "session_state") != null) return dispatch("aws_session_state", body, resp); + if (std.mem.indexOf(u8, body, "deauthenticate") != null) return dispatch("aws_deauthenticate", body, resp); + return .{ .status = 200, .body = errJson(resp, "Unrecognised GraphQL operation") }; +} + +const Protocol = enum { rest, grpc, graphql }; + +fn handleConnection(conn: std.net.Server.Connection, proto: Protocol) void { + defer conn.stream.close(); + var in_buf: [MAX_CONN_BUF]u8 = undefined; + const n = conn.stream.read(&in_buf) catch return; + const req = in_buf[0..n]; + var path: []const u8 = "/"; var body: []const u8 = ""; + if (n > 4) { + const le = std.mem.indexOf(u8, req, "\r\n") orelse req.len; + const fl = req[0..le]; const sp1 = std.mem.indexOfScalar(u8, fl, ' ') orelse 0; + const ro = fl[sp1+1..]; const sp2 = std.mem.indexOfScalar(u8, ro, ' ') orelse ro.len; + path = ro[0..sp2]; + const bs = std.mem.indexOf(u8, req, "\r\n\r\n") orelse n; body = req[@min(bs+4,n)..]; + } + var rb: [MAX_CONN_BUF]u8 = undefined; + const result = switch (proto) { .rest => dispatchRest(path, body, &rb), .grpc => dispatchGrpc(path, body, &rb), .graphql => dispatchGraphql(body, &rb) }; + const ct: []const u8 = if (proto == .grpc) "application/grpc+json" else "application/json"; + var hb: [256]u8 = undefined; + const hdr = std.fmt.bufPrint(&hb, "HTTP/1.1 {d} OK\r\nContent-Type: {s}\r\nContent-Length: {d}\r\nConnection: close\r\n\r\n", .{ result.status, ct, result.body.len }) catch return; + _ = conn.stream.writeAll(hdr) catch return; _ = conn.stream.writeAll(result.body) catch return; +} + +fn listenLoop(port: u16, proto: Protocol) void { + const addr = std.net.Address.initIp4(.{ 127, 0, 0, 1 }, port) catch return; + var srv = addr.listen(.{ .reuse_address = true }) catch return; defer srv.deinit(); + while (true) { const conn = srv.accept() catch continue; handleConnection(conn, proto); } +} + +pub fn main() !void { + ffi.aws_mcp_init(); + const t1 = try std.Thread.spawn(.{}, listenLoop, .{ REST_PORT, Protocol.rest }); + const t2 = try std.Thread.spawn(.{}, listenLoop, .{ GRPC_PORT, Protocol.grpc }); + const t3 = try std.Thread.spawn(.{}, listenLoop, .{ GQL_PORT, Protocol.graphql }); + t1.join(); t2.join(); t3.join(); +} diff --git a/cartridges/domains/cloud/aws-mcp/adapter/build.zig b/cartridges/domains/cloud/aws-mcp/adapter/build.zig new file mode 100644 index 0000000..e3fb41a --- /dev/null +++ b/cartridges/domains/cloud/aws-mcp/adapter/build.zig @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// aws-mcp/adapter/build.zig + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); const optimize = b.standardOptimizeOption(.{}); + const ffi_mod = b.createModule(.{ .root_source_file = b.path("../ffi/aws_mcp_ffi.zig"), .target = target, .optimize = optimize }); + const adapter = b.addExecutable(.{ .name = "aws_mcp_adapter", .root_source_file = b.path("aws_mcp_adapter.zig"), .target = target, .optimize = optimize }); + adapter.root_module.addImport("aws_mcp_ffi", ffi_mod); b.installArtifact(adapter); + const rs = b.step("run", "Run aws-mcp adapter"); rs.dependOn(&b.addRunArtifact(adapter).step); + const tests = b.addTest(.{ .root_source_file = b.path("aws_mcp_adapter.zig"), .target = target, .optimize = optimize }); + tests.root_module.addImport("aws_mcp_ffi", ffi_mod); + const ts = b.step("test", "Test aws-mcp adapter"); ts.dependOn(&b.addRunArtifact(tests).step); +} diff --git a/cartridges/domains/cloud/aws-mcp/benchmarks/quick-bench.sh b/cartridges/domains/cloud/aws-mcp/benchmarks/quick-bench.sh new file mode 100755 index 0000000..faaf25a --- /dev/null +++ b/cartridges/domains/cloud/aws-mcp/benchmarks/quick-bench.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Benchmarks for aws-mcp cartridge. +set -euo pipefail + +echo "=== aws-mcp benchmarks ===" + +cd "$(dirname "${BASH_SOURCE[0]}")/../ffi" + +# Build with ReleaseFast for benchmarking +zig build -Doptimize=ReleaseFast 2>&1 + +echo "Session open/close cycle (1000 iterations):" +time for i in $(seq 1 1000); do + # This would call the FFI benchmark binary + true +done + +echo "" +echo "Benchmark placeholder β€” implement real benchmarks in Zig test blocks" +echo "or via the zig adapter HTTP benchmark tool." diff --git a/cartridges/domains/cloud/aws-mcp/cartridge.json b/cartridges/domains/cloud/aws-mcp/cartridge.json new file mode 100644 index 0000000..5f600f7 --- /dev/null +++ b/cartridges/domains/cloud/aws-mcp/cartridge.json @@ -0,0 +1,191 @@ +{ + "$schema": "https://boj.dev/schemas/cartridge/v1.json", + "spdx": "MPL-2.0", + "copyright": "Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath)", + "name": "aws-mcp", + "version": "0.1.0", + "description": "AWS cloud gateway. Session-based authentication with per-region slots, throttle management, and multi-service action routing.", + "domain": "Cloud", + "tier": "Ayo", + "protocols": [ + "MCP", + "REST" + ], + "auth": { + "method": "none", + "env_var": null, + "credential_source": null + }, + "api": { + "base_url": "local://aws-mcp", + "content_type": "application/json" + }, + "tools": [ + { + "name": "aws_authenticate", + "description": "Authenticate with AWS for a region. Returns a session slot index.", + "inputSchema": { + "type": "object", + "properties": { + "region": { + "type": "string", + "description": "AWS region (e.g. 'us-east-1')" + }, + "profile": { + "type": "string", + "description": "AWS profile name (default: 'default')" + } + }, + "required": [ + "region" + ] + } + }, + { + "name": "aws_s3_list", + "description": "List S3 buckets or objects in a bucket.", + "inputSchema": { + "type": "object", + "properties": { + "bucket": { + "type": "string", + "description": "Bucket name (omit to list all buckets)" + }, + "prefix": { + "type": "string", + "description": "Key prefix filter" + } + }, + "required": [] + } + }, + { + "name": "aws_s3_get", + "description": "Get an S3 object's content or metadata.", + "inputSchema": { + "type": "object", + "properties": { + "bucket": { + "type": "string", + "description": "Bucket name" + }, + "key": { + "type": "string", + "description": "Object key" + } + }, + "required": [ + "bucket", + "key" + ] + } + }, + { + "name": "aws_s3_put", + "description": "Upload content to an S3 object.", + "inputSchema": { + "type": "object", + "properties": { + "bucket": { + "type": "string", + "description": "Bucket name" + }, + "key": { + "type": "string", + "description": "Object key" + }, + "body": { + "type": "string", + "description": "Object content" + } + }, + "required": [ + "bucket", + "key", + "body" + ] + } + }, + { + "name": "aws_ec2_list", + "description": "List EC2 instances with optional filters.", + "inputSchema": { + "type": "object", + "properties": { + "region": { + "type": "string", + "description": "AWS region" + }, + "state": { + "type": "string", + "description": "Filter by state: running / stopped / terminated" + } + }, + "required": [] + } + }, + { + "name": "aws_lambda_invoke", + "description": "Invoke a Lambda function.", + "inputSchema": { + "type": "object", + "properties": { + "function_name": { + "type": "string", + "description": "Lambda function name or ARN" + }, + "payload": { + "type": "string", + "description": "JSON payload to send to the function" + } + }, + "required": [ + "function_name" + ] + } + }, + { + "name": "aws_session_state", + "description": "Get the state of an AWS session slot.", + "inputSchema": { + "type": "object", + "properties": { + "slot": { + "type": "integer", + "description": "Session slot from aws_authenticate" + } + }, + "required": [ + "slot" + ] + } + }, + { + "name": "aws_deauthenticate", + "description": "Release an AWS session slot.", + "inputSchema": { + "type": "object", + "properties": { + "slot": { + "type": "integer", + "description": "Session slot to release" + } + }, + "required": [ + "slot" + ] + } + } + ], + "ffi": { + "so_path": "ffi/zig-out/lib/libaws_mcp.so", + "abi_version": "ADR-0006", + "symbols": [ + "boj_cartridge_init", + "boj_cartridge_deinit", + "boj_cartridge_name", + "boj_cartridge_version", + "boj_cartridge_invoke" + ] + } +} diff --git a/cartridges/domains/cloud/aws-mcp/ffi/README.adoc b/cartridges/domains/cloud/aws-mcp/ffi/README.adoc new file mode 100644 index 0000000..8c04ff3 --- /dev/null +++ b/cartridges/domains/cloud/aws-mcp/ffi/README.adoc @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += aws-mcp / ffi β€” Zig FFI layer +:orientation: shallow + +== Purpose + +C-ABI exports bridging this cartridge's Idris2 ABI to the runtime. Consumed by +the adapter for protocol dispatch and by the trunk loader for mount. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” produces `zig-out/lib/libaws.so`. +| `aws_mcp_ffi.zig` | C-ABI exports (14 exports, 9 inline tests, 465 lines). +|=== + +== Invariants + +* **Null-pointer rejection** on `[*c]const u8` arguments before any + dereference. +* **Negative return values** carry documented error meanings; zero is + success (matching the trunk error-code convention). +* **No allocator leaks** β€” every allocation path pairs with `defer free`. + +== Test/proof surface + +9 inline `test "..."` block(s) in `aws_mcp_ffi.zig`. Run with +`cd ffi && zig build test`. + +== Read-first + +. `aws_mcp_ffi.zig` β€” C-ABI signatures and guards. +. Trunk `ffi/zig/src/catalogue.zig` for how this `.so` is loaded. diff --git a/cartridges/domains/cloud/aws-mcp/ffi/aws_mcp_ffi.zig b/cartridges/domains/cloud/aws-mcp/ffi/aws_mcp_ffi.zig new file mode 100644 index 0000000..0acedc8 --- /dev/null +++ b/cartridges/domains/cloud/aws-mcp/ffi/aws_mcp_ffi.zig @@ -0,0 +1,568 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// aws_mcp_ffi.zig β€” C-ABI FFI implementation for aws-mcp cartridge. +// +// Implements the state machine defined in AwsMcp.SafeCloud (Idris2 ABI). +// State machine: Unauthenticated | Authenticated | RateLimited | Error +// Auth: AWS Signature V4 (access_key_id + secret_access_key + region + +// optional session_token) via vault-mcp. +// Services: S3, Lambda, DynamoDB, SQS, CloudWatch, IAM, STS with +// configurable region and endpoint routing. +// Thread-safe via std.Thread.Mutex. Fixed-size session pool, no heap allocations. + +const std = @import("std"); + +// --------------------------------------------------------------------------- +// State machine (matches Idris2 ABI SessionState exactly) +// --------------------------------------------------------------------------- + +/// Session authentication/lifecycle state. +/// 0 = Unauthenticated, 1 = Authenticated, 2 = RateLimited, 3 = Error. +pub const SessionState = enum(c_int) { + unauthenticated = 0, + authenticated = 1, + rate_limited = 2, + err = 3, +}; + +/// AWS service identifiers matching Idris2 AwsService encoding. +/// 0=S3, 1=Lambda, 2=DynamoDB, 3=SQS, 4=CloudWatch, 5=IAM, 6=STS. +pub const AwsService = enum(c_int) { + s3 = 0, + lambda = 1, + dynamodb = 2, + sqs = 3, + cloudwatch = 4, + iam = 5, + sts = 6, +}; + +/// AWS action identifiers matching Idris2 AwsAction encoding. +/// Actions 0-4: S3 (ListBuckets, GetObject, PutObject, DeleteObject, PresignedUrl) +/// Actions 5-6: Lambda (ListFunctions, Invoke) +/// Actions 7-10: DynamoDB (Query, Scan, PutItem, GetItem) +/// Actions 11-14: SQS (ListQueues, SendMessage, ReceiveMessage, DeleteMessage) +/// Actions 15-16: CloudWatch (GetMetrics, PutMetricData) +/// Actions 17-18: IAM (ListUsers, ListRoles) β€” read-only +/// Actions 19-20: STS (GetCallerIdentity, AssumeRole) +pub const AwsAction = enum(c_int) { + // S3 + s3_list_buckets = 0, + s3_get_object = 1, + s3_put_object = 2, + s3_delete_object = 3, + s3_presigned_url = 4, + // Lambda + lambda_list_functions = 5, + lambda_invoke = 6, + // DynamoDB + dynamo_query = 7, + dynamo_scan = 8, + dynamo_put_item = 9, + dynamo_get_item = 10, + // SQS + sqs_list_queues = 11, + sqs_send_message = 12, + sqs_receive_message = 13, + sqs_delete_message = 14, + // CloudWatch + cw_get_metrics = 15, + cw_put_metric_data = 16, + // IAM (read-only) + iam_list_users = 17, + iam_list_roles = 18, + // STS + sts_get_caller_identity = 19, + sts_assume_role = 20, +}; + +/// Check valid state transitions per the Idris2 ValidTransition proof. +fn isValidTransition(from: SessionState, to: SessionState) bool { + return switch (from) { + .unauthenticated => to == .authenticated, + .authenticated => to == .unauthenticated or to == .rate_limited or to == .err, + .rate_limited => to == .authenticated, + .err => to == .unauthenticated, + }; +} + +/// Map action integer to its service integer. Returns -1 for invalid action. +fn actionToService(action: c_int) c_int { + const a = std.meta.intToEnum(AwsAction, action) catch return -1; + return switch (a) { + .s3_list_buckets, .s3_get_object, .s3_put_object, .s3_delete_object, .s3_presigned_url => 0, + .lambda_list_functions, .lambda_invoke => 1, + .dynamo_query, .dynamo_scan, .dynamo_put_item, .dynamo_get_item => 2, + .sqs_list_queues, .sqs_send_message, .sqs_receive_message, .sqs_delete_message => 3, + .cw_get_metrics, .cw_put_metric_data => 4, + .iam_list_users, .iam_list_roles => 5, + .sts_get_caller_identity, .sts_assume_role => 6, + }; +} + +/// Check if an action is mutating (write operation). Returns 1 for mutating, 0 for read-only. +fn actionIsMutating(action: c_int) c_int { + const a = std.meta.intToEnum(AwsAction, action) catch return -1; + return switch (a) { + .s3_put_object, .s3_delete_object, .lambda_invoke, .dynamo_put_item, .sqs_send_message, .sqs_delete_message, .cw_put_metric_data, .sts_assume_role => 1, + else => 0, + }; +} + +// --------------------------------------------------------------------------- +// Session slots (thread-safe, fixed-size pool) +// --------------------------------------------------------------------------- + +const MAX_SESSIONS: usize = 16; +const REGION_BUF_SIZE: usize = 64; +const KEY_BUF_SIZE: usize = 256; + +const SessionSlot = struct { + active: bool = false, + state: SessionState = .unauthenticated, + region_buf: [REGION_BUF_SIZE]u8 = .{0} ** REGION_BUF_SIZE, + region_len: usize = 0, + access_key_buf: [KEY_BUF_SIZE]u8 = .{0} ** KEY_BUF_SIZE, + access_key_len: usize = 0, + api_call_count: u64 = 0, + last_action: c_int = -1, +}; + +var sessions: [MAX_SESSIONS]SessionSlot = .{SessionSlot{}} ** MAX_SESSIONS; +var mutex: std.Thread.Mutex = .{}; + +// --------------------------------------------------------------------------- +// C-ABI exports β€” state machine +// --------------------------------------------------------------------------- + +/// Check if a state transition is valid. Returns 1 (valid) or 0 (invalid). +pub export fn aws_mcp_can_transition(from: c_int, to: c_int) c_int { + const f = std.meta.intToEnum(SessionState, from) catch return 0; + const t = std.meta.intToEnum(SessionState, to) catch return 0; + return if (isValidTransition(f, t)) 1 else 0; +} + +/// Authenticate a session with region. Returns slot index (>= 0) or error (< 0). +/// Error codes: -1 = no free slots, -2 = region too long. +pub export fn aws_mcp_authenticate(region_ptr: [*]const u8, region_len: c_int) c_int { + const rlen: usize = std.math.cast(usize, region_len) orelse return -2; + if (rlen > REGION_BUF_SIZE) return -2; + + mutex.lock(); + defer mutex.unlock(); + + for (&sessions, 0..) |*slot, idx| { + if (!slot.active) { + slot.active = true; + slot.state = .authenticated; + @memcpy(slot.region_buf[0..rlen], region_ptr[0..rlen]); + slot.region_len = rlen; + slot.access_key_len = 0; + slot.api_call_count = 0; + slot.last_action = -1; + return @intCast(idx); + } + } + return -1; +} + +/// Deauthenticate (close) a session. Returns 0 on success. +/// Error codes: -1 = invalid slot, -2 = invalid state transition. +pub export fn aws_mcp_deauthenticate(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + if (!isValidTransition(slot.state, .unauthenticated)) return -2; + + sessions[idx] = SessionSlot{}; + return 0; +} + +/// Get current state of a session. Returns state int or -1 if invalid. +pub export fn aws_mcp_session_state(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + return @intFromEnum(slot.state); +} + +/// Signal rate limiting on a session. Returns 0 on success. +pub export fn aws_mcp_throttle(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + if (!isValidTransition(slot.state, .rate_limited)) return -2; + + sessions[idx].state = .rate_limited; + return 0; +} + +/// Clear rate limiting (resume authenticated). Returns 0 on success. +pub export fn aws_mcp_unthrottle(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + if (!isValidTransition(slot.state, .authenticated)) return -2; + + sessions[idx].state = .authenticated; + return 0; +} + +/// Signal an error on a session. Returns 0 on success. +pub export fn aws_mcp_signal_error(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + if (!isValidTransition(slot.state, .err)) return -2; + + sessions[idx].state = .err; + return 0; +} + +// --------------------------------------------------------------------------- +// C-ABI exports β€” service routing and actions +// --------------------------------------------------------------------------- + +/// Get the service for an action. Returns service int (0-6) or -1 for invalid. +pub export fn aws_mcp_action_service(action: c_int) c_int { + return actionToService(action); +} + +/// Check if an action is mutating. Returns 1 (mutating), 0 (read-only), -1 (invalid). +pub export fn aws_mcp_action_is_mutating(action: c_int) c_int { + return actionIsMutating(action); +} + +/// Record an API call on a session. Returns 0 on success. +/// Error codes: -1 = invalid slot, -2 = not authenticated, -3 = invalid action. +pub export fn aws_mcp_record_call(slot_idx: c_int, action: c_int) c_int { + _ = std.meta.intToEnum(AwsAction, action) catch return -3; + + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + if (slot.state != .authenticated) return -2; + + sessions[idx].api_call_count += 1; + sessions[idx].last_action = action; + return 0; +} + +/// Get API call count for a session. Returns count or -1 if invalid. +pub export fn aws_mcp_call_count(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + return @intCast(slot.api_call_count); +} + +/// Get total service count. Always returns 7. +pub export fn aws_mcp_service_count() c_int { + return 7; +} + +/// Get total action count. Always returns 21. +pub export fn aws_mcp_action_count() c_int { + return 21; +} + +/// Reset all sessions (test/debug use only). +pub export fn aws_mcp_reset() void { + mutex.lock(); + defer mutex.unlock(); + sessions = .{SessionSlot{}} ** MAX_SESSIONS; +} + +// --------------------------------------------------------------------------- +// ═══════════════════════════════════════════════════════════════════════ +// Standard ABI (ADR-0005 four symbols + ADR-0006 invoke) +// ═══════════════════════════════════════════════════════════════════════ + +const shim = @import("cartridge_shim.zig"); + +const CARTRIDGE_NAME_PTR: [*:0]const u8 = "aws-mcp"; +const CARTRIDGE_VERSION_PTR: [*:0]const u8 = "0.1.0"; + +export fn boj_cartridge_init() callconv(.c) c_int { + return 0; +} + +export fn boj_cartridge_deinit() callconv(.c) void {} + +export fn boj_cartridge_name() callconv(.c) [*:0]const u8 { + return CARTRIDGE_NAME_PTR; +} + +export fn boj_cartridge_version() callconv(.c) [*:0]const u8 { + return CARTRIDGE_VERSION_PTR; +} + +/// Dispatch the cartridge.json MCP tools. Grade D Alpha stubs. +export fn boj_cartridge_invoke( + tool_name: [*c]const u8, + json_args: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) callconv(.c) i32 { + _ = json_args; + if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; + + const body: []const u8 = if (shim.toolIs(tool_name, "aws_authenticate")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "aws_s3_list")) + "{\"result\":{\"items\":[],\"count\":0,\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "aws_s3_get")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "aws_s3_put")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "aws_ec2_list")) + "{\"result\":{\"items\":[],\"count\":0,\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "aws_lambda_invoke")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "aws_session_state")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "aws_deauthenticate")) + "{\"result\":{\"status\":\"stub\"}}" +else + return shim.RC_UNKNOWN_TOOL; + + return shim.writeResult(out_buf, in_out_len, body); +} + +// Tests +// --------------------------------------------------------------------------- + +test "authentication lifecycle" { + aws_mcp_reset(); + + const region = "us-east-1"; + const slot = aws_mcp_authenticate(region.ptr, @intCast(region.len)); + try std.testing.expect(slot >= 0); + + // Should be authenticated (1) + try std.testing.expectEqual(@as(c_int, 1), aws_mcp_session_state(slot)); + + // Record an S3 ListBuckets call + try std.testing.expectEqual(@as(c_int, 0), aws_mcp_record_call(slot, 0)); + try std.testing.expectEqual(@as(c_int, 1), aws_mcp_call_count(slot)); + + // Deauthenticate + try std.testing.expectEqual(@as(c_int, 0), aws_mcp_deauthenticate(slot)); +} + +test "rate limiting flow" { + aws_mcp_reset(); + + const region = "eu-west-1"; + const slot = aws_mcp_authenticate(region.ptr, @intCast(region.len)); + try std.testing.expect(slot >= 0); + + // Throttle + try std.testing.expectEqual(@as(c_int, 0), aws_mcp_throttle(slot)); + try std.testing.expectEqual(@as(c_int, 2), aws_mcp_session_state(slot)); + + // Cannot invoke while rate limited + try std.testing.expectEqual(@as(c_int, -2), aws_mcp_record_call(slot, 0)); + + // Unthrottle + try std.testing.expectEqual(@as(c_int, 0), aws_mcp_unthrottle(slot)); + try std.testing.expectEqual(@as(c_int, 1), aws_mcp_session_state(slot)); +} + +test "error and recovery" { + aws_mcp_reset(); + + const region = "ap-south-1"; + const slot = aws_mcp_authenticate(region.ptr, @intCast(region.len)); + try std.testing.expect(slot >= 0); + + // Signal error + try std.testing.expectEqual(@as(c_int, 0), aws_mcp_signal_error(slot)); + try std.testing.expectEqual(@as(c_int, 3), aws_mcp_session_state(slot)); + + // Recover to unauthenticated + try std.testing.expectEqual(@as(c_int, 0), aws_mcp_deauthenticate(slot)); +} + +test "invalid transitions rejected" { + aws_mcp_reset(); + + const region = "us-west-2"; + const slot = aws_mcp_authenticate(region.ptr, @intCast(region.len)); + try std.testing.expect(slot >= 0); + + // Cannot throttle from rate_limited (must be authenticated) + try std.testing.expectEqual(@as(c_int, 0), aws_mcp_throttle(slot)); + try std.testing.expectEqual(@as(c_int, -2), aws_mcp_throttle(slot)); + + // Cannot signal error from rate_limited + try std.testing.expectEqual(@as(c_int, -2), aws_mcp_signal_error(slot)); +} + +test "transition validator" { + // Valid transitions + try std.testing.expectEqual(@as(c_int, 1), aws_mcp_can_transition(0, 1)); // unauth -> auth + try std.testing.expectEqual(@as(c_int, 1), aws_mcp_can_transition(1, 0)); // auth -> unauth + try std.testing.expectEqual(@as(c_int, 1), aws_mcp_can_transition(1, 2)); // auth -> rate_limited + try std.testing.expectEqual(@as(c_int, 1), aws_mcp_can_transition(2, 1)); // rate_limited -> auth + try std.testing.expectEqual(@as(c_int, 1), aws_mcp_can_transition(1, 3)); // auth -> error + try std.testing.expectEqual(@as(c_int, 1), aws_mcp_can_transition(3, 0)); // error -> unauth + + // Invalid transitions + try std.testing.expectEqual(@as(c_int, 0), aws_mcp_can_transition(0, 2)); // unauth -> rate_limited + try std.testing.expectEqual(@as(c_int, 0), aws_mcp_can_transition(0, 3)); // unauth -> error + try std.testing.expectEqual(@as(c_int, 0), aws_mcp_can_transition(2, 0)); // rate_limited -> unauth + try std.testing.expectEqual(@as(c_int, 0), aws_mcp_can_transition(3, 1)); // error -> auth +} + +test "action service routing β€” all 7 services" { + // S3 + try std.testing.expectEqual(@as(c_int, 0), aws_mcp_action_service(0)); // S3ListBuckets + try std.testing.expectEqual(@as(c_int, 0), aws_mcp_action_service(4)); // S3PresignedUrl + // Lambda + try std.testing.expectEqual(@as(c_int, 1), aws_mcp_action_service(5)); // LambdaListFunctions + try std.testing.expectEqual(@as(c_int, 1), aws_mcp_action_service(6)); // LambdaInvoke + // DynamoDB + try std.testing.expectEqual(@as(c_int, 2), aws_mcp_action_service(7)); // DynamoQuery + try std.testing.expectEqual(@as(c_int, 2), aws_mcp_action_service(10)); // DynamoGetItem + // SQS + try std.testing.expectEqual(@as(c_int, 3), aws_mcp_action_service(11)); // SqsListQueues + try std.testing.expectEqual(@as(c_int, 3), aws_mcp_action_service(14)); // SqsDeleteMessage + // CloudWatch + try std.testing.expectEqual(@as(c_int, 4), aws_mcp_action_service(15)); // CwGetMetrics + try std.testing.expectEqual(@as(c_int, 4), aws_mcp_action_service(16)); // CwPutMetricData + // IAM + try std.testing.expectEqual(@as(c_int, 5), aws_mcp_action_service(17)); // IamListUsers + try std.testing.expectEqual(@as(c_int, 5), aws_mcp_action_service(18)); // IamListRoles + // STS + try std.testing.expectEqual(@as(c_int, 6), aws_mcp_action_service(19)); // StsGetCallerIdentity + try std.testing.expectEqual(@as(c_int, 6), aws_mcp_action_service(20)); // StsAssumeRole + // Invalid + try std.testing.expectEqual(@as(c_int, -1), aws_mcp_action_service(99)); +} + +test "action mutability checks" { + // Read-only actions + try std.testing.expectEqual(@as(c_int, 0), aws_mcp_action_is_mutating(0)); // S3ListBuckets + try std.testing.expectEqual(@as(c_int, 0), aws_mcp_action_is_mutating(1)); // S3GetObject + try std.testing.expectEqual(@as(c_int, 0), aws_mcp_action_is_mutating(4)); // S3PresignedUrl + try std.testing.expectEqual(@as(c_int, 0), aws_mcp_action_is_mutating(7)); // DynamoQuery + try std.testing.expectEqual(@as(c_int, 0), aws_mcp_action_is_mutating(8)); // DynamoScan + try std.testing.expectEqual(@as(c_int, 0), aws_mcp_action_is_mutating(15)); // CwGetMetrics + try std.testing.expectEqual(@as(c_int, 0), aws_mcp_action_is_mutating(17)); // IamListUsers + try std.testing.expectEqual(@as(c_int, 0), aws_mcp_action_is_mutating(18)); // IamListRoles + try std.testing.expectEqual(@as(c_int, 0), aws_mcp_action_is_mutating(19)); // StsGetCallerIdentity + + // Mutating actions + try std.testing.expectEqual(@as(c_int, 1), aws_mcp_action_is_mutating(2)); // S3PutObject + try std.testing.expectEqual(@as(c_int, 1), aws_mcp_action_is_mutating(3)); // S3DeleteObject + try std.testing.expectEqual(@as(c_int, 1), aws_mcp_action_is_mutating(6)); // LambdaInvoke + try std.testing.expectEqual(@as(c_int, 1), aws_mcp_action_is_mutating(9)); // DynamoPutItem + try std.testing.expectEqual(@as(c_int, 1), aws_mcp_action_is_mutating(12)); // SqsSendMessage + try std.testing.expectEqual(@as(c_int, 1), aws_mcp_action_is_mutating(16)); // CwPutMetricData + try std.testing.expectEqual(@as(c_int, 1), aws_mcp_action_is_mutating(20)); // StsAssumeRole + + // Invalid + try std.testing.expectEqual(@as(c_int, -1), aws_mcp_action_is_mutating(99)); +} + +test "slot exhaustion" { + aws_mcp_reset(); + + const region = "us-east-1"; + var slots: [MAX_SESSIONS]c_int = undefined; + for (&slots) |*s| { + s.* = aws_mcp_authenticate(region.ptr, @intCast(region.len)); + try std.testing.expect(s.* >= 0); + } + + // Next open should fail + try std.testing.expectEqual(@as(c_int, -1), aws_mcp_authenticate(region.ptr, @intCast(region.len))); + + // Free one and try again + try std.testing.expectEqual(@as(c_int, 0), aws_mcp_deauthenticate(slots[0])); + const new_slot = aws_mcp_authenticate(region.ptr, @intCast(region.len)); + try std.testing.expect(new_slot >= 0); +} + +test "counts" { + try std.testing.expectEqual(@as(c_int, 7), aws_mcp_service_count()); + try std.testing.expectEqual(@as(c_int, 21), aws_mcp_action_count()); +} + +// ═══════════════════════════════════════════════════════════════════════ +// ADR-0006 invoke dispatch tests +// ═══════════════════════════════════════════════════════════════════════ + +test "boj_cartridge_name returns aws-mcp" { + const n = std.mem.span(boj_cartridge_name()); + try std.testing.expectEqualStrings("aws-mcp", n); +} + +test "boj_cartridge_init returns 0" { + try std.testing.expectEqual(@as(c_int, 0), boj_cartridge_init()); +} + +test "invoke: each declared tool succeeds" { + var buf: [256]u8 = undefined; + const tools = [_][]const u8{ + "aws_authenticate", + "aws_s3_list", + "aws_s3_get", + "aws_s3_put", + "aws_ec2_list", + "aws_lambda_invoke", + "aws_session_state", + "aws_deauthenticate", + }; + for (tools) |t| { + var len: usize = buf.len; + const rc = boj_cartridge_invoke(t.ptr, "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, 0), rc); + try std.testing.expect(std.mem.indexOf(u8, buf[0..len], "result") != null); + } +} + +test "invoke: unknown tool returns -1" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("nope", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -1), rc); +} + +test "invoke: buffer too small returns -3" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("aws_authenticate", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -3), rc); + try std.testing.expect(len > 4); +} diff --git a/cartridges/domains/cloud/aws-mcp/ffi/build.zig b/cartridges/domains/cloud/aws-mcp/ffi/build.zig new file mode 100644 index 0000000..acd957c --- /dev/null +++ b/cartridges/domains/cloud/aws-mcp/ffi/build.zig @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// build.zig β€” Build configuration for aws-mcp FFI shared library and tests. + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + // Module + // Shared ADR-0006 invoke-shim module (relative path up to boj-server trunk). + + const shim_mod = b.addModule("cartridge_shim", .{ + + .root_source_file = b.path("../../../ffi/zig/src/cartridge_shim.zig"), + + .target = target, + + .optimize = optimize, + + }); + + const ffi_mod = b.addModule("aws_mcp", .{ + .root_source_file = b.path("aws_mcp_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + ffi_mod.addImport("cartridge_shim", shim_mod); + + // Shared library + const lib = b.addLibrary(.{ + .name = "aws_mcp", + .root_module = ffi_mod, + .linkage = .dynamic, + }); + lib.linkLibC(); + b.installArtifact(lib); + + // Tests + const tests = b.addTest(.{ + .root_module = ffi_mod, + }); + tests.linkLibC(); + + const run_tests = b.addRunArtifact(tests); + const test_step = b.step("test", "Run FFI tests"); + test_step.dependOn(&run_tests.step); +} diff --git a/cartridges/domains/cloud/aws-mcp/ffi/cartridge_shim.zig b/cartridges/domains/cloud/aws-mcp/ffi/cartridge_shim.zig new file mode 100644 index 0000000..0e399db --- /dev/null +++ b/cartridges/domains/cloud/aws-mcp/ffi/cartridge_shim.zig @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// cartridge_shim.zig β€” Shared helpers for the ADR-0006 five-symbol +// cartridge ABI (`boj_cartridge_init / deinit / name / version / invoke`). +// +// The shim centralises the seven-code return convention, NUL-argument +// guards, tool-name comparison, and the buffer-too-small path so each +// cartridge's `boj_cartridge_invoke` can stay short β€” typically a tool +// table plus `shim.writeResult(...)`. +// +// Cartridges import this file by relative path (no build-graph change +// needed). Example: +// +// const shim = @import("../../../ffi/zig/src/cartridge_shim.zig"); +// +// export fn boj_cartridge_invoke( +// tool_name: [*c]const u8, +// json_args: [*c]const u8, +// out_buf: [*c]u8, +// in_out_len: [*c]usize, +// ) callconv(.c) i32 { +// _ = json_args; +// if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; +// const body = if (shim.toolIs(tool_name, "foo")) "{\"result\":{}}" +// else return shim.RC_UNKNOWN_TOOL; +// return shim.writeResult(out_buf, in_out_len, body); +// } + +const std = @import("std"); + +// ── Return codes (ADR-0006 Β§Return codes) ──────────────────────────── +// +// Frozen by ADR-0006. New failure modes compose these via the error +// JSON body β€” the integer surface does not grow without a follow-up ADR. + +pub const RC_SUCCESS: i32 = 0; +pub const RC_UNKNOWN_TOOL: i32 = -1; +pub const RC_BAD_ARGS: i32 = -2; +pub const RC_BUFFER_TOO_SMALL: i32 = -3; +pub const RC_RUNTIME_ERROR: i32 = -4; +pub const RC_PANIC: i32 = -5; +pub const RC_AUTH_DENIED: i32 = -6; + +// ── Invoke-path helpers ────────────────────────────────────────────── + +/// True if any of the three mandatory `boj_cartridge_invoke` output-path +/// pointers is null. Use at the top of every invoke to short-circuit to +/// `RC_BAD_ARGS`. +pub fn invokeArgsNull( + tool_name: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) bool { + return tool_name == null or out_buf == null or in_out_len == null; +} + +/// Compare a C-NUL-terminated tool-name pointer against a Zig string +/// literal. Caller must have already verified `tool_name` is non-null +/// (usually via `invokeArgsNull`). +pub fn toolIs(tool_name: [*c]const u8, expected: []const u8) bool { + const s = std.mem.span(@as([*:0]const u8, @ptrCast(tool_name))); + return std.mem.eql(u8, s, expected); +} + +/// Copy `body` into `out_buf[0..*in_out_len]` (as a capacity) and update +/// `*in_out_len` to the number of bytes written. Returns `RC_SUCCESS`. +/// +/// If `body.len` exceeds the current capacity stored in `*in_out_len`, +/// sets `*in_out_len` to the required size and returns +/// `RC_BUFFER_TOO_SMALL` β€” the caller is then expected to re-allocate +/// and retry, per ADR-0006 Β§Memory ownership. +/// +/// Caller must have already verified that `out_buf` and `in_out_len` +/// are non-null. +pub fn writeResult( + out_buf: [*c]u8, + in_out_len: [*c]usize, + body: []const u8, +) i32 { + const cap = in_out_len.*; + if (body.len > cap) { + in_out_len.* = body.len; + return RC_BUFFER_TOO_SMALL; + } + @memcpy(out_buf[0..body.len], body); + in_out_len.* = body.len; + return RC_SUCCESS; +} + +// ── Tests ──────────────────────────────────────────────────────────── + +test "writeResult: body fits, writes and sets length" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); + try std.testing.expectEqualStrings("hello", buf[0..len]); +} + +test "writeResult: too small returns -3 and sets required length" { + var buf: [2]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_BUFFER_TOO_SMALL, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: exact-fit succeeds" { + var buf: [5]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: empty body" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, ""); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 0), len); +} + +test "toolIs: matches and rejects" { + const name: [*:0]const u8 = "foo"; + try std.testing.expect(toolIs(@ptrCast(name), "foo")); + try std.testing.expect(!toolIs(@ptrCast(name), "bar")); + try std.testing.expect(!toolIs(@ptrCast(name), "foobar")); + try std.testing.expect(!toolIs(@ptrCast(name), "fo")); +} + +test "invokeArgsNull: detects each null slot" { + var buf: [4]u8 = undefined; + var len: usize = 4; + const name: [*:0]const u8 = "x"; + try std.testing.expect(!invokeArgsNull(@ptrCast(name), &buf, &len)); + try std.testing.expect(invokeArgsNull(null, &buf, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), null, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), &buf, null)); +} diff --git a/cartridges/domains/cloud/aws-mcp/minter.toml b/cartridges/domains/cloud/aws-mcp/minter.toml new file mode 100644 index 0000000..daebd12 --- /dev/null +++ b/cartridges/domains/cloud/aws-mcp/minter.toml @@ -0,0 +1,28 @@ +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> + +name = "aws-mcp" +description = "AWS SDK cartridge β€” Signature V4 auth, multi-service routing (S3/Lambda/DynamoDB/SQS/CloudWatch/IAM/STS) with rate-limit back-pressure" +version = "0.3.0" +domain = "Cloud" +protocols = [ + "MCP", + "REST", +] +tier = "Ayo" +backend = "universal" +generate_panel = true + +[auth] +method = "aws_signature_v4" +credential_source = "vault-mcp" +fields = ["access_key_id", "secret_access_key", "region", "session_token"] + +[services] +s3 = "https://s3.{region}.amazonaws.com" +lambda = "https://lambda.{region}.amazonaws.com" +dynamodb = "https://dynamodb.{region}.amazonaws.com" +sqs = "https://sqs.{region}.amazonaws.com" +cloudwatch = "https://monitoring.{region}.amazonaws.com" +iam = "https://iam.amazonaws.com" +sts = "https://sts.{region}.amazonaws.com" diff --git a/cartridges/domains/cloud/aws-mcp/mod.js b/cartridges/domains/cloud/aws-mcp/mod.js new file mode 100644 index 0000000..283d62b --- /dev/null +++ b/cartridges/domains/cloud/aws-mcp/mod.js @@ -0,0 +1,83 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// aws-mcp/mod.js -- aws gateway. + +const BASE_URL = Deno.env.get("AWS_MCP_BACKEND_URL") ?? "http://127.0.0.1:7713"; + +async function post(path, payload) { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), 15000); + try { + const r = await fetch(`${BASE_URL}${path}`, { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), signal: ctrl.signal, + }); + const data = await r.json().catch(() => ({ success: false, error: "non-JSON response" })); + return { status: r.status, data }; + } catch (e) { + if (e.name === "AbortError") return { status: 504, data: { success: false, error: "aws-mcp backend timed out" } }; + return { status: 503, data: { success: false, error: `aws-mcp backend unavailable: ${e.message}` } }; + } finally { clearTimeout(t); } +} + +export async function handleTool(toolName, args) { + switch (toolName) { + + case "aws_authenticate": { + const { region, profile } = args ?? {}; + if (!region) return { status: 400, data: { error: "region is required" } }; + const payload = { region }; + if (profile !== undefined) payload.profile = profile; + return post("/api/v1/authenticate", payload); + } + case "aws_s3_list": { + const { bucket, prefix } = args ?? {}; + const payload = { }; + if (bucket !== undefined) payload.bucket = bucket; + if (prefix !== undefined) payload.prefix = prefix; + return post("/api/v1/s3-list", payload); + } + case "aws_s3_get": { + const { bucket, key } = args ?? {}; + if (!bucket || !key) return { status: 400, data: { error: "bucket is required" } }; + const payload = { bucket, key }; + return post("/api/v1/s3-get", payload); + } + case "aws_s3_put": { + const { bucket, key, body } = args ?? {}; + if (!bucket || !key || !body) return { status: 400, data: { error: "bucket is required" } }; + const payload = { bucket, key, body }; + return post("/api/v1/s3-put", payload); + } + case "aws_ec2_list": { + const { region, state } = args ?? {}; + const payload = { }; + if (region !== undefined) payload.region = region; + if (state !== undefined) payload.state = state; + return post("/api/v1/ec2-list", payload); + } + case "aws_lambda_invoke": { + const { function_name, payload } = args ?? {}; + if (!function_name) return { status: 400, data: { error: "function_name is required" } }; + const payload = { function_name }; + if (payload !== undefined) payload.payload = payload; + return post("/api/v1/lambda-invoke", payload); + } + case "aws_session_state": { + const { slot } = args ?? {}; + if (!slot) return { status: 400, data: { error: "slot is required" } }; + const payload = { slot }; + return post("/api/v1/session-state", payload); + } + case "aws_deauthenticate": { + const { slot } = args ?? {}; + if (!slot) return { status: 400, data: { error: "slot is required" } }; + const payload = { slot }; + return post("/api/v1/deauthenticate", payload); + } + + default: + return { status: 404, data: { error: `Unknown tool: ${toolName}` } }; + } +} diff --git a/cartridges/domains/cloud/aws-mcp/panels/manifest.json b/cartridges/domains/cloud/aws-mcp/panels/manifest.json new file mode 100644 index 0000000..7e0068c --- /dev/null +++ b/cartridges/domains/cloud/aws-mcp/panels/manifest.json @@ -0,0 +1,126 @@ +{ + "$schema": "https://panll.dev/schemas/panel-manifest/v1.json", + "spdx": "MPL-2.0", + "copyright": "Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath)", + "cartridge": "aws-mcp", + "domain": "Cloud", + "version": "0.3.0", + "panels": [ + { + "id": "aws-auth-status", + "title": "Auth Status", + "description": "AWS Signature V4 authentication state (Unauthenticated / Authenticated / RateLimited / Error)", + "type": "status-indicator", + "data_source": { + "endpoint": "/aws/state", + "method": "GET", + "refresh_interval_ms": 5000 + }, + "widgets": [ + { + "type": "state-badge", + "field": "state", + "states": { + "unauthenticated": { "color": "#95a5a6", "icon": "cloud-off" }, + "authenticated": { "color": "#2ecc71", "icon": "cloud-check" }, + "rate_limited": { "color": "#f39c12", "icon": "clock" }, + "error": { "color": "#e74c3c", "icon": "alert-triangle" } + } + } + ] + }, + { + "id": "aws-region", + "title": "Region", + "description": "Currently configured AWS region for API routing", + "type": "metric", + "data_source": { + "endpoint": "/aws/status", + "method": "GET", + "refresh_interval_ms": 30000 + }, + "widgets": [ + { + "type": "text", + "field": "region", + "label": "AWS Region", + "icon": "globe" + } + ] + }, + { + "id": "aws-service-count", + "title": "Service Count", + "description": "Number of AWS services available (S3, Lambda, DynamoDB, SQS, CloudWatch, IAM, STS)", + "type": "metric", + "data_source": { + "endpoint": "/aws/status", + "method": "GET", + "refresh_interval_ms": 60000 + }, + "widgets": [ + { + "type": "counter", + "field": "service_count", + "label": "Services", + "icon": "layers" + }, + { + "type": "counter", + "field": "action_count", + "label": "Actions", + "icon": "zap" + } + ] + }, + { + "id": "aws-recent-calls", + "title": "Recent API Calls", + "description": "Recent AWS API invocations with service, action, mutability, and timestamp", + "type": "log-stream", + "data_source": { + "endpoint": "/aws/calls", + "method": "GET", + "refresh_interval_ms": 10000, + "max_entries": 50 + }, + "widgets": [ + { + "type": "log-table", + "columns": [ + { "field": "timestamp", "label": "Time", "format": "datetime" }, + { "field": "service", "label": "Service" }, + { "field": "action", "label": "Action" }, + { "field": "mutating", "label": "Mutating", "format": "boolean" }, + { "field": "result", "label": "Result" } + ] + } + ] + }, + { + "id": "aws-caller-identity", + "title": "Caller Identity", + "description": "STS caller identity β€” shows current AWS account, ARN, and user ID", + "type": "status-indicator", + "data_source": { + "endpoint": "/aws/sts/caller-identity", + "method": "GET", + "refresh_interval_ms": 60000 + }, + "widgets": [ + { + "type": "text", + "field": "account", + "label": "Account", + "icon": "user" + }, + { + "type": "text", + "field": "arn", + "label": "ARN", + "icon": "key" + } + ] + } + ] +} diff --git a/cartridges/domains/cloud/aws-mcp/tests/integration_test.sh b/cartridges/domains/cloud/aws-mcp/tests/integration_test.sh new file mode 100755 index 0000000..8cf6113 --- /dev/null +++ b/cartridges/domains/cloud/aws-mcp/tests/integration_test.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +# Integration tests for aws-mcp cartridge. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CART_DIR="$(dirname "$SCRIPT_DIR")" +FFI_DIR="$CART_DIR/ffi" + +echo "=== aws-mcp integration tests ===" + +# Build FFI +echo "[1/3] Building FFI..." +cd "$FFI_DIR" && zig build 2>&1 + +# Run Zig unit tests +echo "[2/3] Running FFI unit tests..." +cd "$FFI_DIR" && zig build test 2>&1 + +# Validate Idris2 ABI (if idris2 available) +echo "[3/3] Checking ABI..." +if command -v idris2 &>/dev/null; then + cd "$CART_DIR/abi" && idris2 --check aws_mcp.ipkg 2>&1 + echo " ABI: OK" +else + echo " ABI: SKIPPED (idris2 not in PATH)" +fi + +echo "" +echo "All tests passed for aws-mcp!" +echo " Services: 7 (S3, Lambda, DynamoDB, SQS, CloudWatch, IAM, STS)" +echo " Actions: 21" +echo " Tests: 8 suites" diff --git a/cartridges/domains/cloud/cloud-mcp/README.adoc b/cartridges/domains/cloud/cloud-mcp/README.adoc new file mode 100644 index 0000000..1619dc4 --- /dev/null +++ b/cartridges/domains/cloud/cloud-mcp/README.adoc @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += cloud-mcp +Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk> +:spdx: MPL-2.0 +:tier: Ayo +:domain: cloud +:protocols: MCP, REST + +== Overview + +Multi-cloud provider session manager (AWS/GCP/Azure/DO/Vercel) + +== Tools (4) + +[cols="2,4"] +|=== +| Tool | Description + +| `cloud_authenticate` | Authenticate with a cloud provider +| `cloud_logout` | End a cloud session +| `cloud_state` | Get cloud session state +| `cloud_execute` | Execute a cloud operation via the provider API +|=== + +== Architecture + +Three-layer stack per the BoJ cartridge standard: + +[cols="1,1,3"] +|=== +| Layer | Language | Purpose + +| ABI | Idris2 | Formally verified state machine with dependent-type proofs +| FFI | Zig | C-ABI implementation: session pool, action metrics, thread safety +| Adapter| Zig | Adapter bridge to the BoJ unified adapter protocol (zig predecessors sidelined) +|=== + +== Building + +[source,bash] +---- +# ABI (Idris2) +cd abi && idris2 --check *.idr + +# FFI (Zig) +cd ffi && zig build +cd ffi && zig build test + +# Adapter (Zig) +cd adapter && zig build +---- + +== Status + +Alpha (CRG D). Cartridge metadata declared in `cartridge.json`; 4 tool(s) exposed. +Not yet dogfooded externally. See `docs/READINESS.md` at repo root for the +project-wide grade table and `docs/practice/DOGFOOD-LOG.adoc` for usage evidence. diff --git a/cartridges/domains/cloud/cloud-mcp/abi/CloudMcp/SafeCloud.idr b/cartridges/domains/cloud/cloud-mcp/abi/CloudMcp/SafeCloud.idr new file mode 100644 index 0000000..5ee7bdf --- /dev/null +++ b/cartridges/domains/cloud/cloud-mcp/abi/CloudMcp/SafeCloud.idr @@ -0,0 +1,246 @@ +-- SPDX-License-Identifier: MPL-2.0 +-- Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +||| CloudMcp.SafeCloud: Formally verified multi-cloud provider operations. +||| +||| Cartridge: cloud-mcp +||| Matrix cell: Cloud domain x {MCP, LSP} protocols +||| +||| This module defines type-safe cloud provider operations with a +||| session state machine that prevents: +||| - Operations on unauthenticated providers +||| - Credential leaks by tracking auth lifecycle +||| - Operations without proper session teardown +||| +||| State machine: Unauthenticated -> Authenticated -> Operating -> Authenticated -> Unauthenticated +module CloudMcp.SafeCloud + +import Data.List + +%default total + +-- ═══════════════════════════════════════════════════════════════════════════ +-- Session State Machine +-- ═══════════════════════════════════════════════════════════════════════════ + +||| Provider session lifecycle states. +||| A session progresses: Unauthenticated -> Authenticated -> Operating -> Authenticated -> Unauthenticated +public export +data SessionState = Unauthenticated | Authenticated | Operating | AuthError + +||| Equality for session states. +public export +Eq SessionState where + Unauthenticated == Unauthenticated = True + Authenticated == Authenticated = True + Operating == Operating = True + AuthError == AuthError = True + _ == _ = False + +||| Valid state transitions (enforced at the type level). +public export +data ValidTransition : SessionState -> SessionState -> Type where + Authenticate : ValidTransition Unauthenticated Authenticated + BeginOperation : ValidTransition Authenticated Operating + EndOperation : ValidTransition Operating Authenticated + Logout : ValidTransition Authenticated Unauthenticated + OpError : ValidTransition Operating AuthError + Recover : ValidTransition AuthError Unauthenticated + +||| Runtime transition validator. +public export +canTransition : SessionState -> SessionState -> Bool +canTransition Unauthenticated Authenticated = True +canTransition Authenticated Operating = True +canTransition Operating Authenticated = True +canTransition Authenticated Unauthenticated = True +canTransition Operating AuthError = True +canTransition AuthError Unauthenticated = True +canTransition _ _ = False + +-- ═══════════════════════════════════════════════════════════════════════════ +-- Cloud Provider Types +-- ═══════════════════════════════════════════════════════════════════════════ + +||| Supported cloud providers. +public export +data CloudProvider + = AWS -- Amazon Web Services + | GCloud -- Google Cloud Platform + | Azure -- Microsoft Azure + | DigitalOcean -- DigitalOcean + | Verpex -- Verpex hosting + | Cloudflare -- Cloudflare + | Vercel -- Vercel platform + | Custom String -- User-defined provider + +||| C-ABI encoding. +public export +providerToInt : CloudProvider -> Int +providerToInt AWS = 1 +providerToInt GCloud = 2 +providerToInt Azure = 3 +providerToInt DigitalOcean = 4 +providerToInt Verpex = 5 +providerToInt Cloudflare = 6 +providerToInt Vercel = 7 +providerToInt (Custom _) = 99 + +-- ═══════════════════════════════════════════════════════════════════════════ +-- Provider Capabilities +-- ═══════════════════════════════════════════════════════════════════════════ + +||| Capabilities a cloud provider may support. +public export +data ProviderCapability + = Workers -- Serverless workers / functions + | KV -- Key-value storage + | R2 -- Object storage (R2-style) + | DNS -- DNS zone and record management + | Deployments -- Site / project deployments + | D1 -- Serverless SQL databases + | Pages -- Static site hosting / Pages projects + +-- ═══════════════════════════════════════════════════════════════════════════ +-- Cloudflare Resource Types +-- ═══════════════════════════════════════════════════════════════════════════ + +||| Resource types available on the Cloudflare provider. +public export +data CloudflareResource + = CfWorker -- Workers script + | CfD1Database -- D1 serverless SQL database + | CfKVNamespace -- KV namespace + | CfR2Bucket -- R2 object storage bucket + | CfDNSZone -- DNS zone + | CfDNSRecord -- DNS record within a zone + | CfPagesProject -- Pages deployment project + +||| C-ABI encoding for Cloudflare resource types. +public export +cfResourceToInt : CloudflareResource -> Int +cfResourceToInt CfWorker = 1 +cfResourceToInt CfD1Database = 2 +cfResourceToInt CfKVNamespace = 3 +cfResourceToInt CfR2Bucket = 4 +cfResourceToInt CfDNSZone = 5 +cfResourceToInt CfDNSRecord = 6 +cfResourceToInt CfPagesProject = 7 + +||| Map Cloudflare to its supported capabilities. +public export +cloudflareCapabilities : List ProviderCapability +cloudflareCapabilities = [Workers, KV, R2, DNS, Deployments, D1, Pages] + +-- ═══════════════════════════════════════════════════════════════════════════ +-- Vercel Resource Types +-- ═══════════════════════════════════════════════════════════════════════════ + +||| Resource types available on the Vercel provider. +public export +data VercelResource + = VclProject -- Vercel project + | VclDeployment -- Deployment instance + | VclDomain -- Custom domain + | VclEnvVar -- Environment variable + | VclServerlessFunction -- Serverless function (lambda) + +||| C-ABI encoding for Vercel resource types. +public export +vclResourceToInt : VercelResource -> Int +vclResourceToInt VclProject = 1 +vclResourceToInt VclDeployment = 2 +vclResourceToInt VclDomain = 3 +vclResourceToInt VclEnvVar = 4 +vclResourceToInt VclServerlessFunction = 5 + +||| Map Vercel to its supported capabilities. +public export +vercelCapabilities : List ProviderCapability +vercelCapabilities = [Deployments, DNS, Workers] + +-- ═══════════════════════════════════════════════════════════════════════════ +-- Session Record +-- ═══════════════════════════════════════════════════════════════════════════ + +||| A cloud provider session with tracked state. +public export +record Session where + constructor MkSession + sessionId : String + provider : CloudProvider + state : SessionState + region : String + +||| Proof that a session is authenticated (ready for operations). +public export +data IsAuthenticated : Session -> Type where + ActiveSession : (s : Session) -> + (state s = Authenticated) -> + IsAuthenticated s + +-- ═══════════════════════════════════════════════════════════════════════════ +-- MCP Tool Definitions +-- ═══════════════════════════════════════════════════════════════════════════ + +||| MCP tools exposed by this cartridge. +||| These map to MCP tool definitions that AI agents can call. +public export +data McpTool + = ToolAuthenticate -- Authenticate with a cloud provider + | ToolListResources -- List cloud resources + | ToolProvision -- Provision a new resource + | ToolDeprovision -- Deprovision (tear down) a resource + | ToolStatus -- Provider/resource status + | ToolCost -- Cost estimation/reporting + | ToolLogout -- End provider session + +||| MCP tool name (for JSON-RPC method name). +public export +toolName : McpTool -> String +toolName ToolAuthenticate = "cloud/authenticate" +toolName ToolListResources = "cloud/list-resources" +toolName ToolProvision = "cloud/provision" +toolName ToolDeprovision = "cloud/deprovision" +toolName ToolStatus = "cloud/status" +toolName ToolCost = "cloud/cost" +toolName ToolLogout = "cloud/logout" + +||| Which tools require an authenticated session. +public export +requiresAuth : McpTool -> Bool +requiresAuth ToolAuthenticate = False +requiresAuth _ = True + +-- ═══════════════════════════════════════════════════════════════════════════ +-- C-ABI Exports +-- ═══════════════════════════════════════════════════════════════════════════ + +||| Session state to integer. +public export +sessionStateToInt : SessionState -> Int +sessionStateToInt Unauthenticated = 0 +sessionStateToInt Authenticated = 1 +sessionStateToInt Operating = 2 +sessionStateToInt AuthError = 3 + +||| FFI: Validate a state transition. +export +cloud_can_transition : Int -> Int -> Int +cloud_can_transition from to = + let fromState = case from of + 0 => Unauthenticated + 1 => Authenticated + 2 => Operating + _ => AuthError + toState = case to of + 0 => Unauthenticated + 1 => Authenticated + 2 => Operating + _ => AuthError + in if canTransition fromState toState then 1 else 0 + +||| FFI: Check if a tool requires authentication. +export +cloud_tool_requires_auth : Int -> Int +cloud_tool_requires_auth 1 = 0 -- ToolAuthenticate +cloud_tool_requires_auth _ = 1 -- All others require auth diff --git a/cartridges/domains/cloud/cloud-mcp/abi/README.adoc b/cartridges/domains/cloud/cloud-mcp/abi/README.adoc new file mode 100644 index 0000000..29019f1 --- /dev/null +++ b/cartridges/domains/cloud/cloud-mcp/abi/README.adoc @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += cloud-mcp / abi β€” Idris2 ABI layer +:orientation: shallow + +== Purpose + +Cartridge-local protocol types for `cloud-mcp`. Defines the data, tool, and +transition vocabulary this cartridge speaks. Inherits safety lemmas and the +matrix-cell identifier scheme from the trunk ABI at `src/abi/`. + +== Files + +1 Idris2 module(s), ~246 lines total. Lead module: +`SafeCloud.idr`. + +No `.ipkg` β€” consumed directly by wider BoJ callers; type-checks inside the +trunk `boj.ipkg`. + +== Invariants + +* `%default total` at every module top. +* Zero `believe_me`, `assert_total`, `postulate`, `Admitted`, or + `sorry` (the trunk holds the four declared `believe_me` sites; leaf + cartridges carry none). + +== Test/proof surface + +Thin module(s), no inline tests. Proof surface = type-check success under the +trunk `idris2 --check`. + +== Read-first + +. `SafeCloud.idr` β€” cartridge-local data records and enums. +. Trunk `src/abi/Boj/Catalogue.idr` for how this cell participates in the + 2D matrix. diff --git a/cartridges/domains/cloud/cloud-mcp/abi/cloud-mcp.ipkg b/cartridges/domains/cloud/cloud-mcp/abi/cloud-mcp.ipkg new file mode 100644 index 0000000..ab8dc87 --- /dev/null +++ b/cartridges/domains/cloud/cloud-mcp/abi/cloud-mcp.ipkg @@ -0,0 +1,11 @@ +-- SPDX-License-Identifier: MPL-2.0 +package cloudmcp + +authors = "Jonathan D.A. Jewell" +version = 0.1.0 +license = "MPL-2.0" +brief = "Cloud MCP cartridge β€” provider session state machine with credential lifecycle safety" + +sourcedir = "." +modules = CloudMcp.SafeCloud +depends = base, contrib diff --git a/cartridges/domains/cloud/cloud-mcp/adapter/README.adoc b/cartridges/domains/cloud/cloud-mcp/adapter/README.adoc new file mode 100644 index 0000000..88586f2 --- /dev/null +++ b/cartridges/domains/cloud/cloud-mcp/adapter/README.adoc @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += cloud-mcp / adapter β€” protocol bridge +:orientation: shallow + +== Purpose + +Exposes this cartridge's FFI over the wire protocols declared in +`cartridge.json`. Dispatches tool invocations to the C-ABI exports at +`../ffi/` and formats responses per protocol. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” depends on the sibling `ffi` target. +| `cloud_adapter.zig` | Protocol dispatch (129 lines). Tool catalogue matches `cartridge.json`. +| `SIDELINED-*.v.adoc` | zig predecessor(s). Not built β€” zig banned 2026-04-10. +|=== + +== Invariants + +* **Stateless** β€” all state lives behind the FFI, never in the adapter. +* **Response passthrough** β€” whatever the FFI returns goes back to the wire + unmodified (no embellishment, no silent recovery). +* **Cartridge.json is source of truth** for the tool catalogue; drift between + adapter and manifest is a CI failure. + +== Test/proof surface + +No inline tests at the adapter layer β€” protocol routing is exercised by the +trunk seam tests at `ffi/zig/src/seams.zig`. + +== Read-first + +. `cloud_adapter.zig` β€” dispatch function and per-protocol routers. +. `../cartridge.json` β€” the tool manifest the dispatcher mirrors. diff --git a/cartridges/domains/cloud/cloud-mcp/adapter/build.zig b/cartridges/domains/cloud/cloud-mcp/adapter/build.zig new file mode 100644 index 0000000..4ea8a53 --- /dev/null +++ b/cartridges/domains/cloud/cloud-mcp/adapter/build.zig @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// cloud-mcp/adapter/build.zig + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const ffi_mod = b.createModule(.{ + .root_source_file = b.path("../ffi/cloud_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + const adapter = b.addExecutable(.{ + .name = "cloud_adapter", + .root_source_file = b.path("cloud_adapter.zig"), + .target = target, + .optimize = optimize, + }); + adapter.root_module.addImport("cloud_ffi", ffi_mod); + b.installArtifact(adapter); +} diff --git a/cartridges/domains/cloud/cloud-mcp/adapter/cloud_adapter.zig b/cartridges/domains/cloud/cloud-mcp/adapter/cloud_adapter.zig new file mode 100644 index 0000000..c3f4165 --- /dev/null +++ b/cartridges/domains/cloud/cloud-mcp/adapter/cloud_adapter.zig @@ -0,0 +1,129 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// cloud-mcp/adapter/cloud_adapter.zig +// +// Three-protocol BoJ adapter: REST (port 9211), gRPC-compat (port 9212), +// GraphQL (port 9213). +// Replaces the banned zig adapter (cloud_adapter.v). + +const std = @import("std"); +const ffi = @import("cloud_ffi"); + +const REST_PORT: u16 = 9211; +const GRPC_PORT: u16 = 9212; +const GQL_PORT: u16 = 9213; + +const Response = struct { status: u16, body: []const u8 }; + +fn okJson(buf: []u8, msg: []const u8) []u8 { + return std.fmt.bufPrint(buf, "{{\"success\":true,\"message\":\"{s}\"}}", .{msg}) catch buf[0..0]; +} + +fn errJson(buf: []u8, msg: []const u8) []u8 { + return std.fmt.bufPrint(buf, "{{\"success\":false,\"error\":\"{s}\"}}", .{msg}) catch buf[0..0]; +} + +fn dispatch(tool: []const u8, _body: []const u8, resp: []u8) Response { + _ = _body; + if (std.mem.eql(u8, tool, "cloud_authenticate")) { + return .{ .status = 200, .body = okJson(resp, "cloud_authenticate forwarded") }; + } + if (std.mem.eql(u8, tool, "cloud_logout")) { + return .{ .status = 200, .body = okJson(resp, "cloud_logout forwarded") }; + } + if (std.mem.eql(u8, tool, "cloud_state")) { + return .{ .status = 200, .body = okJson(resp, "cloud_state forwarded") }; + } + if (std.mem.eql(u8, tool, "cloud_execute")) { + return .{ .status = 200, .body = okJson(resp, "cloud_execute forwarded") }; + } + return .{ .status = 404, .body = errJson(resp, "unknown tool") }; +} + +fn dispatchRest(path: []const u8, body: []const u8, resp: []u8) Response { + // Expect /tools/<tool_name> + const prefix = "/tools/"; + if (std.mem.startsWith(u8, path, prefix)) { + const tool = path[prefix.len..]; + return dispatch(tool, body, resp); + } + return .{ .status = 404, .body = errJson(resp, "not found") }; +} + +fn dispatchGrpc(path: []const u8, body: []const u8, resp: []u8) Response { + // Expect /<Service>/<Method> β€” derive tool from Method + var it = std.mem.splitScalar(u8, path, '/'); + _ = it.next(); // leading empty + _ = it.next(); // service + const method = it.next() orelse return .{ .status = 404, .body = errJson(resp, "bad gRPC path") }; + return dispatch(method, body, resp); +} + +fn dispatchGraphql(body: []const u8, resp: []u8) Response { + if (std.mem.indexOf(u8, body, "cloud_authenticate") != null) + return dispatch("cloud_authenticate", body, resp); + if (std.mem.indexOf(u8, body, "cloud_logout") != null) + return dispatch("cloud_logout", body, resp); + if (std.mem.indexOf(u8, body, "cloud_state") != null) + return dispatch("cloud_state", body, resp); + if (std.mem.indexOf(u8, body, "cloud_execute") != null) + return dispatch("cloud_execute", body, resp); + return .{ .status = 400, .body = errJson(resp, "unrecognised GraphQL operation") }; +} + +fn handleConnection(stream: std.net.Stream, port: u16) void { + defer stream.close(); + var buf: [4096]u8 = undefined; + var resp_buf: [4096]u8 = undefined; + const n = stream.read(&buf) catch return; + const req = buf[0..n]; + const result = switch (port) { + REST_PORT => blk: { + // Parse HTTP/1.1: first line = METHOD PATH HTTP/x.y + var lines = std.mem.splitScalar(u8, req, '\n'); + const first = lines.next() orelse break :blk Response{ .status = 400, .body = "" }; + var parts = std.mem.splitScalar(u8, std.mem.trim(u8, first, "\r"), ' '); + _ = parts.next(); // method + const path = parts.next() orelse break :blk Response{ .status = 400, .body = "" }; + break :blk dispatchRest(path, req, &resp_buf); + }, + GRPC_PORT => blk: { + var lines = std.mem.splitScalar(u8, req, '\n'); + const first = lines.next() orelse break :blk Response{ .status = 400, .body = "" }; + var parts = std.mem.splitScalar(u8, std.mem.trim(u8, first, "\r"), ' '); + _ = parts.next(); + const path = parts.next() orelse break :blk Response{ .status = 400, .body = "" }; + break :blk dispatchGrpc(path, req, &resp_buf); + }, + GQL_PORT => dispatchGraphql(req, &resp_buf), + else => Response{ .status = 500, .body = "" }, + }; + var http_resp: [512]u8 = undefined; + const http = std.fmt.bufPrint(&http_resp, + "HTTP/1.1 {d} OK\r\nContent-Length: {d}\r\nContent-Type: application/json\r\n\r\n", + .{ result.status, result.body.len }) catch return; + _ = stream.write(http) catch {}; + _ = stream.write(result.body) catch {}; +} + +fn listenLoop(port: u16) !void { + const addr = std.net.Address.initIp4(.{ 127, 0, 0, 1 }, port); + var server = try addr.listen(.{ .reuse_address = true }); + defer server.deinit(); + while (true) { + const conn = try server.accept(); + const t = try std.Thread.spawn(.{}, handleConnection, .{ conn.stream, port }); + t.detach(); + } +} + +pub fn main() !void { + ffi.cloud_init(); + const t1 = try std.Thread.spawn(.{}, listenLoop, .{REST_PORT}); + const t2 = try std.Thread.spawn(.{}, listenLoop, .{GRPC_PORT}); + const t3 = try std.Thread.spawn(.{}, listenLoop, .{GQL_PORT}); + t1.join(); + t2.join(); + t3.join(); +} diff --git a/cartridges/domains/cloud/cloud-mcp/cartridge.json b/cartridges/domains/cloud/cloud-mcp/cartridge.json new file mode 100644 index 0000000..4f22b00 --- /dev/null +++ b/cartridges/domains/cloud/cloud-mcp/cartridge.json @@ -0,0 +1,115 @@ +{ + "$schema": "https://boj.dev/schemas/cartridge/v1.json", + "spdx": "MPL-2.0", + "copyright": "Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk>", + "name": "cloud-mcp", + "version": "0.1.0", + "description": "Multi-cloud provider session manager (AWS/GCP/Azure/DO/Vercel)", + "domain": "cloud", + "tier": "Ayo", + "protocols": [ + "MCP", + "REST" + ], + "auth": { + "method": "none", + "env_var": null, + "credential_source": null + }, + "api": { + "base_url": "local://cloud-mcp", + "content_type": "application/json" + }, + "tools": [ + { + "name": "cloud_authenticate", + "description": "Authenticate with a cloud provider", + "inputSchema": { + "type": "object", + "properties": { + "provider": { + "type": "string", + "description": "Provider: aws|gcloud|azure|digital_ocean|verpex|cloudflare|vercel" + }, + "credentials": { + "type": "object", + "description": "Provider-specific credentials" + } + }, + "required": [ + "provider", + "credentials" + ] + } + }, + { + "name": "cloud_logout", + "description": "End a cloud session", + "inputSchema": { + "type": "object", + "properties": { + "session_id": { + "type": "integer", + "description": "Session index" + } + }, + "required": [ + "session_id" + ] + } + }, + { + "name": "cloud_state", + "description": "Get cloud session state", + "inputSchema": { + "type": "object", + "properties": { + "session_id": { + "type": "integer", + "description": "Session index" + } + }, + "required": [ + "session_id" + ] + } + }, + { + "name": "cloud_execute", + "description": "Execute a cloud operation via the provider API", + "inputSchema": { + "type": "object", + "properties": { + "session_id": { + "type": "integer", + "description": "Session index" + }, + "operation": { + "type": "string", + "description": "Operation name" + }, + "params": { + "type": "object", + "description": "Operation parameters" + } + }, + "required": [ + "session_id", + "operation", + "params" + ] + } + } + ], + "ffi": { + "so_path": "ffi/zig-out/lib/libcloud_mcp.so", + "abi_version": "ADR-0006", + "symbols": [ + "boj_cartridge_init", + "boj_cartridge_deinit", + "boj_cartridge_name", + "boj_cartridge_version", + "boj_cartridge_invoke" + ] + } +} diff --git a/cartridges/domains/cloud/cloud-mcp/ffi/README.adoc b/cartridges/domains/cloud/cloud-mcp/ffi/README.adoc new file mode 100644 index 0000000..8d86468 --- /dev/null +++ b/cartridges/domains/cloud/cloud-mcp/ffi/README.adoc @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += cloud-mcp / ffi β€” Zig FFI layer +:orientation: shallow + +== Purpose + +C-ABI exports bridging this cartridge's Idris2 ABI to the runtime. Consumed by +the adapter for protocol dispatch and by the trunk loader for mount. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” produces `zig-out/lib/libcloud.so`. +| `cloud_ffi.zig` | C-ABI exports (47 exports, 19 inline tests, 1204 lines). +|=== + +== Invariants + +* **Null-pointer rejection** on `[*c]const u8` arguments before any + dereference. +* **Negative return values** carry documented error meanings; zero is + success (matching the trunk error-code convention). +* **No allocator leaks** β€” every allocation path pairs with `defer free`. + +== Test/proof surface + +19 inline `test "..."` block(s) in `cloud_ffi.zig`. Run with +`cd ffi && zig build test`. + +== Read-first + +. `cloud_ffi.zig` β€” C-ABI signatures and guards. +. Trunk `ffi/zig/src/catalogue.zig` for how this `.so` is loaded. diff --git a/cartridges/domains/cloud/cloud-mcp/ffi/build.zig b/cartridges/domains/cloud/cloud-mcp/ffi/build.zig new file mode 100644 index 0000000..6f82717 --- /dev/null +++ b/cartridges/domains/cloud/cloud-mcp/ffi/build.zig @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// Cloud-MCP Cartridge β€” Zig FFI build configuration (Zig 0.15+). + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + // Shared ADR-0006 invoke-shim module (relative path up to boj-server trunk). + + const shim_mod = b.addModule("cartridge_shim", .{ + + .root_source_file = b.path("../../../ffi/zig/src/cartridge_shim.zig"), + + .target = target, + + .optimize = optimize, + + }); + + const cloud_mod = b.addModule("cloud_ffi", .{ + .root_source_file = b.path("cloud_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + cloud_mod.addImport("cartridge_shim", shim_mod); + + // ── Tests ──────────────────────────────────────────────────────── + const cloud_tests = b.addTest(.{ + .root_module = cloud_mod, + }); + + const run_tests = b.addRunArtifact(cloud_tests); + + const test_step = b.step("test", "Run cloud-mcp FFI tests"); + test_step.dependOn(&run_tests.step); + + // ── Shared library ────────────────────────────────────────────── + const lib_mod = b.createModule(.{ + .root_source_file = b.path("cloud_ffi.zig"), + .target = target, + .optimize = optimize, + }); + lib_mod.addImport("cartridge_shim", shim_mod); + + const lib = b.addLibrary(.{ + .name = "cloud_mcp", + .root_module = lib_mod, + .linkage = .dynamic, + }); + b.installArtifact(lib); + + const lib_step = b.step("lib", "Build shared library"); + lib_step.dependOn(&lib.step); +} diff --git a/cartridges/domains/cloud/cloud-mcp/ffi/cartridge_shim.zig b/cartridges/domains/cloud/cloud-mcp/ffi/cartridge_shim.zig new file mode 100644 index 0000000..0e399db --- /dev/null +++ b/cartridges/domains/cloud/cloud-mcp/ffi/cartridge_shim.zig @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// cartridge_shim.zig β€” Shared helpers for the ADR-0006 five-symbol +// cartridge ABI (`boj_cartridge_init / deinit / name / version / invoke`). +// +// The shim centralises the seven-code return convention, NUL-argument +// guards, tool-name comparison, and the buffer-too-small path so each +// cartridge's `boj_cartridge_invoke` can stay short β€” typically a tool +// table plus `shim.writeResult(...)`. +// +// Cartridges import this file by relative path (no build-graph change +// needed). Example: +// +// const shim = @import("../../../ffi/zig/src/cartridge_shim.zig"); +// +// export fn boj_cartridge_invoke( +// tool_name: [*c]const u8, +// json_args: [*c]const u8, +// out_buf: [*c]u8, +// in_out_len: [*c]usize, +// ) callconv(.c) i32 { +// _ = json_args; +// if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; +// const body = if (shim.toolIs(tool_name, "foo")) "{\"result\":{}}" +// else return shim.RC_UNKNOWN_TOOL; +// return shim.writeResult(out_buf, in_out_len, body); +// } + +const std = @import("std"); + +// ── Return codes (ADR-0006 Β§Return codes) ──────────────────────────── +// +// Frozen by ADR-0006. New failure modes compose these via the error +// JSON body β€” the integer surface does not grow without a follow-up ADR. + +pub const RC_SUCCESS: i32 = 0; +pub const RC_UNKNOWN_TOOL: i32 = -1; +pub const RC_BAD_ARGS: i32 = -2; +pub const RC_BUFFER_TOO_SMALL: i32 = -3; +pub const RC_RUNTIME_ERROR: i32 = -4; +pub const RC_PANIC: i32 = -5; +pub const RC_AUTH_DENIED: i32 = -6; + +// ── Invoke-path helpers ────────────────────────────────────────────── + +/// True if any of the three mandatory `boj_cartridge_invoke` output-path +/// pointers is null. Use at the top of every invoke to short-circuit to +/// `RC_BAD_ARGS`. +pub fn invokeArgsNull( + tool_name: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) bool { + return tool_name == null or out_buf == null or in_out_len == null; +} + +/// Compare a C-NUL-terminated tool-name pointer against a Zig string +/// literal. Caller must have already verified `tool_name` is non-null +/// (usually via `invokeArgsNull`). +pub fn toolIs(tool_name: [*c]const u8, expected: []const u8) bool { + const s = std.mem.span(@as([*:0]const u8, @ptrCast(tool_name))); + return std.mem.eql(u8, s, expected); +} + +/// Copy `body` into `out_buf[0..*in_out_len]` (as a capacity) and update +/// `*in_out_len` to the number of bytes written. Returns `RC_SUCCESS`. +/// +/// If `body.len` exceeds the current capacity stored in `*in_out_len`, +/// sets `*in_out_len` to the required size and returns +/// `RC_BUFFER_TOO_SMALL` β€” the caller is then expected to re-allocate +/// and retry, per ADR-0006 Β§Memory ownership. +/// +/// Caller must have already verified that `out_buf` and `in_out_len` +/// are non-null. +pub fn writeResult( + out_buf: [*c]u8, + in_out_len: [*c]usize, + body: []const u8, +) i32 { + const cap = in_out_len.*; + if (body.len > cap) { + in_out_len.* = body.len; + return RC_BUFFER_TOO_SMALL; + } + @memcpy(out_buf[0..body.len], body); + in_out_len.* = body.len; + return RC_SUCCESS; +} + +// ── Tests ──────────────────────────────────────────────────────────── + +test "writeResult: body fits, writes and sets length" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); + try std.testing.expectEqualStrings("hello", buf[0..len]); +} + +test "writeResult: too small returns -3 and sets required length" { + var buf: [2]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_BUFFER_TOO_SMALL, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: exact-fit succeeds" { + var buf: [5]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: empty body" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, ""); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 0), len); +} + +test "toolIs: matches and rejects" { + const name: [*:0]const u8 = "foo"; + try std.testing.expect(toolIs(@ptrCast(name), "foo")); + try std.testing.expect(!toolIs(@ptrCast(name), "bar")); + try std.testing.expect(!toolIs(@ptrCast(name), "foobar")); + try std.testing.expect(!toolIs(@ptrCast(name), "fo")); +} + +test "invokeArgsNull: detects each null slot" { + var buf: [4]u8 = undefined; + var len: usize = 4; + const name: [*:0]const u8 = "x"; + try std.testing.expect(!invokeArgsNull(@ptrCast(name), &buf, &len)); + try std.testing.expect(invokeArgsNull(null, &buf, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), null, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), &buf, null)); +} diff --git a/cartridges/domains/cloud/cloud-mcp/ffi/cloud_ffi.zig b/cartridges/domains/cloud/cloud-mcp/ffi/cloud_ffi.zig new file mode 100644 index 0000000..53ebca2 --- /dev/null +++ b/cartridges/domains/cloud/cloud-mcp/ffi/cloud_ffi.zig @@ -0,0 +1,1298 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// Cloud-MCP Cartridge β€” Zig FFI bridge for multi-cloud provider operations. +// +// Implements the provider session state machine from SafeCloud.idr. +// Ensures no operation can execute on an unauthenticated provider, +// and tracks credential lifecycle to prevent leaks. + +const std = @import("std"); + +// ═══════════════════════════════════════════════════════════════════════ +// Types (must match CloudMcp.SafeCloud encodings) +// ═══════════════════════════════════════════════════════════════════════ + +pub const SessionState = enum(c_int) { + unauthenticated = 0, + authenticated = 1, + operating = 2, + auth_error = 3, +}; + +pub const CloudProvider = enum(c_int) { + aws = 1, + gcloud = 2, + azure = 3, + digital_ocean = 4, + verpex = 5, + cloudflare = 6, + vercel = 7, + custom = 99, +}; + +/// Cloudflare resource types β€” mirrors `CloudMcp.SafeCloud.CloudflareResource` +/// + `cfResourceToInt` encoding. Declared here so `iseriser abi-verify` can +/// structurally check the encoding against the Idris2 source. +/// +/// Note: the snake_case variants follow the iseriser converter's current +/// output for multi-cap Idris2 names (e.g. `CfKVNamespace β†’ cf_kvnamespace`, +/// `CfDNSZone β†’ cf_dnszone`). When iseriser#18 lands a smarter multi-cap +/// normaliser, these names may be canonicalised to e.g. `cf_kv_namespace`. +pub const CloudflareResource = enum(c_int) { + cf_worker = 1, + cf_d1_database = 2, + cf_kvnamespace = 3, + cf_r2_bucket = 4, + cf_dnszone = 5, + cf_dnsrecord = 6, + cf_pages_project = 7, +}; + +/// Vercel resource types β€” mirrors `CloudMcp.SafeCloud.VercelResource` +/// + `vclResourceToInt` encoding. +pub const VercelResource = enum(c_int) { + vcl_project = 1, + vcl_deployment = 2, + vcl_domain = 3, + vcl_env_var = 4, + vcl_serverless_function = 5, +}; + +// ═══════════════════════════════════════════════════════════════════════ +// Session State Machine +// ═══════════════════════════════════════════════════════════════════════ + +const MAX_SESSIONS: usize = 8; + +const RESULT_BUF_SIZE: usize = 4096; + +const CredentialKind = enum { + none, + bearer_token, + api_key, +}; + +const SessionSlot = struct { + active: bool, + state: SessionState, + provider: CloudProvider, + cred_kind: CredentialKind = .none, + cred_hash: u64 = 0, // FNV hash of credential β€” never store plaintext + result_buf: [RESULT_BUF_SIZE]u8 = [_]u8{0} ** RESULT_BUF_SIZE, + result_len: usize = 0, +}; + +var sessions: [MAX_SESSIONS]SessionSlot = [_]SessionSlot{.{ + .active = false, + .state = .unauthenticated, + .provider = .aws, + .cred_kind = .none, + .cred_hash = 0, + .result_buf = [_]u8{0} ** RESULT_BUF_SIZE, + .result_len = 0, +}} ** MAX_SESSIONS; + +var mutex: std.Thread.Mutex = .{}; + +/// Validate a state transition (matches Idris2 canTransition). +fn isValidTransition(from: SessionState, to: SessionState) bool { + return switch (from) { + .unauthenticated => to == .authenticated, + .authenticated => to == .operating or to == .unauthenticated, + .operating => to == .authenticated or to == .auth_error, + .auth_error => to == .unauthenticated, + }; +} + +/// Authenticate with a provider. Returns slot index or -1 on failure. +pub export fn cloud_authenticate(provider: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + for (&sessions, 0..) |*slot, i| { + if (!slot.active) { + slot.active = true; + slot.state = .authenticated; + slot.provider = @enumFromInt(provider); + return @intCast(i); + } + } + return -1; // No slots available +} + +/// Logout from a provider session by slot index. +pub export fn cloud_logout(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + if (slot_idx < 0 or slot_idx >= MAX_SESSIONS) return -1; + const idx: usize = @intCast(slot_idx); + if (!sessions[idx].active) return -1; + if (!isValidTransition(sessions[idx].state, .unauthenticated)) return -2; + + sessions[idx].active = false; + sessions[idx].state = .unauthenticated; + return 0; +} + +/// Begin an operation (transition Authenticated -> Operating). +pub export fn cloud_begin_operation(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + if (slot_idx < 0 or slot_idx >= MAX_SESSIONS) return -1; + const idx: usize = @intCast(slot_idx); + if (!sessions[idx].active) return -1; + if (!isValidTransition(sessions[idx].state, .operating)) return -2; + + sessions[idx].state = .operating; + return 0; +} + +/// End an operation (transition Operating -> Authenticated). +pub export fn cloud_end_operation(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + if (slot_idx < 0 or slot_idx >= MAX_SESSIONS) return -1; + const idx: usize = @intCast(slot_idx); + if (!sessions[idx].active) return -1; + if (!isValidTransition(sessions[idx].state, .authenticated)) return -2; + + sessions[idx].state = .authenticated; + return 0; +} + +/// Get the state of a session. +pub export fn cloud_state(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + if (slot_idx < 0 or slot_idx >= MAX_SESSIONS) return -1; + const idx: usize = @intCast(slot_idx); + if (!sessions[idx].active) return @intFromEnum(SessionState.unauthenticated); + return @intFromEnum(sessions[idx].state); +} + +/// Validate a state transition (C-ABI export). +pub export fn cloud_can_transition(from: c_int, to: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + const f: SessionState = @enumFromInt(from); + const t: SessionState = @enumFromInt(to); + return if (isValidTransition(f, t)) 1 else 0; +} + +/// Reset all sessions (for testing). +pub export fn cloud_reset() void { + mutex.lock(); + defer mutex.unlock(); + for (&sessions) |*slot| { + slot.active = false; + slot.state = .unauthenticated; + } +} + +// ═══════════════════════════════════════════════════════════════════════ +// Standard Cartridge Interface (loader expects these 4 C-ABI symbols) +// ═══════════════════════════════════════════════════════════════════════ + +/// Initialise the cloud-mcp cartridge. Resets all session slots. +pub export fn boj_cartridge_init() c_int { + cloud_reset(); + return 0; +} + +/// Deinitialise the cloud-mcp cartridge. Resets all session slots. +pub export fn boj_cartridge_deinit() void { + cloud_reset(); +} + +/// Return the cartridge name as a null-terminated C string. +pub export fn boj_cartridge_name() [*:0]const u8 { + mutex.lock(); + defer mutex.unlock(); + return "cloud-mcp"; +} + +/// Return the cartridge version as a null-terminated C string. +pub export fn boj_cartridge_version() [*:0]const u8 { + mutex.lock(); + defer mutex.unlock(); + return "0.1.0"; +} + +// ═══════════════════════════════════════════════════════════════════════ +// ADR-0006 dispatch (boj_cartridge_invoke, 5th standard symbol) +// ═══════════════════════════════════════════════════════════════════════ + +const shim = @import("cartridge_shim.zig"); + +/// Dispatch the cartridge.json MCP tools. Grade D Alpha β€” each arm +/// returns a stub JSON body shaped to the tool's intended response. +export fn boj_cartridge_invoke( + tool_name: [*c]const u8, + json_args: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) callconv(.c) i32 { + _ = json_args; + if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; + + const body: []const u8 = if (shim.toolIs(tool_name, "cloud_authenticate")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "cloud_logout")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "cloud_state")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "cloud_execute")) + "{\"result\":{\"status\":\"stub\"}}" +else + return shim.RC_UNKNOWN_TOOL; + + return shim.writeResult(out_buf, in_out_len, body); +} + +// ═══════════════════════════════════════════════════════════════════════ +// Vercel Provider (provider code 7) +// Grade D Alpha β€” stub implementations +// Real API: https://api.vercel.com/v{version}/{endpoint} +// Auth: Authorization: Bearer {token} +// ═══════════════════════════════════════════════════════════════════════ + +/// Issue a Vercel API request (Grade D Alpha stub). +/// Real implementation would hit https://api.vercel.com/v{version}/{endpoint} +/// with header: Authorization: Bearer {token} +fn vercelRequest(token: []const u8, endpoint: []const u8, method: []const u8) void { + // Grade D Alpha stub β€” log intent, no network I/O + _ = token; + _ = endpoint; + _ = method; +} + +/// Write a JSON stub response into a session's result buffer. +fn writeVercelResult(slot: *SessionSlot, endpoint: []const u8, method: []const u8) void { + const prefix = "{\"provider\":\"vercel\",\"endpoint\":\""; + const mid1 = "\",\"method\":\""; + const mid2 = "\",\"status\":\"stub\",\"note\":\"Grade D Alpha\"}"; + + var pos: usize = 0; + const parts = [_][]const u8{ prefix, endpoint, mid1, method, mid2 }; + for (parts) |part| { + if (pos + part.len > RESULT_BUF_SIZE) break; + @memcpy(slot.result_buf[pos .. pos + part.len], part); + pos += part.len; + } + slot.result_len = pos; +} + +/// Validate that a slot is active, authenticated, and bound to the Vercel provider. +fn validateVercelSlot(slot_idx: c_int) ?usize { + if (slot_idx < 0 or slot_idx >= MAX_SESSIONS) return null; + const idx: usize = @intCast(slot_idx); + if (!sessions[idx].active) return null; + if (sessions[idx].provider != .vercel) return null; + if (sessions[idx].state != .authenticated) return null; + return idx; +} + +/// Set credentials on a Vercel session slot. +pub export fn cloud_vercel_set_credentials(slot_idx: c_int, token_ptr: [*]const u8, token_len: usize) c_int { + mutex.lock(); + defer mutex.unlock(); + const idx = validateVercelSlot(slot_idx) orelse return -1; + sessions[idx].cred_kind = .bearer_token; + sessions[idx].cred_hash = std.hash.Fnv1a_64.hash(token_ptr[0..token_len]); + return 0; +} + +/// List Vercel projects. +pub export fn cloud_vercel_list_projects(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + const idx = validateVercelSlot(slot_idx) orelse return -1; + vercelRequest(&.{}, "v9/projects", "GET"); + writeVercelResult(&sessions[idx], "v9/projects", "GET"); + return 0; +} + +/// Get a specific Vercel project by name. +pub export fn cloud_vercel_get_project(slot_idx: c_int, name_ptr: [*]const u8, name_len: usize) c_int { + mutex.lock(); + defer mutex.unlock(); + const idx = validateVercelSlot(slot_idx) orelse return -1; + _ = name_ptr; + _ = name_len; + vercelRequest(&.{}, "v9/projects/{name}", "GET"); + writeVercelResult(&sessions[idx], "v9/projects/{name}", "GET"); + return 0; +} + +/// List Vercel deployments. +pub export fn cloud_vercel_list_deployments(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + const idx = validateVercelSlot(slot_idx) orelse return -1; + vercelRequest(&.{}, "v6/deployments", "GET"); + writeVercelResult(&sessions[idx], "v6/deployments", "GET"); + return 0; +} + +/// Get a specific Vercel deployment by ID. +pub export fn cloud_vercel_get_deployment(slot_idx: c_int, id_ptr: [*]const u8, id_len: usize) c_int { + mutex.lock(); + defer mutex.unlock(); + const idx = validateVercelSlot(slot_idx) orelse return -1; + _ = id_ptr; + _ = id_len; + vercelRequest(&.{}, "v13/deployments/{id}", "GET"); + writeVercelResult(&sessions[idx], "v13/deployments/{id}", "GET"); + return 0; +} + +/// List Vercel domains. +pub export fn cloud_vercel_list_domains(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + const idx = validateVercelSlot(slot_idx) orelse return -1; + vercelRequest(&.{}, "v5/domains", "GET"); + writeVercelResult(&sessions[idx], "v5/domains", "GET"); + return 0; +} + +/// List environment variables for a Vercel project. +pub export fn cloud_vercel_list_env_vars(slot_idx: c_int, project_ptr: [*]const u8, project_len: usize) c_int { + mutex.lock(); + defer mutex.unlock(); + const idx = validateVercelSlot(slot_idx) orelse return -1; + _ = project_ptr; + _ = project_len; + vercelRequest(&.{}, "v9/projects/{project}/env", "GET"); + writeVercelResult(&sessions[idx], "v9/projects/{project}/env", "GET"); + return 0; +} + +/// Get deployment logs for a Vercel deployment. +pub export fn cloud_vercel_deployment_logs(slot_idx: c_int, id_ptr: [*]const u8, id_len: usize) c_int { + mutex.lock(); + defer mutex.unlock(); + const idx = validateVercelSlot(slot_idx) orelse return -1; + _ = id_ptr; + _ = id_len; + vercelRequest(&.{}, "v2/deployments/{id}/events", "GET"); + writeVercelResult(&sessions[idx], "v2/deployments/{id}/events", "GET"); + return 0; +} + +/// List serverless functions for a Vercel deployment. +pub export fn cloud_vercel_list_functions(slot_idx: c_int, deployment_ptr: [*]const u8, deployment_len: usize) c_int { + mutex.lock(); + defer mutex.unlock(); + const idx = validateVercelSlot(slot_idx) orelse return -1; + _ = deployment_ptr; + _ = deployment_len; + vercelRequest(&.{}, "v1/deployments/{id}/functions", "GET"); + writeVercelResult(&sessions[idx], "v1/deployments/{id}/functions", "GET"); + return 0; +} + +/// Read the result buffer for a session slot. Returns length or -1 on error. +pub export fn cloud_vercel_read_result(slot_idx: c_int, out_ptr: [*]u8, out_cap: usize) c_int { + mutex.lock(); + defer mutex.unlock(); + if (slot_idx < 0 or slot_idx >= MAX_SESSIONS) return -1; + const idx: usize = @intCast(slot_idx); + if (!sessions[idx].active) return -1; + const len = @min(sessions[idx].result_len, out_cap); + @memcpy(out_ptr[0..len], sessions[idx].result_buf[0..len]); + return @intCast(len); +} + +// ═══════════════════════════════════════════════════════════════════════ +// Tests +// ═══════════════════════════════════════════════════════════════════════ + +test "authenticate and logout" { + cloud_reset(); + const slot = cloud_authenticate(@intFromEnum(CloudProvider.aws)); + try std.testing.expect(slot >= 0); + try std.testing.expectEqual(@as(c_int, @intFromEnum(SessionState.authenticated)), cloud_state(slot)); + try std.testing.expectEqual(@as(c_int, 0), cloud_logout(slot)); +} + +test "cannot operate on unauthenticated" { + cloud_reset(); + const slot = cloud_authenticate(@intFromEnum(CloudProvider.gcloud)); + _ = cloud_logout(slot); + // Should fail β€” can't begin operation on unauthenticated session + try std.testing.expectEqual(@as(c_int, -1), cloud_begin_operation(slot)); +} + +test "operation lifecycle" { + cloud_reset(); + const slot = cloud_authenticate(@intFromEnum(CloudProvider.azure)); + try std.testing.expectEqual(@as(c_int, 0), cloud_begin_operation(slot)); + try std.testing.expectEqual(@as(c_int, @intFromEnum(SessionState.operating)), cloud_state(slot)); + try std.testing.expectEqual(@as(c_int, 0), cloud_end_operation(slot)); + try std.testing.expectEqual(@as(c_int, @intFromEnum(SessionState.authenticated)), cloud_state(slot)); + try std.testing.expectEqual(@as(c_int, 0), cloud_logout(slot)); +} + +test "cannot double-logout" { + cloud_reset(); + const slot = cloud_authenticate(@intFromEnum(CloudProvider.digital_ocean)); + _ = cloud_logout(slot); + // Second logout should fail β€” already unauthenticated + try std.testing.expectEqual(@as(c_int, -1), cloud_logout(slot)); +} + +test "cannot logout while operating" { + cloud_reset(); + const slot = cloud_authenticate(@intFromEnum(CloudProvider.aws)); + _ = cloud_begin_operation(slot); + // Cannot logout directly from operating β€” must end operation first + try std.testing.expectEqual(@as(c_int, -2), cloud_logout(slot)); +} + +test "state transition validation" { + // Valid transitions + try std.testing.expectEqual(@as(c_int, 1), cloud_can_transition(0, 1)); // unauth -> auth + try std.testing.expectEqual(@as(c_int, 1), cloud_can_transition(1, 2)); // auth -> operating + try std.testing.expectEqual(@as(c_int, 1), cloud_can_transition(2, 1)); // operating -> auth + try std.testing.expectEqual(@as(c_int, 1), cloud_can_transition(1, 0)); // auth -> unauth + try std.testing.expectEqual(@as(c_int, 1), cloud_can_transition(2, 3)); // operating -> error + try std.testing.expectEqual(@as(c_int, 1), cloud_can_transition(3, 0)); // error -> unauth + // Invalid transitions + try std.testing.expectEqual(@as(c_int, 0), cloud_can_transition(0, 2)); // unauth -> operating + try std.testing.expectEqual(@as(c_int, 0), cloud_can_transition(2, 0)); // operating -> unauth +} + +test "max sessions enforced" { + cloud_reset(); + var slots: [MAX_SESSIONS]c_int = undefined; + for (&slots) |*s| { + s.* = cloud_authenticate(@intFromEnum(CloudProvider.aws)); + try std.testing.expect(s.* >= 0); + } + // Next authenticate should fail + try std.testing.expectEqual(@as(c_int, -1), cloud_authenticate(@intFromEnum(CloudProvider.aws))); + // Free one and retry + _ = cloud_logout(slots[0]); + try std.testing.expect(cloud_authenticate(@intFromEnum(CloudProvider.aws)) >= 0); +} + +// ═══════════════════════════════════════════════════════════════════════ +// Vercel Provider Tests +// ═══════════════════════════════════════════════════════════════════════ + +test "vercel auth and credential storage" { + cloud_reset(); + const slot = cloud_authenticate(@intFromEnum(CloudProvider.vercel)); + try std.testing.expect(slot >= 0); + try std.testing.expectEqual(@as(c_int, @intFromEnum(SessionState.authenticated)), cloud_state(slot)); + // Set credentials (bearer token) + const token = "vcel_test_token_abc123"; + try std.testing.expectEqual(@as(c_int, 0), cloud_vercel_set_credentials(slot, token.ptr, token.len)); + try std.testing.expectEqual(@as(c_int, 0), cloud_logout(slot)); +} + +test "vercel list projects and list deployments" { + cloud_reset(); + const slot = cloud_authenticate(@intFromEnum(CloudProvider.vercel)); + try std.testing.expect(slot >= 0); + // list_projects should succeed on an authenticated Vercel slot + try std.testing.expectEqual(@as(c_int, 0), cloud_vercel_list_projects(slot)); + // Read the result buffer to verify JSON stub was written + var buf: [RESULT_BUF_SIZE]u8 = undefined; + const len = cloud_vercel_read_result(slot, &buf, buf.len); + try std.testing.expect(len > 0); + // list_deployments should also succeed + try std.testing.expectEqual(@as(c_int, 0), cloud_vercel_list_deployments(slot)); + // get_project should succeed + const name = "my-project"; + try std.testing.expectEqual(@as(c_int, 0), cloud_vercel_get_project(slot, name.ptr, name.len)); + _ = cloud_logout(slot); +} + +test "vercel domains, env vars, logs, and functions" { + cloud_reset(); + const slot = cloud_authenticate(@intFromEnum(CloudProvider.vercel)); + try std.testing.expect(slot >= 0); + // list_domains + try std.testing.expectEqual(@as(c_int, 0), cloud_vercel_list_domains(slot)); + // list_env_vars + const proj = "my-project"; + try std.testing.expectEqual(@as(c_int, 0), cloud_vercel_list_env_vars(slot, proj.ptr, proj.len)); + // deployment_logs + const dep_id = "dpl_abc123"; + try std.testing.expectEqual(@as(c_int, 0), cloud_vercel_deployment_logs(slot, dep_id.ptr, dep_id.len)); + // list_functions + try std.testing.expectEqual(@as(c_int, 0), cloud_vercel_list_functions(slot, dep_id.ptr, dep_id.len)); + _ = cloud_logout(slot); +} + +test "vercel wrong-provider rejection" { + cloud_reset(); + // Authenticate as AWS, then try Vercel operations β€” should fail + const slot = cloud_authenticate(@intFromEnum(CloudProvider.aws)); + try std.testing.expect(slot >= 0); + // All Vercel operations should return -1 (wrong provider) + try std.testing.expectEqual(@as(c_int, -1), cloud_vercel_list_projects(slot)); + try std.testing.expectEqual(@as(c_int, -1), cloud_vercel_list_deployments(slot)); + try std.testing.expectEqual(@as(c_int, -1), cloud_vercel_list_domains(slot)); + const token = "vcel_test"; + try std.testing.expectEqual(@as(c_int, -1), cloud_vercel_set_credentials(slot, token.ptr, token.len)); + _ = cloud_logout(slot); +} + +// ═══════════════════════════════════════════════════════════════════════ +// Cloudflare Provider (provider code 6) +// Grade D Alpha β€” stub implementations +// Real API: https://api.cloudflare.com/client/v4/{endpoint} +// Auth: Authorization: Bearer {token} +// ═══════════════════════════════════════════════════════════════════════ + +/// Grade D Alpha stub for Cloudflare API requests. +/// Real implementation would call https://api.cloudflare.com/client/v4/{endpoint} +/// with Authorization: Bearer {token} header. +fn cloudflareRequest(token: []const u8, endpoint: []const u8, method: []const u8) void { + // Grade D Alpha stub β€” logs intent, does not make real HTTP calls. + // In production this would: + // 1. Build URL: https://api.cloudflare.com/client/v4/{endpoint} + // 2. Set header: Authorization: Bearer {token} + // 3. Execute {method} request + // 4. Parse JSON response + _ = token; + _ = endpoint; + _ = method; +} + +/// Validate that a session slot is active, authenticated/operating, and belongs to cloudflare. +/// Returns the validated index or null if the slot is invalid or wrong provider. +fn validateCloudflareSlot(slot_idx: c_int) ?usize { + if (slot_idx < 0 or slot_idx >= MAX_SESSIONS) return null; + const idx: usize = @intCast(slot_idx); + if (!sessions[idx].active) return null; + if (sessions[idx].provider != .cloudflare) return null; + if (sessions[idx].state != .authenticated and sessions[idx].state != .operating) return null; + return idx; +} + +/// Write a structured JSON stub response into the session result buffer. +fn writeCloudflareResult(idx: usize, endpoint: []const u8, method: []const u8) void { + const prefix = "{\"provider\":\"cloudflare\",\"endpoint\":\""; + const mid1 = "\",\"method\":\""; + const mid2 = "\",\"status\":\"stub\",\"note\":\"Grade D Alpha\"}"; + + var pos: usize = 0; + const parts = [_][]const u8{ prefix, endpoint, mid1, method, mid2 }; + for (parts) |part| { + if (pos + part.len > RESULT_BUF_SIZE) break; + @memcpy(sessions[idx].result_buf[pos .. pos + part.len], part); + pos += part.len; + } + sessions[idx].result_len = pos; +} + +/// Set credentials on a Cloudflare session slot. +pub export fn cloud_cf_set_credentials(slot_idx: c_int, token_ptr: [*]const u8, token_len: usize) c_int { + mutex.lock(); + defer mutex.unlock(); + const idx = validateCloudflareSlot(slot_idx) orelse return -1; + sessions[idx].cred_kind = .bearer_token; + sessions[idx].cred_hash = std.hash.Fnv1a_64.hash(token_ptr[0..token_len]); + return 0; +} + +/// List Cloudflare Workers scripts. +pub export fn cloud_cf_list_workers(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + const idx = validateCloudflareSlot(slot_idx) orelse return -1; + cloudflareRequest(&.{}, "workers/scripts", "GET"); + writeCloudflareResult(idx, "workers/scripts", "GET"); + return 0; +} + +/// Get a specific Cloudflare Worker by name. +pub export fn cloud_cf_get_worker(slot_idx: c_int, name_ptr: [*]const u8, name_len: usize) c_int { + mutex.lock(); + defer mutex.unlock(); + const idx = validateCloudflareSlot(slot_idx) orelse return -1; + _ = name_ptr[0..name_len]; + cloudflareRequest(&.{}, "workers/scripts/{name}", "GET"); + writeCloudflareResult(idx, "workers/scripts/{name}", "GET"); + return 0; +} + +/// List Cloudflare D1 databases. +pub export fn cloud_cf_list_d1(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + const idx = validateCloudflareSlot(slot_idx) orelse return -1; + cloudflareRequest(&.{}, "d1/database", "GET"); + writeCloudflareResult(idx, "d1/database", "GET"); + return 0; +} + +/// Query a Cloudflare D1 database. json_ptr/json_len contain the SQL query JSON. +pub export fn cloud_cf_query_d1(slot_idx: c_int, json_ptr: [*]const u8, json_len: usize) c_int { + mutex.lock(); + defer mutex.unlock(); + const idx = validateCloudflareSlot(slot_idx) orelse return -1; + _ = json_ptr[0..json_len]; + cloudflareRequest(&.{}, "d1/database/{id}/query", "POST"); + writeCloudflareResult(idx, "d1/database/{id}/query", "POST"); + return 0; +} + +/// List Cloudflare KV namespaces. +pub export fn cloud_cf_list_kv(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + const idx = validateCloudflareSlot(slot_idx) orelse return -1; + cloudflareRequest(&.{}, "storage/kv/namespaces", "GET"); + writeCloudflareResult(idx, "storage/kv/namespaces", "GET"); + return 0; +} + +/// Get a value from a Cloudflare KV namespace. json_ptr/json_len contain namespace + key JSON. +pub export fn cloud_cf_kv_get(slot_idx: c_int, json_ptr: [*]const u8, json_len: usize) c_int { + mutex.lock(); + defer mutex.unlock(); + const idx = validateCloudflareSlot(slot_idx) orelse return -1; + _ = json_ptr[0..json_len]; + cloudflareRequest(&.{}, "storage/kv/namespaces/{ns}/values/{key}", "GET"); + writeCloudflareResult(idx, "storage/kv/namespaces/{ns}/values/{key}", "GET"); + return 0; +} + +/// Put a value into a Cloudflare KV namespace. json_ptr/json_len contain namespace + key + value JSON. +pub export fn cloud_cf_kv_put(slot_idx: c_int, json_ptr: [*]const u8, json_len: usize) c_int { + mutex.lock(); + defer mutex.unlock(); + const idx = validateCloudflareSlot(slot_idx) orelse return -1; + _ = json_ptr[0..json_len]; + cloudflareRequest(&.{}, "storage/kv/namespaces/{ns}/values/{key}", "PUT"); + writeCloudflareResult(idx, "storage/kv/namespaces/{ns}/values/{key}", "PUT"); + return 0; +} + +/// List Cloudflare R2 buckets. +pub export fn cloud_cf_list_r2(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + const idx = validateCloudflareSlot(slot_idx) orelse return -1; + cloudflareRequest(&.{}, "r2/buckets", "GET"); + writeCloudflareResult(idx, "r2/buckets", "GET"); + return 0; +} + +/// List Cloudflare DNS zones. +pub export fn cloud_cf_list_dns_zones(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + const idx = validateCloudflareSlot(slot_idx) orelse return -1; + cloudflareRequest(&.{}, "zones", "GET"); + writeCloudflareResult(idx, "zones", "GET"); + return 0; +} + +/// List DNS records for a specific Cloudflare zone. +pub export fn cloud_cf_list_dns_records(slot_idx: c_int, zone_ptr: [*]const u8, zone_len: usize) c_int { + mutex.lock(); + defer mutex.unlock(); + const idx = validateCloudflareSlot(slot_idx) orelse return -1; + _ = zone_ptr[0..zone_len]; + cloudflareRequest(&.{}, "zones/{zone}/dns_records", "GET"); + writeCloudflareResult(idx, "zones/{zone}/dns_records", "GET"); + return 0; +} + +/// Add a DNS record to a Cloudflare zone. json_ptr/json_len contain record JSON. +pub export fn cloud_cf_add_dns_record(slot_idx: c_int, json_ptr: [*]const u8, json_len: usize) c_int { + mutex.lock(); + defer mutex.unlock(); + const idx = validateCloudflareSlot(slot_idx) orelse return -1; + _ = json_ptr[0..json_len]; + cloudflareRequest(&.{}, "zones/{zone}/dns_records", "POST"); + writeCloudflareResult(idx, "zones/{zone}/dns_records", "POST"); + return 0; +} + +/// Read the result buffer for a Cloudflare session slot. Returns length or -1 on error. +pub export fn cloud_cf_read_result(slot_idx: c_int, out_ptr: [*]u8, out_cap: usize) c_int { + mutex.lock(); + defer mutex.unlock(); + if (slot_idx < 0 or slot_idx >= MAX_SESSIONS) return -1; + const idx: usize = @intCast(slot_idx); + if (!sessions[idx].active) return -1; + const len = @min(sessions[idx].result_len, out_cap); + @memcpy(out_ptr[0..len], sessions[idx].result_buf[0..len]); + return @intCast(len); +} + +// ═══════════════════════════════════════════════════════════════════════ +// Cloudflare Provider Tests +// ═══════════════════════════════════════════════════════════════════════ + +test "cloudflare auth and list workers" { + cloud_reset(); + const slot = cloud_authenticate(@intFromEnum(CloudProvider.cloudflare)); + try std.testing.expect(slot >= 0); + try std.testing.expectEqual(@as(c_int, @intFromEnum(SessionState.authenticated)), cloud_state(slot)); + + // Set credential + const token = "cf_test_token_abc123"; + try std.testing.expectEqual(@as(c_int, 0), cloud_cf_set_credentials(slot, token.ptr, token.len)); + + // List workers should succeed + try std.testing.expectEqual(@as(c_int, 0), cloud_cf_list_workers(slot)); + + // Verify result buffer contains expected JSON + var buf: [RESULT_BUF_SIZE]u8 = undefined; + const rlen = cloud_cf_read_result(slot, &buf, RESULT_BUF_SIZE); + try std.testing.expect(rlen > 0); + const result = buf[0..@intCast(rlen)]; + try std.testing.expect(std.mem.indexOf(u8, result, "\"provider\":\"cloudflare\"") != null); + try std.testing.expect(std.mem.indexOf(u8, result, "\"endpoint\":\"workers/scripts\"") != null); + + _ = cloud_logout(slot); +} + +test "cloudflare d1 kv and r2 operations" { + cloud_reset(); + const slot = cloud_authenticate(@intFromEnum(CloudProvider.cloudflare)); + try std.testing.expect(slot >= 0); + const token = "cf_test_token_d1kv"; + _ = cloud_cf_set_credentials(slot, token.ptr, token.len); + + // D1 list + try std.testing.expectEqual(@as(c_int, 0), cloud_cf_list_d1(slot)); + var buf: [RESULT_BUF_SIZE]u8 = undefined; + var rlen = cloud_cf_read_result(slot, &buf, RESULT_BUF_SIZE); + try std.testing.expect(std.mem.indexOf(u8, buf[0..@intCast(rlen)], "\"endpoint\":\"d1/database\"") != null); + + // KV list + try std.testing.expectEqual(@as(c_int, 0), cloud_cf_list_kv(slot)); + rlen = cloud_cf_read_result(slot, &buf, RESULT_BUF_SIZE); + try std.testing.expect(std.mem.indexOf(u8, buf[0..@intCast(rlen)], "\"endpoint\":\"storage/kv/namespaces\"") != null); + + // R2 list + try std.testing.expectEqual(@as(c_int, 0), cloud_cf_list_r2(slot)); + rlen = cloud_cf_read_result(slot, &buf, RESULT_BUF_SIZE); + try std.testing.expect(std.mem.indexOf(u8, buf[0..@intCast(rlen)], "\"endpoint\":\"r2/buckets\"") != null); + + _ = cloud_logout(slot); +} + +test "cloudflare dns operations" { + cloud_reset(); + const slot = cloud_authenticate(@intFromEnum(CloudProvider.cloudflare)); + try std.testing.expect(slot >= 0); + const token = "cf_test_token_dns"; + _ = cloud_cf_set_credentials(slot, token.ptr, token.len); + + // List DNS zones + try std.testing.expectEqual(@as(c_int, 0), cloud_cf_list_dns_zones(slot)); + + // List DNS records for a zone + const zone_id = "zone123abc"; + try std.testing.expectEqual(@as(c_int, 0), cloud_cf_list_dns_records(slot, zone_id.ptr, zone_id.len)); + + // Add DNS record + const record_json = "{\"type\":\"A\",\"name\":\"test.example.com\",\"content\":\"192.0.2.1\"}"; + try std.testing.expectEqual(@as(c_int, 0), cloud_cf_add_dns_record(slot, record_json.ptr, record_json.len)); + + _ = cloud_logout(slot); +} + +test "cloudflare wrong-provider rejection" { + cloud_reset(); + // Authenticate as AWS, not Cloudflare + const slot = cloud_authenticate(@intFromEnum(CloudProvider.aws)); + try std.testing.expect(slot >= 0); + + // All Cloudflare operations should reject with -1 (wrong provider) + try std.testing.expectEqual(@as(c_int, -1), cloud_cf_list_workers(slot)); + try std.testing.expectEqual(@as(c_int, -1), cloud_cf_list_d1(slot)); + try std.testing.expectEqual(@as(c_int, -1), cloud_cf_list_kv(slot)); + try std.testing.expectEqual(@as(c_int, -1), cloud_cf_list_r2(slot)); + try std.testing.expectEqual(@as(c_int, -1), cloud_cf_list_dns_zones(slot)); + + const json_data = "{}"; + try std.testing.expectEqual(@as(c_int, -1), cloud_cf_kv_get(slot, json_data.ptr, json_data.len)); + try std.testing.expectEqual(@as(c_int, -1), cloud_cf_kv_put(slot, json_data.ptr, json_data.len)); + try std.testing.expectEqual(@as(c_int, -1), cloud_cf_query_d1(slot, json_data.ptr, json_data.len)); + try std.testing.expectEqual(@as(c_int, -1), cloud_cf_add_dns_record(slot, json_data.ptr, json_data.len)); + + const name = "test"; + try std.testing.expectEqual(@as(c_int, -1), cloud_cf_get_worker(slot, name.ptr, name.len)); + try std.testing.expectEqual(@as(c_int, -1), cloud_cf_list_dns_records(slot, name.ptr, name.len)); + + const cf_token = "cf_test"; + try std.testing.expectEqual(@as(c_int, -1), cloud_cf_set_credentials(slot, cf_token.ptr, cf_token.len)); + + _ = cloud_logout(slot); +} + +// ═══════════════════════════════════════════════════════════════════════ +// Verpex Provider (provider code 5) +// Grade D Alpha β€” stub implementations +// Real API: cPanel UAPI at https://{hostname}:2083/execute/{Module}/{function} +// Auth: Authorization: cpanel {username}:{api_token} +// ═══════════════════════════════════════════════════════════════════════ + +const CRED_BUF_SIZE: usize = 256; + +/// Per-slot Verpex credential storage (hostname + username + api_token). +/// Stored separately because SessionSlot only has a single cred_hash field, +/// and Verpex cPanel auth requires three distinct credential components. +var verpex_hostnames: [MAX_SESSIONS][CRED_BUF_SIZE]u8 = [_][CRED_BUF_SIZE]u8{[_]u8{0} ** CRED_BUF_SIZE} ** MAX_SESSIONS; +var verpex_hostname_lens: [MAX_SESSIONS]usize = [_]usize{0} ** MAX_SESSIONS; +var verpex_usernames: [MAX_SESSIONS][CRED_BUF_SIZE]u8 = [_][CRED_BUF_SIZE]u8{[_]u8{0} ** CRED_BUF_SIZE} ** MAX_SESSIONS; +var verpex_username_lens: [MAX_SESSIONS]usize = [_]usize{0} ** MAX_SESSIONS; +var verpex_tokens: [MAX_SESSIONS][CRED_BUF_SIZE]u8 = [_][CRED_BUF_SIZE]u8{[_]u8{0} ** CRED_BUF_SIZE} ** MAX_SESSIONS; +var verpex_token_lens: [MAX_SESSIONS]usize = [_]usize{0} ** MAX_SESSIONS; + +/// Issue a Verpex cPanel UAPI request (Grade D Alpha stub). +/// Real implementation would hit https://{hostname}:2083/execute/{module}/{function} +/// with header: Authorization: cpanel {username}:{api_token} +fn verpexRequest(hostname: []const u8, username: []const u8, token: []const u8, module: []const u8, function: []const u8) void { + // Grade D Alpha stub β€” log intent, no network I/O + _ = hostname; + _ = username; + _ = token; + _ = module; + _ = function; +} + +/// Write a JSON stub response into a session's result buffer for Verpex operations. +fn writeVerpexResult(slot: *SessionSlot, module: []const u8, function: []const u8) void { + const prefix = "{\"provider\":\"verpex\",\"cpanel_module\":\""; + const mid1 = "\",\"cpanel_function\":\""; + const mid2 = "\",\"status\":\"stub\",\"note\":\"Grade D Alpha\"}"; + + var pos: usize = 0; + const parts = [_][]const u8{ prefix, module, mid1, function, mid2 }; + for (parts) |part| { + if (pos + part.len > RESULT_BUF_SIZE) break; + @memcpy(slot.result_buf[pos .. pos + part.len], part); + pos += part.len; + } + slot.result_len = pos; +} + +/// Validate that a slot is active, authenticated/operating, and bound to the Verpex provider. +fn validateVerpexSlot(slot_idx: c_int) ?usize { + if (slot_idx < 0 or slot_idx >= MAX_SESSIONS) return null; + const idx: usize = @intCast(slot_idx); + if (!sessions[idx].active) return null; + if (sessions[idx].provider != .verpex) return null; + if (sessions[idx].state != .authenticated and sessions[idx].state != .operating) return null; + return idx; +} + +/// Set credentials on a Verpex session slot (hostname + username + api_token). +/// Verpex uses cPanel UAPI auth which requires all three components. +pub export fn cloud_verpex_set_credentials( + slot_idx: c_int, + host_ptr: [*]const u8, + host_len: usize, + user_ptr: [*]const u8, + user_len: usize, + token_ptr: [*]const u8, + token_len: usize, +) c_int { + mutex.lock(); + defer mutex.unlock(); + const idx = validateVerpexSlot(slot_idx) orelse return -1; + if (host_len > CRED_BUF_SIZE or user_len > CRED_BUF_SIZE or token_len > CRED_BUF_SIZE) return -2; + + @memcpy(verpex_hostnames[idx][0..host_len], host_ptr[0..host_len]); + verpex_hostname_lens[idx] = host_len; + @memcpy(verpex_usernames[idx][0..user_len], user_ptr[0..user_len]); + verpex_username_lens[idx] = user_len; + @memcpy(verpex_tokens[idx][0..token_len], token_ptr[0..token_len]); + verpex_token_lens[idx] = token_len; + + sessions[idx].cred_kind = .api_key; + sessions[idx].cred_hash = std.hash.Fnv1a_64.hash(token_ptr[0..token_len]); + return 0; +} + +/// List all domains on the Verpex hosting account (cPanel DomainInfo::list_domains). +pub export fn cloud_verpex_list_domains(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + const idx = validateVerpexSlot(slot_idx) orelse return -1; + verpexRequest( + verpex_hostnames[idx][0..verpex_hostname_lens[idx]], + verpex_usernames[idx][0..verpex_username_lens[idx]], + verpex_tokens[idx][0..verpex_token_lens[idx]], + "DomainInfo", + "list_domains", + ); + writeVerpexResult(&sessions[idx], "DomainInfo", "list_domains"); + return 0; +} + +/// List DNS zone records for a domain (cPanel DNS::parse_zone). +pub export fn cloud_verpex_list_dns(slot_idx: c_int, domain_ptr: [*]const u8, domain_len: usize) c_int { + mutex.lock(); + defer mutex.unlock(); + const idx = validateVerpexSlot(slot_idx) orelse return -1; + _ = domain_ptr[0..domain_len]; + verpexRequest( + verpex_hostnames[idx][0..verpex_hostname_lens[idx]], + verpex_usernames[idx][0..verpex_username_lens[idx]], + verpex_tokens[idx][0..verpex_token_lens[idx]], + "DNS", + "parse_zone", + ); + writeVerpexResult(&sessions[idx], "DNS", "parse_zone"); + return 0; +} + +/// Add a DNS record to a domain (cPanel DNS::mass_edit_zone). json_ptr/json_len contain record JSON. +pub export fn cloud_verpex_add_dns(slot_idx: c_int, json_ptr: [*]const u8, json_len: usize) c_int { + mutex.lock(); + defer mutex.unlock(); + const idx = validateVerpexSlot(slot_idx) orelse return -1; + _ = json_ptr[0..json_len]; + verpexRequest( + verpex_hostnames[idx][0..verpex_hostname_lens[idx]], + verpex_usernames[idx][0..verpex_username_lens[idx]], + verpex_tokens[idx][0..verpex_token_lens[idx]], + "DNS", + "mass_edit_zone", + ); + writeVerpexResult(&sessions[idx], "DNS", "mass_edit_zone"); + return 0; +} + +/// Remove a DNS record from a domain (cPanel DNS::remove_zone_record). json_ptr/json_len contain record JSON. +pub export fn cloud_verpex_remove_dns(slot_idx: c_int, json_ptr: [*]const u8, json_len: usize) c_int { + mutex.lock(); + defer mutex.unlock(); + const idx = validateVerpexSlot(slot_idx) orelse return -1; + _ = json_ptr[0..json_len]; + verpexRequest( + verpex_hostnames[idx][0..verpex_hostname_lens[idx]], + verpex_usernames[idx][0..verpex_username_lens[idx]], + verpex_tokens[idx][0..verpex_token_lens[idx]], + "DNS", + "remove_zone_record", + ); + writeVerpexResult(&sessions[idx], "DNS", "remove_zone_record"); + return 0; +} + +/// List email accounts (cPanel Email::list_pops). +pub export fn cloud_verpex_list_email(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + const idx = validateVerpexSlot(slot_idx) orelse return -1; + verpexRequest( + verpex_hostnames[idx][0..verpex_hostname_lens[idx]], + verpex_usernames[idx][0..verpex_username_lens[idx]], + verpex_tokens[idx][0..verpex_token_lens[idx]], + "Email", + "list_pops", + ); + writeVerpexResult(&sessions[idx], "Email", "list_pops"); + return 0; +} + +/// Create an email account (cPanel Email::add_pop). json_ptr/json_len contain account JSON. +pub export fn cloud_verpex_create_email(slot_idx: c_int, json_ptr: [*]const u8, json_len: usize) c_int { + mutex.lock(); + defer mutex.unlock(); + const idx = validateVerpexSlot(slot_idx) orelse return -1; + _ = json_ptr[0..json_len]; + verpexRequest( + verpex_hostnames[idx][0..verpex_hostname_lens[idx]], + verpex_usernames[idx][0..verpex_username_lens[idx]], + verpex_tokens[idx][0..verpex_token_lens[idx]], + "Email", + "add_pop", + ); + writeVerpexResult(&sessions[idx], "Email", "add_pop"); + return 0; +} + +/// List MySQL databases (cPanel Mysql::list_databases). +pub export fn cloud_verpex_list_databases(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + const idx = validateVerpexSlot(slot_idx) orelse return -1; + verpexRequest( + verpex_hostnames[idx][0..verpex_hostname_lens[idx]], + verpex_usernames[idx][0..verpex_username_lens[idx]], + verpex_tokens[idx][0..verpex_token_lens[idx]], + "Mysql", + "list_databases", + ); + writeVerpexResult(&sessions[idx], "Mysql", "list_databases"); + return 0; +} + +/// Create a MySQL database (cPanel Mysql::create_database). +pub export fn cloud_verpex_create_database(slot_idx: c_int, name_ptr: [*]const u8, name_len: usize) c_int { + mutex.lock(); + defer mutex.unlock(); + const idx = validateVerpexSlot(slot_idx) orelse return -1; + _ = name_ptr[0..name_len]; + verpexRequest( + verpex_hostnames[idx][0..verpex_hostname_lens[idx]], + verpex_usernames[idx][0..verpex_username_lens[idx]], + verpex_tokens[idx][0..verpex_token_lens[idx]], + "Mysql", + "create_database", + ); + writeVerpexResult(&sessions[idx], "Mysql", "create_database"); + return 0; +} + +/// Get SSL/TLS certificate status for a domain (cPanel SSL::installed_hosts). +pub export fn cloud_verpex_ssl_status(slot_idx: c_int, domain_ptr: [*]const u8, domain_len: usize) c_int { + mutex.lock(); + defer mutex.unlock(); + const idx = validateVerpexSlot(slot_idx) orelse return -1; + _ = domain_ptr[0..domain_len]; + verpexRequest( + verpex_hostnames[idx][0..verpex_hostname_lens[idx]], + verpex_usernames[idx][0..verpex_username_lens[idx]], + verpex_tokens[idx][0..verpex_token_lens[idx]], + "SSL", + "installed_hosts", + ); + writeVerpexResult(&sessions[idx], "SSL", "installed_hosts"); + return 0; +} + +/// List cron jobs (cPanel Cron::list_cron). +pub export fn cloud_verpex_list_cron(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + const idx = validateVerpexSlot(slot_idx) orelse return -1; + verpexRequest( + verpex_hostnames[idx][0..verpex_hostname_lens[idx]], + verpex_usernames[idx][0..verpex_username_lens[idx]], + verpex_tokens[idx][0..verpex_token_lens[idx]], + "Cron", + "list_cron", + ); + writeVerpexResult(&sessions[idx], "Cron", "list_cron"); + return 0; +} + +/// Get hosting metrics and resource usage (cPanel ResourceUsage::get_usages). +pub export fn cloud_verpex_metrics(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + const idx = validateVerpexSlot(slot_idx) orelse return -1; + verpexRequest( + verpex_hostnames[idx][0..verpex_hostname_lens[idx]], + verpex_usernames[idx][0..verpex_username_lens[idx]], + verpex_tokens[idx][0..verpex_token_lens[idx]], + "ResourceUsage", + "get_usages", + ); + writeVerpexResult(&sessions[idx], "ResourceUsage", "get_usages"); + return 0; +} + +/// Read the result buffer for a Verpex session slot. Returns length or -1 on error. +pub export fn cloud_verpex_read_result(slot_idx: c_int, out_ptr: [*]u8, out_cap: usize) c_int { + mutex.lock(); + defer mutex.unlock(); + if (slot_idx < 0 or slot_idx >= MAX_SESSIONS) return -1; + const idx: usize = @intCast(slot_idx); + if (!sessions[idx].active) return -1; + const len = @min(sessions[idx].result_len, out_cap); + @memcpy(out_ptr[0..len], sessions[idx].result_buf[0..len]); + return @intCast(len); +} + +// ═══════════════════════════════════════════════════════════════════════ +// Verpex Provider Tests +// ═══════════════════════════════════════════════════════════════════════ + +test "verpex auth and credential storage" { + cloud_reset(); + const slot = cloud_authenticate(@intFromEnum(CloudProvider.verpex)); + try std.testing.expect(slot >= 0); + try std.testing.expectEqual(@as(c_int, @intFromEnum(SessionState.authenticated)), cloud_state(slot)); + + // Set credentials (hostname + username + api_token) + const host = "server42.verpex.com"; + const user = "testuser"; + const token = "ABCDEF0123456789ABCDEF0123456789"; + try std.testing.expectEqual(@as(c_int, 0), cloud_verpex_set_credentials( + slot, + host.ptr, + host.len, + user.ptr, + user.len, + token.ptr, + token.len, + )); + + // Verify credential storage + const idx: usize = @intCast(slot); + try std.testing.expectEqual(@as(usize, host.len), verpex_hostname_lens[idx]); + try std.testing.expectEqualSlices(u8, host, verpex_hostnames[idx][0..verpex_hostname_lens[idx]]); + try std.testing.expectEqual(@as(usize, user.len), verpex_username_lens[idx]); + try std.testing.expectEqualSlices(u8, user, verpex_usernames[idx][0..verpex_username_lens[idx]]); + + try std.testing.expectEqual(@as(c_int, 0), cloud_logout(slot)); +} + +test "verpex domain and dns operations" { + cloud_reset(); + const slot = cloud_authenticate(@intFromEnum(CloudProvider.verpex)); + try std.testing.expect(slot >= 0); + const host = "server42.verpex.com"; + const user = "testuser"; + const token = "ABCDEF0123456789"; + _ = cloud_verpex_set_credentials(slot, host.ptr, host.len, user.ptr, user.len, token.ptr, token.len); + + // list_domains + try std.testing.expectEqual(@as(c_int, 0), cloud_verpex_list_domains(slot)); + var buf: [RESULT_BUF_SIZE]u8 = undefined; + var rlen = cloud_verpex_read_result(slot, &buf, RESULT_BUF_SIZE); + try std.testing.expect(rlen > 0); + try std.testing.expect(std.mem.indexOf(u8, buf[0..@intCast(rlen)], "\"provider\":\"verpex\"") != null); + try std.testing.expect(std.mem.indexOf(u8, buf[0..@intCast(rlen)], "\"cpanel_module\":\"DomainInfo\"") != null); + + // list_dns + const domain = "example.com"; + try std.testing.expectEqual(@as(c_int, 0), cloud_verpex_list_dns(slot, domain.ptr, domain.len)); + rlen = cloud_verpex_read_result(slot, &buf, RESULT_BUF_SIZE); + try std.testing.expect(std.mem.indexOf(u8, buf[0..@intCast(rlen)], "\"cpanel_module\":\"DNS\"") != null); + + // add_dns + const add_json = "{\"zone\":\"example.com\",\"type\":\"A\",\"name\":\"test\",\"address\":\"192.0.2.1\"}"; + try std.testing.expectEqual(@as(c_int, 0), cloud_verpex_add_dns(slot, add_json.ptr, add_json.len)); + + // remove_dns + const rm_json = "{\"zone\":\"example.com\",\"line\":42}"; + try std.testing.expectEqual(@as(c_int, 0), cloud_verpex_remove_dns(slot, rm_json.ptr, rm_json.len)); + + _ = cloud_logout(slot); +} + +test "verpex email and database operations" { + cloud_reset(); + const slot = cloud_authenticate(@intFromEnum(CloudProvider.verpex)); + try std.testing.expect(slot >= 0); + const host = "server42.verpex.com"; + const user = "testuser"; + const token = "ABCDEF0123456789"; + _ = cloud_verpex_set_credentials(slot, host.ptr, host.len, user.ptr, user.len, token.ptr, token.len); + + // list_email + try std.testing.expectEqual(@as(c_int, 0), cloud_verpex_list_email(slot)); + var buf: [RESULT_BUF_SIZE]u8 = undefined; + var rlen = cloud_verpex_read_result(slot, &buf, RESULT_BUF_SIZE); + try std.testing.expect(std.mem.indexOf(u8, buf[0..@intCast(rlen)], "\"cpanel_module\":\"Email\"") != null); + + // create_email + const email_json = "{\"email\":\"info@example.com\",\"password\":\"s3cure!\",\"quota\":1024}"; + try std.testing.expectEqual(@as(c_int, 0), cloud_verpex_create_email(slot, email_json.ptr, email_json.len)); + + // list_databases + try std.testing.expectEqual(@as(c_int, 0), cloud_verpex_list_databases(slot)); + rlen = cloud_verpex_read_result(slot, &buf, RESULT_BUF_SIZE); + try std.testing.expect(std.mem.indexOf(u8, buf[0..@intCast(rlen)], "\"cpanel_module\":\"Mysql\"") != null); + + // create_database + const db_name = "testuser_mydb"; + try std.testing.expectEqual(@as(c_int, 0), cloud_verpex_create_database(slot, db_name.ptr, db_name.len)); + + // ssl_status + const domain = "example.com"; + try std.testing.expectEqual(@as(c_int, 0), cloud_verpex_ssl_status(slot, domain.ptr, domain.len)); + rlen = cloud_verpex_read_result(slot, &buf, RESULT_BUF_SIZE); + try std.testing.expect(std.mem.indexOf(u8, buf[0..@intCast(rlen)], "\"cpanel_module\":\"SSL\"") != null); + + // list_cron + try std.testing.expectEqual(@as(c_int, 0), cloud_verpex_list_cron(slot)); + + // metrics + try std.testing.expectEqual(@as(c_int, 0), cloud_verpex_metrics(slot)); + rlen = cloud_verpex_read_result(slot, &buf, RESULT_BUF_SIZE); + try std.testing.expect(std.mem.indexOf(u8, buf[0..@intCast(rlen)], "\"cpanel_module\":\"ResourceUsage\"") != null); + + _ = cloud_logout(slot); +} + +test "verpex wrong-provider rejection" { + cloud_reset(); + // Authenticate as AWS, not Verpex β€” all Verpex operations should fail + const slot = cloud_authenticate(@intFromEnum(CloudProvider.aws)); + try std.testing.expect(slot >= 0); + + const host = "server42.verpex.com"; + const user = "testuser"; + const token = "ABCDEF0123456789"; + try std.testing.expectEqual(@as(c_int, -1), cloud_verpex_set_credentials( + slot, + host.ptr, + host.len, + user.ptr, + user.len, + token.ptr, + token.len, + )); + + try std.testing.expectEqual(@as(c_int, -1), cloud_verpex_list_domains(slot)); + const domain = "example.com"; + try std.testing.expectEqual(@as(c_int, -1), cloud_verpex_list_dns(slot, domain.ptr, domain.len)); + + const json_data = "{}"; + try std.testing.expectEqual(@as(c_int, -1), cloud_verpex_add_dns(slot, json_data.ptr, json_data.len)); + try std.testing.expectEqual(@as(c_int, -1), cloud_verpex_remove_dns(slot, json_data.ptr, json_data.len)); + try std.testing.expectEqual(@as(c_int, -1), cloud_verpex_list_email(slot)); + try std.testing.expectEqual(@as(c_int, -1), cloud_verpex_create_email(slot, json_data.ptr, json_data.len)); + try std.testing.expectEqual(@as(c_int, -1), cloud_verpex_list_databases(slot)); + const name = "testdb"; + try std.testing.expectEqual(@as(c_int, -1), cloud_verpex_create_database(slot, name.ptr, name.len)); + try std.testing.expectEqual(@as(c_int, -1), cloud_verpex_ssl_status(slot, domain.ptr, domain.len)); + try std.testing.expectEqual(@as(c_int, -1), cloud_verpex_list_cron(slot)); + try std.testing.expectEqual(@as(c_int, -1), cloud_verpex_metrics(slot)); + + _ = cloud_logout(slot); +} + +// ═══════════════════════════════════════════════════════════════════════ +// ADR-0006 invoke dispatch tests +// ═══════════════════════════════════════════════════════════════════════ + +test "invoke: each declared tool succeeds" { + var buf: [256]u8 = undefined; + const tools = [_][]const u8{ + "cloud_authenticate", + "cloud_logout", + "cloud_state", + "cloud_execute", + }; + for (tools) |t| { + var len: usize = buf.len; + const rc = boj_cartridge_invoke(t.ptr, "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, 0), rc); + try std.testing.expect(std.mem.indexOf(u8, buf[0..len], "result") != null); + } +} + +test "invoke: unknown tool returns -1" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("nope", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -1), rc); +} + +test "invoke: buffer too small returns -3" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("cloud_authenticate", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -3), rc); + try std.testing.expect(len > 4); +} diff --git a/cartridges/domains/cloud/cloud-mcp/mod.js b/cartridges/domains/cloud/cloud-mcp/mod.js new file mode 100644 index 0000000..a541647 --- /dev/null +++ b/cartridges/domains/cloud/cloud-mcp/mod.js @@ -0,0 +1,55 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// cloud-mcp/mod.js β€” Multi-cloud provider session manager (AWS/GCP/Azure/DO/Vercel) +// +// Delegates to backend at http://127.0.0.1:7715 (override with CLOUD_BACKEND_URL). + +const BASE_URL = Deno.env.get("CLOUD_BACKEND_URL") ?? "http://127.0.0.1:7715"; +const TIMEOUT_MS = 15_000; + +async function post(path, payload) { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), TIMEOUT_MS); + try { + const r = await fetch(`${BASE_URL}${path}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + signal: ctrl.signal, + }); + const data = await r.json().catch(() => ({ success: false, error: "non-JSON response" })); + return { status: r.status, data }; + } catch (e) { + if (e.name === "AbortError") return { status: 504, data: { success: false, error: "cloud-mcp backend timed out" } }; + return { status: 503, data: { success: false, error: `cloud-mcp backend unavailable: ${e.message}` } }; + } finally { clearTimeout(t); } +} + +async function get(path) { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), TIMEOUT_MS); + try { + const r = await fetch(`${BASE_URL}${path}`, { method: "GET", signal: ctrl.signal }); + const data = await r.json().catch(() => ({ success: false, error: "non-JSON response" })); + return { status: r.status, data }; + } catch (e) { + if (e.name === "AbortError") return { status: 504, data: { success: false, error: "cloud-mcp backend timed out" } }; + return { status: 503, data: { success: false, error: `cloud-mcp backend unavailable: ${e.message}` } }; + } finally { clearTimeout(t); } +} + +export async function handleTool(toolName, args) { + switch (toolName) { + case "cloud_authenticate": + return post("/api/v1/cloud_authenticate", args ?? {}); + case "cloud_logout": + return post("/api/v1/cloud_logout", args ?? {}); + case "cloud_state": + return post("/api/v1/cloud_state", args ?? {}); + case "cloud_execute": + return post("/api/v1/cloud_execute", args ?? {}); + default: + return { status: 404, data: { error: `Unknown tool: ${toolName}` } }; + } +} diff --git a/cartridges/domains/cloud/cloud-mcp/panels/manifest.json b/cartridges/domains/cloud/cloud-mcp/panels/manifest.json new file mode 100644 index 0000000..2d121b2 --- /dev/null +++ b/cartridges/domains/cloud/cloud-mcp/panels/manifest.json @@ -0,0 +1,65 @@ +{ + "$schema": "https://panll.dev/schemas/panel-manifest/v1.json", + "spdx": "MPL-2.0", + "cartridge": "cloud-mcp", + "domain": "Cloud Providers", + "version": "0.1.0", + "panels": [ + { + "id": "cloud-status", + "title": "Cloud Provider Status", + "description": "Aggregate health across configured cloud providers (Cloudflare, Vercel, Verpex, etc.)", + "type": "status-indicator", + "data_source": { + "endpoint": "/cartridge/cloud-mcp/invoke", + "method": "POST", + "body": { "tool": "status" }, + "refresh_interval_ms": 5000 + }, + "widgets": [ + { + "type": "state-badge", + "field": "state", + "states": { + "ready": { "color": "#2ecc71", "icon": "cloud" }, + "degraded": { "color": "#f39c12", "icon": "cloud-off" }, + "error": { "color": "#e74c3c", "icon": "alert-triangle" } + } + }, + { "type": "text", "field": "version", "label": "Version" } + ] + }, + { + "id": "cloud-providers", + "title": "Provider Connections", + "description": "Connection status for each configured cloud provider", + "type": "metric", + "data_source": { + "endpoint": "/cartridge/cloud-mcp/invoke", + "method": "POST", + "body": { "tool": "providers" }, + "refresh_interval_ms": 15000 + }, + "widgets": [ + { "type": "counter", "field": "connected_providers", "label": "Connected", "icon": "link" }, + { "type": "counter", "field": "total_providers", "label": "Total Configured", "icon": "cloud" }, + { "type": "counter", "field": "active_zones", "label": "Active Zones", "icon": "globe" } + ] + }, + { + "id": "cloud-deploy-history", + "title": "Recent Deployments", + "description": "Last 10 deployments across all cloud providers with status", + "type": "table", + "data_source": { + "endpoint": "/cartridge/cloud-mcp/invoke", + "method": "POST", + "body": { "tool": "recent_deploys" }, + "refresh_interval_ms": 30000 + }, + "widgets": [ + { "type": "table", "field": "deployments", "columns": ["provider", "project", "status", "timestamp"] } + ] + } + ] +} diff --git a/cartridges/domains/cloud/cloudflare-mcp/README.adoc b/cartridges/domains/cloud/cloudflare-mcp/README.adoc new file mode 100644 index 0000000..48fc07e --- /dev/null +++ b/cartridges/domains/cloud/cloudflare-mcp/README.adoc @@ -0,0 +1,89 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += cloudflare-mcp +Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk> +:spdx: MPL-2.0 +:tier: Ayo +:domain: Cloud +:protocols: MCP, REST + +== Overview + +Cloudflare API v4 cartridge for BoJ. Provides type-safe access to DNS record +management, zone settings, and cache purge via the Cloudflare REST API at +`https://api.cloudflare.com/client/v4/`. + +Authentication uses a scoped API token (`CF_API_TOKEN`) with at minimum: +`Zone:DNS:Edit` and `Zone:Settings:Edit` permissions. + +== Key Design Notes + +=== IPv6 and the proxy flag + +A DNS record with `proxied: true` (orange cloud) causes Cloudflare to terminate +connections at its edge. End users see Cloudflare's own IPv4 *and* IPv6 +addresses regardless of what the origin record type is. An `A` record pointing +to a Fly.io shared IPv4 with `proxied: true` gives full IPv6 to end users. +An `AAAA` record is only required when the record is *unproxied* (grey cloud). + +=== Fly.io custom cert workflow + +Fly.io issues TLS certificates via ACME HTTP-01 challenge. The challenge +requires direct HTTP access to the hostname. Procedure: + +. Add DNS record with `proxied: false` (grey cloud). +. Run `flyctl certs add <hostname> --app <app>`. +. Poll `flyctl certs check <hostname> --app <app>` until `Status = Issued`. +. Use `cf_patch_dns_record` to set `proxied: true`. +. Set SSL/TLS mode to `full_strict` via `cf_update_zone_setting`. + +== Actions + +ListZones, GetZone, ListDnsRecords, GetDnsRecord, CreateDnsRecord, +UpdateDnsRecord, PatchDnsRecord, DeleteDnsRecord, GetZoneSetting, +UpdateZoneSetting, PurgeCache. + +== Architecture + +[cols="1,1,2"] +|=== +| Layer | Language | Purpose + +| ABI +| Idris2 +| State machine with dependent-type proofs; `proxiedProvidesIPv6` theorem + +| FFI +| Zig +| C-ABI session pool, state transition guard, proxy type checks + +| Adapter +| Gleam +| MCP protocol routing to mod.js handlers + +| Implementation +| Deno JS +| Cloudflare API v4 HTTP calls with structured error handling +|=== + +== Required Token Permissions + +[cols="1,1"] +|=== +| Permission | Required for + +| Zone:DNS:Edit +| All DNS record operations + +| Zone:Settings:Edit +| `cf_update_zone_setting`, `cf_purge_cache` + +| Zone:DNS:Read +| Read-only operations (`cf_list_*`, `cf_get_*`) +|=== + +Create tokens at: `https://dash.cloudflare.com/profile/api-tokens` + +== Environment + +`CF_API_TOKEN` β€” Cloudflare API token. In production, provided by vault-mcp. diff --git a/cartridges/domains/cloud/cloudflare-mcp/abi/CloudflareMcp/SafeCloud.idr b/cartridges/domains/cloud/cloudflare-mcp/abi/CloudflareMcp/SafeCloud.idr new file mode 100644 index 0000000..78190f9 --- /dev/null +++ b/cartridges/domains/cloud/cloudflare-mcp/abi/CloudflareMcp/SafeCloud.idr @@ -0,0 +1,122 @@ +-- SPDX-License-Identifier: MPL-2.0 +-- Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +-- +-- CloudflareMcp.SafeCloud -- Type-safe ABI for cloudflare-mcp cartridge. +-- +-- State machine with dependent-type proofs ensuring only valid transitions +-- can occur at the FFI boundary. Auth: Bearer token (CF_API_TOKEN). +-- API: https://api.cloudflare.com/client/v4/ + +module CloudflareMcp.SafeCloud + +%default total + +-- --------------------------------------------------------------------------- +-- Session state machine +-- --------------------------------------------------------------------------- + +||| Authentication and rate-limit state for Cloudflare API operations. +public export +data SessionState + = Unauthenticated + | Authenticated + | RateLimited + | Error + +||| Proof that a state transition is valid. +public export +data ValidTransition : SessionState -> SessionState -> Type where + Authenticate : ValidTransition Unauthenticated Authenticated + BeginRateLimit : ValidTransition Authenticated RateLimited + EndRateLimit : ValidTransition RateLimited Authenticated + AuthError : ValidTransition Unauthenticated Error + OpError : ValidTransition Authenticated Error + RateError : ValidTransition RateLimited Error + RecoverAuth : ValidTransition Error Unauthenticated + Deauthenticate : ValidTransition Authenticated Unauthenticated + +export +sessionStateToInt : SessionState -> Int +sessionStateToInt Unauthenticated = 0 +sessionStateToInt Authenticated = 1 +sessionStateToInt RateLimited = 2 +sessionStateToInt Error = 3 + +export +intToSessionState : Int -> Maybe SessionState +intToSessionState 0 = Just Unauthenticated +intToSessionState 1 = Just Authenticated +intToSessionState 2 = Just RateLimited +intToSessionState 3 = Just Error +intToSessionState _ = Nothing + +-- --------------------------------------------------------------------------- +-- Action codes +-- --------------------------------------------------------------------------- + +||| All Cloudflare API actions exposed by this cartridge. +public export +data CloudflareAction + = ListZones + | GetZone + | ListDnsRecords + | GetDnsRecord + | CreateDnsRecord + | UpdateDnsRecord + | PatchDnsRecord + | DeleteDnsRecord + | GetZoneSetting + | UpdateZoneSetting + | PurgeCache + +export +actionToInt : CloudflareAction -> Int +actionToInt ListZones = 0 +actionToInt GetZone = 1 +actionToInt ListDnsRecords = 2 +actionToInt GetDnsRecord = 3 +actionToInt CreateDnsRecord = 4 +actionToInt UpdateDnsRecord = 5 +actionToInt PatchDnsRecord = 6 +actionToInt DeleteDnsRecord = 7 +actionToInt GetZoneSetting = 8 +actionToInt UpdateZoneSetting = 9 +actionToInt PurgeCache = 10 + +-- --------------------------------------------------------------------------- +-- Proof: only Authenticated sessions may perform actions +-- --------------------------------------------------------------------------- + +||| Proof that a given state permits API actions. +public export +data CanPerformAction : SessionState -> Type where + AuthOk : CanPerformAction Authenticated + +||| Safely perform an action, requiring an Authenticated state proof. +export +performAction : (s : SessionState) + -> CanPerformAction s + -> CloudflareAction + -> IO Int -- returns HTTP status code +performAction Authenticated AuthOk action = + pure (actionToInt action) -- FFI fills real HTTP call + +-- --------------------------------------------------------------------------- +-- DNS record proxy constraint +-- --------------------------------------------------------------------------- + +||| DNS record types that support Cloudflare proxying (orange cloud). +public export +data ProxyableType = ARecord | AAAARecord | CNAMERecord + +||| Proof that a record type is proxyable. +export +isProxyable : ProxyableType -> Bool +isProxyable _ = True -- all three support proxying + +||| When proxied, IPv6 is provided by Cloudflare's edge regardless of record type. +||| An A record with proxied=True gives full IPv6 to end users. +export +proxiedProvidesIPv6 : (t : ProxyableType) -> (proxied : Bool) -> Bool +proxiedProvidesIPv6 _ True = True +proxiedProvidesIPv6 _ False = False diff --git a/cartridges/domains/cloud/cloudflare-mcp/abi/README.adoc b/cartridges/domains/cloud/cloudflare-mcp/abi/README.adoc new file mode 100644 index 0000000..36c97a0 --- /dev/null +++ b/cartridges/domains/cloud/cloudflare-mcp/abi/README.adoc @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += cloudflare-mcp / abi β€” Idris2 ABI layer +:orientation: shallow + +== Purpose + +Cartridge-local protocol types for `cloudflare-mcp`. Defines the data, tool, and +transition vocabulary this cartridge speaks. Inherits safety lemmas and the +matrix-cell identifier scheme from the trunk ABI at `src/abi/`. + +== Files + +1 Idris2 module(s), ~122 lines total. Lead module: +`SafeCloud.idr`. + +No `.ipkg` β€” consumed directly by wider BoJ callers; type-checks inside the +trunk `boj.ipkg`. + +== Invariants + +* `%default total` at every module top. +* Zero `believe_me`, `assert_total`, `postulate`, `Admitted`, or + `sorry` (the trunk holds the four declared `believe_me` sites; leaf + cartridges carry none). + +== Test/proof surface + +Thin module(s), no inline tests. Proof surface = type-check success under the +trunk `idris2 --check`. + +== Read-first + +. `SafeCloud.idr` β€” cartridge-local data records and enums. +. Trunk `src/abi/Boj/Catalogue.idr` for how this cell participates in the + 2D matrix. diff --git a/cartridges/domains/cloud/cloudflare-mcp/abi/cloudflare_mcp.ipkg b/cartridges/domains/cloud/cloudflare-mcp/abi/cloudflare_mcp.ipkg new file mode 100644 index 0000000..a9d824a --- /dev/null +++ b/cartridges/domains/cloud/cloudflare-mcp/abi/cloudflare_mcp.ipkg @@ -0,0 +1,9 @@ +-- SPDX-License-Identifier: MPL-2.0 +package cloudflare_mcp + +authors = "Jonathan D.A. Jewell" +version = "0.1.0" +brief = "Cloudflare API v4 ABI for cloudflare-mcp cartridge" + +sourcedir = "." +modules = CloudflareMcp.SafeCloud diff --git a/cartridges/domains/cloud/cloudflare-mcp/adapter/cloudflare_mcp_adapter.gleam b/cartridges/domains/cloud/cloudflare-mcp/adapter/cloudflare_mcp_adapter.gleam new file mode 100644 index 0000000..d2fc57a --- /dev/null +++ b/cartridges/domains/cloud/cloudflare-mcp/adapter/cloudflare_mcp_adapter.gleam @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// cloudflare_mcp_adapter.gleam -- Gleam adapter bridging BoJ MCP protocol +// to the cloudflare-mcp mod.js tool handlers. +// +// Routes incoming MCP tool-call messages to the correct handler and +// wraps results in standard BoJ response envelopes. + +import gleam/json +import gleam/result +import gleam/string + +pub type McpRequest { + McpRequest(tool: String, args: json.Json) +} + +pub type McpResponse { + McpResponse(success: Bool, result: json.Json, error: Option(String)) +} + +pub fn route(req: McpRequest) -> McpResponse { + case req.tool { + "cf_list_zones" + | "cf_get_zone" + | "cf_list_dns_records" + | "cf_get_dns_record" + | "cf_create_dns_record" + | "cf_update_dns_record" + | "cf_patch_dns_record" + | "cf_delete_dns_record" + | "cf_get_zone_setting" + | "cf_update_zone_setting" + | "cf_purge_cache" -> + McpResponse( + success: True, + result: json.string("dispatched to mod.js handler: " <> req.tool), + error: None, + ) + unknown -> + McpResponse( + success: False, + result: json.null(), + error: Some("Unknown tool: " <> unknown), + ) + } +} diff --git a/cartridges/domains/cloud/cloudflare-mcp/cartridge.json b/cartridges/domains/cloud/cloudflare-mcp/cartridge.json new file mode 100644 index 0000000..6f073cf --- /dev/null +++ b/cartridges/domains/cloud/cloudflare-mcp/cartridge.json @@ -0,0 +1,364 @@ +{ + "$schema": "https://boj.dev/schemas/cartridge/v1.json", + "spdx": "MPL-2.0", + "copyright": "Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath)", + "name": "cloudflare-mcp", + "version": "0.1.0", + "description": "Cloudflare API v4 cartridge -- DNS record management, zone settings, and SSL/TLS configuration", + "domain": "Cloud", + "tier": "Ayo", + "protocols": [ + "MCP", + "REST" + ], + "auth": { + "method": "bearer_token", + "env_var": "CF_API_TOKEN", + "credential_source": "vault-mcp" + }, + "api": { + "base_url": "https://api.cloudflare.com/client/v4", + "content_type": "application/json" + }, + "tools": [ + { + "name": "cf_list_zones", + "description": "List all zones in the Cloudflare account, optionally filtered by name", + "inputSchema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Filter by exact zone name (e.g. nesy-prover.dev)" + }, + "status": { + "type": "string", + "description": "Filter by status: active, pending, initialising, moved, deleted (default: active)" + }, + "page": { + "type": "number", + "description": "Page number (default 1)" + }, + "per_page": { + "type": "number", + "description": "Results per page, max 50 (default 20)" + } + } + } + }, + { + "name": "cf_get_zone", + "description": "Get details of a specific Cloudflare zone by ID", + "inputSchema": { + "type": "object", + "properties": { + "zone_id": { + "type": "string", + "description": "Zone ID (32-char hex)" + } + }, + "required": [ + "zone_id" + ] + } + }, + { + "name": "cf_list_dns_records", + "description": "List DNS records in a zone, optionally filtered by type or name", + "inputSchema": { + "type": "object", + "properties": { + "zone_id": { + "type": "string", + "description": "Zone ID" + }, + "type": { + "type": "string", + "description": "Record type filter: A, AAAA, CNAME, TXT, MX, NS, SRV, CAA" + }, + "name": { + "type": "string", + "description": "Name filter (e.g. solve.nesy-prover.dev)" + }, + "content": { + "type": "string", + "description": "Content filter (e.g. IP address)" + }, + "proxied": { + "type": "boolean", + "description": "Filter by proxy status" + }, + "page": { + "type": "number", + "description": "Page number (default 1)" + }, + "per_page": { + "type": "number", + "description": "Results per page, max 100 (default 20)" + } + }, + "required": [ + "zone_id" + ] + } + }, + { + "name": "cf_get_dns_record", + "description": "Get a specific DNS record by ID", + "inputSchema": { + "type": "object", + "properties": { + "zone_id": { + "type": "string", + "description": "Zone ID" + }, + "record_id": { + "type": "string", + "description": "DNS record ID" + } + }, + "required": [ + "zone_id", + "record_id" + ] + } + }, + { + "name": "cf_create_dns_record", + "description": "Create a DNS record in a zone. For proxied records, Cloudflare provides IPv6 regardless of record type.", + "inputSchema": { + "type": "object", + "properties": { + "zone_id": { + "type": "string", + "description": "Zone ID" + }, + "type": { + "type": "string", + "description": "Record type: A, AAAA, CNAME, TXT, MX, NS, SRV, CAA" + }, + "name": { + "type": "string", + "description": "Record name (e.g. solve or @ for apex)" + }, + "content": { + "type": "string", + "description": "Record content (IP address, hostname, or text value)" + }, + "ttl": { + "type": "number", + "description": "TTL in seconds (1 = automatic when proxied, min 60 otherwise)" + }, + "proxied": { + "type": "boolean", + "description": "Whether to proxy through Cloudflare (orange cloud). Provides DDoS protection, IPv6, and TLS termination." + }, + "priority": { + "type": "number", + "description": "MX/SRV record priority" + }, + "comment": { + "type": "string", + "description": "Optional comment for the record" + } + }, + "required": [ + "zone_id", + "type", + "name", + "content" + ] + } + }, + { + "name": "cf_update_dns_record", + "description": "Update a DNS record (full replace). Use cf_patch_dns_record to update individual fields.", + "inputSchema": { + "type": "object", + "properties": { + "zone_id": { + "type": "string", + "description": "Zone ID" + }, + "record_id": { + "type": "string", + "description": "DNS record ID" + }, + "type": { + "type": "string", + "description": "Record type: A, AAAA, CNAME, TXT, MX, NS, SRV, CAA" + }, + "name": { + "type": "string", + "description": "Record name" + }, + "content": { + "type": "string", + "description": "Record content" + }, + "ttl": { + "type": "number", + "description": "TTL in seconds (1 = automatic)" + }, + "proxied": { + "type": "boolean", + "description": "Whether to proxy through Cloudflare" + }, + "comment": { + "type": "string", + "description": "Optional comment" + } + }, + "required": [ + "zone_id", + "record_id", + "type", + "name", + "content" + ] + } + }, + { + "name": "cf_patch_dns_record", + "description": "Partially update a DNS record β€” change only the specified fields (e.g. toggle proxied without changing content)", + "inputSchema": { + "type": "object", + "properties": { + "zone_id": { + "type": "string", + "description": "Zone ID" + }, + "record_id": { + "type": "string", + "description": "DNS record ID" + }, + "proxied": { + "type": "boolean", + "description": "Toggle Cloudflare proxy (orange/grey cloud)" + }, + "content": { + "type": "string", + "description": "New record content" + }, + "ttl": { + "type": "number", + "description": "New TTL" + }, + "comment": { + "type": "string", + "description": "New comment" + } + }, + "required": [ + "zone_id", + "record_id" + ] + } + }, + { + "name": "cf_delete_dns_record", + "description": "Delete a DNS record from a zone (irreversible)", + "inputSchema": { + "type": "object", + "properties": { + "zone_id": { + "type": "string", + "description": "Zone ID" + }, + "record_id": { + "type": "string", + "description": "DNS record ID to delete" + } + }, + "required": [ + "zone_id", + "record_id" + ] + } + }, + { + "name": "cf_get_zone_setting", + "description": "Get a specific zone setting (e.g. ssl, min_tls_version, always_use_https, ipv6)", + "inputSchema": { + "type": "object", + "properties": { + "zone_id": { + "type": "string", + "description": "Zone ID" + }, + "setting_id": { + "type": "string", + "description": "Setting name: ssl, min_tls_version, always_use_https, ipv6, http3, brotli, minify, security_level, cache_level" + } + }, + "required": [ + "zone_id", + "setting_id" + ] + } + }, + { + "name": "cf_update_zone_setting", + "description": "Update a zone setting (e.g. set SSL mode to full_strict, enable always_use_https)", + "inputSchema": { + "type": "object", + "properties": { + "zone_id": { + "type": "string", + "description": "Zone ID" + }, + "setting_id": { + "type": "string", + "description": "Setting name (e.g. ssl, always_use_https, min_tls_version)" + }, + "value": { + "description": "New value. ssl: off|flexible|full|strict. always_use_https: on|off. min_tls_version: 1.0|1.1|1.2|1.3." + } + }, + "required": [ + "zone_id", + "setting_id", + "value" + ] + } + }, + { + "name": "cf_purge_cache", + "description": "Purge cached files from Cloudflare's edge for a zone", + "inputSchema": { + "type": "object", + "properties": { + "zone_id": { + "type": "string", + "description": "Zone ID" + }, + "purge_everything": { + "type": "boolean", + "description": "Purge all cached files (use with caution)" + }, + "files": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Specific URLs to purge (alternative to purge_everything)" + } + }, + "required": [ + "zone_id" + ] + } + } + ], + "ffi": { + "so_path": "ffi/zig-out/lib/libcloudflare_mcp_ffi.so", + "abi_version": "ADR-0006", + "symbols": [ + "boj_cartridge_init", + "boj_cartridge_deinit", + "boj_cartridge_name", + "boj_cartridge_version", + "boj_cartridge_invoke" + ] + } +} diff --git a/cartridges/domains/cloud/cloudflare-mcp/ffi/README.adoc b/cartridges/domains/cloud/cloudflare-mcp/ffi/README.adoc new file mode 100644 index 0000000..8a64a43 --- /dev/null +++ b/cartridges/domains/cloud/cloudflare-mcp/ffi/README.adoc @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += cloudflare-mcp / ffi β€” Zig FFI layer +:orientation: shallow + +== Purpose + +C-ABI exports bridging this cartridge's Idris2 ABI to the runtime. Consumed by +the adapter for protocol dispatch and by the trunk loader for mount. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” produces `zig-out/lib/libcloudflare.so`. +| `cloudflare_mcp_ffi.zig` | C-ABI exports (6 exports, 0 +0 inline tests, 138 lines). +|=== + +== Invariants + +* **Null-pointer rejection** on `[*c]const u8` arguments before any + dereference. +* **Negative return values** carry documented error meanings; zero is + success (matching the trunk error-code convention). +* **No allocator leaks** β€” every allocation path pairs with `defer free`. + +== Test/proof surface + +0 +0 inline `test "..."` block(s) in `cloudflare_mcp_ffi.zig`. Run with +`cd ffi && zig build test`. + +== Read-first + +. `cloudflare_mcp_ffi.zig` β€” C-ABI signatures and guards. +. Trunk `ffi/zig/src/catalogue.zig` for how this `.so` is loaded. diff --git a/cartridges/domains/cloud/cloudflare-mcp/ffi/build.zig b/cartridges/domains/cloud/cloudflare-mcp/ffi/build.zig new file mode 100644 index 0000000..36a4de6 --- /dev/null +++ b/cartridges/domains/cloud/cloudflare-mcp/ffi/build.zig @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + // Shared ADR-0006 invoke-shim module (relative path up to boj-server trunk). + + const shim_mod = b.addModule("cartridge_shim", .{ + + .root_source_file = b.path("../../../ffi/zig/src/cartridge_shim.zig"), + + .target = target, + + .optimize = optimize, + + }); + + const ffi_mod = b.addModule("cloudflare_mcp_ffi", .{ + .root_source_file = b.path("cloudflare_mcp_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + ffi_mod.addImport("cartridge_shim", shim_mod); + const lib = b.addLibrary(.{ + .name = "cloudflare_mcp_ffi", + .root_module = ffi_mod, + .linkage = .dynamic, + }); + + b.installArtifact(lib); + + const unit_tests = b.addTest(.{ + .root_module = ffi_mod, + }); + const run_tests = b.addRunArtifact(unit_tests); + const test_step = b.step("test", "Run FFI unit tests"); + test_step.dependOn(&run_tests.step); +} diff --git a/cartridges/domains/cloud/cloudflare-mcp/ffi/cartridge_shim.zig b/cartridges/domains/cloud/cloudflare-mcp/ffi/cartridge_shim.zig new file mode 100644 index 0000000..0e399db --- /dev/null +++ b/cartridges/domains/cloud/cloudflare-mcp/ffi/cartridge_shim.zig @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// cartridge_shim.zig β€” Shared helpers for the ADR-0006 five-symbol +// cartridge ABI (`boj_cartridge_init / deinit / name / version / invoke`). +// +// The shim centralises the seven-code return convention, NUL-argument +// guards, tool-name comparison, and the buffer-too-small path so each +// cartridge's `boj_cartridge_invoke` can stay short β€” typically a tool +// table plus `shim.writeResult(...)`. +// +// Cartridges import this file by relative path (no build-graph change +// needed). Example: +// +// const shim = @import("../../../ffi/zig/src/cartridge_shim.zig"); +// +// export fn boj_cartridge_invoke( +// tool_name: [*c]const u8, +// json_args: [*c]const u8, +// out_buf: [*c]u8, +// in_out_len: [*c]usize, +// ) callconv(.c) i32 { +// _ = json_args; +// if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; +// const body = if (shim.toolIs(tool_name, "foo")) "{\"result\":{}}" +// else return shim.RC_UNKNOWN_TOOL; +// return shim.writeResult(out_buf, in_out_len, body); +// } + +const std = @import("std"); + +// ── Return codes (ADR-0006 Β§Return codes) ──────────────────────────── +// +// Frozen by ADR-0006. New failure modes compose these via the error +// JSON body β€” the integer surface does not grow without a follow-up ADR. + +pub const RC_SUCCESS: i32 = 0; +pub const RC_UNKNOWN_TOOL: i32 = -1; +pub const RC_BAD_ARGS: i32 = -2; +pub const RC_BUFFER_TOO_SMALL: i32 = -3; +pub const RC_RUNTIME_ERROR: i32 = -4; +pub const RC_PANIC: i32 = -5; +pub const RC_AUTH_DENIED: i32 = -6; + +// ── Invoke-path helpers ────────────────────────────────────────────── + +/// True if any of the three mandatory `boj_cartridge_invoke` output-path +/// pointers is null. Use at the top of every invoke to short-circuit to +/// `RC_BAD_ARGS`. +pub fn invokeArgsNull( + tool_name: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) bool { + return tool_name == null or out_buf == null or in_out_len == null; +} + +/// Compare a C-NUL-terminated tool-name pointer against a Zig string +/// literal. Caller must have already verified `tool_name` is non-null +/// (usually via `invokeArgsNull`). +pub fn toolIs(tool_name: [*c]const u8, expected: []const u8) bool { + const s = std.mem.span(@as([*:0]const u8, @ptrCast(tool_name))); + return std.mem.eql(u8, s, expected); +} + +/// Copy `body` into `out_buf[0..*in_out_len]` (as a capacity) and update +/// `*in_out_len` to the number of bytes written. Returns `RC_SUCCESS`. +/// +/// If `body.len` exceeds the current capacity stored in `*in_out_len`, +/// sets `*in_out_len` to the required size and returns +/// `RC_BUFFER_TOO_SMALL` β€” the caller is then expected to re-allocate +/// and retry, per ADR-0006 Β§Memory ownership. +/// +/// Caller must have already verified that `out_buf` and `in_out_len` +/// are non-null. +pub fn writeResult( + out_buf: [*c]u8, + in_out_len: [*c]usize, + body: []const u8, +) i32 { + const cap = in_out_len.*; + if (body.len > cap) { + in_out_len.* = body.len; + return RC_BUFFER_TOO_SMALL; + } + @memcpy(out_buf[0..body.len], body); + in_out_len.* = body.len; + return RC_SUCCESS; +} + +// ── Tests ──────────────────────────────────────────────────────────── + +test "writeResult: body fits, writes and sets length" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); + try std.testing.expectEqualStrings("hello", buf[0..len]); +} + +test "writeResult: too small returns -3 and sets required length" { + var buf: [2]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_BUFFER_TOO_SMALL, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: exact-fit succeeds" { + var buf: [5]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: empty body" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, ""); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 0), len); +} + +test "toolIs: matches and rejects" { + const name: [*:0]const u8 = "foo"; + try std.testing.expect(toolIs(@ptrCast(name), "foo")); + try std.testing.expect(!toolIs(@ptrCast(name), "bar")); + try std.testing.expect(!toolIs(@ptrCast(name), "foobar")); + try std.testing.expect(!toolIs(@ptrCast(name), "fo")); +} + +test "invokeArgsNull: detects each null slot" { + var buf: [4]u8 = undefined; + var len: usize = 4; + const name: [*:0]const u8 = "x"; + try std.testing.expect(!invokeArgsNull(@ptrCast(name), &buf, &len)); + try std.testing.expect(invokeArgsNull(null, &buf, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), null, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), &buf, null)); +} diff --git a/cartridges/domains/cloud/cloudflare-mcp/ffi/cloudflare_mcp_ffi.zig b/cartridges/domains/cloud/cloudflare-mcp/ffi/cloudflare_mcp_ffi.zig new file mode 100644 index 0000000..f79d40d --- /dev/null +++ b/cartridges/domains/cloud/cloudflare-mcp/ffi/cloudflare_mcp_ffi.zig @@ -0,0 +1,250 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// cloudflare_mcp_ffi.zig -- C-ABI FFI for cloudflare-mcp cartridge. +// +// Implements the state machine defined in CloudflareMcp.SafeCloud (Idris2 ABI). +// Auth: Bearer token (CF_API_TOKEN). Thread-safe via std.Thread.Mutex. + +const std = @import("std"); + +// --------------------------------------------------------------------------- +// State machine (matches Idris2 ABI) +// --------------------------------------------------------------------------- + +pub const SessionState = enum(c_int) { + unauthenticated = 0, + authenticated = 1, + rate_limited = 2, + err = 3, +}; + +pub const CloudflareAction = enum(c_int) { + list_zones = 0, + get_zone = 1, + list_dns_records = 2, + get_dns_record = 3, + create_dns_record = 4, + update_dns_record = 5, + patch_dns_record = 6, + delete_dns_record = 7, + get_zone_setting = 8, + update_zone_setting = 9, + purge_cache = 10, +}; + +fn isValidTransition(from: SessionState, to: SessionState) bool { + return switch (from) { + .unauthenticated => to == .authenticated or to == .err, + .authenticated => to == .rate_limited or to == .err or to == .unauthenticated, + .rate_limited => to == .authenticated or to == .err, + .err => to == .unauthenticated, + }; +} + +// --------------------------------------------------------------------------- +// Session pool (thread-safe, fixed-size) +// --------------------------------------------------------------------------- + +const MAX_SESSIONS: usize = 16; +const TOKEN_BUF_SIZE: usize = 512; + +const SessionSlot = struct { + active: bool = false, + state: SessionState = .unauthenticated, + token: [TOKEN_BUF_SIZE]u8 = std.mem.zeroes([TOKEN_BUF_SIZE]u8), + token_len: usize = 0, +}; + +var session_pool: [MAX_SESSIONS]SessionSlot = undefined; +var pool_mutex: std.Thread.Mutex = .{}; +var pool_initialised: bool = false; + +fn initPool() void { + if (pool_initialised) return; + for (&session_pool) |*slot| slot.* = SessionSlot{}; + pool_initialised = true; +} + +// --------------------------------------------------------------------------- +// Exported C ABI functions +// --------------------------------------------------------------------------- + +/// Allocate a session slot and store the API token. +/// Returns slot index (0-based) or -1 on failure. +export fn cf_session_create(token_ptr: [*c]const u8, token_len: usize) c_int { + pool_mutex.lock(); + defer pool_mutex.unlock(); + initPool(); + + if (token_len == 0 or token_len >= TOKEN_BUF_SIZE) return -1; + + for (&session_pool, 0..) |*slot, i| { + if (!slot.active) { + slot.active = true; + slot.state = .authenticated; + slot.token_len = token_len; + @memcpy(slot.token[0..token_len], token_ptr[0..token_len]); + return @intCast(i); + } + } + return -1; +} + +/// Return the current state of a session slot. +export fn cf_session_state(slot_index: c_int) c_int { + pool_mutex.lock(); + defer pool_mutex.unlock(); + + const i: usize = @intCast(slot_index); + if (i >= MAX_SESSIONS or !session_pool[i].active) return @intFromEnum(SessionState.err); + return @intFromEnum(session_pool[i].state); +} + +/// Transition a session to a new state (validates transition before applying). +export fn cf_session_transition(slot_index: c_int, new_state: c_int) c_int { + pool_mutex.lock(); + defer pool_mutex.unlock(); + + const i: usize = @intCast(slot_index); + if (i >= MAX_SESSIONS or !session_pool[i].active) return -1; + + const from = session_pool[i].state; + const to: SessionState = @enumFromInt(new_state); + + if (!isValidTransition(from, to)) return -1; + session_pool[i].state = to; + return 0; +} + +/// Release a session slot. +export fn cf_session_destroy(slot_index: c_int) void { + pool_mutex.lock(); + defer pool_mutex.unlock(); + + const i: usize = @intCast(slot_index); + if (i < MAX_SESSIONS) session_pool[i] = SessionSlot{}; +} + +/// Check whether a DNS record type supports Cloudflare proxying. +/// Returns 1 if proxyable (A=1, AAAA=2, CNAME=3), 0 otherwise. +export fn cf_record_type_is_proxyable(record_type_int: c_int) c_int { + return if (record_type_int >= 1 and record_type_int <= 3) 1 else 0; +} + +/// Check whether a proxied record provides IPv6 (always true when proxied). +export fn cf_proxied_provides_ipv6(proxied: c_int) c_int { + return if (proxied != 0) 1 else 0; +} + +// ═══════════════════════════════════════════════════════════════════════ +// Standard ABI (ADR-0005 four symbols + ADR-0006 invoke) +// ═══════════════════════════════════════════════════════════════════════ + +const shim = @import("cartridge_shim.zig"); + +const CARTRIDGE_NAME_PTR: [*:0]const u8 = "cloudflare-mcp"; +const CARTRIDGE_VERSION_PTR: [*:0]const u8 = "0.1.0"; + +export fn boj_cartridge_init() callconv(.c) c_int { + return 0; +} + +export fn boj_cartridge_deinit() callconv(.c) void {} + +export fn boj_cartridge_name() callconv(.c) [*:0]const u8 { + return CARTRIDGE_NAME_PTR; +} + +export fn boj_cartridge_version() callconv(.c) [*:0]const u8 { + return CARTRIDGE_VERSION_PTR; +} + +/// Dispatch the cartridge.json MCP tools. Grade D Alpha stubs. +export fn boj_cartridge_invoke( + tool_name: [*c]const u8, + json_args: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) callconv(.c) i32 { + _ = json_args; + if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; + + const body: []const u8 = if (shim.toolIs(tool_name, "cf_list_zones")) + "{\"result\":{\"items\":[],\"count\":0,\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "cf_get_zone")) + "{\"result\":{\"metadata\":{},\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "cf_list_dns_records")) + "{\"result\":{\"items\":[],\"count\":0,\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "cf_get_dns_record")) + "{\"result\":{\"metadata\":{},\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "cf_create_dns_record")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "cf_update_dns_record")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "cf_patch_dns_record")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "cf_delete_dns_record")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "cf_get_zone_setting")) + "{\"result\":{\"metadata\":{},\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "cf_update_zone_setting")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "cf_purge_cache")) + "{\"result\":{\"status\":\"stub\"}}" +else + return shim.RC_UNKNOWN_TOOL; + + return shim.writeResult(out_buf, in_out_len, body); +} + +// ═══════════════════════════════════════════════════════════════════════ +// ADR-0006 invoke dispatch tests +// ═══════════════════════════════════════════════════════════════════════ + +test "boj_cartridge_name returns cloudflare-mcp" { + const n = std.mem.span(boj_cartridge_name()); + try std.testing.expectEqualStrings("cloudflare-mcp", n); +} + +test "boj_cartridge_init returns 0" { + try std.testing.expectEqual(@as(c_int, 0), boj_cartridge_init()); +} + +test "invoke: each declared tool succeeds" { + var buf: [256]u8 = undefined; + const tools = [_][]const u8{ + "cf_list_zones", + "cf_get_zone", + "cf_list_dns_records", + "cf_get_dns_record", + "cf_create_dns_record", + "cf_update_dns_record", + "cf_patch_dns_record", + "cf_delete_dns_record", + "cf_get_zone_setting", + "cf_update_zone_setting", + "cf_purge_cache", + }; + for (tools) |t| { + var len: usize = buf.len; + const rc = boj_cartridge_invoke(t.ptr, "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, 0), rc); + try std.testing.expect(std.mem.indexOf(u8, buf[0..len], "result") != null); + } +} + +test "invoke: unknown tool returns -1" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("nope", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -1), rc); +} + +test "invoke: buffer too small returns -3" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("cf_list_zones", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -3), rc); + try std.testing.expect(len > 4); +} diff --git a/cartridges/domains/cloud/cloudflare-mcp/minter.toml b/cartridges/domains/cloud/cloudflare-mcp/minter.toml new file mode 100644 index 0000000..90eed8f --- /dev/null +++ b/cartridges/domains/cloud/cloudflare-mcp/minter.toml @@ -0,0 +1,34 @@ +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> + +name = "cloudflare-mcp" +description = "Cloudflare API v4 cartridge -- DNS records, zone settings, SSL/TLS, cache purge" +version = "0.1.0" +domain = "Cloud" +protocols = [ + "MCP", + "REST", +] +tier = "Ayo" +backend = "universal" +generate_panel = true + +[auth] +method = "bearer" +token_env = "CF_API_TOKEN" +base_url = "https://api.cloudflare.com/client/v4/" + +[actions] +list = [ + "ListZones", + "GetZone", + "ListDnsRecords", + "GetDnsRecord", + "CreateDnsRecord", + "UpdateDnsRecord", + "PatchDnsRecord", + "DeleteDnsRecord", + "GetZoneSetting", + "UpdateZoneSetting", + "PurgeCache", +] diff --git a/cartridges/domains/cloud/cloudflare-mcp/mod.js b/cartridges/domains/cloud/cloudflare-mcp/mod.js new file mode 100644 index 0000000..c19fb72 --- /dev/null +++ b/cartridges/domains/cloud/cloudflare-mcp/mod.js @@ -0,0 +1,189 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// cloudflare-mcp/mod.js -- Cloudflare API v4 cartridge implementation. +// +// Provides MCP tool handlers for the Cloudflare API v4: +// - Zone management (list, get) +// - DNS record CRUD (list, get, create, update, patch, delete) +// - Zone settings (get, update) -- SSL mode, TLS version, always_use_https, IPv6 +// - Cache purge +// +// Auth: Bearer token via CF_API_TOKEN env var or vault-mcp proxy. +// API docs: https://developers.cloudflare.com/api/ +// +// Key design notes: +// - All responses follow Cloudflare's standard envelope: +// { success: bool, result: T, errors: [...], messages: [...] } +// - proxied=true (orange cloud) enables DDoS protection, IPv6 for any record +// type, and Cloudflare TLS termination. proxied=false (grey cloud) is +// required for Fly.io custom cert ACME validation; switch back to proxied +// after cert issuance. +// - For IPv6 on proxied origins: A record + proxied=true is sufficient. +// No AAAA needed unless the zone is unproxied. +// +// Usage: import { handleTool } from "./mod.js"; + +const CF_API_BASE = "https://api.cloudflare.com/client/v4"; + +// --------------------------------------------------------------------------- +// Auth helper +// --------------------------------------------------------------------------- + +function getToken() { + const token = typeof Deno !== "undefined" + ? Deno.env.get("CF_API_TOKEN") + : process.env.CF_API_TOKEN; + if (!token) { + throw new Error( + "CF_API_TOKEN not set. Create an API token at https://dash.cloudflare.com/profile/api-tokens " + + "with Zone:DNS:Edit and Zone:Settings:Edit permissions." + ); + } + return token; +} + +// --------------------------------------------------------------------------- +// HTTP helpers +// --------------------------------------------------------------------------- + +async function cfFetch(method, path, body) { + const token = getToken(); + const url = `${CF_API_BASE}${path}`; + + const options = { + method, + headers: { + "Authorization": `Bearer ${token}`, + "Content-Type": "application/json", + "User-Agent": "boj-server/cloudflare-mcp/0.1.0", + }, + }; + + if (body !== undefined) { + options.body = JSON.stringify(body); + } + + const resp = await fetch(url, options); + const data = await resp.json(); + + if (!data.success) { + const errs = (data.errors || []).map(e => `[${e.code}] ${e.message}`).join("; "); + throw new Error(`Cloudflare API error: ${errs}`); + } + + return data.result; +} + +function buildQuery(params) { + const entries = Object.entries(params).filter(([, v]) => v !== undefined && v !== null); + if (entries.length === 0) return ""; + return "?" + entries.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`).join("&"); +} + +// --------------------------------------------------------------------------- +// Tool handlers +// --------------------------------------------------------------------------- + +async function cfListZones({ name, status, page, per_page }) { + const q = buildQuery({ name, status: status ?? "active", page, per_page }); + const zones = await cfFetch("GET", `/zones${q}`); + return zones.map(z => ({ + id: z.id, + name: z.name, + status: z.status, + name_servers: z.name_servers, + original_name_servers: z.original_name_servers, + paused: z.paused, + type: z.type, + created_on: z.created_on, + modified_on: z.modified_on, + })); +} + +async function cfGetZone({ zone_id }) { + return cfFetch("GET", `/zones/${zone_id}`); +} + +async function cfListDnsRecords({ zone_id, type, name, content, proxied, page, per_page }) { + const q = buildQuery({ type, name, content, proxied, page, per_page }); + return cfFetch("GET", `/zones/${zone_id}/dns_records${q}`); +} + +async function cfGetDnsRecord({ zone_id, record_id }) { + return cfFetch("GET", `/zones/${zone_id}/dns_records/${record_id}`); +} + +async function cfCreateDnsRecord({ zone_id, type, name, content, ttl, proxied, priority, comment }) { + const body = { + type, + name, + content, + ttl: ttl ?? 1, + proxied: proxied ?? false, + }; + if (priority !== undefined) body.priority = priority; + if (comment !== undefined) body.comment = comment; + return cfFetch("POST", `/zones/${zone_id}/dns_records`, body); +} + +async function cfUpdateDnsRecord({ zone_id, record_id, type, name, content, ttl, proxied, comment }) { + const body = { type, name, content, ttl: ttl ?? 1, proxied: proxied ?? false }; + if (comment !== undefined) body.comment = comment; + return cfFetch("PUT", `/zones/${zone_id}/dns_records/${record_id}`, body); +} + +async function cfPatchDnsRecord({ zone_id, record_id, proxied, content, ttl, comment }) { + const body = {}; + if (proxied !== undefined) body.proxied = proxied; + if (content !== undefined) body.content = content; + if (ttl !== undefined) body.ttl = ttl; + if (comment !== undefined) body.comment = comment; + return cfFetch("PATCH", `/zones/${zone_id}/dns_records/${record_id}`, body); +} + +async function cfDeleteDnsRecord({ zone_id, record_id }) { + return cfFetch("DELETE", `/zones/${zone_id}/dns_records/${record_id}`); +} + +async function cfGetZoneSetting({ zone_id, setting_id }) { + return cfFetch("GET", `/zones/${zone_id}/settings/${setting_id}`); +} + +async function cfUpdateZoneSetting({ zone_id, setting_id, value }) { + return cfFetch("PATCH", `/zones/${zone_id}/settings/${setting_id}`, { value }); +} + +async function cfPurgeCache({ zone_id, purge_everything, files }) { + if (!purge_everything && (!files || files.length === 0)) { + throw new Error("Provide either purge_everything: true or a non-empty files array."); + } + const body = purge_everything ? { purge_everything: true } : { files }; + return cfFetch("POST", `/zones/${zone_id}/purge_cache`, body); +} + +// --------------------------------------------------------------------------- +// Dispatch +// --------------------------------------------------------------------------- + +const HANDLERS = { + cf_list_zones: cfListZones, + cf_get_zone: cfGetZone, + cf_list_dns_records: cfListDnsRecords, + cf_get_dns_record: cfGetDnsRecord, + cf_create_dns_record: cfCreateDnsRecord, + cf_update_dns_record: cfUpdateDnsRecord, + cf_patch_dns_record: cfPatchDnsRecord, + cf_delete_dns_record: cfDeleteDnsRecord, + cf_get_zone_setting: cfGetZoneSetting, + cf_update_zone_setting: cfUpdateZoneSetting, + cf_purge_cache: cfPurgeCache, +}; + +export async function handleTool(name, args) { + const handler = HANDLERS[name]; + if (!handler) { + throw new Error(`Unknown tool: ${name}. Available: ${Object.keys(HANDLERS).join(", ")}`); + } + return handler(args ?? {}); +} diff --git a/cartridges/domains/cloud/digitalocean-mcp/README.adoc b/cartridges/domains/cloud/digitalocean-mcp/README.adoc new file mode 100644 index 0000000..9db30d1 --- /dev/null +++ b/cartridges/domains/cloud/digitalocean-mcp/README.adoc @@ -0,0 +1,99 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> + += digitalocean-mcp +Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk> +:spdx: MPL-2.0 +:tier: Ayo +:domain: Cloud +:protocols: MCP, REST + +== Overview + +DigitalOcean cloud infrastructure MCP cartridge. Manages droplets, block storage +volumes, domains, SSH keys, snapshots, and databases via the DigitalOcean REST API +(`https://api.digitalocean.com/v2/`). + +Authentication uses bearer tokens (personal access tokens). Rate limited to 5000 +requests per hour. + +== State Machine + +[source] +---- +Unauthenticated --> Authenticated (bearer token provided) +Authenticated --> RateLimited (5000 req/hour exceeded) +RateLimited --> Authenticated (rate limit window reset) +Authenticated --> Error (API/network failure) +RateLimited --> Error (failure during rate limit) +Error --> Authenticated (recovery) +Authenticated --> Unauthenticated (logout) +Error --> Unauthenticated (logout from error) +---- + +== Actions (16) + +|=== +| Action | Destructive | Endpoint + +| ListDroplets | No | GET /v2/droplets +| GetDroplet | No | GET /v2/droplets/{id} +| CreateDroplet | Yes | POST /v2/droplets +| DeleteDroplet | Yes | DELETE /v2/droplets/{id} +| PowerOn | Yes | POST /v2/droplets/{id}/actions +| PowerOff | Yes | POST /v2/droplets/{id}/actions +| Reboot | Yes | POST /v2/droplets/{id}/actions +| ListVolumes | No | GET /v2/volumes +| CreateVolume | Yes | POST /v2/volumes +| ListDomains | No | GET /v2/domains +| CreateDomain | Yes | POST /v2/domains +| ListSSHKeys | No | GET /v2/account/keys +| ListSnapshots | No | GET /v2/snapshots +| CreateSnapshot | Yes | POST /v2/droplets/{id}/actions +| ListDatabases | No | GET /v2/databases +| GetAccount | No | GET /v2/account +|=== + +== Architecture + +[cols="1,1,2"] +|=== +| Layer | Language | Purpose + +| ABI +| Idris2 +| Formally verified auth state machine with dependent-type proofs + +| FFI +| Zig +| C-ABI implementation with thread-safe session pool and rate limit tracking + +| Adapter +| zig +| REST bridge to BoJ unified adapter protocol +|=== + +== Building + +[source,bash] +---- +# Build FFI shared library +cd ffi && zig build + +# Run FFI tests +cd ffi && zig build test + +# Type-check ABI +cd abi && idris2 --check DigitaloceanMcp.SafeCloud +---- + +== Panels + +* Auth status (unauthenticated/authenticated/rate_limited/error) +* Droplet count +* Volume count +* Account balance + +== Status + +Development -- not yet ready for mounting. diff --git a/cartridges/domains/cloud/digitalocean-mcp/abi/DigitaloceanMcp/SafeCloud.idr b/cartridges/domains/cloud/digitalocean-mcp/abi/DigitaloceanMcp/SafeCloud.idr new file mode 100644 index 0000000..310c3d7 --- /dev/null +++ b/cartridges/domains/cloud/digitalocean-mcp/abi/DigitaloceanMcp/SafeCloud.idr @@ -0,0 +1,224 @@ +-- SPDX-License-Identifier: MPL-2.0 +-- Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +-- +-- DigitaloceanMcp.SafeCloud β€” Type-safe ABI for digitalocean-mcp cartridge. +-- +-- Formally verified state machine for DigitalOcean API interactions. +-- Bearer token authentication, REST API (https://api.digitalocean.com/v2/). +-- Rate limit: 5000 requests/hour. + +module DigitaloceanMcp.SafeCloud + +%default total + +-- --------------------------------------------------------------------------- +-- Authentication state machine +-- --------------------------------------------------------------------------- + +||| Authentication/session state for DigitalOcean API operations. +||| Unauthenticated: No bearer token configured. +||| Authenticated: Valid personal access token, ready for API calls. +||| RateLimited: Hit 5000 req/hour limit, must wait for reset. +||| Error: API or network error requiring recovery. +public export +data AuthState = Unauthenticated | Authenticated | RateLimited | Error + +||| Proof that a state transition is valid. +||| Only well-typed transitions compile β€” invalid paths are rejected. +public export +data ValidTransition : AuthState -> AuthState -> Type where + Authenticate : ValidTransition Unauthenticated Authenticated + HitRateLimit : ValidTransition Authenticated RateLimited + RateLimitReset : ValidTransition RateLimited Authenticated + AuthError : ValidTransition Authenticated Error + RateLimitError : ValidTransition RateLimited Error + RecoverToAuth : ValidTransition Error Authenticated + Logout : ValidTransition Authenticated Unauthenticated + ErrorLogout : ValidTransition Error Unauthenticated + +-- --------------------------------------------------------------------------- +-- C-ABI integer encoding +-- --------------------------------------------------------------------------- + +||| Encode auth state as C-compatible integer for FFI boundary. +export +authStateToInt : AuthState -> Int +authStateToInt Unauthenticated = 0 +authStateToInt Authenticated = 1 +authStateToInt RateLimited = 2 +authStateToInt Error = 3 + +||| Decode integer back to auth state. Returns Nothing for invalid values. +export +intToAuthState : Int -> Maybe AuthState +intToAuthState 0 = Just Unauthenticated +intToAuthState 1 = Just Authenticated +intToAuthState 2 = Just RateLimited +intToAuthState 3 = Just Error +intToAuthState _ = Nothing + +||| Check if a state transition is valid (C-ABI export). +||| Returns 1 for valid, 0 for invalid. +export +digitalocean_mcp_can_transition : Int -> Int -> Int +digitalocean_mcp_can_transition from to = + case (intToAuthState from, intToAuthState to) of + (Just Unauthenticated, Just Authenticated) => 1 + (Just Authenticated, Just RateLimited) => 1 + (Just RateLimited, Just Authenticated) => 1 + (Just Authenticated, Just Error) => 1 + (Just RateLimited, Just Error) => 1 + (Just Error, Just Authenticated) => 1 + (Just Authenticated, Just Unauthenticated) => 1 + (Just Error, Just Unauthenticated) => 1 + _ => 0 + +-- --------------------------------------------------------------------------- +-- DigitalOcean API actions +-- --------------------------------------------------------------------------- + +||| All supported DigitalOcean REST API actions. +||| Each maps to one or more endpoints under https://api.digitalocean.com/v2/. +public export +data DigitaloceanAction + = ListDroplets + | GetDroplet + | CreateDroplet + | DeleteDroplet + | PowerOn + | PowerOff + | Reboot + | ListVolumes + | CreateVolume + | ListDomains + | CreateDomain + | ListSSHKeys + | ListSnapshots + | CreateSnapshot + | ListDatabases + | GetAccount + +||| Check whether an action requires Authenticated state. +||| All DigitalOcean API calls require a valid bearer token. +export +actionRequiresAuth : DigitaloceanAction -> Bool +actionRequiresAuth ListDroplets = True +actionRequiresAuth GetDroplet = True +actionRequiresAuth CreateDroplet = True +actionRequiresAuth DeleteDroplet = True +actionRequiresAuth PowerOn = True +actionRequiresAuth PowerOff = True +actionRequiresAuth Reboot = True +actionRequiresAuth ListVolumes = True +actionRequiresAuth CreateVolume = True +actionRequiresAuth ListDomains = True +actionRequiresAuth CreateDomain = True +actionRequiresAuth ListSSHKeys = True +actionRequiresAuth ListSnapshots = True +actionRequiresAuth CreateSnapshot = True +actionRequiresAuth ListDatabases = True +actionRequiresAuth GetAccount = True + +||| Check whether an action is destructive (mutates remote state). +export +actionIsDestructive : DigitaloceanAction -> Bool +actionIsDestructive CreateDroplet = True +actionIsDestructive DeleteDroplet = True +actionIsDestructive PowerOn = True +actionIsDestructive PowerOff = True +actionIsDestructive Reboot = True +actionIsDestructive CreateVolume = True +actionIsDestructive CreateDomain = True +actionIsDestructive CreateSnapshot = True +actionIsDestructive _ = False + +||| Encode action as C-compatible integer for FFI boundary. +export +actionToInt : DigitaloceanAction -> Int +actionToInt ListDroplets = 0 +actionToInt GetDroplet = 1 +actionToInt CreateDroplet = 2 +actionToInt DeleteDroplet = 3 +actionToInt PowerOn = 4 +actionToInt PowerOff = 5 +actionToInt Reboot = 6 +actionToInt ListVolumes = 7 +actionToInt CreateVolume = 8 +actionToInt ListDomains = 9 +actionToInt CreateDomain = 10 +actionToInt ListSSHKeys = 11 +actionToInt ListSnapshots = 12 +actionToInt CreateSnapshot = 13 +actionToInt ListDatabases = 14 +actionToInt GetAccount = 15 + +||| Decode integer back to action. Returns Nothing for invalid values. +export +intToAction : Int -> Maybe DigitaloceanAction +intToAction 0 = Just ListDroplets +intToAction 1 = Just GetDroplet +intToAction 2 = Just CreateDroplet +intToAction 3 = Just DeleteDroplet +intToAction 4 = Just PowerOn +intToAction 5 = Just PowerOff +intToAction 6 = Just Reboot +intToAction 7 = Just ListVolumes +intToAction 8 = Just CreateVolume +intToAction 9 = Just ListDomains +intToAction 10 = Just CreateDomain +intToAction 11 = Just ListSSHKeys +intToAction 12 = Just ListSnapshots +intToAction 13 = Just CreateSnapshot +intToAction 14 = Just ListDatabases +intToAction 15 = Just GetAccount +intToAction _ = Nothing + +||| Total number of supported actions. +export +actionCount : Nat +actionCount = 16 + +-- --------------------------------------------------------------------------- +-- Rate limiting +-- --------------------------------------------------------------------------- + +||| DigitalOcean rate limit: 5000 requests per hour. +export +rateLimitPerHour : Nat +rateLimitPerHour = 5000 + +-- --------------------------------------------------------------------------- +-- MCP tool declarations +-- --------------------------------------------------------------------------- + +||| Tools exposed via MCP protocol for this cartridge. +public export +data McpTool + = ToolAuthenticate + | ToolListDroplets + | ToolGetDroplet + | ToolCreateDroplet + | ToolDeleteDroplet + | ToolPowerAction + | ToolListVolumes + | ToolCreateVolume + | ToolListDomains + | ToolCreateDomain + | ToolListSSHKeys + | ToolListSnapshots + | ToolCreateSnapshot + | ToolListDatabases + | ToolGetAccount + | ToolStatus + +||| Check if a tool requires authenticated state. +export +toolRequiresAuth : McpTool -> Bool +toolRequiresAuth ToolAuthenticate = False +toolRequiresAuth ToolStatus = False +toolRequiresAuth _ = True + +||| Tool count for this cartridge. +export +toolCount : Nat +toolCount = 16 diff --git a/cartridges/domains/cloud/digitalocean-mcp/abi/README.adoc b/cartridges/domains/cloud/digitalocean-mcp/abi/README.adoc new file mode 100644 index 0000000..e2f3acc --- /dev/null +++ b/cartridges/domains/cloud/digitalocean-mcp/abi/README.adoc @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += digitalocean-mcp / abi β€” Idris2 ABI layer +:orientation: shallow + +== Purpose + +Cartridge-local protocol types for `digitalocean-mcp`. Defines the data, tool, and +transition vocabulary this cartridge speaks. Inherits safety lemmas and the +matrix-cell identifier scheme from the trunk ABI at `src/abi/`. + +== Files + +1 Idris2 module(s), ~224 lines total. Lead module: +`SafeCloud.idr`. + +No `.ipkg` β€” consumed directly by wider BoJ callers; type-checks inside the +trunk `boj.ipkg`. + +== Invariants + +* `%default total` at every module top. +* Zero `believe_me`, `assert_total`, `postulate`, `Admitted`, or + `sorry` (the trunk holds the four declared `believe_me` sites; leaf + cartridges carry none). + +== Test/proof surface + +Thin module(s), no inline tests. Proof surface = type-check success under the +trunk `idris2 --check`. + +== Read-first + +. `SafeCloud.idr` β€” cartridge-local data records and enums. +. Trunk `src/abi/Boj/Catalogue.idr` for how this cell participates in the + 2D matrix. diff --git a/cartridges/domains/cloud/digitalocean-mcp/abi/digitalocean_mcp.ipkg b/cartridges/domains/cloud/digitalocean-mcp/abi/digitalocean_mcp.ipkg new file mode 100644 index 0000000..d13f41b --- /dev/null +++ b/cartridges/domains/cloud/digitalocean-mcp/abi/digitalocean_mcp.ipkg @@ -0,0 +1,13 @@ +-- SPDX-License-Identifier: MPL-2.0 +-- Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +package digitalocean_mcp + +version = "0.2.0" +authors = "Jonathan D.A. Jewell" +brief = "Type-safe ABI for DigitalOcean MCP cartridge (bearer token, REST API)" + +depends = base + +sourcedir = "." + +modules = DigitaloceanMcp.SafeCloud diff --git a/cartridges/domains/cloud/digitalocean-mcp/adapter/README.adoc b/cartridges/domains/cloud/digitalocean-mcp/adapter/README.adoc new file mode 100644 index 0000000..3095543 --- /dev/null +++ b/cartridges/domains/cloud/digitalocean-mcp/adapter/README.adoc @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += digitalocean-mcp / adapter β€” protocol bridge +:orientation: shallow + +== Purpose + +Exposes this cartridge's FFI over the wire protocols declared in +`cartridge.json`. Dispatches tool invocations to the C-ABI exports at +`../ffi/` and formats responses per protocol. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” depends on the sibling `ffi` target. +| `digitalocean_mcp_adapter.zig` | Protocol dispatch (159 lines). Tool catalogue matches `cartridge.json`. +| `SIDELINED-*.v.adoc` | zig predecessor(s). Not built β€” zig banned 2026-04-10. +|=== + +== Invariants + +* **Stateless** β€” all state lives behind the FFI, never in the adapter. +* **Response passthrough** β€” whatever the FFI returns goes back to the wire + unmodified (no embellishment, no silent recovery). +* **Cartridge.json is source of truth** for the tool catalogue; drift between + adapter and manifest is a CI failure. + +== Test/proof surface + +No inline tests at the adapter layer β€” protocol routing is exercised by the +trunk seam tests at `ffi/zig/src/seams.zig`. + +== Read-first + +. `digitalocean_mcp_adapter.zig` β€” dispatch function and per-protocol routers. +. `../cartridge.json` β€” the tool manifest the dispatcher mirrors. diff --git a/cartridges/domains/cloud/digitalocean-mcp/adapter/build.zig b/cartridges/domains/cloud/digitalocean-mcp/adapter/build.zig new file mode 100644 index 0000000..bc4901d --- /dev/null +++ b/cartridges/domains/cloud/digitalocean-mcp/adapter/build.zig @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// digitalocean-mcp/adapter/build.zig + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const ffi_mod = b.createModule(.{ + .root_source_file = b.path("../ffi/digitalocean_mcp_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + const adapter = b.addExecutable(.{ + .name = "digitalocean_mcp_adapter", + .root_source_file = b.path("digitalocean_mcp_adapter.zig"), + .target = target, + .optimize = optimize, + }); + adapter.root_module.addImport("digitalocean_mcp_ffi", ffi_mod); + b.installArtifact(adapter); +} diff --git a/cartridges/domains/cloud/digitalocean-mcp/adapter/digitalocean_mcp_adapter.zig b/cartridges/domains/cloud/digitalocean-mcp/adapter/digitalocean_mcp_adapter.zig new file mode 100644 index 0000000..88ad52b --- /dev/null +++ b/cartridges/domains/cloud/digitalocean-mcp/adapter/digitalocean_mcp_adapter.zig @@ -0,0 +1,159 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// digitalocean-mcp/adapter/digitalocean_mcp_adapter.zig +// +// Three-protocol BoJ adapter: REST (port 9223), gRPC-compat (port 9224), +// GraphQL (port 9225). +// Replaces the banned zig adapter (digitalocean_mcp_adapter.v). + +const std = @import("std"); +const ffi = @import("digitalocean_mcp_ffi"); + +const REST_PORT: u16 = 9223; +const GRPC_PORT: u16 = 9224; +const GQL_PORT: u16 = 9225; + +const Response = struct { status: u16, body: []const u8 }; + +fn okJson(buf: []u8, msg: []const u8) []u8 { + return std.fmt.bufPrint(buf, "{{\"success\":true,\"message\":\"{s}\"}}", .{msg}) catch buf[0..0]; +} + +fn errJson(buf: []u8, msg: []const u8) []u8 { + return std.fmt.bufPrint(buf, "{{\"success\":false,\"error\":\"{s}\"}}", .{msg}) catch buf[0..0]; +} + +fn dispatch(tool: []const u8, _body: []const u8, resp: []u8) Response { + _ = _body; + if (std.mem.eql(u8, tool, "do_authenticate")) { + return .{ .status = 200, .body = okJson(resp, "do_authenticate forwarded") }; + } + if (std.mem.eql(u8, tool, "do_list_droplets")) { + return .{ .status = 200, .body = okJson(resp, "do_list_droplets forwarded") }; + } + if (std.mem.eql(u8, tool, "do_get_droplet")) { + return .{ .status = 200, .body = okJson(resp, "do_get_droplet forwarded") }; + } + if (std.mem.eql(u8, tool, "do_create_droplet")) { + return .{ .status = 200, .body = okJson(resp, "do_create_droplet forwarded") }; + } + if (std.mem.eql(u8, tool, "do_delete_droplet")) { + return .{ .status = 200, .body = okJson(resp, "do_delete_droplet forwarded") }; + } + if (std.mem.eql(u8, tool, "do_power_on")) { + return .{ .status = 200, .body = okJson(resp, "do_power_on forwarded") }; + } + if (std.mem.eql(u8, tool, "do_power_off")) { + return .{ .status = 200, .body = okJson(resp, "do_power_off forwarded") }; + } + if (std.mem.eql(u8, tool, "do_list_volumes")) { + return .{ .status = 200, .body = okJson(resp, "do_list_volumes forwarded") }; + } + if (std.mem.eql(u8, tool, "do_list_domains")) { + return .{ .status = 200, .body = okJson(resp, "do_list_domains forwarded") }; + } + if (std.mem.eql(u8, tool, "do_get_account")) { + return .{ .status = 200, .body = okJson(resp, "do_get_account forwarded") }; + } + return .{ .status = 404, .body = errJson(resp, "unknown tool") }; +} + +fn dispatchRest(path: []const u8, body: []const u8, resp: []u8) Response { + // Expect /tools/<tool_name> + const prefix = "/tools/"; + if (std.mem.startsWith(u8, path, prefix)) { + const tool = path[prefix.len..]; + return dispatch(tool, body, resp); + } + return .{ .status = 404, .body = errJson(resp, "not found") }; +} + +fn dispatchGrpc(path: []const u8, body: []const u8, resp: []u8) Response { + // Expect /<Service>/<Method> β€” derive tool from Method + var it = std.mem.splitScalar(u8, path, '/'); + _ = it.next(); // leading empty + _ = it.next(); // service + const method = it.next() orelse return .{ .status = 404, .body = errJson(resp, "bad gRPC path") }; + return dispatch(method, body, resp); +} + +fn dispatchGraphql(body: []const u8, resp: []u8) Response { + if (std.mem.indexOf(u8, body, "do_authenticate") != null) + return dispatch("do_authenticate", body, resp); + if (std.mem.indexOf(u8, body, "do_list_droplets") != null) + return dispatch("do_list_droplets", body, resp); + if (std.mem.indexOf(u8, body, "do_get_droplet") != null) + return dispatch("do_get_droplet", body, resp); + if (std.mem.indexOf(u8, body, "do_create_droplet") != null) + return dispatch("do_create_droplet", body, resp); + if (std.mem.indexOf(u8, body, "do_delete_droplet") != null) + return dispatch("do_delete_droplet", body, resp); + if (std.mem.indexOf(u8, body, "do_power_on") != null) + return dispatch("do_power_on", body, resp); + if (std.mem.indexOf(u8, body, "do_power_off") != null) + return dispatch("do_power_off", body, resp); + if (std.mem.indexOf(u8, body, "do_list_volumes") != null) + return dispatch("do_list_volumes", body, resp); + if (std.mem.indexOf(u8, body, "do_list_domains") != null) + return dispatch("do_list_domains", body, resp); + if (std.mem.indexOf(u8, body, "do_get_account") != null) + return dispatch("do_get_account", body, resp); + return .{ .status = 400, .body = errJson(resp, "unrecognised GraphQL operation") }; +} + +fn handleConnection(stream: std.net.Stream, port: u16) void { + defer stream.close(); + var buf: [4096]u8 = undefined; + var resp_buf: [4096]u8 = undefined; + const n = stream.read(&buf) catch return; + const req = buf[0..n]; + const result = switch (port) { + REST_PORT => blk: { + // Parse HTTP/1.1: first line = METHOD PATH HTTP/x.y + var lines = std.mem.splitScalar(u8, req, '\n'); + const first = lines.next() orelse break :blk Response{ .status = 400, .body = "" }; + var parts = std.mem.splitScalar(u8, std.mem.trim(u8, first, "\r"), ' '); + _ = parts.next(); // method + const path = parts.next() orelse break :blk Response{ .status = 400, .body = "" }; + break :blk dispatchRest(path, req, &resp_buf); + }, + GRPC_PORT => blk: { + var lines = std.mem.splitScalar(u8, req, '\n'); + const first = lines.next() orelse break :blk Response{ .status = 400, .body = "" }; + var parts = std.mem.splitScalar(u8, std.mem.trim(u8, first, "\r"), ' '); + _ = parts.next(); + const path = parts.next() orelse break :blk Response{ .status = 400, .body = "" }; + break :blk dispatchGrpc(path, req, &resp_buf); + }, + GQL_PORT => dispatchGraphql(req, &resp_buf), + else => Response{ .status = 500, .body = "" }, + }; + var http_resp: [512]u8 = undefined; + const http = std.fmt.bufPrint(&http_resp, + "HTTP/1.1 {d} OK\r\nContent-Length: {d}\r\nContent-Type: application/json\r\n\r\n", + .{ result.status, result.body.len }) catch return; + _ = stream.write(http) catch {}; + _ = stream.write(result.body) catch {}; +} + +fn listenLoop(port: u16) !void { + const addr = std.net.Address.initIp4(.{ 127, 0, 0, 1 }, port); + var server = try addr.listen(.{ .reuse_address = true }); + defer server.deinit(); + while (true) { + const conn = try server.accept(); + const t = try std.Thread.spawn(.{}, handleConnection, .{ conn.stream, port }); + t.detach(); + } +} + +pub fn main() !void { + ffi.digitalocean_mcp_init(); + const t1 = try std.Thread.spawn(.{}, listenLoop, .{REST_PORT}); + const t2 = try std.Thread.spawn(.{}, listenLoop, .{GRPC_PORT}); + const t3 = try std.Thread.spawn(.{}, listenLoop, .{GQL_PORT}); + t1.join(); + t2.join(); + t3.join(); +} diff --git a/cartridges/domains/cloud/digitalocean-mcp/benchmarks/quick-bench.sh b/cartridges/domains/cloud/digitalocean-mcp/benchmarks/quick-bench.sh new file mode 100755 index 0000000..a54d45b --- /dev/null +++ b/cartridges/domains/cloud/digitalocean-mcp/benchmarks/quick-bench.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Benchmarks for digitalocean-mcp cartridge. +set -euo pipefail + +echo "=== digitalocean-mcp benchmarks ===" + +cd "$(dirname "${BASH_SOURCE[0]}")/../ffi" + +# Build with ReleaseFast for benchmarking +zig build -Doptimize=ReleaseFast 2>&1 + +echo "Session open/close cycle (1000 iterations):" +time for i in $(seq 1 1000); do + # This would call the FFI benchmark binary + true +done + +echo "" +echo "Benchmark placeholder β€” implement real benchmarks in Zig test blocks" +echo "or via the zig adapter HTTP benchmark tool." diff --git a/cartridges/domains/cloud/digitalocean-mcp/cartridge.json b/cartridges/domains/cloud/digitalocean-mcp/cartridge.json new file mode 100644 index 0000000..56db86c --- /dev/null +++ b/cartridges/domains/cloud/digitalocean-mcp/cartridge.json @@ -0,0 +1,482 @@ +{ + "$schema": "https://boj.dev/schemas/cartridge/v1.json", + "spdx": "MPL-2.0", + "copyright": "Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath)", + "name": "digitalocean-mcp", + "version": "0.2.0", + "description": "DigitalOcean cloud infrastructure cartridge -- droplets, volumes, domains, SSH keys, snapshots, databases, Kubernetes, firewalls, load balancers, and account management", + "domain": "Cloud", + "tier": "Ayo", + "protocols": [ + "MCP", + "REST" + ], + "auth": { + "method": "bearer_token", + "env_var": "DIGITALOCEAN_TOKEN", + "credential_source": "vault-mcp" + }, + "api": { + "base_url": "https://api.digitalocean.com/v2", + "rate_limit_per_hour": 5000, + "content_type": "application/json" + }, + "tools": [ + { + "name": "digitalocean_list_droplets", + "description": "List all droplets in the DigitalOcean account", + "inputSchema": { + "type": "object", + "properties": { + "page": { + "type": "number", + "description": "Page number (default 1)" + }, + "per_page": { + "type": "number", + "description": "Results per page (default 20, max 200)" + }, + "tag_name": { + "type": "string", + "description": "Filter by tag name" + }, + "name": { + "type": "string", + "description": "Filter by exact droplet name" + } + } + } + }, + { + "name": "digitalocean_get_droplet", + "description": "Get details of a specific DigitalOcean droplet by ID", + "inputSchema": { + "type": "object", + "properties": { + "droplet_id": { + "type": "number", + "description": "Numeric droplet ID" + } + }, + "required": [ + "droplet_id" + ] + } + }, + { + "name": "digitalocean_create_droplet", + "description": "Create a new DigitalOcean droplet", + "inputSchema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Droplet hostname" + }, + "region": { + "type": "string", + "description": "Region slug (e.g. nyc1, sfo3, ams3, lon1, fra1, sgp1, blr1, syd1)" + }, + "size": { + "type": "string", + "description": "Size slug (e.g. s-1vcpu-1gb, s-2vcpu-2gb, s-4vcpu-8gb, g-2vcpu-8gb)" + }, + "image": { + "type": "string", + "description": "Image slug or ID (e.g. ubuntu-24-04-x64, debian-12-x64, fedora-41-x64)" + }, + "ssh_keys": { + "type": "array", + "items": { + "type": "string" + }, + "description": "SSH key IDs or fingerprints to install" + }, + "backups": { + "type": "boolean", + "description": "Enable weekly backups (default false)" + }, + "ipv6": { + "type": "boolean", + "description": "Enable IPv6 (default false)" + }, + "monitoring": { + "type": "boolean", + "description": "Enable monitoring agent (default false)" + }, + "user_data": { + "type": "string", + "description": "Cloud-init user data (cloud-config or script)" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Tags to apply to the droplet" + }, + "vpc_uuid": { + "type": "string", + "description": "VPC UUID to place the droplet in" + }, + "volumes": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Volume IDs to attach" + } + }, + "required": [ + "name", + "region", + "size", + "image" + ] + } + }, + { + "name": "digitalocean_delete_droplet", + "description": "Delete a DigitalOcean droplet (irreversible)", + "inputSchema": { + "type": "object", + "properties": { + "droplet_id": { + "type": "number", + "description": "Numeric droplet ID to delete" + } + }, + "required": [ + "droplet_id" + ] + } + }, + { + "name": "digitalocean_droplet_action", + "description": "Perform a power action on a DigitalOcean droplet (power_on, power_off, reboot, shutdown, power_cycle, rebuild, resize, rename)", + "inputSchema": { + "type": "object", + "properties": { + "droplet_id": { + "type": "number", + "description": "Numeric droplet ID" + }, + "type": { + "type": "string", + "description": "Action type: power_on, power_off, reboot, shutdown, power_cycle, rebuild, resize, rename" + }, + "image": { + "type": "string", + "description": "Image slug or ID (required for rebuild)" + }, + "size": { + "type": "string", + "description": "Size slug (required for resize)" + }, + "name": { + "type": "string", + "description": "New name (required for rename)" + } + }, + "required": [ + "droplet_id", + "type" + ] + } + }, + { + "name": "digitalocean_list_volumes", + "description": "List all block storage volumes in the DigitalOcean account", + "inputSchema": { + "type": "object", + "properties": { + "page": { + "type": "number", + "description": "Page number" + }, + "per_page": { + "type": "number", + "description": "Results per page" + }, + "region": { + "type": "string", + "description": "Filter by region slug" + }, + "name": { + "type": "string", + "description": "Filter by exact volume name" + } + } + } + }, + { + "name": "digitalocean_create_volume", + "description": "Create a new block storage volume in DigitalOcean", + "inputSchema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Volume name" + }, + "size_gigabytes": { + "type": "number", + "description": "Size in GB (min 1, max 16384)" + }, + "region": { + "type": "string", + "description": "Region slug (e.g. nyc1)" + }, + "description": { + "type": "string", + "description": "Volume description" + }, + "filesystem_type": { + "type": "string", + "description": "Filesystem type: ext4 or xfs" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Tags to apply" + }, + "snapshot_id": { + "type": "string", + "description": "Snapshot ID to create volume from" + } + }, + "required": [ + "name", + "size_gigabytes", + "region" + ] + } + }, + { + "name": "digitalocean_list_domains", + "description": "List all DNS domains in the DigitalOcean account", + "inputSchema": { + "type": "object", + "properties": { + "page": { + "type": "number", + "description": "Page number" + }, + "per_page": { + "type": "number", + "description": "Results per page" + } + } + } + }, + { + "name": "digitalocean_create_domain", + "description": "Create a new DNS domain in DigitalOcean", + "inputSchema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Domain name (e.g. example.com)" + }, + "ip_address": { + "type": "string", + "description": "IP address to create an initial A record" + } + }, + "required": [ + "name" + ] + } + }, + { + "name": "digitalocean_list_ssh_keys", + "description": "List all SSH keys in the DigitalOcean account", + "inputSchema": { + "type": "object", + "properties": { + "page": { + "type": "number", + "description": "Page number" + }, + "per_page": { + "type": "number", + "description": "Results per page" + } + } + } + }, + { + "name": "digitalocean_list_snapshots", + "description": "List all snapshots (droplet snapshots and volume snapshots) in the account", + "inputSchema": { + "type": "object", + "properties": { + "page": { + "type": "number", + "description": "Page number" + }, + "per_page": { + "type": "number", + "description": "Results per page" + }, + "resource_type": { + "type": "string", + "description": "Filter by type: droplet or volume" + } + } + } + }, + { + "name": "digitalocean_create_snapshot", + "description": "Create a snapshot of a DigitalOcean droplet", + "inputSchema": { + "type": "object", + "properties": { + "droplet_id": { + "type": "number", + "description": "Droplet ID to snapshot" + }, + "name": { + "type": "string", + "description": "Snapshot name" + } + }, + "required": [ + "droplet_id", + "name" + ] + } + }, + { + "name": "digitalocean_list_databases", + "description": "List all managed database clusters in the DigitalOcean account", + "inputSchema": { + "type": "object", + "properties": { + "page": { + "type": "number", + "description": "Page number" + }, + "per_page": { + "type": "number", + "description": "Results per page" + }, + "tag_name": { + "type": "string", + "description": "Filter by tag" + } + } + } + }, + { + "name": "digitalocean_list_firewalls", + "description": "List all firewalls in the DigitalOcean account", + "inputSchema": { + "type": "object", + "properties": { + "page": { + "type": "number", + "description": "Page number" + }, + "per_page": { + "type": "number", + "description": "Results per page" + } + } + } + }, + { + "name": "digitalocean_create_firewall", + "description": "Create a new firewall with inbound and outbound rules in DigitalOcean", + "inputSchema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Firewall name" + }, + "inbound_rules": { + "type": "array", + "items": { + "type": "object" + }, + "description": "Inbound rules (protocol, ports, sources)" + }, + "outbound_rules": { + "type": "array", + "items": { + "type": "object" + }, + "description": "Outbound rules (protocol, ports, destinations)" + }, + "droplet_ids": { + "type": "array", + "items": { + "type": "number" + }, + "description": "Droplet IDs to apply to" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Tags to apply to" + } + }, + "required": [ + "name", + "inbound_rules", + "outbound_rules" + ] + } + }, + { + "name": "digitalocean_list_load_balancers", + "description": "List all load balancers in the DigitalOcean account", + "inputSchema": { + "type": "object", + "properties": { + "page": { + "type": "number", + "description": "Page number" + }, + "per_page": { + "type": "number", + "description": "Results per page" + } + } + } + }, + { + "name": "digitalocean_get_account", + "description": "Get DigitalOcean account information (email, UUID, droplet limit, status, team)", + "inputSchema": { + "type": "object", + "properties": {} + } + }, + { + "name": "digitalocean_get_balance", + "description": "Get current DigitalOcean account balance and billing information", + "inputSchema": { + "type": "object", + "properties": {} + } + } + ], + "ffi": { + "so_path": "ffi/zig-out/lib/libdigitalocean_mcp.so", + "abi_version": "ADR-0006", + "symbols": [ + "boj_cartridge_init", + "boj_cartridge_deinit", + "boj_cartridge_name", + "boj_cartridge_version", + "boj_cartridge_invoke" + ] + } +} diff --git a/cartridges/domains/cloud/digitalocean-mcp/ffi/README.adoc b/cartridges/domains/cloud/digitalocean-mcp/ffi/README.adoc new file mode 100644 index 0000000..4c4d21a --- /dev/null +++ b/cartridges/domains/cloud/digitalocean-mcp/ffi/README.adoc @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += digitalocean-mcp / ffi β€” Zig FFI layer +:orientation: shallow + +== Purpose + +C-ABI exports bridging this cartridge's Idris2 ABI to the runtime. Consumed by +the adapter for protocol dispatch and by the trunk loader for mount. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” produces `zig-out/lib/libdigitalocean.so`. +| `digitalocean_mcp_ffi.zig` | C-ABI exports (10 exports, 6 inline tests, 324 lines). +|=== + +== Invariants + +* **Null-pointer rejection** on `[*c]const u8` arguments before any + dereference. +* **Negative return values** carry documented error meanings; zero is + success (matching the trunk error-code convention). +* **No allocator leaks** β€” every allocation path pairs with `defer free`. + +== Test/proof surface + +6 inline `test "..."` block(s) in `digitalocean_mcp_ffi.zig`. Run with +`cd ffi && zig build test`. + +== Read-first + +. `digitalocean_mcp_ffi.zig` β€” C-ABI signatures and guards. +. Trunk `ffi/zig/src/catalogue.zig` for how this `.so` is loaded. diff --git a/cartridges/domains/cloud/digitalocean-mcp/ffi/build.zig b/cartridges/domains/cloud/digitalocean-mcp/ffi/build.zig new file mode 100644 index 0000000..872f1d2 --- /dev/null +++ b/cartridges/domains/cloud/digitalocean-mcp/ffi/build.zig @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// build.zig β€” Build configuration for digitalocean-mcp FFI shared library. + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + // Module + // Shared ADR-0006 invoke-shim module (relative path up to boj-server trunk). + + const shim_mod = b.addModule("cartridge_shim", .{ + + .root_source_file = b.path("../../../ffi/zig/src/cartridge_shim.zig"), + + .target = target, + + .optimize = optimize, + + }); + + const ffi_mod = b.addModule("digitalocean_mcp", .{ + .root_source_file = b.path("digitalocean_mcp_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + ffi_mod.addImport("cartridge_shim", shim_mod); + + // Shared library + const lib = b.addLibrary(.{ + .name = "digitalocean_mcp", + .root_module = ffi_mod, + .linkage = .dynamic, + }); + lib.linkLibC(); + b.installArtifact(lib); + + // Tests + const tests = b.addTest(.{ + .root_module = ffi_mod, + }); + tests.linkLibC(); + + const run_tests = b.addRunArtifact(tests); + const test_step = b.step("test", "Run DigitalOcean MCP FFI tests"); + test_step.dependOn(&run_tests.step); +} diff --git a/cartridges/domains/cloud/digitalocean-mcp/ffi/cartridge_shim.zig b/cartridges/domains/cloud/digitalocean-mcp/ffi/cartridge_shim.zig new file mode 100644 index 0000000..0e399db --- /dev/null +++ b/cartridges/domains/cloud/digitalocean-mcp/ffi/cartridge_shim.zig @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// cartridge_shim.zig β€” Shared helpers for the ADR-0006 five-symbol +// cartridge ABI (`boj_cartridge_init / deinit / name / version / invoke`). +// +// The shim centralises the seven-code return convention, NUL-argument +// guards, tool-name comparison, and the buffer-too-small path so each +// cartridge's `boj_cartridge_invoke` can stay short β€” typically a tool +// table plus `shim.writeResult(...)`. +// +// Cartridges import this file by relative path (no build-graph change +// needed). Example: +// +// const shim = @import("../../../ffi/zig/src/cartridge_shim.zig"); +// +// export fn boj_cartridge_invoke( +// tool_name: [*c]const u8, +// json_args: [*c]const u8, +// out_buf: [*c]u8, +// in_out_len: [*c]usize, +// ) callconv(.c) i32 { +// _ = json_args; +// if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; +// const body = if (shim.toolIs(tool_name, "foo")) "{\"result\":{}}" +// else return shim.RC_UNKNOWN_TOOL; +// return shim.writeResult(out_buf, in_out_len, body); +// } + +const std = @import("std"); + +// ── Return codes (ADR-0006 Β§Return codes) ──────────────────────────── +// +// Frozen by ADR-0006. New failure modes compose these via the error +// JSON body β€” the integer surface does not grow without a follow-up ADR. + +pub const RC_SUCCESS: i32 = 0; +pub const RC_UNKNOWN_TOOL: i32 = -1; +pub const RC_BAD_ARGS: i32 = -2; +pub const RC_BUFFER_TOO_SMALL: i32 = -3; +pub const RC_RUNTIME_ERROR: i32 = -4; +pub const RC_PANIC: i32 = -5; +pub const RC_AUTH_DENIED: i32 = -6; + +// ── Invoke-path helpers ────────────────────────────────────────────── + +/// True if any of the three mandatory `boj_cartridge_invoke` output-path +/// pointers is null. Use at the top of every invoke to short-circuit to +/// `RC_BAD_ARGS`. +pub fn invokeArgsNull( + tool_name: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) bool { + return tool_name == null or out_buf == null or in_out_len == null; +} + +/// Compare a C-NUL-terminated tool-name pointer against a Zig string +/// literal. Caller must have already verified `tool_name` is non-null +/// (usually via `invokeArgsNull`). +pub fn toolIs(tool_name: [*c]const u8, expected: []const u8) bool { + const s = std.mem.span(@as([*:0]const u8, @ptrCast(tool_name))); + return std.mem.eql(u8, s, expected); +} + +/// Copy `body` into `out_buf[0..*in_out_len]` (as a capacity) and update +/// `*in_out_len` to the number of bytes written. Returns `RC_SUCCESS`. +/// +/// If `body.len` exceeds the current capacity stored in `*in_out_len`, +/// sets `*in_out_len` to the required size and returns +/// `RC_BUFFER_TOO_SMALL` β€” the caller is then expected to re-allocate +/// and retry, per ADR-0006 Β§Memory ownership. +/// +/// Caller must have already verified that `out_buf` and `in_out_len` +/// are non-null. +pub fn writeResult( + out_buf: [*c]u8, + in_out_len: [*c]usize, + body: []const u8, +) i32 { + const cap = in_out_len.*; + if (body.len > cap) { + in_out_len.* = body.len; + return RC_BUFFER_TOO_SMALL; + } + @memcpy(out_buf[0..body.len], body); + in_out_len.* = body.len; + return RC_SUCCESS; +} + +// ── Tests ──────────────────────────────────────────────────────────── + +test "writeResult: body fits, writes and sets length" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); + try std.testing.expectEqualStrings("hello", buf[0..len]); +} + +test "writeResult: too small returns -3 and sets required length" { + var buf: [2]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_BUFFER_TOO_SMALL, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: exact-fit succeeds" { + var buf: [5]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: empty body" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, ""); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 0), len); +} + +test "toolIs: matches and rejects" { + const name: [*:0]const u8 = "foo"; + try std.testing.expect(toolIs(@ptrCast(name), "foo")); + try std.testing.expect(!toolIs(@ptrCast(name), "bar")); + try std.testing.expect(!toolIs(@ptrCast(name), "foobar")); + try std.testing.expect(!toolIs(@ptrCast(name), "fo")); +} + +test "invokeArgsNull: detects each null slot" { + var buf: [4]u8 = undefined; + var len: usize = 4; + const name: [*:0]const u8 = "x"; + try std.testing.expect(!invokeArgsNull(@ptrCast(name), &buf, &len)); + try std.testing.expect(invokeArgsNull(null, &buf, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), null, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), &buf, null)); +} diff --git a/cartridges/domains/cloud/digitalocean-mcp/ffi/digitalocean_mcp_ffi.zig b/cartridges/domains/cloud/digitalocean-mcp/ffi/digitalocean_mcp_ffi.zig new file mode 100644 index 0000000..c979bfd --- /dev/null +++ b/cartridges/domains/cloud/digitalocean-mcp/ffi/digitalocean_mcp_ffi.zig @@ -0,0 +1,457 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// digitalocean_mcp_ffi.zig β€” C-ABI FFI for DigitalOcean MCP cartridge. +// +// Implements the auth state machine defined in the Idris2 ABI layer. +// Bearer token authentication, 5000 req/hour rate limit tracking. +// Thread-safe via std.Thread.Mutex. No heap allocations for results. + +const std = @import("std"); + +// --------------------------------------------------------------------------- +// Auth state machine (matches Idris2 ABI: Unauthenticated/Authenticated/RateLimited/Error) +// --------------------------------------------------------------------------- + +pub const AuthState = enum(c_int) { + unauthenticated = 0, + authenticated = 1, + rate_limited = 2, + err = 3, +}; + +/// Validate whether a transition between two auth states is permitted. +fn isValidTransition(from: AuthState, to: AuthState) bool { + return switch (from) { + .unauthenticated => to == .authenticated, + .authenticated => to == .rate_limited or to == .err or to == .unauthenticated, + .rate_limited => to == .authenticated or to == .err, + .err => to == .authenticated or to == .unauthenticated, + }; +} + +// --------------------------------------------------------------------------- +// DigitalOcean action codes (matches Idris2 DigitaloceanAction) +// --------------------------------------------------------------------------- + +pub const DigitaloceanAction = enum(c_int) { + list_droplets = 0, + get_droplet = 1, + create_droplet = 2, + delete_droplet = 3, + power_on = 4, + power_off = 5, + reboot = 6, + list_volumes = 7, + create_volume = 8, + list_domains = 9, + create_domain = 10, + list_ssh_keys = 11, + list_snapshots = 12, + create_snapshot = 13, + list_databases = 14, + get_account = 15, +}; + +/// Returns 1 if the action mutates remote state, 0 otherwise. +fn isDestructiveAction(action: DigitaloceanAction) bool { + return switch (action) { + .create_droplet, .delete_droplet, .power_on, .power_off, .reboot, .create_volume, .create_domain, .create_snapshot => true, + .list_droplets, .get_droplet, .list_volumes, .list_domains, .list_ssh_keys, .list_snapshots, .list_databases, .get_account => false, + }; +} + +// --------------------------------------------------------------------------- +// Session slots (thread-safe, fixed-size pool) +// --------------------------------------------------------------------------- + +const MAX_SESSIONS: usize = 16; +const TOKEN_BUF_SIZE: usize = 256; + +/// Rate limit: 5000 requests per hour for DigitalOcean API. +const RATE_LIMIT_PER_HOUR: u32 = 5000; + +const SessionSlot = struct { + active: bool = false, + state: AuthState = .unauthenticated, + token_buf: [TOKEN_BUF_SIZE]u8 = .{0} ** TOKEN_BUF_SIZE, + token_len: usize = 0, + requests_remaining: u32 = RATE_LIMIT_PER_HOUR, + last_action: c_int = -1, +}; + +var sessions: [MAX_SESSIONS]SessionSlot = .{SessionSlot{}} ** MAX_SESSIONS; +var mutex: std.Thread.Mutex = .{}; + +// --------------------------------------------------------------------------- +// C-ABI exports +// --------------------------------------------------------------------------- + +/// Check if a state transition is valid. Returns 1 (valid) or 0 (invalid). +pub export fn digitalocean_mcp_can_transition(from: c_int, to: c_int) c_int { + const f = std.meta.intToEnum(AuthState, from) catch return 0; + const t = std.meta.intToEnum(AuthState, to) catch return 0; + return if (isValidTransition(f, t)) 1 else 0; +} + +/// Authenticate a session with a bearer token. Returns slot index (>= 0) or error (< 0). +/// Error codes: -1 = no free slots, -2 = null/empty token. +pub export fn digitalocean_mcp_authenticate(token_ptr: ?[*]const u8, token_len: c_int) c_int { + const ptr = token_ptr orelse return -2; + const len: usize = std.math.cast(usize, token_len) orelse return -2; + if (len == 0 or len > TOKEN_BUF_SIZE) return -2; + + mutex.lock(); + defer mutex.unlock(); + + for (&sessions, 0..) |*slot, idx| { + if (!slot.active) { + slot.active = true; + slot.state = .authenticated; + @memcpy(slot.token_buf[0..len], ptr[0..len]); + slot.token_len = len; + slot.requests_remaining = RATE_LIMIT_PER_HOUR; + slot.last_action = -1; + return @intCast(idx); + } + } + return -1; +} + +/// Close/logout a session. Returns 0 on success, -1 if invalid slot, -2 if bad transition. +pub export fn digitalocean_mcp_session_close(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + if (!isValidTransition(slot.state, .unauthenticated)) return -2; + + sessions[idx] = SessionSlot{}; + return 0; +} + +/// Get the current auth state of a session. Returns state int or -1 if invalid slot. +pub export fn digitalocean_mcp_session_state(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + return @intFromEnum(slot.state); +} + +/// Execute an action on a session. Returns 0 on success. +/// Error codes: -1 = invalid slot, -2 = not authenticated, -3 = rate limited, -4 = invalid action. +pub export fn digitalocean_mcp_execute_action(slot_idx: c_int, action_code: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + if (slot.state != .authenticated) return -2; + + const action = std.meta.intToEnum(DigitaloceanAction, action_code) catch return -4; + _ = isDestructiveAction(action); + + if (slot.requests_remaining == 0) { + sessions[idx].state = .rate_limited; + return -3; + } + + sessions[idx].requests_remaining -= 1; + sessions[idx].last_action = action_code; + return 0; +} + +/// Get remaining rate limit for a session. Returns count or -1 if invalid. +pub export fn digitalocean_mcp_rate_limit_remaining(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + return @intCast(slot.requests_remaining); +} + +/// Reset rate limit (called when the hourly window resets). Returns 0 on success. +pub export fn digitalocean_mcp_rate_limit_reset(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + + sessions[idx].requests_remaining = RATE_LIMIT_PER_HOUR; + if (slot.state == .rate_limited) { + sessions[idx].state = .authenticated; + } + return 0; +} + +/// Signal an error on a session. Returns 0 on success. +pub export fn digitalocean_mcp_signal_error(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + if (!isValidTransition(slot.state, .err)) return -2; + + sessions[idx].state = .err; + return 0; +} + +/// Recover from error back to authenticated. Returns 0 on success. +pub export fn digitalocean_mcp_recover(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + if (slot.state != .err) return -2; + + sessions[idx].state = .authenticated; + return 0; +} + +/// Reset all sessions (test/debug use only). +pub export fn digitalocean_mcp_reset() void { + mutex.lock(); + defer mutex.unlock(); + sessions = .{SessionSlot{}} ** MAX_SESSIONS; +} + +// --------------------------------------------------------------------------- +// ═══════════════════════════════════════════════════════════════════════ +// Standard ABI (ADR-0005 four symbols + ADR-0006 invoke) +// ═══════════════════════════════════════════════════════════════════════ + +const shim = @import("cartridge_shim.zig"); + +const CARTRIDGE_NAME_PTR: [*:0]const u8 = "digitalocean-mcp"; +const CARTRIDGE_VERSION_PTR: [*:0]const u8 = "0.1.0"; + +export fn boj_cartridge_init() callconv(.c) c_int { + return 0; +} + +export fn boj_cartridge_deinit() callconv(.c) void {} + +export fn boj_cartridge_name() callconv(.c) [*:0]const u8 { + return CARTRIDGE_NAME_PTR; +} + +export fn boj_cartridge_version() callconv(.c) [*:0]const u8 { + return CARTRIDGE_VERSION_PTR; +} + +/// Dispatch the cartridge.json MCP tools. Grade D Alpha stubs. +export fn boj_cartridge_invoke( + tool_name: [*c]const u8, + json_args: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) callconv(.c) i32 { + _ = json_args; + if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; + + const body: []const u8 = if (shim.toolIs(tool_name, "digitalocean_list_droplets")) + "{\"result\":{\"items\":[],\"count\":0,\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "digitalocean_get_droplet")) + "{\"result\":{\"metadata\":{},\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "digitalocean_create_droplet")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "digitalocean_delete_droplet")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "digitalocean_droplet_action")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "digitalocean_list_volumes")) + "{\"result\":{\"items\":[],\"count\":0,\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "digitalocean_create_volume")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "digitalocean_list_domains")) + "{\"result\":{\"items\":[],\"count\":0,\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "digitalocean_create_domain")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "digitalocean_list_ssh_keys")) + "{\"result\":{\"items\":[],\"count\":0,\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "digitalocean_list_snapshots")) + "{\"result\":{\"items\":[],\"count\":0,\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "digitalocean_create_snapshot")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "digitalocean_list_databases")) + "{\"result\":{\"items\":[],\"count\":0,\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "digitalocean_list_firewalls")) + "{\"result\":{\"items\":[],\"count\":0,\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "digitalocean_create_firewall")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "digitalocean_list_load_balancers")) + "{\"result\":{\"items\":[],\"count\":0,\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "digitalocean_get_account")) + "{\"result\":{\"metadata\":{},\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "digitalocean_get_balance")) + "{\"result\":{\"metadata\":{},\"status\":\"stub\"}}" +else + return shim.RC_UNKNOWN_TOOL; + + return shim.writeResult(out_buf, in_out_len, body); +} + +// Tests +// --------------------------------------------------------------------------- + +test "authenticate and close lifecycle" { + digitalocean_mcp_reset(); + + const token = "dop_v1_test_token_abc123"; + const slot = digitalocean_mcp_authenticate(token.ptr, @intCast(token.len)); + try std.testing.expect(slot >= 0); + + // Should be authenticated + try std.testing.expectEqual(@as(c_int, 1), digitalocean_mcp_session_state(slot)); + + // Rate limit should be full + try std.testing.expectEqual(@as(c_int, 5000), digitalocean_mcp_rate_limit_remaining(slot)); + + // Close session + try std.testing.expectEqual(@as(c_int, 0), digitalocean_mcp_session_close(slot)); +} + +test "execute action decrements rate limit" { + digitalocean_mcp_reset(); + + const token = "dop_v1_test"; + const slot = digitalocean_mcp_authenticate(token.ptr, @intCast(token.len)); + try std.testing.expect(slot >= 0); + + // Execute ListDroplets (action 0) + try std.testing.expectEqual(@as(c_int, 0), digitalocean_mcp_execute_action(slot, 0)); + try std.testing.expectEqual(@as(c_int, 4999), digitalocean_mcp_rate_limit_remaining(slot)); + + // Execute GetAccount (action 15) + try std.testing.expectEqual(@as(c_int, 0), digitalocean_mcp_execute_action(slot, 15)); + try std.testing.expectEqual(@as(c_int, 4998), digitalocean_mcp_rate_limit_remaining(slot)); +} + +test "invalid transitions rejected" { + digitalocean_mcp_reset(); + + const token = "dop_v1_test"; + const slot = digitalocean_mcp_authenticate(token.ptr, @intCast(token.len)); + try std.testing.expect(slot >= 0); + + // Cannot go from authenticated to rate_limited directly via transition check + // (rate limiting happens via execute_action) + try std.testing.expectEqual(@as(c_int, 1), digitalocean_mcp_can_transition(1, 2)); // auth -> rate_limited: valid + + // Cannot go from unauthenticated to error + try std.testing.expectEqual(@as(c_int, 0), digitalocean_mcp_can_transition(0, 3)); +} + +test "error and recovery" { + digitalocean_mcp_reset(); + + const token = "dop_v1_test"; + const slot = digitalocean_mcp_authenticate(token.ptr, @intCast(token.len)); + try std.testing.expect(slot >= 0); + + // Signal error + try std.testing.expectEqual(@as(c_int, 0), digitalocean_mcp_signal_error(slot)); + try std.testing.expectEqual(@as(c_int, 3), digitalocean_mcp_session_state(slot)); + + // Recover + try std.testing.expectEqual(@as(c_int, 0), digitalocean_mcp_recover(slot)); + try std.testing.expectEqual(@as(c_int, 1), digitalocean_mcp_session_state(slot)); +} + +test "null token rejected" { + digitalocean_mcp_reset(); + try std.testing.expectEqual(@as(c_int, -2), digitalocean_mcp_authenticate(null, 0)); +} + +test "slot exhaustion" { + digitalocean_mcp_reset(); + + const token = "dop_v1_test"; + var slot_count: usize = 0; + while (slot_count < MAX_SESSIONS) : (slot_count += 1) { + const s = digitalocean_mcp_authenticate(token.ptr, @intCast(token.len)); + try std.testing.expect(s >= 0); + } + + // Next should fail + try std.testing.expectEqual(@as(c_int, -1), digitalocean_mcp_authenticate(token.ptr, @intCast(token.len))); +} + +// ═══════════════════════════════════════════════════════════════════════ +// ADR-0006 invoke dispatch tests +// ═══════════════════════════════════════════════════════════════════════ + +test "boj_cartridge_name returns digitalocean-mcp" { + const n = std.mem.span(boj_cartridge_name()); + try std.testing.expectEqualStrings("digitalocean-mcp", n); +} + +test "boj_cartridge_init returns 0" { + try std.testing.expectEqual(@as(c_int, 0), boj_cartridge_init()); +} + +test "invoke: each declared tool succeeds" { + var buf: [256]u8 = undefined; + const tools = [_][]const u8{ + "digitalocean_list_droplets", + "digitalocean_get_droplet", + "digitalocean_create_droplet", + "digitalocean_delete_droplet", + "digitalocean_droplet_action", + "digitalocean_list_volumes", + "digitalocean_create_volume", + "digitalocean_list_domains", + "digitalocean_create_domain", + "digitalocean_list_ssh_keys", + "digitalocean_list_snapshots", + "digitalocean_create_snapshot", + "digitalocean_list_databases", + "digitalocean_list_firewalls", + "digitalocean_create_firewall", + "digitalocean_list_load_balancers", + "digitalocean_get_account", + "digitalocean_get_balance", + }; + for (tools) |t| { + var len: usize = buf.len; + const rc = boj_cartridge_invoke(t.ptr, "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, 0), rc); + try std.testing.expect(std.mem.indexOf(u8, buf[0..len], "result") != null); + } +} + +test "invoke: unknown tool returns -1" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("nope", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -1), rc); +} + +test "invoke: buffer too small returns -3" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("digitalocean_list_droplets", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -3), rc); + try std.testing.expect(len > 4); +} diff --git a/cartridges/domains/cloud/digitalocean-mcp/minter.toml b/cartridges/domains/cloud/digitalocean-mcp/minter.toml new file mode 100644 index 0000000..909a05c --- /dev/null +++ b/cartridges/domains/cloud/digitalocean-mcp/minter.toml @@ -0,0 +1,44 @@ +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> + +name = "digitalocean-mcp" +description = "DigitalOcean cloud infrastructure MCP cartridge β€” droplets, volumes, domains, SSH keys, snapshots, databases" +version = "0.2.0" +domain = "Cloud" +protocols = [ + "MCP", + "REST", +] +tier = "Ayo" +backend = "universal" +generate_panel = true + +[auth] +method = "bearer" +token_type = "personal_access_token" +base_url = "https://api.digitalocean.com/v2/" + +[rate_limit] +requests = 5000 +window = "1h" + +[actions] +count = 16 +list = [ + "ListDroplets", + "GetDroplet", + "CreateDroplet", + "DeleteDroplet", + "PowerOn", + "PowerOff", + "Reboot", + "ListVolumes", + "CreateVolume", + "ListDomains", + "CreateDomain", + "ListSSHKeys", + "ListSnapshots", + "CreateSnapshot", + "ListDatabases", + "GetAccount", +] diff --git a/cartridges/domains/cloud/digitalocean-mcp/mod.js b/cartridges/domains/cloud/digitalocean-mcp/mod.js new file mode 100644 index 0000000..31cce08 --- /dev/null +++ b/cartridges/domains/cloud/digitalocean-mcp/mod.js @@ -0,0 +1,316 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// digitalocean-mcp/mod.js -- DigitalOcean API v2 cartridge implementation. +// +// Provides MCP tool handlers for DigitalOcean REST API v2: +// - Droplet management (list, get, create, delete, power actions) +// - Block storage volumes (list, create) +// - DNS domains (list, create) +// - SSH keys (list) +// - Snapshots (list, create) +// - Managed databases (list) +// - Firewalls (list, create) +// - Load balancers (list) +// - Account info and billing +// +// Auth: Bearer token via DIGITALOCEAN_TOKEN env var or vault-mcp proxy. +// API docs: https://docs.digitalocean.com/reference/api/api-reference/ +// Rate limit: 5000 requests per hour. +// +// Usage: import { handleTool } from "./mod.js"; +// or: deno run --allow-net --allow-env mod.js + +const API_BASE = "https://api.digitalocean.com/v2"; + +// --------------------------------------------------------------------------- +// Auth helper -- retrieves the DigitalOcean API token from environment. +// In production, vault-mcp provides zero-knowledge credential proxying; +// for development, DIGITALOCEAN_TOKEN is read directly. +// --------------------------------------------------------------------------- + +function getToken() { + const token = typeof Deno !== "undefined" + ? Deno.env.get("DIGITALOCEAN_TOKEN") + : process.env.DIGITALOCEAN_TOKEN; + if (!token) { + throw new Error("DIGITALOCEAN_TOKEN not set. Store in vault-mcp or export to environment."); + } + return token; +} + +// --------------------------------------------------------------------------- +// HTTP request helper -- wraps fetch with DigitalOcean auth headers, error +// handling, pagination, and rate-limit header extraction. +// DigitalOcean uses page-based pagination with link headers. +// --------------------------------------------------------------------------- + +async function doFetch(method, path, body, queryParams) { + const token = getToken(); + const url = new URL(`${API_BASE}${path}`); + + // Append query parameters (pagination, filters) + if (queryParams) { + for (const [key, value] of Object.entries(queryParams)) { + if (value !== undefined && value !== null && value !== "") { + url.searchParams.set(key, String(value)); + } + } + } + + const options = { + method, + headers: { + "Authorization": `Bearer ${token}`, + "Accept": "application/json", + "User-Agent": "boj-server/digitalocean-mcp/0.2.0", + }, + }; + + if (body && method !== "GET") { + options.headers["Content-Type"] = "application/json"; + options.body = JSON.stringify(body); + } + + const response = await fetch(url.toString(), options); + + // Extract rate-limit headers for caller awareness + const rateLimit = { + limit: response.headers.get("ratelimit-limit"), + remaining: response.headers.get("ratelimit-remaining"), + reset: response.headers.get("ratelimit-reset"), + }; + + // Handle 204 No Content (successful deletes) + if (response.status === 204) { + return { status: response.status, data: { success: true }, rateLimit }; + } + + const data = await response.json(); + + // Surface DigitalOcean API errors clearly + if (!response.ok) { + const errorMessage = data.message + ? data.message + : `HTTP ${response.status}: ${data.id || "unknown_error"}`; + return { status: response.status, error: errorMessage, data, rateLimit }; + } + + return { status: response.status, data, rateLimit }; +} + +// --------------------------------------------------------------------------- +// Tool handler dispatch -- maps MCP tool names to DigitalOcean API operations. +// Each handler validates required arguments, builds the API request, +// and returns structured results with rate-limit metadata. +// --------------------------------------------------------------------------- + +export async function handleTool(toolName, args) { + switch (toolName) { + + // --- Droplets --- + + case "digitalocean_list_droplets": { + const query = { + page: args.page, + per_page: args.per_page, + tag_name: args.tag_name, + name: args.name, + }; + return doFetch("GET", "/droplets", null, query); + } + + case "digitalocean_get_droplet": { + if (!args.droplet_id) return { error: "Missing required field: droplet_id" }; + return doFetch("GET", `/droplets/${args.droplet_id}`); + } + + case "digitalocean_create_droplet": { + if (!args.name) return { error: "Missing required field: name" }; + if (!args.region) return { error: "Missing required field: region" }; + if (!args.size) return { error: "Missing required field: size" }; + if (!args.image) return { error: "Missing required field: image" }; + const body = { + name: args.name, + region: args.region, + size: args.size, + image: args.image, + }; + if (args.ssh_keys) body.ssh_keys = args.ssh_keys; + if (args.backups !== undefined) body.backups = args.backups; + if (args.ipv6 !== undefined) body.ipv6 = args.ipv6; + if (args.monitoring !== undefined) body.monitoring = args.monitoring; + if (args.user_data) body.user_data = args.user_data; + if (args.tags) body.tags = args.tags; + if (args.vpc_uuid) body.vpc_uuid = args.vpc_uuid; + if (args.volumes) body.volumes = args.volumes; + return doFetch("POST", "/droplets", body); + } + + case "digitalocean_delete_droplet": { + if (!args.droplet_id) return { error: "Missing required field: droplet_id" }; + return doFetch("DELETE", `/droplets/${args.droplet_id}`); + } + + case "digitalocean_droplet_action": { + if (!args.droplet_id) return { error: "Missing required field: droplet_id" }; + if (!args.type) return { error: "Missing required field: type" }; + const validTypes = ["power_on", "power_off", "reboot", "shutdown", "power_cycle", "rebuild", "resize", "rename"]; + if (!validTypes.includes(args.type)) { + return { error: `Invalid action type '${args.type}'. Must be one of: ${validTypes.join(", ")}` }; + } + const body = { type: args.type }; + if (args.type === "rebuild" && args.image) body.image = args.image; + if (args.type === "resize" && args.size) body.size = args.size; + if (args.type === "rename" && args.name) body.name = args.name; + return doFetch("POST", `/droplets/${args.droplet_id}/actions`, body); + } + + // --- Volumes --- + + case "digitalocean_list_volumes": { + const query = { + page: args.page, + per_page: args.per_page, + region: args.region, + name: args.name, + }; + return doFetch("GET", "/volumes", null, query); + } + + case "digitalocean_create_volume": { + if (!args.name) return { error: "Missing required field: name" }; + if (!args.size_gigabytes) return { error: "Missing required field: size_gigabytes" }; + if (!args.region) return { error: "Missing required field: region" }; + const body = { + name: args.name, + size_gigabytes: args.size_gigabytes, + region: args.region, + }; + if (args.description) body.description = args.description; + if (args.filesystem_type) body.filesystem_type = args.filesystem_type; + if (args.tags) body.tags = args.tags; + if (args.snapshot_id) body.snapshot_id = args.snapshot_id; + return doFetch("POST", "/volumes", body); + } + + // --- Domains --- + + case "digitalocean_list_domains": { + const query = { + page: args.page, + per_page: args.per_page, + }; + return doFetch("GET", "/domains", null, query); + } + + case "digitalocean_create_domain": { + if (!args.name) return { error: "Missing required field: name" }; + const body = { name: args.name }; + if (args.ip_address) body.ip_address = args.ip_address; + return doFetch("POST", "/domains", body); + } + + // --- SSH Keys --- + + case "digitalocean_list_ssh_keys": { + const query = { + page: args.page, + per_page: args.per_page, + }; + return doFetch("GET", "/account/keys", null, query); + } + + // --- Snapshots --- + + case "digitalocean_list_snapshots": { + const query = { + page: args.page, + per_page: args.per_page, + resource_type: args.resource_type, + }; + return doFetch("GET", "/snapshots", null, query); + } + + case "digitalocean_create_snapshot": { + if (!args.droplet_id) return { error: "Missing required field: droplet_id" }; + if (!args.name) return { error: "Missing required field: name" }; + return doFetch("POST", `/droplets/${args.droplet_id}/actions`, { + type: "snapshot", + name: args.name, + }); + } + + // --- Databases --- + + case "digitalocean_list_databases": { + const query = { + page: args.page, + per_page: args.per_page, + tag_name: args.tag_name, + }; + return doFetch("GET", "/databases", null, query); + } + + // --- Firewalls --- + + case "digitalocean_list_firewalls": { + const query = { + page: args.page, + per_page: args.per_page, + }; + return doFetch("GET", "/firewalls", null, query); + } + + case "digitalocean_create_firewall": { + if (!args.name) return { error: "Missing required field: name" }; + if (!args.inbound_rules) return { error: "Missing required field: inbound_rules" }; + if (!args.outbound_rules) return { error: "Missing required field: outbound_rules" }; + const body = { + name: args.name, + inbound_rules: args.inbound_rules, + outbound_rules: args.outbound_rules, + }; + if (args.droplet_ids) body.droplet_ids = args.droplet_ids; + if (args.tags) body.tags = args.tags; + return doFetch("POST", "/firewalls", body); + } + + // --- Load Balancers --- + + case "digitalocean_list_load_balancers": { + const query = { + page: args.page, + per_page: args.per_page, + }; + return doFetch("GET", "/load_balancers", null, query); + } + + // --- Account --- + + case "digitalocean_get_account": { + return doFetch("GET", "/account"); + } + + case "digitalocean_get_balance": { + return doFetch("GET", "/customers/my/balance"); + } + + default: + return { error: `Unknown digitalocean-mcp tool: ${toolName}` }; + } +} + +// --------------------------------------------------------------------------- +// Cartridge metadata export -- used by the BoJ cartridge loader to register +// this cartridge's tools without reading cartridge.json separately. +// --------------------------------------------------------------------------- + +export const metadata = { + name: "digitalocean-mcp", + version: "0.2.0", + domain: "Cloud", + tier: "Ayo", + protocols: ["MCP", "REST"], + toolCount: 18, +}; diff --git a/cartridges/domains/cloud/digitalocean-mcp/panels/manifest.json b/cartridges/domains/cloud/digitalocean-mcp/panels/manifest.json new file mode 100644 index 0000000..f31b94f --- /dev/null +++ b/cartridges/domains/cloud/digitalocean-mcp/panels/manifest.json @@ -0,0 +1,97 @@ +{ + "$schema": "https://panll.dev/schemas/panel-manifest/v1.json", + "spdx": "MPL-2.0", + "cartridge": "digitalocean-mcp", + "domain": "Cloud", + "version": "0.2.0", + "panels": [ + { + "id": "digitalocean-auth-status", + "title": "Auth Status", + "description": "Current DigitalOcean authentication state (Unauthenticated / Authenticated / RateLimited / Error)", + "type": "status-indicator", + "data_source": { + "endpoint": "/digitalocean/state", + "method": "GET", + "refresh_interval_ms": 5000 + }, + "widgets": [ + { + "type": "state-badge", + "field": "state", + "states": { + "unauthenticated": { "color": "#95a5a6", "icon": "user-x" }, + "authenticated": { "color": "#2ecc71", "icon": "user-check" }, + "rate_limited": { "color": "#f39c12", "icon": "clock" }, + "error": { "color": "#e74c3c", "icon": "alert-triangle" } + } + } + ] + }, + { + "id": "digitalocean-droplet-count", + "title": "Droplet Count", + "description": "Total number of active droplets in the DigitalOcean account", + "type": "metric", + "data_source": { + "endpoint": "/digitalocean/droplets/count", + "method": "GET", + "refresh_interval_ms": 30000 + }, + "widgets": [ + { + "type": "counter", + "field": "count", + "label": "Droplets", + "icon": "server" + } + ] + }, + { + "id": "digitalocean-volume-count", + "title": "Volume Count", + "description": "Total number of block storage volumes attached to the account", + "type": "metric", + "data_source": { + "endpoint": "/digitalocean/volumes/count", + "method": "GET", + "refresh_interval_ms": 30000 + }, + "widgets": [ + { + "type": "counter", + "field": "count", + "label": "Volumes", + "icon": "hard-drive" + } + ] + }, + { + "id": "digitalocean-account-balance", + "title": "Account Balance", + "description": "Current DigitalOcean account balance and month-to-date usage", + "type": "metric", + "data_source": { + "endpoint": "/digitalocean/account/balance", + "method": "GET", + "refresh_interval_ms": 60000 + }, + "widgets": [ + { + "type": "currency", + "field": "month_to_date_balance", + "label": "MTD Balance", + "currency": "USD", + "icon": "dollar-sign" + }, + { + "type": "currency", + "field": "account_balance", + "label": "Account Balance", + "currency": "USD", + "icon": "credit-card" + } + ] + } + ] +} diff --git a/cartridges/domains/cloud/digitalocean-mcp/tests/integration_test.sh b/cartridges/domains/cloud/digitalocean-mcp/tests/integration_test.sh new file mode 100755 index 0000000..4a4c41d --- /dev/null +++ b/cartridges/domains/cloud/digitalocean-mcp/tests/integration_test.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Integration tests for digitalocean-mcp cartridge. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CART_DIR="$(dirname "$SCRIPT_DIR")" +FFI_DIR="$CART_DIR/ffi" + +echo "=== digitalocean-mcp integration tests ===" + +# Build FFI +echo "[1/3] Building FFI..." +cd "$FFI_DIR" && zig build 2>&1 + +# Run Zig unit tests +echo "[2/3] Running FFI unit tests..." +cd "$FFI_DIR" && zig build test 2>&1 + +# Validate Idris2 ABI (if idris2 available) +echo "[3/3] Checking ABI..." +if command -v idris2 &>/dev/null; then + cd "$CART_DIR/abi" && idris2 --check DigitaloceanMcp.SafeCloud 2>&1 + echo " ABI: OK" +else + echo " ABI: SKIPPED (idris2 not in PATH)" +fi + +echo "" +echo "All tests passed for digitalocean-mcp!" diff --git a/cartridges/domains/cloud/fly-mcp/README.adoc b/cartridges/domains/cloud/fly-mcp/README.adoc new file mode 100644 index 0000000..f0bfd44 --- /dev/null +++ b/cartridges/domains/cloud/fly-mcp/README.adoc @@ -0,0 +1,71 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += fly-mcp +Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk> +:spdx: MPL-2.0 +:tier: Ayo +:domain: Cloud +:protocols: MCP, REST + +== Overview + +Fly.io Machines API v1 cartridge for BoJ. Provides type-safe access to apps, +machines, volumes, secrets, regions, and IP allocation via the Machines REST API +at `https://api.machines.dev/v1/`. Authentication uses a Bearer token obtained +from `fly auth token`. + +== State Machine + +`Unauthenticated` -> `Authenticated` -> `RateLimited` -> `Error` + +Valid transitions are enforced by the Idris2 ABI layer with dependent-type proofs. + +== Actions + +ListApps, GetApp, CreateApp, DestroyApp, ListMachines, GetMachine, +StartMachine, StopMachine, ListVolumes, CreateVolume, ListSecrets, +SetSecret, DeleteSecret, ListRegions, AllocateIP, ReleaseIP. + +== Architecture + +[cols="1,1,2"] +|=== +| Layer | Language | Purpose + +| ABI +| Idris2 +| Formally verified state machine with dependent-type proofs + +| FFI +| Zig +| C-compatible implementation with thread-safe session pool + +| Adapter +| zig +| REST bridge to BoJ unified adapter protocol +|=== + +== Panels + +* Auth status (Unauthenticated / Authenticated / RateLimited / Error) +* App count +* Machine count +* Region distribution + +== Building + +[source,bash] +---- +# Build FFI shared library +cd ffi && zig build + +# Run FFI tests +cd ffi && zig build test + +# Type-check ABI +cd abi && idris2 --check FlyMcp.SafeCloud +---- + +== Status + +Development -- not yet ready for mounting. diff --git a/cartridges/domains/cloud/fly-mcp/abi/FlyMcp/SafeCloud.idr b/cartridges/domains/cloud/fly-mcp/abi/FlyMcp/SafeCloud.idr new file mode 100644 index 0000000..7b9601d --- /dev/null +++ b/cartridges/domains/cloud/fly-mcp/abi/FlyMcp/SafeCloud.idr @@ -0,0 +1,176 @@ +-- SPDX-License-Identifier: MPL-2.0 +-- Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +-- +-- FlyMcp.SafeCloud -- Type-safe ABI for fly-mcp cartridge (Fly.io Machines API). +-- +-- State machine with dependent-type proofs ensuring only valid transitions +-- can occur at the FFI boundary. Zero unsafe escape hatches. +-- Auth: Bearer token (fly auth token), REST API (https://api.machines.dev/v1/). + +module FlyMcp.SafeCloud + +%default total + +-- --------------------------------------------------------------------------- +-- Authentication / session state machine +-- --------------------------------------------------------------------------- + +||| Authentication and session state for Fly.io Machines API operations. +public export +data SessionState + = Unauthenticated + | Authenticated + | RateLimited + | Error + +||| Proof that a state transition is valid. +public export +data ValidTransition : SessionState -> SessionState -> Type where + Authenticate : ValidTransition Unauthenticated Authenticated + BeginRateLimit : ValidTransition Authenticated RateLimited + EndRateLimit : ValidTransition RateLimited Authenticated + AuthError : ValidTransition Unauthenticated Error + OpError : ValidTransition Authenticated Error + RateError : ValidTransition RateLimited Error + RecoverAuth : ValidTransition Error Unauthenticated + Deauthenticate : ValidTransition Authenticated Unauthenticated + +-- --------------------------------------------------------------------------- +-- C-ABI integer encoding +-- --------------------------------------------------------------------------- + +||| Encode session state as C-compatible integer. +export +sessionStateToInt : SessionState -> Int +sessionStateToInt Unauthenticated = 0 +sessionStateToInt Authenticated = 1 +sessionStateToInt RateLimited = 2 +sessionStateToInt Error = 3 + +||| Decode integer back to session state. +export +intToSessionState : Int -> Maybe SessionState +intToSessionState 0 = Just Unauthenticated +intToSessionState 1 = Just Authenticated +intToSessionState 2 = Just RateLimited +intToSessionState 3 = Just Error +intToSessionState _ = Nothing + +||| Check if a state transition is valid (C-ABI export). +||| Returns 1 for valid, 0 for invalid. +export +fly_mcp_can_transition : Int -> Int -> Int +fly_mcp_can_transition from to = + case (intToSessionState from, intToSessionState to) of + (Just Unauthenticated, Just Authenticated) => 1 + (Just Authenticated, Just RateLimited) => 1 + (Just RateLimited, Just Authenticated) => 1 + (Just Unauthenticated, Just Error) => 1 + (Just Authenticated, Just Error) => 1 + (Just RateLimited, Just Error) => 1 + (Just Error, Just Unauthenticated) => 1 + (Just Authenticated, Just Unauthenticated) => 1 + _ => 0 + +-- --------------------------------------------------------------------------- +-- Fly.io Machines API actions +-- --------------------------------------------------------------------------- + +||| Actions available on the Fly.io Machines API v1. +public export +data FlyAction + = ListApps + | GetApp + | CreateApp + | DestroyApp + | ListMachines + | GetMachine + | StartMachine + | StopMachine + | ListVolumes + | CreateVolume + | ListSecrets + | SetSecret + | DeleteSecret + | ListRegions + | AllocateIP + | ReleaseIP + +||| Encode action as C-compatible integer for FFI. +export +flyActionToInt : FlyAction -> Int +flyActionToInt ListApps = 0 +flyActionToInt GetApp = 1 +flyActionToInt CreateApp = 2 +flyActionToInt DestroyApp = 3 +flyActionToInt ListMachines = 4 +flyActionToInt GetMachine = 5 +flyActionToInt StartMachine = 6 +flyActionToInt StopMachine = 7 +flyActionToInt ListVolumes = 8 +flyActionToInt CreateVolume = 9 +flyActionToInt ListSecrets = 10 +flyActionToInt SetSecret = 11 +flyActionToInt DeleteSecret = 12 +flyActionToInt ListRegions = 13 +flyActionToInt AllocateIP = 14 +flyActionToInt ReleaseIP = 15 + +||| Decode integer back to action. +export +intToFlyAction : Int -> Maybe FlyAction +intToFlyAction 0 = Just ListApps +intToFlyAction 1 = Just GetApp +intToFlyAction 2 = Just CreateApp +intToFlyAction 3 = Just DestroyApp +intToFlyAction 4 = Just ListMachines +intToFlyAction 5 = Just GetMachine +intToFlyAction 6 = Just StartMachine +intToFlyAction 7 = Just StopMachine +intToFlyAction 8 = Just ListVolumes +intToFlyAction 9 = Just CreateVolume +intToFlyAction 10 = Just ListSecrets +intToFlyAction 11 = Just SetSecret +intToFlyAction 12 = Just DeleteSecret +intToFlyAction 13 = Just ListRegions +intToFlyAction 14 = Just AllocateIP +intToFlyAction 15 = Just ReleaseIP +intToFlyAction _ = Nothing + +||| Whether an action requires Authenticated state. +export +actionRequiresAuth : FlyAction -> Bool +actionRequiresAuth ListRegions = False +actionRequiresAuth _ = True + +||| Total number of actions exposed by this cartridge. +export +actionCount : Nat +actionCount = 16 + +-- --------------------------------------------------------------------------- +-- MCP tool declarations +-- --------------------------------------------------------------------------- + +||| Tools exposed via MCP protocol. +public export +data McpTool + = ToolAuthenticate + | ToolDeauthenticate + | ToolStatus + | ToolInvoke + | ToolList + +||| Check if a tool requires an authenticated session. +export +toolRequiresAuth : McpTool -> Bool +toolRequiresAuth ToolAuthenticate = False +toolRequiresAuth ToolDeauthenticate = True +toolRequiresAuth ToolStatus = False +toolRequiresAuth ToolInvoke = True +toolRequiresAuth ToolList = False + +||| Tool count for this cartridge. +export +toolCount : Nat +toolCount = 5 diff --git a/cartridges/domains/cloud/fly-mcp/abi/README.adoc b/cartridges/domains/cloud/fly-mcp/abi/README.adoc new file mode 100644 index 0000000..e9cbd7c --- /dev/null +++ b/cartridges/domains/cloud/fly-mcp/abi/README.adoc @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += fly-mcp / abi β€” Idris2 ABI layer +:orientation: shallow + +== Purpose + +Cartridge-local protocol types for `fly-mcp`. Defines the data, tool, and +transition vocabulary this cartridge speaks. Inherits safety lemmas and the +matrix-cell identifier scheme from the trunk ABI at `src/abi/`. + +== Files + +1 Idris2 module(s), ~176 lines total. Lead module: +`SafeCloud.idr`. + +No `.ipkg` β€” consumed directly by wider BoJ callers; type-checks inside the +trunk `boj.ipkg`. + +== Invariants + +* `%default total` at every module top. +* Zero `believe_me`, `assert_total`, `postulate`, `Admitted`, or + `sorry` (the trunk holds the four declared `believe_me` sites; leaf + cartridges carry none). + +== Test/proof surface + +Thin module(s), no inline tests. Proof surface = type-check success under the +trunk `idris2 --check`. + +== Read-first + +. `SafeCloud.idr` β€” cartridge-local data records and enums. +. Trunk `src/abi/Boj/Catalogue.idr` for how this cell participates in the + 2D matrix. diff --git a/cartridges/domains/cloud/fly-mcp/abi/fly_mcp.ipkg b/cartridges/domains/cloud/fly-mcp/abi/fly_mcp.ipkg new file mode 100644 index 0000000..8405e38 --- /dev/null +++ b/cartridges/domains/cloud/fly-mcp/abi/fly_mcp.ipkg @@ -0,0 +1,13 @@ +-- SPDX-License-Identifier: MPL-2.0 +-- Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +package fly_mcp + +version = "0.1.0" +authors = "Jonathan D.A. Jewell" +brief = "Fly.io Machines API v1 cartridge β€” type-safe ABI with dependent-type proofs" + +depends = base + +sourcedir = "." + +modules = FlyMcp.SafeCloud diff --git a/cartridges/domains/cloud/fly-mcp/adapter/README.adoc b/cartridges/domains/cloud/fly-mcp/adapter/README.adoc new file mode 100644 index 0000000..da28a93 --- /dev/null +++ b/cartridges/domains/cloud/fly-mcp/adapter/README.adoc @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += fly-mcp / adapter β€” protocol bridge +:orientation: shallow + +== Purpose + +Exposes this cartridge's FFI over the wire protocols declared in +`cartridge.json`. Dispatches tool invocations to the C-ABI exports at +`../ffi/` and formats responses per protocol. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” depends on the sibling `ffi` target. +| `fly_adapter.zig` | Protocol dispatch (248 lines). Tool catalogue matches `cartridge.json`. +| `SIDELINED-*.v.adoc` | zig predecessor(s). Not built β€” zig banned 2026-04-10. +|=== + +== Invariants + +* **Stateless** β€” all state lives behind the FFI, never in the adapter. +* **Response passthrough** β€” whatever the FFI returns goes back to the wire + unmodified (no embellishment, no silent recovery). +* **Cartridge.json is source of truth** for the tool catalogue; drift between + adapter and manifest is a CI failure. + +== Test/proof surface + +No inline tests at the adapter layer β€” protocol routing is exercised by the +trunk seam tests at `ffi/zig/src/seams.zig`. + +== Read-first + +. `fly_adapter.zig` β€” dispatch function and per-protocol routers. +. `../cartridge.json` β€” the tool manifest the dispatcher mirrors. diff --git a/cartridges/domains/cloud/fly-mcp/adapter/build.zig b/cartridges/domains/cloud/fly-mcp/adapter/build.zig new file mode 100644 index 0000000..4d2dbcb --- /dev/null +++ b/cartridges/domains/cloud/fly-mcp/adapter/build.zig @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// fly-mcp/adapter/build.zig + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const ffi_mod = b.createModule(.{ + .root_source_file = b.path("../ffi/fly_mcp_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + const adapter = b.addExecutable(.{ + .name = "fly_adapter", + .root_source_file = b.path("fly_adapter.zig"), + .target = target, + .optimize = optimize, + }); + adapter.root_module.addImport("fly_mcp_ffi", ffi_mod); + b.installArtifact(adapter); + + const run_artifact = b.addRunArtifact(adapter); + const run_step = b.step("run", "Run the fly-mcp adapter"); + run_step.dependOn(&run_artifact.step); + + const tests = b.addTest(.{ + .root_source_file = b.path("fly_adapter.zig"), + .target = target, + .optimize = optimize, + }); + tests.root_module.addImport("fly_mcp_ffi", ffi_mod); + const run_tests = b.addRunArtifact(tests); + const test_step = b.step("test", "Run fly-mcp adapter tests"); + test_step.dependOn(&run_tests.step); +} diff --git a/cartridges/domains/cloud/fly-mcp/adapter/fly_adapter.zig b/cartridges/domains/cloud/fly-mcp/adapter/fly_adapter.zig new file mode 100644 index 0000000..2497465 --- /dev/null +++ b/cartridges/domains/cloud/fly-mcp/adapter/fly_adapter.zig @@ -0,0 +1,248 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// fly-mcp/adapter/fly_adapter.zig -- Unified three-protocol adapter. +// +// Replaces the banned fly_adapter.v (zig, removed 2026-04-12). +// +// Bridges the Zig FFI (fly_mcp_ffi.zig) to three network protocols: +// REST :9049 POST /tools/<tool> +// gRPC-compat :9050 /FlyMcpService/<Method> +// GraphQL :9051 POST /graphql { query: "..." } +// +// Fly.io platform: apps, machines, volumes, secrets, certificates, IPs +// Tools: +// fly_list_apps +// fly_get_app +// fly_create_app +// fly_destroy_app +// fly_list_machines +// fly_get_machine +// fly_create_machine +// fly_start_machine +// fly_stop_machine +// fly_restart_machine +// fly_destroy_machine +// fly_list_volumes +// fly_create_volume +// fly_list_secrets +// fly_set_secrets +// fly_delete_secret +// fly_list_certificates +// fly_add_certificate +// fly_list_regions +// fly_allocate_ip +// fly_release_ip + +const std = @import("std"); +const ffi = @import("fly_mcp_ffi"); + +const REST_PORT: u16 = 9049; +const GRPC_PORT: u16 = 9050; +const GQL_PORT: u16 = 9051; + +const MAX_CONN_BUF: usize = 16 * 1024; + +// ============================================================================ +// JSON response builders +// ============================================================================ + +fn okJson(buf: []u8, msg: []const u8) []u8 { + return std.fmt.bufPrint(buf, + \{{"success":true,"message":"{}"}} + , .{msg}) catch buf[0..0]; +} + +fn errJson(buf: []u8, msg: []const u8) []u8 { + return std.fmt.bufPrint(buf, + \{{"success":false,"error":"{}"}} + , .{msg}) catch buf[0..0]; +} + +fn statusJson(buf: []u8) []u8 { + return std.fmt.bufPrint(buf, + \{{"success":true,"state":"ready","service":"fly-mcp"}} + , .{}) catch buf[0..0]; +} + +// ============================================================================ +// Tool dispatcher +// ============================================================================ + +const Response = struct { status: u16, body: []u8 }; + +fn dispatch(tool: []const u8, body: []const u8, resp: []u8) Response { + _ = body; + if (std.mem.eql(u8, tool, "fly_list_apps")) return .{ .status = 200, .body = okJson(resp, "fly_list_apps forwarded to backend") }; + if (std.mem.eql(u8, tool, "fly_get_app")) return .{ .status = 200, .body = okJson(resp, "fly_get_app forwarded to backend") }; + if (std.mem.eql(u8, tool, "fly_create_app")) return .{ .status = 200, .body = okJson(resp, "fly_create_app forwarded to backend") }; + if (std.mem.eql(u8, tool, "fly_destroy_app")) return .{ .status = 200, .body = okJson(resp, "fly_destroy_app forwarded to backend") }; + if (std.mem.eql(u8, tool, "fly_list_machines")) return .{ .status = 200, .body = okJson(resp, "fly_list_machines forwarded to backend") }; + if (std.mem.eql(u8, tool, "fly_get_machine")) return .{ .status = 200, .body = okJson(resp, "fly_get_machine forwarded to backend") }; + if (std.mem.eql(u8, tool, "fly_create_machine")) return .{ .status = 200, .body = okJson(resp, "fly_create_machine forwarded to backend") }; + if (std.mem.eql(u8, tool, "fly_start_machine")) return .{ .status = 200, .body = okJson(resp, "fly_start_machine forwarded to backend") }; + if (std.mem.eql(u8, tool, "fly_stop_machine")) return .{ .status = 200, .body = okJson(resp, "fly_stop_machine forwarded to backend") }; + if (std.mem.eql(u8, tool, "fly_restart_machine")) return .{ .status = 200, .body = okJson(resp, "fly_restart_machine forwarded to backend") }; + if (std.mem.eql(u8, tool, "fly_destroy_machine")) return .{ .status = 200, .body = okJson(resp, "fly_destroy_machine forwarded to backend") }; + if (std.mem.eql(u8, tool, "fly_list_volumes")) return .{ .status = 200, .body = okJson(resp, "fly_list_volumes forwarded to backend") }; + if (std.mem.eql(u8, tool, "fly_create_volume")) return .{ .status = 200, .body = okJson(resp, "fly_create_volume forwarded to backend") }; + if (std.mem.eql(u8, tool, "fly_list_secrets")) return .{ .status = 200, .body = okJson(resp, "fly_list_secrets forwarded to backend") }; + if (std.mem.eql(u8, tool, "fly_set_secrets")) return .{ .status = 200, .body = okJson(resp, "fly_set_secrets forwarded to backend") }; + if (std.mem.eql(u8, tool, "fly_delete_secret")) return .{ .status = 200, .body = okJson(resp, "fly_delete_secret forwarded to backend") }; + if (std.mem.eql(u8, tool, "fly_list_certificates")) return .{ .status = 200, .body = okJson(resp, "fly_list_certificates forwarded to backend") }; + if (std.mem.eql(u8, tool, "fly_add_certificate")) return .{ .status = 200, .body = okJson(resp, "fly_add_certificate forwarded to backend") }; + if (std.mem.eql(u8, tool, "fly_list_regions")) return .{ .status = 200, .body = okJson(resp, "fly_list_regions forwarded to backend") }; + if (std.mem.eql(u8, tool, "fly_allocate_ip")) return .{ .status = 200, .body = okJson(resp, "fly_allocate_ip forwarded to backend") }; + if (std.mem.eql(u8, tool, "fly_release_ip")) return .{ .status = 200, .body = okJson(resp, "fly_release_ip forwarded to backend") }; + if (std.mem.eql(u8, tool, "status") or std.mem.eql(u8, tool, "health")) + return .{ .status = 200, .body = statusJson(resp) }; + return .{ .status = 404, .body = errJson(resp, "Unknown tool") }; +} + +// ============================================================================ +// REST handler +// ============================================================================ + +fn dispatchRest(path: []const u8, body: []const u8, resp: []u8) Response { + if (std.mem.startsWith(u8, path, "/tools/")) { + return dispatch(path["/tools/".len..], body, resp); + } + if (std.mem.eql(u8, path, "/status") or std.mem.eql(u8, path, "/health")) { + return .{ .status = 200, .body = statusJson(resp) }; + } + return .{ .status = 404, .body = errJson(resp, "Not found") }; +} + +// ============================================================================ +// gRPC-compat handler +// ============================================================================ + +fn dispatchGrpc(path: []const u8, body: []const u8, resp: []u8) Response { + const prefix = "/FlyMcpService/"; + if (!std.mem.startsWith(u8, path, prefix)) + return .{ .status = 404, .body = errJson(resp, "Not a recognized gRPC path") }; + const method = path[prefix.len..]; + const tool = blk: { + if (std.mem.eql(u8, method, "FlyListApps")) break :blk "fly_list_apps"; + if (std.mem.eql(u8, method, "FlyGetApp")) break :blk "fly_get_app"; + if (std.mem.eql(u8, method, "FlyCreateApp")) break :blk "fly_create_app"; + if (std.mem.eql(u8, method, "FlyDestroyApp")) break :blk "fly_destroy_app"; + if (std.mem.eql(u8, method, "FlyListMachines")) break :blk "fly_list_machines"; + if (std.mem.eql(u8, method, "FlyGetMachine")) break :blk "fly_get_machine"; + if (std.mem.eql(u8, method, "FlyCreateMachine")) break :blk "fly_create_machine"; + if (std.mem.eql(u8, method, "FlyStartMachine")) break :blk "fly_start_machine"; + if (std.mem.eql(u8, method, "FlyStopMachine")) break :blk "fly_stop_machine"; + if (std.mem.eql(u8, method, "FlyRestartMachine")) break :blk "fly_restart_machine"; + if (std.mem.eql(u8, method, "FlyDestroyMachine")) break :blk "fly_destroy_machine"; + if (std.mem.eql(u8, method, "FlyListVolumes")) break :blk "fly_list_volumes"; + if (std.mem.eql(u8, method, "FlyCreateVolume")) break :blk "fly_create_volume"; + if (std.mem.eql(u8, method, "FlyListSecrets")) break :blk "fly_list_secrets"; + if (std.mem.eql(u8, method, "FlySetSecrets")) break :blk "fly_set_secrets"; + if (std.mem.eql(u8, method, "FlyDeleteSecret")) break :blk "fly_delete_secret"; + if (std.mem.eql(u8, method, "FlyListCertificates")) break :blk "fly_list_certificates"; + if (std.mem.eql(u8, method, "FlyAddCertificate")) break :blk "fly_add_certificate"; + if (std.mem.eql(u8, method, "FlyListRegions")) break :blk "fly_list_regions"; + if (std.mem.eql(u8, method, "FlyAllocateIp")) break :blk "fly_allocate_ip"; + if (std.mem.eql(u8, method, "FlyReleaseIp")) break :blk "fly_release_ip"; + return .{ .status = 404, .body = errJson(resp, "Unknown gRPC method") }; + }; + return dispatch(tool, body, resp); +} + +// ============================================================================ +// GraphQL handler +// ============================================================================ + +fn dispatchGraphql(body: []const u8, resp: []u8) Response { + if (std.mem.indexOf(u8, body, "__schema") != null) + return .{ .status = 200, .body = okJson(resp, "schema introspection not yet supported") }; + if (std.mem.indexOf(u8, body, "list_apps") != null) return dispatch("fly_list_apps", body, resp); + if (std.mem.indexOf(u8, body, "get_app") != null) return dispatch("fly_get_app", body, resp); + if (std.mem.indexOf(u8, body, "create_app") != null) return dispatch("fly_create_app", body, resp); + if (std.mem.indexOf(u8, body, "destroy_app") != null) return dispatch("fly_destroy_app", body, resp); + if (std.mem.indexOf(u8, body, "list_machines") != null) return dispatch("fly_list_machines", body, resp); + if (std.mem.indexOf(u8, body, "get_machine") != null) return dispatch("fly_get_machine", body, resp); + if (std.mem.indexOf(u8, body, "create_machine") != null) return dispatch("fly_create_machine", body, resp); + if (std.mem.indexOf(u8, body, "start_machine") != null) return dispatch("fly_start_machine", body, resp); + if (std.mem.indexOf(u8, body, "stop_machine") != null) return dispatch("fly_stop_machine", body, resp); + if (std.mem.indexOf(u8, body, "restart_machine") != null) return dispatch("fly_restart_machine", body, resp); + if (std.mem.indexOf(u8, body, "destroy_machine") != null) return dispatch("fly_destroy_machine", body, resp); + if (std.mem.indexOf(u8, body, "list_volumes") != null) return dispatch("fly_list_volumes", body, resp); + if (std.mem.indexOf(u8, body, "create_volume") != null) return dispatch("fly_create_volume", body, resp); + if (std.mem.indexOf(u8, body, "list_secrets") != null) return dispatch("fly_list_secrets", body, resp); + if (std.mem.indexOf(u8, body, "set_secrets") != null) return dispatch("fly_set_secrets", body, resp); + if (std.mem.indexOf(u8, body, "delete_secret") != null) return dispatch("fly_delete_secret", body, resp); + if (std.mem.indexOf(u8, body, "list_certificates") != null) return dispatch("fly_list_certificates", body, resp); + if (std.mem.indexOf(u8, body, "add_certificate") != null) return dispatch("fly_add_certificate", body, resp); + if (std.mem.indexOf(u8, body, "list_regions") != null) return dispatch("fly_list_regions", body, resp); + if (std.mem.indexOf(u8, body, "allocate_ip") != null) return dispatch("fly_allocate_ip", body, resp); + if (std.mem.indexOf(u8, body, "release_ip") != null) return dispatch("fly_release_ip", body, resp); + return .{ .status = 200, .body = errJson(resp, "Unrecognised GraphQL operation") }; +} + +// ============================================================================ +// HTTP/1.1 connection handler +// ============================================================================ + +const Protocol = enum { rest, grpc, graphql }; + +fn handleConnection(conn: std.net.Server.Connection, proto: Protocol) void { + defer conn.stream.close(); + var in_buf: [MAX_CONN_BUF]u8 = undefined; + const n = conn.stream.read(&in_buf) catch return; + const req = in_buf[0..n]; + + var path: []const u8 = "/"; + var body: []const u8 = ""; + if (n > 4) { + const line_end = std.mem.indexOf(u8, req, "\r\n") orelse req.len; + const first_line = req[0..line_end]; + const sp1 = std.mem.indexOfScalar(u8, first_line, ' ') orelse 0; + const rest_of = first_line[sp1 + 1 ..]; + const sp2 = std.mem.indexOfScalar(u8, rest_of, ' ') orelse rest_of.len; + path = rest_of[0..sp2]; + const body_sep = std.mem.indexOf(u8, req, "\r\n\r\n") orelse n; + body = req[@min(body_sep + 4, n)..]; + } + + var resp_buf: [MAX_CONN_BUF]u8 = undefined; + const result = switch (proto) { + .rest => dispatchRest(path, body, &resp_buf), + .grpc => dispatchGrpc(path, body, &resp_buf), + .graphql => dispatchGraphql(body, &resp_buf), + }; + + const content_type = switch (proto) { + .rest => "application/json", + .grpc => "application/grpc+json", + .graphql => "application/json", + }; + + var hdr_buf: [256]u8 = undefined; + const hdr = std.fmt.bufPrint(&hdr_buf, + "HTTP/1.1 {d} OK\r\nContent-Type: {s}\r\nContent-Length: {d}\r\nConnection: close\r\n\r\n", + .{ result.status, content_type, result.body.len }, + ) catch return; + _ = conn.stream.writeAll(hdr) catch return; + _ = conn.stream.writeAll(result.body) catch return; +} + +fn listenLoop(port: u16, proto: Protocol) void { + const addr = std.net.Address.initIp4(.{ 127, 0, 0, 1 }, port) catch return; + var server = addr.listen(.{ .reuse_address = true }) catch return; + defer server.deinit(); + while (true) { + const conn = server.accept() catch continue; + handleConnection(conn, proto); + } +} + +pub fn main() !void { + ffi.fly_init(); + const rest_thread = try std.Thread.spawn(.{}, listenLoop, .{ REST_PORT, Protocol.rest }); + const grpc_thread = try std.Thread.spawn(.{}, listenLoop, .{ GRPC_PORT, Protocol.grpc }); + const gql_thread = try std.Thread.spawn(.{}, listenLoop, .{ GQL_PORT, Protocol.graphql }); + rest_thread.join(); + grpc_thread.join(); + gql_thread.join(); +} diff --git a/cartridges/domains/cloud/fly-mcp/benchmarks/quick-bench.sh b/cartridges/domains/cloud/fly-mcp/benchmarks/quick-bench.sh new file mode 100755 index 0000000..720e609 --- /dev/null +++ b/cartridges/domains/cloud/fly-mcp/benchmarks/quick-bench.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Benchmarks for fly-mcp cartridge. +set -euo pipefail + +echo "=== fly-mcp benchmarks ===" + +cd "$(dirname "${BASH_SOURCE[0]}")/../ffi" + +# Build with ReleaseFast for benchmarking +zig build -Doptimize=ReleaseFast 2>&1 + +echo "Session open/close cycle (1000 iterations):" +time for i in $(seq 1 1000); do + # This would call the FFI benchmark binary + true +done + +echo "" +echo "Benchmark placeholder β€” implement real benchmarks in Zig test blocks" +echo "or via the zig adapter HTTP benchmark tool." diff --git a/cartridges/domains/cloud/fly-mcp/cartridge.json b/cartridges/domains/cloud/fly-mcp/cartridge.json new file mode 100644 index 0000000..85446f7 --- /dev/null +++ b/cartridges/domains/cloud/fly-mcp/cartridge.json @@ -0,0 +1,478 @@ +{ + "$schema": "https://boj.dev/schemas/cartridge/v1.json", + "spdx": "MPL-2.0", + "copyright": "Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath)", + "name": "fly-mcp", + "version": "0.1.0", + "description": "Fly.io Machines API v1 cartridge -- app, machine, volume, secret, region, IP, and certificate management", + "domain": "Cloud", + "tier": "Ayo", + "protocols": [ + "MCP", + "REST" + ], + "auth": { + "method": "bearer_token", + "env_var": "FLY_API_TOKEN", + "credential_source": "vault-mcp" + }, + "api": { + "base_url": "https://api.machines.dev/v1", + "content_type": "application/json" + }, + "tools": [ + { + "name": "fly_list_apps", + "description": "List all Fly.io apps in the authenticated organisation", + "inputSchema": { + "type": "object", + "properties": { + "org_slug": { + "type": "string", + "description": "Organisation slug (default: personal)" + } + } + } + }, + { + "name": "fly_get_app", + "description": "Get details of a specific Fly.io app", + "inputSchema": { + "type": "object", + "properties": { + "app_name": { + "type": "string", + "description": "App name" + } + }, + "required": [ + "app_name" + ] + } + }, + { + "name": "fly_create_app", + "description": "Create a new Fly.io app", + "inputSchema": { + "type": "object", + "properties": { + "app_name": { + "type": "string", + "description": "App name (must be globally unique)" + }, + "org_slug": { + "type": "string", + "description": "Organisation slug (default: personal)" + }, + "network": { + "type": "string", + "description": "Network name to join (optional)" + } + }, + "required": [ + "app_name" + ] + } + }, + { + "name": "fly_destroy_app", + "description": "Destroy a Fly.io app and all its machines (irreversible)", + "inputSchema": { + "type": "object", + "properties": { + "app_name": { + "type": "string", + "description": "App name to destroy" + } + }, + "required": [ + "app_name" + ] + } + }, + { + "name": "fly_list_machines", + "description": "List all machines in a Fly.io app", + "inputSchema": { + "type": "object", + "properties": { + "app_name": { + "type": "string", + "description": "App name" + }, + "include_deleted": { + "type": "boolean", + "description": "Include deleted machines (default false)" + }, + "region": { + "type": "string", + "description": "Filter by region code (e.g. lhr, iad, sin)" + } + }, + "required": [ + "app_name" + ] + } + }, + { + "name": "fly_get_machine", + "description": "Get details of a specific machine in a Fly.io app", + "inputSchema": { + "type": "object", + "properties": { + "app_name": { + "type": "string", + "description": "App name" + }, + "machine_id": { + "type": "string", + "description": "Machine ID" + } + }, + "required": [ + "app_name", + "machine_id" + ] + } + }, + { + "name": "fly_create_machine", + "description": "Create a new machine in a Fly.io app", + "inputSchema": { + "type": "object", + "properties": { + "app_name": { + "type": "string", + "description": "App name" + }, + "name": { + "type": "string", + "description": "Machine name (optional)" + }, + "region": { + "type": "string", + "description": "Region code (e.g. lhr, iad, sin, cdg, nrt)" + }, + "config": { + "type": "object", + "description": "Machine config (image, guest: {cpus, memory_mb}, services, env, auto_destroy)" + } + }, + "required": [ + "app_name", + "config" + ] + } + }, + { + "name": "fly_start_machine", + "description": "Start a stopped Fly.io machine", + "inputSchema": { + "type": "object", + "properties": { + "app_name": { + "type": "string", + "description": "App name" + }, + "machine_id": { + "type": "string", + "description": "Machine ID" + } + }, + "required": [ + "app_name", + "machine_id" + ] + } + }, + { + "name": "fly_stop_machine", + "description": "Stop a running Fly.io machine", + "inputSchema": { + "type": "object", + "properties": { + "app_name": { + "type": "string", + "description": "App name" + }, + "machine_id": { + "type": "string", + "description": "Machine ID" + }, + "signal": { + "type": "string", + "description": "Signal to send (e.g. SIGTERM, SIGKILL). Default SIGTERM." + } + }, + "required": [ + "app_name", + "machine_id" + ] + } + }, + { + "name": "fly_restart_machine", + "description": "Restart a Fly.io machine (stop then start)", + "inputSchema": { + "type": "object", + "properties": { + "app_name": { + "type": "string", + "description": "App name" + }, + "machine_id": { + "type": "string", + "description": "Machine ID" + }, + "timeout": { + "type": "string", + "description": "Timeout for graceful shutdown (e.g. '30s')" + } + }, + "required": [ + "app_name", + "machine_id" + ] + } + }, + { + "name": "fly_destroy_machine", + "description": "Destroy a Fly.io machine (irreversible)", + "inputSchema": { + "type": "object", + "properties": { + "app_name": { + "type": "string", + "description": "App name" + }, + "machine_id": { + "type": "string", + "description": "Machine ID" + }, + "force": { + "type": "boolean", + "description": "Force destroy even if running (default false)" + } + }, + "required": [ + "app_name", + "machine_id" + ] + } + }, + { + "name": "fly_list_volumes", + "description": "List all volumes in a Fly.io app", + "inputSchema": { + "type": "object", + "properties": { + "app_name": { + "type": "string", + "description": "App name" + } + }, + "required": [ + "app_name" + ] + } + }, + { + "name": "fly_create_volume", + "description": "Create a new persistent volume for a Fly.io app", + "inputSchema": { + "type": "object", + "properties": { + "app_name": { + "type": "string", + "description": "App name" + }, + "name": { + "type": "string", + "description": "Volume name" + }, + "region": { + "type": "string", + "description": "Region code" + }, + "size_gb": { + "type": "number", + "description": "Size in GB (min 1)" + }, + "encrypted": { + "type": "boolean", + "description": "Encrypt volume (default true)" + }, + "snapshot_id": { + "type": "string", + "description": "Snapshot ID to restore from (optional)" + } + }, + "required": [ + "app_name", + "name", + "region", + "size_gb" + ] + } + }, + { + "name": "fly_list_secrets", + "description": "List secret names (not values) for a Fly.io app", + "inputSchema": { + "type": "object", + "properties": { + "app_name": { + "type": "string", + "description": "App name" + } + }, + "required": [ + "app_name" + ] + } + }, + { + "name": "fly_set_secrets", + "description": "Set one or more secrets on a Fly.io app (triggers redeploy)", + "inputSchema": { + "type": "object", + "properties": { + "app_name": { + "type": "string", + "description": "App name" + }, + "secrets": { + "type": "object", + "description": "Key-value map of secrets to set" + } + }, + "required": [ + "app_name", + "secrets" + ] + } + }, + { + "name": "fly_delete_secret", + "description": "Remove a secret from a Fly.io app (triggers redeploy)", + "inputSchema": { + "type": "object", + "properties": { + "app_name": { + "type": "string", + "description": "App name" + }, + "key": { + "type": "string", + "description": "Secret key name to delete" + } + }, + "required": [ + "app_name", + "key" + ] + } + }, + { + "name": "fly_list_certificates", + "description": "List TLS certificates for a Fly.io app", + "inputSchema": { + "type": "object", + "properties": { + "app_name": { + "type": "string", + "description": "App name" + } + }, + "required": [ + "app_name" + ] + } + }, + { + "name": "fly_add_certificate", + "description": "Add a TLS certificate for a hostname to a Fly.io app", + "inputSchema": { + "type": "object", + "properties": { + "app_name": { + "type": "string", + "description": "App name" + }, + "hostname": { + "type": "string", + "description": "Hostname for the certificate (e.g. example.com)" + } + }, + "required": [ + "app_name", + "hostname" + ] + } + }, + { + "name": "fly_list_regions", + "description": "List all available Fly.io regions", + "inputSchema": { + "type": "object", + "properties": {} + } + }, + { + "name": "fly_allocate_ip", + "description": "Allocate a public IP address (v4 or v6) for a Fly.io app", + "inputSchema": { + "type": "object", + "properties": { + "app_name": { + "type": "string", + "description": "App name" + }, + "type": { + "type": "string", + "description": "IP type: v4 or v6 (default v6)" + }, + "region": { + "type": "string", + "description": "Region for shared IPv4 (optional)" + } + }, + "required": [ + "app_name" + ] + } + }, + { + "name": "fly_release_ip", + "description": "Release a public IP address from a Fly.io app", + "inputSchema": { + "type": "object", + "properties": { + "app_name": { + "type": "string", + "description": "App name" + }, + "ip_address_id": { + "type": "string", + "description": "IP address ID to release" + } + }, + "required": [ + "app_name", + "ip_address_id" + ] + } + } + ], + "ffi": { + "so_path": "ffi/zig-out/lib/libfly_mcp.so", + "abi_version": "ADR-0006", + "symbols": [ + "boj_cartridge_init", + "boj_cartridge_deinit", + "boj_cartridge_name", + "boj_cartridge_version", + "boj_cartridge_invoke" + ] + } +} diff --git a/cartridges/domains/cloud/fly-mcp/ffi/README.adoc b/cartridges/domains/cloud/fly-mcp/ffi/README.adoc new file mode 100644 index 0000000..e169899 --- /dev/null +++ b/cartridges/domains/cloud/fly-mcp/ffi/README.adoc @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += fly-mcp / ffi β€” Zig FFI layer +:orientation: shallow + +== Purpose + +C-ABI exports bridging this cartridge's Idris2 ABI to the runtime. Consumed by +the adapter for protocol dispatch and by the trunk loader for mount. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” produces `zig-out/lib/libfly.so`. +| `fly_mcp_ffi.zig` | C-ABI exports (14 exports, 6 inline tests, 348 lines). +|=== + +== Invariants + +* **Null-pointer rejection** on `[*c]const u8` arguments before any + dereference. +* **Negative return values** carry documented error meanings; zero is + success (matching the trunk error-code convention). +* **No allocator leaks** β€” every allocation path pairs with `defer free`. + +== Test/proof surface + +6 inline `test "..."` block(s) in `fly_mcp_ffi.zig`. Run with +`cd ffi && zig build test`. + +== Read-first + +. `fly_mcp_ffi.zig` β€” C-ABI signatures and guards. +. Trunk `ffi/zig/src/catalogue.zig` for how this `.so` is loaded. diff --git a/cartridges/domains/cloud/fly-mcp/ffi/build.zig b/cartridges/domains/cloud/fly-mcp/ffi/build.zig new file mode 100644 index 0000000..d09d85e --- /dev/null +++ b/cartridges/domains/cloud/fly-mcp/ffi/build.zig @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + // Module + // Shared ADR-0006 invoke-shim module (relative path up to boj-server trunk). + + const shim_mod = b.addModule("cartridge_shim", .{ + + .root_source_file = b.path("../../../ffi/zig/src/cartridge_shim.zig"), + + .target = target, + + .optimize = optimize, + + }); + + const ffi_mod = b.addModule("fly_mcp", .{ + .root_source_file = b.path("fly_mcp_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + ffi_mod.addImport("cartridge_shim", shim_mod); + + // Shared library + const lib = b.addLibrary(.{ + .name = "fly_mcp", + .root_module = ffi_mod, + .linkage = .dynamic, + }); + lib.linkLibC(); + b.installArtifact(lib); + + // Tests + const tests = b.addTest(.{ + .root_module = ffi_mod, + }); + tests.linkLibC(); + + const run_tests = b.addRunArtifact(tests); + const test_step = b.step("test", "Run FFI tests"); + test_step.dependOn(&run_tests.step); +} diff --git a/cartridges/domains/cloud/fly-mcp/ffi/cartridge_shim.zig b/cartridges/domains/cloud/fly-mcp/ffi/cartridge_shim.zig new file mode 100644 index 0000000..0e399db --- /dev/null +++ b/cartridges/domains/cloud/fly-mcp/ffi/cartridge_shim.zig @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// cartridge_shim.zig β€” Shared helpers for the ADR-0006 five-symbol +// cartridge ABI (`boj_cartridge_init / deinit / name / version / invoke`). +// +// The shim centralises the seven-code return convention, NUL-argument +// guards, tool-name comparison, and the buffer-too-small path so each +// cartridge's `boj_cartridge_invoke` can stay short β€” typically a tool +// table plus `shim.writeResult(...)`. +// +// Cartridges import this file by relative path (no build-graph change +// needed). Example: +// +// const shim = @import("../../../ffi/zig/src/cartridge_shim.zig"); +// +// export fn boj_cartridge_invoke( +// tool_name: [*c]const u8, +// json_args: [*c]const u8, +// out_buf: [*c]u8, +// in_out_len: [*c]usize, +// ) callconv(.c) i32 { +// _ = json_args; +// if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; +// const body = if (shim.toolIs(tool_name, "foo")) "{\"result\":{}}" +// else return shim.RC_UNKNOWN_TOOL; +// return shim.writeResult(out_buf, in_out_len, body); +// } + +const std = @import("std"); + +// ── Return codes (ADR-0006 Β§Return codes) ──────────────────────────── +// +// Frozen by ADR-0006. New failure modes compose these via the error +// JSON body β€” the integer surface does not grow without a follow-up ADR. + +pub const RC_SUCCESS: i32 = 0; +pub const RC_UNKNOWN_TOOL: i32 = -1; +pub const RC_BAD_ARGS: i32 = -2; +pub const RC_BUFFER_TOO_SMALL: i32 = -3; +pub const RC_RUNTIME_ERROR: i32 = -4; +pub const RC_PANIC: i32 = -5; +pub const RC_AUTH_DENIED: i32 = -6; + +// ── Invoke-path helpers ────────────────────────────────────────────── + +/// True if any of the three mandatory `boj_cartridge_invoke` output-path +/// pointers is null. Use at the top of every invoke to short-circuit to +/// `RC_BAD_ARGS`. +pub fn invokeArgsNull( + tool_name: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) bool { + return tool_name == null or out_buf == null or in_out_len == null; +} + +/// Compare a C-NUL-terminated tool-name pointer against a Zig string +/// literal. Caller must have already verified `tool_name` is non-null +/// (usually via `invokeArgsNull`). +pub fn toolIs(tool_name: [*c]const u8, expected: []const u8) bool { + const s = std.mem.span(@as([*:0]const u8, @ptrCast(tool_name))); + return std.mem.eql(u8, s, expected); +} + +/// Copy `body` into `out_buf[0..*in_out_len]` (as a capacity) and update +/// `*in_out_len` to the number of bytes written. Returns `RC_SUCCESS`. +/// +/// If `body.len` exceeds the current capacity stored in `*in_out_len`, +/// sets `*in_out_len` to the required size and returns +/// `RC_BUFFER_TOO_SMALL` β€” the caller is then expected to re-allocate +/// and retry, per ADR-0006 Β§Memory ownership. +/// +/// Caller must have already verified that `out_buf` and `in_out_len` +/// are non-null. +pub fn writeResult( + out_buf: [*c]u8, + in_out_len: [*c]usize, + body: []const u8, +) i32 { + const cap = in_out_len.*; + if (body.len > cap) { + in_out_len.* = body.len; + return RC_BUFFER_TOO_SMALL; + } + @memcpy(out_buf[0..body.len], body); + in_out_len.* = body.len; + return RC_SUCCESS; +} + +// ── Tests ──────────────────────────────────────────────────────────── + +test "writeResult: body fits, writes and sets length" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); + try std.testing.expectEqualStrings("hello", buf[0..len]); +} + +test "writeResult: too small returns -3 and sets required length" { + var buf: [2]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_BUFFER_TOO_SMALL, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: exact-fit succeeds" { + var buf: [5]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: empty body" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, ""); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 0), len); +} + +test "toolIs: matches and rejects" { + const name: [*:0]const u8 = "foo"; + try std.testing.expect(toolIs(@ptrCast(name), "foo")); + try std.testing.expect(!toolIs(@ptrCast(name), "bar")); + try std.testing.expect(!toolIs(@ptrCast(name), "foobar")); + try std.testing.expect(!toolIs(@ptrCast(name), "fo")); +} + +test "invokeArgsNull: detects each null slot" { + var buf: [4]u8 = undefined; + var len: usize = 4; + const name: [*:0]const u8 = "x"; + try std.testing.expect(!invokeArgsNull(@ptrCast(name), &buf, &len)); + try std.testing.expect(invokeArgsNull(null, &buf, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), null, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), &buf, null)); +} diff --git a/cartridges/domains/cloud/fly-mcp/ffi/fly_mcp_ffi.zig b/cartridges/domains/cloud/fly-mcp/ffi/fly_mcp_ffi.zig new file mode 100644 index 0000000..db64a8c --- /dev/null +++ b/cartridges/domains/cloud/fly-mcp/ffi/fly_mcp_ffi.zig @@ -0,0 +1,490 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// fly_mcp_ffi.zig -- C-ABI FFI implementation for fly-mcp cartridge. +// +// Implements the state machine defined in the Idris2 ABI layer for +// Fly.io Machines API v1 (https://api.machines.dev/v1/). +// Auth: Bearer token. Thread-safe via std.Thread.Mutex. + +const std = @import("std"); + +// --------------------------------------------------------------------------- +// State machine (matches Idris2 ABI: Unauthenticated=0, Authenticated=1, +// RateLimited=2, Error=3) +// --------------------------------------------------------------------------- + +pub const SessionState = enum(c_int) { + unauthenticated = 0, + authenticated = 1, + rate_limited = 2, + err = 3, +}; + +/// Fly.io Machines API action codes (matches Idris2 FlyAction). +pub const FlyAction = enum(c_int) { + list_apps = 0, + get_app = 1, + create_app = 2, + destroy_app = 3, + list_machines = 4, + get_machine = 5, + start_machine = 6, + stop_machine = 7, + list_volumes = 8, + create_volume = 9, + list_secrets = 10, + set_secret = 11, + delete_secret = 12, + list_regions = 13, + allocate_ip = 14, + release_ip = 15, +}; + +fn isValidTransition(from: SessionState, to: SessionState) bool { + return switch (from) { + .unauthenticated => to == .authenticated or to == .err, + .authenticated => to == .rate_limited or to == .err or to == .unauthenticated, + .rate_limited => to == .authenticated or to == .err, + .err => to == .unauthenticated, + }; +} + +// --------------------------------------------------------------------------- +// Session slots (thread-safe, fixed-size pool) +// --------------------------------------------------------------------------- + +const MAX_SESSIONS: usize = 16; +const TOKEN_BUF_SIZE: usize = 512; + +const SessionSlot = struct { + occupied: bool = false, + state: SessionState = .unauthenticated, + token_buf: [TOKEN_BUF_SIZE]u8 = .{0} ** TOKEN_BUF_SIZE, + token_len: usize = 0, + app_count: c_int = 0, + machine_count: c_int = 0, +}; + +var sessions: [MAX_SESSIONS]SessionSlot = .{SessionSlot{}} ** MAX_SESSIONS; +var mutex: std.Thread.Mutex = .{}; + +// --------------------------------------------------------------------------- +// C-ABI exports +// --------------------------------------------------------------------------- + +/// Check if a state transition is valid. Returns 1 (valid) or 0 (invalid). +pub export fn fly_mcp_can_transition(from: c_int, to: c_int) c_int { + const f = std.meta.intToEnum(SessionState, from) catch return 0; + const t = std.meta.intToEnum(SessionState, to) catch return 0; + return if (isValidTransition(f, t)) 1 else 0; +} + +/// Authenticate and open a session. Returns slot index (>= 0) or -1 (no slots). +pub export fn fly_mcp_session_open() c_int { + mutex.lock(); + defer mutex.unlock(); + + for (0..MAX_SESSIONS) |idx| { + const slot = &sessions[idx]; + if (!slot.occupied) { + slot.occupied = true; + slot.state = .authenticated; + slot.token_len = 0; + slot.app_count = 0; + slot.machine_count = 0; + return @intCast(idx); + } + } + return -1; +} + +/// Close a session. Returns 0 on success, -1 if invalid slot, -2 if bad transition. +pub export fn fly_mcp_session_close(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.occupied) return -1; + if (!isValidTransition(slot.state, .unauthenticated)) return -2; + + slot.occupied = false; + slot.state = .unauthenticated; + slot.token_len = 0; + slot.app_count = 0; + slot.machine_count = 0; + return 0; +} + +/// Get the current state of a session. Returns state int or -1 if invalid. +pub export fn fly_mcp_session_state(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.occupied) return -1; + return @intFromEnum(slot.state); +} + +/// Transition to rate-limited state. Returns 0 on success. +pub export fn fly_mcp_rate_limit(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.occupied) return -1; + if (!isValidTransition(slot.state, .rate_limited)) return -2; + + slot.state = .rate_limited; + return 0; +} + +/// Recover from rate-limited back to authenticated. Returns 0 on success. +pub export fn fly_mcp_rate_recover(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.occupied) return -1; + if (!isValidTransition(slot.state, .authenticated)) return -2; + + slot.state = .authenticated; + return 0; +} + +/// Signal an error on a session. Returns 0 on success. +pub export fn fly_mcp_signal_error(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.occupied) return -1; + if (!isValidTransition(slot.state, .err)) return -2; + + slot.state = .err; + return 0; +} + +/// Recover from error back to unauthenticated. Returns 0 on success. +pub export fn fly_mcp_error_recover(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.occupied) return -1; + if (!isValidTransition(slot.state, .unauthenticated)) return -2; + + slot.state = .unauthenticated; + return 0; +} + +/// Check if an action requires authentication. Returns 1 (yes) or 0 (no). +pub export fn fly_mcp_action_requires_auth(action: c_int) c_int { + const act = std.meta.intToEnum(FlyAction, action) catch return 1; + return switch (act) { + .list_regions => 0, + else => 1, + }; +} + +/// Get app count for a session. Returns count or -1 if invalid. +pub export fn fly_mcp_app_count(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.occupied) return -1; + return slot.app_count; +} + +/// Get machine count for a session. Returns count or -1 if invalid. +pub export fn fly_mcp_machine_count(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.occupied) return -1; + return slot.machine_count; +} + +/// Set app count for a session. Returns 0 on success. +pub export fn fly_mcp_set_app_count(slot_idx: c_int, count: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.occupied) return -1; + slot.app_count = count; + return 0; +} + +/// Set machine count for a session. Returns 0 on success. +pub export fn fly_mcp_set_machine_count(slot_idx: c_int, count: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.occupied) return -1; + slot.machine_count = count; + return 0; +} + +/// Reset all sessions (test/debug use only). +pub export fn fly_mcp_reset() void { + mutex.lock(); + defer mutex.unlock(); + sessions = .{SessionSlot{}} ** MAX_SESSIONS; +} + +// --------------------------------------------------------------------------- +// ═══════════════════════════════════════════════════════════════════════ +// Standard ABI (ADR-0005 four symbols + ADR-0006 invoke) +// ═══════════════════════════════════════════════════════════════════════ + +const shim = @import("cartridge_shim.zig"); + +const CARTRIDGE_NAME_PTR: [*:0]const u8 = "fly-mcp"; +const CARTRIDGE_VERSION_PTR: [*:0]const u8 = "0.1.0"; + +export fn boj_cartridge_init() callconv(.c) c_int { + return 0; +} + +export fn boj_cartridge_deinit() callconv(.c) void {} + +export fn boj_cartridge_name() callconv(.c) [*:0]const u8 { + return CARTRIDGE_NAME_PTR; +} + +export fn boj_cartridge_version() callconv(.c) [*:0]const u8 { + return CARTRIDGE_VERSION_PTR; +} + +/// Dispatch the cartridge.json MCP tools. Grade D Alpha stubs. +export fn boj_cartridge_invoke( + tool_name: [*c]const u8, + json_args: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) callconv(.c) i32 { + _ = json_args; + if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; + + const body: []const u8 = if (shim.toolIs(tool_name, "fly_list_apps")) + "{\"result\":{\"items\":[],\"count\":0,\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "fly_get_app")) + "{\"result\":{\"metadata\":{},\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "fly_create_app")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "fly_destroy_app")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "fly_list_machines")) + "{\"result\":{\"items\":[],\"count\":0,\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "fly_get_machine")) + "{\"result\":{\"metadata\":{},\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "fly_create_machine")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "fly_start_machine")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "fly_stop_machine")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "fly_restart_machine")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "fly_destroy_machine")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "fly_list_volumes")) + "{\"result\":{\"items\":[],\"count\":0,\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "fly_create_volume")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "fly_list_secrets")) + "{\"result\":{\"items\":[],\"count\":0,\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "fly_set_secrets")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "fly_delete_secret")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "fly_list_certificates")) + "{\"result\":{\"items\":[],\"count\":0,\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "fly_add_certificate")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "fly_list_regions")) + "{\"result\":{\"items\":[],\"count\":0,\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "fly_allocate_ip")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "fly_release_ip")) + "{\"result\":{\"status\":\"stub\"}}" +else + return shim.RC_UNKNOWN_TOOL; + + return shim.writeResult(out_buf, in_out_len, body); +} + +// Tests +// --------------------------------------------------------------------------- + +test "session lifecycle" { + fly_mcp_reset(); + + const slot = fly_mcp_session_open(); + try std.testing.expect(slot >= 0); + + // Should be authenticated after open + try std.testing.expectEqual(@as(c_int, 1), fly_mcp_session_state(slot)); + + // Rate limit then recover + try std.testing.expectEqual(@as(c_int, 0), fly_mcp_rate_limit(slot)); + try std.testing.expectEqual(@as(c_int, 2), fly_mcp_session_state(slot)); + try std.testing.expectEqual(@as(c_int, 0), fly_mcp_rate_recover(slot)); + try std.testing.expectEqual(@as(c_int, 1), fly_mcp_session_state(slot)); + + // Close + try std.testing.expectEqual(@as(c_int, 0), fly_mcp_session_close(slot)); +} + +test "invalid transitions rejected" { + fly_mcp_reset(); + + const slot = fly_mcp_session_open(); + try std.testing.expect(slot >= 0); + + // Cannot go authenticated -> unauthenticated -> (already tested via close) + // Cannot recover from rate_limited to unauthenticated directly + try std.testing.expectEqual(@as(c_int, 0), fly_mcp_rate_limit(slot)); + try std.testing.expectEqual(@as(c_int, -2), fly_mcp_session_close(slot)); +} + +test "transition validator" { + // Valid transitions + try std.testing.expectEqual(@as(c_int, 1), fly_mcp_can_transition(0, 1)); // unauth -> auth + try std.testing.expectEqual(@as(c_int, 1), fly_mcp_can_transition(1, 2)); // auth -> rate_limited + try std.testing.expectEqual(@as(c_int, 1), fly_mcp_can_transition(2, 1)); // rate_limited -> auth + try std.testing.expectEqual(@as(c_int, 1), fly_mcp_can_transition(1, 3)); // auth -> error + try std.testing.expectEqual(@as(c_int, 1), fly_mcp_can_transition(3, 0)); // error -> unauth + + // Invalid transitions + try std.testing.expectEqual(@as(c_int, 0), fly_mcp_can_transition(0, 2)); // unauth -> rate_limited + try std.testing.expectEqual(@as(c_int, 0), fly_mcp_can_transition(2, 0)); // rate_limited -> unauth + try std.testing.expectEqual(@as(c_int, 0), fly_mcp_can_transition(3, 1)); // error -> auth + + // Out of range + try std.testing.expectEqual(@as(c_int, 0), fly_mcp_can_transition(99, 0)); +} + +test "slot exhaustion" { + fly_mcp_reset(); + + var slots: [MAX_SESSIONS]c_int = undefined; + for (0..MAX_SESSIONS) |idx| { + slots[idx] = fly_mcp_session_open(); + try std.testing.expect(slots[idx] >= 0); + } + + // Next open should fail + try std.testing.expectEqual(@as(c_int, -1), fly_mcp_session_open()); + + // Free one and try again + try std.testing.expectEqual(@as(c_int, 0), fly_mcp_session_close(slots[0])); + const new_slot = fly_mcp_session_open(); + try std.testing.expect(new_slot >= 0); +} + +test "action auth requirements" { + try std.testing.expectEqual(@as(c_int, 0), fly_mcp_action_requires_auth(13)); // list_regions + try std.testing.expectEqual(@as(c_int, 1), fly_mcp_action_requires_auth(0)); // list_apps + try std.testing.expectEqual(@as(c_int, 1), fly_mcp_action_requires_auth(2)); // create_app +} + +test "app and machine counters" { + fly_mcp_reset(); + + const slot = fly_mcp_session_open(); + try std.testing.expect(slot >= 0); + + try std.testing.expectEqual(@as(c_int, 0), fly_mcp_app_count(slot)); + try std.testing.expectEqual(@as(c_int, 0), fly_mcp_set_app_count(slot, 5)); + try std.testing.expectEqual(@as(c_int, 5), fly_mcp_app_count(slot)); + + try std.testing.expectEqual(@as(c_int, 0), fly_mcp_machine_count(slot)); + try std.testing.expectEqual(@as(c_int, 0), fly_mcp_set_machine_count(slot, 12)); + try std.testing.expectEqual(@as(c_int, 12), fly_mcp_machine_count(slot)); +} + +// ═══════════════════════════════════════════════════════════════════════ +// ADR-0006 invoke dispatch tests +// ═══════════════════════════════════════════════════════════════════════ + +test "boj_cartridge_name returns fly-mcp" { + const n = std.mem.span(boj_cartridge_name()); + try std.testing.expectEqualStrings("fly-mcp", n); +} + +test "boj_cartridge_init returns 0" { + try std.testing.expectEqual(@as(c_int, 0), boj_cartridge_init()); +} + +test "invoke: each declared tool succeeds" { + var buf: [256]u8 = undefined; + const tools = [_][]const u8{ + "fly_list_apps", + "fly_get_app", + "fly_create_app", + "fly_destroy_app", + "fly_list_machines", + "fly_get_machine", + "fly_create_machine", + "fly_start_machine", + "fly_stop_machine", + "fly_restart_machine", + "fly_destroy_machine", + "fly_list_volumes", + "fly_create_volume", + "fly_list_secrets", + "fly_set_secrets", + "fly_delete_secret", + "fly_list_certificates", + "fly_add_certificate", + "fly_list_regions", + "fly_allocate_ip", + "fly_release_ip", + }; + for (tools) |t| { + var len: usize = buf.len; + const rc = boj_cartridge_invoke(t.ptr, "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, 0), rc); + try std.testing.expect(std.mem.indexOf(u8, buf[0..len], "result") != null); + } +} + +test "invoke: unknown tool returns -1" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("nope", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -1), rc); +} + +test "invoke: buffer too small returns -3" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("fly_list_apps", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -3), rc); + try std.testing.expect(len > 4); +} diff --git a/cartridges/domains/cloud/fly-mcp/minter.toml b/cartridges/domains/cloud/fly-mcp/minter.toml new file mode 100644 index 0000000..5f4bc0a --- /dev/null +++ b/cartridges/domains/cloud/fly-mcp/minter.toml @@ -0,0 +1,39 @@ +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> + +name = "fly-mcp" +description = "Fly.io Machines API v1 cartridge -- apps, machines, volumes, secrets, regions, IPs" +version = "0.1.0" +domain = "Cloud" +protocols = [ + "MCP", + "REST", +] +tier = "Ayo" +backend = "universal" +generate_panel = true + +[auth] +method = "bearer" +token_env = "FLY_API_TOKEN" +base_url = "https://api.machines.dev/v1/" + +[actions] +list = [ + "ListApps", + "GetApp", + "CreateApp", + "DestroyApp", + "ListMachines", + "GetMachine", + "StartMachine", + "StopMachine", + "ListVolumes", + "CreateVolume", + "ListSecrets", + "SetSecret", + "DeleteSecret", + "ListRegions", + "AllocateIP", + "ReleaseIP", +] diff --git a/cartridges/domains/cloud/fly-mcp/mod.js b/cartridges/domains/cloud/fly-mcp/mod.js new file mode 100644 index 0000000..100dc14 --- /dev/null +++ b/cartridges/domains/cloud/fly-mcp/mod.js @@ -0,0 +1,419 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// fly-mcp/mod.js -- Fly.io Machines API v1 cartridge implementation. +// +// Provides MCP tool handlers for the Fly.io Machines REST API: +// - App management (list, get, create, destroy) +// - Machine management (list, get, create, start, stop, restart, destroy) +// - Volumes (list, create) +// - Secrets (list, set, delete) +// - Certificates (list, add) +// - Regions (list) +// - IP allocation (allocate, release) +// +// Auth: Bearer token via FLY_API_TOKEN env var or vault-mcp proxy. +// API docs: https://fly.io/docs/machines/api/ +// +// The Fly.io API uses two base URLs: +// - Machines API: https://api.machines.dev/v1 +// - Platform API: https://api.fly.io (for secrets, certs, IPs via GraphQL) +// +// Usage: import { handleTool } from "./mod.js"; +// or: deno run --allow-net --allow-env mod.js + +const MACHINES_API_BASE = "https://api.machines.dev/v1"; +const PLATFORM_API_BASE = "https://api.fly.io"; + +// --------------------------------------------------------------------------- +// Auth helper β€” retrieves the Fly.io API token from environment. +// In production, vault-mcp provides zero-knowledge credential proxying; +// for development, FLY_API_TOKEN is read directly. +// --------------------------------------------------------------------------- + +function getToken() { + const token = typeof Deno !== "undefined" + ? Deno.env.get("FLY_API_TOKEN") + : process.env.FLY_API_TOKEN; + if (!token) { + throw new Error("FLY_API_TOKEN not set. Run 'fly auth token' or export FLY_API_TOKEN."); + } + return token; +} + +// --------------------------------------------------------------------------- +// HTTP request helpers β€” wraps fetch for both Machines API and Platform API +// with auth headers, error handling, and structured responses. +// --------------------------------------------------------------------------- + +async function machinesFetch(method, path, body) { + const token = getToken(); + const url = `${MACHINES_API_BASE}${path}`; + + const options = { + method, + headers: { + "Authorization": `Bearer ${token}`, + "Accept": "application/json", + "User-Agent": "boj-server/fly-mcp/0.1.0", + }, + }; + + if (body && method !== "GET") { + options.headers["Content-Type"] = "application/json"; + options.body = JSON.stringify(body); + } + + const response = await fetch(url, options); + + // Handle 204 No Content (successful actions like stop/start) + if (response.status === 204) { + return { status: response.status, data: { success: true } }; + } + + // Handle empty responses + const text = await response.text(); + if (!text) { + return { status: response.status, data: { success: response.ok } }; + } + + let data; + try { + data = JSON.parse(text); + } catch { + return { status: response.status, error: `Non-JSON response: ${text.slice(0, 200)}` }; + } + + if (!response.ok) { + const errorMessage = data.error || `HTTP ${response.status}`; + return { status: response.status, error: errorMessage, data }; + } + + return { status: response.status, data }; +} + +// Platform API uses GraphQL for secrets, certificates, and IPs +async function platformGraphQL(query, variables) { + const token = getToken(); + + const response = await fetch(`${PLATFORM_API_BASE}/graphql`, { + method: "POST", + headers: { + "Authorization": `Bearer ${token}`, + "Content-Type": "application/json", + "User-Agent": "boj-server/fly-mcp/0.1.0", + }, + body: JSON.stringify({ query, variables: variables || {} }), + }); + + const data = await response.json(); + + if (data.errors) { + return { status: response.status, error: data.errors[0].message, data }; + } + + return { status: response.status, data: data.data }; +} + +// --------------------------------------------------------------------------- +// Tool handler dispatch β€” maps MCP tool names to Fly.io API operations. +// Each handler validates required arguments, builds the API request, +// and returns structured results. +// --------------------------------------------------------------------------- + +export async function handleTool(toolName, args) { + switch (toolName) { + + // --- Apps --- + + case "fly_list_apps": { + const orgSlug = args.org_slug || "personal"; + return machinesFetch("GET", `/apps?org_slug=${encodeURIComponent(orgSlug)}`); + } + + case "fly_get_app": { + if (!args.app_name) return { error: "Missing required field: app_name" }; + return machinesFetch("GET", `/apps/${encodeURIComponent(args.app_name)}`); + } + + case "fly_create_app": { + if (!args.app_name) return { error: "Missing required field: app_name" }; + const body = { + app_name: args.app_name, + org_slug: args.org_slug || "personal", + }; + if (args.network) body.network = args.network; + return machinesFetch("POST", "/apps", body); + } + + case "fly_destroy_app": { + if (!args.app_name) return { error: "Missing required field: app_name" }; + return machinesFetch("DELETE", `/apps/${encodeURIComponent(args.app_name)}`); + } + + // --- Machines --- + + case "fly_list_machines": { + if (!args.app_name) return { error: "Missing required field: app_name" }; + let path = `/apps/${encodeURIComponent(args.app_name)}/machines`; + const params = []; + if (args.include_deleted) params.push("include_deleted=true"); + if (args.region) params.push(`region=${encodeURIComponent(args.region)}`); + if (params.length > 0) path += `?${params.join("&")}`; + return machinesFetch("GET", path); + } + + case "fly_get_machine": { + if (!args.app_name) return { error: "Missing required field: app_name" }; + if (!args.machine_id) return { error: "Missing required field: machine_id" }; + return machinesFetch("GET", + `/apps/${encodeURIComponent(args.app_name)}/machines/${encodeURIComponent(args.machine_id)}`); + } + + case "fly_create_machine": { + if (!args.app_name) return { error: "Missing required field: app_name" }; + if (!args.config) return { error: "Missing required field: config" }; + const body = { config: args.config }; + if (args.name) body.name = args.name; + if (args.region) body.region = args.region; + return machinesFetch("POST", + `/apps/${encodeURIComponent(args.app_name)}/machines`, body); + } + + case "fly_start_machine": { + if (!args.app_name) return { error: "Missing required field: app_name" }; + if (!args.machine_id) return { error: "Missing required field: machine_id" }; + return machinesFetch("POST", + `/apps/${encodeURIComponent(args.app_name)}/machines/${encodeURIComponent(args.machine_id)}/start`); + } + + case "fly_stop_machine": { + if (!args.app_name) return { error: "Missing required field: app_name" }; + if (!args.machine_id) return { error: "Missing required field: machine_id" }; + const body = {}; + if (args.signal) body.signal = args.signal; + return machinesFetch("POST", + `/apps/${encodeURIComponent(args.app_name)}/machines/${encodeURIComponent(args.machine_id)}/stop`, body); + } + + case "fly_restart_machine": { + if (!args.app_name) return { error: "Missing required field: app_name" }; + if (!args.machine_id) return { error: "Missing required field: machine_id" }; + let path = `/apps/${encodeURIComponent(args.app_name)}/machines/${encodeURIComponent(args.machine_id)}/restart`; + if (args.timeout) path += `?timeout=${encodeURIComponent(args.timeout)}`; + return machinesFetch("POST", path); + } + + case "fly_destroy_machine": { + if (!args.app_name) return { error: "Missing required field: app_name" }; + if (!args.machine_id) return { error: "Missing required field: machine_id" }; + let path = `/apps/${encodeURIComponent(args.app_name)}/machines/${encodeURIComponent(args.machine_id)}`; + if (args.force) path += "?force=true"; + return machinesFetch("DELETE", path); + } + + // --- Volumes --- + + case "fly_list_volumes": { + if (!args.app_name) return { error: "Missing required field: app_name" }; + return machinesFetch("GET", + `/apps/${encodeURIComponent(args.app_name)}/volumes`); + } + + case "fly_create_volume": { + if (!args.app_name) return { error: "Missing required field: app_name" }; + if (!args.name) return { error: "Missing required field: name" }; + if (!args.region) return { error: "Missing required field: region" }; + if (!args.size_gb) return { error: "Missing required field: size_gb" }; + const body = { + name: args.name, + region: args.region, + size_gb: args.size_gb, + }; + if (args.encrypted !== undefined) body.encrypted = args.encrypted; + if (args.snapshot_id) body.snapshot_id = args.snapshot_id; + return machinesFetch("POST", + `/apps/${encodeURIComponent(args.app_name)}/volumes`, body); + } + + // --- Secrets (via Platform GraphQL API) --- + + case "fly_list_secrets": { + if (!args.app_name) return { error: "Missing required field: app_name" }; + const query = ` + query($appName: String!) { + app(name: $appName) { + secrets { + name + digest + createdAt + } + } + } + `; + return platformGraphQL(query, { appName: args.app_name }); + } + + case "fly_set_secrets": { + if (!args.app_name) return { error: "Missing required field: app_name" }; + if (!args.secrets) return { error: "Missing required field: secrets" }; + // Convert object map to array of {key, value} pairs for the GraphQL mutation + const secretInputs = Object.entries(args.secrets).map(([key, value]) => ({ + key, + value, + })); + const query = ` + mutation($input: SetSecretsInput!) { + setSecrets(input: $input) { + app { name } + release { id version } + } + } + `; + return platformGraphQL(query, { + input: { appId: args.app_name, secrets: secretInputs }, + }); + } + + case "fly_delete_secret": { + if (!args.app_name) return { error: "Missing required field: app_name" }; + if (!args.key) return { error: "Missing required field: key" }; + const query = ` + mutation($input: UnsetSecretsInput!) { + unsetSecrets(input: $input) { + app { name } + release { id version } + } + } + `; + return platformGraphQL(query, { + input: { appId: args.app_name, keys: [args.key] }, + }); + } + + // --- Certificates (via Platform GraphQL API) --- + + case "fly_list_certificates": { + if (!args.app_name) return { error: "Missing required field: app_name" }; + const query = ` + query($appName: String!) { + app(name: $appName) { + certificates { + nodes { + id + hostname + createdAt + source + clientStatus + issued { + nodes { type expiresAt } + } + } + } + } + } + `; + return platformGraphQL(query, { appName: args.app_name }); + } + + case "fly_add_certificate": { + if (!args.app_name) return { error: "Missing required field: app_name" }; + if (!args.hostname) return { error: "Missing required field: hostname" }; + const query = ` + mutation($appId: ID!, $hostname: String!) { + addCertificate(appId: $appId, hostname: $hostname) { + certificate { + id + hostname + createdAt + source + clientStatus + } + } + } + `; + return platformGraphQL(query, { + appId: args.app_name, + hostname: args.hostname, + }); + } + + // --- Regions --- + + case "fly_list_regions": { + // Platform API endpoint for region listing + const token = getToken(); + const response = await fetch(`${PLATFORM_API_BASE}/graphql`, { + method: "POST", + headers: { + "Authorization": `Bearer ${token}`, + "Content-Type": "application/json", + "User-Agent": "boj-server/fly-mcp/0.1.0", + }, + body: JSON.stringify({ + query: `{ platform { regions { code name gatewayAvailable requiresPaidPlan } } }`, + }), + }); + const data = await response.json(); + if (data.errors) { + return { status: response.status, error: data.errors[0].message, data }; + } + return { status: response.status, data: data.data }; + } + + // --- IP Allocation (via Platform GraphQL API) --- + + case "fly_allocate_ip": { + if (!args.app_name) return { error: "Missing required field: app_name" }; + const ipType = args.type === "v4" ? "v4" : "v6"; + const query = ` + mutation($input: AllocateIPAddressInput!) { + allocateIpAddress(input: $input) { + ipAddress { + id + address + type + region + createdAt + } + } + } + `; + const input = { appId: args.app_name, type: ipType }; + if (args.region) input.region = args.region; + return platformGraphQL(query, { input }); + } + + case "fly_release_ip": { + if (!args.app_name) return { error: "Missing required field: app_name" }; + if (!args.ip_address_id) return { error: "Missing required field: ip_address_id" }; + const query = ` + mutation($input: ReleaseIPAddressInput!) { + releaseIpAddress(input: $input) { + app { name } + } + } + `; + return platformGraphQL(query, { + input: { appId: args.app_name, ipAddressId: args.ip_address_id }, + }); + } + + default: + return { error: `Unknown fly-mcp tool: ${toolName}` }; + } +} + +// --------------------------------------------------------------------------- +// Cartridge metadata export β€” used by the BoJ cartridge loader to register +// this cartridge's tools without reading cartridge.json separately. +// --------------------------------------------------------------------------- + +export const metadata = { + name: "fly-mcp", + version: "0.1.0", + domain: "Cloud", + tier: "Ayo", + protocols: ["MCP", "REST"], + toolCount: 21, +}; diff --git a/cartridges/domains/cloud/fly-mcp/panels/manifest.json b/cartridges/domains/cloud/fly-mcp/panels/manifest.json new file mode 100644 index 0000000..b9a2908 --- /dev/null +++ b/cartridges/domains/cloud/fly-mcp/panels/manifest.json @@ -0,0 +1,90 @@ +{ + "$schema": "https://panll.dev/schemas/panel-manifest/v1.json", + "spdx": "MPL-2.0", + "cartridge": "fly-mcp", + "domain": "Cloud", + "version": "0.1.0", + "panels": [ + { + "id": "fly-auth-status", + "title": "Auth Status", + "description": "Current Fly.io authentication state (Unauthenticated / Authenticated / RateLimited / Error)", + "type": "status-indicator", + "data_source": { + "endpoint": "/fly/state", + "method": "GET", + "refresh_interval_ms": 5000 + }, + "widgets": [ + { + "type": "state-badge", + "field": "state", + "states": { + "unauthenticated": { "color": "#95a5a6", "icon": "plug-disconnected" }, + "authenticated": { "color": "#2ecc71", "icon": "plug-connected" }, + "rate_limited": { "color": "#e67e22", "icon": "clock" }, + "error": { "color": "#e74c3c", "icon": "alert-triangle" } + } + } + ] + }, + { + "id": "fly-app-count", + "title": "App Count", + "description": "Number of Fly.io apps in the authenticated organisation", + "type": "metric", + "data_source": { + "endpoint": "/fly/metrics", + "method": "GET", + "refresh_interval_ms": 30000 + }, + "widgets": [ + { + "type": "counter", + "field": "app_count", + "label": "Apps", + "icon": "box" + } + ] + }, + { + "id": "fly-machine-count", + "title": "Machine Count", + "description": "Total number of machines across all Fly.io apps", + "type": "metric", + "data_source": { + "endpoint": "/fly/metrics", + "method": "GET", + "refresh_interval_ms": 30000 + }, + "widgets": [ + { + "type": "counter", + "field": "machine_count", + "label": "Machines", + "icon": "cpu" + } + ] + }, + { + "id": "fly-region-distribution", + "title": "Region Distribution", + "description": "Machine distribution across Fly.io regions", + "type": "multi-metric", + "data_source": { + "endpoint": "/fly/regions", + "method": "GET", + "refresh_interval_ms": 60000 + }, + "widgets": [ + { + "type": "bar-chart", + "field": "regions", + "label_field": "region_code", + "value_field": "machine_count", + "color": "#3498db" + } + ] + } + ] +} diff --git a/cartridges/domains/cloud/fly-mcp/tests/integration_test.sh b/cartridges/domains/cloud/fly-mcp/tests/integration_test.sh new file mode 100755 index 0000000..2da3384 --- /dev/null +++ b/cartridges/domains/cloud/fly-mcp/tests/integration_test.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Integration tests for fly-mcp cartridge. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CART_DIR="$(dirname "$SCRIPT_DIR")" +FFI_DIR="$CART_DIR/ffi" + +echo "=== fly-mcp integration tests ===" + +# Build FFI +echo "[1/3] Building FFI..." +cd "$FFI_DIR" && zig build 2>&1 + +# Run Zig unit tests +echo "[2/3] Running FFI unit tests..." +cd "$FFI_DIR" && zig build test 2>&1 + +# Validate Idris2 ABI (if idris2 available) +echo "[3/3] Checking ABI..." +if command -v idris2 &>/dev/null; then + cd "$CART_DIR/abi" && idris2 --check FlyMcp.SafeCloud 2>&1 + echo " ABI: OK" +else + echo " ABI: SKIPPED (idris2 not in PATH)" +fi + +echo "" +echo "All tests passed for fly-mcp!" diff --git a/cartridges/domains/cloud/gcp-mcp/README.adoc b/cartridges/domains/cloud/gcp-mcp/README.adoc new file mode 100644 index 0000000..5bf55df --- /dev/null +++ b/cartridges/domains/cloud/gcp-mcp/README.adoc @@ -0,0 +1,90 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += gcp-mcp +Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk> +:spdx: MPL-2.0 +:tier: Ayo +:domain: Cloud +:protocols: MCP, REST + +== Overview + +Google Cloud Platform cartridge for the BoJ server. Provides type-safe access +to GCP services via service account JSON key or OAuth2 token authentication. +Supports Compute Engine, Cloud Storage (with signed URLs), Cloud Functions, +Firestore (document CRUD and queries), Pub/Sub, Cloud Run, BigQuery, +and IAM (read-only) with configurable project_id and automatic service endpoint routing. + +=== State Machine + +`Unauthenticated -> Authenticated -> RateLimited -> Authenticated` (normal flow) + +`Authenticated -> Error -> Unauthenticated` (error recovery) + +=== Actions (27) + +[cols="1,1"] +|=== +| Service | Actions + +| Compute +| ListProjects, ListInstances, StartInstance, StopInstance + +| Storage +| ListBuckets, GetObject, PutObject, GenerateSignedUrl + +| Functions +| ListFunctions, InvokeFunction + +| Firestore +| CreateDocument, GetDocument, UpdateDocument, DeleteDocument, Query + +| Pub/Sub +| ListPubSubTopics, PublishMessage, ListSubscriptions, CreateSubscription + +| Cloud Run +| ListServices, DeployService + +| BigQuery +| RunQuery, ListDatasets, ListTables, CreateDataset + +| IAM +| GetIamPolicy, TestIamPermissions (read-only) +|=== + +== Architecture + +[cols="1,1,2"] +|=== +| Layer | Language | Purpose + +| ABI +| Idris2 +| Formally verified state machine with dependent-type proofs (GcpMcp.SafeCloud) + +| FFI +| Zig +| C-compatible implementation with thread-safe session pool, service routing, quota tracking + +| Adapter +| zig +| REST bridge to BoJ unified adapter protocol +|=== + +== Building + +[source,bash] +---- +# Build FFI shared library +cd ffi && zig build + +# Run FFI tests +cd ffi && zig build test + +# Type-check ABI +cd abi && idris2 --check gcp_mcp.ipkg +---- + +== Status + +Development (v0.3.0) β€” 8 services, 27 actions, GCP-specific state machine with Firestore, Cloud Run, signed URLs, and IAM test permissions. diff --git a/cartridges/domains/cloud/gcp-mcp/abi/GcpMcp/SafeCloud.idr b/cartridges/domains/cloud/gcp-mcp/abi/GcpMcp/SafeCloud.idr new file mode 100644 index 0000000..521f599 --- /dev/null +++ b/cartridges/domains/cloud/gcp-mcp/abi/GcpMcp/SafeCloud.idr @@ -0,0 +1,330 @@ +-- SPDX-License-Identifier: MPL-2.0 +-- Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +-- +-- GcpMcp.SafeCloud β€” Type-safe ABI for gcp-mcp cartridge. +-- +-- Dependent-type state machine governing Google Cloud Platform API access. +-- Encodes service account / OAuth2 auth flow, multi-service routing +-- (Compute, Storage, Functions, Pub/Sub, BigQuery, IAM), and quota +-- back-pressure as compile-time invariants. No unsafe escape hatches. + +module GcpMcp.SafeCloud + +%default total + +-- --------------------------------------------------------------------------- +-- Authentication state machine +-- --------------------------------------------------------------------------- + +||| Session state for GCP MCP operations. +||| Unauthenticated: no credentials loaded. +||| Authenticated: service account JSON key or OAuth2 token active. +||| RateLimited: GCP quota exceeded; must wait before retry. +||| Error: unrecoverable error (invalid credentials, permission denied, etc.). +public export +data SessionState + = Unauthenticated + | Authenticated + | RateLimited + | Error + +||| Proof that a state transition is valid. +||| Only these six edges are permitted in the session lifecycle. +public export +data ValidTransition : SessionState -> SessionState -> Type where + Authenticate : ValidTransition Unauthenticated Authenticated + Deauthenticate : ValidTransition Authenticated Unauthenticated + Throttle : ValidTransition Authenticated RateLimited + Unthrottle : ValidTransition RateLimited Authenticated + AuthError : ValidTransition Authenticated Error + Recover : ValidTransition Error Unauthenticated + +-- --------------------------------------------------------------------------- +-- C-ABI integer encoding +-- --------------------------------------------------------------------------- + +||| Encode session state as C-compatible integer for the Zig FFI boundary. +export +sessionStateToInt : SessionState -> Int +sessionStateToInt Unauthenticated = 0 +sessionStateToInt Authenticated = 1 +sessionStateToInt RateLimited = 2 +sessionStateToInt Error = 3 + +||| Decode integer back to session state. Returns Nothing for out-of-range. +export +intToSessionState : Int -> Maybe SessionState +intToSessionState 0 = Just Unauthenticated +intToSessionState 1 = Just Authenticated +intToSessionState 2 = Just RateLimited +intToSessionState 3 = Just Error +intToSessionState _ = Nothing + +||| Check if a state transition is valid (C-ABI export). +||| Returns 1 for valid, 0 for invalid. +export +gcp_mcp_can_transition : Int -> Int -> Int +gcp_mcp_can_transition from to = + case (intToSessionState from, intToSessionState to) of + (Just Unauthenticated, Just Authenticated) => 1 + (Just Authenticated, Just Unauthenticated) => 1 + (Just Authenticated, Just RateLimited) => 1 + (Just RateLimited, Just Authenticated) => 1 + (Just Authenticated, Just Error) => 1 + (Just Error, Just Unauthenticated) => 1 + _ => 0 + +-- --------------------------------------------------------------------------- +-- GCP service routing +-- --------------------------------------------------------------------------- + +||| GCP services accessible through this cartridge. +public export +data GcpService + = Compute + | Storage + | Functions + | Firestore + | PubSub + | CloudRun + | BigQuery + | IAM + +||| Map service to its googleapis.com endpoint prefix. +export +serviceEndpoint : GcpService -> String +serviceEndpoint Compute = "compute.googleapis.com" +serviceEndpoint Storage = "storage.googleapis.com" +serviceEndpoint Functions = "cloudfunctions.googleapis.com" +serviceEndpoint Firestore = "firestore.googleapis.com" +serviceEndpoint PubSub = "pubsub.googleapis.com" +serviceEndpoint CloudRun = "run.googleapis.com" +serviceEndpoint BigQuery = "bigquery.googleapis.com" +serviceEndpoint IAM = "iam.googleapis.com" + +||| Encode service as C-compatible integer for FFI. +export +serviceToInt : GcpService -> Int +serviceToInt Compute = 0 +serviceToInt Storage = 1 +serviceToInt Functions = 2 +serviceToInt Firestore = 3 +serviceToInt PubSub = 4 +serviceToInt CloudRun = 5 +serviceToInt BigQuery = 6 +serviceToInt IAM = 7 + +||| Decode integer to GCP service. +export +intToService : Int -> Maybe GcpService +intToService 0 = Just Compute +intToService 1 = Just Storage +intToService 2 = Just Functions +intToService 3 = Just Firestore +intToService 4 = Just PubSub +intToService 5 = Just CloudRun +intToService 6 = Just BigQuery +intToService 7 = Just IAM +intToService _ = Nothing + +-- --------------------------------------------------------------------------- +-- GCP actions +-- --------------------------------------------------------------------------- + +||| Actions available through the GCP MCP cartridge. +||| Grouped by service: Compute (instances), Storage (buckets/objects/signed URLs), +||| Functions (cloud functions), Firestore (document CRUD/queries), +||| Pub/Sub (topics/subscriptions), Cloud Run (services/deploy), +||| BigQuery (datasets/tables/queries), IAM (policies/permissions). +public export +data GcpAction + -- Compute (0-3) + = ListProjects + | ListInstances + | StartInstance + | StopInstance + -- Storage (4-7) + | ListBuckets + | GetObject + | PutObject + | GenerateSignedUrl + -- Functions (8-9) + | ListFunctions + | InvokeFunction + -- Firestore (10-14) + | FirestoreCreateDocument + | FirestoreGetDocument + | FirestoreUpdateDocument + | FirestoreDeleteDocument + | FirestoreQuery + -- Pub/Sub (15-18) + | ListPubSubTopics + | PublishMessage + | ListSubscriptions + | CreateSubscription + -- Cloud Run (19-20) + | CloudRunListServices + | CloudRunDeployService + -- BigQuery (21-24) + | RunQuery + | ListDatasets + | ListTables + | CreateDataset + -- IAM (25-26) + | GetIamPolicy + | TestIamPermissions + +||| Which service handles a given action. +export +actionService : GcpAction -> GcpService +actionService ListProjects = Compute +actionService ListInstances = Compute +actionService StartInstance = Compute +actionService StopInstance = Compute +actionService ListBuckets = Storage +actionService GetObject = Storage +actionService PutObject = Storage +actionService GenerateSignedUrl = Storage +actionService ListFunctions = Functions +actionService InvokeFunction = Functions +actionService FirestoreCreateDocument = Firestore +actionService FirestoreGetDocument = Firestore +actionService FirestoreUpdateDocument = Firestore +actionService FirestoreDeleteDocument = Firestore +actionService FirestoreQuery = Firestore +actionService ListPubSubTopics = PubSub +actionService PublishMessage = PubSub +actionService ListSubscriptions = PubSub +actionService CreateSubscription = PubSub +actionService CloudRunListServices = CloudRun +actionService CloudRunDeployService = CloudRun +actionService RunQuery = BigQuery +actionService ListDatasets = BigQuery +actionService ListTables = BigQuery +actionService CreateDataset = BigQuery +actionService GetIamPolicy = IAM +actionService TestIamPermissions = IAM + +||| Encode action as C-compatible integer for FFI. +export +actionToInt : GcpAction -> Int +actionToInt ListProjects = 0 +actionToInt ListInstances = 1 +actionToInt StartInstance = 2 +actionToInt StopInstance = 3 +actionToInt ListBuckets = 4 +actionToInt GetObject = 5 +actionToInt PutObject = 6 +actionToInt GenerateSignedUrl = 7 +actionToInt ListFunctions = 8 +actionToInt InvokeFunction = 9 +actionToInt FirestoreCreateDocument = 10 +actionToInt FirestoreGetDocument = 11 +actionToInt FirestoreUpdateDocument = 12 +actionToInt FirestoreDeleteDocument = 13 +actionToInt FirestoreQuery = 14 +actionToInt ListPubSubTopics = 15 +actionToInt PublishMessage = 16 +actionToInt ListSubscriptions = 17 +actionToInt CreateSubscription = 18 +actionToInt CloudRunListServices = 19 +actionToInt CloudRunDeployService = 20 +actionToInt RunQuery = 21 +actionToInt ListDatasets = 22 +actionToInt ListTables = 23 +actionToInt CreateDataset = 24 +actionToInt GetIamPolicy = 25 +actionToInt TestIamPermissions = 26 + +||| Decode integer to GCP action. +export +intToAction : Int -> Maybe GcpAction +intToAction 0 = Just ListProjects +intToAction 1 = Just ListInstances +intToAction 2 = Just StartInstance +intToAction 3 = Just StopInstance +intToAction 4 = Just ListBuckets +intToAction 5 = Just GetObject +intToAction 6 = Just PutObject +intToAction 7 = Just GenerateSignedUrl +intToAction 8 = Just ListFunctions +intToAction 9 = Just InvokeFunction +intToAction 10 = Just FirestoreCreateDocument +intToAction 11 = Just FirestoreGetDocument +intToAction 12 = Just FirestoreUpdateDocument +intToAction 13 = Just FirestoreDeleteDocument +intToAction 14 = Just FirestoreQuery +intToAction 15 = Just ListPubSubTopics +intToAction 16 = Just PublishMessage +intToAction 17 = Just ListSubscriptions +intToAction 18 = Just CreateSubscription +intToAction 19 = Just CloudRunListServices +intToAction 20 = Just CloudRunDeployService +intToAction 21 = Just RunQuery +intToAction 22 = Just ListDatasets +intToAction 23 = Just ListTables +intToAction 24 = Just CreateDataset +intToAction 25 = Just GetIamPolicy +intToAction 26 = Just TestIamPermissions +intToAction _ = Nothing + +||| Whether an action requires Authenticated state. +||| All GCP actions require authentication. +export +actionRequiresAuth : GcpAction -> Bool +actionRequiresAuth _ = True + +||| Whether an action is a write/mutating operation. +export +actionIsMutating : GcpAction -> Bool +actionIsMutating StartInstance = True +actionIsMutating StopInstance = True +actionIsMutating PutObject = True +actionIsMutating InvokeFunction = True +actionIsMutating FirestoreCreateDocument = True +actionIsMutating FirestoreUpdateDocument = True +actionIsMutating FirestoreDeleteDocument = True +actionIsMutating PublishMessage = True +actionIsMutating CreateSubscription = True +actionIsMutating CloudRunDeployService = True +actionIsMutating CreateDataset = True +actionIsMutating _ = False + +-- --------------------------------------------------------------------------- +-- MCP tool declarations +-- --------------------------------------------------------------------------- + +||| Tools exposed via MCP protocol for this cartridge. +public export +data McpTool + = ToolAuthenticate + | ToolDeauthenticate + | ToolStatus + | ToolInvoke + | ToolListServices + | ToolListActions + +||| Check if a tool requires an authenticated session. +export +toolRequiresSession : McpTool -> Bool +toolRequiresSession ToolAuthenticate = False +toolRequiresSession ToolDeauthenticate = True +toolRequiresSession ToolStatus = False +toolRequiresSession ToolInvoke = True +toolRequiresSession ToolListServices = False +toolRequiresSession ToolListActions = False + +||| Total tool count for this cartridge. +export +toolCount : Nat +toolCount = 6 + +||| Total action count for this cartridge. +export +actionCount : Nat +actionCount = 27 + +||| Total service count for this cartridge. +export +serviceCount : Nat +serviceCount = 8 diff --git a/cartridges/domains/cloud/gcp-mcp/abi/README.adoc b/cartridges/domains/cloud/gcp-mcp/abi/README.adoc new file mode 100644 index 0000000..7363f7c --- /dev/null +++ b/cartridges/domains/cloud/gcp-mcp/abi/README.adoc @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += gcp-mcp / abi β€” Idris2 ABI layer +:orientation: shallow + +== Purpose + +Cartridge-local protocol types for `gcp-mcp`. Defines the data, tool, and +transition vocabulary this cartridge speaks. Inherits safety lemmas and the +matrix-cell identifier scheme from the trunk ABI at `src/abi/`. + +== Files + +1 Idris2 module(s), ~330 lines total. Lead module: +`SafeCloud.idr`. + +No `.ipkg` β€” consumed directly by wider BoJ callers; type-checks inside the +trunk `boj.ipkg`. + +== Invariants + +* `%default total` at every module top. +* Zero `believe_me`, `assert_total`, `postulate`, `Admitted`, or + `sorry` (the trunk holds the four declared `believe_me` sites; leaf + cartridges carry none). + +== Test/proof surface + +Thin module(s), no inline tests. Proof surface = type-check success under the +trunk `idris2 --check`. + +== Read-first + +. `SafeCloud.idr` β€” cartridge-local data records and enums. +. Trunk `src/abi/Boj/Catalogue.idr` for how this cell participates in the + 2D matrix. diff --git a/cartridges/domains/cloud/gcp-mcp/abi/gcp_mcp.ipkg b/cartridges/domains/cloud/gcp-mcp/abi/gcp_mcp.ipkg new file mode 100644 index 0000000..8a60f76 --- /dev/null +++ b/cartridges/domains/cloud/gcp-mcp/abi/gcp_mcp.ipkg @@ -0,0 +1,13 @@ +-- SPDX-License-Identifier: MPL-2.0 +-- Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +package gcp_mcp + +version = "0.3.0" +authors = "Jonathan D.A. Jewell" +brief = "Type-safe ABI for GCP MCP cartridge β€” service account/OAuth2 auth, multi-service routing (Compute/Storage/Functions/Firestore/PubSub/CloudRun/BigQuery/IAM)" + +sourcedir = "." + +depends = base + +modules = GcpMcp.SafeCloud diff --git a/cartridges/domains/cloud/gcp-mcp/adapter/README.adoc b/cartridges/domains/cloud/gcp-mcp/adapter/README.adoc new file mode 100644 index 0000000..ffc40da --- /dev/null +++ b/cartridges/domains/cloud/gcp-mcp/adapter/README.adoc @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += gcp-mcp / adapter β€” protocol bridge +:orientation: shallow + +== Purpose + +Exposes this cartridge's FFI over the wire protocols declared in +`cartridge.json`. Dispatches tool invocations to the C-ABI exports at +`../ffi/` and formats responses per protocol. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” depends on the sibling `ffi` target. +| `gcp_mcp_adapter.zig` | Protocol dispatch (112 lines). Tool catalogue matches `cartridge.json`. +| `SIDELINED-*.v.adoc` | zig predecessor(s). Not built β€” zig banned 2026-04-10. +|=== + +== Invariants + +* **Stateless** β€” all state lives behind the FFI, never in the adapter. +* **Response passthrough** β€” whatever the FFI returns goes back to the wire + unmodified (no embellishment, no silent recovery). +* **Cartridge.json is source of truth** for the tool catalogue; drift between + adapter and manifest is a CI failure. + +== Test/proof surface + +No inline tests at the adapter layer β€” protocol routing is exercised by the +trunk seam tests at `ffi/zig/src/seams.zig`. + +== Read-first + +. `gcp_mcp_adapter.zig` β€” dispatch function and per-protocol routers. +. `../cartridge.json` β€” the tool manifest the dispatcher mirrors. diff --git a/cartridges/domains/cloud/gcp-mcp/adapter/build.zig b/cartridges/domains/cloud/gcp-mcp/adapter/build.zig new file mode 100644 index 0000000..b7cc05a --- /dev/null +++ b/cartridges/domains/cloud/gcp-mcp/adapter/build.zig @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// gcp-mcp/adapter/build.zig + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); const optimize = b.standardOptimizeOption(.{}); + const ffi_mod = b.createModule(.{ .root_source_file = b.path("../ffi/gcp_mcp_ffi.zig"), .target = target, .optimize = optimize }); + const adapter = b.addExecutable(.{ .name = "gcp_mcp_adapter", .root_source_file = b.path("gcp_mcp_adapter.zig"), .target = target, .optimize = optimize }); + adapter.root_module.addImport("gcp_mcp_ffi", ffi_mod); b.installArtifact(adapter); + const rs = b.step("run", "Run gcp-mcp adapter"); rs.dependOn(&b.addRunArtifact(adapter).step); + const tests = b.addTest(.{ .root_source_file = b.path("gcp_mcp_adapter.zig"), .target = target, .optimize = optimize }); + tests.root_module.addImport("gcp_mcp_ffi", ffi_mod); + const ts = b.step("test", "Test gcp-mcp adapter"); ts.dependOn(&b.addRunArtifact(tests).step); +} diff --git a/cartridges/domains/cloud/gcp-mcp/adapter/gcp_mcp_adapter.zig b/cartridges/domains/cloud/gcp-mcp/adapter/gcp_mcp_adapter.zig new file mode 100644 index 0000000..9ca02f1 --- /dev/null +++ b/cartridges/domains/cloud/gcp-mcp/adapter/gcp_mcp_adapter.zig @@ -0,0 +1,112 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// gcp-mcp/adapter/gcp_mcp_adapter.zig -- Unified three-protocol adapter. +// Replaces banned gcp_mcp_adapter.v (zig, removed 2026-04-12). +// REST:9142 gRPC:9143 GraphQL:9144 +// Tools: gcp_authenticate, gcp_storage_list, gcp_storage_get, gcp_compute_list... + +const std = @import("std"); +const ffi = @import("gcp_mcp_ffi"); + +const REST_PORT: u16 = 9142; +const GRPC_PORT: u16 = 9143; +const GQL_PORT: u16 = 9144; +const MAX_CONN_BUF: usize = 16 * 1024; + +fn okJson(buf: []u8, msg: []const u8) []u8 { + const n = std.fmt.bufPrint(buf, "{\"success\":true,\"message\":\"{s}\"}", .{msg}) catch return buf[0..0]; return n; +} +fn errJson(buf: []u8, msg: []const u8) []u8 { + const n = std.fmt.bufPrint(buf, "{\"success\":false,\"error\":\"{s}\"}", .{msg}) catch return buf[0..0]; return n; +} +fn statusJson(buf: []u8) []u8 { + const n = std.fmt.bufPrint(buf, "{\"success\":true,\"state\":\"ready\",\"service\":\"gcp-mcp\"}", .{}) catch return buf[0..0]; return n; +} + +const Response = struct { status: u16, body: []u8 }; + +fn dispatch(tool: []const u8, body: []const u8, resp: []u8) Response { + _ = body; + if (std.mem.eql(u8, tool, "gcp_authenticate")) return .{ .status = 200, .body = okJson(resp, "gcp_authenticate forwarded") }; + if (std.mem.eql(u8, tool, "gcp_storage_list")) return .{ .status = 200, .body = okJson(resp, "gcp_storage_list forwarded") }; + if (std.mem.eql(u8, tool, "gcp_storage_get")) return .{ .status = 200, .body = okJson(resp, "gcp_storage_get forwarded") }; + if (std.mem.eql(u8, tool, "gcp_compute_list")) return .{ .status = 200, .body = okJson(resp, "gcp_compute_list forwarded") }; + if (std.mem.eql(u8, tool, "gcp_run_invoke")) return .{ .status = 200, .body = okJson(resp, "gcp_run_invoke forwarded") }; + if (std.mem.eql(u8, tool, "gcp_session_state")) return .{ .status = 200, .body = okJson(resp, "gcp_session_state forwarded") }; + if (std.mem.eql(u8, tool, "gcp_deauthenticate")) return .{ .status = 200, .body = okJson(resp, "gcp_deauthenticate forwarded") }; + if (std.mem.eql(u8, tool, "status") or std.mem.eql(u8, tool, "health")) return .{ .status = 200, .body = statusJson(resp) }; + return .{ .status = 404, .body = errJson(resp, "Unknown tool") }; +} + +fn dispatchRest(path: []const u8, body: []const u8, resp: []u8) Response { + if (std.mem.startsWith(u8, path, "/tools/")) return dispatch(path["/tools/".len..], body, resp); + if (std.mem.eql(u8, path, "/status") or std.mem.eql(u8, path, "/health")) return .{ .status = 200, .body = statusJson(resp) }; + return .{ .status = 404, .body = errJson(resp, "Not found") }; +} + +fn dispatchGrpc(path: []const u8, body: []const u8, resp: []u8) Response { + const prefix = "/GcpMcpservice/"; + if (!std.mem.startsWith(u8, path, prefix)) return .{ .status = 404, .body = errJson(resp, "Not a recognized gRPC path") }; + const method = path[prefix.len..]; + const tool = blk: { + if (std.mem.eql(u8, method, "gcp_authenticate")) break :blk "gcp_authenticate"; + if (std.mem.eql(u8, method, "gcp_storage_list")) break :blk "gcp_storage_list"; + if (std.mem.eql(u8, method, "gcp_storage_get")) break :blk "gcp_storage_get"; + if (std.mem.eql(u8, method, "gcp_compute_list")) break :blk "gcp_compute_list"; + if (std.mem.eql(u8, method, "gcp_run_invoke")) break :blk "gcp_run_invoke"; + if (std.mem.eql(u8, method, "gcp_session_state")) break :blk "gcp_session_state"; + if (std.mem.eql(u8, method, "gcp_deauthenticate")) break :blk "gcp_deauthenticate"; + return .{ .status = 404, .body = errJson(resp, "Unknown gRPC method") }; + }; + return dispatch(tool, body, resp); +} + +fn dispatchGraphql(body: []const u8, resp: []u8) Response { + if (std.mem.indexOf(u8, body, "__schema") != null) return .{ .status = 200, .body = okJson(resp, "schema not supported") }; + if (std.mem.indexOf(u8, body, "authenticate") != null) return dispatch("gcp_authenticate", body, resp); + if (std.mem.indexOf(u8, body, "storage_list") != null) return dispatch("gcp_storage_list", body, resp); + if (std.mem.indexOf(u8, body, "storage_get") != null) return dispatch("gcp_storage_get", body, resp); + if (std.mem.indexOf(u8, body, "compute_list") != null) return dispatch("gcp_compute_list", body, resp); + if (std.mem.indexOf(u8, body, "run_invoke") != null) return dispatch("gcp_run_invoke", body, resp); + if (std.mem.indexOf(u8, body, "session_state") != null) return dispatch("gcp_session_state", body, resp); + if (std.mem.indexOf(u8, body, "deauthenticate") != null) return dispatch("gcp_deauthenticate", body, resp); + return .{ .status = 200, .body = errJson(resp, "Unrecognised GraphQL operation") }; +} + +const Protocol = enum { rest, grpc, graphql }; + +fn handleConnection(conn: std.net.Server.Connection, proto: Protocol) void { + defer conn.stream.close(); + var in_buf: [MAX_CONN_BUF]u8 = undefined; + const n = conn.stream.read(&in_buf) catch return; + const req = in_buf[0..n]; + var path: []const u8 = "/"; var body: []const u8 = ""; + if (n > 4) { + const le = std.mem.indexOf(u8, req, "\r\n") orelse req.len; + const fl = req[0..le]; const sp1 = std.mem.indexOfScalar(u8, fl, ' ') orelse 0; + const ro = fl[sp1+1..]; const sp2 = std.mem.indexOfScalar(u8, ro, ' ') orelse ro.len; + path = ro[0..sp2]; + const bs = std.mem.indexOf(u8, req, "\r\n\r\n") orelse n; body = req[@min(bs+4,n)..]; + } + var rb: [MAX_CONN_BUF]u8 = undefined; + const result = switch (proto) { .rest => dispatchRest(path, body, &rb), .grpc => dispatchGrpc(path, body, &rb), .graphql => dispatchGraphql(body, &rb) }; + const ct: []const u8 = if (proto == .grpc) "application/grpc+json" else "application/json"; + var hb: [256]u8 = undefined; + const hdr = std.fmt.bufPrint(&hb, "HTTP/1.1 {d} OK\r\nContent-Type: {s}\r\nContent-Length: {d}\r\nConnection: close\r\n\r\n", .{ result.status, ct, result.body.len }) catch return; + _ = conn.stream.writeAll(hdr) catch return; _ = conn.stream.writeAll(result.body) catch return; +} + +fn listenLoop(port: u16, proto: Protocol) void { + const addr = std.net.Address.initIp4(.{ 127, 0, 0, 1 }, port) catch return; + var srv = addr.listen(.{ .reuse_address = true }) catch return; defer srv.deinit(); + while (true) { const conn = srv.accept() catch continue; handleConnection(conn, proto); } +} + +pub fn main() !void { + ffi.gcp_mcp_init(); + const t1 = try std.Thread.spawn(.{}, listenLoop, .{ REST_PORT, Protocol.rest }); + const t2 = try std.Thread.spawn(.{}, listenLoop, .{ GRPC_PORT, Protocol.grpc }); + const t3 = try std.Thread.spawn(.{}, listenLoop, .{ GQL_PORT, Protocol.graphql }); + t1.join(); t2.join(); t3.join(); +} diff --git a/cartridges/domains/cloud/gcp-mcp/benchmarks/quick-bench.sh b/cartridges/domains/cloud/gcp-mcp/benchmarks/quick-bench.sh new file mode 100755 index 0000000..817be37 --- /dev/null +++ b/cartridges/domains/cloud/gcp-mcp/benchmarks/quick-bench.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Benchmarks for gcp-mcp cartridge. +set -euo pipefail + +echo "=== gcp-mcp benchmarks ===" + +cd "$(dirname "${BASH_SOURCE[0]}")/../ffi" + +# Build with ReleaseFast for benchmarking +zig build -Doptimize=ReleaseFast 2>&1 + +echo "Session open/close cycle (1000 iterations):" +time for i in $(seq 1 1000); do + # This would call the FFI benchmark binary + true +done + +echo "" +echo "Benchmark placeholder β€” implement real benchmarks in Zig test blocks" +echo "or via the zig adapter HTTP benchmark tool." diff --git a/cartridges/domains/cloud/gcp-mcp/cartridge.json b/cartridges/domains/cloud/gcp-mcp/cartridge.json new file mode 100644 index 0000000..ea1eb59 --- /dev/null +++ b/cartridges/domains/cloud/gcp-mcp/cartridge.json @@ -0,0 +1,169 @@ +{ + "$schema": "https://boj.dev/schemas/cartridge/v1.json", + "spdx": "MPL-2.0", + "copyright": "Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath)", + "name": "gcp-mcp", + "version": "0.1.0", + "description": "GCP cloud gateway. Project-scoped authentication with quota tracking and multi-service routing.", + "domain": "Cloud", + "tier": "Ayo", + "protocols": [ + "MCP", + "REST" + ], + "auth": { + "method": "none", + "env_var": null, + "credential_source": null + }, + "api": { + "base_url": "local://gcp-mcp", + "content_type": "application/json" + }, + "tools": [ + { + "name": "gcp_authenticate", + "description": "Authenticate with GCP for a project. Returns a session slot index.", + "inputSchema": { + "type": "object", + "properties": { + "project": { + "type": "string", + "description": "GCP project ID" + }, + "credentials_file": { + "type": "string", + "description": "Path to service account JSON (uses ADC if omitted)" + } + }, + "required": [ + "project" + ] + } + }, + { + "name": "gcp_storage_list", + "description": "List GCS buckets or objects.", + "inputSchema": { + "type": "object", + "properties": { + "bucket": { + "type": "string", + "description": "Bucket name (omit to list all buckets in project)" + }, + "prefix": { + "type": "string", + "description": "Object prefix filter" + } + }, + "required": [] + } + }, + { + "name": "gcp_storage_get", + "description": "Get a GCS object.", + "inputSchema": { + "type": "object", + "properties": { + "bucket": { + "type": "string", + "description": "Bucket name" + }, + "object": { + "type": "string", + "description": "Object name" + } + }, + "required": [ + "bucket", + "object" + ] + } + }, + { + "name": "gcp_compute_list", + "description": "List Compute Engine instances.", + "inputSchema": { + "type": "object", + "properties": { + "zone": { + "type": "string", + "description": "GCP zone (e.g. 'us-central1-a')" + }, + "filter": { + "type": "string", + "description": "Filter expression" + } + }, + "required": [] + } + }, + { + "name": "gcp_run_invoke", + "description": "Invoke a Cloud Run service.", + "inputSchema": { + "type": "object", + "properties": { + "service_url": { + "type": "string", + "description": "Cloud Run service URL" + }, + "method": { + "type": "string", + "description": "HTTP method (default: GET)" + }, + "body": { + "type": "string", + "description": "Request body (JSON)" + } + }, + "required": [ + "service_url" + ] + } + }, + { + "name": "gcp_session_state", + "description": "Get the state of a GCP session slot.", + "inputSchema": { + "type": "object", + "properties": { + "slot": { + "type": "integer", + "description": "Session slot from gcp_authenticate" + } + }, + "required": [ + "slot" + ] + } + }, + { + "name": "gcp_deauthenticate", + "description": "Release a GCP session slot.", + "inputSchema": { + "type": "object", + "properties": { + "slot": { + "type": "integer", + "description": "Session slot to release" + } + }, + "required": [ + "slot" + ] + } + } + ], + "ffi": { + "so_path": "ffi/zig-out/lib/libgcp_mcp.so", + "abi_version": "ADR-0006", + "symbols": [ + "boj_cartridge_init", + "boj_cartridge_deinit", + "boj_cartridge_name", + "boj_cartridge_version", + "boj_cartridge_invoke" + ] + } +} diff --git a/cartridges/domains/cloud/gcp-mcp/ffi/README.adoc b/cartridges/domains/cloud/gcp-mcp/ffi/README.adoc new file mode 100644 index 0000000..80c0414 --- /dev/null +++ b/cartridges/domains/cloud/gcp-mcp/ffi/README.adoc @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += gcp-mcp / ffi β€” Zig FFI layer +:orientation: shallow + +== Purpose + +C-ABI exports bridging this cartridge's Idris2 ABI to the runtime. Consumed by +the adapter for protocol dispatch and by the trunk loader for mount. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” produces `zig-out/lib/libgcp.so`. +| `gcp_mcp_ffi.zig` | C-ABI exports (14 exports, 7 inline tests, 431 lines). +|=== + +== Invariants + +* **Null-pointer rejection** on `[*c]const u8` arguments before any + dereference. +* **Negative return values** carry documented error meanings; zero is + success (matching the trunk error-code convention). +* **No allocator leaks** β€” every allocation path pairs with `defer free`. + +== Test/proof surface + +7 inline `test "..."` block(s) in `gcp_mcp_ffi.zig`. Run with +`cd ffi && zig build test`. + +== Read-first + +. `gcp_mcp_ffi.zig` β€” C-ABI signatures and guards. +. Trunk `ffi/zig/src/catalogue.zig` for how this `.so` is loaded. diff --git a/cartridges/domains/cloud/gcp-mcp/ffi/build.zig b/cartridges/domains/cloud/gcp-mcp/ffi/build.zig new file mode 100644 index 0000000..84733c1 --- /dev/null +++ b/cartridges/domains/cloud/gcp-mcp/ffi/build.zig @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// build.zig β€” Build configuration for gcp-mcp FFI shared library and tests. + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + // Module + // Shared ADR-0006 invoke-shim module (relative path up to boj-server trunk). + + const shim_mod = b.addModule("cartridge_shim", .{ + + .root_source_file = b.path("../../../ffi/zig/src/cartridge_shim.zig"), + + .target = target, + + .optimize = optimize, + + }); + + const ffi_mod = b.addModule("gcp_mcp", .{ + .root_source_file = b.path("gcp_mcp_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + ffi_mod.addImport("cartridge_shim", shim_mod); + + // Shared library + const lib = b.addLibrary(.{ + .name = "gcp_mcp", + .root_module = ffi_mod, + .linkage = .dynamic, + }); + lib.linkLibC(); + b.installArtifact(lib); + + // Tests + const tests = b.addTest(.{ + .root_module = ffi_mod, + }); + tests.linkLibC(); + + const run_tests = b.addRunArtifact(tests); + const test_step = b.step("test", "Run FFI tests"); + test_step.dependOn(&run_tests.step); +} diff --git a/cartridges/domains/cloud/gcp-mcp/ffi/cartridge_shim.zig b/cartridges/domains/cloud/gcp-mcp/ffi/cartridge_shim.zig new file mode 100644 index 0000000..0e399db --- /dev/null +++ b/cartridges/domains/cloud/gcp-mcp/ffi/cartridge_shim.zig @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// cartridge_shim.zig β€” Shared helpers for the ADR-0006 five-symbol +// cartridge ABI (`boj_cartridge_init / deinit / name / version / invoke`). +// +// The shim centralises the seven-code return convention, NUL-argument +// guards, tool-name comparison, and the buffer-too-small path so each +// cartridge's `boj_cartridge_invoke` can stay short β€” typically a tool +// table plus `shim.writeResult(...)`. +// +// Cartridges import this file by relative path (no build-graph change +// needed). Example: +// +// const shim = @import("../../../ffi/zig/src/cartridge_shim.zig"); +// +// export fn boj_cartridge_invoke( +// tool_name: [*c]const u8, +// json_args: [*c]const u8, +// out_buf: [*c]u8, +// in_out_len: [*c]usize, +// ) callconv(.c) i32 { +// _ = json_args; +// if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; +// const body = if (shim.toolIs(tool_name, "foo")) "{\"result\":{}}" +// else return shim.RC_UNKNOWN_TOOL; +// return shim.writeResult(out_buf, in_out_len, body); +// } + +const std = @import("std"); + +// ── Return codes (ADR-0006 Β§Return codes) ──────────────────────────── +// +// Frozen by ADR-0006. New failure modes compose these via the error +// JSON body β€” the integer surface does not grow without a follow-up ADR. + +pub const RC_SUCCESS: i32 = 0; +pub const RC_UNKNOWN_TOOL: i32 = -1; +pub const RC_BAD_ARGS: i32 = -2; +pub const RC_BUFFER_TOO_SMALL: i32 = -3; +pub const RC_RUNTIME_ERROR: i32 = -4; +pub const RC_PANIC: i32 = -5; +pub const RC_AUTH_DENIED: i32 = -6; + +// ── Invoke-path helpers ────────────────────────────────────────────── + +/// True if any of the three mandatory `boj_cartridge_invoke` output-path +/// pointers is null. Use at the top of every invoke to short-circuit to +/// `RC_BAD_ARGS`. +pub fn invokeArgsNull( + tool_name: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) bool { + return tool_name == null or out_buf == null or in_out_len == null; +} + +/// Compare a C-NUL-terminated tool-name pointer against a Zig string +/// literal. Caller must have already verified `tool_name` is non-null +/// (usually via `invokeArgsNull`). +pub fn toolIs(tool_name: [*c]const u8, expected: []const u8) bool { + const s = std.mem.span(@as([*:0]const u8, @ptrCast(tool_name))); + return std.mem.eql(u8, s, expected); +} + +/// Copy `body` into `out_buf[0..*in_out_len]` (as a capacity) and update +/// `*in_out_len` to the number of bytes written. Returns `RC_SUCCESS`. +/// +/// If `body.len` exceeds the current capacity stored in `*in_out_len`, +/// sets `*in_out_len` to the required size and returns +/// `RC_BUFFER_TOO_SMALL` β€” the caller is then expected to re-allocate +/// and retry, per ADR-0006 Β§Memory ownership. +/// +/// Caller must have already verified that `out_buf` and `in_out_len` +/// are non-null. +pub fn writeResult( + out_buf: [*c]u8, + in_out_len: [*c]usize, + body: []const u8, +) i32 { + const cap = in_out_len.*; + if (body.len > cap) { + in_out_len.* = body.len; + return RC_BUFFER_TOO_SMALL; + } + @memcpy(out_buf[0..body.len], body); + in_out_len.* = body.len; + return RC_SUCCESS; +} + +// ── Tests ──────────────────────────────────────────────────────────── + +test "writeResult: body fits, writes and sets length" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); + try std.testing.expectEqualStrings("hello", buf[0..len]); +} + +test "writeResult: too small returns -3 and sets required length" { + var buf: [2]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_BUFFER_TOO_SMALL, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: exact-fit succeeds" { + var buf: [5]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: empty body" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, ""); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 0), len); +} + +test "toolIs: matches and rejects" { + const name: [*:0]const u8 = "foo"; + try std.testing.expect(toolIs(@ptrCast(name), "foo")); + try std.testing.expect(!toolIs(@ptrCast(name), "bar")); + try std.testing.expect(!toolIs(@ptrCast(name), "foobar")); + try std.testing.expect(!toolIs(@ptrCast(name), "fo")); +} + +test "invokeArgsNull: detects each null slot" { + var buf: [4]u8 = undefined; + var len: usize = 4; + const name: [*:0]const u8 = "x"; + try std.testing.expect(!invokeArgsNull(@ptrCast(name), &buf, &len)); + try std.testing.expect(invokeArgsNull(null, &buf, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), null, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), &buf, null)); +} diff --git a/cartridges/domains/cloud/gcp-mcp/ffi/gcp_mcp_ffi.zig b/cartridges/domains/cloud/gcp-mcp/ffi/gcp_mcp_ffi.zig new file mode 100644 index 0000000..bb43785 --- /dev/null +++ b/cartridges/domains/cloud/gcp-mcp/ffi/gcp_mcp_ffi.zig @@ -0,0 +1,531 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// gcp_mcp_ffi.zig β€” C-ABI FFI implementation for gcp-mcp cartridge. +// +// Implements the state machine defined in GcpMcp.SafeCloud (Idris2 ABI). +// State machine: Unauthenticated | Authenticated | RateLimited | Error +// Auth: Service account JSON key or OAuth2 token, REST API (googleapis.com). +// Services: Compute, Storage, Functions, Pub/Sub, BigQuery, IAM with +// configurable project_id and multi-service routing. +// Thread-safe via std.Thread.Mutex. Fixed-size session pool, no heap allocations. + +const std = @import("std"); + +// --------------------------------------------------------------------------- +// State machine (matches Idris2 ABI SessionState exactly) +// --------------------------------------------------------------------------- + +/// Session authentication/lifecycle state. +/// 0 = Unauthenticated, 1 = Authenticated, 2 = RateLimited, 3 = Error. +pub const SessionState = enum(c_int) { + unauthenticated = 0, + authenticated = 1, + rate_limited = 2, + err = 3, +}; + +/// GCP service identifiers matching Idris2 GcpService encoding. +pub const GcpService = enum(c_int) { + compute = 0, + storage = 1, + functions = 2, + firestore = 3, + pubsub = 4, + cloud_run = 5, + bigquery = 6, + iam = 7, +}; + +/// GCP action identifiers matching Idris2 GcpAction encoding. +pub const GcpAction = enum(c_int) { + // Compute (0-3) + list_projects = 0, + list_instances = 1, + start_instance = 2, + stop_instance = 3, + // Storage (4-7) + list_buckets = 4, + get_object = 5, + put_object = 6, + generate_signed_url = 7, + // Functions (8-9) + list_functions = 8, + invoke_function = 9, + // Firestore (10-14) + firestore_create_document = 10, + firestore_get_document = 11, + firestore_update_document = 12, + firestore_delete_document = 13, + firestore_query = 14, + // Pub/Sub (15-18) + list_pubsub_topics = 15, + publish_message = 16, + list_subscriptions = 17, + create_subscription = 18, + // Cloud Run (19-20) + cloud_run_list_services = 19, + cloud_run_deploy_service = 20, + // BigQuery (21-24) + run_query = 21, + list_datasets = 22, + list_tables = 23, + create_dataset = 24, + // IAM (25-26) + get_iam_policy = 25, + test_iam_permissions = 26, +}; + +/// Check valid state transitions per the Idris2 ValidTransition proof. +fn isValidTransition(from: SessionState, to: SessionState) bool { + return switch (from) { + .unauthenticated => to == .authenticated, + .authenticated => to == .unauthenticated or to == .rate_limited or to == .err, + .rate_limited => to == .authenticated, + .err => to == .unauthenticated, + }; +} + +/// Map action integer to its service integer. Returns -1 for invalid action. +fn actionToService(action: c_int) c_int { + const a = std.meta.intToEnum(GcpAction, action) catch return -1; + return switch (a) { + .list_projects, .list_instances, .start_instance, .stop_instance => 0, + .list_buckets, .get_object, .put_object, .generate_signed_url => 1, + .list_functions, .invoke_function => 2, + .firestore_create_document, .firestore_get_document, .firestore_update_document, .firestore_delete_document, .firestore_query => 3, + .list_pubsub_topics, .publish_message, .list_subscriptions, .create_subscription => 4, + .cloud_run_list_services, .cloud_run_deploy_service => 5, + .run_query, .list_datasets, .list_tables, .create_dataset => 6, + .get_iam_policy, .test_iam_permissions => 7, + }; +} + +// --------------------------------------------------------------------------- +// Session slots (thread-safe, fixed-size pool) +// --------------------------------------------------------------------------- + +const MAX_SESSIONS: usize = 16; +const PROJECT_BUF_SIZE: usize = 128; + +const SessionSlot = struct { + active: bool = false, + state: SessionState = .unauthenticated, + project_buf: [PROJECT_BUF_SIZE]u8 = .{0} ** PROJECT_BUF_SIZE, + project_len: usize = 0, + api_call_count: u64 = 0, + last_action: c_int = -1, + quota_remaining: u32 = 10000, +}; + +var sessions: [MAX_SESSIONS]SessionSlot = .{SessionSlot{}} ** MAX_SESSIONS; +var mutex: std.Thread.Mutex = .{}; + +// --------------------------------------------------------------------------- +// C-ABI exports β€” state machine +// --------------------------------------------------------------------------- + +/// Check if a state transition is valid. Returns 1 (valid) or 0 (invalid). +pub export fn gcp_mcp_can_transition(from: c_int, to: c_int) c_int { + const f = std.meta.intToEnum(SessionState, from) catch return 0; + const t = std.meta.intToEnum(SessionState, to) catch return 0; + return if (isValidTransition(f, t)) 1 else 0; +} + +/// Authenticate a session with project_id. Returns slot index (>= 0) or error (< 0). +/// Error codes: -1 = no free slots, -2 = project_id too long. +pub export fn gcp_mcp_authenticate(project_ptr: [*]const u8, project_len: c_int) c_int { + const plen: usize = std.math.cast(usize, project_len) orelse return -2; + if (plen > PROJECT_BUF_SIZE) return -2; + + mutex.lock(); + defer mutex.unlock(); + + for (&sessions, 0..) |*slot, idx| { + if (!slot.active) { + slot.active = true; + slot.state = .authenticated; + @memcpy(slot.project_buf[0..plen], project_ptr[0..plen]); + slot.project_len = plen; + slot.api_call_count = 0; + slot.last_action = -1; + slot.quota_remaining = 10000; + return @intCast(idx); + } + } + return -1; +} + +/// Deauthenticate (close) a session. Returns 0 on success. +/// Error codes: -1 = invalid slot, -2 = invalid state transition. +pub export fn gcp_mcp_deauthenticate(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + if (!isValidTransition(slot.state, .unauthenticated)) return -2; + + sessions[idx] = SessionSlot{}; + return 0; +} + +/// Get current state of a session. Returns state int or -1 if invalid. +pub export fn gcp_mcp_session_state(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + return @intFromEnum(slot.state); +} + +/// Signal rate limiting (quota exceeded) on a session. Returns 0 on success. +pub export fn gcp_mcp_throttle(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + if (!isValidTransition(slot.state, .rate_limited)) return -2; + + sessions[idx].state = .rate_limited; + return 0; +} + +/// Clear rate limiting (resume authenticated). Returns 0 on success. +pub export fn gcp_mcp_unthrottle(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + if (!isValidTransition(slot.state, .authenticated)) return -2; + + sessions[idx].state = .authenticated; + return 0; +} + +/// Signal an error on a session. Returns 0 on success. +pub export fn gcp_mcp_signal_error(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + if (!isValidTransition(slot.state, .err)) return -2; + + sessions[idx].state = .err; + return 0; +} + +// --------------------------------------------------------------------------- +// C-ABI exports β€” service routing and actions +// --------------------------------------------------------------------------- + +/// Get the service for an action. Returns service int (0-5) or -1 for invalid. +pub export fn gcp_mcp_action_service(action: c_int) c_int { + return actionToService(action); +} + +/// Record an API call on a session. Returns 0 on success. +/// Error codes: -1 = invalid slot, -2 = not authenticated, -3 = invalid action. +pub export fn gcp_mcp_record_call(slot_idx: c_int, action: c_int) c_int { + _ = std.meta.intToEnum(GcpAction, action) catch return -3; + + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + if (slot.state != .authenticated) return -2; + + sessions[idx].api_call_count += 1; + sessions[idx].last_action = action; + if (sessions[idx].quota_remaining > 0) { + sessions[idx].quota_remaining -= 1; + } + return 0; +} + +/// Get API call count for a session. Returns count or -1 if invalid. +pub export fn gcp_mcp_call_count(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + return @intCast(slot.api_call_count); +} + +/// Get remaining quota for a session. Returns quota or -1 if invalid. +pub export fn gcp_mcp_quota_remaining(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + return @intCast(slot.quota_remaining); +} + +/// Get total service count. Always returns 8. +pub export fn gcp_mcp_service_count() c_int { + return 8; +} + +/// Get total action count. Always returns 27. +pub export fn gcp_mcp_action_count() c_int { + return 27; +} + +/// Reset all sessions (test/debug use only). +pub export fn gcp_mcp_reset() void { + mutex.lock(); + defer mutex.unlock(); + sessions = .{SessionSlot{}} ** MAX_SESSIONS; +} + +// --------------------------------------------------------------------------- +// ═══════════════════════════════════════════════════════════════════════ +// Standard ABI (ADR-0005 four symbols + ADR-0006 invoke) +// ═══════════════════════════════════════════════════════════════════════ + +const shim = @import("cartridge_shim.zig"); + +const CARTRIDGE_NAME_PTR: [*:0]const u8 = "gcp-mcp"; +const CARTRIDGE_VERSION_PTR: [*:0]const u8 = "0.1.0"; + +export fn boj_cartridge_init() callconv(.c) c_int { + return 0; +} + +export fn boj_cartridge_deinit() callconv(.c) void {} + +export fn boj_cartridge_name() callconv(.c) [*:0]const u8 { + return CARTRIDGE_NAME_PTR; +} + +export fn boj_cartridge_version() callconv(.c) [*:0]const u8 { + return CARTRIDGE_VERSION_PTR; +} + +/// Dispatch the cartridge.json MCP tools. Grade D Alpha stubs. +export fn boj_cartridge_invoke( + tool_name: [*c]const u8, + json_args: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) callconv(.c) i32 { + _ = json_args; + if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; + + const body: []const u8 = if (shim.toolIs(tool_name, "gcp_authenticate")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "gcp_storage_list")) + "{\"result\":{\"items\":[],\"count\":0,\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "gcp_storage_get")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "gcp_compute_list")) + "{\"result\":{\"items\":[],\"count\":0,\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "gcp_run_invoke")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "gcp_session_state")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "gcp_deauthenticate")) + "{\"result\":{\"status\":\"stub\"}}" +else + return shim.RC_UNKNOWN_TOOL; + + return shim.writeResult(out_buf, in_out_len, body); +} + +// Tests +// --------------------------------------------------------------------------- + +test "authentication lifecycle" { + gcp_mcp_reset(); + + const project = "my-gcp-project"; + const slot = gcp_mcp_authenticate(project.ptr, @intCast(project.len)); + try std.testing.expect(slot >= 0); + + // Should be authenticated (1) + try std.testing.expectEqual(@as(c_int, 1), gcp_mcp_session_state(slot)); + + // Record an API call + try std.testing.expectEqual(@as(c_int, 0), gcp_mcp_record_call(slot, 0)); + try std.testing.expectEqual(@as(c_int, 1), gcp_mcp_call_count(slot)); + + // Deauthenticate + try std.testing.expectEqual(@as(c_int, 0), gcp_mcp_deauthenticate(slot)); +} + +test "rate limiting and quota" { + gcp_mcp_reset(); + + const project = "quota-test"; + const slot = gcp_mcp_authenticate(project.ptr, @intCast(project.len)); + try std.testing.expect(slot >= 0); + + // Check initial quota + try std.testing.expectEqual(@as(c_int, 10000), gcp_mcp_quota_remaining(slot)); + + // Record a call, quota should decrease + try std.testing.expectEqual(@as(c_int, 0), gcp_mcp_record_call(slot, 4)); + try std.testing.expectEqual(@as(c_int, 9999), gcp_mcp_quota_remaining(slot)); + + // Throttle + try std.testing.expectEqual(@as(c_int, 0), gcp_mcp_throttle(slot)); + try std.testing.expectEqual(@as(c_int, 2), gcp_mcp_session_state(slot)); + + // Cannot invoke while rate limited + try std.testing.expectEqual(@as(c_int, -2), gcp_mcp_record_call(slot, 0)); + + // Unthrottle + try std.testing.expectEqual(@as(c_int, 0), gcp_mcp_unthrottle(slot)); + try std.testing.expectEqual(@as(c_int, 1), gcp_mcp_session_state(slot)); +} + +test "error and recovery" { + gcp_mcp_reset(); + + const project = "error-test"; + const slot = gcp_mcp_authenticate(project.ptr, @intCast(project.len)); + try std.testing.expect(slot >= 0); + + // Signal error + try std.testing.expectEqual(@as(c_int, 0), gcp_mcp_signal_error(slot)); + try std.testing.expectEqual(@as(c_int, 3), gcp_mcp_session_state(slot)); + + // Recover to unauthenticated + try std.testing.expectEqual(@as(c_int, 0), gcp_mcp_deauthenticate(slot)); +} + +test "invalid transitions rejected" { + gcp_mcp_reset(); + + const project = "transition-test"; + const slot = gcp_mcp_authenticate(project.ptr, @intCast(project.len)); + try std.testing.expect(slot >= 0); + + // Cannot throttle twice + try std.testing.expectEqual(@as(c_int, 0), gcp_mcp_throttle(slot)); + try std.testing.expectEqual(@as(c_int, -2), gcp_mcp_throttle(slot)); + + // Cannot error from rate_limited + try std.testing.expectEqual(@as(c_int, -2), gcp_mcp_signal_error(slot)); +} + +test "transition validator" { + // Valid transitions + try std.testing.expectEqual(@as(c_int, 1), gcp_mcp_can_transition(0, 1)); + try std.testing.expectEqual(@as(c_int, 1), gcp_mcp_can_transition(1, 0)); + try std.testing.expectEqual(@as(c_int, 1), gcp_mcp_can_transition(1, 2)); + try std.testing.expectEqual(@as(c_int, 1), gcp_mcp_can_transition(2, 1)); + try std.testing.expectEqual(@as(c_int, 1), gcp_mcp_can_transition(1, 3)); + try std.testing.expectEqual(@as(c_int, 1), gcp_mcp_can_transition(3, 0)); + + // Invalid transitions + try std.testing.expectEqual(@as(c_int, 0), gcp_mcp_can_transition(0, 2)); + try std.testing.expectEqual(@as(c_int, 0), gcp_mcp_can_transition(0, 3)); + try std.testing.expectEqual(@as(c_int, 0), gcp_mcp_can_transition(2, 0)); + try std.testing.expectEqual(@as(c_int, 0), gcp_mcp_can_transition(3, 1)); +} + +test "action service routing" { + try std.testing.expectEqual(@as(c_int, 0), gcp_mcp_action_service(0)); // ListProjects -> Compute + try std.testing.expectEqual(@as(c_int, 1), gcp_mcp_action_service(4)); // ListBuckets -> Storage + try std.testing.expectEqual(@as(c_int, 1), gcp_mcp_action_service(7)); // GenerateSignedUrl -> Storage + try std.testing.expectEqual(@as(c_int, 2), gcp_mcp_action_service(8)); // ListFunctions -> Functions + try std.testing.expectEqual(@as(c_int, 3), gcp_mcp_action_service(10)); // FirestoreCreateDocument -> Firestore + try std.testing.expectEqual(@as(c_int, 3), gcp_mcp_action_service(14)); // FirestoreQuery -> Firestore + try std.testing.expectEqual(@as(c_int, 4), gcp_mcp_action_service(15)); // ListPubSubTopics -> PubSub + try std.testing.expectEqual(@as(c_int, 4), gcp_mcp_action_service(18)); // CreateSubscription -> PubSub + try std.testing.expectEqual(@as(c_int, 5), gcp_mcp_action_service(19)); // CloudRunListServices -> CloudRun + try std.testing.expectEqual(@as(c_int, 5), gcp_mcp_action_service(20)); // CloudRunDeployService -> CloudRun + try std.testing.expectEqual(@as(c_int, 6), gcp_mcp_action_service(21)); // RunQuery -> BigQuery + try std.testing.expectEqual(@as(c_int, 6), gcp_mcp_action_service(23)); // ListTables -> BigQuery + try std.testing.expectEqual(@as(c_int, 7), gcp_mcp_action_service(25)); // GetIamPolicy -> IAM + try std.testing.expectEqual(@as(c_int, 7), gcp_mcp_action_service(26)); // TestIamPermissions -> IAM + try std.testing.expectEqual(@as(c_int, -1), gcp_mcp_action_service(99)); // invalid +} + +test "slot exhaustion" { + gcp_mcp_reset(); + + const project = "exhaust-test"; + var slots: [MAX_SESSIONS]c_int = undefined; + for (&slots) |*s| { + s.* = gcp_mcp_authenticate(project.ptr, @intCast(project.len)); + try std.testing.expect(s.* >= 0); + } + + try std.testing.expectEqual(@as(c_int, -1), gcp_mcp_authenticate(project.ptr, @intCast(project.len))); + + try std.testing.expectEqual(@as(c_int, 0), gcp_mcp_deauthenticate(slots[0])); + const new_slot = gcp_mcp_authenticate(project.ptr, @intCast(project.len)); + try std.testing.expect(new_slot >= 0); +} + +// ═══════════════════════════════════════════════════════════════════════ +// ADR-0006 invoke dispatch tests +// ═══════════════════════════════════════════════════════════════════════ + +test "boj_cartridge_name returns gcp-mcp" { + const n = std.mem.span(boj_cartridge_name()); + try std.testing.expectEqualStrings("gcp-mcp", n); +} + +test "boj_cartridge_init returns 0" { + try std.testing.expectEqual(@as(c_int, 0), boj_cartridge_init()); +} + +test "invoke: each declared tool succeeds" { + var buf: [256]u8 = undefined; + const tools = [_][]const u8{ + "gcp_authenticate", + "gcp_storage_list", + "gcp_storage_get", + "gcp_compute_list", + "gcp_run_invoke", + "gcp_session_state", + "gcp_deauthenticate", + }; + for (tools) |t| { + var len: usize = buf.len; + const rc = boj_cartridge_invoke(t.ptr, "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, 0), rc); + try std.testing.expect(std.mem.indexOf(u8, buf[0..len], "result") != null); + } +} + +test "invoke: unknown tool returns -1" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("nope", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -1), rc); +} + +test "invoke: buffer too small returns -3" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("gcp_authenticate", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -3), rc); + try std.testing.expect(len > 4); +} diff --git a/cartridges/domains/cloud/gcp-mcp/minter.toml b/cartridges/domains/cloud/gcp-mcp/minter.toml new file mode 100644 index 0000000..00fef3a --- /dev/null +++ b/cartridges/domains/cloud/gcp-mcp/minter.toml @@ -0,0 +1,29 @@ +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> + +name = "gcp-mcp" +description = "GCP cloud cartridge β€” service account/OAuth2 auth, multi-service routing (Compute/Storage/Functions/Firestore/PubSub/CloudRun/BigQuery/IAM) with quota tracking" +version = "0.3.0" +domain = "Cloud" +protocols = [ + "MCP", + "REST", +] +tier = "Ayo" +backend = "universal" +generate_panel = true + +[auth] +method = "service_account_or_oauth2" +credential_source = "vault-mcp" +fields = ["project_id", "service_account_json_or_oauth2_token"] + +[services] +compute = "https://compute.googleapis.com" +storage = "https://storage.googleapis.com" +functions = "https://cloudfunctions.googleapis.com" +firestore = "https://firestore.googleapis.com" +pubsub = "https://pubsub.googleapis.com" +cloud_run = "https://run.googleapis.com" +bigquery = "https://bigquery.googleapis.com" +iam = "https://iam.googleapis.com" diff --git a/cartridges/domains/cloud/gcp-mcp/mod.js b/cartridges/domains/cloud/gcp-mcp/mod.js new file mode 100644 index 0000000..4ed2d31 --- /dev/null +++ b/cartridges/domains/cloud/gcp-mcp/mod.js @@ -0,0 +1,78 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// gcp-mcp/mod.js -- gcp gateway. + +const BASE_URL = Deno.env.get("GCP_MCP_BACKEND_URL") ?? "http://127.0.0.1:7714"; + +async function post(path, payload) { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), 15000); + try { + const r = await fetch(`${BASE_URL}${path}`, { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), signal: ctrl.signal, + }); + const data = await r.json().catch(() => ({ success: false, error: "non-JSON response" })); + return { status: r.status, data }; + } catch (e) { + if (e.name === "AbortError") return { status: 504, data: { success: false, error: "gcp-mcp backend timed out" } }; + return { status: 503, data: { success: false, error: `gcp-mcp backend unavailable: ${e.message}` } }; + } finally { clearTimeout(t); } +} + +export async function handleTool(toolName, args) { + switch (toolName) { + + case "gcp_authenticate": { + const { project, credentials_file } = args ?? {}; + if (!project) return { status: 400, data: { error: "project is required" } }; + const payload = { project }; + if (credentials_file !== undefined) payload.credentials_file = credentials_file; + return post("/api/v1/authenticate", payload); + } + case "gcp_storage_list": { + const { bucket, prefix } = args ?? {}; + const payload = { }; + if (bucket !== undefined) payload.bucket = bucket; + if (prefix !== undefined) payload.prefix = prefix; + return post("/api/v1/storage-list", payload); + } + case "gcp_storage_get": { + const { bucket, object } = args ?? {}; + if (!bucket || !object) return { status: 400, data: { error: "bucket is required" } }; + const payload = { bucket, object }; + return post("/api/v1/storage-get", payload); + } + case "gcp_compute_list": { + const { zone, filter } = args ?? {}; + const payload = { }; + if (zone !== undefined) payload.zone = zone; + if (filter !== undefined) payload.filter = filter; + return post("/api/v1/compute-list", payload); + } + case "gcp_run_invoke": { + const { service_url, method, body } = args ?? {}; + if (!service_url) return { status: 400, data: { error: "service_url is required" } }; + const payload = { service_url }; + if (method !== undefined) payload.method = method; + if (body !== undefined) payload.body = body; + return post("/api/v1/run-invoke", payload); + } + case "gcp_session_state": { + const { slot } = args ?? {}; + if (!slot) return { status: 400, data: { error: "slot is required" } }; + const payload = { slot }; + return post("/api/v1/session-state", payload); + } + case "gcp_deauthenticate": { + const { slot } = args ?? {}; + if (!slot) return { status: 400, data: { error: "slot is required" } }; + const payload = { slot }; + return post("/api/v1/deauthenticate", payload); + } + + default: + return { status: 404, data: { error: `Unknown tool: ${toolName}` } }; + } +} diff --git a/cartridges/domains/cloud/gcp-mcp/panels/manifest.json b/cartridges/domains/cloud/gcp-mcp/panels/manifest.json new file mode 100644 index 0000000..2e21279 --- /dev/null +++ b/cartridges/domains/cloud/gcp-mcp/panels/manifest.json @@ -0,0 +1,129 @@ +{ + "$schema": "https://panll.dev/schemas/panel-manifest/v1.json", + "spdx": "MPL-2.0", + "copyright": "Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath)", + "cartridge": "gcp-mcp", + "domain": "Cloud", + "version": "0.3.0", + "panels": [ + { + "id": "gcp-auth-status", + "title": "Auth Status", + "description": "GCP authentication state (Unauthenticated / Authenticated / RateLimited / Error)", + "type": "status-indicator", + "data_source": { + "endpoint": "/gcp/state", + "method": "GET", + "refresh_interval_ms": 5000 + }, + "widgets": [ + { + "type": "state-badge", + "field": "state", + "states": { + "unauthenticated": { "color": "#95a5a6", "icon": "cloud-off" }, + "authenticated": { "color": "#2ecc71", "icon": "cloud-check" }, + "rate_limited": { "color": "#f39c12", "icon": "clock" }, + "error": { "color": "#e74c3c", "icon": "alert-triangle" } + } + } + ] + }, + { + "id": "gcp-project-id", + "title": "Project ID", + "description": "Currently configured GCP project identifier", + "type": "metric", + "data_source": { + "endpoint": "/gcp/status", + "method": "GET", + "refresh_interval_ms": 30000 + }, + "widgets": [ + { + "type": "text", + "field": "project_id", + "label": "GCP Project", + "icon": "folder" + } + ] + }, + { + "id": "gcp-service-count", + "title": "Service Count", + "description": "Number of GCP services available (Compute, Storage, Functions, Firestore, PubSub, CloudRun, BigQuery, IAM)", + "type": "metric", + "data_source": { + "endpoint": "/gcp/status", + "method": "GET", + "refresh_interval_ms": 60000 + }, + "widgets": [ + { + "type": "counter", + "field": "service_count", + "label": "Services", + "icon": "layers" + } + ] + }, + { + "id": "gcp-quota-usage", + "title": "Quota Usage", + "description": "Remaining API quota for the current session", + "type": "metric", + "data_source": { + "endpoint": "/gcp/status", + "method": "GET", + "refresh_interval_ms": 10000 + }, + "widgets": [ + { + "type": "gauge", + "field": "quota_remaining", + "label": "Quota Remaining", + "max_field": "quota_total", + "icon": "activity" + } + ] + }, + { + "id": "gcp-firestore-status", + "title": "Firestore", + "description": "Firestore document operations (CRUD and queries)", + "type": "status-indicator", + "data_source": { + "endpoint": "/gcp/firestore/status", + "method": "GET", + "refresh_interval_ms": 15000 + }, + "widgets": [ + { + "type": "counter", + "field": "document_ops", + "label": "Document Ops", + "icon": "database" + } + ] + }, + { + "id": "gcp-cloud-run-status", + "title": "Cloud Run", + "description": "Cloud Run services and deployments", + "type": "status-indicator", + "data_source": { + "endpoint": "/gcp/cloud-run/status", + "method": "GET", + "refresh_interval_ms": 30000 + }, + "widgets": [ + { + "type": "counter", + "field": "service_count", + "label": "Running Services", + "icon": "play-circle" + } + ] + } + ] +} diff --git a/cartridges/domains/cloud/gcp-mcp/tests/integration_test.sh b/cartridges/domains/cloud/gcp-mcp/tests/integration_test.sh new file mode 100755 index 0000000..1f1ca4d --- /dev/null +++ b/cartridges/domains/cloud/gcp-mcp/tests/integration_test.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Integration tests for gcp-mcp cartridge. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CART_DIR="$(dirname "$SCRIPT_DIR")" +FFI_DIR="$CART_DIR/ffi" + +echo "=== gcp-mcp integration tests ===" + +# Build FFI +echo "[1/3] Building FFI..." +cd "$FFI_DIR" && zig build 2>&1 + +# Run Zig unit tests +echo "[2/3] Running FFI unit tests..." +cd "$FFI_DIR" && zig build test 2>&1 + +# Validate Idris2 ABI (if idris2 available) +echo "[3/3] Checking ABI..." +if command -v idris2 &>/dev/null; then + cd "$CART_DIR/abi" && idris2 --check GcpMcp.SafeCloud 2>&1 + echo " ABI: OK" +else + echo " ABI: SKIPPED (idris2 not in PATH)" +fi + +echo "" +echo "All tests passed for gcp-mcp!" diff --git a/cartridges/domains/cloud/hetzner-mcp/README.adoc b/cartridges/domains/cloud/hetzner-mcp/README.adoc new file mode 100644 index 0000000..cb86cc3 --- /dev/null +++ b/cartridges/domains/cloud/hetzner-mcp/README.adoc @@ -0,0 +1,83 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += hetzner-mcp +Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk> +:spdx: MPL-2.0 +:tier: Ayo +:domain: Cloud +:protocols: MCP, REST + +== Overview + +Hetzner Cloud cartridge for the BoJ server. Provides type-safe access to +Hetzner Cloud API via Bearer token authentication (proxied through vault-mcp). +Manages servers, images, SSH keys, volumes, firewalls, and networks with +configurable per-second rate limiting. + +=== State Machine + +`Unauthenticated -> Authenticated -> RateLimited -> Authenticated` (normal flow) + +`Authenticated -> Error -> Unauthenticated` (error recovery) + +=== Actions (16) + +[cols="1,1"] +|=== +| Resource | Actions + +| Servers +| ListServers, GetServer, CreateServer, DeleteServer, PowerOn, PowerOff, Reboot + +| Images +| ListImages + +| SSH Keys +| ListSSHKeys, CreateSSHKey + +| Volumes +| ListVolumes, CreateVolume, AttachVolume + +| Firewalls +| ListFirewalls, CreateFirewall + +| Networks +| ListNetworks +|=== + +== Architecture + +[cols="1,1,2"] +|=== +| Layer | Language | Purpose + +| ABI +| Idris2 +| Formally verified state machine with dependent-type proofs (HetznerMcp.SafeCloud) + +| FFI +| Zig +| C-compatible implementation with thread-safe session pool, resource routing, rate limiting + +| Adapter +| zig +| REST bridge to BoJ unified adapter protocol +|=== + +== Building + +[source,bash] +---- +# Build FFI shared library +cd ffi && zig build + +# Run FFI tests +cd ffi && zig build test + +# Type-check ABI +cd abi && idris2 --check hetzner_mcp.ipkg +---- + +== Status + +Development β€” customised with Hetzner-specific state machine, auth, and resource management. diff --git a/cartridges/domains/cloud/hetzner-mcp/abi/HetznerMcp/SafeCloud.idr b/cartridges/domains/cloud/hetzner-mcp/abi/HetznerMcp/SafeCloud.idr new file mode 100644 index 0000000..b4feedf --- /dev/null +++ b/cartridges/domains/cloud/hetzner-mcp/abi/HetznerMcp/SafeCloud.idr @@ -0,0 +1,257 @@ +-- SPDX-License-Identifier: MPL-2.0 +-- Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +-- +-- HetznerMcp.SafeCloud β€” Type-safe ABI for hetzner-mcp cartridge. +-- +-- Dependent-type state machine governing Hetzner Cloud API access. +-- Encodes Bearer token auth flow, server/volume/firewall/network management, +-- and per-second rate limiting as compile-time invariants. +-- REST API: https://api.hetzner.cloud/v1/ +-- No unsafe escape hatches. + +module HetznerMcp.SafeCloud + +%default total + +-- --------------------------------------------------------------------------- +-- Authentication state machine +-- --------------------------------------------------------------------------- + +||| Session state for Hetzner MCP operations. +||| Unauthenticated: no API token loaded. +||| Authenticated: Bearer token active, obtained via vault-mcp. +||| RateLimited: Hetzner per-second rate limit hit; must wait. +||| Error: unrecoverable error (invalid token, permission denied, etc.). +public export +data SessionState + = Unauthenticated + | Authenticated + | RateLimited + | Error + +||| Proof that a state transition is valid. +||| Only these six edges are permitted in the session lifecycle. +public export +data ValidTransition : SessionState -> SessionState -> Type where + Authenticate : ValidTransition Unauthenticated Authenticated + Deauthenticate : ValidTransition Authenticated Unauthenticated + Throttle : ValidTransition Authenticated RateLimited + Unthrottle : ValidTransition RateLimited Authenticated + AuthError : ValidTransition Authenticated Error + Recover : ValidTransition Error Unauthenticated + +-- --------------------------------------------------------------------------- +-- C-ABI integer encoding +-- --------------------------------------------------------------------------- + +||| Encode session state as C-compatible integer for the Zig FFI boundary. +export +sessionStateToInt : SessionState -> Int +sessionStateToInt Unauthenticated = 0 +sessionStateToInt Authenticated = 1 +sessionStateToInt RateLimited = 2 +sessionStateToInt Error = 3 + +||| Decode integer back to session state. Returns Nothing for out-of-range. +export +intToSessionState : Int -> Maybe SessionState +intToSessionState 0 = Just Unauthenticated +intToSessionState 1 = Just Authenticated +intToSessionState 2 = Just RateLimited +intToSessionState 3 = Just Error +intToSessionState _ = Nothing + +||| Check if a state transition is valid (C-ABI export). +||| Returns 1 for valid, 0 for invalid. +export +hetzner_mcp_can_transition : Int -> Int -> Int +hetzner_mcp_can_transition from to = + case (intToSessionState from, intToSessionState to) of + (Just Unauthenticated, Just Authenticated) => 1 + (Just Authenticated, Just Unauthenticated) => 1 + (Just Authenticated, Just RateLimited) => 1 + (Just RateLimited, Just Authenticated) => 1 + (Just Authenticated, Just Error) => 1 + (Just Error, Just Unauthenticated) => 1 + _ => 0 + +-- --------------------------------------------------------------------------- +-- Hetzner resource categories +-- --------------------------------------------------------------------------- + +||| Hetzner Cloud resource categories managed by this cartridge. +public export +data HetznerResource + = Servers + | Images + | SSHKeys + | Volumes + | Firewalls + | Networks + +||| Encode resource category as C-compatible integer for FFI. +export +resourceToInt : HetznerResource -> Int +resourceToInt Servers = 0 +resourceToInt Images = 1 +resourceToInt SSHKeys = 2 +resourceToInt Volumes = 3 +resourceToInt Firewalls = 4 +resourceToInt Networks = 5 + +||| Decode integer to resource category. +export +intToResource : Int -> Maybe HetznerResource +intToResource 0 = Just Servers +intToResource 1 = Just Images +intToResource 2 = Just SSHKeys +intToResource 3 = Just Volumes +intToResource 4 = Just Firewalls +intToResource 5 = Just Networks +intToResource _ = Nothing + +-- --------------------------------------------------------------------------- +-- Hetzner actions +-- --------------------------------------------------------------------------- + +||| Actions available through the Hetzner MCP cartridge. +||| Grouped by resource: Servers (CRUD + power), Images, SSH Keys, +||| Volumes (CRUD + attach), Firewalls, Networks. +public export +data HetznerAction + = ListServers + | GetServer + | CreateServer + | DeleteServer + | PowerOn + | PowerOff + | Reboot + | ListImages + | ListSSHKeys + | CreateSSHKey + | ListVolumes + | CreateVolume + | AttachVolume + | ListFirewalls + | CreateFirewall + | ListNetworks + +||| Which resource category handles a given action. +export +actionResource : HetznerAction -> HetznerResource +actionResource ListServers = Servers +actionResource GetServer = Servers +actionResource CreateServer = Servers +actionResource DeleteServer = Servers +actionResource PowerOn = Servers +actionResource PowerOff = Servers +actionResource Reboot = Servers +actionResource ListImages = Images +actionResource ListSSHKeys = SSHKeys +actionResource CreateSSHKey = SSHKeys +actionResource ListVolumes = Volumes +actionResource CreateVolume = Volumes +actionResource AttachVolume = Volumes +actionResource ListFirewalls = Firewalls +actionResource CreateFirewall = Firewalls +actionResource ListNetworks = Networks + +||| Encode action as C-compatible integer for FFI. +export +actionToInt : HetznerAction -> Int +actionToInt ListServers = 0 +actionToInt GetServer = 1 +actionToInt CreateServer = 2 +actionToInt DeleteServer = 3 +actionToInt PowerOn = 4 +actionToInt PowerOff = 5 +actionToInt Reboot = 6 +actionToInt ListImages = 7 +actionToInt ListSSHKeys = 8 +actionToInt CreateSSHKey = 9 +actionToInt ListVolumes = 10 +actionToInt CreateVolume = 11 +actionToInt AttachVolume = 12 +actionToInt ListFirewalls = 13 +actionToInt CreateFirewall = 14 +actionToInt ListNetworks = 15 + +||| Decode integer to Hetzner action. +export +intToAction : Int -> Maybe HetznerAction +intToAction 0 = Just ListServers +intToAction 1 = Just GetServer +intToAction 2 = Just CreateServer +intToAction 3 = Just DeleteServer +intToAction 4 = Just PowerOn +intToAction 5 = Just PowerOff +intToAction 6 = Just Reboot +intToAction 7 = Just ListImages +intToAction 8 = Just ListSSHKeys +intToAction 9 = Just CreateSSHKey +intToAction 10 = Just ListVolumes +intToAction 11 = Just CreateVolume +intToAction 12 = Just AttachVolume +intToAction 13 = Just ListFirewalls +intToAction 14 = Just CreateFirewall +intToAction 15 = Just ListNetworks +intToAction _ = Nothing + +||| Whether an action requires Authenticated state. +||| All Hetzner actions require authentication. +export +actionRequiresAuth : HetznerAction -> Bool +actionRequiresAuth _ = True + +||| Whether an action is a write/mutating operation. +export +actionIsMutating : HetznerAction -> Bool +actionIsMutating CreateServer = True +actionIsMutating DeleteServer = True +actionIsMutating PowerOn = True +actionIsMutating PowerOff = True +actionIsMutating Reboot = True +actionIsMutating CreateSSHKey = True +actionIsMutating CreateVolume = True +actionIsMutating AttachVolume = True +actionIsMutating CreateFirewall = True +actionIsMutating _ = False + +-- --------------------------------------------------------------------------- +-- MCP tool declarations +-- --------------------------------------------------------------------------- + +||| Tools exposed via MCP protocol for this cartridge. +public export +data McpTool + = ToolAuthenticate + | ToolDeauthenticate + | ToolStatus + | ToolInvoke + | ToolListResources + | ToolListActions + +||| Check if a tool requires an authenticated session. +export +toolRequiresSession : McpTool -> Bool +toolRequiresSession ToolAuthenticate = False +toolRequiresSession ToolDeauthenticate = True +toolRequiresSession ToolStatus = False +toolRequiresSession ToolInvoke = True +toolRequiresSession ToolListResources = False +toolRequiresSession ToolListActions = False + +||| Total tool count for this cartridge. +export +toolCount : Nat +toolCount = 6 + +||| Total action count for this cartridge. +export +actionCount : Nat +actionCount = 16 + +||| Total resource category count for this cartridge. +export +resourceCount : Nat +resourceCount = 6 diff --git a/cartridges/domains/cloud/hetzner-mcp/abi/README.adoc b/cartridges/domains/cloud/hetzner-mcp/abi/README.adoc new file mode 100644 index 0000000..9b6d683 --- /dev/null +++ b/cartridges/domains/cloud/hetzner-mcp/abi/README.adoc @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += hetzner-mcp / abi β€” Idris2 ABI layer +:orientation: shallow + +== Purpose + +Cartridge-local protocol types for `hetzner-mcp`. Defines the data, tool, and +transition vocabulary this cartridge speaks. Inherits safety lemmas and the +matrix-cell identifier scheme from the trunk ABI at `src/abi/`. + +== Files + +1 Idris2 module(s), ~257 lines total. Lead module: +`SafeCloud.idr`. + +No `.ipkg` β€” consumed directly by wider BoJ callers; type-checks inside the +trunk `boj.ipkg`. + +== Invariants + +* `%default total` at every module top. +* Zero `believe_me`, `assert_total`, `postulate`, `Admitted`, or + `sorry` (the trunk holds the four declared `believe_me` sites; leaf + cartridges carry none). + +== Test/proof surface + +Thin module(s), no inline tests. Proof surface = type-check success under the +trunk `idris2 --check`. + +== Read-first + +. `SafeCloud.idr` β€” cartridge-local data records and enums. +. Trunk `src/abi/Boj/Catalogue.idr` for how this cell participates in the + 2D matrix. diff --git a/cartridges/domains/cloud/hetzner-mcp/abi/hetzner_mcp.ipkg b/cartridges/domains/cloud/hetzner-mcp/abi/hetzner_mcp.ipkg new file mode 100644 index 0000000..6601a5a --- /dev/null +++ b/cartridges/domains/cloud/hetzner-mcp/abi/hetzner_mcp.ipkg @@ -0,0 +1,13 @@ +-- SPDX-License-Identifier: MPL-2.0 +-- Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +package hetzner_mcp + +version = "0.2.0" +authors = "Jonathan D.A. Jewell" +brief = "Type-safe ABI for Hetzner MCP cartridge β€” Bearer token auth, server/volume/firewall/network management with per-second rate limiting" + +sourcedir = "." + +depends = base + +modules = HetznerMcp.SafeCloud diff --git a/cartridges/domains/cloud/hetzner-mcp/adapter/README.adoc b/cartridges/domains/cloud/hetzner-mcp/adapter/README.adoc new file mode 100644 index 0000000..d97c821 --- /dev/null +++ b/cartridges/domains/cloud/hetzner-mcp/adapter/README.adoc @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += hetzner-mcp / adapter β€” protocol bridge +:orientation: shallow + +== Purpose + +Exposes this cartridge's FFI over the wire protocols declared in +`cartridge.json`. Dispatches tool invocations to the C-ABI exports at +`../ffi/` and formats responses per protocol. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” depends on the sibling `ffi` target. +| `hetzner_adapter.zig` | Protocol dispatch (244 lines). Tool catalogue matches `cartridge.json`. +| `SIDELINED-*.v.adoc` | zig predecessor(s). Not built β€” zig banned 2026-04-10. +|=== + +== Invariants + +* **Stateless** β€” all state lives behind the FFI, never in the adapter. +* **Response passthrough** β€” whatever the FFI returns goes back to the wire + unmodified (no embellishment, no silent recovery). +* **Cartridge.json is source of truth** for the tool catalogue; drift between + adapter and manifest is a CI failure. + +== Test/proof surface + +No inline tests at the adapter layer β€” protocol routing is exercised by the +trunk seam tests at `ffi/zig/src/seams.zig`. + +== Read-first + +. `hetzner_adapter.zig` β€” dispatch function and per-protocol routers. +. `../cartridge.json` β€” the tool manifest the dispatcher mirrors. diff --git a/cartridges/domains/cloud/hetzner-mcp/adapter/build.zig b/cartridges/domains/cloud/hetzner-mcp/adapter/build.zig new file mode 100644 index 0000000..988d338 --- /dev/null +++ b/cartridges/domains/cloud/hetzner-mcp/adapter/build.zig @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// hetzner-mcp/adapter/build.zig + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const ffi_mod = b.createModule(.{ + .root_source_file = b.path("../ffi/hetzner_mcp_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + const adapter = b.addExecutable(.{ + .name = "hetzner_adapter", + .root_source_file = b.path("hetzner_adapter.zig"), + .target = target, + .optimize = optimize, + }); + adapter.root_module.addImport("hetzner_mcp_ffi", ffi_mod); + b.installArtifact(adapter); + + const run_artifact = b.addRunArtifact(adapter); + const run_step = b.step("run", "Run the hetzner-mcp adapter"); + run_step.dependOn(&run_artifact.step); + + const tests = b.addTest(.{ + .root_source_file = b.path("hetzner_adapter.zig"), + .target = target, + .optimize = optimize, + }); + tests.root_module.addImport("hetzner_mcp_ffi", ffi_mod); + const run_tests = b.addRunArtifact(tests); + const test_step = b.step("test", "Run hetzner-mcp adapter tests"); + test_step.dependOn(&run_tests.step); +} diff --git a/cartridges/domains/cloud/hetzner-mcp/adapter/hetzner_adapter.zig b/cartridges/domains/cloud/hetzner-mcp/adapter/hetzner_adapter.zig new file mode 100644 index 0000000..7b18f0c --- /dev/null +++ b/cartridges/domains/cloud/hetzner-mcp/adapter/hetzner_adapter.zig @@ -0,0 +1,244 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// hetzner-mcp/adapter/hetzner_adapter.zig -- Unified three-protocol adapter. +// +// Replaces the banned hetzner_adapter.v (zig, removed 2026-04-12). +// +// Bridges the Zig FFI (hetzner_mcp_ffi.zig) to three network protocols: +// REST :9064 POST /tools/<tool> +// gRPC-compat :9065 /HetznerMcpService/<Method> +// GraphQL :9066 POST /graphql { query: "..." } +// +// Hetzner Cloud: servers, volumes, firewalls, load balancers, floating IPs +// Tools: +// hetzner_list_servers +// hetzner_get_server +// hetzner_create_server +// hetzner_delete_server +// hetzner_server_action +// hetzner_resize_server +// hetzner_list_floating_ips +// hetzner_create_floating_ip +// hetzner_list_volumes +// hetzner_create_volume +// hetzner_list_firewalls +// hetzner_create_firewall +// hetzner_list_ssh_keys +// hetzner_create_ssh_key +// hetzner_list_images +// hetzner_create_snapshot +// hetzner_list_networks +// hetzner_create_network +// hetzner_list_load_balancers +// hetzner_create_load_balancer + +const std = @import("std"); +const ffi = @import("hetzner_mcp_ffi"); + +const REST_PORT: u16 = 9064; +const GRPC_PORT: u16 = 9065; +const GQL_PORT: u16 = 9066; + +const MAX_CONN_BUF: usize = 16 * 1024; + +// ============================================================================ +// JSON response builders +// ============================================================================ + +fn okJson(buf: []u8, msg: []const u8) []u8 { + return std.fmt.bufPrint(buf, + \{{"success":true,"message":"{}"}} + , .{msg}) catch buf[0..0]; +} + +fn errJson(buf: []u8, msg: []const u8) []u8 { + return std.fmt.bufPrint(buf, + \{{"success":false,"error":"{}"}} + , .{msg}) catch buf[0..0]; +} + +fn statusJson(buf: []u8) []u8 { + return std.fmt.bufPrint(buf, + \{{"success":true,"state":"ready","service":"hetzner-mcp"}} + , .{}) catch buf[0..0]; +} + +// ============================================================================ +// Tool dispatcher +// ============================================================================ + +const Response = struct { status: u16, body: []u8 }; + +fn dispatch(tool: []const u8, body: []const u8, resp: []u8) Response { + _ = body; + if (std.mem.eql(u8, tool, "hetzner_list_servers")) return .{ .status = 200, .body = okJson(resp, "hetzner_list_servers forwarded to backend") }; + if (std.mem.eql(u8, tool, "hetzner_get_server")) return .{ .status = 200, .body = okJson(resp, "hetzner_get_server forwarded to backend") }; + if (std.mem.eql(u8, tool, "hetzner_create_server")) return .{ .status = 200, .body = okJson(resp, "hetzner_create_server forwarded to backend") }; + if (std.mem.eql(u8, tool, "hetzner_delete_server")) return .{ .status = 200, .body = okJson(resp, "hetzner_delete_server forwarded to backend") }; + if (std.mem.eql(u8, tool, "hetzner_server_action")) return .{ .status = 200, .body = okJson(resp, "hetzner_server_action forwarded to backend") }; + if (std.mem.eql(u8, tool, "hetzner_resize_server")) return .{ .status = 200, .body = okJson(resp, "hetzner_resize_server forwarded to backend") }; + if (std.mem.eql(u8, tool, "hetzner_list_floating_ips")) return .{ .status = 200, .body = okJson(resp, "hetzner_list_floating_ips forwarded to backend") }; + if (std.mem.eql(u8, tool, "hetzner_create_floating_ip")) return .{ .status = 200, .body = okJson(resp, "hetzner_create_floating_ip forwarded to backend") }; + if (std.mem.eql(u8, tool, "hetzner_list_volumes")) return .{ .status = 200, .body = okJson(resp, "hetzner_list_volumes forwarded to backend") }; + if (std.mem.eql(u8, tool, "hetzner_create_volume")) return .{ .status = 200, .body = okJson(resp, "hetzner_create_volume forwarded to backend") }; + if (std.mem.eql(u8, tool, "hetzner_list_firewalls")) return .{ .status = 200, .body = okJson(resp, "hetzner_list_firewalls forwarded to backend") }; + if (std.mem.eql(u8, tool, "hetzner_create_firewall")) return .{ .status = 200, .body = okJson(resp, "hetzner_create_firewall forwarded to backend") }; + if (std.mem.eql(u8, tool, "hetzner_list_ssh_keys")) return .{ .status = 200, .body = okJson(resp, "hetzner_list_ssh_keys forwarded to backend") }; + if (std.mem.eql(u8, tool, "hetzner_create_ssh_key")) return .{ .status = 200, .body = okJson(resp, "hetzner_create_ssh_key forwarded to backend") }; + if (std.mem.eql(u8, tool, "hetzner_list_images")) return .{ .status = 200, .body = okJson(resp, "hetzner_list_images forwarded to backend") }; + if (std.mem.eql(u8, tool, "hetzner_create_snapshot")) return .{ .status = 200, .body = okJson(resp, "hetzner_create_snapshot forwarded to backend") }; + if (std.mem.eql(u8, tool, "hetzner_list_networks")) return .{ .status = 200, .body = okJson(resp, "hetzner_list_networks forwarded to backend") }; + if (std.mem.eql(u8, tool, "hetzner_create_network")) return .{ .status = 200, .body = okJson(resp, "hetzner_create_network forwarded to backend") }; + if (std.mem.eql(u8, tool, "hetzner_list_load_balancers")) return .{ .status = 200, .body = okJson(resp, "hetzner_list_load_balancers forwarded to backend") }; + if (std.mem.eql(u8, tool, "hetzner_create_load_balancer")) return .{ .status = 200, .body = okJson(resp, "hetzner_create_load_balancer forwarded to backend") }; + if (std.mem.eql(u8, tool, "status") or std.mem.eql(u8, tool, "health")) + return .{ .status = 200, .body = statusJson(resp) }; + return .{ .status = 404, .body = errJson(resp, "Unknown tool") }; +} + +// ============================================================================ +// REST handler +// ============================================================================ + +fn dispatchRest(path: []const u8, body: []const u8, resp: []u8) Response { + if (std.mem.startsWith(u8, path, "/tools/")) { + return dispatch(path["/tools/".len..], body, resp); + } + if (std.mem.eql(u8, path, "/status") or std.mem.eql(u8, path, "/health")) { + return .{ .status = 200, .body = statusJson(resp) }; + } + return .{ .status = 404, .body = errJson(resp, "Not found") }; +} + +// ============================================================================ +// gRPC-compat handler +// ============================================================================ + +fn dispatchGrpc(path: []const u8, body: []const u8, resp: []u8) Response { + const prefix = "/HetznerMcpService/"; + if (!std.mem.startsWith(u8, path, prefix)) + return .{ .status = 404, .body = errJson(resp, "Not a recognized gRPC path") }; + const method = path[prefix.len..]; + const tool = blk: { + if (std.mem.eql(u8, method, "HetznerListServers")) break :blk "hetzner_list_servers"; + if (std.mem.eql(u8, method, "HetznerGetServer")) break :blk "hetzner_get_server"; + if (std.mem.eql(u8, method, "HetznerCreateServer")) break :blk "hetzner_create_server"; + if (std.mem.eql(u8, method, "HetznerDeleteServer")) break :blk "hetzner_delete_server"; + if (std.mem.eql(u8, method, "HetznerServerAction")) break :blk "hetzner_server_action"; + if (std.mem.eql(u8, method, "HetznerResizeServer")) break :blk "hetzner_resize_server"; + if (std.mem.eql(u8, method, "HetznerListFloatingIps")) break :blk "hetzner_list_floating_ips"; + if (std.mem.eql(u8, method, "HetznerCreateFloatingIp")) break :blk "hetzner_create_floating_ip"; + if (std.mem.eql(u8, method, "HetznerListVolumes")) break :blk "hetzner_list_volumes"; + if (std.mem.eql(u8, method, "HetznerCreateVolume")) break :blk "hetzner_create_volume"; + if (std.mem.eql(u8, method, "HetznerListFirewalls")) break :blk "hetzner_list_firewalls"; + if (std.mem.eql(u8, method, "HetznerCreateFirewall")) break :blk "hetzner_create_firewall"; + if (std.mem.eql(u8, method, "HetznerListSshKeys")) break :blk "hetzner_list_ssh_keys"; + if (std.mem.eql(u8, method, "HetznerCreateSshKey")) break :blk "hetzner_create_ssh_key"; + if (std.mem.eql(u8, method, "HetznerListImages")) break :blk "hetzner_list_images"; + if (std.mem.eql(u8, method, "HetznerCreateSnapshot")) break :blk "hetzner_create_snapshot"; + if (std.mem.eql(u8, method, "HetznerListNetworks")) break :blk "hetzner_list_networks"; + if (std.mem.eql(u8, method, "HetznerCreateNetwork")) break :blk "hetzner_create_network"; + if (std.mem.eql(u8, method, "HetznerListLoadBalancers")) break :blk "hetzner_list_load_balancers"; + if (std.mem.eql(u8, method, "HetznerCreateLoadBalancer")) break :blk "hetzner_create_load_balancer"; + return .{ .status = 404, .body = errJson(resp, "Unknown gRPC method") }; + }; + return dispatch(tool, body, resp); +} + +// ============================================================================ +// GraphQL handler +// ============================================================================ + +fn dispatchGraphql(body: []const u8, resp: []u8) Response { + if (std.mem.indexOf(u8, body, "__schema") != null) + return .{ .status = 200, .body = okJson(resp, "schema introspection not yet supported") }; + if (std.mem.indexOf(u8, body, "list_servers") != null) return dispatch("hetzner_list_servers", body, resp); + if (std.mem.indexOf(u8, body, "get_server") != null) return dispatch("hetzner_get_server", body, resp); + if (std.mem.indexOf(u8, body, "create_server") != null) return dispatch("hetzner_create_server", body, resp); + if (std.mem.indexOf(u8, body, "delete_server") != null) return dispatch("hetzner_delete_server", body, resp); + if (std.mem.indexOf(u8, body, "server_action") != null) return dispatch("hetzner_server_action", body, resp); + if (std.mem.indexOf(u8, body, "resize_server") != null) return dispatch("hetzner_resize_server", body, resp); + if (std.mem.indexOf(u8, body, "list_floating_ips") != null) return dispatch("hetzner_list_floating_ips", body, resp); + if (std.mem.indexOf(u8, body, "create_floating_ip") != null) return dispatch("hetzner_create_floating_ip", body, resp); + if (std.mem.indexOf(u8, body, "list_volumes") != null) return dispatch("hetzner_list_volumes", body, resp); + if (std.mem.indexOf(u8, body, "create_volume") != null) return dispatch("hetzner_create_volume", body, resp); + if (std.mem.indexOf(u8, body, "list_firewalls") != null) return dispatch("hetzner_list_firewalls", body, resp); + if (std.mem.indexOf(u8, body, "create_firewall") != null) return dispatch("hetzner_create_firewall", body, resp); + if (std.mem.indexOf(u8, body, "list_ssh_keys") != null) return dispatch("hetzner_list_ssh_keys", body, resp); + if (std.mem.indexOf(u8, body, "create_ssh_key") != null) return dispatch("hetzner_create_ssh_key", body, resp); + if (std.mem.indexOf(u8, body, "list_images") != null) return dispatch("hetzner_list_images", body, resp); + if (std.mem.indexOf(u8, body, "create_snapshot") != null) return dispatch("hetzner_create_snapshot", body, resp); + if (std.mem.indexOf(u8, body, "list_networks") != null) return dispatch("hetzner_list_networks", body, resp); + if (std.mem.indexOf(u8, body, "create_network") != null) return dispatch("hetzner_create_network", body, resp); + if (std.mem.indexOf(u8, body, "list_load_balancers") != null) return dispatch("hetzner_list_load_balancers", body, resp); + if (std.mem.indexOf(u8, body, "create_load_balancer") != null) return dispatch("hetzner_create_load_balancer", body, resp); + return .{ .status = 200, .body = errJson(resp, "Unrecognised GraphQL operation") }; +} + +// ============================================================================ +// HTTP/1.1 connection handler +// ============================================================================ + +const Protocol = enum { rest, grpc, graphql }; + +fn handleConnection(conn: std.net.Server.Connection, proto: Protocol) void { + defer conn.stream.close(); + var in_buf: [MAX_CONN_BUF]u8 = undefined; + const n = conn.stream.read(&in_buf) catch return; + const req = in_buf[0..n]; + + var path: []const u8 = "/"; + var body: []const u8 = ""; + if (n > 4) { + const line_end = std.mem.indexOf(u8, req, "\r\n") orelse req.len; + const first_line = req[0..line_end]; + const sp1 = std.mem.indexOfScalar(u8, first_line, ' ') orelse 0; + const rest_of = first_line[sp1 + 1 ..]; + const sp2 = std.mem.indexOfScalar(u8, rest_of, ' ') orelse rest_of.len; + path = rest_of[0..sp2]; + const body_sep = std.mem.indexOf(u8, req, "\r\n\r\n") orelse n; + body = req[@min(body_sep + 4, n)..]; + } + + var resp_buf: [MAX_CONN_BUF]u8 = undefined; + const result = switch (proto) { + .rest => dispatchRest(path, body, &resp_buf), + .grpc => dispatchGrpc(path, body, &resp_buf), + .graphql => dispatchGraphql(body, &resp_buf), + }; + + const content_type = switch (proto) { + .rest => "application/json", + .grpc => "application/grpc+json", + .graphql => "application/json", + }; + + var hdr_buf: [256]u8 = undefined; + const hdr = std.fmt.bufPrint(&hdr_buf, + "HTTP/1.1 {d} OK\r\nContent-Type: {s}\r\nContent-Length: {d}\r\nConnection: close\r\n\r\n", + .{ result.status, content_type, result.body.len }, + ) catch return; + _ = conn.stream.writeAll(hdr) catch return; + _ = conn.stream.writeAll(result.body) catch return; +} + +fn listenLoop(port: u16, proto: Protocol) void { + const addr = std.net.Address.initIp4(.{ 127, 0, 0, 1 }, port) catch return; + var server = addr.listen(.{ .reuse_address = true }) catch return; + defer server.deinit(); + while (true) { + const conn = server.accept() catch continue; + handleConnection(conn, proto); + } +} + +pub fn main() !void { + ffi.hetzner_init(); + const rest_thread = try std.Thread.spawn(.{}, listenLoop, .{ REST_PORT, Protocol.rest }); + const grpc_thread = try std.Thread.spawn(.{}, listenLoop, .{ GRPC_PORT, Protocol.grpc }); + const gql_thread = try std.Thread.spawn(.{}, listenLoop, .{ GQL_PORT, Protocol.graphql }); + rest_thread.join(); + grpc_thread.join(); + gql_thread.join(); +} diff --git a/cartridges/domains/cloud/hetzner-mcp/benchmarks/quick-bench.sh b/cartridges/domains/cloud/hetzner-mcp/benchmarks/quick-bench.sh new file mode 100755 index 0000000..216c918 --- /dev/null +++ b/cartridges/domains/cloud/hetzner-mcp/benchmarks/quick-bench.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Benchmarks for hetzner-mcp cartridge. +set -euo pipefail + +echo "=== hetzner-mcp benchmarks ===" + +cd "$(dirname "${BASH_SOURCE[0]}")/../ffi" + +# Build with ReleaseFast for benchmarking +zig build -Doptimize=ReleaseFast 2>&1 + +echo "Session open/close cycle (1000 iterations):" +time for i in $(seq 1 1000); do + # This would call the FFI benchmark binary + true +done + +echo "" +echo "Benchmark placeholder β€” implement real benchmarks in Zig test blocks" +echo "or via the zig adapter HTTP benchmark tool." diff --git a/cartridges/domains/cloud/hetzner-mcp/cartridge.json b/cartridges/domains/cloud/hetzner-mcp/cartridge.json new file mode 100644 index 0000000..9c8912d --- /dev/null +++ b/cartridges/domains/cloud/hetzner-mcp/cartridge.json @@ -0,0 +1,607 @@ +{ + "$schema": "https://boj.dev/schemas/cartridge/v1.json", + "spdx": "MPL-2.0", + "copyright": "Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath)", + "name": "hetzner-mcp", + "version": "0.2.0", + "description": "Hetzner Cloud API cartridge -- server, volume, firewall, network, SSH key, image, snapshot, floating IP, and load balancer management with per-second rate limiting", + "domain": "Cloud", + "tier": "Ayo", + "protocols": [ + "MCP", + "REST" + ], + "auth": { + "method": "bearer_token", + "env_var": "HETZNER_API_TOKEN", + "credential_source": "vault-mcp" + }, + "api": { + "base_url": "https://api.hetzner.cloud/v1", + "rate_limit_per_hour": 3600, + "content_type": "application/json" + }, + "tools": [ + { + "name": "hetzner_list_servers", + "description": "List all servers in the Hetzner Cloud project", + "inputSchema": { + "type": "object", + "properties": { + "page": { + "type": "number", + "description": "Page number (default 1)" + }, + "per_page": { + "type": "number", + "description": "Results per page (default 25, max 50)" + }, + "sort": { + "type": "string", + "description": "Sort field (id, name, created)" + }, + "status": { + "type": "string", + "description": "Filter by status (initializing, starting, running, stopping, off, deleting, migrating, rebuilding, unknown)" + }, + "label_selector": { + "type": "string", + "description": "Filter by label selector (e.g. 'env=production')" + } + } + } + }, + { + "name": "hetzner_get_server", + "description": "Get details of a specific Hetzner Cloud server by ID", + "inputSchema": { + "type": "object", + "properties": { + "server_id": { + "type": "number", + "description": "Numeric server ID" + } + }, + "required": [ + "server_id" + ] + } + }, + { + "name": "hetzner_create_server", + "description": "Create a new Hetzner Cloud server", + "inputSchema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Server name (must be unique within project)" + }, + "server_type": { + "type": "string", + "description": "Server type (e.g. cx22, cx32, cx42, cpx11, cax11)" + }, + "image": { + "type": "string", + "description": "Image name or ID (e.g. ubuntu-24.04, debian-12, fedora-41)" + }, + "location": { + "type": "string", + "description": "Location name (e.g. nbg1, fsn1, hel1, ash, hil)" + }, + "ssh_keys": { + "type": "array", + "items": { + "type": "string" + }, + "description": "SSH key names or IDs to install" + }, + "labels": { + "type": "object", + "description": "Key-value labels for the server" + }, + "user_data": { + "type": "string", + "description": "Cloud-init user data (cloud-config or script)" + }, + "firewalls": { + "type": "array", + "items": { + "type": "object" + }, + "description": "Firewall IDs to apply" + }, + "networks": { + "type": "array", + "items": { + "type": "number" + }, + "description": "Network IDs to attach" + }, + "public_net": { + "type": "object", + "description": "Public network config (enable_ipv4, enable_ipv6)" + } + }, + "required": [ + "name", + "server_type", + "image" + ] + } + }, + { + "name": "hetzner_delete_server", + "description": "Delete a Hetzner Cloud server (irreversible)", + "inputSchema": { + "type": "object", + "properties": { + "server_id": { + "type": "number", + "description": "Numeric server ID to delete" + } + }, + "required": [ + "server_id" + ] + } + }, + { + "name": "hetzner_server_action", + "description": "Perform a power action on a Hetzner Cloud server (poweron, poweroff, reboot, shutdown, reset, rebuild)", + "inputSchema": { + "type": "object", + "properties": { + "server_id": { + "type": "number", + "description": "Numeric server ID" + }, + "action": { + "type": "string", + "description": "Action to perform: poweron, poweroff, reboot, shutdown, reset, rebuild" + }, + "image": { + "type": "string", + "description": "Image for rebuild action (required when action=rebuild)" + } + }, + "required": [ + "server_id", + "action" + ] + } + }, + { + "name": "hetzner_resize_server", + "description": "Change the type (resize) of a Hetzner Cloud server. Server must be powered off first for non-disk upgrades.", + "inputSchema": { + "type": "object", + "properties": { + "server_id": { + "type": "number", + "description": "Numeric server ID" + }, + "server_type": { + "type": "string", + "description": "New server type (e.g. cx32, cx42)" + }, + "upgrade_disk": { + "type": "boolean", + "description": "Whether to upgrade disk size (irreversible, default false)" + } + }, + "required": [ + "server_id", + "server_type" + ] + } + }, + { + "name": "hetzner_list_floating_ips", + "description": "List all floating IPs in the Hetzner Cloud project", + "inputSchema": { + "type": "object", + "properties": { + "page": { + "type": "number", + "description": "Page number" + }, + "per_page": { + "type": "number", + "description": "Results per page" + }, + "label_selector": { + "type": "string", + "description": "Filter by label selector" + } + } + } + }, + { + "name": "hetzner_create_floating_ip", + "description": "Create a new floating IP (IPv4 or IPv6) in Hetzner Cloud", + "inputSchema": { + "type": "object", + "properties": { + "type": { + "type": "string", + "description": "IP type: ipv4 or ipv6" + }, + "home_location": { + "type": "string", + "description": "Home location (e.g. nbg1, fsn1)" + }, + "server": { + "type": "number", + "description": "Server ID to assign to (optional)" + }, + "description": { + "type": "string", + "description": "Description for the floating IP" + }, + "labels": { + "type": "object", + "description": "Key-value labels" + } + }, + "required": [ + "type", + "home_location" + ] + } + }, + { + "name": "hetzner_list_volumes", + "description": "List all block storage volumes in the Hetzner Cloud project", + "inputSchema": { + "type": "object", + "properties": { + "page": { + "type": "number", + "description": "Page number" + }, + "per_page": { + "type": "number", + "description": "Results per page" + }, + "label_selector": { + "type": "string", + "description": "Filter by label selector" + }, + "status": { + "type": "string", + "description": "Filter by status (creating, available)" + } + } + } + }, + { + "name": "hetzner_create_volume", + "description": "Create a new block storage volume in Hetzner Cloud", + "inputSchema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Volume name" + }, + "size": { + "type": "number", + "description": "Size in GB (min 10)" + }, + "location": { + "type": "string", + "description": "Location (e.g. nbg1, fsn1)" + }, + "server": { + "type": "number", + "description": "Server ID to attach to (optional)" + }, + "format": { + "type": "string", + "description": "Filesystem format (ext4, xfs, or empty for unformatted)" + }, + "labels": { + "type": "object", + "description": "Key-value labels" + }, + "automount": { + "type": "boolean", + "description": "Auto-mount when attached (default false)" + } + }, + "required": [ + "name", + "size" + ] + } + }, + { + "name": "hetzner_list_firewalls", + "description": "List all firewalls in the Hetzner Cloud project", + "inputSchema": { + "type": "object", + "properties": { + "page": { + "type": "number", + "description": "Page number" + }, + "per_page": { + "type": "number", + "description": "Results per page" + }, + "label_selector": { + "type": "string", + "description": "Filter by label selector" + } + } + } + }, + { + "name": "hetzner_create_firewall", + "description": "Create a new firewall with rules in Hetzner Cloud", + "inputSchema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Firewall name" + }, + "rules": { + "type": "array", + "items": { + "type": "object" + }, + "description": "Array of firewall rules (direction, protocol, port, source_ips/destination_ips)" + }, + "apply_to": { + "type": "array", + "items": { + "type": "object" + }, + "description": "Resources to apply to (type: server, server: {id: N})" + }, + "labels": { + "type": "object", + "description": "Key-value labels" + } + }, + "required": [ + "name", + "rules" + ] + } + }, + { + "name": "hetzner_list_ssh_keys", + "description": "List all SSH keys in the Hetzner Cloud project", + "inputSchema": { + "type": "object", + "properties": { + "page": { + "type": "number", + "description": "Page number" + }, + "per_page": { + "type": "number", + "description": "Results per page" + }, + "label_selector": { + "type": "string", + "description": "Filter by label selector" + } + } + } + }, + { + "name": "hetzner_create_ssh_key", + "description": "Add an SSH public key to the Hetzner Cloud project", + "inputSchema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Key name" + }, + "public_key": { + "type": "string", + "description": "SSH public key string" + }, + "labels": { + "type": "object", + "description": "Key-value labels" + } + }, + "required": [ + "name", + "public_key" + ] + } + }, + { + "name": "hetzner_list_images", + "description": "List available images (OS images, snapshots, backups) in Hetzner Cloud", + "inputSchema": { + "type": "object", + "properties": { + "type": { + "type": "string", + "description": "Filter by type: system, snapshot, backup, app" + }, + "status": { + "type": "string", + "description": "Filter by status: available, creating" + }, + "sort": { + "type": "string", + "description": "Sort field (id, name, created)" + }, + "page": { + "type": "number", + "description": "Page number" + }, + "per_page": { + "type": "number", + "description": "Results per page" + } + } + } + }, + { + "name": "hetzner_create_snapshot", + "description": "Create a snapshot of a Hetzner Cloud server", + "inputSchema": { + "type": "object", + "properties": { + "server_id": { + "type": "number", + "description": "Server ID to snapshot" + }, + "description": { + "type": "string", + "description": "Snapshot description" + }, + "labels": { + "type": "object", + "description": "Key-value labels" + } + }, + "required": [ + "server_id" + ] + } + }, + { + "name": "hetzner_list_networks", + "description": "List all networks in the Hetzner Cloud project", + "inputSchema": { + "type": "object", + "properties": { + "page": { + "type": "number", + "description": "Page number" + }, + "per_page": { + "type": "number", + "description": "Results per page" + }, + "label_selector": { + "type": "string", + "description": "Filter by label selector" + } + } + } + }, + { + "name": "hetzner_create_network", + "description": "Create a new network in Hetzner Cloud", + "inputSchema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Network name" + }, + "ip_range": { + "type": "string", + "description": "IP range in CIDR notation (e.g. 10.0.0.0/16)" + }, + "subnets": { + "type": "array", + "items": { + "type": "object" + }, + "description": "Subnets (type, ip_range, network_zone)" + }, + "labels": { + "type": "object", + "description": "Key-value labels" + } + }, + "required": [ + "name", + "ip_range" + ] + } + }, + { + "name": "hetzner_list_load_balancers", + "description": "List all load balancers in the Hetzner Cloud project", + "inputSchema": { + "type": "object", + "properties": { + "page": { + "type": "number", + "description": "Page number" + }, + "per_page": { + "type": "number", + "description": "Results per page" + }, + "label_selector": { + "type": "string", + "description": "Filter by label selector" + } + } + } + }, + { + "name": "hetzner_create_load_balancer", + "description": "Create a new load balancer in Hetzner Cloud", + "inputSchema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Load balancer name" + }, + "load_balancer_type": { + "type": "string", + "description": "Type (e.g. lb11, lb21, lb31)" + }, + "location": { + "type": "string", + "description": "Location (e.g. nbg1, fsn1)" + }, + "algorithm": { + "type": "object", + "description": "Algorithm config (type: round_robin or least_connections)" + }, + "services": { + "type": "array", + "items": { + "type": "object" + }, + "description": "Service definitions (protocol, listen_port, destination_port, health_check)" + }, + "targets": { + "type": "array", + "items": { + "type": "object" + }, + "description": "Targets (type: server, server: {id: N})" + }, + "labels": { + "type": "object", + "description": "Key-value labels" + } + }, + "required": [ + "name", + "load_balancer_type" + ] + } + } + ], + "ffi": { + "so_path": "ffi/zig-out/lib/libhetzner_mcp.so", + "abi_version": "ADR-0006", + "symbols": [ + "boj_cartridge_init", + "boj_cartridge_deinit", + "boj_cartridge_name", + "boj_cartridge_version", + "boj_cartridge_invoke" + ] + } +} diff --git a/cartridges/domains/cloud/hetzner-mcp/ffi/README.adoc b/cartridges/domains/cloud/hetzner-mcp/ffi/README.adoc new file mode 100644 index 0000000..e37e3f7 --- /dev/null +++ b/cartridges/domains/cloud/hetzner-mcp/ffi/README.adoc @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += hetzner-mcp / ffi β€” Zig FFI layer +:orientation: shallow + +== Purpose + +C-ABI exports bridging this cartridge's Idris2 ABI to the runtime. Consumed by +the adapter for protocol dispatch and by the trunk loader for mount. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” produces `zig-out/lib/libhetzner.so`. +| `hetzner_mcp_ffi.zig` | C-ABI exports (17 exports, 8 inline tests, 445 lines). +|=== + +== Invariants + +* **Null-pointer rejection** on `[*c]const u8` arguments before any + dereference. +* **Negative return values** carry documented error meanings; zero is + success (matching the trunk error-code convention). +* **No allocator leaks** β€” every allocation path pairs with `defer free`. + +== Test/proof surface + +8 inline `test "..."` block(s) in `hetzner_mcp_ffi.zig`. Run with +`cd ffi && zig build test`. + +== Read-first + +. `hetzner_mcp_ffi.zig` β€” C-ABI signatures and guards. +. Trunk `ffi/zig/src/catalogue.zig` for how this `.so` is loaded. diff --git a/cartridges/domains/cloud/hetzner-mcp/ffi/build.zig b/cartridges/domains/cloud/hetzner-mcp/ffi/build.zig new file mode 100644 index 0000000..b4c64ab --- /dev/null +++ b/cartridges/domains/cloud/hetzner-mcp/ffi/build.zig @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// build.zig β€” Build configuration for hetzner-mcp FFI shared library and tests. + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + // Module + // Shared ADR-0006 invoke-shim module (relative path up to boj-server trunk). + + const shim_mod = b.addModule("cartridge_shim", .{ + + .root_source_file = b.path("../../../ffi/zig/src/cartridge_shim.zig"), + + .target = target, + + .optimize = optimize, + + }); + + const ffi_mod = b.addModule("hetzner_mcp", .{ + .root_source_file = b.path("hetzner_mcp_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + ffi_mod.addImport("cartridge_shim", shim_mod); + + // Shared library + const lib = b.addLibrary(.{ + .name = "hetzner_mcp", + .root_module = ffi_mod, + .linkage = .dynamic, + }); + lib.linkLibC(); + b.installArtifact(lib); + + // Tests + const tests = b.addTest(.{ + .root_module = ffi_mod, + }); + tests.linkLibC(); + + const run_tests = b.addRunArtifact(tests); + const test_step = b.step("test", "Run FFI tests"); + test_step.dependOn(&run_tests.step); +} diff --git a/cartridges/domains/cloud/hetzner-mcp/ffi/cartridge_shim.zig b/cartridges/domains/cloud/hetzner-mcp/ffi/cartridge_shim.zig new file mode 100644 index 0000000..0e399db --- /dev/null +++ b/cartridges/domains/cloud/hetzner-mcp/ffi/cartridge_shim.zig @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// cartridge_shim.zig β€” Shared helpers for the ADR-0006 five-symbol +// cartridge ABI (`boj_cartridge_init / deinit / name / version / invoke`). +// +// The shim centralises the seven-code return convention, NUL-argument +// guards, tool-name comparison, and the buffer-too-small path so each +// cartridge's `boj_cartridge_invoke` can stay short β€” typically a tool +// table plus `shim.writeResult(...)`. +// +// Cartridges import this file by relative path (no build-graph change +// needed). Example: +// +// const shim = @import("../../../ffi/zig/src/cartridge_shim.zig"); +// +// export fn boj_cartridge_invoke( +// tool_name: [*c]const u8, +// json_args: [*c]const u8, +// out_buf: [*c]u8, +// in_out_len: [*c]usize, +// ) callconv(.c) i32 { +// _ = json_args; +// if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; +// const body = if (shim.toolIs(tool_name, "foo")) "{\"result\":{}}" +// else return shim.RC_UNKNOWN_TOOL; +// return shim.writeResult(out_buf, in_out_len, body); +// } + +const std = @import("std"); + +// ── Return codes (ADR-0006 Β§Return codes) ──────────────────────────── +// +// Frozen by ADR-0006. New failure modes compose these via the error +// JSON body β€” the integer surface does not grow without a follow-up ADR. + +pub const RC_SUCCESS: i32 = 0; +pub const RC_UNKNOWN_TOOL: i32 = -1; +pub const RC_BAD_ARGS: i32 = -2; +pub const RC_BUFFER_TOO_SMALL: i32 = -3; +pub const RC_RUNTIME_ERROR: i32 = -4; +pub const RC_PANIC: i32 = -5; +pub const RC_AUTH_DENIED: i32 = -6; + +// ── Invoke-path helpers ────────────────────────────────────────────── + +/// True if any of the three mandatory `boj_cartridge_invoke` output-path +/// pointers is null. Use at the top of every invoke to short-circuit to +/// `RC_BAD_ARGS`. +pub fn invokeArgsNull( + tool_name: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) bool { + return tool_name == null or out_buf == null or in_out_len == null; +} + +/// Compare a C-NUL-terminated tool-name pointer against a Zig string +/// literal. Caller must have already verified `tool_name` is non-null +/// (usually via `invokeArgsNull`). +pub fn toolIs(tool_name: [*c]const u8, expected: []const u8) bool { + const s = std.mem.span(@as([*:0]const u8, @ptrCast(tool_name))); + return std.mem.eql(u8, s, expected); +} + +/// Copy `body` into `out_buf[0..*in_out_len]` (as a capacity) and update +/// `*in_out_len` to the number of bytes written. Returns `RC_SUCCESS`. +/// +/// If `body.len` exceeds the current capacity stored in `*in_out_len`, +/// sets `*in_out_len` to the required size and returns +/// `RC_BUFFER_TOO_SMALL` β€” the caller is then expected to re-allocate +/// and retry, per ADR-0006 Β§Memory ownership. +/// +/// Caller must have already verified that `out_buf` and `in_out_len` +/// are non-null. +pub fn writeResult( + out_buf: [*c]u8, + in_out_len: [*c]usize, + body: []const u8, +) i32 { + const cap = in_out_len.*; + if (body.len > cap) { + in_out_len.* = body.len; + return RC_BUFFER_TOO_SMALL; + } + @memcpy(out_buf[0..body.len], body); + in_out_len.* = body.len; + return RC_SUCCESS; +} + +// ── Tests ──────────────────────────────────────────────────────────── + +test "writeResult: body fits, writes and sets length" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); + try std.testing.expectEqualStrings("hello", buf[0..len]); +} + +test "writeResult: too small returns -3 and sets required length" { + var buf: [2]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_BUFFER_TOO_SMALL, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: exact-fit succeeds" { + var buf: [5]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: empty body" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, ""); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 0), len); +} + +test "toolIs: matches and rejects" { + const name: [*:0]const u8 = "foo"; + try std.testing.expect(toolIs(@ptrCast(name), "foo")); + try std.testing.expect(!toolIs(@ptrCast(name), "bar")); + try std.testing.expect(!toolIs(@ptrCast(name), "foobar")); + try std.testing.expect(!toolIs(@ptrCast(name), "fo")); +} + +test "invokeArgsNull: detects each null slot" { + var buf: [4]u8 = undefined; + var len: usize = 4; + const name: [*:0]const u8 = "x"; + try std.testing.expect(!invokeArgsNull(@ptrCast(name), &buf, &len)); + try std.testing.expect(invokeArgsNull(null, &buf, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), null, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), &buf, null)); +} diff --git a/cartridges/domains/cloud/hetzner-mcp/ffi/hetzner_mcp_ffi.zig b/cartridges/domains/cloud/hetzner-mcp/ffi/hetzner_mcp_ffi.zig new file mode 100644 index 0000000..a4da85d --- /dev/null +++ b/cartridges/domains/cloud/hetzner-mcp/ffi/hetzner_mcp_ffi.zig @@ -0,0 +1,584 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// hetzner_mcp_ffi.zig β€” C-ABI FFI implementation for hetzner-mcp cartridge. +// +// Implements the state machine defined in HetznerMcp.SafeCloud (Idris2 ABI). +// State machine: Unauthenticated | Authenticated | RateLimited | Error +// Auth: Bearer token (API token) via vault-mcp. +// REST API: https://api.hetzner.cloud/v1/ +// Resources: Servers, Images, SSH Keys, Volumes, Firewalls, Networks. +// Per-second rate limiting with configurable limit. +// Thread-safe via std.Thread.Mutex. Fixed-size session pool, no heap allocations. + +const std = @import("std"); + +// --------------------------------------------------------------------------- +// State machine (matches Idris2 ABI SessionState exactly) +// --------------------------------------------------------------------------- + +/// Session authentication/lifecycle state. +/// 0 = Unauthenticated, 1 = Authenticated, 2 = RateLimited, 3 = Error. +pub const SessionState = enum(c_int) { + unauthenticated = 0, + authenticated = 1, + rate_limited = 2, + err = 3, +}; + +/// Hetzner resource categories matching Idris2 HetznerResource encoding. +pub const HetznerResource = enum(c_int) { + servers = 0, + images = 1, + ssh_keys = 2, + volumes = 3, + firewalls = 4, + networks = 5, +}; + +/// Hetzner action identifiers matching Idris2 HetznerAction encoding. +pub const HetznerAction = enum(c_int) { + list_servers = 0, + get_server = 1, + create_server = 2, + delete_server = 3, + power_on = 4, + power_off = 5, + reboot = 6, + list_images = 7, + list_ssh_keys = 8, + create_ssh_key = 9, + list_volumes = 10, + create_volume = 11, + attach_volume = 12, + list_firewalls = 13, + create_firewall = 14, + list_networks = 15, +}; + +/// Check valid state transitions per the Idris2 ValidTransition proof. +fn isValidTransition(from: SessionState, to: SessionState) bool { + return switch (from) { + .unauthenticated => to == .authenticated, + .authenticated => to == .unauthenticated or to == .rate_limited or to == .err, + .rate_limited => to == .authenticated, + .err => to == .unauthenticated, + }; +} + +/// Map action integer to its resource category integer. Returns -1 for invalid. +fn actionToResource(action: c_int) c_int { + const a = std.meta.intToEnum(HetznerAction, action) catch return -1; + return switch (a) { + .list_servers, .get_server, .create_server, .delete_server, .power_on, .power_off, .reboot => 0, + .list_images => 1, + .list_ssh_keys, .create_ssh_key => 2, + .list_volumes, .create_volume, .attach_volume => 3, + .list_firewalls, .create_firewall => 4, + .list_networks => 5, + }; +} + +// --------------------------------------------------------------------------- +// Session slots (thread-safe, fixed-size pool) +// --------------------------------------------------------------------------- + +const MAX_SESSIONS: usize = 16; +const DEFAULT_RATE_LIMIT: u32 = 3600; + +const SessionSlot = struct { + active: bool = false, + state: SessionState = .unauthenticated, + api_call_count: u64 = 0, + last_action: c_int = -1, + rate_limit_per_hour: u32 = DEFAULT_RATE_LIMIT, + calls_this_window: u32 = 0, + server_count: u32 = 0, + volume_count: u32 = 0, + firewall_count: u32 = 0, +}; + +var sessions: [MAX_SESSIONS]SessionSlot = .{SessionSlot{}} ** MAX_SESSIONS; +var mutex: std.Thread.Mutex = .{}; + +// --------------------------------------------------------------------------- +// C-ABI exports β€” state machine +// --------------------------------------------------------------------------- + +/// Check if a state transition is valid. Returns 1 (valid) or 0 (invalid). +pub export fn hetzner_mcp_can_transition(from: c_int, to: c_int) c_int { + const f = std.meta.intToEnum(SessionState, from) catch return 0; + const t = std.meta.intToEnum(SessionState, to) catch return 0; + return if (isValidTransition(f, t)) 1 else 0; +} + +/// Authenticate a session. Returns slot index (>= 0) or error (< 0). +/// Error codes: -1 = no free slots. +/// The rate_limit_per_hour parameter configures per-session throttling. +pub export fn hetzner_mcp_authenticate(rate_limit: c_int) c_int { + const rl: u32 = std.math.cast(u32, rate_limit) orelse DEFAULT_RATE_LIMIT; + + mutex.lock(); + defer mutex.unlock(); + + for (&sessions, 0..) |*slot, idx| { + if (!slot.active) { + slot.active = true; + slot.state = .authenticated; + slot.api_call_count = 0; + slot.last_action = -1; + slot.rate_limit_per_hour = rl; + slot.calls_this_window = 0; + slot.server_count = 0; + slot.volume_count = 0; + slot.firewall_count = 0; + return @intCast(idx); + } + } + return -1; +} + +/// Deauthenticate (close) a session. Returns 0 on success. +/// Error codes: -1 = invalid slot, -2 = invalid state transition. +pub export fn hetzner_mcp_deauthenticate(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + if (!isValidTransition(slot.state, .unauthenticated)) return -2; + + sessions[idx] = SessionSlot{}; + return 0; +} + +/// Get current state of a session. Returns state int or -1 if invalid. +pub export fn hetzner_mcp_session_state(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + return @intFromEnum(slot.state); +} + +/// Signal rate limiting on a session. Returns 0 on success. +pub export fn hetzner_mcp_throttle(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + if (!isValidTransition(slot.state, .rate_limited)) return -2; + + sessions[idx].state = .rate_limited; + return 0; +} + +/// Clear rate limiting (resume authenticated). Returns 0 on success. +pub export fn hetzner_mcp_unthrottle(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + if (!isValidTransition(slot.state, .authenticated)) return -2; + + sessions[idx].state = .authenticated; + sessions[idx].calls_this_window = 0; + return 0; +} + +/// Signal an error on a session. Returns 0 on success. +pub export fn hetzner_mcp_signal_error(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + if (!isValidTransition(slot.state, .err)) return -2; + + sessions[idx].state = .err; + return 0; +} + +// --------------------------------------------------------------------------- +// C-ABI exports β€” resource routing and actions +// --------------------------------------------------------------------------- + +/// Get the resource category for an action. Returns resource int (0-5) or -1. +pub export fn hetzner_mcp_action_resource(action: c_int) c_int { + return actionToResource(action); +} + +/// Record an API call on a session. Returns 0 on success. +/// Error codes: -1 = invalid slot, -2 = not authenticated, -3 = invalid action. +pub export fn hetzner_mcp_record_call(slot_idx: c_int, action: c_int) c_int { + _ = std.meta.intToEnum(HetznerAction, action) catch return -3; + + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + if (slot.state != .authenticated) return -2; + + sessions[idx].api_call_count += 1; + sessions[idx].last_action = action; + sessions[idx].calls_this_window += 1; + return 0; +} + +/// Get API call count for a session. Returns count or -1 if invalid. +pub export fn hetzner_mcp_call_count(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + return @intCast(slot.api_call_count); +} + +/// Update resource counts (server, volume, firewall) for panel display. +/// Returns 0 on success, -1 if invalid slot. +pub export fn hetzner_mcp_set_counts(slot_idx: c_int, servers: c_int, volumes: c_int, firewalls: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + + sessions[idx].server_count = std.math.cast(u32, servers) orelse 0; + sessions[idx].volume_count = std.math.cast(u32, volumes) orelse 0; + sessions[idx].firewall_count = std.math.cast(u32, firewalls) orelse 0; + return 0; +} + +/// Get server count for a session. Returns count or -1 if invalid. +pub export fn hetzner_mcp_server_count(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + return @intCast(slot.server_count); +} + +/// Get volume count for a session. Returns count or -1 if invalid. +pub export fn hetzner_mcp_volume_count(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + return @intCast(slot.volume_count); +} + +/// Get firewall count for a session. Returns count or -1 if invalid. +pub export fn hetzner_mcp_firewall_count(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + return @intCast(slot.firewall_count); +} + +/// Get total resource category count. Always returns 6. +pub export fn hetzner_mcp_resource_count() c_int { + return 6; +} + +/// Get total action count. Always returns 16. +pub export fn hetzner_mcp_action_count() c_int { + return 16; +} + +/// Reset all sessions (test/debug use only). +pub export fn hetzner_mcp_reset() void { + mutex.lock(); + defer mutex.unlock(); + sessions = .{SessionSlot{}} ** MAX_SESSIONS; +} + +// --------------------------------------------------------------------------- +// ═══════════════════════════════════════════════════════════════════════ +// Standard ABI (ADR-0005 four symbols + ADR-0006 invoke) +// ═══════════════════════════════════════════════════════════════════════ + +const shim = @import("cartridge_shim.zig"); + +const CARTRIDGE_NAME_PTR: [*:0]const u8 = "hetzner-mcp"; +const CARTRIDGE_VERSION_PTR: [*:0]const u8 = "0.1.0"; + +export fn boj_cartridge_init() callconv(.c) c_int { + return 0; +} + +export fn boj_cartridge_deinit() callconv(.c) void {} + +export fn boj_cartridge_name() callconv(.c) [*:0]const u8 { + return CARTRIDGE_NAME_PTR; +} + +export fn boj_cartridge_version() callconv(.c) [*:0]const u8 { + return CARTRIDGE_VERSION_PTR; +} + +/// Dispatch the cartridge.json MCP tools. Grade D Alpha stubs. +export fn boj_cartridge_invoke( + tool_name: [*c]const u8, + json_args: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) callconv(.c) i32 { + _ = json_args; + if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; + + const body: []const u8 = if (shim.toolIs(tool_name, "hetzner_list_servers")) + "{\"result\":{\"items\":[],\"count\":0,\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "hetzner_get_server")) + "{\"result\":{\"metadata\":{},\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "hetzner_create_server")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "hetzner_delete_server")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "hetzner_server_action")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "hetzner_resize_server")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "hetzner_list_floating_ips")) + "{\"result\":{\"items\":[],\"count\":0,\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "hetzner_create_floating_ip")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "hetzner_list_volumes")) + "{\"result\":{\"items\":[],\"count\":0,\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "hetzner_create_volume")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "hetzner_list_firewalls")) + "{\"result\":{\"items\":[],\"count\":0,\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "hetzner_create_firewall")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "hetzner_list_ssh_keys")) + "{\"result\":{\"items\":[],\"count\":0,\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "hetzner_create_ssh_key")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "hetzner_list_images")) + "{\"result\":{\"items\":[],\"count\":0,\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "hetzner_create_snapshot")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "hetzner_list_networks")) + "{\"result\":{\"items\":[],\"count\":0,\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "hetzner_create_network")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "hetzner_list_load_balancers")) + "{\"result\":{\"items\":[],\"count\":0,\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "hetzner_create_load_balancer")) + "{\"result\":{\"status\":\"stub\"}}" +else + return shim.RC_UNKNOWN_TOOL; + + return shim.writeResult(out_buf, in_out_len, body); +} + +// Tests +// --------------------------------------------------------------------------- + +test "authentication lifecycle" { + hetzner_mcp_reset(); + + const slot = hetzner_mcp_authenticate(3600); + try std.testing.expect(slot >= 0); + + // Should be authenticated (1) + try std.testing.expectEqual(@as(c_int, 1), hetzner_mcp_session_state(slot)); + + // Record an API call (ListServers = 0) + try std.testing.expectEqual(@as(c_int, 0), hetzner_mcp_record_call(slot, 0)); + try std.testing.expectEqual(@as(c_int, 1), hetzner_mcp_call_count(slot)); + + // Deauthenticate + try std.testing.expectEqual(@as(c_int, 0), hetzner_mcp_deauthenticate(slot)); +} + +test "rate limiting flow" { + hetzner_mcp_reset(); + + const slot = hetzner_mcp_authenticate(100); + try std.testing.expect(slot >= 0); + + // Throttle + try std.testing.expectEqual(@as(c_int, 0), hetzner_mcp_throttle(slot)); + try std.testing.expectEqual(@as(c_int, 2), hetzner_mcp_session_state(slot)); + + // Cannot invoke while rate limited + try std.testing.expectEqual(@as(c_int, -2), hetzner_mcp_record_call(slot, 0)); + + // Unthrottle + try std.testing.expectEqual(@as(c_int, 0), hetzner_mcp_unthrottle(slot)); + try std.testing.expectEqual(@as(c_int, 1), hetzner_mcp_session_state(slot)); +} + +test "error and recovery" { + hetzner_mcp_reset(); + + const slot = hetzner_mcp_authenticate(3600); + try std.testing.expect(slot >= 0); + + // Signal error + try std.testing.expectEqual(@as(c_int, 0), hetzner_mcp_signal_error(slot)); + try std.testing.expectEqual(@as(c_int, 3), hetzner_mcp_session_state(slot)); + + // Recover to unauthenticated + try std.testing.expectEqual(@as(c_int, 0), hetzner_mcp_deauthenticate(slot)); +} + +test "resource counts" { + hetzner_mcp_reset(); + + const slot = hetzner_mcp_authenticate(3600); + try std.testing.expect(slot >= 0); + + // Set counts + try std.testing.expectEqual(@as(c_int, 0), hetzner_mcp_set_counts(slot, 5, 3, 2)); + try std.testing.expectEqual(@as(c_int, 5), hetzner_mcp_server_count(slot)); + try std.testing.expectEqual(@as(c_int, 3), hetzner_mcp_volume_count(slot)); + try std.testing.expectEqual(@as(c_int, 2), hetzner_mcp_firewall_count(slot)); +} + +test "invalid transitions rejected" { + hetzner_mcp_reset(); + + const slot = hetzner_mcp_authenticate(3600); + try std.testing.expect(slot >= 0); + + // Cannot throttle twice + try std.testing.expectEqual(@as(c_int, 0), hetzner_mcp_throttle(slot)); + try std.testing.expectEqual(@as(c_int, -2), hetzner_mcp_throttle(slot)); + + // Cannot error from rate_limited + try std.testing.expectEqual(@as(c_int, -2), hetzner_mcp_signal_error(slot)); +} + +test "transition validator" { + try std.testing.expectEqual(@as(c_int, 1), hetzner_mcp_can_transition(0, 1)); + try std.testing.expectEqual(@as(c_int, 1), hetzner_mcp_can_transition(1, 0)); + try std.testing.expectEqual(@as(c_int, 1), hetzner_mcp_can_transition(1, 2)); + try std.testing.expectEqual(@as(c_int, 1), hetzner_mcp_can_transition(2, 1)); + try std.testing.expectEqual(@as(c_int, 1), hetzner_mcp_can_transition(1, 3)); + try std.testing.expectEqual(@as(c_int, 1), hetzner_mcp_can_transition(3, 0)); + + try std.testing.expectEqual(@as(c_int, 0), hetzner_mcp_can_transition(0, 2)); + try std.testing.expectEqual(@as(c_int, 0), hetzner_mcp_can_transition(0, 3)); + try std.testing.expectEqual(@as(c_int, 0), hetzner_mcp_can_transition(2, 0)); + try std.testing.expectEqual(@as(c_int, 0), hetzner_mcp_can_transition(3, 1)); +} + +test "action resource routing" { + try std.testing.expectEqual(@as(c_int, 0), hetzner_mcp_action_resource(0)); // ListServers -> Servers + try std.testing.expectEqual(@as(c_int, 0), hetzner_mcp_action_resource(6)); // Reboot -> Servers + try std.testing.expectEqual(@as(c_int, 1), hetzner_mcp_action_resource(7)); // ListImages -> Images + try std.testing.expectEqual(@as(c_int, 2), hetzner_mcp_action_resource(8)); // ListSSHKeys -> SSHKeys + try std.testing.expectEqual(@as(c_int, 3), hetzner_mcp_action_resource(10)); // ListVolumes -> Volumes + try std.testing.expectEqual(@as(c_int, 4), hetzner_mcp_action_resource(13)); // ListFirewalls -> Firewalls + try std.testing.expectEqual(@as(c_int, 5), hetzner_mcp_action_resource(15)); // ListNetworks -> Networks + try std.testing.expectEqual(@as(c_int, -1), hetzner_mcp_action_resource(99)); // invalid +} + +test "slot exhaustion" { + hetzner_mcp_reset(); + + var slots: [MAX_SESSIONS]c_int = undefined; + for (&slots) |*s| { + s.* = hetzner_mcp_authenticate(3600); + try std.testing.expect(s.* >= 0); + } + + try std.testing.expectEqual(@as(c_int, -1), hetzner_mcp_authenticate(3600)); + + try std.testing.expectEqual(@as(c_int, 0), hetzner_mcp_deauthenticate(slots[0])); + const new_slot = hetzner_mcp_authenticate(3600); + try std.testing.expect(new_slot >= 0); +} + +// ═══════════════════════════════════════════════════════════════════════ +// ADR-0006 invoke dispatch tests +// ═══════════════════════════════════════════════════════════════════════ + +test "boj_cartridge_name returns hetzner-mcp" { + const n = std.mem.span(boj_cartridge_name()); + try std.testing.expectEqualStrings("hetzner-mcp", n); +} + +test "boj_cartridge_init returns 0" { + try std.testing.expectEqual(@as(c_int, 0), boj_cartridge_init()); +} + +test "invoke: each declared tool succeeds" { + var buf: [256]u8 = undefined; + const tools = [_][]const u8{ + "hetzner_list_servers", + "hetzner_get_server", + "hetzner_create_server", + "hetzner_delete_server", + "hetzner_server_action", + "hetzner_resize_server", + "hetzner_list_floating_ips", + "hetzner_create_floating_ip", + "hetzner_list_volumes", + "hetzner_create_volume", + "hetzner_list_firewalls", + "hetzner_create_firewall", + "hetzner_list_ssh_keys", + "hetzner_create_ssh_key", + "hetzner_list_images", + "hetzner_create_snapshot", + "hetzner_list_networks", + "hetzner_create_network", + "hetzner_list_load_balancers", + "hetzner_create_load_balancer", + }; + for (tools) |t| { + var len: usize = buf.len; + const rc = boj_cartridge_invoke(t.ptr, "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, 0), rc); + try std.testing.expect(std.mem.indexOf(u8, buf[0..len], "result") != null); + } +} + +test "invoke: unknown tool returns -1" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("nope", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -1), rc); +} + +test "invoke: buffer too small returns -3" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("hetzner_list_servers", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -3), rc); + try std.testing.expect(len > 4); +} diff --git a/cartridges/domains/cloud/hetzner-mcp/minter.toml b/cartridges/domains/cloud/hetzner-mcp/minter.toml new file mode 100644 index 0000000..52193fb --- /dev/null +++ b/cartridges/domains/cloud/hetzner-mcp/minter.toml @@ -0,0 +1,23 @@ +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> + +name = "hetzner-mcp" +description = "Hetzner Cloud cartridge β€” Bearer token auth, server/volume/firewall/network management with per-second rate limiting" +version = "0.2.0" +domain = "Cloud" +protocols = [ + "MCP", + "REST", +] +tier = "Ayo" +backend = "universal" +generate_panel = true + +[auth] +method = "bearer_token" +credential_source = "vault-mcp" +fields = ["api_token"] + +[api] +base_url = "https://api.hetzner.cloud/v1" +rate_limit_per_hour = 3600 diff --git a/cartridges/domains/cloud/hetzner-mcp/mod.js b/cartridges/domains/cloud/hetzner-mcp/mod.js new file mode 100644 index 0000000..34d7c14 --- /dev/null +++ b/cartridges/domains/cloud/hetzner-mcp/mod.js @@ -0,0 +1,358 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// hetzner-mcp/mod.js -- Hetzner Cloud API cartridge implementation. +// +// Provides MCP tool handlers for Hetzner Cloud REST API v1: +// - Server management (list, get, create, delete, power actions, resize, snapshots) +// - Floating IPs (list, create) +// - Volumes (list, create) +// - Firewalls (list, create) +// - SSH keys (list, create) +// - Images (list) +// - Networks (list, create) +// - Load balancers (list, create) +// +// Auth: Bearer token via HETZNER_API_TOKEN env var or vault-mcp proxy. +// API docs: https://docs.hetzner.cloud/ +// +// Usage: import { handleTool } from "./mod.js"; +// or: deno run --allow-net --allow-env mod.js + +const API_BASE = "https://api.hetzner.cloud/v1"; + +// --------------------------------------------------------------------------- +// Auth helper β€” retrieves the Hetzner API token from environment. +// In production, vault-mcp provides zero-knowledge credential proxying; +// for development, HETZNER_API_TOKEN is read directly. +// --------------------------------------------------------------------------- + +function getToken() { + const token = typeof Deno !== "undefined" + ? Deno.env.get("HETZNER_API_TOKEN") + : process.env.HETZNER_API_TOKEN; + if (!token) { + throw new Error("HETZNER_API_TOKEN not set. Store in vault-mcp or export to environment."); + } + return token; +} + +// --------------------------------------------------------------------------- +// HTTP request helper β€” wraps fetch with Hetzner auth headers, error +// handling, pagination parameter forwarding, and rate-limit extraction. +// --------------------------------------------------------------------------- + +async function hetznerFetch(method, path, body, queryParams) { + const token = getToken(); + const url = new URL(`${API_BASE}${path}`); + + // Append query parameters (pagination, filters, sorting) + if (queryParams) { + for (const [key, value] of Object.entries(queryParams)) { + if (value !== undefined && value !== null && value !== "") { + url.searchParams.set(key, String(value)); + } + } + } + + const options = { + method, + headers: { + "Authorization": `Bearer ${token}`, + "Accept": "application/json", + "User-Agent": "boj-server/hetzner-mcp/0.2.0", + }, + }; + + if (body && method !== "GET") { + options.headers["Content-Type"] = "application/json"; + options.body = JSON.stringify(body); + } + + const response = await fetch(url.toString(), options); + + // Extract rate-limit headers for caller awareness + const rateLimit = { + limit: response.headers.get("ratelimit-limit"), + remaining: response.headers.get("ratelimit-remaining"), + reset: response.headers.get("ratelimit-reset"), + }; + + // Handle 204 No Content (successful deletes) + if (response.status === 204) { + return { status: response.status, data: { success: true }, rateLimit }; + } + + const data = await response.json(); + + // Surface Hetzner API errors clearly + if (!response.ok) { + const errorMessage = data.error + ? `${data.error.code}: ${data.error.message}` + : `HTTP ${response.status}`; + return { status: response.status, error: errorMessage, data, rateLimit }; + } + + return { status: response.status, data, rateLimit }; +} + +// --------------------------------------------------------------------------- +// Tool handler dispatch β€” maps MCP tool names to Hetzner API operations. +// Each handler validates required arguments, builds the API request, +// and returns structured results with rate-limit metadata. +// --------------------------------------------------------------------------- + +export async function handleTool(toolName, args) { + switch (toolName) { + + // --- Servers --- + + case "hetzner_list_servers": { + const query = { + page: args.page, + per_page: args.per_page, + sort: args.sort, + status: args.status, + label_selector: args.label_selector, + }; + return hetznerFetch("GET", "/servers", null, query); + } + + case "hetzner_get_server": { + if (!args.server_id) return { error: "Missing required field: server_id" }; + return hetznerFetch("GET", `/servers/${args.server_id}`); + } + + case "hetzner_create_server": { + if (!args.name) return { error: "Missing required field: name" }; + if (!args.server_type) return { error: "Missing required field: server_type" }; + if (!args.image) return { error: "Missing required field: image" }; + const body = { + name: args.name, + server_type: args.server_type, + image: args.image, + }; + if (args.location) body.location = args.location; + if (args.ssh_keys) body.ssh_keys = args.ssh_keys; + if (args.labels) body.labels = args.labels; + if (args.user_data) body.user_data = args.user_data; + if (args.firewalls) body.firewalls = args.firewalls; + if (args.networks) body.networks = args.networks; + if (args.public_net) body.public_net = args.public_net; + return hetznerFetch("POST", "/servers", body); + } + + case "hetzner_delete_server": { + if (!args.server_id) return { error: "Missing required field: server_id" }; + return hetznerFetch("DELETE", `/servers/${args.server_id}`); + } + + case "hetzner_server_action": { + if (!args.server_id) return { error: "Missing required field: server_id" }; + if (!args.action) return { error: "Missing required field: action" }; + const validActions = ["poweron", "poweroff", "reboot", "shutdown", "reset", "rebuild"]; + if (!validActions.includes(args.action)) { + return { error: `Invalid action '${args.action}'. Must be one of: ${validActions.join(", ")}` }; + } + const body = {}; + if (args.action === "rebuild") { + if (!args.image) return { error: "Missing required field: image (required for rebuild)" }; + body.image = args.image; + } + return hetznerFetch("POST", `/servers/${args.server_id}/actions/${args.action}`, body); + } + + case "hetzner_resize_server": { + if (!args.server_id) return { error: "Missing required field: server_id" }; + if (!args.server_type) return { error: "Missing required field: server_type" }; + const body = { + server_type: args.server_type, + upgrade_disk: args.upgrade_disk || false, + }; + return hetznerFetch("POST", `/servers/${args.server_id}/actions/change_type`, body); + } + + // --- Floating IPs --- + + case "hetzner_list_floating_ips": { + const query = { + page: args.page, + per_page: args.per_page, + label_selector: args.label_selector, + }; + return hetznerFetch("GET", "/floating_ips", null, query); + } + + case "hetzner_create_floating_ip": { + if (!args.type) return { error: "Missing required field: type" }; + if (!args.home_location) return { error: "Missing required field: home_location" }; + const body = { + type: args.type, + home_location: args.home_location, + }; + if (args.server) body.server = args.server; + if (args.description) body.description = args.description; + if (args.labels) body.labels = args.labels; + return hetznerFetch("POST", "/floating_ips", body); + } + + // --- Volumes --- + + case "hetzner_list_volumes": { + const query = { + page: args.page, + per_page: args.per_page, + label_selector: args.label_selector, + status: args.status, + }; + return hetznerFetch("GET", "/volumes", null, query); + } + + case "hetzner_create_volume": { + if (!args.name) return { error: "Missing required field: name" }; + if (!args.size) return { error: "Missing required field: size" }; + const body = { + name: args.name, + size: args.size, + }; + if (args.location) body.location = args.location; + if (args.server) body.server = args.server; + if (args.format) body.format = args.format; + if (args.labels) body.labels = args.labels; + if (args.automount !== undefined) body.automount = args.automount; + return hetznerFetch("POST", "/volumes", body); + } + + // --- Firewalls --- + + case "hetzner_list_firewalls": { + const query = { + page: args.page, + per_page: args.per_page, + label_selector: args.label_selector, + }; + return hetznerFetch("GET", "/firewalls", null, query); + } + + case "hetzner_create_firewall": { + if (!args.name) return { error: "Missing required field: name" }; + if (!args.rules) return { error: "Missing required field: rules" }; + const body = { + name: args.name, + rules: args.rules, + }; + if (args.apply_to) body.apply_to = args.apply_to; + if (args.labels) body.labels = args.labels; + return hetznerFetch("POST", "/firewalls", body); + } + + // --- SSH Keys --- + + case "hetzner_list_ssh_keys": { + const query = { + page: args.page, + per_page: args.per_page, + label_selector: args.label_selector, + }; + return hetznerFetch("GET", "/ssh_keys", null, query); + } + + case "hetzner_create_ssh_key": { + if (!args.name) return { error: "Missing required field: name" }; + if (!args.public_key) return { error: "Missing required field: public_key" }; + const body = { + name: args.name, + public_key: args.public_key, + }; + if (args.labels) body.labels = args.labels; + return hetznerFetch("POST", "/ssh_keys", body); + } + + // --- Images --- + + case "hetzner_list_images": { + const query = { + type: args.type, + status: args.status, + sort: args.sort, + page: args.page, + per_page: args.per_page, + }; + return hetznerFetch("GET", "/images", null, query); + } + + case "hetzner_create_snapshot": { + if (!args.server_id) return { error: "Missing required field: server_id" }; + const body = {}; + if (args.description) body.description = args.description; + if (args.labels) body.labels = args.labels; + return hetznerFetch("POST", `/servers/${args.server_id}/actions/create_image`, body); + } + + // --- Networks --- + + case "hetzner_list_networks": { + const query = { + page: args.page, + per_page: args.per_page, + label_selector: args.label_selector, + }; + return hetznerFetch("GET", "/networks", null, query); + } + + case "hetzner_create_network": { + if (!args.name) return { error: "Missing required field: name" }; + if (!args.ip_range) return { error: "Missing required field: ip_range" }; + const body = { + name: args.name, + ip_range: args.ip_range, + }; + if (args.subnets) body.subnets = args.subnets; + if (args.labels) body.labels = args.labels; + return hetznerFetch("POST", "/networks", body); + } + + // --- Load Balancers --- + + case "hetzner_list_load_balancers": { + const query = { + page: args.page, + per_page: args.per_page, + label_selector: args.label_selector, + }; + return hetznerFetch("GET", "/load_balancers", null, query); + } + + case "hetzner_create_load_balancer": { + if (!args.name) return { error: "Missing required field: name" }; + if (!args.load_balancer_type) return { error: "Missing required field: load_balancer_type" }; + const body = { + name: args.name, + load_balancer_type: args.load_balancer_type, + }; + if (args.location) body.location = args.location; + if (args.algorithm) body.algorithm = args.algorithm; + if (args.services) body.services = args.services; + if (args.targets) body.targets = args.targets; + if (args.labels) body.labels = args.labels; + return hetznerFetch("POST", "/load_balancers", body); + } + + default: + return { error: `Unknown hetzner-mcp tool: ${toolName}` }; + } +} + +// --------------------------------------------------------------------------- +// Cartridge metadata export β€” used by the BoJ cartridge loader to register +// this cartridge's tools without reading cartridge.json separately. +// --------------------------------------------------------------------------- + +export const metadata = { + name: "hetzner-mcp", + version: "0.2.0", + domain: "Cloud", + tier: "Ayo", + protocols: ["MCP", "REST"], + toolCount: 20, +}; diff --git a/cartridges/domains/cloud/hetzner-mcp/panels/manifest.json b/cartridges/domains/cloud/hetzner-mcp/panels/manifest.json new file mode 100644 index 0000000..e273bd9 --- /dev/null +++ b/cartridges/domains/cloud/hetzner-mcp/panels/manifest.json @@ -0,0 +1,90 @@ +{ + "$schema": "https://panll.dev/schemas/panel-manifest/v1.json", + "spdx": "MPL-2.0", + "copyright": "Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath)", + "cartridge": "hetzner-mcp", + "domain": "Cloud", + "version": "0.2.0", + "panels": [ + { + "id": "hetzner-auth-status", + "title": "Auth Status", + "description": "Hetzner Cloud authentication state (Unauthenticated / Authenticated / RateLimited / Error)", + "type": "status-indicator", + "data_source": { + "endpoint": "/hetzner/state", + "method": "GET", + "refresh_interval_ms": 5000 + }, + "widgets": [ + { + "type": "state-badge", + "field": "state", + "states": { + "unauthenticated": { "color": "#95a5a6", "icon": "cloud-off" }, + "authenticated": { "color": "#2ecc71", "icon": "cloud-check" }, + "rate_limited": { "color": "#f39c12", "icon": "clock" }, + "error": { "color": "#e74c3c", "icon": "alert-triangle" } + } + } + ] + }, + { + "id": "hetzner-server-count", + "title": "Server Count", + "description": "Number of Hetzner Cloud servers in the account", + "type": "metric", + "data_source": { + "endpoint": "/hetzner/status", + "method": "GET", + "refresh_interval_ms": 30000 + }, + "widgets": [ + { + "type": "counter", + "field": "server_count", + "label": "Servers", + "icon": "server" + } + ] + }, + { + "id": "hetzner-volume-count", + "title": "Volume Count", + "description": "Number of block storage volumes", + "type": "metric", + "data_source": { + "endpoint": "/hetzner/status", + "method": "GET", + "refresh_interval_ms": 30000 + }, + "widgets": [ + { + "type": "counter", + "field": "volume_count", + "label": "Volumes", + "icon": "hard-drive" + } + ] + }, + { + "id": "hetzner-firewall-count", + "title": "Firewall Count", + "description": "Number of firewall rules configured", + "type": "metric", + "data_source": { + "endpoint": "/hetzner/status", + "method": "GET", + "refresh_interval_ms": 30000 + }, + "widgets": [ + { + "type": "counter", + "field": "firewall_count", + "label": "Firewalls", + "icon": "shield" + } + ] + } + ] +} diff --git a/cartridges/domains/cloud/hetzner-mcp/tests/integration_test.sh b/cartridges/domains/cloud/hetzner-mcp/tests/integration_test.sh new file mode 100755 index 0000000..12c6bbe --- /dev/null +++ b/cartridges/domains/cloud/hetzner-mcp/tests/integration_test.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Integration tests for hetzner-mcp cartridge. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CART_DIR="$(dirname "$SCRIPT_DIR")" +FFI_DIR="$CART_DIR/ffi" + +echo "=== hetzner-mcp integration tests ===" + +# Build FFI +echo "[1/3] Building FFI..." +cd "$FFI_DIR" && zig build 2>&1 + +# Run Zig unit tests +echo "[2/3] Running FFI unit tests..." +cd "$FFI_DIR" && zig build test 2>&1 + +# Validate Idris2 ABI (if idris2 available) +echo "[3/3] Checking ABI..." +if command -v idris2 &>/dev/null; then + cd "$CART_DIR/abi" && idris2 --check HetznerMcp.SafeCloud 2>&1 + echo " ABI: OK" +else + echo " ABI: SKIPPED (idris2 not in PATH)" +fi + +echo "" +echo "All tests passed for hetzner-mcp!" diff --git a/cartridges/domains/cloud/linode-mcp/README.adoc b/cartridges/domains/cloud/linode-mcp/README.adoc new file mode 100644 index 0000000..b6a4048 --- /dev/null +++ b/cartridges/domains/cloud/linode-mcp/README.adoc @@ -0,0 +1,99 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> + += linode-mcp +Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk> +:spdx: MPL-2.0 +:tier: Ayo +:domain: Cloud +:protocols: MCP, REST + +== Overview + +Linode/Akamai cloud infrastructure MCP cartridge. Manages compute instances, +block storage volumes, domains, NodeBalancers, StackScripts, and images via the +Linode REST API (`https://api.linode.com/v4/`). + +Authentication uses bearer tokens (personal access tokens). Rate limited to 800 +requests per 2 minutes. + +== State Machine + +[source] +---- +Unauthenticated --> Authenticated (bearer token provided) +Authenticated --> RateLimited (800 req/2min exceeded) +RateLimited --> Authenticated (rate limit window reset) +Authenticated --> Error (API/network failure) +RateLimited --> Error (failure during rate limit) +Error --> Authenticated (recovery) +Authenticated --> Unauthenticated (logout) +Error --> Unauthenticated (logout from error) +---- + +== Actions (16) + +|=== +| Action | Destructive | Endpoint + +| ListInstances | No | GET /v4/linode/instances +| GetInstance | No | GET /v4/linode/instances/{id} +| CreateInstance | Yes | POST /v4/linode/instances +| DeleteInstance | Yes | DELETE /v4/linode/instances/{id} +| Boot | Yes | POST /v4/linode/instances/{id}/boot +| Shutdown | Yes | POST /v4/linode/instances/{id}/shutdown +| Reboot | Yes | POST /v4/linode/instances/{id}/reboot +| ListVolumes | No | GET /v4/volumes +| CreateVolume | Yes | POST /v4/volumes +| ListDomains | No | GET /v4/domains +| CreateDomain | Yes | POST /v4/domains +| ListNodeBalancers | No | GET /v4/nodebalancers +| ListStackScripts | No | GET /v4/linode/stackscripts +| ListImages | No | GET /v4/images +| ListRegions | No | GET /v4/regions +| GetAccount | No | GET /v4/account +|=== + +== Architecture + +[cols="1,1,2"] +|=== +| Layer | Language | Purpose + +| ABI +| Idris2 +| Formally verified auth state machine with dependent-type proofs + +| FFI +| Zig +| C-ABI implementation with thread-safe session pool and rate limit tracking + +| Adapter +| zig +| REST bridge to BoJ unified adapter protocol +|=== + +== Building + +[source,bash] +---- +# Build FFI shared library +cd ffi && zig build + +# Run FFI tests +cd ffi && zig build test + +# Type-check ABI +cd abi && idris2 --check LinodeMcp.SafeCloud +---- + +== Panels + +* Auth status (unauthenticated/authenticated/rate_limited/error) +* Instance count +* Volume count +* NodeBalancer count + +== Status + +Development -- not yet ready for mounting. diff --git a/cartridges/domains/cloud/linode-mcp/abi/LinodeMcp/SafeCloud.idr b/cartridges/domains/cloud/linode-mcp/abi/LinodeMcp/SafeCloud.idr new file mode 100644 index 0000000..7788907 --- /dev/null +++ b/cartridges/domains/cloud/linode-mcp/abi/LinodeMcp/SafeCloud.idr @@ -0,0 +1,224 @@ +-- SPDX-License-Identifier: MPL-2.0 +-- Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +-- +-- LinodeMcp.SafeCloud β€” Type-safe ABI for linode-mcp cartridge. +-- +-- Formally verified state machine for Linode/Akamai API interactions. +-- Bearer token authentication, REST API (https://api.linode.com/v4/). +-- Rate limit: 800 requests per 2 minutes. + +module LinodeMcp.SafeCloud + +%default total + +-- --------------------------------------------------------------------------- +-- Authentication state machine +-- --------------------------------------------------------------------------- + +||| Authentication/session state for Linode API operations. +||| Unauthenticated: No bearer token configured. +||| Authenticated: Valid personal access token, ready for API calls. +||| RateLimited: Hit 800 req/2min limit, must wait for reset. +||| Error: API or network error requiring recovery. +public export +data AuthState = Unauthenticated | Authenticated | RateLimited | Error + +||| Proof that a state transition is valid. +||| Only well-typed transitions compile β€” invalid paths are rejected. +public export +data ValidTransition : AuthState -> AuthState -> Type where + Authenticate : ValidTransition Unauthenticated Authenticated + HitRateLimit : ValidTransition Authenticated RateLimited + RateLimitReset : ValidTransition RateLimited Authenticated + AuthError : ValidTransition Authenticated Error + RateLimitError : ValidTransition RateLimited Error + RecoverToAuth : ValidTransition Error Authenticated + Logout : ValidTransition Authenticated Unauthenticated + ErrorLogout : ValidTransition Error Unauthenticated + +-- --------------------------------------------------------------------------- +-- C-ABI integer encoding +-- --------------------------------------------------------------------------- + +||| Encode auth state as C-compatible integer for FFI boundary. +export +authStateToInt : AuthState -> Int +authStateToInt Unauthenticated = 0 +authStateToInt Authenticated = 1 +authStateToInt RateLimited = 2 +authStateToInt Error = 3 + +||| Decode integer back to auth state. Returns Nothing for invalid values. +export +intToAuthState : Int -> Maybe AuthState +intToAuthState 0 = Just Unauthenticated +intToAuthState 1 = Just Authenticated +intToAuthState 2 = Just RateLimited +intToAuthState 3 = Just Error +intToAuthState _ = Nothing + +||| Check if a state transition is valid (C-ABI export). +||| Returns 1 for valid, 0 for invalid. +export +linode_mcp_can_transition : Int -> Int -> Int +linode_mcp_can_transition from to = + case (intToAuthState from, intToAuthState to) of + (Just Unauthenticated, Just Authenticated) => 1 + (Just Authenticated, Just RateLimited) => 1 + (Just RateLimited, Just Authenticated) => 1 + (Just Authenticated, Just Error) => 1 + (Just RateLimited, Just Error) => 1 + (Just Error, Just Authenticated) => 1 + (Just Authenticated, Just Unauthenticated) => 1 + (Just Error, Just Unauthenticated) => 1 + _ => 0 + +-- --------------------------------------------------------------------------- +-- Linode API actions +-- --------------------------------------------------------------------------- + +||| All supported Linode REST API actions. +||| Each maps to one or more endpoints under https://api.linode.com/v4/. +public export +data LinodeAction + = ListInstances + | GetInstance + | CreateInstance + | DeleteInstance + | Boot + | Shutdown + | Reboot + | ListVolumes + | CreateVolume + | ListDomains + | CreateDomain + | ListNodeBalancers + | ListStackScripts + | ListImages + | ListRegions + | GetAccount + +||| Check whether an action requires Authenticated state. +||| All Linode API calls require a valid bearer token. +export +actionRequiresAuth : LinodeAction -> Bool +actionRequiresAuth ListInstances = True +actionRequiresAuth GetInstance = True +actionRequiresAuth CreateInstance = True +actionRequiresAuth DeleteInstance = True +actionRequiresAuth Boot = True +actionRequiresAuth Shutdown = True +actionRequiresAuth Reboot = True +actionRequiresAuth ListVolumes = True +actionRequiresAuth CreateVolume = True +actionRequiresAuth ListDomains = True +actionRequiresAuth CreateDomain = True +actionRequiresAuth ListNodeBalancers = True +actionRequiresAuth ListStackScripts = True +actionRequiresAuth ListImages = True +actionRequiresAuth ListRegions = True +actionRequiresAuth GetAccount = True + +||| Check whether an action is destructive (mutates remote state). +export +actionIsDestructive : LinodeAction -> Bool +actionIsDestructive CreateInstance = True +actionIsDestructive DeleteInstance = True +actionIsDestructive Boot = True +actionIsDestructive Shutdown = True +actionIsDestructive Reboot = True +actionIsDestructive CreateVolume = True +actionIsDestructive CreateDomain = True +actionIsDestructive _ = False + +||| Encode action as C-compatible integer for FFI boundary. +export +actionToInt : LinodeAction -> Int +actionToInt ListInstances = 0 +actionToInt GetInstance = 1 +actionToInt CreateInstance = 2 +actionToInt DeleteInstance = 3 +actionToInt Boot = 4 +actionToInt Shutdown = 5 +actionToInt Reboot = 6 +actionToInt ListVolumes = 7 +actionToInt CreateVolume = 8 +actionToInt ListDomains = 9 +actionToInt CreateDomain = 10 +actionToInt ListNodeBalancers = 11 +actionToInt ListStackScripts = 12 +actionToInt ListImages = 13 +actionToInt ListRegions = 14 +actionToInt GetAccount = 15 + +||| Decode integer back to action. Returns Nothing for invalid values. +export +intToAction : Int -> Maybe LinodeAction +intToAction 0 = Just ListInstances +intToAction 1 = Just GetInstance +intToAction 2 = Just CreateInstance +intToAction 3 = Just DeleteInstance +intToAction 4 = Just Boot +intToAction 5 = Just Shutdown +intToAction 6 = Just Reboot +intToAction 7 = Just ListVolumes +intToAction 8 = Just CreateVolume +intToAction 9 = Just ListDomains +intToAction 10 = Just CreateDomain +intToAction 11 = Just ListNodeBalancers +intToAction 12 = Just ListStackScripts +intToAction 13 = Just ListImages +intToAction 14 = Just ListRegions +intToAction 15 = Just GetAccount +intToAction _ = Nothing + +||| Total number of supported actions. +export +actionCount : Nat +actionCount = 16 + +-- --------------------------------------------------------------------------- +-- Rate limiting +-- --------------------------------------------------------------------------- + +||| Linode rate limit: 800 requests per 2 minutes. +export +rateLimitPerWindow : Nat +rateLimitPerWindow = 800 + +-- --------------------------------------------------------------------------- +-- MCP tool declarations +-- --------------------------------------------------------------------------- + +||| Tools exposed via MCP protocol for this cartridge. +public export +data McpTool + = ToolAuthenticate + | ToolListInstances + | ToolGetInstance + | ToolCreateInstance + | ToolDeleteInstance + | ToolBootAction + | ToolShutdownAction + | ToolRebootAction + | ToolListVolumes + | ToolCreateVolume + | ToolListDomains + | ToolCreateDomain + | ToolListNodeBalancers + | ToolListStackScripts + | ToolListImages + | ToolGetAccount + | ToolStatus + +||| Check if a tool requires authenticated state. +export +toolRequiresAuth : McpTool -> Bool +toolRequiresAuth ToolAuthenticate = False +toolRequiresAuth ToolStatus = False +toolRequiresAuth _ = True + +||| Tool count for this cartridge. +export +toolCount : Nat +toolCount = 17 diff --git a/cartridges/domains/cloud/linode-mcp/abi/README.adoc b/cartridges/domains/cloud/linode-mcp/abi/README.adoc new file mode 100644 index 0000000..63cc05a --- /dev/null +++ b/cartridges/domains/cloud/linode-mcp/abi/README.adoc @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += linode-mcp / abi β€” Idris2 ABI layer +:orientation: shallow + +== Purpose + +Cartridge-local protocol types for `linode-mcp`. Defines the data, tool, and +transition vocabulary this cartridge speaks. Inherits safety lemmas and the +matrix-cell identifier scheme from the trunk ABI at `src/abi/`. + +== Files + +1 Idris2 module(s), ~224 lines total. Lead module: +`SafeCloud.idr`. + +No `.ipkg` β€” consumed directly by wider BoJ callers; type-checks inside the +trunk `boj.ipkg`. + +== Invariants + +* `%default total` at every module top. +* Zero `believe_me`, `assert_total`, `postulate`, `Admitted`, or + `sorry` (the trunk holds the four declared `believe_me` sites; leaf + cartridges carry none). + +== Test/proof surface + +Thin module(s), no inline tests. Proof surface = type-check success under the +trunk `idris2 --check`. + +== Read-first + +. `SafeCloud.idr` β€” cartridge-local data records and enums. +. Trunk `src/abi/Boj/Catalogue.idr` for how this cell participates in the + 2D matrix. diff --git a/cartridges/domains/cloud/linode-mcp/abi/linode_mcp.ipkg b/cartridges/domains/cloud/linode-mcp/abi/linode_mcp.ipkg new file mode 100644 index 0000000..729f320 --- /dev/null +++ b/cartridges/domains/cloud/linode-mcp/abi/linode_mcp.ipkg @@ -0,0 +1,13 @@ +-- SPDX-License-Identifier: MPL-2.0 +-- Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +package linode_mcp + +version = "0.2.0" +authors = "Jonathan D.A. Jewell" +brief = "Type-safe ABI for Linode MCP cartridge (bearer token, REST API)" + +depends = base + +sourcedir = "." + +modules = LinodeMcp.SafeCloud diff --git a/cartridges/domains/cloud/linode-mcp/adapter/README.adoc b/cartridges/domains/cloud/linode-mcp/adapter/README.adoc new file mode 100644 index 0000000..627e347 --- /dev/null +++ b/cartridges/domains/cloud/linode-mcp/adapter/README.adoc @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += linode-mcp / adapter β€” protocol bridge +:orientation: shallow + +== Purpose + +Exposes this cartridge's FFI over the wire protocols declared in +`cartridge.json`. Dispatches tool invocations to the C-ABI exports at +`../ffi/` and formats responses per protocol. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” depends on the sibling `ffi` target. +| `linode_adapter.zig` | Protocol dispatch (236 lines). Tool catalogue matches `cartridge.json`. +| `SIDELINED-*.v.adoc` | zig predecessor(s). Not built β€” zig banned 2026-04-10. +|=== + +== Invariants + +* **Stateless** β€” all state lives behind the FFI, never in the adapter. +* **Response passthrough** β€” whatever the FFI returns goes back to the wire + unmodified (no embellishment, no silent recovery). +* **Cartridge.json is source of truth** for the tool catalogue; drift between + adapter and manifest is a CI failure. + +== Test/proof surface + +No inline tests at the adapter layer β€” protocol routing is exercised by the +trunk seam tests at `ffi/zig/src/seams.zig`. + +== Read-first + +. `linode_adapter.zig` β€” dispatch function and per-protocol routers. +. `../cartridge.json` β€” the tool manifest the dispatcher mirrors. diff --git a/cartridges/domains/cloud/linode-mcp/adapter/build.zig b/cartridges/domains/cloud/linode-mcp/adapter/build.zig new file mode 100644 index 0000000..cc69ad9 --- /dev/null +++ b/cartridges/domains/cloud/linode-mcp/adapter/build.zig @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// linode-mcp/adapter/build.zig + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const ffi_mod = b.createModule(.{ + .root_source_file = b.path("../ffi/linode_mcp_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + const adapter = b.addExecutable(.{ + .name = "linode_adapter", + .root_source_file = b.path("linode_adapter.zig"), + .target = target, + .optimize = optimize, + }); + adapter.root_module.addImport("linode_mcp_ffi", ffi_mod); + b.installArtifact(adapter); + + const run_artifact = b.addRunArtifact(adapter); + const run_step = b.step("run", "Run the linode-mcp adapter"); + run_step.dependOn(&run_artifact.step); + + const tests = b.addTest(.{ + .root_source_file = b.path("linode_adapter.zig"), + .target = target, + .optimize = optimize, + }); + tests.root_module.addImport("linode_mcp_ffi", ffi_mod); + const run_tests = b.addRunArtifact(tests); + const test_step = b.step("test", "Run linode-mcp adapter tests"); + test_step.dependOn(&run_tests.step); +} diff --git a/cartridges/domains/cloud/linode-mcp/adapter/linode_adapter.zig b/cartridges/domains/cloud/linode-mcp/adapter/linode_adapter.zig new file mode 100644 index 0000000..5138948 --- /dev/null +++ b/cartridges/domains/cloud/linode-mcp/adapter/linode_adapter.zig @@ -0,0 +1,236 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// linode-mcp/adapter/linode_adapter.zig -- Unified three-protocol adapter. +// +// Replaces the banned linode_adapter.v (zig, removed 2026-04-12). +// +// Bridges the Zig FFI (linode_mcp_ffi.zig) to three network protocols: +// REST :9070 POST /tools/<tool> +// gRPC-compat :9071 /LinodeMcpService/<Method> +// GraphQL :9072 POST /graphql { query: "..." } +// +// Linode/Akamai Cloud: instances, volumes, domains, nodebalancers, firewalls +// Tools: +// linode_list_instances +// linode_get_instance +// linode_create_instance +// linode_delete_instance +// linode_boot_instance +// linode_shutdown_instance +// linode_reboot_instance +// linode_list_volumes +// linode_create_volume +// linode_list_domains +// linode_create_domain +// linode_list_nodebalancers +// linode_list_stackscripts +// linode_list_images +// linode_list_regions +// linode_list_firewalls +// linode_create_firewall +// linode_get_account + +const std = @import("std"); +const ffi = @import("linode_mcp_ffi"); + +const REST_PORT: u16 = 9070; +const GRPC_PORT: u16 = 9071; +const GQL_PORT: u16 = 9072; + +const MAX_CONN_BUF: usize = 16 * 1024; + +// ============================================================================ +// JSON response builders +// ============================================================================ + +fn okJson(buf: []u8, msg: []const u8) []u8 { + return std.fmt.bufPrint(buf, + \{{"success":true,"message":"{}"}} + , .{msg}) catch buf[0..0]; +} + +fn errJson(buf: []u8, msg: []const u8) []u8 { + return std.fmt.bufPrint(buf, + \{{"success":false,"error":"{}"}} + , .{msg}) catch buf[0..0]; +} + +fn statusJson(buf: []u8) []u8 { + return std.fmt.bufPrint(buf, + \{{"success":true,"state":"ready","service":"linode-mcp"}} + , .{}) catch buf[0..0]; +} + +// ============================================================================ +// Tool dispatcher +// ============================================================================ + +const Response = struct { status: u16, body: []u8 }; + +fn dispatch(tool: []const u8, body: []const u8, resp: []u8) Response { + _ = body; + if (std.mem.eql(u8, tool, "linode_list_instances")) return .{ .status = 200, .body = okJson(resp, "linode_list_instances forwarded to backend") }; + if (std.mem.eql(u8, tool, "linode_get_instance")) return .{ .status = 200, .body = okJson(resp, "linode_get_instance forwarded to backend") }; + if (std.mem.eql(u8, tool, "linode_create_instance")) return .{ .status = 200, .body = okJson(resp, "linode_create_instance forwarded to backend") }; + if (std.mem.eql(u8, tool, "linode_delete_instance")) return .{ .status = 200, .body = okJson(resp, "linode_delete_instance forwarded to backend") }; + if (std.mem.eql(u8, tool, "linode_boot_instance")) return .{ .status = 200, .body = okJson(resp, "linode_boot_instance forwarded to backend") }; + if (std.mem.eql(u8, tool, "linode_shutdown_instance")) return .{ .status = 200, .body = okJson(resp, "linode_shutdown_instance forwarded to backend") }; + if (std.mem.eql(u8, tool, "linode_reboot_instance")) return .{ .status = 200, .body = okJson(resp, "linode_reboot_instance forwarded to backend") }; + if (std.mem.eql(u8, tool, "linode_list_volumes")) return .{ .status = 200, .body = okJson(resp, "linode_list_volumes forwarded to backend") }; + if (std.mem.eql(u8, tool, "linode_create_volume")) return .{ .status = 200, .body = okJson(resp, "linode_create_volume forwarded to backend") }; + if (std.mem.eql(u8, tool, "linode_list_domains")) return .{ .status = 200, .body = okJson(resp, "linode_list_domains forwarded to backend") }; + if (std.mem.eql(u8, tool, "linode_create_domain")) return .{ .status = 200, .body = okJson(resp, "linode_create_domain forwarded to backend") }; + if (std.mem.eql(u8, tool, "linode_list_nodebalancers")) return .{ .status = 200, .body = okJson(resp, "linode_list_nodebalancers forwarded to backend") }; + if (std.mem.eql(u8, tool, "linode_list_stackscripts")) return .{ .status = 200, .body = okJson(resp, "linode_list_stackscripts forwarded to backend") }; + if (std.mem.eql(u8, tool, "linode_list_images")) return .{ .status = 200, .body = okJson(resp, "linode_list_images forwarded to backend") }; + if (std.mem.eql(u8, tool, "linode_list_regions")) return .{ .status = 200, .body = okJson(resp, "linode_list_regions forwarded to backend") }; + if (std.mem.eql(u8, tool, "linode_list_firewalls")) return .{ .status = 200, .body = okJson(resp, "linode_list_firewalls forwarded to backend") }; + if (std.mem.eql(u8, tool, "linode_create_firewall")) return .{ .status = 200, .body = okJson(resp, "linode_create_firewall forwarded to backend") }; + if (std.mem.eql(u8, tool, "linode_get_account")) return .{ .status = 200, .body = okJson(resp, "linode_get_account forwarded to backend") }; + if (std.mem.eql(u8, tool, "status") or std.mem.eql(u8, tool, "health")) + return .{ .status = 200, .body = statusJson(resp) }; + return .{ .status = 404, .body = errJson(resp, "Unknown tool") }; +} + +// ============================================================================ +// REST handler +// ============================================================================ + +fn dispatchRest(path: []const u8, body: []const u8, resp: []u8) Response { + if (std.mem.startsWith(u8, path, "/tools/")) { + return dispatch(path["/tools/".len..], body, resp); + } + if (std.mem.eql(u8, path, "/status") or std.mem.eql(u8, path, "/health")) { + return .{ .status = 200, .body = statusJson(resp) }; + } + return .{ .status = 404, .body = errJson(resp, "Not found") }; +} + +// ============================================================================ +// gRPC-compat handler +// ============================================================================ + +fn dispatchGrpc(path: []const u8, body: []const u8, resp: []u8) Response { + const prefix = "/LinodeMcpService/"; + if (!std.mem.startsWith(u8, path, prefix)) + return .{ .status = 404, .body = errJson(resp, "Not a recognized gRPC path") }; + const method = path[prefix.len..]; + const tool = blk: { + if (std.mem.eql(u8, method, "LinodeListInstances")) break :blk "linode_list_instances"; + if (std.mem.eql(u8, method, "LinodeGetInstance")) break :blk "linode_get_instance"; + if (std.mem.eql(u8, method, "LinodeCreateInstance")) break :blk "linode_create_instance"; + if (std.mem.eql(u8, method, "LinodeDeleteInstance")) break :blk "linode_delete_instance"; + if (std.mem.eql(u8, method, "LinodeBootInstance")) break :blk "linode_boot_instance"; + if (std.mem.eql(u8, method, "LinodeShutdownInstance")) break :blk "linode_shutdown_instance"; + if (std.mem.eql(u8, method, "LinodeRebootInstance")) break :blk "linode_reboot_instance"; + if (std.mem.eql(u8, method, "LinodeListVolumes")) break :blk "linode_list_volumes"; + if (std.mem.eql(u8, method, "LinodeCreateVolume")) break :blk "linode_create_volume"; + if (std.mem.eql(u8, method, "LinodeListDomains")) break :blk "linode_list_domains"; + if (std.mem.eql(u8, method, "LinodeCreateDomain")) break :blk "linode_create_domain"; + if (std.mem.eql(u8, method, "LinodeListNodebalancers")) break :blk "linode_list_nodebalancers"; + if (std.mem.eql(u8, method, "LinodeListStackscripts")) break :blk "linode_list_stackscripts"; + if (std.mem.eql(u8, method, "LinodeListImages")) break :blk "linode_list_images"; + if (std.mem.eql(u8, method, "LinodeListRegions")) break :blk "linode_list_regions"; + if (std.mem.eql(u8, method, "LinodeListFirewalls")) break :blk "linode_list_firewalls"; + if (std.mem.eql(u8, method, "LinodeCreateFirewall")) break :blk "linode_create_firewall"; + if (std.mem.eql(u8, method, "LinodeGetAccount")) break :blk "linode_get_account"; + return .{ .status = 404, .body = errJson(resp, "Unknown gRPC method") }; + }; + return dispatch(tool, body, resp); +} + +// ============================================================================ +// GraphQL handler +// ============================================================================ + +fn dispatchGraphql(body: []const u8, resp: []u8) Response { + if (std.mem.indexOf(u8, body, "__schema") != null) + return .{ .status = 200, .body = okJson(resp, "schema introspection not yet supported") }; + if (std.mem.indexOf(u8, body, "list_instances") != null) return dispatch("linode_list_instances", body, resp); + if (std.mem.indexOf(u8, body, "get_instance") != null) return dispatch("linode_get_instance", body, resp); + if (std.mem.indexOf(u8, body, "create_instance") != null) return dispatch("linode_create_instance", body, resp); + if (std.mem.indexOf(u8, body, "delete_instance") != null) return dispatch("linode_delete_instance", body, resp); + if (std.mem.indexOf(u8, body, "boot_instance") != null) return dispatch("linode_boot_instance", body, resp); + if (std.mem.indexOf(u8, body, "shutdown_instance") != null) return dispatch("linode_shutdown_instance", body, resp); + if (std.mem.indexOf(u8, body, "reboot_instance") != null) return dispatch("linode_reboot_instance", body, resp); + if (std.mem.indexOf(u8, body, "list_volumes") != null) return dispatch("linode_list_volumes", body, resp); + if (std.mem.indexOf(u8, body, "create_volume") != null) return dispatch("linode_create_volume", body, resp); + if (std.mem.indexOf(u8, body, "list_domains") != null) return dispatch("linode_list_domains", body, resp); + if (std.mem.indexOf(u8, body, "create_domain") != null) return dispatch("linode_create_domain", body, resp); + if (std.mem.indexOf(u8, body, "list_nodebalancers") != null) return dispatch("linode_list_nodebalancers", body, resp); + if (std.mem.indexOf(u8, body, "list_stackscripts") != null) return dispatch("linode_list_stackscripts", body, resp); + if (std.mem.indexOf(u8, body, "list_images") != null) return dispatch("linode_list_images", body, resp); + if (std.mem.indexOf(u8, body, "list_regions") != null) return dispatch("linode_list_regions", body, resp); + if (std.mem.indexOf(u8, body, "list_firewalls") != null) return dispatch("linode_list_firewalls", body, resp); + if (std.mem.indexOf(u8, body, "create_firewall") != null) return dispatch("linode_create_firewall", body, resp); + if (std.mem.indexOf(u8, body, "get_account") != null) return dispatch("linode_get_account", body, resp); + return .{ .status = 200, .body = errJson(resp, "Unrecognised GraphQL operation") }; +} + +// ============================================================================ +// HTTP/1.1 connection handler +// ============================================================================ + +const Protocol = enum { rest, grpc, graphql }; + +fn handleConnection(conn: std.net.Server.Connection, proto: Protocol) void { + defer conn.stream.close(); + var in_buf: [MAX_CONN_BUF]u8 = undefined; + const n = conn.stream.read(&in_buf) catch return; + const req = in_buf[0..n]; + + var path: []const u8 = "/"; + var body: []const u8 = ""; + if (n > 4) { + const line_end = std.mem.indexOf(u8, req, "\r\n") orelse req.len; + const first_line = req[0..line_end]; + const sp1 = std.mem.indexOfScalar(u8, first_line, ' ') orelse 0; + const rest_of = first_line[sp1 + 1 ..]; + const sp2 = std.mem.indexOfScalar(u8, rest_of, ' ') orelse rest_of.len; + path = rest_of[0..sp2]; + const body_sep = std.mem.indexOf(u8, req, "\r\n\r\n") orelse n; + body = req[@min(body_sep + 4, n)..]; + } + + var resp_buf: [MAX_CONN_BUF]u8 = undefined; + const result = switch (proto) { + .rest => dispatchRest(path, body, &resp_buf), + .grpc => dispatchGrpc(path, body, &resp_buf), + .graphql => dispatchGraphql(body, &resp_buf), + }; + + const content_type = switch (proto) { + .rest => "application/json", + .grpc => "application/grpc+json", + .graphql => "application/json", + }; + + var hdr_buf: [256]u8 = undefined; + const hdr = std.fmt.bufPrint(&hdr_buf, + "HTTP/1.1 {d} OK\r\nContent-Type: {s}\r\nContent-Length: {d}\r\nConnection: close\r\n\r\n", + .{ result.status, content_type, result.body.len }, + ) catch return; + _ = conn.stream.writeAll(hdr) catch return; + _ = conn.stream.writeAll(result.body) catch return; +} + +fn listenLoop(port: u16, proto: Protocol) void { + const addr = std.net.Address.initIp4(.{ 127, 0, 0, 1 }, port) catch return; + var server = addr.listen(.{ .reuse_address = true }) catch return; + defer server.deinit(); + while (true) { + const conn = server.accept() catch continue; + handleConnection(conn, proto); + } +} + +pub fn main() !void { + ffi.linode_init(); + const rest_thread = try std.Thread.spawn(.{}, listenLoop, .{ REST_PORT, Protocol.rest }); + const grpc_thread = try std.Thread.spawn(.{}, listenLoop, .{ GRPC_PORT, Protocol.grpc }); + const gql_thread = try std.Thread.spawn(.{}, listenLoop, .{ GQL_PORT, Protocol.graphql }); + rest_thread.join(); + grpc_thread.join(); + gql_thread.join(); +} diff --git a/cartridges/domains/cloud/linode-mcp/benchmarks/quick-bench.sh b/cartridges/domains/cloud/linode-mcp/benchmarks/quick-bench.sh new file mode 100755 index 0000000..136859f --- /dev/null +++ b/cartridges/domains/cloud/linode-mcp/benchmarks/quick-bench.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Benchmarks for linode-mcp cartridge. +set -euo pipefail + +echo "=== linode-mcp benchmarks ===" + +cd "$(dirname "${BASH_SOURCE[0]}")/../ffi" + +# Build with ReleaseFast for benchmarking +zig build -Doptimize=ReleaseFast 2>&1 + +echo "Session open/close cycle (1000 iterations):" +time for i in $(seq 1 1000); do + # This would call the FFI benchmark binary + true +done + +echo "" +echo "Benchmark placeholder β€” implement real benchmarks in Zig test blocks" +echo "or via the zig adapter HTTP benchmark tool." diff --git a/cartridges/domains/cloud/linode-mcp/cartridge.json b/cartridges/domains/cloud/linode-mcp/cartridge.json new file mode 100644 index 0000000..4733df8 --- /dev/null +++ b/cartridges/domains/cloud/linode-mcp/cartridge.json @@ -0,0 +1,466 @@ +{ + "$schema": "https://boj.dev/schemas/cartridge/v1.json", + "spdx": "MPL-2.0", + "copyright": "Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath)", + "name": "linode-mcp", + "version": "0.2.0", + "description": "Linode/Akamai cloud infrastructure cartridge -- instances, volumes, domains, NodeBalancers, StackScripts, images, regions, firewalls, and account management", + "domain": "Cloud", + "tier": "Ayo", + "protocols": [ + "MCP", + "REST" + ], + "auth": { + "method": "bearer_token", + "env_var": "LINODE_TOKEN", + "credential_source": "vault-mcp" + }, + "api": { + "base_url": "https://api.linode.com/v4", + "rate_limit_per_2min": 800, + "content_type": "application/json" + }, + "tools": [ + { + "name": "linode_list_instances", + "description": "List all Linode compute instances in the account", + "inputSchema": { + "type": "object", + "properties": { + "page": { + "type": "number", + "description": "Page number (default 1)" + }, + "page_size": { + "type": "number", + "description": "Results per page (default 100, max 500)" + } + } + } + }, + { + "name": "linode_get_instance", + "description": "Get details of a specific Linode instance by ID", + "inputSchema": { + "type": "object", + "properties": { + "linode_id": { + "type": "number", + "description": "Numeric Linode instance ID" + } + }, + "required": [ + "linode_id" + ] + } + }, + { + "name": "linode_create_instance", + "description": "Create a new Linode compute instance", + "inputSchema": { + "type": "object", + "properties": { + "label": { + "type": "string", + "description": "Linode label (3-64 characters, alphanumeric, dashes, underscores)" + }, + "region": { + "type": "string", + "description": "Region ID (e.g. us-east, us-central, eu-west, ap-south, ap-southeast, ca-central)" + }, + "type": { + "type": "string", + "description": "Instance type (e.g. g6-nanode-1, g6-standard-2, g6-dedicated-4, g7-highmem-2)" + }, + "image": { + "type": "string", + "description": "Image ID (e.g. linode/ubuntu24.04, linode/debian12, linode/fedora41, linode/alpine3.20)" + }, + "root_pass": { + "type": "string", + "description": "Root password for the Linode (if no authorized keys)" + }, + "authorized_keys": { + "type": "array", + "items": { + "type": "string" + }, + "description": "SSH public keys to install" + }, + "authorized_users": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Linode usernames whose keys to install" + }, + "booted": { + "type": "boolean", + "description": "Boot on creation (default true)" + }, + "backups_enabled": { + "type": "boolean", + "description": "Enable backup service" + }, + "swap_size": { + "type": "number", + "description": "Swap size in MB (default 512)" + }, + "private_ip": { + "type": "boolean", + "description": "Add private IP" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Tags to apply" + }, + "group": { + "type": "string", + "description": "Display group (deprecated but supported)" + }, + "stackscript_id": { + "type": "number", + "description": "StackScript ID to run on first boot" + }, + "stackscript_data": { + "type": "object", + "description": "UDF data for the StackScript" + } + }, + "required": [ + "label", + "region", + "type" + ] + } + }, + { + "name": "linode_delete_instance", + "description": "Delete a Linode compute instance (irreversible)", + "inputSchema": { + "type": "object", + "properties": { + "linode_id": { + "type": "number", + "description": "Numeric Linode instance ID to delete" + } + }, + "required": [ + "linode_id" + ] + } + }, + { + "name": "linode_boot_instance", + "description": "Boot a Linode instance that is powered off", + "inputSchema": { + "type": "object", + "properties": { + "linode_id": { + "type": "number", + "description": "Linode instance ID" + }, + "config_id": { + "type": "number", + "description": "Configuration profile ID to boot with (optional)" + } + }, + "required": [ + "linode_id" + ] + } + }, + { + "name": "linode_shutdown_instance", + "description": "Gracefully shut down a running Linode instance", + "inputSchema": { + "type": "object", + "properties": { + "linode_id": { + "type": "number", + "description": "Linode instance ID" + } + }, + "required": [ + "linode_id" + ] + } + }, + { + "name": "linode_reboot_instance", + "description": "Reboot a Linode instance", + "inputSchema": { + "type": "object", + "properties": { + "linode_id": { + "type": "number", + "description": "Linode instance ID" + }, + "config_id": { + "type": "number", + "description": "Configuration profile ID to boot with (optional)" + } + }, + "required": [ + "linode_id" + ] + } + }, + { + "name": "linode_list_volumes", + "description": "List all block storage volumes in the Linode account", + "inputSchema": { + "type": "object", + "properties": { + "page": { + "type": "number", + "description": "Page number" + }, + "page_size": { + "type": "number", + "description": "Results per page" + } + } + } + }, + { + "name": "linode_create_volume", + "description": "Create a new block storage volume in Linode", + "inputSchema": { + "type": "object", + "properties": { + "label": { + "type": "string", + "description": "Volume label" + }, + "size": { + "type": "number", + "description": "Size in GB (min 10, max 10240)" + }, + "region": { + "type": "string", + "description": "Region ID (required if not attaching to a Linode)" + }, + "linode_id": { + "type": "number", + "description": "Linode ID to attach to (optional)" + }, + "config_id": { + "type": "number", + "description": "Config profile ID to attach to (optional)" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Tags to apply" + } + }, + "required": [ + "label", + "size" + ] + } + }, + { + "name": "linode_list_domains", + "description": "List all DNS domains managed in Linode", + "inputSchema": { + "type": "object", + "properties": { + "page": { + "type": "number", + "description": "Page number" + }, + "page_size": { + "type": "number", + "description": "Results per page" + } + } + } + }, + { + "name": "linode_create_domain", + "description": "Create a new DNS domain in Linode", + "inputSchema": { + "type": "object", + "properties": { + "domain": { + "type": "string", + "description": "Domain name (e.g. example.com)" + }, + "type": { + "type": "string", + "description": "Domain type: master or slave" + }, + "soa_email": { + "type": "string", + "description": "SOA email address (required for master)" + }, + "master_ips": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Master IPs (required for slave)" + }, + "group": { + "type": "string", + "description": "Display group" + }, + "description": { + "type": "string", + "description": "Domain description" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Tags to apply" + } + }, + "required": [ + "domain", + "type" + ] + } + }, + { + "name": "linode_list_nodebalancers", + "description": "List all NodeBalancers in the Linode account", + "inputSchema": { + "type": "object", + "properties": { + "page": { + "type": "number", + "description": "Page number" + }, + "page_size": { + "type": "number", + "description": "Results per page" + } + } + } + }, + { + "name": "linode_list_stackscripts", + "description": "List StackScripts (private and public) available to the Linode account", + "inputSchema": { + "type": "object", + "properties": { + "page": { + "type": "number", + "description": "Page number" + }, + "page_size": { + "type": "number", + "description": "Results per page" + }, + "mine": { + "type": "boolean", + "description": "Show only your StackScripts (default false)" + } + } + } + }, + { + "name": "linode_list_images", + "description": "List available images (official distributions and custom images) in Linode", + "inputSchema": { + "type": "object", + "properties": { + "page": { + "type": "number", + "description": "Page number" + }, + "page_size": { + "type": "number", + "description": "Results per page" + } + } + } + }, + { + "name": "linode_list_regions", + "description": "List all available Linode/Akamai regions with capability information", + "inputSchema": { + "type": "object", + "properties": {} + } + }, + { + "name": "linode_list_firewalls", + "description": "List all Cloud Firewalls in the Linode account", + "inputSchema": { + "type": "object", + "properties": { + "page": { + "type": "number", + "description": "Page number" + }, + "page_size": { + "type": "number", + "description": "Results per page" + } + } + } + }, + { + "name": "linode_create_firewall", + "description": "Create a new Cloud Firewall with inbound and outbound rules", + "inputSchema": { + "type": "object", + "properties": { + "label": { + "type": "string", + "description": "Firewall label" + }, + "rules": { + "type": "object", + "description": "Rules object with inbound and outbound arrays" + }, + "devices": { + "type": "object", + "description": "Devices to apply to (linodes: [id], nodebalancers: [id])" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Tags to apply" + } + }, + "required": [ + "label", + "rules" + ] + } + }, + { + "name": "linode_get_account", + "description": "Get Linode account information (email, company, address, balance, credit)", + "inputSchema": { + "type": "object", + "properties": {} + } + } + ], + "ffi": { + "so_path": "ffi/zig-out/lib/liblinode_mcp.so", + "abi_version": "ADR-0006", + "symbols": [ + "boj_cartridge_init", + "boj_cartridge_deinit", + "boj_cartridge_name", + "boj_cartridge_version", + "boj_cartridge_invoke" + ] + } +} diff --git a/cartridges/domains/cloud/linode-mcp/ffi/README.adoc b/cartridges/domains/cloud/linode-mcp/ffi/README.adoc new file mode 100644 index 0000000..e392530 --- /dev/null +++ b/cartridges/domains/cloud/linode-mcp/ffi/README.adoc @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += linode-mcp / ffi β€” Zig FFI layer +:orientation: shallow + +== Purpose + +C-ABI exports bridging this cartridge's Idris2 ABI to the runtime. Consumed by +the adapter for protocol dispatch and by the trunk loader for mount. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” produces `zig-out/lib/liblinode.so`. +| `linode_mcp_ffi.zig` | C-ABI exports (10 exports, 6 inline tests, 319 lines). +|=== + +== Invariants + +* **Null-pointer rejection** on `[*c]const u8` arguments before any + dereference. +* **Negative return values** carry documented error meanings; zero is + success (matching the trunk error-code convention). +* **No allocator leaks** β€” every allocation path pairs with `defer free`. + +== Test/proof surface + +6 inline `test "..."` block(s) in `linode_mcp_ffi.zig`. Run with +`cd ffi && zig build test`. + +== Read-first + +. `linode_mcp_ffi.zig` β€” C-ABI signatures and guards. +. Trunk `ffi/zig/src/catalogue.zig` for how this `.so` is loaded. diff --git a/cartridges/domains/cloud/linode-mcp/ffi/build.zig b/cartridges/domains/cloud/linode-mcp/ffi/build.zig new file mode 100644 index 0000000..cef6662 --- /dev/null +++ b/cartridges/domains/cloud/linode-mcp/ffi/build.zig @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// build.zig β€” Build configuration for linode-mcp FFI shared library. + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + // Module + // Shared ADR-0006 invoke-shim module (relative path up to boj-server trunk). + + const shim_mod = b.addModule("cartridge_shim", .{ + + .root_source_file = b.path("../../../ffi/zig/src/cartridge_shim.zig"), + + .target = target, + + .optimize = optimize, + + }); + + const ffi_mod = b.addModule("linode_mcp", .{ + .root_source_file = b.path("linode_mcp_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + ffi_mod.addImport("cartridge_shim", shim_mod); + + // Shared library + const lib = b.addLibrary(.{ + .name = "linode_mcp", + .root_module = ffi_mod, + .linkage = .dynamic, + }); + lib.linkLibC(); + b.installArtifact(lib); + + // Tests + const tests = b.addTest(.{ + .root_module = ffi_mod, + }); + tests.linkLibC(); + + const run_tests = b.addRunArtifact(tests); + const test_step = b.step("test", "Run Linode MCP FFI tests"); + test_step.dependOn(&run_tests.step); +} diff --git a/cartridges/domains/cloud/linode-mcp/ffi/cartridge_shim.zig b/cartridges/domains/cloud/linode-mcp/ffi/cartridge_shim.zig new file mode 100644 index 0000000..0e399db --- /dev/null +++ b/cartridges/domains/cloud/linode-mcp/ffi/cartridge_shim.zig @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// cartridge_shim.zig β€” Shared helpers for the ADR-0006 five-symbol +// cartridge ABI (`boj_cartridge_init / deinit / name / version / invoke`). +// +// The shim centralises the seven-code return convention, NUL-argument +// guards, tool-name comparison, and the buffer-too-small path so each +// cartridge's `boj_cartridge_invoke` can stay short β€” typically a tool +// table plus `shim.writeResult(...)`. +// +// Cartridges import this file by relative path (no build-graph change +// needed). Example: +// +// const shim = @import("../../../ffi/zig/src/cartridge_shim.zig"); +// +// export fn boj_cartridge_invoke( +// tool_name: [*c]const u8, +// json_args: [*c]const u8, +// out_buf: [*c]u8, +// in_out_len: [*c]usize, +// ) callconv(.c) i32 { +// _ = json_args; +// if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; +// const body = if (shim.toolIs(tool_name, "foo")) "{\"result\":{}}" +// else return shim.RC_UNKNOWN_TOOL; +// return shim.writeResult(out_buf, in_out_len, body); +// } + +const std = @import("std"); + +// ── Return codes (ADR-0006 Β§Return codes) ──────────────────────────── +// +// Frozen by ADR-0006. New failure modes compose these via the error +// JSON body β€” the integer surface does not grow without a follow-up ADR. + +pub const RC_SUCCESS: i32 = 0; +pub const RC_UNKNOWN_TOOL: i32 = -1; +pub const RC_BAD_ARGS: i32 = -2; +pub const RC_BUFFER_TOO_SMALL: i32 = -3; +pub const RC_RUNTIME_ERROR: i32 = -4; +pub const RC_PANIC: i32 = -5; +pub const RC_AUTH_DENIED: i32 = -6; + +// ── Invoke-path helpers ────────────────────────────────────────────── + +/// True if any of the three mandatory `boj_cartridge_invoke` output-path +/// pointers is null. Use at the top of every invoke to short-circuit to +/// `RC_BAD_ARGS`. +pub fn invokeArgsNull( + tool_name: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) bool { + return tool_name == null or out_buf == null or in_out_len == null; +} + +/// Compare a C-NUL-terminated tool-name pointer against a Zig string +/// literal. Caller must have already verified `tool_name` is non-null +/// (usually via `invokeArgsNull`). +pub fn toolIs(tool_name: [*c]const u8, expected: []const u8) bool { + const s = std.mem.span(@as([*:0]const u8, @ptrCast(tool_name))); + return std.mem.eql(u8, s, expected); +} + +/// Copy `body` into `out_buf[0..*in_out_len]` (as a capacity) and update +/// `*in_out_len` to the number of bytes written. Returns `RC_SUCCESS`. +/// +/// If `body.len` exceeds the current capacity stored in `*in_out_len`, +/// sets `*in_out_len` to the required size and returns +/// `RC_BUFFER_TOO_SMALL` β€” the caller is then expected to re-allocate +/// and retry, per ADR-0006 Β§Memory ownership. +/// +/// Caller must have already verified that `out_buf` and `in_out_len` +/// are non-null. +pub fn writeResult( + out_buf: [*c]u8, + in_out_len: [*c]usize, + body: []const u8, +) i32 { + const cap = in_out_len.*; + if (body.len > cap) { + in_out_len.* = body.len; + return RC_BUFFER_TOO_SMALL; + } + @memcpy(out_buf[0..body.len], body); + in_out_len.* = body.len; + return RC_SUCCESS; +} + +// ── Tests ──────────────────────────────────────────────────────────── + +test "writeResult: body fits, writes and sets length" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); + try std.testing.expectEqualStrings("hello", buf[0..len]); +} + +test "writeResult: too small returns -3 and sets required length" { + var buf: [2]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_BUFFER_TOO_SMALL, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: exact-fit succeeds" { + var buf: [5]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: empty body" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, ""); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 0), len); +} + +test "toolIs: matches and rejects" { + const name: [*:0]const u8 = "foo"; + try std.testing.expect(toolIs(@ptrCast(name), "foo")); + try std.testing.expect(!toolIs(@ptrCast(name), "bar")); + try std.testing.expect(!toolIs(@ptrCast(name), "foobar")); + try std.testing.expect(!toolIs(@ptrCast(name), "fo")); +} + +test "invokeArgsNull: detects each null slot" { + var buf: [4]u8 = undefined; + var len: usize = 4; + const name: [*:0]const u8 = "x"; + try std.testing.expect(!invokeArgsNull(@ptrCast(name), &buf, &len)); + try std.testing.expect(invokeArgsNull(null, &buf, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), null, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), &buf, null)); +} diff --git a/cartridges/domains/cloud/linode-mcp/ffi/linode_mcp_ffi.zig b/cartridges/domains/cloud/linode-mcp/ffi/linode_mcp_ffi.zig new file mode 100644 index 0000000..54fcc6b --- /dev/null +++ b/cartridges/domains/cloud/linode-mcp/ffi/linode_mcp_ffi.zig @@ -0,0 +1,452 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// linode_mcp_ffi.zig β€” C-ABI FFI for Linode MCP cartridge. +// +// Implements the auth state machine defined in the Idris2 ABI layer. +// Bearer token authentication, 800 req/2min rate limit tracking. +// Thread-safe via std.Thread.Mutex. No heap allocations for results. + +const std = @import("std"); + +// --------------------------------------------------------------------------- +// Auth state machine (matches Idris2 ABI: Unauthenticated/Authenticated/RateLimited/Error) +// --------------------------------------------------------------------------- + +pub const AuthState = enum(c_int) { + unauthenticated = 0, + authenticated = 1, + rate_limited = 2, + err = 3, +}; + +/// Validate whether a transition between two auth states is permitted. +fn isValidTransition(from: AuthState, to: AuthState) bool { + return switch (from) { + .unauthenticated => to == .authenticated, + .authenticated => to == .rate_limited or to == .err or to == .unauthenticated, + .rate_limited => to == .authenticated or to == .err, + .err => to == .authenticated or to == .unauthenticated, + }; +} + +// --------------------------------------------------------------------------- +// Linode action codes (matches Idris2 LinodeAction) +// --------------------------------------------------------------------------- + +pub const LinodeAction = enum(c_int) { + list_instances = 0, + get_instance = 1, + create_instance = 2, + delete_instance = 3, + boot = 4, + shutdown = 5, + reboot = 6, + list_volumes = 7, + create_volume = 8, + list_domains = 9, + create_domain = 10, + list_nodebalancers = 11, + list_stackscripts = 12, + list_images = 13, + list_regions = 14, + get_account = 15, +}; + +/// Returns true if the action mutates remote state. +fn isDestructiveAction(action: LinodeAction) bool { + return switch (action) { + .create_instance, .delete_instance, .boot, .shutdown, .reboot, .create_volume, .create_domain => true, + .list_instances, .get_instance, .list_volumes, .list_domains, .list_nodebalancers, .list_stackscripts, .list_images, .list_regions, .get_account => false, + }; +} + +// --------------------------------------------------------------------------- +// Session slots (thread-safe, fixed-size pool) +// --------------------------------------------------------------------------- + +const MAX_SESSIONS: usize = 16; +const TOKEN_BUF_SIZE: usize = 256; + +/// Rate limit: 800 requests per 2 minutes for Linode API. +const RATE_LIMIT_PER_WINDOW: u32 = 800; + +const SessionSlot = struct { + active: bool = false, + state: AuthState = .unauthenticated, + token_buf: [TOKEN_BUF_SIZE]u8 = .{0} ** TOKEN_BUF_SIZE, + token_len: usize = 0, + requests_remaining: u32 = RATE_LIMIT_PER_WINDOW, + last_action: c_int = -1, +}; + +var sessions: [MAX_SESSIONS]SessionSlot = .{SessionSlot{}} ** MAX_SESSIONS; +var mutex: std.Thread.Mutex = .{}; + +// --------------------------------------------------------------------------- +// C-ABI exports +// --------------------------------------------------------------------------- + +/// Check if a state transition is valid. Returns 1 (valid) or 0 (invalid). +pub export fn linode_mcp_can_transition(from: c_int, to: c_int) c_int { + const f = std.meta.intToEnum(AuthState, from) catch return 0; + const t = std.meta.intToEnum(AuthState, to) catch return 0; + return if (isValidTransition(f, t)) 1 else 0; +} + +/// Authenticate a session with a bearer token. Returns slot index (>= 0) or error (< 0). +/// Error codes: -1 = no free slots, -2 = null/empty token. +pub export fn linode_mcp_authenticate(token_ptr: ?[*]const u8, token_len: c_int) c_int { + const ptr = token_ptr orelse return -2; + const len: usize = std.math.cast(usize, token_len) orelse return -2; + if (len == 0 or len > TOKEN_BUF_SIZE) return -2; + + mutex.lock(); + defer mutex.unlock(); + + for (&sessions, 0..) |*slot, idx| { + if (!slot.active) { + slot.active = true; + slot.state = .authenticated; + @memcpy(slot.token_buf[0..len], ptr[0..len]); + slot.token_len = len; + slot.requests_remaining = RATE_LIMIT_PER_WINDOW; + slot.last_action = -1; + return @intCast(idx); + } + } + return -1; +} + +/// Close/logout a session. Returns 0 on success, -1 if invalid slot, -2 if bad transition. +pub export fn linode_mcp_session_close(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + if (!isValidTransition(slot.state, .unauthenticated)) return -2; + + sessions[idx] = SessionSlot{}; + return 0; +} + +/// Get the current auth state of a session. Returns state int or -1 if invalid slot. +pub export fn linode_mcp_session_state(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + return @intFromEnum(slot.state); +} + +/// Execute an action on a session. Returns 0 on success. +/// Error codes: -1 = invalid slot, -2 = not authenticated, -3 = rate limited, -4 = invalid action. +pub export fn linode_mcp_execute_action(slot_idx: c_int, action_code: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + if (slot.state != .authenticated) return -2; + + const action = std.meta.intToEnum(LinodeAction, action_code) catch return -4; + _ = isDestructiveAction(action); + + if (slot.requests_remaining == 0) { + sessions[idx].state = .rate_limited; + return -3; + } + + sessions[idx].requests_remaining -= 1; + sessions[idx].last_action = action_code; + return 0; +} + +/// Get remaining rate limit for a session. Returns count or -1 if invalid. +pub export fn linode_mcp_rate_limit_remaining(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + return @intCast(slot.requests_remaining); +} + +/// Reset rate limit (called when the 2-minute window resets). Returns 0 on success. +pub export fn linode_mcp_rate_limit_reset(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + + sessions[idx].requests_remaining = RATE_LIMIT_PER_WINDOW; + if (slot.state == .rate_limited) { + sessions[idx].state = .authenticated; + } + return 0; +} + +/// Signal an error on a session. Returns 0 on success. +pub export fn linode_mcp_signal_error(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + if (!isValidTransition(slot.state, .err)) return -2; + + sessions[idx].state = .err; + return 0; +} + +/// Recover from error back to authenticated. Returns 0 on success. +pub export fn linode_mcp_recover(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + if (slot.state != .err) return -2; + + sessions[idx].state = .authenticated; + return 0; +} + +/// Reset all sessions (test/debug use only). +pub export fn linode_mcp_reset() void { + mutex.lock(); + defer mutex.unlock(); + sessions = .{SessionSlot{}} ** MAX_SESSIONS; +} + +// --------------------------------------------------------------------------- +// ═══════════════════════════════════════════════════════════════════════ +// Standard ABI (ADR-0005 four symbols + ADR-0006 invoke) +// ═══════════════════════════════════════════════════════════════════════ + +const shim = @import("cartridge_shim.zig"); + +const CARTRIDGE_NAME_PTR: [*:0]const u8 = "linode-mcp"; +const CARTRIDGE_VERSION_PTR: [*:0]const u8 = "0.1.0"; + +export fn boj_cartridge_init() callconv(.c) c_int { + return 0; +} + +export fn boj_cartridge_deinit() callconv(.c) void {} + +export fn boj_cartridge_name() callconv(.c) [*:0]const u8 { + return CARTRIDGE_NAME_PTR; +} + +export fn boj_cartridge_version() callconv(.c) [*:0]const u8 { + return CARTRIDGE_VERSION_PTR; +} + +/// Dispatch the cartridge.json MCP tools. Grade D Alpha stubs. +export fn boj_cartridge_invoke( + tool_name: [*c]const u8, + json_args: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) callconv(.c) i32 { + _ = json_args; + if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; + + const body: []const u8 = if (shim.toolIs(tool_name, "linode_list_instances")) + "{\"result\":{\"items\":[],\"count\":0,\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "linode_get_instance")) + "{\"result\":{\"metadata\":{},\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "linode_create_instance")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "linode_delete_instance")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "linode_boot_instance")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "linode_shutdown_instance")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "linode_reboot_instance")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "linode_list_volumes")) + "{\"result\":{\"items\":[],\"count\":0,\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "linode_create_volume")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "linode_list_domains")) + "{\"result\":{\"items\":[],\"count\":0,\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "linode_create_domain")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "linode_list_nodebalancers")) + "{\"result\":{\"items\":[],\"count\":0,\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "linode_list_stackscripts")) + "{\"result\":{\"items\":[],\"count\":0,\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "linode_list_images")) + "{\"result\":{\"items\":[],\"count\":0,\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "linode_list_regions")) + "{\"result\":{\"items\":[],\"count\":0,\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "linode_list_firewalls")) + "{\"result\":{\"items\":[],\"count\":0,\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "linode_create_firewall")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "linode_get_account")) + "{\"result\":{\"metadata\":{},\"status\":\"stub\"}}" +else + return shim.RC_UNKNOWN_TOOL; + + return shim.writeResult(out_buf, in_out_len, body); +} + +// Tests +// --------------------------------------------------------------------------- + +test "authenticate and close lifecycle" { + linode_mcp_reset(); + + const token = "lin_test_token_abc123"; + const slot = linode_mcp_authenticate(token.ptr, @intCast(token.len)); + try std.testing.expect(slot >= 0); + + // Should be authenticated + try std.testing.expectEqual(@as(c_int, 1), linode_mcp_session_state(slot)); + + // Rate limit should be full + try std.testing.expectEqual(@as(c_int, 800), linode_mcp_rate_limit_remaining(slot)); + + // Close session + try std.testing.expectEqual(@as(c_int, 0), linode_mcp_session_close(slot)); +} + +test "execute action decrements rate limit" { + linode_mcp_reset(); + + const token = "lin_test"; + const slot = linode_mcp_authenticate(token.ptr, @intCast(token.len)); + try std.testing.expect(slot >= 0); + + // Execute ListInstances (action 0) + try std.testing.expectEqual(@as(c_int, 0), linode_mcp_execute_action(slot, 0)); + try std.testing.expectEqual(@as(c_int, 799), linode_mcp_rate_limit_remaining(slot)); + + // Execute GetAccount (action 15) + try std.testing.expectEqual(@as(c_int, 0), linode_mcp_execute_action(slot, 15)); + try std.testing.expectEqual(@as(c_int, 798), linode_mcp_rate_limit_remaining(slot)); +} + +test "invalid transitions rejected" { + linode_mcp_reset(); + + // Cannot go from unauthenticated to error + try std.testing.expectEqual(@as(c_int, 0), linode_mcp_can_transition(0, 3)); + + // Can go from authenticated to rate_limited + try std.testing.expectEqual(@as(c_int, 1), linode_mcp_can_transition(1, 2)); +} + +test "error and recovery" { + linode_mcp_reset(); + + const token = "lin_test"; + const slot = linode_mcp_authenticate(token.ptr, @intCast(token.len)); + try std.testing.expect(slot >= 0); + + // Signal error + try std.testing.expectEqual(@as(c_int, 0), linode_mcp_signal_error(slot)); + try std.testing.expectEqual(@as(c_int, 3), linode_mcp_session_state(slot)); + + // Recover + try std.testing.expectEqual(@as(c_int, 0), linode_mcp_recover(slot)); + try std.testing.expectEqual(@as(c_int, 1), linode_mcp_session_state(slot)); +} + +test "null token rejected" { + linode_mcp_reset(); + try std.testing.expectEqual(@as(c_int, -2), linode_mcp_authenticate(null, 0)); +} + +test "slot exhaustion" { + linode_mcp_reset(); + + const token = "lin_test"; + var slot_count: usize = 0; + while (slot_count < MAX_SESSIONS) : (slot_count += 1) { + const s = linode_mcp_authenticate(token.ptr, @intCast(token.len)); + try std.testing.expect(s >= 0); + } + + // Next should fail + try std.testing.expectEqual(@as(c_int, -1), linode_mcp_authenticate(token.ptr, @intCast(token.len))); +} + +// ═══════════════════════════════════════════════════════════════════════ +// ADR-0006 invoke dispatch tests +// ═══════════════════════════════════════════════════════════════════════ + +test "boj_cartridge_name returns linode-mcp" { + const n = std.mem.span(boj_cartridge_name()); + try std.testing.expectEqualStrings("linode-mcp", n); +} + +test "boj_cartridge_init returns 0" { + try std.testing.expectEqual(@as(c_int, 0), boj_cartridge_init()); +} + +test "invoke: each declared tool succeeds" { + var buf: [256]u8 = undefined; + const tools = [_][]const u8{ + "linode_list_instances", + "linode_get_instance", + "linode_create_instance", + "linode_delete_instance", + "linode_boot_instance", + "linode_shutdown_instance", + "linode_reboot_instance", + "linode_list_volumes", + "linode_create_volume", + "linode_list_domains", + "linode_create_domain", + "linode_list_nodebalancers", + "linode_list_stackscripts", + "linode_list_images", + "linode_list_regions", + "linode_list_firewalls", + "linode_create_firewall", + "linode_get_account", + }; + for (tools) |t| { + var len: usize = buf.len; + const rc = boj_cartridge_invoke(t.ptr, "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, 0), rc); + try std.testing.expect(std.mem.indexOf(u8, buf[0..len], "result") != null); + } +} + +test "invoke: unknown tool returns -1" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("nope", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -1), rc); +} + +test "invoke: buffer too small returns -3" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("linode_list_instances", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -3), rc); + try std.testing.expect(len > 4); +} diff --git a/cartridges/domains/cloud/linode-mcp/minter.toml b/cartridges/domains/cloud/linode-mcp/minter.toml new file mode 100644 index 0000000..27cae8a --- /dev/null +++ b/cartridges/domains/cloud/linode-mcp/minter.toml @@ -0,0 +1,44 @@ +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> + +name = "linode-mcp" +description = "Linode/Akamai cloud infrastructure MCP cartridge β€” instances, volumes, domains, NodeBalancers, StackScripts, images" +version = "0.2.0" +domain = "Cloud" +protocols = [ + "MCP", + "REST", +] +tier = "Ayo" +backend = "universal" +generate_panel = true + +[auth] +method = "bearer" +token_type = "personal_access_token" +base_url = "https://api.linode.com/v4/" + +[rate_limit] +requests = 800 +window = "2min" + +[actions] +count = 16 +list = [ + "ListInstances", + "GetInstance", + "CreateInstance", + "DeleteInstance", + "Boot", + "Shutdown", + "Reboot", + "ListVolumes", + "CreateVolume", + "ListDomains", + "CreateDomain", + "ListNodeBalancers", + "ListStackScripts", + "ListImages", + "ListRegions", + "GetAccount", +] diff --git a/cartridges/domains/cloud/linode-mcp/mod.js b/cartridges/domains/cloud/linode-mcp/mod.js new file mode 100644 index 0000000..c0e31df --- /dev/null +++ b/cartridges/domains/cloud/linode-mcp/mod.js @@ -0,0 +1,306 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// linode-mcp/mod.js -- Linode/Akamai API v4 cartridge implementation. +// +// Provides MCP tool handlers for Linode REST API v4: +// - Instance management (list, get, create, delete, boot, shutdown, reboot) +// - Block storage volumes (list, create) +// - DNS domains (list, create) +// - NodeBalancers (list) +// - StackScripts (list) +// - Images (list) +// - Regions (list) +// - Cloud Firewalls (list, create) +// - Account info +// +// Auth: Bearer token via LINODE_TOKEN env var or vault-mcp proxy. +// API docs: https://techdocs.akamai.com/linode-api/reference/api +// Rate limit: 800 requests per 2 minutes. +// +// Usage: import { handleTool } from "./mod.js"; +// or: deno run --allow-net --allow-env mod.js + +const API_BASE = "https://api.linode.com/v4"; + +// --------------------------------------------------------------------------- +// Auth helper -- retrieves the Linode API token from environment. +// In production, vault-mcp provides zero-knowledge credential proxying; +// for development, LINODE_TOKEN is read directly. +// --------------------------------------------------------------------------- + +function getToken() { + const token = typeof Deno !== "undefined" + ? Deno.env.get("LINODE_TOKEN") + : process.env.LINODE_TOKEN; + if (!token) { + throw new Error("LINODE_TOKEN not set. Store in vault-mcp or export to environment."); + } + return token; +} + +// --------------------------------------------------------------------------- +// HTTP request helper -- wraps fetch with Linode auth headers, error +// handling, page-based pagination, and rate-limit header extraction. +// Linode uses page/page_size pagination with X-Filter header for filtering. +// --------------------------------------------------------------------------- + +async function linodeFetch(method, path, body, queryParams) { + const token = getToken(); + const url = new URL(`${API_BASE}${path}`); + + // Append query parameters (pagination, filters) + if (queryParams) { + for (const [key, value] of Object.entries(queryParams)) { + if (value !== undefined && value !== null && value !== "") { + url.searchParams.set(key, String(value)); + } + } + } + + const options = { + method, + headers: { + "Authorization": `Bearer ${token}`, + "Accept": "application/json", + "User-Agent": "boj-server/linode-mcp/0.2.0", + }, + }; + + if (body && method !== "GET") { + options.headers["Content-Type"] = "application/json"; + options.body = JSON.stringify(body); + } + + const response = await fetch(url.toString(), options); + + // Extract rate-limit headers for caller awareness + // Linode uses X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset + const rateLimit = { + limit: response.headers.get("x-ratelimit-limit"), + remaining: response.headers.get("x-ratelimit-remaining"), + reset: response.headers.get("x-ratelimit-reset"), + }; + + // Handle 204 No Content (successful deletes) + if (response.status === 204) { + return { status: response.status, data: { success: true }, rateLimit }; + } + + const data = await response.json(); + + // Surface Linode API errors clearly + if (!response.ok) { + const errors = data.errors + ? data.errors.map((e) => e.reason).join("; ") + : `HTTP ${response.status}`; + return { status: response.status, error: errors, data, rateLimit }; + } + + return { status: response.status, data, rateLimit }; +} + +// --------------------------------------------------------------------------- +// Tool handler dispatch -- maps MCP tool names to Linode API operations. +// Each handler validates required arguments, builds the API request, +// and returns structured results with rate-limit metadata. +// --------------------------------------------------------------------------- + +export async function handleTool(toolName, args) { + switch (toolName) { + + // --- Instances --- + + case "linode_list_instances": { + const query = { + page: args.page, + page_size: args.page_size, + }; + return linodeFetch("GET", "/linode/instances", null, query); + } + + case "linode_get_instance": { + if (!args.linode_id) return { error: "Missing required field: linode_id" }; + return linodeFetch("GET", `/linode/instances/${args.linode_id}`); + } + + case "linode_create_instance": { + if (!args.label) return { error: "Missing required field: label" }; + if (!args.region) return { error: "Missing required field: region" }; + if (!args.type) return { error: "Missing required field: type" }; + const body = { + label: args.label, + region: args.region, + type: args.type, + }; + if (args.image) body.image = args.image; + if (args.root_pass) body.root_pass = args.root_pass; + if (args.authorized_keys) body.authorized_keys = args.authorized_keys; + if (args.authorized_users) body.authorized_users = args.authorized_users; + if (args.booted !== undefined) body.booted = args.booted; + if (args.backups_enabled !== undefined) body.backups_enabled = args.backups_enabled; + if (args.swap_size !== undefined) body.swap_size = args.swap_size; + if (args.private_ip !== undefined) body.private_ip = args.private_ip; + if (args.tags) body.tags = args.tags; + if (args.group) body.group = args.group; + if (args.stackscript_id) body.stackscript_id = args.stackscript_id; + if (args.stackscript_data) body.stackscript_data = args.stackscript_data; + return linodeFetch("POST", "/linode/instances", body); + } + + case "linode_delete_instance": { + if (!args.linode_id) return { error: "Missing required field: linode_id" }; + return linodeFetch("DELETE", `/linode/instances/${args.linode_id}`); + } + + case "linode_boot_instance": { + if (!args.linode_id) return { error: "Missing required field: linode_id" }; + const body = {}; + if (args.config_id) body.config_id = args.config_id; + return linodeFetch("POST", `/linode/instances/${args.linode_id}/boot`, body); + } + + case "linode_shutdown_instance": { + if (!args.linode_id) return { error: "Missing required field: linode_id" }; + return linodeFetch("POST", `/linode/instances/${args.linode_id}/shutdown`); + } + + case "linode_reboot_instance": { + if (!args.linode_id) return { error: "Missing required field: linode_id" }; + const body = {}; + if (args.config_id) body.config_id = args.config_id; + return linodeFetch("POST", `/linode/instances/${args.linode_id}/reboot`, body); + } + + // --- Volumes --- + + case "linode_list_volumes": { + const query = { + page: args.page, + page_size: args.page_size, + }; + return linodeFetch("GET", "/volumes", null, query); + } + + case "linode_create_volume": { + if (!args.label) return { error: "Missing required field: label" }; + if (!args.size) return { error: "Missing required field: size" }; + const body = { + label: args.label, + size: args.size, + }; + if (args.region) body.region = args.region; + if (args.linode_id) body.linode_id = args.linode_id; + if (args.config_id) body.config_id = args.config_id; + if (args.tags) body.tags = args.tags; + return linodeFetch("POST", "/volumes", body); + } + + // --- Domains --- + + case "linode_list_domains": { + const query = { + page: args.page, + page_size: args.page_size, + }; + return linodeFetch("GET", "/domains", null, query); + } + + case "linode_create_domain": { + if (!args.domain) return { error: "Missing required field: domain" }; + if (!args.type) return { error: "Missing required field: type" }; + const body = { + domain: args.domain, + type: args.type, + }; + if (args.soa_email) body.soa_email = args.soa_email; + if (args.master_ips) body.master_ips = args.master_ips; + if (args.group) body.group = args.group; + if (args.description) body.description = args.description; + if (args.tags) body.tags = args.tags; + return linodeFetch("POST", "/domains", body); + } + + // --- NodeBalancers --- + + case "linode_list_nodebalancers": { + const query = { + page: args.page, + page_size: args.page_size, + }; + return linodeFetch("GET", "/nodebalancers", null, query); + } + + // --- StackScripts --- + + case "linode_list_stackscripts": { + const query = { + page: args.page, + page_size: args.page_size, + mine: args.mine, + }; + return linodeFetch("GET", "/linode/stackscripts", null, query); + } + + // --- Images --- + + case "linode_list_images": { + const query = { + page: args.page, + page_size: args.page_size, + }; + return linodeFetch("GET", "/images", null, query); + } + + // --- Regions --- + + case "linode_list_regions": { + return linodeFetch("GET", "/regions"); + } + + // --- Firewalls --- + + case "linode_list_firewalls": { + const query = { + page: args.page, + page_size: args.page_size, + }; + return linodeFetch("GET", "/networking/firewalls", null, query); + } + + case "linode_create_firewall": { + if (!args.label) return { error: "Missing required field: label" }; + if (!args.rules) return { error: "Missing required field: rules" }; + const body = { + label: args.label, + rules: args.rules, + }; + if (args.devices) body.devices = args.devices; + if (args.tags) body.tags = args.tags; + return linodeFetch("POST", "/networking/firewalls", body); + } + + // --- Account --- + + case "linode_get_account": { + return linodeFetch("GET", "/account"); + } + + default: + return { error: `Unknown linode-mcp tool: ${toolName}` }; + } +} + +// --------------------------------------------------------------------------- +// Cartridge metadata export -- used by the BoJ cartridge loader to register +// this cartridge's tools without reading cartridge.json separately. +// --------------------------------------------------------------------------- + +export const metadata = { + name: "linode-mcp", + version: "0.2.0", + domain: "Cloud", + tier: "Ayo", + protocols: ["MCP", "REST"], + toolCount: 18, +}; diff --git a/cartridges/domains/cloud/linode-mcp/panels/manifest.json b/cartridges/domains/cloud/linode-mcp/panels/manifest.json new file mode 100644 index 0000000..7d473b0 --- /dev/null +++ b/cartridges/domains/cloud/linode-mcp/panels/manifest.json @@ -0,0 +1,89 @@ +{ + "$schema": "https://panll.dev/schemas/panel-manifest/v1.json", + "spdx": "MPL-2.0", + "cartridge": "linode-mcp", + "domain": "Cloud", + "version": "0.2.0", + "panels": [ + { + "id": "linode-auth-status", + "title": "Auth Status", + "description": "Current Linode authentication state (Unauthenticated / Authenticated / RateLimited / Error)", + "type": "status-indicator", + "data_source": { + "endpoint": "/linode/state", + "method": "GET", + "refresh_interval_ms": 5000 + }, + "widgets": [ + { + "type": "state-badge", + "field": "state", + "states": { + "unauthenticated": { "color": "#95a5a6", "icon": "user-x" }, + "authenticated": { "color": "#2ecc71", "icon": "user-check" }, + "rate_limited": { "color": "#f39c12", "icon": "clock" }, + "error": { "color": "#e74c3c", "icon": "alert-triangle" } + } + } + ] + }, + { + "id": "linode-instance-count", + "title": "Instance Count", + "description": "Total number of active Linode compute instances", + "type": "metric", + "data_source": { + "endpoint": "/linode/instances/count", + "method": "GET", + "refresh_interval_ms": 30000 + }, + "widgets": [ + { + "type": "counter", + "field": "count", + "label": "Instances", + "icon": "server" + } + ] + }, + { + "id": "linode-volume-count", + "title": "Volume Count", + "description": "Total number of block storage volumes", + "type": "metric", + "data_source": { + "endpoint": "/linode/volumes/count", + "method": "GET", + "refresh_interval_ms": 30000 + }, + "widgets": [ + { + "type": "counter", + "field": "count", + "label": "Volumes", + "icon": "hard-drive" + } + ] + }, + { + "id": "linode-nodebalancer-count", + "title": "NodeBalancer Count", + "description": "Total number of active NodeBalancers", + "type": "metric", + "data_source": { + "endpoint": "/linode/nodebalancers/count", + "method": "GET", + "refresh_interval_ms": 30000 + }, + "widgets": [ + { + "type": "counter", + "field": "count", + "label": "NodeBalancers", + "icon": "git-branch" + } + ] + } + ] +} diff --git a/cartridges/domains/cloud/linode-mcp/tests/integration_test.sh b/cartridges/domains/cloud/linode-mcp/tests/integration_test.sh new file mode 100755 index 0000000..26f0a2e --- /dev/null +++ b/cartridges/domains/cloud/linode-mcp/tests/integration_test.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Integration tests for linode-mcp cartridge. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CART_DIR="$(dirname "$SCRIPT_DIR")" +FFI_DIR="$CART_DIR/ffi" + +echo "=== linode-mcp integration tests ===" + +# Build FFI +echo "[1/3] Building FFI..." +cd "$FFI_DIR" && zig build 2>&1 + +# Run Zig unit tests +echo "[2/3] Running FFI unit tests..." +cd "$FFI_DIR" && zig build test 2>&1 + +# Validate Idris2 ABI (if idris2 available) +echo "[3/3] Checking ABI..." +if command -v idris2 &>/dev/null; then + cd "$CART_DIR/abi" && idris2 --check LinodeMcp.SafeCloud 2>&1 + echo " ABI: OK" +else + echo " ABI: SKIPPED (idris2 not in PATH)" +fi + +echo "" +echo "All tests passed for linode-mcp!" diff --git a/cartridges/domains/cloud/railway-mcp/README.adoc b/cartridges/domains/cloud/railway-mcp/README.adoc new file mode 100644 index 0000000..28f7a63 --- /dev/null +++ b/cartridges/domains/cloud/railway-mcp/README.adoc @@ -0,0 +1,71 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += railway-mcp +Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk> +:spdx: MPL-2.0 +:tier: Ayo +:domain: Cloud +:protocols: MCP, GraphQL + +== Overview + +Railway GraphQL API v2 cartridge for BoJ. Provides type-safe access to projects, +services, deployments, variables, domains, logs, and metrics via the GraphQL API +at `https://backboard.railway.app/graphql/v2`. Authentication uses a Bearer token +(Railway API token). This is a GraphQL-only API (no REST fallback). + +== State Machine + +`Unauthenticated` -> `Authenticated` -> `RateLimited` -> `Error` + +Valid transitions are enforced by the Idris2 ABI layer with dependent-type proofs. + +== Actions + +ListProjects, GetProject, CreateProject, DeleteProject, ListServices, +GetService, ListDeployments, GetDeployment, Redeploy, ListVariables, +SetVariable, DeleteVariable, ListDomains, AddDomain, GetLogs, GetMetrics. + +== Architecture + +[cols="1,1,2"] +|=== +| Layer | Language | Purpose + +| ABI +| Idris2 +| Formally verified state machine with dependent-type proofs + +| FFI +| Zig +| C-compatible implementation with thread-safe session pool + +| Adapter +| zig +| GraphQL bridge to BoJ unified adapter protocol +|=== + +== Panels + +* Auth status (Unauthenticated / Authenticated / RateLimited / Error) +* Project count +* Deployment status (success / failure counts) +* Service health + +== Building + +[source,bash] +---- +# Build FFI shared library +cd ffi && zig build + +# Run FFI tests +cd ffi && zig build test + +# Type-check ABI +cd abi && idris2 --check RailwayMcp.SafeCloud +---- + +== Status + +Development -- not yet ready for mounting. diff --git a/cartridges/domains/cloud/railway-mcp/abi/README.adoc b/cartridges/domains/cloud/railway-mcp/abi/README.adoc new file mode 100644 index 0000000..78599f9 --- /dev/null +++ b/cartridges/domains/cloud/railway-mcp/abi/README.adoc @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += railway-mcp / abi β€” Idris2 ABI layer +:orientation: shallow + +== Purpose + +Cartridge-local protocol types for `railway-mcp`. Defines the data, tool, and +transition vocabulary this cartridge speaks. Inherits safety lemmas and the +matrix-cell identifier scheme from the trunk ABI at `src/abi/`. + +== Files + +1 Idris2 module(s), ~176 lines total. Lead module: +`SafeCloud.idr`. + +No `.ipkg` β€” consumed directly by wider BoJ callers; type-checks inside the +trunk `boj.ipkg`. + +== Invariants + +* `%default total` at every module top. +* Zero `believe_me`, `assert_total`, `postulate`, `Admitted`, or + `sorry` (the trunk holds the four declared `believe_me` sites; leaf + cartridges carry none). + +== Test/proof surface + +Thin module(s), no inline tests. Proof surface = type-check success under the +trunk `idris2 --check`. + +== Read-first + +. `SafeCloud.idr` β€” cartridge-local data records and enums. +. Trunk `src/abi/Boj/Catalogue.idr` for how this cell participates in the + 2D matrix. diff --git a/cartridges/domains/cloud/railway-mcp/abi/RailwayMcp/SafeCloud.idr b/cartridges/domains/cloud/railway-mcp/abi/RailwayMcp/SafeCloud.idr new file mode 100644 index 0000000..4f64303 --- /dev/null +++ b/cartridges/domains/cloud/railway-mcp/abi/RailwayMcp/SafeCloud.idr @@ -0,0 +1,176 @@ +-- SPDX-License-Identifier: MPL-2.0 +-- Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +-- +-- RailwayMcp.SafeCloud -- Type-safe ABI for railway-mcp cartridge (Railway GraphQL API). +-- +-- State machine with dependent-type proofs ensuring only valid transitions +-- can occur at the FFI boundary. Zero unsafe escape hatches. +-- Auth: Bearer token (API token), GraphQL API (https://backboard.railway.app/graphql/v2). + +module RailwayMcp.SafeCloud + +%default total + +-- --------------------------------------------------------------------------- +-- Authentication / session state machine +-- --------------------------------------------------------------------------- + +||| Authentication and session state for Railway GraphQL API operations. +public export +data SessionState + = Unauthenticated + | Authenticated + | RateLimited + | Error + +||| Proof that a state transition is valid. +public export +data ValidTransition : SessionState -> SessionState -> Type where + Authenticate : ValidTransition Unauthenticated Authenticated + BeginRateLimit : ValidTransition Authenticated RateLimited + EndRateLimit : ValidTransition RateLimited Authenticated + AuthError : ValidTransition Unauthenticated Error + OpError : ValidTransition Authenticated Error + RateError : ValidTransition RateLimited Error + RecoverAuth : ValidTransition Error Unauthenticated + Deauthenticate : ValidTransition Authenticated Unauthenticated + +-- --------------------------------------------------------------------------- +-- C-ABI integer encoding +-- --------------------------------------------------------------------------- + +||| Encode session state as C-compatible integer. +export +sessionStateToInt : SessionState -> Int +sessionStateToInt Unauthenticated = 0 +sessionStateToInt Authenticated = 1 +sessionStateToInt RateLimited = 2 +sessionStateToInt Error = 3 + +||| Decode integer back to session state. +export +intToSessionState : Int -> Maybe SessionState +intToSessionState 0 = Just Unauthenticated +intToSessionState 1 = Just Authenticated +intToSessionState 2 = Just RateLimited +intToSessionState 3 = Just Error +intToSessionState _ = Nothing + +||| Check if a state transition is valid (C-ABI export). +||| Returns 1 for valid, 0 for invalid. +export +railway_mcp_can_transition : Int -> Int -> Int +railway_mcp_can_transition from to = + case (intToSessionState from, intToSessionState to) of + (Just Unauthenticated, Just Authenticated) => 1 + (Just Authenticated, Just RateLimited) => 1 + (Just RateLimited, Just Authenticated) => 1 + (Just Unauthenticated, Just Error) => 1 + (Just Authenticated, Just Error) => 1 + (Just RateLimited, Just Error) => 1 + (Just Error, Just Unauthenticated) => 1 + (Just Authenticated, Just Unauthenticated) => 1 + _ => 0 + +-- --------------------------------------------------------------------------- +-- Railway API actions +-- --------------------------------------------------------------------------- + +||| Actions available on the Railway GraphQL API v2. +public export +data RailwayAction + = ListProjects + | GetProject + | CreateProject + | DeleteProject + | ListServices + | GetService + | ListDeployments + | GetDeployment + | Redeploy + | ListVariables + | SetVariable + | DeleteVariable + | ListDomains + | AddDomain + | GetLogs + | GetMetrics + +||| Encode action as C-compatible integer for FFI. +export +railwayActionToInt : RailwayAction -> Int +railwayActionToInt ListProjects = 0 +railwayActionToInt GetProject = 1 +railwayActionToInt CreateProject = 2 +railwayActionToInt DeleteProject = 3 +railwayActionToInt ListServices = 4 +railwayActionToInt GetService = 5 +railwayActionToInt ListDeployments = 6 +railwayActionToInt GetDeployment = 7 +railwayActionToInt Redeploy = 8 +railwayActionToInt ListVariables = 9 +railwayActionToInt SetVariable = 10 +railwayActionToInt DeleteVariable = 11 +railwayActionToInt ListDomains = 12 +railwayActionToInt AddDomain = 13 +railwayActionToInt GetLogs = 14 +railwayActionToInt GetMetrics = 15 + +||| Decode integer back to action. +export +intToRailwayAction : Int -> Maybe RailwayAction +intToRailwayAction 0 = Just ListProjects +intToRailwayAction 1 = Just GetProject +intToRailwayAction 2 = Just CreateProject +intToRailwayAction 3 = Just DeleteProject +intToRailwayAction 4 = Just ListServices +intToRailwayAction 5 = Just GetService +intToRailwayAction 6 = Just ListDeployments +intToRailwayAction 7 = Just GetDeployment +intToRailwayAction 8 = Just Redeploy +intToRailwayAction 9 = Just ListVariables +intToRailwayAction 10 = Just SetVariable +intToRailwayAction 11 = Just DeleteVariable +intToRailwayAction 12 = Just ListDomains +intToRailwayAction 13 = Just AddDomain +intToRailwayAction 14 = Just GetLogs +intToRailwayAction 15 = Just GetMetrics +intToRailwayAction _ = Nothing + +||| Whether an action requires Authenticated state. +||| All Railway actions require authentication (no public endpoints). +export +actionRequiresAuth : RailwayAction -> Bool +actionRequiresAuth _ = True + +||| Total number of actions exposed by this cartridge. +export +actionCount : Nat +actionCount = 16 + +-- --------------------------------------------------------------------------- +-- MCP tool declarations +-- --------------------------------------------------------------------------- + +||| Tools exposed via MCP protocol. +public export +data McpTool + = ToolAuthenticate + | ToolDeauthenticate + | ToolStatus + | ToolInvoke + | ToolList + +||| Check if a tool requires an authenticated session. +export +toolRequiresAuth : McpTool -> Bool +toolRequiresAuth ToolAuthenticate = False +toolRequiresAuth ToolDeauthenticate = True +toolRequiresAuth ToolStatus = False +toolRequiresAuth ToolInvoke = True +toolRequiresAuth ToolList = False + +||| Tool count for this cartridge. +export +toolCount : Nat +toolCount = 5 diff --git a/cartridges/domains/cloud/railway-mcp/abi/railway_mcp.ipkg b/cartridges/domains/cloud/railway-mcp/abi/railway_mcp.ipkg new file mode 100644 index 0000000..8b53de9 --- /dev/null +++ b/cartridges/domains/cloud/railway-mcp/abi/railway_mcp.ipkg @@ -0,0 +1,13 @@ +-- SPDX-License-Identifier: MPL-2.0 +-- Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +package railway_mcp + +version = "0.1.0" +authors = "Jonathan D.A. Jewell" +brief = "Railway GraphQL API v2 cartridge -- type-safe ABI with dependent-type proofs" + +depends = base + +sourcedir = "." + +modules = RailwayMcp.SafeCloud diff --git a/cartridges/domains/cloud/railway-mcp/adapter/README.adoc b/cartridges/domains/cloud/railway-mcp/adapter/README.adoc new file mode 100644 index 0000000..bb9bcad --- /dev/null +++ b/cartridges/domains/cloud/railway-mcp/adapter/README.adoc @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += railway-mcp / adapter β€” protocol bridge +:orientation: shallow + +== Purpose + +Exposes this cartridge's FFI over the wire protocols declared in +`cartridge.json`. Dispatches tool invocations to the C-ABI exports at +`../ffi/` and formats responses per protocol. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” depends on the sibling `ffi` target. +| `railway_adapter.zig` | Protocol dispatch (240 lines). Tool catalogue matches `cartridge.json`. +| `SIDELINED-*.v.adoc` | zig predecessor(s). Not built β€” zig banned 2026-04-10. +|=== + +== Invariants + +* **Stateless** β€” all state lives behind the FFI, never in the adapter. +* **Response passthrough** β€” whatever the FFI returns goes back to the wire + unmodified (no embellishment, no silent recovery). +* **Cartridge.json is source of truth** for the tool catalogue; drift between + adapter and manifest is a CI failure. + +== Test/proof surface + +No inline tests at the adapter layer β€” protocol routing is exercised by the +trunk seam tests at `ffi/zig/src/seams.zig`. + +== Read-first + +. `railway_adapter.zig` β€” dispatch function and per-protocol routers. +. `../cartridge.json` β€” the tool manifest the dispatcher mirrors. diff --git a/cartridges/domains/cloud/railway-mcp/adapter/build.zig b/cartridges/domains/cloud/railway-mcp/adapter/build.zig new file mode 100644 index 0000000..1a16865 --- /dev/null +++ b/cartridges/domains/cloud/railway-mcp/adapter/build.zig @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// railway-mcp/adapter/build.zig + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const ffi_mod = b.createModule(.{ + .root_source_file = b.path("../ffi/railway_mcp_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + const adapter = b.addExecutable(.{ + .name = "railway_adapter", + .root_source_file = b.path("railway_adapter.zig"), + .target = target, + .optimize = optimize, + }); + adapter.root_module.addImport("railway_mcp_ffi", ffi_mod); + b.installArtifact(adapter); + + const run_artifact = b.addRunArtifact(adapter); + const run_step = b.step("run", "Run the railway-mcp adapter"); + run_step.dependOn(&run_artifact.step); + + const tests = b.addTest(.{ + .root_source_file = b.path("railway_adapter.zig"), + .target = target, + .optimize = optimize, + }); + tests.root_module.addImport("railway_mcp_ffi", ffi_mod); + const run_tests = b.addRunArtifact(tests); + const test_step = b.step("test", "Run railway-mcp adapter tests"); + test_step.dependOn(&run_tests.step); +} diff --git a/cartridges/domains/cloud/railway-mcp/adapter/railway_adapter.zig b/cartridges/domains/cloud/railway-mcp/adapter/railway_adapter.zig new file mode 100644 index 0000000..3809d8d --- /dev/null +++ b/cartridges/domains/cloud/railway-mcp/adapter/railway_adapter.zig @@ -0,0 +1,240 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// railway-mcp/adapter/railway_adapter.zig -- Unified three-protocol adapter. +// +// Replaces the banned railway_adapter.v (zig, removed 2026-04-12). +// +// Bridges the Zig FFI (railway_mcp_ffi.zig) to three network protocols: +// REST :9088 POST /tools/<tool> +// gRPC-compat :9089 /RailwayMcpService/<Method> +// GraphQL :9090 POST /graphql { query: "..." } +// +// Railway platform: projects, services, deployments, variables, domains +// Tools: +// railway_list_projects +// railway_get_project +// railway_create_project +// railway_delete_project +// railway_list_services +// railway_get_service +// railway_create_service +// railway_restart_service +// railway_list_deployments +// railway_get_deployment +// railway_redeploy +// railway_rollback +// railway_list_variables +// railway_set_variable +// railway_delete_variable +// railway_list_domains +// railway_add_domain +// railway_get_logs +// railway_get_metrics + +const std = @import("std"); +const ffi = @import("railway_mcp_ffi"); + +const REST_PORT: u16 = 9088; +const GRPC_PORT: u16 = 9089; +const GQL_PORT: u16 = 9090; + +const MAX_CONN_BUF: usize = 16 * 1024; + +// ============================================================================ +// JSON response builders +// ============================================================================ + +fn okJson(buf: []u8, msg: []const u8) []u8 { + return std.fmt.bufPrint(buf, + \{{"success":true,"message":"{}"}} + , .{msg}) catch buf[0..0]; +} + +fn errJson(buf: []u8, msg: []const u8) []u8 { + return std.fmt.bufPrint(buf, + \{{"success":false,"error":"{}"}} + , .{msg}) catch buf[0..0]; +} + +fn statusJson(buf: []u8) []u8 { + return std.fmt.bufPrint(buf, + \{{"success":true,"state":"ready","service":"railway-mcp"}} + , .{}) catch buf[0..0]; +} + +// ============================================================================ +// Tool dispatcher +// ============================================================================ + +const Response = struct { status: u16, body: []u8 }; + +fn dispatch(tool: []const u8, body: []const u8, resp: []u8) Response { + _ = body; + if (std.mem.eql(u8, tool, "railway_list_projects")) return .{ .status = 200, .body = okJson(resp, "railway_list_projects forwarded to backend") }; + if (std.mem.eql(u8, tool, "railway_get_project")) return .{ .status = 200, .body = okJson(resp, "railway_get_project forwarded to backend") }; + if (std.mem.eql(u8, tool, "railway_create_project")) return .{ .status = 200, .body = okJson(resp, "railway_create_project forwarded to backend") }; + if (std.mem.eql(u8, tool, "railway_delete_project")) return .{ .status = 200, .body = okJson(resp, "railway_delete_project forwarded to backend") }; + if (std.mem.eql(u8, tool, "railway_list_services")) return .{ .status = 200, .body = okJson(resp, "railway_list_services forwarded to backend") }; + if (std.mem.eql(u8, tool, "railway_get_service")) return .{ .status = 200, .body = okJson(resp, "railway_get_service forwarded to backend") }; + if (std.mem.eql(u8, tool, "railway_create_service")) return .{ .status = 200, .body = okJson(resp, "railway_create_service forwarded to backend") }; + if (std.mem.eql(u8, tool, "railway_restart_service")) return .{ .status = 200, .body = okJson(resp, "railway_restart_service forwarded to backend") }; + if (std.mem.eql(u8, tool, "railway_list_deployments")) return .{ .status = 200, .body = okJson(resp, "railway_list_deployments forwarded to backend") }; + if (std.mem.eql(u8, tool, "railway_get_deployment")) return .{ .status = 200, .body = okJson(resp, "railway_get_deployment forwarded to backend") }; + if (std.mem.eql(u8, tool, "railway_redeploy")) return .{ .status = 200, .body = okJson(resp, "railway_redeploy forwarded to backend") }; + if (std.mem.eql(u8, tool, "railway_rollback")) return .{ .status = 200, .body = okJson(resp, "railway_rollback forwarded to backend") }; + if (std.mem.eql(u8, tool, "railway_list_variables")) return .{ .status = 200, .body = okJson(resp, "railway_list_variables forwarded to backend") }; + if (std.mem.eql(u8, tool, "railway_set_variable")) return .{ .status = 200, .body = okJson(resp, "railway_set_variable forwarded to backend") }; + if (std.mem.eql(u8, tool, "railway_delete_variable")) return .{ .status = 200, .body = okJson(resp, "railway_delete_variable forwarded to backend") }; + if (std.mem.eql(u8, tool, "railway_list_domains")) return .{ .status = 200, .body = okJson(resp, "railway_list_domains forwarded to backend") }; + if (std.mem.eql(u8, tool, "railway_add_domain")) return .{ .status = 200, .body = okJson(resp, "railway_add_domain forwarded to backend") }; + if (std.mem.eql(u8, tool, "railway_get_logs")) return .{ .status = 200, .body = okJson(resp, "railway_get_logs forwarded to backend") }; + if (std.mem.eql(u8, tool, "railway_get_metrics")) return .{ .status = 200, .body = okJson(resp, "railway_get_metrics forwarded to backend") }; + if (std.mem.eql(u8, tool, "status") or std.mem.eql(u8, tool, "health")) + return .{ .status = 200, .body = statusJson(resp) }; + return .{ .status = 404, .body = errJson(resp, "Unknown tool") }; +} + +// ============================================================================ +// REST handler +// ============================================================================ + +fn dispatchRest(path: []const u8, body: []const u8, resp: []u8) Response { + if (std.mem.startsWith(u8, path, "/tools/")) { + return dispatch(path["/tools/".len..], body, resp); + } + if (std.mem.eql(u8, path, "/status") or std.mem.eql(u8, path, "/health")) { + return .{ .status = 200, .body = statusJson(resp) }; + } + return .{ .status = 404, .body = errJson(resp, "Not found") }; +} + +// ============================================================================ +// gRPC-compat handler +// ============================================================================ + +fn dispatchGrpc(path: []const u8, body: []const u8, resp: []u8) Response { + const prefix = "/RailwayMcpService/"; + if (!std.mem.startsWith(u8, path, prefix)) + return .{ .status = 404, .body = errJson(resp, "Not a recognized gRPC path") }; + const method = path[prefix.len..]; + const tool = blk: { + if (std.mem.eql(u8, method, "RailwayListProjects")) break :blk "railway_list_projects"; + if (std.mem.eql(u8, method, "RailwayGetProject")) break :blk "railway_get_project"; + if (std.mem.eql(u8, method, "RailwayCreateProject")) break :blk "railway_create_project"; + if (std.mem.eql(u8, method, "RailwayDeleteProject")) break :blk "railway_delete_project"; + if (std.mem.eql(u8, method, "RailwayListServices")) break :blk "railway_list_services"; + if (std.mem.eql(u8, method, "RailwayGetService")) break :blk "railway_get_service"; + if (std.mem.eql(u8, method, "RailwayCreateService")) break :blk "railway_create_service"; + if (std.mem.eql(u8, method, "RailwayRestartService")) break :blk "railway_restart_service"; + if (std.mem.eql(u8, method, "RailwayListDeployments")) break :blk "railway_list_deployments"; + if (std.mem.eql(u8, method, "RailwayGetDeployment")) break :blk "railway_get_deployment"; + if (std.mem.eql(u8, method, "RailwayRedeploy")) break :blk "railway_redeploy"; + if (std.mem.eql(u8, method, "RailwayRollback")) break :blk "railway_rollback"; + if (std.mem.eql(u8, method, "RailwayListVariables")) break :blk "railway_list_variables"; + if (std.mem.eql(u8, method, "RailwaySetVariable")) break :blk "railway_set_variable"; + if (std.mem.eql(u8, method, "RailwayDeleteVariable")) break :blk "railway_delete_variable"; + if (std.mem.eql(u8, method, "RailwayListDomains")) break :blk "railway_list_domains"; + if (std.mem.eql(u8, method, "RailwayAddDomain")) break :blk "railway_add_domain"; + if (std.mem.eql(u8, method, "RailwayGetLogs")) break :blk "railway_get_logs"; + if (std.mem.eql(u8, method, "RailwayGetMetrics")) break :blk "railway_get_metrics"; + return .{ .status = 404, .body = errJson(resp, "Unknown gRPC method") }; + }; + return dispatch(tool, body, resp); +} + +// ============================================================================ +// GraphQL handler +// ============================================================================ + +fn dispatchGraphql(body: []const u8, resp: []u8) Response { + if (std.mem.indexOf(u8, body, "__schema") != null) + return .{ .status = 200, .body = okJson(resp, "schema introspection not yet supported") }; + if (std.mem.indexOf(u8, body, "list_projects") != null) return dispatch("railway_list_projects", body, resp); + if (std.mem.indexOf(u8, body, "get_project") != null) return dispatch("railway_get_project", body, resp); + if (std.mem.indexOf(u8, body, "create_project") != null) return dispatch("railway_create_project", body, resp); + if (std.mem.indexOf(u8, body, "delete_project") != null) return dispatch("railway_delete_project", body, resp); + if (std.mem.indexOf(u8, body, "list_services") != null) return dispatch("railway_list_services", body, resp); + if (std.mem.indexOf(u8, body, "get_service") != null) return dispatch("railway_get_service", body, resp); + if (std.mem.indexOf(u8, body, "create_service") != null) return dispatch("railway_create_service", body, resp); + if (std.mem.indexOf(u8, body, "restart_service") != null) return dispatch("railway_restart_service", body, resp); + if (std.mem.indexOf(u8, body, "list_deployments") != null) return dispatch("railway_list_deployments", body, resp); + if (std.mem.indexOf(u8, body, "get_deployment") != null) return dispatch("railway_get_deployment", body, resp); + if (std.mem.indexOf(u8, body, "redeploy") != null) return dispatch("railway_redeploy", body, resp); + if (std.mem.indexOf(u8, body, "rollback") != null) return dispatch("railway_rollback", body, resp); + if (std.mem.indexOf(u8, body, "list_variables") != null) return dispatch("railway_list_variables", body, resp); + if (std.mem.indexOf(u8, body, "set_variable") != null) return dispatch("railway_set_variable", body, resp); + if (std.mem.indexOf(u8, body, "delete_variable") != null) return dispatch("railway_delete_variable", body, resp); + if (std.mem.indexOf(u8, body, "list_domains") != null) return dispatch("railway_list_domains", body, resp); + if (std.mem.indexOf(u8, body, "add_domain") != null) return dispatch("railway_add_domain", body, resp); + if (std.mem.indexOf(u8, body, "get_logs") != null) return dispatch("railway_get_logs", body, resp); + if (std.mem.indexOf(u8, body, "get_metrics") != null) return dispatch("railway_get_metrics", body, resp); + return .{ .status = 200, .body = errJson(resp, "Unrecognised GraphQL operation") }; +} + +// ============================================================================ +// HTTP/1.1 connection handler +// ============================================================================ + +const Protocol = enum { rest, grpc, graphql }; + +fn handleConnection(conn: std.net.Server.Connection, proto: Protocol) void { + defer conn.stream.close(); + var in_buf: [MAX_CONN_BUF]u8 = undefined; + const n = conn.stream.read(&in_buf) catch return; + const req = in_buf[0..n]; + + var path: []const u8 = "/"; + var body: []const u8 = ""; + if (n > 4) { + const line_end = std.mem.indexOf(u8, req, "\r\n") orelse req.len; + const first_line = req[0..line_end]; + const sp1 = std.mem.indexOfScalar(u8, first_line, ' ') orelse 0; + const rest_of = first_line[sp1 + 1 ..]; + const sp2 = std.mem.indexOfScalar(u8, rest_of, ' ') orelse rest_of.len; + path = rest_of[0..sp2]; + const body_sep = std.mem.indexOf(u8, req, "\r\n\r\n") orelse n; + body = req[@min(body_sep + 4, n)..]; + } + + var resp_buf: [MAX_CONN_BUF]u8 = undefined; + const result = switch (proto) { + .rest => dispatchRest(path, body, &resp_buf), + .grpc => dispatchGrpc(path, body, &resp_buf), + .graphql => dispatchGraphql(body, &resp_buf), + }; + + const content_type = switch (proto) { + .rest => "application/json", + .grpc => "application/grpc+json", + .graphql => "application/json", + }; + + var hdr_buf: [256]u8 = undefined; + const hdr = std.fmt.bufPrint(&hdr_buf, + "HTTP/1.1 {d} OK\r\nContent-Type: {s}\r\nContent-Length: {d}\r\nConnection: close\r\n\r\n", + .{ result.status, content_type, result.body.len }, + ) catch return; + _ = conn.stream.writeAll(hdr) catch return; + _ = conn.stream.writeAll(result.body) catch return; +} + +fn listenLoop(port: u16, proto: Protocol) void { + const addr = std.net.Address.initIp4(.{ 127, 0, 0, 1 }, port) catch return; + var server = addr.listen(.{ .reuse_address = true }) catch return; + defer server.deinit(); + while (true) { + const conn = server.accept() catch continue; + handleConnection(conn, proto); + } +} + +pub fn main() !void { + ffi.railway_init(); + const rest_thread = try std.Thread.spawn(.{}, listenLoop, .{ REST_PORT, Protocol.rest }); + const grpc_thread = try std.Thread.spawn(.{}, listenLoop, .{ GRPC_PORT, Protocol.grpc }); + const gql_thread = try std.Thread.spawn(.{}, listenLoop, .{ GQL_PORT, Protocol.graphql }); + rest_thread.join(); + grpc_thread.join(); + gql_thread.join(); +} diff --git a/cartridges/domains/cloud/railway-mcp/benchmarks/quick-bench.sh b/cartridges/domains/cloud/railway-mcp/benchmarks/quick-bench.sh new file mode 100755 index 0000000..088d312 --- /dev/null +++ b/cartridges/domains/cloud/railway-mcp/benchmarks/quick-bench.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Benchmarks for railway-mcp cartridge. +set -euo pipefail + +echo "=== railway-mcp benchmarks ===" + +cd "$(dirname "${BASH_SOURCE[0]}")/../ffi" + +# Build with ReleaseFast for benchmarking +zig build -Doptimize=ReleaseFast 2>&1 + +echo "Session open/close cycle (1000 iterations):" +time for i in $(seq 1 1000); do + # This would call the FFI benchmark binary + true +done + +echo "" +echo "Benchmark placeholder β€” implement real benchmarks in Zig test blocks" +echo "or via the zig adapter HTTP benchmark tool." diff --git a/cartridges/domains/cloud/railway-mcp/cartridge.json b/cartridges/domains/cloud/railway-mcp/cartridge.json new file mode 100644 index 0000000..67c9b76 --- /dev/null +++ b/cartridges/domains/cloud/railway-mcp/cartridge.json @@ -0,0 +1,474 @@ +{ + "$schema": "https://boj.dev/schemas/cartridge/v1.json", + "spdx": "MPL-2.0", + "copyright": "Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath)", + "name": "railway-mcp", + "version": "0.1.0", + "description": "Railway GraphQL API v2 cartridge -- project, service, deployment, environment variable, domain, log, and metrics management", + "domain": "Cloud", + "tier": "Ayo", + "protocols": [ + "MCP", + "GraphQL" + ], + "auth": { + "method": "bearer_token", + "env_var": "RAILWAY_TOKEN", + "credential_source": "vault-mcp" + }, + "api": { + "base_url": "https://backboard.railway.app/graphql/v2", + "content_type": "application/json", + "protocol": "graphql" + }, + "tools": [ + { + "name": "railway_list_projects", + "description": "List all Railway projects in the authenticated account", + "inputSchema": { + "type": "object", + "properties": { + "limit": { + "type": "number", + "description": "Maximum number of projects to return (default 25)" + }, + "after": { + "type": "string", + "description": "Cursor for pagination (from previous response)" + } + } + } + }, + { + "name": "railway_get_project", + "description": "Get details of a specific Railway project by ID", + "inputSchema": { + "type": "object", + "properties": { + "project_id": { + "type": "string", + "description": "Railway project ID" + } + }, + "required": [ + "project_id" + ] + } + }, + { + "name": "railway_create_project", + "description": "Create a new Railway project", + "inputSchema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Project name" + }, + "description": { + "type": "string", + "description": "Project description (optional)" + }, + "default_environment_name": { + "type": "string", + "description": "Name for the default environment (default: production)" + }, + "is_public": { + "type": "boolean", + "description": "Whether the project is public (default false)" + }, + "repo": { + "type": "object", + "description": "GitHub repo to connect (fullRepoName: 'owner/repo')" + } + }, + "required": [ + "name" + ] + } + }, + { + "name": "railway_delete_project", + "description": "Delete a Railway project and all its services (irreversible)", + "inputSchema": { + "type": "object", + "properties": { + "project_id": { + "type": "string", + "description": "Railway project ID to delete" + } + }, + "required": [ + "project_id" + ] + } + }, + { + "name": "railway_list_services", + "description": "List all services in a Railway project", + "inputSchema": { + "type": "object", + "properties": { + "project_id": { + "type": "string", + "description": "Railway project ID" + } + }, + "required": [ + "project_id" + ] + } + }, + { + "name": "railway_get_service", + "description": "Get details of a specific Railway service", + "inputSchema": { + "type": "object", + "properties": { + "service_id": { + "type": "string", + "description": "Railway service ID" + } + }, + "required": [ + "service_id" + ] + } + }, + { + "name": "railway_create_service", + "description": "Create a new service in a Railway project", + "inputSchema": { + "type": "object", + "properties": { + "project_id": { + "type": "string", + "description": "Railway project ID" + }, + "name": { + "type": "string", + "description": "Service name" + }, + "source": { + "type": "object", + "description": "Service source (image or repo). For Docker: {image: 'image:tag'}. For GitHub: {repo: 'owner/repo'}" + } + }, + "required": [ + "project_id", + "name" + ] + } + }, + { + "name": "railway_restart_service", + "description": "Restart a Railway service (triggers a new deployment)", + "inputSchema": { + "type": "object", + "properties": { + "service_id": { + "type": "string", + "description": "Railway service ID" + }, + "environment_id": { + "type": "string", + "description": "Environment ID to restart in" + } + }, + "required": [ + "service_id", + "environment_id" + ] + } + }, + { + "name": "railway_list_deployments", + "description": "List deployments for a Railway service", + "inputSchema": { + "type": "object", + "properties": { + "service_id": { + "type": "string", + "description": "Railway service ID" + }, + "environment_id": { + "type": "string", + "description": "Filter by environment ID (optional)" + }, + "limit": { + "type": "number", + "description": "Maximum results (default 10)" + }, + "status": { + "type": "string", + "description": "Filter by status (SUCCESS, FAILED, BUILDING, DEPLOYING, CRASHED)" + } + }, + "required": [ + "service_id" + ] + } + }, + { + "name": "railway_get_deployment", + "description": "Get details of a specific Railway deployment", + "inputSchema": { + "type": "object", + "properties": { + "deployment_id": { + "type": "string", + "description": "Railway deployment ID" + } + }, + "required": [ + "deployment_id" + ] + } + }, + { + "name": "railway_redeploy", + "description": "Trigger a redeployment of a Railway service", + "inputSchema": { + "type": "object", + "properties": { + "service_id": { + "type": "string", + "description": "Railway service ID" + }, + "environment_id": { + "type": "string", + "description": "Environment ID to redeploy in" + } + }, + "required": [ + "service_id", + "environment_id" + ] + } + }, + { + "name": "railway_rollback", + "description": "Rollback a Railway service to a previous deployment", + "inputSchema": { + "type": "object", + "properties": { + "deployment_id": { + "type": "string", + "description": "Deployment ID to rollback to" + } + }, + "required": [ + "deployment_id" + ] + } + }, + { + "name": "railway_list_variables", + "description": "List environment variables for a Railway service in a specific environment", + "inputSchema": { + "type": "object", + "properties": { + "project_id": { + "type": "string", + "description": "Railway project ID" + }, + "service_id": { + "type": "string", + "description": "Railway service ID" + }, + "environment_id": { + "type": "string", + "description": "Environment ID" + } + }, + "required": [ + "project_id", + "service_id", + "environment_id" + ] + } + }, + { + "name": "railway_set_variable", + "description": "Set an environment variable on a Railway service", + "inputSchema": { + "type": "object", + "properties": { + "project_id": { + "type": "string", + "description": "Railway project ID" + }, + "service_id": { + "type": "string", + "description": "Railway service ID" + }, + "environment_id": { + "type": "string", + "description": "Environment ID" + }, + "name": { + "type": "string", + "description": "Variable name" + }, + "value": { + "type": "string", + "description": "Variable value" + } + }, + "required": [ + "project_id", + "service_id", + "environment_id", + "name", + "value" + ] + } + }, + { + "name": "railway_delete_variable", + "description": "Delete an environment variable from a Railway service", + "inputSchema": { + "type": "object", + "properties": { + "project_id": { + "type": "string", + "description": "Railway project ID" + }, + "service_id": { + "type": "string", + "description": "Railway service ID" + }, + "environment_id": { + "type": "string", + "description": "Environment ID" + }, + "name": { + "type": "string", + "description": "Variable name to delete" + } + }, + "required": [ + "project_id", + "service_id", + "environment_id", + "name" + ] + } + }, + { + "name": "railway_list_domains", + "description": "List custom domains for a Railway service", + "inputSchema": { + "type": "object", + "properties": { + "service_id": { + "type": "string", + "description": "Railway service ID" + }, + "environment_id": { + "type": "string", + "description": "Environment ID" + } + }, + "required": [ + "service_id", + "environment_id" + ] + } + }, + { + "name": "railway_add_domain", + "description": "Add a custom domain to a Railway service", + "inputSchema": { + "type": "object", + "properties": { + "service_id": { + "type": "string", + "description": "Railway service ID" + }, + "environment_id": { + "type": "string", + "description": "Environment ID" + }, + "domain": { + "type": "string", + "description": "Custom domain (e.g. example.com)" + } + }, + "required": [ + "service_id", + "environment_id", + "domain" + ] + } + }, + { + "name": "railway_get_logs", + "description": "Retrieve build or deploy logs for a Railway deployment", + "inputSchema": { + "type": "object", + "properties": { + "deployment_id": { + "type": "string", + "description": "Railway deployment ID" + }, + "limit": { + "type": "number", + "description": "Maximum log lines (default 100)" + }, + "filter": { + "type": "string", + "description": "Filter string for log content (optional)" + } + }, + "required": [ + "deployment_id" + ] + } + }, + { + "name": "railway_get_metrics", + "description": "Get resource usage metrics (CPU, memory, network) for a Railway service", + "inputSchema": { + "type": "object", + "properties": { + "service_id": { + "type": "string", + "description": "Railway service ID" + }, + "environment_id": { + "type": "string", + "description": "Environment ID" + }, + "start_date": { + "type": "string", + "description": "Start date ISO 8601 (e.g. 2026-03-01T00:00:00Z)" + }, + "end_date": { + "type": "string", + "description": "End date ISO 8601" + }, + "resolution": { + "type": "string", + "description": "Metric resolution (1m, 5m, 1h, 1d)" + } + }, + "required": [ + "service_id", + "environment_id" + ] + } + } + ], + "ffi": { + "so_path": "ffi/zig-out/lib/librailway_mcp.so", + "abi_version": "ADR-0006", + "symbols": [ + "boj_cartridge_init", + "boj_cartridge_deinit", + "boj_cartridge_name", + "boj_cartridge_version", + "boj_cartridge_invoke" + ] + } +} diff --git a/cartridges/domains/cloud/railway-mcp/ffi/README.adoc b/cartridges/domains/cloud/railway-mcp/ffi/README.adoc new file mode 100644 index 0000000..83d87ac --- /dev/null +++ b/cartridges/domains/cloud/railway-mcp/ffi/README.adoc @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += railway-mcp / ffi β€” Zig FFI layer +:orientation: shallow + +== Purpose + +C-ABI exports bridging this cartridge's Idris2 ABI to the runtime. Consumed by +the adapter for protocol dispatch and by the trunk loader for mount. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” produces `zig-out/lib/librailway.so`. +| `railway_mcp_ffi.zig` | C-ABI exports (17 exports, 6 inline tests, 389 lines). +|=== + +== Invariants + +* **Null-pointer rejection** on `[*c]const u8` arguments before any + dereference. +* **Negative return values** carry documented error meanings; zero is + success (matching the trunk error-code convention). +* **No allocator leaks** β€” every allocation path pairs with `defer free`. + +== Test/proof surface + +6 inline `test "..."` block(s) in `railway_mcp_ffi.zig`. Run with +`cd ffi && zig build test`. + +== Read-first + +. `railway_mcp_ffi.zig` β€” C-ABI signatures and guards. +. Trunk `ffi/zig/src/catalogue.zig` for how this `.so` is loaded. diff --git a/cartridges/domains/cloud/railway-mcp/ffi/build.zig b/cartridges/domains/cloud/railway-mcp/ffi/build.zig new file mode 100644 index 0000000..1ae12e0 --- /dev/null +++ b/cartridges/domains/cloud/railway-mcp/ffi/build.zig @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + // Module + // Shared ADR-0006 invoke-shim module (relative path up to boj-server trunk). + + const shim_mod = b.addModule("cartridge_shim", .{ + + .root_source_file = b.path("../../../ffi/zig/src/cartridge_shim.zig"), + + .target = target, + + .optimize = optimize, + + }); + + const ffi_mod = b.addModule("railway_mcp", .{ + .root_source_file = b.path("railway_mcp_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + ffi_mod.addImport("cartridge_shim", shim_mod); + + // Shared library + const lib = b.addLibrary(.{ + .name = "railway_mcp", + .root_module = ffi_mod, + .linkage = .dynamic, + }); + lib.linkLibC(); + b.installArtifact(lib); + + // Tests + const tests = b.addTest(.{ + .root_module = ffi_mod, + }); + tests.linkLibC(); + + const run_tests = b.addRunArtifact(tests); + const test_step = b.step("test", "Run FFI tests"); + test_step.dependOn(&run_tests.step); +} diff --git a/cartridges/domains/cloud/railway-mcp/ffi/cartridge_shim.zig b/cartridges/domains/cloud/railway-mcp/ffi/cartridge_shim.zig new file mode 100644 index 0000000..0e399db --- /dev/null +++ b/cartridges/domains/cloud/railway-mcp/ffi/cartridge_shim.zig @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// cartridge_shim.zig β€” Shared helpers for the ADR-0006 five-symbol +// cartridge ABI (`boj_cartridge_init / deinit / name / version / invoke`). +// +// The shim centralises the seven-code return convention, NUL-argument +// guards, tool-name comparison, and the buffer-too-small path so each +// cartridge's `boj_cartridge_invoke` can stay short β€” typically a tool +// table plus `shim.writeResult(...)`. +// +// Cartridges import this file by relative path (no build-graph change +// needed). Example: +// +// const shim = @import("../../../ffi/zig/src/cartridge_shim.zig"); +// +// export fn boj_cartridge_invoke( +// tool_name: [*c]const u8, +// json_args: [*c]const u8, +// out_buf: [*c]u8, +// in_out_len: [*c]usize, +// ) callconv(.c) i32 { +// _ = json_args; +// if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; +// const body = if (shim.toolIs(tool_name, "foo")) "{\"result\":{}}" +// else return shim.RC_UNKNOWN_TOOL; +// return shim.writeResult(out_buf, in_out_len, body); +// } + +const std = @import("std"); + +// ── Return codes (ADR-0006 Β§Return codes) ──────────────────────────── +// +// Frozen by ADR-0006. New failure modes compose these via the error +// JSON body β€” the integer surface does not grow without a follow-up ADR. + +pub const RC_SUCCESS: i32 = 0; +pub const RC_UNKNOWN_TOOL: i32 = -1; +pub const RC_BAD_ARGS: i32 = -2; +pub const RC_BUFFER_TOO_SMALL: i32 = -3; +pub const RC_RUNTIME_ERROR: i32 = -4; +pub const RC_PANIC: i32 = -5; +pub const RC_AUTH_DENIED: i32 = -6; + +// ── Invoke-path helpers ────────────────────────────────────────────── + +/// True if any of the three mandatory `boj_cartridge_invoke` output-path +/// pointers is null. Use at the top of every invoke to short-circuit to +/// `RC_BAD_ARGS`. +pub fn invokeArgsNull( + tool_name: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) bool { + return tool_name == null or out_buf == null or in_out_len == null; +} + +/// Compare a C-NUL-terminated tool-name pointer against a Zig string +/// literal. Caller must have already verified `tool_name` is non-null +/// (usually via `invokeArgsNull`). +pub fn toolIs(tool_name: [*c]const u8, expected: []const u8) bool { + const s = std.mem.span(@as([*:0]const u8, @ptrCast(tool_name))); + return std.mem.eql(u8, s, expected); +} + +/// Copy `body` into `out_buf[0..*in_out_len]` (as a capacity) and update +/// `*in_out_len` to the number of bytes written. Returns `RC_SUCCESS`. +/// +/// If `body.len` exceeds the current capacity stored in `*in_out_len`, +/// sets `*in_out_len` to the required size and returns +/// `RC_BUFFER_TOO_SMALL` β€” the caller is then expected to re-allocate +/// and retry, per ADR-0006 Β§Memory ownership. +/// +/// Caller must have already verified that `out_buf` and `in_out_len` +/// are non-null. +pub fn writeResult( + out_buf: [*c]u8, + in_out_len: [*c]usize, + body: []const u8, +) i32 { + const cap = in_out_len.*; + if (body.len > cap) { + in_out_len.* = body.len; + return RC_BUFFER_TOO_SMALL; + } + @memcpy(out_buf[0..body.len], body); + in_out_len.* = body.len; + return RC_SUCCESS; +} + +// ── Tests ──────────────────────────────────────────────────────────── + +test "writeResult: body fits, writes and sets length" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); + try std.testing.expectEqualStrings("hello", buf[0..len]); +} + +test "writeResult: too small returns -3 and sets required length" { + var buf: [2]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_BUFFER_TOO_SMALL, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: exact-fit succeeds" { + var buf: [5]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: empty body" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, ""); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 0), len); +} + +test "toolIs: matches and rejects" { + const name: [*:0]const u8 = "foo"; + try std.testing.expect(toolIs(@ptrCast(name), "foo")); + try std.testing.expect(!toolIs(@ptrCast(name), "bar")); + try std.testing.expect(!toolIs(@ptrCast(name), "foobar")); + try std.testing.expect(!toolIs(@ptrCast(name), "fo")); +} + +test "invokeArgsNull: detects each null slot" { + var buf: [4]u8 = undefined; + var len: usize = 4; + const name: [*:0]const u8 = "x"; + try std.testing.expect(!invokeArgsNull(@ptrCast(name), &buf, &len)); + try std.testing.expect(invokeArgsNull(null, &buf, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), null, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), &buf, null)); +} diff --git a/cartridges/domains/cloud/railway-mcp/ffi/railway_mcp_ffi.zig b/cartridges/domains/cloud/railway-mcp/ffi/railway_mcp_ffi.zig new file mode 100644 index 0000000..d0ecf0e --- /dev/null +++ b/cartridges/domains/cloud/railway-mcp/ffi/railway_mcp_ffi.zig @@ -0,0 +1,525 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// railway_mcp_ffi.zig -- C-ABI FFI implementation for railway-mcp cartridge. +// +// Implements the state machine defined in the Idris2 ABI layer for +// Railway GraphQL API v2 (https://backboard.railway.app/graphql/v2). +// Auth: Bearer token. Thread-safe via std.Thread.Mutex. + +const std = @import("std"); + +// --------------------------------------------------------------------------- +// State machine (matches Idris2 ABI: Unauthenticated=0, Authenticated=1, +// RateLimited=2, Error=3) +// --------------------------------------------------------------------------- + +pub const SessionState = enum(c_int) { + unauthenticated = 0, + authenticated = 1, + rate_limited = 2, + err = 3, +}; + +/// Railway GraphQL API action codes (matches Idris2 RailwayAction). +pub const RailwayAction = enum(c_int) { + list_projects = 0, + get_project = 1, + create_project = 2, + delete_project = 3, + list_services = 4, + get_service = 5, + list_deployments = 6, + get_deployment = 7, + redeploy = 8, + list_variables = 9, + set_variable = 10, + delete_variable = 11, + list_domains = 12, + add_domain = 13, + get_logs = 14, + get_metrics = 15, +}; + +fn isValidTransition(from: SessionState, to: SessionState) bool { + return switch (from) { + .unauthenticated => to == .authenticated or to == .err, + .authenticated => to == .rate_limited or to == .err or to == .unauthenticated, + .rate_limited => to == .authenticated or to == .err, + .err => to == .unauthenticated, + }; +} + +// --------------------------------------------------------------------------- +// Session slots (thread-safe, fixed-size pool) +// --------------------------------------------------------------------------- + +const MAX_SESSIONS: usize = 16; +const TOKEN_BUF_SIZE: usize = 512; + +const SessionSlot = struct { + occupied: bool = false, + state: SessionState = .unauthenticated, + token_buf: [TOKEN_BUF_SIZE]u8 = .{0} ** TOKEN_BUF_SIZE, + token_len: usize = 0, + project_count: c_int = 0, + deployment_ok: c_int = 0, + deployment_fail: c_int = 0, + service_count: c_int = 0, +}; + +var sessions: [MAX_SESSIONS]SessionSlot = .{SessionSlot{}} ** MAX_SESSIONS; +var mutex: std.Thread.Mutex = .{}; + +// --------------------------------------------------------------------------- +// C-ABI exports +// --------------------------------------------------------------------------- + +/// Check if a state transition is valid. Returns 1 (valid) or 0 (invalid). +pub export fn railway_mcp_can_transition(from: c_int, to: c_int) c_int { + const f = std.meta.intToEnum(SessionState, from) catch return 0; + const t = std.meta.intToEnum(SessionState, to) catch return 0; + return if (isValidTransition(f, t)) 1 else 0; +} + +/// Authenticate and open a session. Returns slot index (>= 0) or -1 (no slots). +pub export fn railway_mcp_session_open() c_int { + mutex.lock(); + defer mutex.unlock(); + + for (0..MAX_SESSIONS) |idx| { + const slot = &sessions[idx]; + if (!slot.occupied) { + slot.occupied = true; + slot.state = .authenticated; + slot.token_len = 0; + slot.project_count = 0; + slot.deployment_ok = 0; + slot.deployment_fail = 0; + slot.service_count = 0; + return @intCast(idx); + } + } + return -1; +} + +/// Close a session. Returns 0 on success, -1 if invalid slot, -2 if bad transition. +pub export fn railway_mcp_session_close(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.occupied) return -1; + if (!isValidTransition(slot.state, .unauthenticated)) return -2; + + slot.occupied = false; + slot.state = .unauthenticated; + slot.token_len = 0; + slot.project_count = 0; + slot.deployment_ok = 0; + slot.deployment_fail = 0; + slot.service_count = 0; + return 0; +} + +/// Get the current state of a session. Returns state int or -1 if invalid. +pub export fn railway_mcp_session_state(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.occupied) return -1; + return @intFromEnum(slot.state); +} + +/// Transition to rate-limited state. Returns 0 on success. +pub export fn railway_mcp_rate_limit(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.occupied) return -1; + if (!isValidTransition(slot.state, .rate_limited)) return -2; + + slot.state = .rate_limited; + return 0; +} + +/// Recover from rate-limited back to authenticated. Returns 0 on success. +pub export fn railway_mcp_rate_recover(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.occupied) return -1; + if (!isValidTransition(slot.state, .authenticated)) return -2; + + slot.state = .authenticated; + return 0; +} + +/// Signal an error on a session. Returns 0 on success. +pub export fn railway_mcp_signal_error(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.occupied) return -1; + if (!isValidTransition(slot.state, .err)) return -2; + + slot.state = .err; + return 0; +} + +/// Recover from error back to unauthenticated. Returns 0 on success. +pub export fn railway_mcp_error_recover(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.occupied) return -1; + if (!isValidTransition(slot.state, .unauthenticated)) return -2; + + slot.state = .unauthenticated; + return 0; +} + +/// All Railway actions require auth. Returns 1 always. +pub export fn railway_mcp_action_requires_auth(action: c_int) c_int { + _ = std.meta.intToEnum(RailwayAction, action) catch return 1; + return 1; +} + +/// Get project count for a session. +pub export fn railway_mcp_project_count(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.occupied) return -1; + return slot.project_count; +} + +/// Set project count for a session. +pub export fn railway_mcp_set_project_count(slot_idx: c_int, count: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.occupied) return -1; + slot.project_count = count; + return 0; +} + +/// Get service count for a session. +pub export fn railway_mcp_service_count(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.occupied) return -1; + return slot.service_count; +} + +/// Set service count for a session. +pub export fn railway_mcp_set_service_count(slot_idx: c_int, count: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.occupied) return -1; + slot.service_count = count; + return 0; +} + +/// Get successful deployment count. +pub export fn railway_mcp_deployment_ok(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.occupied) return -1; + return slot.deployment_ok; +} + +/// Get failed deployment count. +pub export fn railway_mcp_deployment_fail(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.occupied) return -1; + return slot.deployment_fail; +} + +/// Set deployment counts. +pub export fn railway_mcp_set_deployment_counts(slot_idx: c_int, ok: c_int, fail: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.occupied) return -1; + slot.deployment_ok = ok; + slot.deployment_fail = fail; + return 0; +} + +/// Reset all sessions (test/debug use only). +pub export fn railway_mcp_reset() void { + mutex.lock(); + defer mutex.unlock(); + sessions = .{SessionSlot{}} ** MAX_SESSIONS; +} + +// --------------------------------------------------------------------------- +// ═══════════════════════════════════════════════════════════════════════ +// Standard ABI (ADR-0005 four symbols + ADR-0006 invoke) +// ═══════════════════════════════════════════════════════════════════════ + +const shim = @import("cartridge_shim.zig"); + +const CARTRIDGE_NAME_PTR: [*:0]const u8 = "railway-mcp"; +const CARTRIDGE_VERSION_PTR: [*:0]const u8 = "0.1.0"; + +export fn boj_cartridge_init() callconv(.c) c_int { + return 0; +} + +export fn boj_cartridge_deinit() callconv(.c) void {} + +export fn boj_cartridge_name() callconv(.c) [*:0]const u8 { + return CARTRIDGE_NAME_PTR; +} + +export fn boj_cartridge_version() callconv(.c) [*:0]const u8 { + return CARTRIDGE_VERSION_PTR; +} + +/// Dispatch the cartridge.json MCP tools. Grade D Alpha stubs. +export fn boj_cartridge_invoke( + tool_name: [*c]const u8, + json_args: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) callconv(.c) i32 { + _ = json_args; + if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; + + const body: []const u8 = if (shim.toolIs(tool_name, "railway_list_projects")) + "{\"result\":{\"items\":[],\"count\":0,\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "railway_get_project")) + "{\"result\":{\"metadata\":{},\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "railway_create_project")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "railway_delete_project")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "railway_list_services")) + "{\"result\":{\"items\":[],\"count\":0,\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "railway_get_service")) + "{\"result\":{\"metadata\":{},\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "railway_create_service")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "railway_restart_service")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "railway_list_deployments")) + "{\"result\":{\"items\":[],\"count\":0,\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "railway_get_deployment")) + "{\"result\":{\"metadata\":{},\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "railway_redeploy")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "railway_rollback")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "railway_list_variables")) + "{\"result\":{\"items\":[],\"count\":0,\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "railway_set_variable")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "railway_delete_variable")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "railway_list_domains")) + "{\"result\":{\"items\":[],\"count\":0,\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "railway_add_domain")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "railway_get_logs")) + "{\"result\":{\"metadata\":{},\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "railway_get_metrics")) + "{\"result\":{\"metadata\":{},\"status\":\"stub\"}}" +else + return shim.RC_UNKNOWN_TOOL; + + return shim.writeResult(out_buf, in_out_len, body); +} + +// Tests +// --------------------------------------------------------------------------- + +test "session lifecycle" { + railway_mcp_reset(); + + const slot = railway_mcp_session_open(); + try std.testing.expect(slot >= 0); + + // Should be authenticated after open + try std.testing.expectEqual(@as(c_int, 1), railway_mcp_session_state(slot)); + + // Rate limit then recover + try std.testing.expectEqual(@as(c_int, 0), railway_mcp_rate_limit(slot)); + try std.testing.expectEqual(@as(c_int, 2), railway_mcp_session_state(slot)); + try std.testing.expectEqual(@as(c_int, 0), railway_mcp_rate_recover(slot)); + try std.testing.expectEqual(@as(c_int, 1), railway_mcp_session_state(slot)); + + // Close + try std.testing.expectEqual(@as(c_int, 0), railway_mcp_session_close(slot)); +} + +test "invalid transitions rejected" { + railway_mcp_reset(); + + const slot = railway_mcp_session_open(); + try std.testing.expect(slot >= 0); + + // Cannot close while rate-limited + try std.testing.expectEqual(@as(c_int, 0), railway_mcp_rate_limit(slot)); + try std.testing.expectEqual(@as(c_int, -2), railway_mcp_session_close(slot)); +} + +test "transition validator" { + try std.testing.expectEqual(@as(c_int, 1), railway_mcp_can_transition(0, 1)); + try std.testing.expectEqual(@as(c_int, 1), railway_mcp_can_transition(1, 2)); + try std.testing.expectEqual(@as(c_int, 1), railway_mcp_can_transition(2, 1)); + try std.testing.expectEqual(@as(c_int, 1), railway_mcp_can_transition(1, 3)); + try std.testing.expectEqual(@as(c_int, 1), railway_mcp_can_transition(3, 0)); + + try std.testing.expectEqual(@as(c_int, 0), railway_mcp_can_transition(0, 2)); + try std.testing.expectEqual(@as(c_int, 0), railway_mcp_can_transition(2, 0)); + try std.testing.expectEqual(@as(c_int, 0), railway_mcp_can_transition(3, 1)); + try std.testing.expectEqual(@as(c_int, 0), railway_mcp_can_transition(99, 0)); +} + +test "slot exhaustion" { + railway_mcp_reset(); + + var slots: [MAX_SESSIONS]c_int = undefined; + for (0..MAX_SESSIONS) |idx| { + slots[idx] = railway_mcp_session_open(); + try std.testing.expect(slots[idx] >= 0); + } + + try std.testing.expectEqual(@as(c_int, -1), railway_mcp_session_open()); + + try std.testing.expectEqual(@as(c_int, 0), railway_mcp_session_close(slots[0])); + const new_slot = railway_mcp_session_open(); + try std.testing.expect(new_slot >= 0); +} + +test "project and service counters" { + railway_mcp_reset(); + + const slot = railway_mcp_session_open(); + try std.testing.expect(slot >= 0); + + try std.testing.expectEqual(@as(c_int, 0), railway_mcp_project_count(slot)); + try std.testing.expectEqual(@as(c_int, 0), railway_mcp_set_project_count(slot, 3)); + try std.testing.expectEqual(@as(c_int, 3), railway_mcp_project_count(slot)); + + try std.testing.expectEqual(@as(c_int, 0), railway_mcp_service_count(slot)); + try std.testing.expectEqual(@as(c_int, 0), railway_mcp_set_service_count(slot, 7)); + try std.testing.expectEqual(@as(c_int, 7), railway_mcp_service_count(slot)); +} + +test "deployment counters" { + railway_mcp_reset(); + + const slot = railway_mcp_session_open(); + try std.testing.expect(slot >= 0); + + try std.testing.expectEqual(@as(c_int, 0), railway_mcp_deployment_ok(slot)); + try std.testing.expectEqual(@as(c_int, 0), railway_mcp_deployment_fail(slot)); + try std.testing.expectEqual(@as(c_int, 0), railway_mcp_set_deployment_counts(slot, 10, 2)); + try std.testing.expectEqual(@as(c_int, 10), railway_mcp_deployment_ok(slot)); + try std.testing.expectEqual(@as(c_int, 2), railway_mcp_deployment_fail(slot)); +} + +// ═══════════════════════════════════════════════════════════════════════ +// ADR-0006 invoke dispatch tests +// ═══════════════════════════════════════════════════════════════════════ + +test "boj_cartridge_name returns railway-mcp" { + const n = std.mem.span(boj_cartridge_name()); + try std.testing.expectEqualStrings("railway-mcp", n); +} + +test "boj_cartridge_init returns 0" { + try std.testing.expectEqual(@as(c_int, 0), boj_cartridge_init()); +} + +test "invoke: each declared tool succeeds" { + var buf: [256]u8 = undefined; + const tools = [_][]const u8{ + "railway_list_projects", + "railway_get_project", + "railway_create_project", + "railway_delete_project", + "railway_list_services", + "railway_get_service", + "railway_create_service", + "railway_restart_service", + "railway_list_deployments", + "railway_get_deployment", + "railway_redeploy", + "railway_rollback", + "railway_list_variables", + "railway_set_variable", + "railway_delete_variable", + "railway_list_domains", + "railway_add_domain", + "railway_get_logs", + "railway_get_metrics", + }; + for (tools) |t| { + var len: usize = buf.len; + const rc = boj_cartridge_invoke(t.ptr, "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, 0), rc); + try std.testing.expect(std.mem.indexOf(u8, buf[0..len], "result") != null); + } +} + +test "invoke: unknown tool returns -1" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("nope", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -1), rc); +} + +test "invoke: buffer too small returns -3" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("railway_list_projects", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -3), rc); + try std.testing.expect(len > 4); +} diff --git a/cartridges/domains/cloud/railway-mcp/minter.toml b/cartridges/domains/cloud/railway-mcp/minter.toml new file mode 100644 index 0000000..11875ab --- /dev/null +++ b/cartridges/domains/cloud/railway-mcp/minter.toml @@ -0,0 +1,39 @@ +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> + +name = "railway-mcp" +description = "Railway GraphQL API v2 cartridge -- projects, services, deployments, variables, domains, logs, metrics" +version = "0.1.0" +domain = "Cloud" +protocols = [ + "MCP", + "GraphQL", +] +tier = "Ayo" +backend = "universal" +generate_panel = true + +[auth] +method = "bearer" +token_env = "RAILWAY_API_TOKEN" +base_url = "https://backboard.railway.app/graphql/v2" + +[actions] +list = [ + "ListProjects", + "GetProject", + "CreateProject", + "DeleteProject", + "ListServices", + "GetService", + "ListDeployments", + "GetDeployment", + "Redeploy", + "ListVariables", + "SetVariable", + "DeleteVariable", + "ListDomains", + "AddDomain", + "GetLogs", + "GetMetrics", +] diff --git a/cartridges/domains/cloud/railway-mcp/mod.js b/cartridges/domains/cloud/railway-mcp/mod.js new file mode 100644 index 0000000..2567e7c --- /dev/null +++ b/cartridges/domains/cloud/railway-mcp/mod.js @@ -0,0 +1,539 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// railway-mcp/mod.js -- Railway GraphQL API v2 cartridge implementation. +// +// Provides MCP tool handlers for the Railway GraphQL API: +// - Project management (list, get, create, delete) +// - Service management (list, get, create, restart) +// - Deployment management (list, get, redeploy, rollback) +// - Environment variables (list, set, delete) +// - Domains (list, add) +// - Logs (retrieve) +// - Metrics (CPU, memory, network) +// +// Auth: Bearer token via RAILWAY_TOKEN env var or vault-mcp proxy. +// API endpoint: https://backboard.railway.app/graphql/v2 +// API docs: https://docs.railway.app/reference/public-api +// +// Railway's entire public API is GraphQL-only (no REST endpoints). +// All operations use POST to the single GraphQL endpoint. +// +// Usage: import { handleTool } from "./mod.js"; +// or: deno run --allow-net --allow-env mod.js + +const API_BASE = "https://backboard.railway.app/graphql/v2"; + +// --------------------------------------------------------------------------- +// Auth helper β€” retrieves the Railway API token from environment. +// In production, vault-mcp provides zero-knowledge credential proxying; +// for development, RAILWAY_TOKEN is read directly. +// --------------------------------------------------------------------------- + +function getToken() { + const token = typeof Deno !== "undefined" + ? Deno.env.get("RAILWAY_TOKEN") + : process.env.RAILWAY_TOKEN; + if (!token) { + throw new Error("RAILWAY_TOKEN not set. Generate at https://railway.app/account/tokens or export RAILWAY_TOKEN."); + } + return token; +} + +// --------------------------------------------------------------------------- +// GraphQL request helper β€” wraps fetch with Railway auth headers, error +// handling, and structured responses. All Railway API calls go through +// this single function since the entire API is GraphQL. +// --------------------------------------------------------------------------- + +async function railwayGQL(query, variables) { + const token = getToken(); + + const response = await fetch(API_BASE, { + method: "POST", + headers: { + "Authorization": `Bearer ${token}`, + "Content-Type": "application/json", + "User-Agent": "boj-server/railway-mcp/0.1.0", + }, + body: JSON.stringify({ + query, + variables: variables || {}, + }), + }); + + const data = await response.json(); + + // Surface GraphQL-level errors + if (data.errors && data.errors.length > 0) { + const messages = data.errors.map((e) => e.message).join("; "); + return { + status: response.status, + error: messages, + data: data.data || null, + graphqlErrors: data.errors, + }; + } + + if (!response.ok) { + return { + status: response.status, + error: `HTTP ${response.status}`, + data: data.data || null, + }; + } + + return { status: response.status, data: data.data }; +} + +// --------------------------------------------------------------------------- +// Tool handler dispatch β€” maps MCP tool names to Railway GraphQL operations. +// Each handler validates required arguments, builds the GraphQL query, +// and returns structured results. +// --------------------------------------------------------------------------- + +export async function handleTool(toolName, args) { + switch (toolName) { + + // --- Projects --- + + case "railway_list_projects": { + const limit = args.limit || 25; + const query = ` + query($after: String) { + me { + projects(first: ${limit}, after: $after) { + edges { + node { + id + name + description + createdAt + updatedAt + isPublic + environments { + edges { + node { id name } + } + } + services { + edges { + node { id name } + } + } + } + } + pageInfo { + hasNextPage + endCursor + } + } + } + } + `; + return railwayGQL(query, { after: args.after || null }); + } + + case "railway_get_project": { + if (!args.project_id) return { error: "Missing required field: project_id" }; + const query = ` + query($projectId: String!) { + project(id: $projectId) { + id + name + description + createdAt + updatedAt + isPublic + environments { + edges { + node { id name } + } + } + services { + edges { + node { + id + name + createdAt + updatedAt + } + } + } + } + } + `; + return railwayGQL(query, { projectId: args.project_id }); + } + + case "railway_create_project": { + if (!args.name) return { error: "Missing required field: name" }; + const query = ` + mutation($input: ProjectCreateInput!) { + projectCreate(input: $input) { + id + name + description + createdAt + environments { + edges { + node { id name } + } + } + } + } + `; + const input = { name: args.name }; + if (args.description) input.description = args.description; + if (args.default_environment_name) input.defaultEnvironmentName = args.default_environment_name; + if (args.is_public !== undefined) input.isPublic = args.is_public; + if (args.repo) input.repo = args.repo; + return railwayGQL(query, { input }); + } + + case "railway_delete_project": { + if (!args.project_id) return { error: "Missing required field: project_id" }; + const query = ` + mutation($id: String!) { + projectDelete(id: $id) + } + `; + return railwayGQL(query, { id: args.project_id }); + } + + // --- Services --- + + case "railway_list_services": { + if (!args.project_id) return { error: "Missing required field: project_id" }; + const query = ` + query($projectId: String!) { + project(id: $projectId) { + services { + edges { + node { + id + name + createdAt + updatedAt + icon + } + } + } + } + } + `; + return railwayGQL(query, { projectId: args.project_id }); + } + + case "railway_get_service": { + if (!args.service_id) return { error: "Missing required field: service_id" }; + const query = ` + query($serviceId: String!) { + service(id: $serviceId) { + id + name + createdAt + updatedAt + icon + projectId + } + } + `; + return railwayGQL(query, { serviceId: args.service_id }); + } + + case "railway_create_service": { + if (!args.project_id) return { error: "Missing required field: project_id" }; + if (!args.name) return { error: "Missing required field: name" }; + const query = ` + mutation($input: ServiceCreateInput!) { + serviceCreate(input: $input) { + id + name + createdAt + projectId + } + } + `; + const input = { + projectId: args.project_id, + name: args.name, + }; + if (args.source) input.source = args.source; + return railwayGQL(query, { input }); + } + + case "railway_restart_service": { + if (!args.service_id) return { error: "Missing required field: service_id" }; + if (!args.environment_id) return { error: "Missing required field: environment_id" }; + const query = ` + mutation($serviceId: String!, $environmentId: String!) { + serviceInstanceRedeploy(serviceId: $serviceId, environmentId: $environmentId) + } + `; + return railwayGQL(query, { + serviceId: args.service_id, + environmentId: args.environment_id, + }); + } + + // --- Deployments --- + + case "railway_list_deployments": { + if (!args.service_id) return { error: "Missing required field: service_id" }; + const limit = args.limit || 10; + const query = ` + query($input: DeploymentListInput!) { + deployments(input: $input, first: ${limit}) { + edges { + node { + id + status + createdAt + updatedAt + staticUrl + } + } + pageInfo { + hasNextPage + endCursor + } + } + } + `; + const input = { serviceId: args.service_id }; + if (args.environment_id) input.environmentId = args.environment_id; + if (args.status) input.status = { in: [args.status] }; + return railwayGQL(query, { input }); + } + + case "railway_get_deployment": { + if (!args.deployment_id) return { error: "Missing required field: deployment_id" }; + const query = ` + query($id: String!) { + deployment(id: $id) { + id + status + createdAt + updatedAt + staticUrl + meta + } + } + `; + return railwayGQL(query, { id: args.deployment_id }); + } + + case "railway_redeploy": { + if (!args.service_id) return { error: "Missing required field: service_id" }; + if (!args.environment_id) return { error: "Missing required field: environment_id" }; + const query = ` + mutation($serviceId: String!, $environmentId: String!) { + serviceInstanceRedeploy(serviceId: $serviceId, environmentId: $environmentId) + } + `; + return railwayGQL(query, { + serviceId: args.service_id, + environmentId: args.environment_id, + }); + } + + case "railway_rollback": { + if (!args.deployment_id) return { error: "Missing required field: deployment_id" }; + const query = ` + mutation($id: String!) { + deploymentRollback(id: $id) { + id + status + createdAt + } + } + `; + return railwayGQL(query, { id: args.deployment_id }); + } + + // --- Environment Variables --- + + case "railway_list_variables": { + if (!args.project_id) return { error: "Missing required field: project_id" }; + if (!args.service_id) return { error: "Missing required field: service_id" }; + if (!args.environment_id) return { error: "Missing required field: environment_id" }; + const query = ` + query($projectId: String!, $serviceId: String!, $environmentId: String!) { + variables( + projectId: $projectId, + serviceId: $serviceId, + environmentId: $environmentId + ) + } + `; + return railwayGQL(query, { + projectId: args.project_id, + serviceId: args.service_id, + environmentId: args.environment_id, + }); + } + + case "railway_set_variable": { + if (!args.project_id) return { error: "Missing required field: project_id" }; + if (!args.service_id) return { error: "Missing required field: service_id" }; + if (!args.environment_id) return { error: "Missing required field: environment_id" }; + if (!args.name) return { error: "Missing required field: name" }; + if (args.value === undefined) return { error: "Missing required field: value" }; + const query = ` + mutation($input: VariableCollectionUpsertInput!) { + variableCollectionUpsert(input: $input) + } + `; + return railwayGQL(query, { + input: { + projectId: args.project_id, + serviceId: args.service_id, + environmentId: args.environment_id, + variables: { [args.name]: args.value }, + }, + }); + } + + case "railway_delete_variable": { + if (!args.project_id) return { error: "Missing required field: project_id" }; + if (!args.service_id) return { error: "Missing required field: service_id" }; + if (!args.environment_id) return { error: "Missing required field: environment_id" }; + if (!args.name) return { error: "Missing required field: name" }; + const query = ` + mutation($input: VariableDeleteInput!) { + variableDelete(input: $input) + } + `; + return railwayGQL(query, { + input: { + projectId: args.project_id, + serviceId: args.service_id, + environmentId: args.environment_id, + name: args.name, + }, + }); + } + + // --- Domains --- + + case "railway_list_domains": { + if (!args.service_id) return { error: "Missing required field: service_id" }; + if (!args.environment_id) return { error: "Missing required field: environment_id" }; + const query = ` + query($serviceId: String!, $environmentId: String!) { + customDomains(serviceId: $serviceId, environmentId: $environmentId) { + id + domain + status { + dnsRecords { + hostlabel + requiredValue + currentValue + zone + status + } + } + createdAt + } + } + `; + return railwayGQL(query, { + serviceId: args.service_id, + environmentId: args.environment_id, + }); + } + + case "railway_add_domain": { + if (!args.service_id) return { error: "Missing required field: service_id" }; + if (!args.environment_id) return { error: "Missing required field: environment_id" }; + if (!args.domain) return { error: "Missing required field: domain" }; + const query = ` + mutation($input: CustomDomainCreateInput!) { + customDomainCreate(input: $input) { + id + domain + createdAt + } + } + `; + return railwayGQL(query, { + input: { + serviceId: args.service_id, + environmentId: args.environment_id, + domain: args.domain, + }, + }); + } + + // --- Logs --- + + case "railway_get_logs": { + if (!args.deployment_id) return { error: "Missing required field: deployment_id" }; + const limit = args.limit || 100; + const query = ` + query($deploymentId: String!, $limit: Int, $filter: String) { + deploymentLogs(deploymentId: $deploymentId, limit: $limit, filter: $filter) { + message + timestamp + severity + attributes + } + } + `; + return railwayGQL(query, { + deploymentId: args.deployment_id, + limit, + filter: args.filter || null, + }); + } + + // --- Metrics --- + + case "railway_get_metrics": { + if (!args.service_id) return { error: "Missing required field: service_id" }; + if (!args.environment_id) return { error: "Missing required field: environment_id" }; + const query = ` + query($serviceId: String!, $environmentId: String!, $startDate: DateTime!, $endDate: DateTime!) { + metrics( + serviceId: $serviceId, + environmentId: $environmentId, + startDate: $startDate, + endDate: $endDate + ) { + cpuUsage { date value } + memoryUsageMb { date value } + networkRxMb { date value } + networkTxMb { date value } + } + } + `; + // Default to last 24 hours if no dates specified + const endDate = args.end_date || new Date().toISOString(); + const startDate = args.start_date || new Date(Date.now() - 86400000).toISOString(); + return railwayGQL(query, { + serviceId: args.service_id, + environmentId: args.environment_id, + startDate, + endDate, + }); + } + + default: + return { error: `Unknown railway-mcp tool: ${toolName}` }; + } +} + +// --------------------------------------------------------------------------- +// Cartridge metadata export β€” used by the BoJ cartridge loader to register +// this cartridge's tools without reading cartridge.json separately. +// --------------------------------------------------------------------------- + +export const metadata = { + name: "railway-mcp", + version: "0.1.0", + domain: "Cloud", + tier: "Ayo", + protocols: ["MCP", "GraphQL"], + toolCount: 19, +}; diff --git a/cartridges/domains/cloud/railway-mcp/panels/manifest.json b/cartridges/domains/cloud/railway-mcp/panels/manifest.json new file mode 100644 index 0000000..5802aea --- /dev/null +++ b/cartridges/domains/cloud/railway-mcp/panels/manifest.json @@ -0,0 +1,90 @@ +{ + "$schema": "https://panll.dev/schemas/panel-manifest/v1.json", + "spdx": "MPL-2.0", + "cartridge": "railway-mcp", + "domain": "Cloud", + "version": "0.1.0", + "panels": [ + { + "id": "railway-auth-status", + "title": "Auth Status", + "description": "Current Railway authentication state (Unauthenticated / Authenticated / RateLimited / Error)", + "type": "status-indicator", + "data_source": { + "endpoint": "/railway/state", + "method": "GET", + "refresh_interval_ms": 5000 + }, + "widgets": [ + { + "type": "state-badge", + "field": "state", + "states": { + "unauthenticated": { "color": "#95a5a6", "icon": "plug-disconnected" }, + "authenticated": { "color": "#2ecc71", "icon": "plug-connected" }, + "rate_limited": { "color": "#e67e22", "icon": "clock" }, + "error": { "color": "#e74c3c", "icon": "alert-triangle" } + } + } + ] + }, + { + "id": "railway-project-count", + "title": "Project Count", + "description": "Number of Railway projects in the authenticated account", + "type": "metric", + "data_source": { + "endpoint": "/railway/metrics", + "method": "GET", + "refresh_interval_ms": 30000 + }, + "widgets": [ + { + "type": "counter", + "field": "project_count", + "label": "Projects", + "icon": "folder" + } + ] + }, + { + "id": "railway-deployment-status", + "title": "Deployment Status", + "description": "Deployment success and failure counts across all Railway services", + "type": "multi-metric", + "data_source": { + "endpoint": "/railway/metrics", + "method": "GET", + "refresh_interval_ms": 30000 + }, + "widgets": [ + { + "type": "progress-bar", + "items": [ + { "field": "deployment_ok", "max_field": "deployment_total", "label": "Successful", "color": "#2ecc71" }, + { "field": "deployment_fail", "max_field": "deployment_total", "label": "Failed", "color": "#e74c3c" } + ] + } + ] + }, + { + "id": "railway-service-health", + "title": "Service Health", + "description": "Health overview of Railway services", + "type": "metric", + "data_source": { + "endpoint": "/railway/metrics", + "method": "GET", + "refresh_interval_ms": 30000 + }, + "widgets": [ + { + "type": "counter", + "field": "service_count", + "label": "Services", + "icon": "server" + } + ] + } + ] +} diff --git a/cartridges/domains/cloud/railway-mcp/tests/integration_test.sh b/cartridges/domains/cloud/railway-mcp/tests/integration_test.sh new file mode 100755 index 0000000..0ecadf8 --- /dev/null +++ b/cartridges/domains/cloud/railway-mcp/tests/integration_test.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Integration tests for railway-mcp cartridge. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CART_DIR="$(dirname "$SCRIPT_DIR")" +FFI_DIR="$CART_DIR/ffi" + +echo "=== railway-mcp integration tests ===" + +# Build FFI +echo "[1/3] Building FFI..." +cd "$FFI_DIR" && zig build 2>&1 + +# Run Zig unit tests +echo "[2/3] Running FFI unit tests..." +cd "$FFI_DIR" && zig build test 2>&1 + +# Validate Idris2 ABI (if idris2 available) +echo "[3/3] Checking ABI..." +if command -v idris2 &>/dev/null; then + cd "$CART_DIR/abi" && idris2 --check RailwayMcp.SafeCloud 2>&1 + echo " ABI: OK" +else + echo " ABI: SKIPPED (idris2 not in PATH)" +fi + +echo "" +echo "All tests passed for railway-mcp!" diff --git a/cartridges/domains/cloud/render-mcp/README.adoc b/cartridges/domains/cloud/render-mcp/README.adoc new file mode 100644 index 0000000..e79e0d6 --- /dev/null +++ b/cartridges/domains/cloud/render-mcp/README.adoc @@ -0,0 +1,72 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += render-mcp +Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk> +:spdx: MPL-2.0 +:tier: Ayo +:domain: Cloud +:protocols: MCP, REST + +== Overview + +Render REST API v1 cartridge for BoJ. Provides type-safe access to services, +deploys, environment groups, custom domains, jobs, and bandwidth metrics via +the REST API at `https://api.render.com/v1/`. Authentication uses a Bearer token +(Render API key). Rate limited to 100 requests per minute. + +== State Machine + +`Unauthenticated` -> `Authenticated` -> `RateLimited` -> `Error` + +Valid transitions are enforced by the Idris2 ABI layer with dependent-type proofs. + +== Actions + +ListServices, GetService, CreateService, DeleteService, ListDeploys, +TriggerDeploy, GetDeploy, ListEnvGroups, GetEnvGroup, ListCustomDomains, +AddCustomDomain, ListJobs, CreateJob, SuspendService, ResumeService, +GetBandwidth. + +== Architecture + +[cols="1,1,2"] +|=== +| Layer | Language | Purpose + +| ABI +| Idris2 +| Formally verified state machine with dependent-type proofs + +| FFI +| Zig +| C-compatible implementation with thread-safe session pool + +| Adapter +| zig +| REST bridge to BoJ unified adapter protocol +|=== + +== Panels + +* Auth status (Unauthenticated / Authenticated / RateLimited / Error) +* Service count +* Deploy status (success / failure counts) +* Bandwidth usage (MB) + +== Building + +[source,bash] +---- +# Build FFI shared library +cd ffi && zig build + +# Run FFI tests +cd ffi && zig build test + +# Type-check ABI +cd abi && idris2 --check RenderMcp.SafeCloud +---- + +== Status + +Development -- not yet ready for mounting. diff --git a/cartridges/domains/cloud/render-mcp/abi/README.adoc b/cartridges/domains/cloud/render-mcp/abi/README.adoc new file mode 100644 index 0000000..2ea02ca --- /dev/null +++ b/cartridges/domains/cloud/render-mcp/abi/README.adoc @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += render-mcp / abi β€” Idris2 ABI layer +:orientation: shallow + +== Purpose + +Cartridge-local protocol types for `render-mcp`. Defines the data, tool, and +transition vocabulary this cartridge speaks. Inherits safety lemmas and the +matrix-cell identifier scheme from the trunk ABI at `src/abi/`. + +== Files + +1 Idris2 module(s), ~186 lines total. Lead module: +`SafeCloud.idr`. + +No `.ipkg` β€” consumed directly by wider BoJ callers; type-checks inside the +trunk `boj.ipkg`. + +== Invariants + +* `%default total` at every module top. +* Zero `believe_me`, `assert_total`, `postulate`, `Admitted`, or + `sorry` (the trunk holds the four declared `believe_me` sites; leaf + cartridges carry none). + +== Test/proof surface + +Thin module(s), no inline tests. Proof surface = type-check success under the +trunk `idris2 --check`. + +== Read-first + +. `SafeCloud.idr` β€” cartridge-local data records and enums. +. Trunk `src/abi/Boj/Catalogue.idr` for how this cell participates in the + 2D matrix. diff --git a/cartridges/domains/cloud/render-mcp/abi/RenderMcp/SafeCloud.idr b/cartridges/domains/cloud/render-mcp/abi/RenderMcp/SafeCloud.idr new file mode 100644 index 0000000..029703d --- /dev/null +++ b/cartridges/domains/cloud/render-mcp/abi/RenderMcp/SafeCloud.idr @@ -0,0 +1,186 @@ +-- SPDX-License-Identifier: MPL-2.0 +-- Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +-- +-- RenderMcp.SafeCloud -- Type-safe ABI for render-mcp cartridge (Render REST API). +-- +-- State machine with dependent-type proofs ensuring only valid transitions +-- can occur at the FFI boundary. Zero unsafe escape hatches. +-- Auth: Bearer token (API key), REST API (https://api.render.com/v1/). +-- Rate limit: 100 req/min. + +module RenderMcp.SafeCloud + +%default total + +-- --------------------------------------------------------------------------- +-- Authentication / session state machine +-- --------------------------------------------------------------------------- + +||| Authentication and session state for Render REST API operations. +public export +data SessionState + = Unauthenticated + | Authenticated + | RateLimited + | Error + +||| Proof that a state transition is valid. +public export +data ValidTransition : SessionState -> SessionState -> Type where + Authenticate : ValidTransition Unauthenticated Authenticated + BeginRateLimit : ValidTransition Authenticated RateLimited + EndRateLimit : ValidTransition RateLimited Authenticated + AuthError : ValidTransition Unauthenticated Error + OpError : ValidTransition Authenticated Error + RateError : ValidTransition RateLimited Error + RecoverAuth : ValidTransition Error Unauthenticated + Deauthenticate : ValidTransition Authenticated Unauthenticated + +-- --------------------------------------------------------------------------- +-- C-ABI integer encoding +-- --------------------------------------------------------------------------- + +||| Encode session state as C-compatible integer. +export +sessionStateToInt : SessionState -> Int +sessionStateToInt Unauthenticated = 0 +sessionStateToInt Authenticated = 1 +sessionStateToInt RateLimited = 2 +sessionStateToInt Error = 3 + +||| Decode integer back to session state. +export +intToSessionState : Int -> Maybe SessionState +intToSessionState 0 = Just Unauthenticated +intToSessionState 1 = Just Authenticated +intToSessionState 2 = Just RateLimited +intToSessionState 3 = Just Error +intToSessionState _ = Nothing + +||| Check if a state transition is valid (C-ABI export). +||| Returns 1 for valid, 0 for invalid. +export +render_mcp_can_transition : Int -> Int -> Int +render_mcp_can_transition from to = + case (intToSessionState from, intToSessionState to) of + (Just Unauthenticated, Just Authenticated) => 1 + (Just Authenticated, Just RateLimited) => 1 + (Just RateLimited, Just Authenticated) => 1 + (Just Unauthenticated, Just Error) => 1 + (Just Authenticated, Just Error) => 1 + (Just RateLimited, Just Error) => 1 + (Just Error, Just Unauthenticated) => 1 + (Just Authenticated, Just Unauthenticated) => 1 + _ => 0 + +-- --------------------------------------------------------------------------- +-- Render API actions +-- --------------------------------------------------------------------------- + +||| Actions available on the Render REST API v1. +public export +data RenderAction + = ListServices + | GetService + | CreateService + | DeleteService + | ListDeploys + | TriggerDeploy + | GetDeploy + | ListEnvGroups + | GetEnvGroup + | ListCustomDomains + | AddCustomDomain + | ListJobs + | CreateJob + | SuspendService + | ResumeService + | GetBandwidth + +||| Encode action as C-compatible integer for FFI. +export +renderActionToInt : RenderAction -> Int +renderActionToInt ListServices = 0 +renderActionToInt GetService = 1 +renderActionToInt CreateService = 2 +renderActionToInt DeleteService = 3 +renderActionToInt ListDeploys = 4 +renderActionToInt TriggerDeploy = 5 +renderActionToInt GetDeploy = 6 +renderActionToInt ListEnvGroups = 7 +renderActionToInt GetEnvGroup = 8 +renderActionToInt ListCustomDomains = 9 +renderActionToInt AddCustomDomain = 10 +renderActionToInt ListJobs = 11 +renderActionToInt CreateJob = 12 +renderActionToInt SuspendService = 13 +renderActionToInt ResumeService = 14 +renderActionToInt GetBandwidth = 15 + +||| Decode integer back to action. +export +intToRenderAction : Int -> Maybe RenderAction +intToRenderAction 0 = Just ListServices +intToRenderAction 1 = Just GetService +intToRenderAction 2 = Just CreateService +intToRenderAction 3 = Just DeleteService +intToRenderAction 4 = Just ListDeploys +intToRenderAction 5 = Just TriggerDeploy +intToRenderAction 6 = Just GetDeploy +intToRenderAction 7 = Just ListEnvGroups +intToRenderAction 8 = Just GetEnvGroup +intToRenderAction 9 = Just ListCustomDomains +intToRenderAction 10 = Just AddCustomDomain +intToRenderAction 11 = Just ListJobs +intToRenderAction 12 = Just CreateJob +intToRenderAction 13 = Just SuspendService +intToRenderAction 14 = Just ResumeService +intToRenderAction 15 = Just GetBandwidth +intToRenderAction _ = Nothing + +||| Whether an action requires Authenticated state. +||| All Render actions require authentication. +export +actionRequiresAuth : RenderAction -> Bool +actionRequiresAuth _ = True + +||| Total number of actions exposed by this cartridge. +export +actionCount : Nat +actionCount = 16 + +-- --------------------------------------------------------------------------- +-- Rate limit metadata +-- --------------------------------------------------------------------------- + +||| Render API rate limit: 100 requests per minute. +export +rateLimitPerMinute : Nat +rateLimitPerMinute = 100 + +-- --------------------------------------------------------------------------- +-- MCP tool declarations +-- --------------------------------------------------------------------------- + +||| Tools exposed via MCP protocol. +public export +data McpTool + = ToolAuthenticate + | ToolDeauthenticate + | ToolStatus + | ToolInvoke + | ToolList + +||| Check if a tool requires an authenticated session. +export +toolRequiresAuth : McpTool -> Bool +toolRequiresAuth ToolAuthenticate = False +toolRequiresAuth ToolDeauthenticate = True +toolRequiresAuth ToolStatus = False +toolRequiresAuth ToolInvoke = True +toolRequiresAuth ToolList = False + +||| Tool count for this cartridge. +export +toolCount : Nat +toolCount = 5 diff --git a/cartridges/domains/cloud/render-mcp/abi/render_mcp.ipkg b/cartridges/domains/cloud/render-mcp/abi/render_mcp.ipkg new file mode 100644 index 0000000..e9eed56 --- /dev/null +++ b/cartridges/domains/cloud/render-mcp/abi/render_mcp.ipkg @@ -0,0 +1,13 @@ +-- SPDX-License-Identifier: MPL-2.0 +-- Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +package render_mcp + +version = "0.1.0" +authors = "Jonathan D.A. Jewell" +brief = "Render REST API v1 cartridge -- type-safe ABI with dependent-type proofs" + +depends = base + +sourcedir = "." + +modules = RenderMcp.SafeCloud diff --git a/cartridges/domains/cloud/render-mcp/adapter/README.adoc b/cartridges/domains/cloud/render-mcp/adapter/README.adoc new file mode 100644 index 0000000..2b40487 --- /dev/null +++ b/cartridges/domains/cloud/render-mcp/adapter/README.adoc @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += render-mcp / adapter β€” protocol bridge +:orientation: shallow + +== Purpose + +Exposes this cartridge's FFI over the wire protocols declared in +`cartridge.json`. Dispatches tool invocations to the C-ABI exports at +`../ffi/` and formats responses per protocol. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” depends on the sibling `ffi` target. +| `render_adapter.zig` | Protocol dispatch (228 lines). Tool catalogue matches `cartridge.json`. +| `SIDELINED-*.v.adoc` | zig predecessor(s). Not built β€” zig banned 2026-04-10. +|=== + +== Invariants + +* **Stateless** β€” all state lives behind the FFI, never in the adapter. +* **Response passthrough** β€” whatever the FFI returns goes back to the wire + unmodified (no embellishment, no silent recovery). +* **Cartridge.json is source of truth** for the tool catalogue; drift between + adapter and manifest is a CI failure. + +== Test/proof surface + +No inline tests at the adapter layer β€” protocol routing is exercised by the +trunk seam tests at `ffi/zig/src/seams.zig`. + +== Read-first + +. `render_adapter.zig` β€” dispatch function and per-protocol routers. +. `../cartridge.json` β€” the tool manifest the dispatcher mirrors. diff --git a/cartridges/domains/cloud/render-mcp/adapter/build.zig b/cartridges/domains/cloud/render-mcp/adapter/build.zig new file mode 100644 index 0000000..9a548bf --- /dev/null +++ b/cartridges/domains/cloud/render-mcp/adapter/build.zig @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// render-mcp/adapter/build.zig + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const ffi_mod = b.createModule(.{ + .root_source_file = b.path("../ffi/render_mcp_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + const adapter = b.addExecutable(.{ + .name = "render_adapter", + .root_source_file = b.path("render_adapter.zig"), + .target = target, + .optimize = optimize, + }); + adapter.root_module.addImport("render_mcp_ffi", ffi_mod); + b.installArtifact(adapter); + + const run_artifact = b.addRunArtifact(adapter); + const run_step = b.step("run", "Run the render-mcp adapter"); + run_step.dependOn(&run_artifact.step); + + const tests = b.addTest(.{ + .root_source_file = b.path("render_adapter.zig"), + .target = target, + .optimize = optimize, + }); + tests.root_module.addImport("render_mcp_ffi", ffi_mod); + const run_tests = b.addRunArtifact(tests); + const test_step = b.step("test", "Run render-mcp adapter tests"); + test_step.dependOn(&run_tests.step); +} diff --git a/cartridges/domains/cloud/render-mcp/adapter/render_adapter.zig b/cartridges/domains/cloud/render-mcp/adapter/render_adapter.zig new file mode 100644 index 0000000..a4c500f --- /dev/null +++ b/cartridges/domains/cloud/render-mcp/adapter/render_adapter.zig @@ -0,0 +1,228 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// render-mcp/adapter/render_adapter.zig -- Unified three-protocol adapter. +// +// Replaces the banned render_adapter.v (zig, removed 2026-04-12). +// +// Bridges the Zig FFI (render_mcp_ffi.zig) to three network protocols: +// REST :9091 POST /tools/<tool> +// gRPC-compat :9092 /RenderMcpService/<Method> +// GraphQL :9093 POST /graphql { query: "..." } +// +// Render cloud: services, deploys, env groups, custom domains, jobs +// Tools: +// render_list_services +// render_get_service +// render_create_service +// render_delete_service +// render_list_deploys +// render_trigger_deploy +// render_get_deploy +// render_list_env_groups +// render_get_env_group +// render_list_custom_domains +// render_add_custom_domain +// render_list_jobs +// render_create_job +// render_suspend_service +// render_resume_service +// render_get_bandwidth + +const std = @import("std"); +const ffi = @import("render_mcp_ffi"); + +const REST_PORT: u16 = 9091; +const GRPC_PORT: u16 = 9092; +const GQL_PORT: u16 = 9093; + +const MAX_CONN_BUF: usize = 16 * 1024; + +// ============================================================================ +// JSON response builders +// ============================================================================ + +fn okJson(buf: []u8, msg: []const u8) []u8 { + return std.fmt.bufPrint(buf, + \{{"success":true,"message":"{}"}} + , .{msg}) catch buf[0..0]; +} + +fn errJson(buf: []u8, msg: []const u8) []u8 { + return std.fmt.bufPrint(buf, + \{{"success":false,"error":"{}"}} + , .{msg}) catch buf[0..0]; +} + +fn statusJson(buf: []u8) []u8 { + return std.fmt.bufPrint(buf, + \{{"success":true,"state":"ready","service":"render-mcp"}} + , .{}) catch buf[0..0]; +} + +// ============================================================================ +// Tool dispatcher +// ============================================================================ + +const Response = struct { status: u16, body: []u8 }; + +fn dispatch(tool: []const u8, body: []const u8, resp: []u8) Response { + _ = body; + if (std.mem.eql(u8, tool, "render_list_services")) return .{ .status = 200, .body = okJson(resp, "render_list_services forwarded to backend") }; + if (std.mem.eql(u8, tool, "render_get_service")) return .{ .status = 200, .body = okJson(resp, "render_get_service forwarded to backend") }; + if (std.mem.eql(u8, tool, "render_create_service")) return .{ .status = 200, .body = okJson(resp, "render_create_service forwarded to backend") }; + if (std.mem.eql(u8, tool, "render_delete_service")) return .{ .status = 200, .body = okJson(resp, "render_delete_service forwarded to backend") }; + if (std.mem.eql(u8, tool, "render_list_deploys")) return .{ .status = 200, .body = okJson(resp, "render_list_deploys forwarded to backend") }; + if (std.mem.eql(u8, tool, "render_trigger_deploy")) return .{ .status = 200, .body = okJson(resp, "render_trigger_deploy forwarded to backend") }; + if (std.mem.eql(u8, tool, "render_get_deploy")) return .{ .status = 200, .body = okJson(resp, "render_get_deploy forwarded to backend") }; + if (std.mem.eql(u8, tool, "render_list_env_groups")) return .{ .status = 200, .body = okJson(resp, "render_list_env_groups forwarded to backend") }; + if (std.mem.eql(u8, tool, "render_get_env_group")) return .{ .status = 200, .body = okJson(resp, "render_get_env_group forwarded to backend") }; + if (std.mem.eql(u8, tool, "render_list_custom_domains")) return .{ .status = 200, .body = okJson(resp, "render_list_custom_domains forwarded to backend") }; + if (std.mem.eql(u8, tool, "render_add_custom_domain")) return .{ .status = 200, .body = okJson(resp, "render_add_custom_domain forwarded to backend") }; + if (std.mem.eql(u8, tool, "render_list_jobs")) return .{ .status = 200, .body = okJson(resp, "render_list_jobs forwarded to backend") }; + if (std.mem.eql(u8, tool, "render_create_job")) return .{ .status = 200, .body = okJson(resp, "render_create_job forwarded to backend") }; + if (std.mem.eql(u8, tool, "render_suspend_service")) return .{ .status = 200, .body = okJson(resp, "render_suspend_service forwarded to backend") }; + if (std.mem.eql(u8, tool, "render_resume_service")) return .{ .status = 200, .body = okJson(resp, "render_resume_service forwarded to backend") }; + if (std.mem.eql(u8, tool, "render_get_bandwidth")) return .{ .status = 200, .body = okJson(resp, "render_get_bandwidth forwarded to backend") }; + if (std.mem.eql(u8, tool, "status") or std.mem.eql(u8, tool, "health")) + return .{ .status = 200, .body = statusJson(resp) }; + return .{ .status = 404, .body = errJson(resp, "Unknown tool") }; +} + +// ============================================================================ +// REST handler +// ============================================================================ + +fn dispatchRest(path: []const u8, body: []const u8, resp: []u8) Response { + if (std.mem.startsWith(u8, path, "/tools/")) { + return dispatch(path["/tools/".len..], body, resp); + } + if (std.mem.eql(u8, path, "/status") or std.mem.eql(u8, path, "/health")) { + return .{ .status = 200, .body = statusJson(resp) }; + } + return .{ .status = 404, .body = errJson(resp, "Not found") }; +} + +// ============================================================================ +// gRPC-compat handler +// ============================================================================ + +fn dispatchGrpc(path: []const u8, body: []const u8, resp: []u8) Response { + const prefix = "/RenderMcpService/"; + if (!std.mem.startsWith(u8, path, prefix)) + return .{ .status = 404, .body = errJson(resp, "Not a recognized gRPC path") }; + const method = path[prefix.len..]; + const tool = blk: { + if (std.mem.eql(u8, method, "RenderListServices")) break :blk "render_list_services"; + if (std.mem.eql(u8, method, "RenderGetService")) break :blk "render_get_service"; + if (std.mem.eql(u8, method, "RenderCreateService")) break :blk "render_create_service"; + if (std.mem.eql(u8, method, "RenderDeleteService")) break :blk "render_delete_service"; + if (std.mem.eql(u8, method, "RenderListDeploys")) break :blk "render_list_deploys"; + if (std.mem.eql(u8, method, "RenderTriggerDeploy")) break :blk "render_trigger_deploy"; + if (std.mem.eql(u8, method, "RenderGetDeploy")) break :blk "render_get_deploy"; + if (std.mem.eql(u8, method, "RenderListEnvGroups")) break :blk "render_list_env_groups"; + if (std.mem.eql(u8, method, "RenderGetEnvGroup")) break :blk "render_get_env_group"; + if (std.mem.eql(u8, method, "RenderListCustomDomains")) break :blk "render_list_custom_domains"; + if (std.mem.eql(u8, method, "RenderAddCustomDomain")) break :blk "render_add_custom_domain"; + if (std.mem.eql(u8, method, "RenderListJobs")) break :blk "render_list_jobs"; + if (std.mem.eql(u8, method, "RenderCreateJob")) break :blk "render_create_job"; + if (std.mem.eql(u8, method, "RenderSuspendService")) break :blk "render_suspend_service"; + if (std.mem.eql(u8, method, "RenderResumeService")) break :blk "render_resume_service"; + if (std.mem.eql(u8, method, "RenderGetBandwidth")) break :blk "render_get_bandwidth"; + return .{ .status = 404, .body = errJson(resp, "Unknown gRPC method") }; + }; + return dispatch(tool, body, resp); +} + +// ============================================================================ +// GraphQL handler +// ============================================================================ + +fn dispatchGraphql(body: []const u8, resp: []u8) Response { + if (std.mem.indexOf(u8, body, "__schema") != null) + return .{ .status = 200, .body = okJson(resp, "schema introspection not yet supported") }; + if (std.mem.indexOf(u8, body, "list_services") != null) return dispatch("render_list_services", body, resp); + if (std.mem.indexOf(u8, body, "get_service") != null) return dispatch("render_get_service", body, resp); + if (std.mem.indexOf(u8, body, "create_service") != null) return dispatch("render_create_service", body, resp); + if (std.mem.indexOf(u8, body, "delete_service") != null) return dispatch("render_delete_service", body, resp); + if (std.mem.indexOf(u8, body, "list_deploys") != null) return dispatch("render_list_deploys", body, resp); + if (std.mem.indexOf(u8, body, "trigger_deploy") != null) return dispatch("render_trigger_deploy", body, resp); + if (std.mem.indexOf(u8, body, "get_deploy") != null) return dispatch("render_get_deploy", body, resp); + if (std.mem.indexOf(u8, body, "list_env_groups") != null) return dispatch("render_list_env_groups", body, resp); + if (std.mem.indexOf(u8, body, "get_env_group") != null) return dispatch("render_get_env_group", body, resp); + if (std.mem.indexOf(u8, body, "list_custom_domains") != null) return dispatch("render_list_custom_domains", body, resp); + if (std.mem.indexOf(u8, body, "add_custom_domain") != null) return dispatch("render_add_custom_domain", body, resp); + if (std.mem.indexOf(u8, body, "list_jobs") != null) return dispatch("render_list_jobs", body, resp); + if (std.mem.indexOf(u8, body, "create_job") != null) return dispatch("render_create_job", body, resp); + if (std.mem.indexOf(u8, body, "suspend_service") != null) return dispatch("render_suspend_service", body, resp); + if (std.mem.indexOf(u8, body, "resume_service") != null) return dispatch("render_resume_service", body, resp); + if (std.mem.indexOf(u8, body, "get_bandwidth") != null) return dispatch("render_get_bandwidth", body, resp); + return .{ .status = 200, .body = errJson(resp, "Unrecognised GraphQL operation") }; +} + +// ============================================================================ +// HTTP/1.1 connection handler +// ============================================================================ + +const Protocol = enum { rest, grpc, graphql }; + +fn handleConnection(conn: std.net.Server.Connection, proto: Protocol) void { + defer conn.stream.close(); + var in_buf: [MAX_CONN_BUF]u8 = undefined; + const n = conn.stream.read(&in_buf) catch return; + const req = in_buf[0..n]; + + var path: []const u8 = "/"; + var body: []const u8 = ""; + if (n > 4) { + const line_end = std.mem.indexOf(u8, req, "\r\n") orelse req.len; + const first_line = req[0..line_end]; + const sp1 = std.mem.indexOfScalar(u8, first_line, ' ') orelse 0; + const rest_of = first_line[sp1 + 1 ..]; + const sp2 = std.mem.indexOfScalar(u8, rest_of, ' ') orelse rest_of.len; + path = rest_of[0..sp2]; + const body_sep = std.mem.indexOf(u8, req, "\r\n\r\n") orelse n; + body = req[@min(body_sep + 4, n)..]; + } + + var resp_buf: [MAX_CONN_BUF]u8 = undefined; + const result = switch (proto) { + .rest => dispatchRest(path, body, &resp_buf), + .grpc => dispatchGrpc(path, body, &resp_buf), + .graphql => dispatchGraphql(body, &resp_buf), + }; + + const content_type = switch (proto) { + .rest => "application/json", + .grpc => "application/grpc+json", + .graphql => "application/json", + }; + + var hdr_buf: [256]u8 = undefined; + const hdr = std.fmt.bufPrint(&hdr_buf, + "HTTP/1.1 {d} OK\r\nContent-Type: {s}\r\nContent-Length: {d}\r\nConnection: close\r\n\r\n", + .{ result.status, content_type, result.body.len }, + ) catch return; + _ = conn.stream.writeAll(hdr) catch return; + _ = conn.stream.writeAll(result.body) catch return; +} + +fn listenLoop(port: u16, proto: Protocol) void { + const addr = std.net.Address.initIp4(.{ 127, 0, 0, 1 }, port) catch return; + var server = addr.listen(.{ .reuse_address = true }) catch return; + defer server.deinit(); + while (true) { + const conn = server.accept() catch continue; + handleConnection(conn, proto); + } +} + +pub fn main() !void { + ffi.render_init(); + const rest_thread = try std.Thread.spawn(.{}, listenLoop, .{ REST_PORT, Protocol.rest }); + const grpc_thread = try std.Thread.spawn(.{}, listenLoop, .{ GRPC_PORT, Protocol.grpc }); + const gql_thread = try std.Thread.spawn(.{}, listenLoop, .{ GQL_PORT, Protocol.graphql }); + rest_thread.join(); + grpc_thread.join(); + gql_thread.join(); +} diff --git a/cartridges/domains/cloud/render-mcp/benchmarks/quick-bench.sh b/cartridges/domains/cloud/render-mcp/benchmarks/quick-bench.sh new file mode 100755 index 0000000..c2e6b8e --- /dev/null +++ b/cartridges/domains/cloud/render-mcp/benchmarks/quick-bench.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Benchmarks for render-mcp cartridge. +set -euo pipefail + +echo "=== render-mcp benchmarks ===" + +cd "$(dirname "${BASH_SOURCE[0]}")/../ffi" + +# Build with ReleaseFast for benchmarking +zig build -Doptimize=ReleaseFast 2>&1 + +echo "Session open/close cycle (1000 iterations):" +time for i in $(seq 1 1000); do + # This would call the FFI benchmark binary + true +done + +echo "" +echo "Benchmark placeholder β€” implement real benchmarks in Zig test blocks" +echo "or via the zig adapter HTTP benchmark tool." diff --git a/cartridges/domains/cloud/render-mcp/cartridge.json b/cartridges/domains/cloud/render-mcp/cartridge.json new file mode 100644 index 0000000..cc1acdf --- /dev/null +++ b/cartridges/domains/cloud/render-mcp/cartridge.json @@ -0,0 +1,418 @@ +{ + "$schema": "https://boj.dev/schemas/cartridge/v1.json", + "spdx": "MPL-2.0", + "copyright": "Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath)", + "name": "render-mcp", + "version": "0.2.0", + "description": "Render REST API v1 cartridge -- services, deploys, env groups, custom domains, jobs, suspend/resume, and bandwidth monitoring", + "domain": "Cloud", + "tier": "Ayo", + "protocols": [ + "MCP", + "REST" + ], + "auth": { + "method": "bearer_token", + "env_var": "RENDER_API_KEY", + "credential_source": "vault-mcp" + }, + "api": { + "base_url": "https://api.render.com/v1", + "rate_limit_per_minute": 100, + "content_type": "application/json" + }, + "tools": [ + { + "name": "render_list_services", + "description": "List all services in the Render account (web services, static sites, background workers, private services, cron jobs)", + "inputSchema": { + "type": "object", + "properties": { + "cursor": { + "type": "string", + "description": "Pagination cursor from previous response" + }, + "limit": { + "type": "number", + "description": "Results per page (default 20, max 100)" + }, + "name": { + "type": "string", + "description": "Filter by service name (partial match)" + }, + "type": { + "type": "string", + "description": "Filter by type: web_service, static_site, background_worker, private_service, cron_job" + }, + "region": { + "type": "string", + "description": "Filter by region (e.g. oregon, ohio, frankfurt, singapore)" + }, + "suspended": { + "type": "string", + "description": "Filter by suspended status: suspended, not_suspended" + }, + "env": { + "type": "string", + "description": "Filter by environment: docker, node, python, go, ruby, rust, elixir, static" + } + } + } + }, + { + "name": "render_get_service", + "description": "Get details of a specific Render service by ID", + "inputSchema": { + "type": "object", + "properties": { + "service_id": { + "type": "string", + "description": "Service ID (e.g. srv-abc123)" + } + }, + "required": [ + "service_id" + ] + } + }, + { + "name": "render_create_service", + "description": "Create a new Render service from a Git repository or Docker image", + "inputSchema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Service name" + }, + "type": { + "type": "string", + "description": "Service type: web_service, static_site, background_worker, private_service, cron_job" + }, + "repo": { + "type": "string", + "description": "Git repository URL (e.g. https://github.com/user/repo)" + }, + "branch": { + "type": "string", + "description": "Branch to deploy (default: main)" + }, + "region": { + "type": "string", + "description": "Deployment region (e.g. oregon, frankfurt)" + }, + "plan": { + "type": "string", + "description": "Plan name: free, starter, standard, pro, pro_plus, pro_max, pro_ultra" + }, + "env_vars": { + "type": "array", + "items": { + "type": "object" + }, + "description": "Environment variables (key, value pairs)" + }, + "build_command": { + "type": "string", + "description": "Custom build command" + }, + "start_command": { + "type": "string", + "description": "Custom start command" + }, + "docker_image": { + "type": "string", + "description": "Docker image URL (alternative to repo)" + }, + "auto_deploy": { + "type": "boolean", + "description": "Auto-deploy on push (default true)" + }, + "health_check_path": { + "type": "string", + "description": "Health check endpoint path" + } + }, + "required": [ + "name", + "type" + ] + } + }, + { + "name": "render_delete_service", + "description": "Delete a Render service (irreversible)", + "inputSchema": { + "type": "object", + "properties": { + "service_id": { + "type": "string", + "description": "Service ID to delete" + } + }, + "required": [ + "service_id" + ] + } + }, + { + "name": "render_list_deploys", + "description": "List all deploys for a specific Render service", + "inputSchema": { + "type": "object", + "properties": { + "service_id": { + "type": "string", + "description": "Service ID" + }, + "cursor": { + "type": "string", + "description": "Pagination cursor" + }, + "limit": { + "type": "number", + "description": "Results per page (default 20)" + } + }, + "required": [ + "service_id" + ] + } + }, + { + "name": "render_trigger_deploy", + "description": "Trigger a new deploy for a Render service (redeploy latest commit or clear build cache)", + "inputSchema": { + "type": "object", + "properties": { + "service_id": { + "type": "string", + "description": "Service ID" + }, + "clear_cache": { + "type": "boolean", + "description": "Clear build cache before deploy (default false)" + } + }, + "required": [ + "service_id" + ] + } + }, + { + "name": "render_get_deploy", + "description": "Get details of a specific deploy by service and deploy ID", + "inputSchema": { + "type": "object", + "properties": { + "service_id": { + "type": "string", + "description": "Service ID" + }, + "deploy_id": { + "type": "string", + "description": "Deploy ID (e.g. dep-abc123)" + } + }, + "required": [ + "service_id", + "deploy_id" + ] + } + }, + { + "name": "render_list_env_groups", + "description": "List all environment variable groups in the Render account", + "inputSchema": { + "type": "object", + "properties": { + "cursor": { + "type": "string", + "description": "Pagination cursor" + }, + "limit": { + "type": "number", + "description": "Results per page" + }, + "name": { + "type": "string", + "description": "Filter by name" + } + } + } + }, + { + "name": "render_get_env_group", + "description": "Get details of a specific environment variable group", + "inputSchema": { + "type": "object", + "properties": { + "env_group_id": { + "type": "string", + "description": "Env group ID (e.g. evg-abc123)" + } + }, + "required": [ + "env_group_id" + ] + } + }, + { + "name": "render_list_custom_domains", + "description": "List all custom domains for a specific Render service", + "inputSchema": { + "type": "object", + "properties": { + "service_id": { + "type": "string", + "description": "Service ID" + }, + "cursor": { + "type": "string", + "description": "Pagination cursor" + }, + "limit": { + "type": "number", + "description": "Results per page" + } + }, + "required": [ + "service_id" + ] + } + }, + { + "name": "render_add_custom_domain", + "description": "Add a custom domain to a Render service", + "inputSchema": { + "type": "object", + "properties": { + "service_id": { + "type": "string", + "description": "Service ID" + }, + "name": { + "type": "string", + "description": "Domain name (e.g. app.example.com)" + } + }, + "required": [ + "service_id", + "name" + ] + } + }, + { + "name": "render_list_jobs", + "description": "List all jobs (one-off tasks) for a specific Render service", + "inputSchema": { + "type": "object", + "properties": { + "service_id": { + "type": "string", + "description": "Service ID" + }, + "cursor": { + "type": "string", + "description": "Pagination cursor" + }, + "limit": { + "type": "number", + "description": "Results per page" + }, + "status": { + "type": "string", + "description": "Filter by status: pending, running, succeeded, failed" + } + }, + "required": [ + "service_id" + ] + } + }, + { + "name": "render_create_job", + "description": "Create and run a one-off job on a Render service", + "inputSchema": { + "type": "object", + "properties": { + "service_id": { + "type": "string", + "description": "Service ID" + }, + "start_command": { + "type": "string", + "description": "Command to execute" + }, + "plan_id": { + "type": "string", + "description": "Plan for the job (optional, defaults to service plan)" + } + }, + "required": [ + "service_id", + "start_command" + ] + } + }, + { + "name": "render_suspend_service", + "description": "Suspend a Render service (stops billing for compute)", + "inputSchema": { + "type": "object", + "properties": { + "service_id": { + "type": "string", + "description": "Service ID to suspend" + } + }, + "required": [ + "service_id" + ] + } + }, + { + "name": "render_resume_service", + "description": "Resume a suspended Render service", + "inputSchema": { + "type": "object", + "properties": { + "service_id": { + "type": "string", + "description": "Service ID to resume" + } + }, + "required": [ + "service_id" + ] + } + }, + { + "name": "render_get_bandwidth", + "description": "Get bandwidth usage statistics for a Render service", + "inputSchema": { + "type": "object", + "properties": { + "service_id": { + "type": "string", + "description": "Service ID" + } + }, + "required": [ + "service_id" + ] + } + } + ], + "ffi": { + "so_path": "ffi/zig-out/lib/librender_mcp.so", + "abi_version": "ADR-0006", + "symbols": [ + "boj_cartridge_init", + "boj_cartridge_deinit", + "boj_cartridge_name", + "boj_cartridge_version", + "boj_cartridge_invoke" + ] + } +} diff --git a/cartridges/domains/cloud/render-mcp/ffi/README.adoc b/cartridges/domains/cloud/render-mcp/ffi/README.adoc new file mode 100644 index 0000000..04ddc38 --- /dev/null +++ b/cartridges/domains/cloud/render-mcp/ffi/README.adoc @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += render-mcp / ffi β€” Zig FFI layer +:orientation: shallow + +== Purpose + +C-ABI exports bridging this cartridge's Idris2 ABI to the runtime. Consumed by +the adapter for protocol dispatch and by the trunk loader for mount. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” produces `zig-out/lib/librender.so`. +| `render_mcp_ffi.zig` | C-ABI exports (18 exports, 7 inline tests, 401 lines). +|=== + +== Invariants + +* **Null-pointer rejection** on `[*c]const u8` arguments before any + dereference. +* **Negative return values** carry documented error meanings; zero is + success (matching the trunk error-code convention). +* **No allocator leaks** β€” every allocation path pairs with `defer free`. + +== Test/proof surface + +7 inline `test "..."` block(s) in `render_mcp_ffi.zig`. Run with +`cd ffi && zig build test`. + +== Read-first + +. `render_mcp_ffi.zig` β€” C-ABI signatures and guards. +. Trunk `ffi/zig/src/catalogue.zig` for how this `.so` is loaded. diff --git a/cartridges/domains/cloud/render-mcp/ffi/build.zig b/cartridges/domains/cloud/render-mcp/ffi/build.zig new file mode 100644 index 0000000..44ba1bf --- /dev/null +++ b/cartridges/domains/cloud/render-mcp/ffi/build.zig @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + // Module + // Shared ADR-0006 invoke-shim module (relative path up to boj-server trunk). + + const shim_mod = b.addModule("cartridge_shim", .{ + + .root_source_file = b.path("../../../ffi/zig/src/cartridge_shim.zig"), + + .target = target, + + .optimize = optimize, + + }); + + const ffi_mod = b.addModule("render_mcp", .{ + .root_source_file = b.path("render_mcp_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + ffi_mod.addImport("cartridge_shim", shim_mod); + + // Shared library + const lib = b.addLibrary(.{ + .name = "render_mcp", + .root_module = ffi_mod, + .linkage = .dynamic, + }); + lib.linkLibC(); + b.installArtifact(lib); + + // Tests + const tests = b.addTest(.{ + .root_module = ffi_mod, + }); + tests.linkLibC(); + + const run_tests = b.addRunArtifact(tests); + const test_step = b.step("test", "Run FFI tests"); + test_step.dependOn(&run_tests.step); +} diff --git a/cartridges/domains/cloud/render-mcp/ffi/cartridge_shim.zig b/cartridges/domains/cloud/render-mcp/ffi/cartridge_shim.zig new file mode 100644 index 0000000..0e399db --- /dev/null +++ b/cartridges/domains/cloud/render-mcp/ffi/cartridge_shim.zig @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// cartridge_shim.zig β€” Shared helpers for the ADR-0006 five-symbol +// cartridge ABI (`boj_cartridge_init / deinit / name / version / invoke`). +// +// The shim centralises the seven-code return convention, NUL-argument +// guards, tool-name comparison, and the buffer-too-small path so each +// cartridge's `boj_cartridge_invoke` can stay short β€” typically a tool +// table plus `shim.writeResult(...)`. +// +// Cartridges import this file by relative path (no build-graph change +// needed). Example: +// +// const shim = @import("../../../ffi/zig/src/cartridge_shim.zig"); +// +// export fn boj_cartridge_invoke( +// tool_name: [*c]const u8, +// json_args: [*c]const u8, +// out_buf: [*c]u8, +// in_out_len: [*c]usize, +// ) callconv(.c) i32 { +// _ = json_args; +// if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; +// const body = if (shim.toolIs(tool_name, "foo")) "{\"result\":{}}" +// else return shim.RC_UNKNOWN_TOOL; +// return shim.writeResult(out_buf, in_out_len, body); +// } + +const std = @import("std"); + +// ── Return codes (ADR-0006 Β§Return codes) ──────────────────────────── +// +// Frozen by ADR-0006. New failure modes compose these via the error +// JSON body β€” the integer surface does not grow without a follow-up ADR. + +pub const RC_SUCCESS: i32 = 0; +pub const RC_UNKNOWN_TOOL: i32 = -1; +pub const RC_BAD_ARGS: i32 = -2; +pub const RC_BUFFER_TOO_SMALL: i32 = -3; +pub const RC_RUNTIME_ERROR: i32 = -4; +pub const RC_PANIC: i32 = -5; +pub const RC_AUTH_DENIED: i32 = -6; + +// ── Invoke-path helpers ────────────────────────────────────────────── + +/// True if any of the three mandatory `boj_cartridge_invoke` output-path +/// pointers is null. Use at the top of every invoke to short-circuit to +/// `RC_BAD_ARGS`. +pub fn invokeArgsNull( + tool_name: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) bool { + return tool_name == null or out_buf == null or in_out_len == null; +} + +/// Compare a C-NUL-terminated tool-name pointer against a Zig string +/// literal. Caller must have already verified `tool_name` is non-null +/// (usually via `invokeArgsNull`). +pub fn toolIs(tool_name: [*c]const u8, expected: []const u8) bool { + const s = std.mem.span(@as([*:0]const u8, @ptrCast(tool_name))); + return std.mem.eql(u8, s, expected); +} + +/// Copy `body` into `out_buf[0..*in_out_len]` (as a capacity) and update +/// `*in_out_len` to the number of bytes written. Returns `RC_SUCCESS`. +/// +/// If `body.len` exceeds the current capacity stored in `*in_out_len`, +/// sets `*in_out_len` to the required size and returns +/// `RC_BUFFER_TOO_SMALL` β€” the caller is then expected to re-allocate +/// and retry, per ADR-0006 Β§Memory ownership. +/// +/// Caller must have already verified that `out_buf` and `in_out_len` +/// are non-null. +pub fn writeResult( + out_buf: [*c]u8, + in_out_len: [*c]usize, + body: []const u8, +) i32 { + const cap = in_out_len.*; + if (body.len > cap) { + in_out_len.* = body.len; + return RC_BUFFER_TOO_SMALL; + } + @memcpy(out_buf[0..body.len], body); + in_out_len.* = body.len; + return RC_SUCCESS; +} + +// ── Tests ──────────────────────────────────────────────────────────── + +test "writeResult: body fits, writes and sets length" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); + try std.testing.expectEqualStrings("hello", buf[0..len]); +} + +test "writeResult: too small returns -3 and sets required length" { + var buf: [2]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_BUFFER_TOO_SMALL, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: exact-fit succeeds" { + var buf: [5]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: empty body" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, ""); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 0), len); +} + +test "toolIs: matches and rejects" { + const name: [*:0]const u8 = "foo"; + try std.testing.expect(toolIs(@ptrCast(name), "foo")); + try std.testing.expect(!toolIs(@ptrCast(name), "bar")); + try std.testing.expect(!toolIs(@ptrCast(name), "foobar")); + try std.testing.expect(!toolIs(@ptrCast(name), "fo")); +} + +test "invokeArgsNull: detects each null slot" { + var buf: [4]u8 = undefined; + var len: usize = 4; + const name: [*:0]const u8 = "x"; + try std.testing.expect(!invokeArgsNull(@ptrCast(name), &buf, &len)); + try std.testing.expect(invokeArgsNull(null, &buf, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), null, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), &buf, null)); +} diff --git a/cartridges/domains/cloud/render-mcp/ffi/render_mcp_ffi.zig b/cartridges/domains/cloud/render-mcp/ffi/render_mcp_ffi.zig new file mode 100644 index 0000000..e477ebf --- /dev/null +++ b/cartridges/domains/cloud/render-mcp/ffi/render_mcp_ffi.zig @@ -0,0 +1,528 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// render_mcp_ffi.zig -- C-ABI FFI implementation for render-mcp cartridge. +// +// Implements the state machine defined in the Idris2 ABI layer for +// Render REST API v1 (https://api.render.com/v1/). +// Auth: Bearer token (API key). Rate limit: 100 req/min. +// Thread-safe via std.Thread.Mutex. + +const std = @import("std"); + +// --------------------------------------------------------------------------- +// State machine (matches Idris2 ABI: Unauthenticated=0, Authenticated=1, +// RateLimited=2, Error=3) +// --------------------------------------------------------------------------- + +pub const SessionState = enum(c_int) { + unauthenticated = 0, + authenticated = 1, + rate_limited = 2, + err = 3, +}; + +/// Render REST API action codes (matches Idris2 RenderAction). +pub const RenderAction = enum(c_int) { + list_services = 0, + get_service = 1, + create_service = 2, + delete_service = 3, + list_deploys = 4, + trigger_deploy = 5, + get_deploy = 6, + list_env_groups = 7, + get_env_group = 8, + list_custom_domains = 9, + add_custom_domain = 10, + list_jobs = 11, + create_job = 12, + suspend_service = 13, + resume_service = 14, + get_bandwidth = 15, +}; + +/// Render API rate limit: 100 requests per minute. +pub const RATE_LIMIT_PER_MINUTE: c_int = 100; + +fn isValidTransition(from: SessionState, to: SessionState) bool { + return switch (from) { + .unauthenticated => to == .authenticated or to == .err, + .authenticated => to == .rate_limited or to == .err or to == .unauthenticated, + .rate_limited => to == .authenticated or to == .err, + .err => to == .unauthenticated, + }; +} + +// --------------------------------------------------------------------------- +// Session slots (thread-safe, fixed-size pool) +// --------------------------------------------------------------------------- + +const MAX_SESSIONS: usize = 16; +const TOKEN_BUF_SIZE: usize = 512; + +const SessionSlot = struct { + occupied: bool = false, + state: SessionState = .unauthenticated, + token_buf: [TOKEN_BUF_SIZE]u8 = .{0} ** TOKEN_BUF_SIZE, + token_len: usize = 0, + service_count: c_int = 0, + deploy_ok: c_int = 0, + deploy_fail: c_int = 0, + bandwidth_mb: c_int = 0, +}; + +var sessions: [MAX_SESSIONS]SessionSlot = .{SessionSlot{}} ** MAX_SESSIONS; +var mutex: std.Thread.Mutex = .{}; + +// --------------------------------------------------------------------------- +// C-ABI exports +// --------------------------------------------------------------------------- + +/// Check if a state transition is valid. Returns 1 (valid) or 0 (invalid). +pub export fn render_mcp_can_transition(from: c_int, to: c_int) c_int { + const f = std.meta.intToEnum(SessionState, from) catch return 0; + const t = std.meta.intToEnum(SessionState, to) catch return 0; + return if (isValidTransition(f, t)) 1 else 0; +} + +/// Authenticate and open a session. Returns slot index (>= 0) or -1 (no slots). +pub export fn render_mcp_session_open() c_int { + mutex.lock(); + defer mutex.unlock(); + + for (0..MAX_SESSIONS) |idx| { + const slot = &sessions[idx]; + if (!slot.occupied) { + slot.occupied = true; + slot.state = .authenticated; + slot.token_len = 0; + slot.service_count = 0; + slot.deploy_ok = 0; + slot.deploy_fail = 0; + slot.bandwidth_mb = 0; + return @intCast(idx); + } + } + return -1; +} + +/// Close a session. Returns 0 on success, -1 if invalid slot, -2 if bad transition. +pub export fn render_mcp_session_close(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.occupied) return -1; + if (!isValidTransition(slot.state, .unauthenticated)) return -2; + + slot.occupied = false; + slot.state = .unauthenticated; + slot.token_len = 0; + slot.service_count = 0; + slot.deploy_ok = 0; + slot.deploy_fail = 0; + slot.bandwidth_mb = 0; + return 0; +} + +/// Get the current state of a session. Returns state int or -1 if invalid. +pub export fn render_mcp_session_state(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.occupied) return -1; + return @intFromEnum(slot.state); +} + +/// Transition to rate-limited state. Returns 0 on success. +pub export fn render_mcp_rate_limit(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.occupied) return -1; + if (!isValidTransition(slot.state, .rate_limited)) return -2; + + slot.state = .rate_limited; + return 0; +} + +/// Recover from rate-limited back to authenticated. Returns 0 on success. +pub export fn render_mcp_rate_recover(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.occupied) return -1; + if (!isValidTransition(slot.state, .authenticated)) return -2; + + slot.state = .authenticated; + return 0; +} + +/// Signal an error on a session. Returns 0 on success. +pub export fn render_mcp_signal_error(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.occupied) return -1; + if (!isValidTransition(slot.state, .err)) return -2; + + slot.state = .err; + return 0; +} + +/// Recover from error back to unauthenticated. Returns 0 on success. +pub export fn render_mcp_error_recover(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.occupied) return -1; + if (!isValidTransition(slot.state, .unauthenticated)) return -2; + + slot.state = .unauthenticated; + return 0; +} + +/// All Render actions require auth. Returns 1 always. +pub export fn render_mcp_action_requires_auth(action: c_int) c_int { + _ = std.meta.intToEnum(RenderAction, action) catch return 1; + return 1; +} + +/// Get the rate limit (requests per minute). +pub export fn render_mcp_rate_limit_per_minute() c_int { + return RATE_LIMIT_PER_MINUTE; +} + +/// Get service count for a session. +pub export fn render_mcp_service_count(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.occupied) return -1; + return slot.service_count; +} + +/// Set service count for a session. +pub export fn render_mcp_set_service_count(slot_idx: c_int, count: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.occupied) return -1; + slot.service_count = count; + return 0; +} + +/// Get successful deploy count. +pub export fn render_mcp_deploy_ok(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.occupied) return -1; + return slot.deploy_ok; +} + +/// Get failed deploy count. +pub export fn render_mcp_deploy_fail(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.occupied) return -1; + return slot.deploy_fail; +} + +/// Set deploy counts. +pub export fn render_mcp_set_deploy_counts(slot_idx: c_int, ok: c_int, fail: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.occupied) return -1; + slot.deploy_ok = ok; + slot.deploy_fail = fail; + return 0; +} + +/// Get bandwidth usage in MB. +pub export fn render_mcp_bandwidth_mb(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.occupied) return -1; + return slot.bandwidth_mb; +} + +/// Set bandwidth usage in MB. +pub export fn render_mcp_set_bandwidth_mb(slot_idx: c_int, mb: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.occupied) return -1; + slot.bandwidth_mb = mb; + return 0; +} + +/// Reset all sessions (test/debug use only). +pub export fn render_mcp_reset() void { + mutex.lock(); + defer mutex.unlock(); + sessions = .{SessionSlot{}} ** MAX_SESSIONS; +} + +// --------------------------------------------------------------------------- +// ═══════════════════════════════════════════════════════════════════════ +// Standard ABI (ADR-0005 four symbols + ADR-0006 invoke) +// ═══════════════════════════════════════════════════════════════════════ + +const shim = @import("cartridge_shim.zig"); + +const CARTRIDGE_NAME_PTR: [*:0]const u8 = "render-mcp"; +const CARTRIDGE_VERSION_PTR: [*:0]const u8 = "0.1.0"; + +export fn boj_cartridge_init() callconv(.c) c_int { + return 0; +} + +export fn boj_cartridge_deinit() callconv(.c) void {} + +export fn boj_cartridge_name() callconv(.c) [*:0]const u8 { + return CARTRIDGE_NAME_PTR; +} + +export fn boj_cartridge_version() callconv(.c) [*:0]const u8 { + return CARTRIDGE_VERSION_PTR; +} + +/// Dispatch the cartridge.json MCP tools. Grade D Alpha stubs. +export fn boj_cartridge_invoke( + tool_name: [*c]const u8, + json_args: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) callconv(.c) i32 { + _ = json_args; + if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; + + const body: []const u8 = if (shim.toolIs(tool_name, "render_list_services")) + "{\"result\":{\"items\":[],\"count\":0,\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "render_get_service")) + "{\"result\":{\"metadata\":{},\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "render_create_service")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "render_delete_service")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "render_list_deploys")) + "{\"result\":{\"items\":[],\"count\":0,\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "render_trigger_deploy")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "render_get_deploy")) + "{\"result\":{\"metadata\":{},\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "render_list_env_groups")) + "{\"result\":{\"items\":[],\"count\":0,\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "render_get_env_group")) + "{\"result\":{\"metadata\":{},\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "render_list_custom_domains")) + "{\"result\":{\"items\":[],\"count\":0,\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "render_add_custom_domain")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "render_list_jobs")) + "{\"result\":{\"items\":[],\"count\":0,\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "render_create_job")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "render_suspend_service")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "render_resume_service")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "render_get_bandwidth")) + "{\"result\":{\"metadata\":{},\"status\":\"stub\"}}" +else + return shim.RC_UNKNOWN_TOOL; + + return shim.writeResult(out_buf, in_out_len, body); +} + +// Tests +// --------------------------------------------------------------------------- + +test "session lifecycle" { + render_mcp_reset(); + + const slot = render_mcp_session_open(); + try std.testing.expect(slot >= 0); + + // Should be authenticated after open + try std.testing.expectEqual(@as(c_int, 1), render_mcp_session_state(slot)); + + // Rate limit then recover + try std.testing.expectEqual(@as(c_int, 0), render_mcp_rate_limit(slot)); + try std.testing.expectEqual(@as(c_int, 2), render_mcp_session_state(slot)); + try std.testing.expectEqual(@as(c_int, 0), render_mcp_rate_recover(slot)); + try std.testing.expectEqual(@as(c_int, 1), render_mcp_session_state(slot)); + + // Close + try std.testing.expectEqual(@as(c_int, 0), render_mcp_session_close(slot)); +} + +test "invalid transitions rejected" { + render_mcp_reset(); + + const slot = render_mcp_session_open(); + try std.testing.expect(slot >= 0); + + // Cannot close while rate-limited + try std.testing.expectEqual(@as(c_int, 0), render_mcp_rate_limit(slot)); + try std.testing.expectEqual(@as(c_int, -2), render_mcp_session_close(slot)); +} + +test "transition validator" { + try std.testing.expectEqual(@as(c_int, 1), render_mcp_can_transition(0, 1)); + try std.testing.expectEqual(@as(c_int, 1), render_mcp_can_transition(1, 2)); + try std.testing.expectEqual(@as(c_int, 1), render_mcp_can_transition(2, 1)); + try std.testing.expectEqual(@as(c_int, 1), render_mcp_can_transition(1, 3)); + try std.testing.expectEqual(@as(c_int, 1), render_mcp_can_transition(3, 0)); + + try std.testing.expectEqual(@as(c_int, 0), render_mcp_can_transition(0, 2)); + try std.testing.expectEqual(@as(c_int, 0), render_mcp_can_transition(2, 0)); + try std.testing.expectEqual(@as(c_int, 0), render_mcp_can_transition(3, 1)); + try std.testing.expectEqual(@as(c_int, 0), render_mcp_can_transition(99, 0)); +} + +test "slot exhaustion" { + render_mcp_reset(); + + var slots: [MAX_SESSIONS]c_int = undefined; + for (0..MAX_SESSIONS) |idx| { + slots[idx] = render_mcp_session_open(); + try std.testing.expect(slots[idx] >= 0); + } + + try std.testing.expectEqual(@as(c_int, -1), render_mcp_session_open()); + + try std.testing.expectEqual(@as(c_int, 0), render_mcp_session_close(slots[0])); + const new_slot = render_mcp_session_open(); + try std.testing.expect(new_slot >= 0); +} + +test "service and deploy counters" { + render_mcp_reset(); + + const slot = render_mcp_session_open(); + try std.testing.expect(slot >= 0); + + try std.testing.expectEqual(@as(c_int, 0), render_mcp_service_count(slot)); + try std.testing.expectEqual(@as(c_int, 0), render_mcp_set_service_count(slot, 8)); + try std.testing.expectEqual(@as(c_int, 8), render_mcp_service_count(slot)); + + try std.testing.expectEqual(@as(c_int, 0), render_mcp_deploy_ok(slot)); + try std.testing.expectEqual(@as(c_int, 0), render_mcp_set_deploy_counts(slot, 15, 3)); + try std.testing.expectEqual(@as(c_int, 15), render_mcp_deploy_ok(slot)); + try std.testing.expectEqual(@as(c_int, 3), render_mcp_deploy_fail(slot)); +} + +test "bandwidth counter" { + render_mcp_reset(); + + const slot = render_mcp_session_open(); + try std.testing.expect(slot >= 0); + + try std.testing.expectEqual(@as(c_int, 0), render_mcp_bandwidth_mb(slot)); + try std.testing.expectEqual(@as(c_int, 0), render_mcp_set_bandwidth_mb(slot, 1024)); + try std.testing.expectEqual(@as(c_int, 1024), render_mcp_bandwidth_mb(slot)); +} + +test "rate limit constant" { + try std.testing.expectEqual(@as(c_int, 100), render_mcp_rate_limit_per_minute()); +} + +// ═══════════════════════════════════════════════════════════════════════ +// ADR-0006 invoke dispatch tests +// ═══════════════════════════════════════════════════════════════════════ + +test "boj_cartridge_name returns render-mcp" { + const n = std.mem.span(boj_cartridge_name()); + try std.testing.expectEqualStrings("render-mcp", n); +} + +test "boj_cartridge_init returns 0" { + try std.testing.expectEqual(@as(c_int, 0), boj_cartridge_init()); +} + +test "invoke: each declared tool succeeds" { + var buf: [256]u8 = undefined; + const tools = [_][]const u8{ + "render_list_services", + "render_get_service", + "render_create_service", + "render_delete_service", + "render_list_deploys", + "render_trigger_deploy", + "render_get_deploy", + "render_list_env_groups", + "render_get_env_group", + "render_list_custom_domains", + "render_add_custom_domain", + "render_list_jobs", + "render_create_job", + "render_suspend_service", + "render_resume_service", + "render_get_bandwidth", + }; + for (tools) |t| { + var len: usize = buf.len; + const rc = boj_cartridge_invoke(t.ptr, "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, 0), rc); + try std.testing.expect(std.mem.indexOf(u8, buf[0..len], "result") != null); + } +} + +test "invoke: unknown tool returns -1" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("nope", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -1), rc); +} + +test "invoke: buffer too small returns -3" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("render_list_services", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -3), rc); + try std.testing.expect(len > 4); +} diff --git a/cartridges/domains/cloud/render-mcp/minter.toml b/cartridges/domains/cloud/render-mcp/minter.toml new file mode 100644 index 0000000..6c3db1b --- /dev/null +++ b/cartridges/domains/cloud/render-mcp/minter.toml @@ -0,0 +1,42 @@ +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> + +name = "render-mcp" +description = "Render REST API v1 cartridge -- services, deploys, env groups, custom domains, jobs, bandwidth" +version = "0.1.0" +domain = "Cloud" +protocols = [ + "MCP", + "REST", +] +tier = "Ayo" +backend = "universal" +generate_panel = true + +[auth] +method = "bearer" +token_env = "RENDER_API_KEY" +base_url = "https://api.render.com/v1/" + +[rate_limit] +requests_per_minute = 100 + +[actions] +list = [ + "ListServices", + "GetService", + "CreateService", + "DeleteService", + "ListDeploys", + "TriggerDeploy", + "GetDeploy", + "ListEnvGroups", + "GetEnvGroup", + "ListCustomDomains", + "AddCustomDomain", + "ListJobs", + "CreateJob", + "SuspendService", + "ResumeService", + "GetBandwidth", +] diff --git a/cartridges/domains/cloud/render-mcp/mod.js b/cartridges/domains/cloud/render-mcp/mod.js new file mode 100644 index 0000000..902f563 --- /dev/null +++ b/cartridges/domains/cloud/render-mcp/mod.js @@ -0,0 +1,266 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// render-mcp/mod.js -- Render REST API v1 cartridge implementation. +// +// Provides MCP tool handlers for Render cloud platform: +// - Service management (list, get, create, delete, suspend, resume) +// - Deploy management (list, trigger, get) +// - Environment groups (list, get) +// - Custom domains (list, add) +// - Jobs (list, create) +// - Bandwidth monitoring +// +// Auth: Bearer token via RENDER_API_KEY env var or vault-mcp proxy. +// API docs: https://api-docs.render.com/reference/introduction +// Rate limit: 100 requests per minute. +// +// Usage: import { handleTool } from "./mod.js"; +// or: deno run --allow-net --allow-env mod.js + +const API_BASE = "https://api.render.com/v1"; + +// --------------------------------------------------------------------------- +// Auth helper -- retrieves the Render API key from environment. +// In production, vault-mcp provides zero-knowledge credential proxying; +// for development, RENDER_API_KEY is read directly. +// --------------------------------------------------------------------------- + +function getToken() { + const token = typeof Deno !== "undefined" + ? Deno.env.get("RENDER_API_KEY") + : process.env.RENDER_API_KEY; + if (!token) { + throw new Error("RENDER_API_KEY not set. Store in vault-mcp or export to environment."); + } + return token; +} + +// --------------------------------------------------------------------------- +// HTTP request helper -- wraps fetch with Render auth headers, error +// handling, pagination cursor support, and rate-limit extraction. +// Render uses cursor-based pagination, not page numbers. +// --------------------------------------------------------------------------- + +async function renderFetch(method, path, body, queryParams) { + const token = getToken(); + const url = new URL(`${API_BASE}${path}`); + + // Append query parameters (pagination cursors, filters) + if (queryParams) { + for (const [key, value] of Object.entries(queryParams)) { + if (value !== undefined && value !== null && value !== "") { + url.searchParams.set(key, String(value)); + } + } + } + + const options = { + method, + headers: { + "Authorization": `Bearer ${token}`, + "Accept": "application/json", + "User-Agent": "boj-server/render-mcp/0.2.0", + }, + }; + + if (body && method !== "GET") { + options.headers["Content-Type"] = "application/json"; + options.body = JSON.stringify(body); + } + + const response = await fetch(url.toString(), options); + + // Extract rate-limit headers for caller awareness + const rateLimit = { + limit: response.headers.get("ratelimit-limit"), + remaining: response.headers.get("ratelimit-remaining"), + reset: response.headers.get("ratelimit-reset"), + }; + + // Handle 204 No Content (successful deletes, suspend/resume) + if (response.status === 204) { + return { status: response.status, data: { success: true }, rateLimit }; + } + + const data = await response.json(); + + // Surface Render API errors clearly + if (!response.ok) { + const errorMessage = data.message + ? data.message + : `HTTP ${response.status}`; + return { status: response.status, error: errorMessage, data, rateLimit }; + } + + return { status: response.status, data, rateLimit }; +} + +// --------------------------------------------------------------------------- +// Tool handler dispatch -- maps MCP tool names to Render API operations. +// Each handler validates required arguments, builds the API request, +// and returns structured results with rate-limit metadata. +// --------------------------------------------------------------------------- + +export async function handleTool(toolName, args) { + switch (toolName) { + + // --- Services --- + + case "render_list_services": { + const query = { + cursor: args.cursor, + limit: args.limit, + name: args.name, + type: args.type, + region: args.region, + suspended: args.suspended, + env: args.env, + }; + return renderFetch("GET", "/services", null, query); + } + + case "render_get_service": { + if (!args.service_id) return { error: "Missing required field: service_id" }; + return renderFetch("GET", `/services/${args.service_id}`); + } + + case "render_create_service": { + if (!args.name) return { error: "Missing required field: name" }; + if (!args.type) return { error: "Missing required field: type" }; + const body = { + name: args.name, + type: args.type, + }; + if (args.repo) body.repo = args.repo; + if (args.branch) body.branch = args.branch; + if (args.region) body.region = args.region; + if (args.plan) body.plan = args.plan; + if (args.env_vars) body.envVars = args.env_vars; + if (args.build_command) body.buildCommand = args.build_command; + if (args.start_command) body.startCommand = args.start_command; + if (args.docker_image) body.image = { ownerId: "usr-owner", imagePath: args.docker_image }; + if (args.auto_deploy !== undefined) body.autoDeploy = args.auto_deploy ? "yes" : "no"; + if (args.health_check_path) body.healthCheckPath = args.health_check_path; + return renderFetch("POST", "/services", body); + } + + case "render_delete_service": { + if (!args.service_id) return { error: "Missing required field: service_id" }; + return renderFetch("DELETE", `/services/${args.service_id}`); + } + + // --- Deploys --- + + case "render_list_deploys": { + if (!args.service_id) return { error: "Missing required field: service_id" }; + const query = { + cursor: args.cursor, + limit: args.limit, + }; + return renderFetch("GET", `/services/${args.service_id}/deploys`, null, query); + } + + case "render_trigger_deploy": { + if (!args.service_id) return { error: "Missing required field: service_id" }; + const body = {}; + if (args.clear_cache) body.clearCache = "clear"; + return renderFetch("POST", `/services/${args.service_id}/deploys`, body); + } + + case "render_get_deploy": { + if (!args.service_id) return { error: "Missing required field: service_id" }; + if (!args.deploy_id) return { error: "Missing required field: deploy_id" }; + return renderFetch("GET", `/services/${args.service_id}/deploys/${args.deploy_id}`); + } + + // --- Environment Groups --- + + case "render_list_env_groups": { + const query = { + cursor: args.cursor, + limit: args.limit, + name: args.name, + }; + return renderFetch("GET", "/env-groups", null, query); + } + + case "render_get_env_group": { + if (!args.env_group_id) return { error: "Missing required field: env_group_id" }; + return renderFetch("GET", `/env-groups/${args.env_group_id}`); + } + + // --- Custom Domains --- + + case "render_list_custom_domains": { + if (!args.service_id) return { error: "Missing required field: service_id" }; + const query = { + cursor: args.cursor, + limit: args.limit, + }; + return renderFetch("GET", `/services/${args.service_id}/custom-domains`, null, query); + } + + case "render_add_custom_domain": { + if (!args.service_id) return { error: "Missing required field: service_id" }; + if (!args.name) return { error: "Missing required field: name" }; + return renderFetch("POST", `/services/${args.service_id}/custom-domains`, { name: args.name }); + } + + // --- Jobs --- + + case "render_list_jobs": { + if (!args.service_id) return { error: "Missing required field: service_id" }; + const query = { + cursor: args.cursor, + limit: args.limit, + status: args.status, + }; + return renderFetch("GET", `/services/${args.service_id}/jobs`, null, query); + } + + case "render_create_job": { + if (!args.service_id) return { error: "Missing required field: service_id" }; + if (!args.start_command) return { error: "Missing required field: start_command" }; + const body = { startCommand: args.start_command }; + if (args.plan_id) body.planId = args.plan_id; + return renderFetch("POST", `/services/${args.service_id}/jobs`, body); + } + + // --- Suspend / Resume --- + + case "render_suspend_service": { + if (!args.service_id) return { error: "Missing required field: service_id" }; + return renderFetch("POST", `/services/${args.service_id}/suspend`); + } + + case "render_resume_service": { + if (!args.service_id) return { error: "Missing required field: service_id" }; + return renderFetch("POST", `/services/${args.service_id}/resume`); + } + + // --- Bandwidth --- + + case "render_get_bandwidth": { + if (!args.service_id) return { error: "Missing required field: service_id" }; + return renderFetch("GET", `/services/${args.service_id}/metrics/bandwidth`); + } + + default: + return { error: `Unknown render-mcp tool: ${toolName}` }; + } +} + +// --------------------------------------------------------------------------- +// Cartridge metadata export -- used by the BoJ cartridge loader to register +// this cartridge's tools without reading cartridge.json separately. +// --------------------------------------------------------------------------- + +export const metadata = { + name: "render-mcp", + version: "0.2.0", + domain: "Cloud", + tier: "Ayo", + protocols: ["MCP", "REST"], + toolCount: 16, +}; diff --git a/cartridges/domains/cloud/render-mcp/panels/manifest.json b/cartridges/domains/cloud/render-mcp/panels/manifest.json new file mode 100644 index 0000000..56908a7 --- /dev/null +++ b/cartridges/domains/cloud/render-mcp/panels/manifest.json @@ -0,0 +1,90 @@ +{ + "$schema": "https://panll.dev/schemas/panel-manifest/v1.json", + "spdx": "MPL-2.0", + "cartridge": "render-mcp", + "domain": "Cloud", + "version": "0.1.0", + "panels": [ + { + "id": "render-auth-status", + "title": "Auth Status", + "description": "Current Render authentication state (Unauthenticated / Authenticated / RateLimited / Error)", + "type": "status-indicator", + "data_source": { + "endpoint": "/render/state", + "method": "GET", + "refresh_interval_ms": 5000 + }, + "widgets": [ + { + "type": "state-badge", + "field": "state", + "states": { + "unauthenticated": { "color": "#95a5a6", "icon": "plug-disconnected" }, + "authenticated": { "color": "#2ecc71", "icon": "plug-connected" }, + "rate_limited": { "color": "#e67e22", "icon": "clock" }, + "error": { "color": "#e74c3c", "icon": "alert-triangle" } + } + } + ] + }, + { + "id": "render-service-count", + "title": "Service Count", + "description": "Number of Render services in the authenticated account", + "type": "metric", + "data_source": { + "endpoint": "/render/metrics", + "method": "GET", + "refresh_interval_ms": 30000 + }, + "widgets": [ + { + "type": "counter", + "field": "service_count", + "label": "Services", + "icon": "server" + } + ] + }, + { + "id": "render-deploy-status", + "title": "Deploy Status", + "description": "Deploy success and failure counts across all Render services", + "type": "multi-metric", + "data_source": { + "endpoint": "/render/metrics", + "method": "GET", + "refresh_interval_ms": 30000 + }, + "widgets": [ + { + "type": "progress-bar", + "items": [ + { "field": "deploy_ok", "max_field": "deploy_total", "label": "Successful", "color": "#2ecc71" }, + { "field": "deploy_fail", "max_field": "deploy_total", "label": "Failed", "color": "#e74c3c" } + ] + } + ] + }, + { + "id": "render-bandwidth-usage", + "title": "Bandwidth Usage", + "description": "Total bandwidth usage in MB across all Render services", + "type": "metric", + "data_source": { + "endpoint": "/render/metrics", + "method": "GET", + "refresh_interval_ms": 60000 + }, + "widgets": [ + { + "type": "counter", + "field": "bandwidth_mb", + "label": "Bandwidth (MB)", + "icon": "activity" + } + ] + } + ] +} diff --git a/cartridges/domains/cloud/render-mcp/tests/integration_test.sh b/cartridges/domains/cloud/render-mcp/tests/integration_test.sh new file mode 100755 index 0000000..e471dbf --- /dev/null +++ b/cartridges/domains/cloud/render-mcp/tests/integration_test.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Integration tests for render-mcp cartridge. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CART_DIR="$(dirname "$SCRIPT_DIR")" +FFI_DIR="$CART_DIR/ffi" + +echo "=== render-mcp integration tests ===" + +# Build FFI +echo "[1/3] Building FFI..." +cd "$FFI_DIR" && zig build 2>&1 + +# Run Zig unit tests +echo "[2/3] Running FFI unit tests..." +cd "$FFI_DIR" && zig build test 2>&1 + +# Validate Idris2 ABI (if idris2 available) +echo "[3/3] Checking ABI..." +if command -v idris2 &>/dev/null; then + cd "$CART_DIR/abi" && idris2 --check RenderMcp.SafeCloud 2>&1 + echo " ABI: OK" +else + echo " ABI: SKIPPED (idris2 not in PATH)" +fi + +echo "" +echo "All tests passed for render-mcp!" diff --git a/cartridges/domains/code-quality/coderag-mcp/README.adoc b/cartridges/domains/code-quality/coderag-mcp/README.adoc new file mode 100644 index 0000000..42c0257 --- /dev/null +++ b/cartridges/domains/code-quality/coderag-mcp/README.adoc @@ -0,0 +1,67 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += coderag-mcp +Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk> +:spdx: MPL-2.0 +:tier: Ayo +:domain: Code Analysis +:protocols: MCP, REST + +== Overview + +Enterprise Code Intelligence Platform. Advanced graph-based code analysis for AI-assisted software development. Transforms complex software projects into searchable knowledge graphs using Neo4j. + +== Tools (5) + +[cols="2,4"] +|=== +| Tool | Description + +| `coderag_analyze_repository` | Analyze a GitHub/GitLab/Bitbucket repository and build a knowledge graph. +| `coderag_query_knowledge_graph` | Query the knowledge graph using Cypher or natural language. +| `coderag_calculate_metrics` | Calculate code quality metrics (CK metrics, package coupling, etc.). +| `coderag_semantic_search` | Semantic search across the codebase using natural language. +| `coderag_detect_language` | Detect programming languages and frameworks in a repository. +|=== + +== Architecture + +[cols="1,1,3"] +|=== +| Layer | Language | Purpose + +| ABI | Idris2 | Formally verified state machine with dependent-type proofs +| FFI | Zig | C-ABI implementation: Neo4j integration, graph traversal, metric calculation +| Adapter| Zig | Adapter bridge to the BoJ unified adapter protocol +|=== + +== Building + +[source,bash] +---- +# ABI (Idris2) +cd abi && idris2 --check *.idr + +# FFI (Zig) +cd ffi && zig build +cd ffi && zig build test + +# Adapter (Zig) +cd adapter && zig build +---- + +== Status + +Alpha (CRG D). Cartridge metadata declared in `cartridge.json`; 5 tool(s) exposed. +Not yet dogfooded externally. See `docs/READINESS.md` at repo root for the +project-wide grade table and `docs/practice/DOGFOOD-LOG.adoc` for usage evidence. + +== Ports + +Allowed:: 7687 (Neo4j Bolt), 7474 (Neo4j Browser) +Denied:: 22 (SSH), 23 (Telnet), 25 (SMTP), 135 (RPC), 139 (NetBIOS), 445 (SMB) + +== References + +- [CodeRAG GitHub](https://github.com/JonnoC/CodeRAG) +- [Neo4j Documentation](https://neo4j.com/docs/) diff --git a/cartridges/domains/code-quality/coderag-mcp/abi/README.adoc b/cartridges/domains/code-quality/coderag-mcp/abi/README.adoc new file mode 100644 index 0000000..a9b5388 --- /dev/null +++ b/cartridges/domains/code-quality/coderag-mcp/abi/README.adoc @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) += coderag-mcp / abi β€” Idris2 ABI layer +:orientation: deep + +== Purpose + +Encodes the coderag-mcp connection state machine and the MCP tool catalogue. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| coderag.ipkg | Idris2 package descriptor. +| coderag/Safecoderag.idr | State machine and tool definitions. +|=== + +== Invariants + +* at module top. +* Zero . + +== Test/proof surface + +Type-check only β€” + Error loading file "coderag.ipkg": File Not Found. + +== Read-first + +. Safecoderag.idr β€” state machine and tool definitions. + diff --git a/cartridges/domains/code-quality/coderag-mcp/adapter/README.adoc b/cartridges/domains/code-quality/coderag-mcp/adapter/README.adoc new file mode 100644 index 0000000..1334ba7 --- /dev/null +++ b/cartridges/domains/code-quality/coderag-mcp/adapter/README.adoc @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) += coderag-mcp / adapter β€” REST/gRPC/GraphQL bridge +:orientation: deep + +== Purpose + +Exposes the FFI layer over three protocols. Stateless β€” all state lives behind the FFI mutex. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| build.zig | Zig build graph. +| coderag_adapter.zig | Three-protocol dispatcher. +|=== + +== Invariants + +* Stateless β€” no global mutable state. +* One tool call per HTTP request. +* JSON content type for all responses. + +== Test/proof surface + +No inline tests β€” FFI tests cover correctness. Integration tested via cartridge-matrix tests. + +== Read-first + +. coderag_adapter.zig β€” tool-name β†’ FFI-call mapping. diff --git a/cartridges/domains/code-quality/coderag-mcp/cartridge.json b/cartridges/domains/code-quality/coderag-mcp/cartridge.json new file mode 100644 index 0000000..21b9ec3 --- /dev/null +++ b/cartridges/domains/code-quality/coderag-mcp/cartridge.json @@ -0,0 +1,158 @@ +{ + "$schema": "https://boj.dev/schemas/cartridge/v1.json", + "spdx": "MPL-2.0", + "copyright": "Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath)", + "name": "coderag-mcp", + "version": "0.1.0", + "description": "Enterprise Code Intelligence Platform. Advanced graph-based code analysis for AI-assisted software development. Transforms complex software projects into searchable knowledge graphs using Neo4j.", + "domain": "Code Analysis", + "tier": "Ayo", + "protocols": [ + "MCP", + "REST" + ], + "auth": { + "method": "none", + "env_var": null, + "credential_source": null + }, + "api": { + "base_url": "local://coderag-mcp", + "content_type": "application/json" + }, + "ports": { + "allowed": [ + 7687, + 7474 + ], + "denied": [ + 22, + 23, + 25, + 135, + 139, + 445 + ] + }, + "tools": [ + { + "name": "coderag_analyze_repository", + "description": "Analyze a GitHub/GitLab/Bitbucket repository and build a knowledge graph.", + "inputSchema": { + "type": "object", + "properties": { + "repository_url": { + "type": "string", + "description": "URL of the repository (GitHub/GitLab/Bitbucket)" + }, + "branch": { + "type": "string", + "description": "Branch to analyze (default: main)" + }, + "auth_token": { + "type": "string", + "description": "Authentication token for private repositories (optional)" + } + }, + "required": [ + "repository_url" + ] + } + }, + { + "name": "coderag_query_knowledge_graph", + "description": "Query the knowledge graph using Cypher or natural language.", + "inputSchema": { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "Cypher query or natural language question" + }, + "language": { + "type": "string", + "description": "Query language (cypher/nl, default: nl)" + } + }, + "required": [ + "query" + ] + } + }, + { + "name": "coderag_calculate_metrics", + "description": "Calculate code quality metrics (CK metrics, package coupling, etc.).", + "inputSchema": { + "type": "object", + "properties": { + "repository_url": { + "type": "string", + "description": "URL of the repository" + }, + "metric_types": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "ck", + "package", + "architectural" + ] + }, + "description": "Types of metrics to calculate" + } + }, + "required": [ + "repository_url" + ] + } + }, + { + "name": "coderag_semantic_search", + "description": "Semantic search across the codebase using natural language.", + "inputSchema": { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "Natural language query" + }, + "repository_url": { + "type": "string", + "description": "URL of the repository (optional)" + } + }, + "required": [ + "query" + ] + } + }, + { + "name": "coderag_detect_language", + "description": "Detect programming languages and frameworks in a repository.", + "inputSchema": { + "type": "object", + "properties": { + "repository_url": { + "type": "string", + "description": "URL of the repository" + } + }, + "required": [ + "repository_url" + ] + } + } + ], + "ffi": { + "so_path": "ffi/zig-out/lib/libcoderag_mcp.so", + "abi_version": "ADR-0006", + "symbols": [ + "boj_cartridge_init", + "boj_cartridge_deinit", + "boj_cartridge_name", + "boj_cartridge_version", + "boj_cartridge_invoke" + ] + } +} diff --git a/cartridges/domains/code-quality/coderag-mcp/ffi/README.adoc b/cartridges/domains/code-quality/coderag-mcp/ffi/README.adoc new file mode 100644 index 0000000..74933f9 --- /dev/null +++ b/cartridges/domains/code-quality/coderag-mcp/ffi/README.adoc @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) += coderag-mcp / ffi β€” Zig FFI layer +:orientation: deep + +== Purpose + +Zig implementation of the connection state machine from . + +== Files + +[cols="1,4"] +|=== +| File | Role + +| build.zig | Zig build graph. +| coderag_ffi.zig | FFI implementation. +|=== + +== Invariants + +* Mutex discipline for all shared state. +* Bounds checks before dereference. +* State-machine mirror of ABI. + +== Test/proof surface + +Inline blocks in coderag_ffi.zig. Run with . + +== Read-first + +. coderag_ffi.zig β€” FFI exports and guards. + diff --git a/cartridges/domains/code-quality/coderag-mcp/ffi/build.zig b/cartridges/domains/code-quality/coderag-mcp/ffi/build.zig new file mode 100644 index 0000000..c4327dc --- /dev/null +++ b/cartridges/domains/code-quality/coderag-mcp/ffi/build.zig @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const ffi_mod = b.addModule("coderag_mcp", .{ + .root_source_file = b.path("coderag_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + const lib = b.addLibrary(.{ + .name = "coderag_mcp", + .root_module = ffi_mod, + .linkage = .dynamic, + }); + lib.linkLibC(); + b.installArtifact(lib); + + const tests = b.addTest(.{ .root_module = ffi_mod }); + tests.linkLibC(); + const run_tests = b.addRunArtifact(tests); + const test_step = b.step("test", "Run FFI tests"); + test_step.dependOn(&run_tests.step); +} diff --git a/cartridges/domains/code-quality/coderag-mcp/ffi/cartridge_shim.zig b/cartridges/domains/code-quality/coderag-mcp/ffi/cartridge_shim.zig new file mode 100644 index 0000000..0e399db --- /dev/null +++ b/cartridges/domains/code-quality/coderag-mcp/ffi/cartridge_shim.zig @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// cartridge_shim.zig β€” Shared helpers for the ADR-0006 five-symbol +// cartridge ABI (`boj_cartridge_init / deinit / name / version / invoke`). +// +// The shim centralises the seven-code return convention, NUL-argument +// guards, tool-name comparison, and the buffer-too-small path so each +// cartridge's `boj_cartridge_invoke` can stay short β€” typically a tool +// table plus `shim.writeResult(...)`. +// +// Cartridges import this file by relative path (no build-graph change +// needed). Example: +// +// const shim = @import("../../../ffi/zig/src/cartridge_shim.zig"); +// +// export fn boj_cartridge_invoke( +// tool_name: [*c]const u8, +// json_args: [*c]const u8, +// out_buf: [*c]u8, +// in_out_len: [*c]usize, +// ) callconv(.c) i32 { +// _ = json_args; +// if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; +// const body = if (shim.toolIs(tool_name, "foo")) "{\"result\":{}}" +// else return shim.RC_UNKNOWN_TOOL; +// return shim.writeResult(out_buf, in_out_len, body); +// } + +const std = @import("std"); + +// ── Return codes (ADR-0006 Β§Return codes) ──────────────────────────── +// +// Frozen by ADR-0006. New failure modes compose these via the error +// JSON body β€” the integer surface does not grow without a follow-up ADR. + +pub const RC_SUCCESS: i32 = 0; +pub const RC_UNKNOWN_TOOL: i32 = -1; +pub const RC_BAD_ARGS: i32 = -2; +pub const RC_BUFFER_TOO_SMALL: i32 = -3; +pub const RC_RUNTIME_ERROR: i32 = -4; +pub const RC_PANIC: i32 = -5; +pub const RC_AUTH_DENIED: i32 = -6; + +// ── Invoke-path helpers ────────────────────────────────────────────── + +/// True if any of the three mandatory `boj_cartridge_invoke` output-path +/// pointers is null. Use at the top of every invoke to short-circuit to +/// `RC_BAD_ARGS`. +pub fn invokeArgsNull( + tool_name: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) bool { + return tool_name == null or out_buf == null or in_out_len == null; +} + +/// Compare a C-NUL-terminated tool-name pointer against a Zig string +/// literal. Caller must have already verified `tool_name` is non-null +/// (usually via `invokeArgsNull`). +pub fn toolIs(tool_name: [*c]const u8, expected: []const u8) bool { + const s = std.mem.span(@as([*:0]const u8, @ptrCast(tool_name))); + return std.mem.eql(u8, s, expected); +} + +/// Copy `body` into `out_buf[0..*in_out_len]` (as a capacity) and update +/// `*in_out_len` to the number of bytes written. Returns `RC_SUCCESS`. +/// +/// If `body.len` exceeds the current capacity stored in `*in_out_len`, +/// sets `*in_out_len` to the required size and returns +/// `RC_BUFFER_TOO_SMALL` β€” the caller is then expected to re-allocate +/// and retry, per ADR-0006 Β§Memory ownership. +/// +/// Caller must have already verified that `out_buf` and `in_out_len` +/// are non-null. +pub fn writeResult( + out_buf: [*c]u8, + in_out_len: [*c]usize, + body: []const u8, +) i32 { + const cap = in_out_len.*; + if (body.len > cap) { + in_out_len.* = body.len; + return RC_BUFFER_TOO_SMALL; + } + @memcpy(out_buf[0..body.len], body); + in_out_len.* = body.len; + return RC_SUCCESS; +} + +// ── Tests ──────────────────────────────────────────────────────────── + +test "writeResult: body fits, writes and sets length" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); + try std.testing.expectEqualStrings("hello", buf[0..len]); +} + +test "writeResult: too small returns -3 and sets required length" { + var buf: [2]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_BUFFER_TOO_SMALL, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: exact-fit succeeds" { + var buf: [5]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: empty body" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, ""); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 0), len); +} + +test "toolIs: matches and rejects" { + const name: [*:0]const u8 = "foo"; + try std.testing.expect(toolIs(@ptrCast(name), "foo")); + try std.testing.expect(!toolIs(@ptrCast(name), "bar")); + try std.testing.expect(!toolIs(@ptrCast(name), "foobar")); + try std.testing.expect(!toolIs(@ptrCast(name), "fo")); +} + +test "invokeArgsNull: detects each null slot" { + var buf: [4]u8 = undefined; + var len: usize = 4; + const name: [*:0]const u8 = "x"; + try std.testing.expect(!invokeArgsNull(@ptrCast(name), &buf, &len)); + try std.testing.expect(invokeArgsNull(null, &buf, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), null, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), &buf, null)); +} diff --git a/cartridges/domains/code-quality/coderag-mcp/ffi/coderag_ffi.zig b/cartridges/domains/code-quality/coderag-mcp/ffi/coderag_ffi.zig new file mode 100644 index 0000000..f26661f --- /dev/null +++ b/cartridges/domains/code-quality/coderag-mcp/ffi/coderag_ffi.zig @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// coderag-mcp FFI β€” ADR-0006 five-symbol cartridge ABI implementation. + +const std = @import("std"); +const shim = @import("cartridge_shim.zig"); + +const CARTRIDGE_NAME_PTR: [*:0]const u8 = "coderag-mcp"; +const CARTRIDGE_VERSION_PTR: [*:0]const u8 = "0.1.0"; + +export fn boj_cartridge_init() callconv(.c) c_int { + return 0; +} + +export fn boj_cartridge_deinit() callconv(.c) void {} + +export fn boj_cartridge_name() callconv(.c) [*:0]const u8 { + return CARTRIDGE_NAME_PTR; +} + +export fn boj_cartridge_version() callconv(.c) [*:0]const u8 { + return CARTRIDGE_VERSION_PTR; +} + +export fn boj_cartridge_invoke( + tool_name: [*c]const u8, + json_args: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) callconv(.c) i32 { + _ = json_args; + if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; + const body: []const u8 = if (shim.toolIs(tool_name, "coderag_analyze_repository")) + "{\"result\":{}}" + else if (shim.toolIs(tool_name, "coderag_query_knowledge_graph")) + "{\"result\":{}}" + else if (shim.toolIs(tool_name, "coderag_calculate_metrics")) + "{\"result\":{}}" + else if (shim.toolIs(tool_name, "coderag_semantic_search")) + "{\"result\":{}}" + else if (shim.toolIs(tool_name, "coderag_detect_language")) + "{\"result\":{}}" + else + return shim.RC_UNKNOWN_TOOL; + + return shim.writeResult(out_buf, in_out_len, body); +} + +test "boj_cartridge_name returns coderag-mcp" { + try std.testing.expectEqualStrings("coderag-mcp", std.mem.span(boj_cartridge_name())); +} + +test "boj_cartridge_init returns 0" { + try std.testing.expectEqual(@as(c_int, 0), boj_cartridge_init()); +} + +test "invoke unknown tool returns RC_UNKNOWN_TOOL" { + var buf: [256]u8 = undefined; + var len: usize = buf.len; + try std.testing.expectEqual(@as(i32, shim.RC_UNKNOWN_TOOL), boj_cartridge_invoke("unknown_xyz", "{}", &buf, &len)); +} + +test "invoke coderag_analyze_repository returns 0" { + var buf: [256]u8 = undefined; + var len: usize = buf.len; + try std.testing.expectEqual(@as(i32, 0), boj_cartridge_invoke("coderag_analyze_repository", "{}", &buf, &len)); +} diff --git a/cartridges/domains/code-quality/coderag-mcp/mod.js b/cartridges/domains/code-quality/coderag-mcp/mod.js new file mode 100644 index 0000000..cd15ffd --- /dev/null +++ b/cartridges/domains/code-quality/coderag-mcp/mod.js @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// coderag-mcp/mod.js β€” CodeRAG enterprise code intelligence cartridge. +// +// Delegates to backend at http://127.0.0.1:7474 (override with CODERAG_URL). +// No auth required. The backend connects to Neo4j on bolt://127.0.0.1:7687. + +const BASE_URL = Deno.env.get("CODERAG_URL") ?? "http://127.0.0.1:7474"; +const TIMEOUT_MS = 60_000; // graph analysis can be slow + +async function post(path, payload) { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), TIMEOUT_MS); + try { + const r = await fetch(`${BASE_URL}${path}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + signal: ctrl.signal, + }); + const data = await r.json().catch(() => ({ success: false, error: "non-JSON response" })); + return { status: r.status, data }; + } catch (e) { + if (e.name === "AbortError") + return { status: 504, data: { success: false, error: "coderag-mcp backend timed out" } }; + return { status: 503, data: { success: false, error: `coderag-mcp backend unavailable: ${e.message}` } }; + } finally { clearTimeout(t); } +} + +export async function handleTool(toolName, args) { + switch (toolName) { + case "coderag_analyze_repository": { + const { repository_url, branch, auth_token } = args ?? {}; + if (!repository_url) return { status: 400, data: { error: "repository_url is required" } }; + const payload = { repository_url }; + if (branch !== undefined) payload.branch = branch; + if (auth_token !== undefined) payload.auth_token = auth_token; + return post("/api/v1/analyze", payload); + } + + case "coderag_query_knowledge_graph": { + const { query, language } = args ?? {}; + if (!query) return { status: 400, data: { error: "query is required" } }; + const payload = { query }; + if (language !== undefined) payload.language = language; + return post("/api/v1/query", payload); + } + + case "coderag_calculate_metrics": { + const { repository_url, metric_types } = args ?? {}; + if (!repository_url) return { status: 400, data: { error: "repository_url is required" } }; + const payload = { repository_url }; + if (metric_types !== undefined) payload.metric_types = metric_types; + return post("/api/v1/metrics", payload); + } + + case "coderag_semantic_search": { + const { query, repository_url } = args ?? {}; + if (!query) return { status: 400, data: { error: "query is required" } }; + const payload = { query }; + if (repository_url !== undefined) payload.repository_url = repository_url; + return post("/api/v1/search", payload); + } + + case "coderag_detect_language": { + const { repository_url } = args ?? {}; + if (!repository_url) return { status: 400, data: { error: "repository_url is required" } }; + return post("/api/v1/detect-language", { repository_url }); + } + + default: + return { status: 404, data: { error: `Unknown tool: ${toolName}` } }; + } +} diff --git a/cartridges/domains/code-quality/sanctify-mcp/.editorconfig b/cartridges/domains/code-quality/sanctify-mcp/.editorconfig new file mode 100644 index 0000000..36a608d --- /dev/null +++ b/cartridges/domains/code-quality/sanctify-mcp/.editorconfig @@ -0,0 +1,13 @@ +# SPDX-License-Identifier: MPL-2.0 +root = true + +[*] +charset = utf-8 +indent_style = space +indent_size = 2 +end_of_line = lf +trim_trailing_whitespace = true +insert_final_newline = true + +[*.md] +trim_trailing_whitespace = false diff --git a/cartridges/domains/code-quality/sanctify-mcp/.gitignore b/cartridges/domains/code-quality/sanctify-mcp/.gitignore new file mode 100644 index 0000000..6f90203 --- /dev/null +++ b/cartridges/domains/code-quality/sanctify-mcp/.gitignore @@ -0,0 +1,11 @@ +// SPDX-License-Identifier: MPL-2.0 +*.swp +*.swo +*~ +.DS_Store +node_modules/ +dist/ +build/ +target/ +.deno +deno.lock diff --git a/cartridges/domains/code-quality/sanctify-mcp/LICENSE b/cartridges/domains/code-quality/sanctify-mcp/LICENSE new file mode 100644 index 0000000..6e8c527 --- /dev/null +++ b/cartridges/domains/code-quality/sanctify-mcp/LICENSE @@ -0,0 +1,15 @@ +SPDX-License-Identifier: MPL-2.0 + +Sanctify Cartridge +Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) + +This software is licensed under the MPL-2.0 license. + +MPL-2.0 is a license supporting dual licensing with MPL-2.0 as automatic fallback. +For the full license text, see: https://hyperpolymath.dev/standards/PMPL-1.0 + +Legal Notice: +Until PMPL achieves formal recognition as a standalone license, this software is +automatically operative under the Mozilla Public License 2.0 (MPL-2.0). + +This is a legal fallback arrangement confirmed by legal counsel. diff --git a/cartridges/domains/code-quality/sanctify-mcp/README.adoc b/cartridges/domains/code-quality/sanctify-mcp/README.adoc new file mode 100644 index 0000000..e82b589 --- /dev/null +++ b/cartridges/domains/code-quality/sanctify-mcp/README.adoc @@ -0,0 +1,67 @@ += Sanctify Cartridge +:toc: preamble +:author: Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk> +:date: 2026-04-25 +:spdx: MPL-2.0 + +// SPDX-License-Identifier: MPL-2.0 + +PHP linter and best-practice deviation detection β€” analyze code for syntax errors, style issues, and security concerns. + +== Features + +- **File Linting** β€” Syntax and style issue detection +- **Deviation Detection** β€” Identify deviations from best practices (naming, style, security, performance, deprecated APIs) +- **Code Analysis** β€” Comprehensive file-level analysis +- **Snippet Checking** β€” Quick validation of code snippets +- **Syntax Validation** β€” Check PHP syntax without execution + +== Architecture + +[cols="1,3"] +|=== +| Component | Purpose + +| `abi/Sanctify.idr` +| Idris2 interface with lint types (LintSeverity, LintIssue, DeviationType, AnalysisResult). + +| `ffi/sanctify_ffi.zig` +| Zig bindings for PHP linting and deviation detection. + +| `adapter/mod.ts` +| Deno MCP server exposing PHP linting tools. + Runs on `127.0.0.1:5176` (loopback only). + +| `cartridge.json` +| Tool manifest with 5 MCP tools for PHP analysis. +|=== + +== MCP Tools + +=== `lint_file` +Lint PHP file for syntax and style issues with severity levels (error, warning, notice, info). + +=== `detect_deviations` +Detect deviations from PHP best practices (naming conventions, style guide, security, performance, deprecated APIs). + +=== `analyze_file` +Comprehensive analysis combining linting, deviation detection, and validation. + +=== `check_snippet` +Quickly check a PHP code snippet for issues without file I/O. + +=== `validate_syntax` +Validate PHP syntax without execution risk. + +== Integration + +Connects to sanctify (PHP linter) via: +- **Syntax parsing** for code validation +- **Best practice rules** for deviation detection +- **Severity classification** for issue prioritization + +Loopback proof pinning: `IsLoopback 5176` at compile-time. + +== License + +MPL-2.0 (MPL-2.0 legal fallback). diff --git a/cartridges/domains/code-quality/sanctify-mcp/abi/Sanctify.idr b/cartridges/domains/code-quality/sanctify-mcp/abi/Sanctify.idr new file mode 100644 index 0000000..06106a6 --- /dev/null +++ b/cartridges/domains/code-quality/sanctify-mcp/abi/Sanctify.idr @@ -0,0 +1,68 @@ +-- SPDX-License-Identifier: MPL-2.0 +-- Sanctify Cartridge ABI β€” PHP lint and deviation detection interface + +module ABI.Sanctify + +%language ElabReflection + +-- Lint severity levels +public export +data LintSeverity : Type where + Error : LintSeverity + Warning : LintSeverity + Notice : LintSeverity + Info : LintSeverity + +-- Lint issue record +public export +record LintIssue where + constructor MkLintIssue + file : String + line : Nat + column : Nat + severity : LintSeverity + code : String + message : String + suggestion : String + +-- Deviation detection type +public export +data DeviationType : Type where + NamingConvention : DeviationType + StyleGuide : DeviationType + SecurityPractice : DeviationType + PerformanceAntipattern : DeviationType + DeprecatedAPI : DeviationType + +-- Code analysis result +public export +record AnalysisResult where + constructor MkAnalysisResult + filePath : String + isValid : Bool + lintIssues : List LintIssue + deviations : List DeviationType + scanTimeMs : Nat + +-- Sanctify cartridge interface +public export +interface Sanctify.Linter where + -- Lint PHP file for syntax and style issues + lintFile : String -> IO (List LintIssue) + + -- Detect deviations from PHP best practices + detectDeviations : String -> IO (List DeviationType) + + -- Analyze entire PHP file + analyzeFile : String -> IO AnalysisResult + + -- Check a code snippet for issues + checkSnippet : String -> IO (List LintIssue) + + -- Loopback proof: cartridge runs on localhost only + IsLoopback : (port : Nat) -> Type + IsLoopback 5176 = () + +public export +Loopback.proof : IsLoopback 5176 +Loopback.proof = () diff --git a/cartridges/domains/code-quality/sanctify-mcp/adapter/mod.ts b/cartridges/domains/code-quality/sanctify-mcp/adapter/mod.ts new file mode 100644 index 0000000..356f19c --- /dev/null +++ b/cartridges/domains/code-quality/sanctify-mcp/adapter/mod.ts @@ -0,0 +1,193 @@ +// SPDX-License-Identifier: MPL-2.0 +// Sanctify Cartridge β€” PHP linter and deviation detector MCP server + +import { Server } from "https://esm.sh/@modelcontextprotocol/sdk/server/index.js"; +import { + CallToolRequestSchema, + ListToolsRequestSchema, + Tool, +} from "https://esm.sh/@modelcontextprotocol/sdk/types.js"; + +// MCP tool definitions for PHP linting +const TOOLS: Tool[] = [ + { + name: "lint_file", + description: "Lint PHP file for syntax and style issues", + inputSchema: { + type: "object" as const, + properties: { + file_path: { + type: "string", + description: "Path to PHP file to lint", + }, + }, + required: ["file_path"], + }, + }, + { + name: "detect_deviations", + description: "Detect deviations from PHP best practices (naming, style, security)", + inputSchema: { + type: "object" as const, + properties: { + file_path: { + type: "string", + description: "Path to PHP file to analyze", + }, + }, + required: ["file_path"], + }, + }, + { + name: "analyze_file", + description: "Comprehensive analysis of PHP file (syntax, style, deviations)", + inputSchema: { + type: "object" as const, + properties: { + file_path: { + type: "string", + description: "Path to PHP file to analyze", + }, + }, + required: ["file_path"], + }, + }, + { + name: "check_snippet", + description: "Check a PHP code snippet for lint issues", + inputSchema: { + type: "object" as const, + properties: { + snippet: { + type: "string", + description: "PHP code snippet to check", + }, + }, + required: ["snippet"], + }, + }, + { + name: "validate_syntax", + description: "Validate PHP syntax (without execution)", + inputSchema: { + type: "object" as const, + properties: { + code: { + type: "string", + description: "PHP code to validate", + }, + }, + required: ["code"], + }, + }, +]; + +// Tool handlers +async function handleLintFile( + args: Record<string, unknown> +): Promise<string> { + const filePath = String(args.file_path); + return JSON.stringify({ + file: filePath, + issues: [], + count: 0, + }); +} + +async function handleDetectDeviations( + args: Record<string, unknown> +): Promise<string> { + const filePath = String(args.file_path); + return JSON.stringify({ + file: filePath, + deviations: [], + count: 0, + }); +} + +async function handleAnalyzeFile( + args: Record<string, unknown> +): Promise<string> { + const filePath = String(args.file_path); + return JSON.stringify({ + file: filePath, + is_valid: true, + lint_issues: [], + deviations: [], + scan_time_ms: 0, + }); +} + +async function handleCheckSnippet( + args: Record<string, unknown> +): Promise<string> { + const snippet = String(args.snippet); + return JSON.stringify({ + snippet_hash: "abc123", + issues: [], + count: 0, + }); +} + +async function handleValidateSyntax( + args: Record<string, unknown> +): Promise<string> { + const code = String(args.code); + return JSON.stringify({ + is_valid: true, + errors: [], + warnings: [], + }); +} + +// Initialize MCP server +const server = new Server({ + name: "sanctify-mcp", + version: "1.0.0", +}); + +// Register tool handlers +server.setRequestHandler(ListToolsRequestSchema, async () => { + return { tools: TOOLS }; +}); + +server.setRequestHandler(CallToolRequestSchema, async (request) => { + const { name, arguments: args } = request; + + let result: string; + if (name === "lint_file") { + result = await handleLintFile(args as Record<string, unknown>); + } else if (name === "detect_deviations") { + result = await handleDetectDeviations(args as Record<string, unknown>); + } else if (name === "analyze_file") { + result = await handleAnalyzeFile(args as Record<string, unknown>); + } else if (name === "check_snippet") { + result = await handleCheckSnippet(args as Record<string, unknown>); + } else if (name === "validate_syntax") { + result = await handleValidateSyntax(args as Record<string, unknown>); + } else { + return { + content: [ + { + type: "text" as const, + text: `Unknown tool: ${name}`, + }, + ], + isError: true, + }; + } + + return { + content: [ + { + type: "text" as const, + text: result, + }, + ], + }; +}); + +// Start server on loopback +const port = 5176; +await server.connect(new WebSocket(`ws://127.0.0.1:${port}`)); +console.log("Sanctify MCP server running on ws://127.0.0.1:5176"); diff --git a/cartridges/domains/code-quality/sanctify-mcp/cartridge.json b/cartridges/domains/code-quality/sanctify-mcp/cartridge.json new file mode 100644 index 0000000..0344b87 --- /dev/null +++ b/cartridges/domains/code-quality/sanctify-mcp/cartridge.json @@ -0,0 +1,70 @@ +{ + "$schema": "https://boj.dev/schemas/cartridge/v1.json", + "spdx": "MPL-2.0", + "copyright": "Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath)", + "name": "sanctify-mcp", + "version": "1.0.0", + "description": "Sanctify Cartridge \u2014 PHP lint and deviation detection tools", + "domain": "Code Quality", + "tier": "Ayo", + "auth": { + "method": "none" + }, + "author": "Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk>", + "license": "MPL-2.0", + "tools": [ + { + "id": "lint_file", + "name": "Lint File", + "description": "Lint PHP file for syntax and style issues" + }, + { + "id": "detect_deviations", + "name": "Detect Deviations", + "description": "Detect deviations from PHP best practices (naming, style, security)" + }, + { + "id": "analyze_file", + "name": "Analyze File", + "description": "Comprehensive analysis of PHP file (syntax, style, deviations)" + }, + { + "id": "check_snippet", + "name": "Check Snippet", + "description": "Check a PHP code snippet for lint issues" + }, + { + "id": "validate_syntax", + "name": "Validate Syntax", + "description": "Validate PHP syntax (without execution)" + } + ], + "abi": { + "interface": "abi/Sanctify.idr", + "loopback_proof": "IsLoopback 5176" + }, + "ffi": { + "so_path": "ffi/zig-out/lib/libsanctify_mcp.so", + "abi_version": "ADR-0006", + "symbols": [ + "boj_cartridge_init", + "boj_cartridge_deinit", + "boj_cartridge_name", + "boj_cartridge_version", + "boj_cartridge_invoke" + ] + }, + "adapter": { + "language": "typescript", + "runtime": "deno", + "entry": "adapter/mod.ts", + "permissions": [ + "net", + "env" + ] + }, + "loopback": { + "host": "127.0.0.1", + "port": 5176 + } +} diff --git a/cartridges/domains/code-quality/sanctify-mcp/ffi/build.zig b/cartridges/domains/code-quality/sanctify-mcp/ffi/build.zig new file mode 100644 index 0000000..2886f3d --- /dev/null +++ b/cartridges/domains/code-quality/sanctify-mcp/ffi/build.zig @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const ffi_mod = b.addModule("sanctify_mcp", .{ + .root_source_file = b.path("sanctify_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + const lib = b.addLibrary(.{ + .name = "sanctify_mcp", + .root_module = ffi_mod, + .linkage = .dynamic, + }); + lib.linkLibC(); + b.installArtifact(lib); + + const tests = b.addTest(.{ .root_module = ffi_mod }); + tests.linkLibC(); + const run_tests = b.addRunArtifact(tests); + const test_step = b.step("test", "Run FFI tests"); + test_step.dependOn(&run_tests.step); +} diff --git a/cartridges/domains/code-quality/sanctify-mcp/ffi/cartridge_shim.zig b/cartridges/domains/code-quality/sanctify-mcp/ffi/cartridge_shim.zig new file mode 100644 index 0000000..0e399db --- /dev/null +++ b/cartridges/domains/code-quality/sanctify-mcp/ffi/cartridge_shim.zig @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// cartridge_shim.zig β€” Shared helpers for the ADR-0006 five-symbol +// cartridge ABI (`boj_cartridge_init / deinit / name / version / invoke`). +// +// The shim centralises the seven-code return convention, NUL-argument +// guards, tool-name comparison, and the buffer-too-small path so each +// cartridge's `boj_cartridge_invoke` can stay short β€” typically a tool +// table plus `shim.writeResult(...)`. +// +// Cartridges import this file by relative path (no build-graph change +// needed). Example: +// +// const shim = @import("../../../ffi/zig/src/cartridge_shim.zig"); +// +// export fn boj_cartridge_invoke( +// tool_name: [*c]const u8, +// json_args: [*c]const u8, +// out_buf: [*c]u8, +// in_out_len: [*c]usize, +// ) callconv(.c) i32 { +// _ = json_args; +// if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; +// const body = if (shim.toolIs(tool_name, "foo")) "{\"result\":{}}" +// else return shim.RC_UNKNOWN_TOOL; +// return shim.writeResult(out_buf, in_out_len, body); +// } + +const std = @import("std"); + +// ── Return codes (ADR-0006 Β§Return codes) ──────────────────────────── +// +// Frozen by ADR-0006. New failure modes compose these via the error +// JSON body β€” the integer surface does not grow without a follow-up ADR. + +pub const RC_SUCCESS: i32 = 0; +pub const RC_UNKNOWN_TOOL: i32 = -1; +pub const RC_BAD_ARGS: i32 = -2; +pub const RC_BUFFER_TOO_SMALL: i32 = -3; +pub const RC_RUNTIME_ERROR: i32 = -4; +pub const RC_PANIC: i32 = -5; +pub const RC_AUTH_DENIED: i32 = -6; + +// ── Invoke-path helpers ────────────────────────────────────────────── + +/// True if any of the three mandatory `boj_cartridge_invoke` output-path +/// pointers is null. Use at the top of every invoke to short-circuit to +/// `RC_BAD_ARGS`. +pub fn invokeArgsNull( + tool_name: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) bool { + return tool_name == null or out_buf == null or in_out_len == null; +} + +/// Compare a C-NUL-terminated tool-name pointer against a Zig string +/// literal. Caller must have already verified `tool_name` is non-null +/// (usually via `invokeArgsNull`). +pub fn toolIs(tool_name: [*c]const u8, expected: []const u8) bool { + const s = std.mem.span(@as([*:0]const u8, @ptrCast(tool_name))); + return std.mem.eql(u8, s, expected); +} + +/// Copy `body` into `out_buf[0..*in_out_len]` (as a capacity) and update +/// `*in_out_len` to the number of bytes written. Returns `RC_SUCCESS`. +/// +/// If `body.len` exceeds the current capacity stored in `*in_out_len`, +/// sets `*in_out_len` to the required size and returns +/// `RC_BUFFER_TOO_SMALL` β€” the caller is then expected to re-allocate +/// and retry, per ADR-0006 Β§Memory ownership. +/// +/// Caller must have already verified that `out_buf` and `in_out_len` +/// are non-null. +pub fn writeResult( + out_buf: [*c]u8, + in_out_len: [*c]usize, + body: []const u8, +) i32 { + const cap = in_out_len.*; + if (body.len > cap) { + in_out_len.* = body.len; + return RC_BUFFER_TOO_SMALL; + } + @memcpy(out_buf[0..body.len], body); + in_out_len.* = body.len; + return RC_SUCCESS; +} + +// ── Tests ──────────────────────────────────────────────────────────── + +test "writeResult: body fits, writes and sets length" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); + try std.testing.expectEqualStrings("hello", buf[0..len]); +} + +test "writeResult: too small returns -3 and sets required length" { + var buf: [2]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_BUFFER_TOO_SMALL, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: exact-fit succeeds" { + var buf: [5]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: empty body" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, ""); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 0), len); +} + +test "toolIs: matches and rejects" { + const name: [*:0]const u8 = "foo"; + try std.testing.expect(toolIs(@ptrCast(name), "foo")); + try std.testing.expect(!toolIs(@ptrCast(name), "bar")); + try std.testing.expect(!toolIs(@ptrCast(name), "foobar")); + try std.testing.expect(!toolIs(@ptrCast(name), "fo")); +} + +test "invokeArgsNull: detects each null slot" { + var buf: [4]u8 = undefined; + var len: usize = 4; + const name: [*:0]const u8 = "x"; + try std.testing.expect(!invokeArgsNull(@ptrCast(name), &buf, &len)); + try std.testing.expect(invokeArgsNull(null, &buf, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), null, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), &buf, null)); +} diff --git a/cartridges/domains/code-quality/sanctify-mcp/ffi/sanctify_ffi.zig b/cartridges/domains/code-quality/sanctify-mcp/ffi/sanctify_ffi.zig new file mode 100644 index 0000000..d3d5215 --- /dev/null +++ b/cartridges/domains/code-quality/sanctify-mcp/ffi/sanctify_ffi.zig @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// sanctify-mcp FFI β€” ADR-0006 five-symbol cartridge ABI implementation. + +const std = @import("std"); +const shim = @import("cartridge_shim.zig"); + +const CARTRIDGE_NAME_PTR: [*:0]const u8 = "sanctify-mcp"; +const CARTRIDGE_VERSION_PTR: [*:0]const u8 = "0.1.0"; + +export fn boj_cartridge_init() callconv(.c) c_int { + return 0; +} + +export fn boj_cartridge_deinit() callconv(.c) void {} + +export fn boj_cartridge_name() callconv(.c) [*:0]const u8 { + return CARTRIDGE_NAME_PTR; +} + +export fn boj_cartridge_version() callconv(.c) [*:0]const u8 { + return CARTRIDGE_VERSION_PTR; +} + +export fn boj_cartridge_invoke( + tool_name: [*c]const u8, + json_args: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) callconv(.c) i32 { + _ = json_args; + if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; + const body: []const u8 = if (shim.toolIs(tool_name, "lint_file")) + "{\"result\":{}}" + else if (shim.toolIs(tool_name, "detect_deviations")) + "{\"result\":{}}" + else if (shim.toolIs(tool_name, "analyze_file")) + "{\"result\":{}}" + else if (shim.toolIs(tool_name, "check_snippet")) + "{\"result\":{}}" + else if (shim.toolIs(tool_name, "validate_syntax")) + "{\"result\":{}}" + else + return shim.RC_UNKNOWN_TOOL; + + return shim.writeResult(out_buf, in_out_len, body); +} + +test "boj_cartridge_name returns sanctify-mcp" { + try std.testing.expectEqualStrings("sanctify-mcp", std.mem.span(boj_cartridge_name())); +} + +test "boj_cartridge_init returns 0" { + try std.testing.expectEqual(@as(c_int, 0), boj_cartridge_init()); +} + +test "invoke unknown tool returns RC_UNKNOWN_TOOL" { + var buf: [256]u8 = undefined; + var len: usize = buf.len; + try std.testing.expectEqual(@as(i32, shim.RC_UNKNOWN_TOOL), boj_cartridge_invoke("unknown_xyz", "{}", &buf, &len)); +} + +test "invoke lint_file returns 0" { + var buf: [256]u8 = undefined; + var len: usize = buf.len; + try std.testing.expectEqual(@as(i32, 0), boj_cartridge_invoke("lint_file", "{}", &buf, &len)); +} diff --git a/cartridges/domains/code-quality/sanctify-mcp/mod.js b/cartridges/domains/code-quality/sanctify-mcp/mod.js new file mode 100644 index 0000000..5269f82 --- /dev/null +++ b/cartridges/domains/code-quality/sanctify-mcp/mod.js @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: MPL-2.0 +export const cartridge = { + name: "sanctify-mcp", + version: "1.0.0", + description: "Sanctify cartridge β€” PHP lint and deviation detection", + tools: [ + { id: "lint_file", name: "Lint File" }, + { id: "detect_deviations", name: "Detect Deviations" }, + { id: "analyze_file", name: "Analyze File" }, + { id: "check_snippet", name: "Check Snippet" }, + { id: "validate_syntax", name: "Validate Syntax" }, + ], +}; + +export async function health() { + return { status: "healthy", cartridge: "sanctify-mcp" }; +} + +export async function init() { + console.log("[sanctify-mcp] Initializing"); + return { initialized: true }; +} + +export async function cleanup() { + console.log("[sanctify-mcp] Shutting down"); + return { cleaned: true }; +} diff --git a/cartridges/domains/communications/burble-admin-mcp/README.adoc b/cartridges/domains/communications/burble-admin-mcp/README.adoc new file mode 100644 index 0000000..962ed88 --- /dev/null +++ b/cartridges/domains/communications/burble-admin-mcp/README.adoc @@ -0,0 +1,64 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += burble-admin-mcp +Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk> +:spdx: MPL-2.0 +:tier: Ayo +:domain: communications +:protocols: MCP, REST + +== Overview + +Burble WebRTC server administration + +== Tools (10) + +[cols="2,4"] +|=== +| Tool | Description + +| `burble_check_health` | Check Burble server health +| `burble_list_rooms` | List active rooms +| `burble_create_room` | Create a new room +| `burble_close_room` | Close a room +| `burble_kick_user` | Kick a user from a room +| `burble_get_config` | Get server configuration +| `burble_update_config` | Update server configuration +| `burble_voice_stats` | Get voice/media statistics +| `burble_toggle_recording` | Toggle room recording +| `burble_node_status` | Get media node status +|=== + +== Architecture + +Three-layer stack per the BoJ cartridge standard: + +[cols="1,1,3"] +|=== +| Layer | Language | Purpose + +| ABI | Idris2 | Formally verified state machine with dependent-type proofs +| FFI | Zig | C-ABI implementation: session pool, action metrics, thread safety +| Adapter| Zig | Adapter bridge to the BoJ unified adapter protocol (zig predecessors sidelined) +|=== + +== Building + +[source,bash] +---- +# ABI (Idris2) +cd abi && idris2 --check *.idr + +# FFI (Zig) +cd ffi && zig build +cd ffi && zig build test + +# Adapter (Zig) +cd adapter && zig build +---- + +== Status + +Alpha (CRG D). Cartridge metadata declared in `cartridge.json`; 10 tool(s) exposed. +Not yet dogfooded externally. See `docs/READINESS.md` at repo root for the +project-wide grade table and `docs/practice/DOGFOOD-LOG.adoc` for usage evidence. diff --git a/cartridges/domains/communications/burble-admin-mcp/abi/BurbleAdmin/Protocol.idr b/cartridges/domains/communications/burble-admin-mcp/abi/BurbleAdmin/Protocol.idr new file mode 100644 index 0000000..330977a --- /dev/null +++ b/cartridges/domains/communications/burble-admin-mcp/abi/BurbleAdmin/Protocol.idr @@ -0,0 +1,83 @@ +-- SPDX-License-Identifier: MPL-2.0 +-- Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +||| Protocol: Burble voice platform administration via BoJ MCP +||| +||| Cartridge: burble-admin +||| Matrix cell: Voice Platform x Administration +||| +||| Defines the formal interface for managing Burble rooms, users, +||| and voice sessions through the BoJ Server. Proves that: +||| 1. Admin operations require authentication +||| 2. Room capacity is bounded +||| 3. Recording access is permission-gated +module BurbleAdmin.Protocol + +import Data.Fin + +%default total + +-- ═══════════════════════════════════════════════════════════════════════ +-- Core Types +-- ═══════════════════════════════════════════════════════════════════════ + +||| Administrative operation on a Burble instance +public export +data Operation + = ListRooms -- List active voice rooms + | CreateRoom -- Create a new room + | DeleteRoom -- Delete an existing room + | ListUsers -- List connected users + | KickUser -- Remove a user from a room + | GetMetrics -- Get platform metrics + | ManageRecordings -- Access recording management + +||| Permission level required for operations +public export +data PermLevel = ReadOnly | Moderator | Admin + +||| Proof that an operation requires at least the given permission +public export +data RequiresPermission : Operation -> PermLevel -> Type where + ListRoomsRead : RequiresPermission ListRooms ReadOnly + CreateRoomMod : RequiresPermission CreateRoom Moderator + DeleteRoomAdmin : RequiresPermission DeleteRoom Admin + ListUsersRead : RequiresPermission ListUsers ReadOnly + KickUserMod : RequiresPermission KickUser Moderator + GetMetricsRead : RequiresPermission GetMetrics ReadOnly + RecordingsAdmin : RequiresPermission ManageRecordings Admin + +||| Room capacity is bounded (1-500 participants) +public export +data RoomCapacity = MkCapacity (n : Fin 500) + +-- ═══════════════════════════════════════════════════════════════════════ +-- C ABI Exports +-- ═══════════════════════════════════════════════════════════════════════ + +export +operationToInt : Operation -> Int +operationToInt ListRooms = 0 +operationToInt CreateRoom = 1 +operationToInt DeleteRoom = 2 +operationToInt ListUsers = 3 +operationToInt KickUser = 4 +operationToInt GetMetrics = 5 +operationToInt ManageRecordings = 6 + +export +permLevelToInt : PermLevel -> Int +permLevelToInt ReadOnly = 0 +permLevelToInt Moderator = 1 +permLevelToInt Admin = 2 + +||| Minimum permission level for an operation (C ABI) +export +burble_min_perm : Int -> Int +burble_min_perm 0 = 0 -- ListRooms β†’ ReadOnly +burble_min_perm 1 = 1 -- CreateRoom β†’ Moderator +burble_min_perm 2 = 2 -- DeleteRoom β†’ Admin +burble_min_perm 3 = 0 -- ListUsers β†’ ReadOnly +burble_min_perm 4 = 1 -- KickUser β†’ Moderator +burble_min_perm 5 = 0 -- GetMetrics β†’ ReadOnly +burble_min_perm 6 = 2 -- ManageRecordings β†’ Admin +burble_min_perm _ = 2 -- Unknown β†’ require Admin (safe default) diff --git a/cartridges/domains/communications/burble-admin-mcp/abi/README.adoc b/cartridges/domains/communications/burble-admin-mcp/abi/README.adoc new file mode 100644 index 0000000..de92f8f --- /dev/null +++ b/cartridges/domains/communications/burble-admin-mcp/abi/README.adoc @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += burble-admin-mcp / abi β€” Idris2 ABI layer +:orientation: shallow + +== Purpose + +Cartridge-local protocol types for `burble-admin-mcp`. Defines the data, tool, and +transition vocabulary this cartridge speaks. Inherits safety lemmas and the +matrix-cell identifier scheme from the trunk ABI at `src/abi/`. + +== Files + +1 Idris2 module(s), ~83 lines total. Lead module: +`Protocol.idr`. + +No `.ipkg` β€” consumed directly by wider BoJ callers; type-checks inside the +trunk `boj.ipkg`. + +== Invariants + +* `%default total` at every module top. +* Zero `believe_me`, `assert_total`, `postulate`, `Admitted`, or + `sorry` (the trunk holds the four declared `believe_me` sites; leaf + cartridges carry none). + +== Test/proof surface + +Thin module(s), no inline tests. Proof surface = type-check success under the +trunk `idris2 --check`. + +== Read-first + +. `Protocol.idr` β€” cartridge-local data records and enums. +. Trunk `src/abi/Boj/Catalogue.idr` for how this cell participates in the + 2D matrix. diff --git a/cartridges/domains/communications/burble-admin-mcp/abi/burble-admin.ipkg b/cartridges/domains/communications/burble-admin-mcp/abi/burble-admin.ipkg new file mode 100644 index 0000000..ed01cd8 --- /dev/null +++ b/cartridges/domains/communications/burble-admin-mcp/abi/burble-admin.ipkg @@ -0,0 +1,6 @@ +-- SPDX-License-Identifier: MPL-2.0 +package burbleAdmin + +modules = BurbleAdmin.Protocol + +sourcedir = "." diff --git a/cartridges/domains/communications/burble-admin-mcp/adapter/README.adoc b/cartridges/domains/communications/burble-admin-mcp/adapter/README.adoc new file mode 100644 index 0000000..934b436 --- /dev/null +++ b/cartridges/domains/communications/burble-admin-mcp/adapter/README.adoc @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += burble-admin-mcp / adapter β€” protocol bridge +:orientation: shallow + +== Purpose + +Exposes this cartridge's FFI over the wire protocols declared in +`cartridge.json`. Dispatches tool invocations to the C-ABI exports at +`../ffi/` and formats responses per protocol. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” depends on the sibling `ffi` target. +| `burble_admin_adapter.zig` | Protocol dispatch (159 lines). Tool catalogue matches `cartridge.json`. +| `SIDELINED-*.v.adoc` | zig predecessor(s). Not built β€” zig banned 2026-04-10. +|=== + +== Invariants + +* **Stateless** β€” all state lives behind the FFI, never in the adapter. +* **Response passthrough** β€” whatever the FFI returns goes back to the wire + unmodified (no embellishment, no silent recovery). +* **Cartridge.json is source of truth** for the tool catalogue; drift between + adapter and manifest is a CI failure. + +== Test/proof surface + +No inline tests at the adapter layer β€” protocol routing is exercised by the +trunk seam tests at `ffi/zig/src/seams.zig`. + +== Read-first + +. `burble_admin_adapter.zig` β€” dispatch function and per-protocol routers. +. `../cartridge.json` β€” the tool manifest the dispatcher mirrors. diff --git a/cartridges/domains/communications/burble-admin-mcp/adapter/build.zig b/cartridges/domains/communications/burble-admin-mcp/adapter/build.zig new file mode 100644 index 0000000..672c7ac --- /dev/null +++ b/cartridges/domains/communications/burble-admin-mcp/adapter/build.zig @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// burble-admin-mcp/adapter/build.zig + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const ffi_mod = b.createModule(.{ + .root_source_file = b.path("../ffi/burble_admin_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + const adapter = b.addExecutable(.{ + .name = "burble_admin_adapter", + .root_source_file = b.path("burble_admin_adapter.zig"), + .target = target, + .optimize = optimize, + }); + adapter.root_module.addImport("burble_admin_ffi", ffi_mod); + b.installArtifact(adapter); +} diff --git a/cartridges/domains/communications/burble-admin-mcp/adapter/burble_admin_adapter.zig b/cartridges/domains/communications/burble-admin-mcp/adapter/burble_admin_adapter.zig new file mode 100644 index 0000000..17604ca --- /dev/null +++ b/cartridges/domains/communications/burble-admin-mcp/adapter/burble_admin_adapter.zig @@ -0,0 +1,159 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// burble-admin-mcp/adapter/burble_admin_adapter.zig +// +// Three-protocol BoJ adapter: REST (port 9205), gRPC-compat (port 9206), +// GraphQL (port 9207). +// Replaces the banned zig adapter (burble_admin_adapter.v). + +const std = @import("std"); +const ffi = @import("burble_admin_ffi"); + +const REST_PORT: u16 = 9205; +const GRPC_PORT: u16 = 9206; +const GQL_PORT: u16 = 9207; + +const Response = struct { status: u16, body: []const u8 }; + +fn okJson(buf: []u8, msg: []const u8) []u8 { + return std.fmt.bufPrint(buf, "{{\"success\":true,\"message\":\"{s}\"}}", .{msg}) catch buf[0..0]; +} + +fn errJson(buf: []u8, msg: []const u8) []u8 { + return std.fmt.bufPrint(buf, "{{\"success\":false,\"error\":\"{s}\"}}", .{msg}) catch buf[0..0]; +} + +fn dispatch(tool: []const u8, _body: []const u8, resp: []u8) Response { + _ = _body; + if (std.mem.eql(u8, tool, "burble_check_health")) { + return .{ .status = 200, .body = okJson(resp, "burble_check_health forwarded") }; + } + if (std.mem.eql(u8, tool, "burble_list_rooms")) { + return .{ .status = 200, .body = okJson(resp, "burble_list_rooms forwarded") }; + } + if (std.mem.eql(u8, tool, "burble_create_room")) { + return .{ .status = 200, .body = okJson(resp, "burble_create_room forwarded") }; + } + if (std.mem.eql(u8, tool, "burble_close_room")) { + return .{ .status = 200, .body = okJson(resp, "burble_close_room forwarded") }; + } + if (std.mem.eql(u8, tool, "burble_kick_user")) { + return .{ .status = 200, .body = okJson(resp, "burble_kick_user forwarded") }; + } + if (std.mem.eql(u8, tool, "burble_get_config")) { + return .{ .status = 200, .body = okJson(resp, "burble_get_config forwarded") }; + } + if (std.mem.eql(u8, tool, "burble_update_config")) { + return .{ .status = 200, .body = okJson(resp, "burble_update_config forwarded") }; + } + if (std.mem.eql(u8, tool, "burble_voice_stats")) { + return .{ .status = 200, .body = okJson(resp, "burble_voice_stats forwarded") }; + } + if (std.mem.eql(u8, tool, "burble_toggle_recording")) { + return .{ .status = 200, .body = okJson(resp, "burble_toggle_recording forwarded") }; + } + if (std.mem.eql(u8, tool, "burble_node_status")) { + return .{ .status = 200, .body = okJson(resp, "burble_node_status forwarded") }; + } + return .{ .status = 404, .body = errJson(resp, "unknown tool") }; +} + +fn dispatchRest(path: []const u8, body: []const u8, resp: []u8) Response { + // Expect /tools/<tool_name> + const prefix = "/tools/"; + if (std.mem.startsWith(u8, path, prefix)) { + const tool = path[prefix.len..]; + return dispatch(tool, body, resp); + } + return .{ .status = 404, .body = errJson(resp, "not found") }; +} + +fn dispatchGrpc(path: []const u8, body: []const u8, resp: []u8) Response { + // Expect /<Service>/<Method> β€” derive tool from Method + var it = std.mem.splitScalar(u8, path, '/'); + _ = it.next(); // leading empty + _ = it.next(); // service + const method = it.next() orelse return .{ .status = 404, .body = errJson(resp, "bad gRPC path") }; + return dispatch(method, body, resp); +} + +fn dispatchGraphql(body: []const u8, resp: []u8) Response { + if (std.mem.indexOf(u8, body, "burble_check_health") != null) + return dispatch("burble_check_health", body, resp); + if (std.mem.indexOf(u8, body, "burble_list_rooms") != null) + return dispatch("burble_list_rooms", body, resp); + if (std.mem.indexOf(u8, body, "burble_create_room") != null) + return dispatch("burble_create_room", body, resp); + if (std.mem.indexOf(u8, body, "burble_close_room") != null) + return dispatch("burble_close_room", body, resp); + if (std.mem.indexOf(u8, body, "burble_kick_user") != null) + return dispatch("burble_kick_user", body, resp); + if (std.mem.indexOf(u8, body, "burble_get_config") != null) + return dispatch("burble_get_config", body, resp); + if (std.mem.indexOf(u8, body, "burble_update_config") != null) + return dispatch("burble_update_config", body, resp); + if (std.mem.indexOf(u8, body, "burble_voice_stats") != null) + return dispatch("burble_voice_stats", body, resp); + if (std.mem.indexOf(u8, body, "burble_toggle_recording") != null) + return dispatch("burble_toggle_recording", body, resp); + if (std.mem.indexOf(u8, body, "burble_node_status") != null) + return dispatch("burble_node_status", body, resp); + return .{ .status = 400, .body = errJson(resp, "unrecognised GraphQL operation") }; +} + +fn handleConnection(stream: std.net.Stream, port: u16) void { + defer stream.close(); + var buf: [4096]u8 = undefined; + var resp_buf: [4096]u8 = undefined; + const n = stream.read(&buf) catch return; + const req = buf[0..n]; + const result = switch (port) { + REST_PORT => blk: { + // Parse HTTP/1.1: first line = METHOD PATH HTTP/x.y + var lines = std.mem.splitScalar(u8, req, '\n'); + const first = lines.next() orelse break :blk Response{ .status = 400, .body = "" }; + var parts = std.mem.splitScalar(u8, std.mem.trim(u8, first, "\r"), ' '); + _ = parts.next(); // method + const path = parts.next() orelse break :blk Response{ .status = 400, .body = "" }; + break :blk dispatchRest(path, req, &resp_buf); + }, + GRPC_PORT => blk: { + var lines = std.mem.splitScalar(u8, req, '\n'); + const first = lines.next() orelse break :blk Response{ .status = 400, .body = "" }; + var parts = std.mem.splitScalar(u8, std.mem.trim(u8, first, "\r"), ' '); + _ = parts.next(); + const path = parts.next() orelse break :blk Response{ .status = 400, .body = "" }; + break :blk dispatchGrpc(path, req, &resp_buf); + }, + GQL_PORT => dispatchGraphql(req, &resp_buf), + else => Response{ .status = 500, .body = "" }, + }; + var http_resp: [512]u8 = undefined; + const http = std.fmt.bufPrint(&http_resp, + "HTTP/1.1 {d} OK\r\nContent-Length: {d}\r\nContent-Type: application/json\r\n\r\n", + .{ result.status, result.body.len }) catch return; + _ = stream.write(http) catch {}; + _ = stream.write(result.body) catch {}; +} + +fn listenLoop(port: u16) !void { + const addr = std.net.Address.initIp4(.{ 127, 0, 0, 1 }, port); + var server = try addr.listen(.{ .reuse_address = true }); + defer server.deinit(); + while (true) { + const conn = try server.accept(); + const t = try std.Thread.spawn(.{}, handleConnection, .{ conn.stream, port }); + t.detach(); + } +} + +pub fn main() !void { + ffi.burble_admin_init(); + const t1 = try std.Thread.spawn(.{}, listenLoop, .{REST_PORT}); + const t2 = try std.Thread.spawn(.{}, listenLoop, .{GRPC_PORT}); + const t3 = try std.Thread.spawn(.{}, listenLoop, .{GQL_PORT}); + t1.join(); + t2.join(); + t3.join(); +} diff --git a/cartridges/domains/communications/burble-admin-mcp/cartridge.json b/cartridges/domains/communications/burble-admin-mcp/cartridge.json new file mode 100644 index 0000000..6cd74fe --- /dev/null +++ b/cartridges/domains/communications/burble-admin-mcp/cartridge.json @@ -0,0 +1,170 @@ +{ + "$schema": "https://boj.dev/schemas/cartridge/v1.json", + "spdx": "MPL-2.0", + "copyright": "Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk>", + "name": "burble-admin-mcp", + "version": "0.1.0", + "description": "Burble WebRTC server administration", + "domain": "communications", + "tier": "Ayo", + "protocols": [ + "MCP", + "REST" + ], + "auth": { + "method": "none", + "env_var": null, + "credential_source": null + }, + "api": { + "base_url": "local://burble-admin-mcp", + "content_type": "application/json" + }, + "tools": [ + { + "name": "burble_check_health", + "description": "Check Burble server health", + "inputSchema": { + "type": "object", + "properties": {} + } + }, + { + "name": "burble_list_rooms", + "description": "List active rooms", + "inputSchema": { + "type": "object", + "properties": {} + } + }, + { + "name": "burble_create_room", + "description": "Create a new room", + "inputSchema": { + "type": "object", + "properties": { + "room_name": { + "type": "string", + "description": "Room name" + }, + "max_participants": { + "type": "integer", + "description": "Max participants" + } + }, + "required": [ + "room_name" + ] + } + }, + { + "name": "burble_close_room", + "description": "Close a room", + "inputSchema": { + "type": "object", + "properties": { + "room_id": { + "type": "string", + "description": "Room ID" + } + }, + "required": [ + "room_id" + ] + } + }, + { + "name": "burble_kick_user", + "description": "Kick a user from a room", + "inputSchema": { + "type": "object", + "properties": { + "room_id": { + "type": "string", + "description": "Room ID" + }, + "user_id": { + "type": "string", + "description": "User ID" + } + }, + "required": [ + "room_id", + "user_id" + ] + } + }, + { + "name": "burble_get_config", + "description": "Get server configuration", + "inputSchema": { + "type": "object", + "properties": {} + } + }, + { + "name": "burble_update_config", + "description": "Update server configuration", + "inputSchema": { + "type": "object", + "properties": { + "config": { + "type": "object", + "description": "Configuration object" + } + }, + "required": [ + "config" + ] + } + }, + { + "name": "burble_voice_stats", + "description": "Get voice/media statistics", + "inputSchema": { + "type": "object", + "properties": {} + } + }, + { + "name": "burble_toggle_recording", + "description": "Toggle room recording", + "inputSchema": { + "type": "object", + "properties": { + "room_id": { + "type": "string", + "description": "Room ID" + }, + "enabled": { + "type": "boolean", + "description": "Enable or disable recording" + } + }, + "required": [ + "room_id", + "enabled" + ] + } + }, + { + "name": "burble_node_status", + "description": "Get media node status", + "inputSchema": { + "type": "object", + "properties": {} + } + } + ], + "ffi": { + "so_path": "ffi/zig-out/lib/libburble_admin_mcp.so", + "abi_version": "ADR-0006", + "symbols": [ + "boj_cartridge_init", + "boj_cartridge_deinit", + "boj_cartridge_name", + "boj_cartridge_version", + "boj_cartridge_invoke" + ] + } +} diff --git a/cartridges/domains/communications/burble-admin-mcp/ffi/README.adoc b/cartridges/domains/communications/burble-admin-mcp/ffi/README.adoc new file mode 100644 index 0000000..ca8ceee --- /dev/null +++ b/cartridges/domains/communications/burble-admin-mcp/ffi/README.adoc @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += burble-admin-mcp / ffi β€” Zig FFI layer **(stub)** +:orientation: shallow + +== Purpose + +C-ABI exports bridging this cartridge's Idris2 ABI to the runtime. Consumed by +the adapter for protocol dispatch and by the trunk loader for mount. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” produces `zig-out/lib/libburble-admin.so`. +| `burble_admin_ffi.zig` | C-ABI exports (3 exports, 3 inline tests, 97 lines). +|=== + +== Invariants + +* **Null-pointer rejection** on `[*c]const u8` arguments before any + dereference. +* **Negative return values** carry documented error meanings; zero is + success (matching the trunk error-code convention). +* **No allocator leaks** β€” every allocation path pairs with `defer free`. + +== Test/proof surface + +3 inline `test "..."` block(s) in `burble_admin_ffi.zig`. Run with +`cd ffi && zig build test`. + +== Read-first + +. `burble_admin_ffi.zig` β€” C-ABI signatures and guards. +. Trunk `ffi/zig/src/catalogue.zig` for how this `.so` is loaded. + +== Promotion gate + +Wire these exports to real backing state (not `return 0` / `return 1` +placeholders) before promoting past D. diff --git a/cartridges/domains/communications/burble-admin-mcp/ffi/build.zig b/cartridges/domains/communications/burble-admin-mcp/ffi/build.zig new file mode 100644 index 0000000..401047c --- /dev/null +++ b/cartridges/domains/communications/burble-admin-mcp/ffi/build.zig @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const ffi_mod = b.addModule("burble_admin_mcp", .{ + .root_source_file = b.path("burble_admin_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + const lib = b.addLibrary(.{ + .name = "burble_admin_mcp", + .root_module = ffi_mod, + .linkage = .dynamic, + }); + lib.linkLibC(); + b.installArtifact(lib); + + const tests = b.addTest(.{ .root_module = ffi_mod }); + tests.linkLibC(); + const run_tests = b.addRunArtifact(tests); + const test_step = b.step("test", "Run FFI tests"); + test_step.dependOn(&run_tests.step); +} diff --git a/cartridges/domains/communications/burble-admin-mcp/ffi/burble_admin_ffi.zig b/cartridges/domains/communications/burble-admin-mcp/ffi/burble_admin_ffi.zig new file mode 100644 index 0000000..ddafdcb --- /dev/null +++ b/cartridges/domains/communications/burble-admin-mcp/ffi/burble_admin_ffi.zig @@ -0,0 +1,206 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// Burble Admin FFI β€” C-compatible bridge for BoJ MCP cartridge. +// Implements the operations defined in BurbleAdmin.Protocol (Idris2 ABI). + +const std = @import("std"); + +// ═══════════════════════════════════════════════════════════════════════ +// Types (mirrors Idris2 ABI) +// ═══════════════════════════════════════════════════════════════════════ + +pub const Operation = enum(i32) { + list_rooms = 0, + create_room = 1, + delete_room = 2, + list_users = 3, + kick_user = 4, + get_metrics = 5, + manage_recordings = 6, +}; + +pub const PermLevel = enum(i32) { + read_only = 0, + moderator = 1, + admin = 2, +}; + +// ═══════════════════════════════════════════════════════════════════════ +// Permission checking (matches Idris2 proof) +// ═══════════════════════════════════════════════════════════════════════ + +/// Returns the minimum permission level for an operation. +/// Matches burble_min_perm in Protocol.idr exactly. +pub export fn burble_admin_min_perm(op: i32) i32 { + return switch (@as(Operation, @enumFromInt(op))) { + .list_rooms => 0, + .create_room => 1, + .delete_room => 2, + .list_users => 0, + .kick_user => 1, + .get_metrics => 0, + .manage_recordings => 2, + }; +} + +/// Check if a user with the given permission level can perform the operation. +/// Returns 1 if allowed, 0 if denied. +pub export fn burble_admin_check_perm(op: i32, user_perm: i32) i32 { + const required = burble_admin_min_perm(op); + return if (user_perm >= required) 1 else 0; +} + +// ═══════════════════════════════════════════════════════════════════════ +// Room capacity validation +// ═══════════════════════════════════════════════════════════════════════ + +/// Validate room capacity (1-500). Returns clamped value. +pub export fn burble_admin_clamp_capacity(requested: i32) i32 { + if (requested < 1) return 1; + if (requested > 500) return 500; + return requested; +} + +// ═══════════════════════════════════════════════════════════════════════ +// Standard ABI (ADR-0005 four symbols + ADR-0006 invoke) +// ═══════════════════════════════════════════════════════════════════════ + +const shim = @import("cartridge_shim.zig"); + +const CARTRIDGE_NAME_PTR: [*:0]const u8 = "burble-admin-mcp"; +const CARTRIDGE_VERSION_PTR: [*:0]const u8 = "0.1.0"; + +export fn boj_cartridge_init() callconv(.c) c_int { + return 0; +} + +export fn boj_cartridge_deinit() callconv(.c) void {} + +export fn boj_cartridge_name() callconv(.c) [*:0]const u8 { + return CARTRIDGE_NAME_PTR; +} + +export fn boj_cartridge_version() callconv(.c) [*:0]const u8 { + return CARTRIDGE_VERSION_PTR; +} + +/// Dispatch the cartridge.json MCP tools. Grade D Alpha stubs. +export fn boj_cartridge_invoke( + tool_name: [*c]const u8, + json_args: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) callconv(.c) i32 { + _ = json_args; + if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; + + const body: []const u8 = if (shim.toolIs(tool_name, "burble_check_health")) + "{\"result\":{\"health\":\"healthy\",\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "burble_list_rooms")) + "{\"result\":{\"items\":[],\"count\":0,\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "burble_create_room")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "burble_close_room")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "burble_kick_user")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "burble_get_config")) + "{\"result\":{\"metadata\":{},\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "burble_update_config")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "burble_voice_stats")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "burble_toggle_recording")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "burble_node_status")) + "{\"result\":{\"metadata\":{},\"status\":\"stub\"}}" +else + return shim.RC_UNKNOWN_TOOL; + + return shim.writeResult(out_buf, in_out_len, body); +} + +// ═══════════════════════════════════════════════════════════════════════ +// Tests +// ═══════════════════════════════════════════════════════════════════════ + +test "permission levels match ABI" { + // ReadOnly ops + try std.testing.expectEqual(@as(i32, 0), burble_admin_min_perm(0)); // list_rooms + try std.testing.expectEqual(@as(i32, 0), burble_admin_min_perm(3)); // list_users + try std.testing.expectEqual(@as(i32, 0), burble_admin_min_perm(5)); // get_metrics + + // Moderator ops + try std.testing.expectEqual(@as(i32, 1), burble_admin_min_perm(1)); // create_room + try std.testing.expectEqual(@as(i32, 1), burble_admin_min_perm(4)); // kick_user + + // Admin ops + try std.testing.expectEqual(@as(i32, 2), burble_admin_min_perm(2)); // delete_room + try std.testing.expectEqual(@as(i32, 2), burble_admin_min_perm(6)); // manage_recordings +} + +test "permission check" { + // Admin can do everything + try std.testing.expectEqual(@as(i32, 1), burble_admin_check_perm(0, 2)); + try std.testing.expectEqual(@as(i32, 1), burble_admin_check_perm(2, 2)); + + // ReadOnly can't delete + try std.testing.expectEqual(@as(i32, 0), burble_admin_check_perm(2, 0)); +} + +test "capacity clamping" { + try std.testing.expectEqual(@as(i32, 1), burble_admin_clamp_capacity(0)); + try std.testing.expectEqual(@as(i32, 50), burble_admin_clamp_capacity(50)); + try std.testing.expectEqual(@as(i32, 500), burble_admin_clamp_capacity(999)); +} + +// ═══════════════════════════════════════════════════════════════════════ +// ADR-0006 invoke dispatch tests +// ═══════════════════════════════════════════════════════════════════════ + +test "boj_cartridge_name returns burble-admin-mcp" { + const n = std.mem.span(boj_cartridge_name()); + try std.testing.expectEqualStrings("burble-admin-mcp", n); +} + +test "boj_cartridge_init returns 0" { + try std.testing.expectEqual(@as(c_int, 0), boj_cartridge_init()); +} + +test "invoke: each declared tool succeeds" { + var buf: [256]u8 = undefined; + const tools = [_][]const u8{ + "burble_check_health", + "burble_list_rooms", + "burble_create_room", + "burble_close_room", + "burble_kick_user", + "burble_get_config", + "burble_update_config", + "burble_voice_stats", + "burble_toggle_recording", + "burble_node_status", + }; + for (tools) |t| { + var len: usize = buf.len; + const rc = boj_cartridge_invoke(t.ptr, "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, 0), rc); + try std.testing.expect(std.mem.indexOf(u8, buf[0..len], "result") != null); + } +} + +test "invoke: unknown tool returns -1" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("nope", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -1), rc); +} + +test "invoke: buffer too small returns -3" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("burble_check_health", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -3), rc); + try std.testing.expect(len > 4); +} diff --git a/cartridges/domains/communications/burble-admin-mcp/ffi/cartridge_shim.zig b/cartridges/domains/communications/burble-admin-mcp/ffi/cartridge_shim.zig new file mode 100644 index 0000000..0e399db --- /dev/null +++ b/cartridges/domains/communications/burble-admin-mcp/ffi/cartridge_shim.zig @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// cartridge_shim.zig β€” Shared helpers for the ADR-0006 five-symbol +// cartridge ABI (`boj_cartridge_init / deinit / name / version / invoke`). +// +// The shim centralises the seven-code return convention, NUL-argument +// guards, tool-name comparison, and the buffer-too-small path so each +// cartridge's `boj_cartridge_invoke` can stay short β€” typically a tool +// table plus `shim.writeResult(...)`. +// +// Cartridges import this file by relative path (no build-graph change +// needed). Example: +// +// const shim = @import("../../../ffi/zig/src/cartridge_shim.zig"); +// +// export fn boj_cartridge_invoke( +// tool_name: [*c]const u8, +// json_args: [*c]const u8, +// out_buf: [*c]u8, +// in_out_len: [*c]usize, +// ) callconv(.c) i32 { +// _ = json_args; +// if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; +// const body = if (shim.toolIs(tool_name, "foo")) "{\"result\":{}}" +// else return shim.RC_UNKNOWN_TOOL; +// return shim.writeResult(out_buf, in_out_len, body); +// } + +const std = @import("std"); + +// ── Return codes (ADR-0006 Β§Return codes) ──────────────────────────── +// +// Frozen by ADR-0006. New failure modes compose these via the error +// JSON body β€” the integer surface does not grow without a follow-up ADR. + +pub const RC_SUCCESS: i32 = 0; +pub const RC_UNKNOWN_TOOL: i32 = -1; +pub const RC_BAD_ARGS: i32 = -2; +pub const RC_BUFFER_TOO_SMALL: i32 = -3; +pub const RC_RUNTIME_ERROR: i32 = -4; +pub const RC_PANIC: i32 = -5; +pub const RC_AUTH_DENIED: i32 = -6; + +// ── Invoke-path helpers ────────────────────────────────────────────── + +/// True if any of the three mandatory `boj_cartridge_invoke` output-path +/// pointers is null. Use at the top of every invoke to short-circuit to +/// `RC_BAD_ARGS`. +pub fn invokeArgsNull( + tool_name: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) bool { + return tool_name == null or out_buf == null or in_out_len == null; +} + +/// Compare a C-NUL-terminated tool-name pointer against a Zig string +/// literal. Caller must have already verified `tool_name` is non-null +/// (usually via `invokeArgsNull`). +pub fn toolIs(tool_name: [*c]const u8, expected: []const u8) bool { + const s = std.mem.span(@as([*:0]const u8, @ptrCast(tool_name))); + return std.mem.eql(u8, s, expected); +} + +/// Copy `body` into `out_buf[0..*in_out_len]` (as a capacity) and update +/// `*in_out_len` to the number of bytes written. Returns `RC_SUCCESS`. +/// +/// If `body.len` exceeds the current capacity stored in `*in_out_len`, +/// sets `*in_out_len` to the required size and returns +/// `RC_BUFFER_TOO_SMALL` β€” the caller is then expected to re-allocate +/// and retry, per ADR-0006 Β§Memory ownership. +/// +/// Caller must have already verified that `out_buf` and `in_out_len` +/// are non-null. +pub fn writeResult( + out_buf: [*c]u8, + in_out_len: [*c]usize, + body: []const u8, +) i32 { + const cap = in_out_len.*; + if (body.len > cap) { + in_out_len.* = body.len; + return RC_BUFFER_TOO_SMALL; + } + @memcpy(out_buf[0..body.len], body); + in_out_len.* = body.len; + return RC_SUCCESS; +} + +// ── Tests ──────────────────────────────────────────────────────────── + +test "writeResult: body fits, writes and sets length" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); + try std.testing.expectEqualStrings("hello", buf[0..len]); +} + +test "writeResult: too small returns -3 and sets required length" { + var buf: [2]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_BUFFER_TOO_SMALL, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: exact-fit succeeds" { + var buf: [5]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: empty body" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, ""); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 0), len); +} + +test "toolIs: matches and rejects" { + const name: [*:0]const u8 = "foo"; + try std.testing.expect(toolIs(@ptrCast(name), "foo")); + try std.testing.expect(!toolIs(@ptrCast(name), "bar")); + try std.testing.expect(!toolIs(@ptrCast(name), "foobar")); + try std.testing.expect(!toolIs(@ptrCast(name), "fo")); +} + +test "invokeArgsNull: detects each null slot" { + var buf: [4]u8 = undefined; + var len: usize = 4; + const name: [*:0]const u8 = "x"; + try std.testing.expect(!invokeArgsNull(@ptrCast(name), &buf, &len)); + try std.testing.expect(invokeArgsNull(null, &buf, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), null, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), &buf, null)); +} diff --git a/cartridges/domains/communications/burble-admin-mcp/mod.js b/cartridges/domains/communications/burble-admin-mcp/mod.js new file mode 100644 index 0000000..8aca3dd --- /dev/null +++ b/cartridges/domains/communications/burble-admin-mcp/mod.js @@ -0,0 +1,67 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// burble-admin-mcp/mod.js β€” Burble WebRTC server administration +// +// Delegates to backend at http://127.0.0.1:7713 (override with BURBLE_ADMIN_URL). + +const BASE_URL = Deno.env.get("BURBLE_ADMIN_URL") ?? "http://127.0.0.1:7713"; +const TIMEOUT_MS = 15_000; + +async function post(path, payload) { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), TIMEOUT_MS); + try { + const r = await fetch(`${BASE_URL}${path}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + signal: ctrl.signal, + }); + const data = await r.json().catch(() => ({ success: false, error: "non-JSON response" })); + return { status: r.status, data }; + } catch (e) { + if (e.name === "AbortError") return { status: 504, data: { success: false, error: "burble-admin-mcp backend timed out" } }; + return { status: 503, data: { success: false, error: `burble-admin-mcp backend unavailable: ${e.message}` } }; + } finally { clearTimeout(t); } +} + +async function get(path) { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), TIMEOUT_MS); + try { + const r = await fetch(`${BASE_URL}${path}`, { method: "GET", signal: ctrl.signal }); + const data = await r.json().catch(() => ({ success: false, error: "non-JSON response" })); + return { status: r.status, data }; + } catch (e) { + if (e.name === "AbortError") return { status: 504, data: { success: false, error: "burble-admin-mcp backend timed out" } }; + return { status: 503, data: { success: false, error: `burble-admin-mcp backend unavailable: ${e.message}` } }; + } finally { clearTimeout(t); } +} + +export async function handleTool(toolName, args) { + switch (toolName) { + case "burble_check_health": + return post("/api/v1/burble_check_health", args ?? {}); + case "burble_list_rooms": + return post("/api/v1/burble_list_rooms", args ?? {}); + case "burble_create_room": + return post("/api/v1/burble_create_room", args ?? {}); + case "burble_close_room": + return post("/api/v1/burble_close_room", args ?? {}); + case "burble_kick_user": + return post("/api/v1/burble_kick_user", args ?? {}); + case "burble_get_config": + return post("/api/v1/burble_get_config", args ?? {}); + case "burble_update_config": + return post("/api/v1/burble_update_config", args ?? {}); + case "burble_voice_stats": + return post("/api/v1/burble_voice_stats", args ?? {}); + case "burble_toggle_recording": + return post("/api/v1/burble_toggle_recording", args ?? {}); + case "burble_node_status": + return post("/api/v1/burble_node_status", args ?? {}); + default: + return { status: 404, data: { error: `Unknown tool: ${toolName}` } }; + } +} diff --git a/cartridges/domains/communications/burble-admin-mcp/panels/manifest.json b/cartridges/domains/communications/burble-admin-mcp/panels/manifest.json new file mode 100644 index 0000000..3109f94 --- /dev/null +++ b/cartridges/domains/communications/burble-admin-mcp/panels/manifest.json @@ -0,0 +1,68 @@ +{ + "$schema": "https://panll.dev/schemas/panel-manifest/v1.json", + "spdx": "MPL-2.0", + "cartridge": "burble-admin-mcp", + "domain": "Voice Platform", + "version": "0.1.0", + "clade": "infrastructure/voice", + "description": "MCP cartridge for Burble voice platform administration β€” monitor rooms, voice quality, and node health via BoJ", + "panels": [ + { + "id": "burble-server-status", + "title": "Burble Server Status", + "description": "Node health, uptime, active rooms count, and total participants", + "type": "status-indicator", + "data_source": { + "endpoint": "/cartridge/burble-admin-mcp/invoke", + "method": "POST", + "body": { "tool": "node_status" }, + "refresh_interval_ms": 10000 + }, + "widgets": [ + { "type": "state-badge", "field": "health", "label": "Health", "states": { + "healthy": { "color": "#2ecc71", "icon": "check-circle" }, + "degraded": { "color": "#f39c12", "icon": "alert-circle" }, + "down": { "color": "#e74c3c", "icon": "x-circle" } + }}, + { "type": "text", "field": "uptime", "label": "Uptime" }, + { "type": "counter", "field": "active_rooms", "label": "Active Rooms", "icon": "mic" }, + { "type": "counter", "field": "total_participants", "label": "Participants", "icon": "users" } + ] + }, + { + "id": "burble-voice-quality", + "title": "Voice Quality Metrics", + "description": "WebRTC quality indicators β€” bandwidth, jitter, packet loss, and codec distribution", + "type": "metric", + "data_source": { + "endpoint": "/cartridge/burble-admin-mcp/invoke", + "method": "POST", + "body": { "tool": "voice_stats" }, + "refresh_interval_ms": 5000 + }, + "widgets": [ + { "type": "gauge", "field": "avg_bandwidth_kbps", "label": "Avg Bandwidth (kbps)", "min": 0, "max": 512 }, + { "type": "gauge", "field": "avg_jitter_ms", "label": "Avg Jitter (ms)", "min": 0, "max": 100 }, + { "type": "gauge", "field": "avg_packet_loss_pct", "label": "Avg Packet Loss (%)", "min": 0, "max": 10 }, + { "type": "text", "field": "codec_distribution", "label": "Codec Distribution" } + ] + }, + { + "id": "burble-room-activity", + "title": "Room Activity", + "description": "Active rooms, peak participant count, and recordings in progress", + "type": "metric", + "data_source": { + "endpoint": "/cartridge/burble-admin-mcp/invoke", + "method": "POST", + "body": { "tool": "list_rooms" }, + "refresh_interval_ms": 10000 + }, + "widgets": [ + { "type": "counter", "field": "active_rooms", "label": "Active Rooms", "icon": "radio" }, + { "type": "counter", "field": "peak_participants", "label": "Peak Participants", "icon": "trending-up" }, + { "type": "counter", "field": "recordings_in_progress", "label": "Recordings", "icon": "disc" } + ] + } + ] +} diff --git a/cartridges/domains/communications/comms-mcp/README.adoc b/cartridges/domains/communications/comms-mcp/README.adoc new file mode 100644 index 0000000..18884bc --- /dev/null +++ b/cartridges/domains/communications/comms-mcp/README.adoc @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += comms-mcp +Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk> +:spdx: MPL-2.0 +:tier: Ayo +:domain: communications +:protocols: MCP, REST + +== Overview + +Multi-provider communications (Gmail, Google Calendar) + +== Tools (4) + +[cols="2,4"] +|=== +| Tool | Description + +| `comms_authenticate` | Authenticate with a comms provider +| `comms_logout` | End a comms session +| `comms_execute` | Execute a comms operation +| `comms_state` | Get comms session state +|=== + +== Architecture + +Three-layer stack per the BoJ cartridge standard: + +[cols="1,1,3"] +|=== +| Layer | Language | Purpose + +| ABI | Idris2 | Formally verified state machine with dependent-type proofs +| FFI | Zig | C-ABI implementation: session pool, action metrics, thread safety +| Adapter| Zig | Adapter bridge to the BoJ unified adapter protocol (zig predecessors sidelined) +|=== + +== Building + +[source,bash] +---- +# ABI (Idris2) +cd abi && idris2 --check *.idr + +# FFI (Zig) +cd ffi && zig build +cd ffi && zig build test + +# Adapter (Zig) +cd adapter && zig build +---- + +== Status + +Alpha (CRG D). Cartridge metadata declared in `cartridge.json`; 4 tool(s) exposed. +Not yet dogfooded externally. See `docs/READINESS.md` at repo root for the +project-wide grade table and `docs/practice/DOGFOOD-LOG.adoc` for usage evidence. diff --git a/cartridges/domains/communications/comms-mcp/abi/CommsMcp/SafeComms.idr b/cartridges/domains/communications/comms-mcp/abi/CommsMcp/SafeComms.idr new file mode 100644 index 0000000..6104f23 --- /dev/null +++ b/cartridges/domains/communications/comms-mcp/abi/CommsMcp/SafeComms.idr @@ -0,0 +1,230 @@ +-- SPDX-License-Identifier: MPL-2.0 +-- Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +||| CommsMcp.SafeComms: Formally verified communications provider operations. +||| +||| Cartridge: comms-mcp +||| Matrix cell: Communications domain x {MCP, LSP} protocols +||| +||| This module defines type-safe communications provider operations with a +||| session state machine that prevents: +||| - Operations on unauthenticated providers +||| - Credential leaks by tracking auth lifecycle +||| - Operations without proper session teardown +||| +||| State machine: Unauthenticated -> Authenticated -> Operating -> Authenticated -> Unauthenticated +module CommsMcp.SafeComms + +import Data.List + +%default total + +-- ═══════════════════════════════════════════════════════════════════════════ +-- Session State Machine +-- ═══════════════════════════════════════════════════════════════════════════ + +||| Provider session lifecycle states. +||| A session progresses: Unauthenticated -> Authenticated -> Operating -> Authenticated -> Unauthenticated +public export +data SessionState = Unauthenticated | Authenticated | Operating | AuthError + +||| Equality for session states. +public export +Eq SessionState where + Unauthenticated == Unauthenticated = True + Authenticated == Authenticated = True + Operating == Operating = True + AuthError == AuthError = True + _ == _ = False + +||| Valid state transitions (enforced at the type level). +public export +data ValidTransition : SessionState -> SessionState -> Type where + Authenticate : ValidTransition Unauthenticated Authenticated + BeginOperation : ValidTransition Authenticated Operating + EndOperation : ValidTransition Operating Authenticated + Logout : ValidTransition Authenticated Unauthenticated + OpError : ValidTransition Operating AuthError + Recover : ValidTransition AuthError Unauthenticated + +||| Runtime transition validator. +public export +canTransition : SessionState -> SessionState -> Bool +canTransition Unauthenticated Authenticated = True +canTransition Authenticated Operating = True +canTransition Operating Authenticated = True +canTransition Authenticated Unauthenticated = True +canTransition Operating AuthError = True +canTransition AuthError Unauthenticated = True +canTransition _ _ = False + +-- ═══════════════════════════════════════════════════════════════════════════ +-- Communications Provider Types +-- ═══════════════════════════════════════════════════════════════════════════ + +||| Supported communications providers. +public export +data CommsProvider + = Gmail -- Google Gmail + | GoogleCalendar -- Google Calendar + | Custom String -- User-defined provider + +||| C-ABI encoding. +public export +providerToInt : CommsProvider -> Int +providerToInt Gmail = 1 +providerToInt GoogleCalendar = 2 +providerToInt (Custom _) = 99 + +-- ═══════════════════════════════════════════════════════════════════════════ +-- Provider Capabilities +-- ═══════════════════════════════════════════════════════════════════════════ + +||| Capabilities a communications provider may support. +public export +data ProviderCapability + = SendMessage -- Send email / message + | ReadMessage -- Read email / message + | SearchMessage -- Search messages + | ManageLabels -- Label / folder management + | ListEvents -- List calendar events + | CreateEvent -- Create calendar events + | FreeBusy -- Free/busy lookup + +-- ═══════════════════════════════════════════════════════════════════════════ +-- Gmail Resource Types +-- ═══════════════════════════════════════════════════════════════════════════ + +||| Resource types available on the Gmail provider. +public export +data GmailResource + = GmMessage -- Email message + | GmThread -- Email thread + | GmLabel -- Gmail label + | GmDraft -- Draft message + +||| C-ABI encoding for Gmail resource types. +public export +gmResourceToInt : GmailResource -> Int +gmResourceToInt GmMessage = 1 +gmResourceToInt GmThread = 2 +gmResourceToInt GmLabel = 3 +gmResourceToInt GmDraft = 4 + +||| Map Gmail to its supported capabilities. +public export +gmailCapabilities : List ProviderCapability +gmailCapabilities = [SendMessage, ReadMessage, SearchMessage, ManageLabels] + +-- ═══════════════════════════════════════════════════════════════════════════ +-- Google Calendar Resource Types +-- ═══════════════════════════════════════════════════════════════════════════ + +||| Resource types available on the Google Calendar provider. +public export +data CalendarResource + = CalEvent -- Calendar event + | CalCalendar -- Calendar itself + | CalFreeBusy -- Free/busy query + +||| C-ABI encoding for Calendar resource types. +public export +calResourceToInt : CalendarResource -> Int +calResourceToInt CalEvent = 1 +calResourceToInt CalCalendar = 2 +calResourceToInt CalFreeBusy = 3 + +||| Map Google Calendar to its supported capabilities. +public export +calendarCapabilities : List ProviderCapability +calendarCapabilities = [ListEvents, CreateEvent, FreeBusy] + +-- ═══════════════════════════════════════════════════════════════════════════ +-- Session Record +-- ═══════════════════════════════════════════════════════════════════════════ + +||| A communications provider session with tracked state. +public export +record Session where + constructor MkSession + sessionId : String + provider : CommsProvider + state : SessionState + scope : String + +||| Proof that a session is authenticated (ready for operations). +public export +data IsAuthenticated : Session -> Type where + ActiveSession : (s : Session) -> + (state s = Authenticated) -> + IsAuthenticated s + +-- ═══════════════════════════════════════════════════════════════════════════ +-- MCP Tool Definitions +-- ═══════════════════════════════════════════════════════════════════════════ + +||| MCP tools exposed by this cartridge. +||| These map to MCP tool definitions that AI agents can call. +public export +data McpTool + = ToolAuthenticate -- Authenticate with a comms provider (OAuth) + | ToolSend -- Send an email (Gmail) + | ToolRead -- Read an email (Gmail) + | ToolSearch -- Search emails (Gmail) + | ToolLabels -- List/manage labels (Gmail) + | ToolEvents -- List calendar events (Google Calendar) + | ToolCreateEvent -- Create a calendar event (Google Calendar) + | ToolFreeBusy -- Query free/busy (Google Calendar) + | ToolLogout -- End provider session + +||| MCP tool name (for JSON-RPC method name). +public export +toolName : McpTool -> String +toolName ToolAuthenticate = "comms/authenticate" +toolName ToolSend = "comms/gmail/send" +toolName ToolRead = "comms/gmail/read" +toolName ToolSearch = "comms/gmail/search" +toolName ToolLabels = "comms/gmail/labels" +toolName ToolEvents = "comms/calendar/events" +toolName ToolCreateEvent = "comms/calendar/create-event" +toolName ToolFreeBusy = "comms/calendar/free-busy" +toolName ToolLogout = "comms/logout" + +||| Which tools require an authenticated session. +public export +requiresAuth : McpTool -> Bool +requiresAuth ToolAuthenticate = False +requiresAuth _ = True + +-- ═══════════════════════════════════════════════════════════════════════════ +-- C-ABI Exports +-- ═══════════════════════════════════════════════════════════════════════════ + +||| Session state to integer. +public export +sessionStateToInt : SessionState -> Int +sessionStateToInt Unauthenticated = 0 +sessionStateToInt Authenticated = 1 +sessionStateToInt Operating = 2 +sessionStateToInt AuthError = 3 + +||| FFI: Validate a state transition. +export +comms_can_transition : Int -> Int -> Int +comms_can_transition from to = + let fromState = case from of + 0 => Unauthenticated + 1 => Authenticated + 2 => Operating + _ => AuthError + toState = case to of + 0 => Unauthenticated + 1 => Authenticated + 2 => Operating + _ => AuthError + in if canTransition fromState toState then 1 else 0 + +||| FFI: Check if a tool requires authentication. +export +comms_tool_requires_auth : Int -> Int +comms_tool_requires_auth 1 = 0 -- ToolAuthenticate +comms_tool_requires_auth _ = 1 -- All others require auth diff --git a/cartridges/domains/communications/comms-mcp/abi/README.adoc b/cartridges/domains/communications/comms-mcp/abi/README.adoc new file mode 100644 index 0000000..9331f53 --- /dev/null +++ b/cartridges/domains/communications/comms-mcp/abi/README.adoc @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += comms-mcp / abi β€” Idris2 ABI layer +:orientation: shallow + +== Purpose + +Cartridge-local protocol types for `comms-mcp`. Defines the data, tool, and +transition vocabulary this cartridge speaks. Inherits safety lemmas and the +matrix-cell identifier scheme from the trunk ABI at `src/abi/`. + +== Files + +1 Idris2 module(s), ~230 lines total. Lead module: +`SafeComms.idr`. + +No `.ipkg` β€” consumed directly by wider BoJ callers; type-checks inside the +trunk `boj.ipkg`. + +== Invariants + +* `%default total` at every module top. +* Zero `believe_me`, `assert_total`, `postulate`, `Admitted`, or + `sorry` (the trunk holds the four declared `believe_me` sites; leaf + cartridges carry none). + +== Test/proof surface + +Thin module(s), no inline tests. Proof surface = type-check success under the +trunk `idris2 --check`. + +== Read-first + +. `SafeComms.idr` β€” cartridge-local data records and enums. +. Trunk `src/abi/Boj/Catalogue.idr` for how this cell participates in the + 2D matrix. diff --git a/cartridges/domains/communications/comms-mcp/abi/comms-mcp.ipkg b/cartridges/domains/communications/comms-mcp/abi/comms-mcp.ipkg new file mode 100644 index 0000000..4c4ef2b --- /dev/null +++ b/cartridges/domains/communications/comms-mcp/abi/comms-mcp.ipkg @@ -0,0 +1,11 @@ +-- SPDX-License-Identifier: MPL-2.0 +package commsmcp + +authors = "Jonathan D.A. Jewell" +version = 0.1.0 +license = "MPL-2.0" +brief = "Communications MCP cartridge β€” Gmail and Google Calendar with session safety" + +sourcedir = "." +modules = CommsMcp.SafeComms +depends = base, contrib diff --git a/cartridges/domains/communications/comms-mcp/adapter/README.adoc b/cartridges/domains/communications/comms-mcp/adapter/README.adoc new file mode 100644 index 0000000..3c84c27 --- /dev/null +++ b/cartridges/domains/communications/comms-mcp/adapter/README.adoc @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += comms-mcp / adapter β€” protocol bridge +:orientation: shallow + +== Purpose + +Exposes this cartridge's FFI over the wire protocols declared in +`cartridge.json`. Dispatches tool invocations to the C-ABI exports at +`../ffi/` and formats responses per protocol. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” depends on the sibling `ffi` target. +| `comms_adapter.zig` | Protocol dispatch (129 lines). Tool catalogue matches `cartridge.json`. +| `SIDELINED-*.v.adoc` | zig predecessor(s). Not built β€” zig banned 2026-04-10. +|=== + +== Invariants + +* **Stateless** β€” all state lives behind the FFI, never in the adapter. +* **Response passthrough** β€” whatever the FFI returns goes back to the wire + unmodified (no embellishment, no silent recovery). +* **Cartridge.json is source of truth** for the tool catalogue; drift between + adapter and manifest is a CI failure. + +== Test/proof surface + +No inline tests at the adapter layer β€” protocol routing is exercised by the +trunk seam tests at `ffi/zig/src/seams.zig`. + +== Read-first + +. `comms_adapter.zig` β€” dispatch function and per-protocol routers. +. `../cartridge.json` β€” the tool manifest the dispatcher mirrors. diff --git a/cartridges/domains/communications/comms-mcp/adapter/build.zig b/cartridges/domains/communications/comms-mcp/adapter/build.zig new file mode 100644 index 0000000..23ebd63 --- /dev/null +++ b/cartridges/domains/communications/comms-mcp/adapter/build.zig @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// comms-mcp/adapter/build.zig + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const ffi_mod = b.createModule(.{ + .root_source_file = b.path("../ffi/comms_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + const adapter = b.addExecutable(.{ + .name = "comms_adapter", + .root_source_file = b.path("comms_adapter.zig"), + .target = target, + .optimize = optimize, + }); + adapter.root_module.addImport("comms_ffi", ffi_mod); + b.installArtifact(adapter); +} diff --git a/cartridges/domains/communications/comms-mcp/adapter/comms_adapter.zig b/cartridges/domains/communications/comms-mcp/adapter/comms_adapter.zig new file mode 100644 index 0000000..efedba7 --- /dev/null +++ b/cartridges/domains/communications/comms-mcp/adapter/comms_adapter.zig @@ -0,0 +1,129 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// comms-mcp/adapter/comms_adapter.zig +// +// Three-protocol BoJ adapter: REST (port 9217), gRPC-compat (port 9218), +// GraphQL (port 9219). +// Replaces the banned zig adapter (comms_adapter.v). + +const std = @import("std"); +const ffi = @import("comms_ffi"); + +const REST_PORT: u16 = 9217; +const GRPC_PORT: u16 = 9218; +const GQL_PORT: u16 = 9219; + +const Response = struct { status: u16, body: []const u8 }; + +fn okJson(buf: []u8, msg: []const u8) []u8 { + return std.fmt.bufPrint(buf, "{{\"success\":true,\"message\":\"{s}\"}}", .{msg}) catch buf[0..0]; +} + +fn errJson(buf: []u8, msg: []const u8) []u8 { + return std.fmt.bufPrint(buf, "{{\"success\":false,\"error\":\"{s}\"}}", .{msg}) catch buf[0..0]; +} + +fn dispatch(tool: []const u8, _body: []const u8, resp: []u8) Response { + _ = _body; + if (std.mem.eql(u8, tool, "comms_authenticate")) { + return .{ .status = 200, .body = okJson(resp, "comms_authenticate forwarded") }; + } + if (std.mem.eql(u8, tool, "comms_logout")) { + return .{ .status = 200, .body = okJson(resp, "comms_logout forwarded") }; + } + if (std.mem.eql(u8, tool, "comms_execute")) { + return .{ .status = 200, .body = okJson(resp, "comms_execute forwarded") }; + } + if (std.mem.eql(u8, tool, "comms_state")) { + return .{ .status = 200, .body = okJson(resp, "comms_state forwarded") }; + } + return .{ .status = 404, .body = errJson(resp, "unknown tool") }; +} + +fn dispatchRest(path: []const u8, body: []const u8, resp: []u8) Response { + // Expect /tools/<tool_name> + const prefix = "/tools/"; + if (std.mem.startsWith(u8, path, prefix)) { + const tool = path[prefix.len..]; + return dispatch(tool, body, resp); + } + return .{ .status = 404, .body = errJson(resp, "not found") }; +} + +fn dispatchGrpc(path: []const u8, body: []const u8, resp: []u8) Response { + // Expect /<Service>/<Method> β€” derive tool from Method + var it = std.mem.splitScalar(u8, path, '/'); + _ = it.next(); // leading empty + _ = it.next(); // service + const method = it.next() orelse return .{ .status = 404, .body = errJson(resp, "bad gRPC path") }; + return dispatch(method, body, resp); +} + +fn dispatchGraphql(body: []const u8, resp: []u8) Response { + if (std.mem.indexOf(u8, body, "comms_authenticate") != null) + return dispatch("comms_authenticate", body, resp); + if (std.mem.indexOf(u8, body, "comms_logout") != null) + return dispatch("comms_logout", body, resp); + if (std.mem.indexOf(u8, body, "comms_execute") != null) + return dispatch("comms_execute", body, resp); + if (std.mem.indexOf(u8, body, "comms_state") != null) + return dispatch("comms_state", body, resp); + return .{ .status = 400, .body = errJson(resp, "unrecognised GraphQL operation") }; +} + +fn handleConnection(stream: std.net.Stream, port: u16) void { + defer stream.close(); + var buf: [4096]u8 = undefined; + var resp_buf: [4096]u8 = undefined; + const n = stream.read(&buf) catch return; + const req = buf[0..n]; + const result = switch (port) { + REST_PORT => blk: { + // Parse HTTP/1.1: first line = METHOD PATH HTTP/x.y + var lines = std.mem.splitScalar(u8, req, '\n'); + const first = lines.next() orelse break :blk Response{ .status = 400, .body = "" }; + var parts = std.mem.splitScalar(u8, std.mem.trim(u8, first, "\r"), ' '); + _ = parts.next(); // method + const path = parts.next() orelse break :blk Response{ .status = 400, .body = "" }; + break :blk dispatchRest(path, req, &resp_buf); + }, + GRPC_PORT => blk: { + var lines = std.mem.splitScalar(u8, req, '\n'); + const first = lines.next() orelse break :blk Response{ .status = 400, .body = "" }; + var parts = std.mem.splitScalar(u8, std.mem.trim(u8, first, "\r"), ' '); + _ = parts.next(); + const path = parts.next() orelse break :blk Response{ .status = 400, .body = "" }; + break :blk dispatchGrpc(path, req, &resp_buf); + }, + GQL_PORT => dispatchGraphql(req, &resp_buf), + else => Response{ .status = 500, .body = "" }, + }; + var http_resp: [512]u8 = undefined; + const http = std.fmt.bufPrint(&http_resp, + "HTTP/1.1 {d} OK\r\nContent-Length: {d}\r\nContent-Type: application/json\r\n\r\n", + .{ result.status, result.body.len }) catch return; + _ = stream.write(http) catch {}; + _ = stream.write(result.body) catch {}; +} + +fn listenLoop(port: u16) !void { + const addr = std.net.Address.initIp4(.{ 127, 0, 0, 1 }, port); + var server = try addr.listen(.{ .reuse_address = true }); + defer server.deinit(); + while (true) { + const conn = try server.accept(); + const t = try std.Thread.spawn(.{}, handleConnection, .{ conn.stream, port }); + t.detach(); + } +} + +pub fn main() !void { + ffi.comms_init(); + const t1 = try std.Thread.spawn(.{}, listenLoop, .{REST_PORT}); + const t2 = try std.Thread.spawn(.{}, listenLoop, .{GRPC_PORT}); + const t3 = try std.Thread.spawn(.{}, listenLoop, .{GQL_PORT}); + t1.join(); + t2.join(); + t3.join(); +} diff --git a/cartridges/domains/communications/comms-mcp/cartridge.json b/cartridges/domains/communications/comms-mcp/cartridge.json new file mode 100644 index 0000000..dd43a86 --- /dev/null +++ b/cartridges/domains/communications/comms-mcp/cartridge.json @@ -0,0 +1,115 @@ +{ + "$schema": "https://boj.dev/schemas/cartridge/v1.json", + "spdx": "MPL-2.0", + "copyright": "Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk>", + "name": "comms-mcp", + "version": "0.1.0", + "description": "Multi-provider communications (Gmail, Google Calendar)", + "domain": "communications", + "tier": "Ayo", + "protocols": [ + "MCP", + "REST" + ], + "auth": { + "method": "none", + "env_var": null, + "credential_source": null + }, + "api": { + "base_url": "local://comms-mcp", + "content_type": "application/json" + }, + "tools": [ + { + "name": "comms_authenticate", + "description": "Authenticate with a comms provider", + "inputSchema": { + "type": "object", + "properties": { + "provider": { + "type": "string", + "description": "Provider: gmail|google_calendar" + }, + "credentials": { + "type": "object", + "description": "OAuth credentials" + } + }, + "required": [ + "provider", + "credentials" + ] + } + }, + { + "name": "comms_logout", + "description": "End a comms session", + "inputSchema": { + "type": "object", + "properties": { + "session_id": { + "type": "integer", + "description": "Session index" + } + }, + "required": [ + "session_id" + ] + } + }, + { + "name": "comms_execute", + "description": "Execute a comms operation", + "inputSchema": { + "type": "object", + "properties": { + "session_id": { + "type": "integer", + "description": "Session index" + }, + "operation": { + "type": "string", + "description": "Operation name" + }, + "params": { + "type": "object", + "description": "Operation parameters" + } + }, + "required": [ + "session_id", + "operation", + "params" + ] + } + }, + { + "name": "comms_state", + "description": "Get comms session state", + "inputSchema": { + "type": "object", + "properties": { + "session_id": { + "type": "integer", + "description": "Session index" + } + }, + "required": [ + "session_id" + ] + } + } + ], + "ffi": { + "so_path": "ffi/zig-out/lib/libcomms_mcp.so", + "abi_version": "ADR-0006", + "symbols": [ + "boj_cartridge_init", + "boj_cartridge_deinit", + "boj_cartridge_name", + "boj_cartridge_version", + "boj_cartridge_invoke" + ] + } +} diff --git a/cartridges/domains/communications/comms-mcp/ffi/README.adoc b/cartridges/domains/communications/comms-mcp/ffi/README.adoc new file mode 100644 index 0000000..8364a36 --- /dev/null +++ b/cartridges/domains/communications/comms-mcp/ffi/README.adoc @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += comms-mcp / ffi β€” Zig FFI layer +:orientation: shallow + +== Purpose + +C-ABI exports bridging this cartridge's Idris2 ABI to the runtime. Consumed by +the adapter for protocol dispatch and by the trunk loader for mount. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” produces `zig-out/lib/libcomms.so`. +| `comms_ffi.zig` | C-ABI exports (22 exports, 12 inline tests, 543 lines). +|=== + +== Invariants + +* **Null-pointer rejection** on `[*c]const u8` arguments before any + dereference. +* **Negative return values** carry documented error meanings; zero is + success (matching the trunk error-code convention). +* **No allocator leaks** β€” every allocation path pairs with `defer free`. + +== Test/proof surface + +12 inline `test "..."` block(s) in `comms_ffi.zig`. Run with +`cd ffi && zig build test`. + +== Read-first + +. `comms_ffi.zig` β€” C-ABI signatures and guards. +. Trunk `ffi/zig/src/catalogue.zig` for how this `.so` is loaded. diff --git a/cartridges/domains/communications/comms-mcp/ffi/build.zig b/cartridges/domains/communications/comms-mcp/ffi/build.zig new file mode 100644 index 0000000..98d2b49 --- /dev/null +++ b/cartridges/domains/communications/comms-mcp/ffi/build.zig @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// Comms-MCP Cartridge β€” Zig FFI build configuration (Zig 0.15+). + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + // Shared ADR-0006 invoke-shim module (relative path up to boj-server trunk). + + const shim_mod = b.addModule("cartridge_shim", .{ + + .root_source_file = b.path("../../../ffi/zig/src/cartridge_shim.zig"), + + .target = target, + + .optimize = optimize, + + }); + + const comms_mod = b.addModule("comms_ffi", .{ + .root_source_file = b.path("comms_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + comms_mod.addImport("cartridge_shim", shim_mod); + + // ── Tests ──────────────────────────────────────────────────────── + const comms_tests = b.addTest(.{ + .root_module = comms_mod, + }); + + const run_tests = b.addRunArtifact(comms_tests); + + const test_step = b.step("test", "Run comms-mcp FFI tests"); + test_step.dependOn(&run_tests.step); + + // ── Shared library ────────────────────────────────────────────── + const lib_mod = b.createModule(.{ + .root_source_file = b.path("comms_ffi.zig"), + .target = target, + .optimize = optimize, + }); + lib_mod.addImport("cartridge_shim", shim_mod); + + const lib = b.addLibrary(.{ + .name = "comms_mcp", + .root_module = lib_mod, + .linkage = .dynamic, + }); + b.installArtifact(lib); + + const lib_step = b.step("lib", "Build shared library"); + lib_step.dependOn(&lib.step); +} diff --git a/cartridges/domains/communications/comms-mcp/ffi/cartridge_shim.zig b/cartridges/domains/communications/comms-mcp/ffi/cartridge_shim.zig new file mode 100644 index 0000000..0e399db --- /dev/null +++ b/cartridges/domains/communications/comms-mcp/ffi/cartridge_shim.zig @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// cartridge_shim.zig β€” Shared helpers for the ADR-0006 five-symbol +// cartridge ABI (`boj_cartridge_init / deinit / name / version / invoke`). +// +// The shim centralises the seven-code return convention, NUL-argument +// guards, tool-name comparison, and the buffer-too-small path so each +// cartridge's `boj_cartridge_invoke` can stay short β€” typically a tool +// table plus `shim.writeResult(...)`. +// +// Cartridges import this file by relative path (no build-graph change +// needed). Example: +// +// const shim = @import("../../../ffi/zig/src/cartridge_shim.zig"); +// +// export fn boj_cartridge_invoke( +// tool_name: [*c]const u8, +// json_args: [*c]const u8, +// out_buf: [*c]u8, +// in_out_len: [*c]usize, +// ) callconv(.c) i32 { +// _ = json_args; +// if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; +// const body = if (shim.toolIs(tool_name, "foo")) "{\"result\":{}}" +// else return shim.RC_UNKNOWN_TOOL; +// return shim.writeResult(out_buf, in_out_len, body); +// } + +const std = @import("std"); + +// ── Return codes (ADR-0006 Β§Return codes) ──────────────────────────── +// +// Frozen by ADR-0006. New failure modes compose these via the error +// JSON body β€” the integer surface does not grow without a follow-up ADR. + +pub const RC_SUCCESS: i32 = 0; +pub const RC_UNKNOWN_TOOL: i32 = -1; +pub const RC_BAD_ARGS: i32 = -2; +pub const RC_BUFFER_TOO_SMALL: i32 = -3; +pub const RC_RUNTIME_ERROR: i32 = -4; +pub const RC_PANIC: i32 = -5; +pub const RC_AUTH_DENIED: i32 = -6; + +// ── Invoke-path helpers ────────────────────────────────────────────── + +/// True if any of the three mandatory `boj_cartridge_invoke` output-path +/// pointers is null. Use at the top of every invoke to short-circuit to +/// `RC_BAD_ARGS`. +pub fn invokeArgsNull( + tool_name: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) bool { + return tool_name == null or out_buf == null or in_out_len == null; +} + +/// Compare a C-NUL-terminated tool-name pointer against a Zig string +/// literal. Caller must have already verified `tool_name` is non-null +/// (usually via `invokeArgsNull`). +pub fn toolIs(tool_name: [*c]const u8, expected: []const u8) bool { + const s = std.mem.span(@as([*:0]const u8, @ptrCast(tool_name))); + return std.mem.eql(u8, s, expected); +} + +/// Copy `body` into `out_buf[0..*in_out_len]` (as a capacity) and update +/// `*in_out_len` to the number of bytes written. Returns `RC_SUCCESS`. +/// +/// If `body.len` exceeds the current capacity stored in `*in_out_len`, +/// sets `*in_out_len` to the required size and returns +/// `RC_BUFFER_TOO_SMALL` β€” the caller is then expected to re-allocate +/// and retry, per ADR-0006 Β§Memory ownership. +/// +/// Caller must have already verified that `out_buf` and `in_out_len` +/// are non-null. +pub fn writeResult( + out_buf: [*c]u8, + in_out_len: [*c]usize, + body: []const u8, +) i32 { + const cap = in_out_len.*; + if (body.len > cap) { + in_out_len.* = body.len; + return RC_BUFFER_TOO_SMALL; + } + @memcpy(out_buf[0..body.len], body); + in_out_len.* = body.len; + return RC_SUCCESS; +} + +// ── Tests ──────────────────────────────────────────────────────────── + +test "writeResult: body fits, writes and sets length" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); + try std.testing.expectEqualStrings("hello", buf[0..len]); +} + +test "writeResult: too small returns -3 and sets required length" { + var buf: [2]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_BUFFER_TOO_SMALL, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: exact-fit succeeds" { + var buf: [5]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: empty body" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, ""); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 0), len); +} + +test "toolIs: matches and rejects" { + const name: [*:0]const u8 = "foo"; + try std.testing.expect(toolIs(@ptrCast(name), "foo")); + try std.testing.expect(!toolIs(@ptrCast(name), "bar")); + try std.testing.expect(!toolIs(@ptrCast(name), "foobar")); + try std.testing.expect(!toolIs(@ptrCast(name), "fo")); +} + +test "invokeArgsNull: detects each null slot" { + var buf: [4]u8 = undefined; + var len: usize = 4; + const name: [*:0]const u8 = "x"; + try std.testing.expect(!invokeArgsNull(@ptrCast(name), &buf, &len)); + try std.testing.expect(invokeArgsNull(null, &buf, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), null, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), &buf, null)); +} diff --git a/cartridges/domains/communications/comms-mcp/ffi/comms_ffi.zig b/cartridges/domains/communications/comms-mcp/ffi/comms_ffi.zig new file mode 100644 index 0000000..f06526d --- /dev/null +++ b/cartridges/domains/communications/comms-mcp/ffi/comms_ffi.zig @@ -0,0 +1,627 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// Comms-MCP Cartridge β€” Zig FFI bridge for communications provider operations. +// +// Implements the provider session state machine from SafeComms.idr. +// Ensures no operation can execute on an unauthenticated provider, +// and tracks credential lifecycle to prevent leaks. + +const std = @import("std"); + +// ═══════════════════════════════════════════════════════════════════════ +// Types (must match CommsMcp.SafeComms encodings) +// ═══════════════════════════════════════════════════════════════════════ + +pub const SessionState = enum(c_int) { + unauthenticated = 0, + authenticated = 1, + operating = 2, + auth_error = 3, +}; + +pub const CommsProvider = enum(c_int) { + gmail = 1, + google_calendar = 2, + custom = 99, +}; + +/// Gmail resource types β€” mirrors `CommsMcp.SafeComms.GmailResource` +/// + `gmResourceToInt` encoding. Declared here so `iseriser abi-verify` +/// can structurally check the encoding against the Idris2 source. +pub const GmailResource = enum(c_int) { + gm_message = 1, + gm_thread = 2, + gm_label = 3, + gm_draft = 4, +}; + +/// Google Calendar resource types β€” mirrors +/// `CommsMcp.SafeComms.CalendarResource` + `calResourceToInt` encoding. +pub const CalendarResource = enum(c_int) { + cal_event = 1, + cal_calendar = 2, + cal_free_busy = 3, +}; + +// ═══════════════════════════════════════════════════════════════════════ +// Session State Machine +// ═══════════════════════════════════════════════════════════════════════ + +const MAX_SESSIONS: usize = 8; + +const RESULT_BUF_SIZE: usize = 4096; + +const OAUTH_TOKEN_SIZE: usize = 2048; + +const SessionSlot = struct { + active: bool, + state: SessionState, + provider: CommsProvider, + oauth_token: [OAUTH_TOKEN_SIZE]u8 = [_]u8{0} ** OAUTH_TOKEN_SIZE, + oauth_token_len: usize = 0, + result_buf: [RESULT_BUF_SIZE]u8 = [_]u8{0} ** RESULT_BUF_SIZE, + result_len: usize = 0, +}; + +var sessions: [MAX_SESSIONS]SessionSlot = [_]SessionSlot{.{ + .active = false, + .state = .unauthenticated, + .provider = .gmail, + .oauth_token = [_]u8{0} ** OAUTH_TOKEN_SIZE, + .oauth_token_len = 0, + .result_buf = [_]u8{0} ** RESULT_BUF_SIZE, + .result_len = 0, +}} ** MAX_SESSIONS; + +var mutex: std.Thread.Mutex = .{}; + +/// Validate a state transition (matches Idris2 canTransition). +fn isValidTransition(from: SessionState, to: SessionState) bool { + return switch (from) { + .unauthenticated => to == .authenticated, + .authenticated => to == .operating or to == .unauthenticated, + .operating => to == .authenticated or to == .auth_error, + .auth_error => to == .unauthenticated, + }; +} + +/// Authenticate with a provider. Returns slot index or -1 on failure. +pub export fn comms_authenticate(provider: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + for (&sessions, 0..) |*slot, i| { + if (!slot.active) { + slot.active = true; + slot.state = .authenticated; + slot.provider = @enumFromInt(provider); + return @intCast(i); + } + } + return -1; // No slots available +} + +/// Logout from a provider session by slot index. +pub export fn comms_logout(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + if (slot_idx < 0 or slot_idx >= MAX_SESSIONS) return -1; + const idx: usize = @intCast(slot_idx); + if (!sessions[idx].active) return -1; + if (!isValidTransition(sessions[idx].state, .unauthenticated)) return -2; + + // Wipe OAuth token on logout + @memset(&sessions[idx].oauth_token, 0); + sessions[idx].oauth_token_len = 0; + sessions[idx].active = false; + sessions[idx].state = .unauthenticated; + return 0; +} + +/// Begin an operation (transition Authenticated -> Operating). +pub export fn comms_begin_operation(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + if (slot_idx < 0 or slot_idx >= MAX_SESSIONS) return -1; + const idx: usize = @intCast(slot_idx); + if (!sessions[idx].active) return -1; + if (!isValidTransition(sessions[idx].state, .operating)) return -2; + + sessions[idx].state = .operating; + return 0; +} + +/// End an operation (transition Operating -> Authenticated). +pub export fn comms_end_operation(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + if (slot_idx < 0 or slot_idx >= MAX_SESSIONS) return -1; + const idx: usize = @intCast(slot_idx); + if (!sessions[idx].active) return -1; + if (!isValidTransition(sessions[idx].state, .authenticated)) return -2; + + sessions[idx].state = .authenticated; + return 0; +} + +/// Get the state of a session. +pub export fn comms_state(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + if (slot_idx < 0 or slot_idx >= MAX_SESSIONS) return -1; + const idx: usize = @intCast(slot_idx); + if (!sessions[idx].active) return @intFromEnum(SessionState.unauthenticated); + return @intFromEnum(sessions[idx].state); +} + +/// Validate a state transition (C-ABI export). +pub export fn comms_can_transition(from: c_int, to: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + const f: SessionState = @enumFromInt(from); + const t: SessionState = @enumFromInt(to); + return if (isValidTransition(f, t)) 1 else 0; +} + +/// Reset all sessions (for testing). +pub export fn comms_reset() void { + mutex.lock(); + defer mutex.unlock(); + for (&sessions) |*slot| { + @memset(&slot.oauth_token, 0); + slot.oauth_token_len = 0; + slot.active = false; + slot.state = .unauthenticated; + slot.result_len = 0; + } +} + +// ═══════════════════════════════════════════════════════════════════════ +// Standard Cartridge Interface (loader expects these 4 C-ABI symbols) +// ═══════════════════════════════════════════════════════════════════════ + +/// Initialise the comms-mcp cartridge. Resets all session slots. +pub export fn boj_cartridge_init() c_int { + comms_reset(); + return 0; +} + +/// Deinitialise the comms-mcp cartridge. Resets all session slots. +pub export fn boj_cartridge_deinit() void { + comms_reset(); +} + +/// Return the cartridge name as a null-terminated C string. +pub export fn boj_cartridge_name() [*:0]const u8 { + mutex.lock(); + defer mutex.unlock(); + return "comms-mcp"; +} + +/// Return the cartridge version as a null-terminated C string. +pub export fn boj_cartridge_version() [*:0]const u8 { + mutex.lock(); + defer mutex.unlock(); + return "0.1.0"; +} + +// ═══════════════════════════════════════════════════════════════════════ +// ADR-0006 dispatch (boj_cartridge_invoke, 5th standard symbol) +// ═══════════════════════════════════════════════════════════════════════ + +const shim = @import("cartridge_shim.zig"); + +/// Dispatch the cartridge.json MCP tools. Grade D Alpha β€” each arm +/// returns a stub JSON body shaped to the tool's intended response. +export fn boj_cartridge_invoke( + tool_name: [*c]const u8, + json_args: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) callconv(.c) i32 { + _ = json_args; + if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; + + const body: []const u8 = if (shim.toolIs(tool_name, "comms_authenticate")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "comms_logout")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "comms_execute")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "comms_state")) + "{\"result\":{\"status\":\"stub\"}}" +else + return shim.RC_UNKNOWN_TOOL; + + return shim.writeResult(out_buf, in_out_len, body); +} + +// ═══════════════════════════════════════════════════════════════════════ +// Gmail Provider (provider code 1) +// Grade D Alpha β€” stub implementations +// Real API: https://gmail.googleapis.com/gmail/v1/users/me/{endpoint} +// Auth: Authorization: Bearer {oauth_token} +// ═══════════════════════════════════════════════════════════════════════ + +/// Validate that a slot is active, authenticated, and bound to the Gmail provider. +fn validateGmailSlot(slot_idx: c_int) ?usize { + if (slot_idx < 0 or slot_idx >= MAX_SESSIONS) return null; + const idx: usize = @intCast(slot_idx); + if (!sessions[idx].active) return null; + if (sessions[idx].provider != .gmail) return null; + if (sessions[idx].state != .authenticated) return null; + return idx; +} + +/// Write a JSON stub response into a session's result buffer. +fn writeGmailResult(slot: *SessionSlot, endpoint: []const u8, method: []const u8) void { + const prefix = "{\"provider\":\"gmail\",\"endpoint\":\""; + const mid1 = "\",\"method\":\""; + const mid2 = "\",\"status\":\"stub\",\"note\":\"Grade D Alpha\"}"; + + var pos: usize = 0; + const parts = [_][]const u8{ prefix, endpoint, mid1, method, mid2 }; + for (parts) |part| { + if (pos + part.len > RESULT_BUF_SIZE) break; + @memcpy(slot.result_buf[pos .. pos + part.len], part); + pos += part.len; + } + slot.result_len = pos; +} + +/// Set OAuth credentials on a Gmail session slot. +pub export fn comms_gmail_set_credentials(slot_idx: c_int, token_ptr: [*]const u8, token_len: usize) c_int { + mutex.lock(); + defer mutex.unlock(); + const idx = validateGmailSlot(slot_idx) orelse return -1; + if (token_len > OAUTH_TOKEN_SIZE) return -3; + @memcpy(sessions[idx].oauth_token[0..token_len], token_ptr[0..token_len]); + sessions[idx].oauth_token_len = token_len; + return 0; +} + +/// Send an email via Gmail. json_ptr/json_len contain the message JSON. +pub export fn comms_gmail_send(slot_idx: c_int, json_ptr: [*]const u8, json_len: usize) c_int { + mutex.lock(); + defer mutex.unlock(); + const idx = validateGmailSlot(slot_idx) orelse return -1; + _ = json_ptr[0..json_len]; + writeGmailResult(&sessions[idx], "messages/send", "POST"); + return 0; +} + +/// Read an email by message ID via Gmail. +pub export fn comms_gmail_read(slot_idx: c_int, msg_id_ptr: [*]const u8, msg_id_len: usize) c_int { + mutex.lock(); + defer mutex.unlock(); + const idx = validateGmailSlot(slot_idx) orelse return -1; + _ = msg_id_ptr[0..msg_id_len]; + writeGmailResult(&sessions[idx], "messages/{id}", "GET"); + return 0; +} + +/// Search emails via Gmail. query_ptr/query_len contain the search query. +pub export fn comms_gmail_search(slot_idx: c_int, query_ptr: [*]const u8, query_len: usize) c_int { + mutex.lock(); + defer mutex.unlock(); + const idx = validateGmailSlot(slot_idx) orelse return -1; + _ = query_ptr[0..query_len]; + writeGmailResult(&sessions[idx], "messages?q={query}", "GET"); + return 0; +} + +/// List Gmail labels. +pub export fn comms_gmail_labels(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + const idx = validateGmailSlot(slot_idx) orelse return -1; + writeGmailResult(&sessions[idx], "labels", "GET"); + return 0; +} + +/// Read the result buffer for a Gmail session slot. Returns length or -1 on error. +pub export fn comms_gmail_read_result(slot_idx: c_int, out_ptr: [*]u8, out_cap: usize) c_int { + mutex.lock(); + defer mutex.unlock(); + if (slot_idx < 0 or slot_idx >= MAX_SESSIONS) return -1; + const idx: usize = @intCast(slot_idx); + if (!sessions[idx].active) return -1; + const len = @min(sessions[idx].result_len, out_cap); + @memcpy(out_ptr[0..len], sessions[idx].result_buf[0..len]); + return @intCast(len); +} + +// ═══════════════════════════════════════════════════════════════════════ +// Google Calendar Provider (provider code 2) +// Grade D Alpha β€” stub implementations +// Real API: https://www.googleapis.com/calendar/v3/{endpoint} +// Auth: Authorization: Bearer {oauth_token} +// ═══════════════════════════════════════════════════════════════════════ + +/// Validate that a slot is active, authenticated, and bound to the Google Calendar provider. +fn validateCalendarSlot(slot_idx: c_int) ?usize { + if (slot_idx < 0 or slot_idx >= MAX_SESSIONS) return null; + const idx: usize = @intCast(slot_idx); + if (!sessions[idx].active) return null; + if (sessions[idx].provider != .google_calendar) return null; + if (sessions[idx].state != .authenticated) return null; + return idx; +} + +/// Write a JSON stub response into a session's result buffer for Calendar. +fn writeCalendarResult(slot: *SessionSlot, endpoint: []const u8, method: []const u8) void { + const prefix = "{\"provider\":\"google_calendar\",\"endpoint\":\""; + const mid1 = "\",\"method\":\""; + const mid2 = "\",\"status\":\"stub\",\"note\":\"Grade D Alpha\"}"; + + var pos: usize = 0; + const parts = [_][]const u8{ prefix, endpoint, mid1, method, mid2 }; + for (parts) |part| { + if (pos + part.len > RESULT_BUF_SIZE) break; + @memcpy(slot.result_buf[pos .. pos + part.len], part); + pos += part.len; + } + slot.result_len = pos; +} + +/// Set OAuth credentials on a Google Calendar session slot. +pub export fn comms_calendar_set_credentials(slot_idx: c_int, token_ptr: [*]const u8, token_len: usize) c_int { + mutex.lock(); + defer mutex.unlock(); + const idx = validateCalendarSlot(slot_idx) orelse return -1; + if (token_len > OAUTH_TOKEN_SIZE) return -3; + @memcpy(sessions[idx].oauth_token[0..token_len], token_ptr[0..token_len]); + sessions[idx].oauth_token_len = token_len; + return 0; +} + +/// List calendar events. json_ptr/json_len contain optional filter JSON. +pub export fn comms_calendar_events(slot_idx: c_int, json_ptr: [*]const u8, json_len: usize) c_int { + mutex.lock(); + defer mutex.unlock(); + const idx = validateCalendarSlot(slot_idx) orelse return -1; + _ = json_ptr[0..json_len]; + writeCalendarResult(&sessions[idx], "calendars/primary/events", "GET"); + return 0; +} + +/// Create a calendar event. json_ptr/json_len contain event JSON. +pub export fn comms_calendar_create_event(slot_idx: c_int, json_ptr: [*]const u8, json_len: usize) c_int { + mutex.lock(); + defer mutex.unlock(); + const idx = validateCalendarSlot(slot_idx) orelse return -1; + _ = json_ptr[0..json_len]; + writeCalendarResult(&sessions[idx], "calendars/primary/events", "POST"); + return 0; +} + +/// Query free/busy information. json_ptr/json_len contain time range JSON. +pub export fn comms_calendar_free_busy(slot_idx: c_int, json_ptr: [*]const u8, json_len: usize) c_int { + mutex.lock(); + defer mutex.unlock(); + const idx = validateCalendarSlot(slot_idx) orelse return -1; + _ = json_ptr[0..json_len]; + writeCalendarResult(&sessions[idx], "freeBusy", "POST"); + return 0; +} + +/// Read the result buffer for a Calendar session slot. Returns length or -1 on error. +pub export fn comms_calendar_read_result(slot_idx: c_int, out_ptr: [*]u8, out_cap: usize) c_int { + mutex.lock(); + defer mutex.unlock(); + if (slot_idx < 0 or slot_idx >= MAX_SESSIONS) return -1; + const idx: usize = @intCast(slot_idx); + if (!sessions[idx].active) return -1; + const len = @min(sessions[idx].result_len, out_cap); + @memcpy(out_ptr[0..len], sessions[idx].result_buf[0..len]); + return @intCast(len); +} + +// ═══════════════════════════════════════════════════════════════════════ +// Tests +// ═══════════════════════════════════════════════════════════════════════ + +test "authenticate and logout" { + comms_reset(); + const slot = comms_authenticate(@intFromEnum(CommsProvider.gmail)); + try std.testing.expect(slot >= 0); + try std.testing.expectEqual(@as(c_int, @intFromEnum(SessionState.authenticated)), comms_state(slot)); + try std.testing.expectEqual(@as(c_int, 0), comms_logout(slot)); +} + +test "cannot operate on unauthenticated" { + comms_reset(); + const slot = comms_authenticate(@intFromEnum(CommsProvider.gmail)); + _ = comms_logout(slot); + // Should fail β€” can't begin operation on unauthenticated session + try std.testing.expectEqual(@as(c_int, -1), comms_begin_operation(slot)); +} + +test "operation lifecycle" { + comms_reset(); + const slot = comms_authenticate(@intFromEnum(CommsProvider.gmail)); + try std.testing.expectEqual(@as(c_int, 0), comms_begin_operation(slot)); + try std.testing.expectEqual(@as(c_int, @intFromEnum(SessionState.operating)), comms_state(slot)); + try std.testing.expectEqual(@as(c_int, 0), comms_end_operation(slot)); + try std.testing.expectEqual(@as(c_int, @intFromEnum(SessionState.authenticated)), comms_state(slot)); + try std.testing.expectEqual(@as(c_int, 0), comms_logout(slot)); +} + +test "cannot double-logout" { + comms_reset(); + const slot = comms_authenticate(@intFromEnum(CommsProvider.google_calendar)); + _ = comms_logout(slot); + // Second logout should fail β€” already unauthenticated + try std.testing.expectEqual(@as(c_int, -1), comms_logout(slot)); +} + +test "cannot logout while operating" { + comms_reset(); + const slot = comms_authenticate(@intFromEnum(CommsProvider.gmail)); + _ = comms_begin_operation(slot); + // Cannot logout directly from operating β€” must end operation first + try std.testing.expectEqual(@as(c_int, -2), comms_logout(slot)); +} + +test "state transition validation" { + // Valid transitions + try std.testing.expectEqual(@as(c_int, 1), comms_can_transition(0, 1)); // unauth -> auth + try std.testing.expectEqual(@as(c_int, 1), comms_can_transition(1, 2)); // auth -> operating + try std.testing.expectEqual(@as(c_int, 1), comms_can_transition(2, 1)); // operating -> auth + try std.testing.expectEqual(@as(c_int, 1), comms_can_transition(1, 0)); // auth -> unauth + try std.testing.expectEqual(@as(c_int, 1), comms_can_transition(2, 3)); // operating -> error + try std.testing.expectEqual(@as(c_int, 1), comms_can_transition(3, 0)); // error -> unauth + // Invalid transitions + try std.testing.expectEqual(@as(c_int, 0), comms_can_transition(0, 2)); // unauth -> operating + try std.testing.expectEqual(@as(c_int, 0), comms_can_transition(2, 0)); // operating -> unauth +} + +test "max sessions enforced" { + comms_reset(); + var slots: [MAX_SESSIONS]c_int = undefined; + for (&slots) |*s| { + s.* = comms_authenticate(@intFromEnum(CommsProvider.gmail)); + try std.testing.expect(s.* >= 0); + } + // Next authenticate should fail + try std.testing.expectEqual(@as(c_int, -1), comms_authenticate(@intFromEnum(CommsProvider.gmail))); + // Free one and retry + _ = comms_logout(slots[0]); + try std.testing.expect(comms_authenticate(@intFromEnum(CommsProvider.gmail)) >= 0); +} + +// ═══════════════════════════════════════════════════════════════════════ +// Gmail Provider Tests +// ═══════════════════════════════════════════════════════════════════════ + +test "gmail auth and credential storage" { + comms_reset(); + const slot = comms_authenticate(@intFromEnum(CommsProvider.gmail)); + try std.testing.expect(slot >= 0); + try std.testing.expectEqual(@as(c_int, @intFromEnum(SessionState.authenticated)), comms_state(slot)); + // Set OAuth credentials + const token = "ya29.test_oauth_token_gmail_abc123"; + try std.testing.expectEqual(@as(c_int, 0), comms_gmail_set_credentials(slot, token.ptr, token.len)); + try std.testing.expectEqual(@as(c_int, 0), comms_logout(slot)); +} + +test "gmail send and search" { + comms_reset(); + const slot = comms_authenticate(@intFromEnum(CommsProvider.gmail)); + try std.testing.expect(slot >= 0); + // Send email + const email_json = "{\"to\":\"test@example.com\",\"subject\":\"Test\",\"body\":\"Hello\"}"; + try std.testing.expectEqual(@as(c_int, 0), comms_gmail_send(slot, email_json.ptr, email_json.len)); + // Read result + var buf: [RESULT_BUF_SIZE]u8 = undefined; + const len = comms_gmail_read_result(slot, &buf, buf.len); + try std.testing.expect(len > 0); + const result = buf[0..@intCast(len)]; + try std.testing.expect(std.mem.indexOf(u8, result, "\"provider\":\"gmail\"") != null); + try std.testing.expect(std.mem.indexOf(u8, result, "\"endpoint\":\"messages/send\"") != null); + // Search + const query = "from:sender@example.com"; + try std.testing.expectEqual(@as(c_int, 0), comms_gmail_search(slot, query.ptr, query.len)); + // Read message + const msg_id = "msg_abc123"; + try std.testing.expectEqual(@as(c_int, 0), comms_gmail_read(slot, msg_id.ptr, msg_id.len)); + // Labels + try std.testing.expectEqual(@as(c_int, 0), comms_gmail_labels(slot)); + _ = comms_logout(slot); +} + +// ═══════════════════════════════════════════════════════════════════════ +// Google Calendar Provider Tests +// ═══════════════════════════════════════════════════════════════════════ + +test "calendar auth and events" { + comms_reset(); + const slot = comms_authenticate(@intFromEnum(CommsProvider.google_calendar)); + try std.testing.expect(slot >= 0); + try std.testing.expectEqual(@as(c_int, @intFromEnum(SessionState.authenticated)), comms_state(slot)); + // Set OAuth credentials + const token = "ya29.test_oauth_token_calendar_xyz"; + try std.testing.expectEqual(@as(c_int, 0), comms_calendar_set_credentials(slot, token.ptr, token.len)); + // List events + const filter = "{\"timeMin\":\"2026-01-01T00:00:00Z\"}"; + try std.testing.expectEqual(@as(c_int, 0), comms_calendar_events(slot, filter.ptr, filter.len)); + // Read result + var buf: [RESULT_BUF_SIZE]u8 = undefined; + const len = comms_calendar_read_result(slot, &buf, buf.len); + try std.testing.expect(len > 0); + const result = buf[0..@intCast(len)]; + try std.testing.expect(std.mem.indexOf(u8, result, "\"provider\":\"google_calendar\"") != null); + // Create event + const event_json = "{\"summary\":\"Meeting\",\"start\":\"2026-03-15T10:00:00Z\"}"; + try std.testing.expectEqual(@as(c_int, 0), comms_calendar_create_event(slot, event_json.ptr, event_json.len)); + // Free/busy + const range_json = "{\"timeMin\":\"2026-03-15T00:00:00Z\",\"timeMax\":\"2026-03-16T00:00:00Z\"}"; + try std.testing.expectEqual(@as(c_int, 0), comms_calendar_free_busy(slot, range_json.ptr, range_json.len)); + _ = comms_logout(slot); +} + +test "cross-provider rejection gmail on calendar slot" { + comms_reset(); + // Authenticate as Google Calendar, then try Gmail operations β€” should fail + const slot = comms_authenticate(@intFromEnum(CommsProvider.google_calendar)); + try std.testing.expect(slot >= 0); + // All Gmail operations should return -1 (wrong provider) + const email_json = "{\"to\":\"test@example.com\"}"; + try std.testing.expectEqual(@as(c_int, -1), comms_gmail_send(slot, email_json.ptr, email_json.len)); + try std.testing.expectEqual(@as(c_int, -1), comms_gmail_read(slot, email_json.ptr, email_json.len)); + try std.testing.expectEqual(@as(c_int, -1), comms_gmail_search(slot, email_json.ptr, email_json.len)); + try std.testing.expectEqual(@as(c_int, -1), comms_gmail_labels(slot)); + const token = "ya29.test"; + try std.testing.expectEqual(@as(c_int, -1), comms_gmail_set_credentials(slot, token.ptr, token.len)); + _ = comms_logout(slot); +} + +test "cross-provider rejection calendar on gmail slot" { + comms_reset(); + // Authenticate as Gmail, then try Calendar operations β€” should fail + const slot = comms_authenticate(@intFromEnum(CommsProvider.gmail)); + try std.testing.expect(slot >= 0); + // All Calendar operations should return -1 (wrong provider) + const json_data = "{}"; + try std.testing.expectEqual(@as(c_int, -1), comms_calendar_events(slot, json_data.ptr, json_data.len)); + try std.testing.expectEqual(@as(c_int, -1), comms_calendar_create_event(slot, json_data.ptr, json_data.len)); + try std.testing.expectEqual(@as(c_int, -1), comms_calendar_free_busy(slot, json_data.ptr, json_data.len)); + const token = "ya29.test"; + try std.testing.expectEqual(@as(c_int, -1), comms_calendar_set_credentials(slot, token.ptr, token.len)); + _ = comms_logout(slot); +} + +// ═══════════════════════════════════════════════════════════════════════ +// ADR-0006 invoke dispatch tests +// ═══════════════════════════════════════════════════════════════════════ + +test "invoke: each declared tool succeeds" { + var buf: [256]u8 = undefined; + const tools = [_][]const u8{ + "comms_authenticate", + "comms_logout", + "comms_execute", + "comms_state", + }; + for (tools) |t| { + var len: usize = buf.len; + const rc = boj_cartridge_invoke(t.ptr, "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, 0), rc); + try std.testing.expect(std.mem.indexOf(u8, buf[0..len], "result") != null); + } +} + +test "invoke: unknown tool returns -1" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("nope", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -1), rc); +} + +test "invoke: buffer too small returns -3" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("comms_authenticate", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -3), rc); + try std.testing.expect(len > 4); +} diff --git a/cartridges/domains/communications/comms-mcp/mod.js b/cartridges/domains/communications/comms-mcp/mod.js new file mode 100644 index 0000000..5293224 --- /dev/null +++ b/cartridges/domains/communications/comms-mcp/mod.js @@ -0,0 +1,55 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// comms-mcp/mod.js β€” Multi-provider communications (Gmail, Google Calendar) +// +// Delegates to backend at http://127.0.0.1:7717 (override with COMMS_BACKEND_URL). + +const BASE_URL = Deno.env.get("COMMS_BACKEND_URL") ?? "http://127.0.0.1:7717"; +const TIMEOUT_MS = 15_000; + +async function post(path, payload) { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), TIMEOUT_MS); + try { + const r = await fetch(`${BASE_URL}${path}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + signal: ctrl.signal, + }); + const data = await r.json().catch(() => ({ success: false, error: "non-JSON response" })); + return { status: r.status, data }; + } catch (e) { + if (e.name === "AbortError") return { status: 504, data: { success: false, error: "comms-mcp backend timed out" } }; + return { status: 503, data: { success: false, error: `comms-mcp backend unavailable: ${e.message}` } }; + } finally { clearTimeout(t); } +} + +async function get(path) { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), TIMEOUT_MS); + try { + const r = await fetch(`${BASE_URL}${path}`, { method: "GET", signal: ctrl.signal }); + const data = await r.json().catch(() => ({ success: false, error: "non-JSON response" })); + return { status: r.status, data }; + } catch (e) { + if (e.name === "AbortError") return { status: 504, data: { success: false, error: "comms-mcp backend timed out" } }; + return { status: 503, data: { success: false, error: `comms-mcp backend unavailable: ${e.message}` } }; + } finally { clearTimeout(t); } +} + +export async function handleTool(toolName, args) { + switch (toolName) { + case "comms_authenticate": + return post("/api/v1/comms_authenticate", args ?? {}); + case "comms_logout": + return post("/api/v1/comms_logout", args ?? {}); + case "comms_execute": + return post("/api/v1/comms_execute", args ?? {}); + case "comms_state": + return post("/api/v1/comms_state", args ?? {}); + default: + return { status: 404, data: { error: `Unknown tool: ${toolName}` } }; + } +} diff --git a/cartridges/domains/communications/comms-mcp/panels/manifest.json b/cartridges/domains/communications/comms-mcp/panels/manifest.json new file mode 100644 index 0000000..b27697f --- /dev/null +++ b/cartridges/domains/communications/comms-mcp/panels/manifest.json @@ -0,0 +1,66 @@ +{ + "$schema": "https://panll.dev/schemas/panel-manifest/v1.json", + "spdx": "MPL-2.0", + "cartridge": "comms-mcp", + "domain": "Communications", + "version": "0.1.0", + "panels": [ + { + "id": "comms-status", + "title": "Comms Gateway Status", + "description": "Health of email, calendar, and messaging integrations", + "type": "status-indicator", + "data_source": { + "endpoint": "/cartridge/comms-mcp/invoke", + "method": "POST", + "body": { "tool": "status" }, + "refresh_interval_ms": 5000 + }, + "widgets": [ + { + "type": "state-badge", + "field": "state", + "states": { + "ready": { "color": "#2ecc71", "icon": "mail" }, + "degraded": { "color": "#f39c12", "icon": "mail-x" }, + "error": { "color": "#e74c3c", "icon": "alert-triangle" } + } + }, + { "type": "text", "field": "version", "label": "Version" } + ] + }, + { + "id": "comms-channels", + "title": "Active Channels", + "description": "Connected communication channels β€” Gmail, Calendar, Slack, Matrix, etc.", + "type": "metric", + "data_source": { + "endpoint": "/cartridge/comms-mcp/invoke", + "method": "POST", + "body": { "tool": "channels" }, + "refresh_interval_ms": 15000 + }, + "widgets": [ + { "type": "counter", "field": "email_accounts", "label": "Email Accounts", "icon": "inbox" }, + { "type": "counter", "field": "calendar_sources", "label": "Calendars", "icon": "calendar" }, + { "type": "counter", "field": "chat_bridges", "label": "Chat Bridges", "icon": "message-square" } + ] + }, + { + "id": "comms-unread", + "title": "Unread Summary", + "description": "Unread message counts across all communication channels", + "type": "metric", + "data_source": { + "endpoint": "/cartridge/comms-mcp/invoke", + "method": "POST", + "body": { "tool": "unread_summary" }, + "refresh_interval_ms": 10000 + }, + "widgets": [ + { "type": "counter", "field": "unread_emails", "label": "Unread Emails", "icon": "mail" }, + { "type": "counter", "field": "upcoming_events", "label": "Upcoming Events", "icon": "bell" } + ] + } + ] +} diff --git a/cartridges/domains/communications/discord-mcp/README.adoc b/cartridges/domains/communications/discord-mcp/README.adoc new file mode 100644 index 0000000..9362e57 --- /dev/null +++ b/cartridges/domains/communications/discord-mcp/README.adoc @@ -0,0 +1,89 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> + += discord-mcp +Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk> +:spdx: MPL-2.0 +:tier: Ayo +:domain: Comms +:protocols: MCP, REST + +== Overview + +Discord REST API v10 cartridge for BoJ server. Provides type-safe access to +16 Discord actions: messages, channels, guilds, members, reactions, threads, +search, status, and file uploads. Bot token authentication with "Bot" prefix +in the Authorization header. Bucket-based per-route rate limiting. + +== Actions + +[cols="1,2"] +|=== +| Action | Description + +| SendMessage | Send a message to a channel +| EditMessage | Edit an existing message +| DeleteMessage | Delete a message +| ListChannels | List channels in a guild +| GetChannel | Get a single channel by ID +| ListGuilds | List guilds the bot is in +| GetGuild | Get a single guild by ID +| ListMembers | List members of a guild +| GetMember | Get a single guild member +| AddReaction | Add a reaction to a message +| RemoveReaction | Remove a reaction from a message +| CreateThread | Create a new thread +| ListThreads | List threads in a channel +| SearchMessages | Search messages in a guild +| SetStatus | Set the bot's status/presence +| UploadFile | Upload a file attachment +|=== + +== Architecture + +[cols="1,1,2"] +|=== +| Layer | Language | Purpose + +| ABI +| Idris2 +| Formally verified state machine (DiscordMcp.SafeComms) with dependent-type proofs + +| FFI +| Zig +| C-compatible implementation with thread-safe session pool, token validation, bucket tracking + +| Adapter +| zig +| REST bridge to BoJ unified adapter protocol with all 16 actions +|=== + +== State Machine + +.... +Disconnected -> Authenticating -> Connected <-> RateLimited + | | | + v v v + Error <----------+--------------+ + | + v + Disconnected +.... + +== Building + +[source,bash] +---- +# Build FFI shared library +cd ffi && zig build + +# Run FFI tests +cd ffi && zig build test + +# Type-check ABI +cd abi && idris2 --check DiscordMcp.SafeComms +---- + +== Status + +Development -- not yet ready for mounting. diff --git a/cartridges/domains/communications/discord-mcp/abi/DiscordMcp/SafeComms.idr b/cartridges/domains/communications/discord-mcp/abi/DiscordMcp/SafeComms.idr new file mode 100644 index 0000000..192f875 --- /dev/null +++ b/cartridges/domains/communications/discord-mcp/abi/DiscordMcp/SafeComms.idr @@ -0,0 +1,236 @@ +-- SPDX-License-Identifier: MPL-2.0 +-- Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +-- +-- DiscordMcp.SafeComms β€” Type-safe ABI for discord-mcp cartridge. +-- +-- Dependent-type-proven state machine for Discord bot communication. +-- Covers the full Discord REST API v10 surface: messages, channels, +-- guilds, members, reactions, threads, search, status, and file uploads. +-- Bucket-based per-route rate limiting modelled in the state machine. + +module DiscordMcp.SafeComms + +%default total + +-- --------------------------------------------------------------------------- +-- Connection state machine +-- --------------------------------------------------------------------------- + +||| Connection state for Discord bot sessions. +||| Discord uses a Bot token with "Bot" prefix in the Authorization header, +||| communicating via REST at https://discord.com/api/v10/. +public export +data ConnState + = Disconnected + | Authenticating + | Connected + | RateLimited + | Error + +||| Proof that a connection state transition is valid. +public export +data ValidTransition : ConnState -> ConnState -> Type where + StartAuth : ValidTransition Disconnected Authenticating + AuthSuccess : ValidTransition Authenticating Connected + AuthFail : ValidTransition Authenticating Error + HitRateLimit : ValidTransition Connected RateLimited + RateLimitDone : ValidTransition RateLimited Connected + ConnError : ValidTransition Connected Error + RateError : ValidTransition RateLimited Error + Recover : ValidTransition Error Disconnected + Disconnect : ValidTransition Connected Disconnected + +-- --------------------------------------------------------------------------- +-- C-ABI integer encoding for ConnState +-- --------------------------------------------------------------------------- + +||| Encode connection state as C-compatible integer. +export +connStateToInt : ConnState -> Int +connStateToInt Disconnected = 0 +connStateToInt Authenticating = 1 +connStateToInt Connected = 2 +connStateToInt RateLimited = 3 +connStateToInt Error = 4 + +||| Decode integer back to connection state. +export +intToConnState : Int -> Maybe ConnState +intToConnState 0 = Just Disconnected +intToConnState 1 = Just Authenticating +intToConnState 2 = Just Connected +intToConnState 3 = Just RateLimited +intToConnState 4 = Just Error +intToConnState _ = Nothing + +||| Check if a connection state transition is valid (C-ABI export). +||| Returns 1 for valid, 0 for invalid. +export +discord_mcp_can_transition : Int -> Int -> Int +discord_mcp_can_transition from to = + case (intToConnState from, intToConnState to) of + (Just Disconnected, Just Authenticating) => 1 + (Just Authenticating, Just Connected) => 1 + (Just Authenticating, Just Error) => 1 + (Just Connected, Just RateLimited) => 1 + (Just RateLimited, Just Connected) => 1 + (Just Connected, Just Error) => 1 + (Just RateLimited, Just Error) => 1 + (Just Error, Just Disconnected) => 1 + (Just Connected, Just Disconnected) => 1 + _ => 0 + +-- --------------------------------------------------------------------------- +-- Discord actions +-- --------------------------------------------------------------------------- + +||| Actions available through the Discord REST API v10. +||| Each action maps to one or more Discord REST endpoints. +public export +data DiscordAction + = SendMessage + | EditMessage + | DeleteMessage + | ListChannels + | GetChannel + | ListGuilds + | GetGuild + | ListMembers + | GetMember + | AddReaction + | RemoveReaction + | CreateThread + | ListThreads + | SearchMessages + | SetStatus + | UploadFile + +||| Total count of supported Discord actions. +export +actionCount : Nat +actionCount = 16 + +||| Encode a Discord action as a C-compatible integer. +export +actionToInt : DiscordAction -> Int +actionToInt SendMessage = 0 +actionToInt EditMessage = 1 +actionToInt DeleteMessage = 2 +actionToInt ListChannels = 3 +actionToInt GetChannel = 4 +actionToInt ListGuilds = 5 +actionToInt GetGuild = 6 +actionToInt ListMembers = 7 +actionToInt GetMember = 8 +actionToInt AddReaction = 9 +actionToInt RemoveReaction = 10 +actionToInt CreateThread = 11 +actionToInt ListThreads = 12 +actionToInt SearchMessages = 13 +actionToInt SetStatus = 14 +actionToInt UploadFile = 15 + +||| Decode integer back to a Discord action. +export +intToAction : Int -> Maybe DiscordAction +intToAction 0 = Just SendMessage +intToAction 1 = Just EditMessage +intToAction 2 = Just DeleteMessage +intToAction 3 = Just ListChannels +intToAction 4 = Just GetChannel +intToAction 5 = Just ListGuilds +intToAction 6 = Just GetGuild +intToAction 7 = Just ListMembers +intToAction 8 = Just GetMember +intToAction 9 = Just AddReaction +intToAction 10 = Just RemoveReaction +intToAction 11 = Just CreateThread +intToAction 12 = Just ListThreads +intToAction 13 = Just SearchMessages +intToAction 14 = Just SetStatus +intToAction 15 = Just UploadFile +intToAction _ = Nothing + +||| Check whether a given action requires a Connected state. +||| All actions require Connected; none can run while Disconnected, +||| Authenticating, RateLimited, or in Error. +export +actionRequiresConnected : DiscordAction -> Bool +actionRequiresConnected _ = True + +-- --------------------------------------------------------------------------- +-- Rate limit bucket model +-- --------------------------------------------------------------------------- + +||| Discord uses bucket-based per-route rate limiting. +||| Each route belongs to a bucket identified by a string hash. +||| The server returns X-RateLimit-Bucket, X-RateLimit-Remaining, +||| and X-RateLimit-Reset headers. +public export +data RateLimitInfo : Type where + MkRateLimitInfo : + (bucket : String) -> + (remaining : Nat) -> + (resetAfter : Nat) -> + RateLimitInfo + +||| Check whether a rate limit bucket still has remaining requests. +export +bucketHasCapacity : RateLimitInfo -> Bool +bucketHasCapacity (MkRateLimitInfo _ remaining _) = + case remaining of + Z => False + S _ => True + +-- --------------------------------------------------------------------------- +-- Auth model +-- --------------------------------------------------------------------------- + +||| Discord bot authentication: Bot token with "Bot " prefix in +||| the Authorization header. +public export +data AuthConfig : Type where + MkAuthConfig : + (token : String) -> + (baseUrl : String) -> + AuthConfig + +||| Default base URL for Discord REST API v10. +export +defaultBaseUrl : String +defaultBaseUrl = "https://discord.com/api/v10/" + +||| Validate that a bot token is non-empty and does not contain +||| whitespace (basic structural validation). +export +validateToken : String -> Bool +validateToken tok = + let len = length tok + in len > 0 && len < 200 + +-- --------------------------------------------------------------------------- +-- MCP tool declarations +-- --------------------------------------------------------------------------- + +||| Tools exposed via MCP protocol for this cartridge. +public export +data McpTool + = ToolConnect + | ToolDisconnect + | ToolStatus + | ToolInvoke + | ToolList + +||| Check if a tool requires a connected session. +export +toolRequiresSession : McpTool -> Bool +toolRequiresSession ToolConnect = False +toolRequiresSession ToolDisconnect = True +toolRequiresSession ToolStatus = False +toolRequiresSession ToolInvoke = True +toolRequiresSession ToolList = False + +||| Tool count for this cartridge. +export +toolCount : Nat +toolCount = 5 diff --git a/cartridges/domains/communications/discord-mcp/abi/README.adoc b/cartridges/domains/communications/discord-mcp/abi/README.adoc new file mode 100644 index 0000000..bc5f1e5 --- /dev/null +++ b/cartridges/domains/communications/discord-mcp/abi/README.adoc @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += discord-mcp / abi β€” Idris2 ABI layer +:orientation: shallow + +== Purpose + +Cartridge-local protocol types for `discord-mcp`. Defines the data, tool, and +transition vocabulary this cartridge speaks. Inherits safety lemmas and the +matrix-cell identifier scheme from the trunk ABI at `src/abi/`. + +== Files + +1 Idris2 module(s), ~236 lines total. Lead module: +`SafeComms.idr`. + +No `.ipkg` β€” consumed directly by wider BoJ callers; type-checks inside the +trunk `boj.ipkg`. + +== Invariants + +* `%default total` at every module top. +* Zero `believe_me`, `assert_total`, `postulate`, `Admitted`, or + `sorry` (the trunk holds the four declared `believe_me` sites; leaf + cartridges carry none). + +== Test/proof surface + +Thin module(s), no inline tests. Proof surface = type-check success under the +trunk `idris2 --check`. + +== Read-first + +. `SafeComms.idr` β€” cartridge-local data records and enums. +. Trunk `src/abi/Boj/Catalogue.idr` for how this cell participates in the + 2D matrix. diff --git a/cartridges/domains/communications/discord-mcp/abi/discord_mcp.ipkg b/cartridges/domains/communications/discord-mcp/abi/discord_mcp.ipkg new file mode 100644 index 0000000..e6554ac --- /dev/null +++ b/cartridges/domains/communications/discord-mcp/abi/discord_mcp.ipkg @@ -0,0 +1,11 @@ +-- SPDX-License-Identifier: MPL-2.0 +-- Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +package discord_mcp + +version = "0.1.0" +authors = "Jonathan D.A. Jewell" +brief = "Type-safe ABI for discord-mcp cartridge (Discord REST API v10)" + +depends = base + +modules = DiscordMcp.SafeComms diff --git a/cartridges/domains/communications/discord-mcp/adapter/README.adoc b/cartridges/domains/communications/discord-mcp/adapter/README.adoc new file mode 100644 index 0000000..d946e9d --- /dev/null +++ b/cartridges/domains/communications/discord-mcp/adapter/README.adoc @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += discord-mcp / adapter β€” protocol bridge +:orientation: shallow + +== Purpose + +Exposes this cartridge's FFI over the wire protocols declared in +`cartridge.json`. Dispatches tool invocations to the C-ABI exports at +`../ffi/` and formats responses per protocol. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” depends on the sibling `ffi` target. +| `discord_mcp_adapter.zig` | Protocol dispatch (112 lines). Tool catalogue matches `cartridge.json`. +| `SIDELINED-*.v.adoc` | zig predecessor(s). Not built β€” zig banned 2026-04-10. +|=== + +== Invariants + +* **Stateless** β€” all state lives behind the FFI, never in the adapter. +* **Response passthrough** β€” whatever the FFI returns goes back to the wire + unmodified (no embellishment, no silent recovery). +* **Cartridge.json is source of truth** for the tool catalogue; drift between + adapter and manifest is a CI failure. + +== Test/proof surface + +No inline tests at the adapter layer β€” protocol routing is exercised by the +trunk seam tests at `ffi/zig/src/seams.zig`. + +== Read-first + +. `discord_mcp_adapter.zig` β€” dispatch function and per-protocol routers. +. `../cartridge.json` β€” the tool manifest the dispatcher mirrors. diff --git a/cartridges/domains/communications/discord-mcp/adapter/build.zig b/cartridges/domains/communications/discord-mcp/adapter/build.zig new file mode 100644 index 0000000..b7b45fa --- /dev/null +++ b/cartridges/domains/communications/discord-mcp/adapter/build.zig @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// discord-mcp/adapter/build.zig + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); const optimize = b.standardOptimizeOption(.{}); + const ffi_mod = b.createModule(.{ .root_source_file = b.path("../ffi/discord_mcp_ffi.zig"), .target = target, .optimize = optimize }); + const adapter = b.addExecutable(.{ .name = "discord_mcp_adapter", .root_source_file = b.path("discord_mcp_adapter.zig"), .target = target, .optimize = optimize }); + adapter.root_module.addImport("discord_mcp_ffi", ffi_mod); b.installArtifact(adapter); + const rs = b.step("run", "Run discord-mcp adapter"); rs.dependOn(&b.addRunArtifact(adapter).step); + const tests = b.addTest(.{ .root_source_file = b.path("discord_mcp_adapter.zig"), .target = target, .optimize = optimize }); + tests.root_module.addImport("discord_mcp_ffi", ffi_mod); + const ts = b.step("test", "Test discord-mcp adapter"); ts.dependOn(&b.addRunArtifact(tests).step); +} diff --git a/cartridges/domains/communications/discord-mcp/adapter/discord_mcp_adapter.zig b/cartridges/domains/communications/discord-mcp/adapter/discord_mcp_adapter.zig new file mode 100644 index 0000000..3d50b77 --- /dev/null +++ b/cartridges/domains/communications/discord-mcp/adapter/discord_mcp_adapter.zig @@ -0,0 +1,112 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// discord-mcp/adapter/discord_mcp_adapter.zig -- Unified three-protocol adapter. +// Replaces banned discord_mcp_adapter.v (zig, removed 2026-04-12). +// REST:9187 gRPC:9188 GraphQL:9189 +// Tools: discord_authenticate, discord_send_message, discord_list_guilds, discord_list_channels... + +const std = @import("std"); +const ffi = @import("discord_mcp_ffi"); + +const REST_PORT: u16 = 9187; +const GRPC_PORT: u16 = 9188; +const GQL_PORT: u16 = 9189; +const MAX_CONN_BUF: usize = 16 * 1024; + +fn okJson(buf: []u8, msg: []const u8) []u8 { + const n = std.fmt.bufPrint(buf, "{\"success\":true,\"message\":\"{s}\"}", .{msg}) catch return buf[0..0]; return n; +} +fn errJson(buf: []u8, msg: []const u8) []u8 { + const n = std.fmt.bufPrint(buf, "{\"success\":false,\"error\":\"{s}\"}", .{msg}) catch return buf[0..0]; return n; +} +fn statusJson(buf: []u8) []u8 { + const n = std.fmt.bufPrint(buf, "{\"success\":true,\"state\":\"ready\",\"service\":\"discord-mcp\"}", .{}) catch return buf[0..0]; return n; +} + +const Response = struct { status: u16, body: []u8 }; + +fn dispatch(tool: []const u8, body: []const u8, resp: []u8) Response { + _ = body; + if (std.mem.eql(u8, tool, "discord_authenticate")) return .{ .status = 200, .body = okJson(resp, "discord_authenticate forwarded") }; + if (std.mem.eql(u8, tool, "discord_send_message")) return .{ .status = 200, .body = okJson(resp, "discord_send_message forwarded") }; + if (std.mem.eql(u8, tool, "discord_list_guilds")) return .{ .status = 200, .body = okJson(resp, "discord_list_guilds forwarded") }; + if (std.mem.eql(u8, tool, "discord_list_channels")) return .{ .status = 200, .body = okJson(resp, "discord_list_channels forwarded") }; + if (std.mem.eql(u8, tool, "discord_read_messages")) return .{ .status = 200, .body = okJson(resp, "discord_read_messages forwarded") }; + if (std.mem.eql(u8, tool, "discord_add_reaction")) return .{ .status = 200, .body = okJson(resp, "discord_add_reaction forwarded") }; + if (std.mem.eql(u8, tool, "discord_deauthenticate")) return .{ .status = 200, .body = okJson(resp, "discord_deauthenticate forwarded") }; + if (std.mem.eql(u8, tool, "status") or std.mem.eql(u8, tool, "health")) return .{ .status = 200, .body = statusJson(resp) }; + return .{ .status = 404, .body = errJson(resp, "Unknown tool") }; +} + +fn dispatchRest(path: []const u8, body: []const u8, resp: []u8) Response { + if (std.mem.startsWith(u8, path, "/tools/")) return dispatch(path["/tools/".len..], body, resp); + if (std.mem.eql(u8, path, "/status") or std.mem.eql(u8, path, "/health")) return .{ .status = 200, .body = statusJson(resp) }; + return .{ .status = 404, .body = errJson(resp, "Not found") }; +} + +fn dispatchGrpc(path: []const u8, body: []const u8, resp: []u8) Response { + const prefix = "/DiscordMcpservice/"; + if (!std.mem.startsWith(u8, path, prefix)) return .{ .status = 404, .body = errJson(resp, "Not a recognized gRPC path") }; + const method = path[prefix.len..]; + const tool = blk: { + if (std.mem.eql(u8, method, "discord_authenticate")) break :blk "discord_authenticate"; + if (std.mem.eql(u8, method, "discord_send_message")) break :blk "discord_send_message"; + if (std.mem.eql(u8, method, "discord_list_guilds")) break :blk "discord_list_guilds"; + if (std.mem.eql(u8, method, "discord_list_channels")) break :blk "discord_list_channels"; + if (std.mem.eql(u8, method, "discord_read_messages")) break :blk "discord_read_messages"; + if (std.mem.eql(u8, method, "discord_add_reaction")) break :blk "discord_add_reaction"; + if (std.mem.eql(u8, method, "discord_deauthenticate")) break :blk "discord_deauthenticate"; + return .{ .status = 404, .body = errJson(resp, "Unknown gRPC method") }; + }; + return dispatch(tool, body, resp); +} + +fn dispatchGraphql(body: []const u8, resp: []u8) Response { + if (std.mem.indexOf(u8, body, "__schema") != null) return .{ .status = 200, .body = okJson(resp, "schema not supported") }; + if (std.mem.indexOf(u8, body, "authenticate") != null) return dispatch("discord_authenticate", body, resp); + if (std.mem.indexOf(u8, body, "send_message") != null) return dispatch("discord_send_message", body, resp); + if (std.mem.indexOf(u8, body, "list_guilds") != null) return dispatch("discord_list_guilds", body, resp); + if (std.mem.indexOf(u8, body, "list_channels") != null) return dispatch("discord_list_channels", body, resp); + if (std.mem.indexOf(u8, body, "read_messages") != null) return dispatch("discord_read_messages", body, resp); + if (std.mem.indexOf(u8, body, "add_reaction") != null) return dispatch("discord_add_reaction", body, resp); + if (std.mem.indexOf(u8, body, "deauthenticate") != null) return dispatch("discord_deauthenticate", body, resp); + return .{ .status = 200, .body = errJson(resp, "Unrecognised GraphQL operation") }; +} + +const Protocol = enum { rest, grpc, graphql }; + +fn handleConnection(conn: std.net.Server.Connection, proto: Protocol) void { + defer conn.stream.close(); + var in_buf: [MAX_CONN_BUF]u8 = undefined; + const n = conn.stream.read(&in_buf) catch return; + const req = in_buf[0..n]; + var path: []const u8 = "/"; var body: []const u8 = ""; + if (n > 4) { + const le = std.mem.indexOf(u8, req, "\r\n") orelse req.len; + const fl = req[0..le]; const sp1 = std.mem.indexOfScalar(u8, fl, ' ') orelse 0; + const ro = fl[sp1+1..]; const sp2 = std.mem.indexOfScalar(u8, ro, ' ') orelse ro.len; + path = ro[0..sp2]; + const bs = std.mem.indexOf(u8, req, "\r\n\r\n") orelse n; body = req[@min(bs+4,n)..]; + } + var rb: [MAX_CONN_BUF]u8 = undefined; + const result = switch (proto) { .rest => dispatchRest(path, body, &rb), .grpc => dispatchGrpc(path, body, &rb), .graphql => dispatchGraphql(body, &rb) }; + const ct: []const u8 = if (proto == .grpc) "application/grpc+json" else "application/json"; + var hb: [256]u8 = undefined; + const hdr = std.fmt.bufPrint(&hb, "HTTP/1.1 {d} OK\r\nContent-Type: {s}\r\nContent-Length: {d}\r\nConnection: close\r\n\r\n", .{ result.status, ct, result.body.len }) catch return; + _ = conn.stream.writeAll(hdr) catch return; _ = conn.stream.writeAll(result.body) catch return; +} + +fn listenLoop(port: u16, proto: Protocol) void { + const addr = std.net.Address.initIp4(.{ 127, 0, 0, 1 }, port) catch return; + var srv = addr.listen(.{ .reuse_address = true }) catch return; defer srv.deinit(); + while (true) { const conn = srv.accept() catch continue; handleConnection(conn, proto); } +} + +pub fn main() !void { + ffi.discord_mcp_init(); + const t1 = try std.Thread.spawn(.{}, listenLoop, .{ REST_PORT, Protocol.rest }); + const t2 = try std.Thread.spawn(.{}, listenLoop, .{ GRPC_PORT, Protocol.grpc }); + const t3 = try std.Thread.spawn(.{}, listenLoop, .{ GQL_PORT, Protocol.graphql }); + t1.join(); t2.join(); t3.join(); +} diff --git a/cartridges/domains/communications/discord-mcp/benchmarks/quick-bench.sh b/cartridges/domains/communications/discord-mcp/benchmarks/quick-bench.sh new file mode 100755 index 0000000..9f69f0e --- /dev/null +++ b/cartridges/domains/communications/discord-mcp/benchmarks/quick-bench.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Benchmarks for discord-mcp cartridge. +set -euo pipefail + +echo "=== discord-mcp benchmarks ===" + +cd "$(dirname "${BASH_SOURCE[0]}")/../ffi" + +# Build with ReleaseFast for benchmarking +zig build -Doptimize=ReleaseFast 2>&1 + +echo "Session open/close cycle (1000 iterations):" +time for i in $(seq 1 1000); do + # This would call the FFI benchmark binary + true +done + +echo "" +echo "Benchmark placeholder β€” implement real benchmarks in Zig test blocks" +echo "or via the zig adapter HTTP benchmark tool." diff --git a/cartridges/domains/communications/discord-mcp/cartridge.json b/cartridges/domains/communications/discord-mcp/cartridge.json new file mode 100644 index 0000000..34386a1 --- /dev/null +++ b/cartridges/domains/communications/discord-mcp/cartridge.json @@ -0,0 +1,187 @@ +{ + "$schema": "https://boj.dev/schemas/cartridge/v1.json", + "spdx": "MPL-2.0", + "copyright": "Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath)", + "name": "discord-mcp", + "version": "0.1.0", + "description": "Discord bot gateway. Send messages, read channel history, manage guilds, and react to messages.", + "domain": "Communications", + "tier": "Ayo", + "protocols": [ + "MCP", + "REST" + ], + "auth": { + "method": "none", + "env_var": null, + "credential_source": null + }, + "api": { + "base_url": "local://discord-mcp", + "content_type": "application/json" + }, + "tools": [ + { + "name": "discord_authenticate", + "description": "Authenticate with Discord using a bot token.", + "inputSchema": { + "type": "object", + "properties": { + "token": { + "type": "string", + "description": "Discord bot token" + } + }, + "required": [ + "token" + ] + } + }, + { + "name": "discord_send_message", + "description": "Send a message to a Discord channel.", + "inputSchema": { + "type": "object", + "properties": { + "slot": { + "type": "integer", + "description": "Session slot from discord_authenticate" + }, + "channel_id": { + "type": "string", + "description": "Discord channel ID" + }, + "content": { + "type": "string", + "description": "Message content" + } + }, + "required": [ + "slot", + "channel_id", + "content" + ] + } + }, + { + "name": "discord_list_guilds", + "description": "List guilds (servers) the bot is a member of.", + "inputSchema": { + "type": "object", + "properties": { + "slot": { + "type": "integer", + "description": "Session slot" + } + }, + "required": [ + "slot" + ] + } + }, + { + "name": "discord_list_channels", + "description": "List channels in a guild.", + "inputSchema": { + "type": "object", + "properties": { + "slot": { + "type": "integer", + "description": "Session slot" + }, + "guild_id": { + "type": "string", + "description": "Guild ID" + } + }, + "required": [ + "slot", + "guild_id" + ] + } + }, + { + "name": "discord_read_messages", + "description": "Read recent messages from a channel.", + "inputSchema": { + "type": "object", + "properties": { + "slot": { + "type": "integer", + "description": "Session slot" + }, + "channel_id": { + "type": "string", + "description": "Channel ID" + }, + "limit": { + "type": "integer", + "description": "Number of messages (default: 50)" + } + }, + "required": [ + "slot", + "channel_id" + ] + } + }, + { + "name": "discord_add_reaction", + "description": "Add a reaction emoji to a message.", + "inputSchema": { + "type": "object", + "properties": { + "slot": { + "type": "integer", + "description": "Session slot" + }, + "channel_id": { + "type": "string", + "description": "Channel ID" + }, + "message_id": { + "type": "string", + "description": "Message ID" + }, + "emoji": { + "type": "string", + "description": "Emoji (unicode or :name:)" + } + }, + "required": [ + "slot", + "channel_id", + "message_id", + "emoji" + ] + } + }, + { + "name": "discord_deauthenticate", + "description": "Release a Discord session slot.", + "inputSchema": { + "type": "object", + "properties": { + "slot": { + "type": "integer", + "description": "Session slot to release" + } + }, + "required": [ + "slot" + ] + } + } + ], + "ffi": { + "so_path": "ffi/zig-out/lib/libdiscord_mcp.so", + "abi_version": "ADR-0006", + "symbols": [ + "boj_cartridge_init", + "boj_cartridge_deinit", + "boj_cartridge_name", + "boj_cartridge_version", + "boj_cartridge_invoke" + ] + } +} diff --git a/cartridges/domains/communications/discord-mcp/ffi/README.adoc b/cartridges/domains/communications/discord-mcp/ffi/README.adoc new file mode 100644 index 0000000..39f2ffc --- /dev/null +++ b/cartridges/domains/communications/discord-mcp/ffi/README.adoc @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += discord-mcp / ffi β€” Zig FFI layer +:orientation: shallow + +== Purpose + +C-ABI exports bridging this cartridge's Idris2 ABI to the runtime. Consumed by +the adapter for protocol dispatch and by the trunk loader for mount. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” produces `zig-out/lib/libdiscord.so`. +| `discord_mcp_ffi.zig` | C-ABI exports (13 exports, 6 inline tests, 374 lines). +|=== + +== Invariants + +* **Null-pointer rejection** on `[*c]const u8` arguments before any + dereference. +* **Negative return values** carry documented error meanings; zero is + success (matching the trunk error-code convention). +* **No allocator leaks** β€” every allocation path pairs with `defer free`. + +== Test/proof surface + +6 inline `test "..."` block(s) in `discord_mcp_ffi.zig`. Run with +`cd ffi && zig build test`. + +== Read-first + +. `discord_mcp_ffi.zig` β€” C-ABI signatures and guards. +. Trunk `ffi/zig/src/catalogue.zig` for how this `.so` is loaded. diff --git a/cartridges/domains/communications/discord-mcp/ffi/build.zig b/cartridges/domains/communications/discord-mcp/ffi/build.zig new file mode 100644 index 0000000..677ce0e --- /dev/null +++ b/cartridges/domains/communications/discord-mcp/ffi/build.zig @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + // Module + // Shared ADR-0006 invoke-shim module (relative path up to boj-server trunk). + + const shim_mod = b.addModule("cartridge_shim", .{ + + .root_source_file = b.path("../../../ffi/zig/src/cartridge_shim.zig"), + + .target = target, + + .optimize = optimize, + + }); + + const ffi_mod = b.addModule("discord_mcp", .{ + .root_source_file = b.path("discord_mcp_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + ffi_mod.addImport("cartridge_shim", shim_mod); + + // Shared library + const lib = b.addLibrary(.{ + .name = "discord_mcp", + .root_module = ffi_mod, + .linkage = .dynamic, + }); + lib.linkLibC(); + b.installArtifact(lib); + + // Tests + const tests = b.addTest(.{ + .root_module = ffi_mod, + }); + tests.linkLibC(); + + const run_tests = b.addRunArtifact(tests); + const test_step = b.step("test", "Run FFI tests"); + test_step.dependOn(&run_tests.step); +} diff --git a/cartridges/domains/communications/discord-mcp/ffi/cartridge_shim.zig b/cartridges/domains/communications/discord-mcp/ffi/cartridge_shim.zig new file mode 100644 index 0000000..0e399db --- /dev/null +++ b/cartridges/domains/communications/discord-mcp/ffi/cartridge_shim.zig @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// cartridge_shim.zig β€” Shared helpers for the ADR-0006 five-symbol +// cartridge ABI (`boj_cartridge_init / deinit / name / version / invoke`). +// +// The shim centralises the seven-code return convention, NUL-argument +// guards, tool-name comparison, and the buffer-too-small path so each +// cartridge's `boj_cartridge_invoke` can stay short β€” typically a tool +// table plus `shim.writeResult(...)`. +// +// Cartridges import this file by relative path (no build-graph change +// needed). Example: +// +// const shim = @import("../../../ffi/zig/src/cartridge_shim.zig"); +// +// export fn boj_cartridge_invoke( +// tool_name: [*c]const u8, +// json_args: [*c]const u8, +// out_buf: [*c]u8, +// in_out_len: [*c]usize, +// ) callconv(.c) i32 { +// _ = json_args; +// if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; +// const body = if (shim.toolIs(tool_name, "foo")) "{\"result\":{}}" +// else return shim.RC_UNKNOWN_TOOL; +// return shim.writeResult(out_buf, in_out_len, body); +// } + +const std = @import("std"); + +// ── Return codes (ADR-0006 Β§Return codes) ──────────────────────────── +// +// Frozen by ADR-0006. New failure modes compose these via the error +// JSON body β€” the integer surface does not grow without a follow-up ADR. + +pub const RC_SUCCESS: i32 = 0; +pub const RC_UNKNOWN_TOOL: i32 = -1; +pub const RC_BAD_ARGS: i32 = -2; +pub const RC_BUFFER_TOO_SMALL: i32 = -3; +pub const RC_RUNTIME_ERROR: i32 = -4; +pub const RC_PANIC: i32 = -5; +pub const RC_AUTH_DENIED: i32 = -6; + +// ── Invoke-path helpers ────────────────────────────────────────────── + +/// True if any of the three mandatory `boj_cartridge_invoke` output-path +/// pointers is null. Use at the top of every invoke to short-circuit to +/// `RC_BAD_ARGS`. +pub fn invokeArgsNull( + tool_name: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) bool { + return tool_name == null or out_buf == null or in_out_len == null; +} + +/// Compare a C-NUL-terminated tool-name pointer against a Zig string +/// literal. Caller must have already verified `tool_name` is non-null +/// (usually via `invokeArgsNull`). +pub fn toolIs(tool_name: [*c]const u8, expected: []const u8) bool { + const s = std.mem.span(@as([*:0]const u8, @ptrCast(tool_name))); + return std.mem.eql(u8, s, expected); +} + +/// Copy `body` into `out_buf[0..*in_out_len]` (as a capacity) and update +/// `*in_out_len` to the number of bytes written. Returns `RC_SUCCESS`. +/// +/// If `body.len` exceeds the current capacity stored in `*in_out_len`, +/// sets `*in_out_len` to the required size and returns +/// `RC_BUFFER_TOO_SMALL` β€” the caller is then expected to re-allocate +/// and retry, per ADR-0006 Β§Memory ownership. +/// +/// Caller must have already verified that `out_buf` and `in_out_len` +/// are non-null. +pub fn writeResult( + out_buf: [*c]u8, + in_out_len: [*c]usize, + body: []const u8, +) i32 { + const cap = in_out_len.*; + if (body.len > cap) { + in_out_len.* = body.len; + return RC_BUFFER_TOO_SMALL; + } + @memcpy(out_buf[0..body.len], body); + in_out_len.* = body.len; + return RC_SUCCESS; +} + +// ── Tests ──────────────────────────────────────────────────────────── + +test "writeResult: body fits, writes and sets length" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); + try std.testing.expectEqualStrings("hello", buf[0..len]); +} + +test "writeResult: too small returns -3 and sets required length" { + var buf: [2]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_BUFFER_TOO_SMALL, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: exact-fit succeeds" { + var buf: [5]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: empty body" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, ""); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 0), len); +} + +test "toolIs: matches and rejects" { + const name: [*:0]const u8 = "foo"; + try std.testing.expect(toolIs(@ptrCast(name), "foo")); + try std.testing.expect(!toolIs(@ptrCast(name), "bar")); + try std.testing.expect(!toolIs(@ptrCast(name), "foobar")); + try std.testing.expect(!toolIs(@ptrCast(name), "fo")); +} + +test "invokeArgsNull: detects each null slot" { + var buf: [4]u8 = undefined; + var len: usize = 4; + const name: [*:0]const u8 = "x"; + try std.testing.expect(!invokeArgsNull(@ptrCast(name), &buf, &len)); + try std.testing.expect(invokeArgsNull(null, &buf, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), null, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), &buf, null)); +} diff --git a/cartridges/domains/communications/discord-mcp/ffi/discord_mcp_ffi.zig b/cartridges/domains/communications/discord-mcp/ffi/discord_mcp_ffi.zig new file mode 100644 index 0000000..3df6853 --- /dev/null +++ b/cartridges/domains/communications/discord-mcp/ffi/discord_mcp_ffi.zig @@ -0,0 +1,474 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// discord_mcp_ffi.zig β€” C-ABI FFI implementation for discord-mcp cartridge. +// +// Implements the connection state machine and Discord action dispatch defined +// in the Idris2 ABI layer (DiscordMcp.SafeComms). Thread-safe via +// std.Thread.Mutex. Supports bucket-based per-route rate limiting matching +// Discord REST API v10 semantics. Bot token format validation included. + +const std = @import("std"); + +// --------------------------------------------------------------------------- +// Connection state machine (matches Idris2 ABI: DiscordMcp.SafeComms) +// --------------------------------------------------------------------------- + +/// Connection state for Discord bot sessions. +/// Disconnected(0) | Authenticating(1) | Connected(2) | RateLimited(3) | Error(4) +pub const ConnState = enum(c_int) { + disconnected = 0, + authenticating = 1, + connected = 2, + rate_limited = 3, + err = 4, +}; + +/// Check whether a state transition is permitted by the state machine. +fn isValidTransition(from: ConnState, to: ConnState) bool { + return switch (from) { + .disconnected => to == .authenticating, + .authenticating => to == .connected or to == .err, + .connected => to == .rate_limited or to == .err or to == .disconnected, + .rate_limited => to == .connected or to == .err, + .err => to == .disconnected, + }; +} + +// --------------------------------------------------------------------------- +// Discord action codes (matches Idris2 ABI: DiscordAction) +// --------------------------------------------------------------------------- + +/// All 16 Discord actions supported by this cartridge. +pub const DiscordAction = enum(c_int) { + send_message = 0, + edit_message = 1, + delete_message = 2, + list_channels = 3, + get_channel = 4, + list_guilds = 5, + get_guild = 6, + list_members = 7, + get_member = 8, + add_reaction = 9, + remove_reaction = 10, + create_thread = 11, + list_threads = 12, + search_messages = 13, + set_status = 14, + upload_file = 15, +}; + +// --------------------------------------------------------------------------- +// Rate limit bucket tracking +// --------------------------------------------------------------------------- + +const MAX_BUCKETS: usize = 64; +const BUCKET_ID_LEN: usize = 64; + +const RateBucket = struct { + active: bool = false, + id: [BUCKET_ID_LEN]u8 = undefined, + id_len: usize = 0, + remaining: u32 = 0, + reset_after_ms: u64 = 0, +}; + +var buckets: [MAX_BUCKETS]RateBucket = [_]RateBucket{.{}} ** MAX_BUCKETS; + +// --------------------------------------------------------------------------- +// Session slots (thread-safe, fixed-size pool) +// --------------------------------------------------------------------------- + +const MAX_SESSIONS: usize = 16; +const BUF_SIZE: usize = 4096; + +const SessionSlot = struct { + active: bool = false, + state: ConnState = .disconnected, + token_buf: [BUF_SIZE]u8 = undefined, + token_len: usize = 0, + guild_count: u32 = 0, +}; + +var sessions: [MAX_SESSIONS]SessionSlot = [_]SessionSlot{.{}} ** MAX_SESSIONS; +var mutex: std.Thread.Mutex = .{}; + +// --------------------------------------------------------------------------- +// C-ABI exports: state machine +// --------------------------------------------------------------------------- + +/// Check if a state transition is valid. Returns 1 (valid) or 0 (invalid). +pub export fn discord_mcp_can_transition(from: c_int, to: c_int) c_int { + const f = std.meta.intToEnum(ConnState, from) catch return 0; + const t = std.meta.intToEnum(ConnState, to) catch return 0; + return if (isValidTransition(f, t)) 1 else 0; +} + +/// Open a new session in Disconnected state. Returns slot index (>= 0) or -1. +pub export fn discord_mcp_session_open() c_int { + mutex.lock(); + defer mutex.unlock(); + + for (&sessions, 0..) |*slot, idx| { + if (!slot.active) { + slot.active = true; + slot.state = .disconnected; + slot.token_len = 0; + slot.guild_count = 0; + return @intCast(idx); + } + } + return -1; +} + +/// Close a session. Returns 0 on success, -1 if invalid slot, -2 if bad transition. +pub export fn discord_mcp_session_close(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + var slot = &sessions[idx]; + if (!slot.active) return -1; + + // Can only close from Connected or Error (transition to Disconnected) + if (!isValidTransition(slot.state, .disconnected)) return -2; + + slot.active = false; + slot.state = .disconnected; + slot.token_len = 0; + slot.guild_count = 0; + return 0; +} + +/// Get the current state of a session. Returns state int or -1 if invalid slot. +pub export fn discord_mcp_session_state(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + return @intFromEnum(slot.state); +} + +/// Transition a session to Authenticating state. +/// Returns 0 on success, -1 if invalid slot, -2 if bad transition. +pub export fn discord_mcp_authenticate(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + var slot = &sessions[idx]; + if (!slot.active) return -1; + if (!isValidTransition(slot.state, .authenticating)) return -2; + + slot.state = .authenticating; + return 0; +} + +/// Transition a session to Connected state (auth succeeded). +/// Returns 0 on success, -1 if invalid slot, -2 if bad transition. +pub export fn discord_mcp_connect(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + var slot = &sessions[idx]; + if (!slot.active) return -1; + if (!isValidTransition(slot.state, .connected)) return -2; + + slot.state = .connected; + return 0; +} + +/// Transition a session to RateLimited state. +/// Returns 0 on success, -1 if invalid slot, -2 if bad transition. +pub export fn discord_mcp_rate_limit(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + var slot = &sessions[idx]; + if (!slot.active) return -1; + if (!isValidTransition(slot.state, .rate_limited)) return -2; + + slot.state = .rate_limited; + return 0; +} + +/// Signal an error on a session. Returns 0 on success. +pub export fn discord_mcp_signal_error(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + var slot = &sessions[idx]; + if (!slot.active) return -1; + if (!isValidTransition(slot.state, .err)) return -2; + + slot.state = .err; + return 0; +} + +/// Recover from error (transition to Disconnected). Returns 0 on success. +pub export fn discord_mcp_recover(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + var slot = &sessions[idx]; + if (!slot.active) return -1; + if (!isValidTransition(slot.state, .disconnected)) return -2; + + slot.state = .disconnected; + return 0; +} + +// --------------------------------------------------------------------------- +// C-ABI exports: token validation +// --------------------------------------------------------------------------- + +/// Validate a Discord bot token (basic structural check). +/// Returns 1 if valid, 0 if invalid. Token must be non-empty and < 200 chars. +pub export fn discord_mcp_validate_token(ptr: [*]const u8, len: usize) c_int { + if (len == 0 or len >= 200) return 0; + // Check for NUL bytes or control characters in the token + for (ptr[0..len]) |byte| { + if (byte < 0x20 or byte == 0x7F) return 0; + } + return 1; +} + +/// Check if a Discord action code is valid. Returns 1 if valid, 0 otherwise. +pub export fn discord_mcp_is_valid_action(action: c_int) c_int { + _ = std.meta.intToEnum(DiscordAction, action) catch return 0; + return 1; +} + +/// Get the total number of supported actions. +pub export fn discord_mcp_action_count() c_int { + return 16; +} + +/// Reset all sessions and buckets (test/debug use only). +pub export fn discord_mcp_reset() void { + mutex.lock(); + defer mutex.unlock(); + sessions = [_]SessionSlot{.{}} ** MAX_SESSIONS; + buckets = [_]RateBucket{.{}} ** MAX_BUCKETS; +} + +// --------------------------------------------------------------------------- +// ═══════════════════════════════════════════════════════════════════════ +// Standard ABI (ADR-0005 four symbols + ADR-0006 invoke) +// ═══════════════════════════════════════════════════════════════════════ + +const shim = @import("cartridge_shim.zig"); + +const CARTRIDGE_NAME_PTR: [*:0]const u8 = "discord-mcp"; +const CARTRIDGE_VERSION_PTR: [*:0]const u8 = "0.1.0"; + +export fn boj_cartridge_init() callconv(.c) c_int { + return 0; +} + +export fn boj_cartridge_deinit() callconv(.c) void {} + +export fn boj_cartridge_name() callconv(.c) [*:0]const u8 { + return CARTRIDGE_NAME_PTR; +} + +export fn boj_cartridge_version() callconv(.c) [*:0]const u8 { + return CARTRIDGE_VERSION_PTR; +} + +/// Dispatch the cartridge.json MCP tools. Grade D Alpha stubs. +export fn boj_cartridge_invoke( + tool_name: [*c]const u8, + json_args: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) callconv(.c) i32 { + _ = json_args; + if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; + + const body: []const u8 = if (shim.toolIs(tool_name, "discord_authenticate")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "discord_send_message")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "discord_list_guilds")) + "{\"result\":{\"items\":[],\"count\":0,\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "discord_list_channels")) + "{\"result\":{\"items\":[],\"count\":0,\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "discord_read_messages")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "discord_add_reaction")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "discord_deauthenticate")) + "{\"result\":{\"status\":\"stub\"}}" +else + return shim.RC_UNKNOWN_TOOL; + + return shim.writeResult(out_buf, in_out_len, body); +} + +// Tests +// --------------------------------------------------------------------------- + +test "connection lifecycle" { + discord_mcp_reset(); + + const slot = discord_mcp_session_open(); + try std.testing.expect(slot >= 0); + + // Should be in disconnected state + try std.testing.expectEqual(@as(c_int, 0), discord_mcp_session_state(slot)); + + // Authenticate + try std.testing.expectEqual(@as(c_int, 0), discord_mcp_authenticate(slot)); + try std.testing.expectEqual(@as(c_int, 1), discord_mcp_session_state(slot)); + + // Connect + try std.testing.expectEqual(@as(c_int, 0), discord_mcp_connect(slot)); + try std.testing.expectEqual(@as(c_int, 2), discord_mcp_session_state(slot)); + + // Rate limit and recover + try std.testing.expectEqual(@as(c_int, 0), discord_mcp_rate_limit(slot)); + try std.testing.expectEqual(@as(c_int, 3), discord_mcp_session_state(slot)); + try std.testing.expectEqual(@as(c_int, 0), discord_mcp_connect(slot)); + try std.testing.expectEqual(@as(c_int, 2), discord_mcp_session_state(slot)); + + // Close + try std.testing.expectEqual(@as(c_int, 0), discord_mcp_session_close(slot)); +} + +test "invalid transitions rejected" { + discord_mcp_reset(); + + const slot = discord_mcp_session_open(); + try std.testing.expect(slot >= 0); + + // Can't connect from disconnected (must authenticate first) + try std.testing.expectEqual(@as(c_int, -2), discord_mcp_connect(slot)); + + // Can't rate-limit from disconnected + try std.testing.expectEqual(@as(c_int, -2), discord_mcp_rate_limit(slot)); + + // Authenticate, then can't re-authenticate + try std.testing.expectEqual(@as(c_int, 0), discord_mcp_authenticate(slot)); + try std.testing.expectEqual(@as(c_int, -2), discord_mcp_authenticate(slot)); +} + +test "transition validator" { + try std.testing.expectEqual(@as(c_int, 1), discord_mcp_can_transition(0, 1)); // disconnected -> authenticating + try std.testing.expectEqual(@as(c_int, 1), discord_mcp_can_transition(1, 2)); // authenticating -> connected + try std.testing.expectEqual(@as(c_int, 1), discord_mcp_can_transition(1, 4)); // authenticating -> error + try std.testing.expectEqual(@as(c_int, 1), discord_mcp_can_transition(2, 3)); // connected -> rate_limited + try std.testing.expectEqual(@as(c_int, 1), discord_mcp_can_transition(3, 2)); // rate_limited -> connected + try std.testing.expectEqual(@as(c_int, 1), discord_mcp_can_transition(2, 4)); // connected -> error + try std.testing.expectEqual(@as(c_int, 1), discord_mcp_can_transition(4, 0)); // error -> disconnected + try std.testing.expectEqual(@as(c_int, 1), discord_mcp_can_transition(2, 0)); // connected -> disconnected + + // Invalid + try std.testing.expectEqual(@as(c_int, 0), discord_mcp_can_transition(0, 2)); // disconnected -> connected + try std.testing.expectEqual(@as(c_int, 0), discord_mcp_can_transition(0, 3)); // disconnected -> rate_limited + try std.testing.expectEqual(@as(c_int, 0), discord_mcp_can_transition(4, 2)); // error -> connected + try std.testing.expectEqual(@as(c_int, 0), discord_mcp_can_transition(99, 0)); +} + +test "token validation" { + // Valid token + try std.testing.expectEqual(@as(c_int, 1), discord_mcp_validate_token("abc123".ptr, 6)); + + // Empty token + try std.testing.expectEqual(@as(c_int, 0), discord_mcp_validate_token("".ptr, 0)); + + // Token with control character + try std.testing.expectEqual(@as(c_int, 0), discord_mcp_validate_token("abc\x00def".ptr, 7)); +} + +test "action validation" { + // All 16 actions should be valid + var i: c_int = 0; + while (i < 16) : (i += 1) { + try std.testing.expectEqual(@as(c_int, 1), discord_mcp_is_valid_action(i)); + } + // Out of range + try std.testing.expectEqual(@as(c_int, 0), discord_mcp_is_valid_action(16)); + try std.testing.expectEqual(@as(c_int, 0), discord_mcp_is_valid_action(-1)); + try std.testing.expectEqual(@as(c_int, 16), discord_mcp_action_count()); +} + +test "slot exhaustion" { + discord_mcp_reset(); + + var slots: [MAX_SESSIONS]c_int = undefined; + for (&slots) |*s| { + s.* = discord_mcp_session_open(); + try std.testing.expect(s.* >= 0); + } + + try std.testing.expectEqual(@as(c_int, -1), discord_mcp_session_open()); + + // Bring first slot to Connected so we can close it + try std.testing.expectEqual(@as(c_int, 0), discord_mcp_authenticate(slots[0])); + try std.testing.expectEqual(@as(c_int, 0), discord_mcp_connect(slots[0])); + try std.testing.expectEqual(@as(c_int, 0), discord_mcp_session_close(slots[0])); + const new_slot = discord_mcp_session_open(); + try std.testing.expect(new_slot >= 0); +} + +// ═══════════════════════════════════════════════════════════════════════ +// ADR-0006 invoke dispatch tests +// ═══════════════════════════════════════════════════════════════════════ + +test "boj_cartridge_name returns discord-mcp" { + const n = std.mem.span(boj_cartridge_name()); + try std.testing.expectEqualStrings("discord-mcp", n); +} + +test "boj_cartridge_init returns 0" { + try std.testing.expectEqual(@as(c_int, 0), boj_cartridge_init()); +} + +test "invoke: each declared tool succeeds" { + var buf: [256]u8 = undefined; + const tools = [_][]const u8{ + "discord_authenticate", + "discord_send_message", + "discord_list_guilds", + "discord_list_channels", + "discord_read_messages", + "discord_add_reaction", + "discord_deauthenticate", + }; + for (tools) |t| { + var len: usize = buf.len; + const rc = boj_cartridge_invoke(t.ptr, "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, 0), rc); + try std.testing.expect(std.mem.indexOf(u8, buf[0..len], "result") != null); + } +} + +test "invoke: unknown tool returns -1" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("nope", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -1), rc); +} + +test "invoke: buffer too small returns -3" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("discord_authenticate", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -3), rc); + try std.testing.expect(len > 4); +} diff --git a/cartridges/domains/communications/discord-mcp/minter.toml b/cartridges/domains/communications/discord-mcp/minter.toml new file mode 100644 index 0000000..4336346 --- /dev/null +++ b/cartridges/domains/communications/discord-mcp/minter.toml @@ -0,0 +1,45 @@ +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> + +name = "discord-mcp" +description = "Discord REST API v10 cartridge β€” messages, channels, guilds, members, reactions, threads, search, status, file uploads" +version = "0.1.0" +domain = "Comms" +protocols = [ + "MCP", + "REST", +] +tier = "Ayo" +backend = "universal" +generate_panel = true + +[auth] +type = "bot_token" +header = "Authorization" +prefix = "Bot" +base_url = "https://discord.com/api/v10/" + +[rate_limiting] +strategy = "bucket" +description = "Per-route bucket-based rate limiting via X-RateLimit-Bucket headers" + +[actions] +count = 16 +list = [ + "SendMessage", + "EditMessage", + "DeleteMessage", + "ListChannels", + "GetChannel", + "ListGuilds", + "GetGuild", + "ListMembers", + "GetMember", + "AddReaction", + "RemoveReaction", + "CreateThread", + "ListThreads", + "SearchMessages", + "SetStatus", + "UploadFile", +] diff --git a/cartridges/domains/communications/discord-mcp/mod.js b/cartridges/domains/communications/discord-mcp/mod.js new file mode 100644 index 0000000..18a46d3 --- /dev/null +++ b/cartridges/domains/communications/discord-mcp/mod.js @@ -0,0 +1,74 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// discord-mcp/mod.js -- discord gateway. + +const BASE_URL = Deno.env.get("DISCORD_MCP_BACKEND_URL") ?? "http://127.0.0.1:7729"; + +async function post(path, payload) { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), 15000); + try { + const r = await fetch(`${BASE_URL}${path}`, { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), signal: ctrl.signal, + }); + const data = await r.json().catch(() => ({ success: false, error: "non-JSON response" })); + return { status: r.status, data }; + } catch (e) { + if (e.name === "AbortError") return { status: 504, data: { success: false, error: "discord-mcp backend timed out" } }; + return { status: 503, data: { success: false, error: `discord-mcp backend unavailable: ${e.message}` } }; + } finally { clearTimeout(t); } +} + +export async function handleTool(toolName, args) { + switch (toolName) { + + case "discord_authenticate": { + const { token } = args ?? {}; + if (!token) return { status: 400, data: { error: "token is required" } }; + const payload = { token }; + return post("/api/v1/authenticate", payload); + } + case "discord_send_message": { + const { slot, channel_id, content } = args ?? {}; + if (!slot || !channel_id || !content) return { status: 400, data: { error: "slot is required" } }; + const payload = { slot, channel_id, content }; + return post("/api/v1/send-message", payload); + } + case "discord_list_guilds": { + const { slot } = args ?? {}; + if (!slot) return { status: 400, data: { error: "slot is required" } }; + const payload = { slot }; + return post("/api/v1/list-guilds", payload); + } + case "discord_list_channels": { + const { slot, guild_id } = args ?? {}; + if (!slot || !guild_id) return { status: 400, data: { error: "slot is required" } }; + const payload = { slot, guild_id }; + return post("/api/v1/list-channels", payload); + } + case "discord_read_messages": { + const { slot, channel_id, limit } = args ?? {}; + if (!slot || !channel_id) return { status: 400, data: { error: "slot is required" } }; + const payload = { slot, channel_id }; + if (limit !== undefined) payload.limit = limit; + return post("/api/v1/read-messages", payload); + } + case "discord_add_reaction": { + const { slot, channel_id, message_id, emoji } = args ?? {}; + if (!slot || !channel_id || !message_id || !emoji) return { status: 400, data: { error: "slot is required" } }; + const payload = { slot, channel_id, message_id, emoji }; + return post("/api/v1/add-reaction", payload); + } + case "discord_deauthenticate": { + const { slot } = args ?? {}; + if (!slot) return { status: 400, data: { error: "slot is required" } }; + const payload = { slot }; + return post("/api/v1/deauthenticate", payload); + } + + default: + return { status: 404, data: { error: `Unknown tool: ${toolName}` } }; + } +} diff --git a/cartridges/domains/communications/discord-mcp/panels/manifest.json b/cartridges/domains/communications/discord-mcp/panels/manifest.json new file mode 100644 index 0000000..8ecd23c --- /dev/null +++ b/cartridges/domains/communications/discord-mcp/panels/manifest.json @@ -0,0 +1,74 @@ +{ + "$schema": "https://panll.dev/schemas/panel-manifest/v1.json", + "spdx": "MPL-2.0", + "cartridge": "discord-mcp", + "domain": "Comms", + "version": "0.1.0", + "panels": [ + { + "id": "discord-connection-status", + "title": "Connection Status", + "description": "Current Discord connection state (Disconnected / Authenticating / Connected / RateLimited / Error)", + "type": "status-indicator", + "data_source": { + "endpoint": "/discord/state", + "method": "GET", + "refresh_interval_ms": 5000 + }, + "widgets": [ + { + "type": "state-badge", + "field": "state", + "states": { + "disconnected": { "color": "#95a5a6", "icon": "plug-disconnected" }, + "authenticating": { "color": "#f39c12", "icon": "key" }, + "connected": { "color": "#2ecc71", "icon": "plug-connected" }, + "rate_limited": { "color": "#e67e22", "icon": "clock" }, + "error": { "color": "#e74c3c", "icon": "alert-triangle" } + } + } + ] + }, + { + "id": "discord-guild-count", + "title": "Guild Count", + "description": "Number of Discord guilds (servers) the bot is a member of", + "type": "metric", + "data_source": { + "endpoint": "/discord/metrics", + "method": "GET", + "refresh_interval_ms": 30000 + }, + "widgets": [ + { + "type": "counter", + "field": "guild_count", + "label": "Guilds", + "icon": "server" + } + ] + }, + { + "id": "discord-rate-limit-buckets", + "title": "Rate Limit Buckets", + "description": "Per-route rate limit bucket usage (Discord bucket-based rate limiting)", + "type": "multi-metric", + "data_source": { + "endpoint": "/discord/rates", + "method": "GET", + "refresh_interval_ms": 10000 + }, + "widgets": [ + { + "type": "progress-bar", + "items": [ + { "field": "global_remaining", "max_field": "global_limit", "label": "Global", "color": "#3498db" }, + { "field": "messages_remaining", "max_field": "messages_limit", "label": "Messages", "color": "#2ecc71" }, + { "field": "reactions_remaining", "max_field": "reactions_limit", "label": "Reactions", "color": "#f39c12" }, + { "field": "members_remaining", "max_field": "members_limit", "label": "Members", "color": "#9b59b6" } + ] + } + ] + } + ] +} diff --git a/cartridges/domains/communications/discord-mcp/tests/integration_test.sh b/cartridges/domains/communications/discord-mcp/tests/integration_test.sh new file mode 100755 index 0000000..27712ce --- /dev/null +++ b/cartridges/domains/communications/discord-mcp/tests/integration_test.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Integration tests for discord-mcp cartridge. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CART_DIR="$(dirname "$SCRIPT_DIR")" +FFI_DIR="$CART_DIR/ffi" + +echo "=== discord-mcp integration tests ===" + +# Build FFI +echo "[1/3] Building FFI..." +cd "$FFI_DIR" && zig build 2>&1 + +# Run Zig unit tests +echo "[2/3] Running FFI unit tests..." +cd "$FFI_DIR" && zig build test 2>&1 + +# Validate Idris2 ABI (if idris2 available) +echo "[3/3] Checking ABI..." +if command -v idris2 &>/dev/null; then + cd "$CART_DIR/abi" && idris2 --check DiscordMcp.SafeComms 2>&1 + echo " ABI: OK" +else + echo " ABI: SKIPPED (idris2 not in PATH)" +fi + +echo "" +echo "All tests passed for discord-mcp!" diff --git a/cartridges/domains/communications/matrix-mcp/README.adoc b/cartridges/domains/communications/matrix-mcp/README.adoc new file mode 100644 index 0000000..378e7ce --- /dev/null +++ b/cartridges/domains/communications/matrix-mcp/README.adoc @@ -0,0 +1,90 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> + += matrix-mcp +Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk> +:spdx: MPL-2.0 +:tier: Ayo +:domain: Comms +:protocols: MCP, REST + +== Overview + +Matrix Client-Server API v3 cartridge for BoJ server. Provides type-safe +access to 16 Matrix actions: messages, events, rooms, membership, state, +sync, search, media, profiles, and room creation. Bearer token authentication +against a configurable homeserver URL (default: https://matrix.org). +Transaction ID generation for idempotent PUT requests. + +== Actions + +[cols="1,2"] +|=== +| Action | Description + +| SendMessage | Send a message event to a room +| SendEvent | Send a custom event to a room +| GetRoom | Get room information by ID +| ListRooms | List rooms the user has joined +| JoinRoom | Join a room by ID or alias +| LeaveRoom | Leave a room +| InviteUser | Invite a user to a room +| KickUser | Kick a user from a room +| SetRoomState | Set state in a room (topic, name, etc.) +| GetRoomState | Get current room state +| Sync | Perform an incremental sync (long-poll) +| SearchMessages | Search messages across rooms +| UploadMedia | Upload media to the content repository +| GetProfile | Get a user's profile (display name, avatar) +| SetDisplayName | Set the authenticated user's display name +| CreateRoom | Create a new room +|=== + +== Architecture + +[cols="1,1,2"] +|=== +| Layer | Language | Purpose + +| ABI +| Idris2 +| Formally verified state machine (MatrixMcp.SafeComms) with dependent-type proofs + +| FFI +| Zig +| C-compatible implementation with thread-safe session pool, token validation, transaction ID generation + +| Adapter +| zig +| REST bridge to BoJ unified adapter protocol with all 16 actions +|=== + +== State Machine + +.... +Disconnected -> Authenticating -> Connected <-> Syncing + | | | + v v v + Error <----------+------------+ + | + v + Disconnected +.... + +== Building + +[source,bash] +---- +# Build FFI shared library +cd ffi && zig build + +# Run FFI tests +cd ffi && zig build test + +# Type-check ABI +cd abi && idris2 --check MatrixMcp.SafeComms +---- + +== Status + +Development -- not yet ready for mounting. diff --git a/cartridges/domains/communications/matrix-mcp/abi/MatrixMcp/SafeComms.idr b/cartridges/domains/communications/matrix-mcp/abi/MatrixMcp/SafeComms.idr new file mode 100644 index 0000000..53ebebb --- /dev/null +++ b/cartridges/domains/communications/matrix-mcp/abi/MatrixMcp/SafeComms.idr @@ -0,0 +1,242 @@ +-- SPDX-License-Identifier: MPL-2.0 +-- Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +-- +-- MatrixMcp.SafeComms β€” Type-safe ABI for matrix-mcp cartridge. +-- +-- Dependent-type-proven state machine for Matrix Client-Server API +-- communication. Covers the full Client-Server API v3 surface: messages, +-- events, rooms, membership, state, sync, search, media, profiles, and +-- room creation. Bearer token auth with configurable homeserver URL. +-- Transaction ID generation for idempotent PUT requests. + +module MatrixMcp.SafeComms + +%default total + +-- --------------------------------------------------------------------------- +-- Connection state machine +-- --------------------------------------------------------------------------- + +||| Connection state for Matrix client sessions. +||| Matrix uses Bearer token authentication against a configurable +||| homeserver URL (default: https://matrix.org) via the +||| Client-Server API (/_matrix/client/v3/). +public export +data ConnState + = Disconnected + | Authenticating + | Connected + | Syncing + | Error + +||| Proof that a connection state transition is valid. +public export +data ValidTransition : ConnState -> ConnState -> Type where + StartAuth : ValidTransition Disconnected Authenticating + AuthSuccess : ValidTransition Authenticating Connected + AuthFail : ValidTransition Authenticating Error + BeginSync : ValidTransition Connected Syncing + SyncDone : ValidTransition Syncing Connected + ConnError : ValidTransition Connected Error + SyncError : ValidTransition Syncing Error + Recover : ValidTransition Error Disconnected + Disconnect : ValidTransition Connected Disconnected + +-- --------------------------------------------------------------------------- +-- C-ABI integer encoding for ConnState +-- --------------------------------------------------------------------------- + +||| Encode connection state as C-compatible integer. +export +connStateToInt : ConnState -> Int +connStateToInt Disconnected = 0 +connStateToInt Authenticating = 1 +connStateToInt Connected = 2 +connStateToInt Syncing = 3 +connStateToInt Error = 4 + +||| Decode integer back to connection state. +export +intToConnState : Int -> Maybe ConnState +intToConnState 0 = Just Disconnected +intToConnState 1 = Just Authenticating +intToConnState 2 = Just Connected +intToConnState 3 = Just Syncing +intToConnState 4 = Just Error +intToConnState _ = Nothing + +||| Check if a connection state transition is valid (C-ABI export). +||| Returns 1 for valid, 0 for invalid. +export +matrix_mcp_can_transition : Int -> Int -> Int +matrix_mcp_can_transition from to = + case (intToConnState from, intToConnState to) of + (Just Disconnected, Just Authenticating) => 1 + (Just Authenticating, Just Connected) => 1 + (Just Authenticating, Just Error) => 1 + (Just Connected, Just Syncing) => 1 + (Just Syncing, Just Connected) => 1 + (Just Connected, Just Error) => 1 + (Just Syncing, Just Error) => 1 + (Just Error, Just Disconnected) => 1 + (Just Connected, Just Disconnected) => 1 + _ => 0 + +-- --------------------------------------------------------------------------- +-- Matrix actions +-- --------------------------------------------------------------------------- + +||| Actions available through the Matrix Client-Server API v3. +||| Each action maps to one or more Matrix REST endpoints. +public export +data MatrixAction + = SendMessage + | SendEvent + | GetRoom + | ListRooms + | JoinRoom + | LeaveRoom + | InviteUser + | KickUser + | SetRoomState + | GetRoomState + | Sync + | SearchMessages + | UploadMedia + | GetProfile + | SetDisplayName + | CreateRoom + +||| Total count of supported Matrix actions. +export +actionCount : Nat +actionCount = 16 + +||| Encode a Matrix action as a C-compatible integer. +export +actionToInt : MatrixAction -> Int +actionToInt SendMessage = 0 +actionToInt SendEvent = 1 +actionToInt GetRoom = 2 +actionToInt ListRooms = 3 +actionToInt JoinRoom = 4 +actionToInt LeaveRoom = 5 +actionToInt InviteUser = 6 +actionToInt KickUser = 7 +actionToInt SetRoomState = 8 +actionToInt GetRoomState = 9 +actionToInt Sync = 10 +actionToInt SearchMessages = 11 +actionToInt UploadMedia = 12 +actionToInt GetProfile = 13 +actionToInt SetDisplayName = 14 +actionToInt CreateRoom = 15 + +||| Decode integer back to a Matrix action. +export +intToAction : Int -> Maybe MatrixAction +intToAction 0 = Just SendMessage +intToAction 1 = Just SendEvent +intToAction 2 = Just GetRoom +intToAction 3 = Just ListRooms +intToAction 4 = Just JoinRoom +intToAction 5 = Just LeaveRoom +intToAction 6 = Just InviteUser +intToAction 7 = Just KickUser +intToAction 8 = Just SetRoomState +intToAction 9 = Just GetRoomState +intToAction 10 = Just Sync +intToAction 11 = Just SearchMessages +intToAction 12 = Just UploadMedia +intToAction 13 = Just GetProfile +intToAction 14 = Just SetDisplayName +intToAction 15 = Just CreateRoom +intToAction _ = Nothing + +||| Check whether a given action requires a Connected (or Syncing) state. +||| The Sync action itself triggers the Connected -> Syncing transition. +||| All other actions require Connected state. +export +actionRequiresConnected : MatrixAction -> Bool +actionRequiresConnected _ = True + +-- --------------------------------------------------------------------------- +-- Transaction ID model +-- --------------------------------------------------------------------------- + +||| Matrix uses transaction IDs for idempotent PUT requests. +||| Each event-sending request includes a unique txnId in the URL path. +||| The server deduplicates based on (access_token, txnId) pairs. +public export +data TxnIdConfig : Type where + MkTxnIdConfig : + (prefix : String) -> + (counter : Nat) -> + TxnIdConfig + +||| Create a default transaction ID configuration. +export +defaultTxnIdConfig : TxnIdConfig +defaultTxnIdConfig = MkTxnIdConfig "boj" 0 + +||| Increment the transaction ID counter, returning the new config. +export +nextTxnId : TxnIdConfig -> TxnIdConfig +nextTxnId (MkTxnIdConfig prefix counter) = MkTxnIdConfig prefix (S counter) + +-- --------------------------------------------------------------------------- +-- Auth model +-- --------------------------------------------------------------------------- + +||| Matrix authentication: Bearer token against a configurable homeserver. +||| The Client-Server API lives at /_matrix/client/v3/ on the homeserver. +public export +data AuthConfig : Type where + MkAuthConfig : + (token : String) -> + (homeserver : String) -> + AuthConfig + +||| Default homeserver URL for Matrix. +export +defaultHomeserver : String +defaultHomeserver = "https://matrix.org" + +||| Client-Server API path prefix. +export +apiPrefix : String +apiPrefix = "/_matrix/client/v3/" + +||| Validate that a bearer token is non-empty (basic structural check). +export +validateToken : String -> Bool +validateToken tok = + let len = length tok + in len > 0 && len < 1000 + +-- --------------------------------------------------------------------------- +-- MCP tool declarations +-- --------------------------------------------------------------------------- + +||| Tools exposed via MCP protocol for this cartridge. +public export +data McpTool + = ToolConnect + | ToolDisconnect + | ToolStatus + | ToolInvoke + | ToolList + +||| Check if a tool requires a connected session. +export +toolRequiresSession : McpTool -> Bool +toolRequiresSession ToolConnect = False +toolRequiresSession ToolDisconnect = True +toolRequiresSession ToolStatus = False +toolRequiresSession ToolInvoke = True +toolRequiresSession ToolList = False + +||| Tool count for this cartridge. +export +toolCount : Nat +toolCount = 5 diff --git a/cartridges/domains/communications/matrix-mcp/abi/README.adoc b/cartridges/domains/communications/matrix-mcp/abi/README.adoc new file mode 100644 index 0000000..94a60dc --- /dev/null +++ b/cartridges/domains/communications/matrix-mcp/abi/README.adoc @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += matrix-mcp / abi β€” Idris2 ABI layer +:orientation: shallow + +== Purpose + +Cartridge-local protocol types for `matrix-mcp`. Defines the data, tool, and +transition vocabulary this cartridge speaks. Inherits safety lemmas and the +matrix-cell identifier scheme from the trunk ABI at `src/abi/`. + +== Files + +1 Idris2 module(s), ~242 lines total. Lead module: +`SafeComms.idr`. + +No `.ipkg` β€” consumed directly by wider BoJ callers; type-checks inside the +trunk `boj.ipkg`. + +== Invariants + +* `%default total` at every module top. +* Zero `believe_me`, `assert_total`, `postulate`, `Admitted`, or + `sorry` (the trunk holds the four declared `believe_me` sites; leaf + cartridges carry none). + +== Test/proof surface + +Thin module(s), no inline tests. Proof surface = type-check success under the +trunk `idris2 --check`. + +== Read-first + +. `SafeComms.idr` β€” cartridge-local data records and enums. +. Trunk `src/abi/Boj/Catalogue.idr` for how this cell participates in the + 2D matrix. diff --git a/cartridges/domains/communications/matrix-mcp/abi/matrix_mcp.ipkg b/cartridges/domains/communications/matrix-mcp/abi/matrix_mcp.ipkg new file mode 100644 index 0000000..ed207ec --- /dev/null +++ b/cartridges/domains/communications/matrix-mcp/abi/matrix_mcp.ipkg @@ -0,0 +1,11 @@ +-- SPDX-License-Identifier: MPL-2.0 +-- Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +package matrix_mcp + +version = "0.1.0" +authors = "Jonathan D.A. Jewell" +brief = "Type-safe ABI for matrix-mcp cartridge (Matrix Client-Server API v3)" + +depends = base + +modules = MatrixMcp.SafeComms diff --git a/cartridges/domains/communications/matrix-mcp/adapter/README.adoc b/cartridges/domains/communications/matrix-mcp/adapter/README.adoc new file mode 100644 index 0000000..abcbce3 --- /dev/null +++ b/cartridges/domains/communications/matrix-mcp/adapter/README.adoc @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += matrix-mcp / adapter β€” protocol bridge +:orientation: shallow + +== Purpose + +Exposes this cartridge's FFI over the wire protocols declared in +`cartridge.json`. Dispatches tool invocations to the C-ABI exports at +`../ffi/` and formats responses per protocol. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” depends on the sibling `ffi` target. +| `matrix_mcp_adapter.zig` | Protocol dispatch (112 lines). Tool catalogue matches `cartridge.json`. +| `SIDELINED-*.v.adoc` | zig predecessor(s). Not built β€” zig banned 2026-04-10. +|=== + +== Invariants + +* **Stateless** β€” all state lives behind the FFI, never in the adapter. +* **Response passthrough** β€” whatever the FFI returns goes back to the wire + unmodified (no embellishment, no silent recovery). +* **Cartridge.json is source of truth** for the tool catalogue; drift between + adapter and manifest is a CI failure. + +== Test/proof surface + +No inline tests at the adapter layer β€” protocol routing is exercised by the +trunk seam tests at `ffi/zig/src/seams.zig`. + +== Read-first + +. `matrix_mcp_adapter.zig` β€” dispatch function and per-protocol routers. +. `../cartridge.json` β€” the tool manifest the dispatcher mirrors. diff --git a/cartridges/domains/communications/matrix-mcp/adapter/build.zig b/cartridges/domains/communications/matrix-mcp/adapter/build.zig new file mode 100644 index 0000000..24fd6cc --- /dev/null +++ b/cartridges/domains/communications/matrix-mcp/adapter/build.zig @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// matrix-mcp/adapter/build.zig + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); const optimize = b.standardOptimizeOption(.{}); + const ffi_mod = b.createModule(.{ .root_source_file = b.path("../ffi/matrix_mcp_ffi.zig"), .target = target, .optimize = optimize }); + const adapter = b.addExecutable(.{ .name = "matrix_mcp_adapter", .root_source_file = b.path("matrix_mcp_adapter.zig"), .target = target, .optimize = optimize }); + adapter.root_module.addImport("matrix_mcp_ffi", ffi_mod); b.installArtifact(adapter); + const rs = b.step("run", "Run matrix-mcp adapter"); rs.dependOn(&b.addRunArtifact(adapter).step); + const tests = b.addTest(.{ .root_source_file = b.path("matrix_mcp_adapter.zig"), .target = target, .optimize = optimize }); + tests.root_module.addImport("matrix_mcp_ffi", ffi_mod); + const ts = b.step("test", "Test matrix-mcp adapter"); ts.dependOn(&b.addRunArtifact(tests).step); +} diff --git a/cartridges/domains/communications/matrix-mcp/adapter/matrix_mcp_adapter.zig b/cartridges/domains/communications/matrix-mcp/adapter/matrix_mcp_adapter.zig new file mode 100644 index 0000000..a3fee2c --- /dev/null +++ b/cartridges/domains/communications/matrix-mcp/adapter/matrix_mcp_adapter.zig @@ -0,0 +1,112 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// matrix-mcp/adapter/matrix_mcp_adapter.zig -- Unified three-protocol adapter. +// Replaces banned matrix_mcp_adapter.v (zig, removed 2026-04-12). +// REST:9193 gRPC:9194 GraphQL:9195 +// Tools: matrix_authenticate, matrix_send_message, matrix_list_rooms, matrix_get_messages... + +const std = @import("std"); +const ffi = @import("matrix_mcp_ffi"); + +const REST_PORT: u16 = 9193; +const GRPC_PORT: u16 = 9194; +const GQL_PORT: u16 = 9195; +const MAX_CONN_BUF: usize = 16 * 1024; + +fn okJson(buf: []u8, msg: []const u8) []u8 { + const n = std.fmt.bufPrint(buf, "{\"success\":true,\"message\":\"{s}\"}", .{msg}) catch return buf[0..0]; return n; +} +fn errJson(buf: []u8, msg: []const u8) []u8 { + const n = std.fmt.bufPrint(buf, "{\"success\":false,\"error\":\"{s}\"}", .{msg}) catch return buf[0..0]; return n; +} +fn statusJson(buf: []u8) []u8 { + const n = std.fmt.bufPrint(buf, "{\"success\":true,\"state\":\"ready\",\"service\":\"matrix-mcp\"}", .{}) catch return buf[0..0]; return n; +} + +const Response = struct { status: u16, body: []u8 }; + +fn dispatch(tool: []const u8, body: []const u8, resp: []u8) Response { + _ = body; + if (std.mem.eql(u8, tool, "matrix_authenticate")) return .{ .status = 200, .body = okJson(resp, "matrix_authenticate forwarded") }; + if (std.mem.eql(u8, tool, "matrix_send_message")) return .{ .status = 200, .body = okJson(resp, "matrix_send_message forwarded") }; + if (std.mem.eql(u8, tool, "matrix_list_rooms")) return .{ .status = 200, .body = okJson(resp, "matrix_list_rooms forwarded") }; + if (std.mem.eql(u8, tool, "matrix_get_messages")) return .{ .status = 200, .body = okJson(resp, "matrix_get_messages forwarded") }; + if (std.mem.eql(u8, tool, "matrix_join_room")) return .{ .status = 200, .body = okJson(resp, "matrix_join_room forwarded") }; + if (std.mem.eql(u8, tool, "matrix_leave_room")) return .{ .status = 200, .body = okJson(resp, "matrix_leave_room forwarded") }; + if (std.mem.eql(u8, tool, "matrix_deauthenticate")) return .{ .status = 200, .body = okJson(resp, "matrix_deauthenticate forwarded") }; + if (std.mem.eql(u8, tool, "status") or std.mem.eql(u8, tool, "health")) return .{ .status = 200, .body = statusJson(resp) }; + return .{ .status = 404, .body = errJson(resp, "Unknown tool") }; +} + +fn dispatchRest(path: []const u8, body: []const u8, resp: []u8) Response { + if (std.mem.startsWith(u8, path, "/tools/")) return dispatch(path["/tools/".len..], body, resp); + if (std.mem.eql(u8, path, "/status") or std.mem.eql(u8, path, "/health")) return .{ .status = 200, .body = statusJson(resp) }; + return .{ .status = 404, .body = errJson(resp, "Not found") }; +} + +fn dispatchGrpc(path: []const u8, body: []const u8, resp: []u8) Response { + const prefix = "/MatrixMcpservice/"; + if (!std.mem.startsWith(u8, path, prefix)) return .{ .status = 404, .body = errJson(resp, "Not a recognized gRPC path") }; + const method = path[prefix.len..]; + const tool = blk: { + if (std.mem.eql(u8, method, "matrix_authenticate")) break :blk "matrix_authenticate"; + if (std.mem.eql(u8, method, "matrix_send_message")) break :blk "matrix_send_message"; + if (std.mem.eql(u8, method, "matrix_list_rooms")) break :blk "matrix_list_rooms"; + if (std.mem.eql(u8, method, "matrix_get_messages")) break :blk "matrix_get_messages"; + if (std.mem.eql(u8, method, "matrix_join_room")) break :blk "matrix_join_room"; + if (std.mem.eql(u8, method, "matrix_leave_room")) break :blk "matrix_leave_room"; + if (std.mem.eql(u8, method, "matrix_deauthenticate")) break :blk "matrix_deauthenticate"; + return .{ .status = 404, .body = errJson(resp, "Unknown gRPC method") }; + }; + return dispatch(tool, body, resp); +} + +fn dispatchGraphql(body: []const u8, resp: []u8) Response { + if (std.mem.indexOf(u8, body, "__schema") != null) return .{ .status = 200, .body = okJson(resp, "schema not supported") }; + if (std.mem.indexOf(u8, body, "authenticate") != null) return dispatch("matrix_authenticate", body, resp); + if (std.mem.indexOf(u8, body, "send_message") != null) return dispatch("matrix_send_message", body, resp); + if (std.mem.indexOf(u8, body, "list_rooms") != null) return dispatch("matrix_list_rooms", body, resp); + if (std.mem.indexOf(u8, body, "get_messages") != null) return dispatch("matrix_get_messages", body, resp); + if (std.mem.indexOf(u8, body, "join_room") != null) return dispatch("matrix_join_room", body, resp); + if (std.mem.indexOf(u8, body, "leave_room") != null) return dispatch("matrix_leave_room", body, resp); + if (std.mem.indexOf(u8, body, "deauthenticate") != null) return dispatch("matrix_deauthenticate", body, resp); + return .{ .status = 200, .body = errJson(resp, "Unrecognised GraphQL operation") }; +} + +const Protocol = enum { rest, grpc, graphql }; + +fn handleConnection(conn: std.net.Server.Connection, proto: Protocol) void { + defer conn.stream.close(); + var in_buf: [MAX_CONN_BUF]u8 = undefined; + const n = conn.stream.read(&in_buf) catch return; + const req = in_buf[0..n]; + var path: []const u8 = "/"; var body: []const u8 = ""; + if (n > 4) { + const le = std.mem.indexOf(u8, req, "\r\n") orelse req.len; + const fl = req[0..le]; const sp1 = std.mem.indexOfScalar(u8, fl, ' ') orelse 0; + const ro = fl[sp1+1..]; const sp2 = std.mem.indexOfScalar(u8, ro, ' ') orelse ro.len; + path = ro[0..sp2]; + const bs = std.mem.indexOf(u8, req, "\r\n\r\n") orelse n; body = req[@min(bs+4,n)..]; + } + var rb: [MAX_CONN_BUF]u8 = undefined; + const result = switch (proto) { .rest => dispatchRest(path, body, &rb), .grpc => dispatchGrpc(path, body, &rb), .graphql => dispatchGraphql(body, &rb) }; + const ct: []const u8 = if (proto == .grpc) "application/grpc+json" else "application/json"; + var hb: [256]u8 = undefined; + const hdr = std.fmt.bufPrint(&hb, "HTTP/1.1 {d} OK\r\nContent-Type: {s}\r\nContent-Length: {d}\r\nConnection: close\r\n\r\n", .{ result.status, ct, result.body.len }) catch return; + _ = conn.stream.writeAll(hdr) catch return; _ = conn.stream.writeAll(result.body) catch return; +} + +fn listenLoop(port: u16, proto: Protocol) void { + const addr = std.net.Address.initIp4(.{ 127, 0, 0, 1 }, port) catch return; + var srv = addr.listen(.{ .reuse_address = true }) catch return; defer srv.deinit(); + while (true) { const conn = srv.accept() catch continue; handleConnection(conn, proto); } +} + +pub fn main() !void { + ffi.matrix_mcp_init(); + const t1 = try std.Thread.spawn(.{}, listenLoop, .{ REST_PORT, Protocol.rest }); + const t2 = try std.Thread.spawn(.{}, listenLoop, .{ GRPC_PORT, Protocol.grpc }); + const t3 = try std.Thread.spawn(.{}, listenLoop, .{ GQL_PORT, Protocol.graphql }); + t1.join(); t2.join(); t3.join(); +} diff --git a/cartridges/domains/communications/matrix-mcp/benchmarks/quick-bench.sh b/cartridges/domains/communications/matrix-mcp/benchmarks/quick-bench.sh new file mode 100755 index 0000000..bc0d2ba --- /dev/null +++ b/cartridges/domains/communications/matrix-mcp/benchmarks/quick-bench.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Benchmarks for matrix-mcp cartridge. +set -euo pipefail + +echo "=== matrix-mcp benchmarks ===" + +cd "$(dirname "${BASH_SOURCE[0]}")/../ffi" + +# Build with ReleaseFast for benchmarking +zig build -Doptimize=ReleaseFast 2>&1 + +echo "Session open/close cycle (1000 iterations):" +time for i in $(seq 1 1000); do + # This would call the FFI benchmark binary + true +done + +echo "" +echo "Benchmark placeholder β€” implement real benchmarks in Zig test blocks" +echo "or via the zig adapter HTTP benchmark tool." diff --git a/cartridges/domains/communications/matrix-mcp/cartridge.json b/cartridges/domains/communications/matrix-mcp/cartridge.json new file mode 100644 index 0000000..7fb77de --- /dev/null +++ b/cartridges/domains/communications/matrix-mcp/cartridge.json @@ -0,0 +1,186 @@ +{ + "$schema": "https://boj.dev/schemas/cartridge/v1.json", + "spdx": "MPL-2.0", + "copyright": "Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath)", + "name": "matrix-mcp", + "version": "0.1.0", + "description": "Matrix homeserver gateway. Send messages, join/leave rooms, read room history, and manage membership.", + "domain": "Communications", + "tier": "Ayo", + "protocols": [ + "MCP", + "REST" + ], + "auth": { + "method": "none", + "env_var": null, + "credential_source": null + }, + "api": { + "base_url": "local://matrix-mcp", + "content_type": "application/json" + }, + "tools": [ + { + "name": "matrix_authenticate", + "description": "Authenticate with a Matrix homeserver.", + "inputSchema": { + "type": "object", + "properties": { + "homeserver": { + "type": "string", + "description": "Matrix homeserver URL (https://matrix.example.org)" + }, + "access_token": { + "type": "string", + "description": "Matrix access token" + } + }, + "required": [ + "homeserver", + "access_token" + ] + } + }, + { + "name": "matrix_send_message", + "description": "Send a message to a Matrix room.", + "inputSchema": { + "type": "object", + "properties": { + "slot": { + "type": "integer", + "description": "Session slot from matrix_authenticate" + }, + "room_id": { + "type": "string", + "description": "Matrix room ID (!abc:example.org)" + }, + "body": { + "type": "string", + "description": "Message body" + }, + "msgtype": { + "type": "string", + "description": "Message type: m.text (default) or m.notice" + } + }, + "required": [ + "slot", + "room_id", + "body" + ] + } + }, + { + "name": "matrix_list_rooms", + "description": "List rooms the user is a member of.", + "inputSchema": { + "type": "object", + "properties": { + "slot": { + "type": "integer", + "description": "Session slot" + } + }, + "required": [ + "slot" + ] + } + }, + { + "name": "matrix_get_messages", + "description": "Get recent messages from a room.", + "inputSchema": { + "type": "object", + "properties": { + "slot": { + "type": "integer", + "description": "Session slot" + }, + "room_id": { + "type": "string", + "description": "Room ID" + }, + "limit": { + "type": "integer", + "description": "Number of messages (default: 50)" + } + }, + "required": [ + "slot", + "room_id" + ] + } + }, + { + "name": "matrix_join_room", + "description": "Join a Matrix room by ID or alias.", + "inputSchema": { + "type": "object", + "properties": { + "slot": { + "type": "integer", + "description": "Session slot" + }, + "room_id": { + "type": "string", + "description": "Room ID or alias" + } + }, + "required": [ + "slot", + "room_id" + ] + } + }, + { + "name": "matrix_leave_room", + "description": "Leave a Matrix room.", + "inputSchema": { + "type": "object", + "properties": { + "slot": { + "type": "integer", + "description": "Session slot" + }, + "room_id": { + "type": "string", + "description": "Room ID" + } + }, + "required": [ + "slot", + "room_id" + ] + } + }, + { + "name": "matrix_deauthenticate", + "description": "Release a Matrix session slot.", + "inputSchema": { + "type": "object", + "properties": { + "slot": { + "type": "integer", + "description": "Session slot to release" + } + }, + "required": [ + "slot" + ] + } + } + ], + "ffi": { + "so_path": "ffi/zig-out/lib/libmatrix_mcp.so", + "abi_version": "ADR-0006", + "symbols": [ + "boj_cartridge_init", + "boj_cartridge_deinit", + "boj_cartridge_name", + "boj_cartridge_version", + "boj_cartridge_invoke" + ] + } +} diff --git a/cartridges/domains/communications/matrix-mcp/ffi/README.adoc b/cartridges/domains/communications/matrix-mcp/ffi/README.adoc new file mode 100644 index 0000000..1278be7 --- /dev/null +++ b/cartridges/domains/communications/matrix-mcp/ffi/README.adoc @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += matrix-mcp / ffi β€” Zig FFI layer +:orientation: shallow + +== Purpose + +C-ABI exports bridging this cartridge's Idris2 ABI to the runtime. Consumed by +the adapter for protocol dispatch and by the trunk loader for mount. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” produces `zig-out/lib/libmatrix.so`. +| `matrix_mcp_ffi.zig` | C-ABI exports (15 exports, 7 inline tests, 391 lines). +|=== + +== Invariants + +* **Null-pointer rejection** on `[*c]const u8` arguments before any + dereference. +* **Negative return values** carry documented error meanings; zero is + success (matching the trunk error-code convention). +* **No allocator leaks** β€” every allocation path pairs with `defer free`. + +== Test/proof surface + +7 inline `test "..."` block(s) in `matrix_mcp_ffi.zig`. Run with +`cd ffi && zig build test`. + +== Read-first + +. `matrix_mcp_ffi.zig` β€” C-ABI signatures and guards. +. Trunk `ffi/zig/src/catalogue.zig` for how this `.so` is loaded. diff --git a/cartridges/domains/communications/matrix-mcp/ffi/build.zig b/cartridges/domains/communications/matrix-mcp/ffi/build.zig new file mode 100644 index 0000000..d6ee15b --- /dev/null +++ b/cartridges/domains/communications/matrix-mcp/ffi/build.zig @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + // Module + // Shared ADR-0006 invoke-shim module (relative path up to boj-server trunk). + + const shim_mod = b.addModule("cartridge_shim", .{ + + .root_source_file = b.path("../../../ffi/zig/src/cartridge_shim.zig"), + + .target = target, + + .optimize = optimize, + + }); + + const ffi_mod = b.addModule("matrix_mcp", .{ + .root_source_file = b.path("matrix_mcp_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + ffi_mod.addImport("cartridge_shim", shim_mod); + + // Shared library + const lib = b.addLibrary(.{ + .name = "matrix_mcp", + .root_module = ffi_mod, + .linkage = .dynamic, + }); + lib.linkLibC(); + b.installArtifact(lib); + + // Tests + const tests = b.addTest(.{ + .root_module = ffi_mod, + }); + tests.linkLibC(); + + const run_tests = b.addRunArtifact(tests); + const test_step = b.step("test", "Run FFI tests"); + test_step.dependOn(&run_tests.step); +} diff --git a/cartridges/domains/communications/matrix-mcp/ffi/cartridge_shim.zig b/cartridges/domains/communications/matrix-mcp/ffi/cartridge_shim.zig new file mode 100644 index 0000000..0e399db --- /dev/null +++ b/cartridges/domains/communications/matrix-mcp/ffi/cartridge_shim.zig @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// cartridge_shim.zig β€” Shared helpers for the ADR-0006 five-symbol +// cartridge ABI (`boj_cartridge_init / deinit / name / version / invoke`). +// +// The shim centralises the seven-code return convention, NUL-argument +// guards, tool-name comparison, and the buffer-too-small path so each +// cartridge's `boj_cartridge_invoke` can stay short β€” typically a tool +// table plus `shim.writeResult(...)`. +// +// Cartridges import this file by relative path (no build-graph change +// needed). Example: +// +// const shim = @import("../../../ffi/zig/src/cartridge_shim.zig"); +// +// export fn boj_cartridge_invoke( +// tool_name: [*c]const u8, +// json_args: [*c]const u8, +// out_buf: [*c]u8, +// in_out_len: [*c]usize, +// ) callconv(.c) i32 { +// _ = json_args; +// if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; +// const body = if (shim.toolIs(tool_name, "foo")) "{\"result\":{}}" +// else return shim.RC_UNKNOWN_TOOL; +// return shim.writeResult(out_buf, in_out_len, body); +// } + +const std = @import("std"); + +// ── Return codes (ADR-0006 Β§Return codes) ──────────────────────────── +// +// Frozen by ADR-0006. New failure modes compose these via the error +// JSON body β€” the integer surface does not grow without a follow-up ADR. + +pub const RC_SUCCESS: i32 = 0; +pub const RC_UNKNOWN_TOOL: i32 = -1; +pub const RC_BAD_ARGS: i32 = -2; +pub const RC_BUFFER_TOO_SMALL: i32 = -3; +pub const RC_RUNTIME_ERROR: i32 = -4; +pub const RC_PANIC: i32 = -5; +pub const RC_AUTH_DENIED: i32 = -6; + +// ── Invoke-path helpers ────────────────────────────────────────────── + +/// True if any of the three mandatory `boj_cartridge_invoke` output-path +/// pointers is null. Use at the top of every invoke to short-circuit to +/// `RC_BAD_ARGS`. +pub fn invokeArgsNull( + tool_name: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) bool { + return tool_name == null or out_buf == null or in_out_len == null; +} + +/// Compare a C-NUL-terminated tool-name pointer against a Zig string +/// literal. Caller must have already verified `tool_name` is non-null +/// (usually via `invokeArgsNull`). +pub fn toolIs(tool_name: [*c]const u8, expected: []const u8) bool { + const s = std.mem.span(@as([*:0]const u8, @ptrCast(tool_name))); + return std.mem.eql(u8, s, expected); +} + +/// Copy `body` into `out_buf[0..*in_out_len]` (as a capacity) and update +/// `*in_out_len` to the number of bytes written. Returns `RC_SUCCESS`. +/// +/// If `body.len` exceeds the current capacity stored in `*in_out_len`, +/// sets `*in_out_len` to the required size and returns +/// `RC_BUFFER_TOO_SMALL` β€” the caller is then expected to re-allocate +/// and retry, per ADR-0006 Β§Memory ownership. +/// +/// Caller must have already verified that `out_buf` and `in_out_len` +/// are non-null. +pub fn writeResult( + out_buf: [*c]u8, + in_out_len: [*c]usize, + body: []const u8, +) i32 { + const cap = in_out_len.*; + if (body.len > cap) { + in_out_len.* = body.len; + return RC_BUFFER_TOO_SMALL; + } + @memcpy(out_buf[0..body.len], body); + in_out_len.* = body.len; + return RC_SUCCESS; +} + +// ── Tests ──────────────────────────────────────────────────────────── + +test "writeResult: body fits, writes and sets length" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); + try std.testing.expectEqualStrings("hello", buf[0..len]); +} + +test "writeResult: too small returns -3 and sets required length" { + var buf: [2]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_BUFFER_TOO_SMALL, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: exact-fit succeeds" { + var buf: [5]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: empty body" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, ""); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 0), len); +} + +test "toolIs: matches and rejects" { + const name: [*:0]const u8 = "foo"; + try std.testing.expect(toolIs(@ptrCast(name), "foo")); + try std.testing.expect(!toolIs(@ptrCast(name), "bar")); + try std.testing.expect(!toolIs(@ptrCast(name), "foobar")); + try std.testing.expect(!toolIs(@ptrCast(name), "fo")); +} + +test "invokeArgsNull: detects each null slot" { + var buf: [4]u8 = undefined; + var len: usize = 4; + const name: [*:0]const u8 = "x"; + try std.testing.expect(!invokeArgsNull(@ptrCast(name), &buf, &len)); + try std.testing.expect(invokeArgsNull(null, &buf, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), null, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), &buf, null)); +} diff --git a/cartridges/domains/communications/matrix-mcp/ffi/matrix_mcp_ffi.zig b/cartridges/domains/communications/matrix-mcp/ffi/matrix_mcp_ffi.zig new file mode 100644 index 0000000..19caf2f --- /dev/null +++ b/cartridges/domains/communications/matrix-mcp/ffi/matrix_mcp_ffi.zig @@ -0,0 +1,491 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// matrix_mcp_ffi.zig β€” C-ABI FFI implementation for matrix-mcp cartridge. +// +// Implements the connection state machine and Matrix action dispatch defined +// in the Idris2 ABI layer (MatrixMcp.SafeComms). Thread-safe via +// std.Thread.Mutex. Bearer token auth with configurable homeserver URL. +// Transaction ID generation for idempotent PUT requests. + +const std = @import("std"); + +// --------------------------------------------------------------------------- +// Connection state machine (matches Idris2 ABI: MatrixMcp.SafeComms) +// --------------------------------------------------------------------------- + +/// Connection state for Matrix client sessions. +/// Disconnected(0) | Authenticating(1) | Connected(2) | Syncing(3) | Error(4) +pub const ConnState = enum(c_int) { + disconnected = 0, + authenticating = 1, + connected = 2, + syncing = 3, + err = 4, +}; + +/// Check whether a state transition is permitted by the state machine. +fn isValidTransition(from: ConnState, to: ConnState) bool { + return switch (from) { + .disconnected => to == .authenticating, + .authenticating => to == .connected or to == .err, + .connected => to == .syncing or to == .err or to == .disconnected, + .syncing => to == .connected or to == .err, + .err => to == .disconnected, + }; +} + +// --------------------------------------------------------------------------- +// Matrix action codes (matches Idris2 ABI: MatrixAction) +// --------------------------------------------------------------------------- + +/// All 16 Matrix actions supported by this cartridge. +pub const MatrixAction = enum(c_int) { + send_message = 0, + send_event = 1, + get_room = 2, + list_rooms = 3, + join_room = 4, + leave_room = 5, + invite_user = 6, + kick_user = 7, + set_room_state = 8, + get_room_state = 9, + sync = 10, + search_messages = 11, + upload_media = 12, + get_profile = 13, + set_display_name = 14, + create_room = 15, +}; + +// --------------------------------------------------------------------------- +// Transaction ID generation +// --------------------------------------------------------------------------- + +const TXN_PREFIX_LEN: usize = 32; + +var txn_counter: u64 = 0; + +// --------------------------------------------------------------------------- +// Session slots (thread-safe, fixed-size pool) +// --------------------------------------------------------------------------- + +const MAX_SESSIONS: usize = 16; +const BUF_SIZE: usize = 4096; +const HOMESERVER_BUF_SIZE: usize = 512; + +const SessionSlot = struct { + active: bool = false, + state: ConnState = .disconnected, + token_buf: [BUF_SIZE]u8 = undefined, + token_len: usize = 0, + homeserver_buf: [HOMESERVER_BUF_SIZE]u8 = undefined, + homeserver_len: usize = 0, + room_count: u32 = 0, + local_txn_counter: u64 = 0, +}; + +var sessions: [MAX_SESSIONS]SessionSlot = [_]SessionSlot{.{}} ** MAX_SESSIONS; +var mutex: std.Thread.Mutex = .{}; + +// --------------------------------------------------------------------------- +// C-ABI exports: state machine +// --------------------------------------------------------------------------- + +/// Check if a state transition is valid. Returns 1 (valid) or 0 (invalid). +pub export fn matrix_mcp_can_transition(from: c_int, to: c_int) c_int { + const f = std.meta.intToEnum(ConnState, from) catch return 0; + const t = std.meta.intToEnum(ConnState, to) catch return 0; + return if (isValidTransition(f, t)) 1 else 0; +} + +/// Open a new session in Disconnected state. Returns slot index (>= 0) or -1. +pub export fn matrix_mcp_session_open() c_int { + mutex.lock(); + defer mutex.unlock(); + + for (&sessions, 0..) |*slot, idx| { + if (!slot.active) { + slot.active = true; + slot.state = .disconnected; + slot.token_len = 0; + slot.homeserver_len = 0; + slot.room_count = 0; + slot.local_txn_counter = 0; + return @intCast(idx); + } + } + return -1; +} + +/// Close a session. Returns 0 on success, -1 if invalid slot, -2 if bad transition. +pub export fn matrix_mcp_session_close(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + var slot = &sessions[idx]; + if (!slot.active) return -1; + if (!isValidTransition(slot.state, .disconnected)) return -2; + + slot.active = false; + slot.state = .disconnected; + slot.token_len = 0; + slot.homeserver_len = 0; + slot.room_count = 0; + slot.local_txn_counter = 0; + return 0; +} + +/// Get the current state of a session. Returns state int or -1 if invalid slot. +pub export fn matrix_mcp_session_state(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + return @intFromEnum(slot.state); +} + +/// Transition a session to Authenticating state. +pub export fn matrix_mcp_authenticate(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + var slot = &sessions[idx]; + if (!slot.active) return -1; + if (!isValidTransition(slot.state, .authenticating)) return -2; + + slot.state = .authenticating; + return 0; +} + +/// Transition a session to Connected state (auth succeeded). +pub export fn matrix_mcp_connect(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + var slot = &sessions[idx]; + if (!slot.active) return -1; + if (!isValidTransition(slot.state, .connected)) return -2; + + slot.state = .connected; + return 0; +} + +/// Transition a session to Syncing state. +pub export fn matrix_mcp_begin_sync(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + var slot = &sessions[idx]; + if (!slot.active) return -1; + if (!isValidTransition(slot.state, .syncing)) return -2; + + slot.state = .syncing; + return 0; +} + +/// Transition a session from Syncing back to Connected. +pub export fn matrix_mcp_end_sync(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + var slot = &sessions[idx]; + if (!slot.active) return -1; + if (!isValidTransition(slot.state, .connected)) return -2; + + slot.state = .connected; + return 0; +} + +/// Signal an error on a session. +pub export fn matrix_mcp_signal_error(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + var slot = &sessions[idx]; + if (!slot.active) return -1; + if (!isValidTransition(slot.state, .err)) return -2; + + slot.state = .err; + return 0; +} + +/// Recover from error (transition to Disconnected). +pub export fn matrix_mcp_recover(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + var slot = &sessions[idx]; + if (!slot.active) return -1; + if (!isValidTransition(slot.state, .disconnected)) return -2; + + slot.state = .disconnected; + return 0; +} + +// --------------------------------------------------------------------------- +// C-ABI exports: token, homeserver, actions, txn IDs +// --------------------------------------------------------------------------- + +/// Validate a Matrix bearer token (basic structural check). +/// Must be non-empty and < 1000 chars with no control characters. +pub export fn matrix_mcp_validate_token(ptr: [*]const u8, len: usize) c_int { + if (len == 0 or len >= 1000) return 0; + for (ptr[0..len]) |byte| { + if (byte < 0x20 or byte == 0x7F) return 0; + } + return 1; +} + +/// Check if a Matrix action code is valid. Returns 1 if valid, 0 otherwise. +pub export fn matrix_mcp_is_valid_action(action: c_int) c_int { + _ = std.meta.intToEnum(MatrixAction, action) catch return 0; + return 1; +} + +/// Get the total number of supported actions. +pub export fn matrix_mcp_action_count() c_int { + return 16; +} + +/// Generate the next transaction ID. Returns a monotonically increasing counter. +/// The caller should combine this with a prefix to form the full txnId string. +pub export fn matrix_mcp_next_txn_id() u64 { + mutex.lock(); + defer mutex.unlock(); + txn_counter += 1; + return txn_counter; +} + +/// Reset all sessions and the transaction counter (test/debug use only). +pub export fn matrix_mcp_reset() void { + mutex.lock(); + defer mutex.unlock(); + sessions = [_]SessionSlot{.{}} ** MAX_SESSIONS; + txn_counter = 0; +} + +// --------------------------------------------------------------------------- +// ═══════════════════════════════════════════════════════════════════════ +// Standard ABI (ADR-0005 four symbols + ADR-0006 invoke) +// ═══════════════════════════════════════════════════════════════════════ + +const shim = @import("cartridge_shim.zig"); + +const CARTRIDGE_NAME_PTR: [*:0]const u8 = "matrix-mcp"; +const CARTRIDGE_VERSION_PTR: [*:0]const u8 = "0.1.0"; + +export fn boj_cartridge_init() callconv(.c) c_int { + return 0; +} + +export fn boj_cartridge_deinit() callconv(.c) void {} + +export fn boj_cartridge_name() callconv(.c) [*:0]const u8 { + return CARTRIDGE_NAME_PTR; +} + +export fn boj_cartridge_version() callconv(.c) [*:0]const u8 { + return CARTRIDGE_VERSION_PTR; +} + +/// Dispatch the cartridge.json MCP tools. Grade D Alpha stubs. +export fn boj_cartridge_invoke( + tool_name: [*c]const u8, + json_args: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) callconv(.c) i32 { + _ = json_args; + if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; + + const body: []const u8 = if (shim.toolIs(tool_name, "matrix_authenticate")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "matrix_send_message")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "matrix_list_rooms")) + "{\"result\":{\"items\":[],\"count\":0,\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "matrix_get_messages")) + "{\"result\":{\"metadata\":{},\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "matrix_join_room")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "matrix_leave_room")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "matrix_deauthenticate")) + "{\"result\":{\"status\":\"stub\"}}" +else + return shim.RC_UNKNOWN_TOOL; + + return shim.writeResult(out_buf, in_out_len, body); +} + +// Tests +// --------------------------------------------------------------------------- + +test "connection lifecycle" { + matrix_mcp_reset(); + + const slot = matrix_mcp_session_open(); + try std.testing.expect(slot >= 0); + + // Should be disconnected + try std.testing.expectEqual(@as(c_int, 0), matrix_mcp_session_state(slot)); + + // Authenticate + try std.testing.expectEqual(@as(c_int, 0), matrix_mcp_authenticate(slot)); + try std.testing.expectEqual(@as(c_int, 1), matrix_mcp_session_state(slot)); + + // Connect + try std.testing.expectEqual(@as(c_int, 0), matrix_mcp_connect(slot)); + try std.testing.expectEqual(@as(c_int, 2), matrix_mcp_session_state(slot)); + + // Sync and return + try std.testing.expectEqual(@as(c_int, 0), matrix_mcp_begin_sync(slot)); + try std.testing.expectEqual(@as(c_int, 3), matrix_mcp_session_state(slot)); + try std.testing.expectEqual(@as(c_int, 0), matrix_mcp_end_sync(slot)); + try std.testing.expectEqual(@as(c_int, 2), matrix_mcp_session_state(slot)); + + // Close + try std.testing.expectEqual(@as(c_int, 0), matrix_mcp_session_close(slot)); +} + +test "invalid transitions rejected" { + matrix_mcp_reset(); + + const slot = matrix_mcp_session_open(); + try std.testing.expect(slot >= 0); + + // Can't connect from disconnected + try std.testing.expectEqual(@as(c_int, -2), matrix_mcp_connect(slot)); + + // Can't sync from disconnected + try std.testing.expectEqual(@as(c_int, -2), matrix_mcp_begin_sync(slot)); + + // Authenticate, then can't sync + try std.testing.expectEqual(@as(c_int, 0), matrix_mcp_authenticate(slot)); + try std.testing.expectEqual(@as(c_int, -2), matrix_mcp_begin_sync(slot)); +} + +test "transition validator" { + try std.testing.expectEqual(@as(c_int, 1), matrix_mcp_can_transition(0, 1)); // disconnected -> authenticating + try std.testing.expectEqual(@as(c_int, 1), matrix_mcp_can_transition(1, 2)); // authenticating -> connected + try std.testing.expectEqual(@as(c_int, 1), matrix_mcp_can_transition(1, 4)); // authenticating -> error + try std.testing.expectEqual(@as(c_int, 1), matrix_mcp_can_transition(2, 3)); // connected -> syncing + try std.testing.expectEqual(@as(c_int, 1), matrix_mcp_can_transition(3, 2)); // syncing -> connected + try std.testing.expectEqual(@as(c_int, 1), matrix_mcp_can_transition(2, 4)); // connected -> error + try std.testing.expectEqual(@as(c_int, 1), matrix_mcp_can_transition(3, 4)); // syncing -> error + try std.testing.expectEqual(@as(c_int, 1), matrix_mcp_can_transition(4, 0)); // error -> disconnected + try std.testing.expectEqual(@as(c_int, 1), matrix_mcp_can_transition(2, 0)); // connected -> disconnected + + // Invalid + try std.testing.expectEqual(@as(c_int, 0), matrix_mcp_can_transition(0, 2)); // disconnected -> connected + try std.testing.expectEqual(@as(c_int, 0), matrix_mcp_can_transition(0, 3)); // disconnected -> syncing + try std.testing.expectEqual(@as(c_int, 0), matrix_mcp_can_transition(4, 2)); // error -> connected + try std.testing.expectEqual(@as(c_int, 0), matrix_mcp_can_transition(99, 0)); +} + +test "token validation" { + try std.testing.expectEqual(@as(c_int, 1), matrix_mcp_validate_token("syt_abc_123".ptr, 11)); + try std.testing.expectEqual(@as(c_int, 0), matrix_mcp_validate_token("".ptr, 0)); + try std.testing.expectEqual(@as(c_int, 0), matrix_mcp_validate_token("abc\x00def".ptr, 7)); +} + +test "action validation" { + var i: c_int = 0; + while (i < 16) : (i += 1) { + try std.testing.expectEqual(@as(c_int, 1), matrix_mcp_is_valid_action(i)); + } + try std.testing.expectEqual(@as(c_int, 0), matrix_mcp_is_valid_action(16)); + try std.testing.expectEqual(@as(c_int, 0), matrix_mcp_is_valid_action(-1)); + try std.testing.expectEqual(@as(c_int, 16), matrix_mcp_action_count()); +} + +test "transaction ID generation" { + matrix_mcp_reset(); + const id1 = matrix_mcp_next_txn_id(); + const id2 = matrix_mcp_next_txn_id(); + try std.testing.expect(id2 > id1); +} + +test "slot exhaustion" { + matrix_mcp_reset(); + + var slots: [MAX_SESSIONS]c_int = undefined; + for (&slots) |*s| { + s.* = matrix_mcp_session_open(); + try std.testing.expect(s.* >= 0); + } + + try std.testing.expectEqual(@as(c_int, -1), matrix_mcp_session_open()); + + try std.testing.expectEqual(@as(c_int, 0), matrix_mcp_authenticate(slots[0])); + try std.testing.expectEqual(@as(c_int, 0), matrix_mcp_connect(slots[0])); + try std.testing.expectEqual(@as(c_int, 0), matrix_mcp_session_close(slots[0])); + const new_slot = matrix_mcp_session_open(); + try std.testing.expect(new_slot >= 0); +} + +// ═══════════════════════════════════════════════════════════════════════ +// ADR-0006 invoke dispatch tests +// ═══════════════════════════════════════════════════════════════════════ + +test "boj_cartridge_name returns matrix-mcp" { + const n = std.mem.span(boj_cartridge_name()); + try std.testing.expectEqualStrings("matrix-mcp", n); +} + +test "boj_cartridge_init returns 0" { + try std.testing.expectEqual(@as(c_int, 0), boj_cartridge_init()); +} + +test "invoke: each declared tool succeeds" { + var buf: [256]u8 = undefined; + const tools = [_][]const u8{ + "matrix_authenticate", + "matrix_send_message", + "matrix_list_rooms", + "matrix_get_messages", + "matrix_join_room", + "matrix_leave_room", + "matrix_deauthenticate", + }; + for (tools) |t| { + var len: usize = buf.len; + const rc = boj_cartridge_invoke(t.ptr, "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, 0), rc); + try std.testing.expect(std.mem.indexOf(u8, buf[0..len], "result") != null); + } +} + +test "invoke: unknown tool returns -1" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("nope", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -1), rc); +} + +test "invoke: buffer too small returns -3" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("matrix_authenticate", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -3), rc); + try std.testing.expect(len > 4); +} diff --git a/cartridges/domains/communications/matrix-mcp/minter.toml b/cartridges/domains/communications/matrix-mcp/minter.toml new file mode 100644 index 0000000..3dcc2e6 --- /dev/null +++ b/cartridges/domains/communications/matrix-mcp/minter.toml @@ -0,0 +1,46 @@ +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> + +name = "matrix-mcp" +description = "Matrix Client-Server API v3 cartridge β€” messages, events, rooms, membership, state, sync, search, media, profiles, room creation" +version = "0.1.0" +domain = "Comms" +protocols = [ + "MCP", + "REST", +] +tier = "Ayo" +backend = "universal" +generate_panel = true + +[auth] +type = "bearer_token" +header = "Authorization" +prefix = "Bearer" +default_homeserver = "https://matrix.org" +api_prefix = "/_matrix/client/v3/" + +[idempotency] +strategy = "transaction_id" +description = "Monotonic txnId in PUT request paths for event deduplication" + +[actions] +count = 16 +list = [ + "SendMessage", + "SendEvent", + "GetRoom", + "ListRooms", + "JoinRoom", + "LeaveRoom", + "InviteUser", + "KickUser", + "SetRoomState", + "GetRoomState", + "Sync", + "SearchMessages", + "UploadMedia", + "GetProfile", + "SetDisplayName", + "CreateRoom", +] diff --git a/cartridges/domains/communications/matrix-mcp/mod.js b/cartridges/domains/communications/matrix-mcp/mod.js new file mode 100644 index 0000000..2a35a9e --- /dev/null +++ b/cartridges/domains/communications/matrix-mcp/mod.js @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// matrix-mcp/mod.js -- matrix gateway. + +const BASE_URL = Deno.env.get("MATRIX_MCP_BACKEND_URL") ?? "http://127.0.0.1:7731"; + +async function post(path, payload) { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), 15000); + try { + const r = await fetch(`${BASE_URL}${path}`, { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), signal: ctrl.signal, + }); + const data = await r.json().catch(() => ({ success: false, error: "non-JSON response" })); + return { status: r.status, data }; + } catch (e) { + if (e.name === "AbortError") return { status: 504, data: { success: false, error: "matrix-mcp backend timed out" } }; + return { status: 503, data: { success: false, error: `matrix-mcp backend unavailable: ${e.message}` } }; + } finally { clearTimeout(t); } +} + +export async function handleTool(toolName, args) { + switch (toolName) { + + case "matrix_authenticate": { + const { homeserver, access_token } = args ?? {}; + if (!homeserver || !access_token) return { status: 400, data: { error: "homeserver is required" } }; + const payload = { homeserver, access_token }; + return post("/api/v1/authenticate", payload); + } + case "matrix_send_message": { + const { slot, room_id, body, msgtype } = args ?? {}; + if (!slot || !room_id || !body) return { status: 400, data: { error: "slot is required" } }; + const payload = { slot, room_id, body }; + if (msgtype !== undefined) payload.msgtype = msgtype; + return post("/api/v1/send-message", payload); + } + case "matrix_list_rooms": { + const { slot } = args ?? {}; + if (!slot) return { status: 400, data: { error: "slot is required" } }; + const payload = { slot }; + return post("/api/v1/list-rooms", payload); + } + case "matrix_get_messages": { + const { slot, room_id, limit } = args ?? {}; + if (!slot || !room_id) return { status: 400, data: { error: "slot is required" } }; + const payload = { slot, room_id }; + if (limit !== undefined) payload.limit = limit; + return post("/api/v1/get-messages", payload); + } + case "matrix_join_room": { + const { slot, room_id } = args ?? {}; + if (!slot || !room_id) return { status: 400, data: { error: "slot is required" } }; + const payload = { slot, room_id }; + return post("/api/v1/join-room", payload); + } + case "matrix_leave_room": { + const { slot, room_id } = args ?? {}; + if (!slot || !room_id) return { status: 400, data: { error: "slot is required" } }; + const payload = { slot, room_id }; + return post("/api/v1/leave-room", payload); + } + case "matrix_deauthenticate": { + const { slot } = args ?? {}; + if (!slot) return { status: 400, data: { error: "slot is required" } }; + const payload = { slot }; + return post("/api/v1/deauthenticate", payload); + } + + default: + return { status: 404, data: { error: `Unknown tool: ${toolName}` } }; + } +} diff --git a/cartridges/domains/communications/matrix-mcp/panels/manifest.json b/cartridges/domains/communications/matrix-mcp/panels/manifest.json new file mode 100644 index 0000000..976d76c --- /dev/null +++ b/cartridges/domains/communications/matrix-mcp/panels/manifest.json @@ -0,0 +1,90 @@ +{ + "$schema": "https://panll.dev/schemas/panel-manifest/v1.json", + "spdx": "MPL-2.0", + "cartridge": "matrix-mcp", + "domain": "Comms", + "version": "0.1.0", + "panels": [ + { + "id": "matrix-connection-status", + "title": "Connection Status", + "description": "Current Matrix connection state (Disconnected / Authenticating / Connected / Syncing / Error)", + "type": "status-indicator", + "data_source": { + "endpoint": "/matrix/state", + "method": "GET", + "refresh_interval_ms": 5000 + }, + "widgets": [ + { + "type": "state-badge", + "field": "state", + "states": { + "disconnected": { "color": "#95a5a6", "icon": "plug-disconnected" }, + "authenticating": { "color": "#f39c12", "icon": "key" }, + "connected": { "color": "#2ecc71", "icon": "plug-connected" }, + "syncing": { "color": "#3498db", "icon": "refresh-cw" }, + "error": { "color": "#e74c3c", "icon": "alert-triangle" } + } + } + ] + }, + { + "id": "matrix-homeserver", + "title": "Homeserver", + "description": "URL of the Matrix homeserver the client is connected to", + "type": "metric", + "data_source": { + "endpoint": "/matrix/metrics", + "method": "GET", + "refresh_interval_ms": 30000 + }, + "widgets": [ + { + "type": "text", + "field": "homeserver", + "label": "Homeserver", + "icon": "home" + } + ] + }, + { + "id": "matrix-room-count", + "title": "Room Count", + "description": "Number of rooms the client has joined", + "type": "metric", + "data_source": { + "endpoint": "/matrix/metrics", + "method": "GET", + "refresh_interval_ms": 30000 + }, + "widgets": [ + { + "type": "counter", + "field": "room_count", + "label": "Rooms", + "icon": "hash" + } + ] + }, + { + "id": "matrix-sync-state", + "title": "Sync State", + "description": "Current sync status and last sync token", + "type": "metric", + "data_source": { + "endpoint": "/matrix/sync-status", + "method": "GET", + "refresh_interval_ms": 10000 + }, + "widgets": [ + { + "type": "text", + "field": "sync_state", + "label": "Sync", + "icon": "refresh-cw" + } + ] + } + ] +} diff --git a/cartridges/domains/communications/matrix-mcp/tests/integration_test.sh b/cartridges/domains/communications/matrix-mcp/tests/integration_test.sh new file mode 100755 index 0000000..b6784de --- /dev/null +++ b/cartridges/domains/communications/matrix-mcp/tests/integration_test.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Integration tests for matrix-mcp cartridge. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CART_DIR="$(dirname "$SCRIPT_DIR")" +FFI_DIR="$CART_DIR/ffi" + +echo "=== matrix-mcp integration tests ===" + +# Build FFI +echo "[1/3] Building FFI..." +cd "$FFI_DIR" && zig build 2>&1 + +# Run Zig unit tests +echo "[2/3] Running FFI unit tests..." +cd "$FFI_DIR" && zig build test 2>&1 + +# Validate Idris2 ABI (if idris2 available) +echo "[3/3] Checking ABI..." +if command -v idris2 &>/dev/null; then + cd "$CART_DIR/abi" && idris2 --check MatrixMcp.SafeComms 2>&1 + echo " ABI: OK" +else + echo " ABI: SKIPPED (idris2 not in PATH)" +fi + +echo "" +echo "All tests passed for matrix-mcp!" diff --git a/cartridges/domains/communications/notifyhub-mcp/README.adoc b/cartridges/domains/communications/notifyhub-mcp/README.adoc new file mode 100644 index 0000000..6e9b0e4 --- /dev/null +++ b/cartridges/domains/communications/notifyhub-mcp/README.adoc @@ -0,0 +1,73 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += notifyhub-mcp +Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk> +:spdx: MPL-2.0 +:tier: Ayo +:domain: Communication +:protocols: MCP, REST + +== Overview + +Unified notification platform. Send notifications via Email, SMS, WhatsApp, Slack, Telegram, Discord, Teams, Firebase Push, Webhooks, WebSocket, Google Chat, Twitter/X, LinkedIn, Notion, Twitch, YouTube, Instagram, SendGrid, TikTok Shop, Facebook, AWS SNS, Mailgun, PagerDuty, Kick. + +== Tools (5) + +[cols="2,4"] +|=== +| Tool | Description + +| `notifyhub_send_email` | Send email via SMTP (Gmail, SES, Outlook, etc). +| `notifyhub_send_sms` | Send SMS via Twilio. +| `notifyhub_send_slack` | Send message to Slack channel via webhook. +| `notifyhub_send_discord` | Send message to Discord channel via webhook. +| `notifyhub_send_telegram` | Send message via Telegram Bot API. +|=== + +== Architecture + +[cols="1,1,3"] +|=== +| Layer | Language | Purpose + +| ABI | Idris2 | Formally verified state machine with dependent-type proofs +| FFI | Zig | C-ABI implementation: Multi-channel notification routing, template rendering, rate limiting +| Adapter| Zig | Adapter bridge to the BoJ unified adapter protocol +|=== + +== Building + +[source,bash] +---- +# ABI (Idris2) +cd abi && idris2 --check *.idr + +# FFI (Zig) +cd ffi && zig build +cd ffi && zig build test + +# Adapter (Zig) +cd adapter && zig build +---- + +== Status + +Alpha (CRG D). Cartridge metadata declared in `cartridge.json`; 5 tool(s) exposed. +Not yet dogfooded externally. See `docs/READINESS.md` at repo root for the +project-wide grade table and `docs/practice/DOGFOOD-LOG.adoc` for usage evidence. + +== Ports + +Allowed:: 8080 (REST API), 443 (HTTPS) +Denied:: 22 (SSH), 23 (Telnet), 25 (SMTP), 135 (RPC), 139 (NetBIOS), 445 (SMB) + +== Authentication + +Method:: API Key +Env Var:: NOTIFYHUB_API_KEY +Source:: User-provided + +== References + +- [NotifyHub GitHub](https://github.com/GabrielBBaldez/notify-hub) +- [MCP Specification](https://spec.modelcontextprotocol.io/) diff --git a/cartridges/domains/communications/notifyhub-mcp/abi/README.adoc b/cartridges/domains/communications/notifyhub-mcp/abi/README.adoc new file mode 100644 index 0000000..17afab7 --- /dev/null +++ b/cartridges/domains/communications/notifyhub-mcp/abi/README.adoc @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) += notifyhub-mcp / abi β€” Idris2 ABI layer +:orientation: deep + +== Purpose + +Encodes the notifyhub-mcp connection state machine and the MCP tool catalogue. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| notifyhub.ipkg | Idris2 package descriptor. +| notifyhub/Safenotifyhub.idr | State machine and tool definitions. +|=== + +== Invariants + +* at module top. +* Zero . + +== Test/proof surface + +Type-check only β€” + Error loading file "notifyhub.ipkg": File Not Found. + +== Read-first + +. Safenotifyhub.idr β€” state machine and tool definitions. + diff --git a/cartridges/domains/communications/notifyhub-mcp/adapter/README.adoc b/cartridges/domains/communications/notifyhub-mcp/adapter/README.adoc new file mode 100644 index 0000000..a7a5db6 --- /dev/null +++ b/cartridges/domains/communications/notifyhub-mcp/adapter/README.adoc @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) += notifyhub-mcp / adapter β€” REST/gRPC/GraphQL bridge +:orientation: deep + +== Purpose + +Exposes the FFI layer over three protocols. Stateless β€” all state lives behind the FFI mutex. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| build.zig | Zig build graph. +| notifyhub_adapter.zig | Three-protocol dispatcher. +|=== + +== Invariants + +* Stateless β€” no global mutable state. +* One tool call per HTTP request. +* JSON content type for all responses. + +== Test/proof surface + +No inline tests β€” FFI tests cover correctness. Integration tested via cartridge-matrix tests. + +== Read-first + +. notifyhub_adapter.zig β€” tool-name β†’ FFI-call mapping. diff --git a/cartridges/domains/communications/notifyhub-mcp/cartridge.json b/cartridges/domains/communications/notifyhub-mcp/cartridge.json new file mode 100644 index 0000000..56defcd --- /dev/null +++ b/cartridges/domains/communications/notifyhub-mcp/cartridge.json @@ -0,0 +1,168 @@ +{ + "$schema": "https://boj.dev/schemas/cartridge/v1.json", + "spdx": "MPL-2.0", + "copyright": "Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath)", + "name": "notifyhub-mcp", + "version": "0.1.0", + "description": "Unified notification platform. Send notifications via Email, SMS, WhatsApp, Slack, Telegram, Discord, Teams, Firebase Push, Webhooks, WebSocket, Google Chat, Twitter/X, LinkedIn, Notion, Twitch, YouTube, Instagram, SendGrid, TikTok Shop, Facebook, AWS SNS, Mailgun, PagerDuty, Kick.", + "domain": "Communication", + "tier": "Ayo", + "protocols": [ + "MCP", + "REST" + ], + "auth": { + "method": "api-key", + "env_var": "NOTIFYHUB_API_KEY", + "credential_source": "user-provided" + }, + "api": { + "base_url": "local://notifyhub-mcp", + "content_type": "application/json" + }, + "ports": { + "allowed": [ + 8080, + 443 + ], + "denied": [ + 22, + 23, + 25, + 135, + 139, + 445 + ] + }, + "tools": [ + { + "name": "notifyhub_send_email", + "description": "Send email via SMTP (Gmail, SES, Outlook, etc).", + "inputSchema": { + "type": "object", + "properties": { + "to": { + "type": "string", + "description": "Recipient email address" + }, + "subject": { + "type": "string", + "description": "Email subject" + }, + "body": { + "type": "string", + "description": "Email body (plain text or HTML)" + }, + "template": { + "type": "string", + "description": "Mustache template name (optional)" + }, + "params": { + "type": "object", + "description": "Template parameters (optional)" + } + }, + "required": [ + "to", + "subject", + "body" + ] + } + }, + { + "name": "notifyhub_send_sms", + "description": "Send SMS via Twilio.", + "inputSchema": { + "type": "object", + "properties": { + "phone": { + "type": "string", + "description": "Recipient phone number (E.164 format)" + }, + "body": { + "type": "string", + "description": "SMS message content" + } + }, + "required": [ + "phone", + "body" + ] + } + }, + { + "name": "notifyhub_send_slack", + "description": "Send message to Slack channel via webhook.", + "inputSchema": { + "type": "object", + "properties": { + "recipient": { + "type": "string", + "description": "Slack channel or named alias" + }, + "body": { + "type": "string", + "description": "Message content" + } + }, + "required": [ + "recipient", + "body" + ] + } + }, + { + "name": "notifyhub_send_discord", + "description": "Send message to Discord channel via webhook.", + "inputSchema": { + "type": "object", + "properties": { + "recipient": { + "type": "string", + "description": "Discord channel or named alias" + }, + "body": { + "type": "string", + "description": "Message content" + } + }, + "required": [ + "recipient", + "body" + ] + } + }, + { + "name": "notifyhub_send_telegram", + "description": "Send message via Telegram Bot API.", + "inputSchema": { + "type": "object", + "properties": { + "recipient": { + "type": "string", + "description": "Telegram chat ID or named alias" + }, + "body": { + "type": "string", + "description": "Message content" + } + }, + "required": [ + "recipient", + "body" + ] + } + } + ], + "ffi": { + "so_path": "ffi/zig-out/lib/libnotifyhub_mcp.so", + "abi_version": "ADR-0006", + "symbols": [ + "boj_cartridge_init", + "boj_cartridge_deinit", + "boj_cartridge_name", + "boj_cartridge_version", + "boj_cartridge_invoke" + ] + } +} diff --git a/cartridges/domains/communications/notifyhub-mcp/ffi/README.adoc b/cartridges/domains/communications/notifyhub-mcp/ffi/README.adoc new file mode 100644 index 0000000..f64ab3b --- /dev/null +++ b/cartridges/domains/communications/notifyhub-mcp/ffi/README.adoc @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) += notifyhub-mcp / ffi β€” Zig FFI layer +:orientation: deep + +== Purpose + +Zig implementation of the connection state machine from . + +== Files + +[cols="1,4"] +|=== +| File | Role + +| build.zig | Zig build graph. +| notifyhub_ffi.zig | FFI implementation. +|=== + +== Invariants + +* Mutex discipline for all shared state. +* Bounds checks before dereference. +* State-machine mirror of ABI. + +== Test/proof surface + +Inline blocks in notifyhub_ffi.zig. Run with . + +== Read-first + +. notifyhub_ffi.zig β€” FFI exports and guards. + diff --git a/cartridges/domains/communications/notifyhub-mcp/ffi/build.zig b/cartridges/domains/communications/notifyhub-mcp/ffi/build.zig new file mode 100644 index 0000000..89f6a31 --- /dev/null +++ b/cartridges/domains/communications/notifyhub-mcp/ffi/build.zig @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const ffi_mod = b.addModule("notifyhub_mcp", .{ + .root_source_file = b.path("notifyhub_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + const lib = b.addLibrary(.{ + .name = "notifyhub_mcp", + .root_module = ffi_mod, + .linkage = .dynamic, + }); + lib.linkLibC(); + b.installArtifact(lib); + + const tests = b.addTest(.{ .root_module = ffi_mod }); + tests.linkLibC(); + const run_tests = b.addRunArtifact(tests); + const test_step = b.step("test", "Run FFI tests"); + test_step.dependOn(&run_tests.step); +} diff --git a/cartridges/domains/communications/notifyhub-mcp/ffi/cartridge_shim.zig b/cartridges/domains/communications/notifyhub-mcp/ffi/cartridge_shim.zig new file mode 100644 index 0000000..0e399db --- /dev/null +++ b/cartridges/domains/communications/notifyhub-mcp/ffi/cartridge_shim.zig @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// cartridge_shim.zig β€” Shared helpers for the ADR-0006 five-symbol +// cartridge ABI (`boj_cartridge_init / deinit / name / version / invoke`). +// +// The shim centralises the seven-code return convention, NUL-argument +// guards, tool-name comparison, and the buffer-too-small path so each +// cartridge's `boj_cartridge_invoke` can stay short β€” typically a tool +// table plus `shim.writeResult(...)`. +// +// Cartridges import this file by relative path (no build-graph change +// needed). Example: +// +// const shim = @import("../../../ffi/zig/src/cartridge_shim.zig"); +// +// export fn boj_cartridge_invoke( +// tool_name: [*c]const u8, +// json_args: [*c]const u8, +// out_buf: [*c]u8, +// in_out_len: [*c]usize, +// ) callconv(.c) i32 { +// _ = json_args; +// if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; +// const body = if (shim.toolIs(tool_name, "foo")) "{\"result\":{}}" +// else return shim.RC_UNKNOWN_TOOL; +// return shim.writeResult(out_buf, in_out_len, body); +// } + +const std = @import("std"); + +// ── Return codes (ADR-0006 Β§Return codes) ──────────────────────────── +// +// Frozen by ADR-0006. New failure modes compose these via the error +// JSON body β€” the integer surface does not grow without a follow-up ADR. + +pub const RC_SUCCESS: i32 = 0; +pub const RC_UNKNOWN_TOOL: i32 = -1; +pub const RC_BAD_ARGS: i32 = -2; +pub const RC_BUFFER_TOO_SMALL: i32 = -3; +pub const RC_RUNTIME_ERROR: i32 = -4; +pub const RC_PANIC: i32 = -5; +pub const RC_AUTH_DENIED: i32 = -6; + +// ── Invoke-path helpers ────────────────────────────────────────────── + +/// True if any of the three mandatory `boj_cartridge_invoke` output-path +/// pointers is null. Use at the top of every invoke to short-circuit to +/// `RC_BAD_ARGS`. +pub fn invokeArgsNull( + tool_name: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) bool { + return tool_name == null or out_buf == null or in_out_len == null; +} + +/// Compare a C-NUL-terminated tool-name pointer against a Zig string +/// literal. Caller must have already verified `tool_name` is non-null +/// (usually via `invokeArgsNull`). +pub fn toolIs(tool_name: [*c]const u8, expected: []const u8) bool { + const s = std.mem.span(@as([*:0]const u8, @ptrCast(tool_name))); + return std.mem.eql(u8, s, expected); +} + +/// Copy `body` into `out_buf[0..*in_out_len]` (as a capacity) and update +/// `*in_out_len` to the number of bytes written. Returns `RC_SUCCESS`. +/// +/// If `body.len` exceeds the current capacity stored in `*in_out_len`, +/// sets `*in_out_len` to the required size and returns +/// `RC_BUFFER_TOO_SMALL` β€” the caller is then expected to re-allocate +/// and retry, per ADR-0006 Β§Memory ownership. +/// +/// Caller must have already verified that `out_buf` and `in_out_len` +/// are non-null. +pub fn writeResult( + out_buf: [*c]u8, + in_out_len: [*c]usize, + body: []const u8, +) i32 { + const cap = in_out_len.*; + if (body.len > cap) { + in_out_len.* = body.len; + return RC_BUFFER_TOO_SMALL; + } + @memcpy(out_buf[0..body.len], body); + in_out_len.* = body.len; + return RC_SUCCESS; +} + +// ── Tests ──────────────────────────────────────────────────────────── + +test "writeResult: body fits, writes and sets length" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); + try std.testing.expectEqualStrings("hello", buf[0..len]); +} + +test "writeResult: too small returns -3 and sets required length" { + var buf: [2]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_BUFFER_TOO_SMALL, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: exact-fit succeeds" { + var buf: [5]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: empty body" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, ""); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 0), len); +} + +test "toolIs: matches and rejects" { + const name: [*:0]const u8 = "foo"; + try std.testing.expect(toolIs(@ptrCast(name), "foo")); + try std.testing.expect(!toolIs(@ptrCast(name), "bar")); + try std.testing.expect(!toolIs(@ptrCast(name), "foobar")); + try std.testing.expect(!toolIs(@ptrCast(name), "fo")); +} + +test "invokeArgsNull: detects each null slot" { + var buf: [4]u8 = undefined; + var len: usize = 4; + const name: [*:0]const u8 = "x"; + try std.testing.expect(!invokeArgsNull(@ptrCast(name), &buf, &len)); + try std.testing.expect(invokeArgsNull(null, &buf, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), null, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), &buf, null)); +} diff --git a/cartridges/domains/communications/notifyhub-mcp/ffi/notifyhub_ffi.zig b/cartridges/domains/communications/notifyhub-mcp/ffi/notifyhub_ffi.zig new file mode 100644 index 0000000..d4c1670 --- /dev/null +++ b/cartridges/domains/communications/notifyhub-mcp/ffi/notifyhub_ffi.zig @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// notifyhub-mcp FFI β€” ADR-0006 five-symbol cartridge ABI implementation. + +const std = @import("std"); +const shim = @import("cartridge_shim.zig"); + +const CARTRIDGE_NAME_PTR: [*:0]const u8 = "notifyhub-mcp"; +const CARTRIDGE_VERSION_PTR: [*:0]const u8 = "0.1.0"; + +export fn boj_cartridge_init() callconv(.c) c_int { + return 0; +} + +export fn boj_cartridge_deinit() callconv(.c) void {} + +export fn boj_cartridge_name() callconv(.c) [*:0]const u8 { + return CARTRIDGE_NAME_PTR; +} + +export fn boj_cartridge_version() callconv(.c) [*:0]const u8 { + return CARTRIDGE_VERSION_PTR; +} + +export fn boj_cartridge_invoke( + tool_name: [*c]const u8, + json_args: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) callconv(.c) i32 { + _ = json_args; + if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; + const body: []const u8 = if (shim.toolIs(tool_name, "notifyhub_send_email")) + "{\"result\":{}}" + else if (shim.toolIs(tool_name, "notifyhub_send_sms")) + "{\"result\":{}}" + else if (shim.toolIs(tool_name, "notifyhub_send_slack")) + "{\"result\":{}}" + else if (shim.toolIs(tool_name, "notifyhub_send_discord")) + "{\"result\":{}}" + else if (shim.toolIs(tool_name, "notifyhub_send_telegram")) + "{\"result\":{}}" + else + return shim.RC_UNKNOWN_TOOL; + + return shim.writeResult(out_buf, in_out_len, body); +} + +test "boj_cartridge_name returns notifyhub-mcp" { + try std.testing.expectEqualStrings("notifyhub-mcp", std.mem.span(boj_cartridge_name())); +} + +test "boj_cartridge_init returns 0" { + try std.testing.expectEqual(@as(c_int, 0), boj_cartridge_init()); +} + +test "invoke unknown tool returns RC_UNKNOWN_TOOL" { + var buf: [256]u8 = undefined; + var len: usize = buf.len; + try std.testing.expectEqual(@as(i32, shim.RC_UNKNOWN_TOOL), boj_cartridge_invoke("unknown_xyz", "{}", &buf, &len)); +} + +test "invoke notifyhub_send_email returns 0" { + var buf: [256]u8 = undefined; + var len: usize = buf.len; + try std.testing.expectEqual(@as(i32, 0), boj_cartridge_invoke("notifyhub_send_email", "{}", &buf, &len)); +} diff --git a/cartridges/domains/communications/notifyhub-mcp/mod.js b/cartridges/domains/communications/notifyhub-mcp/mod.js new file mode 100644 index 0000000..ce1394a --- /dev/null +++ b/cartridges/domains/communications/notifyhub-mcp/mod.js @@ -0,0 +1,88 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// notifyhub-mcp/mod.js β€” NotifyHub unified notification platform cartridge. +// +// Delegates to backend at http://127.0.0.1:8080 (override with NOTIFYHUB_URL). +// Auth: NOTIFYHUB_API_KEY (required for all operations). + +const BASE_URL = Deno.env.get("NOTIFYHUB_URL") ?? "http://127.0.0.1:8080"; +const TIMEOUT_MS = 20_000; + +function getKey() { + return Deno.env.get("NOTIFYHUB_API_KEY") ?? null; +} + +function authHeaders() { + const key = getKey(); + if (!key) return null; + return { "Content-Type": "application/json", "X-API-Key": key }; +} + +async function post(path, payload) { + const headers = authHeaders(); + if (!headers) + return { status: 401, data: { success: false, error: "NOTIFYHUB_API_KEY env var is required" } }; + + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), TIMEOUT_MS); + try { + const r = await fetch(`${BASE_URL}${path}`, { + method: "POST", + headers, + body: JSON.stringify(payload), + signal: ctrl.signal, + }); + const data = await r.json().catch(() => ({ success: false, error: "non-JSON response" })); + return { status: r.status, data }; + } catch (e) { + if (e.name === "AbortError") + return { status: 504, data: { success: false, error: "notifyhub-mcp backend timed out" } }; + return { status: 503, data: { success: false, error: `notifyhub-mcp backend unavailable: ${e.message}` } }; + } finally { clearTimeout(t); } +} + +export async function handleTool(toolName, args) { + switch (toolName) { + case "notifyhub_send_email": { + const { to, subject, body, template, params } = args ?? {}; + if (!to || !subject || !body) + return { status: 400, data: { error: "to, subject, and body are required" } }; + const payload = { to, subject, body }; + if (template !== undefined) payload.template = template; + if (params !== undefined) payload.params = params; + return post("/api/v1/notify/email", payload); + } + + case "notifyhub_send_sms": { + const { phone, body } = args ?? {}; + if (!phone || !body) + return { status: 400, data: { error: "phone and body are required" } }; + return post("/api/v1/notify/sms", { phone, body }); + } + + case "notifyhub_send_slack": { + const { recipient, body } = args ?? {}; + if (!recipient || !body) + return { status: 400, data: { error: "recipient and body are required" } }; + return post("/api/v1/notify/slack", { recipient, body }); + } + + case "notifyhub_send_discord": { + const { recipient, body } = args ?? {}; + if (!recipient || !body) + return { status: 400, data: { error: "recipient and body are required" } }; + return post("/api/v1/notify/discord", { recipient, body }); + } + + case "notifyhub_send_telegram": { + const { recipient, body } = args ?? {}; + if (!recipient || !body) + return { status: 400, data: { error: "recipient and body are required" } }; + return post("/api/v1/notify/telegram", { recipient, body }); + } + + default: + return { status: 404, data: { error: `Unknown tool: ${toolName}` } }; + } +} diff --git a/cartridges/domains/communications/slack-mcp/README.adoc b/cartridges/domains/communications/slack-mcp/README.adoc new file mode 100644 index 0000000..628e714 --- /dev/null +++ b/cartridges/domains/communications/slack-mcp/README.adoc @@ -0,0 +1,141 @@ += slack-mcp +Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk> +:spdx: MPL-2.0 +:tier: Ayo +:domain: Comms +:protocols: MCP, REST + +== Overview + +Slack Web API and Events API cartridge for the BoJ server. +Wraps the full Slack platform β€” messaging, channels, users, reactions, +file uploads, search, threads, and workspace management β€” behind a +type-safe, rate-limit-aware interface. + +== Architecture + +[cols="1,1,2"] +|=== +| Layer | Language | Purpose + +| ABI +| Idris2 +| Formally verified connection state machine (`SafeComms.idr`) with + dependent-type proofs for all valid transitions + +| FFI +| Zig +| C-compatible implementation with thread-safe session pool, Slack + HTTP client stubs, and per-method rate-tier tracking + +| Adapter +| zig +| REST bridge exposing all 16 Slack actions to the BoJ unified adapter +|=== + +== Bot Token Setup + +This cartridge authenticates using Slack bot tokens (`xoxb-*`). + +1. Create a Slack App at https://api.slack.com/apps +2. Under **OAuth & Permissions**, add the bot scopes you need: + - `chat:write` β€” send and update messages + - `channels:read`, `groups:read` β€” list and inspect channels + - `users:read` β€” list and inspect users + - `reactions:write` β€” add/remove emoji reactions + - `files:write` β€” upload files + - `search:read` β€” search messages + - `channels:manage` β€” create channels, invite users + - `users.profile:write` β€” set bot status +3. Install the app to your workspace and copy the **Bot User OAuth Token** (`xoxb-...`) +4. Store the token in **vault-mcp** so the cartridge can retrieve it at authentication time + +== Slack Actions (16 total) + +[cols="1,2,1"] +|=== +| Action | Slack API Method | Rate Tier + +| SendMessage | `chat.postMessage` | Tier 3 +| ListChannels | `conversations.list` | Tier 2 +| GetChannel | `conversations.info` | Tier 3 +| ListUsers | `users.list` | Tier 2 +| GetUser | `users.info` | Tier 4 +| PostReaction | `reactions.add` | Tier 3 +| RemoveReaction | `reactions.remove` | Tier 3 +| UploadFile | `files.upload` | Tier 2 +| SearchMessages | `search.messages` | Tier 2 +| ListConversations | `conversations.list` | Tier 2 +| GetThread | `conversations.replies` | Tier 3 +| UpdateMessage | `chat.update` | Tier 3 +| DeleteMessage | `chat.delete` | Tier 3 +| SetStatus | `users.profile.set` | Tier 3 +| CreateChannel | `conversations.create` | Tier 2 +| InviteToChannel | `conversations.invite` | Tier 3 +|=== + +== Rate Tiers + +Slack enforces per-method rate limits grouped into four tiers. +The cartridge tracks usage per tier and transitions to `RateLimited` +state when a tier's budget is exhausted. The budget resets every 60 seconds. + +[cols="1,1,2"] +|=== +| Tier | Budget (req/min) | Description + +| Tier 1 | 1 | Extremely restricted (special admin methods) +| Tier 2 | 20 | List/search operations +| Tier 3 | 50 | Standard read/write operations +| Tier 4 | 100+ | High-throughput lookups +|=== + +== Connection State Machine + +.... +Disconnected ──→ Authenticating ──→ Connected ──→ Disconnected + β”‚ β”‚ ↑ + ↓ ↓ β”‚ + Error ←── Connected RateLimited + β”‚ β”‚ + ↓ β”‚ + Disconnected β†β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ (via Errorβ†’Disconn) +.... + +Valid transitions: + +- `Disconnected` -> `Authenticating` (begin auth) +- `Authenticating` -> `Connected` (auth success) +- `Authenticating` -> `Error` (auth failure) +- `Connected` -> `RateLimited` (tier budget exhausted) +- `RateLimited` -> `Connected` (budget window reset) +- `Connected` -> `Error` (network/API fault) +- `Connected` -> `Disconnected` (graceful close) +- `Error` -> `Disconnected` (reset for re-auth) + +== Building + +[source,bash] +---- +# Build FFI shared library +cd ffi && zig build + +# Run FFI tests +cd ffi && zig build test + +# Type-check ABI +cd abi && idris2 --check slack_mcp.ipkg +---- + +== PanLL Panels + +The `panels/manifest.json` provides four data sources for the PanLL dashboard: + +- **Connection Status** β€” live state badge (5 s refresh) +- **Workspace** β€” connected workspace name (30 s refresh) +- **Rate Limits** β€” per-tier usage bars (10 s refresh) +- **Messages Sent** β€” session message counter (10 s refresh) + +== Status + +Development β€” not yet ready for mounting. diff --git a/cartridges/domains/communications/slack-mcp/abi/README.adoc b/cartridges/domains/communications/slack-mcp/abi/README.adoc new file mode 100644 index 0000000..b879573 --- /dev/null +++ b/cartridges/domains/communications/slack-mcp/abi/README.adoc @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += slack-mcp / abi β€” Idris2 ABI layer +:orientation: shallow + +== Purpose + +Cartridge-local protocol types for `slack-mcp`. Defines the data, tool, and +transition vocabulary this cartridge speaks. Inherits safety lemmas and the +matrix-cell identifier scheme from the trunk ABI at `src/abi/`. + +== Files + +1 Idris2 module(s), ~258 lines total. Lead module: +`SafeComms.idr`. + +No `.ipkg` β€” consumed directly by wider BoJ callers; type-checks inside the +trunk `boj.ipkg`. + +== Invariants + +* `%default total` at every module top. +* Zero `believe_me`, `assert_total`, `postulate`, `Admitted`, or + `sorry` (the trunk holds the four declared `believe_me` sites; leaf + cartridges carry none). + +== Test/proof surface + +Thin module(s), no inline tests. Proof surface = type-check success under the +trunk `idris2 --check`. + +== Read-first + +. `SafeComms.idr` β€” cartridge-local data records and enums. +. Trunk `src/abi/Boj/Catalogue.idr` for how this cell participates in the + 2D matrix. diff --git a/cartridges/domains/communications/slack-mcp/abi/SlackMcp/SafeComms.idr b/cartridges/domains/communications/slack-mcp/abi/SlackMcp/SafeComms.idr new file mode 100644 index 0000000..ee36558 --- /dev/null +++ b/cartridges/domains/communications/slack-mcp/abi/SlackMcp/SafeComms.idr @@ -0,0 +1,258 @@ +-- SPDX-License-Identifier: MPL-2.0 +-- Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +-- +-- SlackMcp.SafeComms β€” Type-safe ABI for the Slack Web API / Events API cartridge. +-- +-- Dependent-type state machine governing Slack connection lifecycle. +-- All transitions proven valid at compile time. Zero unsafe escape hatches. + +module SlackMcp.SafeComms + +%default total + +-- --------------------------------------------------------------------------- +-- Connection state machine +-- --------------------------------------------------------------------------- + +||| Connection lifecycle states for Slack API interactions. +||| Models the full lifecycle: authentication, connected operation, +||| rate limiting (Slack enforces per-method tiers), and error recovery. +public export +data ConnState + = Disconnected + | Authenticating + | Connected + | RateLimited + | Error + +-- --------------------------------------------------------------------------- +-- Valid state transitions (proven at the type level) +-- --------------------------------------------------------------------------- + +||| Proof witness that a state transition is permitted. +||| Only the transitions enumerated here can ever occur at the FFI boundary. +public export +data ValidTransition : ConnState -> ConnState -> Type where + ||| Begin authentication from disconnected state. + StartAuth : ValidTransition Disconnected Authenticating + ||| Authentication succeeded β€” now connected to workspace. + AuthSuccess : ValidTransition Authenticating Connected + ||| Hit a Slack rate limit (Tier 1–4 throttle). + HitRateLimit : ValidTransition Connected RateLimited + ||| Rate limit window expired β€” resume operations. + RateRecovered : ValidTransition RateLimited Connected + ||| Operational error while connected (network, API fault). + ConnError : ValidTransition Connected Error + ||| Authentication failed. + AuthError : ValidTransition Authenticating Error + ||| Error recovery β€” return to disconnected for re-auth. + ErrorReset : ValidTransition Error Disconnected + ||| Graceful disconnect from a connected workspace. + GracefulClose : ValidTransition Connected Disconnected + +-- --------------------------------------------------------------------------- +-- C-ABI integer encoding for ConnState +-- --------------------------------------------------------------------------- + +||| Encode connection state as a C-compatible integer. +||| Mapping: Disconnected=0, Authenticating=1, Connected=2, RateLimited=3, Error=4. +export +connStateToInt : ConnState -> Int +connStateToInt Disconnected = 0 +connStateToInt Authenticating = 1 +connStateToInt Connected = 2 +connStateToInt RateLimited = 3 +connStateToInt Error = 4 + +||| Decode a C integer back to a connection state. +||| Returns Nothing for out-of-range values. +export +intToConnState : Int -> Maybe ConnState +intToConnState 0 = Just Disconnected +intToConnState 1 = Just Authenticating +intToConnState 2 = Just Connected +intToConnState 3 = Just RateLimited +intToConnState 4 = Just Error +intToConnState _ = Nothing + +||| C-ABI export: check whether a state transition is valid. +||| Returns 1 for valid, 0 for invalid. Used by the Zig FFI layer. +export +slack_mcp_can_transition : Int -> Int -> Int +slack_mcp_can_transition from to = + case (intToConnState from, intToConnState to) of + (Just Disconnected, Just Authenticating) => 1 + (Just Authenticating, Just Connected) => 1 + (Just Connected, Just RateLimited) => 1 + (Just RateLimited, Just Connected) => 1 + (Just Connected, Just Error) => 1 + (Just Authenticating, Just Error) => 1 + (Just Error, Just Disconnected) => 1 + (Just Connected, Just Disconnected) => 1 + _ => 0 + +-- --------------------------------------------------------------------------- +-- Slack action vocabulary +-- --------------------------------------------------------------------------- + +||| All actions supported by the slack-mcp cartridge. +||| Each maps to a Slack Web API method. +public export +data SlackAction + = SendMessage -- chat.postMessage + | ListChannels -- conversations.list + | GetChannel -- conversations.info + | ListUsers -- users.list + | GetUser -- users.info + | PostReaction -- reactions.add + | RemoveReaction -- reactions.remove + | UploadFile -- files.upload + | SearchMessages -- search.messages + | ListConversations -- conversations.list (with types filter) + | GetThread -- conversations.replies + | UpdateMessage -- chat.update + | DeleteMessage -- chat.delete + | SetStatus -- users.profile.set + | CreateChannel -- conversations.create + | InviteToChannel -- conversations.invite + +||| Encode a SlackAction as a C integer for FFI. +export +slackActionToInt : SlackAction -> Int +slackActionToInt SendMessage = 0 +slackActionToInt ListChannels = 1 +slackActionToInt GetChannel = 2 +slackActionToInt ListUsers = 3 +slackActionToInt GetUser = 4 +slackActionToInt PostReaction = 5 +slackActionToInt RemoveReaction = 6 +slackActionToInt UploadFile = 7 +slackActionToInt SearchMessages = 8 +slackActionToInt ListConversations = 9 +slackActionToInt GetThread = 10 +slackActionToInt UpdateMessage = 11 +slackActionToInt DeleteMessage = 12 +slackActionToInt SetStatus = 13 +slackActionToInt CreateChannel = 14 +slackActionToInt InviteToChannel = 15 + +||| Decode a C integer back to a SlackAction. +export +intToSlackAction : Int -> Maybe SlackAction +intToSlackAction 0 = Just SendMessage +intToSlackAction 1 = Just ListChannels +intToSlackAction 2 = Just GetChannel +intToSlackAction 3 = Just ListUsers +intToSlackAction 4 = Just GetUser +intToSlackAction 5 = Just PostReaction +intToSlackAction 6 = Just RemoveReaction +intToSlackAction 7 = Just UploadFile +intToSlackAction 8 = Just SearchMessages +intToSlackAction 9 = Just ListConversations +intToSlackAction 10 = Just GetThread +intToSlackAction 11 = Just UpdateMessage +intToSlackAction 12 = Just DeleteMessage +intToSlackAction 13 = Just SetStatus +intToSlackAction 14 = Just CreateChannel +intToSlackAction 15 = Just InviteToChannel +intToSlackAction _ = Nothing + +||| Total action count exposed via C-ABI. +export +slack_mcp_action_count : Int +slack_mcp_action_count = 16 + +-- --------------------------------------------------------------------------- +-- Slack rate-limit tiers +-- --------------------------------------------------------------------------- + +||| Slack rate-limit tier classification. +||| Tier 1: 1 req/min, Tier 2: 20 req/min, Tier 3: 50 req/min, Tier 4: 100 req/min. +public export +data RateTier = Tier1 | Tier2 | Tier3 | Tier4 + +||| Encode rate tier as C integer. +export +rateTierToInt : RateTier -> Int +rateTierToInt Tier1 = 1 +rateTierToInt Tier2 = 2 +rateTierToInt Tier3 = 3 +rateTierToInt Tier4 = 4 + +||| Decode C integer to rate tier. +export +intToRateTier : Int -> Maybe RateTier +intToRateTier 1 = Just Tier1 +intToRateTier 2 = Just Tier2 +intToRateTier 3 = Just Tier3 +intToRateTier 4 = Just Tier4 +intToRateTier _ = Nothing + +||| Map each action to its Slack rate tier. +||| Based on Slack Web API documentation. +export +actionRateTier : SlackAction -> RateTier +actionRateTier SendMessage = Tier3 +actionRateTier ListChannels = Tier2 +actionRateTier GetChannel = Tier3 +actionRateTier ListUsers = Tier2 +actionRateTier GetUser = Tier4 +actionRateTier PostReaction = Tier3 +actionRateTier RemoveReaction = Tier3 +actionRateTier UploadFile = Tier2 +actionRateTier SearchMessages = Tier2 +actionRateTier ListConversations = Tier2 +actionRateTier GetThread = Tier3 +actionRateTier UpdateMessage = Tier3 +actionRateTier DeleteMessage = Tier3 +actionRateTier SetStatus = Tier3 +actionRateTier CreateChannel = Tier2 +actionRateTier InviteToChannel = Tier3 + +-- --------------------------------------------------------------------------- +-- Message target record +-- --------------------------------------------------------------------------- + +||| Target for message-oriented operations. +||| threadTs is Nothing for top-level messages, Just ts for threaded replies. +public export +record MessageTarget where + constructor MkMessageTarget + channel : String + threadTs : Maybe String + +||| C-ABI export: check if an action requires an active (Connected) state. +||| All Slack actions require a connected session. +export +slack_mcp_action_requires_connected : Int -> Int +slack_mcp_action_requires_connected actionId = + case intToSlackAction actionId of + Just _ => 1 -- all actions require Connected state + Nothing => 0 -- unknown action + +-- --------------------------------------------------------------------------- +-- MCP tool declarations +-- --------------------------------------------------------------------------- + +||| Tools exposed via the MCP protocol by this cartridge. +public export +data McpTool + = ToolConnect + | ToolDisconnect + | ToolStatus + | ToolInvoke + | ToolList + +||| Check if a tool requires a connected session. +export +toolRequiresSession : McpTool -> Bool +toolRequiresSession ToolConnect = False +toolRequiresSession ToolDisconnect = True +toolRequiresSession ToolStatus = False +toolRequiresSession ToolInvoke = True +toolRequiresSession ToolList = False + +||| Tool count for this cartridge. +export +toolCount : Nat +toolCount = 5 diff --git a/cartridges/domains/communications/slack-mcp/abi/slack_mcp.ipkg b/cartridges/domains/communications/slack-mcp/abi/slack_mcp.ipkg new file mode 100644 index 0000000..98c8d45 --- /dev/null +++ b/cartridges/domains/communications/slack-mcp/abi/slack_mcp.ipkg @@ -0,0 +1,11 @@ +-- SPDX-License-Identifier: MPL-2.0 +-- Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +package slack_mcp + +version = "0.1.0" +authors = "Jonathan D.A. Jewell" +brief = "Type-safe ABI for the Slack Web API / Events API cartridge" + +depends = base + +modules = SlackMcp.SafeComms diff --git a/cartridges/domains/communications/slack-mcp/adapter/README.adoc b/cartridges/domains/communications/slack-mcp/adapter/README.adoc new file mode 100644 index 0000000..f102440 --- /dev/null +++ b/cartridges/domains/communications/slack-mcp/adapter/README.adoc @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += slack-mcp / adapter β€” protocol bridge +:orientation: shallow + +== Purpose + +Exposes this cartridge's FFI over the wire protocols declared in +`cartridge.json`. Dispatches tool invocations to the C-ABI exports at +`../ffi/` and formats responses per protocol. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” depends on the sibling `ffi` target. +| `slack_mcp_adapter.zig` | Protocol dispatch (112 lines). Tool catalogue matches `cartridge.json`. +| `SIDELINED-*.v.adoc` | zig predecessor(s). Not built β€” zig banned 2026-04-10. +|=== + +== Invariants + +* **Stateless** β€” all state lives behind the FFI, never in the adapter. +* **Response passthrough** β€” whatever the FFI returns goes back to the wire + unmodified (no embellishment, no silent recovery). +* **Cartridge.json is source of truth** for the tool catalogue; drift between + adapter and manifest is a CI failure. + +== Test/proof surface + +No inline tests at the adapter layer β€” protocol routing is exercised by the +trunk seam tests at `ffi/zig/src/seams.zig`. + +== Read-first + +. `slack_mcp_adapter.zig` β€” dispatch function and per-protocol routers. +. `../cartridge.json` β€” the tool manifest the dispatcher mirrors. diff --git a/cartridges/domains/communications/slack-mcp/adapter/build.zig b/cartridges/domains/communications/slack-mcp/adapter/build.zig new file mode 100644 index 0000000..6cce0ef --- /dev/null +++ b/cartridges/domains/communications/slack-mcp/adapter/build.zig @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// slack-mcp/adapter/build.zig + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); const optimize = b.standardOptimizeOption(.{}); + const ffi_mod = b.createModule(.{ .root_source_file = b.path("../ffi/slack_mcp_ffi.zig"), .target = target, .optimize = optimize }); + const adapter = b.addExecutable(.{ .name = "slack_mcp_adapter", .root_source_file = b.path("slack_mcp_adapter.zig"), .target = target, .optimize = optimize }); + adapter.root_module.addImport("slack_mcp_ffi", ffi_mod); b.installArtifact(adapter); + const rs = b.step("run", "Run slack-mcp adapter"); rs.dependOn(&b.addRunArtifact(adapter).step); + const tests = b.addTest(.{ .root_source_file = b.path("slack_mcp_adapter.zig"), .target = target, .optimize = optimize }); + tests.root_module.addImport("slack_mcp_ffi", ffi_mod); + const ts = b.step("test", "Test slack-mcp adapter"); ts.dependOn(&b.addRunArtifact(tests).step); +} diff --git a/cartridges/domains/communications/slack-mcp/adapter/slack_mcp_adapter.zig b/cartridges/domains/communications/slack-mcp/adapter/slack_mcp_adapter.zig new file mode 100644 index 0000000..de8f87e --- /dev/null +++ b/cartridges/domains/communications/slack-mcp/adapter/slack_mcp_adapter.zig @@ -0,0 +1,112 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// slack-mcp/adapter/slack_mcp_adapter.zig -- Unified three-protocol adapter. +// Replaces banned slack_mcp_adapter.v (zig, removed 2026-04-12). +// REST:9184 gRPC:9185 GraphQL:9186 +// Tools: slack_authenticate, slack_send_message, slack_list_channels, slack_read_thread... + +const std = @import("std"); +const ffi = @import("slack_mcp_ffi"); + +const REST_PORT: u16 = 9184; +const GRPC_PORT: u16 = 9185; +const GQL_PORT: u16 = 9186; +const MAX_CONN_BUF: usize = 16 * 1024; + +fn okJson(buf: []u8, msg: []const u8) []u8 { + const n = std.fmt.bufPrint(buf, "{\"success\":true,\"message\":\"{s}\"}", .{msg}) catch return buf[0..0]; return n; +} +fn errJson(buf: []u8, msg: []const u8) []u8 { + const n = std.fmt.bufPrint(buf, "{\"success\":false,\"error\":\"{s}\"}", .{msg}) catch return buf[0..0]; return n; +} +fn statusJson(buf: []u8) []u8 { + const n = std.fmt.bufPrint(buf, "{\"success\":true,\"state\":\"ready\",\"service\":\"slack-mcp\"}", .{}) catch return buf[0..0]; return n; +} + +const Response = struct { status: u16, body: []u8 }; + +fn dispatch(tool: []const u8, body: []const u8, resp: []u8) Response { + _ = body; + if (std.mem.eql(u8, tool, "slack_authenticate")) return .{ .status = 200, .body = okJson(resp, "slack_authenticate forwarded") }; + if (std.mem.eql(u8, tool, "slack_send_message")) return .{ .status = 200, .body = okJson(resp, "slack_send_message forwarded") }; + if (std.mem.eql(u8, tool, "slack_list_channels")) return .{ .status = 200, .body = okJson(resp, "slack_list_channels forwarded") }; + if (std.mem.eql(u8, tool, "slack_read_thread")) return .{ .status = 200, .body = okJson(resp, "slack_read_thread forwarded") }; + if (std.mem.eql(u8, tool, "slack_search")) return .{ .status = 200, .body = okJson(resp, "slack_search forwarded") }; + if (std.mem.eql(u8, tool, "slack_get_user")) return .{ .status = 200, .body = okJson(resp, "slack_get_user forwarded") }; + if (std.mem.eql(u8, tool, "slack_deauthenticate")) return .{ .status = 200, .body = okJson(resp, "slack_deauthenticate forwarded") }; + if (std.mem.eql(u8, tool, "status") or std.mem.eql(u8, tool, "health")) return .{ .status = 200, .body = statusJson(resp) }; + return .{ .status = 404, .body = errJson(resp, "Unknown tool") }; +} + +fn dispatchRest(path: []const u8, body: []const u8, resp: []u8) Response { + if (std.mem.startsWith(u8, path, "/tools/")) return dispatch(path["/tools/".len..], body, resp); + if (std.mem.eql(u8, path, "/status") or std.mem.eql(u8, path, "/health")) return .{ .status = 200, .body = statusJson(resp) }; + return .{ .status = 404, .body = errJson(resp, "Not found") }; +} + +fn dispatchGrpc(path: []const u8, body: []const u8, resp: []u8) Response { + const prefix = "/SlackMcpservice/"; + if (!std.mem.startsWith(u8, path, prefix)) return .{ .status = 404, .body = errJson(resp, "Not a recognized gRPC path") }; + const method = path[prefix.len..]; + const tool = blk: { + if (std.mem.eql(u8, method, "slack_authenticate")) break :blk "slack_authenticate"; + if (std.mem.eql(u8, method, "slack_send_message")) break :blk "slack_send_message"; + if (std.mem.eql(u8, method, "slack_list_channels")) break :blk "slack_list_channels"; + if (std.mem.eql(u8, method, "slack_read_thread")) break :blk "slack_read_thread"; + if (std.mem.eql(u8, method, "slack_search")) break :blk "slack_search"; + if (std.mem.eql(u8, method, "slack_get_user")) break :blk "slack_get_user"; + if (std.mem.eql(u8, method, "slack_deauthenticate")) break :blk "slack_deauthenticate"; + return .{ .status = 404, .body = errJson(resp, "Unknown gRPC method") }; + }; + return dispatch(tool, body, resp); +} + +fn dispatchGraphql(body: []const u8, resp: []u8) Response { + if (std.mem.indexOf(u8, body, "__schema") != null) return .{ .status = 200, .body = okJson(resp, "schema not supported") }; + if (std.mem.indexOf(u8, body, "authenticate") != null) return dispatch("slack_authenticate", body, resp); + if (std.mem.indexOf(u8, body, "send_message") != null) return dispatch("slack_send_message", body, resp); + if (std.mem.indexOf(u8, body, "list_channels") != null) return dispatch("slack_list_channels", body, resp); + if (std.mem.indexOf(u8, body, "read_thread") != null) return dispatch("slack_read_thread", body, resp); + if (std.mem.indexOf(u8, body, "search") != null) return dispatch("slack_search", body, resp); + if (std.mem.indexOf(u8, body, "get_user") != null) return dispatch("slack_get_user", body, resp); + if (std.mem.indexOf(u8, body, "deauthenticate") != null) return dispatch("slack_deauthenticate", body, resp); + return .{ .status = 200, .body = errJson(resp, "Unrecognised GraphQL operation") }; +} + +const Protocol = enum { rest, grpc, graphql }; + +fn handleConnection(conn: std.net.Server.Connection, proto: Protocol) void { + defer conn.stream.close(); + var in_buf: [MAX_CONN_BUF]u8 = undefined; + const n = conn.stream.read(&in_buf) catch return; + const req = in_buf[0..n]; + var path: []const u8 = "/"; var body: []const u8 = ""; + if (n > 4) { + const le = std.mem.indexOf(u8, req, "\r\n") orelse req.len; + const fl = req[0..le]; const sp1 = std.mem.indexOfScalar(u8, fl, ' ') orelse 0; + const ro = fl[sp1+1..]; const sp2 = std.mem.indexOfScalar(u8, ro, ' ') orelse ro.len; + path = ro[0..sp2]; + const bs = std.mem.indexOf(u8, req, "\r\n\r\n") orelse n; body = req[@min(bs+4,n)..]; + } + var rb: [MAX_CONN_BUF]u8 = undefined; + const result = switch (proto) { .rest => dispatchRest(path, body, &rb), .grpc => dispatchGrpc(path, body, &rb), .graphql => dispatchGraphql(body, &rb) }; + const ct: []const u8 = if (proto == .grpc) "application/grpc+json" else "application/json"; + var hb: [256]u8 = undefined; + const hdr = std.fmt.bufPrint(&hb, "HTTP/1.1 {d} OK\r\nContent-Type: {s}\r\nContent-Length: {d}\r\nConnection: close\r\n\r\n", .{ result.status, ct, result.body.len }) catch return; + _ = conn.stream.writeAll(hdr) catch return; _ = conn.stream.writeAll(result.body) catch return; +} + +fn listenLoop(port: u16, proto: Protocol) void { + const addr = std.net.Address.initIp4(.{ 127, 0, 0, 1 }, port) catch return; + var srv = addr.listen(.{ .reuse_address = true }) catch return; defer srv.deinit(); + while (true) { const conn = srv.accept() catch continue; handleConnection(conn, proto); } +} + +pub fn main() !void { + ffi.slack_mcp_init(); + const t1 = try std.Thread.spawn(.{}, listenLoop, .{ REST_PORT, Protocol.rest }); + const t2 = try std.Thread.spawn(.{}, listenLoop, .{ GRPC_PORT, Protocol.grpc }); + const t3 = try std.Thread.spawn(.{}, listenLoop, .{ GQL_PORT, Protocol.graphql }); + t1.join(); t2.join(); t3.join(); +} diff --git a/cartridges/domains/communications/slack-mcp/benchmarks/quick-bench.sh b/cartridges/domains/communications/slack-mcp/benchmarks/quick-bench.sh new file mode 100755 index 0000000..02acf6f --- /dev/null +++ b/cartridges/domains/communications/slack-mcp/benchmarks/quick-bench.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Benchmarks for slack-mcp cartridge. +set -euo pipefail + +echo "=== slack-mcp benchmarks ===" + +cd "$(dirname "${BASH_SOURCE[0]}")/../ffi" + +# Build with ReleaseFast for benchmarking +zig build -Doptimize=ReleaseFast 2>&1 + +echo "Session open/close cycle (1000 iterations):" +time for i in $(seq 1 1000); do + # This would call the FFI benchmark binary + true +done + +echo "" +echo "Benchmark placeholder β€” implement real benchmarks in Zig test blocks" +echo "or via the zig adapter HTTP benchmark tool." diff --git a/cartridges/domains/communications/slack-mcp/cartridge.json b/cartridges/domains/communications/slack-mcp/cartridge.json new file mode 100644 index 0000000..1bc6945 --- /dev/null +++ b/cartridges/domains/communications/slack-mcp/cartridge.json @@ -0,0 +1,190 @@ +{ + "$schema": "https://boj.dev/schemas/cartridge/v1.json", + "spdx": "MPL-2.0", + "copyright": "Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath)", + "name": "slack-mcp", + "version": "0.1.0", + "description": "Slack workspace gateway. Send messages, list channels, read threads, search messages, and manage users.", + "domain": "Communications", + "tier": "Ayo", + "protocols": [ + "MCP", + "REST" + ], + "auth": { + "method": "none", + "env_var": null, + "credential_source": null + }, + "api": { + "base_url": "local://slack-mcp", + "content_type": "application/json" + }, + "tools": [ + { + "name": "slack_authenticate", + "description": "Authenticate with a Slack workspace using a bot token.", + "inputSchema": { + "type": "object", + "properties": { + "token": { + "type": "string", + "description": "Slack bot token (xoxb-...)" + } + }, + "required": [ + "token" + ] + } + }, + { + "name": "slack_send_message", + "description": "Send a message to a channel or thread.", + "inputSchema": { + "type": "object", + "properties": { + "slot": { + "type": "integer", + "description": "Session slot from slack_authenticate" + }, + "channel": { + "type": "string", + "description": "Channel ID or name (e.g. '#general')" + }, + "text": { + "type": "string", + "description": "Message text (supports mrkdwn)" + }, + "thread_ts": { + "type": "string", + "description": "Thread timestamp to reply to (optional)" + } + }, + "required": [ + "slot", + "channel", + "text" + ] + } + }, + { + "name": "slack_list_channels", + "description": "List channels in the workspace.", + "inputSchema": { + "type": "object", + "properties": { + "slot": { + "type": "integer", + "description": "Session slot" + }, + "types": { + "type": "string", + "description": "Channel types: public_channel / private_channel / mpim / im (default: public_channel)" + } + }, + "required": [ + "slot" + ] + } + }, + { + "name": "slack_read_thread", + "description": "Read messages in a thread.", + "inputSchema": { + "type": "object", + "properties": { + "slot": { + "type": "integer", + "description": "Session slot" + }, + "channel": { + "type": "string", + "description": "Channel ID" + }, + "thread_ts": { + "type": "string", + "description": "Thread timestamp" + } + }, + "required": [ + "slot", + "channel", + "thread_ts" + ] + } + }, + { + "name": "slack_search", + "description": "Search messages in the workspace.", + "inputSchema": { + "type": "object", + "properties": { + "slot": { + "type": "integer", + "description": "Session slot" + }, + "query": { + "type": "string", + "description": "Search query" + }, + "count": { + "type": "integer", + "description": "Max results (default: 20)" + } + }, + "required": [ + "slot", + "query" + ] + } + }, + { + "name": "slack_get_user", + "description": "Get user profile information.", + "inputSchema": { + "type": "object", + "properties": { + "slot": { + "type": "integer", + "description": "Session slot" + }, + "user_id": { + "type": "string", + "description": "Slack user ID" + } + }, + "required": [ + "slot", + "user_id" + ] + } + }, + { + "name": "slack_deauthenticate", + "description": "Release a Slack session slot.", + "inputSchema": { + "type": "object", + "properties": { + "slot": { + "type": "integer", + "description": "Session slot to release" + } + }, + "required": [ + "slot" + ] + } + } + ], + "ffi": { + "so_path": "ffi/zig-out/lib/libslack_mcp.so", + "abi_version": "ADR-0006", + "symbols": [ + "boj_cartridge_init", + "boj_cartridge_deinit", + "boj_cartridge_name", + "boj_cartridge_version", + "boj_cartridge_invoke" + ] + } +} diff --git a/cartridges/domains/communications/slack-mcp/ffi/README.adoc b/cartridges/domains/communications/slack-mcp/ffi/README.adoc new file mode 100644 index 0000000..6c3712e --- /dev/null +++ b/cartridges/domains/communications/slack-mcp/ffi/README.adoc @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += slack-mcp / ffi β€” Zig FFI layer +:orientation: shallow + +== Purpose + +C-ABI exports bridging this cartridge's Idris2 ABI to the runtime. Consumed by +the adapter for protocol dispatch and by the trunk loader for mount. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” produces `zig-out/lib/libslack.so`. +| `slack_mcp_ffi.zig` | C-ABI exports (13 exports, 7 inline tests, 753 lines). +|=== + +== Invariants + +* **Null-pointer rejection** on `[*c]const u8` arguments before any + dereference. +* **Negative return values** carry documented error meanings; zero is + success (matching the trunk error-code convention). +* **No allocator leaks** β€” every allocation path pairs with `defer free`. + +== Test/proof surface + +7 inline `test "..."` block(s) in `slack_mcp_ffi.zig`. Run with +`cd ffi && zig build test`. + +== Read-first + +. `slack_mcp_ffi.zig` β€” C-ABI signatures and guards. +. Trunk `ffi/zig/src/catalogue.zig` for how this `.so` is loaded. diff --git a/cartridges/domains/communications/slack-mcp/ffi/build.zig b/cartridges/domains/communications/slack-mcp/ffi/build.zig new file mode 100644 index 0000000..a87fbee --- /dev/null +++ b/cartridges/domains/communications/slack-mcp/ffi/build.zig @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + // Module + // Shared ADR-0006 invoke-shim module (relative path up to boj-server trunk). + + const shim_mod = b.addModule("cartridge_shim", .{ + + .root_source_file = b.path("../../../ffi/zig/src/cartridge_shim.zig"), + + .target = target, + + .optimize = optimize, + + }); + + const ffi_mod = b.addModule("slack_mcp", .{ + .root_source_file = b.path("slack_mcp_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + ffi_mod.addImport("cartridge_shim", shim_mod); + + // Shared library + const lib = b.addLibrary(.{ + .name = "slack_mcp", + .root_module = ffi_mod, + .linkage = .dynamic, + }); + lib.linkLibC(); + b.installArtifact(lib); + + // Tests + const tests = b.addTest(.{ + .root_module = ffi_mod, + }); + tests.linkLibC(); + + const run_tests = b.addRunArtifact(tests); + const test_step = b.step("test", "Run FFI tests"); + test_step.dependOn(&run_tests.step); +} diff --git a/cartridges/domains/communications/slack-mcp/ffi/cartridge_shim.zig b/cartridges/domains/communications/slack-mcp/ffi/cartridge_shim.zig new file mode 100644 index 0000000..0e399db --- /dev/null +++ b/cartridges/domains/communications/slack-mcp/ffi/cartridge_shim.zig @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// cartridge_shim.zig β€” Shared helpers for the ADR-0006 five-symbol +// cartridge ABI (`boj_cartridge_init / deinit / name / version / invoke`). +// +// The shim centralises the seven-code return convention, NUL-argument +// guards, tool-name comparison, and the buffer-too-small path so each +// cartridge's `boj_cartridge_invoke` can stay short β€” typically a tool +// table plus `shim.writeResult(...)`. +// +// Cartridges import this file by relative path (no build-graph change +// needed). Example: +// +// const shim = @import("../../../ffi/zig/src/cartridge_shim.zig"); +// +// export fn boj_cartridge_invoke( +// tool_name: [*c]const u8, +// json_args: [*c]const u8, +// out_buf: [*c]u8, +// in_out_len: [*c]usize, +// ) callconv(.c) i32 { +// _ = json_args; +// if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; +// const body = if (shim.toolIs(tool_name, "foo")) "{\"result\":{}}" +// else return shim.RC_UNKNOWN_TOOL; +// return shim.writeResult(out_buf, in_out_len, body); +// } + +const std = @import("std"); + +// ── Return codes (ADR-0006 Β§Return codes) ──────────────────────────── +// +// Frozen by ADR-0006. New failure modes compose these via the error +// JSON body β€” the integer surface does not grow without a follow-up ADR. + +pub const RC_SUCCESS: i32 = 0; +pub const RC_UNKNOWN_TOOL: i32 = -1; +pub const RC_BAD_ARGS: i32 = -2; +pub const RC_BUFFER_TOO_SMALL: i32 = -3; +pub const RC_RUNTIME_ERROR: i32 = -4; +pub const RC_PANIC: i32 = -5; +pub const RC_AUTH_DENIED: i32 = -6; + +// ── Invoke-path helpers ────────────────────────────────────────────── + +/// True if any of the three mandatory `boj_cartridge_invoke` output-path +/// pointers is null. Use at the top of every invoke to short-circuit to +/// `RC_BAD_ARGS`. +pub fn invokeArgsNull( + tool_name: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) bool { + return tool_name == null or out_buf == null or in_out_len == null; +} + +/// Compare a C-NUL-terminated tool-name pointer against a Zig string +/// literal. Caller must have already verified `tool_name` is non-null +/// (usually via `invokeArgsNull`). +pub fn toolIs(tool_name: [*c]const u8, expected: []const u8) bool { + const s = std.mem.span(@as([*:0]const u8, @ptrCast(tool_name))); + return std.mem.eql(u8, s, expected); +} + +/// Copy `body` into `out_buf[0..*in_out_len]` (as a capacity) and update +/// `*in_out_len` to the number of bytes written. Returns `RC_SUCCESS`. +/// +/// If `body.len` exceeds the current capacity stored in `*in_out_len`, +/// sets `*in_out_len` to the required size and returns +/// `RC_BUFFER_TOO_SMALL` β€” the caller is then expected to re-allocate +/// and retry, per ADR-0006 Β§Memory ownership. +/// +/// Caller must have already verified that `out_buf` and `in_out_len` +/// are non-null. +pub fn writeResult( + out_buf: [*c]u8, + in_out_len: [*c]usize, + body: []const u8, +) i32 { + const cap = in_out_len.*; + if (body.len > cap) { + in_out_len.* = body.len; + return RC_BUFFER_TOO_SMALL; + } + @memcpy(out_buf[0..body.len], body); + in_out_len.* = body.len; + return RC_SUCCESS; +} + +// ── Tests ──────────────────────────────────────────────────────────── + +test "writeResult: body fits, writes and sets length" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); + try std.testing.expectEqualStrings("hello", buf[0..len]); +} + +test "writeResult: too small returns -3 and sets required length" { + var buf: [2]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_BUFFER_TOO_SMALL, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: exact-fit succeeds" { + var buf: [5]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: empty body" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, ""); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 0), len); +} + +test "toolIs: matches and rejects" { + const name: [*:0]const u8 = "foo"; + try std.testing.expect(toolIs(@ptrCast(name), "foo")); + try std.testing.expect(!toolIs(@ptrCast(name), "bar")); + try std.testing.expect(!toolIs(@ptrCast(name), "foobar")); + try std.testing.expect(!toolIs(@ptrCast(name), "fo")); +} + +test "invokeArgsNull: detects each null slot" { + var buf: [4]u8 = undefined; + var len: usize = 4; + const name: [*:0]const u8 = "x"; + try std.testing.expect(!invokeArgsNull(@ptrCast(name), &buf, &len)); + try std.testing.expect(invokeArgsNull(null, &buf, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), null, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), &buf, null)); +} diff --git a/cartridges/domains/communications/slack-mcp/ffi/slack_mcp_ffi.zig b/cartridges/domains/communications/slack-mcp/ffi/slack_mcp_ffi.zig new file mode 100644 index 0000000..b156fad --- /dev/null +++ b/cartridges/domains/communications/slack-mcp/ffi/slack_mcp_ffi.zig @@ -0,0 +1,853 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// slack_mcp_ffi.zig β€” C-ABI FFI for the Slack Web API / Events API cartridge. +// +// Implements the state machine defined in SlackMcp.SafeComms (Idris2 ABI). +// Provides real HTTP dispatch to the Slack Web API (https://slack.com/api/) +// via std.http.Client, per-method rate-limit tracking (Tier 1-4), and +// Bearer-token authentication via xoxb-* bot tokens obtained from vault-mcp. +// +// Thread-safe via std.Thread.Mutex. No heap allocations for result buffers. + +const std = @import("std"); + +// --------------------------------------------------------------------------- +// Connection state machine (matches Idris2 ConnState exactly) +// --------------------------------------------------------------------------- + +/// Connection lifecycle states mirroring SlackMcp.SafeComms.ConnState. +pub const ConnState = enum(c_int) { + disconnected = 0, + authenticating = 1, + connected = 2, + rate_limited = 3, + err = 4, +}; + +/// Check whether a transition between two states is valid. +/// Encodes the same transition table as the Idris2 ValidTransition GADT. +fn isValidTransition(from: ConnState, to: ConnState) bool { + return switch (from) { + .disconnected => to == .authenticating, + .authenticating => to == .connected or to == .err, + .connected => to == .rate_limited or to == .err or to == .disconnected, + .rate_limited => to == .connected, + .err => to == .disconnected, + }; +} + +// --------------------------------------------------------------------------- +// Slack action vocabulary (matches Idris2 SlackAction exactly) +// --------------------------------------------------------------------------- + +/// All 16 Slack actions supported by this cartridge. +pub const SlackAction = enum(c_int) { + send_message = 0, + list_channels = 1, + get_channel = 2, + list_users = 3, + get_user = 4, + post_reaction = 5, + remove_reaction = 6, + upload_file = 7, + search_messages = 8, + list_conversations = 9, + get_thread = 10, + update_message = 11, + delete_message = 12, + set_status = 13, + create_channel = 14, + invite_to_channel = 15, +}; + +/// Map each action to its Slack Web API method path. +fn actionToMethod(action: SlackAction) []const u8 { + return switch (action) { + .send_message => "chat.postMessage", + .list_channels => "conversations.list", + .get_channel => "conversations.info", + .list_users => "users.list", + .get_user => "users.info", + .post_reaction => "reactions.add", + .remove_reaction => "reactions.remove", + .upload_file => "files.upload", + .search_messages => "search.messages", + .list_conversations => "conversations.list", + .get_thread => "conversations.replies", + .update_message => "chat.update", + .delete_message => "chat.delete", + .set_status => "users.profile.set", + .create_channel => "conversations.create", + .invite_to_channel => "conversations.invite", + }; +} + +// --------------------------------------------------------------------------- +// Rate-limit tiers (Slack per-method rate limits) +// --------------------------------------------------------------------------- + +/// Slack rate tiers. Each tier has a different requests-per-minute budget. +/// Tier 1: ~1 req/min, Tier 2: ~20 req/min, Tier 3: ~50 req/min, Tier 4: ~100+ req/min. +pub const RateTier = enum(c_int) { + tier1 = 1, + tier2 = 2, + tier3 = 3, + tier4 = 4, +}; + +/// Map each action to its rate tier (matches Idris2 actionRateTier). +fn actionRateTier(action: SlackAction) RateTier { + return switch (action) { + .send_message => .tier3, + .list_channels => .tier2, + .get_channel => .tier3, + .list_users => .tier2, + .get_user => .tier4, + .post_reaction => .tier3, + .remove_reaction => .tier3, + .upload_file => .tier2, + .search_messages => .tier2, + .list_conversations => .tier2, + .get_thread => .tier3, + .update_message => .tier3, + .delete_message => .tier3, + .set_status => .tier3, + .create_channel => .tier2, + .invite_to_channel => .tier3, + }; +} + +/// Maximum requests per minute for each tier. +fn tierBudget(tier: RateTier) u32 { + return switch (tier) { + .tier1 => 1, + .tier2 => 20, + .tier3 => 50, + .tier4 => 100, + }; +} + +// --------------------------------------------------------------------------- +// Per-tier rate tracking +// --------------------------------------------------------------------------- + +/// Tracks usage within a single rate tier. +const TierTracker = struct { + /// Number of requests made in the current window. + count: u32 = 0, + /// Epoch timestamp (seconds) when the current window started. + window_start: i64 = 0, + + /// Record one request. Returns true if within budget, false if exhausted. + fn record(self: *TierTracker, now: i64, budget: u32) bool { + // Reset window every 60 seconds. + if (now - self.window_start >= 60) { + self.window_start = now; + self.count = 0; + } + if (self.count >= budget) return false; + self.count += 1; + return true; + } +}; + +// --------------------------------------------------------------------------- +// Session slot pool (thread-safe, fixed-size) +// --------------------------------------------------------------------------- + +const MAX_SESSIONS: usize = 16; +const BUF_SIZE: usize = 8192; +const TOKEN_MAX: usize = 256; + +const SessionSlot = struct { + active: bool = false, + state: ConnState = .disconnected, + /// Bearer token (xoxb-*) obtained from vault-mcp. + token_buf: [TOKEN_MAX]u8 = undefined, + token_len: usize = 0, + /// Workspace name populated after authentication. + workspace_buf: [256]u8 = undefined, + workspace_len: usize = 0, + /// Per-tier rate trackers (indexed by tier ordinal 1–4). + rate_trackers: [4]TierTracker = [_]TierTracker{.{}} ** 4, + /// Count of messages sent in this session (for panel metrics). + messages_sent: u32 = 0, + /// General-purpose output buffer for API responses. + out_buf: [BUF_SIZE]u8 = undefined, + out_len: usize = 0, +}; + +var sessions: [MAX_SESSIONS]SessionSlot = [_]SessionSlot{.{}} ** MAX_SESSIONS; +var mutex: std.Thread.Mutex = .{}; + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +/// Copy a null-terminated C string into a fixed buffer. Returns bytes written. +fn copyFromCStr(dest: []u8, src: [*c]const u8) usize { + if (src == null) return 0; + var i: usize = 0; + while (i < dest.len and src[i] != 0) : (i += 1) { + dest[i] = src[i]; + } + return i; +} + +/// Write a slice into an output buffer pointer + length pointer. +fn writeOutput(buf: [*c]u8, buf_cap: usize, out_len: *c_int, data: []const u8) void { + if (buf == null) return; + const to_copy = @min(data.len, buf_cap); + for (0..to_copy) |i| { + buf[i] = data[i]; + } + out_len.* = @intCast(to_copy); +} + +/// Get a slot by index, returning null if invalid or inactive. +fn getSlot(slot_idx: c_int) ?*SessionSlot { + const idx: usize = std.math.cast(usize, slot_idx) orelse return null; + if (idx >= MAX_SESSIONS) return null; + const slot = &sessions[idx]; + if (!slot.active) return null; + return slot; +} + +/// Attempt a state transition on a slot. Returns 0 on success, -2 if invalid. +fn tryTransition(slot: *SessionSlot, target: ConnState) c_int { + if (!isValidTransition(slot.state, target)) return -2; + slot.state = target; + return 0; +} + +/// Slack Web API base URL. +const SLACK_API_BASE: []const u8 = "https://slack.com/api/"; + +/// Perform a real HTTP POST to the Slack Web API. +/// Slack API always uses POST with form or JSON body. +/// slot: session slot (must be connected, caller verifies). +/// method_name: Slack API method (e.g. "chat.postMessage"). +/// params_json: JSON-encoded parameters (may be null/empty). +/// out_buf: fixed output buffer for writing the API response. +/// Returns bytes written to out_buf, or 0 on error. +fn doSlackApiCall(slot: *SessionSlot, method_name: []const u8, params_json: ?[]const u8, out_buf: []u8) usize { + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); + defer arena.deinit(); + const allocator = arena.allocator(); + + // Build URL: https://slack.com/api/<method> + const url_str = std.fmt.allocPrint(allocator, "{s}{s}", .{ SLACK_API_BASE, method_name }) catch return 0; + const uri = std.Uri.parse(url_str) catch return 0; + + // Build Bearer auth header from stored token + const auth_header = std.fmt.allocPrint(allocator, "Bearer {s}", .{slot.token_buf[0..slot.token_len]}) catch return 0; + + var client = std.http.Client{ .allocator = allocator }; + defer client.deinit(); + + var headers_buf: [3]std.http.Header = .{ + .{ .name = "Authorization", .value = auth_header }, + .{ .name = "Content-Type", .value = "application/json; charset=utf-8" }, + .{ .name = "User-Agent", .value = "boj-server/1.0 (slack-mcp cartridge)" }, + }; + + // Fetch the request (Zig 0.15 API β€” replaces open/send/wait) + const body = params_json orelse "{}"; + var aw: std.Io.Writer.Allocating = .init(allocator); + defer aw.deinit(); + + const fetch_result = client.fetch(.{ + .method = .POST, + .location = .{ .uri = uri }, + .extra_headers = &headers_buf, + .payload = body, + .response_writer = &aw.writer, + }) catch return 0; + + // Check for rate limiting (HTTP 429) + const status_code = @intFromEnum(fetch_result.status); + if (status_code == 429) { + slot.state = .rate_limited; + return 0; + } + + // Copy response body into the caller's output buffer + const response_bytes = aw.writer.buffered(); + const to_copy = @min(response_bytes.len, out_buf.len); + @memcpy(out_buf[0..to_copy], response_bytes[0..to_copy]); + return to_copy; +} + +// --------------------------------------------------------------------------- +// C-ABI exports β€” state machine +// --------------------------------------------------------------------------- + +/// Check if a state transition is valid. Returns 1 (valid) or 0 (invalid). +pub export fn slack_mcp_can_transition(from: c_int, to: c_int) c_int { + const f = std.meta.intToEnum(ConnState, from) catch return 0; + const t = std.meta.intToEnum(ConnState, to) catch return 0; + return if (isValidTransition(f, t)) 1 else 0; +} + +// --------------------------------------------------------------------------- +// C-ABI exports β€” authentication +// --------------------------------------------------------------------------- + +/// Authenticate with a Slack bot token (xoxb-*). +/// Transitions: Disconnected -> Authenticating -> Connected (or Error). +/// Returns slot index (>= 0) on success, negative on error. +/// Error codes: -1 = no free slots, -3 = empty token, -4 = invalid token prefix. +pub export fn slack_mcp_authenticate(token: [*c]const u8) c_int { + if (token == null) return -3; + // Validate xoxb- prefix (5 bytes). + const prefix = "xoxb-"; + for (prefix, 0..) |ch, i| { + if (token[i] == 0 or token[i] != ch) return -4; + } + + mutex.lock(); + defer mutex.unlock(); + + // Find a free slot. + for (&sessions, 0..) |*slot, idx| { + if (!slot.active) { + slot.active = true; + slot.state = .authenticating; + slot.token_len = copyFromCStr(&slot.token_buf, token); + slot.messages_sent = 0; + slot.rate_trackers = [_]TierTracker{.{}} ** 4; + + // Call auth.test to verify the token and retrieve workspace info. + const auth_response_len = doSlackApiCall(slot, "auth.test", "{}", &slot.workspace_buf); + if (auth_response_len == 0) { + // Network error during auth verification β€” still connect + // but mark workspace as unknown. + const ws = "unknown"; + @memcpy(slot.workspace_buf[0..ws.len], ws); + slot.workspace_len = ws.len; + } else { + slot.workspace_len = auth_response_len; + } + + // Authenticating -> Connected. + slot.state = .connected; + return @intCast(idx); + } + } + return -1; // No free slots. +} + +// --------------------------------------------------------------------------- +// C-ABI exports β€” send message +// --------------------------------------------------------------------------- + +/// Send a message to a Slack channel (optionally threaded). +/// channel: C string channel ID (e.g. "C01234ABCDE"). +/// text: C string message body. +/// thread_ts: C string thread timestamp, or null for top-level message. +/// out_buf / out_cap: caller-provided output buffer for the JSON response. +/// out_len: receives the number of bytes written. +/// Returns 0 on success, negative on error. +/// Error codes: -1 = invalid slot, -2 = bad state, -5 = rate limited. +pub export fn slack_mcp_send_message( + slot_idx: c_int, + channel: [*c]const u8, + text: [*c]const u8, + thread_ts: [*c]const u8, + out_buf: [*c]u8, + out_cap: c_int, + out_len: *c_int, +) c_int { + mutex.lock(); + defer mutex.unlock(); + + const slot = getSlot(slot_idx) orelse return -1; + if (slot.state != .connected) return -2; + + // Rate-limit check for send_message (Tier 3). + const tier = actionRateTier(.send_message); + const tier_idx: usize = @intCast(@intFromEnum(tier) - 1); + const now = std.time.timestamp(); + if (!slot.rate_trackers[tier_idx].record(now, tierBudget(tier))) { + slot.state = .rate_limited; + return -5; + } + + // Build JSON body for chat.postMessage + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); + defer arena.deinit(); + const allocator = arena.allocator(); + + const ch_str = if (channel != null) blk: { + var i: usize = 0; + while (i < 256 and channel[i] != 0) : (i += 1) {} + break :blk channel[0..i]; + } else ""; + const txt_str = if (text != null) blk: { + var i: usize = 0; + while (i < 4096 and text[i] != 0) : (i += 1) {} + break :blk text[0..i]; + } else ""; + const ts_str: ?[]const u8 = if (thread_ts != null) blk: { + var i: usize = 0; + while (i < 256 and thread_ts[i] != 0) : (i += 1) {} + if (i == 0) break :blk null; + break :blk thread_ts[0..i]; + } else null; + + const params = if (ts_str) |ts| + std.fmt.allocPrint(allocator, "{{\"channel\":\"{s}\",\"text\":\"{s}\",\"thread_ts\":\"{s}\"}}", .{ ch_str, txt_str, ts }) catch return -4 + else + std.fmt.allocPrint(allocator, "{{\"channel\":\"{s}\",\"text\":\"{s}\"}}", .{ ch_str, txt_str }) catch return -4; + + const method = actionToMethod(.send_message); + const len = doSlackApiCall(slot, method, params, &slot.out_buf); + slot.out_len = len; + slot.messages_sent += 1; + + const cap: usize = std.math.cast(usize, out_cap) orelse 0; + writeOutput(out_buf, cap, out_len, slot.out_buf[0..len]); + return 0; +} + +// --------------------------------------------------------------------------- +// C-ABI exports β€” list channels +// --------------------------------------------------------------------------- + +/// List Slack channels in the authenticated workspace. +/// Returns 0 on success, negative on error. +pub export fn slack_mcp_list_channels( + slot_idx: c_int, + out_buf: [*c]u8, + out_cap: c_int, + out_len: *c_int, +) c_int { + mutex.lock(); + defer mutex.unlock(); + + const slot = getSlot(slot_idx) orelse return -1; + if (slot.state != .connected) return -2; + + const tier = actionRateTier(.list_channels); + const tier_idx: usize = @intCast(@intFromEnum(tier) - 1); + const now = std.time.timestamp(); + if (!slot.rate_trackers[tier_idx].record(now, tierBudget(tier))) { + slot.state = .rate_limited; + return -5; + } + + const method = actionToMethod(.list_channels); + const len = doSlackApiCall(slot, method, null, &slot.out_buf); + slot.out_len = len; + + const cap: usize = std.math.cast(usize, out_cap) orelse 0; + writeOutput(out_buf, cap, out_len, slot.out_buf[0..len]); + return 0; +} + +// --------------------------------------------------------------------------- +// C-ABI exports β€” search messages +// --------------------------------------------------------------------------- + +/// Search Slack messages matching a query string. +/// Returns 0 on success, negative on error. +pub export fn slack_mcp_search( + slot_idx: c_int, + query: [*c]const u8, + out_buf: [*c]u8, + out_cap: c_int, + out_len: *c_int, +) c_int { + mutex.lock(); + defer mutex.unlock(); + + const slot = getSlot(slot_idx) orelse return -1; + if (slot.state != .connected) return -2; + + const tier = actionRateTier(.search_messages); + const tier_idx: usize = @intCast(@intFromEnum(tier) - 1); + const now = std.time.timestamp(); + if (!slot.rate_trackers[tier_idx].record(now, tierBudget(tier))) { + slot.state = .rate_limited; + return -5; + } + + // Build search params from query C string + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); + defer arena.deinit(); + const allocator = arena.allocator(); + + const q_str = if (query != null) blk: { + var i: usize = 0; + while (i < 4096 and query[i] != 0) : (i += 1) {} + break :blk query[0..i]; + } else ""; + + const params = std.fmt.allocPrint(allocator, "{{\"query\":\"{s}\"}}", .{q_str}) catch return -4; + + const method = actionToMethod(.search_messages); + const len = doSlackApiCall(slot, method, params, &slot.out_buf); + slot.out_len = len; + + const cap: usize = std.math.cast(usize, out_cap) orelse 0; + writeOutput(out_buf, cap, out_len, slot.out_buf[0..len]); + return 0; +} + +// --------------------------------------------------------------------------- +// C-ABI exports β€” generic API call +// --------------------------------------------------------------------------- + +/// Invoke an arbitrary Slack Web API method by action ID. +/// action_id: integer matching SlackAction enum. +/// params: JSON-encoded parameters (C string, may be null). +/// Returns 0 on success, negative on error. +/// Error codes: -1 = invalid slot, -2 = bad state, -5 = rate limited, -6 = unknown action. +pub export fn slack_mcp_api_call( + slot_idx: c_int, + action_id: c_int, + params: [*c]const u8, + out_buf: [*c]u8, + out_cap: c_int, + out_len: *c_int, +) c_int { + const action = std.meta.intToEnum(SlackAction, action_id) catch return -6; + + mutex.lock(); + defer mutex.unlock(); + + const slot = getSlot(slot_idx) orelse return -1; + if (slot.state != .connected) return -2; + + const tier = actionRateTier(action); + const tier_idx: usize = @intCast(@intFromEnum(tier) - 1); + const now = std.time.timestamp(); + if (!slot.rate_trackers[tier_idx].record(now, tierBudget(tier))) { + slot.state = .rate_limited; + return -5; + } + + // Extract params as a slice if present + const params_slice: ?[]const u8 = if (params != null) blk: { + var i: usize = 0; + while (i < 8192 and params[i] != 0) : (i += 1) {} + if (i == 0) break :blk null; + break :blk params[0..i]; + } else null; + + const method = actionToMethod(action); + const len = doSlackApiCall(slot, method, params_slice, &slot.out_buf); + slot.out_len = len; + + // Track messages sent for panel metrics. + if (action == .send_message) slot.messages_sent += 1; + + const cap: usize = std.math.cast(usize, out_cap) orelse 0; + writeOutput(out_buf, cap, out_len, slot.out_buf[0..len]); + return 0; +} + +// --------------------------------------------------------------------------- +// C-ABI exports β€” session management +// --------------------------------------------------------------------------- + +/// Disconnect a session gracefully. Returns 0 on success. +/// Error codes: -1 = invalid slot, -2 = bad state transition. +pub export fn slack_mcp_disconnect(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const slot = getSlot(slot_idx) orelse return -1; + const rc = tryTransition(slot, .disconnected); + if (rc == 0) { + slot.active = false; + slot.token_len = 0; + slot.workspace_len = 0; + slot.messages_sent = 0; + } + return rc; +} + +/// Get the current connection state. Returns state int or -1 if invalid slot. +pub export fn slack_mcp_session_state(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const slot = getSlot(slot_idx) orelse return -1; + return @intFromEnum(slot.state); +} + +/// Get the workspace name for a connected session. +/// Writes workspace name into out_buf. Returns 0 on success, -1 if invalid. +pub export fn slack_mcp_workspace(slot_idx: c_int, out_buf: [*c]u8, out_cap: c_int, out_len: *c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const slot = getSlot(slot_idx) orelse return -1; + const cap: usize = std.math.cast(usize, out_cap) orelse 0; + writeOutput(out_buf, cap, out_len, slot.workspace_buf[0..slot.workspace_len]); + return 0; +} + +/// Get the messages-sent counter for a session. +pub export fn slack_mcp_messages_sent(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const slot = getSlot(slot_idx) orelse return -1; + return @intCast(slot.messages_sent); +} + +/// Get the current request count for a rate tier (1–4) on a given session. +/// Returns count (>= 0) or -1 if invalid. +pub export fn slack_mcp_rate_count(slot_idx: c_int, tier_id: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const slot = getSlot(slot_idx) orelse return -1; + if (tier_id < 1 or tier_id > 4) return -1; + const idx: usize = @intCast(tier_id - 1); + return @intCast(slot.rate_trackers[idx].count); +} + +/// Recover from rate-limited state (RateLimited -> Connected). +/// Returns 0 on success, -2 if bad transition. +pub export fn slack_mcp_rate_recover(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const slot = getSlot(slot_idx) orelse return -1; + return tryTransition(slot, .connected); +} + +/// Reset all sessions (test/debug use only). +pub export fn slack_mcp_reset() void { + mutex.lock(); + defer mutex.unlock(); + sessions = [_]SessionSlot{.{}} ** MAX_SESSIONS; +} + +// --------------------------------------------------------------------------- +// ═══════════════════════════════════════════════════════════════════════ +// Standard ABI (ADR-0005 four symbols + ADR-0006 invoke) +// ═══════════════════════════════════════════════════════════════════════ + +const shim = @import("cartridge_shim.zig"); + +const CARTRIDGE_NAME_PTR: [*:0]const u8 = "slack-mcp"; +const CARTRIDGE_VERSION_PTR: [*:0]const u8 = "0.1.0"; + +export fn boj_cartridge_init() callconv(.c) c_int { + return 0; +} + +export fn boj_cartridge_deinit() callconv(.c) void {} + +export fn boj_cartridge_name() callconv(.c) [*:0]const u8 { + return CARTRIDGE_NAME_PTR; +} + +export fn boj_cartridge_version() callconv(.c) [*:0]const u8 { + return CARTRIDGE_VERSION_PTR; +} + +/// Dispatch the cartridge.json MCP tools. Grade D Alpha stubs. +export fn boj_cartridge_invoke( + tool_name: [*c]const u8, + json_args: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) callconv(.c) i32 { + _ = json_args; + if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; + + const body: []const u8 = if (shim.toolIs(tool_name, "slack_authenticate")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "slack_send_message")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "slack_list_channels")) + "{\"result\":{\"items\":[],\"count\":0,\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "slack_read_thread")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "slack_search")) + "{\"result\":{\"matches\":[],\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "slack_get_user")) + "{\"result\":{\"metadata\":{},\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "slack_deauthenticate")) + "{\"result\":{\"status\":\"stub\"}}" +else + return shim.RC_UNKNOWN_TOOL; + + return shim.writeResult(out_buf, in_out_len, body); +} + +// Tests +// --------------------------------------------------------------------------- + +test "state machine transitions" { + // Valid transitions. + try std.testing.expectEqual(@as(c_int, 1), slack_mcp_can_transition(0, 1)); // disconn -> auth + try std.testing.expectEqual(@as(c_int, 1), slack_mcp_can_transition(1, 2)); // auth -> connected + try std.testing.expectEqual(@as(c_int, 1), slack_mcp_can_transition(2, 3)); // connected -> rate_limited + try std.testing.expectEqual(@as(c_int, 1), slack_mcp_can_transition(3, 2)); // rate_limited -> connected + try std.testing.expectEqual(@as(c_int, 1), slack_mcp_can_transition(2, 4)); // connected -> error + try std.testing.expectEqual(@as(c_int, 1), slack_mcp_can_transition(1, 4)); // auth -> error + try std.testing.expectEqual(@as(c_int, 1), slack_mcp_can_transition(4, 0)); // error -> disconn + try std.testing.expectEqual(@as(c_int, 1), slack_mcp_can_transition(2, 0)); // connected -> disconn + + // Invalid transitions. + try std.testing.expectEqual(@as(c_int, 0), slack_mcp_can_transition(0, 2)); // disconn -> connected + try std.testing.expectEqual(@as(c_int, 0), slack_mcp_can_transition(3, 0)); // rate_limited -> disconn + try std.testing.expectEqual(@as(c_int, 0), slack_mcp_can_transition(4, 2)); // error -> connected + try std.testing.expectEqual(@as(c_int, 0), slack_mcp_can_transition(0, 4)); // disconn -> error + + // Out of range. + try std.testing.expectEqual(@as(c_int, 0), slack_mcp_can_transition(99, 0)); + try std.testing.expectEqual(@as(c_int, 0), slack_mcp_can_transition(0, 99)); +} + +test "authenticate and disconnect lifecycle" { + slack_mcp_reset(); + + // Authenticate with a valid bot token. + const slot = slack_mcp_authenticate("xoxb-test-token-12345"); + try std.testing.expect(slot >= 0); + + // Should be connected. + try std.testing.expectEqual(@as(c_int, 2), slack_mcp_session_state(slot)); + + // Graceful disconnect. + try std.testing.expectEqual(@as(c_int, 0), slack_mcp_disconnect(slot)); +} + +test "reject invalid token prefix" { + slack_mcp_reset(); + + // Missing xoxb- prefix. + try std.testing.expectEqual(@as(c_int, -4), slack_mcp_authenticate("bad-token")); + + // Null token. + try std.testing.expectEqual(@as(c_int, -3), slack_mcp_authenticate(null)); +} + +test "send message updates counter" { + slack_mcp_reset(); + + const slot = slack_mcp_authenticate("xoxb-test-token-msg"); + try std.testing.expect(slot >= 0); + + var buf: [1024]u8 = undefined; + var out_len: c_int = 0; + + const rc = slack_mcp_send_message(slot, "C01234", "hello", null, &buf, 1024, &out_len); + try std.testing.expectEqual(@as(c_int, 0), rc); + try std.testing.expect(out_len > 0); + try std.testing.expectEqual(@as(c_int, 1), slack_mcp_messages_sent(slot)); + + _ = slack_mcp_disconnect(slot); +} + +test "rate tier tracking per session" { + slack_mcp_reset(); + + const slot = slack_mcp_authenticate("xoxb-test-token-rate"); + try std.testing.expect(slot >= 0); + + // Initially all tier counts should be zero. + try std.testing.expectEqual(@as(c_int, 0), slack_mcp_rate_count(slot, 1)); + try std.testing.expectEqual(@as(c_int, 0), slack_mcp_rate_count(slot, 2)); + try std.testing.expectEqual(@as(c_int, 0), slack_mcp_rate_count(slot, 3)); + try std.testing.expectEqual(@as(c_int, 0), slack_mcp_rate_count(slot, 4)); + + // Invalid tier. + try std.testing.expectEqual(@as(c_int, -1), slack_mcp_rate_count(slot, 0)); + try std.testing.expectEqual(@as(c_int, -1), slack_mcp_rate_count(slot, 5)); + + _ = slack_mcp_disconnect(slot); +} + +test "generic api_call routes correctly" { + slack_mcp_reset(); + + const slot = slack_mcp_authenticate("xoxb-test-token-api"); + try std.testing.expect(slot >= 0); + + var buf: [1024]u8 = undefined; + var out_len: c_int = 0; + + // Call list_users (action_id = 3). + const rc = slack_mcp_api_call(slot, 3, null, &buf, 1024, &out_len); + try std.testing.expectEqual(@as(c_int, 0), rc); + try std.testing.expect(out_len > 0); + + // Unknown action. + try std.testing.expectEqual(@as(c_int, -6), slack_mcp_api_call(slot, 99, null, &buf, 1024, &out_len)); + + _ = slack_mcp_disconnect(slot); +} + +test "slot exhaustion" { + slack_mcp_reset(); + + var slots: [MAX_SESSIONS]c_int = undefined; + for (&slots) |*s| { + s.* = slack_mcp_authenticate("xoxb-fill-token-slot"); + try std.testing.expect(s.* >= 0); + } + + // Next should fail. + try std.testing.expectEqual(@as(c_int, -1), slack_mcp_authenticate("xoxb-overflow")); + + // Free one and retry. + try std.testing.expectEqual(@as(c_int, 0), slack_mcp_disconnect(slots[0])); + const new_slot = slack_mcp_authenticate("xoxb-reuse-slot-ok"); + try std.testing.expect(new_slot >= 0); +} + +// ═══════════════════════════════════════════════════════════════════════ +// ADR-0006 invoke dispatch tests +// ═══════════════════════════════════════════════════════════════════════ + +test "boj_cartridge_name returns slack-mcp" { + const n = std.mem.span(boj_cartridge_name()); + try std.testing.expectEqualStrings("slack-mcp", n); +} + +test "boj_cartridge_init returns 0" { + try std.testing.expectEqual(@as(c_int, 0), boj_cartridge_init()); +} + +test "invoke: each declared tool succeeds" { + var buf: [256]u8 = undefined; + const tools = [_][]const u8{ + "slack_authenticate", + "slack_send_message", + "slack_list_channels", + "slack_read_thread", + "slack_search", + "slack_get_user", + "slack_deauthenticate", + }; + for (tools) |t| { + var len: usize = buf.len; + const rc = boj_cartridge_invoke(t.ptr, "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, 0), rc); + try std.testing.expect(std.mem.indexOf(u8, buf[0..len], "result") != null); + } +} + +test "invoke: unknown tool returns -1" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("nope", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -1), rc); +} + +test "invoke: buffer too small returns -3" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("slack_authenticate", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -3), rc); + try std.testing.expect(len > 4); +} diff --git a/cartridges/domains/communications/slack-mcp/minter.toml b/cartridges/domains/communications/slack-mcp/minter.toml new file mode 100644 index 0000000..e2e269e --- /dev/null +++ b/cartridges/domains/communications/slack-mcp/minter.toml @@ -0,0 +1,11 @@ +name = "slack-mcp" +description = "Slack Web API and Events API cartridge β€” send messages, manage channels, search, react, and more" +version = "0.1.0" +domain = "Comms" +protocols = [ + "MCP", + "REST", +] +tier = "Ayo" +backend = "universal" +generate_panel = true diff --git a/cartridges/domains/communications/slack-mcp/mod.js b/cartridges/domains/communications/slack-mcp/mod.js new file mode 100644 index 0000000..48d7ec9 --- /dev/null +++ b/cartridges/domains/communications/slack-mcp/mod.js @@ -0,0 +1,76 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// slack-mcp/mod.js -- slack gateway. + +const BASE_URL = Deno.env.get("SLACK_MCP_BACKEND_URL") ?? "http://127.0.0.1:7728"; + +async function post(path, payload) { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), 15000); + try { + const r = await fetch(`${BASE_URL}${path}`, { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), signal: ctrl.signal, + }); + const data = await r.json().catch(() => ({ success: false, error: "non-JSON response" })); + return { status: r.status, data }; + } catch (e) { + if (e.name === "AbortError") return { status: 504, data: { success: false, error: "slack-mcp backend timed out" } }; + return { status: 503, data: { success: false, error: `slack-mcp backend unavailable: ${e.message}` } }; + } finally { clearTimeout(t); } +} + +export async function handleTool(toolName, args) { + switch (toolName) { + + case "slack_authenticate": { + const { token } = args ?? {}; + if (!token) return { status: 400, data: { error: "token is required" } }; + const payload = { token }; + return post("/api/v1/authenticate", payload); + } + case "slack_send_message": { + const { slot, channel, text, thread_ts } = args ?? {}; + if (!slot || !channel || !text) return { status: 400, data: { error: "slot is required" } }; + const payload = { slot, channel, text }; + if (thread_ts !== undefined) payload.thread_ts = thread_ts; + return post("/api/v1/send-message", payload); + } + case "slack_list_channels": { + const { slot, types } = args ?? {}; + if (!slot) return { status: 400, data: { error: "slot is required" } }; + const payload = { slot }; + if (types !== undefined) payload.types = types; + return post("/api/v1/list-channels", payload); + } + case "slack_read_thread": { + const { slot, channel, thread_ts } = args ?? {}; + if (!slot || !channel || !thread_ts) return { status: 400, data: { error: "slot is required" } }; + const payload = { slot, channel, thread_ts }; + return post("/api/v1/read-thread", payload); + } + case "slack_search": { + const { slot, query, count } = args ?? {}; + if (!slot || !query) return { status: 400, data: { error: "slot is required" } }; + const payload = { slot, query }; + if (count !== undefined) payload.count = count; + return post("/api/v1/search", payload); + } + case "slack_get_user": { + const { slot, user_id } = args ?? {}; + if (!slot || !user_id) return { status: 400, data: { error: "slot is required" } }; + const payload = { slot, user_id }; + return post("/api/v1/get-user", payload); + } + case "slack_deauthenticate": { + const { slot } = args ?? {}; + if (!slot) return { status: 400, data: { error: "slot is required" } }; + const payload = { slot }; + return post("/api/v1/deauthenticate", payload); + } + + default: + return { status: 404, data: { error: `Unknown tool: ${toolName}` } }; + } +} diff --git a/cartridges/domains/communications/slack-mcp/panels/manifest.json b/cartridges/domains/communications/slack-mcp/panels/manifest.json new file mode 100644 index 0000000..81f4215 --- /dev/null +++ b/cartridges/domains/communications/slack-mcp/panels/manifest.json @@ -0,0 +1,93 @@ +{ + "$schema": "https://panll.dev/schemas/panel-manifest/v1.json", + "spdx": "MPL-2.0", + "cartridge": "slack-mcp", + "domain": "Comms", + "version": "0.1.0", + "panels": [ + { + "id": "slack-connection-status", + "title": "Connection Status", + "description": "Current Slack connection state (Disconnected / Authenticating / Connected / RateLimited / Error)", + "type": "status-indicator", + "data_source": { + "endpoint": "/slack/state", + "method": "GET", + "refresh_interval_ms": 5000 + }, + "widgets": [ + { + "type": "state-badge", + "field": "state", + "states": { + "disconnected": { "color": "#95a5a6", "icon": "plug-disconnected" }, + "authenticating": { "color": "#f39c12", "icon": "key" }, + "connected": { "color": "#2ecc71", "icon": "plug-connected" }, + "rate_limited": { "color": "#e67e22", "icon": "clock" }, + "error": { "color": "#e74c3c", "icon": "alert-triangle" } + } + } + ] + }, + { + "id": "slack-workspace", + "title": "Workspace", + "description": "Name of the Slack workspace the bot is connected to", + "type": "metric", + "data_source": { + "endpoint": "/slack/metrics", + "method": "GET", + "refresh_interval_ms": 30000 + }, + "widgets": [ + { + "type": "text", + "field": "workspace", + "label": "Workspace", + "icon": "building" + } + ] + }, + { + "id": "slack-rate-limits", + "title": "Rate Limits", + "description": "Per-tier rate limit usage (Tier 1: 1/min, Tier 2: 20/min, Tier 3: 50/min, Tier 4: 100/min)", + "type": "multi-metric", + "data_source": { + "endpoint": "/slack/rates", + "method": "GET", + "refresh_interval_ms": 10000 + }, + "widgets": [ + { + "type": "progress-bar", + "items": [ + { "field": "tier1_count", "max_field": "tier1_budget", "label": "Tier 1 (1/min)", "color": "#3498db" }, + { "field": "tier2_count", "max_field": "tier2_budget", "label": "Tier 2 (20/min)", "color": "#2ecc71" }, + { "field": "tier3_count", "max_field": "tier3_budget", "label": "Tier 3 (50/min)", "color": "#f39c12" }, + { "field": "tier4_count", "max_field": "tier4_budget", "label": "Tier 4 (100/min)", "color": "#9b59b6" } + ] + } + ] + }, + { + "id": "slack-messages-sent", + "title": "Recent Messages Sent", + "description": "Count of messages sent in the current session", + "type": "metric", + "data_source": { + "endpoint": "/slack/metrics", + "method": "GET", + "refresh_interval_ms": 10000 + }, + "widgets": [ + { + "type": "counter", + "field": "messages_sent", + "label": "Messages Sent", + "icon": "message-circle" + } + ] + } + ] +} diff --git a/cartridges/domains/communications/slack-mcp/tests/integration_test.sh b/cartridges/domains/communications/slack-mcp/tests/integration_test.sh new file mode 100755 index 0000000..d32c8ff --- /dev/null +++ b/cartridges/domains/communications/slack-mcp/tests/integration_test.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Integration tests for slack-mcp cartridge. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CART_DIR="$(dirname "$SCRIPT_DIR")" +FFI_DIR="$CART_DIR/ffi" + +echo "=== slack-mcp integration tests ===" + +# Build FFI +echo "[1/3] Building FFI..." +cd "$FFI_DIR" && zig build 2>&1 + +# Run Zig unit tests +echo "[2/3] Running FFI unit tests..." +cd "$FFI_DIR" && zig build test 2>&1 + +# Validate Idris2 ABI (if idris2 available) +echo "[3/3] Checking ABI..." +if command -v idris2 &>/dev/null; then + cd "$CART_DIR/abi" && idris2 --check SlackMcp.SafeComms 2>&1 + echo " ABI: OK" +else + echo " ABI: SKIPPED (idris2 not in PATH)" +fi + +echo "" +echo "All tests passed for slack-mcp!" diff --git a/cartridges/domains/communications/telegram-mcp/README.adoc b/cartridges/domains/communications/telegram-mcp/README.adoc new file mode 100644 index 0000000..5124f9c --- /dev/null +++ b/cartridges/domains/communications/telegram-mcp/README.adoc @@ -0,0 +1,90 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> + += telegram-mcp +Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk> +:spdx: MPL-2.0 +:tier: Ayo +:domain: Comms +:protocols: MCP, REST + +== Overview + +Telegram Bot API cartridge for BoJ server. Provides type-safe access to +16 Telegram actions: messages, updates, chats, media, webhooks, callbacks, +stickers, forwarding, pinning, and bot info. Token-in-URL authentication +pattern (https://api.telegram.org/bot\{token}/\{method}). Global rate limit +of 30 messages per second. + +== Actions + +[cols="1,2"] +|=== +| Action | Description + +| SendMessage | Send a text message to a chat +| EditMessage | Edit an existing message +| DeleteMessage | Delete a message +| GetUpdates | Long-poll for new updates +| GetChat | Get information about a chat +| ListChats | List chats the bot participates in +| SendPhoto | Send a photo to a chat +| SendDocument | Send a document/file to a chat +| SetWebhook | Set a webhook URL for receiving updates +| DeleteWebhook | Remove the webhook +| GetWebhookInfo | Get current webhook configuration +| AnswerCallback | Respond to a callback query from an inline button +| SendSticker | Send a sticker to a chat +| ForwardMessage | Forward a message between chats +| PinMessage | Pin a message in a chat +| GetMe | Get bot information (username, id, capabilities) +|=== + +== Architecture + +[cols="1,1,2"] +|=== +| Layer | Language | Purpose + +| ABI +| Idris2 +| Formally verified state machine (TelegramMcp.SafeComms) with dependent-type proofs + +| FFI +| Zig +| C-compatible implementation with thread-safe session pool, token validation, rate limit constants + +| Adapter +| zig +| REST bridge to BoJ unified adapter protocol with all 16 actions +|=== + +== State Machine + +.... +Disconnected -> Authenticating -> Connected <-> RateLimited + | | | + v v v + Error <----------+--------------+ + | + v + Disconnected +.... + +== Building + +[source,bash] +---- +# Build FFI shared library +cd ffi && zig build + +# Run FFI tests +cd ffi && zig build test + +# Type-check ABI +cd abi && idris2 --check TelegramMcp.SafeComms +---- + +== Status + +Development -- not yet ready for mounting. diff --git a/cartridges/domains/communications/telegram-mcp/abi/README.adoc b/cartridges/domains/communications/telegram-mcp/abi/README.adoc new file mode 100644 index 0000000..a31cc08 --- /dev/null +++ b/cartridges/domains/communications/telegram-mcp/abi/README.adoc @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += telegram-mcp / abi β€” Idris2 ABI layer +:orientation: shallow + +== Purpose + +Cartridge-local protocol types for `telegram-mcp`. Defines the data, tool, and +transition vocabulary this cartridge speaks. Inherits safety lemmas and the +matrix-cell identifier scheme from the trunk ABI at `src/abi/`. + +== Files + +1 Idris2 module(s), ~243 lines total. Lead module: +`SafeComms.idr`. + +No `.ipkg` β€” consumed directly by wider BoJ callers; type-checks inside the +trunk `boj.ipkg`. + +== Invariants + +* `%default total` at every module top. +* Zero `believe_me`, `assert_total`, `postulate`, `Admitted`, or + `sorry` (the trunk holds the four declared `believe_me` sites; leaf + cartridges carry none). + +== Test/proof surface + +Thin module(s), no inline tests. Proof surface = type-check success under the +trunk `idris2 --check`. + +== Read-first + +. `SafeComms.idr` β€” cartridge-local data records and enums. +. Trunk `src/abi/Boj/Catalogue.idr` for how this cell participates in the + 2D matrix. diff --git a/cartridges/domains/communications/telegram-mcp/abi/TelegramMcp/SafeComms.idr b/cartridges/domains/communications/telegram-mcp/abi/TelegramMcp/SafeComms.idr new file mode 100644 index 0000000..6a06fa6 --- /dev/null +++ b/cartridges/domains/communications/telegram-mcp/abi/TelegramMcp/SafeComms.idr @@ -0,0 +1,243 @@ +-- SPDX-License-Identifier: MPL-2.0 +-- Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +-- +-- TelegramMcp.SafeComms β€” Type-safe ABI for telegram-mcp cartridge. +-- +-- Dependent-type-proven state machine for Telegram Bot API communication. +-- Covers the full Telegram Bot API surface: messages, updates, chats, +-- media, webhooks, callbacks, stickers, forwarding, and pinning. +-- Token-in-URL auth pattern (https://api.telegram.org/bot{token}/{method}). +-- Global rate limit: 30 messages per second. + +module TelegramMcp.SafeComms + +%default total + +-- --------------------------------------------------------------------------- +-- Connection state machine +-- --------------------------------------------------------------------------- + +||| Connection state for Telegram bot sessions. +||| Telegram uses a bot token embedded in the URL path: +||| https://api.telegram.org/bot{token}/{method} +public export +data ConnState + = Disconnected + | Authenticating + | Connected + | RateLimited + | Error + +||| Proof that a connection state transition is valid. +public export +data ValidTransition : ConnState -> ConnState -> Type where + StartAuth : ValidTransition Disconnected Authenticating + AuthSuccess : ValidTransition Authenticating Connected + AuthFail : ValidTransition Authenticating Error + HitRateLimit : ValidTransition Connected RateLimited + RateLimitDone : ValidTransition RateLimited Connected + ConnError : ValidTransition Connected Error + RateError : ValidTransition RateLimited Error + Recover : ValidTransition Error Disconnected + Disconnect : ValidTransition Connected Disconnected + +-- --------------------------------------------------------------------------- +-- C-ABI integer encoding for ConnState +-- --------------------------------------------------------------------------- + +||| Encode connection state as C-compatible integer. +export +connStateToInt : ConnState -> Int +connStateToInt Disconnected = 0 +connStateToInt Authenticating = 1 +connStateToInt Connected = 2 +connStateToInt RateLimited = 3 +connStateToInt Error = 4 + +||| Decode integer back to connection state. +export +intToConnState : Int -> Maybe ConnState +intToConnState 0 = Just Disconnected +intToConnState 1 = Just Authenticating +intToConnState 2 = Just Connected +intToConnState 3 = Just RateLimited +intToConnState 4 = Just Error +intToConnState _ = Nothing + +||| Check if a connection state transition is valid (C-ABI export). +||| Returns 1 for valid, 0 for invalid. +export +telegram_mcp_can_transition : Int -> Int -> Int +telegram_mcp_can_transition from to = + case (intToConnState from, intToConnState to) of + (Just Disconnected, Just Authenticating) => 1 + (Just Authenticating, Just Connected) => 1 + (Just Authenticating, Just Error) => 1 + (Just Connected, Just RateLimited) => 1 + (Just RateLimited, Just Connected) => 1 + (Just Connected, Just Error) => 1 + (Just RateLimited, Just Error) => 1 + (Just Error, Just Disconnected) => 1 + (Just Connected, Just Disconnected) => 1 + _ => 0 + +-- --------------------------------------------------------------------------- +-- Telegram actions +-- --------------------------------------------------------------------------- + +||| Actions available through the Telegram Bot API. +||| Each action maps to a Telegram Bot API method. +public export +data TelegramAction + = SendMessage + | EditMessage + | DeleteMessage + | GetUpdates + | GetChat + | ListChats + | SendPhoto + | SendDocument + | SetWebhook + | DeleteWebhook + | GetWebhookInfo + | AnswerCallback + | SendSticker + | ForwardMessage + | PinMessage + | GetMe + +||| Total count of supported Telegram actions. +export +actionCount : Nat +actionCount = 16 + +||| Encode a Telegram action as a C-compatible integer. +export +actionToInt : TelegramAction -> Int +actionToInt SendMessage = 0 +actionToInt EditMessage = 1 +actionToInt DeleteMessage = 2 +actionToInt GetUpdates = 3 +actionToInt GetChat = 4 +actionToInt ListChats = 5 +actionToInt SendPhoto = 6 +actionToInt SendDocument = 7 +actionToInt SetWebhook = 8 +actionToInt DeleteWebhook = 9 +actionToInt GetWebhookInfo = 10 +actionToInt AnswerCallback = 11 +actionToInt SendSticker = 12 +actionToInt ForwardMessage = 13 +actionToInt PinMessage = 14 +actionToInt GetMe = 15 + +||| Decode integer back to a Telegram action. +export +intToAction : Int -> Maybe TelegramAction +intToAction 0 = Just SendMessage +intToAction 1 = Just EditMessage +intToAction 2 = Just DeleteMessage +intToAction 3 = Just GetUpdates +intToAction 4 = Just GetChat +intToAction 5 = Just ListChats +intToAction 6 = Just SendPhoto +intToAction 7 = Just SendDocument +intToAction 8 = Just SetWebhook +intToAction 9 = Just DeleteWebhook +intToAction 10 = Just GetWebhookInfo +intToAction 11 = Just AnswerCallback +intToAction 12 = Just SendSticker +intToAction 13 = Just ForwardMessage +intToAction 14 = Just PinMessage +intToAction 15 = Just GetMe +intToAction _ = Nothing + +||| Check whether a given action requires a Connected state. +||| All actions require Connected; none can run while Disconnected, +||| Authenticating, RateLimited, or in Error. +export +actionRequiresConnected : TelegramAction -> Bool +actionRequiresConnected _ = True + +-- --------------------------------------------------------------------------- +-- Rate limit model +-- --------------------------------------------------------------------------- + +||| Telegram enforces a global rate limit of 30 messages per second. +||| Individual chats have a lower limit of 1 message per second. +||| Group chats allow 20 messages per minute. +public export +data RateLimitInfo : Type where + MkRateLimitInfo : + (globalRemaining : Nat) -> + (globalWindowMs : Nat) -> + (perChatBudget : Nat) -> + RateLimitInfo + +||| Default rate limit configuration for Telegram Bot API. +||| 30 messages per 1000ms global window, 1 per-chat budget. +export +defaultRateLimit : RateLimitInfo +defaultRateLimit = MkRateLimitInfo 30 1000 1 + +||| Check whether the global rate limit still has remaining requests. +export +hasGlobalCapacity : RateLimitInfo -> Bool +hasGlobalCapacity (MkRateLimitInfo remaining _ _) = + case remaining of + Z => False + S _ => True + +-- --------------------------------------------------------------------------- +-- Auth model +-- --------------------------------------------------------------------------- + +||| Telegram bot authentication: token embedded in URL path. +||| URL pattern: https://api.telegram.org/bot{token}/{method} +public export +data AuthConfig : Type where + MkAuthConfig : + (token : String) -> + (baseUrl : String) -> + AuthConfig + +||| Default base URL for Telegram Bot API. +export +defaultBaseUrl : String +defaultBaseUrl = "https://api.telegram.org/" + +||| Validate that a bot token matches the expected format. +||| Telegram bot tokens look like: 123456789:ABCDefGHIJKlmNOpqrSTUvwxYZ +||| Basic structural check: non-empty, contains a colon. +export +validateToken : String -> Bool +validateToken tok = + let len = length tok + in len > 10 && len < 200 + +-- --------------------------------------------------------------------------- +-- MCP tool declarations +-- --------------------------------------------------------------------------- + +||| Tools exposed via MCP protocol for this cartridge. +public export +data McpTool + = ToolConnect + | ToolDisconnect + | ToolStatus + | ToolInvoke + | ToolList + +||| Check if a tool requires a connected session. +export +toolRequiresSession : McpTool -> Bool +toolRequiresSession ToolConnect = False +toolRequiresSession ToolDisconnect = True +toolRequiresSession ToolStatus = False +toolRequiresSession ToolInvoke = True +toolRequiresSession ToolList = False + +||| Tool count for this cartridge. +export +toolCount : Nat +toolCount = 5 diff --git a/cartridges/domains/communications/telegram-mcp/abi/telegram_mcp.ipkg b/cartridges/domains/communications/telegram-mcp/abi/telegram_mcp.ipkg new file mode 100644 index 0000000..4ed87d0 --- /dev/null +++ b/cartridges/domains/communications/telegram-mcp/abi/telegram_mcp.ipkg @@ -0,0 +1,11 @@ +-- SPDX-License-Identifier: MPL-2.0 +-- Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +package telegram_mcp + +version = "0.1.0" +authors = "Jonathan D.A. Jewell" +brief = "Type-safe ABI for telegram-mcp cartridge (Telegram Bot API)" + +depends = base + +modules = TelegramMcp.SafeComms diff --git a/cartridges/domains/communications/telegram-mcp/adapter/README.adoc b/cartridges/domains/communications/telegram-mcp/adapter/README.adoc new file mode 100644 index 0000000..2dd8fab --- /dev/null +++ b/cartridges/domains/communications/telegram-mcp/adapter/README.adoc @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += telegram-mcp / adapter β€” protocol bridge +:orientation: shallow + +== Purpose + +Exposes this cartridge's FFI over the wire protocols declared in +`cartridge.json`. Dispatches tool invocations to the C-ABI exports at +`../ffi/` and formats responses per protocol. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” depends on the sibling `ffi` target. +| `telegram_mcp_adapter.zig` | Protocol dispatch (109 lines). Tool catalogue matches `cartridge.json`. +| `SIDELINED-*.v.adoc` | zig predecessor(s). Not built β€” zig banned 2026-04-10. +|=== + +== Invariants + +* **Stateless** β€” all state lives behind the FFI, never in the adapter. +* **Response passthrough** β€” whatever the FFI returns goes back to the wire + unmodified (no embellishment, no silent recovery). +* **Cartridge.json is source of truth** for the tool catalogue; drift between + adapter and manifest is a CI failure. + +== Test/proof surface + +No inline tests at the adapter layer β€” protocol routing is exercised by the +trunk seam tests at `ffi/zig/src/seams.zig`. + +== Read-first + +. `telegram_mcp_adapter.zig` β€” dispatch function and per-protocol routers. +. `../cartridge.json` β€” the tool manifest the dispatcher mirrors. diff --git a/cartridges/domains/communications/telegram-mcp/adapter/build.zig b/cartridges/domains/communications/telegram-mcp/adapter/build.zig new file mode 100644 index 0000000..351c3c2 --- /dev/null +++ b/cartridges/domains/communications/telegram-mcp/adapter/build.zig @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// telegram-mcp/adapter/build.zig + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); const optimize = b.standardOptimizeOption(.{}); + const ffi_mod = b.createModule(.{ .root_source_file = b.path("../ffi/telegram_mcp_ffi.zig"), .target = target, .optimize = optimize }); + const adapter = b.addExecutable(.{ .name = "telegram_mcp_adapter", .root_source_file = b.path("telegram_mcp_adapter.zig"), .target = target, .optimize = optimize }); + adapter.root_module.addImport("telegram_mcp_ffi", ffi_mod); b.installArtifact(adapter); + const rs = b.step("run", "Run telegram-mcp adapter"); rs.dependOn(&b.addRunArtifact(adapter).step); + const tests = b.addTest(.{ .root_source_file = b.path("telegram_mcp_adapter.zig"), .target = target, .optimize = optimize }); + tests.root_module.addImport("telegram_mcp_ffi", ffi_mod); + const ts = b.step("test", "Test telegram-mcp adapter"); ts.dependOn(&b.addRunArtifact(tests).step); +} diff --git a/cartridges/domains/communications/telegram-mcp/adapter/telegram_mcp_adapter.zig b/cartridges/domains/communications/telegram-mcp/adapter/telegram_mcp_adapter.zig new file mode 100644 index 0000000..34acc0f --- /dev/null +++ b/cartridges/domains/communications/telegram-mcp/adapter/telegram_mcp_adapter.zig @@ -0,0 +1,109 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// telegram-mcp/adapter/telegram_mcp_adapter.zig -- Unified three-protocol adapter. +// Replaces banned telegram_mcp_adapter.v (zig, removed 2026-04-12). +// REST:9190 gRPC:9191 GraphQL:9192 +// Tools: telegram_authenticate, telegram_send_message, telegram_get_updates, telegram_get_chat... + +const std = @import("std"); +const ffi = @import("telegram_mcp_ffi"); + +const REST_PORT: u16 = 9190; +const GRPC_PORT: u16 = 9191; +const GQL_PORT: u16 = 9192; +const MAX_CONN_BUF: usize = 16 * 1024; + +fn okJson(buf: []u8, msg: []const u8) []u8 { + const n = std.fmt.bufPrint(buf, "{\"success\":true,\"message\":\"{s}\"}", .{msg}) catch return buf[0..0]; return n; +} +fn errJson(buf: []u8, msg: []const u8) []u8 { + const n = std.fmt.bufPrint(buf, "{\"success\":false,\"error\":\"{s}\"}", .{msg}) catch return buf[0..0]; return n; +} +fn statusJson(buf: []u8) []u8 { + const n = std.fmt.bufPrint(buf, "{\"success\":true,\"state\":\"ready\",\"service\":\"telegram-mcp\"}", .{}) catch return buf[0..0]; return n; +} + +const Response = struct { status: u16, body: []u8 }; + +fn dispatch(tool: []const u8, body: []const u8, resp: []u8) Response { + _ = body; + if (std.mem.eql(u8, tool, "telegram_authenticate")) return .{ .status = 200, .body = okJson(resp, "telegram_authenticate forwarded") }; + if (std.mem.eql(u8, tool, "telegram_send_message")) return .{ .status = 200, .body = okJson(resp, "telegram_send_message forwarded") }; + if (std.mem.eql(u8, tool, "telegram_get_updates")) return .{ .status = 200, .body = okJson(resp, "telegram_get_updates forwarded") }; + if (std.mem.eql(u8, tool, "telegram_get_chat")) return .{ .status = 200, .body = okJson(resp, "telegram_get_chat forwarded") }; + if (std.mem.eql(u8, tool, "telegram_send_photo")) return .{ .status = 200, .body = okJson(resp, "telegram_send_photo forwarded") }; + if (std.mem.eql(u8, tool, "telegram_deauthenticate")) return .{ .status = 200, .body = okJson(resp, "telegram_deauthenticate forwarded") }; + if (std.mem.eql(u8, tool, "status") or std.mem.eql(u8, tool, "health")) return .{ .status = 200, .body = statusJson(resp) }; + return .{ .status = 404, .body = errJson(resp, "Unknown tool") }; +} + +fn dispatchRest(path: []const u8, body: []const u8, resp: []u8) Response { + if (std.mem.startsWith(u8, path, "/tools/")) return dispatch(path["/tools/".len..], body, resp); + if (std.mem.eql(u8, path, "/status") or std.mem.eql(u8, path, "/health")) return .{ .status = 200, .body = statusJson(resp) }; + return .{ .status = 404, .body = errJson(resp, "Not found") }; +} + +fn dispatchGrpc(path: []const u8, body: []const u8, resp: []u8) Response { + const prefix = "/TelegramMcpservice/"; + if (!std.mem.startsWith(u8, path, prefix)) return .{ .status = 404, .body = errJson(resp, "Not a recognized gRPC path") }; + const method = path[prefix.len..]; + const tool = blk: { + if (std.mem.eql(u8, method, "telegram_authenticate")) break :blk "telegram_authenticate"; + if (std.mem.eql(u8, method, "telegram_send_message")) break :blk "telegram_send_message"; + if (std.mem.eql(u8, method, "telegram_get_updates")) break :blk "telegram_get_updates"; + if (std.mem.eql(u8, method, "telegram_get_chat")) break :blk "telegram_get_chat"; + if (std.mem.eql(u8, method, "telegram_send_photo")) break :blk "telegram_send_photo"; + if (std.mem.eql(u8, method, "telegram_deauthenticate")) break :blk "telegram_deauthenticate"; + return .{ .status = 404, .body = errJson(resp, "Unknown gRPC method") }; + }; + return dispatch(tool, body, resp); +} + +fn dispatchGraphql(body: []const u8, resp: []u8) Response { + if (std.mem.indexOf(u8, body, "__schema") != null) return .{ .status = 200, .body = okJson(resp, "schema not supported") }; + if (std.mem.indexOf(u8, body, "authenticate") != null) return dispatch("telegram_authenticate", body, resp); + if (std.mem.indexOf(u8, body, "send_message") != null) return dispatch("telegram_send_message", body, resp); + if (std.mem.indexOf(u8, body, "get_updates") != null) return dispatch("telegram_get_updates", body, resp); + if (std.mem.indexOf(u8, body, "get_chat") != null) return dispatch("telegram_get_chat", body, resp); + if (std.mem.indexOf(u8, body, "send_photo") != null) return dispatch("telegram_send_photo", body, resp); + if (std.mem.indexOf(u8, body, "deauthenticate") != null) return dispatch("telegram_deauthenticate", body, resp); + return .{ .status = 200, .body = errJson(resp, "Unrecognised GraphQL operation") }; +} + +const Protocol = enum { rest, grpc, graphql }; + +fn handleConnection(conn: std.net.Server.Connection, proto: Protocol) void { + defer conn.stream.close(); + var in_buf: [MAX_CONN_BUF]u8 = undefined; + const n = conn.stream.read(&in_buf) catch return; + const req = in_buf[0..n]; + var path: []const u8 = "/"; var body: []const u8 = ""; + if (n > 4) { + const le = std.mem.indexOf(u8, req, "\r\n") orelse req.len; + const fl = req[0..le]; const sp1 = std.mem.indexOfScalar(u8, fl, ' ') orelse 0; + const ro = fl[sp1+1..]; const sp2 = std.mem.indexOfScalar(u8, ro, ' ') orelse ro.len; + path = ro[0..sp2]; + const bs = std.mem.indexOf(u8, req, "\r\n\r\n") orelse n; body = req[@min(bs+4,n)..]; + } + var rb: [MAX_CONN_BUF]u8 = undefined; + const result = switch (proto) { .rest => dispatchRest(path, body, &rb), .grpc => dispatchGrpc(path, body, &rb), .graphql => dispatchGraphql(body, &rb) }; + const ct: []const u8 = if (proto == .grpc) "application/grpc+json" else "application/json"; + var hb: [256]u8 = undefined; + const hdr = std.fmt.bufPrint(&hb, "HTTP/1.1 {d} OK\r\nContent-Type: {s}\r\nContent-Length: {d}\r\nConnection: close\r\n\r\n", .{ result.status, ct, result.body.len }) catch return; + _ = conn.stream.writeAll(hdr) catch return; _ = conn.stream.writeAll(result.body) catch return; +} + +fn listenLoop(port: u16, proto: Protocol) void { + const addr = std.net.Address.initIp4(.{ 127, 0, 0, 1 }, port) catch return; + var srv = addr.listen(.{ .reuse_address = true }) catch return; defer srv.deinit(); + while (true) { const conn = srv.accept() catch continue; handleConnection(conn, proto); } +} + +pub fn main() !void { + ffi.telegram_mcp_init(); + const t1 = try std.Thread.spawn(.{}, listenLoop, .{ REST_PORT, Protocol.rest }); + const t2 = try std.Thread.spawn(.{}, listenLoop, .{ GRPC_PORT, Protocol.grpc }); + const t3 = try std.Thread.spawn(.{}, listenLoop, .{ GQL_PORT, Protocol.graphql }); + t1.join(); t2.join(); t3.join(); +} diff --git a/cartridges/domains/communications/telegram-mcp/benchmarks/quick-bench.sh b/cartridges/domains/communications/telegram-mcp/benchmarks/quick-bench.sh new file mode 100755 index 0000000..993b33e --- /dev/null +++ b/cartridges/domains/communications/telegram-mcp/benchmarks/quick-bench.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Benchmarks for telegram-mcp cartridge. +set -euo pipefail + +echo "=== telegram-mcp benchmarks ===" + +cd "$(dirname "${BASH_SOURCE[0]}")/../ffi" + +# Build with ReleaseFast for benchmarking +zig build -Doptimize=ReleaseFast 2>&1 + +echo "Session open/close cycle (1000 iterations):" +time for i in $(seq 1 1000); do + # This would call the FFI benchmark binary + true +done + +echo "" +echo "Benchmark placeholder β€” implement real benchmarks in Zig test blocks" +echo "or via the zig adapter HTTP benchmark tool." diff --git a/cartridges/domains/communications/telegram-mcp/cartridge.json b/cartridges/domains/communications/telegram-mcp/cartridge.json new file mode 100644 index 0000000..022f54a --- /dev/null +++ b/cartridges/domains/communications/telegram-mcp/cartridge.json @@ -0,0 +1,173 @@ +{ + "$schema": "https://boj.dev/schemas/cartridge/v1.json", + "spdx": "MPL-2.0", + "copyright": "Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath)", + "name": "telegram-mcp", + "version": "0.1.0", + "description": "Telegram Bot API gateway. Send messages, manage chats, handle inline queries, and read updates.", + "domain": "Communications", + "tier": "Ayo", + "protocols": [ + "MCP", + "REST" + ], + "auth": { + "method": "none", + "env_var": null, + "credential_source": null + }, + "api": { + "base_url": "local://telegram-mcp", + "content_type": "application/json" + }, + "tools": [ + { + "name": "telegram_authenticate", + "description": "Authenticate with Telegram using a bot token.", + "inputSchema": { + "type": "object", + "properties": { + "token": { + "type": "string", + "description": "Telegram bot token (from @BotFather)" + } + }, + "required": [ + "token" + ] + } + }, + { + "name": "telegram_send_message", + "description": "Send a message to a chat.", + "inputSchema": { + "type": "object", + "properties": { + "slot": { + "type": "integer", + "description": "Session slot from telegram_authenticate" + }, + "chat_id": { + "type": "string", + "description": "Chat ID or @username" + }, + "text": { + "type": "string", + "description": "Message text (supports HTML and Markdown)" + }, + "parse_mode": { + "type": "string", + "description": "Parse mode: HTML or Markdown (default: HTML)" + } + }, + "required": [ + "slot", + "chat_id", + "text" + ] + } + }, + { + "name": "telegram_get_updates", + "description": "Poll for new updates.", + "inputSchema": { + "type": "object", + "properties": { + "slot": { + "type": "integer", + "description": "Session slot" + }, + "offset": { + "type": "integer", + "description": "Update offset for pagination" + }, + "limit": { + "type": "integer", + "description": "Max updates to return (default: 100)" + } + }, + "required": [ + "slot" + ] + } + }, + { + "name": "telegram_get_chat", + "description": "Get information about a chat.", + "inputSchema": { + "type": "object", + "properties": { + "slot": { + "type": "integer", + "description": "Session slot" + }, + "chat_id": { + "type": "string", + "description": "Chat ID or @username" + } + }, + "required": [ + "slot", + "chat_id" + ] + } + }, + { + "name": "telegram_send_photo", + "description": "Send a photo to a chat.", + "inputSchema": { + "type": "object", + "properties": { + "slot": { + "type": "integer", + "description": "Session slot" + }, + "chat_id": { + "type": "string", + "description": "Chat ID" + }, + "photo": { + "type": "string", + "description": "Photo URL or file_id" + }, + "caption": { + "type": "string", + "description": "Photo caption (optional)" + } + }, + "required": [ + "slot", + "chat_id", + "photo" + ] + } + }, + { + "name": "telegram_deauthenticate", + "description": "Release a Telegram session slot.", + "inputSchema": { + "type": "object", + "properties": { + "slot": { + "type": "integer", + "description": "Session slot to release" + } + }, + "required": [ + "slot" + ] + } + } + ], + "ffi": { + "so_path": "ffi/zig-out/lib/libtelegram_mcp.so", + "abi_version": "ADR-0006", + "symbols": [ + "boj_cartridge_init", + "boj_cartridge_deinit", + "boj_cartridge_name", + "boj_cartridge_version", + "boj_cartridge_invoke" + ] + } +} diff --git a/cartridges/domains/communications/telegram-mcp/ffi/README.adoc b/cartridges/domains/communications/telegram-mcp/ffi/README.adoc new file mode 100644 index 0000000..a7d958c --- /dev/null +++ b/cartridges/domains/communications/telegram-mcp/ffi/README.adoc @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += telegram-mcp / ffi β€” Zig FFI layer +:orientation: shallow + +== Purpose + +C-ABI exports bridging this cartridge's Idris2 ABI to the runtime. Consumed by +the adapter for protocol dispatch and by the trunk loader for mount. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” produces `zig-out/lib/libtelegram.so`. +| `telegram_mcp_ffi.zig` | C-ABI exports (14 exports, 6 inline tests, 374 lines). +|=== + +== Invariants + +* **Null-pointer rejection** on `[*c]const u8` arguments before any + dereference. +* **Negative return values** carry documented error meanings; zero is + success (matching the trunk error-code convention). +* **No allocator leaks** β€” every allocation path pairs with `defer free`. + +== Test/proof surface + +6 inline `test "..."` block(s) in `telegram_mcp_ffi.zig`. Run with +`cd ffi && zig build test`. + +== Read-first + +. `telegram_mcp_ffi.zig` β€” C-ABI signatures and guards. +. Trunk `ffi/zig/src/catalogue.zig` for how this `.so` is loaded. diff --git a/cartridges/domains/communications/telegram-mcp/ffi/build.zig b/cartridges/domains/communications/telegram-mcp/ffi/build.zig new file mode 100644 index 0000000..cc57139 --- /dev/null +++ b/cartridges/domains/communications/telegram-mcp/ffi/build.zig @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + // Module + // Shared ADR-0006 invoke-shim module (relative path up to boj-server trunk). + + const shim_mod = b.addModule("cartridge_shim", .{ + + .root_source_file = b.path("../../../ffi/zig/src/cartridge_shim.zig"), + + .target = target, + + .optimize = optimize, + + }); + + const ffi_mod = b.addModule("telegram_mcp", .{ + .root_source_file = b.path("telegram_mcp_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + ffi_mod.addImport("cartridge_shim", shim_mod); + + // Shared library + const lib = b.addLibrary(.{ + .name = "telegram_mcp", + .root_module = ffi_mod, + .linkage = .dynamic, + }); + lib.linkLibC(); + b.installArtifact(lib); + + // Tests + const tests = b.addTest(.{ + .root_module = ffi_mod, + }); + tests.linkLibC(); + + const run_tests = b.addRunArtifact(tests); + const test_step = b.step("test", "Run FFI tests"); + test_step.dependOn(&run_tests.step); +} diff --git a/cartridges/domains/communications/telegram-mcp/ffi/cartridge_shim.zig b/cartridges/domains/communications/telegram-mcp/ffi/cartridge_shim.zig new file mode 100644 index 0000000..0e399db --- /dev/null +++ b/cartridges/domains/communications/telegram-mcp/ffi/cartridge_shim.zig @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// cartridge_shim.zig β€” Shared helpers for the ADR-0006 five-symbol +// cartridge ABI (`boj_cartridge_init / deinit / name / version / invoke`). +// +// The shim centralises the seven-code return convention, NUL-argument +// guards, tool-name comparison, and the buffer-too-small path so each +// cartridge's `boj_cartridge_invoke` can stay short β€” typically a tool +// table plus `shim.writeResult(...)`. +// +// Cartridges import this file by relative path (no build-graph change +// needed). Example: +// +// const shim = @import("../../../ffi/zig/src/cartridge_shim.zig"); +// +// export fn boj_cartridge_invoke( +// tool_name: [*c]const u8, +// json_args: [*c]const u8, +// out_buf: [*c]u8, +// in_out_len: [*c]usize, +// ) callconv(.c) i32 { +// _ = json_args; +// if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; +// const body = if (shim.toolIs(tool_name, "foo")) "{\"result\":{}}" +// else return shim.RC_UNKNOWN_TOOL; +// return shim.writeResult(out_buf, in_out_len, body); +// } + +const std = @import("std"); + +// ── Return codes (ADR-0006 Β§Return codes) ──────────────────────────── +// +// Frozen by ADR-0006. New failure modes compose these via the error +// JSON body β€” the integer surface does not grow without a follow-up ADR. + +pub const RC_SUCCESS: i32 = 0; +pub const RC_UNKNOWN_TOOL: i32 = -1; +pub const RC_BAD_ARGS: i32 = -2; +pub const RC_BUFFER_TOO_SMALL: i32 = -3; +pub const RC_RUNTIME_ERROR: i32 = -4; +pub const RC_PANIC: i32 = -5; +pub const RC_AUTH_DENIED: i32 = -6; + +// ── Invoke-path helpers ────────────────────────────────────────────── + +/// True if any of the three mandatory `boj_cartridge_invoke` output-path +/// pointers is null. Use at the top of every invoke to short-circuit to +/// `RC_BAD_ARGS`. +pub fn invokeArgsNull( + tool_name: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) bool { + return tool_name == null or out_buf == null or in_out_len == null; +} + +/// Compare a C-NUL-terminated tool-name pointer against a Zig string +/// literal. Caller must have already verified `tool_name` is non-null +/// (usually via `invokeArgsNull`). +pub fn toolIs(tool_name: [*c]const u8, expected: []const u8) bool { + const s = std.mem.span(@as([*:0]const u8, @ptrCast(tool_name))); + return std.mem.eql(u8, s, expected); +} + +/// Copy `body` into `out_buf[0..*in_out_len]` (as a capacity) and update +/// `*in_out_len` to the number of bytes written. Returns `RC_SUCCESS`. +/// +/// If `body.len` exceeds the current capacity stored in `*in_out_len`, +/// sets `*in_out_len` to the required size and returns +/// `RC_BUFFER_TOO_SMALL` β€” the caller is then expected to re-allocate +/// and retry, per ADR-0006 Β§Memory ownership. +/// +/// Caller must have already verified that `out_buf` and `in_out_len` +/// are non-null. +pub fn writeResult( + out_buf: [*c]u8, + in_out_len: [*c]usize, + body: []const u8, +) i32 { + const cap = in_out_len.*; + if (body.len > cap) { + in_out_len.* = body.len; + return RC_BUFFER_TOO_SMALL; + } + @memcpy(out_buf[0..body.len], body); + in_out_len.* = body.len; + return RC_SUCCESS; +} + +// ── Tests ──────────────────────────────────────────────────────────── + +test "writeResult: body fits, writes and sets length" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); + try std.testing.expectEqualStrings("hello", buf[0..len]); +} + +test "writeResult: too small returns -3 and sets required length" { + var buf: [2]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_BUFFER_TOO_SMALL, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: exact-fit succeeds" { + var buf: [5]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: empty body" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, ""); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 0), len); +} + +test "toolIs: matches and rejects" { + const name: [*:0]const u8 = "foo"; + try std.testing.expect(toolIs(@ptrCast(name), "foo")); + try std.testing.expect(!toolIs(@ptrCast(name), "bar")); + try std.testing.expect(!toolIs(@ptrCast(name), "foobar")); + try std.testing.expect(!toolIs(@ptrCast(name), "fo")); +} + +test "invokeArgsNull: detects each null slot" { + var buf: [4]u8 = undefined; + var len: usize = 4; + const name: [*:0]const u8 = "x"; + try std.testing.expect(!invokeArgsNull(@ptrCast(name), &buf, &len)); + try std.testing.expect(invokeArgsNull(null, &buf, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), null, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), &buf, null)); +} diff --git a/cartridges/domains/communications/telegram-mcp/ffi/telegram_mcp_ffi.zig b/cartridges/domains/communications/telegram-mcp/ffi/telegram_mcp_ffi.zig new file mode 100644 index 0000000..c598758 --- /dev/null +++ b/cartridges/domains/communications/telegram-mcp/ffi/telegram_mcp_ffi.zig @@ -0,0 +1,471 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// telegram_mcp_ffi.zig β€” C-ABI FFI implementation for telegram-mcp cartridge. +// +// Implements the connection state machine and Telegram action dispatch defined +// in the Idris2 ABI layer (TelegramMcp.SafeComms). Thread-safe via +// std.Thread.Mutex. Token-in-URL auth pattern for Telegram Bot API. +// Global rate limit: 30 messages per second. + +const std = @import("std"); + +// --------------------------------------------------------------------------- +// Connection state machine (matches Idris2 ABI: TelegramMcp.SafeComms) +// --------------------------------------------------------------------------- + +/// Connection state for Telegram bot sessions. +/// Disconnected(0) | Authenticating(1) | Connected(2) | RateLimited(3) | Error(4) +pub const ConnState = enum(c_int) { + disconnected = 0, + authenticating = 1, + connected = 2, + rate_limited = 3, + err = 4, +}; + +/// Check whether a state transition is permitted by the state machine. +fn isValidTransition(from: ConnState, to: ConnState) bool { + return switch (from) { + .disconnected => to == .authenticating, + .authenticating => to == .connected or to == .err, + .connected => to == .rate_limited or to == .err or to == .disconnected, + .rate_limited => to == .connected or to == .err, + .err => to == .disconnected, + }; +} + +// --------------------------------------------------------------------------- +// Telegram action codes (matches Idris2 ABI: TelegramAction) +// --------------------------------------------------------------------------- + +/// All 16 Telegram actions supported by this cartridge. +pub const TelegramAction = enum(c_int) { + send_message = 0, + edit_message = 1, + delete_message = 2, + get_updates = 3, + get_chat = 4, + list_chats = 5, + send_photo = 6, + send_document = 7, + set_webhook = 8, + delete_webhook = 9, + get_webhook_info = 10, + answer_callback = 11, + send_sticker = 12, + forward_message = 13, + pin_message = 14, + get_me = 15, +}; + +// --------------------------------------------------------------------------- +// Rate limit tracking +// --------------------------------------------------------------------------- + +/// Telegram global rate limit: 30 messages per second. +const GLOBAL_RATE_LIMIT: u32 = 30; +/// Per-chat rate limit: 1 message per second. +const PER_CHAT_RATE_LIMIT: u32 = 1; +/// Group chat rate limit: 20 messages per minute. +const GROUP_RATE_LIMIT: u32 = 20; + +// --------------------------------------------------------------------------- +// Session slots (thread-safe, fixed-size pool) +// --------------------------------------------------------------------------- + +const MAX_SESSIONS: usize = 16; +const BUF_SIZE: usize = 4096; + +const SessionSlot = struct { + active: bool = false, + state: ConnState = .disconnected, + token_buf: [BUF_SIZE]u8 = undefined, + token_len: usize = 0, + bot_username_buf: [256]u8 = undefined, + bot_username_len: usize = 0, + message_count: u64 = 0, +}; + +var sessions: [MAX_SESSIONS]SessionSlot = [_]SessionSlot{.{}} ** MAX_SESSIONS; +var mutex: std.Thread.Mutex = .{}; + +// --------------------------------------------------------------------------- +// C-ABI exports: state machine +// --------------------------------------------------------------------------- + +/// Check if a state transition is valid. Returns 1 (valid) or 0 (invalid). +pub export fn telegram_mcp_can_transition(from: c_int, to: c_int) c_int { + const f = std.meta.intToEnum(ConnState, from) catch return 0; + const t = std.meta.intToEnum(ConnState, to) catch return 0; + return if (isValidTransition(f, t)) 1 else 0; +} + +/// Open a new session in Disconnected state. Returns slot index (>= 0) or -1. +pub export fn telegram_mcp_session_open() c_int { + mutex.lock(); + defer mutex.unlock(); + + for (&sessions, 0..) |*slot, idx| { + if (!slot.active) { + slot.active = true; + slot.state = .disconnected; + slot.token_len = 0; + slot.bot_username_len = 0; + slot.message_count = 0; + return @intCast(idx); + } + } + return -1; +} + +/// Close a session. Returns 0 on success, -1 if invalid slot, -2 if bad transition. +pub export fn telegram_mcp_session_close(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + var slot = &sessions[idx]; + if (!slot.active) return -1; + if (!isValidTransition(slot.state, .disconnected)) return -2; + + slot.active = false; + slot.state = .disconnected; + slot.token_len = 0; + slot.bot_username_len = 0; + slot.message_count = 0; + return 0; +} + +/// Get the current state of a session. Returns state int or -1 if invalid slot. +pub export fn telegram_mcp_session_state(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + return @intFromEnum(slot.state); +} + +/// Transition a session to Authenticating state. +pub export fn telegram_mcp_authenticate(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + var slot = &sessions[idx]; + if (!slot.active) return -1; + if (!isValidTransition(slot.state, .authenticating)) return -2; + + slot.state = .authenticating; + return 0; +} + +/// Transition a session to Connected state (auth succeeded). +pub export fn telegram_mcp_connect(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + var slot = &sessions[idx]; + if (!slot.active) return -1; + if (!isValidTransition(slot.state, .connected)) return -2; + + slot.state = .connected; + return 0; +} + +/// Transition a session to RateLimited state. +pub export fn telegram_mcp_rate_limit(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + var slot = &sessions[idx]; + if (!slot.active) return -1; + if (!isValidTransition(slot.state, .rate_limited)) return -2; + + slot.state = .rate_limited; + return 0; +} + +/// Signal an error on a session. +pub export fn telegram_mcp_signal_error(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + var slot = &sessions[idx]; + if (!slot.active) return -1; + if (!isValidTransition(slot.state, .err)) return -2; + + slot.state = .err; + return 0; +} + +/// Recover from error (transition to Disconnected). +pub export fn telegram_mcp_recover(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + var slot = &sessions[idx]; + if (!slot.active) return -1; + if (!isValidTransition(slot.state, .disconnected)) return -2; + + slot.state = .disconnected; + return 0; +} + +// --------------------------------------------------------------------------- +// C-ABI exports: token validation and actions +// --------------------------------------------------------------------------- + +/// Validate a Telegram bot token (basic structural check). +/// Telegram tokens look like: 123456789:ABCDefGHIJKlmNOpqrSTUvwxYZ +/// Must contain a colon, be > 10 chars, and < 200 chars. +pub export fn telegram_mcp_validate_token(ptr: [*]const u8, len: usize) c_int { + if (len <= 10 or len >= 200) return 0; + var has_colon = false; + for (ptr[0..len]) |byte| { + if (byte < 0x20 or byte == 0x7F) return 0; + if (byte == ':') has_colon = true; + } + return if (has_colon) 1 else 0; +} + +/// Check if a Telegram action code is valid. Returns 1 if valid, 0 otherwise. +pub export fn telegram_mcp_is_valid_action(action: c_int) c_int { + _ = std.meta.intToEnum(TelegramAction, action) catch return 0; + return 1; +} + +/// Get the total number of supported actions. +pub export fn telegram_mcp_action_count() c_int { + return 16; +} + +/// Get the global rate limit (messages per second). +pub export fn telegram_mcp_global_rate_limit() c_int { + return @intCast(GLOBAL_RATE_LIMIT); +} + +/// Reset all sessions (test/debug use only). +pub export fn telegram_mcp_reset() void { + mutex.lock(); + defer mutex.unlock(); + sessions = [_]SessionSlot{.{}} ** MAX_SESSIONS; +} + +// --------------------------------------------------------------------------- +// ═══════════════════════════════════════════════════════════════════════ +// Standard ABI (ADR-0005 four symbols + ADR-0006 invoke) +// ═══════════════════════════════════════════════════════════════════════ + +const shim = @import("cartridge_shim.zig"); + +const CARTRIDGE_NAME_PTR: [*:0]const u8 = "telegram-mcp"; +const CARTRIDGE_VERSION_PTR: [*:0]const u8 = "0.1.0"; + +export fn boj_cartridge_init() callconv(.c) c_int { + return 0; +} + +export fn boj_cartridge_deinit() callconv(.c) void {} + +export fn boj_cartridge_name() callconv(.c) [*:0]const u8 { + return CARTRIDGE_NAME_PTR; +} + +export fn boj_cartridge_version() callconv(.c) [*:0]const u8 { + return CARTRIDGE_VERSION_PTR; +} + +/// Dispatch the cartridge.json MCP tools. Grade D Alpha stubs. +export fn boj_cartridge_invoke( + tool_name: [*c]const u8, + json_args: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) callconv(.c) i32 { + _ = json_args; + if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; + + const body: []const u8 = if (shim.toolIs(tool_name, "telegram_authenticate")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "telegram_send_message")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "telegram_get_updates")) + "{\"result\":{\"metadata\":{},\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "telegram_get_chat")) + "{\"result\":{\"metadata\":{},\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "telegram_send_photo")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "telegram_deauthenticate")) + "{\"result\":{\"status\":\"stub\"}}" +else + return shim.RC_UNKNOWN_TOOL; + + return shim.writeResult(out_buf, in_out_len, body); +} + +// Tests +// --------------------------------------------------------------------------- + +test "connection lifecycle" { + telegram_mcp_reset(); + + const slot = telegram_mcp_session_open(); + try std.testing.expect(slot >= 0); + + // Should be disconnected + try std.testing.expectEqual(@as(c_int, 0), telegram_mcp_session_state(slot)); + + // Authenticate + try std.testing.expectEqual(@as(c_int, 0), telegram_mcp_authenticate(slot)); + try std.testing.expectEqual(@as(c_int, 1), telegram_mcp_session_state(slot)); + + // Connect + try std.testing.expectEqual(@as(c_int, 0), telegram_mcp_connect(slot)); + try std.testing.expectEqual(@as(c_int, 2), telegram_mcp_session_state(slot)); + + // Rate limit and recover + try std.testing.expectEqual(@as(c_int, 0), telegram_mcp_rate_limit(slot)); + try std.testing.expectEqual(@as(c_int, 3), telegram_mcp_session_state(slot)); + try std.testing.expectEqual(@as(c_int, 0), telegram_mcp_connect(slot)); + try std.testing.expectEqual(@as(c_int, 2), telegram_mcp_session_state(slot)); + + // Close + try std.testing.expectEqual(@as(c_int, 0), telegram_mcp_session_close(slot)); +} + +test "invalid transitions rejected" { + telegram_mcp_reset(); + + const slot = telegram_mcp_session_open(); + try std.testing.expect(slot >= 0); + + // Can't connect from disconnected + try std.testing.expectEqual(@as(c_int, -2), telegram_mcp_connect(slot)); + + // Can't rate-limit from disconnected + try std.testing.expectEqual(@as(c_int, -2), telegram_mcp_rate_limit(slot)); + + // Authenticate, then can't re-authenticate + try std.testing.expectEqual(@as(c_int, 0), telegram_mcp_authenticate(slot)); + try std.testing.expectEqual(@as(c_int, -2), telegram_mcp_authenticate(slot)); +} + +test "transition validator" { + try std.testing.expectEqual(@as(c_int, 1), telegram_mcp_can_transition(0, 1)); + try std.testing.expectEqual(@as(c_int, 1), telegram_mcp_can_transition(1, 2)); + try std.testing.expectEqual(@as(c_int, 1), telegram_mcp_can_transition(1, 4)); + try std.testing.expectEqual(@as(c_int, 1), telegram_mcp_can_transition(2, 3)); + try std.testing.expectEqual(@as(c_int, 1), telegram_mcp_can_transition(3, 2)); + try std.testing.expectEqual(@as(c_int, 1), telegram_mcp_can_transition(2, 4)); + try std.testing.expectEqual(@as(c_int, 1), telegram_mcp_can_transition(4, 0)); + try std.testing.expectEqual(@as(c_int, 1), telegram_mcp_can_transition(2, 0)); + + // Invalid + try std.testing.expectEqual(@as(c_int, 0), telegram_mcp_can_transition(0, 2)); + try std.testing.expectEqual(@as(c_int, 0), telegram_mcp_can_transition(0, 3)); + try std.testing.expectEqual(@as(c_int, 0), telegram_mcp_can_transition(4, 2)); + try std.testing.expectEqual(@as(c_int, 0), telegram_mcp_can_transition(99, 0)); +} + +test "token validation" { + // Valid token (contains colon, > 10 chars) + try std.testing.expectEqual(@as(c_int, 1), telegram_mcp_validate_token("123456789:ABCdef".ptr, 16)); + + // Too short + try std.testing.expectEqual(@as(c_int, 0), telegram_mcp_validate_token("12:ab".ptr, 5)); + + // No colon + try std.testing.expectEqual(@as(c_int, 0), telegram_mcp_validate_token("12345678901234".ptr, 14)); + + // Empty + try std.testing.expectEqual(@as(c_int, 0), telegram_mcp_validate_token("".ptr, 0)); +} + +test "action validation" { + var i: c_int = 0; + while (i < 16) : (i += 1) { + try std.testing.expectEqual(@as(c_int, 1), telegram_mcp_is_valid_action(i)); + } + try std.testing.expectEqual(@as(c_int, 0), telegram_mcp_is_valid_action(16)); + try std.testing.expectEqual(@as(c_int, 0), telegram_mcp_is_valid_action(-1)); + try std.testing.expectEqual(@as(c_int, 16), telegram_mcp_action_count()); + try std.testing.expectEqual(@as(c_int, 30), telegram_mcp_global_rate_limit()); +} + +test "slot exhaustion" { + telegram_mcp_reset(); + + var slots: [MAX_SESSIONS]c_int = undefined; + for (&slots) |*s| { + s.* = telegram_mcp_session_open(); + try std.testing.expect(s.* >= 0); + } + + try std.testing.expectEqual(@as(c_int, -1), telegram_mcp_session_open()); + + try std.testing.expectEqual(@as(c_int, 0), telegram_mcp_authenticate(slots[0])); + try std.testing.expectEqual(@as(c_int, 0), telegram_mcp_connect(slots[0])); + try std.testing.expectEqual(@as(c_int, 0), telegram_mcp_session_close(slots[0])); + const new_slot = telegram_mcp_session_open(); + try std.testing.expect(new_slot >= 0); +} + +// ═══════════════════════════════════════════════════════════════════════ +// ADR-0006 invoke dispatch tests +// ═══════════════════════════════════════════════════════════════════════ + +test "boj_cartridge_name returns telegram-mcp" { + const n = std.mem.span(boj_cartridge_name()); + try std.testing.expectEqualStrings("telegram-mcp", n); +} + +test "boj_cartridge_init returns 0" { + try std.testing.expectEqual(@as(c_int, 0), boj_cartridge_init()); +} + +test "invoke: each declared tool succeeds" { + var buf: [256]u8 = undefined; + const tools = [_][]const u8{ + "telegram_authenticate", + "telegram_send_message", + "telegram_get_updates", + "telegram_get_chat", + "telegram_send_photo", + "telegram_deauthenticate", + }; + for (tools) |t| { + var len: usize = buf.len; + const rc = boj_cartridge_invoke(t.ptr, "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, 0), rc); + try std.testing.expect(std.mem.indexOf(u8, buf[0..len], "result") != null); + } +} + +test "invoke: unknown tool returns -1" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("nope", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -1), rc); +} + +test "invoke: buffer too small returns -3" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("telegram_authenticate", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -3), rc); + try std.testing.expect(len > 4); +} diff --git a/cartridges/domains/communications/telegram-mcp/minter.toml b/cartridges/domains/communications/telegram-mcp/minter.toml new file mode 100644 index 0000000..e460e42 --- /dev/null +++ b/cartridges/domains/communications/telegram-mcp/minter.toml @@ -0,0 +1,46 @@ +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> + +name = "telegram-mcp" +description = "Telegram Bot API cartridge β€” messages, updates, chats, media, webhooks, callbacks, stickers, forwarding, pinning" +version = "0.1.0" +domain = "Comms" +protocols = [ + "MCP", + "REST", +] +tier = "Ayo" +backend = "universal" +generate_panel = true + +[auth] +type = "token_in_url" +pattern = "https://api.telegram.org/bot{token}/{method}" +base_url = "https://api.telegram.org/" + +[rate_limiting] +strategy = "global" +messages_per_second = 30 +per_chat_per_second = 1 +group_per_minute = 20 + +[actions] +count = 16 +list = [ + "SendMessage", + "EditMessage", + "DeleteMessage", + "GetUpdates", + "GetChat", + "ListChats", + "SendPhoto", + "SendDocument", + "SetWebhook", + "DeleteWebhook", + "GetWebhookInfo", + "AnswerCallback", + "SendSticker", + "ForwardMessage", + "PinMessage", + "GetMe", +] diff --git a/cartridges/domains/communications/telegram-mcp/mod.js b/cartridges/domains/communications/telegram-mcp/mod.js new file mode 100644 index 0000000..3e2ca84 --- /dev/null +++ b/cartridges/domains/communications/telegram-mcp/mod.js @@ -0,0 +1,71 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// telegram-mcp/mod.js -- telegram gateway. + +const BASE_URL = Deno.env.get("TELEGRAM_MCP_BACKEND_URL") ?? "http://127.0.0.1:7730"; + +async function post(path, payload) { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), 15000); + try { + const r = await fetch(`${BASE_URL}${path}`, { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), signal: ctrl.signal, + }); + const data = await r.json().catch(() => ({ success: false, error: "non-JSON response" })); + return { status: r.status, data }; + } catch (e) { + if (e.name === "AbortError") return { status: 504, data: { success: false, error: "telegram-mcp backend timed out" } }; + return { status: 503, data: { success: false, error: `telegram-mcp backend unavailable: ${e.message}` } }; + } finally { clearTimeout(t); } +} + +export async function handleTool(toolName, args) { + switch (toolName) { + + case "telegram_authenticate": { + const { token } = args ?? {}; + if (!token) return { status: 400, data: { error: "token is required" } }; + const payload = { token }; + return post("/api/v1/authenticate", payload); + } + case "telegram_send_message": { + const { slot, chat_id, text, parse_mode } = args ?? {}; + if (!slot || !chat_id || !text) return { status: 400, data: { error: "slot is required" } }; + const payload = { slot, chat_id, text }; + if (parse_mode !== undefined) payload.parse_mode = parse_mode; + return post("/api/v1/send-message", payload); + } + case "telegram_get_updates": { + const { slot, offset, limit } = args ?? {}; + if (!slot) return { status: 400, data: { error: "slot is required" } }; + const payload = { slot }; + if (offset !== undefined) payload.offset = offset; + if (limit !== undefined) payload.limit = limit; + return post("/api/v1/get-updates", payload); + } + case "telegram_get_chat": { + const { slot, chat_id } = args ?? {}; + if (!slot || !chat_id) return { status: 400, data: { error: "slot is required" } }; + const payload = { slot, chat_id }; + return post("/api/v1/get-chat", payload); + } + case "telegram_send_photo": { + const { slot, chat_id, photo, caption } = args ?? {}; + if (!slot || !chat_id || !photo) return { status: 400, data: { error: "slot is required" } }; + const payload = { slot, chat_id, photo }; + if (caption !== undefined) payload.caption = caption; + return post("/api/v1/send-photo", payload); + } + case "telegram_deauthenticate": { + const { slot } = args ?? {}; + if (!slot) return { status: 400, data: { error: "slot is required" } }; + const payload = { slot }; + return post("/api/v1/deauthenticate", payload); + } + + default: + return { status: 404, data: { error: `Unknown tool: ${toolName}` } }; + } +} diff --git a/cartridges/domains/communications/telegram-mcp/panels/manifest.json b/cartridges/domains/communications/telegram-mcp/panels/manifest.json new file mode 100644 index 0000000..29285d5 --- /dev/null +++ b/cartridges/domains/communications/telegram-mcp/panels/manifest.json @@ -0,0 +1,71 @@ +{ + "$schema": "https://panll.dev/schemas/panel-manifest/v1.json", + "spdx": "MPL-2.0", + "cartridge": "telegram-mcp", + "domain": "Comms", + "version": "0.1.0", + "panels": [ + { + "id": "telegram-connection-status", + "title": "Connection Status", + "description": "Current Telegram connection state (Disconnected / Authenticating / Connected / RateLimited / Error)", + "type": "status-indicator", + "data_source": { + "endpoint": "/telegram/state", + "method": "GET", + "refresh_interval_ms": 5000 + }, + "widgets": [ + { + "type": "state-badge", + "field": "state", + "states": { + "disconnected": { "color": "#95a5a6", "icon": "plug-disconnected" }, + "authenticating": { "color": "#f39c12", "icon": "key" }, + "connected": { "color": "#2ecc71", "icon": "plug-connected" }, + "rate_limited": { "color": "#e67e22", "icon": "clock" }, + "error": { "color": "#e74c3c", "icon": "alert-triangle" } + } + } + ] + }, + { + "id": "telegram-bot-username", + "title": "Bot Username", + "description": "Username of the connected Telegram bot (from getMe)", + "type": "metric", + "data_source": { + "endpoint": "/telegram/metrics", + "method": "GET", + "refresh_interval_ms": 30000 + }, + "widgets": [ + { + "type": "text", + "field": "bot_username", + "label": "Bot", + "icon": "bot" + } + ] + }, + { + "id": "telegram-message-count", + "title": "Message Count", + "description": "Number of messages sent in the current session (subject to 30 msg/sec global limit)", + "type": "metric", + "data_source": { + "endpoint": "/telegram/metrics", + "method": "GET", + "refresh_interval_ms": 10000 + }, + "widgets": [ + { + "type": "counter", + "field": "message_count", + "label": "Messages Sent", + "icon": "message-circle" + } + ] + } + ] +} diff --git a/cartridges/domains/communications/telegram-mcp/tests/integration_test.sh b/cartridges/domains/communications/telegram-mcp/tests/integration_test.sh new file mode 100755 index 0000000..64ac2de --- /dev/null +++ b/cartridges/domains/communications/telegram-mcp/tests/integration_test.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Integration tests for telegram-mcp cartridge. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CART_DIR="$(dirname "$SCRIPT_DIR")" +FFI_DIR="$CART_DIR/ffi" + +echo "=== telegram-mcp integration tests ===" + +# Build FFI +echo "[1/3] Building FFI..." +cd "$FFI_DIR" && zig build 2>&1 + +# Run Zig unit tests +echo "[2/3] Running FFI unit tests..." +cd "$FFI_DIR" && zig build test 2>&1 + +# Validate Idris2 ABI (if idris2 available) +echo "[3/3] Checking ABI..." +if command -v idris2 &>/dev/null; then + cd "$CART_DIR/abi" && idris2 --check TelegramMcp.SafeComms 2>&1 + echo " ABI: OK" +else + echo " ABI: SKIPPED (idris2 not in PATH)" +fi + +echo "" +echo "All tests passed for telegram-mcp!" diff --git a/cartridges/domains/community/civic-connect-mcp/README.adoc b/cartridges/domains/community/civic-connect-mcp/README.adoc new file mode 100644 index 0000000..07ade86 --- /dev/null +++ b/cartridges/domains/community/civic-connect-mcp/README.adoc @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += civic-connect-mcp +Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk> +:spdx: MPL-2.0 +:tier: Ayo +:domain: community +:protocols: MCP, REST + +== Overview + +CivicConnect community engagement platform + +== Tools (3) + +[cols="2,4"] +|=== +| Tool | Description + +| `civic_list_channels` | List available community channels +| `civic_send_message` | Send a message to a channel +| `civic_get_poll` | Get a community poll +|=== + +== Architecture + +Three-layer stack per the BoJ cartridge standard: + +[cols="1,1,3"] +|=== +| Layer | Language | Purpose + +| ABI | Idris2 | Formally verified state machine with dependent-type proofs +| FFI | Zig | C-ABI implementation: session pool, action metrics, thread safety +| Adapter| Zig | Adapter bridge to the BoJ unified adapter protocol (zig predecessors sidelined) +|=== + +== Building + +[source,bash] +---- +# ABI (Idris2) +cd abi && idris2 --check *.idr + +# FFI (Zig) +cd ffi && zig build +cd ffi && zig build test + +# Adapter (Zig) +cd adapter && zig build +---- + +== Status + +Alpha (CRG D). Cartridge metadata declared in `cartridge.json`; 3 tool(s) exposed. +Not yet dogfooded externally. See `docs/READINESS.md` at repo root for the +project-wide grade table and `docs/practice/DOGFOOD-LOG.adoc` for usage evidence. diff --git a/cartridges/domains/community/civic-connect-mcp/abi/CivicConnect/Protocol.idr b/cartridges/domains/community/civic-connect-mcp/abi/CivicConnect/Protocol.idr new file mode 100644 index 0000000..ba8d0dd --- /dev/null +++ b/cartridges/domains/community/civic-connect-mcp/abi/CivicConnect/Protocol.idr @@ -0,0 +1,50 @@ +-- SPDX-License-Identifier: MPL-2.0 +-- Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +-- +-- CivicConnect ABI β€” civic platform protocol definitions. + +module CivicConnect.Protocol + +import Data.Nat + +||| CivicConnect operation codes. +public export +data CivicConnectOp + = ListChannels + | SendMessage + | GetPoll + +||| Channel with participant count. +public export +record Channel where + constructor MkChannel + channelId : Nat + name : String + participants : Nat + +||| A message in a channel. +public export +record Message where + constructor MkMessage + channelId : Nat + author : String + body : String + {auto prf : NonEmpty (unpack body)} + +||| Poll with vote tallies. +public export +record Poll where + constructor MkPoll + question : String + options : List (String, Nat) + totalVotes : Nat + +||| Proof: message body is always non-empty by construction. +export +messageBodyNonEmpty : (m : Message) -> NonEmpty (unpack m.body) +messageBodyNonEmpty m = m.prf + +||| Proof: total votes equals sum of option votes (stated as type). +export +pollConsistency : (p : Poll) -> p.totalVotes = p.totalVotes +pollConsistency _ = Refl diff --git a/cartridges/domains/community/civic-connect-mcp/abi/README.adoc b/cartridges/domains/community/civic-connect-mcp/abi/README.adoc new file mode 100644 index 0000000..4696985 --- /dev/null +++ b/cartridges/domains/community/civic-connect-mcp/abi/README.adoc @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += civic-connect-mcp / abi β€” Idris2 ABI layer +:orientation: shallow + +== Purpose + +Cartridge-local protocol types for `civic-connect-mcp`. Defines the data, tool, and +transition vocabulary this cartridge speaks. Inherits safety lemmas and the +matrix-cell identifier scheme from the trunk ABI at `src/abi/`. + +== Files + +1 Idris2 module(s), ~50 lines total. Lead module: +`Protocol.idr`. + +No `.ipkg` β€” consumed directly by wider BoJ callers; type-checks inside the +trunk `boj.ipkg`. + +== Invariants + +* `%default total` at every module top. +* Zero `believe_me`, `assert_total`, `postulate`, `Admitted`, or + `sorry` (the trunk holds the four declared `believe_me` sites; leaf + cartridges carry none). + +== Test/proof surface + +Thin module(s), no inline tests. Proof surface = type-check success under the +trunk `idris2 --check`. + +== Read-first + +. `Protocol.idr` β€” cartridge-local data records and enums. +. Trunk `src/abi/Boj/Catalogue.idr` for how this cell participates in the + 2D matrix. diff --git a/cartridges/domains/community/civic-connect-mcp/adapter/README.adoc b/cartridges/domains/community/civic-connect-mcp/adapter/README.adoc new file mode 100644 index 0000000..10e3154 --- /dev/null +++ b/cartridges/domains/community/civic-connect-mcp/adapter/README.adoc @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += civic-connect-mcp / adapter β€” protocol bridge +:orientation: shallow + +== Purpose + +Exposes this cartridge's FFI over the wire protocols declared in +`cartridge.json`. Dispatches tool invocations to the C-ABI exports at +`../ffi/` and formats responses per protocol. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” depends on the sibling `ffi` target. +| `civic_connect_adapter.zig` | Protocol dispatch (124 lines). Tool catalogue matches `cartridge.json`. +| `SIDELINED-*.v.adoc` | zig predecessor(s). Not built β€” zig banned 2026-04-10. +|=== + +== Invariants + +* **Stateless** β€” all state lives behind the FFI, never in the adapter. +* **Response passthrough** β€” whatever the FFI returns goes back to the wire + unmodified (no embellishment, no silent recovery). +* **Cartridge.json is source of truth** for the tool catalogue; drift between + adapter and manifest is a CI failure. + +== Test/proof surface + +No inline tests at the adapter layer β€” protocol routing is exercised by the +trunk seam tests at `ffi/zig/src/seams.zig`. + +== Read-first + +. `civic_connect_adapter.zig` β€” dispatch function and per-protocol routers. +. `../cartridge.json` β€” the tool manifest the dispatcher mirrors. diff --git a/cartridges/domains/community/civic-connect-mcp/adapter/build.zig b/cartridges/domains/community/civic-connect-mcp/adapter/build.zig new file mode 100644 index 0000000..919cb09 --- /dev/null +++ b/cartridges/domains/community/civic-connect-mcp/adapter/build.zig @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// civic-connect-mcp/adapter/build.zig + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const ffi_mod = b.createModule(.{ + .root_source_file = b.path("../ffi/civic_connect_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + const adapter = b.addExecutable(.{ + .name = "civic_connect_adapter", + .root_source_file = b.path("civic_connect_adapter.zig"), + .target = target, + .optimize = optimize, + }); + adapter.root_module.addImport("civic_connect_ffi", ffi_mod); + b.installArtifact(adapter); +} diff --git a/cartridges/domains/community/civic-connect-mcp/adapter/civic_connect_adapter.zig b/cartridges/domains/community/civic-connect-mcp/adapter/civic_connect_adapter.zig new file mode 100644 index 0000000..9b0e35d --- /dev/null +++ b/cartridges/domains/community/civic-connect-mcp/adapter/civic_connect_adapter.zig @@ -0,0 +1,124 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// civic-connect-mcp/adapter/civic_connect_adapter.zig +// +// Three-protocol BoJ adapter: REST (port 9208), gRPC-compat (port 9209), +// GraphQL (port 9210). +// Replaces the banned zig adapter (civic_connect_adapter.v). + +const std = @import("std"); +const ffi = @import("civic_connect_ffi"); + +const REST_PORT: u16 = 9208; +const GRPC_PORT: u16 = 9209; +const GQL_PORT: u16 = 9210; + +const Response = struct { status: u16, body: []const u8 }; + +fn okJson(buf: []u8, msg: []const u8) []u8 { + return std.fmt.bufPrint(buf, "{{\"success\":true,\"message\":\"{s}\"}}", .{msg}) catch buf[0..0]; +} + +fn errJson(buf: []u8, msg: []const u8) []u8 { + return std.fmt.bufPrint(buf, "{{\"success\":false,\"error\":\"{s}\"}}", .{msg}) catch buf[0..0]; +} + +fn dispatch(tool: []const u8, _body: []const u8, resp: []u8) Response { + _ = _body; + if (std.mem.eql(u8, tool, "civic_list_channels")) { + return .{ .status = 200, .body = okJson(resp, "civic_list_channels forwarded") }; + } + if (std.mem.eql(u8, tool, "civic_send_message")) { + return .{ .status = 200, .body = okJson(resp, "civic_send_message forwarded") }; + } + if (std.mem.eql(u8, tool, "civic_get_poll")) { + return .{ .status = 200, .body = okJson(resp, "civic_get_poll forwarded") }; + } + return .{ .status = 404, .body = errJson(resp, "unknown tool") }; +} + +fn dispatchRest(path: []const u8, body: []const u8, resp: []u8) Response { + // Expect /tools/<tool_name> + const prefix = "/tools/"; + if (std.mem.startsWith(u8, path, prefix)) { + const tool = path[prefix.len..]; + return dispatch(tool, body, resp); + } + return .{ .status = 404, .body = errJson(resp, "not found") }; +} + +fn dispatchGrpc(path: []const u8, body: []const u8, resp: []u8) Response { + // Expect /<Service>/<Method> β€” derive tool from Method + var it = std.mem.splitScalar(u8, path, '/'); + _ = it.next(); // leading empty + _ = it.next(); // service + const method = it.next() orelse return .{ .status = 404, .body = errJson(resp, "bad gRPC path") }; + return dispatch(method, body, resp); +} + +fn dispatchGraphql(body: []const u8, resp: []u8) Response { + if (std.mem.indexOf(u8, body, "civic_list_channels") != null) + return dispatch("civic_list_channels", body, resp); + if (std.mem.indexOf(u8, body, "civic_send_message") != null) + return dispatch("civic_send_message", body, resp); + if (std.mem.indexOf(u8, body, "civic_get_poll") != null) + return dispatch("civic_get_poll", body, resp); + return .{ .status = 400, .body = errJson(resp, "unrecognised GraphQL operation") }; +} + +fn handleConnection(stream: std.net.Stream, port: u16) void { + defer stream.close(); + var buf: [4096]u8 = undefined; + var resp_buf: [4096]u8 = undefined; + const n = stream.read(&buf) catch return; + const req = buf[0..n]; + const result = switch (port) { + REST_PORT => blk: { + // Parse HTTP/1.1: first line = METHOD PATH HTTP/x.y + var lines = std.mem.splitScalar(u8, req, '\n'); + const first = lines.next() orelse break :blk Response{ .status = 400, .body = "" }; + var parts = std.mem.splitScalar(u8, std.mem.trim(u8, first, "\r"), ' '); + _ = parts.next(); // method + const path = parts.next() orelse break :blk Response{ .status = 400, .body = "" }; + break :blk dispatchRest(path, req, &resp_buf); + }, + GRPC_PORT => blk: { + var lines = std.mem.splitScalar(u8, req, '\n'); + const first = lines.next() orelse break :blk Response{ .status = 400, .body = "" }; + var parts = std.mem.splitScalar(u8, std.mem.trim(u8, first, "\r"), ' '); + _ = parts.next(); + const path = parts.next() orelse break :blk Response{ .status = 400, .body = "" }; + break :blk dispatchGrpc(path, req, &resp_buf); + }, + GQL_PORT => dispatchGraphql(req, &resp_buf), + else => Response{ .status = 500, .body = "" }, + }; + var http_resp: [512]u8 = undefined; + const http = std.fmt.bufPrint(&http_resp, + "HTTP/1.1 {d} OK\r\nContent-Length: {d}\r\nContent-Type: application/json\r\n\r\n", + .{ result.status, result.body.len }) catch return; + _ = stream.write(http) catch {}; + _ = stream.write(result.body) catch {}; +} + +fn listenLoop(port: u16) !void { + const addr = std.net.Address.initIp4(.{ 127, 0, 0, 1 }, port); + var server = try addr.listen(.{ .reuse_address = true }); + defer server.deinit(); + while (true) { + const conn = try server.accept(); + const t = try std.Thread.spawn(.{}, handleConnection, .{ conn.stream, port }); + t.detach(); + } +} + +pub fn main() !void { + ffi.civic_connect_init(); + const t1 = try std.Thread.spawn(.{}, listenLoop, .{REST_PORT}); + const t2 = try std.Thread.spawn(.{}, listenLoop, .{GRPC_PORT}); + const t3 = try std.Thread.spawn(.{}, listenLoop, .{GQL_PORT}); + t1.join(); + t2.join(); + t3.join(); +} diff --git a/cartridges/domains/community/civic-connect-mcp/cartridge.json b/cartridges/domains/community/civic-connect-mcp/cartridge.json new file mode 100644 index 0000000..0a8bc5a --- /dev/null +++ b/cartridges/domains/community/civic-connect-mcp/cartridge.json @@ -0,0 +1,81 @@ +{ + "$schema": "https://boj.dev/schemas/cartridge/v1.json", + "spdx": "MPL-2.0", + "copyright": "Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk>", + "name": "civic-connect-mcp", + "version": "0.1.0", + "description": "CivicConnect community engagement platform", + "domain": "community", + "tier": "Ayo", + "protocols": [ + "MCP", + "REST" + ], + "auth": { + "method": "none", + "env_var": null, + "credential_source": null + }, + "api": { + "base_url": "local://civic-connect-mcp", + "content_type": "application/json" + }, + "tools": [ + { + "name": "civic_list_channels", + "description": "List available community channels", + "inputSchema": { + "type": "object", + "properties": {} + } + }, + { + "name": "civic_send_message", + "description": "Send a message to a channel", + "inputSchema": { + "type": "object", + "properties": { + "channel_id": { + "type": "integer", + "description": "Channel ID" + }, + "body": { + "type": "string", + "description": "Message body" + } + }, + "required": [ + "channel_id", + "body" + ] + } + }, + { + "name": "civic_get_poll", + "description": "Get a community poll", + "inputSchema": { + "type": "object", + "properties": { + "poll_id": { + "type": "integer", + "description": "Poll ID" + } + }, + "required": [ + "poll_id" + ] + } + } + ], + "ffi": { + "so_path": "ffi/zig-out/lib/libcivic_connect_mcp.so", + "abi_version": "ADR-0006", + "symbols": [ + "boj_cartridge_init", + "boj_cartridge_deinit", + "boj_cartridge_name", + "boj_cartridge_version", + "boj_cartridge_invoke" + ] + } +} diff --git a/cartridges/domains/community/civic-connect-mcp/ffi/README.adoc b/cartridges/domains/community/civic-connect-mcp/ffi/README.adoc new file mode 100644 index 0000000..deed90b --- /dev/null +++ b/cartridges/domains/community/civic-connect-mcp/ffi/README.adoc @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += civic-connect-mcp / ffi β€” Zig FFI layer **(stub)** +:orientation: shallow + +== Purpose + +C-ABI exports bridging this cartridge's Idris2 ABI to the runtime. Consumed by +the adapter for protocol dispatch and by the trunk loader for mount. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” produces `zig-out/lib/libcivic-connect.so`. +| `civic_connect_ffi.zig` | C-ABI exports (3 exports, 3 inline tests, 37 lines). +|=== + +== Invariants + +* **Null-pointer rejection** on `[*c]const u8` arguments before any + dereference. +* **Negative return values** carry documented error meanings; zero is + success (matching the trunk error-code convention). +* **No allocator leaks** β€” every allocation path pairs with `defer free`. + +== Test/proof surface + +3 inline `test "..."` block(s) in `civic_connect_ffi.zig`. Run with +`cd ffi && zig build test`. + +== Read-first + +. `civic_connect_ffi.zig` β€” C-ABI signatures and guards. +. Trunk `ffi/zig/src/catalogue.zig` for how this `.so` is loaded. + +== Promotion gate + +Wire these exports to real backing state (not `return 0` / `return 1` +placeholders) before promoting past D. diff --git a/cartridges/domains/community/civic-connect-mcp/ffi/build.zig b/cartridges/domains/community/civic-connect-mcp/ffi/build.zig new file mode 100644 index 0000000..3b205c3 --- /dev/null +++ b/cartridges/domains/community/civic-connect-mcp/ffi/build.zig @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + // Shared ADR-0006 invoke-shim module (relative path up to boj-server trunk). + + const shim_mod = b.addModule("cartridge_shim", .{ + + .root_source_file = b.path("../../../ffi/zig/src/cartridge_shim.zig"), + + .target = target, + + .optimize = optimize, + + }); + + const ffi_mod = b.addModule("civic_connect_mcp", .{ + .root_source_file = b.path("civic_connect_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + ffi_mod.addImport("cartridge_shim", shim_mod); + + const lib = b.addLibrary(.{ + .name = "civic_connect_mcp", + .root_module = ffi_mod, + .linkage = .dynamic, + }); + lib.linkLibC(); + b.installArtifact(lib); + + const tests = b.addTest(.{ .root_module = ffi_mod }); + tests.linkLibC(); + const run_tests = b.addRunArtifact(tests); + const test_step = b.step("test", "Run FFI tests"); + test_step.dependOn(&run_tests.step); +} + diff --git a/cartridges/domains/community/civic-connect-mcp/ffi/cartridge_shim.zig b/cartridges/domains/community/civic-connect-mcp/ffi/cartridge_shim.zig new file mode 100644 index 0000000..0e399db --- /dev/null +++ b/cartridges/domains/community/civic-connect-mcp/ffi/cartridge_shim.zig @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// cartridge_shim.zig β€” Shared helpers for the ADR-0006 five-symbol +// cartridge ABI (`boj_cartridge_init / deinit / name / version / invoke`). +// +// The shim centralises the seven-code return convention, NUL-argument +// guards, tool-name comparison, and the buffer-too-small path so each +// cartridge's `boj_cartridge_invoke` can stay short β€” typically a tool +// table plus `shim.writeResult(...)`. +// +// Cartridges import this file by relative path (no build-graph change +// needed). Example: +// +// const shim = @import("../../../ffi/zig/src/cartridge_shim.zig"); +// +// export fn boj_cartridge_invoke( +// tool_name: [*c]const u8, +// json_args: [*c]const u8, +// out_buf: [*c]u8, +// in_out_len: [*c]usize, +// ) callconv(.c) i32 { +// _ = json_args; +// if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; +// const body = if (shim.toolIs(tool_name, "foo")) "{\"result\":{}}" +// else return shim.RC_UNKNOWN_TOOL; +// return shim.writeResult(out_buf, in_out_len, body); +// } + +const std = @import("std"); + +// ── Return codes (ADR-0006 Β§Return codes) ──────────────────────────── +// +// Frozen by ADR-0006. New failure modes compose these via the error +// JSON body β€” the integer surface does not grow without a follow-up ADR. + +pub const RC_SUCCESS: i32 = 0; +pub const RC_UNKNOWN_TOOL: i32 = -1; +pub const RC_BAD_ARGS: i32 = -2; +pub const RC_BUFFER_TOO_SMALL: i32 = -3; +pub const RC_RUNTIME_ERROR: i32 = -4; +pub const RC_PANIC: i32 = -5; +pub const RC_AUTH_DENIED: i32 = -6; + +// ── Invoke-path helpers ────────────────────────────────────────────── + +/// True if any of the three mandatory `boj_cartridge_invoke` output-path +/// pointers is null. Use at the top of every invoke to short-circuit to +/// `RC_BAD_ARGS`. +pub fn invokeArgsNull( + tool_name: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) bool { + return tool_name == null or out_buf == null or in_out_len == null; +} + +/// Compare a C-NUL-terminated tool-name pointer against a Zig string +/// literal. Caller must have already verified `tool_name` is non-null +/// (usually via `invokeArgsNull`). +pub fn toolIs(tool_name: [*c]const u8, expected: []const u8) bool { + const s = std.mem.span(@as([*:0]const u8, @ptrCast(tool_name))); + return std.mem.eql(u8, s, expected); +} + +/// Copy `body` into `out_buf[0..*in_out_len]` (as a capacity) and update +/// `*in_out_len` to the number of bytes written. Returns `RC_SUCCESS`. +/// +/// If `body.len` exceeds the current capacity stored in `*in_out_len`, +/// sets `*in_out_len` to the required size and returns +/// `RC_BUFFER_TOO_SMALL` β€” the caller is then expected to re-allocate +/// and retry, per ADR-0006 Β§Memory ownership. +/// +/// Caller must have already verified that `out_buf` and `in_out_len` +/// are non-null. +pub fn writeResult( + out_buf: [*c]u8, + in_out_len: [*c]usize, + body: []const u8, +) i32 { + const cap = in_out_len.*; + if (body.len > cap) { + in_out_len.* = body.len; + return RC_BUFFER_TOO_SMALL; + } + @memcpy(out_buf[0..body.len], body); + in_out_len.* = body.len; + return RC_SUCCESS; +} + +// ── Tests ──────────────────────────────────────────────────────────── + +test "writeResult: body fits, writes and sets length" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); + try std.testing.expectEqualStrings("hello", buf[0..len]); +} + +test "writeResult: too small returns -3 and sets required length" { + var buf: [2]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_BUFFER_TOO_SMALL, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: exact-fit succeeds" { + var buf: [5]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: empty body" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, ""); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 0), len); +} + +test "toolIs: matches and rejects" { + const name: [*:0]const u8 = "foo"; + try std.testing.expect(toolIs(@ptrCast(name), "foo")); + try std.testing.expect(!toolIs(@ptrCast(name), "bar")); + try std.testing.expect(!toolIs(@ptrCast(name), "foobar")); + try std.testing.expect(!toolIs(@ptrCast(name), "fo")); +} + +test "invokeArgsNull: detects each null slot" { + var buf: [4]u8 = undefined; + var len: usize = 4; + const name: [*:0]const u8 = "x"; + try std.testing.expect(!invokeArgsNull(@ptrCast(name), &buf, &len)); + try std.testing.expect(invokeArgsNull(null, &buf, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), null, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), &buf, null)); +} diff --git a/cartridges/domains/community/civic-connect-mcp/ffi/civic_connect_ffi.zig b/cartridges/domains/community/civic-connect-mcp/ffi/civic_connect_ffi.zig new file mode 100644 index 0000000..6e1da31 --- /dev/null +++ b/cartridges/domains/community/civic-connect-mcp/ffi/civic_connect_ffi.zig @@ -0,0 +1,125 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +// +// CivicConnect FFI β€” C-compatible exports for civic platform communications. + +const std = @import("std"); + +/// List active channel count. +export fn civic_connect_list_channels_count() u32 { + return 0; // Stub +} + +/// Send a message to a channel. Returns 0 on success, -1 on error. +export fn civic_connect_send_message(channel_id: u32, body: [*c]const u8) i32 { + if (channel_id == 0 or body == null) return -1; + return 0; // Stub +} + +/// Get poll results. Returns total vote count, or 0 if poll not found. +export fn civic_connect_get_poll(poll_id: u32) u32 { + if (poll_id == 0) return 0; + return 0; // Stub +} + +// ═══════════════════════════════════════════════════════════════════════ +// Standard ABI (ADR-0005 four symbols + ADR-0006 invoke) +// ═══════════════════════════════════════════════════════════════════════ + +const shim = @import("cartridge_shim.zig"); + +const CARTRIDGE_NAME_PTR: [*:0]const u8 = "civic-connect-mcp"; +const CARTRIDGE_VERSION_PTR: [*:0]const u8 = "0.1.0"; + +export fn boj_cartridge_init() callconv(.c) c_int { + return 0; +} + +export fn boj_cartridge_deinit() callconv(.c) void {} + +export fn boj_cartridge_name() callconv(.c) [*:0]const u8 { + return CARTRIDGE_NAME_PTR; +} + +export fn boj_cartridge_version() callconv(.c) [*:0]const u8 { + return CARTRIDGE_VERSION_PTR; +} + +/// Dispatch the cartridge.json MCP tools. Grade D Alpha stubs. +export fn boj_cartridge_invoke( + tool_name: [*c]const u8, + json_args: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) callconv(.c) i32 { + _ = json_args; + if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; + + const body: []const u8 = if (shim.toolIs(tool_name, "civic_list_channels")) + "{\"result\":{\"items\":[],\"count\":0,\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "civic_send_message")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "civic_get_poll")) + "{\"result\":{\"metadata\":{},\"status\":\"stub\"}}" +else + return shim.RC_UNKNOWN_TOOL; + + return shim.writeResult(out_buf, in_out_len, body); +} + +// ── Tests ── + +test "send rejects null body" { + try std.testing.expectEqual(@as(i32, -1), civic_connect_send_message(1, null)); +} + +test "send rejects zero channel" { + try std.testing.expectEqual(@as(i32, -1), civic_connect_send_message(0, "hello")); +} + +test "poll returns zero for invalid id" { + try std.testing.expectEqual(@as(u32, 0), civic_connect_get_poll(0)); +} + +// ═══════════════════════════════════════════════════════════════════════ +// ADR-0006 invoke dispatch tests +// ═══════════════════════════════════════════════════════════════════════ + +test "boj_cartridge_name returns civic-connect-mcp" { + const n = std.mem.span(boj_cartridge_name()); + try std.testing.expectEqualStrings("civic-connect-mcp", n); +} + +test "boj_cartridge_init returns 0" { + try std.testing.expectEqual(@as(c_int, 0), boj_cartridge_init()); +} + +test "invoke: each declared tool succeeds" { + var buf: [256]u8 = undefined; + const tools = [_][]const u8{ + "civic_list_channels", + "civic_send_message", + "civic_get_poll", + }; + for (tools) |t| { + var len: usize = buf.len; + const rc = boj_cartridge_invoke(t.ptr, "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, 0), rc); + try std.testing.expect(std.mem.indexOf(u8, buf[0..len], "result") != null); + } +} + +test "invoke: unknown tool returns -1" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("nope", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -1), rc); +} + +test "invoke: buffer too small returns -3" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("civic_list_channels", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -3), rc); + try std.testing.expect(len > 4); +} diff --git a/cartridges/domains/community/civic-connect-mcp/mod.js b/cartridges/domains/community/civic-connect-mcp/mod.js new file mode 100644 index 0000000..41b6b82 --- /dev/null +++ b/cartridges/domains/community/civic-connect-mcp/mod.js @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// civic-connect-mcp/mod.js β€” CivicConnect community engagement platform +// +// Delegates to backend at http://127.0.0.1:7714 (override with CIVIC_CONNECT_URL). + +const BASE_URL = Deno.env.get("CIVIC_CONNECT_URL") ?? "http://127.0.0.1:7714"; +const TIMEOUT_MS = 15_000; + +async function post(path, payload) { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), TIMEOUT_MS); + try { + const r = await fetch(`${BASE_URL}${path}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + signal: ctrl.signal, + }); + const data = await r.json().catch(() => ({ success: false, error: "non-JSON response" })); + return { status: r.status, data }; + } catch (e) { + if (e.name === "AbortError") return { status: 504, data: { success: false, error: "civic-connect-mcp backend timed out" } }; + return { status: 503, data: { success: false, error: `civic-connect-mcp backend unavailable: ${e.message}` } }; + } finally { clearTimeout(t); } +} + +async function get(path) { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), TIMEOUT_MS); + try { + const r = await fetch(`${BASE_URL}${path}`, { method: "GET", signal: ctrl.signal }); + const data = await r.json().catch(() => ({ success: false, error: "non-JSON response" })); + return { status: r.status, data }; + } catch (e) { + if (e.name === "AbortError") return { status: 504, data: { success: false, error: "civic-connect-mcp backend timed out" } }; + return { status: 503, data: { success: false, error: `civic-connect-mcp backend unavailable: ${e.message}` } }; + } finally { clearTimeout(t); } +} + +export async function handleTool(toolName, args) { + switch (toolName) { + case "civic_list_channels": + return post("/api/v1/civic_list_channels", args ?? {}); + case "civic_send_message": + return post("/api/v1/civic_send_message", args ?? {}); + case "civic_get_poll": + return post("/api/v1/civic_get_poll", args ?? {}); + default: + return { status: 404, data: { error: `Unknown tool: ${toolName}` } }; + } +} diff --git a/cartridges/domains/community/civic-connect-mcp/panels/manifest.json b/cartridges/domains/community/civic-connect-mcp/panels/manifest.json new file mode 100644 index 0000000..0a752eb --- /dev/null +++ b/cartridges/domains/community/civic-connect-mcp/panels/manifest.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://panll.dev/schemas/panel-manifest/v1.json", + "spdx": "MPL-2.0", + "cartridge": "civic-connect-mcp", + "domain": "civic", + "version": "0.1.0", + "panels": [ + { + "id": "civic-connect-channel-list", + "title": "Channel List", + "description": "Active civic channels with participant counts and recent activity indicators.", + "type": "data-table", + "entrypoint": "panels/civic-connect-channel-list.js", + "size": { "cols": 3, "rows": 3 }, + "refresh_interval_ms": 10000, + "data_sources": [ + { "op": "ListChannels", "interval_ms": 10000 }, + { "op": "GetPoll", "on": "event" } + ] + } + ] +} diff --git a/cartridges/domains/community/feedback-mcp/README.adoc b/cartridges/domains/community/feedback-mcp/README.adoc new file mode 100644 index 0000000..82442a7 --- /dev/null +++ b/cartridges/domains/community/feedback-mcp/README.adoc @@ -0,0 +1,153 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> + += feedback-mcp Cartridge +:toc: +:toclevels: 2 + +== Overview + +The feedback-mcp cartridge implements **feedback-o-tron**, a feedback collection +and sentiment tracking system for BoJ Server. It is the self-dogfooding loop: +BoJ collects feedback on itself via its own feedback cartridge. + +The cartridge follows the standard BoJ triple-adapter pipeline: + +* **Idris2 ABI** β€” formal proofs for the feedback state machine +* **Zig FFI** β€” C-compatible state machine execution +* **zig Adapter** β€” HTTP/MCP dispatch with NDJSON persistence + +== Tools (10) + +[cols="2,1,4"] +|=== +| Tool | Args | Description + +| `list_channels` +| _none_ +| Lists all available feedback channel types (web_form, api, email, irc, mastodon, gitea, custom). + +| `open_channel` +| `{"channel": "api"}` +| Registers a new feedback channel and returns a slot index (0-7). Transitions the channel to `channel_registered` state. + +| `submit` +| `{"slot": "0", "sentiment": "positive\|neutral\|negative"}` +| Submits a feedback entry to the specified slot. Appends an NDJSON line to `/tmp/boj/feedback/{slot}.ndjson` with timestamp, slot, sentiment, and running count. + +| `summary` +| _none_ +| Returns a summary of all active channels including per-channel sentiment counts, total feedback, and an overall sentiment classification (good/mixed/poor). + +| `sentiment_summary` +| _none_ +| Aggregates all active channels' sentiment data into a compact summary with overall sentiment, per-channel breakdowns, and totals. + +| `status` +| _none_ +| Health check β€” confirms feedback-o-tron is running and the Zig FFI state machine is wired. + +| `process` +| `{"slot": "0"}` +| Transitions a channel to processing state, performs a no-op analysis pass, then returns the channel to collecting state. Returns current sentiment counts. + +| `export_feedback` +| _none_ +| Reads all NDJSON files from `/tmp/boj/feedback/` and returns concatenated feedback entries as a JSON array. + +| `count_feedback` +| _none_ +| Counts total feedback entries across all NDJSON files, broken down per slot. + +| `close_channel` +| `{"slot": "0"}` +| _(planned)_ Deregisters a feedback channel and transitions it to inactive. + +|=== + +== State Machine + +The feedback state machine is implemented in Zig FFI and exposed via C function declarations. + +.... + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ inactive β”‚ (state 0) + β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ fb_register() + β–Ό + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ channel_registered β”‚ (state 1) + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ fb_start_collecting() + β–Ό + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”Œβ”€β”€β–Άβ”‚ collecting │◀──┐ (state 2) + β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ + β”‚ β”‚ process tool β”‚ + β”‚ β–Ό β”‚ + β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ + β”‚ β”‚ processing β”‚β”€β”€β”€β”˜ (state 3) + β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ + β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + └───│ error β”‚ (state 4) + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +.... + +=== FFI Functions + +[cols="2,3"] +|=== +| C Function | Purpose + +| `C.fb_register(channel_type)` | Register a new channel, returns slot index +| `C.fb_start_collecting(slot)` | Transition slot to collecting state +| `C.fb_submit(slot, sentiment)` | Submit feedback entry with sentiment value +| `C.fb_state(slot)` | Query current state of a slot +| `C.fb_count(slot)` | Total feedback count for slot +| `C.fb_positive_count(slot)` | Positive sentiment count +| `C.fb_negative_count(slot)` | Negative sentiment count +| `C.fb_neutral_count(slot)` | Neutral sentiment count +| `C.fb_deregister(slot)` | Deregister a channel +| `C.fb_can_transition(from, to)` | Check if state transition is valid +| `C.fb_reset()` | Reset all channels +|=== + +== Channels + +Six built-in channel types are supported, plus a custom type: + +[cols="1,1,3"] +|=== +| Name | Type ID | Description + +| `web_form` | 1 | Web form submissions +| `api` | 2 | Direct API calls +| `email` | 3 | Email-based feedback +| `irc` | 4 | IRC channel feedback +| `mastodon` | 5 | Mastodon/fediverse feedback +| `gitea` | 6 | Gitea issue/PR feedback +| `custom` | 99 | Any other source +|=== + +Up to **8 concurrent channels** (slots 0-7) can be active simultaneously. + +== Persistence + +Feedback entries are persisted as NDJSON (newline-delimited JSON) files at +`/tmp/boj/feedback/{slot}.ndjson`. Each line contains: + +[source,json] +---- +{"timestamp":"2026-03-20T14:30:00Z","slot":"0","sentiment":"positive","count":"5"} +---- + +The `export_feedback` tool reads these files and returns all entries. +The `count_feedback` tool counts lines per slot and in total. + +== PanLL Integration + +The cartridge ships a panel manifest at `panels/manifest.json` that provides: + +* **Feedback Dashboard** β€” live sentiment status indicator with counters and state badge +* **Feedback Timeline** β€” chronological log table of all feedback entries diff --git a/cartridges/domains/community/feedback-mcp/abi/FeedbackMcp/SafeFeedback.idr b/cartridges/domains/community/feedback-mcp/abi/FeedbackMcp/SafeFeedback.idr new file mode 100644 index 0000000..20f44d8 --- /dev/null +++ b/cartridges/domains/community/feedback-mcp/abi/FeedbackMcp/SafeFeedback.idr @@ -0,0 +1,173 @@ +-- SPDX-License-Identifier: MPL-2.0 +-- Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +||| FeedbackMcp.SafeFeedback: Formally verified feedback collection operations. +||| +||| Cartridge: feedback-mcp (18th cartridge, feedback-o-tron) +||| Matrix cell: Observe domain x {MCP, REST} protocols +||| +||| This module defines a feedback pipeline state machine that prevents: +||| - Submitting feedback to unconfigured channels +||| - Processing feedback without channel registration +||| - Reading feedback from inactive channels +||| +||| State machine: Inactive -> ChannelRegistered -> Collecting -> Processing -> Collecting +module FeedbackMcp.SafeFeedback + +import Data.List + +%default total + +-- ═══════════════════════════════════════════════════════════════════════════ +-- Feedback Channel State Machine +-- ═══════════════════════════════════════════════════════════════════════════ + +||| Feedback channel lifecycle states. +||| A channel progresses: Inactive -> ChannelRegistered -> Collecting -> Processing -> Collecting +public export +data FeedbackState = Inactive | ChannelRegistered | Collecting | Processing | FeedbackError + +||| Equality for feedback states. +public export +Eq FeedbackState where + Inactive == Inactive = True + ChannelRegistered == ChannelRegistered = True + Collecting == Collecting = True + Processing == Processing = True + FeedbackError == FeedbackError = True + _ == _ = False + +||| Valid state transitions (enforced at the type level). +||| Critically, Inactive -> Processing is NOT valid. +||| You MUST register a channel and start collecting first. +public export +data ValidTransition : FeedbackState -> FeedbackState -> Type where + Register : ValidTransition Inactive ChannelRegistered + StartCollect : ValidTransition ChannelRegistered Collecting + StartProcess : ValidTransition Collecting Processing + EndProcess : ValidTransition Processing Collecting + Deregister : ValidTransition Collecting Inactive + ProcessError : ValidTransition Processing FeedbackError + Recover : ValidTransition FeedbackError Collecting + +||| Runtime transition validator. +public export +canTransition : FeedbackState -> FeedbackState -> Bool +canTransition Inactive ChannelRegistered = True +canTransition ChannelRegistered Collecting = True +canTransition Collecting Processing = True +canTransition Processing Collecting = True +canTransition Collecting Inactive = True -- deregister +canTransition Processing FeedbackError = True +canTransition FeedbackError Collecting = True -- recover +canTransition _ _ = False + +-- ═══════════════════════════════════════════════════════════════════════════ +-- Feedback Channel Types +-- ═══════════════════════════════════════════════════════════════════════════ + +||| Supported feedback channels. +public export +data FeedbackChannel + = WebForm -- HTTP form submissions + | ApiEndpoint -- REST API submissions + | Email -- Email-based feedback + | Irc -- IRC channel feedback + | Mastodon -- Fediverse feedback + | Gitea -- Gitea issue/comment feedback + | Custom String -- User-defined channel + +||| C-ABI encoding. +public export +channelToInt : FeedbackChannel -> Int +channelToInt WebForm = 1 +channelToInt ApiEndpoint = 2 +channelToInt Email = 3 +channelToInt Irc = 4 +channelToInt Mastodon = 5 +channelToInt Gitea = 6 +channelToInt (Custom _) = 99 + +-- ═══════════════════════════════════════════════════════════════════════════ +-- Feedback Sentiment +-- ═══════════════════════════════════════════════════════════════════════════ + +||| Feedback sentiment categories. +public export +data Sentiment = Positive | Neutral | Negative | Unclassified + +||| C-ABI encoding for sentiment. +public export +sentimentToInt : Sentiment -> Int +sentimentToInt Positive = 1 +sentimentToInt Neutral = 0 +sentimentToInt Negative = -1 +sentimentToInt Unclassified = -99 + +-- ═══════════════════════════════════════════════════════════════════════════ +-- MCP Tool Definitions +-- ═══════════════════════════════════════════════════════════════════════════ + +||| MCP tools exposed by this cartridge. +public export +data McpTool + = ToolRegisterChannel -- Register a feedback channel + | ToolSubmitFeedback -- Submit feedback to a channel + | ToolListFeedback -- List collected feedback + | ToolProcessFeedback -- Process/classify feedback + | ToolSentimentSummary -- Aggregate sentiment report + | ToolChannelStatus -- Channel health check + | ToolDeregister -- Deregister a channel + +||| MCP tool name. +public export +toolName : McpTool -> String +toolName ToolRegisterChannel = "feedback/register" +toolName ToolSubmitFeedback = "feedback/submit" +toolName ToolListFeedback = "feedback/list" +toolName ToolProcessFeedback = "feedback/process" +toolName ToolSentimentSummary = "feedback/sentiment" +toolName ToolChannelStatus = "feedback/status" +toolName ToolDeregister = "feedback/deregister" + +||| Which tools require a channel to be registered first. +public export +toolRequiresChannel : McpTool -> Bool +toolRequiresChannel ToolRegisterChannel = False +toolRequiresChannel _ = True + +-- ═══════════════════════════════════════════════════════════════════════════ +-- C-ABI Exports +-- ═══════════════════════════════════════════════════════════════════════════ + +||| Feedback state to integer. +public export +feedbackStateToInt : FeedbackState -> Int +feedbackStateToInt Inactive = 0 +feedbackStateToInt ChannelRegistered = 1 +feedbackStateToInt Collecting = 2 +feedbackStateToInt Processing = 3 +feedbackStateToInt FeedbackError = 4 + +||| FFI: Validate a state transition. +export +fb_can_transition : Int -> Int -> Int +fb_can_transition from to = + let fromState = case from of + 0 => Inactive + 1 => ChannelRegistered + 2 => Collecting + 3 => Processing + _ => FeedbackError + toState = case to of + 0 => Inactive + 1 => ChannelRegistered + 2 => Collecting + 3 => Processing + _ => FeedbackError + in if canTransition fromState toState then 1 else 0 + +||| FFI: Check if a tool requires a registered channel. +export +fb_tool_requires_channel : Int -> Int +fb_tool_requires_channel 1 = 0 -- ToolRegisterChannel +fb_tool_requires_channel _ = 1 -- All others require a channel diff --git a/cartridges/domains/community/feedback-mcp/abi/README.adoc b/cartridges/domains/community/feedback-mcp/abi/README.adoc new file mode 100644 index 0000000..338545e --- /dev/null +++ b/cartridges/domains/community/feedback-mcp/abi/README.adoc @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += feedback-mcp / abi β€” Idris2 ABI layer +:orientation: shallow + +== Purpose + +Cartridge-local protocol types for `feedback-mcp`. Defines the data, tool, and +transition vocabulary this cartridge speaks. Inherits safety lemmas and the +matrix-cell identifier scheme from the trunk ABI at `src/abi/`. + +== Files + +1 Idris2 module(s), ~173 lines total. Lead module: +`SafeFeedback.idr`. + +No `.ipkg` β€” consumed directly by wider BoJ callers; type-checks inside the +trunk `boj.ipkg`. + +== Invariants + +* `%default total` at every module top. +* Zero `believe_me`, `assert_total`, `postulate`, `Admitted`, or + `sorry` (the trunk holds the four declared `believe_me` sites; leaf + cartridges carry none). + +== Test/proof surface + +Thin module(s), no inline tests. Proof surface = type-check success under the +trunk `idris2 --check`. + +== Read-first + +. `SafeFeedback.idr` β€” cartridge-local data records and enums. +. Trunk `src/abi/Boj/Catalogue.idr` for how this cell participates in the + 2D matrix. diff --git a/cartridges/domains/community/feedback-mcp/abi/feedback-mcp.ipkg b/cartridges/domains/community/feedback-mcp/abi/feedback-mcp.ipkg new file mode 100644 index 0000000..fb8c47c --- /dev/null +++ b/cartridges/domains/community/feedback-mcp/abi/feedback-mcp.ipkg @@ -0,0 +1,11 @@ +-- SPDX-License-Identifier: MPL-2.0 +package feedbackmcp + +authors = "Jonathan D.A. Jewell" +version = 0.1.0 +license = "MPL-2.0" +brief = "Feedback MCP cartridge β€” feedback collection state machine with channel validation" + +sourcedir = "." +modules = FeedbackMcp.SafeFeedback +depends = base, contrib diff --git a/cartridges/domains/community/feedback-mcp/adapter/README.adoc b/cartridges/domains/community/feedback-mcp/adapter/README.adoc new file mode 100644 index 0000000..dfbbdfd --- /dev/null +++ b/cartridges/domains/community/feedback-mcp/adapter/README.adoc @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += feedback-mcp / adapter β€” protocol bridge +:orientation: shallow + +== Purpose + +Exposes this cartridge's FFI over the wire protocols declared in +`cartridge.json`. Dispatches tool invocations to the C-ABI exports at +`../ffi/` and formats responses per protocol. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” depends on the sibling `ffi` target. +| `feedback_adapter.zig` | Protocol dispatch (129 lines). Tool catalogue matches `cartridge.json`. +| `SIDELINED-*.v.adoc` | zig predecessor(s). Not built β€” zig banned 2026-04-10. +|=== + +== Invariants + +* **Stateless** β€” all state lives behind the FFI, never in the adapter. +* **Response passthrough** β€” whatever the FFI returns goes back to the wire + unmodified (no embellishment, no silent recovery). +* **Cartridge.json is source of truth** for the tool catalogue; drift between + adapter and manifest is a CI failure. + +== Test/proof surface + +No inline tests at the adapter layer β€” protocol routing is exercised by the +trunk seam tests at `ffi/zig/src/seams.zig`. + +== Read-first + +. `feedback_adapter.zig` β€” dispatch function and per-protocol routers. +. `../cartridge.json` β€” the tool manifest the dispatcher mirrors. diff --git a/cartridges/domains/community/feedback-mcp/adapter/build.zig b/cartridges/domains/community/feedback-mcp/adapter/build.zig new file mode 100644 index 0000000..adf0ba8 --- /dev/null +++ b/cartridges/domains/community/feedback-mcp/adapter/build.zig @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// feedback-mcp/adapter/build.zig + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const ffi_mod = b.createModule(.{ + .root_source_file = b.path("../ffi/feedback_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + const adapter = b.addExecutable(.{ + .name = "feedback_adapter", + .root_source_file = b.path("feedback_adapter.zig"), + .target = target, + .optimize = optimize, + }); + adapter.root_module.addImport("feedback_ffi", ffi_mod); + b.installArtifact(adapter); +} diff --git a/cartridges/domains/community/feedback-mcp/adapter/feedback_adapter.zig b/cartridges/domains/community/feedback-mcp/adapter/feedback_adapter.zig new file mode 100644 index 0000000..c03c8db --- /dev/null +++ b/cartridges/domains/community/feedback-mcp/adapter/feedback_adapter.zig @@ -0,0 +1,129 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// feedback-mcp/adapter/feedback_adapter.zig +// +// Three-protocol BoJ adapter: REST (port 9232), gRPC-compat (port 9233), +// GraphQL (port 9234). +// Replaces the banned zig adapter (feedback_adapter.v). + +const std = @import("std"); +const ffi = @import("feedback_ffi"); + +const REST_PORT: u16 = 9232; +const GRPC_PORT: u16 = 9233; +const GQL_PORT: u16 = 9234; + +const Response = struct { status: u16, body: []const u8 }; + +fn okJson(buf: []u8, msg: []const u8) []u8 { + return std.fmt.bufPrint(buf, "{{\"success\":true,\"message\":\"{s}\"}}", .{msg}) catch buf[0..0]; +} + +fn errJson(buf: []u8, msg: []const u8) []u8 { + return std.fmt.bufPrint(buf, "{{\"success\":false,\"error\":\"{s}\"}}", .{msg}) catch buf[0..0]; +} + +fn dispatch(tool: []const u8, _body: []const u8, resp: []u8) Response { + _ = _body; + if (std.mem.eql(u8, tool, "feedback_register_channel")) { + return .{ .status = 200, .body = okJson(resp, "feedback_register_channel forwarded") }; + } + if (std.mem.eql(u8, tool, "feedback_start_collecting")) { + return .{ .status = 200, .body = okJson(resp, "feedback_start_collecting forwarded") }; + } + if (std.mem.eql(u8, tool, "feedback_submit")) { + return .{ .status = 200, .body = okJson(resp, "feedback_submit forwarded") }; + } + if (std.mem.eql(u8, tool, "feedback_get_stats")) { + return .{ .status = 200, .body = okJson(resp, "feedback_get_stats forwarded") }; + } + return .{ .status = 404, .body = errJson(resp, "unknown tool") }; +} + +fn dispatchRest(path: []const u8, body: []const u8, resp: []u8) Response { + // Expect /tools/<tool_name> + const prefix = "/tools/"; + if (std.mem.startsWith(u8, path, prefix)) { + const tool = path[prefix.len..]; + return dispatch(tool, body, resp); + } + return .{ .status = 404, .body = errJson(resp, "not found") }; +} + +fn dispatchGrpc(path: []const u8, body: []const u8, resp: []u8) Response { + // Expect /<Service>/<Method> β€” derive tool from Method + var it = std.mem.splitScalar(u8, path, '/'); + _ = it.next(); // leading empty + _ = it.next(); // service + const method = it.next() orelse return .{ .status = 404, .body = errJson(resp, "bad gRPC path") }; + return dispatch(method, body, resp); +} + +fn dispatchGraphql(body: []const u8, resp: []u8) Response { + if (std.mem.indexOf(u8, body, "feedback_register_channel") != null) + return dispatch("feedback_register_channel", body, resp); + if (std.mem.indexOf(u8, body, "feedback_start_collecting") != null) + return dispatch("feedback_start_collecting", body, resp); + if (std.mem.indexOf(u8, body, "feedback_submit") != null) + return dispatch("feedback_submit", body, resp); + if (std.mem.indexOf(u8, body, "feedback_get_stats") != null) + return dispatch("feedback_get_stats", body, resp); + return .{ .status = 400, .body = errJson(resp, "unrecognised GraphQL operation") }; +} + +fn handleConnection(stream: std.net.Stream, port: u16) void { + defer stream.close(); + var buf: [4096]u8 = undefined; + var resp_buf: [4096]u8 = undefined; + const n = stream.read(&buf) catch return; + const req = buf[0..n]; + const result = switch (port) { + REST_PORT => blk: { + // Parse HTTP/1.1: first line = METHOD PATH HTTP/x.y + var lines = std.mem.splitScalar(u8, req, '\n'); + const first = lines.next() orelse break :blk Response{ .status = 400, .body = "" }; + var parts = std.mem.splitScalar(u8, std.mem.trim(u8, first, "\r"), ' '); + _ = parts.next(); // method + const path = parts.next() orelse break :blk Response{ .status = 400, .body = "" }; + break :blk dispatchRest(path, req, &resp_buf); + }, + GRPC_PORT => blk: { + var lines = std.mem.splitScalar(u8, req, '\n'); + const first = lines.next() orelse break :blk Response{ .status = 400, .body = "" }; + var parts = std.mem.splitScalar(u8, std.mem.trim(u8, first, "\r"), ' '); + _ = parts.next(); + const path = parts.next() orelse break :blk Response{ .status = 400, .body = "" }; + break :blk dispatchGrpc(path, req, &resp_buf); + }, + GQL_PORT => dispatchGraphql(req, &resp_buf), + else => Response{ .status = 500, .body = "" }, + }; + var http_resp: [512]u8 = undefined; + const http = std.fmt.bufPrint(&http_resp, + "HTTP/1.1 {d} OK\r\nContent-Length: {d}\r\nContent-Type: application/json\r\n\r\n", + .{ result.status, result.body.len }) catch return; + _ = stream.write(http) catch {}; + _ = stream.write(result.body) catch {}; +} + +fn listenLoop(port: u16) !void { + const addr = std.net.Address.initIp4(.{ 127, 0, 0, 1 }, port); + var server = try addr.listen(.{ .reuse_address = true }); + defer server.deinit(); + while (true) { + const conn = try server.accept(); + const t = try std.Thread.spawn(.{}, handleConnection, .{ conn.stream, port }); + t.detach(); + } +} + +pub fn main() !void { + ffi.feedback_init(); + const t1 = try std.Thread.spawn(.{}, listenLoop, .{REST_PORT}); + const t2 = try std.Thread.spawn(.{}, listenLoop, .{GRPC_PORT}); + const t3 = try std.Thread.spawn(.{}, listenLoop, .{GQL_PORT}); + t1.join(); + t2.join(); + t3.join(); +} diff --git a/cartridges/domains/community/feedback-mcp/cartridge.json b/cartridges/domains/community/feedback-mcp/cartridge.json new file mode 100644 index 0000000..f2d4b9a --- /dev/null +++ b/cartridges/domains/community/feedback-mcp/cartridge.json @@ -0,0 +1,113 @@ +{ + "$schema": "https://boj.dev/schemas/cartridge/v1.json", + "spdx": "MPL-2.0", + "copyright": "Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk>", + "name": "feedback-mcp", + "version": "0.1.0", + "description": "Feedback collection and sentiment analysis", + "domain": "community", + "tier": "Ayo", + "protocols": [ + "MCP", + "REST" + ], + "auth": { + "method": "none", + "env_var": null, + "credential_source": null + }, + "api": { + "base_url": "local://feedback-mcp", + "content_type": "application/json" + }, + "tools": [ + { + "name": "feedback_register_channel", + "description": "Register a feedback channel", + "inputSchema": { + "type": "object", + "properties": { + "channel_type": { + "type": "string", + "description": "Channel: web_form|api|email|irc|mastodon|gitea" + }, + "endpoint": { + "type": "string", + "description": "Channel endpoint URL or address" + } + }, + "required": [ + "channel_type" + ] + } + }, + { + "name": "feedback_start_collecting", + "description": "Start collecting feedback on a channel", + "inputSchema": { + "type": "object", + "properties": { + "session_id": { + "type": "integer", + "description": "Session index" + } + }, + "required": [ + "session_id" + ] + } + }, + { + "name": "feedback_submit", + "description": "Submit a feedback entry", + "inputSchema": { + "type": "object", + "properties": { + "session_id": { + "type": "integer", + "description": "Session index" + }, + "text": { + "type": "string", + "description": "Feedback text" + }, + "sentiment": { + "type": "string", + "description": "Explicit sentiment: positive|negative|neutral" + } + }, + "required": [ + "session_id", + "text" + ] + } + }, + { + "name": "feedback_get_stats", + "description": "Get feedback statistics", + "inputSchema": { + "type": "object", + "properties": { + "session_id": { + "type": "integer", + "description": "Session index" + } + }, + "required": [ + "session_id" + ] + } + } + ], + "ffi": { + "so_path": "ffi/zig-out/lib/libfeedback_mcp.so", + "abi_version": "ADR-0006", + "symbols": [ + "boj_cartridge_init", + "boj_cartridge_deinit", + "boj_cartridge_name", + "boj_cartridge_version", + "boj_cartridge_invoke" + ] + } +} diff --git a/cartridges/domains/community/feedback-mcp/ffi/README.adoc b/cartridges/domains/community/feedback-mcp/ffi/README.adoc new file mode 100644 index 0000000..0582241 --- /dev/null +++ b/cartridges/domains/community/feedback-mcp/ffi/README.adoc @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += feedback-mcp / ffi β€” Zig FFI layer +:orientation: shallow + +== Purpose + +C-ABI exports bridging this cartridge's Idris2 ABI to the runtime. Consumed by +the adapter for protocol dispatch and by the trunk loader for mount. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” produces `zig-out/lib/libfeedback.so`. +| `feedback_ffi.zig` | C-ABI exports (15 exports, 5 inline tests, 310 lines). +|=== + +== Invariants + +* **Null-pointer rejection** on `[*c]const u8` arguments before any + dereference. +* **Negative return values** carry documented error meanings; zero is + success (matching the trunk error-code convention). +* **No allocator leaks** β€” every allocation path pairs with `defer free`. + +== Test/proof surface + +5 inline `test "..."` block(s) in `feedback_ffi.zig`. Run with +`cd ffi && zig build test`. + +== Read-first + +. `feedback_ffi.zig` β€” C-ABI signatures and guards. +. Trunk `ffi/zig/src/catalogue.zig` for how this `.so` is loaded. diff --git a/cartridges/domains/community/feedback-mcp/ffi/build.zig b/cartridges/domains/community/feedback-mcp/ffi/build.zig new file mode 100644 index 0000000..0cdc5cc --- /dev/null +++ b/cartridges/domains/community/feedback-mcp/ffi/build.zig @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// Feedback-MCP Cartridge β€” Zig FFI build configuration (Zig 0.15+). + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + // Shared ADR-0006 invoke-shim module (relative path up to boj-server trunk). + + const shim_mod = b.addModule("cartridge_shim", .{ + + .root_source_file = b.path("../../../ffi/zig/src/cartridge_shim.zig"), + + .target = target, + + .optimize = optimize, + + }); + + const fb_mod = b.addModule("feedback_ffi", .{ + .root_source_file = b.path("feedback_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + fb_mod.addImport("cartridge_shim", shim_mod); + + // ── Tests ──────────────────────────────────────────────────────── + const fb_tests = b.addTest(.{ + .root_module = fb_mod, + }); + + const run_tests = b.addRunArtifact(fb_tests); + + const test_step = b.step("test", "Run feedback-mcp FFI tests"); + test_step.dependOn(&run_tests.step); + + // ── Shared library ────────────────────────────────────────────── + const lib_mod = b.createModule(.{ + .root_source_file = b.path("feedback_ffi.zig"), + .target = target, + .optimize = optimize, + }); + lib_mod.addImport("cartridge_shim", shim_mod); + + const lib = b.addLibrary(.{ + .name = "feedback_mcp", + .root_module = lib_mod, + .linkage = .dynamic, + }); + b.installArtifact(lib); + + const lib_step = b.step("lib", "Build shared library"); + lib_step.dependOn(&lib.step); +} diff --git a/cartridges/domains/community/feedback-mcp/ffi/cartridge_shim.zig b/cartridges/domains/community/feedback-mcp/ffi/cartridge_shim.zig new file mode 100644 index 0000000..0e399db --- /dev/null +++ b/cartridges/domains/community/feedback-mcp/ffi/cartridge_shim.zig @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// cartridge_shim.zig β€” Shared helpers for the ADR-0006 five-symbol +// cartridge ABI (`boj_cartridge_init / deinit / name / version / invoke`). +// +// The shim centralises the seven-code return convention, NUL-argument +// guards, tool-name comparison, and the buffer-too-small path so each +// cartridge's `boj_cartridge_invoke` can stay short β€” typically a tool +// table plus `shim.writeResult(...)`. +// +// Cartridges import this file by relative path (no build-graph change +// needed). Example: +// +// const shim = @import("../../../ffi/zig/src/cartridge_shim.zig"); +// +// export fn boj_cartridge_invoke( +// tool_name: [*c]const u8, +// json_args: [*c]const u8, +// out_buf: [*c]u8, +// in_out_len: [*c]usize, +// ) callconv(.c) i32 { +// _ = json_args; +// if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; +// const body = if (shim.toolIs(tool_name, "foo")) "{\"result\":{}}" +// else return shim.RC_UNKNOWN_TOOL; +// return shim.writeResult(out_buf, in_out_len, body); +// } + +const std = @import("std"); + +// ── Return codes (ADR-0006 Β§Return codes) ──────────────────────────── +// +// Frozen by ADR-0006. New failure modes compose these via the error +// JSON body β€” the integer surface does not grow without a follow-up ADR. + +pub const RC_SUCCESS: i32 = 0; +pub const RC_UNKNOWN_TOOL: i32 = -1; +pub const RC_BAD_ARGS: i32 = -2; +pub const RC_BUFFER_TOO_SMALL: i32 = -3; +pub const RC_RUNTIME_ERROR: i32 = -4; +pub const RC_PANIC: i32 = -5; +pub const RC_AUTH_DENIED: i32 = -6; + +// ── Invoke-path helpers ────────────────────────────────────────────── + +/// True if any of the three mandatory `boj_cartridge_invoke` output-path +/// pointers is null. Use at the top of every invoke to short-circuit to +/// `RC_BAD_ARGS`. +pub fn invokeArgsNull( + tool_name: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) bool { + return tool_name == null or out_buf == null or in_out_len == null; +} + +/// Compare a C-NUL-terminated tool-name pointer against a Zig string +/// literal. Caller must have already verified `tool_name` is non-null +/// (usually via `invokeArgsNull`). +pub fn toolIs(tool_name: [*c]const u8, expected: []const u8) bool { + const s = std.mem.span(@as([*:0]const u8, @ptrCast(tool_name))); + return std.mem.eql(u8, s, expected); +} + +/// Copy `body` into `out_buf[0..*in_out_len]` (as a capacity) and update +/// `*in_out_len` to the number of bytes written. Returns `RC_SUCCESS`. +/// +/// If `body.len` exceeds the current capacity stored in `*in_out_len`, +/// sets `*in_out_len` to the required size and returns +/// `RC_BUFFER_TOO_SMALL` β€” the caller is then expected to re-allocate +/// and retry, per ADR-0006 Β§Memory ownership. +/// +/// Caller must have already verified that `out_buf` and `in_out_len` +/// are non-null. +pub fn writeResult( + out_buf: [*c]u8, + in_out_len: [*c]usize, + body: []const u8, +) i32 { + const cap = in_out_len.*; + if (body.len > cap) { + in_out_len.* = body.len; + return RC_BUFFER_TOO_SMALL; + } + @memcpy(out_buf[0..body.len], body); + in_out_len.* = body.len; + return RC_SUCCESS; +} + +// ── Tests ──────────────────────────────────────────────────────────── + +test "writeResult: body fits, writes and sets length" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); + try std.testing.expectEqualStrings("hello", buf[0..len]); +} + +test "writeResult: too small returns -3 and sets required length" { + var buf: [2]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_BUFFER_TOO_SMALL, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: exact-fit succeeds" { + var buf: [5]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: empty body" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, ""); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 0), len); +} + +test "toolIs: matches and rejects" { + const name: [*:0]const u8 = "foo"; + try std.testing.expect(toolIs(@ptrCast(name), "foo")); + try std.testing.expect(!toolIs(@ptrCast(name), "bar")); + try std.testing.expect(!toolIs(@ptrCast(name), "foobar")); + try std.testing.expect(!toolIs(@ptrCast(name), "fo")); +} + +test "invokeArgsNull: detects each null slot" { + var buf: [4]u8 = undefined; + var len: usize = 4; + const name: [*:0]const u8 = "x"; + try std.testing.expect(!invokeArgsNull(@ptrCast(name), &buf, &len)); + try std.testing.expect(invokeArgsNull(null, &buf, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), null, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), &buf, null)); +} diff --git a/cartridges/domains/community/feedback-mcp/ffi/feedback_ffi.zig b/cartridges/domains/community/feedback-mcp/ffi/feedback_ffi.zig new file mode 100644 index 0000000..8f46fc1 --- /dev/null +++ b/cartridges/domains/community/feedback-mcp/ffi/feedback_ffi.zig @@ -0,0 +1,376 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// Feedback-MCP Cartridge β€” Zig FFI bridge for feedback collection. +// +// Implements the feedback pipeline state machine from SafeFeedback.idr. +// Manages feedback channels, collects submissions, tracks sentiment, +// and enforces channel registration before feedback processing. + +const std = @import("std"); + +// ═══════════════════════════════════════════════════════════════════════ +// Types (must match FeedbackMcp.SafeFeedback encodings) +// ═══════════════════════════════════════════════════════════════════════ + +pub const FeedbackState = enum(c_int) { + inactive = 0, + channel_registered = 1, + collecting = 2, + processing = 3, + feedback_error = 4, +}; + +pub const FeedbackChannel = enum(c_int) { + web_form = 1, + api_endpoint = 2, + email = 3, + irc = 4, + mastodon = 5, + gitea = 6, + custom = 99, +}; + +pub const Sentiment = enum(c_int) { + negative = -1, + neutral = 0, + positive = 1, + unclassified = -99, +}; + +// ═══════════════════════════════════════════════════════════════════════ +// Feedback Pipeline State Machine +// ═══════════════════════════════════════════════════════════════════════ + +const MAX_CHANNELS: usize = 8; +const MAX_FEEDBACK_PER_CHANNEL: usize = 64; + +const FeedbackEntry = struct { + active: bool, + sentiment: Sentiment, + timestamp: u64, +}; + +const ChannelSlot = struct { + active: bool, + channel: FeedbackChannel, + state: FeedbackState, + feedback_count: u32, + positive_count: u32, + negative_count: u32, + neutral_count: u32, +}; + +var channels: [MAX_CHANNELS]ChannelSlot = [_]ChannelSlot{.{ + .active = false, + .channel = .web_form, + .state = .inactive, + .feedback_count = 0, + .positive_count = 0, + .negative_count = 0, + .neutral_count = 0, +}} ** MAX_CHANNELS; + +var mutex: std.Thread.Mutex = .{}; + +/// Validate a state transition (matches Idris2 canTransition). +fn isValidTransition(from: FeedbackState, to: FeedbackState) bool { + return switch (from) { + .inactive => to == .channel_registered, + .channel_registered => to == .collecting, + .collecting => to == .processing or to == .inactive, + .processing => to == .collecting or to == .feedback_error, + .feedback_error => to == .collecting, + }; +} + +/// Register a new feedback channel. Returns slot index or -1 on failure. +pub export fn fb_register(channel_type: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + for (&channels, 0..) |*slot, i| { + if (!slot.active) { + slot.active = true; + slot.channel = @enumFromInt(channel_type); + slot.state = .channel_registered; + slot.feedback_count = 0; + slot.positive_count = 0; + slot.negative_count = 0; + slot.neutral_count = 0; + return @intCast(i); + } + } + return -1; // No slots available +} + +/// Transition a channel to collecting state. +pub export fn fb_start_collecting(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + if (slot_idx < 0 or slot_idx >= MAX_CHANNELS) return -1; + const idx: usize = @intCast(slot_idx); + if (!channels[idx].active) return -1; + if (!isValidTransition(channels[idx].state, .collecting)) return -2; + channels[idx].state = .collecting; + return 0; +} + +/// Submit feedback to a channel (must be in collecting state). +pub export fn fb_submit(slot_idx: c_int, sentiment: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + if (slot_idx < 0 or slot_idx >= MAX_CHANNELS) return -1; + const idx: usize = @intCast(slot_idx); + if (!channels[idx].active) return -1; + + // Auto-transition from channel_registered to collecting if needed + if (channels[idx].state == .channel_registered) { + channels[idx].state = .collecting; + } + + if (channels[idx].state != .collecting) return -2; + + channels[idx].feedback_count += 1; + const s: Sentiment = @enumFromInt(sentiment); + switch (s) { + .positive => channels[idx].positive_count += 1, + .negative => channels[idx].negative_count += 1, + .neutral => channels[idx].neutral_count += 1, + .unclassified => {}, + } + return @intCast(channels[idx].feedback_count); +} + +/// Get the state of a channel. +pub export fn fb_state(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + if (slot_idx < 0 or slot_idx >= MAX_CHANNELS) return -1; + const idx: usize = @intCast(slot_idx); + if (!channels[idx].active) return @intFromEnum(FeedbackState.inactive); + return @intFromEnum(channels[idx].state); +} + +/// Get the feedback count for a channel. +pub export fn fb_count(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + if (slot_idx < 0 or slot_idx >= MAX_CHANNELS) return -1; + const idx: usize = @intCast(slot_idx); + if (!channels[idx].active) return 0; + return @intCast(channels[idx].feedback_count); +} + +/// Get the positive feedback count for a channel. +pub export fn fb_positive_count(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + if (slot_idx < 0 or slot_idx >= MAX_CHANNELS) return 0; + const idx: usize = @intCast(slot_idx); + return @intCast(channels[idx].positive_count); +} + +/// Get the negative feedback count for a channel. +pub export fn fb_negative_count(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + if (slot_idx < 0 or slot_idx >= MAX_CHANNELS) return 0; + const idx: usize = @intCast(slot_idx); + return @intCast(channels[idx].negative_count); +} + +/// Get the neutral feedback count for a channel. +pub export fn fb_neutral_count(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + if (slot_idx < 0 or slot_idx >= MAX_CHANNELS) return 0; + const idx: usize = @intCast(slot_idx); + return @intCast(channels[idx].neutral_count); +} + +/// Deregister a channel (must be in collecting state). +pub export fn fb_deregister(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + if (slot_idx < 0 or slot_idx >= MAX_CHANNELS) return -1; + const idx: usize = @intCast(slot_idx); + if (!channels[idx].active) return -1; + if (!isValidTransition(channels[idx].state, .inactive)) return -2; + channels[idx].active = false; + channels[idx].state = .inactive; + return 0; +} + +/// Validate a state transition (C-ABI export). +pub export fn fb_can_transition(from: c_int, to: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + const f: FeedbackState = @enumFromInt(from); + const t: FeedbackState = @enumFromInt(to); + return if (isValidTransition(f, t)) 1 else 0; +} + +/// Reset all channels (for testing). +pub export fn fb_reset() void { + mutex.lock(); + defer mutex.unlock(); + for (&channels) |*slot| { + slot.active = false; + slot.state = .inactive; + slot.feedback_count = 0; + slot.positive_count = 0; + slot.negative_count = 0; + slot.neutral_count = 0; + } +} + +// ═══════════════════════════════════════════════════════════════════════ +// Standard Cartridge Interface (loader expects these 4 C-ABI symbols) +// ═══════════════════════════════════════════════════════════════════════ + +/// Initialise the feedback-mcp cartridge. Resets all channel slots. +pub export fn boj_cartridge_init() c_int { + fb_reset(); + return 0; +} + +/// Deinitialise the feedback-mcp cartridge. Resets all channel slots. +pub export fn boj_cartridge_deinit() void { + fb_reset(); +} + +/// Return the cartridge name as a null-terminated C string. +pub export fn boj_cartridge_name() [*:0]const u8 { + mutex.lock(); + defer mutex.unlock(); + return "feedback-mcp"; +} + +/// Return the cartridge version as a null-terminated C string. +pub export fn boj_cartridge_version() [*:0]const u8 { + mutex.lock(); + defer mutex.unlock(); + return "0.1.0"; +} + +// ═══════════════════════════════════════════════════════════════════════ +// ADR-0006 dispatch (boj_cartridge_invoke, 5th standard symbol) +// ═══════════════════════════════════════════════════════════════════════ + +const shim = @import("cartridge_shim.zig"); + +/// Dispatch the cartridge.json MCP tools. Grade D Alpha β€” each arm +/// returns a stub JSON body shaped to the tool's intended response. +export fn boj_cartridge_invoke( + tool_name: [*c]const u8, + json_args: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) callconv(.c) i32 { + _ = json_args; + if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; + + const body: []const u8 = if (shim.toolIs(tool_name, "feedback_register_channel")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "feedback_start_collecting")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "feedback_submit")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "feedback_get_stats")) + "{\"result\":{\"metadata\":{},\"status\":\"stub\"}}" +else + return shim.RC_UNKNOWN_TOOL; + + return shim.writeResult(out_buf, in_out_len, body); +} + +// ═══════════════════════════════════════════════════════════════════════ +// Tests +// ═══════════════════════════════════════════════════════════════════════ + +test "register and deregister channel" { + fb_reset(); + const slot = fb_register(@intFromEnum(FeedbackChannel.web_form)); + try std.testing.expect(slot >= 0); + try std.testing.expectEqual(@as(c_int, @intFromEnum(FeedbackState.channel_registered)), fb_state(slot)); + // Must start collecting before deregister + try std.testing.expectEqual(@as(c_int, 0), fb_start_collecting(slot)); + try std.testing.expectEqual(@as(c_int, 0), fb_deregister(slot)); +} + +test "cannot submit to inactive channel" { + fb_reset(); + // Slot 0 is not active β€” should fail + try std.testing.expectEqual(@as(c_int, -1), fb_submit(0, @intFromEnum(Sentiment.positive))); +} + +test "submit feedback and track sentiment" { + fb_reset(); + const slot = fb_register(@intFromEnum(FeedbackChannel.api_endpoint)); + // Submit 3 positive, 2 negative, 1 neutral + _ = fb_submit(slot, @intFromEnum(Sentiment.positive)); + _ = fb_submit(slot, @intFromEnum(Sentiment.positive)); + _ = fb_submit(slot, @intFromEnum(Sentiment.positive)); + _ = fb_submit(slot, @intFromEnum(Sentiment.negative)); + _ = fb_submit(slot, @intFromEnum(Sentiment.negative)); + _ = fb_submit(slot, @intFromEnum(Sentiment.neutral)); + try std.testing.expectEqual(@as(c_int, 6), fb_count(slot)); + try std.testing.expectEqual(@as(c_int, 3), fb_positive_count(slot)); + try std.testing.expectEqual(@as(c_int, 2), fb_negative_count(slot)); + try std.testing.expectEqual(@as(c_int, 1), fb_neutral_count(slot)); +} + +test "cannot deregister while not collecting" { + fb_reset(); + const slot = fb_register(@intFromEnum(FeedbackChannel.irc)); + // In channel_registered state, cannot deregister (must be collecting) + try std.testing.expectEqual(@as(c_int, -2), fb_deregister(slot)); +} + +test "state transition validation" { + // Valid transitions + try std.testing.expectEqual(@as(c_int, 1), fb_can_transition(0, 1)); // inactive -> registered + try std.testing.expectEqual(@as(c_int, 1), fb_can_transition(1, 2)); // registered -> collecting + try std.testing.expectEqual(@as(c_int, 1), fb_can_transition(2, 3)); // collecting -> processing + try std.testing.expectEqual(@as(c_int, 1), fb_can_transition(3, 2)); // processing -> collecting + try std.testing.expectEqual(@as(c_int, 1), fb_can_transition(2, 0)); // collecting -> inactive + // Invalid transitions β€” the key safety invariant + try std.testing.expectEqual(@as(c_int, 0), fb_can_transition(0, 3)); // inactive -> processing (BLOCKED) + try std.testing.expectEqual(@as(c_int, 0), fb_can_transition(0, 2)); // inactive -> collecting + try std.testing.expectEqual(@as(c_int, 0), fb_can_transition(3, 0)); // processing -> inactive +} + +// ═══════════════════════════════════════════════════════════════════════ +// ADR-0006 invoke dispatch tests +// ═══════════════════════════════════════════════════════════════════════ + +test "invoke: each declared tool succeeds" { + var buf: [256]u8 = undefined; + const tools = [_][]const u8{ + "feedback_register_channel", + "feedback_start_collecting", + "feedback_submit", + "feedback_get_stats", + }; + for (tools) |t| { + var len: usize = buf.len; + const rc = boj_cartridge_invoke(t.ptr, "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, 0), rc); + try std.testing.expect(std.mem.indexOf(u8, buf[0..len], "result") != null); + } +} + +test "invoke: unknown tool returns -1" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("nope", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -1), rc); +} + +test "invoke: buffer too small returns -3" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("feedback_register_channel", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -3), rc); + try std.testing.expect(len > 4); +} diff --git a/cartridges/domains/community/feedback-mcp/mod.js b/cartridges/domains/community/feedback-mcp/mod.js new file mode 100644 index 0000000..bfe878c --- /dev/null +++ b/cartridges/domains/community/feedback-mcp/mod.js @@ -0,0 +1,55 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// feedback-mcp/mod.js β€” Feedback collection and sentiment analysis +// +// Delegates to backend at http://127.0.0.1:7722 (override with FEEDBACK_BACKEND_URL). + +const BASE_URL = Deno.env.get("FEEDBACK_BACKEND_URL") ?? "http://127.0.0.1:7722"; +const TIMEOUT_MS = 15_000; + +async function post(path, payload) { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), TIMEOUT_MS); + try { + const r = await fetch(`${BASE_URL}${path}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + signal: ctrl.signal, + }); + const data = await r.json().catch(() => ({ success: false, error: "non-JSON response" })); + return { status: r.status, data }; + } catch (e) { + if (e.name === "AbortError") return { status: 504, data: { success: false, error: "feedback-mcp backend timed out" } }; + return { status: 503, data: { success: false, error: `feedback-mcp backend unavailable: ${e.message}` } }; + } finally { clearTimeout(t); } +} + +async function get(path) { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), TIMEOUT_MS); + try { + const r = await fetch(`${BASE_URL}${path}`, { method: "GET", signal: ctrl.signal }); + const data = await r.json().catch(() => ({ success: false, error: "non-JSON response" })); + return { status: r.status, data }; + } catch (e) { + if (e.name === "AbortError") return { status: 504, data: { success: false, error: "feedback-mcp backend timed out" } }; + return { status: 503, data: { success: false, error: `feedback-mcp backend unavailable: ${e.message}` } }; + } finally { clearTimeout(t); } +} + +export async function handleTool(toolName, args) { + switch (toolName) { + case "feedback_register_channel": + return post("/api/v1/feedback_register_channel", args ?? {}); + case "feedback_start_collecting": + return post("/api/v1/feedback_start_collecting", args ?? {}); + case "feedback_submit": + return post("/api/v1/feedback_submit", args ?? {}); + case "feedback_get_stats": + return post("/api/v1/feedback_get_stats", args ?? {}); + default: + return { status: 404, data: { error: `Unknown tool: ${toolName}` } }; + } +} diff --git a/cartridges/domains/community/feedback-mcp/panels/manifest.json b/cartridges/domains/community/feedback-mcp/panels/manifest.json new file mode 100644 index 0000000..bb0102d --- /dev/null +++ b/cartridges/domains/community/feedback-mcp/panels/manifest.json @@ -0,0 +1,41 @@ +{ + "$schema": "https://panll.dev/schemas/panel-manifest/v1.json", + "spdx": "MPL-2.0", + "cartridge": "feedback-mcp", + "domain": "Feedback", + "version": "0.1.0", + "panels": [ + { + "id": "feedback-dashboard", + "title": "Feedback Dashboard", + "description": "Live feedback sentiment across all channels", + "type": "status-indicator", + "data_source": { + "endpoint": "/cartridge/feedback-mcp/invoke", + "method": "POST", + "body": {"tool": "summary"}, + "refresh_interval_ms": 5000 + }, + "widgets": [ + {"type": "counter", "field": "active_channels", "label": "Active Channels"}, + {"type": "counter", "field": "total_feedback", "label": "Total Feedback"}, + {"type": "state-badge", "field": "overall_sentiment", "states": {"good": "green", "mixed": "amber", "poor": "red"}} + ] + }, + { + "id": "feedback-timeline", + "title": "Feedback Timeline", + "description": "Chronological feedback entries", + "type": "log-table", + "data_source": { + "endpoint": "/cartridge/feedback-mcp/invoke", + "method": "POST", + "body": {"tool": "export_feedback"}, + "refresh_interval_ms": 10000 + }, + "widgets": [ + {"type": "log-table", "columns": ["timestamp", "channel", "sentiment", "count"]} + ] + } + ] +} diff --git a/cartridges/domains/config/conflow-mcp/README.adoc b/cartridges/domains/config/conflow-mcp/README.adoc new file mode 100644 index 0000000..b6f3fac --- /dev/null +++ b/cartridges/domains/config/conflow-mcp/README.adoc @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += conflow-mcp +Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk> +:spdx: MPL-2.0 +:tier: Ayo +:domain: configuration +:protocols: MCP, REST + +== Overview + +Conflow configuration management + +== Tools (4) + +[cols="2,4"] +|=== +| Tool | Description + +| `conflow_get_config` | Get a configuration value by key +| `conflow_apply_config` | Apply a configuration blob +| `conflow_validate_config` | Validate a configuration blob +| `conflow_diff_config` | Diff two configuration blobs +|=== + +== Architecture + +Three-layer stack per the BoJ cartridge standard: + +[cols="1,1,3"] +|=== +| Layer | Language | Purpose + +| ABI | Idris2 | Formally verified state machine with dependent-type proofs +| FFI | Zig | C-ABI implementation: session pool, action metrics, thread safety +| Adapter| Zig | Adapter bridge to the BoJ unified adapter protocol (zig predecessors sidelined) +|=== + +== Building + +[source,bash] +---- +# ABI (Idris2) +cd abi && idris2 --check *.idr + +# FFI (Zig) +cd ffi && zig build +cd ffi && zig build test + +# Adapter (Zig) +cd adapter && zig build +---- + +== Status + +Alpha (CRG D). Cartridge metadata declared in `cartridge.json`; 4 tool(s) exposed. +Not yet dogfooded externally. See `docs/READINESS.md` at repo root for the +project-wide grade table and `docs/practice/DOGFOOD-LOG.adoc` for usage evidence. diff --git a/cartridges/domains/config/conflow-mcp/abi/Conflow/Protocol.idr b/cartridges/domains/config/conflow-mcp/abi/Conflow/Protocol.idr new file mode 100644 index 0000000..65ff19d --- /dev/null +++ b/cartridges/domains/config/conflow-mcp/abi/Conflow/Protocol.idr @@ -0,0 +1,56 @@ +-- SPDX-License-Identifier: MPL-2.0 +-- Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +-- +-- Conflow ABI β€” configuration orchestration protocol definitions. + +module Conflow.Protocol + +import Data.Nat + +||| Conflow operation codes. +public export +data ConflowOp + = GetConfig + | ApplyConfig + | ValidateConfig + | DiffConfig + +||| Configuration key-value entry. +public export +record ConfigEntry where + constructor MkConfigEntry + key : String + value : String + +||| Validation result. +public export +data ValidationResult + = Valid + | Invalid (List String) + +||| Diff between two configs. +public export +record ConfigDiff where + constructor MkConfigDiff + added : List ConfigEntry + removed : List ConfigEntry + changed : List (ConfigEntry, ConfigEntry) + +||| Apply result tracks the number of changes made. +public export +record ApplyResult where + constructor MkApplyResult + applied : Nat + skipped : Nat + total : Nat + sumPrf : applied + skipped = total + +||| Proof: a valid config with no errors has empty error list. +export +validHasNoErrors : (r : ValidationResult) -> r = Valid -> List String +validHasNoErrors Valid Refl = [] + +||| Proof: applying zero changes preserves the config. +export +noopApply : ApplyResult +noopApply = MkApplyResult 0 0 0 Refl diff --git a/cartridges/domains/config/conflow-mcp/abi/README.adoc b/cartridges/domains/config/conflow-mcp/abi/README.adoc new file mode 100644 index 0000000..d04854f --- /dev/null +++ b/cartridges/domains/config/conflow-mcp/abi/README.adoc @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += conflow-mcp / abi β€” Idris2 ABI layer +:orientation: shallow + +== Purpose + +Cartridge-local protocol types for `conflow-mcp`. Defines the data, tool, and +transition vocabulary this cartridge speaks. Inherits safety lemmas and the +matrix-cell identifier scheme from the trunk ABI at `src/abi/`. + +== Files + +1 Idris2 module(s), ~56 lines total. Lead module: +`Protocol.idr`. + +No `.ipkg` β€” consumed directly by wider BoJ callers; type-checks inside the +trunk `boj.ipkg`. + +== Invariants + +* `%default total` at every module top. +* Zero `believe_me`, `assert_total`, `postulate`, `Admitted`, or + `sorry` (the trunk holds the four declared `believe_me` sites; leaf + cartridges carry none). + +== Test/proof surface + +Thin module(s), no inline tests. Proof surface = type-check success under the +trunk `idris2 --check`. + +== Read-first + +. `Protocol.idr` β€” cartridge-local data records and enums. +. Trunk `src/abi/Boj/Catalogue.idr` for how this cell participates in the + 2D matrix. diff --git a/cartridges/domains/config/conflow-mcp/adapter/README.adoc b/cartridges/domains/config/conflow-mcp/adapter/README.adoc new file mode 100644 index 0000000..0097766 --- /dev/null +++ b/cartridges/domains/config/conflow-mcp/adapter/README.adoc @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += conflow-mcp / adapter β€” protocol bridge +:orientation: shallow + +== Purpose + +Exposes this cartridge's FFI over the wire protocols declared in +`cartridge.json`. Dispatches tool invocations to the C-ABI exports at +`../ffi/` and formats responses per protocol. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” depends on the sibling `ffi` target. +| `conflow_adapter.zig` | Protocol dispatch (129 lines). Tool catalogue matches `cartridge.json`. +| `SIDELINED-*.v.adoc` | zig predecessor(s). Not built β€” zig banned 2026-04-10. +|=== + +== Invariants + +* **Stateless** β€” all state lives behind the FFI, never in the adapter. +* **Response passthrough** β€” whatever the FFI returns goes back to the wire + unmodified (no embellishment, no silent recovery). +* **Cartridge.json is source of truth** for the tool catalogue; drift between + adapter and manifest is a CI failure. + +== Test/proof surface + +No inline tests at the adapter layer β€” protocol routing is exercised by the +trunk seam tests at `ffi/zig/src/seams.zig`. + +== Read-first + +. `conflow_adapter.zig` β€” dispatch function and per-protocol routers. +. `../cartridge.json` β€” the tool manifest the dispatcher mirrors. diff --git a/cartridges/domains/config/conflow-mcp/adapter/build.zig b/cartridges/domains/config/conflow-mcp/adapter/build.zig new file mode 100644 index 0000000..9b57504 --- /dev/null +++ b/cartridges/domains/config/conflow-mcp/adapter/build.zig @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// conflow-mcp/adapter/build.zig + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const ffi_mod = b.createModule(.{ + .root_source_file = b.path("../ffi/conflow_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + const adapter = b.addExecutable(.{ + .name = "conflow_adapter", + .root_source_file = b.path("conflow_adapter.zig"), + .target = target, + .optimize = optimize, + }); + adapter.root_module.addImport("conflow_ffi", ffi_mod); + b.installArtifact(adapter); +} diff --git a/cartridges/domains/config/conflow-mcp/adapter/conflow_adapter.zig b/cartridges/domains/config/conflow-mcp/adapter/conflow_adapter.zig new file mode 100644 index 0000000..c4ed0b4 --- /dev/null +++ b/cartridges/domains/config/conflow-mcp/adapter/conflow_adapter.zig @@ -0,0 +1,129 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// conflow-mcp/adapter/conflow_adapter.zig +// +// Three-protocol BoJ adapter: REST (port 9220), gRPC-compat (port 9221), +// GraphQL (port 9222). +// Replaces the banned zig adapter (conflow_adapter.v). + +const std = @import("std"); +const ffi = @import("conflow_ffi"); + +const REST_PORT: u16 = 9220; +const GRPC_PORT: u16 = 9221; +const GQL_PORT: u16 = 9222; + +const Response = struct { status: u16, body: []const u8 }; + +fn okJson(buf: []u8, msg: []const u8) []u8 { + return std.fmt.bufPrint(buf, "{{\"success\":true,\"message\":\"{s}\"}}", .{msg}) catch buf[0..0]; +} + +fn errJson(buf: []u8, msg: []const u8) []u8 { + return std.fmt.bufPrint(buf, "{{\"success\":false,\"error\":\"{s}\"}}", .{msg}) catch buf[0..0]; +} + +fn dispatch(tool: []const u8, _body: []const u8, resp: []u8) Response { + _ = _body; + if (std.mem.eql(u8, tool, "conflow_get_config")) { + return .{ .status = 200, .body = okJson(resp, "conflow_get_config forwarded") }; + } + if (std.mem.eql(u8, tool, "conflow_apply_config")) { + return .{ .status = 200, .body = okJson(resp, "conflow_apply_config forwarded") }; + } + if (std.mem.eql(u8, tool, "conflow_validate_config")) { + return .{ .status = 200, .body = okJson(resp, "conflow_validate_config forwarded") }; + } + if (std.mem.eql(u8, tool, "conflow_diff_config")) { + return .{ .status = 200, .body = okJson(resp, "conflow_diff_config forwarded") }; + } + return .{ .status = 404, .body = errJson(resp, "unknown tool") }; +} + +fn dispatchRest(path: []const u8, body: []const u8, resp: []u8) Response { + // Expect /tools/<tool_name> + const prefix = "/tools/"; + if (std.mem.startsWith(u8, path, prefix)) { + const tool = path[prefix.len..]; + return dispatch(tool, body, resp); + } + return .{ .status = 404, .body = errJson(resp, "not found") }; +} + +fn dispatchGrpc(path: []const u8, body: []const u8, resp: []u8) Response { + // Expect /<Service>/<Method> β€” derive tool from Method + var it = std.mem.splitScalar(u8, path, '/'); + _ = it.next(); // leading empty + _ = it.next(); // service + const method = it.next() orelse return .{ .status = 404, .body = errJson(resp, "bad gRPC path") }; + return dispatch(method, body, resp); +} + +fn dispatchGraphql(body: []const u8, resp: []u8) Response { + if (std.mem.indexOf(u8, body, "conflow_get_config") != null) + return dispatch("conflow_get_config", body, resp); + if (std.mem.indexOf(u8, body, "conflow_apply_config") != null) + return dispatch("conflow_apply_config", body, resp); + if (std.mem.indexOf(u8, body, "conflow_validate_config") != null) + return dispatch("conflow_validate_config", body, resp); + if (std.mem.indexOf(u8, body, "conflow_diff_config") != null) + return dispatch("conflow_diff_config", body, resp); + return .{ .status = 400, .body = errJson(resp, "unrecognised GraphQL operation") }; +} + +fn handleConnection(stream: std.net.Stream, port: u16) void { + defer stream.close(); + var buf: [4096]u8 = undefined; + var resp_buf: [4096]u8 = undefined; + const n = stream.read(&buf) catch return; + const req = buf[0..n]; + const result = switch (port) { + REST_PORT => blk: { + // Parse HTTP/1.1: first line = METHOD PATH HTTP/x.y + var lines = std.mem.splitScalar(u8, req, '\n'); + const first = lines.next() orelse break :blk Response{ .status = 400, .body = "" }; + var parts = std.mem.splitScalar(u8, std.mem.trim(u8, first, "\r"), ' '); + _ = parts.next(); // method + const path = parts.next() orelse break :blk Response{ .status = 400, .body = "" }; + break :blk dispatchRest(path, req, &resp_buf); + }, + GRPC_PORT => blk: { + var lines = std.mem.splitScalar(u8, req, '\n'); + const first = lines.next() orelse break :blk Response{ .status = 400, .body = "" }; + var parts = std.mem.splitScalar(u8, std.mem.trim(u8, first, "\r"), ' '); + _ = parts.next(); + const path = parts.next() orelse break :blk Response{ .status = 400, .body = "" }; + break :blk dispatchGrpc(path, req, &resp_buf); + }, + GQL_PORT => dispatchGraphql(req, &resp_buf), + else => Response{ .status = 500, .body = "" }, + }; + var http_resp: [512]u8 = undefined; + const http = std.fmt.bufPrint(&http_resp, + "HTTP/1.1 {d} OK\r\nContent-Length: {d}\r\nContent-Type: application/json\r\n\r\n", + .{ result.status, result.body.len }) catch return; + _ = stream.write(http) catch {}; + _ = stream.write(result.body) catch {}; +} + +fn listenLoop(port: u16) !void { + const addr = std.net.Address.initIp4(.{ 127, 0, 0, 1 }, port); + var server = try addr.listen(.{ .reuse_address = true }); + defer server.deinit(); + while (true) { + const conn = try server.accept(); + const t = try std.Thread.spawn(.{}, handleConnection, .{ conn.stream, port }); + t.detach(); + } +} + +pub fn main() !void { + ffi.conflow_init(); + const t1 = try std.Thread.spawn(.{}, listenLoop, .{REST_PORT}); + const t2 = try std.Thread.spawn(.{}, listenLoop, .{GRPC_PORT}); + const t3 = try std.Thread.spawn(.{}, listenLoop, .{GQL_PORT}); + t1.join(); + t2.join(); + t3.join(); +} diff --git a/cartridges/domains/config/conflow-mcp/cartridge.json b/cartridges/domains/config/conflow-mcp/cartridge.json new file mode 100644 index 0000000..1a04b14 --- /dev/null +++ b/cartridges/domains/config/conflow-mcp/cartridge.json @@ -0,0 +1,109 @@ +{ + "$schema": "https://boj.dev/schemas/cartridge/v1.json", + "spdx": "MPL-2.0", + "copyright": "Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk>", + "name": "conflow-mcp", + "version": "0.1.0", + "description": "Conflow configuration management", + "domain": "configuration", + "tier": "Ayo", + "protocols": [ + "MCP", + "REST" + ], + "auth": { + "method": "none", + "env_var": null, + "credential_source": null + }, + "api": { + "base_url": "local://conflow-mcp", + "content_type": "application/json" + }, + "tools": [ + { + "name": "conflow_get_config", + "description": "Get a configuration value by key", + "inputSchema": { + "type": "object", + "properties": { + "key": { + "type": "string", + "description": "Configuration key" + } + }, + "required": [ + "key" + ] + } + }, + { + "name": "conflow_apply_config", + "description": "Apply a configuration blob", + "inputSchema": { + "type": "object", + "properties": { + "blob": { + "type": "string", + "description": "Configuration blob (JSON/TOML/Nickel)" + }, + "format": { + "type": "string", + "description": "Blob format" + } + }, + "required": [ + "blob" + ] + } + }, + { + "name": "conflow_validate_config", + "description": "Validate a configuration blob", + "inputSchema": { + "type": "object", + "properties": { + "blob": { + "type": "string", + "description": "Configuration blob" + } + }, + "required": [ + "blob" + ] + } + }, + { + "name": "conflow_diff_config", + "description": "Diff two configuration blobs", + "inputSchema": { + "type": "object", + "properties": { + "config_a": { + "type": "string", + "description": "First configuration blob" + }, + "config_b": { + "type": "string", + "description": "Second configuration blob" + } + }, + "required": [ + "config_a", + "config_b" + ] + } + } + ], + "ffi": { + "so_path": "ffi/zig-out/lib/libconflow_mcp.so", + "abi_version": "ADR-0006", + "symbols": [ + "boj_cartridge_init", + "boj_cartridge_deinit", + "boj_cartridge_name", + "boj_cartridge_version", + "boj_cartridge_invoke" + ] + } +} diff --git a/cartridges/domains/config/conflow-mcp/ffi/README.adoc b/cartridges/domains/config/conflow-mcp/ffi/README.adoc new file mode 100644 index 0000000..1a8d5c9 --- /dev/null +++ b/cartridges/domains/config/conflow-mcp/ffi/README.adoc @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += conflow-mcp / ffi β€” Zig FFI layer **(stub)** +:orientation: shallow + +== Purpose + +C-ABI exports bridging this cartridge's Idris2 ABI to the runtime. Consumed by +the adapter for protocol dispatch and by the trunk loader for mount. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” produces `zig-out/lib/libconflow.so`. +| `conflow_ffi.zig` | C-ABI exports (4 exports, 3 inline tests, 44 lines). +|=== + +== Invariants + +* **Null-pointer rejection** on `[*c]const u8` arguments before any + dereference. +* **Negative return values** carry documented error meanings; zero is + success (matching the trunk error-code convention). +* **No allocator leaks** β€” every allocation path pairs with `defer free`. + +== Test/proof surface + +3 inline `test "..."` block(s) in `conflow_ffi.zig`. Run with +`cd ffi && zig build test`. + +== Read-first + +. `conflow_ffi.zig` β€” C-ABI signatures and guards. +. Trunk `ffi/zig/src/catalogue.zig` for how this `.so` is loaded. + +== Promotion gate + +Wire these exports to real backing state (not `return 0` / `return 1` +placeholders) before promoting past D. diff --git a/cartridges/domains/config/conflow-mcp/ffi/build.zig b/cartridges/domains/config/conflow-mcp/ffi/build.zig new file mode 100644 index 0000000..cf57643 --- /dev/null +++ b/cartridges/domains/config/conflow-mcp/ffi/build.zig @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + // Shared ADR-0006 invoke-shim module (relative path up to boj-server trunk). + + const shim_mod = b.addModule("cartridge_shim", .{ + + .root_source_file = b.path("../../../ffi/zig/src/cartridge_shim.zig"), + + .target = target, + + .optimize = optimize, + + }); + + const ffi_mod = b.addModule("conflow_mcp", .{ + .root_source_file = b.path("conflow_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + ffi_mod.addImport("cartridge_shim", shim_mod); + + const lib = b.addLibrary(.{ + .name = "conflow_mcp", + .root_module = ffi_mod, + .linkage = .dynamic, + }); + lib.linkLibC(); + b.installArtifact(lib); + + const tests = b.addTest(.{ .root_module = ffi_mod }); + tests.linkLibC(); + const run_tests = b.addRunArtifact(tests); + const test_step = b.step("test", "Run FFI tests"); + test_step.dependOn(&run_tests.step); +} + diff --git a/cartridges/domains/config/conflow-mcp/ffi/cartridge_shim.zig b/cartridges/domains/config/conflow-mcp/ffi/cartridge_shim.zig new file mode 100644 index 0000000..0e399db --- /dev/null +++ b/cartridges/domains/config/conflow-mcp/ffi/cartridge_shim.zig @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// cartridge_shim.zig β€” Shared helpers for the ADR-0006 five-symbol +// cartridge ABI (`boj_cartridge_init / deinit / name / version / invoke`). +// +// The shim centralises the seven-code return convention, NUL-argument +// guards, tool-name comparison, and the buffer-too-small path so each +// cartridge's `boj_cartridge_invoke` can stay short β€” typically a tool +// table plus `shim.writeResult(...)`. +// +// Cartridges import this file by relative path (no build-graph change +// needed). Example: +// +// const shim = @import("../../../ffi/zig/src/cartridge_shim.zig"); +// +// export fn boj_cartridge_invoke( +// tool_name: [*c]const u8, +// json_args: [*c]const u8, +// out_buf: [*c]u8, +// in_out_len: [*c]usize, +// ) callconv(.c) i32 { +// _ = json_args; +// if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; +// const body = if (shim.toolIs(tool_name, "foo")) "{\"result\":{}}" +// else return shim.RC_UNKNOWN_TOOL; +// return shim.writeResult(out_buf, in_out_len, body); +// } + +const std = @import("std"); + +// ── Return codes (ADR-0006 Β§Return codes) ──────────────────────────── +// +// Frozen by ADR-0006. New failure modes compose these via the error +// JSON body β€” the integer surface does not grow without a follow-up ADR. + +pub const RC_SUCCESS: i32 = 0; +pub const RC_UNKNOWN_TOOL: i32 = -1; +pub const RC_BAD_ARGS: i32 = -2; +pub const RC_BUFFER_TOO_SMALL: i32 = -3; +pub const RC_RUNTIME_ERROR: i32 = -4; +pub const RC_PANIC: i32 = -5; +pub const RC_AUTH_DENIED: i32 = -6; + +// ── Invoke-path helpers ────────────────────────────────────────────── + +/// True if any of the three mandatory `boj_cartridge_invoke` output-path +/// pointers is null. Use at the top of every invoke to short-circuit to +/// `RC_BAD_ARGS`. +pub fn invokeArgsNull( + tool_name: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) bool { + return tool_name == null or out_buf == null or in_out_len == null; +} + +/// Compare a C-NUL-terminated tool-name pointer against a Zig string +/// literal. Caller must have already verified `tool_name` is non-null +/// (usually via `invokeArgsNull`). +pub fn toolIs(tool_name: [*c]const u8, expected: []const u8) bool { + const s = std.mem.span(@as([*:0]const u8, @ptrCast(tool_name))); + return std.mem.eql(u8, s, expected); +} + +/// Copy `body` into `out_buf[0..*in_out_len]` (as a capacity) and update +/// `*in_out_len` to the number of bytes written. Returns `RC_SUCCESS`. +/// +/// If `body.len` exceeds the current capacity stored in `*in_out_len`, +/// sets `*in_out_len` to the required size and returns +/// `RC_BUFFER_TOO_SMALL` β€” the caller is then expected to re-allocate +/// and retry, per ADR-0006 Β§Memory ownership. +/// +/// Caller must have already verified that `out_buf` and `in_out_len` +/// are non-null. +pub fn writeResult( + out_buf: [*c]u8, + in_out_len: [*c]usize, + body: []const u8, +) i32 { + const cap = in_out_len.*; + if (body.len > cap) { + in_out_len.* = body.len; + return RC_BUFFER_TOO_SMALL; + } + @memcpy(out_buf[0..body.len], body); + in_out_len.* = body.len; + return RC_SUCCESS; +} + +// ── Tests ──────────────────────────────────────────────────────────── + +test "writeResult: body fits, writes and sets length" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); + try std.testing.expectEqualStrings("hello", buf[0..len]); +} + +test "writeResult: too small returns -3 and sets required length" { + var buf: [2]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_BUFFER_TOO_SMALL, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: exact-fit succeeds" { + var buf: [5]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: empty body" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, ""); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 0), len); +} + +test "toolIs: matches and rejects" { + const name: [*:0]const u8 = "foo"; + try std.testing.expect(toolIs(@ptrCast(name), "foo")); + try std.testing.expect(!toolIs(@ptrCast(name), "bar")); + try std.testing.expect(!toolIs(@ptrCast(name), "foobar")); + try std.testing.expect(!toolIs(@ptrCast(name), "fo")); +} + +test "invokeArgsNull: detects each null slot" { + var buf: [4]u8 = undefined; + var len: usize = 4; + const name: [*:0]const u8 = "x"; + try std.testing.expect(!invokeArgsNull(@ptrCast(name), &buf, &len)); + try std.testing.expect(invokeArgsNull(null, &buf, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), null, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), &buf, null)); +} diff --git a/cartridges/domains/config/conflow-mcp/ffi/conflow_ffi.zig b/cartridges/domains/config/conflow-mcp/ffi/conflow_ffi.zig new file mode 100644 index 0000000..ee2ffba --- /dev/null +++ b/cartridges/domains/config/conflow-mcp/ffi/conflow_ffi.zig @@ -0,0 +1,135 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +// +// Conflow FFI β€” C-compatible exports for configuration orchestration. + +const std = @import("std"); + +/// Get a config value by key. Returns 0 if found, -1 if missing. +export fn conflow_get_config(key: [*c]const u8) i32 { + if (key == null) return -1; + return 0; // Stub +} + +/// Apply a config blob. Returns number of entries applied, or -1 on error. +export fn conflow_apply_config(blob: [*c]const u8) i32 { + if (blob == null) return -1; + return 0; // Stub +} + +/// Validate a config blob. Returns 0 if valid, error count otherwise. +export fn conflow_validate_config(blob: [*c]const u8) i32 { + if (blob == null) return -1; + return 0; // Stub β€” valid +} + +/// Diff two config blobs. Returns number of differences. +export fn conflow_diff_config(a: [*c]const u8, b: [*c]const u8) u32 { + if (a == null or b == null) return 0; + return 0; // Stub +} + +// ═══════════════════════════════════════════════════════════════════════ +// Standard ABI (ADR-0005 four symbols + ADR-0006 invoke) +// ═══════════════════════════════════════════════════════════════════════ + +const shim = @import("cartridge_shim.zig"); + +const CARTRIDGE_NAME_PTR: [*:0]const u8 = "conflow-mcp"; +const CARTRIDGE_VERSION_PTR: [*:0]const u8 = "0.1.0"; + +export fn boj_cartridge_init() callconv(.c) c_int { + return 0; +} + +export fn boj_cartridge_deinit() callconv(.c) void {} + +export fn boj_cartridge_name() callconv(.c) [*:0]const u8 { + return CARTRIDGE_NAME_PTR; +} + +export fn boj_cartridge_version() callconv(.c) [*:0]const u8 { + return CARTRIDGE_VERSION_PTR; +} + +/// Dispatch the cartridge.json MCP tools. Grade D Alpha stubs. +export fn boj_cartridge_invoke( + tool_name: [*c]const u8, + json_args: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) callconv(.c) i32 { + _ = json_args; + if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; + + const body: []const u8 = if (shim.toolIs(tool_name, "conflow_get_config")) + "{\"result\":{\"metadata\":{},\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "conflow_apply_config")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "conflow_validate_config")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "conflow_diff_config")) + "{\"result\":{\"status\":\"stub\"}}" +else + return shim.RC_UNKNOWN_TOOL; + + return shim.writeResult(out_buf, in_out_len, body); +} + +// ── Tests ── + +test "get rejects null key" { + try std.testing.expectEqual(@as(i32, -1), conflow_get_config(null)); +} + +test "validate rejects null blob" { + try std.testing.expectEqual(@as(i32, -1), conflow_validate_config(null)); +} + +test "diff of identical configs is zero" { + try std.testing.expectEqual(@as(u32, 0), conflow_diff_config("a=1", "a=1")); +} + +// ═══════════════════════════════════════════════════════════════════════ +// ADR-0006 invoke dispatch tests +// ═══════════════════════════════════════════════════════════════════════ + +test "boj_cartridge_name returns conflow-mcp" { + const n = std.mem.span(boj_cartridge_name()); + try std.testing.expectEqualStrings("conflow-mcp", n); +} + +test "boj_cartridge_init returns 0" { + try std.testing.expectEqual(@as(c_int, 0), boj_cartridge_init()); +} + +test "invoke: each declared tool succeeds" { + var buf: [256]u8 = undefined; + const tools = [_][]const u8{ + "conflow_get_config", + "conflow_apply_config", + "conflow_validate_config", + "conflow_diff_config", + }; + for (tools) |t| { + var len: usize = buf.len; + const rc = boj_cartridge_invoke(t.ptr, "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, 0), rc); + try std.testing.expect(std.mem.indexOf(u8, buf[0..len], "result") != null); + } +} + +test "invoke: unknown tool returns -1" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("nope", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -1), rc); +} + +test "invoke: buffer too small returns -3" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("conflow_get_config", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -3), rc); + try std.testing.expect(len > 4); +} diff --git a/cartridges/domains/config/conflow-mcp/mod.js b/cartridges/domains/config/conflow-mcp/mod.js new file mode 100644 index 0000000..903cc95 --- /dev/null +++ b/cartridges/domains/config/conflow-mcp/mod.js @@ -0,0 +1,55 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// conflow-mcp/mod.js β€” Conflow configuration management +// +// Delegates to backend at http://127.0.0.1:7718 (override with CONFLOW_BACKEND_URL). + +const BASE_URL = Deno.env.get("CONFLOW_BACKEND_URL") ?? "http://127.0.0.1:7718"; +const TIMEOUT_MS = 15_000; + +async function post(path, payload) { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), TIMEOUT_MS); + try { + const r = await fetch(`${BASE_URL}${path}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + signal: ctrl.signal, + }); + const data = await r.json().catch(() => ({ success: false, error: "non-JSON response" })); + return { status: r.status, data }; + } catch (e) { + if (e.name === "AbortError") return { status: 504, data: { success: false, error: "conflow-mcp backend timed out" } }; + return { status: 503, data: { success: false, error: `conflow-mcp backend unavailable: ${e.message}` } }; + } finally { clearTimeout(t); } +} + +async function get(path) { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), TIMEOUT_MS); + try { + const r = await fetch(`${BASE_URL}${path}`, { method: "GET", signal: ctrl.signal }); + const data = await r.json().catch(() => ({ success: false, error: "non-JSON response" })); + return { status: r.status, data }; + } catch (e) { + if (e.name === "AbortError") return { status: 504, data: { success: false, error: "conflow-mcp backend timed out" } }; + return { status: 503, data: { success: false, error: `conflow-mcp backend unavailable: ${e.message}` } }; + } finally { clearTimeout(t); } +} + +export async function handleTool(toolName, args) { + switch (toolName) { + case "conflow_get_config": + return post("/api/v1/conflow_get_config", args ?? {}); + case "conflow_apply_config": + return post("/api/v1/conflow_apply_config", args ?? {}); + case "conflow_validate_config": + return post("/api/v1/conflow_validate_config", args ?? {}); + case "conflow_diff_config": + return post("/api/v1/conflow_diff_config", args ?? {}); + default: + return { status: 404, data: { error: `Unknown tool: ${toolName}` } }; + } +} diff --git a/cartridges/domains/config/conflow-mcp/panels/manifest.json b/cartridges/domains/config/conflow-mcp/panels/manifest.json new file mode 100644 index 0000000..1e2bf0d --- /dev/null +++ b/cartridges/domains/config/conflow-mcp/panels/manifest.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://panll.dev/schemas/panel-manifest/v1.json", + "spdx": "MPL-2.0", + "cartridge": "conflow-mcp", + "domain": "configuration", + "version": "0.1.0", + "panels": [ + { + "id": "conflow-config-editor", + "title": "Config Editor", + "description": "Interactive configuration editor with validation feedback and diff preview.", + "type": "editor", + "entrypoint": "panels/conflow-config-editor.js", + "size": { "cols": 4, "rows": 4 }, + "refresh_interval_ms": 0, + "data_sources": [ + { "op": "GetConfig", "on": "load" }, + { "op": "ValidateConfig", "on": "change" }, + { "op": "DiffConfig", "on": "change" } + ] + } + ] +} diff --git a/cartridges/domains/config/k9iser-mcp/README.adoc b/cartridges/domains/config/k9iser-mcp/README.adoc new file mode 100644 index 0000000..333ccce --- /dev/null +++ b/cartridges/domains/config/k9iser-mcp/README.adoc @@ -0,0 +1,65 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += k9iser-mcp β€” K9 contract regeneration cartridge +:toc: macro +:orientation: shallow + +toc::[] + +== Purpose + +Wraps the `k9iser` generator as a BoJ cartridge so that a repository's +`generated/k9iser/*.k9` contracts are regenerated *centrally, on trigger* +instead of being run ad hoc and hand-committed (which is how they rot β€” +see hyperpolymath/k9iser#8 and the idaptik#77 triage). + +This is the reference implementation for the `-iser` regeneration-cartridge +pattern (standards epic): every `-iser` generator should ship an equivalent +so `generated/*` stops being hand-maintained drift estate-wide. + +== Pipeline + +[source] +---- +Empty -> ManifestLoaded -> Generated -> Validated -> Applied +---- + +Enforced safety invariants (formally in `abi/K9iserMcp/SafeK9iser.idr`, +executably in `ffi/k9iser_ffi.zig`): + +. *No apply without validate* β€” contracts that fail the canonical + `k9-validate` ruleset (K9! magic + pedigree) can never be committed back + to a repository. +. *No validate without generate* β€” stale/absent output is never validated. +. *Generate requires a loaded manifest.* + +== Tools (`cartridge.json`) + +[cols="1,3"] +|=== +| `k9_load_manifest` | Load a repo's `k9iser.toml` +| `k9_generate` | Generate K9 contracts from configs + manifest rules +| `k9_validate` | Validate against the canonical K9! + pedigree ruleset +| `k9_apply` | Commit + push regenerated contracts to the branch +| `k9_clean` | Clean the session +|=== + +== Layout + +* `cartridge.json` β€” manifest (tools, ADR-0006 ffi symbols) +* `mod.js` β€” Deno MCP bridge β†’ backend at `K9ISER_BACKEND_URL` + (default `http://127.0.0.1:7743`) +* `abi/` β€” Idris2 ABI: pipeline state machine + safety GADT +* `ffi/` β€” Zig FFI: C-ABI exports + ADR-0006 `boj_cartridge_invoke` + dispatch (16/16 tests pass) +* `adapter/` β€” three-protocol (REST/gRPC-compat/GraphQL) adapter +* `panels/` β€” Panll dashboard manifest + +== Status + +Grade D Alpha. Cartridge package + verified state machine are complete. +`boj_cartridge_invoke` returns shaped stubs; the live path additionally +needs the backend service (runs the `k9iser` binary against a checked-out +working tree) *and* the BoJ REST runtime, which is pending the Elixir +rewrite β€” until that deploys, the templated `k9iser-regen` trigger is +fire-and-forget exactly like `boj-build.yml` is today. diff --git a/cartridges/domains/config/k9iser-mcp/abi/K9iserMcp/SafeK9iser.idr b/cartridges/domains/config/k9iser-mcp/abi/K9iserMcp/SafeK9iser.idr new file mode 100644 index 0000000..27c07d3 --- /dev/null +++ b/cartridges/domains/config/k9iser-mcp/abi/K9iserMcp/SafeK9iser.idr @@ -0,0 +1,211 @@ +-- SPDX-License-Identifier: MPL-2.0 +-- Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +||| K9iserMcp.SafeK9iser: Formally verified K9-contract generation pipeline. +||| +||| Cartridge: k9iser-mcp +||| Matrix cell: config domain x {MCP, REST} protocols +||| +||| This module defines a regeneration pipeline state machine that prevents: +||| - Applying (committing) contracts that were never validated +||| - Validating contracts that were never generated +||| - Generating from an unloaded manifest +||| +||| State machine: Empty -> ManifestLoaded -> Generated -> Validated -> Applied +module K9iserMcp.SafeK9iser + +import Data.List + +%default total + +-- ═══════════════════════════════════════════════════════════════════════════ +-- K9iser Pipeline State Machine +-- ═══════════════════════════════════════════════════════════════════════════ + +||| k9iser regeneration lifecycle states. +||| Progresses: Empty -> ManifestLoaded -> Generated -> Validated -> Applied +public export +data K9State + = Empty + | ManifestLoaded + | Generated + | Validated + | Applied + | K9Error + +||| Equality for K9 states. +public export +Eq K9State where + Empty == Empty = True + ManifestLoaded == ManifestLoaded = True + Generated == Generated = True + Validated == Validated = True + Applied == Applied = True + K9Error == K9Error = True + _ == _ = False + +||| Valid state transitions (enforced at the type level). +||| Critically, Generated -> Applied is NOT valid (must validate first). +||| And ManifestLoaded -> Validated is NOT valid (must generate first). +public export +data ValidTransition : K9State -> K9State -> Type where + LoadManifest : ValidTransition Empty ManifestLoaded + Generate : ValidTransition ManifestLoaded Generated + Regenerate : ValidTransition Generated Generated + Validate : ValidTransition Generated Validated + Apply : ValidTransition Validated Applied + CleanApplied : ValidTransition Applied Empty + CleanReady : ValidTransition Validated Empty + ManifestErr : ValidTransition ManifestLoaded K9Error + GenerateErr : ValidTransition Generated K9Error + Recover : ValidTransition K9Error Empty + +||| Runtime transition validator (matches the Zig FFI isValidTransition). +public export +canTransition : K9State -> K9State -> Bool +canTransition Empty ManifestLoaded = True +canTransition ManifestLoaded Generated = True +canTransition Generated Generated = True -- regenerate +canTransition Generated Validated = True +canTransition Validated Applied = True +canTransition Applied Empty = True -- clean +canTransition Validated Empty = True -- clean without applying +canTransition ManifestLoaded K9Error = True -- parse error +canTransition Generated K9Error = True -- generation error +canTransition K9Error Empty = True -- recover +canTransition _ _ = False + +-- ═══════════════════════════════════════════════════════════════════════════ +-- Config Format Types +-- ═══════════════════════════════════════════════════════════════════════════ + +||| Config formats k9iser can wrap (mirrors k9iser's ConfigFormat). +public export +data K9Format + = Toml + | Yaml + | Json + | Ini + | Custom String + +||| C-ABI encoding. +public export +formatToInt : K9Format -> Int +formatToInt Toml = 1 +formatToInt Yaml = 2 +formatToInt Json = 3 +formatToInt Ini = 4 +formatToInt (Custom _) = 99 + +-- ═══════════════════════════════════════════════════════════════════════════ +-- MCP Tool Definitions +-- ═══════════════════════════════════════════════════════════════════════════ + +||| MCP tools exposed by this cartridge. +public export +data McpTool + = ToolLoadManifest -- Load the k9iser.toml manifest + | ToolGenerate -- Generate K9 contracts from configs + | ToolValidate -- Validate contracts (K9! magic + pedigree) + | ToolApply -- Commit + push regenerated contracts + | ToolClean -- Clean the session + | ToolStatus -- Pipeline health check + +||| MCP tool name (for JSON-RPC method name). +public export +toolName : McpTool -> String +toolName ToolLoadManifest = "k9/load_manifest" +toolName ToolGenerate = "k9/generate" +toolName ToolValidate = "k9/validate" +toolName ToolApply = "k9/apply" +toolName ToolClean = "k9/clean" +toolName ToolStatus = "k9/status" + +||| Which tools require generated contracts already present. +||| Validate and Apply both require a successful generate first. +public export +toolRequiresGenerate : McpTool -> Bool +toolRequiresGenerate ToolValidate = True +toolRequiresGenerate ToolApply = True +toolRequiresGenerate _ = False + +-- ═══════════════════════════════════════════════════════════════════════════ +-- C-ABI Exports +-- ═══════════════════════════════════════════════════════════════════════════ + +||| K9 state to integer. +public export +k9StateToInt : K9State -> Int +k9StateToInt Empty = 0 +k9StateToInt ManifestLoaded = 1 +k9StateToInt Generated = 2 +k9StateToInt Validated = 3 +k9StateToInt Applied = 4 +k9StateToInt K9Error = 5 + +||| FFI: Validate a state transition. +export +k9_can_transition : Int -> Int -> Int +k9_can_transition from to = + let fromState = case from of + 0 => Empty + 1 => ManifestLoaded + 2 => Generated + 3 => Validated + 4 => Applied + _ => K9Error + toState = case to of + 0 => Empty + 1 => ManifestLoaded + 2 => Generated + 3 => Validated + 4 => Applied + _ => K9Error + in if canTransition fromState toState then 1 else 0 + +||| FFI: Check if a tool requires generated contracts. +export +k9_tool_requires_generate : Int -> Int +k9_tool_requires_generate 2 = 1 -- ToolValidate +k9_tool_requires_generate 3 = 1 -- ToolApply +k9_tool_requires_generate _ = 0 -- All others do not + +-- ═══════════════════════════════════════════════════════════════════════════ +-- Exposure / transaction-gating contract (BoJ interface-safety policy) +-- ═══════════════════════════════════════════════════════════════════════════ +-- A port boundary must never be a gatekeeperless gateway: every adapter +-- exposes the unified ABI ONLY behind this transaction gate. Mirrors +-- BojRest.TrustPolicy: caller exposure derived from the cartridge's +-- auth.method, loopback callers locally trusted, X-Trust-Level enforced. + +||| Caller trust the gateway/sidecar has established. +public export +data Exposure = Public | Authenticated | Internal + +||| Required exposure inferred from cartridge auth.method. +||| "none"/absent β†’ Public; any credential-bearing method β†’ Authenticated. +public export +requiredExposure : (authMethodIsNone : Bool) -> Exposure +requiredExposure True = Public +requiredExposure False = Authenticated + +||| The transaction gate. Loopback callers are locally trusted (mcp-bridge, +||| local curl). Otherwise the presented X-Trust-Level must meet the +||| required exposure. This is the total relation the Zig transaction layer +||| mirrors; no dispatch may occur unless it returns True. +public export +exposureSatisfied : (required : Exposure) -> (presented : Exposure) -> (isLocal : Bool) -> Bool +exposureSatisfied _ _ True = True +exposureSatisfied Public _ _ = True +exposureSatisfied Authenticated Authenticated _ = True +exposureSatisfied Authenticated Internal _ = True +exposureSatisfied Internal Internal _ = True +exposureSatisfied _ _ _ = False + +||| FFI: 1 if dispatch is permitted, 0 if the gate rejects. +||| req/pres encoding: 0=Public 1=Authenticated 2=Internal; isLocal: 1/0. +export +k9_exposure_satisfied : Int -> Int -> Int -> Int +k9_exposure_satisfied req pres isLocal = + let r = case req of { 0 => Public; 1 => Authenticated; _ => Internal } + p = case pres of { 0 => Public; 1 => Authenticated; _ => Internal } + in if exposureSatisfied r p (isLocal /= 0) then 1 else 0 diff --git a/cartridges/domains/config/k9iser-mcp/abi/README.adoc b/cartridges/domains/config/k9iser-mcp/abi/README.adoc new file mode 100644 index 0000000..33ae990 --- /dev/null +++ b/cartridges/domains/config/k9iser-mcp/abi/README.adoc @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += k9iser-mcp / abi β€” Idris2 ABI layer +:orientation: shallow + +== Purpose + +Cartridge-local protocol types for `k9iser-mcp`. Defines the data, tool, and +transition vocabulary this cartridge speaks. Inherits safety lemmas and the +matrix-cell identifier scheme from the trunk ABI at `src/abi/`. + +== Files + +* `K9iserMcp/SafeK9iser.idr` β€” the regeneration pipeline state machine + (`Empty -> ManifestLoaded -> Generated -> Validated -> Applied`), the + `ValidTransition` GADT proving the safety invariants (no apply without + validate; no validate without generate), and the C-ABI exports + (`k9_can_transition`, `k9_tool_requires_generate`). +* `k9iser-mcp.ipkg` β€” Idris2 package descriptor. + +== Safety invariants + +. A contract set can only be *applied* (committed back to the repo) from + the `Validated` state β€” regenerated contracts that failed the canonical + `k9-validate` ruleset (K9! magic + pedigree) can never be pushed. +. A contract set can only be *validated* from `Generated` β€” there is no + path that validates stale or absent output. +. Generation requires a loaded manifest. + +The Zig FFI (`../ffi/k9iser_ffi.zig`) mirrors `canTransition` exactly; the +`state transition validation` test there is the executable cross-check. diff --git a/cartridges/domains/config/k9iser-mcp/abi/build/ttc/2025081600/K9iserMcp/SafeK9iser.ttc b/cartridges/domains/config/k9iser-mcp/abi/build/ttc/2025081600/K9iserMcp/SafeK9iser.ttc new file mode 100644 index 0000000..63c6b35 Binary files /dev/null and b/cartridges/domains/config/k9iser-mcp/abi/build/ttc/2025081600/K9iserMcp/SafeK9iser.ttc differ diff --git a/cartridges/domains/config/k9iser-mcp/abi/build/ttc/2025081600/K9iserMcp/SafeK9iser.ttm b/cartridges/domains/config/k9iser-mcp/abi/build/ttc/2025081600/K9iserMcp/SafeK9iser.ttm new file mode 100644 index 0000000..df3e61a Binary files /dev/null and b/cartridges/domains/config/k9iser-mcp/abi/build/ttc/2025081600/K9iserMcp/SafeK9iser.ttm differ diff --git a/cartridges/domains/config/k9iser-mcp/abi/k9iser-mcp.ipkg b/cartridges/domains/config/k9iser-mcp/abi/k9iser-mcp.ipkg new file mode 100644 index 0000000..7edfecb --- /dev/null +++ b/cartridges/domains/config/k9iser-mcp/abi/k9iser-mcp.ipkg @@ -0,0 +1,11 @@ +-- SPDX-License-Identifier: MPL-2.0 +package k9isermcp + +authors = "Jonathan D.A. Jewell" +version = 0.1.0 +license = "MPL-2.0" +brief = "k9iser-mcp cartridge β€” K9 contract regeneration pipeline state machine" + +sourcedir = "." +modules = K9iserMcp.SafeK9iser +depends = base, contrib diff --git a/cartridges/domains/config/k9iser-mcp/adapter/README.adoc b/cartridges/domains/config/k9iser-mcp/adapter/README.adoc new file mode 100644 index 0000000..8a63f77 --- /dev/null +++ b/cartridges/domains/config/k9iser-mcp/adapter/README.adoc @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += k9iser-mcp / adapter β€” protocol bridge +:orientation: shallow + +== Purpose + +Exposes this cartridge's FFI over the wire protocols declared in +`cartridge.json`. Dispatches tool invocations to the C-ABI exports at +`../ffi/` and formats responses per protocol. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” depends on the sibling `ffi` target. +| `k9iser_adapter.zig` | Protocol dispatch (134 lines). Tool catalogue matches `cartridge.json`. +| `SIDELINED-*.v.adoc` | zig predecessor(s). Not built β€” zig banned 2026-04-10. +|=== + +== Invariants + +* **Stateless** β€” all state lives behind the FFI, never in the adapter. +* **Response passthrough** β€” whatever the FFI returns goes back to the wire + unmodified (no embellishment, no silent recovery). +* **Cartridge.json is source of truth** for the tool catalogue; drift between + adapter and manifest is a CI failure. + +== Test/proof surface + +No inline tests at the adapter layer β€” protocol routing is exercised by the +trunk seam tests at `ffi/zig/src/seams.zig`. + +== Read-first + +. `k9iser_adapter.zig` β€” dispatch function and per-protocol routers. +. `../cartridge.json` β€” the tool manifest the dispatcher mirrors. diff --git a/cartridges/domains/config/k9iser-mcp/adapter/build.zig b/cartridges/domains/config/k9iser-mcp/adapter/build.zig new file mode 100644 index 0000000..33f58b3 --- /dev/null +++ b/cartridges/domains/config/k9iser-mcp/adapter/build.zig @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// k9iser-mcp/adapter/build.zig + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const ffi_mod = b.createModule(.{ + .root_source_file = b.path("../ffi/k9iser_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + const adapter_mod = b.createModule(.{ + .root_source_file = b.path("k9iser_adapter.zig"), + .target = target, + .optimize = optimize, + }); + adapter_mod.addImport("k9iser_ffi", ffi_mod); + + const adapter = b.addExecutable(.{ + .name = "k9iser_adapter", + .root_module = adapter_mod, + }); + b.installArtifact(adapter); + + // Unified-adapter tests (classify/toolFor/dispatch β†’ one Zig ABI). + const adapter_tests = b.addTest(.{ .root_module = adapter_mod }); + const run_tests = b.addRunArtifact(adapter_tests); + const test_step = b.step("test", "Run k9iser-mcp unified adapter tests"); + test_step.dependOn(&run_tests.step); +} diff --git a/cartridges/domains/config/k9iser-mcp/adapter/k9iser_adapter.zig b/cartridges/domains/config/k9iser-mcp/adapter/k9iser_adapter.zig new file mode 100644 index 0000000..c8136a7 --- /dev/null +++ b/cartridges/domains/config/k9iser-mcp/adapter/k9iser_adapter.zig @@ -0,0 +1,292 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// k9iser-mcp/adapter/k9iser_adapter.zig +// +// INTERNAL-ONLY unified adapter. This is NOT a public ingress. Per +// ADR-0004 the only governed public surface is the http-capability-gateway +// (tier-2) in front of the unified Zig core; cartridge adapters bind +// loopback and sit behind it. One listener, one port, protocol-routed +// (REST + SSE + GraphQL + gRPC-compat) into a SINGLE transaction-gated +// dispatch β†’ the one Zig ABI (ffi.boj_cartridge_invoke). Deliberately NOT +// N parallel servers and NOT a public listener. +// +// POST /invoke β†’ REST (JSON in/out) +// POST /sse β†’ SSE (text/event-stream) +// POST /graphql β†’ GraphQL (op parsed from body) +// POST /grpc/<Svc>/<Mthd> β†’ gRPC-compat (tool = method) +// +// Every request passes the transaction gate (exposureGate, mirroring the +// Idris2 K9iserMcp.SafeK9iser.exposureSatisfied contract) BEFORE dispatch. +// No request reaches the ABI ungated β€” this boundary is not a +// gatekeeperless gateway (estate interface-safety policy). + +const std = @import("std"); +const ffi = @import("k9iser_ffi"); + +// Loopback-only by construction: this adapter is internal, fronted by the +// http-capability-gateway (ADR-0004). Never bind a routable interface. +const BIND_IP = [4]u8{ 127, 0, 0, 1 }; +const PORT: u16 = 9390; + +// ── Transaction gate (mirrors Idris2 SafeK9iser.exposureSatisfied) ────── +// +// Encoding matches K9iserMcp.SafeK9iser: 0=Public 1=Authenticated 2=Internal. +const Exposure = enum(u8) { public = 0, authenticated = 1, internal = 2 }; + +// k9iser-mcp cartridge.json: auth.method = "none" β†’ requiredExposure = Public. +// (requiredExposure(authMethodIsNone=true) = Public, per the Idris2 contract.) +const REQUIRED_EXPOSURE: Exposure = .public; + +/// Zig mirror of Idris2 `exposureSatisfied`. Cross-checked by the truth-table +/// test below; the Idris2 module is the source-of-truth contract. +fn exposureSatisfied(required: Exposure, presented: Exposure, is_local: bool) bool { + if (is_local) return true; // loopback callers are locally trusted + return switch (required) { + .public => true, + .authenticated => presented == .authenticated or presented == .internal, + .internal => presented == .internal, + }; +} + +/// Parse the `X-Trust-Level` request header the gateway/sidecar sets. +/// Missing/unknown β†’ Public (conservative). Case-insensitive header name. +fn presentedExposure(req: []const u8) Exposure { + const val = headerValue(req, "x-trust-level") orelse return .public; + if (eqIgnoreCase(val, "internal")) return .internal; + if (eqIgnoreCase(val, "authenticated")) return .authenticated; + return .public; +} + +fn eqIgnoreCase(a: []const u8, b: []const u8) bool { + if (a.len != b.len) return false; + for (a, b) |x, y| if (std.ascii.toLower(x) != std.ascii.toLower(y)) return false; + return true; +} + +/// Case-insensitive single-header lookup over a raw HTTP/1.1 request. +fn headerValue(req: []const u8, name: []const u8) ?[]const u8 { + var lines = std.mem.splitScalar(u8, req, '\n'); + _ = lines.next(); // request line + while (lines.next()) |raw| { + const line = std.mem.trim(u8, raw, "\r"); + if (line.len == 0) break; // end of headers + const colon = std.mem.indexOfScalar(u8, line, ':') orelse continue; + if (eqIgnoreCase(std.mem.trim(u8, line[0..colon], " "), name)) + return std.mem.trim(u8, line[colon + 1 ..], " "); + } + return null; +} + +const Dispatch = struct { status: u16, body: []const u8 }; + +/// The single point where every protocol converges onto the one Zig ABI. +fn dispatch(tool: []const u8, args_json: []const u8, out: []u8) Dispatch { + var tnbuf: [128]u8 = undefined; + if (tool.len == 0 or tool.len >= tnbuf.len) + return .{ .status = 400, .body = "{\"error\":\"bad-tool\"}" }; + @memcpy(tnbuf[0..tool.len], tool); + tnbuf[tool.len] = 0; + + var abuf: [4096]u8 = undefined; + const a = if (args_json.len == 0) "{}" else args_json; + if (a.len >= abuf.len) + return .{ .status = 413, .body = "{\"error\":\"args-too-large\"}" }; + @memcpy(abuf[0..a.len], a); + abuf[a.len] = 0; + + var len: usize = out.len; + const rc = ffi.boj_cartridge_invoke(@ptrCast(&tnbuf), @ptrCast(&abuf), @ptrCast(out.ptr), &len); + return switch (rc) { + 0 => .{ .status = 200, .body = out[0..len] }, + -1 => .{ .status = 404, .body = "{\"error\":\"unknown-tool\"}" }, + -2 => .{ .status = 400, .body = "{\"error\":\"bad-args\"}" }, + -3 => .{ .status = 500, .body = "{\"error\":\"buffer-too-small\"}" }, + else => .{ .status = 500, .body = "{\"error\":\"invoke-failed\"}" }, + }; +} + +const Protocol = enum { rest, sse, graphql, grpc, unknown }; + +fn classify(path: []const u8) Protocol { + if (std.mem.startsWith(u8, path, "/invoke")) return .rest; + if (std.mem.startsWith(u8, path, "/sse")) return .sse; + if (std.mem.startsWith(u8, path, "/graphql")) return .graphql; + if (std.mem.startsWith(u8, path, "/grpc/")) return .grpc; + return .unknown; +} + +fn toolFor(proto: Protocol, path: []const u8, body: []const u8) ?[]const u8 { + switch (proto) { + .grpc => { + var it = std.mem.splitScalar(u8, path, '/'); + _ = it.next(); // "" + _ = it.next(); // "grpc" + _ = it.next(); // service + return it.next(); + }, + .rest, .sse => { + if (std.mem.indexOf(u8, path, "tool=")) |q| { + const rest = path[q + 5 ..]; + const end = std.mem.indexOfAny(u8, rest, "& ") orelse rest.len; + if (end > 0) return rest[0..end]; + } + return jsonStringField(body, "tool"); + }, + .graphql => { + const tools = [_][]const u8{ + "k9_load_manifest", "k9_generate", "k9_validate", "k9_apply", "k9_clean", + }; + for (tools) |t| if (std.mem.indexOf(u8, body, t) != null) return t; + return null; + }, + .unknown => return null, + } +} + +fn jsonStringField(body: []const u8, key: []const u8) ?[]const u8 { + var kbuf: [64]u8 = undefined; + if (key.len + 2 >= kbuf.len) return null; + kbuf[0] = '"'; + @memcpy(kbuf[1 .. 1 + key.len], key); + kbuf[1 + key.len] = '"'; + const needle = kbuf[0 .. key.len + 2]; + const k = std.mem.indexOf(u8, body, needle) orelse return null; + var i = k + needle.len; + while (i < body.len and (body[i] == ':' or body[i] == ' ')) : (i += 1) {} + if (i >= body.len or body[i] != '"') return null; + i += 1; + const start = i; + while (i < body.len and body[i] != '"') : (i += 1) {} + if (i > start) return body[start..i] else return null; +} + +fn writeHttp(stream: std.net.Stream, status: u16, ctype: []const u8, body: []const u8) void { + var hdr: [256]u8 = undefined; + const h = std.fmt.bufPrint(&hdr, "HTTP/1.1 {d} OK\r\nContent-Type: {s}\r\nContent-Length: {d}\r\nConnection: close\r\n\r\n", .{ status, ctype, body.len }) catch return; + _ = stream.write(h) catch {}; + _ = stream.write(body) catch {}; +} + +fn writeSse(stream: std.net.Stream, d: Dispatch) void { + const head = "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nCache-Control: no-cache\r\nConnection: close\r\n\r\n"; + _ = stream.write(head) catch {}; + _ = stream.write("event: open\ndata: {\"cartridge\":\"k9iser-mcp\"}\n\n") catch {}; + var fb: [4608]u8 = undefined; + const ev = if (d.status == 200) "result" else "error"; + const frame = std.fmt.bufPrint(&fb, "event: {s}\ndata: {s}\n\n", .{ ev, d.body }) catch "event: error\ndata: {}\n\n"; + _ = stream.write(frame) catch {}; + _ = stream.write("event: done\ndata: {}\n\n") catch {}; +} + +// Loopback-only listener β‡’ peers are local by construction. We still +// evaluate the gate every request (no gatekeeperless path); the +// non-local branch is exercised by exposureSatisfied's tests. +fn handleConnection(stream: std.net.Stream) void { + defer stream.close(); + var buf: [8192]u8 = undefined; + const n = stream.read(&buf) catch return; + const req = buf[0..n]; + + var lines = std.mem.splitScalar(u8, req, '\n'); + const first = lines.next() orelse return; + var parts = std.mem.splitScalar(u8, std.mem.trim(u8, first, "\r"), ' '); + _ = parts.next(); // method + const path = parts.next() orelse return; + + const body_start = std.mem.indexOf(u8, req, "\r\n\r\n"); + const body = if (body_start) |bs| req[bs + 4 ..] else ""; + + const proto = classify(path); + if (proto == .unknown) { + writeHttp(stream, 404, "application/json", "{\"error\":\"route-not-found\"}"); + return; + } + + // ── TRANSACTION GATE β€” runs before dispatch, every request ────────── + const is_local = true; // loopback-bound (BIND_IP); see module header + if (!exposureSatisfied(REQUIRED_EXPOSURE, presentedExposure(req), is_local)) { + writeHttp(stream, 403, "application/json", "{\"error\":\"forbidden\",\"detail\":\"exposure-gate\"}"); + return; + } + + const tool = toolFor(proto, path, body) orelse { + writeHttp(stream, 400, "application/json", "{\"error\":\"missing-tool\"}"); + return; + }; + + var out: [4096]u8 = undefined; + const d = dispatch(tool, body, &out); + + switch (proto) { + .sse => writeSse(stream, d), + .graphql => { + var gb: [4352]u8 = undefined; + const g = std.fmt.bufPrint(&gb, "{{\"data\":{{\"invoke\":{s}}}}}", .{d.body}) catch d.body; + writeHttp(stream, d.status, "application/json", g); + }, + else => writeHttp(stream, d.status, "application/json", d.body), // rest, grpc + } +} + +pub fn main() !void { + _ = ffi.boj_cartridge_init(); + defer ffi.boj_cartridge_deinit(); + const addr = std.net.Address.initIp4(BIND_IP, PORT); + var server = try addr.listen(.{ .reuse_address = true }); + defer server.deinit(); + std.debug.print("k9iser-mcp INTERNAL unified adapter on 127.0.0.1:{d} (behind http-capability-gateway; rest|sse|graphql|grpc; transaction-gated)\n", .{PORT}); + while (true) { + const conn = try server.accept(); + const t = try std.Thread.spawn(.{}, handleConnection, .{conn.stream}); + t.detach(); + } +} + +// ───────────────────────── tests ───────────────────────── + +test "classify routes each protocol to one surface" { + try std.testing.expectEqual(Protocol.rest, classify("/invoke")); + try std.testing.expectEqual(Protocol.sse, classify("/sse")); + try std.testing.expectEqual(Protocol.graphql, classify("/graphql")); + try std.testing.expectEqual(Protocol.grpc, classify("/grpc/K9/k9_generate")); + try std.testing.expectEqual(Protocol.unknown, classify("/nope")); +} + +test "toolFor extracts across protocols" { + try std.testing.expectEqualStrings("k9_generate", toolFor(.grpc, "/grpc/K9/k9_generate", "").?); + try std.testing.expectEqualStrings("k9_apply", toolFor(.rest, "/invoke?tool=k9_apply", "").?); + try std.testing.expectEqualStrings("k9_validate", toolFor(.sse, "/sse", "{\"tool\":\"k9_validate\"}").?); + try std.testing.expectEqualStrings("k9_clean", toolFor(.graphql, "/graphql", "{query: invoke(tool:\"k9_clean\")}").?); + try std.testing.expect(toolFor(.rest, "/invoke", "{}") == null); +} + +test "dispatch funnels into the one Zig ABI" { + var out: [256]u8 = undefined; + const d = dispatch("k9_generate", "{}", &out); + try std.testing.expectEqual(@as(u16, 200), d.status); + try std.testing.expect(std.mem.indexOf(u8, d.body, "result") != null); + try std.testing.expectEqual(@as(u16, 404), dispatch("nope", "{}", &out).status); +} + +// Transaction-gate truth table β€” must match Idris2 +// K9iserMcp.SafeK9iser.exposureSatisfied exactly. +test "exposureSatisfied mirrors the Idris2 contract" { + // local caller: always permitted regardless of required/presented + try std.testing.expect(exposureSatisfied(.internal, .public, true)); + // public requirement: any presented level passes + try std.testing.expect(exposureSatisfied(.public, .public, false)); + // authenticated requirement + try std.testing.expect(!exposureSatisfied(.authenticated, .public, false)); + try std.testing.expect(exposureSatisfied(.authenticated, .authenticated, false)); + try std.testing.expect(exposureSatisfied(.authenticated, .internal, false)); + // internal requirement + try std.testing.expect(!exposureSatisfied(.internal, .authenticated, false)); + try std.testing.expect(exposureSatisfied(.internal, .internal, false)); +} + +test "presentedExposure parses X-Trust-Level (case-insensitive)" { + const req = "POST /invoke HTTP/1.1\r\nHost: x\r\nX-Trust-Level: Internal\r\n\r\n{}"; + try std.testing.expectEqual(Exposure.internal, presentedExposure(req)); + try std.testing.expectEqual(Exposure.public, presentedExposure("POST / HTTP/1.1\r\n\r\n")); +} diff --git a/cartridges/domains/config/k9iser-mcp/cartridge.json b/cartridges/domains/config/k9iser-mcp/cartridge.json new file mode 100644 index 0000000..e56aa17 --- /dev/null +++ b/cartridges/domains/config/k9iser-mcp/cartridge.json @@ -0,0 +1,129 @@ +{ + "$schema": "https://boj.dev/schemas/cartridge/v1.json", + "spdx": "MPL-2.0", + "copyright": "Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk>", + "name": "k9iser-mcp", + "version": "0.1.0", + "description": "Wrap configs into self-validating K9 contracts (k9iser generate/validate/apply)", + "domain": "config", + "tier": "Ayo", + "protocols": [ + "MCP", + "REST" + ], + "auth": { + "method": "none", + "env_var": null, + "credential_source": null + }, + "api": { + "base_url": "local://k9iser-mcp", + "content_type": "application/json" + }, + "tools": [ + { + "name": "k9_load_manifest", + "description": "Load a k9iser.toml manifest for a repository working tree", + "inputSchema": { + "type": "object", + "properties": { + "repo": { + "type": "string", + "description": "owner/name of the repository" + }, + "manifest": { + "type": "string", + "description": "Path to the k9iser.toml manifest (default: k9iser.toml)" + } + }, + "required": [ + "repo" + ] + } + }, + { + "name": "k9_generate", + "description": "Generate K9 contract files from configs + manifest rules", + "inputSchema": { + "type": "object", + "properties": { + "session_id": { + "type": "integer", + "description": "Session index" + }, + "output": { + "type": "string", + "description": "Output directory (default: generated/k9iser)" + } + }, + "required": [ + "session_id" + ] + } + }, + { + "name": "k9_validate", + "description": "Validate generated contracts against the canonical k9-validate ruleset (K9! magic + pedigree)", + "inputSchema": { + "type": "object", + "properties": { + "session_id": { + "type": "integer", + "description": "Session index" + } + }, + "required": [ + "session_id" + ] + } + }, + { + "name": "k9_apply", + "description": "Commit + push the regenerated contracts back to the repository branch", + "inputSchema": { + "type": "object", + "properties": { + "session_id": { + "type": "integer", + "description": "Session index" + }, + "branch": { + "type": "string", + "description": "Target branch" + } + }, + "required": [ + "session_id", + "branch" + ] + } + }, + { + "name": "k9_clean", + "description": "Clean the session and any working artifacts", + "inputSchema": { + "type": "object", + "properties": { + "session_id": { + "type": "integer", + "description": "Session index" + } + }, + "required": [ + "session_id" + ] + } + } + ], + "ffi": { + "so_path": "ffi/zig-out/lib/libk9iser_mcp.so", + "abi_version": "ADR-0006", + "symbols": [ + "boj_cartridge_init", + "boj_cartridge_deinit", + "boj_cartridge_name", + "boj_cartridge_version", + "boj_cartridge_invoke" + ] + } +} diff --git a/cartridges/domains/config/k9iser-mcp/ffi/README.adoc b/cartridges/domains/config/k9iser-mcp/ffi/README.adoc new file mode 100644 index 0000000..b49b6d6 --- /dev/null +++ b/cartridges/domains/config/k9iser-mcp/ffi/README.adoc @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += k9iser-mcp / ffi β€” Zig FFI layer +:orientation: shallow + +== Purpose + +C-ABI exports bridging this cartridge's Idris2 ABI to the runtime. Consumed by +the adapter for protocol dispatch and by the trunk loader for mount. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” produces `zig-out/lib/libssg.so`. +| `k9iser_ffi.zig` | C-ABI exports (13 exports, 7 inline tests, 290 lines). +|=== + +== Invariants + +* **Null-pointer rejection** on `[*c]const u8` arguments before any + dereference. +* **Negative return values** carry documented error meanings; zero is + success (matching the trunk error-code convention). +* **No allocator leaks** β€” every allocation path pairs with `defer free`. + +== Test/proof surface + +7 inline `test "..."` block(s) in `k9iser_ffi.zig`. Run with +`cd ffi && zig build test`. + +== Read-first + +. `k9iser_ffi.zig` β€” C-ABI signatures and guards. +. Trunk `ffi/zig/src/catalogue.zig` for how this `.so` is loaded. diff --git a/cartridges/domains/config/k9iser-mcp/ffi/build.zig b/cartridges/domains/config/k9iser-mcp/ffi/build.zig new file mode 100644 index 0000000..7129880 --- /dev/null +++ b/cartridges/domains/config/k9iser-mcp/ffi/build.zig @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// k9iser-mcp Cartridge β€” Zig FFI build configuration (Zig 0.15+). + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + // Shared ADR-0006 invoke-shim module (relative path up to boj-server trunk). + const shim_mod = b.addModule("cartridge_shim", .{ + .root_source_file = b.path("../../../ffi/zig/src/cartridge_shim.zig"), + .target = target, + .optimize = optimize, + }); + + const k9_mod = b.addModule("k9iser_ffi", .{ + .root_source_file = b.path("k9iser_ffi.zig"), + .target = target, + .optimize = optimize, + }); + k9_mod.addImport("cartridge_shim", shim_mod); + + // ── Tests ──────────────────────────────────────────────────────── + const k9_tests = b.addTest(.{ + .root_module = k9_mod, + }); + + const run_tests = b.addRunArtifact(k9_tests); + + const test_step = b.step("test", "Run k9iser-mcp FFI tests"); + test_step.dependOn(&run_tests.step); + + // ── Shared library ────────────────────────────────────────────── + const lib_mod = b.createModule(.{ + .root_source_file = b.path("k9iser_ffi.zig"), + .target = target, + .optimize = optimize, + }); + lib_mod.addImport("cartridge_shim", shim_mod); + + const lib = b.addLibrary(.{ + .name = "k9iser_mcp", + .root_module = lib_mod, + .linkage = .dynamic, + }); + b.installArtifact(lib); + + const lib_step = b.step("lib", "Build shared library"); + lib_step.dependOn(&lib.step); +} diff --git a/cartridges/domains/config/k9iser-mcp/ffi/cartridge_shim.zig b/cartridges/domains/config/k9iser-mcp/ffi/cartridge_shim.zig new file mode 100644 index 0000000..0e399db --- /dev/null +++ b/cartridges/domains/config/k9iser-mcp/ffi/cartridge_shim.zig @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// cartridge_shim.zig β€” Shared helpers for the ADR-0006 five-symbol +// cartridge ABI (`boj_cartridge_init / deinit / name / version / invoke`). +// +// The shim centralises the seven-code return convention, NUL-argument +// guards, tool-name comparison, and the buffer-too-small path so each +// cartridge's `boj_cartridge_invoke` can stay short β€” typically a tool +// table plus `shim.writeResult(...)`. +// +// Cartridges import this file by relative path (no build-graph change +// needed). Example: +// +// const shim = @import("../../../ffi/zig/src/cartridge_shim.zig"); +// +// export fn boj_cartridge_invoke( +// tool_name: [*c]const u8, +// json_args: [*c]const u8, +// out_buf: [*c]u8, +// in_out_len: [*c]usize, +// ) callconv(.c) i32 { +// _ = json_args; +// if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; +// const body = if (shim.toolIs(tool_name, "foo")) "{\"result\":{}}" +// else return shim.RC_UNKNOWN_TOOL; +// return shim.writeResult(out_buf, in_out_len, body); +// } + +const std = @import("std"); + +// ── Return codes (ADR-0006 Β§Return codes) ──────────────────────────── +// +// Frozen by ADR-0006. New failure modes compose these via the error +// JSON body β€” the integer surface does not grow without a follow-up ADR. + +pub const RC_SUCCESS: i32 = 0; +pub const RC_UNKNOWN_TOOL: i32 = -1; +pub const RC_BAD_ARGS: i32 = -2; +pub const RC_BUFFER_TOO_SMALL: i32 = -3; +pub const RC_RUNTIME_ERROR: i32 = -4; +pub const RC_PANIC: i32 = -5; +pub const RC_AUTH_DENIED: i32 = -6; + +// ── Invoke-path helpers ────────────────────────────────────────────── + +/// True if any of the three mandatory `boj_cartridge_invoke` output-path +/// pointers is null. Use at the top of every invoke to short-circuit to +/// `RC_BAD_ARGS`. +pub fn invokeArgsNull( + tool_name: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) bool { + return tool_name == null or out_buf == null or in_out_len == null; +} + +/// Compare a C-NUL-terminated tool-name pointer against a Zig string +/// literal. Caller must have already verified `tool_name` is non-null +/// (usually via `invokeArgsNull`). +pub fn toolIs(tool_name: [*c]const u8, expected: []const u8) bool { + const s = std.mem.span(@as([*:0]const u8, @ptrCast(tool_name))); + return std.mem.eql(u8, s, expected); +} + +/// Copy `body` into `out_buf[0..*in_out_len]` (as a capacity) and update +/// `*in_out_len` to the number of bytes written. Returns `RC_SUCCESS`. +/// +/// If `body.len` exceeds the current capacity stored in `*in_out_len`, +/// sets `*in_out_len` to the required size and returns +/// `RC_BUFFER_TOO_SMALL` β€” the caller is then expected to re-allocate +/// and retry, per ADR-0006 Β§Memory ownership. +/// +/// Caller must have already verified that `out_buf` and `in_out_len` +/// are non-null. +pub fn writeResult( + out_buf: [*c]u8, + in_out_len: [*c]usize, + body: []const u8, +) i32 { + const cap = in_out_len.*; + if (body.len > cap) { + in_out_len.* = body.len; + return RC_BUFFER_TOO_SMALL; + } + @memcpy(out_buf[0..body.len], body); + in_out_len.* = body.len; + return RC_SUCCESS; +} + +// ── Tests ──────────────────────────────────────────────────────────── + +test "writeResult: body fits, writes and sets length" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); + try std.testing.expectEqualStrings("hello", buf[0..len]); +} + +test "writeResult: too small returns -3 and sets required length" { + var buf: [2]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_BUFFER_TOO_SMALL, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: exact-fit succeeds" { + var buf: [5]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: empty body" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, ""); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 0), len); +} + +test "toolIs: matches and rejects" { + const name: [*:0]const u8 = "foo"; + try std.testing.expect(toolIs(@ptrCast(name), "foo")); + try std.testing.expect(!toolIs(@ptrCast(name), "bar")); + try std.testing.expect(!toolIs(@ptrCast(name), "foobar")); + try std.testing.expect(!toolIs(@ptrCast(name), "fo")); +} + +test "invokeArgsNull: detects each null slot" { + var buf: [4]u8 = undefined; + var len: usize = 4; + const name: [*:0]const u8 = "x"; + try std.testing.expect(!invokeArgsNull(@ptrCast(name), &buf, &len)); + try std.testing.expect(invokeArgsNull(null, &buf, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), null, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), &buf, null)); +} diff --git a/cartridges/domains/config/k9iser-mcp/ffi/k9iser_ffi.zig b/cartridges/domains/config/k9iser-mcp/ffi/k9iser_ffi.zig new file mode 100644 index 0000000..8f82daa --- /dev/null +++ b/cartridges/domains/config/k9iser-mcp/ffi/k9iser_ffi.zig @@ -0,0 +1,357 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// k9iser-mcp Cartridge β€” Zig FFI bridge for the K9-contract regeneration +// pipeline. +// +// Implements the pipeline state machine from SafeK9iser.idr. Ensures no +// contract set can be applied (committed back to a repo) without first +// passing validation, and none can be validated without being generated. + +const std = @import("std"); + +// ═══════════════════════════════════════════════════════════════════════ +// Types (must match K9iserMcp.SafeK9iser encodings) +// ═══════════════════════════════════════════════════════════════════════ + +pub const K9State = enum(c_int) { + empty = 0, + manifest_loaded = 1, + generated = 2, + validated = 3, + applied = 4, + k9_error = 5, +}; + +pub const K9Format = enum(c_int) { + toml = 1, + yaml = 2, + json = 3, + ini = 4, + custom = 99, +}; + +// ═══════════════════════════════════════════════════════════════════════ +// Regeneration Pipeline State Machine +// ═══════════════════════════════════════════════════════════════════════ + +const MAX_SESSIONS: usize = 8; + +const SessionSlot = struct { + active: bool, + format: K9Format, + state: K9State, + manifest_hash: u32, // Hash of the manifest for cache invalidation +}; + +var sessions: [MAX_SESSIONS]SessionSlot = [_]SessionSlot{.{ + .active = false, + .format = .toml, + .state = .empty, + .manifest_hash = 0, +}} ** MAX_SESSIONS; + +var mutex: std.Thread.Mutex = .{}; + +/// Validate a state transition (matches Idris2 canTransition). +fn isValidTransition(from: K9State, to: K9State) bool { + return switch (from) { + .empty => to == .manifest_loaded, + .manifest_loaded => to == .generated or to == .k9_error, + .generated => to == .generated or to == .validated or to == .k9_error, + .validated => to == .applied or to == .empty, + .applied => to == .empty, + .k9_error => to == .empty, + }; +} + +/// Load a manifest into a new session. Returns slot index or -1 on failure. +pub export fn k9_load_manifest(format: c_int, manifest_hash: u32) c_int { + mutex.lock(); + defer mutex.unlock(); + for (&sessions, 0..) |*slot, i| { + if (!slot.active) { + slot.active = true; + slot.format = @enumFromInt(format); + slot.state = .manifest_loaded; + slot.manifest_hash = manifest_hash; + return @intCast(i); + } + } + return -1; // No slots available +} + +/// Generate K9 contracts from the loaded manifest + configs. +pub export fn k9_generate(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + if (slot_idx < 0 or slot_idx >= MAX_SESSIONS) return -1; + const idx: usize = @intCast(slot_idx); + if (!sessions[idx].active) return -1; + if (!isValidTransition(sessions[idx].state, .generated)) return -2; + + sessions[idx].state = .generated; + return 0; +} + +/// Validate generated contracts. REQUIRES state to be Generated. +pub export fn k9_validate(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + if (slot_idx < 0 or slot_idx >= MAX_SESSIONS) return -1; + const idx: usize = @intCast(slot_idx); + if (!sessions[idx].active) return -1; + // SAFETY: Must be in Generated state β€” cannot validate absent output + if (sessions[idx].state != .generated) return -2; + if (!isValidTransition(sessions[idx].state, .validated)) return -2; + + sessions[idx].state = .validated; + return 0; +} + +/// Apply (commit + push) the regenerated contracts. REQUIRES Validated. +/// Key safety invariant: contracts that failed validation can never be +/// pushed back to a repository. +pub export fn k9_apply(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + if (slot_idx < 0 or slot_idx >= MAX_SESSIONS) return -1; + const idx: usize = @intCast(slot_idx); + if (!sessions[idx].active) return -1; + // SAFETY: Must be in Validated state β€” cannot skip validation + if (sessions[idx].state != .validated) return -2; + if (!isValidTransition(sessions[idx].state, .applied)) return -2; + + sessions[idx].state = .applied; + return 0; +} + +/// Mark a generation/parse error. +pub export fn k9_mark_error(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + if (slot_idx < 0 or slot_idx >= MAX_SESSIONS) return -1; + const idx: usize = @intCast(slot_idx); + if (!sessions[idx].active) return -1; + if (!isValidTransition(sessions[idx].state, .k9_error)) return -2; + + sessions[idx].state = .k9_error; + return 0; +} + +/// Clean the session and reset to Empty. +pub export fn k9_clean(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + if (slot_idx < 0 or slot_idx >= MAX_SESSIONS) return -1; + const idx: usize = @intCast(slot_idx); + if (!sessions[idx].active) return -1; + if (!isValidTransition(sessions[idx].state, .empty)) return -2; + + sessions[idx].active = false; + sessions[idx].state = .empty; + sessions[idx].manifest_hash = 0; + return 0; +} + +/// Get the state of a session. +pub export fn k9_state(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + if (slot_idx < 0 or slot_idx >= MAX_SESSIONS) return -1; + const idx: usize = @intCast(slot_idx); + if (!sessions[idx].active) return @intFromEnum(K9State.empty); + return @intFromEnum(sessions[idx].state); +} + +/// Validate a state transition (C-ABI export). +pub export fn k9_can_transition(from: c_int, to: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + const f: K9State = @enumFromInt(from); + const t: K9State = @enumFromInt(to); + return if (isValidTransition(f, t)) 1 else 0; +} + +/// Reset all sessions (for testing). +pub export fn k9_reset() void { + mutex.lock(); + defer mutex.unlock(); + for (&sessions) |*slot| { + slot.active = false; + slot.state = .empty; + slot.manifest_hash = 0; + } +} + +// ═══════════════════════════════════════════════════════════════════════ +// Standard Cartridge Interface (loader expects these 4 C-ABI symbols) +// ═══════════════════════════════════════════════════════════════════════ + +/// Initialise the k9iser-mcp cartridge. Resets all session slots. +pub export fn boj_cartridge_init() c_int { + k9_reset(); + return 0; +} + +/// Deinitialise the k9iser-mcp cartridge. Resets all session slots. +pub export fn boj_cartridge_deinit() void { + k9_reset(); +} + +/// Return the cartridge name as a null-terminated C string. +pub export fn boj_cartridge_name() [*:0]const u8 { + mutex.lock(); + defer mutex.unlock(); + return "k9iser-mcp"; +} + +/// Return the cartridge version as a null-terminated C string. +pub export fn boj_cartridge_version() [*:0]const u8 { + mutex.lock(); + defer mutex.unlock(); + return "0.1.0"; +} + +// ═══════════════════════════════════════════════════════════════════════ +// ADR-0006 dispatch (boj_cartridge_invoke, 5th standard symbol) +// ═══════════════════════════════════════════════════════════════════════ + +const shim = @import("cartridge_shim.zig"); + +/// Dispatch the cartridge.json MCP tools. Grade D Alpha β€” each arm +/// returns a stub JSON body shaped to the tool's intended response. +pub export fn boj_cartridge_invoke( + tool_name: [*c]const u8, + json_args: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) callconv(.c) i32 { + _ = json_args; + if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; + + const body: []const u8 = if (shim.toolIs(tool_name, "k9_load_manifest")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "k9_generate")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "k9_validate")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "k9_apply")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "k9_clean")) + "{\"result\":{\"status\":\"stub\"}}" + else + return shim.RC_UNKNOWN_TOOL; + + return shim.writeResult(out_buf, in_out_len, body); +} + +// ═══════════════════════════════════════════════════════════════════════ +// Tests +// ═══════════════════════════════════════════════════════════════════════ + +test "load manifest and clean blocked before validate" { + k9_reset(); + const slot = k9_load_manifest(@intFromEnum(K9Format.toml), 0xABCD); + try std.testing.expect(slot >= 0); + try std.testing.expectEqual(@as(c_int, @intFromEnum(K9State.manifest_loaded)), k9_state(slot)); + // Cannot clean from manifest_loaded (must generate+validate or error first) + try std.testing.expectEqual(@as(c_int, -2), k9_clean(slot)); +} + +test "cannot apply without validate" { + k9_reset(); + const slot = k9_load_manifest(@intFromEnum(K9Format.yaml), 0x1234); + _ = k9_generate(slot); + // Attempt to apply directly from generated β€” MUST fail + try std.testing.expectEqual(@as(c_int, -2), k9_apply(slot)); + try std.testing.expectEqual(@as(c_int, @intFromEnum(K9State.generated)), k9_state(slot)); +} + +test "cannot validate without generate" { + k9_reset(); + const slot = k9_load_manifest(@intFromEnum(K9Format.json), 0x5678); + // Attempt to validate from manifest_loaded β€” MUST fail + try std.testing.expectEqual(@as(c_int, -2), k9_validate(slot)); +} + +test "full pipeline: load -> generate -> validate -> apply -> clean" { + k9_reset(); + const slot = k9_load_manifest(@intFromEnum(K9Format.toml), 0xCAFE); + try std.testing.expectEqual(@as(c_int, 0), k9_generate(slot)); + try std.testing.expectEqual(@as(c_int, @intFromEnum(K9State.generated)), k9_state(slot)); + try std.testing.expectEqual(@as(c_int, 0), k9_validate(slot)); + try std.testing.expectEqual(@as(c_int, @intFromEnum(K9State.validated)), k9_state(slot)); + try std.testing.expectEqual(@as(c_int, 0), k9_apply(slot)); + try std.testing.expectEqual(@as(c_int, @intFromEnum(K9State.applied)), k9_state(slot)); + try std.testing.expectEqual(@as(c_int, 0), k9_clean(slot)); + try std.testing.expectEqual(@as(c_int, @intFromEnum(K9State.empty)), k9_state(slot)); +} + +test "regenerate allowed" { + k9_reset(); + const slot = k9_load_manifest(@intFromEnum(K9Format.toml), 0xAAAA); + try std.testing.expectEqual(@as(c_int, 0), k9_generate(slot)); + try std.testing.expectEqual(@as(c_int, 0), k9_generate(slot)); // regenerate + try std.testing.expectEqual(@as(c_int, @intFromEnum(K9State.generated)), k9_state(slot)); +} + +test "error then recover" { + k9_reset(); + const slot = k9_load_manifest(@intFromEnum(K9Format.ini), 0xBBBB); + _ = k9_generate(slot); + try std.testing.expectEqual(@as(c_int, 0), k9_mark_error(slot)); + try std.testing.expectEqual(@as(c_int, @intFromEnum(K9State.k9_error)), k9_state(slot)); + try std.testing.expectEqual(@as(c_int, 0), k9_clean(slot)); // recover + try std.testing.expectEqual(@as(c_int, @intFromEnum(K9State.empty)), k9_state(slot)); +} + +test "state transition validation" { + // Valid transitions + try std.testing.expectEqual(@as(c_int, 1), k9_can_transition(0, 1)); // empty -> manifest_loaded + try std.testing.expectEqual(@as(c_int, 1), k9_can_transition(1, 2)); // manifest_loaded -> generated + try std.testing.expectEqual(@as(c_int, 1), k9_can_transition(2, 3)); // generated -> validated + try std.testing.expectEqual(@as(c_int, 1), k9_can_transition(3, 4)); // validated -> applied + try std.testing.expectEqual(@as(c_int, 1), k9_can_transition(4, 0)); // applied -> empty + try std.testing.expectEqual(@as(c_int, 1), k9_can_transition(2, 2)); // regenerate + // Invalid transitions β€” the key safety invariants + try std.testing.expectEqual(@as(c_int, 0), k9_can_transition(1, 3)); // manifest_loaded -> validated (BLOCKED) + try std.testing.expectEqual(@as(c_int, 0), k9_can_transition(2, 4)); // generated -> applied (BLOCKED) + try std.testing.expectEqual(@as(c_int, 0), k9_can_transition(0, 4)); // empty -> applied +} + +// ═══════════════════════════════════════════════════════════════════════ +// ADR-0006 invoke dispatch tests +// ═══════════════════════════════════════════════════════════════════════ + +test "invoke: each declared tool succeeds" { + var buf: [256]u8 = undefined; + const tools = [_][]const u8{ + "k9_load_manifest", + "k9_generate", + "k9_validate", + "k9_apply", + "k9_clean", + }; + for (tools) |t| { + var len: usize = buf.len; + const rc = boj_cartridge_invoke(t.ptr, "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, 0), rc); + try std.testing.expect(std.mem.indexOf(u8, buf[0..len], "result") != null); + } +} + +test "invoke: unknown tool returns -1" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("nope", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -1), rc); +} + +test "invoke: buffer too small returns -3" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("k9_load_manifest", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -3), rc); + try std.testing.expect(len > 4); +} diff --git a/cartridges/domains/config/k9iser-mcp/mod.js b/cartridges/domains/config/k9iser-mcp/mod.js new file mode 100644 index 0000000..0c6b2de --- /dev/null +++ b/cartridges/domains/config/k9iser-mcp/mod.js @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// k9iser-mcp/mod.js β€” Wrap configs into self-validating K9 contracts. +// +// Delegates to backend at http://127.0.0.1:7743 (override with K9ISER_BACKEND_URL). +// The backend runs the `k9iser` binary against a checked-out repo working +// tree: load_manifest -> generate -> validate -> apply (commit+push). + +const BASE_URL = Deno.env.get("K9ISER_BACKEND_URL") ?? "http://127.0.0.1:7743"; +const TIMEOUT_MS = 15_000; + +async function post(path, payload) { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), TIMEOUT_MS); + try { + const r = await fetch(`${BASE_URL}${path}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + signal: ctrl.signal, + }); + const data = await r.json().catch(() => ({ success: false, error: "non-JSON response" })); + return { status: r.status, data }; + } catch (e) { + if (e.name === "AbortError") return { status: 504, data: { success: false, error: "k9iser-mcp backend timed out" } }; + return { status: 503, data: { success: false, error: `k9iser-mcp backend unavailable: ${e.message}` } }; + } finally { clearTimeout(t); } +} + +export async function handleTool(toolName, args) { + switch (toolName) { + case "k9_load_manifest": + return post("/api/v1/k9_load_manifest", args ?? {}); + case "k9_generate": + return post("/api/v1/k9_generate", args ?? {}); + case "k9_validate": + return post("/api/v1/k9_validate", args ?? {}); + case "k9_apply": + return post("/api/v1/k9_apply", args ?? {}); + case "k9_clean": + return post("/api/v1/k9_clean", args ?? {}); + default: + return { status: 404, data: { error: `Unknown tool: ${toolName}` } }; + } +} diff --git a/cartridges/domains/config/k9iser-mcp/panels/manifest.json b/cartridges/domains/config/k9iser-mcp/panels/manifest.json new file mode 100644 index 0000000..aaba3bb --- /dev/null +++ b/cartridges/domains/config/k9iser-mcp/panels/manifest.json @@ -0,0 +1,67 @@ +{ + "$schema": "https://panll.dev/schemas/panel-manifest/v1.json", + "spdx": "MPL-2.0", + "cartridge": "k9iser-mcp", + "domain": "K9 Contract Regeneration", + "version": "0.1.0", + "panels": [ + { + "id": "k9-status", + "title": "k9iser Engine Status", + "description": "Contract regeneration backend readiness", + "type": "status-indicator", + "data_source": { + "endpoint": "/cartridge/k9iser-mcp/invoke", + "method": "POST", + "body": { "tool": "status" }, + "refresh_interval_ms": 5000 + }, + "widgets": [ + { + "type": "state-badge", + "field": "state", + "states": { + "ready": { "color": "#2ecc71", "icon": "shield-check" }, + "degraded": { "color": "#f39c12", "icon": "alert-circle" }, + "error": { "color": "#e74c3c", "icon": "alert-triangle" } + } + }, + { "type": "text", "field": "version", "label": "Version" } + ] + }, + { + "id": "k9-sessions", + "title": "Regeneration Sessions", + "description": "Repos whose K9 contracts are being regenerated and their pipeline state", + "type": "metric", + "data_source": { + "endpoint": "/cartridge/k9iser-mcp/invoke", + "method": "POST", + "body": { "tool": "sessions_summary" }, + "refresh_interval_ms": 30000 + }, + "widgets": [ + { "type": "counter", "field": "total_sessions", "label": "Active Sessions", "icon": "git-branch" }, + { "type": "counter", "field": "validated", "label": "Validated", "icon": "check-circle" }, + { "type": "counter", "field": "failed", "label": "Validation Failed", "icon": "x-circle" } + ] + }, + { + "id": "k9-contract-metrics", + "title": "Contract Metrics", + "description": "Generated contract counts and last regeneration outcome", + "type": "metric", + "data_source": { + "endpoint": "/cartridge/k9iser-mcp/invoke", + "method": "POST", + "body": { "tool": "contract_metrics" }, + "refresh_interval_ms": 30000 + }, + "widgets": [ + { "type": "counter", "field": "total_contracts", "label": "Total Contracts", "icon": "file-text" }, + { "type": "text", "field": "last_regen", "label": "Last Regeneration" }, + { "type": "text", "field": "last_outcome", "label": "Outcome" } + ] + } + ] +} diff --git a/cartridges/domains/container/container-mcp/README.adoc b/cartridges/domains/container/container-mcp/README.adoc new file mode 100644 index 0000000..a4d956b --- /dev/null +++ b/cartridges/domains/container/container-mcp/README.adoc @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += container-mcp +Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk> +:spdx: MPL-2.0 +:tier: Ayo +:domain: Container Orchestration +:protocols: MCP, REST + +== Overview + +Container lifecycle manager. Build, create, start, stop, and remove containers via Podman/Docker with state machine enforcement. + +== Tools (8) + +[cols="2,4"] +|=== +| Tool | Description + +| `container_build` | Build a container image from a Containerfile. +| `container_create` | Create a container from an image. +| `container_start` | Start a created or stopped container. +| `container_stop` | Stop a running container. +| `container_remove` | Remove a stopped container. +| `container_list` | List containers. +| `container_logs` | Get logs from a container. +| `container_inspect` | Inspect a container's full configuration and state. +|=== + +== Architecture + +Three-layer stack per the BoJ cartridge standard: + +[cols="1,1,3"] +|=== +| Layer | Language | Purpose + +| ABI | Idris2 | Formally verified state machine with dependent-type proofs +| FFI | Zig | C-ABI implementation: session pool, action metrics, thread safety +| Adapter| Zig | Adapter bridge to the BoJ unified adapter protocol (zig predecessors sidelined) +|=== + +== Building + +[source,bash] +---- +# ABI (Idris2) +cd abi && idris2 --check *.idr + +# FFI (Zig) +cd ffi && zig build +cd ffi && zig build test + +# Adapter (Zig) +cd adapter && zig build +---- + +== Status + +Alpha (CRG D). Cartridge metadata declared in `cartridge.json`; 8 tool(s) exposed. +Not yet dogfooded externally. See `docs/READINESS.md` at repo root for the +project-wide grade table and `docs/practice/DOGFOOD-LOG.adoc` for usage evidence. diff --git a/cartridges/domains/container/container-mcp/abi/ContainerMcp/SafeContainer.idr b/cartridges/domains/container/container-mcp/abi/ContainerMcp/SafeContainer.idr new file mode 100644 index 0000000..9bb63f3 --- /dev/null +++ b/cartridges/domains/container/container-mcp/abi/ContainerMcp/SafeContainer.idr @@ -0,0 +1,178 @@ +-- SPDX-License-Identifier: MPL-2.0 +-- Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +||| ContainerMcp.SafeContainer: Formally verified container management operations. +||| +||| Cartridge: container-mcp +||| Matrix cell: Container domain x {MCP, LSP} protocols +||| +||| This module defines type-safe container lifecycle operations with a +||| state machine that prevents: +||| - Starting a container that has not been created +||| - Removing a running container without stopping it +||| - Building from a non-existent state +||| +||| State machine: None -> Built -> Created -> Running -> Stopped -> Removed +module ContainerMcp.SafeContainer + +import Data.List + +%default total + +-- ═══════════════════════════════════════════════════════════════════════════ +-- Container Lifecycle State Machine +-- ═══════════════════════════════════════════════════════════════════════════ + +||| Container lifecycle states. +||| A container progresses: None -> Built -> Created -> Running -> Stopped -> Removed +public export +data CtrState = None | Built | Created | Running | Stopped | Removed + +||| Equality for container states. +public export +Eq CtrState where + None == None = True + Built == Built = True + Created == Created = True + Running == Running = True + Stopped == Stopped = True + Removed == Removed = True + _ == _ = False + +||| Valid state transitions (enforced at the type level). +public export +data ValidTransition : CtrState -> CtrState -> Type where + Build : ValidTransition None Built + Create : ValidTransition Built Created + Start : ValidTransition Created Running + Restart : ValidTransition Stopped Running + Stop : ValidTransition Running Stopped + RemoveStopped : ValidTransition Stopped Removed + RemoveCreated : ValidTransition Created Removed + +||| Runtime transition validator. +public export +canTransition : CtrState -> CtrState -> Bool +canTransition None Built = True +canTransition Built Created = True +canTransition Created Running = True +canTransition Stopped Running = True +canTransition Running Stopped = True +canTransition Stopped Removed = True +canTransition Created Removed = True +canTransition _ _ = False + +-- ═══════════════════════════════════════════════════════════════════════════ +-- Container Runtime Types +-- ═══════════════════════════════════════════════════════════════════════════ + +||| Supported container runtimes. +||| Podman is the hyperpolymath default runtime. +public export +data ContainerRuntime + = Podman -- Default (rootless, daemonless) + | Nerdctl -- containerd CLI + | Docker -- Legacy compatibility + +||| C-ABI encoding. +public export +runtimeToInt : ContainerRuntime -> Int +runtimeToInt Podman = 1 +runtimeToInt Nerdctl = 2 +runtimeToInt Docker = 3 + +-- ═══════════════════════════════════════════════════════════════════════════ +-- Container Record +-- ═══════════════════════════════════════════════════════════════════════════ + +||| A container with tracked lifecycle state. +public export +record Container where + constructor MkContainer + containerId : String + runtime : ContainerRuntime + state : CtrState + imageName : String + +||| Proof that a container is in a running state. +public export +data IsRunning : Container -> Type where + ActiveContainer : (c : Container) -> + (state c = Running) -> + IsRunning c + +-- ═══════════════════════════════════════════════════════════════════════════ +-- MCP Tool Definitions +-- ═══════════════════════════════════════════════════════════════════════════ + +||| MCP tools exposed by this cartridge. +||| These map to MCP tool definitions that AI agents can call. +public export +data McpTool + = ToolBuild -- Build a container image + | ToolCreate -- Create a container from an image + | ToolStart -- Start a created/stopped container + | ToolStop -- Stop a running container + | ToolRemove -- Remove a stopped/created container + | ToolLogs -- Retrieve container logs + | ToolInspect -- Inspect container metadata + | ToolListContainers -- List all containers + +||| MCP tool name (for JSON-RPC method name). +public export +toolName : McpTool -> String +toolName ToolBuild = "container/build" +toolName ToolCreate = "container/create" +toolName ToolStart = "container/start" +toolName ToolStop = "container/stop" +toolName ToolRemove = "container/remove" +toolName ToolLogs = "container/logs" +toolName ToolInspect = "container/inspect" +toolName ToolListContainers = "container/list" + +||| Which tools require a running container. +public export +requiresRunning : McpTool -> Bool +requiresRunning ToolLogs = True +requiresRunning ToolStop = True +requiresRunning _ = False + +-- ═══════════════════════════════════════════════════════════════════════════ +-- C-ABI Exports +-- ═══════════════════════════════════════════════════════════════════════════ + +||| Container state to integer. +public export +ctrStateToInt : CtrState -> Int +ctrStateToInt None = 0 +ctrStateToInt Built = 1 +ctrStateToInt Created = 2 +ctrStateToInt Running = 3 +ctrStateToInt Stopped = 4 +ctrStateToInt Removed = 5 + +||| FFI: Validate a state transition. +export +ctr_can_transition : Int -> Int -> Int +ctr_can_transition from to = + let fromState = case from of + 0 => None + 1 => Built + 2 => Created + 3 => Running + 4 => Stopped + _ => Removed + toState = case to of + 0 => None + 1 => Built + 2 => Created + 3 => Running + 4 => Stopped + _ => Removed + in if canTransition fromState toState then 1 else 0 + +||| FFI: Check if a tool requires a running container. +export +ctr_tool_requires_running : Int -> Int +ctr_tool_requires_running 6 = 1 -- ToolLogs +ctr_tool_requires_running 4 = 1 -- ToolStop +ctr_tool_requires_running _ = 0 -- Others do not require running diff --git a/cartridges/domains/container/container-mcp/abi/README.adoc b/cartridges/domains/container/container-mcp/abi/README.adoc new file mode 100644 index 0000000..bb06549 --- /dev/null +++ b/cartridges/domains/container/container-mcp/abi/README.adoc @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += container-mcp / abi β€” Idris2 ABI layer +:orientation: shallow + +== Purpose + +Cartridge-local protocol types for `container-mcp`. Defines the data, tool, and +transition vocabulary this cartridge speaks. Inherits safety lemmas and the +matrix-cell identifier scheme from the trunk ABI at `src/abi/`. + +== Files + +1 Idris2 module(s), ~178 lines total. Lead module: +`SafeContainer.idr`. + +No `.ipkg` β€” consumed directly by wider BoJ callers; type-checks inside the +trunk `boj.ipkg`. + +== Invariants + +* `%default total` at every module top. +* Zero `believe_me`, `assert_total`, `postulate`, `Admitted`, or + `sorry` (the trunk holds the four declared `believe_me` sites; leaf + cartridges carry none). + +== Test/proof surface + +Thin module(s), no inline tests. Proof surface = type-check success under the +trunk `idris2 --check`. + +== Read-first + +. `SafeContainer.idr` β€” cartridge-local data records and enums. +. Trunk `src/abi/Boj/Catalogue.idr` for how this cell participates in the + 2D matrix. diff --git a/cartridges/domains/container/container-mcp/abi/container-mcp.ipkg b/cartridges/domains/container/container-mcp/abi/container-mcp.ipkg new file mode 100644 index 0000000..16430ce --- /dev/null +++ b/cartridges/domains/container/container-mcp/abi/container-mcp.ipkg @@ -0,0 +1,11 @@ +-- SPDX-License-Identifier: MPL-2.0 +package containermcp + +authors = "Jonathan D.A. Jewell" +version = 0.1.0 +license = "MPL-2.0" +brief = "Container MCP cartridge β€” container lifecycle state machine with build/create/start/stop/remove ordering" + +sourcedir = "." +modules = ContainerMcp.SafeContainer +depends = base, contrib diff --git a/cartridges/domains/container/container-mcp/adapter/README.adoc b/cartridges/domains/container/container-mcp/adapter/README.adoc new file mode 100644 index 0000000..b1128e0 --- /dev/null +++ b/cartridges/domains/container/container-mcp/adapter/README.adoc @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += container-mcp / adapter β€” protocol bridge +:orientation: shallow + +== Purpose + +Exposes this cartridge's FFI over the wire protocols declared in +`cartridge.json`. Dispatches tool invocations to the C-ABI exports at +`../ffi/` and formats responses per protocol. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” depends on the sibling `ffi` target. +| `container_adapter.zig` | Protocol dispatch (115 lines). Tool catalogue matches `cartridge.json`. +| `SIDELINED-*.v.adoc` | zig predecessor(s). Not built β€” zig banned 2026-04-10. +|=== + +== Invariants + +* **Stateless** β€” all state lives behind the FFI, never in the adapter. +* **Response passthrough** β€” whatever the FFI returns goes back to the wire + unmodified (no embellishment, no silent recovery). +* **Cartridge.json is source of truth** for the tool catalogue; drift between + adapter and manifest is a CI failure. + +== Test/proof surface + +No inline tests at the adapter layer β€” protocol routing is exercised by the +trunk seam tests at `ffi/zig/src/seams.zig`. + +== Read-first + +. `container_adapter.zig` β€” dispatch function and per-protocol routers. +. `../cartridge.json` β€” the tool manifest the dispatcher mirrors. diff --git a/cartridges/domains/container/container-mcp/adapter/build.zig b/cartridges/domains/container/container-mcp/adapter/build.zig new file mode 100644 index 0000000..56a2493 --- /dev/null +++ b/cartridges/domains/container/container-mcp/adapter/build.zig @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// container-mcp/adapter/build.zig + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); const optimize = b.standardOptimizeOption(.{}); + const ffi_mod = b.createModule(.{ .root_source_file = b.path("../ffi/container_ffi.zig"), .target = target, .optimize = optimize }); + const adapter = b.addExecutable(.{ .name = "container_adapter", .root_source_file = b.path("container_adapter.zig"), .target = target, .optimize = optimize }); + adapter.root_module.addImport("container_ffi", ffi_mod); b.installArtifact(adapter); + const rs = b.step("run", "Run container-mcp adapter"); rs.dependOn(&b.addRunArtifact(adapter).step); + const tests = b.addTest(.{ .root_source_file = b.path("container_adapter.zig"), .target = target, .optimize = optimize }); + tests.root_module.addImport("container_ffi", ffi_mod); + const ts = b.step("test", "Test container-mcp adapter"); ts.dependOn(&b.addRunArtifact(tests).step); +} diff --git a/cartridges/domains/container/container-mcp/adapter/container_adapter.zig b/cartridges/domains/container/container-mcp/adapter/container_adapter.zig new file mode 100644 index 0000000..eafde3f --- /dev/null +++ b/cartridges/domains/container/container-mcp/adapter/container_adapter.zig @@ -0,0 +1,115 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// container-mcp/adapter/container_adapter.zig -- Unified three-protocol adapter. +// Replaces banned container_adapter.v (zig, removed 2026-04-12). +// REST:9148 gRPC:9149 GraphQL:9150 +// Tools: container_build, container_create, container_start, container_stop... + +const std = @import("std"); +const ffi = @import("container_ffi"); + +const REST_PORT: u16 = 9148; +const GRPC_PORT: u16 = 9149; +const GQL_PORT: u16 = 9150; +const MAX_CONN_BUF: usize = 16 * 1024; + +fn okJson(buf: []u8, msg: []const u8) []u8 { + const n = std.fmt.bufPrint(buf, "{\"success\":true,\"message\":\"{s}\"}", .{msg}) catch return buf[0..0]; return n; +} +fn errJson(buf: []u8, msg: []const u8) []u8 { + const n = std.fmt.bufPrint(buf, "{\"success\":false,\"error\":\"{s}\"}", .{msg}) catch return buf[0..0]; return n; +} +fn statusJson(buf: []u8) []u8 { + const n = std.fmt.bufPrint(buf, "{\"success\":true,\"state\":\"ready\",\"service\":\"container-mcp\"}", .{}) catch return buf[0..0]; return n; +} + +const Response = struct { status: u16, body: []u8 }; + +fn dispatch(tool: []const u8, body: []const u8, resp: []u8) Response { + _ = body; + if (std.mem.eql(u8, tool, "container_build")) return .{ .status = 200, .body = okJson(resp, "container_build forwarded") }; + if (std.mem.eql(u8, tool, "container_create")) return .{ .status = 200, .body = okJson(resp, "container_create forwarded") }; + if (std.mem.eql(u8, tool, "container_start")) return .{ .status = 200, .body = okJson(resp, "container_start forwarded") }; + if (std.mem.eql(u8, tool, "container_stop")) return .{ .status = 200, .body = okJson(resp, "container_stop forwarded") }; + if (std.mem.eql(u8, tool, "container_remove")) return .{ .status = 200, .body = okJson(resp, "container_remove forwarded") }; + if (std.mem.eql(u8, tool, "container_list")) return .{ .status = 200, .body = okJson(resp, "container_list forwarded") }; + if (std.mem.eql(u8, tool, "container_logs")) return .{ .status = 200, .body = okJson(resp, "container_logs forwarded") }; + if (std.mem.eql(u8, tool, "container_inspect")) return .{ .status = 200, .body = okJson(resp, "container_inspect forwarded") }; + if (std.mem.eql(u8, tool, "status") or std.mem.eql(u8, tool, "health")) return .{ .status = 200, .body = statusJson(resp) }; + return .{ .status = 404, .body = errJson(resp, "Unknown tool") }; +} + +fn dispatchRest(path: []const u8, body: []const u8, resp: []u8) Response { + if (std.mem.startsWith(u8, path, "/tools/")) return dispatch(path["/tools/".len..], body, resp); + if (std.mem.eql(u8, path, "/status") or std.mem.eql(u8, path, "/health")) return .{ .status = 200, .body = statusJson(resp) }; + return .{ .status = 404, .body = errJson(resp, "Not found") }; +} + +fn dispatchGrpc(path: []const u8, body: []const u8, resp: []u8) Response { + const prefix = "/Containerservice/"; + if (!std.mem.startsWith(u8, path, prefix)) return .{ .status = 404, .body = errJson(resp, "Not a recognized gRPC path") }; + const method = path[prefix.len..]; + const tool = blk: { + if (std.mem.eql(u8, method, "container_build")) break :blk "container_build"; + if (std.mem.eql(u8, method, "container_create")) break :blk "container_create"; + if (std.mem.eql(u8, method, "container_start")) break :blk "container_start"; + if (std.mem.eql(u8, method, "container_stop")) break :blk "container_stop"; + if (std.mem.eql(u8, method, "container_remove")) break :blk "container_remove"; + if (std.mem.eql(u8, method, "container_list")) break :blk "container_list"; + if (std.mem.eql(u8, method, "container_logs")) break :blk "container_logs"; + if (std.mem.eql(u8, method, "container_inspect")) break :blk "container_inspect"; + return .{ .status = 404, .body = errJson(resp, "Unknown gRPC method") }; + }; + return dispatch(tool, body, resp); +} + +fn dispatchGraphql(body: []const u8, resp: []u8) Response { + if (std.mem.indexOf(u8, body, "__schema") != null) return .{ .status = 200, .body = okJson(resp, "schema not supported") }; + if (std.mem.indexOf(u8, body, "build") != null) return dispatch("container_build", body, resp); + if (std.mem.indexOf(u8, body, "create") != null) return dispatch("container_create", body, resp); + if (std.mem.indexOf(u8, body, "start") != null) return dispatch("container_start", body, resp); + if (std.mem.indexOf(u8, body, "stop") != null) return dispatch("container_stop", body, resp); + if (std.mem.indexOf(u8, body, "remove") != null) return dispatch("container_remove", body, resp); + if (std.mem.indexOf(u8, body, "list") != null) return dispatch("container_list", body, resp); + if (std.mem.indexOf(u8, body, "logs") != null) return dispatch("container_logs", body, resp); + if (std.mem.indexOf(u8, body, "inspect") != null) return dispatch("container_inspect", body, resp); + return .{ .status = 200, .body = errJson(resp, "Unrecognised GraphQL operation") }; +} + +const Protocol = enum { rest, grpc, graphql }; + +fn handleConnection(conn: std.net.Server.Connection, proto: Protocol) void { + defer conn.stream.close(); + var in_buf: [MAX_CONN_BUF]u8 = undefined; + const n = conn.stream.read(&in_buf) catch return; + const req = in_buf[0..n]; + var path: []const u8 = "/"; var body: []const u8 = ""; + if (n > 4) { + const le = std.mem.indexOf(u8, req, "\r\n") orelse req.len; + const fl = req[0..le]; const sp1 = std.mem.indexOfScalar(u8, fl, ' ') orelse 0; + const ro = fl[sp1+1..]; const sp2 = std.mem.indexOfScalar(u8, ro, ' ') orelse ro.len; + path = ro[0..sp2]; + const bs = std.mem.indexOf(u8, req, "\r\n\r\n") orelse n; body = req[@min(bs+4,n)..]; + } + var rb: [MAX_CONN_BUF]u8 = undefined; + const result = switch (proto) { .rest => dispatchRest(path, body, &rb), .grpc => dispatchGrpc(path, body, &rb), .graphql => dispatchGraphql(body, &rb) }; + const ct: []const u8 = if (proto == .grpc) "application/grpc+json" else "application/json"; + var hb: [256]u8 = undefined; + const hdr = std.fmt.bufPrint(&hb, "HTTP/1.1 {d} OK\r\nContent-Type: {s}\r\nContent-Length: {d}\r\nConnection: close\r\n\r\n", .{ result.status, ct, result.body.len }) catch return; + _ = conn.stream.writeAll(hdr) catch return; _ = conn.stream.writeAll(result.body) catch return; +} + +fn listenLoop(port: u16, proto: Protocol) void { + const addr = std.net.Address.initIp4(.{ 127, 0, 0, 1 }, port) catch return; + var srv = addr.listen(.{ .reuse_address = true }) catch return; defer srv.deinit(); + while (true) { const conn = srv.accept() catch continue; handleConnection(conn, proto); } +} + +pub fn main() !void { + ffi.container_init(); + const t1 = try std.Thread.spawn(.{}, listenLoop, .{ REST_PORT, Protocol.rest }); + const t2 = try std.Thread.spawn(.{}, listenLoop, .{ GRPC_PORT, Protocol.grpc }); + const t3 = try std.Thread.spawn(.{}, listenLoop, .{ GQL_PORT, Protocol.graphql }); + t1.join(); t2.join(); t3.join(); +} diff --git a/cartridges/domains/container/container-mcp/cartridge.json b/cartridges/domains/container/container-mcp/cartridge.json new file mode 100644 index 0000000..f257591 --- /dev/null +++ b/cartridges/domains/container/container-mcp/cartridge.json @@ -0,0 +1,195 @@ +{ + "$schema": "https://boj.dev/schemas/cartridge/v1.json", + "spdx": "MPL-2.0", + "copyright": "Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath)", + "name": "container-mcp", + "version": "0.1.0", + "description": "Container lifecycle manager. Build, create, start, stop, and remove containers via Podman/Docker with state machine enforcement.", + "domain": "Container Orchestration", + "tier": "Ayo", + "protocols": [ + "MCP", + "REST" + ], + "auth": { + "method": "none", + "env_var": null, + "credential_source": null + }, + "api": { + "base_url": "local://container-mcp", + "content_type": "application/json" + }, + "tools": [ + { + "name": "container_build", + "description": "Build a container image from a Containerfile.", + "inputSchema": { + "type": "object", + "properties": { + "context_path": { + "type": "string", + "description": "Build context directory" + }, + "image_name": { + "type": "string", + "description": "Image name and tag (e.g. 'myapp:v1.0')" + }, + "containerfile": { + "type": "string", + "description": "Path to Containerfile (default: context_path/Containerfile)" + } + }, + "required": [ + "context_path", + "image_name" + ] + } + }, + { + "name": "container_create", + "description": "Create a container from an image.", + "inputSchema": { + "type": "object", + "properties": { + "image": { + "type": "string", + "description": "Image name or digest" + }, + "name": { + "type": "string", + "description": "Container name" + }, + "env": { + "type": "string", + "description": "Environment variables as JSON object" + }, + "ports": { + "type": "string", + "description": "Port mappings as JSON array" + } + }, + "required": [ + "image" + ] + } + }, + { + "name": "container_start", + "description": "Start a created or stopped container.", + "inputSchema": { + "type": "object", + "properties": { + "container_id": { + "type": "string", + "description": "Container ID or name" + } + }, + "required": [ + "container_id" + ] + } + }, + { + "name": "container_stop", + "description": "Stop a running container.", + "inputSchema": { + "type": "object", + "properties": { + "container_id": { + "type": "string", + "description": "Container ID or name" + }, + "timeout": { + "type": "integer", + "description": "Stop timeout in seconds (default: 10)" + } + }, + "required": [ + "container_id" + ] + } + }, + { + "name": "container_remove", + "description": "Remove a stopped container.", + "inputSchema": { + "type": "object", + "properties": { + "container_id": { + "type": "string", + "description": "Container ID or name" + }, + "force": { + "type": "boolean", + "description": "Force remove even if running (default: false)" + } + }, + "required": [ + "container_id" + ] + } + }, + { + "name": "container_list", + "description": "List containers.", + "inputSchema": { + "type": "object", + "properties": { + "all": { + "type": "boolean", + "description": "Include stopped containers (default: false)" + } + }, + "required": [] + } + }, + { + "name": "container_logs", + "description": "Get logs from a container.", + "inputSchema": { + "type": "object", + "properties": { + "container_id": { + "type": "string", + "description": "Container ID or name" + }, + "tail": { + "type": "integer", + "description": "Number of log lines (default: 100)" + } + }, + "required": [ + "container_id" + ] + } + }, + { + "name": "container_inspect", + "description": "Inspect a container's full configuration and state.", + "inputSchema": { + "type": "object", + "properties": { + "container_id": { + "type": "string", + "description": "Container ID or name" + } + }, + "required": [ + "container_id" + ] + } + } + ], + "ffi": { + "so_path": "ffi/zig-out/lib/libcontainer_mcp.so", + "abi_version": "ADR-0006", + "symbols": [ + "boj_cartridge_init", + "boj_cartridge_deinit", + "boj_cartridge_name", + "boj_cartridge_version", + "boj_cartridge_invoke" + ] + } +} diff --git a/cartridges/domains/container/container-mcp/ffi/README.adoc b/cartridges/domains/container/container-mcp/ffi/README.adoc new file mode 100644 index 0000000..859846b --- /dev/null +++ b/cartridges/domains/container/container-mcp/ffi/README.adoc @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += container-mcp / ffi β€” Zig FFI layer +:orientation: shallow + +== Purpose + +C-ABI exports bridging this cartridge's Idris2 ABI to the runtime. Consumed by +the adapter for protocol dispatch and by the trunk loader for mount. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” produces `zig-out/lib/libcontainer.so`. +| `container_ffi.zig` | C-ABI exports (12 exports, 7 inline tests, 280 lines). +|=== + +== Invariants + +* **Null-pointer rejection** on `[*c]const u8` arguments before any + dereference. +* **Negative return values** carry documented error meanings; zero is + success (matching the trunk error-code convention). +* **No allocator leaks** β€” every allocation path pairs with `defer free`. + +== Test/proof surface + +7 inline `test "..."` block(s) in `container_ffi.zig`. Run with +`cd ffi && zig build test`. + +== Read-first + +. `container_ffi.zig` β€” C-ABI signatures and guards. +. Trunk `ffi/zig/src/catalogue.zig` for how this `.so` is loaded. diff --git a/cartridges/domains/container/container-mcp/ffi/build.zig b/cartridges/domains/container/container-mcp/ffi/build.zig new file mode 100644 index 0000000..ae7173a --- /dev/null +++ b/cartridges/domains/container/container-mcp/ffi/build.zig @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// Container-MCP Cartridge β€” Zig FFI build configuration (Zig 0.15+). + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + // Shared ADR-0006 invoke-shim module (relative path up to boj-server trunk). + + const shim_mod = b.addModule("cartridge_shim", .{ + + .root_source_file = b.path("../../../ffi/zig/src/cartridge_shim.zig"), + + .target = target, + + .optimize = optimize, + + }); + + const ctr_mod = b.addModule("container_ffi", .{ + .root_source_file = b.path("container_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + ctr_mod.addImport("cartridge_shim", shim_mod); + + // ── Tests ──────────────────────────────────────────────────────── + const ctr_tests = b.addTest(.{ + .root_module = ctr_mod, + }); + + const run_tests = b.addRunArtifact(ctr_tests); + + const test_step = b.step("test", "Run container-mcp FFI tests"); + test_step.dependOn(&run_tests.step); + + // ── Shared library ────────────────────────────────────────────── + const lib_mod = b.createModule(.{ + .root_source_file = b.path("container_ffi.zig"), + .target = target, + .optimize = optimize, + }); + lib_mod.addImport("cartridge_shim", shim_mod); + + const lib = b.addLibrary(.{ + .name = "container_mcp", + .root_module = lib_mod, + .linkage = .dynamic, + }); + b.installArtifact(lib); + + const lib_step = b.step("lib", "Build shared library"); + lib_step.dependOn(&lib.step); +} diff --git a/cartridges/domains/container/container-mcp/ffi/cartridge_shim.zig b/cartridges/domains/container/container-mcp/ffi/cartridge_shim.zig new file mode 100644 index 0000000..0e399db --- /dev/null +++ b/cartridges/domains/container/container-mcp/ffi/cartridge_shim.zig @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// cartridge_shim.zig β€” Shared helpers for the ADR-0006 five-symbol +// cartridge ABI (`boj_cartridge_init / deinit / name / version / invoke`). +// +// The shim centralises the seven-code return convention, NUL-argument +// guards, tool-name comparison, and the buffer-too-small path so each +// cartridge's `boj_cartridge_invoke` can stay short β€” typically a tool +// table plus `shim.writeResult(...)`. +// +// Cartridges import this file by relative path (no build-graph change +// needed). Example: +// +// const shim = @import("../../../ffi/zig/src/cartridge_shim.zig"); +// +// export fn boj_cartridge_invoke( +// tool_name: [*c]const u8, +// json_args: [*c]const u8, +// out_buf: [*c]u8, +// in_out_len: [*c]usize, +// ) callconv(.c) i32 { +// _ = json_args; +// if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; +// const body = if (shim.toolIs(tool_name, "foo")) "{\"result\":{}}" +// else return shim.RC_UNKNOWN_TOOL; +// return shim.writeResult(out_buf, in_out_len, body); +// } + +const std = @import("std"); + +// ── Return codes (ADR-0006 Β§Return codes) ──────────────────────────── +// +// Frozen by ADR-0006. New failure modes compose these via the error +// JSON body β€” the integer surface does not grow without a follow-up ADR. + +pub const RC_SUCCESS: i32 = 0; +pub const RC_UNKNOWN_TOOL: i32 = -1; +pub const RC_BAD_ARGS: i32 = -2; +pub const RC_BUFFER_TOO_SMALL: i32 = -3; +pub const RC_RUNTIME_ERROR: i32 = -4; +pub const RC_PANIC: i32 = -5; +pub const RC_AUTH_DENIED: i32 = -6; + +// ── Invoke-path helpers ────────────────────────────────────────────── + +/// True if any of the three mandatory `boj_cartridge_invoke` output-path +/// pointers is null. Use at the top of every invoke to short-circuit to +/// `RC_BAD_ARGS`. +pub fn invokeArgsNull( + tool_name: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) bool { + return tool_name == null or out_buf == null or in_out_len == null; +} + +/// Compare a C-NUL-terminated tool-name pointer against a Zig string +/// literal. Caller must have already verified `tool_name` is non-null +/// (usually via `invokeArgsNull`). +pub fn toolIs(tool_name: [*c]const u8, expected: []const u8) bool { + const s = std.mem.span(@as([*:0]const u8, @ptrCast(tool_name))); + return std.mem.eql(u8, s, expected); +} + +/// Copy `body` into `out_buf[0..*in_out_len]` (as a capacity) and update +/// `*in_out_len` to the number of bytes written. Returns `RC_SUCCESS`. +/// +/// If `body.len` exceeds the current capacity stored in `*in_out_len`, +/// sets `*in_out_len` to the required size and returns +/// `RC_BUFFER_TOO_SMALL` β€” the caller is then expected to re-allocate +/// and retry, per ADR-0006 Β§Memory ownership. +/// +/// Caller must have already verified that `out_buf` and `in_out_len` +/// are non-null. +pub fn writeResult( + out_buf: [*c]u8, + in_out_len: [*c]usize, + body: []const u8, +) i32 { + const cap = in_out_len.*; + if (body.len > cap) { + in_out_len.* = body.len; + return RC_BUFFER_TOO_SMALL; + } + @memcpy(out_buf[0..body.len], body); + in_out_len.* = body.len; + return RC_SUCCESS; +} + +// ── Tests ──────────────────────────────────────────────────────────── + +test "writeResult: body fits, writes and sets length" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); + try std.testing.expectEqualStrings("hello", buf[0..len]); +} + +test "writeResult: too small returns -3 and sets required length" { + var buf: [2]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_BUFFER_TOO_SMALL, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: exact-fit succeeds" { + var buf: [5]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: empty body" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, ""); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 0), len); +} + +test "toolIs: matches and rejects" { + const name: [*:0]const u8 = "foo"; + try std.testing.expect(toolIs(@ptrCast(name), "foo")); + try std.testing.expect(!toolIs(@ptrCast(name), "bar")); + try std.testing.expect(!toolIs(@ptrCast(name), "foobar")); + try std.testing.expect(!toolIs(@ptrCast(name), "fo")); +} + +test "invokeArgsNull: detects each null slot" { + var buf: [4]u8 = undefined; + var len: usize = 4; + const name: [*:0]const u8 = "x"; + try std.testing.expect(!invokeArgsNull(@ptrCast(name), &buf, &len)); + try std.testing.expect(invokeArgsNull(null, &buf, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), null, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), &buf, null)); +} diff --git a/cartridges/domains/container/container-mcp/ffi/container_ffi.zig b/cartridges/domains/container/container-mcp/ffi/container_ffi.zig new file mode 100644 index 0000000..4a6846f --- /dev/null +++ b/cartridges/domains/container/container-mcp/ffi/container_ffi.zig @@ -0,0 +1,358 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// Container-MCP Cartridge β€” Zig FFI bridge for container management. +// +// Implements the container lifecycle state machine from SafeContainer.idr. +// Ensures containers follow the correct lifecycle ordering: +// None -> Built -> Created -> Running -> Stopped -> Removed + +const std = @import("std"); + +// ═══════════════════════════════════════════════════════════════════════ +// Types (must match ContainerMcp.SafeContainer encodings) +// ═══════════════════════════════════════════════════════════════════════ + +pub const CtrState = enum(c_int) { + none = 0, + built = 1, + created = 2, + running = 3, + stopped = 4, + removed = 5, +}; + +pub const ContainerRuntime = enum(c_int) { + podman = 1, + nerdctl = 2, + docker = 3, +}; + +// ═══════════════════════════════════════════════════════════════════════ +// Container Lifecycle State Machine +// ═══════════════════════════════════════════════════════════════════════ + +const MAX_CONTAINERS: usize = 32; + +const ContainerSlot = struct { + active: bool, + state: CtrState, + runtime: ContainerRuntime, + image_name_hash: u32, +}; + +var containers: [MAX_CONTAINERS]ContainerSlot = [_]ContainerSlot{.{ + .active = false, + .state = .none, + .runtime = .podman, + .image_name_hash = 0, +}} ** MAX_CONTAINERS; + +var mutex: std.Thread.Mutex = .{}; + +/// Validate a state transition (matches Idris2 canTransition). +fn isValidTransition(from: CtrState, to: CtrState) bool { + return switch (from) { + .none => to == .built, + .built => to == .created, + .created => to == .running or to == .removed, + .running => to == .stopped, + .stopped => to == .running or to == .removed, + .removed => false, + }; +} + +/// Simple hash for image name tracking. +fn hashName(name: [*:0]const u8) u32 { + var h: u32 = 5381; + var i: usize = 0; + while (name[i] != 0) : (i += 1) { + h = ((h << 5) +% h) +% @as(u32, name[i]); + } + return h; +} + +/// Build a container image. Returns slot index or -1 on failure. +pub export fn ctr_build(runtime: c_int, image_name: [*:0]const u8) c_int { + mutex.lock(); + defer mutex.unlock(); + for (&containers, 0..) |*slot, i| { + if (!slot.active) { + slot.active = true; + slot.state = .built; + slot.runtime = @enumFromInt(runtime); + slot.image_name_hash = hashName(image_name); + return @intCast(i); + } + } + return -1; // No slots available +} + +/// Create a container from a built image. +pub export fn ctr_create(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + if (slot_idx < 0 or slot_idx >= MAX_CONTAINERS) return -1; + const idx: usize = @intCast(slot_idx); + if (!containers[idx].active) return -1; + if (!isValidTransition(containers[idx].state, .created)) return -2; + + containers[idx].state = .created; + return 0; +} + +/// Start a created or stopped container. +pub export fn ctr_start(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + if (slot_idx < 0 or slot_idx >= MAX_CONTAINERS) return -1; + const idx: usize = @intCast(slot_idx); + if (!containers[idx].active) return -1; + if (!isValidTransition(containers[idx].state, .running)) return -2; + + containers[idx].state = .running; + return 0; +} + +/// Stop a running container. +pub export fn ctr_stop(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + if (slot_idx < 0 or slot_idx >= MAX_CONTAINERS) return -1; + const idx: usize = @intCast(slot_idx); + if (!containers[idx].active) return -1; + if (!isValidTransition(containers[idx].state, .stopped)) return -2; + + containers[idx].state = .stopped; + return 0; +} + +/// Remove a stopped or created container. +pub export fn ctr_remove(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + if (slot_idx < 0 or slot_idx >= MAX_CONTAINERS) return -1; + const idx: usize = @intCast(slot_idx); + if (!containers[idx].active) return -1; + if (!isValidTransition(containers[idx].state, .removed)) return -2; + + containers[idx].state = .removed; + containers[idx].active = false; + return 0; +} + +/// Get the state of a container. +pub export fn ctr_state(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + if (slot_idx < 0 or slot_idx >= MAX_CONTAINERS) return -1; + const idx: usize = @intCast(slot_idx); + if (!containers[idx].active) return @intFromEnum(CtrState.none); + return @intFromEnum(containers[idx].state); +} + +/// Validate a state transition (C-ABI export). +pub export fn ctr_can_transition(from: c_int, to: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + const f: CtrState = @enumFromInt(from); + const t: CtrState = @enumFromInt(to); + return if (isValidTransition(f, t)) 1 else 0; +} + +/// Reset all containers (for testing). +pub export fn ctr_reset() void { + mutex.lock(); + defer mutex.unlock(); + for (&containers) |*slot| { + slot.active = false; + slot.state = .none; + slot.image_name_hash = 0; + } +} + +// ═══════════════════════════════════════════════════════════════════════ +// Standard Cartridge Interface (loader expects these 4 C-ABI symbols) +// ═══════════════════════════════════════════════════════════════════════ + +/// Initialise the container-mcp cartridge. Resets all container slots. +pub export fn boj_cartridge_init() c_int { + ctr_reset(); + return 0; +} + +/// Deinitialise the container-mcp cartridge. Resets all container slots. +pub export fn boj_cartridge_deinit() void { + ctr_reset(); +} + +/// Return the cartridge name as a null-terminated C string. +pub export fn boj_cartridge_name() [*:0]const u8 { + mutex.lock(); + defer mutex.unlock(); + return "container-mcp"; +} + +/// Return the cartridge version as a null-terminated C string. +pub export fn boj_cartridge_version() [*:0]const u8 { + mutex.lock(); + defer mutex.unlock(); + return "0.1.0"; +} + +// ═══════════════════════════════════════════════════════════════════════ +// ADR-0006 dispatch (boj_cartridge_invoke, 5th standard symbol) +// ═══════════════════════════════════════════════════════════════════════ + +const shim = @import("cartridge_shim.zig"); + +/// Dispatch the cartridge.json MCP tools. Grade D Alpha β€” each arm +/// returns a stub JSON body shaped to the tool's intended response. +export fn boj_cartridge_invoke( + tool_name: [*c]const u8, + json_args: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) callconv(.c) i32 { + _ = json_args; + if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; + + const body: []const u8 = if (shim.toolIs(tool_name, "container_build")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "container_create")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "container_start")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "container_stop")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "container_remove")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "container_list")) + "{\"result\":{\"items\":[],\"count\":0,\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "container_logs")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "container_inspect")) + "{\"result\":{\"status\":\"stub\"}}" +else + return shim.RC_UNKNOWN_TOOL; + + return shim.writeResult(out_buf, in_out_len, body); +} + +// ═══════════════════════════════════════════════════════════════════════ +// Tests +// ═══════════════════════════════════════════════════════════════════════ + +test "full lifecycle: build -> create -> start -> stop -> remove" { + ctr_reset(); + const slot = ctr_build(@intFromEnum(ContainerRuntime.podman), "myapp:latest"); + try std.testing.expect(slot >= 0); + try std.testing.expectEqual(@as(c_int, @intFromEnum(CtrState.built)), ctr_state(slot)); + try std.testing.expectEqual(@as(c_int, 0), ctr_create(slot)); + try std.testing.expectEqual(@as(c_int, @intFromEnum(CtrState.created)), ctr_state(slot)); + try std.testing.expectEqual(@as(c_int, 0), ctr_start(slot)); + try std.testing.expectEqual(@as(c_int, @intFromEnum(CtrState.running)), ctr_state(slot)); + try std.testing.expectEqual(@as(c_int, 0), ctr_stop(slot)); + try std.testing.expectEqual(@as(c_int, @intFromEnum(CtrState.stopped)), ctr_state(slot)); + try std.testing.expectEqual(@as(c_int, 0), ctr_remove(slot)); +} + +test "cannot start unbuilt container" { + ctr_reset(); + const slot = ctr_build(@intFromEnum(ContainerRuntime.nerdctl), "test:v1"); + // Cannot start directly from built β€” must create first + try std.testing.expectEqual(@as(c_int, -2), ctr_start(slot)); +} + +test "cannot remove running container" { + ctr_reset(); + const slot = ctr_build(@intFromEnum(ContainerRuntime.podman), "webapp:latest"); + _ = ctr_create(slot); + _ = ctr_start(slot); + // Cannot remove while running β€” must stop first + try std.testing.expectEqual(@as(c_int, -2), ctr_remove(slot)); +} + +test "restart stopped container" { + ctr_reset(); + const slot = ctr_build(@intFromEnum(ContainerRuntime.docker), "db:14"); + _ = ctr_create(slot); + _ = ctr_start(slot); + _ = ctr_stop(slot); + // Can restart a stopped container + try std.testing.expectEqual(@as(c_int, 0), ctr_start(slot)); + try std.testing.expectEqual(@as(c_int, @intFromEnum(CtrState.running)), ctr_state(slot)); +} + +test "remove created but never started" { + ctr_reset(); + const slot = ctr_build(@intFromEnum(ContainerRuntime.podman), "scratch:latest"); + _ = ctr_create(slot); + // Can remove a created container without starting it + try std.testing.expectEqual(@as(c_int, 0), ctr_remove(slot)); +} + +test "state transition validation" { + // Valid transitions + try std.testing.expectEqual(@as(c_int, 1), ctr_can_transition(0, 1)); // none -> built + try std.testing.expectEqual(@as(c_int, 1), ctr_can_transition(1, 2)); // built -> created + try std.testing.expectEqual(@as(c_int, 1), ctr_can_transition(2, 3)); // created -> running + try std.testing.expectEqual(@as(c_int, 1), ctr_can_transition(3, 4)); // running -> stopped + try std.testing.expectEqual(@as(c_int, 1), ctr_can_transition(4, 3)); // stopped -> running (restart) + try std.testing.expectEqual(@as(c_int, 1), ctr_can_transition(4, 5)); // stopped -> removed + try std.testing.expectEqual(@as(c_int, 1), ctr_can_transition(2, 5)); // created -> removed + // Invalid transitions + try std.testing.expectEqual(@as(c_int, 0), ctr_can_transition(0, 3)); // none -> running + try std.testing.expectEqual(@as(c_int, 0), ctr_can_transition(3, 5)); // running -> removed + try std.testing.expectEqual(@as(c_int, 0), ctr_can_transition(5, 0)); // removed -> none +} + +test "max containers enforced" { + ctr_reset(); + var i: usize = 0; + while (i < MAX_CONTAINERS) : (i += 1) { + const slot = ctr_build(@intFromEnum(ContainerRuntime.podman), "fill:latest"); + try std.testing.expect(slot >= 0); + } + // Next build should fail + try std.testing.expectEqual(@as(c_int, -1), ctr_build(@intFromEnum(ContainerRuntime.podman), "overflow:latest")); +} + +// ═══════════════════════════════════════════════════════════════════════ +// ADR-0006 invoke dispatch tests +// ═══════════════════════════════════════════════════════════════════════ + +test "invoke: each declared tool succeeds" { + var buf: [256]u8 = undefined; + const tools = [_][]const u8{ + "container_build", + "container_create", + "container_start", + "container_stop", + "container_remove", + "container_list", + "container_logs", + "container_inspect", + }; + for (tools) |t| { + var len: usize = buf.len; + const rc = boj_cartridge_invoke(t.ptr, "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, 0), rc); + try std.testing.expect(std.mem.indexOf(u8, buf[0..len], "result") != null); + } +} + +test "invoke: unknown tool returns -1" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("nope", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -1), rc); +} + +test "invoke: buffer too small returns -3" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("container_build", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -3), rc); + try std.testing.expect(len > 4); +} diff --git a/cartridges/domains/container/container-mcp/mod.js b/cartridges/domains/container/container-mcp/mod.js new file mode 100644 index 0000000..69604d8 --- /dev/null +++ b/cartridges/domains/container/container-mcp/mod.js @@ -0,0 +1,86 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// container-mcp/mod.js -- container gateway. + +const BASE_URL = Deno.env.get("CONTAINER_MCP_BACKEND_URL") ?? "http://127.0.0.1:7716"; + +async function post(path, payload) { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), 15000); + try { + const r = await fetch(`${BASE_URL}${path}`, { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), signal: ctrl.signal, + }); + const data = await r.json().catch(() => ({ success: false, error: "non-JSON response" })); + return { status: r.status, data }; + } catch (e) { + if (e.name === "AbortError") return { status: 504, data: { success: false, error: "container-mcp backend timed out" } }; + return { status: 503, data: { success: false, error: `container-mcp backend unavailable: ${e.message}` } }; + } finally { clearTimeout(t); } +} + +export async function handleTool(toolName, args) { + switch (toolName) { + + case "container_build": { + const { context_path, image_name, containerfile } = args ?? {}; + if (!context_path || !image_name) return { status: 400, data: { error: "context_path is required" } }; + const payload = { context_path, image_name }; + if (containerfile !== undefined) payload.containerfile = containerfile; + return post("/api/v1/build", payload); + } + case "container_create": { + const { image, name, env, ports } = args ?? {}; + if (!image) return { status: 400, data: { error: "image is required" } }; + const payload = { image }; + if (name !== undefined) payload.name = name; + if (env !== undefined) payload.env = env; + if (ports !== undefined) payload.ports = ports; + return post("/api/v1/create", payload); + } + case "container_start": { + const { container_id } = args ?? {}; + if (!container_id) return { status: 400, data: { error: "container_id is required" } }; + const payload = { container_id }; + return post("/api/v1/start", payload); + } + case "container_stop": { + const { container_id, timeout } = args ?? {}; + if (!container_id) return { status: 400, data: { error: "container_id is required" } }; + const payload = { container_id }; + if (timeout !== undefined) payload.timeout = timeout; + return post("/api/v1/stop", payload); + } + case "container_remove": { + const { container_id, force } = args ?? {}; + if (!container_id) return { status: 400, data: { error: "container_id is required" } }; + const payload = { container_id }; + if (force !== undefined) payload.force = force; + return post("/api/v1/remove", payload); + } + case "container_list": { + const { all } = args ?? {}; + const payload = { }; + if (all !== undefined) payload.all = all; + return post("/api/v1/list", payload); + } + case "container_logs": { + const { container_id, tail } = args ?? {}; + if (!container_id) return { status: 400, data: { error: "container_id is required" } }; + const payload = { container_id }; + if (tail !== undefined) payload.tail = tail; + return post("/api/v1/logs", payload); + } + case "container_inspect": { + const { container_id } = args ?? {}; + if (!container_id) return { status: 400, data: { error: "container_id is required" } }; + const payload = { container_id }; + return post("/api/v1/inspect", payload); + } + + default: + return { status: 404, data: { error: `Unknown tool: ${toolName}` } }; + } +} diff --git a/cartridges/domains/container/container-mcp/panels/manifest.json b/cartridges/domains/container/container-mcp/panels/manifest.json new file mode 100644 index 0000000..a00ac8d --- /dev/null +++ b/cartridges/domains/container/container-mcp/panels/manifest.json @@ -0,0 +1,67 @@ +{ + "$schema": "https://panll.dev/schemas/panel-manifest/v1.json", + "spdx": "MPL-2.0", + "cartridge": "container-mcp", + "domain": "Containers", + "version": "0.1.0", + "panels": [ + { + "id": "container-status", + "title": "Container Runtime Status", + "description": "Podman/OCI runtime health and socket connectivity", + "type": "status-indicator", + "data_source": { + "endpoint": "/cartridge/container-mcp/invoke", + "method": "POST", + "body": { "tool": "status" }, + "refresh_interval_ms": 5000 + }, + "widgets": [ + { + "type": "state-badge", + "field": "state", + "states": { + "ready": { "color": "#2ecc71", "icon": "box" }, + "degraded": { "color": "#f39c12", "icon": "alert-circle" }, + "error": { "color": "#e74c3c", "icon": "alert-triangle" } + } + }, + { "type": "text", "field": "version", "label": "Version" } + ] + }, + { + "id": "container-inventory", + "title": "Container Inventory", + "description": "Running, stopped, and total container counts with image summary", + "type": "metric", + "data_source": { + "endpoint": "/cartridge/container-mcp/invoke", + "method": "POST", + "body": { "tool": "inventory" }, + "refresh_interval_ms": 10000 + }, + "widgets": [ + { "type": "counter", "field": "running", "label": "Running", "icon": "play-circle" }, + { "type": "counter", "field": "stopped", "label": "Stopped", "icon": "stop-circle" }, + { "type": "counter", "field": "images", "label": "Images", "icon": "layers" } + ] + }, + { + "id": "container-resource-usage", + "title": "Resource Usage", + "description": "Aggregate CPU and memory consumption across all running containers", + "type": "metric", + "data_source": { + "endpoint": "/cartridge/container-mcp/invoke", + "method": "POST", + "body": { "tool": "resource_usage" }, + "refresh_interval_ms": 10000 + }, + "widgets": [ + { "type": "gauge", "field": "cpu_percent", "label": "CPU %", "min": 0, "max": 100 }, + { "type": "gauge", "field": "memory_percent", "label": "Memory %", "min": 0, "max": 100 }, + { "type": "text", "field": "memory_used", "label": "Memory Used" } + ] + } + ] +} diff --git a/cartridges/domains/container/docker-hub-mcp/README.adoc b/cartridges/domains/container/docker-hub-mcp/README.adoc new file mode 100644 index 0000000..9040de8 --- /dev/null +++ b/cartridges/domains/container/docker-hub-mcp/README.adoc @@ -0,0 +1,100 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> + += docker-hub-mcp +Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk> +:spdx: MPL-2.0 +:tier: Ayo +:domain: Container +:protocols: MCP, REST + +== Overview + +Docker Hub container registry MCP cartridge. Manages images, tags, namespaces, +manifests, repositories, and stars via the Docker Hub REST API +(`https://hub.docker.com/v2/`). + +Two-phase authentication: POST credentials to `/v2/users/login` to obtain a JWT +bearer token, then use the JWT for subsequent API calls. Pull rate limited to +100 pulls/6h (anonymous) or 200 pulls/6h (authenticated). + +== State Machine + +[source] +---- +Unauthenticated --> Authenticated (JWT obtained from /v2/users/login) +Authenticated --> RateLimited (pull rate limit exceeded) +RateLimited --> Authenticated (6-hour window reset) +Authenticated --> Error (API/network failure) +RateLimited --> Error (failure during rate limit) +Error --> Authenticated (recovery) +Authenticated --> Unauthenticated (logout) +Error --> Unauthenticated (logout from error) +---- + +== Actions (16) + +|=== +| Action | Destructive | Endpoint + +| SearchImages | No | GET /v2/search/repositories +| GetRepository | No | GET /v2/repositories/{namespace}/{name} +| ListTags | No | GET /v2/repositories/{namespace}/{name}/tags +| GetTag | No | GET /v2/repositories/{namespace}/{name}/tags/{tag} +| ListNamespaces | No | GET /v2/repositories/namespaces +| GetManifest | No | GET /v2/{namespace}/{name}/manifests/{reference} +| DeleteTag | Yes | DELETE /v2/repositories/{namespace}/{name}/tags/{tag} +| GetRateLimit | No | HEAD /v2/ratelimitpreview/test +| ListOrgs | No | GET /v2/orgs +| CreateRepository | Yes | POST /v2/repositories +| DeleteRepository | Yes | DELETE /v2/repositories/{namespace}/{name} +| GetDockerfile | No | GET /v2/repositories/{namespace}/{name}/dockerfile +| ListStarred | No | GET /v2/users/{username}/starred +| StarRepository | Yes | PUT /v2/repositories/{namespace}/{name}/stars +| UnstarRepository | Yes | DELETE /v2/repositories/{namespace}/{name}/stars +| GetUser | No | GET /v2/users/{username} +|=== + +== Architecture + +[cols="1,1,2"] +|=== +| Layer | Language | Purpose + +| ABI +| Idris2 +| Formally verified auth state machine with dependent-type proofs + +| FFI +| Zig +| C-ABI implementation with thread-safe session pool and pull rate tracking + +| Adapter +| zig +| REST bridge to BoJ unified adapter protocol +|=== + +== Building + +[source,bash] +---- +# Build FFI shared library +cd ffi && zig build + +# Run FFI tests +cd ffi && zig build test + +# Type-check ABI +cd abi && idris2 --check DockerHubMcp.SafeContainer +---- + +== Panels + +* Auth status (unauthenticated/authenticated/rate_limited/error) +* Namespace count +* Image count +* Pull rate remaining + +== Status + +Development -- not yet ready for mounting. diff --git a/cartridges/domains/container/docker-hub-mcp/abi/DockerHubMcp/SafeContainer.idr b/cartridges/domains/container/docker-hub-mcp/abi/DockerHubMcp/SafeContainer.idr new file mode 100644 index 0000000..1025c2b --- /dev/null +++ b/cartridges/domains/container/docker-hub-mcp/abi/DockerHubMcp/SafeContainer.idr @@ -0,0 +1,230 @@ +-- SPDX-License-Identifier: MPL-2.0 +-- Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +-- +-- DockerHubMcp.SafeContainer β€” Type-safe ABI for docker-hub-mcp cartridge. +-- +-- Formally verified state machine for Docker Hub API interactions. +-- Two-phase auth: POST /v2/users/login -> JWT bearer token. +-- REST API (https://hub.docker.com/v2/). +-- Pull rate limit: 100 pulls/6h (anonymous), 200 pulls/6h (authenticated). + +module DockerHubMcp.SafeContainer + +%default total + +-- --------------------------------------------------------------------------- +-- Authentication state machine +-- --------------------------------------------------------------------------- + +||| Authentication/session state for Docker Hub API operations. +||| Unauthenticated: No JWT token acquired. +||| Authenticated: Valid JWT from /v2/users/login, ready for API calls. +||| RateLimited: Hit pull rate limit (100 anon / 200 auth per 6h). +||| Error: API or network error requiring recovery. +public export +data AuthState = Unauthenticated | Authenticated | RateLimited | Error + +||| Proof that a state transition is valid. +||| Only well-typed transitions compile β€” invalid paths are rejected. +public export +data ValidTransition : AuthState -> AuthState -> Type where + Authenticate : ValidTransition Unauthenticated Authenticated + HitRateLimit : ValidTransition Authenticated RateLimited + RateLimitReset : ValidTransition RateLimited Authenticated + AuthError : ValidTransition Authenticated Error + RateLimitError : ValidTransition RateLimited Error + RecoverToAuth : ValidTransition Error Authenticated + Logout : ValidTransition Authenticated Unauthenticated + ErrorLogout : ValidTransition Error Unauthenticated + +-- --------------------------------------------------------------------------- +-- C-ABI integer encoding +-- --------------------------------------------------------------------------- + +||| Encode auth state as C-compatible integer for FFI boundary. +export +authStateToInt : AuthState -> Int +authStateToInt Unauthenticated = 0 +authStateToInt Authenticated = 1 +authStateToInt RateLimited = 2 +authStateToInt Error = 3 + +||| Decode integer back to auth state. Returns Nothing for invalid values. +export +intToAuthState : Int -> Maybe AuthState +intToAuthState 0 = Just Unauthenticated +intToAuthState 1 = Just Authenticated +intToAuthState 2 = Just RateLimited +intToAuthState 3 = Just Error +intToAuthState _ = Nothing + +||| Check if a state transition is valid (C-ABI export). +||| Returns 1 for valid, 0 for invalid. +export +docker_hub_mcp_can_transition : Int -> Int -> Int +docker_hub_mcp_can_transition from to = + case (intToAuthState from, intToAuthState to) of + (Just Unauthenticated, Just Authenticated) => 1 + (Just Authenticated, Just RateLimited) => 1 + (Just RateLimited, Just Authenticated) => 1 + (Just Authenticated, Just Error) => 1 + (Just RateLimited, Just Error) => 1 + (Just Error, Just Authenticated) => 1 + (Just Authenticated, Just Unauthenticated) => 1 + (Just Error, Just Unauthenticated) => 1 + _ => 0 + +-- --------------------------------------------------------------------------- +-- Docker Hub API actions +-- --------------------------------------------------------------------------- + +||| All supported Docker Hub REST API actions. +||| Each maps to one or more endpoints under https://hub.docker.com/v2/. +public export +data DockerHubAction + = SearchImages + | GetRepository + | ListTags + | GetTag + | ListNamespaces + | GetManifest + | DeleteTag + | GetRateLimit + | ListOrgs + | CreateRepository + | DeleteRepository + | GetDockerfile + | ListStarred + | StarRepository + | UnstarRepository + | GetUser + +||| Check whether an action requires Authenticated state. +||| Most Docker Hub API calls require a valid JWT. +export +actionRequiresAuth : DockerHubAction -> Bool +actionRequiresAuth SearchImages = False +actionRequiresAuth GetRateLimit = False +actionRequiresAuth GetRepository = True +actionRequiresAuth ListTags = True +actionRequiresAuth GetTag = True +actionRequiresAuth ListNamespaces = True +actionRequiresAuth GetManifest = True +actionRequiresAuth DeleteTag = True +actionRequiresAuth ListOrgs = True +actionRequiresAuth CreateRepository = True +actionRequiresAuth DeleteRepository = True +actionRequiresAuth GetDockerfile = True +actionRequiresAuth ListStarred = True +actionRequiresAuth StarRepository = True +actionRequiresAuth UnstarRepository = True +actionRequiresAuth GetUser = True + +||| Check whether an action is destructive (mutates remote state). +export +actionIsDestructive : DockerHubAction -> Bool +actionIsDestructive DeleteTag = True +actionIsDestructive CreateRepository = True +actionIsDestructive DeleteRepository = True +actionIsDestructive StarRepository = True +actionIsDestructive UnstarRepository = True +actionIsDestructive _ = False + +||| Encode action as C-compatible integer for FFI boundary. +export +actionToInt : DockerHubAction -> Int +actionToInt SearchImages = 0 +actionToInt GetRepository = 1 +actionToInt ListTags = 2 +actionToInt GetTag = 3 +actionToInt ListNamespaces = 4 +actionToInt GetManifest = 5 +actionToInt DeleteTag = 6 +actionToInt GetRateLimit = 7 +actionToInt ListOrgs = 8 +actionToInt CreateRepository = 9 +actionToInt DeleteRepository = 10 +actionToInt GetDockerfile = 11 +actionToInt ListStarred = 12 +actionToInt StarRepository = 13 +actionToInt UnstarRepository = 14 +actionToInt GetUser = 15 + +||| Decode integer back to action. Returns Nothing for invalid values. +export +intToAction : Int -> Maybe DockerHubAction +intToAction 0 = Just SearchImages +intToAction 1 = Just GetRepository +intToAction 2 = Just ListTags +intToAction 3 = Just GetTag +intToAction 4 = Just ListNamespaces +intToAction 5 = Just GetManifest +intToAction 6 = Just DeleteTag +intToAction 7 = Just GetRateLimit +intToAction 8 = Just ListOrgs +intToAction 9 = Just CreateRepository +intToAction 10 = Just DeleteRepository +intToAction 11 = Just GetDockerfile +intToAction 12 = Just ListStarred +intToAction 13 = Just StarRepository +intToAction 14 = Just UnstarRepository +intToAction 15 = Just GetUser +intToAction _ = Nothing + +||| Total number of supported actions. +export +actionCount : Nat +actionCount = 16 + +-- --------------------------------------------------------------------------- +-- Rate limiting +-- --------------------------------------------------------------------------- + +||| Docker Hub authenticated pull rate limit: 200 pulls per 6 hours. +export +pullRateLimitAuth : Nat +pullRateLimitAuth = 200 + +||| Docker Hub anonymous pull rate limit: 100 pulls per 6 hours. +export +pullRateLimitAnon : Nat +pullRateLimitAnon = 100 + +-- --------------------------------------------------------------------------- +-- MCP tool declarations +-- --------------------------------------------------------------------------- + +||| Tools exposed via MCP protocol for this cartridge. +public export +data McpTool + = ToolAuthenticate + | ToolSearchImages + | ToolGetRepository + | ToolListTags + | ToolGetTag + | ToolListNamespaces + | ToolGetManifest + | ToolDeleteTag + | ToolGetRateLimit + | ToolListOrgs + | ToolCreateRepository + | ToolDeleteRepository + | ToolGetDockerfile + | ToolListStarred + | ToolStarRepository + | ToolGetUser + | ToolStatus + +||| Check if a tool requires authenticated state. +export +toolRequiresAuth : McpTool -> Bool +toolRequiresAuth ToolAuthenticate = False +toolRequiresAuth ToolSearchImages = False +toolRequiresAuth ToolGetRateLimit = False +toolRequiresAuth ToolStatus = False +toolRequiresAuth _ = True + +||| Tool count for this cartridge. +export +toolCount : Nat +toolCount = 17 diff --git a/cartridges/domains/container/docker-hub-mcp/abi/README.adoc b/cartridges/domains/container/docker-hub-mcp/abi/README.adoc new file mode 100644 index 0000000..f33d8b7 --- /dev/null +++ b/cartridges/domains/container/docker-hub-mcp/abi/README.adoc @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += docker-hub-mcp / abi β€” Idris2 ABI layer +:orientation: shallow + +== Purpose + +Cartridge-local protocol types for `docker-hub-mcp`. Defines the data, tool, and +transition vocabulary this cartridge speaks. Inherits safety lemmas and the +matrix-cell identifier scheme from the trunk ABI at `src/abi/`. + +== Files + +1 Idris2 module(s), ~230 lines total. Lead module: +`SafeContainer.idr`. + +No `.ipkg` β€” consumed directly by wider BoJ callers; type-checks inside the +trunk `boj.ipkg`. + +== Invariants + +* `%default total` at every module top. +* Zero `believe_me`, `assert_total`, `postulate`, `Admitted`, or + `sorry` (the trunk holds the four declared `believe_me` sites; leaf + cartridges carry none). + +== Test/proof surface + +Thin module(s), no inline tests. Proof surface = type-check success under the +trunk `idris2 --check`. + +== Read-first + +. `SafeContainer.idr` β€” cartridge-local data records and enums. +. Trunk `src/abi/Boj/Catalogue.idr` for how this cell participates in the + 2D matrix. diff --git a/cartridges/domains/container/docker-hub-mcp/abi/docker_hub_mcp.ipkg b/cartridges/domains/container/docker-hub-mcp/abi/docker_hub_mcp.ipkg new file mode 100644 index 0000000..fa40dd6 --- /dev/null +++ b/cartridges/domains/container/docker-hub-mcp/abi/docker_hub_mcp.ipkg @@ -0,0 +1,13 @@ +-- SPDX-License-Identifier: MPL-2.0 +-- Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +package docker_hub_mcp + +version = "0.2.0" +authors = "Jonathan D.A. Jewell" +brief = "Type-safe ABI for Docker Hub MCP cartridge (two-phase JWT auth, REST API)" + +depends = base + +sourcedir = "." + +modules = DockerHubMcp.SafeContainer diff --git a/cartridges/domains/container/docker-hub-mcp/adapter/README.adoc b/cartridges/domains/container/docker-hub-mcp/adapter/README.adoc new file mode 100644 index 0000000..3e3c381 --- /dev/null +++ b/cartridges/domains/container/docker-hub-mcp/adapter/README.adoc @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += docker-hub-mcp / adapter β€” protocol bridge +:orientation: shallow + +== Purpose + +Exposes this cartridge's FFI over the wire protocols declared in +`cartridge.json`. Dispatches tool invocations to the C-ABI exports at +`../ffi/` and formats responses per protocol. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” depends on the sibling `ffi` target. +| `docker_hub_adapter.zig` | Protocol dispatch (228 lines). Tool catalogue matches `cartridge.json`. +| `SIDELINED-*.v.adoc` | zig predecessor(s). Not built β€” zig banned 2026-04-10. +|=== + +== Invariants + +* **Stateless** β€” all state lives behind the FFI, never in the adapter. +* **Response passthrough** β€” whatever the FFI returns goes back to the wire + unmodified (no embellishment, no silent recovery). +* **Cartridge.json is source of truth** for the tool catalogue; drift between + adapter and manifest is a CI failure. + +== Test/proof surface + +No inline tests at the adapter layer β€” protocol routing is exercised by the +trunk seam tests at `ffi/zig/src/seams.zig`. + +== Read-first + +. `docker_hub_adapter.zig` β€” dispatch function and per-protocol routers. +. `../cartridge.json` β€” the tool manifest the dispatcher mirrors. diff --git a/cartridges/domains/container/docker-hub-mcp/adapter/build.zig b/cartridges/domains/container/docker-hub-mcp/adapter/build.zig new file mode 100644 index 0000000..1838ff3 --- /dev/null +++ b/cartridges/domains/container/docker-hub-mcp/adapter/build.zig @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// docker-hub-mcp/adapter/build.zig + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const ffi_mod = b.createModule(.{ + .root_source_file = b.path("../ffi/docker_hub_mcp_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + const adapter = b.addExecutable(.{ + .name = "docker_hub_adapter", + .root_source_file = b.path("docker_hub_adapter.zig"), + .target = target, + .optimize = optimize, + }); + adapter.root_module.addImport("docker_hub_mcp_ffi", ffi_mod); + b.installArtifact(adapter); + + const run_artifact = b.addRunArtifact(adapter); + const run_step = b.step("run", "Run the docker-hub-mcp adapter"); + run_step.dependOn(&run_artifact.step); + + const tests = b.addTest(.{ + .root_source_file = b.path("docker_hub_adapter.zig"), + .target = target, + .optimize = optimize, + }); + tests.root_module.addImport("docker_hub_mcp_ffi", ffi_mod); + const run_tests = b.addRunArtifact(tests); + const test_step = b.step("test", "Run docker-hub-mcp adapter tests"); + test_step.dependOn(&run_tests.step); +} diff --git a/cartridges/domains/container/docker-hub-mcp/adapter/docker_hub_adapter.zig b/cartridges/domains/container/docker-hub-mcp/adapter/docker_hub_adapter.zig new file mode 100644 index 0000000..5d010dc --- /dev/null +++ b/cartridges/domains/container/docker-hub-mcp/adapter/docker_hub_adapter.zig @@ -0,0 +1,228 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// docker-hub-mcp/adapter/docker_hub_adapter.zig -- Unified three-protocol adapter. +// +// Replaces the banned docker_hub_adapter.v (zig, removed 2026-04-12). +// +// Bridges the Zig FFI (docker_hub_mcp_ffi.zig) to three network protocols: +// REST :9046 POST /tools/<tool> +// gRPC-compat :9047 /DockerHubMcpService/<Method> +// GraphQL :9048 POST /graphql { query: "..." } +// +// Docker Hub registry: repositories, tags, manifests, rate limits +// Tools: +// dockerhub_search_images +// dockerhub_get_repository +// dockerhub_list_tags +// dockerhub_get_tag +// dockerhub_list_namespaces +// dockerhub_get_manifest +// dockerhub_delete_tag +// dockerhub_get_rate_limit +// dockerhub_list_orgs +// dockerhub_create_repository +// dockerhub_delete_repository +// dockerhub_get_dockerfile +// dockerhub_list_starred +// dockerhub_star_repository +// dockerhub_unstar_repository +// dockerhub_get_user + +const std = @import("std"); +const ffi = @import("docker_hub_mcp_ffi"); + +const REST_PORT: u16 = 9046; +const GRPC_PORT: u16 = 9047; +const GQL_PORT: u16 = 9048; + +const MAX_CONN_BUF: usize = 16 * 1024; + +// ============================================================================ +// JSON response builders +// ============================================================================ + +fn okJson(buf: []u8, msg: []const u8) []u8 { + return std.fmt.bufPrint(buf, + \{{"success":true,"message":"{}"}} + , .{msg}) catch buf[0..0]; +} + +fn errJson(buf: []u8, msg: []const u8) []u8 { + return std.fmt.bufPrint(buf, + \{{"success":false,"error":"{}"}} + , .{msg}) catch buf[0..0]; +} + +fn statusJson(buf: []u8) []u8 { + return std.fmt.bufPrint(buf, + \{{"success":true,"state":"ready","service":"docker-hub-mcp"}} + , .{}) catch buf[0..0]; +} + +// ============================================================================ +// Tool dispatcher +// ============================================================================ + +const Response = struct { status: u16, body: []u8 }; + +fn dispatch(tool: []const u8, body: []const u8, resp: []u8) Response { + _ = body; + if (std.mem.eql(u8, tool, "dockerhub_search_images")) return .{ .status = 200, .body = okJson(resp, "dockerhub_search_images forwarded to backend") }; + if (std.mem.eql(u8, tool, "dockerhub_get_repository")) return .{ .status = 200, .body = okJson(resp, "dockerhub_get_repository forwarded to backend") }; + if (std.mem.eql(u8, tool, "dockerhub_list_tags")) return .{ .status = 200, .body = okJson(resp, "dockerhub_list_tags forwarded to backend") }; + if (std.mem.eql(u8, tool, "dockerhub_get_tag")) return .{ .status = 200, .body = okJson(resp, "dockerhub_get_tag forwarded to backend") }; + if (std.mem.eql(u8, tool, "dockerhub_list_namespaces")) return .{ .status = 200, .body = okJson(resp, "dockerhub_list_namespaces forwarded to backend") }; + if (std.mem.eql(u8, tool, "dockerhub_get_manifest")) return .{ .status = 200, .body = okJson(resp, "dockerhub_get_manifest forwarded to backend") }; + if (std.mem.eql(u8, tool, "dockerhub_delete_tag")) return .{ .status = 200, .body = okJson(resp, "dockerhub_delete_tag forwarded to backend") }; + if (std.mem.eql(u8, tool, "dockerhub_get_rate_limit")) return .{ .status = 200, .body = okJson(resp, "dockerhub_get_rate_limit forwarded to backend") }; + if (std.mem.eql(u8, tool, "dockerhub_list_orgs")) return .{ .status = 200, .body = okJson(resp, "dockerhub_list_orgs forwarded to backend") }; + if (std.mem.eql(u8, tool, "dockerhub_create_repository")) return .{ .status = 200, .body = okJson(resp, "dockerhub_create_repository forwarded to backend") }; + if (std.mem.eql(u8, tool, "dockerhub_delete_repository")) return .{ .status = 200, .body = okJson(resp, "dockerhub_delete_repository forwarded to backend") }; + if (std.mem.eql(u8, tool, "dockerhub_get_dockerfile")) return .{ .status = 200, .body = okJson(resp, "dockerhub_get_dockerfile forwarded to backend") }; + if (std.mem.eql(u8, tool, "dockerhub_list_starred")) return .{ .status = 200, .body = okJson(resp, "dockerhub_list_starred forwarded to backend") }; + if (std.mem.eql(u8, tool, "dockerhub_star_repository")) return .{ .status = 200, .body = okJson(resp, "dockerhub_star_repository forwarded to backend") }; + if (std.mem.eql(u8, tool, "dockerhub_unstar_repository")) return .{ .status = 200, .body = okJson(resp, "dockerhub_unstar_repository forwarded to backend") }; + if (std.mem.eql(u8, tool, "dockerhub_get_user")) return .{ .status = 200, .body = okJson(resp, "dockerhub_get_user forwarded to backend") }; + if (std.mem.eql(u8, tool, "status") or std.mem.eql(u8, tool, "health")) + return .{ .status = 200, .body = statusJson(resp) }; + return .{ .status = 404, .body = errJson(resp, "Unknown tool") }; +} + +// ============================================================================ +// REST handler +// ============================================================================ + +fn dispatchRest(path: []const u8, body: []const u8, resp: []u8) Response { + if (std.mem.startsWith(u8, path, "/tools/")) { + return dispatch(path["/tools/".len..], body, resp); + } + if (std.mem.eql(u8, path, "/status") or std.mem.eql(u8, path, "/health")) { + return .{ .status = 200, .body = statusJson(resp) }; + } + return .{ .status = 404, .body = errJson(resp, "Not found") }; +} + +// ============================================================================ +// gRPC-compat handler +// ============================================================================ + +fn dispatchGrpc(path: []const u8, body: []const u8, resp: []u8) Response { + const prefix = "/DockerHubMcpService/"; + if (!std.mem.startsWith(u8, path, prefix)) + return .{ .status = 404, .body = errJson(resp, "Not a recognized gRPC path") }; + const method = path[prefix.len..]; + const tool = blk: { + if (std.mem.eql(u8, method, "DockerhubSearchImages")) break :blk "dockerhub_search_images"; + if (std.mem.eql(u8, method, "DockerhubGetRepository")) break :blk "dockerhub_get_repository"; + if (std.mem.eql(u8, method, "DockerhubListTags")) break :blk "dockerhub_list_tags"; + if (std.mem.eql(u8, method, "DockerhubGetTag")) break :blk "dockerhub_get_tag"; + if (std.mem.eql(u8, method, "DockerhubListNamespaces")) break :blk "dockerhub_list_namespaces"; + if (std.mem.eql(u8, method, "DockerhubGetManifest")) break :blk "dockerhub_get_manifest"; + if (std.mem.eql(u8, method, "DockerhubDeleteTag")) break :blk "dockerhub_delete_tag"; + if (std.mem.eql(u8, method, "DockerhubGetRateLimit")) break :blk "dockerhub_get_rate_limit"; + if (std.mem.eql(u8, method, "DockerhubListOrgs")) break :blk "dockerhub_list_orgs"; + if (std.mem.eql(u8, method, "DockerhubCreateRepository")) break :blk "dockerhub_create_repository"; + if (std.mem.eql(u8, method, "DockerhubDeleteRepository")) break :blk "dockerhub_delete_repository"; + if (std.mem.eql(u8, method, "DockerhubGetDockerfile")) break :blk "dockerhub_get_dockerfile"; + if (std.mem.eql(u8, method, "DockerhubListStarred")) break :blk "dockerhub_list_starred"; + if (std.mem.eql(u8, method, "DockerhubStarRepository")) break :blk "dockerhub_star_repository"; + if (std.mem.eql(u8, method, "DockerhubUnstarRepository")) break :blk "dockerhub_unstar_repository"; + if (std.mem.eql(u8, method, "DockerhubGetUser")) break :blk "dockerhub_get_user"; + return .{ .status = 404, .body = errJson(resp, "Unknown gRPC method") }; + }; + return dispatch(tool, body, resp); +} + +// ============================================================================ +// GraphQL handler +// ============================================================================ + +fn dispatchGraphql(body: []const u8, resp: []u8) Response { + if (std.mem.indexOf(u8, body, "__schema") != null) + return .{ .status = 200, .body = okJson(resp, "schema introspection not yet supported") }; + if (std.mem.indexOf(u8, body, "search_images") != null) return dispatch("dockerhub_search_images", body, resp); + if (std.mem.indexOf(u8, body, "get_repository") != null) return dispatch("dockerhub_get_repository", body, resp); + if (std.mem.indexOf(u8, body, "list_tags") != null) return dispatch("dockerhub_list_tags", body, resp); + if (std.mem.indexOf(u8, body, "get_tag") != null) return dispatch("dockerhub_get_tag", body, resp); + if (std.mem.indexOf(u8, body, "list_namespaces") != null) return dispatch("dockerhub_list_namespaces", body, resp); + if (std.mem.indexOf(u8, body, "get_manifest") != null) return dispatch("dockerhub_get_manifest", body, resp); + if (std.mem.indexOf(u8, body, "delete_tag") != null) return dispatch("dockerhub_delete_tag", body, resp); + if (std.mem.indexOf(u8, body, "get_rate_limit") != null) return dispatch("dockerhub_get_rate_limit", body, resp); + if (std.mem.indexOf(u8, body, "list_orgs") != null) return dispatch("dockerhub_list_orgs", body, resp); + if (std.mem.indexOf(u8, body, "create_repository") != null) return dispatch("dockerhub_create_repository", body, resp); + if (std.mem.indexOf(u8, body, "delete_repository") != null) return dispatch("dockerhub_delete_repository", body, resp); + if (std.mem.indexOf(u8, body, "get_dockerfile") != null) return dispatch("dockerhub_get_dockerfile", body, resp); + if (std.mem.indexOf(u8, body, "list_starred") != null) return dispatch("dockerhub_list_starred", body, resp); + if (std.mem.indexOf(u8, body, "star_repository") != null) return dispatch("dockerhub_star_repository", body, resp); + if (std.mem.indexOf(u8, body, "unstar_repository") != null) return dispatch("dockerhub_unstar_repository", body, resp); + if (std.mem.indexOf(u8, body, "get_user") != null) return dispatch("dockerhub_get_user", body, resp); + return .{ .status = 200, .body = errJson(resp, "Unrecognised GraphQL operation") }; +} + +// ============================================================================ +// HTTP/1.1 connection handler +// ============================================================================ + +const Protocol = enum { rest, grpc, graphql }; + +fn handleConnection(conn: std.net.Server.Connection, proto: Protocol) void { + defer conn.stream.close(); + var in_buf: [MAX_CONN_BUF]u8 = undefined; + const n = conn.stream.read(&in_buf) catch return; + const req = in_buf[0..n]; + + var path: []const u8 = "/"; + var body: []const u8 = ""; + if (n > 4) { + const line_end = std.mem.indexOf(u8, req, "\r\n") orelse req.len; + const first_line = req[0..line_end]; + const sp1 = std.mem.indexOfScalar(u8, first_line, ' ') orelse 0; + const rest_of = first_line[sp1 + 1 ..]; + const sp2 = std.mem.indexOfScalar(u8, rest_of, ' ') orelse rest_of.len; + path = rest_of[0..sp2]; + const body_sep = std.mem.indexOf(u8, req, "\r\n\r\n") orelse n; + body = req[@min(body_sep + 4, n)..]; + } + + var resp_buf: [MAX_CONN_BUF]u8 = undefined; + const result = switch (proto) { + .rest => dispatchRest(path, body, &resp_buf), + .grpc => dispatchGrpc(path, body, &resp_buf), + .graphql => dispatchGraphql(body, &resp_buf), + }; + + const content_type = switch (proto) { + .rest => "application/json", + .grpc => "application/grpc+json", + .graphql => "application/json", + }; + + var hdr_buf: [256]u8 = undefined; + const hdr = std.fmt.bufPrint(&hdr_buf, + "HTTP/1.1 {d} OK\r\nContent-Type: {s}\r\nContent-Length: {d}\r\nConnection: close\r\n\r\n", + .{ result.status, content_type, result.body.len }, + ) catch return; + _ = conn.stream.writeAll(hdr) catch return; + _ = conn.stream.writeAll(result.body) catch return; +} + +fn listenLoop(port: u16, proto: Protocol) void { + const addr = std.net.Address.initIp4(.{ 127, 0, 0, 1 }, port) catch return; + var server = addr.listen(.{ .reuse_address = true }) catch return; + defer server.deinit(); + while (true) { + const conn = server.accept() catch continue; + handleConnection(conn, proto); + } +} + +pub fn main() !void { + ffi.docker_hub_init(); + const rest_thread = try std.Thread.spawn(.{}, listenLoop, .{ REST_PORT, Protocol.rest }); + const grpc_thread = try std.Thread.spawn(.{}, listenLoop, .{ GRPC_PORT, Protocol.grpc }); + const gql_thread = try std.Thread.spawn(.{}, listenLoop, .{ GQL_PORT, Protocol.graphql }); + rest_thread.join(); + grpc_thread.join(); + gql_thread.join(); +} diff --git a/cartridges/domains/container/docker-hub-mcp/benchmarks/quick-bench.sh b/cartridges/domains/container/docker-hub-mcp/benchmarks/quick-bench.sh new file mode 100755 index 0000000..480e75b --- /dev/null +++ b/cartridges/domains/container/docker-hub-mcp/benchmarks/quick-bench.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Benchmarks for docker-hub-mcp cartridge. +set -euo pipefail + +echo "=== docker-hub-mcp benchmarks ===" + +cd "$(dirname "${BASH_SOURCE[0]}")/../ffi" + +# Build with ReleaseFast for benchmarking +zig build -Doptimize=ReleaseFast 2>&1 + +echo "Session open/close cycle (1000 iterations):" +time for i in $(seq 1 1000); do + # This would call the FFI benchmark binary + true +done + +echo "" +echo "Benchmark placeholder β€” implement real benchmarks in Zig test blocks" +echo "or via the zig adapter HTTP benchmark tool." diff --git a/cartridges/domains/container/docker-hub-mcp/cartridge.json b/cartridges/domains/container/docker-hub-mcp/cartridge.json new file mode 100644 index 0000000..26d3a7c --- /dev/null +++ b/cartridges/domains/container/docker-hub-mcp/cartridge.json @@ -0,0 +1,375 @@ +{ + "$schema": "https://boj.dev/schemas/cartridge/v1.json", + "spdx": "MPL-2.0", + "copyright": "Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath)", + "name": "docker-hub-mcp", + "version": "0.2.0", + "description": "Docker Hub container registry cartridge -- image search, repository management, tag listing, manifest inspection, namespace browsing, and pull rate limit tracking with JWT auth", + "domain": "Container", + "tier": "Ayo", + "protocols": [ + "MCP", + "REST" + ], + "auth": { + "method": "bearer", + "token_type": "jwt", + "login_endpoint": "POST /v2/users/login", + "env_var": "DOCKER_HUB_TOKEN", + "credential_source": "vault-mcp" + }, + "api": { + "base_url": "https://hub.docker.com/v2", + "rate_limit_pull_auth": 200, + "rate_limit_pull_anon": 100, + "rate_limit_window": "6h", + "content_type": "application/json" + }, + "tools": [ + { + "name": "dockerhub_search_images", + "description": "Search Docker Hub for container images by query string", + "inputSchema": { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "Search query (e.g. 'nginx', 'python')" + }, + "page": { + "type": "number", + "description": "Page number (default 1)" + }, + "page_size": { + "type": "number", + "description": "Results per page (default 25, max 100)" + }, + "is_official": { + "type": "boolean", + "description": "Filter for official images only" + } + }, + "required": [ + "query" + ] + } + }, + { + "name": "dockerhub_get_repository", + "description": "Get detailed information about a Docker Hub repository (namespace/name)", + "inputSchema": { + "type": "object", + "properties": { + "namespace": { + "type": "string", + "description": "Repository namespace (e.g. 'library', 'myorg')" + }, + "name": { + "type": "string", + "description": "Repository name (e.g. 'nginx', 'alpine')" + } + }, + "required": [ + "namespace", + "name" + ] + } + }, + { + "name": "dockerhub_list_tags", + "description": "List all tags for a Docker Hub repository with pagination", + "inputSchema": { + "type": "object", + "properties": { + "namespace": { + "type": "string", + "description": "Repository namespace" + }, + "name": { + "type": "string", + "description": "Repository name" + }, + "page": { + "type": "number", + "description": "Page number (default 1)" + }, + "page_size": { + "type": "number", + "description": "Results per page (default 25, max 100)" + }, + "ordering": { + "type": "string", + "description": "Sort order: last_updated, -last_updated, name, -name" + } + }, + "required": [ + "namespace", + "name" + ] + } + }, + { + "name": "dockerhub_get_tag", + "description": "Get detailed information about a specific tag including digest, size, and architecture", + "inputSchema": { + "type": "object", + "properties": { + "namespace": { + "type": "string", + "description": "Repository namespace" + }, + "name": { + "type": "string", + "description": "Repository name" + }, + "tag": { + "type": "string", + "description": "Tag name (e.g. 'latest', '3.19')" + } + }, + "required": [ + "namespace", + "name", + "tag" + ] + } + }, + { + "name": "dockerhub_list_namespaces", + "description": "List all namespaces accessible to the authenticated user", + "inputSchema": { + "type": "object", + "properties": {} + } + }, + { + "name": "dockerhub_get_manifest", + "description": "Get the OCI manifest for a specific image tag (digest, layers, platform)", + "inputSchema": { + "type": "object", + "properties": { + "namespace": { + "type": "string", + "description": "Repository namespace" + }, + "name": { + "type": "string", + "description": "Repository name" + }, + "reference": { + "type": "string", + "description": "Tag name or digest (e.g. 'latest', 'sha256:abc...')" + } + }, + "required": [ + "namespace", + "name", + "reference" + ] + } + }, + { + "name": "dockerhub_delete_tag", + "description": "Delete a specific tag from a Docker Hub repository (irreversible)", + "inputSchema": { + "type": "object", + "properties": { + "namespace": { + "type": "string", + "description": "Repository namespace" + }, + "name": { + "type": "string", + "description": "Repository name" + }, + "tag": { + "type": "string", + "description": "Tag to delete" + } + }, + "required": [ + "namespace", + "name", + "tag" + ] + } + }, + { + "name": "dockerhub_get_rate_limit", + "description": "Check current pull rate limit status (remaining pulls and window reset time)", + "inputSchema": { + "type": "object", + "properties": {} + } + }, + { + "name": "dockerhub_list_orgs", + "description": "List all organizations the authenticated user belongs to", + "inputSchema": { + "type": "object", + "properties": {} + } + }, + { + "name": "dockerhub_create_repository", + "description": "Create a new Docker Hub repository in a namespace", + "inputSchema": { + "type": "object", + "properties": { + "namespace": { + "type": "string", + "description": "Namespace to create the repository in" + }, + "name": { + "type": "string", + "description": "Repository name" + }, + "description": { + "type": "string", + "description": "Short description" + }, + "full_description": { + "type": "string", + "description": "Full description (Markdown)" + }, + "is_private": { + "type": "boolean", + "description": "Whether the repository is private (default false)" + } + }, + "required": [ + "namespace", + "name" + ] + } + }, + { + "name": "dockerhub_delete_repository", + "description": "Delete an entire Docker Hub repository (irreversible, removes all tags)", + "inputSchema": { + "type": "object", + "properties": { + "namespace": { + "type": "string", + "description": "Repository namespace" + }, + "name": { + "type": "string", + "description": "Repository name" + } + }, + "required": [ + "namespace", + "name" + ] + } + }, + { + "name": "dockerhub_get_dockerfile", + "description": "Get the Dockerfile for a Docker Hub repository (if available via automated builds)", + "inputSchema": { + "type": "object", + "properties": { + "namespace": { + "type": "string", + "description": "Repository namespace" + }, + "name": { + "type": "string", + "description": "Repository name" + } + }, + "required": [ + "namespace", + "name" + ] + } + }, + { + "name": "dockerhub_list_starred", + "description": "List repositories starred by the authenticated user", + "inputSchema": { + "type": "object", + "properties": { + "page": { + "type": "number", + "description": "Page number" + }, + "page_size": { + "type": "number", + "description": "Results per page" + } + } + } + }, + { + "name": "dockerhub_star_repository", + "description": "Star a Docker Hub repository", + "inputSchema": { + "type": "object", + "properties": { + "namespace": { + "type": "string", + "description": "Repository namespace" + }, + "name": { + "type": "string", + "description": "Repository name" + } + }, + "required": [ + "namespace", + "name" + ] + } + }, + { + "name": "dockerhub_unstar_repository", + "description": "Remove star from a Docker Hub repository", + "inputSchema": { + "type": "object", + "properties": { + "namespace": { + "type": "string", + "description": "Repository namespace" + }, + "name": { + "type": "string", + "description": "Repository name" + } + }, + "required": [ + "namespace", + "name" + ] + } + }, + { + "name": "dockerhub_get_user", + "description": "Get profile information for a Docker Hub user", + "inputSchema": { + "type": "object", + "properties": { + "username": { + "type": "string", + "description": "Docker Hub username" + } + }, + "required": [ + "username" + ] + } + } + ], + "ffi": { + "so_path": "ffi/zig-out/lib/libdocker_hub_mcp.so", + "abi_version": "ADR-0006", + "symbols": [ + "boj_cartridge_init", + "boj_cartridge_deinit", + "boj_cartridge_name", + "boj_cartridge_version", + "boj_cartridge_invoke" + ] + } +} diff --git a/cartridges/domains/container/docker-hub-mcp/ffi/README.adoc b/cartridges/domains/container/docker-hub-mcp/ffi/README.adoc new file mode 100644 index 0000000..7b227eb --- /dev/null +++ b/cartridges/domains/container/docker-hub-mcp/ffi/README.adoc @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += docker-hub-mcp / ffi β€” Zig FFI layer +:orientation: shallow + +== Purpose + +C-ABI exports bridging this cartridge's Idris2 ABI to the runtime. Consumed by +the adapter for protocol dispatch and by the trunk loader for mount. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” produces `zig-out/lib/libdocker-hub.so`. +| `docker_hub_mcp_ffi.zig` | C-ABI exports (11 exports, 6 inline tests, 351 lines). +|=== + +== Invariants + +* **Null-pointer rejection** on `[*c]const u8` arguments before any + dereference. +* **Negative return values** carry documented error meanings; zero is + success (matching the trunk error-code convention). +* **No allocator leaks** β€” every allocation path pairs with `defer free`. + +== Test/proof surface + +6 inline `test "..."` block(s) in `docker_hub_mcp_ffi.zig`. Run with +`cd ffi && zig build test`. + +== Read-first + +. `docker_hub_mcp_ffi.zig` β€” C-ABI signatures and guards. +. Trunk `ffi/zig/src/catalogue.zig` for how this `.so` is loaded. diff --git a/cartridges/domains/container/docker-hub-mcp/ffi/build.zig b/cartridges/domains/container/docker-hub-mcp/ffi/build.zig new file mode 100644 index 0000000..90103bd --- /dev/null +++ b/cartridges/domains/container/docker-hub-mcp/ffi/build.zig @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// build.zig β€” Build configuration for docker-hub-mcp FFI shared library. + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + // Module + // Shared ADR-0006 invoke-shim module (relative path up to boj-server trunk). + + const shim_mod = b.addModule("cartridge_shim", .{ + + .root_source_file = b.path("../../../ffi/zig/src/cartridge_shim.zig"), + + .target = target, + + .optimize = optimize, + + }); + + const ffi_mod = b.addModule("docker_hub_mcp", .{ + .root_source_file = b.path("docker_hub_mcp_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + ffi_mod.addImport("cartridge_shim", shim_mod); + + // Shared library + const lib = b.addLibrary(.{ + .name = "docker_hub_mcp", + .root_module = ffi_mod, + .linkage = .dynamic, + }); + lib.linkLibC(); + b.installArtifact(lib); + + // Tests + const tests = b.addTest(.{ + .root_module = ffi_mod, + }); + tests.linkLibC(); + + const run_tests = b.addRunArtifact(tests); + const test_step = b.step("test", "Run Docker Hub MCP FFI tests"); + test_step.dependOn(&run_tests.step); +} diff --git a/cartridges/domains/container/docker-hub-mcp/ffi/cartridge_shim.zig b/cartridges/domains/container/docker-hub-mcp/ffi/cartridge_shim.zig new file mode 100644 index 0000000..0e399db --- /dev/null +++ b/cartridges/domains/container/docker-hub-mcp/ffi/cartridge_shim.zig @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// cartridge_shim.zig β€” Shared helpers for the ADR-0006 five-symbol +// cartridge ABI (`boj_cartridge_init / deinit / name / version / invoke`). +// +// The shim centralises the seven-code return convention, NUL-argument +// guards, tool-name comparison, and the buffer-too-small path so each +// cartridge's `boj_cartridge_invoke` can stay short β€” typically a tool +// table plus `shim.writeResult(...)`. +// +// Cartridges import this file by relative path (no build-graph change +// needed). Example: +// +// const shim = @import("../../../ffi/zig/src/cartridge_shim.zig"); +// +// export fn boj_cartridge_invoke( +// tool_name: [*c]const u8, +// json_args: [*c]const u8, +// out_buf: [*c]u8, +// in_out_len: [*c]usize, +// ) callconv(.c) i32 { +// _ = json_args; +// if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; +// const body = if (shim.toolIs(tool_name, "foo")) "{\"result\":{}}" +// else return shim.RC_UNKNOWN_TOOL; +// return shim.writeResult(out_buf, in_out_len, body); +// } + +const std = @import("std"); + +// ── Return codes (ADR-0006 Β§Return codes) ──────────────────────────── +// +// Frozen by ADR-0006. New failure modes compose these via the error +// JSON body β€” the integer surface does not grow without a follow-up ADR. + +pub const RC_SUCCESS: i32 = 0; +pub const RC_UNKNOWN_TOOL: i32 = -1; +pub const RC_BAD_ARGS: i32 = -2; +pub const RC_BUFFER_TOO_SMALL: i32 = -3; +pub const RC_RUNTIME_ERROR: i32 = -4; +pub const RC_PANIC: i32 = -5; +pub const RC_AUTH_DENIED: i32 = -6; + +// ── Invoke-path helpers ────────────────────────────────────────────── + +/// True if any of the three mandatory `boj_cartridge_invoke` output-path +/// pointers is null. Use at the top of every invoke to short-circuit to +/// `RC_BAD_ARGS`. +pub fn invokeArgsNull( + tool_name: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) bool { + return tool_name == null or out_buf == null or in_out_len == null; +} + +/// Compare a C-NUL-terminated tool-name pointer against a Zig string +/// literal. Caller must have already verified `tool_name` is non-null +/// (usually via `invokeArgsNull`). +pub fn toolIs(tool_name: [*c]const u8, expected: []const u8) bool { + const s = std.mem.span(@as([*:0]const u8, @ptrCast(tool_name))); + return std.mem.eql(u8, s, expected); +} + +/// Copy `body` into `out_buf[0..*in_out_len]` (as a capacity) and update +/// `*in_out_len` to the number of bytes written. Returns `RC_SUCCESS`. +/// +/// If `body.len` exceeds the current capacity stored in `*in_out_len`, +/// sets `*in_out_len` to the required size and returns +/// `RC_BUFFER_TOO_SMALL` β€” the caller is then expected to re-allocate +/// and retry, per ADR-0006 Β§Memory ownership. +/// +/// Caller must have already verified that `out_buf` and `in_out_len` +/// are non-null. +pub fn writeResult( + out_buf: [*c]u8, + in_out_len: [*c]usize, + body: []const u8, +) i32 { + const cap = in_out_len.*; + if (body.len > cap) { + in_out_len.* = body.len; + return RC_BUFFER_TOO_SMALL; + } + @memcpy(out_buf[0..body.len], body); + in_out_len.* = body.len; + return RC_SUCCESS; +} + +// ── Tests ──────────────────────────────────────────────────────────── + +test "writeResult: body fits, writes and sets length" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); + try std.testing.expectEqualStrings("hello", buf[0..len]); +} + +test "writeResult: too small returns -3 and sets required length" { + var buf: [2]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_BUFFER_TOO_SMALL, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: exact-fit succeeds" { + var buf: [5]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: empty body" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, ""); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 0), len); +} + +test "toolIs: matches and rejects" { + const name: [*:0]const u8 = "foo"; + try std.testing.expect(toolIs(@ptrCast(name), "foo")); + try std.testing.expect(!toolIs(@ptrCast(name), "bar")); + try std.testing.expect(!toolIs(@ptrCast(name), "foobar")); + try std.testing.expect(!toolIs(@ptrCast(name), "fo")); +} + +test "invokeArgsNull: detects each null slot" { + var buf: [4]u8 = undefined; + var len: usize = 4; + const name: [*:0]const u8 = "x"; + try std.testing.expect(!invokeArgsNull(@ptrCast(name), &buf, &len)); + try std.testing.expect(invokeArgsNull(null, &buf, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), null, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), &buf, null)); +} diff --git a/cartridges/domains/container/docker-hub-mcp/ffi/docker_hub_mcp_ffi.zig b/cartridges/domains/container/docker-hub-mcp/ffi/docker_hub_mcp_ffi.zig new file mode 100644 index 0000000..9e944fc --- /dev/null +++ b/cartridges/domains/container/docker-hub-mcp/ffi/docker_hub_mcp_ffi.zig @@ -0,0 +1,478 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// docker_hub_mcp_ffi.zig β€” C-ABI FFI for Docker Hub MCP cartridge. +// +// Implements the auth state machine defined in the Idris2 ABI layer. +// Two-phase authentication: POST /v2/users/login -> JWT bearer token. +// Pull rate limit tracking: 100 (anonymous) / 200 (authenticated) per 6 hours. +// Thread-safe via std.Thread.Mutex. No heap allocations for results. + +const std = @import("std"); + +// --------------------------------------------------------------------------- +// Auth state machine (matches Idris2 ABI: Unauthenticated/Authenticated/RateLimited/Error) +// --------------------------------------------------------------------------- + +pub const AuthState = enum(c_int) { + unauthenticated = 0, + authenticated = 1, + rate_limited = 2, + err = 3, +}; + +/// Validate whether a transition between two auth states is permitted. +fn isValidTransition(from: AuthState, to: AuthState) bool { + return switch (from) { + .unauthenticated => to == .authenticated, + .authenticated => to == .rate_limited or to == .err or to == .unauthenticated, + .rate_limited => to == .authenticated or to == .err, + .err => to == .authenticated or to == .unauthenticated, + }; +} + +// --------------------------------------------------------------------------- +// Docker Hub action codes (matches Idris2 DockerHubAction) +// --------------------------------------------------------------------------- + +pub const DockerHubAction = enum(c_int) { + search_images = 0, + get_repository = 1, + list_tags = 2, + get_tag = 3, + list_namespaces = 4, + get_manifest = 5, + delete_tag = 6, + get_rate_limit = 7, + list_orgs = 8, + create_repository = 9, + delete_repository = 10, + get_dockerfile = 11, + list_starred = 12, + star_repository = 13, + unstar_repository = 14, + get_user = 15, +}; + +/// Returns true if the action mutates remote state. +fn isDestructiveAction(action: DockerHubAction) bool { + return switch (action) { + .delete_tag, .create_repository, .delete_repository, .star_repository, .unstar_repository => true, + .search_images, .get_repository, .list_tags, .get_tag, .list_namespaces, .get_manifest, .get_rate_limit, .list_orgs, .get_dockerfile, .list_starred, .get_user => false, + }; +} + +/// Returns true if the action can be performed without authentication. +fn actionAllowsAnonymous(action: DockerHubAction) bool { + return switch (action) { + .search_images, .get_rate_limit => true, + else => false, + }; +} + +// --------------------------------------------------------------------------- +// Session slots (thread-safe, fixed-size pool) +// --------------------------------------------------------------------------- + +const MAX_SESSIONS: usize = 16; +const JWT_BUF_SIZE: usize = 2048; + +/// Pull rate limit: 200 pulls/6h for authenticated users. +const PULL_RATE_LIMIT_AUTH: u32 = 200; + +const SessionSlot = struct { + active: bool = false, + state: AuthState = .unauthenticated, + jwt_buf: [JWT_BUF_SIZE]u8 = .{0} ** JWT_BUF_SIZE, + jwt_len: usize = 0, + pulls_remaining: u32 = PULL_RATE_LIMIT_AUTH, + last_action: c_int = -1, +}; + +var sessions: [MAX_SESSIONS]SessionSlot = .{SessionSlot{}} ** MAX_SESSIONS; +var mutex: std.Thread.Mutex = .{}; + +// --------------------------------------------------------------------------- +// C-ABI exports +// --------------------------------------------------------------------------- + +/// Check if a state transition is valid. Returns 1 (valid) or 0 (invalid). +pub export fn docker_hub_mcp_can_transition(from: c_int, to: c_int) c_int { + const f = std.meta.intToEnum(AuthState, from) catch return 0; + const t = std.meta.intToEnum(AuthState, to) catch return 0; + return if (isValidTransition(f, t)) 1 else 0; +} + +/// Authenticate a session with a JWT token (obtained from POST /v2/users/login). +/// Returns slot index (>= 0) or error (< 0). +/// Error codes: -1 = no free slots, -2 = null/empty JWT. +pub export fn docker_hub_mcp_authenticate(jwt_ptr: ?[*]const u8, jwt_len: c_int) c_int { + const ptr = jwt_ptr orelse return -2; + const len: usize = std.math.cast(usize, jwt_len) orelse return -2; + if (len == 0 or len > JWT_BUF_SIZE) return -2; + + mutex.lock(); + defer mutex.unlock(); + + for (&sessions, 0..) |*slot, idx| { + if (!slot.active) { + slot.active = true; + slot.state = .authenticated; + @memcpy(slot.jwt_buf[0..len], ptr[0..len]); + slot.jwt_len = len; + slot.pulls_remaining = PULL_RATE_LIMIT_AUTH; + slot.last_action = -1; + return @intCast(idx); + } + } + return -1; +} + +/// Close/logout a session. Returns 0 on success, -1 if invalid slot, -2 if bad transition. +pub export fn docker_hub_mcp_session_close(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + if (!isValidTransition(slot.state, .unauthenticated)) return -2; + + sessions[idx] = SessionSlot{}; + return 0; +} + +/// Get the current auth state of a session. Returns state int or -1 if invalid slot. +pub export fn docker_hub_mcp_session_state(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + return @intFromEnum(slot.state); +} + +/// Execute an action on a session. Returns 0 on success. +/// Error codes: -1 = invalid slot, -2 = not authenticated, -3 = rate limited, -4 = invalid action. +pub export fn docker_hub_mcp_execute_action(slot_idx: c_int, action_code: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + + const action = std.meta.intToEnum(DockerHubAction, action_code) catch return -4; + + // Check auth requirement + if (!actionAllowsAnonymous(action) and slot.state != .authenticated) return -2; + + // Check rate limit for pull-consuming actions + if (slot.state == .rate_limited) return -3; + + _ = isDestructiveAction(action); + + sessions[idx].last_action = action_code; + return 0; +} + +/// Get remaining pull rate limit for a session. Returns count or -1 if invalid. +pub export fn docker_hub_mcp_pull_rate_remaining(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + return @intCast(slot.pulls_remaining); +} + +/// Decrement pull rate counter (called after each image pull). Returns remaining or -3 if exhausted. +pub export fn docker_hub_mcp_consume_pull(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + + if (slot.pulls_remaining == 0) { + sessions[idx].state = .rate_limited; + return -3; + } + + sessions[idx].pulls_remaining -= 1; + return @intCast(sessions[idx].pulls_remaining); +} + +/// Reset pull rate limit (called when the 6-hour window resets). Returns 0 on success. +pub export fn docker_hub_mcp_pull_rate_reset(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + + sessions[idx].pulls_remaining = PULL_RATE_LIMIT_AUTH; + if (slot.state == .rate_limited) { + sessions[idx].state = .authenticated; + } + return 0; +} + +/// Signal an error on a session. Returns 0 on success. +pub export fn docker_hub_mcp_signal_error(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + if (!isValidTransition(slot.state, .err)) return -2; + + sessions[idx].state = .err; + return 0; +} + +/// Recover from error back to authenticated. Returns 0 on success. +pub export fn docker_hub_mcp_recover(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + if (slot.state != .err) return -2; + + sessions[idx].state = .authenticated; + return 0; +} + +/// Reset all sessions (test/debug use only). +pub export fn docker_hub_mcp_reset() void { + mutex.lock(); + defer mutex.unlock(); + sessions = .{SessionSlot{}} ** MAX_SESSIONS; +} + +// --------------------------------------------------------------------------- +// ═══════════════════════════════════════════════════════════════════════ +// Standard ABI (ADR-0005 four symbols + ADR-0006 invoke) +// ═══════════════════════════════════════════════════════════════════════ + +const shim = @import("cartridge_shim.zig"); + +const CARTRIDGE_NAME_PTR: [*:0]const u8 = "docker-hub-mcp"; +const CARTRIDGE_VERSION_PTR: [*:0]const u8 = "0.1.0"; + +export fn boj_cartridge_init() callconv(.c) c_int { + return 0; +} + +export fn boj_cartridge_deinit() callconv(.c) void {} + +export fn boj_cartridge_name() callconv(.c) [*:0]const u8 { + return CARTRIDGE_NAME_PTR; +} + +export fn boj_cartridge_version() callconv(.c) [*:0]const u8 { + return CARTRIDGE_VERSION_PTR; +} + +/// Dispatch the cartridge.json MCP tools. Grade D Alpha stubs. +export fn boj_cartridge_invoke( + tool_name: [*c]const u8, + json_args: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) callconv(.c) i32 { + _ = json_args; + if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; + + const body: []const u8 = if (shim.toolIs(tool_name, "dockerhub_search_images")) + "{\"result\":{\"matches\":[],\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "dockerhub_get_repository")) + "{\"result\":{\"metadata\":{},\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "dockerhub_list_tags")) + "{\"result\":{\"items\":[],\"count\":0,\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "dockerhub_get_tag")) + "{\"result\":{\"metadata\":{},\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "dockerhub_list_namespaces")) + "{\"result\":{\"items\":[],\"count\":0,\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "dockerhub_get_manifest")) + "{\"result\":{\"metadata\":{},\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "dockerhub_delete_tag")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "dockerhub_get_rate_limit")) + "{\"result\":{\"metadata\":{},\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "dockerhub_list_orgs")) + "{\"result\":{\"items\":[],\"count\":0,\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "dockerhub_create_repository")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "dockerhub_delete_repository")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "dockerhub_get_dockerfile")) + "{\"result\":{\"metadata\":{},\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "dockerhub_list_starred")) + "{\"result\":{\"items\":[],\"count\":0,\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "dockerhub_star_repository")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "dockerhub_unstar_repository")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "dockerhub_get_user")) + "{\"result\":{\"metadata\":{},\"status\":\"stub\"}}" +else + return shim.RC_UNKNOWN_TOOL; + + return shim.writeResult(out_buf, in_out_len, body); +} + +// Tests +// --------------------------------------------------------------------------- + +test "two-phase auth lifecycle" { + docker_hub_mcp_reset(); + + const jwt = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.test_payload.signature"; + const slot = docker_hub_mcp_authenticate(jwt.ptr, @intCast(jwt.len)); + try std.testing.expect(slot >= 0); + + // Should be authenticated + try std.testing.expectEqual(@as(c_int, 1), docker_hub_mcp_session_state(slot)); + + // Pull rate should be full + try std.testing.expectEqual(@as(c_int, 200), docker_hub_mcp_pull_rate_remaining(slot)); + + // Close session + try std.testing.expectEqual(@as(c_int, 0), docker_hub_mcp_session_close(slot)); +} + +test "consume pull decrements rate" { + docker_hub_mcp_reset(); + + const jwt = "eyJ_test_jwt"; + const slot = docker_hub_mcp_authenticate(jwt.ptr, @intCast(jwt.len)); + try std.testing.expect(slot >= 0); + + // Consume a pull + try std.testing.expectEqual(@as(c_int, 199), docker_hub_mcp_consume_pull(slot)); + try std.testing.expectEqual(@as(c_int, 199), docker_hub_mcp_pull_rate_remaining(slot)); + + // Consume another + try std.testing.expectEqual(@as(c_int, 198), docker_hub_mcp_consume_pull(slot)); +} + +test "execute action checks auth" { + docker_hub_mcp_reset(); + + const jwt = "eyJ_test_jwt"; + const slot = docker_hub_mcp_authenticate(jwt.ptr, @intCast(jwt.len)); + try std.testing.expect(slot >= 0); + + // SearchImages (action 0) works + try std.testing.expectEqual(@as(c_int, 0), docker_hub_mcp_execute_action(slot, 0)); + + // ListTags (action 2) works when authenticated + try std.testing.expectEqual(@as(c_int, 0), docker_hub_mcp_execute_action(slot, 2)); +} + +test "error and recovery" { + docker_hub_mcp_reset(); + + const jwt = "eyJ_test_jwt"; + const slot = docker_hub_mcp_authenticate(jwt.ptr, @intCast(jwt.len)); + try std.testing.expect(slot >= 0); + + // Signal error + try std.testing.expectEqual(@as(c_int, 0), docker_hub_mcp_signal_error(slot)); + try std.testing.expectEqual(@as(c_int, 3), docker_hub_mcp_session_state(slot)); + + // Recover + try std.testing.expectEqual(@as(c_int, 0), docker_hub_mcp_recover(slot)); + try std.testing.expectEqual(@as(c_int, 1), docker_hub_mcp_session_state(slot)); +} + +test "null jwt rejected" { + docker_hub_mcp_reset(); + try std.testing.expectEqual(@as(c_int, -2), docker_hub_mcp_authenticate(null, 0)); +} + +test "slot exhaustion" { + docker_hub_mcp_reset(); + + const jwt = "eyJ_test_jwt"; + var slot_count: usize = 0; + while (slot_count < MAX_SESSIONS) : (slot_count += 1) { + const s = docker_hub_mcp_authenticate(jwt.ptr, @intCast(jwt.len)); + try std.testing.expect(s >= 0); + } + + // Next should fail + try std.testing.expectEqual(@as(c_int, -1), docker_hub_mcp_authenticate(jwt.ptr, @intCast(jwt.len))); +} + +// ═══════════════════════════════════════════════════════════════════════ +// ADR-0006 invoke dispatch tests +// ═══════════════════════════════════════════════════════════════════════ + +test "boj_cartridge_name returns docker-hub-mcp" { + const n = std.mem.span(boj_cartridge_name()); + try std.testing.expectEqualStrings("docker-hub-mcp", n); +} + +test "boj_cartridge_init returns 0" { + try std.testing.expectEqual(@as(c_int, 0), boj_cartridge_init()); +} + +test "invoke: each declared tool succeeds" { + var buf: [256]u8 = undefined; + const tools = [_][]const u8{ + "dockerhub_search_images", + "dockerhub_get_repository", + "dockerhub_list_tags", + "dockerhub_get_tag", + "dockerhub_list_namespaces", + "dockerhub_get_manifest", + "dockerhub_delete_tag", + "dockerhub_get_rate_limit", + "dockerhub_list_orgs", + "dockerhub_create_repository", + "dockerhub_delete_repository", + "dockerhub_get_dockerfile", + "dockerhub_list_starred", + "dockerhub_star_repository", + "dockerhub_unstar_repository", + "dockerhub_get_user", + }; + for (tools) |t| { + var len: usize = buf.len; + const rc = boj_cartridge_invoke(t.ptr, "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, 0), rc); + try std.testing.expect(std.mem.indexOf(u8, buf[0..len], "result") != null); + } +} + +test "invoke: unknown tool returns -1" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("nope", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -1), rc); +} + +test "invoke: buffer too small returns -3" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("dockerhub_search_images", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -3), rc); + try std.testing.expect(len > 4); +} diff --git a/cartridges/domains/container/docker-hub-mcp/minter.toml b/cartridges/domains/container/docker-hub-mcp/minter.toml new file mode 100644 index 0000000..4e626fd --- /dev/null +++ b/cartridges/domains/container/docker-hub-mcp/minter.toml @@ -0,0 +1,47 @@ +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> + +name = "docker-hub-mcp" +description = "Docker Hub container registry MCP cartridge β€” images, tags, namespaces, manifests, repositories, stars" +version = "0.2.0" +domain = "Container" +protocols = [ + "MCP", + "REST", +] +tier = "Ayo" +backend = "universal" +generate_panel = true + +[auth] +method = "bearer" +token_type = "jwt" +login_endpoint = "POST /v2/users/login" +base_url = "https://hub.docker.com/v2/" +two_phase = true + +[rate_limit] +pull_authenticated = 200 +pull_anonymous = 100 +window = "6h" + +[actions] +count = 16 +list = [ + "SearchImages", + "GetRepository", + "ListTags", + "GetTag", + "ListNamespaces", + "GetManifest", + "DeleteTag", + "GetRateLimit", + "ListOrgs", + "CreateRepository", + "DeleteRepository", + "GetDockerfile", + "ListStarred", + "StarRepository", + "UnstarRepository", + "GetUser", +] diff --git a/cartridges/domains/container/docker-hub-mcp/mod.js b/cartridges/domains/container/docker-hub-mcp/mod.js new file mode 100644 index 0000000..9d2b60e --- /dev/null +++ b/cartridges/domains/container/docker-hub-mcp/mod.js @@ -0,0 +1,308 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// docker-hub-mcp/mod.js -- Docker Hub container registry cartridge implementation. +// +// Provides MCP tool handlers for Docker Hub REST API v2: +// - Image search (public, no auth required) +// - Repository management (get, create, delete) +// - Tag management (list, get, delete) +// - Namespace and organization listing +// - Manifest inspection (OCI manifests, digests, layers) +// - Pull rate limit tracking (100 anon / 200 auth per 6h) +// - Star/unstar repositories +// - User profile lookup +// - Dockerfile retrieval (automated builds) +// +// Auth: Two-phase JWT via POST /v2/users/login, then Bearer token. +// Credentials: DOCKER_HUB_USERNAME + DOCKER_HUB_TOKEN env vars or vault-mcp proxy. +// API docs: https://docs.docker.com/docker-hub/api/latest/ +// +// Usage: import { handleTool } from "./mod.js"; +// or: deno run --allow-net --allow-env mod.js + +const API_BASE = "https://hub.docker.com/v2"; + +// --------------------------------------------------------------------------- +// Auth helper β€” retrieves Docker Hub credentials from environment. +// Two-phase login: POST /v2/users/login with username + password/PAT -> JWT. +// For development, DOCKER_HUB_TOKEN can be set directly as a pre-obtained JWT. +// --------------------------------------------------------------------------- + +let cachedJwt = null; + +async function getJwt() { + if (cachedJwt) return cachedJwt; + + // Try pre-set JWT token first + const directToken = typeof Deno !== "undefined" + ? Deno.env.get("DOCKER_HUB_TOKEN") + : process.env.DOCKER_HUB_TOKEN; + + if (directToken && directToken.startsWith("eyJ")) { + cachedJwt = directToken; + return cachedJwt; + } + + // Two-phase login with username + password/PAT + const username = typeof Deno !== "undefined" + ? Deno.env.get("DOCKER_HUB_USERNAME") + : process.env.DOCKER_HUB_USERNAME; + const password = directToken || (typeof Deno !== "undefined" + ? Deno.env.get("DOCKER_HUB_PASSWORD") + : process.env.DOCKER_HUB_PASSWORD); + + if (!username || !password) { + throw new Error( + "Docker Hub auth not configured. Set DOCKER_HUB_TOKEN (JWT) or " + + "DOCKER_HUB_USERNAME + DOCKER_HUB_PASSWORD. Use vault-mcp in production." + ); + } + + const response = await fetch(`${API_BASE}/users/login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username, password }), + }); + + if (!response.ok) { + const err = await response.json().catch(() => ({})); + throw new Error(`Docker Hub login failed: ${err.detail || response.status}`); + } + + const data = await response.json(); + cachedJwt = data.token; + return cachedJwt; +} + +// --------------------------------------------------------------------------- +// HTTP request helper β€” wraps fetch with Docker Hub auth headers, error +// handling, pagination parameter forwarding, and rate-limit extraction. +// --------------------------------------------------------------------------- + +async function dockerHubFetch(method, path, body, queryParams, requireAuth = true) { + const url = new URL(`${API_BASE}${path}`); + + // Append query parameters (pagination, filters, sorting) + if (queryParams) { + for (const [key, value] of Object.entries(queryParams)) { + if (value !== undefined && value !== null && value !== "") { + url.searchParams.set(key, String(value)); + } + } + } + + const options = { + method, + headers: { + "Accept": "application/json", + "User-Agent": "boj-server/docker-hub-mcp/0.2.0", + }, + }; + + // Attach JWT authorization header when required + if (requireAuth) { + const jwt = await getJwt(); + options.headers["Authorization"] = `Bearer ${jwt}`; + } + + if (body && method !== "GET") { + options.headers["Content-Type"] = "application/json"; + options.body = JSON.stringify(body); + } + + const response = await fetch(url.toString(), options); + + // Extract rate-limit headers (Docker Hub uses RateLimit-* headers) + const rateLimit = { + limit: response.headers.get("ratelimit-limit"), + remaining: response.headers.get("ratelimit-remaining"), + reset: response.headers.get("ratelimit-reset"), + }; + + // Handle 204 No Content (successful deletes, star/unstar) + if (response.status === 204) { + return { status: response.status, data: { success: true }, rateLimit }; + } + + const data = await response.json().catch(() => ({})); + + // Surface Docker Hub API errors clearly + if (!response.ok) { + const errorMessage = data.detail || data.message || `HTTP ${response.status}`; + return { status: response.status, error: errorMessage, data, rateLimit }; + } + + return { status: response.status, data, rateLimit }; +} + +// --------------------------------------------------------------------------- +// Tool handler dispatch β€” maps MCP tool names to Docker Hub API operations. +// Each handler validates required arguments, builds the API request, +// and returns structured results with rate-limit metadata. +// --------------------------------------------------------------------------- + +export async function handleTool(toolName, args) { + switch (toolName) { + + // --- Search (no auth required) --- + + case "dockerhub_search_images": { + if (!args.query) return { error: "Missing required field: query" }; + const query = { + q: args.query, + page: args.page, + page_size: args.page_size, + is_official: args.is_official, + }; + return dockerHubFetch("GET", "/search/repositories", null, query, false); + } + + // --- Repositories --- + + case "dockerhub_get_repository": { + if (!args.namespace) return { error: "Missing required field: namespace" }; + if (!args.name) return { error: "Missing required field: name" }; + return dockerHubFetch("GET", `/repositories/${args.namespace}/${args.name}`); + } + + case "dockerhub_create_repository": { + if (!args.namespace) return { error: "Missing required field: namespace" }; + if (!args.name) return { error: "Missing required field: name" }; + const body = { + namespace: args.namespace, + name: args.name, + }; + if (args.description) body.description = args.description; + if (args.full_description) body.full_description = args.full_description; + if (args.is_private !== undefined) body.is_private = args.is_private; + return dockerHubFetch("POST", `/repositories/${args.namespace}/${args.name}`, body); + } + + case "dockerhub_delete_repository": { + if (!args.namespace) return { error: "Missing required field: namespace" }; + if (!args.name) return { error: "Missing required field: name" }; + return dockerHubFetch("DELETE", `/repositories/${args.namespace}/${args.name}`); + } + + // --- Tags --- + + case "dockerhub_list_tags": { + if (!args.namespace) return { error: "Missing required field: namespace" }; + if (!args.name) return { error: "Missing required field: name" }; + const query = { + page: args.page, + page_size: args.page_size, + ordering: args.ordering, + }; + return dockerHubFetch("GET", `/repositories/${args.namespace}/${args.name}/tags`, null, query); + } + + case "dockerhub_get_tag": { + if (!args.namespace) return { error: "Missing required field: namespace" }; + if (!args.name) return { error: "Missing required field: name" }; + if (!args.tag) return { error: "Missing required field: tag" }; + return dockerHubFetch("GET", `/repositories/${args.namespace}/${args.name}/tags/${args.tag}`); + } + + case "dockerhub_delete_tag": { + if (!args.namespace) return { error: "Missing required field: namespace" }; + if (!args.name) return { error: "Missing required field: name" }; + if (!args.tag) return { error: "Missing required field: tag" }; + return dockerHubFetch("DELETE", `/repositories/${args.namespace}/${args.name}/tags/${args.tag}`); + } + + // --- Manifest --- + + case "dockerhub_get_manifest": { + if (!args.namespace) return { error: "Missing required field: namespace" }; + if (!args.name) return { error: "Missing required field: name" }; + if (!args.reference) return { error: "Missing required field: reference" }; + return dockerHubFetch( + "GET", + `/repositories/${args.namespace}/${args.name}/tags/${args.reference}`, + ); + } + + // --- Namespaces and Orgs --- + + case "dockerhub_list_namespaces": { + return dockerHubFetch("GET", "/repositories/namespaces"); + } + + case "dockerhub_list_orgs": { + return dockerHubFetch("GET", "/user/orgs"); + } + + // --- Dockerfile --- + + case "dockerhub_get_dockerfile": { + if (!args.namespace) return { error: "Missing required field: namespace" }; + if (!args.name) return { error: "Missing required field: name" }; + return dockerHubFetch("GET", `/repositories/${args.namespace}/${args.name}/dockerfile`); + } + + // --- Stars --- + + case "dockerhub_list_starred": { + const query = { + page: args.page, + page_size: args.page_size, + }; + return dockerHubFetch("GET", "/user/starred", null, query); + } + + case "dockerhub_star_repository": { + if (!args.namespace) return { error: "Missing required field: namespace" }; + if (!args.name) return { error: "Missing required field: name" }; + return dockerHubFetch("PUT", `/user/starred/${args.namespace}/${args.name}`); + } + + case "dockerhub_unstar_repository": { + if (!args.namespace) return { error: "Missing required field: namespace" }; + if (!args.name) return { error: "Missing required field: name" }; + return dockerHubFetch("DELETE", `/user/starred/${args.namespace}/${args.name}`); + } + + // --- User --- + + case "dockerhub_get_user": { + if (!args.username) return { error: "Missing required field: username" }; + return dockerHubFetch("GET", `/users/${args.username}`, null, null, false); + } + + // --- Rate Limit --- + + case "dockerhub_get_rate_limit": { + // HEAD request to registry to check rate limit headers + const response = await fetch("https://registry-1.docker.io/v2/ratelimitpreview/test/manifests/latest", { + method: "HEAD", + headers: { "Accept": "application/json" }, + }); + return { + status: response.status, + data: { + limit: response.headers.get("ratelimit-limit"), + remaining: response.headers.get("ratelimit-remaining"), + }, + }; + } + + default: + return { error: `Unknown docker-hub-mcp tool: ${toolName}` }; + } +} + +// --------------------------------------------------------------------------- +// Cartridge metadata export β€” used by the BoJ cartridge loader to register +// this cartridge's tools without reading cartridge.json separately. +// --------------------------------------------------------------------------- + +export const metadata = { + name: "docker-hub-mcp", + version: "0.2.0", + domain: "Container", + tier: "Ayo", + protocols: ["MCP", "REST"], + toolCount: 16, +}; diff --git a/cartridges/domains/container/docker-hub-mcp/panels/manifest.json b/cartridges/domains/container/docker-hub-mcp/panels/manifest.json new file mode 100644 index 0000000..6383cb1 --- /dev/null +++ b/cartridges/domains/container/docker-hub-mcp/panels/manifest.json @@ -0,0 +1,96 @@ +{ + "$schema": "https://panll.dev/schemas/panel-manifest/v1.json", + "spdx": "MPL-2.0", + "cartridge": "docker-hub-mcp", + "domain": "Container", + "version": "0.2.0", + "panels": [ + { + "id": "docker-hub-auth-status", + "title": "Auth Status", + "description": "Current Docker Hub authentication state (Unauthenticated / Authenticated / RateLimited / Error)", + "type": "status-indicator", + "data_source": { + "endpoint": "/docker-hub/state", + "method": "GET", + "refresh_interval_ms": 5000 + }, + "widgets": [ + { + "type": "state-badge", + "field": "state", + "states": { + "unauthenticated": { "color": "#95a5a6", "icon": "user-x" }, + "authenticated": { "color": "#2ecc71", "icon": "user-check" }, + "rate_limited": { "color": "#f39c12", "icon": "clock" }, + "error": { "color": "#e74c3c", "icon": "alert-triangle" } + } + } + ] + }, + { + "id": "docker-hub-namespace-count", + "title": "Namespace Count", + "description": "Total number of namespaces accessible to the authenticated user", + "type": "metric", + "data_source": { + "endpoint": "/docker-hub/namespaces/count", + "method": "GET", + "refresh_interval_ms": 30000 + }, + "widgets": [ + { + "type": "counter", + "field": "count", + "label": "Namespaces", + "icon": "folder" + } + ] + }, + { + "id": "docker-hub-image-count", + "title": "Image Count", + "description": "Total number of container images across all namespaces", + "type": "metric", + "data_source": { + "endpoint": "/docker-hub/images/count", + "method": "GET", + "refresh_interval_ms": 30000 + }, + "widgets": [ + { + "type": "counter", + "field": "count", + "label": "Images", + "icon": "package" + } + ] + }, + { + "id": "docker-hub-pull-rate", + "title": "Pull Rate Remaining", + "description": "Remaining image pulls in the current 6-hour rate limit window (100 anon / 200 auth)", + "type": "metric", + "data_source": { + "endpoint": "/docker-hub/rate-limit", + "method": "GET", + "refresh_interval_ms": 10000 + }, + "widgets": [ + { + "type": "counter", + "field": "remaining", + "label": "Pulls Remaining", + "icon": "download" + }, + { + "type": "timestamp", + "field": "reset", + "label": "Resets At", + "format": "relative", + "icon": "clock" + } + ] + } + ] +} diff --git a/cartridges/domains/container/docker-hub-mcp/tests/integration_test.sh b/cartridges/domains/container/docker-hub-mcp/tests/integration_test.sh new file mode 100755 index 0000000..ea94275 --- /dev/null +++ b/cartridges/domains/container/docker-hub-mcp/tests/integration_test.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Integration tests for docker-hub-mcp cartridge. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CART_DIR="$(dirname "$SCRIPT_DIR")" +FFI_DIR="$CART_DIR/ffi" + +echo "=== docker-hub-mcp integration tests ===" + +# Build FFI +echo "[1/3] Building FFI..." +cd "$FFI_DIR" && zig build 2>&1 + +# Run Zig unit tests +echo "[2/3] Running FFI unit tests..." +cd "$FFI_DIR" && zig build test 2>&1 + +# Validate Idris2 ABI (if idris2 available) +echo "[3/3] Checking ABI..." +if command -v idris2 &>/dev/null; then + cd "$CART_DIR/abi" && idris2 --check DockerHubMcp.SafeContainer 2>&1 + echo " ABI: OK" +else + echo " ABI: SKIPPED (idris2 not in PATH)" +fi + +echo "" +echo "All tests passed for docker-hub-mcp!" diff --git a/cartridges/domains/container/k8s-mcp/README.adoc b/cartridges/domains/container/k8s-mcp/README.adoc new file mode 100644 index 0000000..d134946 --- /dev/null +++ b/cartridges/domains/container/k8s-mcp/README.adoc @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += k8s-mcp +Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk> +:spdx: MPL-2.0 +:tier: Ayo +:domain: Container Orchestration +:protocols: MCP, REST + +== Overview + +Kubernetes cluster management. Namespace-scoped operations with connection lifecycle and resource CRUD. + +== Tools (8) + +[cols="2,4"] +|=== +| Tool | Description + +| `k8s_connect` | Connect to a Kubernetes cluster. Returns a session slot index. +| `k8s_list_pods` | List pods in a namespace. +| `k8s_get_pod` | Get details for a specific pod. +| `k8s_list_deployments` | List deployments in a namespace. +| `k8s_apply` | Apply a YAML/JSON manifest to the cluster. +| `k8s_delete` | Delete a Kubernetes resource. +| `k8s_logs` | Get logs from a pod container. +| `k8s_disconnect` | Disconnect and release a session slot. +|=== + +== Architecture + +Three-layer stack per the BoJ cartridge standard: + +[cols="1,1,3"] +|=== +| Layer | Language | Purpose + +| ABI | Idris2 | Formally verified state machine with dependent-type proofs +| FFI | Zig | C-ABI implementation: session pool, action metrics, thread safety +| Adapter| Zig | Adapter bridge to the BoJ unified adapter protocol (zig predecessors sidelined) +|=== + +== Building + +[source,bash] +---- +# ABI (Idris2) +cd abi && idris2 --check *.idr + +# FFI (Zig) +cd ffi && zig build +cd ffi && zig build test + +# Adapter (Zig) +cd adapter && zig build +---- + +== Status + +Alpha (CRG D). Cartridge metadata declared in `cartridge.json`; 8 tool(s) exposed. +Not yet dogfooded externally. See `docs/READINESS.md` at repo root for the +project-wide grade table and `docs/practice/DOGFOOD-LOG.adoc` for usage evidence. diff --git a/cartridges/domains/container/k8s-mcp/abi/K8sMcp/SafeK8s.idr b/cartridges/domains/container/k8s-mcp/abi/K8sMcp/SafeK8s.idr new file mode 100644 index 0000000..a236807 --- /dev/null +++ b/cartridges/domains/container/k8s-mcp/abi/K8sMcp/SafeK8s.idr @@ -0,0 +1,181 @@ +-- SPDX-License-Identifier: MPL-2.0 +-- Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +||| K8sMcp.SafeK8s: Formally verified Kubernetes orchestration operations. +||| +||| Cartridge: k8s-mcp +||| Matrix cell: Kubernetes domain x {MCP, LSP} protocols +||| +||| This module defines type-safe Kubernetes operations with a +||| cluster connection state machine and namespace isolation that prevents: +||| - Operations on disconnected clusters +||| - Cross-namespace operations without explicit namespace selection +||| - Apply/delete without proper cluster auth +||| +||| State machine: Disconnected -> ClusterConnected -> NamespaceSelected -> Operating +||| -> NamespaceSelected -> ClusterConnected -> Disconnected +module K8sMcp.SafeK8s + +import Data.List + +%default total + +-- ═══════════════════════════════════════════════════════════════════════════ +-- Cluster Connection State Machine +-- ═══════════════════════════════════════════════════════════════════════════ + +||| Cluster connection lifecycle states. +||| A cluster session progresses through namespace selection before operations. +public export +data K8sState = Disconnected | ClusterConnected | NamespaceSelected | Operating | K8sError + +||| Equality for cluster states. +public export +Eq K8sState where + Disconnected == Disconnected = True + ClusterConnected == ClusterConnected = True + NamespaceSelected == NamespaceSelected = True + Operating == Operating = True + K8sError == K8sError = True + _ == _ = False + +||| Valid state transitions (enforced at the type level). +public export +data ValidTransition : K8sState -> K8sState -> Type where + Connect : ValidTransition Disconnected ClusterConnected + SelectNamespace : ValidTransition ClusterConnected NamespaceSelected + ChangeNamespace : ValidTransition NamespaceSelected NamespaceSelected + BeginOperation : ValidTransition NamespaceSelected Operating + EndOperation : ValidTransition Operating NamespaceSelected + DeselectNs : ValidTransition NamespaceSelected ClusterConnected + Disconnect : ValidTransition ClusterConnected Disconnected + OpError : ValidTransition Operating K8sError + Recover : ValidTransition K8sError Disconnected + +||| Runtime transition validator. +public export +canTransition : K8sState -> K8sState -> Bool +canTransition Disconnected ClusterConnected = True +canTransition ClusterConnected NamespaceSelected = True +canTransition NamespaceSelected NamespaceSelected = True +canTransition NamespaceSelected Operating = True +canTransition Operating NamespaceSelected = True +canTransition NamespaceSelected ClusterConnected = True +canTransition ClusterConnected Disconnected = True +canTransition Operating K8sError = True +canTransition K8sError Disconnected = True +canTransition _ _ = False + +-- ═══════════════════════════════════════════════════════════════════════════ +-- Kubernetes Tool Types +-- ═══════════════════════════════════════════════════════════════════════════ + +||| Supported Kubernetes tools. +public export +data K8sTool + = Kubectl -- Standard kubectl CLI + | Helm -- Helm chart manager + | Kustomize -- Kustomize overlay manager + +||| C-ABI encoding. +public export +toolTypeToInt : K8sTool -> Int +toolTypeToInt Kubectl = 1 +toolTypeToInt Helm = 2 +toolTypeToInt Kustomize = 3 + +-- ═══════════════════════════════════════════════════════════════════════════ +-- Cluster Record +-- ═══════════════════════════════════════════════════════════════════════════ + +||| A Kubernetes cluster session with tracked state. +public export +record ClusterSession where + constructor MkClusterSession + clusterId : String + tool : K8sTool + state : K8sState + namespace : String + contextName : String + +||| Proof that a cluster session has a namespace selected (ready for operations). +public export +data HasNamespace : ClusterSession -> Type where + NamespaceReady : (cs : ClusterSession) -> + (state cs = NamespaceSelected) -> + HasNamespace cs + +-- ═══════════════════════════════════════════════════════════════════════════ +-- MCP Tool Definitions +-- ═══════════════════════════════════════════════════════════════════════════ + +||| MCP tools exposed by this cartridge. +||| These map to MCP tool definitions that AI agents can call. +public export +data McpTool + = ToolConnectCluster -- Connect to a Kubernetes cluster + | ToolSelectNamespace -- Select a namespace + | ToolApply -- Apply a manifest (kubectl apply) + | ToolDelete -- Delete a resource (kubectl delete) + | ToolGetResources -- List/get resources + | ToolHelmInstall -- Install a Helm chart + | ToolHelmUpgrade -- Upgrade a Helm release + | ToolStatus -- Cluster/resource status + +||| MCP tool name (for JSON-RPC method name). +public export +toolName : McpTool -> String +toolName ToolConnectCluster = "k8s/connect" +toolName ToolSelectNamespace = "k8s/select-namespace" +toolName ToolApply = "k8s/apply" +toolName ToolDelete = "k8s/delete" +toolName ToolGetResources = "k8s/get" +toolName ToolHelmInstall = "k8s/helm-install" +toolName ToolHelmUpgrade = "k8s/helm-upgrade" +toolName ToolStatus = "k8s/status" + +||| Which tools require a namespace to be selected. +public export +requiresNamespace : McpTool -> Bool +requiresNamespace ToolConnectCluster = False +requiresNamespace ToolSelectNamespace = False +requiresNamespace ToolStatus = False +requiresNamespace _ = True + +-- ═══════════════════════════════════════════════════════════════════════════ +-- C-ABI Exports +-- ═══════════════════════════════════════════════════════════════════════════ + +||| Cluster state to integer. +public export +k8sStateToInt : K8sState -> Int +k8sStateToInt Disconnected = 0 +k8sStateToInt ClusterConnected = 1 +k8sStateToInt NamespaceSelected = 2 +k8sStateToInt Operating = 3 +k8sStateToInt K8sError = 4 + +||| FFI: Validate a state transition. +export +k8s_can_transition : Int -> Int -> Int +k8s_can_transition from to = + let fromState = case from of + 0 => Disconnected + 1 => ClusterConnected + 2 => NamespaceSelected + 3 => Operating + _ => K8sError + toState = case to of + 0 => Disconnected + 1 => ClusterConnected + 2 => NamespaceSelected + 3 => Operating + _ => K8sError + in if canTransition fromState toState then 1 else 0 + +||| FFI: Check if a tool requires a namespace to be selected. +export +k8s_tool_requires_namespace : Int -> Int +k8s_tool_requires_namespace 1 = 0 -- ToolConnectCluster +k8s_tool_requires_namespace 2 = 0 -- ToolSelectNamespace +k8s_tool_requires_namespace 8 = 0 -- ToolStatus +k8s_tool_requires_namespace _ = 1 -- All others require namespace diff --git a/cartridges/domains/container/k8s-mcp/abi/README.adoc b/cartridges/domains/container/k8s-mcp/abi/README.adoc new file mode 100644 index 0000000..b475774 --- /dev/null +++ b/cartridges/domains/container/k8s-mcp/abi/README.adoc @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += k8s-mcp / abi β€” Idris2 ABI layer +:orientation: shallow + +== Purpose + +Cartridge-local protocol types for `k8s-mcp`. Defines the data, tool, and +transition vocabulary this cartridge speaks. Inherits safety lemmas and the +matrix-cell identifier scheme from the trunk ABI at `src/abi/`. + +== Files + +1 Idris2 module(s), ~181 lines total. Lead module: +`SafeK8s.idr`. + +No `.ipkg` β€” consumed directly by wider BoJ callers; type-checks inside the +trunk `boj.ipkg`. + +== Invariants + +* `%default total` at every module top. +* Zero `believe_me`, `assert_total`, `postulate`, `Admitted`, or + `sorry` (the trunk holds the four declared `believe_me` sites; leaf + cartridges carry none). + +== Test/proof surface + +Thin module(s), no inline tests. Proof surface = type-check success under the +trunk `idris2 --check`. + +== Read-first + +. `SafeK8s.idr` β€” cartridge-local data records and enums. +. Trunk `src/abi/Boj/Catalogue.idr` for how this cell participates in the + 2D matrix. diff --git a/cartridges/domains/container/k8s-mcp/abi/k8s-mcp.ipkg b/cartridges/domains/container/k8s-mcp/abi/k8s-mcp.ipkg new file mode 100644 index 0000000..f80e229 --- /dev/null +++ b/cartridges/domains/container/k8s-mcp/abi/k8s-mcp.ipkg @@ -0,0 +1,11 @@ +-- SPDX-License-Identifier: MPL-2.0 +package k8smcp + +authors = "Jonathan D.A. Jewell" +version = 0.1.0 +license = "MPL-2.0" +brief = "Kubernetes MCP cartridge β€” cluster connection state machine with namespace isolation" + +sourcedir = "." +modules = K8sMcp.SafeK8s +depends = base, contrib diff --git a/cartridges/domains/container/k8s-mcp/adapter/README.adoc b/cartridges/domains/container/k8s-mcp/adapter/README.adoc new file mode 100644 index 0000000..d510bc4 --- /dev/null +++ b/cartridges/domains/container/k8s-mcp/adapter/README.adoc @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += k8s-mcp / adapter β€” protocol bridge +:orientation: shallow + +== Purpose + +Exposes this cartridge's FFI over the wire protocols declared in +`cartridge.json`. Dispatches tool invocations to the C-ABI exports at +`../ffi/` and formats responses per protocol. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” depends on the sibling `ffi` target. +| `k8s_adapter.zig` | Protocol dispatch (115 lines). Tool catalogue matches `cartridge.json`. +| `SIDELINED-*.v.adoc` | zig predecessor(s). Not built β€” zig banned 2026-04-10. +|=== + +== Invariants + +* **Stateless** β€” all state lives behind the FFI, never in the adapter. +* **Response passthrough** β€” whatever the FFI returns goes back to the wire + unmodified (no embellishment, no silent recovery). +* **Cartridge.json is source of truth** for the tool catalogue; drift between + adapter and manifest is a CI failure. + +== Test/proof surface + +No inline tests at the adapter layer β€” protocol routing is exercised by the +trunk seam tests at `ffi/zig/src/seams.zig`. + +== Read-first + +. `k8s_adapter.zig` β€” dispatch function and per-protocol routers. +. `../cartridge.json` β€” the tool manifest the dispatcher mirrors. diff --git a/cartridges/domains/container/k8s-mcp/adapter/build.zig b/cartridges/domains/container/k8s-mcp/adapter/build.zig new file mode 100644 index 0000000..fc6b526 --- /dev/null +++ b/cartridges/domains/container/k8s-mcp/adapter/build.zig @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// k8s-mcp/adapter/build.zig + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); const optimize = b.standardOptimizeOption(.{}); + const ffi_mod = b.createModule(.{ .root_source_file = b.path("../ffi/k8s_ffi.zig"), .target = target, .optimize = optimize }); + const adapter = b.addExecutable(.{ .name = "k8s_adapter", .root_source_file = b.path("k8s_adapter.zig"), .target = target, .optimize = optimize }); + adapter.root_module.addImport("k8s_ffi", ffi_mod); b.installArtifact(adapter); + const rs = b.step("run", "Run k8s-mcp adapter"); rs.dependOn(&b.addRunArtifact(adapter).step); + const tests = b.addTest(.{ .root_source_file = b.path("k8s_adapter.zig"), .target = target, .optimize = optimize }); + tests.root_module.addImport("k8s_ffi", ffi_mod); + const ts = b.step("test", "Test k8s-mcp adapter"); ts.dependOn(&b.addRunArtifact(tests).step); +} diff --git a/cartridges/domains/container/k8s-mcp/adapter/k8s_adapter.zig b/cartridges/domains/container/k8s-mcp/adapter/k8s_adapter.zig new file mode 100644 index 0000000..f4bc0bb --- /dev/null +++ b/cartridges/domains/container/k8s-mcp/adapter/k8s_adapter.zig @@ -0,0 +1,115 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// k8s-mcp/adapter/k8s_adapter.zig -- Unified three-protocol adapter. +// Replaces banned k8s_adapter.v (zig, removed 2026-04-12). +// REST:9145 gRPC:9146 GraphQL:9147 +// Tools: k8s_connect, k8s_list_pods, k8s_get_pod, k8s_list_deployments... + +const std = @import("std"); +const ffi = @import("k8s_ffi"); + +const REST_PORT: u16 = 9145; +const GRPC_PORT: u16 = 9146; +const GQL_PORT: u16 = 9147; +const MAX_CONN_BUF: usize = 16 * 1024; + +fn okJson(buf: []u8, msg: []const u8) []u8 { + const n = std.fmt.bufPrint(buf, "{\"success\":true,\"message\":\"{s}\"}", .{msg}) catch return buf[0..0]; return n; +} +fn errJson(buf: []u8, msg: []const u8) []u8 { + const n = std.fmt.bufPrint(buf, "{\"success\":false,\"error\":\"{s}\"}", .{msg}) catch return buf[0..0]; return n; +} +fn statusJson(buf: []u8) []u8 { + const n = std.fmt.bufPrint(buf, "{\"success\":true,\"state\":\"ready\",\"service\":\"k8s-mcp\"}", .{}) catch return buf[0..0]; return n; +} + +const Response = struct { status: u16, body: []u8 }; + +fn dispatch(tool: []const u8, body: []const u8, resp: []u8) Response { + _ = body; + if (std.mem.eql(u8, tool, "k8s_connect")) return .{ .status = 200, .body = okJson(resp, "k8s_connect forwarded") }; + if (std.mem.eql(u8, tool, "k8s_list_pods")) return .{ .status = 200, .body = okJson(resp, "k8s_list_pods forwarded") }; + if (std.mem.eql(u8, tool, "k8s_get_pod")) return .{ .status = 200, .body = okJson(resp, "k8s_get_pod forwarded") }; + if (std.mem.eql(u8, tool, "k8s_list_deployments")) return .{ .status = 200, .body = okJson(resp, "k8s_list_deployments forwarded") }; + if (std.mem.eql(u8, tool, "k8s_apply")) return .{ .status = 200, .body = okJson(resp, "k8s_apply forwarded") }; + if (std.mem.eql(u8, tool, "k8s_delete")) return .{ .status = 200, .body = okJson(resp, "k8s_delete forwarded") }; + if (std.mem.eql(u8, tool, "k8s_logs")) return .{ .status = 200, .body = okJson(resp, "k8s_logs forwarded") }; + if (std.mem.eql(u8, tool, "k8s_disconnect")) return .{ .status = 200, .body = okJson(resp, "k8s_disconnect forwarded") }; + if (std.mem.eql(u8, tool, "status") or std.mem.eql(u8, tool, "health")) return .{ .status = 200, .body = statusJson(resp) }; + return .{ .status = 404, .body = errJson(resp, "Unknown tool") }; +} + +fn dispatchRest(path: []const u8, body: []const u8, resp: []u8) Response { + if (std.mem.startsWith(u8, path, "/tools/")) return dispatch(path["/tools/".len..], body, resp); + if (std.mem.eql(u8, path, "/status") or std.mem.eql(u8, path, "/health")) return .{ .status = 200, .body = statusJson(resp) }; + return .{ .status = 404, .body = errJson(resp, "Not found") }; +} + +fn dispatchGrpc(path: []const u8, body: []const u8, resp: []u8) Response { + const prefix = "/K8sservice/"; + if (!std.mem.startsWith(u8, path, prefix)) return .{ .status = 404, .body = errJson(resp, "Not a recognized gRPC path") }; + const method = path[prefix.len..]; + const tool = blk: { + if (std.mem.eql(u8, method, "k8s_connect")) break :blk "k8s_connect"; + if (std.mem.eql(u8, method, "k8s_list_pods")) break :blk "k8s_list_pods"; + if (std.mem.eql(u8, method, "k8s_get_pod")) break :blk "k8s_get_pod"; + if (std.mem.eql(u8, method, "k8s_list_deployments")) break :blk "k8s_list_deployments"; + if (std.mem.eql(u8, method, "k8s_apply")) break :blk "k8s_apply"; + if (std.mem.eql(u8, method, "k8s_delete")) break :blk "k8s_delete"; + if (std.mem.eql(u8, method, "k8s_logs")) break :blk "k8s_logs"; + if (std.mem.eql(u8, method, "k8s_disconnect")) break :blk "k8s_disconnect"; + return .{ .status = 404, .body = errJson(resp, "Unknown gRPC method") }; + }; + return dispatch(tool, body, resp); +} + +fn dispatchGraphql(body: []const u8, resp: []u8) Response { + if (std.mem.indexOf(u8, body, "__schema") != null) return .{ .status = 200, .body = okJson(resp, "schema not supported") }; + if (std.mem.indexOf(u8, body, "connect") != null) return dispatch("k8s_connect", body, resp); + if (std.mem.indexOf(u8, body, "list_pods") != null) return dispatch("k8s_list_pods", body, resp); + if (std.mem.indexOf(u8, body, "get_pod") != null) return dispatch("k8s_get_pod", body, resp); + if (std.mem.indexOf(u8, body, "list_deployments") != null) return dispatch("k8s_list_deployments", body, resp); + if (std.mem.indexOf(u8, body, "apply") != null) return dispatch("k8s_apply", body, resp); + if (std.mem.indexOf(u8, body, "delete") != null) return dispatch("k8s_delete", body, resp); + if (std.mem.indexOf(u8, body, "logs") != null) return dispatch("k8s_logs", body, resp); + if (std.mem.indexOf(u8, body, "disconnect") != null) return dispatch("k8s_disconnect", body, resp); + return .{ .status = 200, .body = errJson(resp, "Unrecognised GraphQL operation") }; +} + +const Protocol = enum { rest, grpc, graphql }; + +fn handleConnection(conn: std.net.Server.Connection, proto: Protocol) void { + defer conn.stream.close(); + var in_buf: [MAX_CONN_BUF]u8 = undefined; + const n = conn.stream.read(&in_buf) catch return; + const req = in_buf[0..n]; + var path: []const u8 = "/"; var body: []const u8 = ""; + if (n > 4) { + const le = std.mem.indexOf(u8, req, "\r\n") orelse req.len; + const fl = req[0..le]; const sp1 = std.mem.indexOfScalar(u8, fl, ' ') orelse 0; + const ro = fl[sp1+1..]; const sp2 = std.mem.indexOfScalar(u8, ro, ' ') orelse ro.len; + path = ro[0..sp2]; + const bs = std.mem.indexOf(u8, req, "\r\n\r\n") orelse n; body = req[@min(bs+4,n)..]; + } + var rb: [MAX_CONN_BUF]u8 = undefined; + const result = switch (proto) { .rest => dispatchRest(path, body, &rb), .grpc => dispatchGrpc(path, body, &rb), .graphql => dispatchGraphql(body, &rb) }; + const ct: []const u8 = if (proto == .grpc) "application/grpc+json" else "application/json"; + var hb: [256]u8 = undefined; + const hdr = std.fmt.bufPrint(&hb, "HTTP/1.1 {d} OK\r\nContent-Type: {s}\r\nContent-Length: {d}\r\nConnection: close\r\n\r\n", .{ result.status, ct, result.body.len }) catch return; + _ = conn.stream.writeAll(hdr) catch return; _ = conn.stream.writeAll(result.body) catch return; +} + +fn listenLoop(port: u16, proto: Protocol) void { + const addr = std.net.Address.initIp4(.{ 127, 0, 0, 1 }, port) catch return; + var srv = addr.listen(.{ .reuse_address = true }) catch return; defer srv.deinit(); + while (true) { const conn = srv.accept() catch continue; handleConnection(conn, proto); } +} + +pub fn main() !void { + ffi.k8s_init(); + const t1 = try std.Thread.spawn(.{}, listenLoop, .{ REST_PORT, Protocol.rest }); + const t2 = try std.Thread.spawn(.{}, listenLoop, .{ GRPC_PORT, Protocol.grpc }); + const t3 = try std.Thread.spawn(.{}, listenLoop, .{ GQL_PORT, Protocol.graphql }); + t1.join(); t2.join(); t3.join(); +} diff --git a/cartridges/domains/container/k8s-mcp/cartridge.json b/cartridges/domains/container/k8s-mcp/cartridge.json new file mode 100644 index 0000000..42ef453 --- /dev/null +++ b/cartridges/domains/container/k8s-mcp/cartridge.json @@ -0,0 +1,226 @@ +{ + "$schema": "https://boj.dev/schemas/cartridge/v1.json", + "spdx": "MPL-2.0", + "copyright": "Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath)", + "name": "k8s-mcp", + "version": "0.1.0", + "description": "Kubernetes cluster management. Namespace-scoped operations with connection lifecycle and resource CRUD.", + "domain": "Container Orchestration", + "tier": "Ayo", + "protocols": [ + "MCP", + "REST" + ], + "auth": { + "method": "none", + "env_var": null, + "credential_source": null + }, + "api": { + "base_url": "local://k8s-mcp", + "content_type": "application/json" + }, + "tools": [ + { + "name": "k8s_connect", + "description": "Connect to a Kubernetes cluster. Returns a session slot index.", + "inputSchema": { + "type": "object", + "properties": { + "kubeconfig": { + "type": "string", + "description": "Path to kubeconfig file (uses default if omitted)" + }, + "context": { + "type": "string", + "description": "kubeconfig context name" + } + }, + "required": [] + } + }, + { + "name": "k8s_list_pods", + "description": "List pods in a namespace.", + "inputSchema": { + "type": "object", + "properties": { + "slot": { + "type": "integer", + "description": "Session slot from k8s_connect" + }, + "namespace": { + "type": "string", + "description": "Namespace (default: all namespaces)" + }, + "label_selector": { + "type": "string", + "description": "Label selector filter" + } + }, + "required": [ + "slot" + ] + } + }, + { + "name": "k8s_get_pod", + "description": "Get details for a specific pod.", + "inputSchema": { + "type": "object", + "properties": { + "slot": { + "type": "integer", + "description": "Session slot" + }, + "namespace": { + "type": "string", + "description": "Namespace" + }, + "name": { + "type": "string", + "description": "Pod name" + } + }, + "required": [ + "slot", + "namespace", + "name" + ] + } + }, + { + "name": "k8s_list_deployments", + "description": "List deployments in a namespace.", + "inputSchema": { + "type": "object", + "properties": { + "slot": { + "type": "integer", + "description": "Session slot" + }, + "namespace": { + "type": "string", + "description": "Namespace (default: all namespaces)" + } + }, + "required": [ + "slot" + ] + } + }, + { + "name": "k8s_apply", + "description": "Apply a YAML/JSON manifest to the cluster.", + "inputSchema": { + "type": "object", + "properties": { + "slot": { + "type": "integer", + "description": "Session slot" + }, + "manifest": { + "type": "string", + "description": "YAML or JSON manifest content" + } + }, + "required": [ + "slot", + "manifest" + ] + } + }, + { + "name": "k8s_delete", + "description": "Delete a Kubernetes resource.", + "inputSchema": { + "type": "object", + "properties": { + "slot": { + "type": "integer", + "description": "Session slot" + }, + "kind": { + "type": "string", + "description": "Resource kind (e.g. Pod, Deployment)" + }, + "namespace": { + "type": "string", + "description": "Namespace" + }, + "name": { + "type": "string", + "description": "Resource name" + } + }, + "required": [ + "slot", + "kind", + "namespace", + "name" + ] + } + }, + { + "name": "k8s_logs", + "description": "Get logs from a pod container.", + "inputSchema": { + "type": "object", + "properties": { + "slot": { + "type": "integer", + "description": "Session slot" + }, + "namespace": { + "type": "string", + "description": "Namespace" + }, + "pod": { + "type": "string", + "description": "Pod name" + }, + "container": { + "type": "string", + "description": "Container name (first container if omitted)" + }, + "tail": { + "type": "integer", + "description": "Number of log lines to return (default: 100)" + } + }, + "required": [ + "slot", + "namespace", + "pod" + ] + } + }, + { + "name": "k8s_disconnect", + "description": "Disconnect and release a session slot.", + "inputSchema": { + "type": "object", + "properties": { + "slot": { + "type": "integer", + "description": "Session slot to release" + } + }, + "required": [ + "slot" + ] + } + } + ], + "ffi": { + "so_path": "ffi/zig-out/lib/libk8s_mcp.so", + "abi_version": "ADR-0006", + "symbols": [ + "boj_cartridge_init", + "boj_cartridge_deinit", + "boj_cartridge_name", + "boj_cartridge_version", + "boj_cartridge_invoke" + ] + } +} diff --git a/cartridges/domains/container/k8s-mcp/ffi/README.adoc b/cartridges/domains/container/k8s-mcp/ffi/README.adoc new file mode 100644 index 0000000..bf35a50 --- /dev/null +++ b/cartridges/domains/container/k8s-mcp/ffi/README.adoc @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += k8s-mcp / ffi β€” Zig FFI layer +:orientation: shallow + +== Purpose + +C-ABI exports bridging this cartridge's Idris2 ABI to the runtime. Consumed by +the adapter for protocol dispatch and by the trunk loader for mount. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” produces `zig-out/lib/libk8s.so`. +| `k8s_ffi.zig` | C-ABI exports (12 exports, 8 inline tests, 299 lines). +|=== + +== Invariants + +* **Null-pointer rejection** on `[*c]const u8` arguments before any + dereference. +* **Negative return values** carry documented error meanings; zero is + success (matching the trunk error-code convention). +* **No allocator leaks** β€” every allocation path pairs with `defer free`. + +== Test/proof surface + +8 inline `test "..."` block(s) in `k8s_ffi.zig`. Run with +`cd ffi && zig build test`. + +== Read-first + +. `k8s_ffi.zig` β€” C-ABI signatures and guards. +. Trunk `ffi/zig/src/catalogue.zig` for how this `.so` is loaded. diff --git a/cartridges/domains/container/k8s-mcp/ffi/build.zig b/cartridges/domains/container/k8s-mcp/ffi/build.zig new file mode 100644 index 0000000..9db3fd5 --- /dev/null +++ b/cartridges/domains/container/k8s-mcp/ffi/build.zig @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// K8s-MCP Cartridge β€” Zig FFI build configuration (Zig 0.15+). + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + // Shared ADR-0006 invoke-shim module (relative path up to boj-server trunk). + + const shim_mod = b.addModule("cartridge_shim", .{ + + .root_source_file = b.path("../../../ffi/zig/src/cartridge_shim.zig"), + + .target = target, + + .optimize = optimize, + + }); + + const k8s_mod = b.addModule("k8s_ffi", .{ + .root_source_file = b.path("k8s_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + k8s_mod.addImport("cartridge_shim", shim_mod); + + // ── Tests ──────────────────────────────────────────────────────── + const k8s_tests = b.addTest(.{ + .root_module = k8s_mod, + }); + + const run_tests = b.addRunArtifact(k8s_tests); + + const test_step = b.step("test", "Run k8s-mcp FFI tests"); + test_step.dependOn(&run_tests.step); + + // ── Shared library ────────────────────────────────────────────── + const lib_mod = b.createModule(.{ + .root_source_file = b.path("k8s_ffi.zig"), + .target = target, + .optimize = optimize, + }); + lib_mod.addImport("cartridge_shim", shim_mod); + + const lib = b.addLibrary(.{ + .name = "k8s_mcp", + .root_module = lib_mod, + .linkage = .dynamic, + }); + b.installArtifact(lib); + + const lib_step = b.step("lib", "Build shared library"); + lib_step.dependOn(&lib.step); +} diff --git a/cartridges/domains/container/k8s-mcp/ffi/cartridge_shim.zig b/cartridges/domains/container/k8s-mcp/ffi/cartridge_shim.zig new file mode 100644 index 0000000..0e399db --- /dev/null +++ b/cartridges/domains/container/k8s-mcp/ffi/cartridge_shim.zig @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// cartridge_shim.zig β€” Shared helpers for the ADR-0006 five-symbol +// cartridge ABI (`boj_cartridge_init / deinit / name / version / invoke`). +// +// The shim centralises the seven-code return convention, NUL-argument +// guards, tool-name comparison, and the buffer-too-small path so each +// cartridge's `boj_cartridge_invoke` can stay short β€” typically a tool +// table plus `shim.writeResult(...)`. +// +// Cartridges import this file by relative path (no build-graph change +// needed). Example: +// +// const shim = @import("../../../ffi/zig/src/cartridge_shim.zig"); +// +// export fn boj_cartridge_invoke( +// tool_name: [*c]const u8, +// json_args: [*c]const u8, +// out_buf: [*c]u8, +// in_out_len: [*c]usize, +// ) callconv(.c) i32 { +// _ = json_args; +// if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; +// const body = if (shim.toolIs(tool_name, "foo")) "{\"result\":{}}" +// else return shim.RC_UNKNOWN_TOOL; +// return shim.writeResult(out_buf, in_out_len, body); +// } + +const std = @import("std"); + +// ── Return codes (ADR-0006 Β§Return codes) ──────────────────────────── +// +// Frozen by ADR-0006. New failure modes compose these via the error +// JSON body β€” the integer surface does not grow without a follow-up ADR. + +pub const RC_SUCCESS: i32 = 0; +pub const RC_UNKNOWN_TOOL: i32 = -1; +pub const RC_BAD_ARGS: i32 = -2; +pub const RC_BUFFER_TOO_SMALL: i32 = -3; +pub const RC_RUNTIME_ERROR: i32 = -4; +pub const RC_PANIC: i32 = -5; +pub const RC_AUTH_DENIED: i32 = -6; + +// ── Invoke-path helpers ────────────────────────────────────────────── + +/// True if any of the three mandatory `boj_cartridge_invoke` output-path +/// pointers is null. Use at the top of every invoke to short-circuit to +/// `RC_BAD_ARGS`. +pub fn invokeArgsNull( + tool_name: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) bool { + return tool_name == null or out_buf == null or in_out_len == null; +} + +/// Compare a C-NUL-terminated tool-name pointer against a Zig string +/// literal. Caller must have already verified `tool_name` is non-null +/// (usually via `invokeArgsNull`). +pub fn toolIs(tool_name: [*c]const u8, expected: []const u8) bool { + const s = std.mem.span(@as([*:0]const u8, @ptrCast(tool_name))); + return std.mem.eql(u8, s, expected); +} + +/// Copy `body` into `out_buf[0..*in_out_len]` (as a capacity) and update +/// `*in_out_len` to the number of bytes written. Returns `RC_SUCCESS`. +/// +/// If `body.len` exceeds the current capacity stored in `*in_out_len`, +/// sets `*in_out_len` to the required size and returns +/// `RC_BUFFER_TOO_SMALL` β€” the caller is then expected to re-allocate +/// and retry, per ADR-0006 Β§Memory ownership. +/// +/// Caller must have already verified that `out_buf` and `in_out_len` +/// are non-null. +pub fn writeResult( + out_buf: [*c]u8, + in_out_len: [*c]usize, + body: []const u8, +) i32 { + const cap = in_out_len.*; + if (body.len > cap) { + in_out_len.* = body.len; + return RC_BUFFER_TOO_SMALL; + } + @memcpy(out_buf[0..body.len], body); + in_out_len.* = body.len; + return RC_SUCCESS; +} + +// ── Tests ──────────────────────────────────────────────────────────── + +test "writeResult: body fits, writes and sets length" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); + try std.testing.expectEqualStrings("hello", buf[0..len]); +} + +test "writeResult: too small returns -3 and sets required length" { + var buf: [2]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_BUFFER_TOO_SMALL, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: exact-fit succeeds" { + var buf: [5]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: empty body" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, ""); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 0), len); +} + +test "toolIs: matches and rejects" { + const name: [*:0]const u8 = "foo"; + try std.testing.expect(toolIs(@ptrCast(name), "foo")); + try std.testing.expect(!toolIs(@ptrCast(name), "bar")); + try std.testing.expect(!toolIs(@ptrCast(name), "foobar")); + try std.testing.expect(!toolIs(@ptrCast(name), "fo")); +} + +test "invokeArgsNull: detects each null slot" { + var buf: [4]u8 = undefined; + var len: usize = 4; + const name: [*:0]const u8 = "x"; + try std.testing.expect(!invokeArgsNull(@ptrCast(name), &buf, &len)); + try std.testing.expect(invokeArgsNull(null, &buf, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), null, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), &buf, null)); +} diff --git a/cartridges/domains/container/k8s-mcp/ffi/k8s_ffi.zig b/cartridges/domains/container/k8s-mcp/ffi/k8s_ffi.zig new file mode 100644 index 0000000..ff712a9 --- /dev/null +++ b/cartridges/domains/container/k8s-mcp/ffi/k8s_ffi.zig @@ -0,0 +1,377 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// K8s-MCP Cartridge β€” Zig FFI bridge for Kubernetes orchestration. +// +// Implements the cluster connection state machine from SafeK8s.idr. +// Ensures all operations require cluster auth and namespace selection, +// preventing cross-namespace operations and unauthenticated access. + +const std = @import("std"); + +// ═══════════════════════════════════════════════════════════════════════ +// Types (must match K8sMcp.SafeK8s encodings) +// ═══════════════════════════════════════════════════════════════════════ + +pub const K8sState = enum(c_int) { + disconnected = 0, + cluster_connected = 1, + namespace_selected = 2, + operating = 3, + k8s_error = 4, +}; + +pub const K8sTool = enum(c_int) { + kubectl = 1, + helm = 2, + kustomize = 3, +}; + +// ═══════════════════════════════════════════════════════════════════════ +// Cluster State Machine +// ═══════════════════════════════════════════════════════════════════════ + +const MAX_CLUSTERS: usize = 4; +const MAX_NS_LEN: usize = 63; // Kubernetes namespace max length + +const ClusterSlot = struct { + active: bool, + state: K8sState, + tool: K8sTool, + namespace: [MAX_NS_LEN + 1]u8, + ns_len: usize, +}; + +const empty_slot: ClusterSlot = .{ + .active = false, + .state = .disconnected, + .tool = .kubectl, + .namespace = [_]u8{0} ** (MAX_NS_LEN + 1), + .ns_len = 0, +}; + +var clusters: [MAX_CLUSTERS]ClusterSlot = [_]ClusterSlot{empty_slot} ** MAX_CLUSTERS; + +var mutex: std.Thread.Mutex = .{}; + +/// Validate a state transition (matches Idris2 canTransition). +fn isValidTransition(from: K8sState, to: K8sState) bool { + return switch (from) { + .disconnected => to == .cluster_connected, + .cluster_connected => to == .namespace_selected or to == .disconnected, + .namespace_selected => to == .operating or to == .namespace_selected or to == .cluster_connected, + .operating => to == .namespace_selected or to == .k8s_error, + .k8s_error => to == .disconnected, + }; +} + +/// Copy a namespace string into a slot buffer. +fn setNamespace(slot: *ClusterSlot, ns: [*:0]const u8) void { + var i: usize = 0; + while (ns[i] != 0 and i < MAX_NS_LEN) : (i += 1) { + slot.namespace[i] = ns[i]; + } + slot.namespace[i] = 0; + slot.ns_len = i; +} + +/// Connect to a Kubernetes cluster. Returns slot index or -1 on failure. +pub export fn k8s_connect(tool: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + for (&clusters, 0..) |*slot, i| { + if (!slot.active) { + slot.active = true; + slot.state = .cluster_connected; + slot.tool = @enumFromInt(tool); + slot.ns_len = 0; + slot.namespace[0] = 0; + return @intCast(i); + } + } + return -1; // No slots available +} + +/// Select a namespace on a connected cluster. +pub export fn k8s_select_namespace(slot_idx: c_int, ns: [*:0]const u8) c_int { + mutex.lock(); + defer mutex.unlock(); + if (slot_idx < 0 or slot_idx >= MAX_CLUSTERS) return -1; + const idx: usize = @intCast(slot_idx); + if (!clusters[idx].active) return -1; + if (!isValidTransition(clusters[idx].state, .namespace_selected)) return -2; + + setNamespace(&clusters[idx], ns); + clusters[idx].state = .namespace_selected; + return 0; +} + +/// Begin an operation (transition NamespaceSelected -> Operating). +pub export fn k8s_begin_operation(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + if (slot_idx < 0 or slot_idx >= MAX_CLUSTERS) return -1; + const idx: usize = @intCast(slot_idx); + if (!clusters[idx].active) return -1; + if (!isValidTransition(clusters[idx].state, .operating)) return -2; + + clusters[idx].state = .operating; + return 0; +} + +/// End an operation (transition Operating -> NamespaceSelected). +pub export fn k8s_end_operation(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + if (slot_idx < 0 or slot_idx >= MAX_CLUSTERS) return -1; + const idx: usize = @intCast(slot_idx); + if (!clusters[idx].active) return -1; + if (!isValidTransition(clusters[idx].state, .namespace_selected)) return -2; + + clusters[idx].state = .namespace_selected; + return 0; +} + +/// Disconnect from a cluster (transition ClusterConnected -> Disconnected). +pub export fn k8s_disconnect(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + if (slot_idx < 0 or slot_idx >= MAX_CLUSTERS) return -1; + const idx: usize = @intCast(slot_idx); + if (!clusters[idx].active) return -1; + if (!isValidTransition(clusters[idx].state, .disconnected)) return -2; + + clusters[idx].active = false; + clusters[idx].state = .disconnected; + clusters[idx].ns_len = 0; + clusters[idx].namespace[0] = 0; + return 0; +} + +/// Get the state of a cluster session. +pub export fn k8s_state(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + if (slot_idx < 0 or slot_idx >= MAX_CLUSTERS) return -1; + const idx: usize = @intCast(slot_idx); + if (!clusters[idx].active) return @intFromEnum(K8sState.disconnected); + return @intFromEnum(clusters[idx].state); +} + +/// Validate a state transition (C-ABI export). +pub export fn k8s_can_transition(from: c_int, to: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + const f: K8sState = @enumFromInt(from); + const t: K8sState = @enumFromInt(to); + return if (isValidTransition(f, t)) 1 else 0; +} + +/// Reset all cluster sessions (for testing). +pub export fn k8s_reset() void { + mutex.lock(); + defer mutex.unlock(); + for (&clusters) |*slot| { + slot.* = empty_slot; + } +} + +// ═══════════════════════════════════════════════════════════════════════ +// Standard Cartridge Interface (loader expects these 4 C-ABI symbols) +// ═══════════════════════════════════════════════════════════════════════ + +/// Initialise the k8s-mcp cartridge. Resets all cluster slots. +pub export fn boj_cartridge_init() c_int { + k8s_reset(); + return 0; +} + +/// Deinitialise the k8s-mcp cartridge. Resets all cluster slots. +pub export fn boj_cartridge_deinit() void { + k8s_reset(); +} + +/// Return the cartridge name as a null-terminated C string. +pub export fn boj_cartridge_name() [*:0]const u8 { + mutex.lock(); + defer mutex.unlock(); + return "k8s-mcp"; +} + +/// Return the cartridge version as a null-terminated C string. +pub export fn boj_cartridge_version() [*:0]const u8 { + mutex.lock(); + defer mutex.unlock(); + return "0.1.0"; +} + +// ═══════════════════════════════════════════════════════════════════════ +// ADR-0006 dispatch (boj_cartridge_invoke, 5th standard symbol) +// ═══════════════════════════════════════════════════════════════════════ + +const shim = @import("cartridge_shim.zig"); + +/// Dispatch the cartridge.json MCP tools. Grade D Alpha β€” each arm +/// returns a stub JSON body shaped to the tool's intended response. +export fn boj_cartridge_invoke( + tool_name: [*c]const u8, + json_args: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) callconv(.c) i32 { + _ = json_args; + if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; + + const body: []const u8 = if (shim.toolIs(tool_name, "k8s_connect")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "k8s_list_pods")) + "{\"result\":{\"items\":[],\"count\":0,\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "k8s_get_pod")) + "{\"result\":{\"metadata\":{},\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "k8s_list_deployments")) + "{\"result\":{\"items\":[],\"count\":0,\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "k8s_apply")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "k8s_delete")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "k8s_logs")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "k8s_disconnect")) + "{\"result\":{\"status\":\"stub\"}}" +else + return shim.RC_UNKNOWN_TOOL; + + return shim.writeResult(out_buf, in_out_len, body); +} + +// ═══════════════════════════════════════════════════════════════════════ +// Tests +// ═══════════════════════════════════════════════════════════════════════ + +test "connect, select namespace, and disconnect" { + k8s_reset(); + const slot = k8s_connect(@intFromEnum(K8sTool.kubectl)); + try std.testing.expect(slot >= 0); + try std.testing.expectEqual(@as(c_int, @intFromEnum(K8sState.cluster_connected)), k8s_state(slot)); + try std.testing.expectEqual(@as(c_int, 0), k8s_select_namespace(slot, "default")); + try std.testing.expectEqual(@as(c_int, @intFromEnum(K8sState.namespace_selected)), k8s_state(slot)); + // Must deselect namespace (go back to cluster_connected) before disconnect + try std.testing.expectEqual(@as(c_int, -2), k8s_disconnect(slot)); +} + +test "cannot operate without namespace" { + k8s_reset(); + const slot = k8s_connect(@intFromEnum(K8sTool.helm)); + // Cannot begin operation from cluster_connected β€” need namespace first + try std.testing.expectEqual(@as(c_int, -2), k8s_begin_operation(slot)); +} + +test "full operation lifecycle" { + k8s_reset(); + const slot = k8s_connect(@intFromEnum(K8sTool.kubectl)); + try std.testing.expectEqual(@as(c_int, 0), k8s_select_namespace(slot, "production")); + try std.testing.expectEqual(@as(c_int, 0), k8s_begin_operation(slot)); + try std.testing.expectEqual(@as(c_int, @intFromEnum(K8sState.operating)), k8s_state(slot)); + try std.testing.expectEqual(@as(c_int, 0), k8s_end_operation(slot)); + try std.testing.expectEqual(@as(c_int, @intFromEnum(K8sState.namespace_selected)), k8s_state(slot)); +} + +test "namespace switching" { + k8s_reset(); + const slot = k8s_connect(@intFromEnum(K8sTool.kustomize)); + try std.testing.expectEqual(@as(c_int, 0), k8s_select_namespace(slot, "staging")); + // Can switch namespace (NamespaceSelected -> NamespaceSelected) + try std.testing.expectEqual(@as(c_int, 0), k8s_select_namespace(slot, "production")); + try std.testing.expectEqual(@as(c_int, @intFromEnum(K8sState.namespace_selected)), k8s_state(slot)); +} + +test "cannot disconnect with namespace selected" { + k8s_reset(); + const slot = k8s_connect(@intFromEnum(K8sTool.kubectl)); + _ = k8s_select_namespace(slot, "default"); + // Cannot disconnect directly β€” must deselect namespace first + try std.testing.expectEqual(@as(c_int, -2), k8s_disconnect(slot)); +} + +test "state transition validation" { + // Valid transitions + try std.testing.expectEqual(@as(c_int, 1), k8s_can_transition(0, 1)); // disconnected -> connected + try std.testing.expectEqual(@as(c_int, 1), k8s_can_transition(1, 2)); // connected -> ns_selected + try std.testing.expectEqual(@as(c_int, 1), k8s_can_transition(2, 3)); // ns_selected -> operating + try std.testing.expectEqual(@as(c_int, 1), k8s_can_transition(3, 2)); // operating -> ns_selected + try std.testing.expectEqual(@as(c_int, 1), k8s_can_transition(2, 2)); // ns_selected -> ns_selected + try std.testing.expectEqual(@as(c_int, 1), k8s_can_transition(2, 1)); // ns_selected -> connected + try std.testing.expectEqual(@as(c_int, 1), k8s_can_transition(1, 0)); // connected -> disconnected + try std.testing.expectEqual(@as(c_int, 1), k8s_can_transition(3, 4)); // operating -> error + try std.testing.expectEqual(@as(c_int, 1), k8s_can_transition(4, 0)); // error -> disconnected + // Invalid transitions + try std.testing.expectEqual(@as(c_int, 0), k8s_can_transition(0, 2)); // disconnected -> ns_selected + try std.testing.expectEqual(@as(c_int, 0), k8s_can_transition(0, 3)); // disconnected -> operating + try std.testing.expectEqual(@as(c_int, 0), k8s_can_transition(1, 3)); // connected -> operating + try std.testing.expectEqual(@as(c_int, 0), k8s_can_transition(2, 0)); // ns_selected -> disconnected +} + +test "deselect namespace then disconnect" { + k8s_reset(); + const slot = k8s_connect(@intFromEnum(K8sTool.helm)); + _ = k8s_select_namespace(slot, "monitoring"); + // Deselect namespace (NamespaceSelected -> ClusterConnected) + // We need a way to go back: ns_selected -> cluster_connected is valid + // but we must use the transition through the state machine properly. + // The select_namespace function only goes TO namespace_selected. + // For deselect, we need the state to go to cluster_connected. + // This is handled by disconnect returning -2 and the user going back via state machine. + // Actually, canTransition(NamespaceSelected, ClusterConnected) = True, + // so we need a deselect function. For now, test the transition validator. + try std.testing.expectEqual(@as(c_int, 1), k8s_can_transition(2, 1)); +} + +test "max clusters enforced" { + k8s_reset(); + var slots: [MAX_CLUSTERS]c_int = undefined; + for (&slots) |*s| { + s.* = k8s_connect(@intFromEnum(K8sTool.kubectl)); + try std.testing.expect(s.* >= 0); + } + // Next connect should fail + try std.testing.expectEqual(@as(c_int, -1), k8s_connect(@intFromEnum(K8sTool.kubectl))); +} + +// ═══════════════════════════════════════════════════════════════════════ +// ADR-0006 invoke dispatch tests +// ═══════════════════════════════════════════════════════════════════════ + +test "invoke: each declared tool succeeds" { + var buf: [256]u8 = undefined; + const tools = [_][]const u8{ + "k8s_connect", + "k8s_list_pods", + "k8s_get_pod", + "k8s_list_deployments", + "k8s_apply", + "k8s_delete", + "k8s_logs", + "k8s_disconnect", + }; + for (tools) |t| { + var len: usize = buf.len; + const rc = boj_cartridge_invoke(t.ptr, "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, 0), rc); + try std.testing.expect(std.mem.indexOf(u8, buf[0..len], "result") != null); + } +} + +test "invoke: unknown tool returns -1" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("nope", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -1), rc); +} + +test "invoke: buffer too small returns -3" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("k8s_connect", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -3), rc); + try std.testing.expect(len > 4); +} diff --git a/cartridges/domains/container/k8s-mcp/mod.js b/cartridges/domains/container/k8s-mcp/mod.js new file mode 100644 index 0000000..9c84503 --- /dev/null +++ b/cartridges/domains/container/k8s-mcp/mod.js @@ -0,0 +1,85 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// k8s-mcp/mod.js -- k8s gateway. + +const BASE_URL = Deno.env.get("K8S_MCP_BACKEND_URL") ?? "http://127.0.0.1:7715"; + +async function post(path, payload) { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), 15000); + try { + const r = await fetch(`${BASE_URL}${path}`, { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), signal: ctrl.signal, + }); + const data = await r.json().catch(() => ({ success: false, error: "non-JSON response" })); + return { status: r.status, data }; + } catch (e) { + if (e.name === "AbortError") return { status: 504, data: { success: false, error: "k8s-mcp backend timed out" } }; + return { status: 503, data: { success: false, error: `k8s-mcp backend unavailable: ${e.message}` } }; + } finally { clearTimeout(t); } +} + +export async function handleTool(toolName, args) { + switch (toolName) { + + case "k8s_connect": { + const { kubeconfig, context } = args ?? {}; + const payload = { }; + if (kubeconfig !== undefined) payload.kubeconfig = kubeconfig; + if (context !== undefined) payload.context = context; + return post("/api/v1/connect", payload); + } + case "k8s_list_pods": { + const { slot, namespace, label_selector } = args ?? {}; + if (!slot) return { status: 400, data: { error: "slot is required" } }; + const payload = { slot }; + if (namespace !== undefined) payload.namespace = namespace; + if (label_selector !== undefined) payload.label_selector = label_selector; + return post("/api/v1/list-pods", payload); + } + case "k8s_get_pod": { + const { slot, namespace, name } = args ?? {}; + if (!slot || !namespace || !name) return { status: 400, data: { error: "slot is required" } }; + const payload = { slot, namespace, name }; + return post("/api/v1/get-pod", payload); + } + case "k8s_list_deployments": { + const { slot, namespace } = args ?? {}; + if (!slot) return { status: 400, data: { error: "slot is required" } }; + const payload = { slot }; + if (namespace !== undefined) payload.namespace = namespace; + return post("/api/v1/list-deployments", payload); + } + case "k8s_apply": { + const { slot, manifest } = args ?? {}; + if (!slot || !manifest) return { status: 400, data: { error: "slot is required" } }; + const payload = { slot, manifest }; + return post("/api/v1/apply", payload); + } + case "k8s_delete": { + const { slot, kind, namespace, name } = args ?? {}; + if (!slot || !kind || !namespace || !name) return { status: 400, data: { error: "slot is required" } }; + const payload = { slot, kind, namespace, name }; + return post("/api/v1/delete", payload); + } + case "k8s_logs": { + const { slot, namespace, pod, container, tail } = args ?? {}; + if (!slot || !namespace || !pod) return { status: 400, data: { error: "slot is required" } }; + const payload = { slot, namespace, pod }; + if (container !== undefined) payload.container = container; + if (tail !== undefined) payload.tail = tail; + return post("/api/v1/logs", payload); + } + case "k8s_disconnect": { + const { slot } = args ?? {}; + if (!slot) return { status: 400, data: { error: "slot is required" } }; + const payload = { slot }; + return post("/api/v1/disconnect", payload); + } + + default: + return { status: 404, data: { error: `Unknown tool: ${toolName}` } }; + } +} diff --git a/cartridges/domains/container/k8s-mcp/panels/manifest.json b/cartridges/domains/container/k8s-mcp/panels/manifest.json new file mode 100644 index 0000000..7c32424 --- /dev/null +++ b/cartridges/domains/container/k8s-mcp/panels/manifest.json @@ -0,0 +1,86 @@ +{ + "$schema": "https://panll.dev/schemas/panel-manifest/v1.json", + "spdx": "MPL-2.0", + "cartridge": "k8s-mcp", + "domain": "Kubernetes", + "version": "0.1.0", + "panels": [ + { + "id": "k8s-status", + "title": "Cluster Status", + "description": "Kubernetes API server connectivity and cluster health", + "type": "status-indicator", + "data_source": { + "endpoint": "/cartridge/k8s-mcp/invoke", + "method": "POST", + "body": { "tool": "status" }, + "refresh_interval_ms": 5000 + }, + "widgets": [ + { + "type": "state-badge", + "field": "state", + "states": { + "ready": { "color": "#2ecc71", "icon": "server" }, + "degraded": { "color": "#f39c12", "icon": "alert-circle" }, + "error": { "color": "#e74c3c", "icon": "alert-triangle" } + } + }, + { "type": "text", "field": "version", "label": "Version" }, + { "type": "text", "field": "cluster_name", "label": "Cluster" } + ] + }, + { + "id": "k8s-workloads", + "title": "Workload Summary", + "description": "Pod, Deployment, StatefulSet, and DaemonSet counts across namespaces", + "type": "metric", + "data_source": { + "endpoint": "/cartridge/k8s-mcp/invoke", + "method": "POST", + "body": { "tool": "workload_summary" }, + "refresh_interval_ms": 10000 + }, + "widgets": [ + { "type": "counter", "field": "pods_running", "label": "Pods Running", "icon": "box" }, + { "type": "counter", "field": "pods_pending", "label": "Pods Pending", "icon": "clock" }, + { "type": "counter", "field": "deployments", "label": "Deployments", "icon": "layers" }, + { "type": "counter", "field": "statefulsets", "label": "StatefulSets", "icon": "database" } + ] + }, + { + "id": "k8s-services", + "title": "Service & Ingress", + "description": "Service endpoints, ingress routes, and load balancer status", + "type": "metric", + "data_source": { + "endpoint": "/cartridge/k8s-mcp/invoke", + "method": "POST", + "body": { "tool": "service_summary" }, + "refresh_interval_ms": 15000 + }, + "widgets": [ + { "type": "counter", "field": "services", "label": "Services", "icon": "globe" }, + { "type": "counter", "field": "ingresses", "label": "Ingresses", "icon": "arrow-right-circle" }, + { "type": "counter", "field": "endpoints_healthy", "label": "Healthy Endpoints", "icon": "check-circle" } + ] + }, + { + "id": "k8s-node-resources", + "title": "Node Resources", + "description": "Aggregate CPU, memory, and storage across cluster nodes", + "type": "metric", + "data_source": { + "endpoint": "/cartridge/k8s-mcp/invoke", + "method": "POST", + "body": { "tool": "node_resources" }, + "refresh_interval_ms": 15000 + }, + "widgets": [ + { "type": "counter", "field": "node_count", "label": "Nodes", "icon": "server" }, + { "type": "gauge", "field": "cpu_utilization", "label": "CPU %", "min": 0, "max": 100 }, + { "type": "gauge", "field": "memory_utilization", "label": "Memory %", "min": 0, "max": 100 } + ] + } + ] +} diff --git a/cartridges/domains/container/stapeln-mcp/README.adoc b/cartridges/domains/container/stapeln-mcp/README.adoc new file mode 100644 index 0000000..4fc5b19 --- /dev/null +++ b/cartridges/domains/container/stapeln-mcp/README.adoc @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += stapeln-mcp +Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk> +:spdx: MPL-2.0 +:tier: Ayo +:domain: Container Orchestration +:protocols: MCP, REST + +== Overview + +Stapeln container stack manager. Deploy, scale, and monitor container stacks using Chainguard base images. + +== Tools (4) + +[cols="2,4"] +|=== +| Tool | Description + +| `stapeln_list_stacks` | List all deployed container stacks with names, replica counts, and health. +| `stapeln_deploy` | Deploy a named stack with a specified replica count. +| `stapeln_scale` | Scale an existing stack to a new replica count. +| `stapeln_get_health` | Get health status of a named stack: healthy / degraded / unhealthy / unknown. +|=== + +== Architecture + +Three-layer stack per the BoJ cartridge standard: + +[cols="1,1,3"] +|=== +| Layer | Language | Purpose + +| ABI | Idris2 | Formally verified state machine with dependent-type proofs +| FFI | Zig | C-ABI implementation: session pool, action metrics, thread safety +| Adapter| Zig | Adapter bridge to the BoJ unified adapter protocol (zig predecessors sidelined) +|=== + +== Building + +[source,bash] +---- +# ABI (Idris2) +cd abi && idris2 --check *.idr + +# FFI (Zig) +cd ffi && zig build +cd ffi && zig build test + +# Adapter (Zig) +cd adapter && zig build +---- + +== Status + +Alpha (CRG D). Cartridge metadata declared in `cartridge.json`; 4 tool(s) exposed. +Not yet dogfooded externally. See `docs/READINESS.md` at repo root for the +project-wide grade table and `docs/practice/DOGFOOD-LOG.adoc` for usage evidence. diff --git a/cartridges/domains/container/stapeln-mcp/abi/README.adoc b/cartridges/domains/container/stapeln-mcp/abi/README.adoc new file mode 100644 index 0000000..75cf89c --- /dev/null +++ b/cartridges/domains/container/stapeln-mcp/abi/README.adoc @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += stapeln-mcp / abi β€” Idris2 ABI layer +:orientation: shallow + +== Purpose + +Cartridge-local protocol types for `stapeln-mcp`. Defines the data, tool, and +transition vocabulary this cartridge speaks. Inherits safety lemmas and the +matrix-cell identifier scheme from the trunk ABI at `src/abi/`. + +== Files + +1 Idris2 module(s), ~52 lines total. Lead module: +`Protocol.idr`. + +No `.ipkg` β€” consumed directly by wider BoJ callers; type-checks inside the +trunk `boj.ipkg`. + +== Invariants + +* `%default total` at every module top. +* Zero `believe_me`, `assert_total`, `postulate`, `Admitted`, or + `sorry` (the trunk holds the four declared `believe_me` sites; leaf + cartridges carry none). + +== Test/proof surface + +Thin module(s), no inline tests. Proof surface = type-check success under the +trunk `idris2 --check`. + +== Read-first + +. `Protocol.idr` β€” cartridge-local data records and enums. +. Trunk `src/abi/Boj/Catalogue.idr` for how this cell participates in the + 2D matrix. diff --git a/cartridges/domains/container/stapeln-mcp/abi/Stapeln/Protocol.idr b/cartridges/domains/container/stapeln-mcp/abi/Stapeln/Protocol.idr new file mode 100644 index 0000000..df83fa5 --- /dev/null +++ b/cartridges/domains/container/stapeln-mcp/abi/Stapeln/Protocol.idr @@ -0,0 +1,52 @@ +-- SPDX-License-Identifier: MPL-2.0 +-- Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +-- +-- Stapeln ABI β€” container orchestration protocol definitions. + +module Stapeln.Protocol + +import Data.Nat + +||| Stapeln operation codes. +public export +data StapelnOp + = ListStacks + | Deploy + | Scale + | GetHealth + +||| Container health status. +public export +data HealthStatus = Healthy | Degraded | Unhealthy | Unknown + +||| Stack identifier β€” must be non-empty. +public export +record StackId where + constructor MkStackId + name : String + {auto prf : NonEmpty (unpack name)} + +||| Replica count β€” guaranteed positive for deployed stacks. +public export +record ReplicaCount where + constructor MkReplicaCount + desired : Nat + running : Nat + +||| A deployed stack. +public export +record Stack where + constructor MkStack + stackId : StackId + replicas : ReplicaCount + health : HealthStatus + +||| Proof: scaling to zero is valid (desired can be 0 for stopped stacks). +export +zeroReplicasValid : ReplicaCount +zeroReplicasValid = MkReplicaCount 0 0 + +||| Proof: running replicas never exceed desired in a healthy system. +export +healthyInvariant : (r : ReplicaCount) -> LTE r.running r.desired -> HealthStatus +healthyInvariant r _ = Healthy diff --git a/cartridges/domains/container/stapeln-mcp/adapter/README.adoc b/cartridges/domains/container/stapeln-mcp/adapter/README.adoc new file mode 100644 index 0000000..6c289e0 --- /dev/null +++ b/cartridges/domains/container/stapeln-mcp/adapter/README.adoc @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += stapeln-mcp / adapter β€” protocol bridge +:orientation: shallow + +== Purpose + +Exposes this cartridge's FFI over the wire protocols declared in +`cartridge.json`. Dispatches tool invocations to the C-ABI exports at +`../ffi/` and formats responses per protocol. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” depends on the sibling `ffi` target. +| `stapeln_adapter.zig` | Protocol dispatch (121 lines). Tool catalogue matches `cartridge.json`. +| `SIDELINED-*.v.adoc` | zig predecessor(s). Not built β€” zig banned 2026-04-10. +|=== + +== Invariants + +* **Stateless** β€” all state lives behind the FFI, never in the adapter. +* **Response passthrough** β€” whatever the FFI returns goes back to the wire + unmodified (no embellishment, no silent recovery). +* **Cartridge.json is source of truth** for the tool catalogue; drift between + adapter and manifest is a CI failure. + +== Test/proof surface + +No inline tests at the adapter layer β€” protocol routing is exercised by the +trunk seam tests at `ffi/zig/src/seams.zig`. + +== Read-first + +. `stapeln_adapter.zig` β€” dispatch function and per-protocol routers. +. `../cartridge.json` β€” the tool manifest the dispatcher mirrors. diff --git a/cartridges/domains/container/stapeln-mcp/adapter/build.zig b/cartridges/domains/container/stapeln-mcp/adapter/build.zig new file mode 100644 index 0000000..b0f5280 --- /dev/null +++ b/cartridges/domains/container/stapeln-mcp/adapter/build.zig @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// stapeln-mcp/adapter/build.zig + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + const ffi_mod = b.createModule(.{ + .root_source_file = b.path("../ffi/stapeln_ffi.zig"), + .target = target, .optimize = optimize, + }); + const adapter = b.addExecutable(.{ + .name = "stapeln_adapter", + .root_source_file = b.path("stapeln_adapter.zig"), + .target = target, .optimize = optimize, + }); + adapter.root_module.addImport("stapeln_ffi", ffi_mod); + b.installArtifact(adapter); + const run_step = b.step("run", "Run the stapeln-mcp adapter"); + run_step.dependOn(&b.addRunArtifact(adapter).step); + const tests = b.addTest(.{ + .root_source_file = b.path("stapeln_adapter.zig"), + .target = target, .optimize = optimize, + }); + tests.root_module.addImport("stapeln_ffi", ffi_mod); + const test_step = b.step("test", "Run stapeln-mcp adapter tests"); + test_step.dependOn(&b.addRunArtifact(tests).step); +} diff --git a/cartridges/domains/container/stapeln-mcp/adapter/stapeln_adapter.zig b/cartridges/domains/container/stapeln-mcp/adapter/stapeln_adapter.zig new file mode 100644 index 0000000..5a181c8 --- /dev/null +++ b/cartridges/domains/container/stapeln-mcp/adapter/stapeln_adapter.zig @@ -0,0 +1,121 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// stapeln-mcp/adapter/stapeln_adapter.zig -- Unified three-protocol adapter. +// +// Replaces the banned stapeln_adapter.v (zig, removed 2026-04-12). +// +// REST :9112 gRPC-compat :9113 GraphQL :9114 +// Stapeln container stack manager. Deploy, scale, and monitor container stacks using Chainguard base i +// Tools: stapeln_list_stacks, stapeln_deploy, stapeln_scale, stapeln_get_health + +const std = @import("std"); +const ffi = @import("stapeln_ffi"); + +const REST_PORT: u16 = 9112; +const GRPC_PORT: u16 = 9113; +const GQL_PORT: u16 = 9114; +const MAX_CONN_BUF: usize = 16 * 1024; + +fn okJson(buf: []u8, msg: []const u8) []u8 { + const n = std.fmt.bufPrint(buf, "{{\"success\":true,\"message\":\"{s}\"}}", .{msg}) catch return buf[0..0]; + return n; +} +fn errJson(buf: []u8, msg: []const u8) []u8 { + const n = std.fmt.bufPrint(buf, "{{\"success\":false,\"error\":\"{s}\"}}", .{msg}) catch return buf[0..0]; + return n; +} +fn statusJson(buf: []u8) []u8 { + const n = std.fmt.bufPrint(buf, "{{\"success\":true,\"state\":\"ready\",\"service\":\"stapeln-mcp\"}}", .{}) catch return buf[0..0]; + return n; +} + +const Response = struct { status: u16, body: []u8 }; + +fn dispatch(tool: []const u8, body: []const u8, resp: []u8) Response { + _ = body; + if (std.mem.eql(u8, tool, "stapeln_list_stacks")) return .{ .status = 200, .body = okJson(resp, "stapeln_list_stacks forwarded") }; + if (std.mem.eql(u8, tool, "stapeln_deploy")) return .{ .status = 200, .body = okJson(resp, "stapeln_deploy forwarded") }; + if (std.mem.eql(u8, tool, "stapeln_scale")) return .{ .status = 200, .body = okJson(resp, "stapeln_scale forwarded") }; + if (std.mem.eql(u8, tool, "stapeln_get_health")) return .{ .status = 200, .body = okJson(resp, "stapeln_get_health forwarded") }; + if (std.mem.eql(u8, tool, "status") or std.mem.eql(u8, tool, "health")) + return .{ .status = 200, .body = statusJson(resp) }; + return .{ .status = 404, .body = errJson(resp, "Unknown tool") }; +} + +fn dispatchRest(path: []const u8, body: []const u8, resp: []u8) Response { + if (std.mem.startsWith(u8, path, "/tools/")) return dispatch(path["/tools/".len..], body, resp); + if (std.mem.eql(u8, path, "/status") or std.mem.eql(u8, path, "/health")) return .{ .status = 200, .body = statusJson(resp) }; + return .{ .status = 404, .body = errJson(resp, "Not found") }; +} + +fn dispatchGrpc(path: []const u8, body: []const u8, resp: []u8) Response { + const prefix = "/Stapelnservice/"; + if (!std.mem.startsWith(u8, path, prefix)) + return .{ .status = 404, .body = errJson(resp, "Not a recognized gRPC path") }; + const method = path[prefix.len..]; + const tool = blk: { + if (std.mem.eql(u8, method, "stapeln_list_stacks")) break :blk "stapeln_list_stacks"; + if (std.mem.eql(u8, method, "stapeln_deploy")) break :blk "stapeln_deploy"; + if (std.mem.eql(u8, method, "stapeln_scale")) break :blk "stapeln_scale"; + if (std.mem.eql(u8, method, "stapeln_get_health")) break :blk "stapeln_get_health"; + return .{ .status = 404, .body = errJson(resp, "Unknown gRPC method") }; + }; + return dispatch(tool, body, resp); +} + +fn dispatchGraphql(body: []const u8, resp: []u8) Response { + if (std.mem.indexOf(u8, body, "__schema") != null) return .{ .status = 200, .body = okJson(resp, "schema not supported") }; + if (std.mem.indexOf(u8, body, "list_stacks") != null) return dispatch("stapeln_list_stacks", body, resp); + if (std.mem.indexOf(u8, body, "deploy") != null) return dispatch("stapeln_deploy", body, resp); + if (std.mem.indexOf(u8, body, "scale") != null) return dispatch("stapeln_scale", body, resp); + if (std.mem.indexOf(u8, body, "get_health") != null) return dispatch("stapeln_get_health", body, resp); + return .{ .status = 200, .body = errJson(resp, "Unrecognised GraphQL operation") }; +} + +const Protocol = enum { rest, grpc, graphql }; + +fn handleConnection(conn: std.net.Server.Connection, proto: Protocol) void { + defer conn.stream.close(); + var in_buf: [MAX_CONN_BUF]u8 = undefined; + const n = conn.stream.read(&in_buf) catch return; + const req = in_buf[0..n]; + var path: []const u8 = "/"; + var body: []const u8 = ""; + if (n > 4) { + const line_end = std.mem.indexOf(u8, req, "\r\n") orelse req.len; + const first_line = req[0..line_end]; + const sp1 = std.mem.indexOfScalar(u8, first_line, ' ') orelse 0; + const rest_of = first_line[sp1 + 1 ..]; + const sp2 = std.mem.indexOfScalar(u8, rest_of, ' ') orelse rest_of.len; + path = rest_of[0..sp2]; + const body_sep = std.mem.indexOf(u8, req, "\r\n\r\n") orelse n; + body = req[@min(body_sep + 4, n)..]; + } + var resp_buf: [MAX_CONN_BUF]u8 = undefined; + const result = switch (proto) { + .rest => dispatchRest(path, body, &resp_buf), + .grpc => dispatchGrpc(path, body, &resp_buf), + .graphql => dispatchGraphql(body, &resp_buf), + }; + const ct: []const u8 = if (proto == .grpc) "application/grpc+json" else "application/json"; + var hdr_buf: [256]u8 = undefined; + const hdr = std.fmt.bufPrint(&hdr_buf, "HTTP/1.1 {d} OK\r\nContent-Type: {s}\r\nContent-Length: {d}\r\nConnection: close\r\n\r\n", .{ result.status, ct, result.body.len }) catch return; + _ = conn.stream.writeAll(hdr) catch return; + _ = conn.stream.writeAll(result.body) catch return; +} + +fn listenLoop(port: u16, proto: Protocol) void { + const addr = std.net.Address.initIp4(.{ 127, 0, 0, 1 }, port) catch return; + var server = addr.listen(.{ .reuse_address = true }) catch return; + defer server.deinit(); + while (true) { const conn = server.accept() catch continue; handleConnection(conn, proto); } +} + +pub fn main() !void { + ffi.stapeln_init(); + const t1 = try std.Thread.spawn(.{}, listenLoop, .{ REST_PORT, Protocol.rest }); + const t2 = try std.Thread.spawn(.{}, listenLoop, .{ GRPC_PORT, Protocol.grpc }); + const t3 = try std.Thread.spawn(.{}, listenLoop, .{ GQL_PORT, Protocol.graphql }); + t1.join(); t2.join(); t3.join(); +} diff --git a/cartridges/domains/container/stapeln-mcp/cartridge.json b/cartridges/domains/container/stapeln-mcp/cartridge.json new file mode 100644 index 0000000..1c154e9 --- /dev/null +++ b/cartridges/domains/container/stapeln-mcp/cartridge.json @@ -0,0 +1,102 @@ +{ + "$schema": "https://boj.dev/schemas/cartridge/v1.json", + "spdx": "MPL-2.0", + "copyright": "Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath)", + "name": "stapeln-mcp", + "version": "0.1.0", + "description": "Stapeln container stack manager. Deploy, scale, and monitor container stacks using Chainguard base images.", + "domain": "Container Orchestration", + "tier": "Ayo", + "protocols": [ + "MCP", + "REST" + ], + "auth": { + "method": "none", + "env_var": null, + "credential_source": null + }, + "api": { + "base_url": "local://stapeln-mcp", + "content_type": "application/json" + }, + "tools": [ + { + "name": "stapeln_list_stacks", + "description": "List all deployed container stacks with names, replica counts, and health.", + "inputSchema": { + "type": "object", + "properties": {}, + "required": [] + } + }, + { + "name": "stapeln_deploy", + "description": "Deploy a named stack with a specified replica count.", + "inputSchema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Stack name" + }, + "replicas": { + "type": "integer", + "description": "Number of replicas (default: 1)" + } + }, + "required": [ + "name" + ] + } + }, + { + "name": "stapeln_scale", + "description": "Scale an existing stack to a new replica count.", + "inputSchema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Stack name" + }, + "replicas": { + "type": "integer", + "description": "Target replica count" + } + }, + "required": [ + "name", + "replicas" + ] + } + }, + { + "name": "stapeln_get_health", + "description": "Get health status of a named stack: healthy / degraded / unhealthy / unknown.", + "inputSchema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Stack name" + } + }, + "required": [ + "name" + ] + } + } + ], + "ffi": { + "so_path": "ffi/zig-out/lib/libstapeln_mcp.so", + "abi_version": "ADR-0006", + "symbols": [ + "boj_cartridge_init", + "boj_cartridge_deinit", + "boj_cartridge_name", + "boj_cartridge_version", + "boj_cartridge_invoke" + ] + } +} diff --git a/cartridges/domains/container/stapeln-mcp/ffi/README.adoc b/cartridges/domains/container/stapeln-mcp/ffi/README.adoc new file mode 100644 index 0000000..4740fb5 --- /dev/null +++ b/cartridges/domains/container/stapeln-mcp/ffi/README.adoc @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += stapeln-mcp / ffi β€” Zig FFI layer **(stub)** +:orientation: shallow + +== Purpose + +C-ABI exports bridging this cartridge's Idris2 ABI to the runtime. Consumed by +the adapter for protocol dispatch and by the trunk loader for mount. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” produces `zig-out/lib/libstapeln.so`. +| `stapeln_ffi.zig` | C-ABI exports (4 exports, 3 inline tests, 44 lines). +|=== + +== Invariants + +* **Null-pointer rejection** on `[*c]const u8` arguments before any + dereference. +* **Negative return values** carry documented error meanings; zero is + success (matching the trunk error-code convention). +* **No allocator leaks** β€” every allocation path pairs with `defer free`. + +== Test/proof surface + +3 inline `test "..."` block(s) in `stapeln_ffi.zig`. Run with +`cd ffi && zig build test`. + +== Read-first + +. `stapeln_ffi.zig` β€” C-ABI signatures and guards. +. Trunk `ffi/zig/src/catalogue.zig` for how this `.so` is loaded. + +== Promotion gate + +Wire these exports to real backing state (not `return 0` / `return 1` +placeholders) before promoting past D. diff --git a/cartridges/domains/container/stapeln-mcp/ffi/build.zig b/cartridges/domains/container/stapeln-mcp/ffi/build.zig new file mode 100644 index 0000000..17e19b6 --- /dev/null +++ b/cartridges/domains/container/stapeln-mcp/ffi/build.zig @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const shim_mod = b.addModule("cartridge_shim", .{ + .root_source_file = b.path("../../../ffi/zig/src/cartridge_shim.zig"), + .target = target, + .optimize = optimize, + }); + + const ffi_mod = b.addModule("stapeln_mcp", .{ + .root_source_file = b.path("stapeln_ffi.zig"), + .target = target, + .optimize = optimize, + }); + ffi_mod.addImport("cartridge_shim", shim_mod); + + const lib = b.addLibrary(.{ + .name = "stapeln_mcp", + .root_module = ffi_mod, + .linkage = .dynamic, + }); + lib.linkLibC(); + b.installArtifact(lib); + + const tests = b.addTest(.{ .root_module = ffi_mod }); + tests.linkLibC(); + const run_tests = b.addRunArtifact(tests); + const test_step = b.step("test", "Run FFI tests"); + test_step.dependOn(&run_tests.step); +} + diff --git a/cartridges/domains/container/stapeln-mcp/ffi/cartridge_shim.zig b/cartridges/domains/container/stapeln-mcp/ffi/cartridge_shim.zig new file mode 100644 index 0000000..0e399db --- /dev/null +++ b/cartridges/domains/container/stapeln-mcp/ffi/cartridge_shim.zig @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// cartridge_shim.zig β€” Shared helpers for the ADR-0006 five-symbol +// cartridge ABI (`boj_cartridge_init / deinit / name / version / invoke`). +// +// The shim centralises the seven-code return convention, NUL-argument +// guards, tool-name comparison, and the buffer-too-small path so each +// cartridge's `boj_cartridge_invoke` can stay short β€” typically a tool +// table plus `shim.writeResult(...)`. +// +// Cartridges import this file by relative path (no build-graph change +// needed). Example: +// +// const shim = @import("../../../ffi/zig/src/cartridge_shim.zig"); +// +// export fn boj_cartridge_invoke( +// tool_name: [*c]const u8, +// json_args: [*c]const u8, +// out_buf: [*c]u8, +// in_out_len: [*c]usize, +// ) callconv(.c) i32 { +// _ = json_args; +// if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; +// const body = if (shim.toolIs(tool_name, "foo")) "{\"result\":{}}" +// else return shim.RC_UNKNOWN_TOOL; +// return shim.writeResult(out_buf, in_out_len, body); +// } + +const std = @import("std"); + +// ── Return codes (ADR-0006 Β§Return codes) ──────────────────────────── +// +// Frozen by ADR-0006. New failure modes compose these via the error +// JSON body β€” the integer surface does not grow without a follow-up ADR. + +pub const RC_SUCCESS: i32 = 0; +pub const RC_UNKNOWN_TOOL: i32 = -1; +pub const RC_BAD_ARGS: i32 = -2; +pub const RC_BUFFER_TOO_SMALL: i32 = -3; +pub const RC_RUNTIME_ERROR: i32 = -4; +pub const RC_PANIC: i32 = -5; +pub const RC_AUTH_DENIED: i32 = -6; + +// ── Invoke-path helpers ────────────────────────────────────────────── + +/// True if any of the three mandatory `boj_cartridge_invoke` output-path +/// pointers is null. Use at the top of every invoke to short-circuit to +/// `RC_BAD_ARGS`. +pub fn invokeArgsNull( + tool_name: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) bool { + return tool_name == null or out_buf == null or in_out_len == null; +} + +/// Compare a C-NUL-terminated tool-name pointer against a Zig string +/// literal. Caller must have already verified `tool_name` is non-null +/// (usually via `invokeArgsNull`). +pub fn toolIs(tool_name: [*c]const u8, expected: []const u8) bool { + const s = std.mem.span(@as([*:0]const u8, @ptrCast(tool_name))); + return std.mem.eql(u8, s, expected); +} + +/// Copy `body` into `out_buf[0..*in_out_len]` (as a capacity) and update +/// `*in_out_len` to the number of bytes written. Returns `RC_SUCCESS`. +/// +/// If `body.len` exceeds the current capacity stored in `*in_out_len`, +/// sets `*in_out_len` to the required size and returns +/// `RC_BUFFER_TOO_SMALL` β€” the caller is then expected to re-allocate +/// and retry, per ADR-0006 Β§Memory ownership. +/// +/// Caller must have already verified that `out_buf` and `in_out_len` +/// are non-null. +pub fn writeResult( + out_buf: [*c]u8, + in_out_len: [*c]usize, + body: []const u8, +) i32 { + const cap = in_out_len.*; + if (body.len > cap) { + in_out_len.* = body.len; + return RC_BUFFER_TOO_SMALL; + } + @memcpy(out_buf[0..body.len], body); + in_out_len.* = body.len; + return RC_SUCCESS; +} + +// ── Tests ──────────────────────────────────────────────────────────── + +test "writeResult: body fits, writes and sets length" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); + try std.testing.expectEqualStrings("hello", buf[0..len]); +} + +test "writeResult: too small returns -3 and sets required length" { + var buf: [2]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_BUFFER_TOO_SMALL, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: exact-fit succeeds" { + var buf: [5]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: empty body" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, ""); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 0), len); +} + +test "toolIs: matches and rejects" { + const name: [*:0]const u8 = "foo"; + try std.testing.expect(toolIs(@ptrCast(name), "foo")); + try std.testing.expect(!toolIs(@ptrCast(name), "bar")); + try std.testing.expect(!toolIs(@ptrCast(name), "foobar")); + try std.testing.expect(!toolIs(@ptrCast(name), "fo")); +} + +test "invokeArgsNull: detects each null slot" { + var buf: [4]u8 = undefined; + var len: usize = 4; + const name: [*:0]const u8 = "x"; + try std.testing.expect(!invokeArgsNull(@ptrCast(name), &buf, &len)); + try std.testing.expect(invokeArgsNull(null, &buf, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), null, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), &buf, null)); +} diff --git a/cartridges/domains/container/stapeln-mcp/ffi/stapeln_ffi.zig b/cartridges/domains/container/stapeln-mcp/ffi/stapeln_ffi.zig new file mode 100644 index 0000000..54f673a --- /dev/null +++ b/cartridges/domains/container/stapeln-mcp/ffi/stapeln_ffi.zig @@ -0,0 +1,128 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +// +// Stapeln FFI β€” C-compatible exports for container orchestration. + +const std = @import("std"); + +/// List active stack count. +export fn stapeln_list_stacks_count() u32 { + return 0; // Stub +} + +/// Deploy a stack by name. Returns 0 on success, -1 on error. +export fn stapeln_deploy(name: [*c]const u8, replicas: u32) i32 { + if (name == null or replicas == 0) return -1; + return 0; // Stub +} + +/// Scale a stack. Returns 0 on success. +export fn stapeln_scale(name: [*c]const u8, replicas: u32) i32 { + _ = replicas; // stub β€” parameter reserved for real implementation + if (name == null) return -1; + return 0; // Stub +} + +/// Get health status: 0=healthy, 1=degraded, 2=unhealthy, 3=unknown. +export fn stapeln_get_health(name: [*c]const u8) u8 { + if (name == null) return 3; + return 0; // Stub +} + +// ── Standard ABI (ADR-0005 four symbols + ADR-0006 invoke) ────────── + +const shim = @import("cartridge_shim.zig"); + +const CARTRIDGE_NAME_PTR: [*:0]const u8 = "stapeln-mcp"; +const CARTRIDGE_VERSION_PTR: [*:0]const u8 = "0.1.0"; + +export fn boj_cartridge_init() callconv(.c) c_int { + return 0; +} + +export fn boj_cartridge_deinit() callconv(.c) void {} + +export fn boj_cartridge_name() callconv(.c) [*:0]const u8 { + return CARTRIDGE_NAME_PTR; +} + +export fn boj_cartridge_version() callconv(.c) [*:0]const u8 { + return CARTRIDGE_VERSION_PTR; +} + +/// Dispatch the 4 cartridge.json MCP tools. Grade D Alpha stubs: +/// each arm returns JSON that reflects the tool's intended shape. +export fn boj_cartridge_invoke( + tool_name: [*c]const u8, + json_args: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) callconv(.c) i32 { + _ = json_args; + if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; + + const body: []const u8 = if (shim.toolIs(tool_name, "stapeln_list_stacks")) + "{\"result\":{\"stacks\":[],\"count\":0,\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "stapeln_deploy")) + "{\"result\":{\"deployed\":true,\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "stapeln_scale")) + "{\"result\":{\"scaled\":true,\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "stapeln_get_health")) + "{\"result\":{\"health\":\"healthy\",\"status\":\"stub\"}}" + else + return shim.RC_UNKNOWN_TOOL; + + return shim.writeResult(out_buf, in_out_len, body); +} + +// ── Tests ── + +test "deploy rejects null name" { + try std.testing.expectEqual(@as(i32, -1), stapeln_deploy(null, 1)); +} + +test "deploy rejects zero replicas" { + try std.testing.expectEqual(@as(i32, -1), stapeln_deploy("web", 0)); +} + +test "health returns unknown for null" { + try std.testing.expectEqual(@as(u8, 3), stapeln_get_health(null)); +} + +test "boj_cartridge_name returns stapeln-mcp" { + const n = std.mem.span(boj_cartridge_name()); + try std.testing.expectEqualStrings("stapeln-mcp", n); +} + +test "boj_cartridge_init returns 0" { + try std.testing.expectEqual(@as(c_int, 0), boj_cartridge_init()); +} + +test "invoke: each declared tool succeeds" { + var buf: [256]u8 = undefined; + const tools = [_][]const u8{ + "stapeln_list_stacks", "stapeln_deploy", + "stapeln_scale", "stapeln_get_health", + }; + for (tools) |t| { + var len: usize = buf.len; + const rc = boj_cartridge_invoke(t.ptr, "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, 0), rc); + try std.testing.expect(std.mem.indexOf(u8, buf[0..len], "result") != null); + } +} + +test "invoke: unknown tool returns -1" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("nope", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -1), rc); +} + +test "invoke: buffer too small returns -3" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("stapeln_list_stacks", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -3), rc); + try std.testing.expect(len > 4); +} diff --git a/cartridges/domains/container/stapeln-mcp/mod.js b/cartridges/domains/container/stapeln-mcp/mod.js new file mode 100644 index 0000000..074f397 --- /dev/null +++ b/cartridges/domains/container/stapeln-mcp/mod.js @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// stapeln-mcp/mod.js -- stapeln gateway + +const BASE_URL = Deno.env.get("STAPELN_BACKEND_URL") ?? "http://127.0.0.1:7704"; + +async function post(path, payload) { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), 15000); + try { + const r = await fetch(`${BASE_URL}${path}`, { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), signal: ctrl.signal, + }); + const data = await r.json().catch(() => ({ success: false, error: "non-JSON response" })); + return { status: r.status, data }; + } catch (e) { + if (e.name === "AbortError") return { status: 504, data: { success: false, error: "stapeln-mcp backend timed out" } }; + return { status: 503, data: { success: false, error: `stapeln-mcp backend unavailable: ${e.message}` } }; + } finally { clearTimeout(t); } +} + +export async function handleTool(toolName, args) { + switch (toolName) { + case "stapeln_list_stacks": { + const { _args } = args ?? {}; + const payload = { }; + return post("/api/v1/list-stacks", payload); + } + + case "stapeln_deploy": { + const { name, replicas } = args ?? {}; + if (!name) return { status: 400, data: { error: "name is required" } }; + const payload = { name }; + if (replicas !== undefined) payload.replicas = replicas; + return post("/api/v1/deploy", payload); + } + + case "stapeln_scale": { + const { name, replicas } = args ?? {}; + if (!name || !replicas) return { status: 400, data: { error: "name is required" } }; + const payload = { name, replicas }; + return post("/api/v1/scale", payload); + } + + case "stapeln_get_health": { + const { name } = args ?? {}; + if (!name) return { status: 400, data: { error: "name is required" } }; + const payload = { name }; + return post("/api/v1/get-health", payload); + } + default: + return { status: 404, data: { error: `Unknown tool: ${toolName}` } }; + } +} diff --git a/cartridges/domains/container/stapeln-mcp/panels/manifest.json b/cartridges/domains/container/stapeln-mcp/panels/manifest.json new file mode 100644 index 0000000..ac18e00 --- /dev/null +++ b/cartridges/domains/container/stapeln-mcp/panels/manifest.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://panll.dev/schemas/panel-manifest/v1.json", + "spdx": "MPL-2.0", + "cartridge": "stapeln-mcp", + "domain": "containers", + "version": "0.1.0", + "panels": [ + { + "id": "stapeln-stack-status", + "title": "Stack Status", + "description": "Table of deployed container stacks with replica counts and health indicators.", + "type": "data-table", + "entrypoint": "panels/stapeln-stack-status.js", + "size": { "cols": 4, "rows": 3 }, + "refresh_interval_ms": 5000, + "data_sources": [ + { "op": "ListStacks", "interval_ms": 5000 }, + { "op": "GetHealth", "interval_ms": 5000 } + ] + } + ] +} diff --git a/cartridges/domains/database/arango-mcp/README.adoc b/cartridges/domains/database/arango-mcp/README.adoc new file mode 100644 index 0000000..c8c7434 --- /dev/null +++ b/cartridges/domains/database/arango-mcp/README.adoc @@ -0,0 +1,69 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> + += arango-mcp +Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk> +:spdx: MPL-2.0 +:tier: Ayo +:domain: Database +:protocols: MCP, REST + +== Overview + +ArangoDB multi-model database MCP cartridge. Provides type-safe access to the +ArangoDB REST API covering databases, collections, documents, AQL queries, and +graphs. Auth via Bearer token (JWT) or Basic auth (username/password). +Self-hosted with configurable base URL (default `https://{host}:8529/_api/`). +Supports document, graph, key-value, and search models. + +=== Actions + +ListDatabases, CreateDatabase, DropDatabase, ListCollections, +CreateCollection, DropCollection, GetDocument, InsertDocument, +UpdateDocument, RemoveDocument, AqlQuery, ExplainQuery, +TraverseGraph, ListGraphs, CreateGraph, DropGraph. + +== Architecture + +[cols="1,1,2"] +|=== +| Layer | Language | Purpose + +| ABI +| Idris2 +| Formally verified state machine (Disconnected / Connected / QueryRunning / Error) + +| FFI +| Zig +| C-compatible implementation with thread-safe session pool + +| Adapter +| zig +| REST bridge to BoJ unified adapter protocol +|=== + +== Building + +[source,bash] +---- +# Build FFI shared library +cd ffi && zig build + +# Run FFI tests +cd ffi && zig build test + +# Type-check ABI +cd abi && idris2 --check ArangoMcp.SafeDatabase +---- + +== Panels + +* Connection status (disconnected / connected / query running / error) +* Database count +* Collection count +* Query metrics (AQL queries + document operations) +* Graph count + +== Status + +Development -- not yet ready for mounting. diff --git a/cartridges/domains/database/arango-mcp/abi/ArangoMcp/SafeDatabase.idr b/cartridges/domains/database/arango-mcp/abi/ArangoMcp/SafeDatabase.idr new file mode 100644 index 0000000..8843419 --- /dev/null +++ b/cartridges/domains/database/arango-mcp/abi/ArangoMcp/SafeDatabase.idr @@ -0,0 +1,165 @@ +-- SPDX-License-Identifier: MPL-2.0 +-- Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +-- +-- ArangoMcp.SafeDatabase β€” Type-safe ABI for the arango-mcp cartridge. +-- +-- Provides a formally verified state machine for ArangoDB multi-model +-- database connections. Dependent-type proofs ensure only valid transitions +-- can occur at the FFI boundary. ArangoDB actions cover the full REST API +-- surface for documents, graphs, key-value, and AQL queries. +-- Auth via Bearer token or Basic auth (self-hosted, configurable base URL). + +module ArangoMcp.SafeDatabase + +%default total + +-- --------------------------------------------------------------------------- +-- Connection state machine +-- --------------------------------------------------------------------------- + +||| Connection state for ArangoDB operations. +||| Disconnected is the natural resting state for self-hosted instances. +public export +data ConnState = Disconnected | Connected | QueryRunning | Error + +||| Proof that a state transition is valid. +public export +data ValidTransition : ConnState -> ConnState -> Type where + Connect : ValidTransition Disconnected Connected + StartQuery : ValidTransition Connected QueryRunning + FinishQuery : ValidTransition QueryRunning Connected + Disconnect : ValidTransition Connected Disconnected + QueryFail : ValidTransition QueryRunning Error + ErrorRecover : ValidTransition Error Disconnected + +-- --------------------------------------------------------------------------- +-- C-ABI integer encoding +-- --------------------------------------------------------------------------- + +||| Encode connection state as C-compatible integer. +export +connStateToInt : ConnState -> Int +connStateToInt Disconnected = 0 +connStateToInt Connected = 1 +connStateToInt QueryRunning = 2 +connStateToInt Error = 3 + +||| Decode integer back to connection state. +export +intToConnState : Int -> Maybe ConnState +intToConnState 0 = Just Disconnected +intToConnState 1 = Just Connected +intToConnState 2 = Just QueryRunning +intToConnState 3 = Just Error +intToConnState _ = Nothing + +||| Check if a state transition is valid (C-ABI export). +||| Returns 1 for valid, 0 for invalid. +export +arango_mcp_can_transition : Int -> Int -> Int +arango_mcp_can_transition from to = + case (intToConnState from, intToConnState to) of + (Just Disconnected, Just Connected) => 1 + (Just Connected, Just QueryRunning) => 1 + (Just QueryRunning, Just Connected) => 1 + (Just Connected, Just Disconnected) => 1 + (Just QueryRunning, Just Error) => 1 + (Just Error, Just Disconnected) => 1 + _ => 0 + +-- --------------------------------------------------------------------------- +-- ArangoDB actions (full REST API surface) +-- --------------------------------------------------------------------------- + +||| Actions supported by the ArangoDB MCP cartridge. +||| Covers databases, collections, documents, AQL queries, and graphs. +public export +data ArangoAction + = ListDatabases + | CreateDatabase + | DropDatabase + | ListCollections + | CreateCollection + | DropCollection + | GetDocument + | InsertDocument + | UpdateDocument + | RemoveDocument + | AqlQuery + | ExplainQuery + | TraverseGraph + | ListGraphs + | CreateGraph + | DropGraph + +||| Encode action as C-compatible integer. +export +arangoActionToInt : ArangoAction -> Int +arangoActionToInt ListDatabases = 0 +arangoActionToInt CreateDatabase = 1 +arangoActionToInt DropDatabase = 2 +arangoActionToInt ListCollections = 3 +arangoActionToInt CreateCollection = 4 +arangoActionToInt DropCollection = 5 +arangoActionToInt GetDocument = 6 +arangoActionToInt InsertDocument = 7 +arangoActionToInt UpdateDocument = 8 +arangoActionToInt RemoveDocument = 9 +arangoActionToInt AqlQuery = 10 +arangoActionToInt ExplainQuery = 11 +arangoActionToInt TraverseGraph = 12 +arangoActionToInt ListGraphs = 13 +arangoActionToInt CreateGraph = 14 +arangoActionToInt DropGraph = 15 + +||| Decode integer back to action. +export +intToArangoAction : Int -> Maybe ArangoAction +intToArangoAction 0 = Just ListDatabases +intToArangoAction 1 = Just CreateDatabase +intToArangoAction 2 = Just DropDatabase +intToArangoAction 3 = Just ListCollections +intToArangoAction 4 = Just CreateCollection +intToArangoAction 5 = Just DropCollection +intToArangoAction 6 = Just GetDocument +intToArangoAction 7 = Just InsertDocument +intToArangoAction 8 = Just UpdateDocument +intToArangoAction 9 = Just RemoveDocument +intToArangoAction 10 = Just AqlQuery +intToArangoAction 11 = Just ExplainQuery +intToArangoAction 12 = Just TraverseGraph +intToArangoAction 13 = Just ListGraphs +intToArangoAction 14 = Just CreateGraph +intToArangoAction 15 = Just DropGraph +intToArangoAction _ = Nothing + +||| Check whether an action requires an active connection. +export +actionRequiresConnection : ArangoAction -> Bool +actionRequiresConnection AqlQuery = True +actionRequiresConnection ExplainQuery = True +actionRequiresConnection GetDocument = True +actionRequiresConnection InsertDocument = True +actionRequiresConnection UpdateDocument = True +actionRequiresConnection RemoveDocument = True +actionRequiresConnection TraverseGraph = True +actionRequiresConnection _ = False + +||| Total number of actions exposed by this cartridge. +export +actionCount : Nat +actionCount = 16 + +-- --------------------------------------------------------------------------- +-- Auth configuration +-- --------------------------------------------------------------------------- + +||| Authentication methods for ArangoDB REST API. +||| Supports both Bearer token (JWT) and Basic auth (username/password). +public export +data ArangoAuth = BearerToken | BasicAuth + +||| Base URL placeholder for ArangoDB REST API (self-hosted, configurable). +export +arangoApiBase : String +arangoApiBase = "https://{host}:8529/_api/" diff --git a/cartridges/domains/database/arango-mcp/abi/README.adoc b/cartridges/domains/database/arango-mcp/abi/README.adoc new file mode 100644 index 0000000..a3bf1bc --- /dev/null +++ b/cartridges/domains/database/arango-mcp/abi/README.adoc @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += arango-mcp / abi β€” Idris2 ABI layer +:orientation: shallow + +== Purpose + +Cartridge-local protocol types for `arango-mcp`. Defines the data, tool, and +transition vocabulary this cartridge speaks. Inherits safety lemmas and the +matrix-cell identifier scheme from the trunk ABI at `src/abi/`. + +== Files + +1 Idris2 module(s), ~165 lines total. Lead module: +`SafeDatabase.idr`. + +No `.ipkg` β€” consumed directly by wider BoJ callers; type-checks inside the +trunk `boj.ipkg`. + +== Invariants + +* `%default total` at every module top. +* Zero `believe_me`, `assert_total`, `postulate`, `Admitted`, or + `sorry` (the trunk holds the four declared `believe_me` sites; leaf + cartridges carry none). + +== Test/proof surface + +Thin module(s), no inline tests. Proof surface = type-check success under the +trunk `idris2 --check`. + +== Read-first + +. `SafeDatabase.idr` β€” cartridge-local data records and enums. +. Trunk `src/abi/Boj/Catalogue.idr` for how this cell participates in the + 2D matrix. diff --git a/cartridges/domains/database/arango-mcp/abi/arango_mcp.ipkg b/cartridges/domains/database/arango-mcp/abi/arango_mcp.ipkg new file mode 100644 index 0000000..e93b419 --- /dev/null +++ b/cartridges/domains/database/arango-mcp/abi/arango_mcp.ipkg @@ -0,0 +1,11 @@ +-- SPDX-License-Identifier: MPL-2.0 +-- Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +package arango_mcp + +version = "0.1.0" +authors = "Jonathan D.A. Jewell" +brief = "ArangoDB multi-model MCP cartridge β€” type-safe ABI layer" + +depends = base + +modules = ArangoMcp.SafeDatabase diff --git a/cartridges/domains/database/arango-mcp/adapter/README.adoc b/cartridges/domains/database/arango-mcp/adapter/README.adoc new file mode 100644 index 0000000..b8d3c02 --- /dev/null +++ b/cartridges/domains/database/arango-mcp/adapter/README.adoc @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += arango-mcp / adapter β€” protocol bridge +:orientation: shallow + +== Purpose + +Exposes this cartridge's FFI over the wire protocols declared in +`cartridge.json`. Dispatches tool invocations to the C-ABI exports at +`../ffi/` and formats responses per protocol. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” depends on the sibling `ffi` target. +| `arango_mcp_adapter.zig` | Protocol dispatch (118 lines). Tool catalogue matches `cartridge.json`. +| `SIDELINED-*.v.adoc` | zig predecessor(s). Not built β€” zig banned 2026-04-10. +|=== + +== Invariants + +* **Stateless** β€” all state lives behind the FFI, never in the adapter. +* **Response passthrough** β€” whatever the FFI returns goes back to the wire + unmodified (no embellishment, no silent recovery). +* **Cartridge.json is source of truth** for the tool catalogue; drift between + adapter and manifest is a CI failure. + +== Test/proof surface + +No inline tests at the adapter layer β€” protocol routing is exercised by the +trunk seam tests at `ffi/zig/src/seams.zig`. + +== Read-first + +. `arango_mcp_adapter.zig` β€” dispatch function and per-protocol routers. +. `../cartridge.json` β€” the tool manifest the dispatcher mirrors. diff --git a/cartridges/domains/database/arango-mcp/adapter/arango_mcp_adapter.zig b/cartridges/domains/database/arango-mcp/adapter/arango_mcp_adapter.zig new file mode 100644 index 0000000..7a7cd85 --- /dev/null +++ b/cartridges/domains/database/arango-mcp/adapter/arango_mcp_adapter.zig @@ -0,0 +1,118 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// arango-mcp/adapter/arango_mcp_adapter.zig -- Unified three-protocol adapter. +// Replaces banned arango_mcp_adapter.v (zig, removed 2026-04-12). +// REST:9181 gRPC:9182 GraphQL:9183 +// Tools: arango_connect, arango_aql, arango_insert, arango_get... + +const std = @import("std"); +const ffi = @import("arango_mcp_ffi"); + +const REST_PORT: u16 = 9181; +const GRPC_PORT: u16 = 9182; +const GQL_PORT: u16 = 9183; +const MAX_CONN_BUF: usize = 16 * 1024; + +fn okJson(buf: []u8, msg: []const u8) []u8 { + const n = std.fmt.bufPrint(buf, "{\"success\":true,\"message\":\"{s}\"}", .{msg}) catch return buf[0..0]; return n; +} +fn errJson(buf: []u8, msg: []const u8) []u8 { + const n = std.fmt.bufPrint(buf, "{\"success\":false,\"error\":\"{s}\"}", .{msg}) catch return buf[0..0]; return n; +} +fn statusJson(buf: []u8) []u8 { + const n = std.fmt.bufPrint(buf, "{\"success\":true,\"state\":\"ready\",\"service\":\"arango-mcp\"}", .{}) catch return buf[0..0]; return n; +} + +const Response = struct { status: u16, body: []u8 }; + +fn dispatch(tool: []const u8, body: []const u8, resp: []u8) Response { + _ = body; + if (std.mem.eql(u8, tool, "arango_connect")) return .{ .status = 200, .body = okJson(resp, "arango_connect forwarded") }; + if (std.mem.eql(u8, tool, "arango_aql")) return .{ .status = 200, .body = okJson(resp, "arango_aql forwarded") }; + if (std.mem.eql(u8, tool, "arango_insert")) return .{ .status = 200, .body = okJson(resp, "arango_insert forwarded") }; + if (std.mem.eql(u8, tool, "arango_get")) return .{ .status = 200, .body = okJson(resp, "arango_get forwarded") }; + if (std.mem.eql(u8, tool, "arango_update")) return .{ .status = 200, .body = okJson(resp, "arango_update forwarded") }; + if (std.mem.eql(u8, tool, "arango_delete")) return .{ .status = 200, .body = okJson(resp, "arango_delete forwarded") }; + if (std.mem.eql(u8, tool, "arango_graph_traversal")) return .{ .status = 200, .body = okJson(resp, "arango_graph_traversal forwarded") }; + if (std.mem.eql(u8, tool, "arango_list_collections")) return .{ .status = 200, .body = okJson(resp, "arango_list_collections forwarded") }; + if (std.mem.eql(u8, tool, "arango_disconnect")) return .{ .status = 200, .body = okJson(resp, "arango_disconnect forwarded") }; + if (std.mem.eql(u8, tool, "status") or std.mem.eql(u8, tool, "health")) return .{ .status = 200, .body = statusJson(resp) }; + return .{ .status = 404, .body = errJson(resp, "Unknown tool") }; +} + +fn dispatchRest(path: []const u8, body: []const u8, resp: []u8) Response { + if (std.mem.startsWith(u8, path, "/tools/")) return dispatch(path["/tools/".len..], body, resp); + if (std.mem.eql(u8, path, "/status") or std.mem.eql(u8, path, "/health")) return .{ .status = 200, .body = statusJson(resp) }; + return .{ .status = 404, .body = errJson(resp, "Not found") }; +} + +fn dispatchGrpc(path: []const u8, body: []const u8, resp: []u8) Response { + const prefix = "/ArangoMcpservice/"; + if (!std.mem.startsWith(u8, path, prefix)) return .{ .status = 404, .body = errJson(resp, "Not a recognized gRPC path") }; + const method = path[prefix.len..]; + const tool = blk: { + if (std.mem.eql(u8, method, "arango_connect")) break :blk "arango_connect"; + if (std.mem.eql(u8, method, "arango_aql")) break :blk "arango_aql"; + if (std.mem.eql(u8, method, "arango_insert")) break :blk "arango_insert"; + if (std.mem.eql(u8, method, "arango_get")) break :blk "arango_get"; + if (std.mem.eql(u8, method, "arango_update")) break :blk "arango_update"; + if (std.mem.eql(u8, method, "arango_delete")) break :blk "arango_delete"; + if (std.mem.eql(u8, method, "arango_graph_traversal")) break :blk "arango_graph_traversal"; + if (std.mem.eql(u8, method, "arango_list_collections")) break :blk "arango_list_collections"; + if (std.mem.eql(u8, method, "arango_disconnect")) break :blk "arango_disconnect"; + return .{ .status = 404, .body = errJson(resp, "Unknown gRPC method") }; + }; + return dispatch(tool, body, resp); +} + +fn dispatchGraphql(body: []const u8, resp: []u8) Response { + if (std.mem.indexOf(u8, body, "__schema") != null) return .{ .status = 200, .body = okJson(resp, "schema not supported") }; + if (std.mem.indexOf(u8, body, "connect") != null) return dispatch("arango_connect", body, resp); + if (std.mem.indexOf(u8, body, "aql") != null) return dispatch("arango_aql", body, resp); + if (std.mem.indexOf(u8, body, "insert") != null) return dispatch("arango_insert", body, resp); + if (std.mem.indexOf(u8, body, "get") != null) return dispatch("arango_get", body, resp); + if (std.mem.indexOf(u8, body, "update") != null) return dispatch("arango_update", body, resp); + if (std.mem.indexOf(u8, body, "delete") != null) return dispatch("arango_delete", body, resp); + if (std.mem.indexOf(u8, body, "graph_traversal") != null) return dispatch("arango_graph_traversal", body, resp); + if (std.mem.indexOf(u8, body, "list_collections") != null) return dispatch("arango_list_collections", body, resp); + if (std.mem.indexOf(u8, body, "disconnect") != null) return dispatch("arango_disconnect", body, resp); + return .{ .status = 200, .body = errJson(resp, "Unrecognised GraphQL operation") }; +} + +const Protocol = enum { rest, grpc, graphql }; + +fn handleConnection(conn: std.net.Server.Connection, proto: Protocol) void { + defer conn.stream.close(); + var in_buf: [MAX_CONN_BUF]u8 = undefined; + const n = conn.stream.read(&in_buf) catch return; + const req = in_buf[0..n]; + var path: []const u8 = "/"; var body: []const u8 = ""; + if (n > 4) { + const le = std.mem.indexOf(u8, req, "\r\n") orelse req.len; + const fl = req[0..le]; const sp1 = std.mem.indexOfScalar(u8, fl, ' ') orelse 0; + const ro = fl[sp1+1..]; const sp2 = std.mem.indexOfScalar(u8, ro, ' ') orelse ro.len; + path = ro[0..sp2]; + const bs = std.mem.indexOf(u8, req, "\r\n\r\n") orelse n; body = req[@min(bs+4,n)..]; + } + var rb: [MAX_CONN_BUF]u8 = undefined; + const result = switch (proto) { .rest => dispatchRest(path, body, &rb), .grpc => dispatchGrpc(path, body, &rb), .graphql => dispatchGraphql(body, &rb) }; + const ct: []const u8 = if (proto == .grpc) "application/grpc+json" else "application/json"; + var hb: [256]u8 = undefined; + const hdr = std.fmt.bufPrint(&hb, "HTTP/1.1 {d} OK\r\nContent-Type: {s}\r\nContent-Length: {d}\r\nConnection: close\r\n\r\n", .{ result.status, ct, result.body.len }) catch return; + _ = conn.stream.writeAll(hdr) catch return; _ = conn.stream.writeAll(result.body) catch return; +} + +fn listenLoop(port: u16, proto: Protocol) void { + const addr = std.net.Address.initIp4(.{ 127, 0, 0, 1 }, port) catch return; + var srv = addr.listen(.{ .reuse_address = true }) catch return; defer srv.deinit(); + while (true) { const conn = srv.accept() catch continue; handleConnection(conn, proto); } +} + +pub fn main() !void { + ffi.arango_mcp_init(); + const t1 = try std.Thread.spawn(.{}, listenLoop, .{ REST_PORT, Protocol.rest }); + const t2 = try std.Thread.spawn(.{}, listenLoop, .{ GRPC_PORT, Protocol.grpc }); + const t3 = try std.Thread.spawn(.{}, listenLoop, .{ GQL_PORT, Protocol.graphql }); + t1.join(); t2.join(); t3.join(); +} diff --git a/cartridges/domains/database/arango-mcp/adapter/build.zig b/cartridges/domains/database/arango-mcp/adapter/build.zig new file mode 100644 index 0000000..820bad8 --- /dev/null +++ b/cartridges/domains/database/arango-mcp/adapter/build.zig @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// arango-mcp/adapter/build.zig + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); const optimize = b.standardOptimizeOption(.{}); + const ffi_mod = b.createModule(.{ .root_source_file = b.path("../ffi/arango_mcp_ffi.zig"), .target = target, .optimize = optimize }); + const adapter = b.addExecutable(.{ .name = "arango_mcp_adapter", .root_source_file = b.path("arango_mcp_adapter.zig"), .target = target, .optimize = optimize }); + adapter.root_module.addImport("arango_mcp_ffi", ffi_mod); b.installArtifact(adapter); + const rs = b.step("run", "Run arango-mcp adapter"); rs.dependOn(&b.addRunArtifact(adapter).step); + const tests = b.addTest(.{ .root_source_file = b.path("arango_mcp_adapter.zig"), .target = target, .optimize = optimize }); + tests.root_module.addImport("arango_mcp_ffi", ffi_mod); + const ts = b.step("test", "Test arango-mcp adapter"); ts.dependOn(&b.addRunArtifact(tests).step); +} diff --git a/cartridges/domains/database/arango-mcp/cartridge.json b/cartridges/domains/database/arango-mcp/cartridge.json new file mode 100644 index 0000000..d183ac0 --- /dev/null +++ b/cartridges/domains/database/arango-mcp/cartridge.json @@ -0,0 +1,260 @@ +{ + "$schema": "https://boj.dev/schemas/cartridge/v1.json", + "spdx": "MPL-2.0", + "copyright": "Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath)", + "name": "arango-mcp", + "version": "0.1.0", + "description": "ArangoDB multi-model database gateway. AQL queries, document operations, graph traversals, and collection management.", + "domain": "Database", + "tier": "Ayo", + "protocols": [ + "MCP", + "REST" + ], + "auth": { + "method": "none", + "env_var": null, + "credential_source": null + }, + "api": { + "base_url": "local://arango-mcp", + "content_type": "application/json" + }, + "tools": [ + { + "name": "arango_connect", + "description": "Connect to an ArangoDB instance. Returns a session slot.", + "inputSchema": { + "type": "object", + "properties": { + "url": { + "type": "string", + "description": "ArangoDB URL (http://localhost:8529)" + }, + "database": { + "type": "string", + "description": "Database name (default: _system)" + }, + "username": { + "type": "string", + "description": "Username (default: root)" + }, + "password": { + "type": "string", + "description": "Password" + } + }, + "required": [ + "url" + ] + } + }, + { + "name": "arango_aql", + "description": "Execute an AQL query.", + "inputSchema": { + "type": "object", + "properties": { + "slot": { + "type": "integer", + "description": "Session slot from arango_connect" + }, + "query": { + "type": "string", + "description": "AQL query string" + }, + "bind_vars": { + "type": "string", + "description": "Bind variables as JSON object (optional)" + } + }, + "required": [ + "slot", + "query" + ] + } + }, + { + "name": "arango_insert", + "description": "Insert a document into a collection.", + "inputSchema": { + "type": "object", + "properties": { + "slot": { + "type": "integer", + "description": "Session slot" + }, + "collection": { + "type": "string", + "description": "Collection name" + }, + "document": { + "type": "string", + "description": "Document as JSON object" + } + }, + "required": [ + "slot", + "collection", + "document" + ] + } + }, + { + "name": "arango_get", + "description": "Get a document by key.", + "inputSchema": { + "type": "object", + "properties": { + "slot": { + "type": "integer", + "description": "Session slot" + }, + "collection": { + "type": "string", + "description": "Collection name" + }, + "key": { + "type": "string", + "description": "Document key" + } + }, + "required": [ + "slot", + "collection", + "key" + ] + } + }, + { + "name": "arango_update", + "description": "Update a document.", + "inputSchema": { + "type": "object", + "properties": { + "slot": { + "type": "integer", + "description": "Session slot" + }, + "collection": { + "type": "string", + "description": "Collection name" + }, + "key": { + "type": "string", + "description": "Document key" + }, + "update": { + "type": "string", + "description": "Partial update as JSON object" + } + }, + "required": [ + "slot", + "collection", + "key", + "update" + ] + } + }, + { + "name": "arango_delete", + "description": "Delete a document.", + "inputSchema": { + "type": "object", + "properties": { + "slot": { + "type": "integer", + "description": "Session slot" + }, + "collection": { + "type": "string", + "description": "Collection name" + }, + "key": { + "type": "string", + "description": "Document key" + } + }, + "required": [ + "slot", + "collection", + "key" + ] + } + }, + { + "name": "arango_graph_traversal", + "description": "Traverse a named graph from a start vertex.", + "inputSchema": { + "type": "object", + "properties": { + "slot": { + "type": "integer", + "description": "Session slot" + }, + "graph": { + "type": "string", + "description": "Graph name" + }, + "start_vertex": { + "type": "string", + "description": "Start vertex ID (collection/key)" + }, + "depth": { + "type": "integer", + "description": "Traversal depth (default: 1)" + } + }, + "required": [ + "slot", + "graph", + "start_vertex" + ] + } + }, + { + "name": "arango_list_collections", + "description": "List collections in the connected database.", + "inputSchema": { + "type": "object", + "properties": { + "slot": { + "type": "integer", + "description": "Session slot" + } + }, + "required": [ + "slot" + ] + } + }, + { + "name": "arango_disconnect", + "description": "Disconnect and release a session slot.", + "inputSchema": { + "type": "object", + "properties": { + "slot": { + "type": "integer", + "description": "Session slot to release" + } + }, + "required": [ + "slot" + ] + } + } + ], + "ffi": { + "so_path": "ffi/zig-out/lib/libarango_mcp.so", + "abi_version": "ADR-0006", + "symbols": [ + "boj_cartridge_init", + "boj_cartridge_deinit", + "boj_cartridge_name", + "boj_cartridge_version", + "boj_cartridge_invoke" + ] + } +} diff --git a/cartridges/domains/database/arango-mcp/ffi/README.adoc b/cartridges/domains/database/arango-mcp/ffi/README.adoc new file mode 100644 index 0000000..fc435d7 --- /dev/null +++ b/cartridges/domains/database/arango-mcp/ffi/README.adoc @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += arango-mcp / ffi β€” Zig FFI layer +:orientation: shallow + +== Purpose + +C-ABI exports bridging this cartridge's Idris2 ABI to the runtime. Consumed by +the adapter for protocol dispatch and by the trunk loader for mount. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” produces `zig-out/lib/libarango.so`. +| `arango_mcp_ffi.zig` | C-ABI exports (13 exports, 7 inline tests, 374 lines). +|=== + +== Invariants + +* **Null-pointer rejection** on `[*c]const u8` arguments before any + dereference. +* **Negative return values** carry documented error meanings; zero is + success (matching the trunk error-code convention). +* **No allocator leaks** β€” every allocation path pairs with `defer free`. + +== Test/proof surface + +7 inline `test "..."` block(s) in `arango_mcp_ffi.zig`. Run with +`cd ffi && zig build test`. + +== Read-first + +. `arango_mcp_ffi.zig` β€” C-ABI signatures and guards. +. Trunk `ffi/zig/src/catalogue.zig` for how this `.so` is loaded. diff --git a/cartridges/domains/database/arango-mcp/ffi/arango_mcp_ffi.zig b/cartridges/domains/database/arango-mcp/ffi/arango_mcp_ffi.zig new file mode 100644 index 0000000..521e11b --- /dev/null +++ b/cartridges/domains/database/arango-mcp/ffi/arango_mcp_ffi.zig @@ -0,0 +1,480 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// arango_mcp_ffi.zig β€” C-ABI FFI implementation for the arango-mcp cartridge. +// +// Implements the connection state machine defined in the Idris2 ABI layer +// (ArangoMcp.SafeDatabase). Thread-safe via std.Thread.Mutex. No heap +// allocations for results. State machine: Disconnected | Connected | +// QueryRunning | Error. Designed for ArangoDB multi-model database +// (document, graph, key-value, search) with configurable self-hosted base URL. + +const std = @import("std"); + +// --------------------------------------------------------------------------- +// Connection state machine (matches Idris2 ABI exactly) +// --------------------------------------------------------------------------- + +/// Connection states for ArangoDB. +pub const ConnState = enum(c_int) { + disconnected = 0, + connected = 1, + query_running = 2, + err = 3, +}; + +/// ArangoDB REST API actions. +pub const ArangoAction = enum(c_int) { + list_databases = 0, + create_database = 1, + drop_database = 2, + list_collections = 3, + create_collection = 4, + drop_collection = 5, + get_document = 6, + insert_document = 7, + update_document = 8, + remove_document = 9, + aql_query = 10, + explain_query = 11, + traverse_graph = 12, + list_graphs = 13, + create_graph = 14, + drop_graph = 15, +}; + +/// Check whether a state transition is valid per the ABI specification. +fn isValidTransition(from: ConnState, to: ConnState) bool { + return switch (from) { + .disconnected => to == .connected, + .connected => to == .query_running or to == .disconnected, + .query_running => to == .connected or to == .err, + .err => to == .disconnected, + }; +} + +// --------------------------------------------------------------------------- +// Session slots (thread-safe, fixed-size pool) +// --------------------------------------------------------------------------- + +const MAX_SESSIONS: usize = 16; +const BUF_SIZE: usize = 4096; + +const SessionSlot = struct { + in_use: bool = false, + state: ConnState = .disconnected, + context_buf: [BUF_SIZE]u8 = .{0} ** BUF_SIZE, + context_len: usize = 0, + database_count: u32 = 0, + collection_count: u32 = 0, + query_count: u32 = 0, + graph_count: u32 = 0, + document_ops: u32 = 0, +}; + +var sessions: [MAX_SESSIONS]SessionSlot = .{SessionSlot{}} ** MAX_SESSIONS; +var mutex: std.Thread.Mutex = .{}; + +// --------------------------------------------------------------------------- +// C-ABI exports +// --------------------------------------------------------------------------- + +/// Check if a state transition is valid. Returns 1 (valid) or 0 (invalid). +pub export fn arango_mcp_can_transition(from: c_int, to: c_int) c_int { + const f = std.meta.intToEnum(ConnState, from) catch return 0; + const t = std.meta.intToEnum(ConnState, to) catch return 0; + return if (isValidTransition(f, t)) 1 else 0; +} + +/// Open a new connection session. Returns slot index (>= 0) or -1 if no slots. +pub export fn arango_mcp_session_open() c_int { + mutex.lock(); + defer mutex.unlock(); + + for (&sessions, 0..) |*slot, idx| { + if (!slot.in_use) { + slot.in_use = true; + slot.state = .connected; + slot.context_len = 0; + slot.database_count = 0; + slot.collection_count = 0; + slot.query_count = 0; + slot.graph_count = 0; + slot.document_ops = 0; + return @intCast(idx); + } + } + return -1; +} + +/// Close a connection session. Returns 0 on success, -1 if invalid slot, -2 if bad transition. +pub export fn arango_mcp_session_close(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.in_use) return -1; + if (!isValidTransition(slot.state, .disconnected)) return -2; + + slot.in_use = false; + slot.state = .disconnected; + slot.context_len = 0; + return 0; +} + +/// Get the current state of a session. Returns state int or -1 if invalid slot. +pub export fn arango_mcp_session_state(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.in_use) return -1; + return @intFromEnum(slot.state); +} + +/// Begin a query (AQL or traversal). Returns 0 on success, -1 if invalid slot, -2 if bad transition. +pub export fn arango_mcp_begin_query(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.in_use) return -1; + if (!isValidTransition(slot.state, .query_running)) return -2; + + slot.state = .query_running; + return 0; +} + +/// End a query (return to connected). Returns 0 on success. +pub export fn arango_mcp_end_query(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.in_use) return -1; + if (!isValidTransition(slot.state, .connected)) return -2; + + slot.state = .connected; + slot.query_count += 1; + return 0; +} + +/// Signal an error on a query-running session. Returns 0 on success. +pub export fn arango_mcp_signal_error(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.in_use) return -1; + if (!isValidTransition(slot.state, .err)) return -2; + + slot.state = .err; + return 0; +} + +/// Recover from error state (return to disconnected). Returns 0 on success. +pub export fn arango_mcp_error_recover(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.in_use) return -1; + if (!isValidTransition(slot.state, .disconnected)) return -2; + + slot.state = .disconnected; + return 0; +} + +/// Get the query count for a session. Returns count or -1 if invalid slot. +pub export fn arango_mcp_query_count(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.in_use) return -1; + return @intCast(slot.query_count); +} + +/// Record a document operation (get/insert/update/remove). Returns 0 on success. +pub export fn arango_mcp_record_document_op(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.in_use) return -1; + if (slot.state != .connected) return -2; + + slot.document_ops += 1; + return 0; +} + +/// Get the document operation count for a session. Returns count or -1 if invalid slot. +pub export fn arango_mcp_document_op_count(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.in_use) return -1; + return @intCast(slot.document_ops); +} + +/// Check if an action requires an active connection. Returns 1 (yes) or 0 (no). +pub export fn arango_mcp_action_requires_connection(action: c_int) c_int { + const a = std.meta.intToEnum(ArangoAction, action) catch return 0; + return switch (a) { + .aql_query, .explain_query, .get_document, .insert_document, .update_document, .remove_document, .traverse_graph => 1, + else => 0, + }; +} + +/// Reset all sessions (test/debug use only). +pub export fn arango_mcp_reset() void { + mutex.lock(); + defer mutex.unlock(); + sessions = .{SessionSlot{}} ** MAX_SESSIONS; +} + +// --------------------------------------------------------------------------- +// ═══════════════════════════════════════════════════════════════════════ +// Standard ABI (ADR-0005 four symbols + ADR-0006 invoke) +// ═══════════════════════════════════════════════════════════════════════ + +const shim = @import("cartridge_shim.zig"); + +const CARTRIDGE_NAME_PTR: [*:0]const u8 = "arango-mcp"; +const CARTRIDGE_VERSION_PTR: [*:0]const u8 = "0.1.0"; + +export fn boj_cartridge_init() callconv(.c) c_int { + return 0; +} + +export fn boj_cartridge_deinit() callconv(.c) void {} + +export fn boj_cartridge_name() callconv(.c) [*:0]const u8 { + return CARTRIDGE_NAME_PTR; +} + +export fn boj_cartridge_version() callconv(.c) [*:0]const u8 { + return CARTRIDGE_VERSION_PTR; +} + +/// Dispatch the cartridge.json MCP tools. Grade D Alpha stubs. +export fn boj_cartridge_invoke( + tool_name: [*c]const u8, + json_args: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) callconv(.c) i32 { + _ = json_args; + if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; + + const body: []const u8 = if (shim.toolIs(tool_name, "arango_connect")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "arango_aql")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "arango_insert")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "arango_get")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "arango_update")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "arango_delete")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "arango_graph_traversal")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "arango_list_collections")) + "{\"result\":{\"items\":[],\"count\":0,\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "arango_disconnect")) + "{\"result\":{\"status\":\"stub\"}}" +else + return shim.RC_UNKNOWN_TOOL; + + return shim.writeResult(out_buf, in_out_len, body); +} + +// Tests +// --------------------------------------------------------------------------- + +test "session lifecycle" { + arango_mcp_reset(); + + const slot = arango_mcp_session_open(); + try std.testing.expect(slot >= 0); + + // Should be in connected state + try std.testing.expectEqual(@as(c_int, 1), arango_mcp_session_state(slot)); + + // Begin query (AQL) + try std.testing.expectEqual(@as(c_int, 0), arango_mcp_begin_query(slot)); + try std.testing.expectEqual(@as(c_int, 2), arango_mcp_session_state(slot)); + + // End query + try std.testing.expectEqual(@as(c_int, 0), arango_mcp_end_query(slot)); + try std.testing.expectEqual(@as(c_int, 1), arango_mcp_session_state(slot)); + + // Query count should be 1 + try std.testing.expectEqual(@as(c_int, 1), arango_mcp_query_count(slot)); + + // Close + try std.testing.expectEqual(@as(c_int, 0), arango_mcp_session_close(slot)); +} + +test "invalid transitions rejected" { + arango_mcp_reset(); + + const slot = arango_mcp_session_open(); + try std.testing.expect(slot >= 0); + + // Can not go connected -> error (must go through query_running) + try std.testing.expectEqual(@as(c_int, -2), arango_mcp_signal_error(slot)); + + // Can not close while query running + try std.testing.expectEqual(@as(c_int, 0), arango_mcp_begin_query(slot)); + try std.testing.expectEqual(@as(c_int, -2), arango_mcp_session_close(slot)); +} + +test "transition validator" { + // Valid transitions + try std.testing.expectEqual(@as(c_int, 1), arango_mcp_can_transition(0, 1)); // disconnected -> connected + try std.testing.expectEqual(@as(c_int, 1), arango_mcp_can_transition(1, 2)); // connected -> query_running + try std.testing.expectEqual(@as(c_int, 1), arango_mcp_can_transition(2, 1)); // query_running -> connected + try std.testing.expectEqual(@as(c_int, 1), arango_mcp_can_transition(1, 0)); // connected -> disconnected + try std.testing.expectEqual(@as(c_int, 1), arango_mcp_can_transition(2, 3)); // query_running -> error + try std.testing.expectEqual(@as(c_int, 1), arango_mcp_can_transition(3, 0)); // error -> disconnected + + // Invalid transitions + try std.testing.expectEqual(@as(c_int, 0), arango_mcp_can_transition(0, 2)); + try std.testing.expectEqual(@as(c_int, 0), arango_mcp_can_transition(1, 3)); + try std.testing.expectEqual(@as(c_int, 0), arango_mcp_can_transition(3, 1)); + try std.testing.expectEqual(@as(c_int, 0), arango_mcp_can_transition(99, 0)); +} + +test "slot exhaustion" { + arango_mcp_reset(); + + var slots: [MAX_SESSIONS]c_int = undefined; + for (&slots, 0..) |*s, i| { + _ = i; + s.* = arango_mcp_session_open(); + try std.testing.expect(s.* >= 0); + } + + // Next open should fail + try std.testing.expectEqual(@as(c_int, -1), arango_mcp_session_open()); + + // Free one and try again + try std.testing.expectEqual(@as(c_int, 0), arango_mcp_session_close(slots[0])); + const new_slot = arango_mcp_session_open(); + try std.testing.expect(new_slot >= 0); +} + +test "action requires connection" { + try std.testing.expectEqual(@as(c_int, 1), arango_mcp_action_requires_connection(10)); // aql_query + try std.testing.expectEqual(@as(c_int, 1), arango_mcp_action_requires_connection(11)); // explain_query + try std.testing.expectEqual(@as(c_int, 1), arango_mcp_action_requires_connection(6)); // get_document + try std.testing.expectEqual(@as(c_int, 1), arango_mcp_action_requires_connection(7)); // insert_document + try std.testing.expectEqual(@as(c_int, 1), arango_mcp_action_requires_connection(8)); // update_document + try std.testing.expectEqual(@as(c_int, 1), arango_mcp_action_requires_connection(9)); // remove_document + try std.testing.expectEqual(@as(c_int, 1), arango_mcp_action_requires_connection(12)); // traverse_graph + try std.testing.expectEqual(@as(c_int, 0), arango_mcp_action_requires_connection(0)); // list_databases + try std.testing.expectEqual(@as(c_int, 0), arango_mcp_action_requires_connection(3)); // list_collections + try std.testing.expectEqual(@as(c_int, 0), arango_mcp_action_requires_connection(99)); // out of range +} + +test "error recovery flow" { + arango_mcp_reset(); + + const slot = arango_mcp_session_open(); + try std.testing.expect(slot >= 0); + + // connected -> query_running -> error -> disconnected + try std.testing.expectEqual(@as(c_int, 0), arango_mcp_begin_query(slot)); + try std.testing.expectEqual(@as(c_int, 0), arango_mcp_signal_error(slot)); + try std.testing.expectEqual(@as(c_int, 3), arango_mcp_session_state(slot)); + try std.testing.expectEqual(@as(c_int, 0), arango_mcp_error_recover(slot)); + try std.testing.expectEqual(@as(c_int, 0), arango_mcp_session_state(slot)); +} + +test "document operation tracking" { + arango_mcp_reset(); + + const slot = arango_mcp_session_open(); + try std.testing.expect(slot >= 0); + + // Record some document ops while connected + try std.testing.expectEqual(@as(c_int, 0), arango_mcp_record_document_op(slot)); + try std.testing.expectEqual(@as(c_int, 0), arango_mcp_record_document_op(slot)); + try std.testing.expectEqual(@as(c_int, 0), arango_mcp_record_document_op(slot)); + try std.testing.expectEqual(@as(c_int, 3), arango_mcp_document_op_count(slot)); + + // Cannot record doc op while query_running + try std.testing.expectEqual(@as(c_int, 0), arango_mcp_begin_query(slot)); + try std.testing.expectEqual(@as(c_int, -2), arango_mcp_record_document_op(slot)); +} + +// ═══════════════════════════════════════════════════════════════════════ +// ADR-0006 invoke dispatch tests +// ═══════════════════════════════════════════════════════════════════════ + +test "boj_cartridge_name returns arango-mcp" { + const n = std.mem.span(boj_cartridge_name()); + try std.testing.expectEqualStrings("arango-mcp", n); +} + +test "boj_cartridge_init returns 0" { + try std.testing.expectEqual(@as(c_int, 0), boj_cartridge_init()); +} + +test "invoke: each declared tool succeeds" { + var buf: [256]u8 = undefined; + const tools = [_][]const u8{ + "arango_connect", + "arango_aql", + "arango_insert", + "arango_get", + "arango_update", + "arango_delete", + "arango_graph_traversal", + "arango_list_collections", + "arango_disconnect", + }; + for (tools) |t| { + var len: usize = buf.len; + const rc = boj_cartridge_invoke(t.ptr, "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, 0), rc); + try std.testing.expect(std.mem.indexOf(u8, buf[0..len], "result") != null); + } +} + +test "invoke: unknown tool returns -1" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("nope", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -1), rc); +} + +test "invoke: buffer too small returns -3" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("arango_connect", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -3), rc); + try std.testing.expect(len > 4); +} diff --git a/cartridges/domains/database/arango-mcp/ffi/build.zig b/cartridges/domains/database/arango-mcp/ffi/build.zig new file mode 100644 index 0000000..33a952c --- /dev/null +++ b/cartridges/domains/database/arango-mcp/ffi/build.zig @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + // Module + // Shared ADR-0006 invoke-shim module (relative path up to boj-server trunk). + + const shim_mod = b.addModule("cartridge_shim", .{ + + .root_source_file = b.path("../../../ffi/zig/src/cartridge_shim.zig"), + + .target = target, + + .optimize = optimize, + + }); + + const ffi_mod = b.addModule("arango_mcp", .{ + .root_source_file = b.path("arango_mcp_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + ffi_mod.addImport("cartridge_shim", shim_mod); + + // Shared library + const lib = b.addLibrary(.{ + .name = "arango_mcp", + .root_module = ffi_mod, + .linkage = .dynamic, + }); + lib.linkLibC(); + b.installArtifact(lib); + + // Tests + const tests = b.addTest(.{ + .root_module = ffi_mod, + }); + tests.linkLibC(); + + const run_tests = b.addRunArtifact(tests); + const test_step = b.step("test", "Run FFI tests"); + test_step.dependOn(&run_tests.step); +} diff --git a/cartridges/domains/database/arango-mcp/ffi/cartridge_shim.zig b/cartridges/domains/database/arango-mcp/ffi/cartridge_shim.zig new file mode 100644 index 0000000..0e399db --- /dev/null +++ b/cartridges/domains/database/arango-mcp/ffi/cartridge_shim.zig @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// cartridge_shim.zig β€” Shared helpers for the ADR-0006 five-symbol +// cartridge ABI (`boj_cartridge_init / deinit / name / version / invoke`). +// +// The shim centralises the seven-code return convention, NUL-argument +// guards, tool-name comparison, and the buffer-too-small path so each +// cartridge's `boj_cartridge_invoke` can stay short β€” typically a tool +// table plus `shim.writeResult(...)`. +// +// Cartridges import this file by relative path (no build-graph change +// needed). Example: +// +// const shim = @import("../../../ffi/zig/src/cartridge_shim.zig"); +// +// export fn boj_cartridge_invoke( +// tool_name: [*c]const u8, +// json_args: [*c]const u8, +// out_buf: [*c]u8, +// in_out_len: [*c]usize, +// ) callconv(.c) i32 { +// _ = json_args; +// if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; +// const body = if (shim.toolIs(tool_name, "foo")) "{\"result\":{}}" +// else return shim.RC_UNKNOWN_TOOL; +// return shim.writeResult(out_buf, in_out_len, body); +// } + +const std = @import("std"); + +// ── Return codes (ADR-0006 Β§Return codes) ──────────────────────────── +// +// Frozen by ADR-0006. New failure modes compose these via the error +// JSON body β€” the integer surface does not grow without a follow-up ADR. + +pub const RC_SUCCESS: i32 = 0; +pub const RC_UNKNOWN_TOOL: i32 = -1; +pub const RC_BAD_ARGS: i32 = -2; +pub const RC_BUFFER_TOO_SMALL: i32 = -3; +pub const RC_RUNTIME_ERROR: i32 = -4; +pub const RC_PANIC: i32 = -5; +pub const RC_AUTH_DENIED: i32 = -6; + +// ── Invoke-path helpers ────────────────────────────────────────────── + +/// True if any of the three mandatory `boj_cartridge_invoke` output-path +/// pointers is null. Use at the top of every invoke to short-circuit to +/// `RC_BAD_ARGS`. +pub fn invokeArgsNull( + tool_name: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) bool { + return tool_name == null or out_buf == null or in_out_len == null; +} + +/// Compare a C-NUL-terminated tool-name pointer against a Zig string +/// literal. Caller must have already verified `tool_name` is non-null +/// (usually via `invokeArgsNull`). +pub fn toolIs(tool_name: [*c]const u8, expected: []const u8) bool { + const s = std.mem.span(@as([*:0]const u8, @ptrCast(tool_name))); + return std.mem.eql(u8, s, expected); +} + +/// Copy `body` into `out_buf[0..*in_out_len]` (as a capacity) and update +/// `*in_out_len` to the number of bytes written. Returns `RC_SUCCESS`. +/// +/// If `body.len` exceeds the current capacity stored in `*in_out_len`, +/// sets `*in_out_len` to the required size and returns +/// `RC_BUFFER_TOO_SMALL` β€” the caller is then expected to re-allocate +/// and retry, per ADR-0006 Β§Memory ownership. +/// +/// Caller must have already verified that `out_buf` and `in_out_len` +/// are non-null. +pub fn writeResult( + out_buf: [*c]u8, + in_out_len: [*c]usize, + body: []const u8, +) i32 { + const cap = in_out_len.*; + if (body.len > cap) { + in_out_len.* = body.len; + return RC_BUFFER_TOO_SMALL; + } + @memcpy(out_buf[0..body.len], body); + in_out_len.* = body.len; + return RC_SUCCESS; +} + +// ── Tests ──────────────────────────────────────────────────────────── + +test "writeResult: body fits, writes and sets length" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); + try std.testing.expectEqualStrings("hello", buf[0..len]); +} + +test "writeResult: too small returns -3 and sets required length" { + var buf: [2]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_BUFFER_TOO_SMALL, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: exact-fit succeeds" { + var buf: [5]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: empty body" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, ""); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 0), len); +} + +test "toolIs: matches and rejects" { + const name: [*:0]const u8 = "foo"; + try std.testing.expect(toolIs(@ptrCast(name), "foo")); + try std.testing.expect(!toolIs(@ptrCast(name), "bar")); + try std.testing.expect(!toolIs(@ptrCast(name), "foobar")); + try std.testing.expect(!toolIs(@ptrCast(name), "fo")); +} + +test "invokeArgsNull: detects each null slot" { + var buf: [4]u8 = undefined; + var len: usize = 4; + const name: [*:0]const u8 = "x"; + try std.testing.expect(!invokeArgsNull(@ptrCast(name), &buf, &len)); + try std.testing.expect(invokeArgsNull(null, &buf, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), null, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), &buf, null)); +} diff --git a/cartridges/domains/database/arango-mcp/minter.toml b/cartridges/domains/database/arango-mcp/minter.toml new file mode 100644 index 0000000..c6c2e5e --- /dev/null +++ b/cartridges/domains/database/arango-mcp/minter.toml @@ -0,0 +1,16 @@ +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> + +name = "arango-mcp" +description = "ArangoDB multi-model MCP cartridge β€” documents, graphs, key-value, AQL queries" +version = "0.1.0" +domain = "Database" +protocols = [ + "MCP", + "REST", +] +tier = "Ayo" +backend = "universal" +generate_panel = true +auth = "bearer_or_basic" +api_base = "https://{host}:8529/_api/" diff --git a/cartridges/domains/database/arango-mcp/mod.js b/cartridges/domains/database/arango-mcp/mod.js new file mode 100644 index 0000000..3bb21ee --- /dev/null +++ b/cartridges/domains/database/arango-mcp/mod.js @@ -0,0 +1,90 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// arango-mcp/mod.js -- arango gateway. + +const BASE_URL = Deno.env.get("ARANGO_MCP_BACKEND_URL") ?? "http://127.0.0.1:7727"; + +async function post(path, payload) { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), 15000); + try { + const r = await fetch(`${BASE_URL}${path}`, { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), signal: ctrl.signal, + }); + const data = await r.json().catch(() => ({ success: false, error: "non-JSON response" })); + return { status: r.status, data }; + } catch (e) { + if (e.name === "AbortError") return { status: 504, data: { success: false, error: "arango-mcp backend timed out" } }; + return { status: 503, data: { success: false, error: `arango-mcp backend unavailable: ${e.message}` } }; + } finally { clearTimeout(t); } +} + +export async function handleTool(toolName, args) { + switch (toolName) { + + case "arango_connect": { + const { url, database, username, password } = args ?? {}; + if (!url) return { status: 400, data: { error: "url is required" } }; + const payload = { url }; + if (database !== undefined) payload.database = database; + if (username !== undefined) payload.username = username; + if (password !== undefined) payload.password = password; + return post("/api/v1/connect", payload); + } + case "arango_aql": { + const { slot, query, bind_vars } = args ?? {}; + if (!slot || !query) return { status: 400, data: { error: "slot is required" } }; + const payload = { slot, query }; + if (bind_vars !== undefined) payload.bind_vars = bind_vars; + return post("/api/v1/aql", payload); + } + case "arango_insert": { + const { slot, collection, document } = args ?? {}; + if (!slot || !collection || !document) return { status: 400, data: { error: "slot is required" } }; + const payload = { slot, collection, document }; + return post("/api/v1/insert", payload); + } + case "arango_get": { + const { slot, collection, key } = args ?? {}; + if (!slot || !collection || !key) return { status: 400, data: { error: "slot is required" } }; + const payload = { slot, collection, key }; + return post("/api/v1/get", payload); + } + case "arango_update": { + const { slot, collection, key, update } = args ?? {}; + if (!slot || !collection || !key || !update) return { status: 400, data: { error: "slot is required" } }; + const payload = { slot, collection, key, update }; + return post("/api/v1/update", payload); + } + case "arango_delete": { + const { slot, collection, key } = args ?? {}; + if (!slot || !collection || !key) return { status: 400, data: { error: "slot is required" } }; + const payload = { slot, collection, key }; + return post("/api/v1/delete", payload); + } + case "arango_graph_traversal": { + const { slot, graph, start_vertex, depth } = args ?? {}; + if (!slot || !graph || !start_vertex) return { status: 400, data: { error: "slot is required" } }; + const payload = { slot, graph, start_vertex }; + if (depth !== undefined) payload.depth = depth; + return post("/api/v1/graph-traversal", payload); + } + case "arango_list_collections": { + const { slot } = args ?? {}; + if (!slot) return { status: 400, data: { error: "slot is required" } }; + const payload = { slot }; + return post("/api/v1/list-collections", payload); + } + case "arango_disconnect": { + const { slot } = args ?? {}; + if (!slot) return { status: 400, data: { error: "slot is required" } }; + const payload = { slot }; + return post("/api/v1/disconnect", payload); + } + + default: + return { status: 404, data: { error: `Unknown tool: ${toolName}` } }; + } +} diff --git a/cartridges/domains/database/arango-mcp/panels/manifest.json b/cartridges/domains/database/arango-mcp/panels/manifest.json new file mode 100644 index 0000000..e78fee4 --- /dev/null +++ b/cartridges/domains/database/arango-mcp/panels/manifest.json @@ -0,0 +1,114 @@ +{ + "$schema": "https://panll.dev/schemas/panel-manifest/v1.json", + "spdx": "MPL-2.0", + "cartridge": "arango-mcp", + "domain": "Database", + "version": "0.1.0", + "panels": [ + { + "id": "arango-connection-status", + "title": "Connection Status", + "description": "Current ArangoDB connection state β€” disconnected, connected, query running, or error", + "type": "status-indicator", + "data_source": { + "endpoint": "/arango/connection/status", + "method": "GET", + "refresh_interval_ms": 10000 + }, + "widgets": [ + { + "type": "state-badge", + "field": "connection_state", + "states": { + "connected": { "color": "#2ecc71", "icon": "zap" }, + "disconnected": { "color": "#95a5a6", "icon": "power-off" }, + "query_running": { "color": "#3498db", "icon": "loader" }, + "error": { "color": "#e74c3c", "icon": "alert-triangle" } + } + } + ] + }, + { + "id": "arango-database-count", + "title": "Database Count", + "description": "Total number of ArangoDB databases accessible via the configured connection", + "type": "metric", + "data_source": { + "endpoint": "/arango/databases", + "method": "GET", + "refresh_interval_ms": 30000 + }, + "widgets": [ + { + "type": "counter", + "field": "database_count", + "label": "Databases", + "icon": "database" + } + ] + }, + { + "id": "arango-collection-count", + "title": "Collection Count", + "description": "Total number of collections across all databases", + "type": "metric", + "data_source": { + "endpoint": "/arango/collections", + "method": "GET", + "refresh_interval_ms": 30000 + }, + "widgets": [ + { + "type": "counter", + "field": "collection_count", + "label": "Collections", + "icon": "layers" + } + ] + }, + { + "id": "arango-query-metrics", + "title": "Query Metrics", + "description": "AQL query count and document operation count across all sessions", + "type": "metric", + "data_source": { + "endpoint": "/arango/metrics", + "method": "GET", + "refresh_interval_ms": 15000 + }, + "widgets": [ + { + "type": "counter", + "field": "total_query_count", + "label": "AQL Queries", + "icon": "file-search" + }, + { + "type": "counter", + "field": "total_document_ops", + "label": "Document Ops", + "icon": "file-text" + } + ] + }, + { + "id": "arango-graph-count", + "title": "Graph Count", + "description": "Total number of named graphs in ArangoDB", + "type": "metric", + "data_source": { + "endpoint": "/arango/graphs", + "method": "GET", + "refresh_interval_ms": 30000 + }, + "widgets": [ + { + "type": "counter", + "field": "graph_count", + "label": "Graphs", + "icon": "share-2" + } + ] + } + ] +} diff --git a/cartridges/domains/database/arango-mcp/tests/integration_test.sh b/cartridges/domains/database/arango-mcp/tests/integration_test.sh new file mode 100755 index 0000000..8bf0edf --- /dev/null +++ b/cartridges/domains/database/arango-mcp/tests/integration_test.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Integration tests for arango-mcp cartridge. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CART_DIR="$(dirname "$SCRIPT_DIR")" +FFI_DIR="$CART_DIR/ffi" + +echo "=== arango-mcp integration tests ===" + +# Build FFI +echo "[1/3] Building FFI..." +cd "$FFI_DIR" && zig build 2>&1 + +# Run Zig unit tests +echo "[2/3] Running FFI unit tests..." +cd "$FFI_DIR" && zig build test 2>&1 + +# Validate Idris2 ABI (if idris2 available) +echo "[3/3] Checking ABI..." +if command -v idris2 &>/dev/null; then + cd "$CART_DIR/abi" && idris2 --check ArangoMcp.SafeDatabase 2>&1 + echo " ABI: OK" +else + echo " ABI: SKIPPED (idris2 not in PATH)" +fi + +echo "" +echo "All tests passed for arango-mcp!" diff --git a/cartridges/domains/database/clickhouse-mcp/README.adoc b/cartridges/domains/database/clickhouse-mcp/README.adoc new file mode 100644 index 0000000..b31a8d5 --- /dev/null +++ b/cartridges/domains/database/clickhouse-mcp/README.adoc @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> + += clickhouse-mcp +Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk> +:spdx: MPL-2.0 +:tier: Ayo +:domain: Database +:protocols: MCP, REST + +== Overview + +ClickHouse column-oriented OLAP database MCP cartridge. Provides type-safe +access to the ClickHouse HTTP interface (port 8123) covering databases, tables +(MergeTree family), queries, partitions, and system management. Auth via basic +auth or no auth (configurable per deployment). Optimised for analytical +workloads with batch inserts and columnar storage. + +=== Actions + +ListDatabases, CreateDatabase, DropDatabase, ListTables, CreateTable, +DropTable, DescribeTable, SelectQuery, InsertData, ExplainQuery, +ShowProcesslist, KillQuery, OptimizeTable, TruncateTable, ListPartitions, +SystemReloadConfig. + +== Architecture + +[cols="1,1,2"] +|=== +| Layer | Language | Purpose + +| ABI +| Idris2 +| Formally verified state machine (Disconnected / Connected / QueryRunning / Error) + +| FFI +| Zig +| C-compatible implementation with thread-safe session pool + +| Adapter +| zig +| REST bridge to BoJ unified adapter protocol +|=== + +== Building + +[source,bash] +---- +# Build FFI shared library +cd ffi && zig build + +# Run FFI tests +cd ffi && zig build test + +# Type-check ABI +cd abi && idris2 --check ClickhouseMcp.SafeDatabase +---- + +== Panels + +* Connection status (disconnected / connected / query running / error) +* Query count +* Insert count +* Database count + +== Status + +Development -- not yet ready for mounting. diff --git a/cartridges/domains/database/clickhouse-mcp/abi/ClickhouseMcp/SafeDatabase.idr b/cartridges/domains/database/clickhouse-mcp/abi/ClickhouseMcp/SafeDatabase.idr new file mode 100644 index 0000000..376e8d5 --- /dev/null +++ b/cartridges/domains/database/clickhouse-mcp/abi/ClickhouseMcp/SafeDatabase.idr @@ -0,0 +1,167 @@ +-- SPDX-License-Identifier: MPL-2.0 +-- Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +-- +-- ClickhouseMcp.SafeDatabase β€” Type-safe ABI for the clickhouse-mcp cartridge. +-- +-- Provides a formally verified state machine for ClickHouse connections. +-- Dependent-type proofs ensure only valid transitions can occur at the FFI +-- boundary. ClickHouse actions cover the HTTP interface surface +-- (https://{host}:8123/). Auth via basic auth or no auth (configurable). +-- Column-oriented OLAP database optimised for analytical queries. + +module ClickhouseMcp.SafeDatabase + +%default total + +-- --------------------------------------------------------------------------- +-- Connection state machine +-- --------------------------------------------------------------------------- + +||| Connection state for ClickHouse OLAP database operations. +||| Disconnected is the initial state; connections use the HTTP interface. +public export +data ConnState = Disconnected | Connected | QueryRunning | Error + +||| Proof that a state transition is valid. +public export +data ValidTransition : ConnState -> ConnState -> Type where + Connect : ValidTransition Disconnected Connected + StartQuery : ValidTransition Connected QueryRunning + FinishQuery : ValidTransition QueryRunning Connected + Disconnect : ValidTransition Connected Disconnected + QueryFail : ValidTransition QueryRunning Error + ErrorRecover : ValidTransition Error Disconnected + +-- --------------------------------------------------------------------------- +-- C-ABI integer encoding +-- --------------------------------------------------------------------------- + +||| Encode connection state as C-compatible integer. +export +connStateToInt : ConnState -> Int +connStateToInt Disconnected = 0 +connStateToInt Connected = 1 +connStateToInt QueryRunning = 2 +connStateToInt Error = 3 + +||| Decode integer back to connection state. +export +intToConnState : Int -> Maybe ConnState +intToConnState 0 = Just Disconnected +intToConnState 1 = Just Connected +intToConnState 2 = Just QueryRunning +intToConnState 3 = Just Error +intToConnState _ = Nothing + +||| Check if a state transition is valid (C-ABI export). +||| Returns 1 for valid, 0 for invalid. +export +clickhouse_mcp_can_transition : Int -> Int -> Int +clickhouse_mcp_can_transition from to = + case (intToConnState from, intToConnState to) of + (Just Disconnected, Just Connected) => 1 + (Just Connected, Just QueryRunning) => 1 + (Just QueryRunning, Just Connected) => 1 + (Just Connected, Just Disconnected) => 1 + (Just QueryRunning, Just Error) => 1 + (Just Error, Just Disconnected) => 1 + _ => 0 + +-- --------------------------------------------------------------------------- +-- ClickHouse actions (full HTTP interface surface) +-- --------------------------------------------------------------------------- + +||| Actions supported by the ClickHouse MCP cartridge. +||| Covers databases, tables, queries, partitions, and system management. +public export +data ClickhouseAction + = ListDatabases + | CreateDatabase + | DropDatabase + | ListTables + | CreateTable + | DropTable + | DescribeTable + | SelectQuery + | InsertData + | ExplainQuery + | ShowProcesslist + | KillQuery + | OptimizeTable + | TruncateTable + | ListPartitions + | SystemReloadConfig + +||| Encode action as C-compatible integer. +export +clickhouseActionToInt : ClickhouseAction -> Int +clickhouseActionToInt ListDatabases = 0 +clickhouseActionToInt CreateDatabase = 1 +clickhouseActionToInt DropDatabase = 2 +clickhouseActionToInt ListTables = 3 +clickhouseActionToInt CreateTable = 4 +clickhouseActionToInt DropTable = 5 +clickhouseActionToInt DescribeTable = 6 +clickhouseActionToInt SelectQuery = 7 +clickhouseActionToInt InsertData = 8 +clickhouseActionToInt ExplainQuery = 9 +clickhouseActionToInt ShowProcesslist = 10 +clickhouseActionToInt KillQuery = 11 +clickhouseActionToInt OptimizeTable = 12 +clickhouseActionToInt TruncateTable = 13 +clickhouseActionToInt ListPartitions = 14 +clickhouseActionToInt SystemReloadConfig = 15 + +||| Decode integer back to action. +export +intToClickhouseAction : Int -> Maybe ClickhouseAction +intToClickhouseAction 0 = Just ListDatabases +intToClickhouseAction 1 = Just CreateDatabase +intToClickhouseAction 2 = Just DropDatabase +intToClickhouseAction 3 = Just ListTables +intToClickhouseAction 4 = Just CreateTable +intToClickhouseAction 5 = Just DropTable +intToClickhouseAction 6 = Just DescribeTable +intToClickhouseAction 7 = Just SelectQuery +intToClickhouseAction 8 = Just InsertData +intToClickhouseAction 9 = Just ExplainQuery +intToClickhouseAction 10 = Just ShowProcesslist +intToClickhouseAction 11 = Just KillQuery +intToClickhouseAction 12 = Just OptimizeTable +intToClickhouseAction 13 = Just TruncateTable +intToClickhouseAction 14 = Just ListPartitions +intToClickhouseAction 15 = Just SystemReloadConfig +intToClickhouseAction _ = Nothing + +||| Check whether an action requires an active connection. +export +actionRequiresConnection : ClickhouseAction -> Bool +actionRequiresConnection SelectQuery = True +actionRequiresConnection InsertData = True +actionRequiresConnection ExplainQuery = True +actionRequiresConnection ShowProcesslist = True +actionRequiresConnection KillQuery = True +actionRequiresConnection OptimizeTable = True +actionRequiresConnection TruncateTable = True +actionRequiresConnection ListPartitions = True +actionRequiresConnection SystemReloadConfig = True +actionRequiresConnection _ = False + +||| Total number of actions exposed by this cartridge. +export +actionCount : Nat +actionCount = 16 + +-- --------------------------------------------------------------------------- +-- Auth configuration +-- --------------------------------------------------------------------------- + +||| Authentication method for ClickHouse HTTP interface. +||| Basic auth (user/password) or no auth (configurable per deployment). +public export +data ClickhouseAuth = BasicAuth | NoAuth + +||| Default base URL for ClickHouse HTTP interface. +export +clickhouseApiBase : String +clickhouseApiBase = "https://{host}:8123/" diff --git a/cartridges/domains/database/clickhouse-mcp/abi/README.adoc b/cartridges/domains/database/clickhouse-mcp/abi/README.adoc new file mode 100644 index 0000000..72dbc3f --- /dev/null +++ b/cartridges/domains/database/clickhouse-mcp/abi/README.adoc @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += clickhouse-mcp / abi β€” Idris2 ABI layer +:orientation: shallow + +== Purpose + +Cartridge-local protocol types for `clickhouse-mcp`. Defines the data, tool, and +transition vocabulary this cartridge speaks. Inherits safety lemmas and the +matrix-cell identifier scheme from the trunk ABI at `src/abi/`. + +== Files + +1 Idris2 module(s), ~167 lines total. Lead module: +`SafeDatabase.idr`. + +No `.ipkg` β€” consumed directly by wider BoJ callers; type-checks inside the +trunk `boj.ipkg`. + +== Invariants + +* `%default total` at every module top. +* Zero `believe_me`, `assert_total`, `postulate`, `Admitted`, or + `sorry` (the trunk holds the four declared `believe_me` sites; leaf + cartridges carry none). + +== Test/proof surface + +Thin module(s), no inline tests. Proof surface = type-check success under the +trunk `idris2 --check`. + +== Read-first + +. `SafeDatabase.idr` β€” cartridge-local data records and enums. +. Trunk `src/abi/Boj/Catalogue.idr` for how this cell participates in the + 2D matrix. diff --git a/cartridges/domains/database/clickhouse-mcp/abi/clickhouse_mcp.ipkg b/cartridges/domains/database/clickhouse-mcp/abi/clickhouse_mcp.ipkg new file mode 100644 index 0000000..4c6a2cf --- /dev/null +++ b/cartridges/domains/database/clickhouse-mcp/abi/clickhouse_mcp.ipkg @@ -0,0 +1,11 @@ +-- SPDX-License-Identifier: MPL-2.0 +-- Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +package clickhouse_mcp + +version = "0.1.0" +authors = "Jonathan D.A. Jewell" +brief = "ClickHouse OLAP database MCP cartridge β€” type-safe ABI layer" + +depends = base + +modules = ClickhouseMcp.SafeDatabase diff --git a/cartridges/domains/database/clickhouse-mcp/adapter/README.adoc b/cartridges/domains/database/clickhouse-mcp/adapter/README.adoc new file mode 100644 index 0000000..7bea2a8 --- /dev/null +++ b/cartridges/domains/database/clickhouse-mcp/adapter/README.adoc @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += clickhouse-mcp / adapter β€” protocol bridge +:orientation: shallow + +== Purpose + +Exposes this cartridge's FFI over the wire protocols declared in +`cartridge.json`. Dispatches tool invocations to the C-ABI exports at +`../ffi/` and formats responses per protocol. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” depends on the sibling `ffi` target. +| `clickhouse_mcp_adapter.zig` | Protocol dispatch (112 lines). Tool catalogue matches `cartridge.json`. +| `SIDELINED-*.v.adoc` | zig predecessor(s). Not built β€” zig banned 2026-04-10. +|=== + +== Invariants + +* **Stateless** β€” all state lives behind the FFI, never in the adapter. +* **Response passthrough** β€” whatever the FFI returns goes back to the wire + unmodified (no embellishment, no silent recovery). +* **Cartridge.json is source of truth** for the tool catalogue; drift between + adapter and manifest is a CI failure. + +== Test/proof surface + +No inline tests at the adapter layer β€” protocol routing is exercised by the +trunk seam tests at `ffi/zig/src/seams.zig`. + +== Read-first + +. `clickhouse_mcp_adapter.zig` β€” dispatch function and per-protocol routers. +. `../cartridge.json` β€” the tool manifest the dispatcher mirrors. diff --git a/cartridges/domains/database/clickhouse-mcp/adapter/build.zig b/cartridges/domains/database/clickhouse-mcp/adapter/build.zig new file mode 100644 index 0000000..119dc59 --- /dev/null +++ b/cartridges/domains/database/clickhouse-mcp/adapter/build.zig @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// clickhouse-mcp/adapter/build.zig + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); const optimize = b.standardOptimizeOption(.{}); + const ffi_mod = b.createModule(.{ .root_source_file = b.path("../ffi/clickhouse_mcp_ffi.zig"), .target = target, .optimize = optimize }); + const adapter = b.addExecutable(.{ .name = "clickhouse_mcp_adapter", .root_source_file = b.path("clickhouse_mcp_adapter.zig"), .target = target, .optimize = optimize }); + adapter.root_module.addImport("clickhouse_mcp_ffi", ffi_mod); b.installArtifact(adapter); + const rs = b.step("run", "Run clickhouse-mcp adapter"); rs.dependOn(&b.addRunArtifact(adapter).step); + const tests = b.addTest(.{ .root_source_file = b.path("clickhouse_mcp_adapter.zig"), .target = target, .optimize = optimize }); + tests.root_module.addImport("clickhouse_mcp_ffi", ffi_mod); + const ts = b.step("test", "Test clickhouse-mcp adapter"); ts.dependOn(&b.addRunArtifact(tests).step); +} diff --git a/cartridges/domains/database/clickhouse-mcp/adapter/clickhouse_mcp_adapter.zig b/cartridges/domains/database/clickhouse-mcp/adapter/clickhouse_mcp_adapter.zig new file mode 100644 index 0000000..5000746 --- /dev/null +++ b/cartridges/domains/database/clickhouse-mcp/adapter/clickhouse_mcp_adapter.zig @@ -0,0 +1,112 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// clickhouse-mcp/adapter/clickhouse_mcp_adapter.zig -- Unified three-protocol adapter. +// Replaces banned clickhouse_mcp_adapter.v (zig, removed 2026-04-12). +// REST:9166 gRPC:9167 GraphQL:9168 +// Tools: clickhouse_connect, clickhouse_query, clickhouse_insert, clickhouse_ddl... + +const std = @import("std"); +const ffi = @import("clickhouse_mcp_ffi"); + +const REST_PORT: u16 = 9166; +const GRPC_PORT: u16 = 9167; +const GQL_PORT: u16 = 9168; +const MAX_CONN_BUF: usize = 16 * 1024; + +fn okJson(buf: []u8, msg: []const u8) []u8 { + const n = std.fmt.bufPrint(buf, "{\"success\":true,\"message\":\"{s}\"}", .{msg}) catch return buf[0..0]; return n; +} +fn errJson(buf: []u8, msg: []const u8) []u8 { + const n = std.fmt.bufPrint(buf, "{\"success\":false,\"error\":\"{s}\"}", .{msg}) catch return buf[0..0]; return n; +} +fn statusJson(buf: []u8) []u8 { + const n = std.fmt.bufPrint(buf, "{\"success\":true,\"state\":\"ready\",\"service\":\"clickhouse-mcp\"}", .{}) catch return buf[0..0]; return n; +} + +const Response = struct { status: u16, body: []u8 }; + +fn dispatch(tool: []const u8, body: []const u8, resp: []u8) Response { + _ = body; + if (std.mem.eql(u8, tool, "clickhouse_connect")) return .{ .status = 200, .body = okJson(resp, "clickhouse_connect forwarded") }; + if (std.mem.eql(u8, tool, "clickhouse_query")) return .{ .status = 200, .body = okJson(resp, "clickhouse_query forwarded") }; + if (std.mem.eql(u8, tool, "clickhouse_insert")) return .{ .status = 200, .body = okJson(resp, "clickhouse_insert forwarded") }; + if (std.mem.eql(u8, tool, "clickhouse_ddl")) return .{ .status = 200, .body = okJson(resp, "clickhouse_ddl forwarded") }; + if (std.mem.eql(u8, tool, "clickhouse_list_tables")) return .{ .status = 200, .body = okJson(resp, "clickhouse_list_tables forwarded") }; + if (std.mem.eql(u8, tool, "clickhouse_describe")) return .{ .status = 200, .body = okJson(resp, "clickhouse_describe forwarded") }; + if (std.mem.eql(u8, tool, "clickhouse_disconnect")) return .{ .status = 200, .body = okJson(resp, "clickhouse_disconnect forwarded") }; + if (std.mem.eql(u8, tool, "status") or std.mem.eql(u8, tool, "health")) return .{ .status = 200, .body = statusJson(resp) }; + return .{ .status = 404, .body = errJson(resp, "Unknown tool") }; +} + +fn dispatchRest(path: []const u8, body: []const u8, resp: []u8) Response { + if (std.mem.startsWith(u8, path, "/tools/")) return dispatch(path["/tools/".len..], body, resp); + if (std.mem.eql(u8, path, "/status") or std.mem.eql(u8, path, "/health")) return .{ .status = 200, .body = statusJson(resp) }; + return .{ .status = 404, .body = errJson(resp, "Not found") }; +} + +fn dispatchGrpc(path: []const u8, body: []const u8, resp: []u8) Response { + const prefix = "/ClickhouseMcpservice/"; + if (!std.mem.startsWith(u8, path, prefix)) return .{ .status = 404, .body = errJson(resp, "Not a recognized gRPC path") }; + const method = path[prefix.len..]; + const tool = blk: { + if (std.mem.eql(u8, method, "clickhouse_connect")) break :blk "clickhouse_connect"; + if (std.mem.eql(u8, method, "clickhouse_query")) break :blk "clickhouse_query"; + if (std.mem.eql(u8, method, "clickhouse_insert")) break :blk "clickhouse_insert"; + if (std.mem.eql(u8, method, "clickhouse_ddl")) break :blk "clickhouse_ddl"; + if (std.mem.eql(u8, method, "clickhouse_list_tables")) break :blk "clickhouse_list_tables"; + if (std.mem.eql(u8, method, "clickhouse_describe")) break :blk "clickhouse_describe"; + if (std.mem.eql(u8, method, "clickhouse_disconnect")) break :blk "clickhouse_disconnect"; + return .{ .status = 404, .body = errJson(resp, "Unknown gRPC method") }; + }; + return dispatch(tool, body, resp); +} + +fn dispatchGraphql(body: []const u8, resp: []u8) Response { + if (std.mem.indexOf(u8, body, "__schema") != null) return .{ .status = 200, .body = okJson(resp, "schema not supported") }; + if (std.mem.indexOf(u8, body, "connect") != null) return dispatch("clickhouse_connect", body, resp); + if (std.mem.indexOf(u8, body, "query") != null) return dispatch("clickhouse_query", body, resp); + if (std.mem.indexOf(u8, body, "insert") != null) return dispatch("clickhouse_insert", body, resp); + if (std.mem.indexOf(u8, body, "ddl") != null) return dispatch("clickhouse_ddl", body, resp); + if (std.mem.indexOf(u8, body, "list_tables") != null) return dispatch("clickhouse_list_tables", body, resp); + if (std.mem.indexOf(u8, body, "describe") != null) return dispatch("clickhouse_describe", body, resp); + if (std.mem.indexOf(u8, body, "disconnect") != null) return dispatch("clickhouse_disconnect", body, resp); + return .{ .status = 200, .body = errJson(resp, "Unrecognised GraphQL operation") }; +} + +const Protocol = enum { rest, grpc, graphql }; + +fn handleConnection(conn: std.net.Server.Connection, proto: Protocol) void { + defer conn.stream.close(); + var in_buf: [MAX_CONN_BUF]u8 = undefined; + const n = conn.stream.read(&in_buf) catch return; + const req = in_buf[0..n]; + var path: []const u8 = "/"; var body: []const u8 = ""; + if (n > 4) { + const le = std.mem.indexOf(u8, req, "\r\n") orelse req.len; + const fl = req[0..le]; const sp1 = std.mem.indexOfScalar(u8, fl, ' ') orelse 0; + const ro = fl[sp1+1..]; const sp2 = std.mem.indexOfScalar(u8, ro, ' ') orelse ro.len; + path = ro[0..sp2]; + const bs = std.mem.indexOf(u8, req, "\r\n\r\n") orelse n; body = req[@min(bs+4,n)..]; + } + var rb: [MAX_CONN_BUF]u8 = undefined; + const result = switch (proto) { .rest => dispatchRest(path, body, &rb), .grpc => dispatchGrpc(path, body, &rb), .graphql => dispatchGraphql(body, &rb) }; + const ct: []const u8 = if (proto == .grpc) "application/grpc+json" else "application/json"; + var hb: [256]u8 = undefined; + const hdr = std.fmt.bufPrint(&hb, "HTTP/1.1 {d} OK\r\nContent-Type: {s}\r\nContent-Length: {d}\r\nConnection: close\r\n\r\n", .{ result.status, ct, result.body.len }) catch return; + _ = conn.stream.writeAll(hdr) catch return; _ = conn.stream.writeAll(result.body) catch return; +} + +fn listenLoop(port: u16, proto: Protocol) void { + const addr = std.net.Address.initIp4(.{ 127, 0, 0, 1 }, port) catch return; + var srv = addr.listen(.{ .reuse_address = true }) catch return; defer srv.deinit(); + while (true) { const conn = srv.accept() catch continue; handleConnection(conn, proto); } +} + +pub fn main() !void { + ffi.clickhouse_mcp_init(); + const t1 = try std.Thread.spawn(.{}, listenLoop, .{ REST_PORT, Protocol.rest }); + const t2 = try std.Thread.spawn(.{}, listenLoop, .{ GRPC_PORT, Protocol.grpc }); + const t3 = try std.Thread.spawn(.{}, listenLoop, .{ GQL_PORT, Protocol.graphql }); + t1.join(); t2.join(); t3.join(); +} diff --git a/cartridges/domains/database/clickhouse-mcp/benchmarks/quick-bench.sh b/cartridges/domains/database/clickhouse-mcp/benchmarks/quick-bench.sh new file mode 100755 index 0000000..98f41fc --- /dev/null +++ b/cartridges/domains/database/clickhouse-mcp/benchmarks/quick-bench.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +# Benchmarks for clickhouse-mcp cartridge. +set -euo pipefail + +echo "=== clickhouse-mcp benchmarks ===" + +cd "$(dirname "${BASH_SOURCE[0]}")/../ffi" + +# Build with ReleaseFast for benchmarking +zig build -Doptimize=ReleaseFast 2>&1 + +echo "Session open/close cycle (1000 iterations):" +time for i in $(seq 1 1000); do + # This would call the FFI benchmark binary + true +done + +echo "" +echo "Benchmark placeholder β€” implement real benchmarks in Zig test blocks" +echo "or via the zig adapter HTTP benchmark tool." diff --git a/cartridges/domains/database/clickhouse-mcp/cartridge.json b/cartridges/domains/database/clickhouse-mcp/cartridge.json new file mode 100644 index 0000000..9d8f1a8 --- /dev/null +++ b/cartridges/domains/database/clickhouse-mcp/cartridge.json @@ -0,0 +1,195 @@ +{ + "$schema": "https://boj.dev/schemas/cartridge/v1.json", + "spdx": "MPL-2.0", + "copyright": "Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath)", + "name": "clickhouse-mcp", + "version": "0.1.0", + "description": "ClickHouse analytics database gateway. Columnar queries, bulk inserts, and real-time analytics via session management.", + "domain": "Database", + "tier": "Ayo", + "protocols": [ + "MCP", + "REST" + ], + "auth": { + "method": "none", + "env_var": null, + "credential_source": null + }, + "api": { + "base_url": "local://clickhouse-mcp", + "content_type": "application/json" + }, + "tools": [ + { + "name": "clickhouse_connect", + "description": "Connect to a ClickHouse server. Returns a session slot.", + "inputSchema": { + "type": "object", + "properties": { + "host": { + "type": "string", + "description": "ClickHouse host (default: 127.0.0.1)" + }, + "port": { + "type": "integer", + "description": "HTTP port (default: 8123)" + }, + "database": { + "type": "string", + "description": "Default database (default: default)" + }, + "user": { + "type": "string", + "description": "Username (default: default)" + }, + "password": { + "type": "string", + "description": "Password" + } + }, + "required": [] + } + }, + { + "name": "clickhouse_query", + "description": "Execute a SELECT query and return results.", + "inputSchema": { + "type": "object", + "properties": { + "slot": { + "type": "integer", + "description": "Session slot from clickhouse_connect" + }, + "sql": { + "type": "string", + "description": "SQL SELECT query" + }, + "format": { + "type": "string", + "description": "Output format: JSON (default) or CSV" + } + }, + "required": [ + "slot", + "sql" + ] + } + }, + { + "name": "clickhouse_insert", + "description": "Insert rows into a table.", + "inputSchema": { + "type": "object", + "properties": { + "slot": { + "type": "integer", + "description": "Session slot" + }, + "table": { + "type": "string", + "description": "Table name" + }, + "data": { + "type": "string", + "description": "Row data as JSON array of objects" + } + }, + "required": [ + "slot", + "table", + "data" + ] + } + }, + { + "name": "clickhouse_ddl", + "description": "Execute a DDL statement (CREATE/ALTER/DROP).", + "inputSchema": { + "type": "object", + "properties": { + "slot": { + "type": "integer", + "description": "Session slot" + }, + "sql": { + "type": "string", + "description": "DDL statement" + } + }, + "required": [ + "slot", + "sql" + ] + } + }, + { + "name": "clickhouse_list_tables", + "description": "List tables in the current database.", + "inputSchema": { + "type": "object", + "properties": { + "slot": { + "type": "integer", + "description": "Session slot" + }, + "database": { + "type": "string", + "description": "Database name (uses connected database if omitted)" + } + }, + "required": [ + "slot" + ] + } + }, + { + "name": "clickhouse_describe", + "description": "Describe a table's column schema.", + "inputSchema": { + "type": "object", + "properties": { + "slot": { + "type": "integer", + "description": "Session slot" + }, + "table": { + "type": "string", + "description": "Table name" + } + }, + "required": [ + "slot", + "table" + ] + } + }, + { + "name": "clickhouse_disconnect", + "description": "Disconnect and release a session slot.", + "inputSchema": { + "type": "object", + "properties": { + "slot": { + "type": "integer", + "description": "Session slot to release" + } + }, + "required": [ + "slot" + ] + } + } + ], + "ffi": { + "so_path": "ffi/zig-out/lib/libclickhouse_mcp.so", + "abi_version": "ADR-0006", + "symbols": [ + "boj_cartridge_init", + "boj_cartridge_deinit", + "boj_cartridge_name", + "boj_cartridge_version", + "boj_cartridge_invoke" + ] + } +} diff --git a/cartridges/domains/database/clickhouse-mcp/ffi/README.adoc b/cartridges/domains/database/clickhouse-mcp/ffi/README.adoc new file mode 100644 index 0000000..1069d52 --- /dev/null +++ b/cartridges/domains/database/clickhouse-mcp/ffi/README.adoc @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += clickhouse-mcp / ffi β€” Zig FFI layer +:orientation: shallow + +== Purpose + +C-ABI exports bridging this cartridge's Idris2 ABI to the runtime. Consumed by +the adapter for protocol dispatch and by the trunk loader for mount. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” produces `zig-out/lib/libclickhouse.so`. +| `clickhouse_mcp_ffi.zig` | C-ABI exports (12 exports, 7 inline tests, 372 lines). +|=== + +== Invariants + +* **Null-pointer rejection** on `[*c]const u8` arguments before any + dereference. +* **Negative return values** carry documented error meanings; zero is + success (matching the trunk error-code convention). +* **No allocator leaks** β€” every allocation path pairs with `defer free`. + +== Test/proof surface + +7 inline `test "..."` block(s) in `clickhouse_mcp_ffi.zig`. Run with +`cd ffi && zig build test`. + +== Read-first + +. `clickhouse_mcp_ffi.zig` β€” C-ABI signatures and guards. +. Trunk `ffi/zig/src/catalogue.zig` for how this `.so` is loaded. diff --git a/cartridges/domains/database/clickhouse-mcp/ffi/build.zig b/cartridges/domains/database/clickhouse-mcp/ffi/build.zig new file mode 100644 index 0000000..c05ea30 --- /dev/null +++ b/cartridges/domains/database/clickhouse-mcp/ffi/build.zig @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + // Module + // Shared ADR-0006 invoke-shim module (relative path up to boj-server trunk). + + const shim_mod = b.addModule("cartridge_shim", .{ + + .root_source_file = b.path("../../../ffi/zig/src/cartridge_shim.zig"), + + .target = target, + + .optimize = optimize, + + }); + + const ffi_mod = b.addModule("clickhouse_mcp", .{ + .root_source_file = b.path("clickhouse_mcp_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + ffi_mod.addImport("cartridge_shim", shim_mod); + + // Shared library + const lib = b.addLibrary(.{ + .name = "clickhouse_mcp", + .root_module = ffi_mod, + .linkage = .dynamic, + }); + lib.linkLibC(); + b.installArtifact(lib); + + // Tests + const tests = b.addTest(.{ + .root_module = ffi_mod, + }); + tests.linkLibC(); + + const run_tests = b.addRunArtifact(tests); + const test_step = b.step("test", "Run FFI tests"); + test_step.dependOn(&run_tests.step); +} diff --git a/cartridges/domains/database/clickhouse-mcp/ffi/cartridge_shim.zig b/cartridges/domains/database/clickhouse-mcp/ffi/cartridge_shim.zig new file mode 100644 index 0000000..0e399db --- /dev/null +++ b/cartridges/domains/database/clickhouse-mcp/ffi/cartridge_shim.zig @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// cartridge_shim.zig β€” Shared helpers for the ADR-0006 five-symbol +// cartridge ABI (`boj_cartridge_init / deinit / name / version / invoke`). +// +// The shim centralises the seven-code return convention, NUL-argument +// guards, tool-name comparison, and the buffer-too-small path so each +// cartridge's `boj_cartridge_invoke` can stay short β€” typically a tool +// table plus `shim.writeResult(...)`. +// +// Cartridges import this file by relative path (no build-graph change +// needed). Example: +// +// const shim = @import("../../../ffi/zig/src/cartridge_shim.zig"); +// +// export fn boj_cartridge_invoke( +// tool_name: [*c]const u8, +// json_args: [*c]const u8, +// out_buf: [*c]u8, +// in_out_len: [*c]usize, +// ) callconv(.c) i32 { +// _ = json_args; +// if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; +// const body = if (shim.toolIs(tool_name, "foo")) "{\"result\":{}}" +// else return shim.RC_UNKNOWN_TOOL; +// return shim.writeResult(out_buf, in_out_len, body); +// } + +const std = @import("std"); + +// ── Return codes (ADR-0006 Β§Return codes) ──────────────────────────── +// +// Frozen by ADR-0006. New failure modes compose these via the error +// JSON body β€” the integer surface does not grow without a follow-up ADR. + +pub const RC_SUCCESS: i32 = 0; +pub const RC_UNKNOWN_TOOL: i32 = -1; +pub const RC_BAD_ARGS: i32 = -2; +pub const RC_BUFFER_TOO_SMALL: i32 = -3; +pub const RC_RUNTIME_ERROR: i32 = -4; +pub const RC_PANIC: i32 = -5; +pub const RC_AUTH_DENIED: i32 = -6; + +// ── Invoke-path helpers ────────────────────────────────────────────── + +/// True if any of the three mandatory `boj_cartridge_invoke` output-path +/// pointers is null. Use at the top of every invoke to short-circuit to +/// `RC_BAD_ARGS`. +pub fn invokeArgsNull( + tool_name: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) bool { + return tool_name == null or out_buf == null or in_out_len == null; +} + +/// Compare a C-NUL-terminated tool-name pointer against a Zig string +/// literal. Caller must have already verified `tool_name` is non-null +/// (usually via `invokeArgsNull`). +pub fn toolIs(tool_name: [*c]const u8, expected: []const u8) bool { + const s = std.mem.span(@as([*:0]const u8, @ptrCast(tool_name))); + return std.mem.eql(u8, s, expected); +} + +/// Copy `body` into `out_buf[0..*in_out_len]` (as a capacity) and update +/// `*in_out_len` to the number of bytes written. Returns `RC_SUCCESS`. +/// +/// If `body.len` exceeds the current capacity stored in `*in_out_len`, +/// sets `*in_out_len` to the required size and returns +/// `RC_BUFFER_TOO_SMALL` β€” the caller is then expected to re-allocate +/// and retry, per ADR-0006 Β§Memory ownership. +/// +/// Caller must have already verified that `out_buf` and `in_out_len` +/// are non-null. +pub fn writeResult( + out_buf: [*c]u8, + in_out_len: [*c]usize, + body: []const u8, +) i32 { + const cap = in_out_len.*; + if (body.len > cap) { + in_out_len.* = body.len; + return RC_BUFFER_TOO_SMALL; + } + @memcpy(out_buf[0..body.len], body); + in_out_len.* = body.len; + return RC_SUCCESS; +} + +// ── Tests ──────────────────────────────────────────────────────────── + +test "writeResult: body fits, writes and sets length" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); + try std.testing.expectEqualStrings("hello", buf[0..len]); +} + +test "writeResult: too small returns -3 and sets required length" { + var buf: [2]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_BUFFER_TOO_SMALL, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: exact-fit succeeds" { + var buf: [5]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: empty body" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, ""); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 0), len); +} + +test "toolIs: matches and rejects" { + const name: [*:0]const u8 = "foo"; + try std.testing.expect(toolIs(@ptrCast(name), "foo")); + try std.testing.expect(!toolIs(@ptrCast(name), "bar")); + try std.testing.expect(!toolIs(@ptrCast(name), "foobar")); + try std.testing.expect(!toolIs(@ptrCast(name), "fo")); +} + +test "invokeArgsNull: detects each null slot" { + var buf: [4]u8 = undefined; + var len: usize = 4; + const name: [*:0]const u8 = "x"; + try std.testing.expect(!invokeArgsNull(@ptrCast(name), &buf, &len)); + try std.testing.expect(invokeArgsNull(null, &buf, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), null, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), &buf, null)); +} diff --git a/cartridges/domains/database/clickhouse-mcp/ffi/clickhouse_mcp_ffi.zig b/cartridges/domains/database/clickhouse-mcp/ffi/clickhouse_mcp_ffi.zig new file mode 100644 index 0000000..dc3468d --- /dev/null +++ b/cartridges/domains/database/clickhouse-mcp/ffi/clickhouse_mcp_ffi.zig @@ -0,0 +1,472 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// clickhouse_mcp_ffi.zig β€” C-ABI FFI implementation for the clickhouse-mcp cartridge. +// +// Implements the connection state machine defined in the Idris2 ABI layer +// (ClickhouseMcp.SafeDatabase). Thread-safe via std.Thread.Mutex. No heap +// allocations for results. State machine: Disconnected | Connected | +// QueryRunning | Error. Designed for ClickHouse column-oriented OLAP semantics +// with HTTP interface at port 8123 and native TCP at port 9000. + +const std = @import("std"); + +// --------------------------------------------------------------------------- +// Connection state machine (matches Idris2 ABI exactly) +// --------------------------------------------------------------------------- + +/// Connection states for ClickHouse OLAP database. +pub const ConnState = enum(c_int) { + disconnected = 0, + connected = 1, + query_running = 2, + err = 3, +}; + +/// ClickHouse HTTP interface actions. +pub const ClickhouseAction = enum(c_int) { + list_databases = 0, + create_database = 1, + drop_database = 2, + list_tables = 3, + create_table = 4, + drop_table = 5, + describe_table = 6, + select_query = 7, + insert_data = 8, + explain_query = 9, + show_processlist = 10, + kill_query = 11, + optimize_table = 12, + truncate_table = 13, + list_partitions = 14, + system_reload_config = 15, +}; + +/// Check whether a state transition is valid per the ABI specification. +fn isValidTransition(from: ConnState, to: ConnState) bool { + return switch (from) { + .disconnected => to == .connected, + .connected => to == .query_running or to == .disconnected, + .query_running => to == .connected or to == .err, + .err => to == .disconnected, + }; +} + +// --------------------------------------------------------------------------- +// Session slots (thread-safe, fixed-size pool) +// --------------------------------------------------------------------------- + +const MAX_SESSIONS: usize = 16; +const BUF_SIZE: usize = 4096; + +const SessionSlot = struct { + in_use: bool = false, + state: ConnState = .disconnected, + context_buf: [BUF_SIZE]u8 = .{0} ** BUF_SIZE, + context_len: usize = 0, + database_count: u32 = 0, + table_count: u32 = 0, + query_count: u32 = 0, + insert_count: u32 = 0, + active_queries: u32 = 0, +}; + +var sessions: [MAX_SESSIONS]SessionSlot = .{SessionSlot{}} ** MAX_SESSIONS; +var mutex: std.Thread.Mutex = .{}; + +// --------------------------------------------------------------------------- +// C-ABI exports +// --------------------------------------------------------------------------- + +/// Check if a state transition is valid. Returns 1 (valid) or 0 (invalid). +pub export fn clickhouse_mcp_can_transition(from: c_int, to: c_int) c_int { + const f = std.meta.intToEnum(ConnState, from) catch return 0; + const t = std.meta.intToEnum(ConnState, to) catch return 0; + return if (isValidTransition(f, t)) 1 else 0; +} + +/// Open a new connection session. Returns slot index (>= 0) or -1 if no slots. +pub export fn clickhouse_mcp_session_open() c_int { + mutex.lock(); + defer mutex.unlock(); + + for (&sessions, 0..) |*slot, idx| { + if (!slot.in_use) { + slot.in_use = true; + slot.state = .connected; + slot.context_len = 0; + slot.database_count = 0; + slot.table_count = 0; + slot.query_count = 0; + slot.insert_count = 0; + slot.active_queries = 0; + return @intCast(idx); + } + } + return -1; +} + +/// Close a connection session. Returns 0 on success, -1 if invalid slot, -2 if bad transition. +pub export fn clickhouse_mcp_session_close(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.in_use) return -1; + if (!isValidTransition(slot.state, .disconnected)) return -2; + + slot.in_use = false; + slot.state = .disconnected; + slot.context_len = 0; + return 0; +} + +/// Get the current state of a session. Returns state int or -1 if invalid slot. +pub export fn clickhouse_mcp_session_state(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.in_use) return -1; + return @intFromEnum(slot.state); +} + +/// Begin a query. Returns 0 on success, -1 if invalid slot, -2 if bad transition. +pub export fn clickhouse_mcp_begin_query(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.in_use) return -1; + if (!isValidTransition(slot.state, .query_running)) return -2; + + slot.state = .query_running; + slot.active_queries += 1; + return 0; +} + +/// End a query (return to connected). Returns 0 on success. +pub export fn clickhouse_mcp_end_query(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.in_use) return -1; + if (!isValidTransition(slot.state, .connected)) return -2; + + slot.state = .connected; + slot.query_count += 1; + if (slot.active_queries > 0) slot.active_queries -= 1; + return 0; +} + +/// Signal an error on a query-running session. Returns 0 on success. +pub export fn clickhouse_mcp_signal_error(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.in_use) return -1; + if (!isValidTransition(slot.state, .err)) return -2; + + slot.state = .err; + return 0; +} + +/// Get the query count for a session. Returns count or -1 if invalid slot. +pub export fn clickhouse_mcp_query_count(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.in_use) return -1; + return @intCast(slot.query_count); +} + +/// Get the insert count for a session. Returns count or -1 if invalid slot. +pub export fn clickhouse_mcp_insert_count(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.in_use) return -1; + return @intCast(slot.insert_count); +} + +/// Record a batch insert completion. Returns 0 on success. +pub export fn clickhouse_mcp_record_insert(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.in_use) return -1; + + slot.insert_count += 1; + return 0; +} + +/// Check if an action requires an active connection. Returns 1 (yes) or 0 (no). +pub export fn clickhouse_mcp_action_requires_connection(action: c_int) c_int { + const a = std.meta.intToEnum(ClickhouseAction, action) catch return 0; + return switch (a) { + .select_query, + .insert_data, + .explain_query, + .show_processlist, + .kill_query, + .optimize_table, + .truncate_table, + .list_partitions, + .system_reload_config, + => 1, + else => 0, + }; +} + +/// Reset all sessions (test/debug use only). +pub export fn clickhouse_mcp_reset() void { + mutex.lock(); + defer mutex.unlock(); + sessions = .{SessionSlot{}} ** MAX_SESSIONS; +} + +// --------------------------------------------------------------------------- +// ═══════════════════════════════════════════════════════════════════════ +// Standard ABI (ADR-0005 four symbols + ADR-0006 invoke) +// ═══════════════════════════════════════════════════════════════════════ + +const shim = @import("cartridge_shim.zig"); + +const CARTRIDGE_NAME_PTR: [*:0]const u8 = "clickhouse-mcp"; +const CARTRIDGE_VERSION_PTR: [*:0]const u8 = "0.1.0"; + +export fn boj_cartridge_init() callconv(.c) c_int { + return 0; +} + +export fn boj_cartridge_deinit() callconv(.c) void {} + +export fn boj_cartridge_name() callconv(.c) [*:0]const u8 { + return CARTRIDGE_NAME_PTR; +} + +export fn boj_cartridge_version() callconv(.c) [*:0]const u8 { + return CARTRIDGE_VERSION_PTR; +} + +/// Dispatch the cartridge.json MCP tools. Grade D Alpha stubs. +export fn boj_cartridge_invoke( + tool_name: [*c]const u8, + json_args: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) callconv(.c) i32 { + _ = json_args; + if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; + + const body: []const u8 = if (shim.toolIs(tool_name, "clickhouse_connect")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "clickhouse_query")) + "{\"result\":{\"matches\":[],\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "clickhouse_insert")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "clickhouse_ddl")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "clickhouse_list_tables")) + "{\"result\":{\"items\":[],\"count\":0,\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "clickhouse_describe")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "clickhouse_disconnect")) + "{\"result\":{\"status\":\"stub\"}}" +else + return shim.RC_UNKNOWN_TOOL; + + return shim.writeResult(out_buf, in_out_len, body); +} + +// Tests +// --------------------------------------------------------------------------- + +test "session lifecycle" { + clickhouse_mcp_reset(); + + const slot = clickhouse_mcp_session_open(); + try std.testing.expect(slot >= 0); + + // Should be in connected state + try std.testing.expectEqual(@as(c_int, 1), clickhouse_mcp_session_state(slot)); + + // Begin query + try std.testing.expectEqual(@as(c_int, 0), clickhouse_mcp_begin_query(slot)); + try std.testing.expectEqual(@as(c_int, 2), clickhouse_mcp_session_state(slot)); + + // End query + try std.testing.expectEqual(@as(c_int, 0), clickhouse_mcp_end_query(slot)); + try std.testing.expectEqual(@as(c_int, 1), clickhouse_mcp_session_state(slot)); + + // Query count should be 1 + try std.testing.expectEqual(@as(c_int, 1), clickhouse_mcp_query_count(slot)); + + // Close + try std.testing.expectEqual(@as(c_int, 0), clickhouse_mcp_session_close(slot)); +} + +test "invalid transitions rejected" { + clickhouse_mcp_reset(); + + const slot = clickhouse_mcp_session_open(); + try std.testing.expect(slot >= 0); + + // Can not go connected -> error (must go through query_running) + try std.testing.expectEqual(@as(c_int, -2), clickhouse_mcp_signal_error(slot)); + + // Can not close while query running + try std.testing.expectEqual(@as(c_int, 0), clickhouse_mcp_begin_query(slot)); + try std.testing.expectEqual(@as(c_int, -2), clickhouse_mcp_session_close(slot)); +} + +test "transition validator" { + // Valid transitions + try std.testing.expectEqual(@as(c_int, 1), clickhouse_mcp_can_transition(0, 1)); // disconnected -> connected + try std.testing.expectEqual(@as(c_int, 1), clickhouse_mcp_can_transition(1, 2)); // connected -> query_running + try std.testing.expectEqual(@as(c_int, 1), clickhouse_mcp_can_transition(2, 1)); // query_running -> connected + try std.testing.expectEqual(@as(c_int, 1), clickhouse_mcp_can_transition(1, 0)); // connected -> disconnected + try std.testing.expectEqual(@as(c_int, 1), clickhouse_mcp_can_transition(2, 3)); // query_running -> error + try std.testing.expectEqual(@as(c_int, 1), clickhouse_mcp_can_transition(3, 0)); // error -> disconnected + + // Invalid transitions + try std.testing.expectEqual(@as(c_int, 0), clickhouse_mcp_can_transition(0, 2)); + try std.testing.expectEqual(@as(c_int, 0), clickhouse_mcp_can_transition(1, 3)); + try std.testing.expectEqual(@as(c_int, 0), clickhouse_mcp_can_transition(3, 1)); + try std.testing.expectEqual(@as(c_int, 0), clickhouse_mcp_can_transition(99, 0)); +} + +test "slot exhaustion" { + clickhouse_mcp_reset(); + + var slots: [MAX_SESSIONS]c_int = undefined; + for (&slots, 0..) |*s, i| { + _ = i; + s.* = clickhouse_mcp_session_open(); + try std.testing.expect(s.* >= 0); + } + + // Next open should fail + try std.testing.expectEqual(@as(c_int, -1), clickhouse_mcp_session_open()); + + // Free one and try again + try std.testing.expectEqual(@as(c_int, 0), clickhouse_mcp_session_close(slots[0])); + const new_slot = clickhouse_mcp_session_open(); + try std.testing.expect(new_slot >= 0); +} + +test "action requires connection" { + // Actions requiring connection + try std.testing.expectEqual(@as(c_int, 1), clickhouse_mcp_action_requires_connection(7)); // select_query + try std.testing.expectEqual(@as(c_int, 1), clickhouse_mcp_action_requires_connection(8)); // insert_data + try std.testing.expectEqual(@as(c_int, 1), clickhouse_mcp_action_requires_connection(9)); // explain_query + try std.testing.expectEqual(@as(c_int, 1), clickhouse_mcp_action_requires_connection(10)); // show_processlist + try std.testing.expectEqual(@as(c_int, 1), clickhouse_mcp_action_requires_connection(11)); // kill_query + try std.testing.expectEqual(@as(c_int, 1), clickhouse_mcp_action_requires_connection(15)); // system_reload_config + + // Actions not requiring connection + try std.testing.expectEqual(@as(c_int, 0), clickhouse_mcp_action_requires_connection(0)); // list_databases + try std.testing.expectEqual(@as(c_int, 0), clickhouse_mcp_action_requires_connection(1)); // create_database + try std.testing.expectEqual(@as(c_int, 0), clickhouse_mcp_action_requires_connection(99)); // out of range +} + +test "insert tracking" { + clickhouse_mcp_reset(); + + const slot = clickhouse_mcp_session_open(); + try std.testing.expect(slot >= 0); + + // Initial insert count should be 0 + try std.testing.expectEqual(@as(c_int, 0), clickhouse_mcp_insert_count(slot)); + + // Record some inserts + try std.testing.expectEqual(@as(c_int, 0), clickhouse_mcp_record_insert(slot)); + try std.testing.expectEqual(@as(c_int, 0), clickhouse_mcp_record_insert(slot)); + try std.testing.expectEqual(@as(c_int, 2), clickhouse_mcp_insert_count(slot)); + + try std.testing.expectEqual(@as(c_int, 0), clickhouse_mcp_session_close(slot)); +} + +test "error recovery cycle" { + clickhouse_mcp_reset(); + + const slot = clickhouse_mcp_session_open(); + try std.testing.expect(slot >= 0); + + // Connected -> QueryRunning -> Error -> Disconnected + try std.testing.expectEqual(@as(c_int, 0), clickhouse_mcp_begin_query(slot)); + try std.testing.expectEqual(@as(c_int, 0), clickhouse_mcp_signal_error(slot)); + try std.testing.expectEqual(@as(c_int, 3), clickhouse_mcp_session_state(slot)); + + // Error state β€” can only go to disconnected (close) + try std.testing.expectEqual(@as(c_int, 0), clickhouse_mcp_session_close(slot)); +} + +// ═══════════════════════════════════════════════════════════════════════ +// ADR-0006 invoke dispatch tests +// ═══════════════════════════════════════════════════════════════════════ + +test "boj_cartridge_name returns clickhouse-mcp" { + const n = std.mem.span(boj_cartridge_name()); + try std.testing.expectEqualStrings("clickhouse-mcp", n); +} + +test "boj_cartridge_init returns 0" { + try std.testing.expectEqual(@as(c_int, 0), boj_cartridge_init()); +} + +test "invoke: each declared tool succeeds" { + var buf: [256]u8 = undefined; + const tools = [_][]const u8{ + "clickhouse_connect", + "clickhouse_query", + "clickhouse_insert", + "clickhouse_ddl", + "clickhouse_list_tables", + "clickhouse_describe", + "clickhouse_disconnect", + }; + for (tools) |t| { + var len: usize = buf.len; + const rc = boj_cartridge_invoke(t.ptr, "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, 0), rc); + try std.testing.expect(std.mem.indexOf(u8, buf[0..len], "result") != null); + } +} + +test "invoke: unknown tool returns -1" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("nope", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -1), rc); +} + +test "invoke: buffer too small returns -3" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("clickhouse_connect", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -3), rc); + try std.testing.expect(len > 4); +} diff --git a/cartridges/domains/database/clickhouse-mcp/minter.toml b/cartridges/domains/database/clickhouse-mcp/minter.toml new file mode 100644 index 0000000..d306bc2 --- /dev/null +++ b/cartridges/domains/database/clickhouse-mcp/minter.toml @@ -0,0 +1,16 @@ +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> + +name = "clickhouse-mcp" +description = "ClickHouse column-oriented OLAP database MCP cartridge β€” databases, tables, queries, system management" +version = "0.1.0" +domain = "Database" +protocols = [ + "MCP", + "REST", +] +tier = "Ayo" +backend = "universal" +generate_panel = true +auth = "basic" +api_base = "https://{host}:8123/" diff --git a/cartridges/domains/database/clickhouse-mcp/mod.js b/cartridges/domains/database/clickhouse-mcp/mod.js new file mode 100644 index 0000000..770ddeb --- /dev/null +++ b/cartridges/domains/database/clickhouse-mcp/mod.js @@ -0,0 +1,79 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// clickhouse-mcp/mod.js -- clickhouse gateway. + +const BASE_URL = Deno.env.get("CLICKHOUSE_MCP_BACKEND_URL") ?? "http://127.0.0.1:7722"; + +async function post(path, payload) { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), 15000); + try { + const r = await fetch(`${BASE_URL}${path}`, { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), signal: ctrl.signal, + }); + const data = await r.json().catch(() => ({ success: false, error: "non-JSON response" })); + return { status: r.status, data }; + } catch (e) { + if (e.name === "AbortError") return { status: 504, data: { success: false, error: "clickhouse-mcp backend timed out" } }; + return { status: 503, data: { success: false, error: `clickhouse-mcp backend unavailable: ${e.message}` } }; + } finally { clearTimeout(t); } +} + +export async function handleTool(toolName, args) { + switch (toolName) { + + case "clickhouse_connect": { + const { host, port, database, user, password } = args ?? {}; + const payload = { }; + if (host !== undefined) payload.host = host; + if (port !== undefined) payload.port = port; + if (database !== undefined) payload.database = database; + if (user !== undefined) payload.user = user; + if (password !== undefined) payload.password = password; + return post("/api/v1/connect", payload); + } + case "clickhouse_query": { + const { slot, sql, format } = args ?? {}; + if (!slot || !sql) return { status: 400, data: { error: "slot is required" } }; + const payload = { slot, sql }; + if (format !== undefined) payload.format = format; + return post("/api/v1/query", payload); + } + case "clickhouse_insert": { + const { slot, table, data } = args ?? {}; + if (!slot || !table || !data) return { status: 400, data: { error: "slot is required" } }; + const payload = { slot, table, data }; + return post("/api/v1/insert", payload); + } + case "clickhouse_ddl": { + const { slot, sql } = args ?? {}; + if (!slot || !sql) return { status: 400, data: { error: "slot is required" } }; + const payload = { slot, sql }; + return post("/api/v1/ddl", payload); + } + case "clickhouse_list_tables": { + const { slot, database } = args ?? {}; + if (!slot) return { status: 400, data: { error: "slot is required" } }; + const payload = { slot }; + if (database !== undefined) payload.database = database; + return post("/api/v1/list-tables", payload); + } + case "clickhouse_describe": { + const { slot, table } = args ?? {}; + if (!slot || !table) return { status: 400, data: { error: "slot is required" } }; + const payload = { slot, table }; + return post("/api/v1/describe", payload); + } + case "clickhouse_disconnect": { + const { slot } = args ?? {}; + if (!slot) return { status: 400, data: { error: "slot is required" } }; + const payload = { slot }; + return post("/api/v1/disconnect", payload); + } + + default: + return { status: 404, data: { error: `Unknown tool: ${toolName}` } }; + } +} diff --git a/cartridges/domains/database/clickhouse-mcp/panels/manifest.json b/cartridges/domains/database/clickhouse-mcp/panels/manifest.json new file mode 100644 index 0000000..2877592 --- /dev/null +++ b/cartridges/domains/database/clickhouse-mcp/panels/manifest.json @@ -0,0 +1,89 @@ +{ + "$schema": "https://panll.dev/schemas/panel-manifest/v1.json", + "spdx": "MPL-2.0", + "cartridge": "clickhouse-mcp", + "domain": "Database", + "version": "0.1.0", + "panels": [ + { + "id": "clickhouse-connection-status", + "title": "Connection Status", + "description": "Current ClickHouse connection state β€” disconnected, connected, query running, or error", + "type": "status-indicator", + "data_source": { + "endpoint": "/clickhouse/session/status", + "method": "GET", + "refresh_interval_ms": 5000 + }, + "widgets": [ + { + "type": "state-badge", + "field": "connection_state", + "states": { + "connected": { "color": "#2ecc71", "icon": "zap" }, + "disconnected": { "color": "#95a5a6", "icon": "power-off" }, + "query_running": { "color": "#3498db", "icon": "loader" }, + "error": { "color": "#e74c3c", "icon": "alert-triangle" } + } + } + ] + }, + { + "id": "clickhouse-query-count", + "title": "Query Count", + "description": "Total number of SELECT queries executed across all sessions", + "type": "metric", + "data_source": { + "endpoint": "/clickhouse/metrics", + "method": "GET", + "refresh_interval_ms": 15000 + }, + "widgets": [ + { + "type": "counter", + "field": "total_query_count", + "label": "Queries", + "icon": "file-search" + } + ] + }, + { + "id": "clickhouse-insert-count", + "title": "Insert Count", + "description": "Total number of batch inserts recorded across all sessions", + "type": "metric", + "data_source": { + "endpoint": "/clickhouse/metrics", + "method": "GET", + "refresh_interval_ms": 15000 + }, + "widgets": [ + { + "type": "counter", + "field": "total_insert_count", + "label": "Inserts", + "icon": "upload" + } + ] + }, + { + "id": "clickhouse-database-count", + "title": "Database Count", + "description": "Total number of ClickHouse databases accessible via the configured connection", + "type": "metric", + "data_source": { + "endpoint": "/clickhouse/databases", + "method": "GET", + "refresh_interval_ms": 30000 + }, + "widgets": [ + { + "type": "counter", + "field": "database_count", + "label": "Databases", + "icon": "database" + } + ] + } + ] +} diff --git a/cartridges/domains/database/clickhouse-mcp/tests/integration_test.sh b/cartridges/domains/database/clickhouse-mcp/tests/integration_test.sh new file mode 100755 index 0000000..8bb48aa --- /dev/null +++ b/cartridges/domains/database/clickhouse-mcp/tests/integration_test.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +# Integration tests for clickhouse-mcp cartridge. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CART_DIR="$(dirname "$SCRIPT_DIR")" +FFI_DIR="$CART_DIR/ffi" + +echo "=== clickhouse-mcp integration tests ===" + +# Build FFI +echo "[1/3] Building FFI..." +cd "$FFI_DIR" && zig build 2>&1 + +# Run Zig unit tests +echo "[2/3] Running FFI unit tests..." +cd "$FFI_DIR" && zig build test 2>&1 + +# Validate Idris2 ABI (if idris2 available) +echo "[3/3] Checking ABI..." +if command -v idris2 &>/dev/null; then + cd "$CART_DIR/abi" && idris2 --check ClickhouseMcp.SafeDatabase 2>&1 + echo " ABI: OK" +else + echo " ABI: SKIPPED (idris2 not in PATH)" +fi + +echo "" +echo "All tests passed for clickhouse-mcp!" diff --git a/cartridges/domains/database/database-mcp/README.adoc b/cartridges/domains/database/database-mcp/README.adoc new file mode 100644 index 0000000..c469d02 --- /dev/null +++ b/cartridges/domains/database/database-mcp/README.adoc @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += database-mcp +Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk> +:spdx: MPL-2.0 +:tier: Ayo +:domain: Database +:protocols: MCP, REST + +== Overview + +Universal database gateway. Connects to PostgreSQL, SQLite, VeriSimDB, QuandleDB, MongoDB, and other backends with unified query interface. + +== Tools (6) + +[cols="2,4"] +|=== +| Tool | Description + +| `database_connect` | Connect to a database backend. Returns a session slot index. +| `database_query` | Execute a SQL or VCL query. +| `database_execute` | Execute a non-returning SQL statement (INSERT/UPDATE/DELETE/DDL). +| `database_list_tables` | List tables/collections in the connected database. +| `database_describe` | Describe a table or collection schema. +| `database_disconnect` | Disconnect and release a session slot. +|=== + +== Architecture + +Three-layer stack per the BoJ cartridge standard: + +[cols="1,1,3"] +|=== +| Layer | Language | Purpose + +| ABI | Idris2 | Formally verified state machine with dependent-type proofs +| FFI | Zig | C-ABI implementation: session pool, action metrics, thread safety +| Adapter| Zig | Adapter bridge to the BoJ unified adapter protocol (zig predecessors sidelined) +|=== + +== Building + +[source,bash] +---- +# ABI (Idris2) +cd abi && idris2 --check *.idr + +# FFI (Zig) +cd ffi && zig build +cd ffi && zig build test + +# Adapter (Zig) +cd adapter && zig build +---- + +== Status + +Alpha (CRG D). Cartridge metadata declared in `cartridge.json`; 6 tool(s) exposed. +Not yet dogfooded externally. See `docs/READINESS.md` at repo root for the +project-wide grade table and `docs/practice/DOGFOOD-LOG.adoc` for usage evidence. diff --git a/cartridges/domains/database/database-mcp/abi/DatabaseMcp/SafeDatabase.idr b/cartridges/domains/database/database-mcp/abi/DatabaseMcp/SafeDatabase.idr new file mode 100644 index 0000000..8eb6303 --- /dev/null +++ b/cartridges/domains/database/database-mcp/abi/DatabaseMcp/SafeDatabase.idr @@ -0,0 +1,196 @@ +-- SPDX-License-Identifier: MPL-2.0 +-- Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +||| DatabaseMcp.SafeDatabase: Formally verified database operations. +||| +||| Cartridge: database-mcp +||| Matrix cell: Database domain x {MCP, LSP} protocols +||| +||| This module defines type-safe database operations with a +||| connection state machine that prevents: +||| - Querying on a closed connection +||| - Double-closing a connection +||| - Executing without proper error handling +||| +||| Designed to integrate with proven-servers dbconn connector. +module DatabaseMcp.SafeDatabase + +import Data.List + +%default total + +-- ═══════════════════════════════════════════════════════════════════════════ +-- Connection State Machine +-- ═══════════════════════════════════════════════════════════════════════════ + +||| Connection lifecycle states. +||| A connection progresses: Disconnected -> Connected -> (query) -> Connected -> Disconnected +public export +data ConnState = Disconnected | Connected | Querying | Error + +||| Equality for connection states. +public export +Eq ConnState where + Disconnected == Disconnected = True + Connected == Connected = True + Querying == Querying = True + Error == Error = True + _ == _ = False + +||| Valid state transitions (enforced at the type level). +public export +data ValidTransition : ConnState -> ConnState -> Type where + Connect : ValidTransition Disconnected Connected + StartQuery : ValidTransition Connected Querying + EndQuery : ValidTransition Querying Connected + Disconnect : ValidTransition Connected Disconnected + QueryError : ValidTransition Querying Error + Recover : ValidTransition Error Disconnected + +||| Runtime transition validator. +public export +canTransition : ConnState -> ConnState -> Bool +canTransition Disconnected Connected = True +canTransition Connected Querying = True +canTransition Querying Connected = True +canTransition Connected Disconnected = True +canTransition Querying Error = True +canTransition Error Disconnected = True +canTransition _ _ = False + +-- ═══════════════════════════════════════════════════════════════════════════ +-- Database Backend Types +-- ═══════════════════════════════════════════════════════════════════════════ + +||| Supported database backends. +||| VeriSimDB is the hyperpolymath native database. +public export +data DatabaseBackend + = VeriSimDB -- Native hyperpolymath database + | PostgreSQL -- Standard relational + | SQLite -- Embedded relational + | Redis -- Key-value / cache + | Custom String -- User-defined backend + +||| C-ABI encoding. +public export +backendToInt : DatabaseBackend -> Int +backendToInt VeriSimDB = 1 +backendToInt PostgreSQL = 2 +backendToInt SQLite = 3 +backendToInt Redis = 4 +backendToInt (Custom _) = 99 + +-- ═══════════════════════════════════════════════════════════════════════════ +-- Query Types +-- ═══════════════════════════════════════════════════════════════════════════ + +||| Query safety classification. +||| ReadOnly queries cannot modify data. +||| Mutation queries can modify data and require explicit confirmation. +public export +data QuerySafety = ReadOnly | Mutation + +||| A database query with safety classification. +public export +record SafeQuery where + constructor MkQuery + queryText : String + safety : QuerySafety + paramCount : Nat -- Number of bound parameters (prevents SQL injection) + +||| Query result status. +public export +data QueryResult + = Success Nat -- Number of rows affected/returned + | NoResults -- Query succeeded but returned nothing + | ResultError String -- Error with message + +-- ═══════════════════════════════════════════════════════════════════════════ +-- Connection Record +-- ═══════════════════════════════════════════════════════════════════════════ + +||| A database connection with tracked state. +public export +record Connection where + constructor MkConnection + connId : String + backend : DatabaseBackend + state : ConnState + host : String + port : Int + +||| Proof that a connection is in a query-ready state. +public export +data IsConnected : Connection -> Type where + ActiveConnection : (c : Connection) -> + (state c = Connected) -> + IsConnected c + +-- ═══════════════════════════════════════════════════════════════════════════ +-- MCP Tool Definitions +-- ═══════════════════════════════════════════════════════════════════════════ + +||| MCP tools exposed by this cartridge. +||| These map to MCP tool definitions that AI agents can call. +public export +data McpTool + = ToolConnect -- Connect to a database + | ToolDisconnect -- Close a connection + | ToolQuery -- Execute a read-only query + | ToolMutate -- Execute a mutation (requires confirmation) + | ToolListDatabases -- List available databases + | ToolDescribeTable -- Get table schema + | ToolStatus -- Connection health check + +||| MCP tool name (for JSON-RPC method name). +public export +toolName : McpTool -> String +toolName ToolConnect = "database/connect" +toolName ToolDisconnect = "database/disconnect" +toolName ToolQuery = "database/query" +toolName ToolMutate = "database/mutate" +toolName ToolListDatabases = "database/list" +toolName ToolDescribeTable = "database/describe" +toolName ToolStatus = "database/status" + +||| Which tools require an active connection. +public export +requiresConnection : McpTool -> Bool +requiresConnection ToolConnect = False +requiresConnection ToolListDatabases = False +requiresConnection _ = True + +-- ═══════════════════════════════════════════════════════════════════════════ +-- C-ABI Exports +-- ═══════════════════════════════════════════════════════════════════════════ + +||| Connection state to integer. +public export +connStateToInt : ConnState -> Int +connStateToInt Disconnected = 0 +connStateToInt Connected = 1 +connStateToInt Querying = 2 +connStateToInt Error = 3 + +||| FFI: Validate a state transition. +export +db_can_transition : Int -> Int -> Int +db_can_transition from to = + let fromState = case from of + 0 => Disconnected + 1 => Connected + 2 => Querying + _ => Error + toState = case to of + 0 => Disconnected + 1 => Connected + 2 => Querying + _ => Error + in if canTransition fromState toState then 1 else 0 + +||| FFI: Check if a tool requires an active connection. +export +db_tool_requires_connection : Int -> Int +db_tool_requires_connection 1 = 0 -- ToolConnect +db_tool_requires_connection 5 = 0 -- ToolListDatabases +db_tool_requires_connection _ = 1 -- All others require connection diff --git a/cartridges/domains/database/database-mcp/abi/README.adoc b/cartridges/domains/database/database-mcp/abi/README.adoc new file mode 100644 index 0000000..e2c2147 --- /dev/null +++ b/cartridges/domains/database/database-mcp/abi/README.adoc @@ -0,0 +1,48 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += database-mcp / abi β€” Idris2 ABI layer +:orientation: deep + +== Purpose + +Encodes the database **connection state machine** and the **MCP tool catalogue** +for the database-mcp cartridge. The state machine (`Disconnected β†’ Connected β†’ +Querying β†’ Connected β†’ Disconnected`, plus `Querying β†’ Error β†’ Disconnected`) +is the source of truth that the Zig FFI layer mirrors β€” the ABI proves the +transition graph is well-formed at the type level via the `ValidTransition` +indexed type, and the FFI re-implements the same graph as a runtime predicate. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `database-mcp.ipkg` | Idris2 package descriptor. Type-check entry point (`idris2 --check database-mcp.ipkg`). +| `DatabaseMcp/SafeDatabase.idr` | Whole cartridge in one module. Defines `ConnState`, `ValidTransition` (6 constructors), `canTransition` runtime predicate, `DatabaseBackend` (5 backends + `Custom`), `QuerySafety`, `SafeQuery`, `QueryResult`, `Connection` record, `IsConnected` proof, the 7 `McpTool` constructors, and two C-ABI exports (`db_can_transition`, `db_tool_requires_connection`). +|=== + +== Invariants + +* `%default total` at module top (line 19) β€” every definition must be total. +* `Disconnected β†’ Querying` is **not** in `ValidTransition`; entering a query + requires passing through `Connected`. The FFI enforces the same by switching + on the current state before every transition. +* `requiresConnection` marks `ToolConnect` and `ToolListDatabases` as + callable without an active connection; every other tool requires one. +* `backendToInt` and `connStateToInt` are the authoritative encoding the + FFI must agree with byte-for-byte. + +== Test/proof surface + +Type-check only β€” `idris2 --check DatabaseMcp/SafeDatabase.idr` via the +`.ipkg`. No inline `test` blocks (that is the FFI's job β€” see `../ffi/`). +Proofs here are structural: `ValidTransition` is a GADT with exactly six +inhabitants. + +== Read-first + +. `SafeDatabase.idr` lines 22–58 β€” state machine (`ConnState`, `ValidTransition`, `canTransition`). +. Lines 64–82 β€” backend enum and its C-ABI encoding. +. Lines 129–161 β€” `Connection` record, `IsConnected` proof, and the seven `McpTool` constructors. +. Lines 167–196 β€” the two exported FFI primitives. diff --git a/cartridges/domains/database/database-mcp/abi/database-mcp.ipkg b/cartridges/domains/database/database-mcp/abi/database-mcp.ipkg new file mode 100644 index 0000000..9c9c206 --- /dev/null +++ b/cartridges/domains/database/database-mcp/abi/database-mcp.ipkg @@ -0,0 +1,11 @@ +-- SPDX-License-Identifier: MPL-2.0 +package databasemcp + +authors = "Jonathan D.A. Jewell" +version = 0.1.0 +license = "MPL-2.0" +brief = "Database MCP cartridge β€” connection state machine with SQL injection prevention" + +sourcedir = "." +modules = DatabaseMcp.SafeDatabase +depends = base, contrib diff --git a/cartridges/domains/database/database-mcp/adapter/README.adoc b/cartridges/domains/database/database-mcp/adapter/README.adoc new file mode 100644 index 0000000..86a80f4 --- /dev/null +++ b/cartridges/domains/database/database-mcp/adapter/README.adoc @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += database-mcp / adapter β€” REST/gRPC/GraphQL bridge +:orientation: deep + +== Purpose + +Exposes the FFI layer over three protocols so non-Zig callers can drive the +cartridge. The adapter is **stateless** β€” it does not hold its own connection +or slot state; all state lives behind the FFI mutex in `../ffi/`. The adapter +simply parses an incoming request, routes to a tool name, calls the matching +FFI export, and serialises the response. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph for the adapter binary. +| `database_adapter.zig` | Three-protocol dispatcher. Listens on three ports, maps tool names to FFI calls, returns JSON over HTTP. +| `SIDELINED-database_adapter.v.adoc` | Historical zig predecessor. Retained for lineage per the 2026-04-10 V-ban transition rule ("V sources MOVE, don't delete"). Not compiled. +|=== + +== Invariants + +* **Stateless.** No global mutable state beyond protocol listeners. The + adapter never caches slot indices; the caller must carry them. +* **One tool call per HTTP request.** Pipelining or keep-alive batching is + out of scope for this layer. +* **JSON content type.** Responses are always `Content-Type: + application/json`; errors are `{"error": "<code>", "message": "<text>"}`. +* **Port binding.** Each protocol runs on a dedicated port so a single + cartridge can simultaneously serve all three without header-based + multiplexing. + +== Test/proof surface + +No inline tests in the adapter (FFI tests cover correctness; the adapter is +thin scaffolding). Adapter-level integration is exercised by the +cartridge-matrix tests run from the repo root via `zig build test` in the +main tree. + +== Read-first + +. `database_adapter.zig` top of file β€” port constants and protocol enum. +. The `dispatch` function β€” canonical tool-name β†’ FFI-call mapping. +. The three protocol routers (REST, gRPC-compat, GraphQL) β€” each is a thin + wrapper around `dispatch`. +. `main` β€” listener thread spawn. + +Skip `SIDELINED-database_adapter.v.adoc` for current work; it documents the +pre-sidelining design only. diff --git a/cartridges/domains/database/database-mcp/adapter/build.zig b/cartridges/domains/database/database-mcp/adapter/build.zig new file mode 100644 index 0000000..6e7cb47 --- /dev/null +++ b/cartridges/domains/database/database-mcp/adapter/build.zig @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// database-mcp/adapter/build.zig + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); const optimize = b.standardOptimizeOption(.{}); + const ffi_mod = b.createModule(.{ .root_source_file = b.path("../ffi/database_ffi.zig"), .target = target, .optimize = optimize }); + const adapter = b.addExecutable(.{ .name = "database_adapter", .root_source_file = b.path("database_adapter.zig"), .target = target, .optimize = optimize }); + adapter.root_module.addImport("database_ffi", ffi_mod); b.installArtifact(adapter); + const rs = b.step("run", "Run database-mcp adapter"); rs.dependOn(&b.addRunArtifact(adapter).step); + const tests = b.addTest(.{ .root_source_file = b.path("database_adapter.zig"), .target = target, .optimize = optimize }); + tests.root_module.addImport("database_ffi", ffi_mod); + const ts = b.step("test", "Test database-mcp adapter"); ts.dependOn(&b.addRunArtifact(tests).step); +} diff --git a/cartridges/domains/database/database-mcp/adapter/database_adapter.zig b/cartridges/domains/database/database-mcp/adapter/database_adapter.zig new file mode 100644 index 0000000..d8ca74d --- /dev/null +++ b/cartridges/domains/database/database-mcp/adapter/database_adapter.zig @@ -0,0 +1,109 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// database-mcp/adapter/database_adapter.zig -- Unified three-protocol adapter. +// Replaces banned database_adapter.v (zig, removed 2026-04-12). +// REST:9154 gRPC:9155 GraphQL:9156 +// Tools: database_connect, database_query, database_execute, database_list_tables... + +const std = @import("std"); +const ffi = @import("database_ffi"); + +const REST_PORT: u16 = 9154; +const GRPC_PORT: u16 = 9155; +const GQL_PORT: u16 = 9156; +const MAX_CONN_BUF: usize = 16 * 1024; + +fn okJson(buf: []u8, msg: []const u8) []u8 { + const n = std.fmt.bufPrint(buf, "{\"success\":true,\"message\":\"{s}\"}", .{msg}) catch return buf[0..0]; return n; +} +fn errJson(buf: []u8, msg: []const u8) []u8 { + const n = std.fmt.bufPrint(buf, "{\"success\":false,\"error\":\"{s}\"}", .{msg}) catch return buf[0..0]; return n; +} +fn statusJson(buf: []u8) []u8 { + const n = std.fmt.bufPrint(buf, "{\"success\":true,\"state\":\"ready\",\"service\":\"database-mcp\"}", .{}) catch return buf[0..0]; return n; +} + +const Response = struct { status: u16, body: []u8 }; + +fn dispatch(tool: []const u8, body: []const u8, resp: []u8) Response { + _ = body; + if (std.mem.eql(u8, tool, "database_connect")) return .{ .status = 200, .body = okJson(resp, "database_connect forwarded") }; + if (std.mem.eql(u8, tool, "database_query")) return .{ .status = 200, .body = okJson(resp, "database_query forwarded") }; + if (std.mem.eql(u8, tool, "database_execute")) return .{ .status = 200, .body = okJson(resp, "database_execute forwarded") }; + if (std.mem.eql(u8, tool, "database_list_tables")) return .{ .status = 200, .body = okJson(resp, "database_list_tables forwarded") }; + if (std.mem.eql(u8, tool, "database_describe")) return .{ .status = 200, .body = okJson(resp, "database_describe forwarded") }; + if (std.mem.eql(u8, tool, "database_disconnect")) return .{ .status = 200, .body = okJson(resp, "database_disconnect forwarded") }; + if (std.mem.eql(u8, tool, "status") or std.mem.eql(u8, tool, "health")) return .{ .status = 200, .body = statusJson(resp) }; + return .{ .status = 404, .body = errJson(resp, "Unknown tool") }; +} + +fn dispatchRest(path: []const u8, body: []const u8, resp: []u8) Response { + if (std.mem.startsWith(u8, path, "/tools/")) return dispatch(path["/tools/".len..], body, resp); + if (std.mem.eql(u8, path, "/status") or std.mem.eql(u8, path, "/health")) return .{ .status = 200, .body = statusJson(resp) }; + return .{ .status = 404, .body = errJson(resp, "Not found") }; +} + +fn dispatchGrpc(path: []const u8, body: []const u8, resp: []u8) Response { + const prefix = "/Databaseservice/"; + if (!std.mem.startsWith(u8, path, prefix)) return .{ .status = 404, .body = errJson(resp, "Not a recognized gRPC path") }; + const method = path[prefix.len..]; + const tool = blk: { + if (std.mem.eql(u8, method, "database_connect")) break :blk "database_connect"; + if (std.mem.eql(u8, method, "database_query")) break :blk "database_query"; + if (std.mem.eql(u8, method, "database_execute")) break :blk "database_execute"; + if (std.mem.eql(u8, method, "database_list_tables")) break :blk "database_list_tables"; + if (std.mem.eql(u8, method, "database_describe")) break :blk "database_describe"; + if (std.mem.eql(u8, method, "database_disconnect")) break :blk "database_disconnect"; + return .{ .status = 404, .body = errJson(resp, "Unknown gRPC method") }; + }; + return dispatch(tool, body, resp); +} + +fn dispatchGraphql(body: []const u8, resp: []u8) Response { + if (std.mem.indexOf(u8, body, "__schema") != null) return .{ .status = 200, .body = okJson(resp, "schema not supported") }; + if (std.mem.indexOf(u8, body, "connect") != null) return dispatch("database_connect", body, resp); + if (std.mem.indexOf(u8, body, "query") != null) return dispatch("database_query", body, resp); + if (std.mem.indexOf(u8, body, "execute") != null) return dispatch("database_execute", body, resp); + if (std.mem.indexOf(u8, body, "list_tables") != null) return dispatch("database_list_tables", body, resp); + if (std.mem.indexOf(u8, body, "describe") != null) return dispatch("database_describe", body, resp); + if (std.mem.indexOf(u8, body, "disconnect") != null) return dispatch("database_disconnect", body, resp); + return .{ .status = 200, .body = errJson(resp, "Unrecognised GraphQL operation") }; +} + +const Protocol = enum { rest, grpc, graphql }; + +fn handleConnection(conn: std.net.Server.Connection, proto: Protocol) void { + defer conn.stream.close(); + var in_buf: [MAX_CONN_BUF]u8 = undefined; + const n = conn.stream.read(&in_buf) catch return; + const req = in_buf[0..n]; + var path: []const u8 = "/"; var body: []const u8 = ""; + if (n > 4) { + const le = std.mem.indexOf(u8, req, "\r\n") orelse req.len; + const fl = req[0..le]; const sp1 = std.mem.indexOfScalar(u8, fl, ' ') orelse 0; + const ro = fl[sp1+1..]; const sp2 = std.mem.indexOfScalar(u8, ro, ' ') orelse ro.len; + path = ro[0..sp2]; + const bs = std.mem.indexOf(u8, req, "\r\n\r\n") orelse n; body = req[@min(bs+4,n)..]; + } + var rb: [MAX_CONN_BUF]u8 = undefined; + const result = switch (proto) { .rest => dispatchRest(path, body, &rb), .grpc => dispatchGrpc(path, body, &rb), .graphql => dispatchGraphql(body, &rb) }; + const ct: []const u8 = if (proto == .grpc) "application/grpc+json" else "application/json"; + var hb: [256]u8 = undefined; + const hdr = std.fmt.bufPrint(&hb, "HTTP/1.1 {d} OK\r\nContent-Type: {s}\r\nContent-Length: {d}\r\nConnection: close\r\n\r\n", .{ result.status, ct, result.body.len }) catch return; + _ = conn.stream.writeAll(hdr) catch return; _ = conn.stream.writeAll(result.body) catch return; +} + +fn listenLoop(port: u16, proto: Protocol) void { + const addr = std.net.Address.initIp4(.{ 127, 0, 0, 1 }, port) catch return; + var srv = addr.listen(.{ .reuse_address = true }) catch return; defer srv.deinit(); + while (true) { const conn = srv.accept() catch continue; handleConnection(conn, proto); } +} + +pub fn main() !void { + ffi.database_init(); + const t1 = try std.Thread.spawn(.{}, listenLoop, .{ REST_PORT, Protocol.rest }); + const t2 = try std.Thread.spawn(.{}, listenLoop, .{ GRPC_PORT, Protocol.grpc }); + const t3 = try std.Thread.spawn(.{}, listenLoop, .{ GQL_PORT, Protocol.graphql }); + t1.join(); t2.join(); t3.join(); +} diff --git a/cartridges/domains/database/database-mcp/cartridge.json b/cartridges/domains/database/database-mcp/cartridge.json new file mode 100644 index 0000000..c459268 --- /dev/null +++ b/cartridges/domains/database/database-mcp/cartridge.json @@ -0,0 +1,179 @@ +{ + "$schema": "https://boj.dev/schemas/cartridge/v1.json", + "spdx": "MPL-2.0", + "copyright": "Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath)", + "name": "database-mcp", + "version": "0.1.0", + "description": "Universal database gateway. Connects to PostgreSQL, SQLite, VeriSimDB, QuandleDB, MongoDB, and other backends with unified query interface.", + "domain": "Database", + "tier": "Ayo", + "protocols": [ + "MCP", + "REST" + ], + "auth": { + "method": "none", + "env_var": null, + "credential_source": null + }, + "api": { + "base_url": "local://database-mcp", + "content_type": "application/json" + }, + "ports": { + "allowed": [ + 5432, + 3306, + 27017 + ], + "denied": [ + 22, + 23, + 25, + 135, + 139, + 445 + ] + }, + "tools": [ + { + "name": "database_connect", + "description": "Connect to a database backend. Returns a session slot index.", + "inputSchema": { + "type": "object", + "properties": { + "backend": { + "type": "string", + "description": "Backend: postgresql / sqlite / verisimdb / quandledb / mongodb / clickhouse" + }, + "connection_string": { + "type": "string", + "description": "Connection string or file path" + } + }, + "required": [ + "backend", + "connection_string" + ] + } + }, + { + "name": "database_query", + "description": "Execute a SQL or VCL query.", + "inputSchema": { + "type": "object", + "properties": { + "slot": { + "type": "integer", + "description": "Session slot from database_connect" + }, + "query": { + "type": "string", + "description": "SQL or VCL query string" + }, + "params": { + "type": "string", + "description": "Query parameters as JSON array (optional)" + } + }, + "required": [ + "slot", + "query" + ] + } + }, + { + "name": "database_execute", + "description": "Execute a non-returning SQL statement (INSERT/UPDATE/DELETE/DDL).", + "inputSchema": { + "type": "object", + "properties": { + "slot": { + "type": "integer", + "description": "Session slot" + }, + "statement": { + "type": "string", + "description": "SQL statement" + }, + "params": { + "type": "string", + "description": "Parameters as JSON array (optional)" + } + }, + "required": [ + "slot", + "statement" + ] + } + }, + { + "name": "database_list_tables", + "description": "List tables/collections in the connected database.", + "inputSchema": { + "type": "object", + "properties": { + "slot": { + "type": "integer", + "description": "Session slot" + }, + "schema": { + "type": "string", + "description": "Schema name (default: public for PostgreSQL)" + } + }, + "required": [ + "slot" + ] + } + }, + { + "name": "database_describe", + "description": "Describe a table or collection schema.", + "inputSchema": { + "type": "object", + "properties": { + "slot": { + "type": "integer", + "description": "Session slot" + }, + "table": { + "type": "string", + "description": "Table or collection name" + } + }, + "required": [ + "slot", + "table" + ] + } + }, + { + "name": "database_disconnect", + "description": "Disconnect and release a session slot.", + "inputSchema": { + "type": "object", + "properties": { + "slot": { + "type": "integer", + "description": "Session slot to release" + } + }, + "required": [ + "slot" + ] + } + } + ], + "ffi": { + "so_path": "ffi/zig-out/lib/libdatabase_mcp.so", + "abi_version": "ADR-0006", + "symbols": [ + "boj_cartridge_init", + "boj_cartridge_deinit", + "boj_cartridge_name", + "boj_cartridge_version", + "boj_cartridge_invoke" + ] + } +} diff --git a/cartridges/domains/database/database-mcp/ffi/README.adoc b/cartridges/domains/database/database-mcp/ffi/README.adoc new file mode 100644 index 0000000..f7d0ef7 --- /dev/null +++ b/cartridges/domains/database/database-mcp/ffi/README.adoc @@ -0,0 +1,73 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += database-mcp / ffi β€” Zig FFI layer +:orientation: deep + +== Purpose + +The Zig implementation of the connection state machine from `../abi/`, plus +live SQL execution against SQLite (linked via `@cImport("sqlite3.h")`) and +live VQL / KQL / GQL execution against VeriSimDB / QuandleDB / LithoGlyph via +child `curl` processes. Exposes the four standard cartridge symbols +(`boj_cartridge_init`, `boj_cartridge_deinit`, `boj_cartridge_name`, +`boj_cartridge_version`) and ~20 database-specific `db_*` exports. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” produces the loadable shared library plus the unit-test binary. +| `database_ffi.zig` | All of it. ~1200 lines. State machine, 16-slot connection pool, SQLite integration, VQL/KQL/GQL curl execution, 24 inline tests. +| `zig-out/` | Build artefacts (gitignored). +|=== + +== Invariants + +* **Mutex discipline.** All reads and writes to the global `connections` + array are protected by `var mutex: std.Thread.Mutex`. Every C-ABI export + opens with `mutex.lock(); defer mutex.unlock();`. Pure helpers + (`isValidTransition`, `appendJsonEscaped`) do not need the lock. +* **Two-phase pattern** for blocking I/O. `db_execute_vql` / `_kql` / `_gql` + release the mutex during the `curl` call and re-acquire it afterwards, + then **re-validate slot state** (`active && state == .querying`) before + writing the result back. See lines 515–587 for the canonical pattern. +* **Bounds checks before dereference.** All `*_ptr` / `*_len` pairs reject + zero-length and oversize inputs (error codes `-6`, `-8`) before any + `@memcpy`. Pointer safety is documented in-line with `SAFETY:` comments. +* **Enum hardening.** All public exports use `std.meta.intToEnum` rather + than `@enumFromInt` on `c_int` arguments, so an invalid enum returns `-1` + instead of panicking. +* **State-machine mirror.** `isValidTransition` (lines 81–88) is the exact + graph from `SafeDatabase.idr`. Changes there must land in both files. +* **Slot capacity** is `MAX_CONNECTIONS = 16`. Exhaustion returns `-1`. +* **Error codes**: `-1` invalid slot / exhausted, `-2` invalid transition, + `-3` no sqlite handle (wrong backend), `-4` sqlite3_exec failed, + `-5` output buffer too small, `-6` URL/path empty or too long, `-7` curl + failed, `-8` zero-length input/output. + +== Test/proof surface + +24 inline `test "..."` blocks in `database_ffi.zig` (lines 878–1189). Coverage: + +* State machine (lines 878–931): connect, disconnect, double-close, query + lifecycle, error recovery, transition validation. +* SQLite live execution (lines 933–1045): in-memory DB open, execute + `CREATE`/`INSERT`/`SELECT`, multiple rows, invalid SQL β†’ error state, + non-sqlite slot rejection. +* VeriSimDB / QuandleDB / LithoGlyph connection lifecycle (lines 1047–1169): + URL storage, empty/oversize rejection, query lifecycle via manual state + transitions (no live server in test). +* Wrong-backend rejection (lines 1171–1188). + +Run with `cd ffi && zig build test`. + +== Read-first + +. Lines 14–57 β€” types and the `ConnectionSlot` struct; note `url_buf[512]` for the HTTP backends. +. Lines 59–88 β€” `connections` array, `mutex` declaration, `isValidTransition`. +. Lines 95–113 β€” the simplest export (`db_connect`); read before the sqlite/verisimdb variants. +. Lines 324–401 β€” `db_execute_sql` β€” full state-machine-guarded sqlite exec with JSON serialisation. +. Lines 515–641 β€” `db_execute_vql` + `runCurlPost` β€” the two-phase lock pattern for blocking I/O. +. Lines 842–872 β€” the standard cartridge-interface exports required by the BoJ loader. diff --git a/cartridges/domains/database/database-mcp/ffi/build.zig b/cartridges/domains/database/database-mcp/ffi/build.zig new file mode 100644 index 0000000..b2fdc50 --- /dev/null +++ b/cartridges/domains/database/database-mcp/ffi/build.zig @@ -0,0 +1,64 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// Database-MCP Cartridge β€” Zig FFI build configuration (Zig 0.15+). +// Links against system libsqlite3 for real database operations. + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + // Shared ADR-0006 invoke-shim module (relative path up to boj-server trunk). + + const shim_mod = b.addModule("cartridge_shim", .{ + + .root_source_file = b.path("../../../ffi/zig/src/cartridge_shim.zig"), + + .target = target, + + .optimize = optimize, + + }); + + const db_mod = b.addModule("database_ffi", .{ + .root_source_file = b.path("database_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + db_mod.addImport("cartridge_shim", shim_mod); + + // ── Tests ──────────────────────────────────────────────────────── + const db_tests = b.addTest(.{ + .root_module = db_mod, + }); + db_tests.linkSystemLibrary("sqlite3"); + db_tests.linkLibC(); + + const run_tests = b.addRunArtifact(db_tests); + + const test_step = b.step("test", "Run database-mcp FFI tests"); + test_step.dependOn(&run_tests.step); + + // ── Shared library ────────────────────────────────────────────── + const lib_mod = b.createModule(.{ + .root_source_file = b.path("database_ffi.zig"), + .target = target, + .optimize = optimize, + }); + lib_mod.addImport("cartridge_shim", shim_mod); + + const lib = b.addLibrary(.{ + .name = "database_mcp", + .root_module = lib_mod, + .linkage = .dynamic, + }); + lib.linkSystemLibrary("sqlite3"); + lib.linkLibC(); + b.installArtifact(lib); + + const lib_step = b.step("lib", "Build shared library"); + lib_step.dependOn(&lib.step); +} diff --git a/cartridges/domains/database/database-mcp/ffi/cartridge_shim.zig b/cartridges/domains/database/database-mcp/ffi/cartridge_shim.zig new file mode 100644 index 0000000..0e399db --- /dev/null +++ b/cartridges/domains/database/database-mcp/ffi/cartridge_shim.zig @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// cartridge_shim.zig β€” Shared helpers for the ADR-0006 five-symbol +// cartridge ABI (`boj_cartridge_init / deinit / name / version / invoke`). +// +// The shim centralises the seven-code return convention, NUL-argument +// guards, tool-name comparison, and the buffer-too-small path so each +// cartridge's `boj_cartridge_invoke` can stay short β€” typically a tool +// table plus `shim.writeResult(...)`. +// +// Cartridges import this file by relative path (no build-graph change +// needed). Example: +// +// const shim = @import("../../../ffi/zig/src/cartridge_shim.zig"); +// +// export fn boj_cartridge_invoke( +// tool_name: [*c]const u8, +// json_args: [*c]const u8, +// out_buf: [*c]u8, +// in_out_len: [*c]usize, +// ) callconv(.c) i32 { +// _ = json_args; +// if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; +// const body = if (shim.toolIs(tool_name, "foo")) "{\"result\":{}}" +// else return shim.RC_UNKNOWN_TOOL; +// return shim.writeResult(out_buf, in_out_len, body); +// } + +const std = @import("std"); + +// ── Return codes (ADR-0006 Β§Return codes) ──────────────────────────── +// +// Frozen by ADR-0006. New failure modes compose these via the error +// JSON body β€” the integer surface does not grow without a follow-up ADR. + +pub const RC_SUCCESS: i32 = 0; +pub const RC_UNKNOWN_TOOL: i32 = -1; +pub const RC_BAD_ARGS: i32 = -2; +pub const RC_BUFFER_TOO_SMALL: i32 = -3; +pub const RC_RUNTIME_ERROR: i32 = -4; +pub const RC_PANIC: i32 = -5; +pub const RC_AUTH_DENIED: i32 = -6; + +// ── Invoke-path helpers ────────────────────────────────────────────── + +/// True if any of the three mandatory `boj_cartridge_invoke` output-path +/// pointers is null. Use at the top of every invoke to short-circuit to +/// `RC_BAD_ARGS`. +pub fn invokeArgsNull( + tool_name: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) bool { + return tool_name == null or out_buf == null or in_out_len == null; +} + +/// Compare a C-NUL-terminated tool-name pointer against a Zig string +/// literal. Caller must have already verified `tool_name` is non-null +/// (usually via `invokeArgsNull`). +pub fn toolIs(tool_name: [*c]const u8, expected: []const u8) bool { + const s = std.mem.span(@as([*:0]const u8, @ptrCast(tool_name))); + return std.mem.eql(u8, s, expected); +} + +/// Copy `body` into `out_buf[0..*in_out_len]` (as a capacity) and update +/// `*in_out_len` to the number of bytes written. Returns `RC_SUCCESS`. +/// +/// If `body.len` exceeds the current capacity stored in `*in_out_len`, +/// sets `*in_out_len` to the required size and returns +/// `RC_BUFFER_TOO_SMALL` β€” the caller is then expected to re-allocate +/// and retry, per ADR-0006 Β§Memory ownership. +/// +/// Caller must have already verified that `out_buf` and `in_out_len` +/// are non-null. +pub fn writeResult( + out_buf: [*c]u8, + in_out_len: [*c]usize, + body: []const u8, +) i32 { + const cap = in_out_len.*; + if (body.len > cap) { + in_out_len.* = body.len; + return RC_BUFFER_TOO_SMALL; + } + @memcpy(out_buf[0..body.len], body); + in_out_len.* = body.len; + return RC_SUCCESS; +} + +// ── Tests ──────────────────────────────────────────────────────────── + +test "writeResult: body fits, writes and sets length" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); + try std.testing.expectEqualStrings("hello", buf[0..len]); +} + +test "writeResult: too small returns -3 and sets required length" { + var buf: [2]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_BUFFER_TOO_SMALL, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: exact-fit succeeds" { + var buf: [5]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: empty body" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, ""); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 0), len); +} + +test "toolIs: matches and rejects" { + const name: [*:0]const u8 = "foo"; + try std.testing.expect(toolIs(@ptrCast(name), "foo")); + try std.testing.expect(!toolIs(@ptrCast(name), "bar")); + try std.testing.expect(!toolIs(@ptrCast(name), "foobar")); + try std.testing.expect(!toolIs(@ptrCast(name), "fo")); +} + +test "invokeArgsNull: detects each null slot" { + var buf: [4]u8 = undefined; + var len: usize = 4; + const name: [*:0]const u8 = "x"; + try std.testing.expect(!invokeArgsNull(@ptrCast(name), &buf, &len)); + try std.testing.expect(invokeArgsNull(null, &buf, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), null, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), &buf, null)); +} diff --git a/cartridges/domains/database/database-mcp/ffi/database_ffi.zig b/cartridges/domains/database/database-mcp/ffi/database_ffi.zig new file mode 100644 index 0000000..dbf9304 --- /dev/null +++ b/cartridges/domains/database/database-mcp/ffi/database_ffi.zig @@ -0,0 +1,1260 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// Database-MCP Cartridge β€” Zig FFI bridge for database operations. +// +// Implements the connection state machine from SafeDatabase.idr. +// Ensures no query can execute on a closed connection, and no +// connection can be double-closed. +// +// SQLite integration: when backend == sqlite, db_connect_sqlite opens +// a real sqlite3 handle, and db_execute_sql runs queries against it. + +const std = @import("std"); +const c = @cImport(@cInclude("sqlite3.h")); + +// ═══════════════════════════════════════════════════════════════════════ +// Types (must match DatabaseMcp.SafeDatabase encodings) +// ═══════════════════════════════════════════════════════════════════════ + +pub const ConnState = enum(c_int) { + disconnected = 0, + connected = 1, + querying = 2, + err = 3, +}; + +pub const DatabaseBackend = enum(c_int) { + verisimdb = 1, + postgresql = 2, + sqlite = 3, + redis = 4, + quandledb = 5, + lithoglyph = 6, + custom = 99, +}; + +pub const QuerySafety = enum(c_int) { + read_only = 0, + mutation = 1, +}; + +// ═══════════════════════════════════════════════════════════════════════ +// Connection State Machine +// ═══════════════════════════════════════════════════════════════════════ + +const MAX_CONNECTIONS: usize = 16; + +const URL_BUF_SIZE: usize = 512; + +const ConnectionSlot = struct { + active: bool, + state: ConnState, + backend: DatabaseBackend, + db_handle: ?*c.sqlite3, + url_buf: [URL_BUF_SIZE]u8, + url_len: usize, +}; + +/// THREAD SAFETY: All reads/writes to `connections` are protected by `mutex`. +/// Every C-ABI export acquires mutex at entry via lock/defer-unlock pattern. +/// The two-phase functions (db_execute_vql, db_execute_kql, db_execute_gql) +/// release the mutex during blocking I/O (curl) and re-acquire afterwards, +/// re-validating slot state after re-acquisition to handle concurrent changes. +var connections: [MAX_CONNECTIONS]ConnectionSlot = [_]ConnectionSlot{.{ + .active = false, + .state = .disconnected, + .backend = .sqlite, + .db_handle = null, + .url_buf = [_]u8{0} ** URL_BUF_SIZE, + .url_len = 0, +}} ** MAX_CONNECTIONS; + +/// Module-level mutex protecting all mutable global state (connections array). +/// +/// INVARIANT: Every C-ABI export function acquires this mutex before accessing +/// `connections`. Internal helpers (isValidTransition, appendJsonEscaped) are +/// pure and do not need the mutex. +var mutex: std.Thread.Mutex = .{}; + +/// Validate a state transition (matches Idris2 canTransition). +fn isValidTransition(from: ConnState, to: ConnState) bool { + return switch (from) { + .disconnected => to == .connected, + .connected => to == .querying or to == .disconnected, + .querying => to == .connected or to == .err, + .err => to == .disconnected, + }; +} + +/// Open a new connection. Returns slot index or -1 on failure. +/// For non-sqlite backends, this only transitions state (no real handle). +/// +/// HARDENED: Validates backend enum value before @enumFromInt to prevent +/// undefined behaviour / panic on out-of-range c_int values. +pub export fn db_connect(backend: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + // SAFETY: validate that backend is a known DatabaseBackend value before + // calling @enumFromInt, which would panic on invalid values + const valid_backend = std.meta.intToEnum(DatabaseBackend, backend) catch return -1; + + for (&connections, 0..) |*slot, i| { + if (!slot.active) { + slot.active = true; + slot.state = .connected; + slot.backend = valid_backend; + slot.db_handle = null; + return @intCast(i); + } + } + return -1; // No slots available +} + +/// Open a new SQLite connection with a file path. +/// path_ptr/path_len: pointer and length of the database file path. +/// Returns slot index or negative error code: +/// -1 = no slots available +/// -3 = sqlite3_open failed +/// -6 = path_len is zero or exceeds maximum +/// +/// HARDENED: Rejects empty and oversized paths before any pointer dereference. +pub export fn db_connect_sqlite(path_ptr: [*]const u8, path_len: usize) c_int { + mutex.lock(); + defer mutex.unlock(); + + // SAFETY: reject empty paths and paths exceeding our buffer before dereference + if (path_len == 0 or path_len >= 4096) return -6; + + // Find a free slot + var free_idx: ?usize = null; + for (&connections, 0..) |*slot, i| { + _ = slot; + if (!connections[i].active) { + free_idx = i; + break; + } + } + const idx = free_idx orelse return -1; + + // Build a null-terminated path for sqlite3_open + var path_buf: [4096]u8 = undefined; + const safe_len = @min(path_len, path_buf.len - 1); + @memcpy(path_buf[0..safe_len], path_ptr[0..safe_len]); + path_buf[safe_len] = 0; + + var db_handle: ?*c.sqlite3 = null; + const rc = c.sqlite3_open(&path_buf, &db_handle); + if (rc != c.SQLITE_OK) { + if (db_handle) |h| { + _ = c.sqlite3_close(h); + } + return -3; // sqlite3_open failed + } + + connections[idx].active = true; + connections[idx].state = .connected; + connections[idx].backend = .sqlite; + connections[idx].db_handle = db_handle; + return @intCast(idx); +} + +/// Open a new VeriSimDB connection by URL (e.g. "http://localhost:8180"). +/// Stores the URL in the slot's url_buf for later use by db_execute_vql. +/// Returns slot index or negative error code: +/// -1 = no slots available +/// -6 = URL too long (exceeds URL_BUF_SIZE) +pub export fn db_connect_verisimdb(url_ptr: [*]const u8, url_len: usize) c_int { + mutex.lock(); + defer mutex.unlock(); + + if (url_len == 0 or url_len >= URL_BUF_SIZE) return -6; + + // Find a free slot + var free_idx: ?usize = null; + for (&connections, 0..) |*slot, i| { + _ = slot; + if (!connections[i].active) { + free_idx = i; + break; + } + } + const idx = free_idx orelse return -1; + + // Store the URL + @memcpy(connections[idx].url_buf[0..url_len], url_ptr[0..url_len]); + connections[idx].url_len = url_len; + connections[idx].active = true; + connections[idx].state = .connected; + connections[idx].backend = .verisimdb; + connections[idx].db_handle = null; + return @intCast(idx); +} + +/// Close a connection by slot index. +/// If the slot holds an open sqlite3 handle, closes it first. +pub export fn db_disconnect(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + if (slot_idx < 0 or slot_idx >= MAX_CONNECTIONS) return -1; + const idx: usize = @intCast(slot_idx); + if (!connections[idx].active) return -1; + if (!isValidTransition(connections[idx].state, .disconnected)) return -2; + + // Close the sqlite3 handle if present + if (connections[idx].db_handle) |h| { + _ = c.sqlite3_close(h); + connections[idx].db_handle = null; + } + + // Clear stored URL + connections[idx].url_len = 0; + + connections[idx].active = false; + connections[idx].state = .disconnected; + return 0; +} + +/// Get the state of a connection. +pub export fn db_state(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + if (slot_idx < 0 or slot_idx >= MAX_CONNECTIONS) return -1; + const idx: usize = @intCast(slot_idx); + if (!connections[idx].active) return @intFromEnum(ConnState.disconnected); + return @intFromEnum(connections[idx].state); +} + +/// Begin a query (transition Connected -> Querying). +pub export fn db_begin_query(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + if (slot_idx < 0 or slot_idx >= MAX_CONNECTIONS) return -1; + const idx: usize = @intCast(slot_idx); + if (!connections[idx].active) return -1; + if (!isValidTransition(connections[idx].state, .querying)) return -2; + + connections[idx].state = .querying; + return 0; +} + +/// End a query successfully (transition Querying -> Connected). +pub export fn db_end_query(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + if (slot_idx < 0 or slot_idx >= MAX_CONNECTIONS) return -1; + const idx: usize = @intCast(slot_idx); + if (!connections[idx].active) return -1; + if (!isValidTransition(connections[idx].state, .connected)) return -2; + + connections[idx].state = .connected; + return 0; +} + +/// Record a query error (transition Querying -> Error). +pub export fn db_query_error(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + if (slot_idx < 0 or slot_idx >= MAX_CONNECTIONS) return -1; + const idx: usize = @intCast(slot_idx); + if (!connections[idx].active) return -1; + if (!isValidTransition(connections[idx].state, .err)) return -2; + + connections[idx].state = .err; + return 0; +} + +/// Validate a state transition (C-ABI export). +/// +/// HARDENED: Uses std.meta.intToEnum instead of raw @enumFromInt to return +/// -1 on invalid enum values rather than panicking/triggering UB. +pub export fn db_can_transition(from: c_int, to: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + // SAFETY: validate enum range before conversion β€” @enumFromInt panics on invalid values + const f = std.meta.intToEnum(ConnState, from) catch return -1; + const t = std.meta.intToEnum(ConnState, to) catch return -1; + return if (isValidTransition(f, t)) 1 else 0; +} + +/// Reset all connections (for testing). +/// Closes any open sqlite3 handles. +pub export fn db_reset() void { + mutex.lock(); + defer mutex.unlock(); + for (&connections) |*slot| { + if (slot.db_handle) |h| { + _ = c.sqlite3_close(h); + slot.db_handle = null; + } + slot.url_len = 0; + slot.active = false; + slot.state = .disconnected; + } +} + +// ═══════════════════════════════════════════════════════════════════════ +// SQL Execution (SQLite only) +// ═══════════════════════════════════════════════════════════════════════ + +/// Execute a SQL query against an open SQLite connection. +/// +/// Manages the state machine: transitions Connected -> Querying, runs the +/// query via sqlite3_exec, then transitions Querying -> Connected (or +/// Querying -> Error on failure). +/// +/// Parameters: +/// slot: connection slot index (must be connected, backend == sqlite) +/// sql_ptr: pointer to the SQL string +/// sql_len: byte length of the SQL string +/// out_ptr: caller-owned buffer for JSON result output +/// out_len: size of the output buffer +/// +/// Returns: +/// >= 0 : number of bytes written to out_ptr (JSON array of objects) +/// -1 : invalid slot +/// -2 : invalid state transition (not in connected state) +/// -3 : no sqlite3 handle on this slot (wrong backend) +/// -4 : sqlite3_exec error (state transitions to Error, then Disconnected) +/// -5 : output buffer too small +/// -8 : sql_len is zero or out_len is zero +/// +/// HARDENED: Bounds checks on sql_len and out_len before pointer dereference. +pub export fn db_execute_sql(slot: u8, sql_ptr: [*]const u8, sql_len: usize, out_ptr: [*]u8, out_len: usize) callconv(.c) i32 { + mutex.lock(); + defer mutex.unlock(); + + // SAFETY: reject zero-length SQL and zero-length output buffers before dereference + if (sql_len == 0 or out_len == 0) return -8; + + if (slot >= MAX_CONNECTIONS) return -1; + const idx: usize = @intCast(slot); + if (!connections[idx].active) return -1; + + // Must be in connected state to begin querying + if (!isValidTransition(connections[idx].state, .querying)) return -2; + + // Must have a real sqlite3 handle + const db_handle = connections[idx].db_handle orelse return -3; + + // Transition to querying + connections[idx].state = .querying; + + // Build null-terminated SQL from the pointer/length pair + var sql_buf: [8192]u8 = undefined; + const safe_sql_len = @min(sql_len, sql_buf.len - 1); + @memcpy(sql_buf[0..safe_sql_len], sql_ptr[0..safe_sql_len]); + sql_buf[safe_sql_len] = 0; + + // Use sqlite3_exec with a callback to collect results into a JSON array. + // We accumulate into a fixed-size arena backed by a stack buffer, using + // ArrayListUnmanaged (Zig 0.15 API). + var result_buf: [65536]u8 = undefined; + var fba = std.heap.FixedBufferAllocator.init(&result_buf); + const allocator = fba.allocator(); + + var json_out = std.ArrayListUnmanaged(u8){}; + json_out.appendSlice(allocator, "[") catch { + connections[idx].state = .err; + return -5; + }; + + var ctx = ExecContext{ + .json_out = &json_out, + .allocator = allocator, + .row_count = 0, + }; + + // SAFETY: @ptrCast is required here to pass ExecContext* as sqlite3_exec's + // void* callback argument. The matching @ptrCast(@alignCast(...)) in + // execCallback reverses this with a null guard (orelse return 1). + var errmsg: [*c]u8 = null; + const rc = c.sqlite3_exec( + db_handle, + &sql_buf, + execCallback, + @ptrCast(&ctx), + &errmsg, + ); + + if (rc != c.SQLITE_OK) { + if (errmsg) |msg| c.sqlite3_free(@ptrCast(msg)); + // Transition to error state; caller can recover via db_disconnect. + connections[idx].state = .err; + return -4; + } + + json_out.appendSlice(allocator, "]") catch { + connections[idx].state = .err; + return -5; + }; + + // Transition back to connected + connections[idx].state = .connected; + + const written = json_out.items.len; + if (written > out_len) return -5; + + @memcpy(out_ptr[0..written], json_out.items[0..written]); + return @intCast(written); +} + +const ExecContext = struct { + json_out: *std.ArrayListUnmanaged(u8), + allocator: std.mem.Allocator, + row_count: usize, +}; + +/// sqlite3_exec callback β€” called once per result row. +/// Serializes each row as a JSON object with column names as keys. +/// +/// HARDENED: Null check on ctx_ptr; bounds check on col_count (reject negative +/// values and cap at a sane maximum to prevent resource exhaustion); null checks +/// on col_values/col_names array pointers before dereference. +fn execCallback( + ctx_ptr: ?*anyopaque, + col_count: c_int, + col_values: [*c][*c]u8, + col_names: [*c][*c]u8, +) callconv(.c) c_int { + // SAFETY: null guard on context pointer β€” return error to sqlite3_exec + const ctx: *ExecContext = @ptrCast(@alignCast(ctx_ptr orelse return 1)); + // SAFETY: reject negative col_count (should never happen but guard against it) + // and cap at 1024 columns to prevent resource exhaustion from malformed data + if (col_count < 0 or col_count > 1024) return 1; + const ncols: usize = @intCast(col_count); + const alloc = ctx.allocator; + + // Comma separator between rows + if (ctx.row_count > 0) { + ctx.json_out.appendSlice(alloc, ",") catch return 1; + } + ctx.row_count += 1; + + ctx.json_out.appendSlice(alloc, "{") catch return 1; + + for (0..ncols) |i| { + if (i > 0) { + ctx.json_out.appendSlice(alloc, ",") catch return 1; + } + + // Key: column name + ctx.json_out.appendSlice(alloc, "\"") catch return 1; + if (col_names[i]) |name| { + const name_slice = std.mem.span(name); + appendJsonEscaped(ctx.json_out, alloc, name_slice) catch return 1; + } + ctx.json_out.appendSlice(alloc, "\":") catch return 1; + + // Value: column value (null-safe) + if (col_values[i]) |val| { + ctx.json_out.appendSlice(alloc, "\"") catch return 1; + const val_slice = std.mem.span(val); + appendJsonEscaped(ctx.json_out, alloc, val_slice) catch return 1; + ctx.json_out.appendSlice(alloc, "\"") catch return 1; + } else { + ctx.json_out.appendSlice(alloc, "null") catch return 1; + } + } + + ctx.json_out.appendSlice(alloc, "}") catch return 1; + return 0; +} + +/// Append a string to the ArrayListUnmanaged, escaping JSON special characters. +fn appendJsonEscaped(list: *std.ArrayListUnmanaged(u8), alloc: std.mem.Allocator, input: []const u8) !void { + for (input) |byte| { + switch (byte) { + '"' => try list.appendSlice(alloc, "\\\""), + '\\' => try list.appendSlice(alloc, "\\\\"), + '\n' => try list.appendSlice(alloc, "\\n"), + '\r' => try list.appendSlice(alloc, "\\r"), + '\t' => try list.appendSlice(alloc, "\\t"), + else => { + if (byte < 0x20) { + // Control character β€” encode as \u00XX + var hex_buf: [6]u8 = undefined; + _ = std.fmt.bufPrint(&hex_buf, "\\u{X:0>4}", .{byte}) catch return error.OutOfMemory; + try list.appendSlice(alloc, &hex_buf); + } else { + try list.append(alloc, byte); + } + }, + } + } +} + + +// ═══════════════════════════════════════════════════════════════════════ +// VQL Execution (VeriSimDB β€” via child curl process) +// ═══════════════════════════════════════════════════════════════════════ + +/// Execute a VQL query against a VeriSimDB connection via the Zig state machine. +/// +/// The stored URL is used to POST to {url}/vql/execute with the VQL query +/// as the JSON request body. Uses a child curl process for HTTP transport. +/// +/// Parameters: +/// slot: connection slot index (must be connected, backend == verisimdb) +/// vql_ptr: pointer to the VQL JSON string (request body) +/// vql_len: byte length of the VQL string +/// out_ptr: caller-owned buffer for response output +/// out_len: size of the output buffer +/// +/// Returns: +/// >= 0 : number of bytes written to out_ptr +/// -1 : invalid slot +/// -2 : invalid state transition (not in connected state) +/// -6 : no URL stored on this slot (wrong backend) +/// -7 : curl execution failed (state transitions to Error) +/// -5 : output buffer too small +/// -8 : vql_len is zero or out_len is zero +/// +/// HARDENED: Bounds checks on vql_len and out_len before pointer dereference. +pub export fn db_execute_vql(slot: u8, vql_ptr: [*]const u8, vql_len: usize, out_ptr: [*]u8, out_len: usize) callconv(.c) i32 { + // SAFETY: reject zero-length query and zero-length output buffers + if (vql_len == 0 or out_len == 0) return -8; + + // Phase 1: validate and transition state under lock + var endpoint_buf: [600]u8 = undefined; + var endpoint_total: usize = 0; + var vql_buf: [8192]u8 = undefined; + var safe_vql_len: usize = 0; + + { + mutex.lock(); + defer mutex.unlock(); + + if (slot >= MAX_CONNECTIONS) return -1; + const idx: usize = @intCast(slot); + if (!connections[idx].active) return -1; + + // Must be in connected state to begin querying + if (!isValidTransition(connections[idx].state, .querying)) return -2; + + // Must have a stored URL (verisimdb backend) + if (connections[idx].url_len == 0) return -6; + + // Build the full endpoint URL: {base_url}/vql/execute + const url_slice = connections[idx].url_buf[0..connections[idx].url_len]; + const suffix = "/vql/execute"; + if (url_slice.len + suffix.len >= endpoint_buf.len) return -6; + @memcpy(endpoint_buf[0..url_slice.len], url_slice); + @memcpy(endpoint_buf[url_slice.len..][0..suffix.len], suffix); + endpoint_total = url_slice.len + suffix.len; + endpoint_buf[endpoint_total] = 0; + + // Build the VQL body as a null-terminated string for the -d argument + safe_vql_len = @min(vql_len, vql_buf.len - 1); + @memcpy(vql_buf[0..safe_vql_len], vql_ptr[0..safe_vql_len]); + vql_buf[safe_vql_len] = 0; + + // Transition to querying + connections[idx].state = .querying; + } + + // Phase 2: run curl WITHOUT holding the mutex (blocking I/O) + const child_result = runCurlPost( + endpoint_buf[0..endpoint_total :0], + vql_buf[0..safe_vql_len :0], + ); + + // Phase 3: update state under lock based on result + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = @intCast(slot); + // Check if slot is still valid after re-acquiring lock + if (!connections[idx].active or connections[idx].state != .querying) { + return -1; + } + + if (child_result) |result| { + defer std.heap.page_allocator.free(result); + const written = result.len; + if (written > out_len) { + connections[idx].state = .err; + return -5; + } + @memcpy(out_ptr[0..written], result[0..written]); + connections[idx].state = .connected; + return @intCast(written); + } else |_| { + connections[idx].state = .err; + return -7; + } +} + +/// Run curl as a child process for an HTTP POST with JSON body. +/// Returns a heap-allocated slice with stdout output, or an error. +/// Caller must free the returned slice with page_allocator.free(). +/// +/// HARDENED: Checks termination signal type (Exited vs Signal/Stopped/Unknown) +/// instead of unconditionally accessing .Exited, which would be undefined +/// behaviour if the child was killed by a signal. +fn runCurlPost(endpoint: [:0]const u8, body: [:0]const u8) ![]u8 { + const argv = [_][]const u8{ + "curl", + "-sf", + "--max-time", + "10", + "-X", + "POST", + "-H", + "Content-Type: application/json", + "-d", + body, + endpoint, + }; + var child = std.process.Child.init(&argv, std.heap.page_allocator); + child.stdout_behavior = .Pipe; + child.stderr_behavior = .Pipe; + try child.spawn(); + + // Collect stdout via the standard API + const alloc = std.heap.page_allocator; + var stdout_list: std.ArrayList(u8) = .empty; + var stderr_list: std.ArrayList(u8) = .empty; + defer stderr_list.deinit(alloc); + + try child.collectOutput(alloc, &stdout_list, &stderr_list, 65536); + const term = try child.wait(); + + // SAFETY: check that process exited normally (not signalled/stopped) + // before inspecting exit code. Accessing .Exited on a Signal term is UB. + switch (term) { + .Exited => |code| { + if (code != 0) { + stdout_list.deinit(alloc); + return error.CurlFailed; + } + }, + else => { + // Process was killed by signal, stopped, or unknown termination + stdout_list.deinit(alloc); + return error.CurlFailed; + }, + } + + return stdout_list.toOwnedSlice(alloc); +} + +// ═══════════════════════════════════════════════════════════════════════ +// KQL Execution (QuandleDB β€” via child curl process) +// ═══════════════════════════════════════════════════════════════════════ + +/// Open a new QuandleDB connection by URL (e.g. "http://localhost:8081"). +/// Stores the URL in the slot's url_buf for later use by db_execute_kql. +/// Returns slot index or negative error code: +/// -1 = no slots available +/// -6 = URL too long (exceeds URL_BUF_SIZE) +pub export fn db_connect_quandledb(url_ptr: [*]const u8, url_len: usize) c_int { + mutex.lock(); + defer mutex.unlock(); + + if (url_len == 0 or url_len >= URL_BUF_SIZE) return -6; + + var free_idx: ?usize = null; + for (&connections, 0..) |*slot, i| { + _ = slot; + if (!connections[i].active) { + free_idx = i; + break; + } + } + const idx = free_idx orelse return -1; + + @memcpy(connections[idx].url_buf[0..url_len], url_ptr[0..url_len]); + connections[idx].url_len = url_len; + connections[idx].active = true; + connections[idx].state = .connected; + connections[idx].backend = .quandledb; + connections[idx].db_handle = null; + return @intCast(idx); +} + +/// Execute a KQL query against a QuandleDB connection via child curl. +/// POSTs to {url}/kql/execute with the KQL query as JSON body. +/// +/// HARDENED: Bounds checks on kql_len and out_len before pointer dereference. +pub export fn db_execute_kql(slot: u8, kql_ptr: [*]const u8, kql_len: usize, out_ptr: [*]u8, out_len: usize) callconv(.c) i32 { + // SAFETY: reject zero-length query and zero-length output buffers + if (kql_len == 0 or out_len == 0) return -8; + + var endpoint_buf: [600]u8 = undefined; + var endpoint_total: usize = 0; + var kql_buf: [8192]u8 = undefined; + var safe_kql_len: usize = 0; + + { + mutex.lock(); + defer mutex.unlock(); + + if (slot >= MAX_CONNECTIONS) return -1; + const idx: usize = @intCast(slot); + if (!connections[idx].active) return -1; + if (!isValidTransition(connections[idx].state, .querying)) return -2; + if (connections[idx].url_len == 0) return -6; + + const url_slice = connections[idx].url_buf[0..connections[idx].url_len]; + const suffix = "/kql/execute"; + if (url_slice.len + suffix.len >= endpoint_buf.len) return -6; + @memcpy(endpoint_buf[0..url_slice.len], url_slice); + @memcpy(endpoint_buf[url_slice.len..][0..suffix.len], suffix); + endpoint_total = url_slice.len + suffix.len; + endpoint_buf[endpoint_total] = 0; + + safe_kql_len = @min(kql_len, kql_buf.len - 1); + @memcpy(kql_buf[0..safe_kql_len], kql_ptr[0..safe_kql_len]); + kql_buf[safe_kql_len] = 0; + + connections[idx].state = .querying; + } + + const child_result = runCurlPost( + endpoint_buf[0..endpoint_total :0], + kql_buf[0..safe_kql_len :0], + ); + + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = @intCast(slot); + if (!connections[idx].active or connections[idx].state != .querying) return -1; + + if (child_result) |result| { + defer std.heap.page_allocator.free(result); + const written = result.len; + if (written > out_len) { + connections[idx].state = .err; + return -5; + } + @memcpy(out_ptr[0..written], result[0..written]); + connections[idx].state = .connected; + return @intCast(written); + } else |_| { + connections[idx].state = .err; + return -7; + } +} + +// ═══════════════════════════════════════════════════════════════════════ +// GQL Execution (LithoGlyph β€” via child curl process) +// ═══════════════════════════════════════════════════════════════════════ + +/// Open a new LithoGlyph connection by URL (e.g. "http://localhost:8082"). +/// Stores the URL in the slot's url_buf for later use by db_execute_gql. +/// Returns slot index or negative error code: +/// -1 = no slots available +/// -6 = URL too long (exceeds URL_BUF_SIZE) +pub export fn db_connect_lithoglyph(url_ptr: [*]const u8, url_len: usize) c_int { + mutex.lock(); + defer mutex.unlock(); + + if (url_len == 0 or url_len >= URL_BUF_SIZE) return -6; + + var free_idx: ?usize = null; + for (&connections, 0..) |*slot, i| { + _ = slot; + if (!connections[i].active) { + free_idx = i; + break; + } + } + const idx = free_idx orelse return -1; + + @memcpy(connections[idx].url_buf[0..url_len], url_ptr[0..url_len]); + connections[idx].url_len = url_len; + connections[idx].active = true; + connections[idx].state = .connected; + connections[idx].backend = .lithoglyph; + connections[idx].db_handle = null; + return @intCast(idx); +} + +/// Execute a GQL query against a LithoGlyph connection via child curl. +/// POSTs to {url}/gql/execute with the GQL query as JSON body. +/// +/// HARDENED: Bounds checks on gql_len and out_len before pointer dereference. +pub export fn db_execute_gql(slot: u8, gql_ptr: [*]const u8, gql_len: usize, out_ptr: [*]u8, out_len: usize) callconv(.c) i32 { + // SAFETY: reject zero-length query and zero-length output buffers + if (gql_len == 0 or out_len == 0) return -8; + + var endpoint_buf: [600]u8 = undefined; + var endpoint_total: usize = 0; + var gql_buf: [8192]u8 = undefined; + var safe_gql_len: usize = 0; + + { + mutex.lock(); + defer mutex.unlock(); + + if (slot >= MAX_CONNECTIONS) return -1; + const idx: usize = @intCast(slot); + if (!connections[idx].active) return -1; + if (!isValidTransition(connections[idx].state, .querying)) return -2; + if (connections[idx].url_len == 0) return -6; + + const url_slice = connections[idx].url_buf[0..connections[idx].url_len]; + const suffix = "/gql/execute"; + if (url_slice.len + suffix.len >= endpoint_buf.len) return -6; + @memcpy(endpoint_buf[0..url_slice.len], url_slice); + @memcpy(endpoint_buf[url_slice.len..][0..suffix.len], suffix); + endpoint_total = url_slice.len + suffix.len; + endpoint_buf[endpoint_total] = 0; + + safe_gql_len = @min(gql_len, gql_buf.len - 1); + @memcpy(gql_buf[0..safe_gql_len], gql_ptr[0..safe_gql_len]); + gql_buf[safe_gql_len] = 0; + + connections[idx].state = .querying; + } + + const child_result = runCurlPost( + endpoint_buf[0..endpoint_total :0], + gql_buf[0..safe_gql_len :0], + ); + + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = @intCast(slot); + if (!connections[idx].active or connections[idx].state != .querying) return -1; + + if (child_result) |result| { + defer std.heap.page_allocator.free(result); + const written = result.len; + if (written > out_len) { + connections[idx].state = .err; + return -5; + } + @memcpy(out_ptr[0..written], result[0..written]); + connections[idx].state = .connected; + return @intCast(written); + } else |_| { + connections[idx].state = .err; + return -7; + } +} + +// ═══════════════════════════════════════════════════════════════════════ +// Standard Cartridge Interface (loader expects these 4 C-ABI symbols) +// ═══════════════════════════════════════════════════════════════════════ + +/// Initialise the database-mcp cartridge. Resets all connection slots. +pub export fn boj_cartridge_init() c_int { + db_reset(); + return 0; +} + +/// Deinitialise the database-mcp cartridge. Resets all connection slots. +pub export fn boj_cartridge_deinit() void { + db_reset(); +} + +/// Return the cartridge name as a null-terminated C string. +/// NOTE: mutex acquired for consistency with C-ABI export contract, even though +/// this returns a compile-time string literal (no mutable state accessed). +pub export fn boj_cartridge_name() [*:0]const u8 { + mutex.lock(); + defer mutex.unlock(); + return "database-mcp"; +} + +/// Return the cartridge version as a null-terminated C string. +/// NOTE: mutex acquired for consistency with C-ABI export contract, even though +/// this returns a compile-time string literal (no mutable state accessed). +pub export fn boj_cartridge_version() [*:0]const u8 { + mutex.lock(); + defer mutex.unlock(); + return "0.1.0"; +} + +// ═══════════════════════════════════════════════════════════════════════ +// ADR-0006 dispatch (boj_cartridge_invoke, 5th standard symbol) +// ═══════════════════════════════════════════════════════════════════════ + +const shim = @import("cartridge_shim.zig"); + +/// Dispatch the cartridge.json MCP tools. Grade D Alpha β€” each arm +/// returns a stub JSON body shaped to the tool's intended response. +export fn boj_cartridge_invoke( + tool_name: [*c]const u8, + json_args: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) callconv(.c) i32 { + _ = json_args; + if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; + + const body: []const u8 = if (shim.toolIs(tool_name, "database_connect")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "database_query")) + "{\"result\":{\"matches\":[],\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "database_execute")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "database_list_tables")) + "{\"result\":{\"items\":[],\"count\":0,\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "database_describe")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "database_disconnect")) + "{\"result\":{\"status\":\"stub\"}}" +else + return shim.RC_UNKNOWN_TOOL; + + return shim.writeResult(out_buf, in_out_len, body); +} + +// ═══════════════════════════════════════════════════════════════════════ +// Tests +// ═══════════════════════════════════════════════════════════════════════ + +test "connect and disconnect" { + db_reset(); + const slot = db_connect(@intFromEnum(DatabaseBackend.sqlite)); + try std.testing.expect(slot >= 0); + try std.testing.expectEqual(@as(c_int, @intFromEnum(ConnState.connected)), db_state(slot)); + try std.testing.expectEqual(@as(c_int, 0), db_disconnect(slot)); +} + +test "cannot query on disconnected" { + db_reset(); + const slot = db_connect(@intFromEnum(DatabaseBackend.postgresql)); + _ = db_disconnect(slot); + // Should fail β€” can't begin query on disconnected connection + try std.testing.expectEqual(@as(c_int, -1), db_begin_query(slot)); +} + +test "query lifecycle" { + db_reset(); + const slot = db_connect(@intFromEnum(DatabaseBackend.verisimdb)); + try std.testing.expectEqual(@as(c_int, 0), db_begin_query(slot)); + try std.testing.expectEqual(@as(c_int, @intFromEnum(ConnState.querying)), db_state(slot)); + try std.testing.expectEqual(@as(c_int, 0), db_end_query(slot)); + try std.testing.expectEqual(@as(c_int, @intFromEnum(ConnState.connected)), db_state(slot)); + try std.testing.expectEqual(@as(c_int, 0), db_disconnect(slot)); +} + +test "cannot double-close" { + db_reset(); + const slot = db_connect(@intFromEnum(DatabaseBackend.redis)); + _ = db_disconnect(slot); + // Second disconnect should fail β€” already disconnected + try std.testing.expectEqual(@as(c_int, -1), db_disconnect(slot)); +} + +test "error recovery" { + db_reset(); + const slot = db_connect(@intFromEnum(DatabaseBackend.sqlite)); + _ = db_begin_query(slot); + _ = db_query_error(slot); + try std.testing.expectEqual(@as(c_int, @intFromEnum(ConnState.err)), db_state(slot)); + // Can only go to disconnected from error + try std.testing.expectEqual(@as(c_int, 0), db_disconnect(slot)); +} + +test "state transition validation" { + // Valid transitions + try std.testing.expectEqual(@as(c_int, 1), db_can_transition(0, 1)); // disconnected -> connected + try std.testing.expectEqual(@as(c_int, 1), db_can_transition(1, 2)); // connected -> querying + try std.testing.expectEqual(@as(c_int, 1), db_can_transition(2, 1)); // querying -> connected + try std.testing.expectEqual(@as(c_int, 1), db_can_transition(1, 0)); // connected -> disconnected + // Invalid transitions + try std.testing.expectEqual(@as(c_int, 0), db_can_transition(0, 2)); // disconnected -> querying + try std.testing.expectEqual(@as(c_int, 0), db_can_transition(2, 0)); // querying -> disconnected +} + +test "sqlite connect and disconnect with real handle" { + db_reset(); + // Use in-memory database for testing + const path = ":memory:"; + const slot = db_connect_sqlite(path, path.len); + try std.testing.expect(slot >= 0); + try std.testing.expectEqual(@as(c_int, @intFromEnum(ConnState.connected)), db_state(slot)); + // Verify the handle is non-null + const idx: usize = @intCast(slot); + try std.testing.expect(connections[idx].db_handle != null); + try std.testing.expectEqual(@as(c_int, 0), db_disconnect(slot)); + // After disconnect, handle should be null + try std.testing.expect(connections[idx].db_handle == null); +} + +test "sqlite execute_sql basic query" { + db_reset(); + const path = ":memory:"; + const slot_i32 = db_connect_sqlite(path, path.len); + try std.testing.expect(slot_i32 >= 0); + const slot: u8 = @intCast(slot_i32); + + // Create a table and insert data + var discard_buf: [256]u8 = undefined; + const create_sql = "CREATE TABLE t(id INTEGER PRIMARY KEY, name TEXT);"; + const rc1 = db_execute_sql(slot, create_sql, create_sql.len, &discard_buf, discard_buf.len); + try std.testing.expect(rc1 >= 0); // empty result is fine: "[]" + + const insert_sql = "INSERT INTO t VALUES(1, 'alice');"; + const rc2 = db_execute_sql(slot, insert_sql, insert_sql.len, &discard_buf, discard_buf.len); + try std.testing.expect(rc2 >= 0); + + // Query the data + var out_buf: [1024]u8 = undefined; + const select_sql = "SELECT id, name FROM t;"; + const written = db_execute_sql(slot, select_sql, select_sql.len, &out_buf, out_buf.len); + try std.testing.expect(written > 0); + + const json_result = out_buf[0..@intCast(written)]; + // Should contain the row data + try std.testing.expect(std.mem.indexOf(u8, json_result, "\"alice\"") != null); + try std.testing.expect(std.mem.indexOf(u8, json_result, "\"1\"") != null); + + // Verify state is back to connected + try std.testing.expectEqual(@as(c_int, @intFromEnum(ConnState.connected)), db_state(slot_i32)); + + try std.testing.expectEqual(@as(c_int, 0), db_disconnect(slot_i32)); +} + +test "sqlite execute_sql error on invalid SQL" { + db_reset(); + const path = ":memory:"; + const slot_i32 = db_connect_sqlite(path, path.len); + try std.testing.expect(slot_i32 >= 0); + const slot: u8 = @intCast(slot_i32); + + var out_buf: [256]u8 = undefined; + const bad_sql = "SELEKT * FORM nonexistent;"; + const rc = db_execute_sql(slot, bad_sql, bad_sql.len, &out_buf, out_buf.len); + try std.testing.expectEqual(@as(i32, -4), rc); + + // State should now be error + try std.testing.expectEqual(@as(c_int, @intFromEnum(ConnState.err)), db_state(slot_i32)); + + // Recovery: disconnect from error state + try std.testing.expectEqual(@as(c_int, 0), db_disconnect(slot_i32)); +} + +test "sqlite execute_sql rejects non-sqlite slot" { + db_reset(); + // Connect with verisimdb backend (no sqlite handle) + const slot_i32 = db_connect(@intFromEnum(DatabaseBackend.verisimdb)); + try std.testing.expect(slot_i32 >= 0); + const slot: u8 = @intCast(slot_i32); + + var out_buf: [256]u8 = undefined; + const sql = "SELECT 1;"; + const rc = db_execute_sql(slot, sql, sql.len, &out_buf, out_buf.len); + // Should return -3 (no sqlite handle) + try std.testing.expectEqual(@as(i32, -3), rc); + + // State should still be connected β€” the handle check occurs before + // the state transition, so no transition happened. + try std.testing.expectEqual(@as(c_int, @intFromEnum(ConnState.connected)), db_state(slot_i32)); + + // Disconnect directly from connected + try std.testing.expectEqual(@as(c_int, 0), db_disconnect(slot_i32)); +} + +test "sqlite execute_sql multiple rows" { + db_reset(); + const path = ":memory:"; + const slot_i32 = db_connect_sqlite(path, path.len); + try std.testing.expect(slot_i32 >= 0); + const slot: u8 = @intCast(slot_i32); + + var discard_buf: [256]u8 = undefined; + const setup = "CREATE TABLE items(id INT, val TEXT); INSERT INTO items VALUES(1,'a'),(2,'b'),(3,'c');"; + _ = db_execute_sql(slot, setup, setup.len, &discard_buf, discard_buf.len); + + var out_buf: [2048]u8 = undefined; + const query = "SELECT * FROM items ORDER BY id;"; + const written = db_execute_sql(slot, query, query.len, &out_buf, out_buf.len); + try std.testing.expect(written > 0); + + const json_result = out_buf[0..@intCast(written)]; + // Should have 3 rows with commas between them + try std.testing.expect(std.mem.indexOf(u8, json_result, "\"a\"") != null); + try std.testing.expect(std.mem.indexOf(u8, json_result, "\"b\"") != null); + try std.testing.expect(std.mem.indexOf(u8, json_result, "\"c\"") != null); + + try std.testing.expectEqual(@as(c_int, 0), db_disconnect(slot_i32)); +} + +// ─── VeriSimDB connection lifecycle tests ──────────────────────────── + +test "verisimdb connect stores URL and disconnects" { + db_reset(); + const url = "http://localhost:8180"; + const slot = db_connect_verisimdb(url, url.len); + try std.testing.expect(slot >= 0); + try std.testing.expectEqual(@as(c_int, @intFromEnum(ConnState.connected)), db_state(slot)); + + // Verify backend and URL are stored correctly + const idx: usize = @intCast(slot); + try std.testing.expectEqual(DatabaseBackend.verisimdb, connections[idx].backend); + try std.testing.expectEqual(url.len, connections[idx].url_len); + try std.testing.expect(std.mem.eql(u8, url, connections[idx].url_buf[0..connections[idx].url_len])); + + // No sqlite handle should be present + try std.testing.expect(connections[idx].db_handle == null); + + // Disconnect + try std.testing.expectEqual(@as(c_int, 0), db_disconnect(slot)); + try std.testing.expectEqual(@as(usize, 0), connections[idx].url_len); +} + +test "verisimdb connect rejects empty URL" { + db_reset(); + const slot = db_connect_verisimdb("", 0); + try std.testing.expectEqual(@as(c_int, -6), slot); +} + +test "verisimdb connect rejects overlong URL" { + db_reset(); + var long_url: [URL_BUF_SIZE]u8 = [_]u8{'x'} ** URL_BUF_SIZE; + const slot = db_connect_verisimdb(&long_url, long_url.len); + try std.testing.expectEqual(@as(c_int, -6), slot); +} + +test "verisimdb query lifecycle through state machine" { + db_reset(); + const url = "http://localhost:8180"; + const slot = db_connect_verisimdb(url, url.len); + try std.testing.expect(slot >= 0); + + // Manual state transitions (not calling db_execute_vql since no server) + try std.testing.expectEqual(@as(c_int, 0), db_begin_query(slot)); + try std.testing.expectEqual(@as(c_int, @intFromEnum(ConnState.querying)), db_state(slot)); + try std.testing.expectEqual(@as(c_int, 0), db_end_query(slot)); + try std.testing.expectEqual(@as(c_int, @intFromEnum(ConnState.connected)), db_state(slot)); + try std.testing.expectEqual(@as(c_int, 0), db_disconnect(slot)); +} + +// ─── QuandleDB connection lifecycle tests ───────────────────────────── + +test "quandledb connect stores URL and disconnects" { + db_reset(); + const url = "http://localhost:8081"; + const slot = db_connect_quandledb(url, url.len); + try std.testing.expect(slot >= 0); + try std.testing.expectEqual(@as(c_int, @intFromEnum(ConnState.connected)), db_state(slot)); + + const idx: usize = @intCast(slot); + try std.testing.expectEqual(DatabaseBackend.quandledb, connections[idx].backend); + try std.testing.expectEqual(url.len, connections[idx].url_len); + try std.testing.expect(connections[idx].db_handle == null); + + try std.testing.expectEqual(@as(c_int, 0), db_disconnect(slot)); + try std.testing.expectEqual(@as(usize, 0), connections[idx].url_len); +} + +test "quandledb connect rejects empty URL" { + db_reset(); + const slot = db_connect_quandledb("", 0); + try std.testing.expectEqual(@as(c_int, -6), slot); +} + +test "quandledb query lifecycle through state machine" { + db_reset(); + const url = "http://localhost:8081"; + const slot = db_connect_quandledb(url, url.len); + try std.testing.expect(slot >= 0); + + try std.testing.expectEqual(@as(c_int, 0), db_begin_query(slot)); + try std.testing.expectEqual(@as(c_int, @intFromEnum(ConnState.querying)), db_state(slot)); + try std.testing.expectEqual(@as(c_int, 0), db_end_query(slot)); + try std.testing.expectEqual(@as(c_int, @intFromEnum(ConnState.connected)), db_state(slot)); + try std.testing.expectEqual(@as(c_int, 0), db_disconnect(slot)); +} + +// ─── LithoGlyph connection lifecycle tests ──────────────────────────── + +test "lithoglyph connect stores URL and disconnects" { + db_reset(); + const url = "http://localhost:8082"; + const slot = db_connect_lithoglyph(url, url.len); + try std.testing.expect(slot >= 0); + try std.testing.expectEqual(@as(c_int, @intFromEnum(ConnState.connected)), db_state(slot)); + + const idx: usize = @intCast(slot); + try std.testing.expectEqual(DatabaseBackend.lithoglyph, connections[idx].backend); + try std.testing.expectEqual(url.len, connections[idx].url_len); + try std.testing.expect(connections[idx].db_handle == null); + + try std.testing.expectEqual(@as(c_int, 0), db_disconnect(slot)); + try std.testing.expectEqual(@as(usize, 0), connections[idx].url_len); +} + +test "lithoglyph connect rejects empty URL" { + db_reset(); + const slot = db_connect_lithoglyph("", 0); + try std.testing.expectEqual(@as(c_int, -6), slot); +} + +test "lithoglyph query lifecycle through state machine" { + db_reset(); + const url = "http://localhost:8082"; + const slot = db_connect_lithoglyph(url, url.len); + try std.testing.expect(slot >= 0); + + try std.testing.expectEqual(@as(c_int, 0), db_begin_query(slot)); + try std.testing.expectEqual(@as(c_int, @intFromEnum(ConnState.querying)), db_state(slot)); + try std.testing.expectEqual(@as(c_int, 0), db_end_query(slot)); + try std.testing.expectEqual(@as(c_int, @intFromEnum(ConnState.connected)), db_state(slot)); + try std.testing.expectEqual(@as(c_int, 0), db_disconnect(slot)); +} + +test "verisimdb execute_vql rejects non-verisimdb slot" { + db_reset(); + // Connect with sqlite backend (has a handle, not a URL) + const path = ":memory:"; + const slot_i32 = db_connect_sqlite(path, path.len); + try std.testing.expect(slot_i32 >= 0); + const slot: u8 = @intCast(slot_i32); + + var out_buf: [256]u8 = undefined; + const vql = "{\"query\": \"SELECT * FROM test\"}"; + const rc = db_execute_vql(slot, vql, vql.len, &out_buf, out_buf.len); + // Should return -6 (no URL stored β€” sqlite slot has url_len == 0) + try std.testing.expectEqual(@as(i32, -6), rc); + + // URL check occurs before state transition, so state remains connected. + try std.testing.expectEqual(@as(c_int, @intFromEnum(ConnState.connected)), db_state(slot_i32)); + try std.testing.expectEqual(@as(c_int, 0), db_disconnect(slot_i32)); +} + +// ═══════════════════════════════════════════════════════════════════════ +// ADR-0006 invoke dispatch tests +// ═══════════════════════════════════════════════════════════════════════ + +test "invoke: each declared tool succeeds" { + var buf: [256]u8 = undefined; + const tools = [_][]const u8{ + "database_connect", + "database_query", + "database_execute", + "database_list_tables", + "database_describe", + "database_disconnect", + }; + for (tools) |t| { + var len: usize = buf.len; + const rc = boj_cartridge_invoke(t.ptr, "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, 0), rc); + try std.testing.expect(std.mem.indexOf(u8, buf[0..len], "result") != null); + } +} + +test "invoke: unknown tool returns -1" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("nope", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -1), rc); +} + +test "invoke: buffer too small returns -3" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("database_connect", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -3), rc); + try std.testing.expect(len > 4); +} diff --git a/cartridges/domains/database/database-mcp/mod.js b/cartridges/domains/database/database-mcp/mod.js new file mode 100644 index 0000000..1c00215 --- /dev/null +++ b/cartridges/domains/database/database-mcp/mod.js @@ -0,0 +1,70 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// database-mcp/mod.js -- database gateway. + +const BASE_URL = Deno.env.get("DATABASE_MCP_BACKEND_URL") ?? "http://127.0.0.1:7718"; + +async function post(path, payload) { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), 15000); + try { + const r = await fetch(`${BASE_URL}${path}`, { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), signal: ctrl.signal, + }); + const data = await r.json().catch(() => ({ success: false, error: "non-JSON response" })); + return { status: r.status, data }; + } catch (e) { + if (e.name === "AbortError") return { status: 504, data: { success: false, error: "database-mcp backend timed out" } }; + return { status: 503, data: { success: false, error: `database-mcp backend unavailable: ${e.message}` } }; + } finally { clearTimeout(t); } +} + +export async function handleTool(toolName, args) { + switch (toolName) { + + case "database_connect": { + const { backend, connection_string } = args ?? {}; + if (!backend || !connection_string) return { status: 400, data: { error: "backend is required" } }; + const payload = { backend, connection_string }; + return post("/api/v1/connect", payload); + } + case "database_query": { + const { slot, query, params } = args ?? {}; + if (!slot || !query) return { status: 400, data: { error: "slot is required" } }; + const payload = { slot, query }; + if (params !== undefined) payload.params = params; + return post("/api/v1/query", payload); + } + case "database_execute": { + const { slot, statement, params } = args ?? {}; + if (!slot || !statement) return { status: 400, data: { error: "slot is required" } }; + const payload = { slot, statement }; + if (params !== undefined) payload.params = params; + return post("/api/v1/execute", payload); + } + case "database_list_tables": { + const { slot, schema } = args ?? {}; + if (!slot) return { status: 400, data: { error: "slot is required" } }; + const payload = { slot }; + if (schema !== undefined) payload.schema = schema; + return post("/api/v1/list-tables", payload); + } + case "database_describe": { + const { slot, table } = args ?? {}; + if (!slot || !table) return { status: 400, data: { error: "slot is required" } }; + const payload = { slot, table }; + return post("/api/v1/describe", payload); + } + case "database_disconnect": { + const { slot } = args ?? {}; + if (!slot) return { status: 400, data: { error: "slot is required" } }; + const payload = { slot }; + return post("/api/v1/disconnect", payload); + } + + default: + return { status: 404, data: { error: `Unknown tool: ${toolName}` } }; + } +} diff --git a/cartridges/domains/database/database-mcp/panels/manifest.json b/cartridges/domains/database/database-mcp/panels/manifest.json new file mode 100644 index 0000000..e1a3cec --- /dev/null +++ b/cartridges/domains/database/database-mcp/panels/manifest.json @@ -0,0 +1,67 @@ +{ + "$schema": "https://panll.dev/schemas/panel-manifest/v1.json", + "spdx": "MPL-2.0", + "cartridge": "database-mcp", + "domain": "Database Operations", + "version": "0.1.0", + "panels": [ + { + "id": "db-status", + "title": "Database Gateway Status", + "description": "Connection pool health and active database connections", + "type": "status-indicator", + "data_source": { + "endpoint": "/cartridge/database-mcp/invoke", + "method": "POST", + "body": { "tool": "status" }, + "refresh_interval_ms": 5000 + }, + "widgets": [ + { + "type": "state-badge", + "field": "state", + "states": { + "ready": { "color": "#2ecc71", "icon": "database" }, + "degraded": { "color": "#f39c12", "icon": "alert-circle" }, + "error": { "color": "#e74c3c", "icon": "alert-triangle" } + } + }, + { "type": "text", "field": "version", "label": "Version" } + ] + }, + { + "id": "db-connections", + "title": "Active Connections", + "description": "Database connection pool metrics and query throughput", + "type": "metric", + "data_source": { + "endpoint": "/cartridge/database-mcp/invoke", + "method": "POST", + "body": { "tool": "pool_stats" }, + "refresh_interval_ms": 10000 + }, + "widgets": [ + { "type": "counter", "field": "active_connections", "label": "Active", "icon": "link" }, + { "type": "counter", "field": "idle_connections", "label": "Idle", "icon": "pause" }, + { "type": "counter", "field": "queries_per_second", "label": "QPS", "icon": "zap" } + ] + }, + { + "id": "db-verisimdb", + "title": "VeriSimDB Backing Store", + "description": "Proof-carrying data store health and octad counts", + "type": "metric", + "data_source": { + "endpoint": "/cartridge/database-mcp/invoke", + "method": "POST", + "body": { "tool": "verisimdb_stats" }, + "refresh_interval_ms": 15000 + }, + "widgets": [ + { "type": "counter", "field": "octad_count", "label": "Octads", "icon": "box" }, + { "type": "counter", "field": "proof_count", "label": "Proofs", "icon": "shield" }, + { "type": "counter", "field": "store_size_mb", "label": "Size (MB)", "icon": "hard-drive" } + ] + } + ] +} diff --git a/cartridges/domains/database/database-mcp/panels/vcl-ut.json b/cartridges/domains/database/database-mcp/panels/vcl-ut.json new file mode 100644 index 0000000..cba0614 --- /dev/null +++ b/cartridges/domains/database/database-mcp/panels/vcl-ut.json @@ -0,0 +1,277 @@ +{ + "$schema": "panll-harness/v1", + "service_id": "vql-ut", + "service_name": "VQL-UT Verified Query Language", + "cartridge": "vql-ut-cartridge", + "domain": "database-tooling", + "version": "0.1.0", + "protocol": "http", + "default_endpoint": "http://localhost:7810/api/v1", + "health_check": { + "path": "/health", + "interval_ms": 30000, + "timeout_ms": 5000, + "healthy_threshold": 1, + "unhealthy_threshold": 3 + }, + "data_sources": { + "vql-ut://levels/status": { + "path": "/levels/status", + "method": "GET", + "returns": "[LevelStatus]", + "description": "Status of all 10 safety levels for the current query (pass/fail/skipped)" + }, + "vql-ut://levels/{id}": { + "path": "/levels/{id}", + "method": "GET", + "returns": "LevelDetail", + "description": "Detailed status and diagnostics for a single safety level (1-10)" + }, + "vql-ut://query/ast": { + "path": "/query/ast", + "method": "GET", + "returns": "TypedAST", + "description": "The current query's typed AST after parsing and binding" + }, + "vql-ut://query/certificate": { + "path": "/query/certificate", + "method": "GET", + "returns": "ProofCertificate", + "description": "The proof certificate for the most recently checked query" + }, + "vql-ut://obligations/pending": { + "path": "/obligations/pending", + "method": "GET", + "returns": "[ProofObligation]", + "description": "List of proof obligations that have not yet been discharged" + }, + "vql-ut://obligations/discharged": { + "path": "/obligations/discharged", + "method": "GET", + "returns": "[ProofObligation]", + "description": "List of proof obligations that have been successfully discharged" + }, + "vql-ut://schema/current": { + "path": "/schema/current", + "method": "GET", + "returns": "SchemaDefinition", + "description": "The current schema definition against which queries are validated" + }, + "vql-ut://typecheck/live": { + "path": "/typecheck/live", + "method": "POST", + "returns": "TypeCheckResult", + "description": "Live type-checking result for an in-progress query (debounced)" + }, + "vql-ut://horror-stories/{level}": { + "path": "/horror-stories/{level}", + "method": "GET", + "returns": "HorrorStory", + "description": "Interactive explanation of what safety level N prevents, with examples" + } + }, + "panels": [ + { + "id": "vql-ut-safety-dashboard", + "title": "VQL-UT Safety Dashboard", + "description": "Shows the 10 safety levels as a vertical pipeline. Each level is coloured green (pass), amber (checking), or red (fail) based on the current query's status. Clicking a level shows detailed diagnostics.", + "type": "pipeline-status", + "data_source": "vql-ut://levels/status", + "widgets": [ + { + "id": "level-pipeline", + "type": "vertical-pipeline", + "title": "Safety Levels", + "data_source": "vql-ut://levels/status", + "config": { + "levels": 10, + "labels": [ + "L1: Construction Safety", + "L2: Schema Pinning", + "L3: Resource Linearity", + "L4: Session Protocols", + "L5: Effect Tracking", + "L6: Scope Isolation", + "L7: Information Flow", + "L8: Quantitative Bounds", + "L9: Proof Attachment", + "L10: Cross-Cutting" + ], + "colours": { + "pass": "#22c55e", + "fail": "#ef4444", + "checking": "#f59e0b", + "skipped": "#6b7280" + } + } + }, + { + "id": "level-detail", + "type": "detail-panel", + "title": "Level Detail", + "data_source": "vql-ut://levels/{id}", + "config": { + "trigger": "click-on-level-pipeline", + "shows": ["error_message", "source_location", "fix_suggestion", "idris2_module"] + } + }, + { + "id": "certificate-summary", + "type": "badge", + "title": "Certificate", + "data_source": "vql-ut://query/certificate", + "config": { + "format": "{passed}/{total} levels | {certificate_hash}", + "click_action": "open-proof-explorer" + } + } + ] + }, + { + "id": "vql-ut-query-editor", + "title": "VQL-UT Query Editor", + "description": "Code editor with VQL-UT syntax highlighting, live type-checking feedback from the Idris2 kernel via TypeLL, and inline error annotations at each safety level.", + "type": "code-editor", + "data_source": "vql-ut://typecheck/live", + "widgets": [ + { + "id": "editor", + "type": "code-editor", + "title": "Query", + "data_source": "vql-ut://typecheck/live", + "config": { + "language": "vql-ut", + "theme": "hyperpolymath-dark", + "live_check": true, + "debounce_ms": 300, + "features": [ + "syntax-highlighting", + "inline-errors", + "level-annotations", + "parameter-type-hints", + "schema-autocomplete" + ] + } + }, + { + "id": "type-info", + "type": "sidebar", + "title": "Type Information", + "data_source": "vql-ut://query/ast", + "config": { + "trigger": "cursor-position", + "shows": ["inferred_type", "safety_level", "proof_obligations"] + } + }, + { + "id": "schema-browser", + "type": "tree-view", + "title": "Schema", + "data_source": "vql-ut://schema/current", + "config": { + "expandable": true, + "shows": ["tables", "columns", "column_types", "schema_version"] + } + } + ] + }, + { + "id": "vql-ut-proof-explorer", + "title": "VQL-UT Proof Explorer", + "description": "Shows proof obligations generated by the type checker and their discharge status. Each obligation links to the Idris2 module that produced it and the evidence that discharged it.", + "type": "obligation-tracker", + "data_source": "vql-ut://obligations/pending", + "widgets": [ + { + "id": "pending-obligations", + "type": "list", + "title": "Pending Obligations", + "data_source": "vql-ut://obligations/pending", + "config": { + "columns": ["obligation_name", "safety_level", "source_module", "created_at"], + "empty_message": "All obligations discharged", + "sort_by": "safety_level" + } + }, + { + "id": "discharged-obligations", + "type": "list", + "title": "Discharged", + "data_source": "vql-ut://obligations/discharged", + "config": { + "columns": ["obligation_name", "safety_level", "discharged_by", "evidence_type"], + "sort_by": "safety_level" + } + }, + { + "id": "certificate-viewer", + "type": "json-viewer", + "title": "Proof Certificate", + "data_source": "vql-ut://query/certificate", + "config": { + "collapsible": true, + "highlight_paths": ["levels", "obligations_discharged", "signature"] + } + } + ] + }, + { + "id": "vql-ut-horror-stories", + "title": "VQL-UT Horror Stories", + "description": "Interactive panel showing what each safety level prevents, with real-world examples of the bugs, vulnerabilities, and data corruption that occur without these checks. Each story links to the corresponding level's documentation.", + "type": "educational", + "data_source": "vql-ut://horror-stories/{level}", + "widgets": [ + { + "id": "level-selector", + "type": "tab-bar", + "title": "Safety Level", + "config": { + "tabs": [ + { "id": 1, "label": "L1: Injection" }, + { "id": 2, "label": "L2: Schema Drift" }, + { "id": 3, "label": "L3: Connection Leaks" }, + { "id": 4, "label": "L4: Protocol Bugs" }, + { "id": 5, "label": "L5: Effect Violations" }, + { "id": 6, "label": "L6: Scope Escapes" }, + { "id": 7, "label": "L7: Data Leaks" }, + { "id": 8, "label": "L8: Resource Abuse" }, + { "id": 9, "label": "L9: Unverified Claims" }, + { "id": 10, "label": "L10: Composition Bugs" } + ] + } + }, + { + "id": "story-content", + "type": "rich-text", + "title": "What Goes Wrong", + "data_source": "vql-ut://horror-stories/{level}", + "config": { + "sections": ["scenario", "vulnerability", "real_world_example", "how_vqlut_prevents_it"], + "includes_code_samples": true, + "link_to_docs": "docs/architecture/ARCHITECTURE.adoc#level-detail" + } + }, + { + "id": "try-it", + "type": "interactive-demo", + "title": "Try Breaking It", + "config": { + "description": "Edit the query to trigger the safety level failure. See the error in real time.", + "pre_filled_queries": true, + "links_to": "vql-ut-query-editor" + } + } + ] + } + ], + "capabilities": [ + "TypeCheck", + "SafetyAnalysis", + "ProofGeneration", + "QueryCompilation", + "EffectTracking", + "LiveFeedback" + ], + "clade": "database-tooling/verified-queries" +} diff --git a/cartridges/domains/database/duckdb-mcp/README.adoc b/cartridges/domains/database/duckdb-mcp/README.adoc new file mode 100644 index 0000000..f4d1d76 --- /dev/null +++ b/cartridges/domains/database/duckdb-mcp/README.adoc @@ -0,0 +1,66 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> + += duckdb-mcp +Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk> +:spdx: MPL-2.0 +:tier: Ayo +:domain: Database +:protocols: MCP, Embedded + +== Overview + +DuckDB embedded analytics MCP cartridge. Provides type-safe access to DuckDB's +in-process OLAP database engine. No external server required β€” DuckDB runs +embedded in the BoJ process. Supports SQL queries, Parquet/CSV import and +export, database attach/detach, and result streaming. + +=== Actions + +CreateDatabase, AttachDatabase, DetachDatabase, Query, ExportParquet, +ExportCSV, ImportParquet, ImportCSV, DescribeTable, ListTables, +GetSchema, Explain, CreateView, DropView, CopyTo, LoadExtension. + +== Architecture + +[cols="1,1,2"] +|=== +| Layer | Language | Purpose + +| ABI +| Idris2 +| Formally verified state machine (Closed / Open / QueryRunning / Exporting / Error) + +| FFI +| Zig +| C-compatible implementation with thread-safe session pool + +| Adapter +| zig +| Embedded bridge to BoJ unified adapter protocol +|=== + +== Building + +[source,bash] +---- +# Build FFI shared library +cd ffi && zig build + +# Run FFI tests +cd ffi && zig build test + +# Type-check ABI +cd abi && idris2 --check DuckdbMcp.SafeDatabase +---- + +== Panels + +* Active database count +* Tables in current database +* Query count +* Export count (Parquet/CSV) + +== Status + +Development -- not yet ready for mounting. diff --git a/cartridges/domains/database/duckdb-mcp/abi/DuckdbMcp/SafeDatabase.idr b/cartridges/domains/database/duckdb-mcp/abi/DuckdbMcp/SafeDatabase.idr new file mode 100644 index 0000000..4352189 --- /dev/null +++ b/cartridges/domains/database/duckdb-mcp/abi/DuckdbMcp/SafeDatabase.idr @@ -0,0 +1,169 @@ +-- SPDX-License-Identifier: MPL-2.0 +-- Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +-- +-- DuckdbMcp.SafeDatabase β€” Type-safe ABI for the duckdb-mcp cartridge. +-- +-- Provides a formally verified state machine for DuckDB embedded analytics. +-- Dependent-type proofs ensure only valid transitions can occur at the FFI +-- boundary. DuckDB runs in-process (no external server). Supports SQL +-- queries, Parquet/CSV import and export, database attach/detach. + +module DuckdbMcp.SafeDatabase + +%default total + +-- --------------------------------------------------------------------------- +-- Connection state machine +-- --------------------------------------------------------------------------- + +||| Connection state for DuckDB embedded database. +||| Closed: no database open. Open: database attached and ready for queries. +||| QueryRunning: SQL query in flight. Exporting: export operation in progress. +||| Error: recoverable error state. +public export +data ConnState = Closed | Open | QueryRunning | Exporting | Error + +||| Proof that a state transition is valid. +public export +data ValidTransition : ConnState -> ConnState -> Type where + OpenDb : ValidTransition Closed Open + StartQuery : ValidTransition Open QueryRunning + FinishQuery : ValidTransition QueryRunning Open + StartExport : ValidTransition Open Exporting + FinishExport : ValidTransition Exporting Open + CloseDb : ValidTransition Open Closed + QueryFail : ValidTransition QueryRunning Error + ExportFail : ValidTransition Exporting Error + ErrorRecover : ValidTransition Error Closed + +-- --------------------------------------------------------------------------- +-- C-ABI integer encoding +-- --------------------------------------------------------------------------- + +||| Encode connection state as C-compatible integer. +export +connStateToInt : ConnState -> Int +connStateToInt Closed = 0 +connStateToInt Open = 1 +connStateToInt QueryRunning = 2 +connStateToInt Exporting = 3 +connStateToInt Error = 4 + +||| Decode integer back to connection state. +export +intToConnState : Int -> Maybe ConnState +intToConnState 0 = Just Closed +intToConnState 1 = Just Open +intToConnState 2 = Just QueryRunning +intToConnState 3 = Just Exporting +intToConnState 4 = Just Error +intToConnState _ = Nothing + +||| Check if a state transition is valid (C-ABI export). +||| Returns 1 for valid, 0 for invalid. +export +duckdb_mcp_can_transition : Int -> Int -> Int +duckdb_mcp_can_transition from to = + case (intToConnState from, intToConnState to) of + (Just Closed, Just Open) => 1 + (Just Open, Just QueryRunning) => 1 + (Just QueryRunning, Just Open) => 1 + (Just Open, Just Exporting) => 1 + (Just Exporting, Just Open) => 1 + (Just Open, Just Closed) => 1 + (Just QueryRunning, Just Error) => 1 + (Just Exporting, Just Error) => 1 + (Just Error, Just Closed) => 1 + _ => 0 + +-- --------------------------------------------------------------------------- +-- DuckDB actions (embedded analytics surface) +-- --------------------------------------------------------------------------- + +||| Actions supported by the DuckDB MCP cartridge. +||| Covers database lifecycle, SQL queries, import/export, schema inspection. +public export +data DuckdbAction + = CreateDatabase + | AttachDatabase + | DetachDatabase + | Query + | ExportParquet + | ExportCSV + | ImportParquet + | ImportCSV + | DescribeTable + | ListTables + | GetSchema + | Explain + | CreateView + | DropView + | CopyTo + | LoadExtension + +||| Encode action as C-compatible integer. +export +duckdbActionToInt : DuckdbAction -> Int +duckdbActionToInt CreateDatabase = 0 +duckdbActionToInt AttachDatabase = 1 +duckdbActionToInt DetachDatabase = 2 +duckdbActionToInt Query = 3 +duckdbActionToInt ExportParquet = 4 +duckdbActionToInt ExportCSV = 5 +duckdbActionToInt ImportParquet = 6 +duckdbActionToInt ImportCSV = 7 +duckdbActionToInt DescribeTable = 8 +duckdbActionToInt ListTables = 9 +duckdbActionToInt GetSchema = 10 +duckdbActionToInt Explain = 11 +duckdbActionToInt CreateView = 12 +duckdbActionToInt DropView = 13 +duckdbActionToInt CopyTo = 14 +duckdbActionToInt LoadExtension = 15 + +||| Decode integer back to action. +export +intToDuckdbAction : Int -> Maybe DuckdbAction +intToDuckdbAction 0 = Just CreateDatabase +intToDuckdbAction 1 = Just AttachDatabase +intToDuckdbAction 2 = Just DetachDatabase +intToDuckdbAction 3 = Just Query +intToDuckdbAction 4 = Just ExportParquet +intToDuckdbAction 5 = Just ExportCSV +intToDuckdbAction 6 = Just ImportParquet +intToDuckdbAction 7 = Just ImportCSV +intToDuckdbAction 8 = Just DescribeTable +intToDuckdbAction 9 = Just ListTables +intToDuckdbAction 10 = Just GetSchema +intToDuckdbAction 11 = Just Explain +intToDuckdbAction 12 = Just CreateView +intToDuckdbAction 13 = Just DropView +intToDuckdbAction 14 = Just CopyTo +intToDuckdbAction 15 = Just LoadExtension +intToDuckdbAction _ = Nothing + +||| Check whether an action requires an open database. +export +actionRequiresOpen : DuckdbAction -> Bool +actionRequiresOpen CreateDatabase = False +actionRequiresOpen LoadExtension = False +actionRequiresOpen _ = True + +||| Total number of actions exposed by this cartridge. +export +actionCount : Nat +actionCount = 16 + +-- --------------------------------------------------------------------------- +-- Auth configuration +-- --------------------------------------------------------------------------- + +||| Authentication method for DuckDB embedded analytics. +||| No external auth required β€” DuckDB runs in-process. +public export +data DuckdbAuth = NoAuth + +||| Base URI for DuckDB embedded operations. +export +duckdbApiBase : String +duckdbApiBase = "embedded://duckdb" diff --git a/cartridges/domains/database/duckdb-mcp/abi/README.adoc b/cartridges/domains/database/duckdb-mcp/abi/README.adoc new file mode 100644 index 0000000..5adc83b --- /dev/null +++ b/cartridges/domains/database/duckdb-mcp/abi/README.adoc @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += duckdb-mcp / abi β€” Idris2 ABI layer +:orientation: shallow + +== Purpose + +Cartridge-local protocol types for `duckdb-mcp`. Defines the data, tool, and +transition vocabulary this cartridge speaks. Inherits safety lemmas and the +matrix-cell identifier scheme from the trunk ABI at `src/abi/`. + +== Files + +1 Idris2 module(s), ~169 lines total. Lead module: +`SafeDatabase.idr`. + +No `.ipkg` β€” consumed directly by wider BoJ callers; type-checks inside the +trunk `boj.ipkg`. + +== Invariants + +* `%default total` at every module top. +* Zero `believe_me`, `assert_total`, `postulate`, `Admitted`, or + `sorry` (the trunk holds the four declared `believe_me` sites; leaf + cartridges carry none). + +== Test/proof surface + +Thin module(s), no inline tests. Proof surface = type-check success under the +trunk `idris2 --check`. + +== Read-first + +. `SafeDatabase.idr` β€” cartridge-local data records and enums. +. Trunk `src/abi/Boj/Catalogue.idr` for how this cell participates in the + 2D matrix. diff --git a/cartridges/domains/database/duckdb-mcp/abi/duckdb_mcp.ipkg b/cartridges/domains/database/duckdb-mcp/abi/duckdb_mcp.ipkg new file mode 100644 index 0000000..87bea5f --- /dev/null +++ b/cartridges/domains/database/duckdb-mcp/abi/duckdb_mcp.ipkg @@ -0,0 +1,11 @@ +-- SPDX-License-Identifier: MPL-2.0 +-- Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +package duckdb_mcp + +version = "0.1.0" +authors = "Jonathan D.A. Jewell" +brief = "DuckDB embedded analytics MCP cartridge β€” type-safe ABI layer" + +depends = base + +modules = DuckdbMcp.SafeDatabase diff --git a/cartridges/domains/database/duckdb-mcp/adapter/README.adoc b/cartridges/domains/database/duckdb-mcp/adapter/README.adoc new file mode 100644 index 0000000..fcf66d3 --- /dev/null +++ b/cartridges/domains/database/duckdb-mcp/adapter/README.adoc @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += duckdb-mcp / adapter β€” protocol bridge +:orientation: shallow + +== Purpose + +Exposes this cartridge's FFI over the wire protocols declared in +`cartridge.json`. Dispatches tool invocations to the C-ABI exports at +`../ffi/` and formats responses per protocol. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” depends on the sibling `ffi` target. +| `duckdb_mcp_adapter.zig` | Protocol dispatch (112 lines). Tool catalogue matches `cartridge.json`. +| `SIDELINED-*.v.adoc` | zig predecessor(s). Not built β€” zig banned 2026-04-10. +|=== + +== Invariants + +* **Stateless** β€” all state lives behind the FFI, never in the adapter. +* **Response passthrough** β€” whatever the FFI returns goes back to the wire + unmodified (no embellishment, no silent recovery). +* **Cartridge.json is source of truth** for the tool catalogue; drift between + adapter and manifest is a CI failure. + +== Test/proof surface + +No inline tests at the adapter layer β€” protocol routing is exercised by the +trunk seam tests at `ffi/zig/src/seams.zig`. + +== Read-first + +. `duckdb_mcp_adapter.zig` β€” dispatch function and per-protocol routers. +. `../cartridge.json` β€” the tool manifest the dispatcher mirrors. diff --git a/cartridges/domains/database/duckdb-mcp/adapter/build.zig b/cartridges/domains/database/duckdb-mcp/adapter/build.zig new file mode 100644 index 0000000..cea86ce --- /dev/null +++ b/cartridges/domains/database/duckdb-mcp/adapter/build.zig @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// duckdb-mcp/adapter/build.zig + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); const optimize = b.standardOptimizeOption(.{}); + const ffi_mod = b.createModule(.{ .root_source_file = b.path("../ffi/duckdb_mcp_ffi.zig"), .target = target, .optimize = optimize }); + const adapter = b.addExecutable(.{ .name = "duckdb_mcp_adapter", .root_source_file = b.path("duckdb_mcp_adapter.zig"), .target = target, .optimize = optimize }); + adapter.root_module.addImport("duckdb_mcp_ffi", ffi_mod); b.installArtifact(adapter); + const rs = b.step("run", "Run duckdb-mcp adapter"); rs.dependOn(&b.addRunArtifact(adapter).step); + const tests = b.addTest(.{ .root_source_file = b.path("duckdb_mcp_adapter.zig"), .target = target, .optimize = optimize }); + tests.root_module.addImport("duckdb_mcp_ffi", ffi_mod); + const ts = b.step("test", "Test duckdb-mcp adapter"); ts.dependOn(&b.addRunArtifact(tests).step); +} diff --git a/cartridges/domains/database/duckdb-mcp/adapter/duckdb_mcp_adapter.zig b/cartridges/domains/database/duckdb-mcp/adapter/duckdb_mcp_adapter.zig new file mode 100644 index 0000000..d613ee0 --- /dev/null +++ b/cartridges/domains/database/duckdb-mcp/adapter/duckdb_mcp_adapter.zig @@ -0,0 +1,112 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// duckdb-mcp/adapter/duckdb_mcp_adapter.zig -- Unified three-protocol adapter. +// Replaces banned duckdb_mcp_adapter.v (zig, removed 2026-04-12). +// REST:9169 gRPC:9170 GraphQL:9171 +// Tools: duckdb_open, duckdb_query, duckdb_execute, duckdb_import... + +const std = @import("std"); +const ffi = @import("duckdb_mcp_ffi"); + +const REST_PORT: u16 = 9169; +const GRPC_PORT: u16 = 9170; +const GQL_PORT: u16 = 9171; +const MAX_CONN_BUF: usize = 16 * 1024; + +fn okJson(buf: []u8, msg: []const u8) []u8 { + const n = std.fmt.bufPrint(buf, "{\"success\":true,\"message\":\"{s}\"}", .{msg}) catch return buf[0..0]; return n; +} +fn errJson(buf: []u8, msg: []const u8) []u8 { + const n = std.fmt.bufPrint(buf, "{\"success\":false,\"error\":\"{s}\"}", .{msg}) catch return buf[0..0]; return n; +} +fn statusJson(buf: []u8) []u8 { + const n = std.fmt.bufPrint(buf, "{\"success\":true,\"state\":\"ready\",\"service\":\"duckdb-mcp\"}", .{}) catch return buf[0..0]; return n; +} + +const Response = struct { status: u16, body: []u8 }; + +fn dispatch(tool: []const u8, body: []const u8, resp: []u8) Response { + _ = body; + if (std.mem.eql(u8, tool, "duckdb_open")) return .{ .status = 200, .body = okJson(resp, "duckdb_open forwarded") }; + if (std.mem.eql(u8, tool, "duckdb_query")) return .{ .status = 200, .body = okJson(resp, "duckdb_query forwarded") }; + if (std.mem.eql(u8, tool, "duckdb_execute")) return .{ .status = 200, .body = okJson(resp, "duckdb_execute forwarded") }; + if (std.mem.eql(u8, tool, "duckdb_import")) return .{ .status = 200, .body = okJson(resp, "duckdb_import forwarded") }; + if (std.mem.eql(u8, tool, "duckdb_export")) return .{ .status = 200, .body = okJson(resp, "duckdb_export forwarded") }; + if (std.mem.eql(u8, tool, "duckdb_list_tables")) return .{ .status = 200, .body = okJson(resp, "duckdb_list_tables forwarded") }; + if (std.mem.eql(u8, tool, "duckdb_close")) return .{ .status = 200, .body = okJson(resp, "duckdb_close forwarded") }; + if (std.mem.eql(u8, tool, "status") or std.mem.eql(u8, tool, "health")) return .{ .status = 200, .body = statusJson(resp) }; + return .{ .status = 404, .body = errJson(resp, "Unknown tool") }; +} + +fn dispatchRest(path: []const u8, body: []const u8, resp: []u8) Response { + if (std.mem.startsWith(u8, path, "/tools/")) return dispatch(path["/tools/".len..], body, resp); + if (std.mem.eql(u8, path, "/status") or std.mem.eql(u8, path, "/health")) return .{ .status = 200, .body = statusJson(resp) }; + return .{ .status = 404, .body = errJson(resp, "Not found") }; +} + +fn dispatchGrpc(path: []const u8, body: []const u8, resp: []u8) Response { + const prefix = "/DuckdbMcpservice/"; + if (!std.mem.startsWith(u8, path, prefix)) return .{ .status = 404, .body = errJson(resp, "Not a recognized gRPC path") }; + const method = path[prefix.len..]; + const tool = blk: { + if (std.mem.eql(u8, method, "duckdb_open")) break :blk "duckdb_open"; + if (std.mem.eql(u8, method, "duckdb_query")) break :blk "duckdb_query"; + if (std.mem.eql(u8, method, "duckdb_execute")) break :blk "duckdb_execute"; + if (std.mem.eql(u8, method, "duckdb_import")) break :blk "duckdb_import"; + if (std.mem.eql(u8, method, "duckdb_export")) break :blk "duckdb_export"; + if (std.mem.eql(u8, method, "duckdb_list_tables")) break :blk "duckdb_list_tables"; + if (std.mem.eql(u8, method, "duckdb_close")) break :blk "duckdb_close"; + return .{ .status = 404, .body = errJson(resp, "Unknown gRPC method") }; + }; + return dispatch(tool, body, resp); +} + +fn dispatchGraphql(body: []const u8, resp: []u8) Response { + if (std.mem.indexOf(u8, body, "__schema") != null) return .{ .status = 200, .body = okJson(resp, "schema not supported") }; + if (std.mem.indexOf(u8, body, "open") != null) return dispatch("duckdb_open", body, resp); + if (std.mem.indexOf(u8, body, "query") != null) return dispatch("duckdb_query", body, resp); + if (std.mem.indexOf(u8, body, "execute") != null) return dispatch("duckdb_execute", body, resp); + if (std.mem.indexOf(u8, body, "import") != null) return dispatch("duckdb_import", body, resp); + if (std.mem.indexOf(u8, body, "export") != null) return dispatch("duckdb_export", body, resp); + if (std.mem.indexOf(u8, body, "list_tables") != null) return dispatch("duckdb_list_tables", body, resp); + if (std.mem.indexOf(u8, body, "close") != null) return dispatch("duckdb_close", body, resp); + return .{ .status = 200, .body = errJson(resp, "Unrecognised GraphQL operation") }; +} + +const Protocol = enum { rest, grpc, graphql }; + +fn handleConnection(conn: std.net.Server.Connection, proto: Protocol) void { + defer conn.stream.close(); + var in_buf: [MAX_CONN_BUF]u8 = undefined; + const n = conn.stream.read(&in_buf) catch return; + const req = in_buf[0..n]; + var path: []const u8 = "/"; var body: []const u8 = ""; + if (n > 4) { + const le = std.mem.indexOf(u8, req, "\r\n") orelse req.len; + const fl = req[0..le]; const sp1 = std.mem.indexOfScalar(u8, fl, ' ') orelse 0; + const ro = fl[sp1+1..]; const sp2 = std.mem.indexOfScalar(u8, ro, ' ') orelse ro.len; + path = ro[0..sp2]; + const bs = std.mem.indexOf(u8, req, "\r\n\r\n") orelse n; body = req[@min(bs+4,n)..]; + } + var rb: [MAX_CONN_BUF]u8 = undefined; + const result = switch (proto) { .rest => dispatchRest(path, body, &rb), .grpc => dispatchGrpc(path, body, &rb), .graphql => dispatchGraphql(body, &rb) }; + const ct: []const u8 = if (proto == .grpc) "application/grpc+json" else "application/json"; + var hb: [256]u8 = undefined; + const hdr = std.fmt.bufPrint(&hb, "HTTP/1.1 {d} OK\r\nContent-Type: {s}\r\nContent-Length: {d}\r\nConnection: close\r\n\r\n", .{ result.status, ct, result.body.len }) catch return; + _ = conn.stream.writeAll(hdr) catch return; _ = conn.stream.writeAll(result.body) catch return; +} + +fn listenLoop(port: u16, proto: Protocol) void { + const addr = std.net.Address.initIp4(.{ 127, 0, 0, 1 }, port) catch return; + var srv = addr.listen(.{ .reuse_address = true }) catch return; defer srv.deinit(); + while (true) { const conn = srv.accept() catch continue; handleConnection(conn, proto); } +} + +pub fn main() !void { + ffi.duckdb_mcp_init(); + const t1 = try std.Thread.spawn(.{}, listenLoop, .{ REST_PORT, Protocol.rest }); + const t2 = try std.Thread.spawn(.{}, listenLoop, .{ GRPC_PORT, Protocol.grpc }); + const t3 = try std.Thread.spawn(.{}, listenLoop, .{ GQL_PORT, Protocol.graphql }); + t1.join(); t2.join(); t3.join(); +} diff --git a/cartridges/domains/database/duckdb-mcp/benchmarks/quick-bench.sh b/cartridges/domains/database/duckdb-mcp/benchmarks/quick-bench.sh new file mode 100755 index 0000000..fb596c5 --- /dev/null +++ b/cartridges/domains/database/duckdb-mcp/benchmarks/quick-bench.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Benchmarks for duckdb-mcp cartridge. +set -euo pipefail + +echo "=== duckdb-mcp benchmarks ===" + +cd "$(dirname "${BASH_SOURCE[0]}")/../ffi" + +# Build with ReleaseFast for benchmarking +zig build -Doptimize=ReleaseFast 2>&1 + +echo "Session open/close cycle (1000 iterations):" +time for i in $(seq 1 1000); do + # This would call the FFI benchmark binary + true +done + +echo "" +echo "Benchmark placeholder β€” implement real benchmarks in Zig test blocks" +echo "or via the zig adapter benchmark tool." diff --git a/cartridges/domains/database/duckdb-mcp/cartridge.json b/cartridges/domains/database/duckdb-mcp/cartridge.json new file mode 100644 index 0000000..e46a56c --- /dev/null +++ b/cartridges/domains/database/duckdb-mcp/cartridge.json @@ -0,0 +1,178 @@ +{ + "$schema": "https://boj.dev/schemas/cartridge/v1.json", + "spdx": "MPL-2.0", + "copyright": "Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath)", + "name": "duckdb-mcp", + "version": "0.1.0", + "description": "DuckDB in-process analytics gateway. SQL queries over Parquet, CSV, JSON, and Arrow files with export support.", + "domain": "Database", + "tier": "Ayo", + "protocols": [ + "MCP", + "REST" + ], + "auth": { + "method": "none", + "env_var": null, + "credential_source": null + }, + "api": { + "base_url": "local://duckdb-mcp", + "content_type": "application/json" + }, + "tools": [ + { + "name": "duckdb_open", + "description": "Open a DuckDB database file. Returns a session slot.", + "inputSchema": { + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "Database file path (':memory:' for in-memory)" + } + }, + "required": [ + "path" + ] + } + }, + { + "name": "duckdb_query", + "description": "Execute a SQL query and return results as JSON.", + "inputSchema": { + "type": "object", + "properties": { + "slot": { + "type": "integer", + "description": "Session slot from duckdb_open" + }, + "sql": { + "type": "string", + "description": "SQL query" + } + }, + "required": [ + "slot", + "sql" + ] + } + }, + { + "name": "duckdb_execute", + "description": "Execute a non-returning SQL statement.", + "inputSchema": { + "type": "object", + "properties": { + "slot": { + "type": "integer", + "description": "Session slot" + }, + "sql": { + "type": "string", + "description": "SQL statement" + } + }, + "required": [ + "slot", + "sql" + ] + } + }, + { + "name": "duckdb_import", + "description": "Import data from a file into a table.", + "inputSchema": { + "type": "object", + "properties": { + "slot": { + "type": "integer", + "description": "Session slot" + }, + "file_path": { + "type": "string", + "description": "Path to Parquet, CSV, or JSON file" + }, + "table_name": { + "type": "string", + "description": "Target table name" + } + }, + "required": [ + "slot", + "file_path", + "table_name" + ] + } + }, + { + "name": "duckdb_export", + "description": "Export a query result or table to a file.", + "inputSchema": { + "type": "object", + "properties": { + "slot": { + "type": "integer", + "description": "Session slot" + }, + "query": { + "type": "string", + "description": "SELECT query or table name" + }, + "output_path": { + "type": "string", + "description": "Output file path (.parquet, .csv, or .json)" + } + }, + "required": [ + "slot", + "query", + "output_path" + ] + } + }, + { + "name": "duckdb_list_tables", + "description": "List tables in the open database.", + "inputSchema": { + "type": "object", + "properties": { + "slot": { + "type": "integer", + "description": "Session slot" + } + }, + "required": [ + "slot" + ] + } + }, + { + "name": "duckdb_close", + "description": "Close the database and release the session slot.", + "inputSchema": { + "type": "object", + "properties": { + "slot": { + "type": "integer", + "description": "Session slot to close" + } + }, + "required": [ + "slot" + ] + } + } + ], + "ffi": { + "so_path": "ffi/zig-out/lib/libduckdb_mcp.so", + "abi_version": "ADR-0006", + "symbols": [ + "boj_cartridge_init", + "boj_cartridge_deinit", + "boj_cartridge_name", + "boj_cartridge_version", + "boj_cartridge_invoke" + ] + } +} diff --git a/cartridges/domains/database/duckdb-mcp/ffi/README.adoc b/cartridges/domains/database/duckdb-mcp/ffi/README.adoc new file mode 100644 index 0000000..2c7333d --- /dev/null +++ b/cartridges/domains/database/duckdb-mcp/ffi/README.adoc @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += duckdb-mcp / ffi β€” Zig FFI layer +:orientation: shallow + +== Purpose + +C-ABI exports bridging this cartridge's Idris2 ABI to the runtime. Consumed by +the adapter for protocol dispatch and by the trunk loader for mount. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” produces `zig-out/lib/libduckdb.so`. +| `duckdb_mcp_ffi.zig` | C-ABI exports (13 exports, 7 inline tests, 385 lines). +|=== + +== Invariants + +* **Null-pointer rejection** on `[*c]const u8` arguments before any + dereference. +* **Negative return values** carry documented error meanings; zero is + success (matching the trunk error-code convention). +* **No allocator leaks** β€” every allocation path pairs with `defer free`. + +== Test/proof surface + +7 inline `test "..."` block(s) in `duckdb_mcp_ffi.zig`. Run with +`cd ffi && zig build test`. + +== Read-first + +. `duckdb_mcp_ffi.zig` β€” C-ABI signatures and guards. +. Trunk `ffi/zig/src/catalogue.zig` for how this `.so` is loaded. diff --git a/cartridges/domains/database/duckdb-mcp/ffi/build.zig b/cartridges/domains/database/duckdb-mcp/ffi/build.zig new file mode 100644 index 0000000..bd1ab44 --- /dev/null +++ b/cartridges/domains/database/duckdb-mcp/ffi/build.zig @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + // Module + // Shared ADR-0006 invoke-shim module (relative path up to boj-server trunk). + + const shim_mod = b.addModule("cartridge_shim", .{ + + .root_source_file = b.path("../../../ffi/zig/src/cartridge_shim.zig"), + + .target = target, + + .optimize = optimize, + + }); + + const ffi_mod = b.addModule("duckdb_mcp", .{ + .root_source_file = b.path("duckdb_mcp_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + ffi_mod.addImport("cartridge_shim", shim_mod); + + // Shared library + const lib = b.addLibrary(.{ + .name = "duckdb_mcp", + .root_module = ffi_mod, + .linkage = .dynamic, + }); + lib.linkLibC(); + b.installArtifact(lib); + + // Tests + const tests = b.addTest(.{ + .root_module = ffi_mod, + }); + tests.linkLibC(); + + const run_tests = b.addRunArtifact(tests); + const test_step = b.step("test", "Run FFI tests"); + test_step.dependOn(&run_tests.step); +} diff --git a/cartridges/domains/database/duckdb-mcp/ffi/cartridge_shim.zig b/cartridges/domains/database/duckdb-mcp/ffi/cartridge_shim.zig new file mode 100644 index 0000000..0e399db --- /dev/null +++ b/cartridges/domains/database/duckdb-mcp/ffi/cartridge_shim.zig @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// cartridge_shim.zig β€” Shared helpers for the ADR-0006 five-symbol +// cartridge ABI (`boj_cartridge_init / deinit / name / version / invoke`). +// +// The shim centralises the seven-code return convention, NUL-argument +// guards, tool-name comparison, and the buffer-too-small path so each +// cartridge's `boj_cartridge_invoke` can stay short β€” typically a tool +// table plus `shim.writeResult(...)`. +// +// Cartridges import this file by relative path (no build-graph change +// needed). Example: +// +// const shim = @import("../../../ffi/zig/src/cartridge_shim.zig"); +// +// export fn boj_cartridge_invoke( +// tool_name: [*c]const u8, +// json_args: [*c]const u8, +// out_buf: [*c]u8, +// in_out_len: [*c]usize, +// ) callconv(.c) i32 { +// _ = json_args; +// if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; +// const body = if (shim.toolIs(tool_name, "foo")) "{\"result\":{}}" +// else return shim.RC_UNKNOWN_TOOL; +// return shim.writeResult(out_buf, in_out_len, body); +// } + +const std = @import("std"); + +// ── Return codes (ADR-0006 Β§Return codes) ──────────────────────────── +// +// Frozen by ADR-0006. New failure modes compose these via the error +// JSON body β€” the integer surface does not grow without a follow-up ADR. + +pub const RC_SUCCESS: i32 = 0; +pub const RC_UNKNOWN_TOOL: i32 = -1; +pub const RC_BAD_ARGS: i32 = -2; +pub const RC_BUFFER_TOO_SMALL: i32 = -3; +pub const RC_RUNTIME_ERROR: i32 = -4; +pub const RC_PANIC: i32 = -5; +pub const RC_AUTH_DENIED: i32 = -6; + +// ── Invoke-path helpers ────────────────────────────────────────────── + +/// True if any of the three mandatory `boj_cartridge_invoke` output-path +/// pointers is null. Use at the top of every invoke to short-circuit to +/// `RC_BAD_ARGS`. +pub fn invokeArgsNull( + tool_name: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) bool { + return tool_name == null or out_buf == null or in_out_len == null; +} + +/// Compare a C-NUL-terminated tool-name pointer against a Zig string +/// literal. Caller must have already verified `tool_name` is non-null +/// (usually via `invokeArgsNull`). +pub fn toolIs(tool_name: [*c]const u8, expected: []const u8) bool { + const s = std.mem.span(@as([*:0]const u8, @ptrCast(tool_name))); + return std.mem.eql(u8, s, expected); +} + +/// Copy `body` into `out_buf[0..*in_out_len]` (as a capacity) and update +/// `*in_out_len` to the number of bytes written. Returns `RC_SUCCESS`. +/// +/// If `body.len` exceeds the current capacity stored in `*in_out_len`, +/// sets `*in_out_len` to the required size and returns +/// `RC_BUFFER_TOO_SMALL` β€” the caller is then expected to re-allocate +/// and retry, per ADR-0006 Β§Memory ownership. +/// +/// Caller must have already verified that `out_buf` and `in_out_len` +/// are non-null. +pub fn writeResult( + out_buf: [*c]u8, + in_out_len: [*c]usize, + body: []const u8, +) i32 { + const cap = in_out_len.*; + if (body.len > cap) { + in_out_len.* = body.len; + return RC_BUFFER_TOO_SMALL; + } + @memcpy(out_buf[0..body.len], body); + in_out_len.* = body.len; + return RC_SUCCESS; +} + +// ── Tests ──────────────────────────────────────────────────────────── + +test "writeResult: body fits, writes and sets length" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); + try std.testing.expectEqualStrings("hello", buf[0..len]); +} + +test "writeResult: too small returns -3 and sets required length" { + var buf: [2]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_BUFFER_TOO_SMALL, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: exact-fit succeeds" { + var buf: [5]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: empty body" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, ""); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 0), len); +} + +test "toolIs: matches and rejects" { + const name: [*:0]const u8 = "foo"; + try std.testing.expect(toolIs(@ptrCast(name), "foo")); + try std.testing.expect(!toolIs(@ptrCast(name), "bar")); + try std.testing.expect(!toolIs(@ptrCast(name), "foobar")); + try std.testing.expect(!toolIs(@ptrCast(name), "fo")); +} + +test "invokeArgsNull: detects each null slot" { + var buf: [4]u8 = undefined; + var len: usize = 4; + const name: [*:0]const u8 = "x"; + try std.testing.expect(!invokeArgsNull(@ptrCast(name), &buf, &len)); + try std.testing.expect(invokeArgsNull(null, &buf, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), null, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), &buf, null)); +} diff --git a/cartridges/domains/database/duckdb-mcp/ffi/duckdb_mcp_ffi.zig b/cartridges/domains/database/duckdb-mcp/ffi/duckdb_mcp_ffi.zig new file mode 100644 index 0000000..0befcd4 --- /dev/null +++ b/cartridges/domains/database/duckdb-mcp/ffi/duckdb_mcp_ffi.zig @@ -0,0 +1,485 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// duckdb_mcp_ffi.zig β€” C-ABI FFI implementation for the duckdb-mcp cartridge. +// +// Implements the connection state machine defined in the Idris2 ABI layer +// (DuckdbMcp.SafeDatabase). Thread-safe via std.Thread.Mutex. No heap +// allocations for results. State machine: Closed | Open | QueryRunning | +// Exporting | Error. DuckDB runs in-process β€” no external server. + +const std = @import("std"); + +// --------------------------------------------------------------------------- +// Connection state machine (matches Idris2 ABI exactly) +// --------------------------------------------------------------------------- + +/// Connection states for DuckDB embedded analytics. +pub const ConnState = enum(c_int) { + closed = 0, + open = 1, + query_running = 2, + exporting = 3, + err = 4, +}; + +/// DuckDB embedded analytics actions. +pub const DuckdbAction = enum(c_int) { + create_database = 0, + attach_database = 1, + detach_database = 2, + query = 3, + export_parquet = 4, + export_csv = 5, + import_parquet = 6, + import_csv = 7, + describe_table = 8, + list_tables = 9, + get_schema = 10, + explain = 11, + create_view = 12, + drop_view = 13, + copy_to = 14, + load_extension = 15, +}; + +/// Check whether a state transition is valid per the ABI specification. +fn isValidTransition(from: ConnState, to: ConnState) bool { + return switch (from) { + .closed => to == .open, + .open => to == .query_running or to == .exporting or to == .closed, + .query_running => to == .open or to == .err, + .exporting => to == .open or to == .err, + .err => to == .closed, + }; +} + +// --------------------------------------------------------------------------- +// Session slots (thread-safe, fixed-size pool) +// --------------------------------------------------------------------------- + +const MAX_SESSIONS: usize = 16; +const BUF_SIZE: usize = 4096; + +const SessionSlot = struct { + in_use: bool = false, + state: ConnState = .closed, + context_buf: [BUF_SIZE]u8 = .{0} ** BUF_SIZE, + context_len: usize = 0, + db_path_buf: [256]u8 = .{0} ** 256, + db_path_len: usize = 0, + table_count: u32 = 0, + query_count: u32 = 0, + export_count: u32 = 0, +}; + +var sessions: [MAX_SESSIONS]SessionSlot = .{SessionSlot{}} ** MAX_SESSIONS; +var mutex: std.Thread.Mutex = .{}; + +// --------------------------------------------------------------------------- +// C-ABI exports +// --------------------------------------------------------------------------- + +/// Check if a state transition is valid. Returns 1 (valid) or 0 (invalid). +pub export fn duckdb_mcp_can_transition(from: c_int, to: c_int) c_int { + const f = std.meta.intToEnum(ConnState, from) catch return 0; + const t = std.meta.intToEnum(ConnState, to) catch return 0; + return if (isValidTransition(f, t)) 1 else 0; +} + +/// Open a new database session. Returns slot index (>= 0) or -1 if no slots. +pub export fn duckdb_mcp_session_open() c_int { + mutex.lock(); + defer mutex.unlock(); + + for (&sessions, 0..) |*slot, idx| { + if (!slot.in_use) { + slot.in_use = true; + slot.state = .open; + slot.context_len = 0; + slot.db_path_len = 0; + slot.table_count = 0; + slot.query_count = 0; + slot.export_count = 0; + return @intCast(idx); + } + } + return -1; +} + +/// Close a database session. Returns 0 on success, -1 if invalid slot, -2 if bad transition. +pub export fn duckdb_mcp_session_close(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.in_use) return -1; + if (!isValidTransition(slot.state, .closed)) return -2; + + slot.in_use = false; + slot.state = .closed; + slot.context_len = 0; + slot.db_path_len = 0; + return 0; +} + +/// Get the current state of a session. Returns state int or -1 if invalid slot. +pub export fn duckdb_mcp_session_state(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.in_use) return -1; + return @intFromEnum(slot.state); +} + +/// Begin a query. Returns 0 on success, -1 if invalid slot, -2 if bad transition. +pub export fn duckdb_mcp_begin_query(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.in_use) return -1; + if (!isValidTransition(slot.state, .query_running)) return -2; + + slot.state = .query_running; + return 0; +} + +/// End a query (return to open). Returns 0 on success. +pub export fn duckdb_mcp_end_query(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.in_use) return -1; + if (!isValidTransition(slot.state, .open)) return -2; + + slot.state = .open; + slot.query_count += 1; + return 0; +} + +/// Begin an export operation (Parquet/CSV). Returns 0 on success. +pub export fn duckdb_mcp_begin_export(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.in_use) return -1; + if (!isValidTransition(slot.state, .exporting)) return -2; + + slot.state = .exporting; + return 0; +} + +/// End an export operation (return to open). Returns 0 on success. +pub export fn duckdb_mcp_end_export(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.in_use) return -1; + if (!isValidTransition(slot.state, .open)) return -2; + + slot.state = .open; + slot.export_count += 1; + return 0; +} + +/// Signal an error on a running session. Returns 0 on success. +pub export fn duckdb_mcp_signal_error(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.in_use) return -1; + if (!isValidTransition(slot.state, .err)) return -2; + + slot.state = .err; + return 0; +} + +/// Get the query count for a session. Returns count or -1 if invalid slot. +pub export fn duckdb_mcp_query_count(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.in_use) return -1; + return @intCast(slot.query_count); +} + +/// Get the export count for a session. Returns count or -1 if invalid slot. +pub export fn duckdb_mcp_export_count(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.in_use) return -1; + return @intCast(slot.export_count); +} + +/// Check if an action requires an open database. Returns 1 (yes) or 0 (no). +pub export fn duckdb_mcp_action_requires_open(action: c_int) c_int { + const a = std.meta.intToEnum(DuckdbAction, action) catch return 0; + return switch (a) { + .create_database, .load_extension => 0, + else => 1, + }; +} + +/// Reset all sessions (test/debug use only). +pub export fn duckdb_mcp_reset() void { + mutex.lock(); + defer mutex.unlock(); + sessions = .{SessionSlot{}} ** MAX_SESSIONS; +} + +// --------------------------------------------------------------------------- +// ═══════════════════════════════════════════════════════════════════════ +// Standard ABI (ADR-0005 four symbols + ADR-0006 invoke) +// ═══════════════════════════════════════════════════════════════════════ + +const shim = @import("cartridge_shim.zig"); + +const CARTRIDGE_NAME_PTR: [*:0]const u8 = "duckdb-mcp"; +const CARTRIDGE_VERSION_PTR: [*:0]const u8 = "0.1.0"; + +export fn boj_cartridge_init() callconv(.c) c_int { + return 0; +} + +export fn boj_cartridge_deinit() callconv(.c) void {} + +export fn boj_cartridge_name() callconv(.c) [*:0]const u8 { + return CARTRIDGE_NAME_PTR; +} + +export fn boj_cartridge_version() callconv(.c) [*:0]const u8 { + return CARTRIDGE_VERSION_PTR; +} + +/// Dispatch the cartridge.json MCP tools. Grade D Alpha stubs. +export fn boj_cartridge_invoke( + tool_name: [*c]const u8, + json_args: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) callconv(.c) i32 { + _ = json_args; + if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; + + const body: []const u8 = if (shim.toolIs(tool_name, "duckdb_open")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "duckdb_query")) + "{\"result\":{\"matches\":[],\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "duckdb_execute")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "duckdb_import")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "duckdb_export")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "duckdb_list_tables")) + "{\"result\":{\"items\":[],\"count\":0,\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "duckdb_close")) + "{\"result\":{\"status\":\"stub\"}}" +else + return shim.RC_UNKNOWN_TOOL; + + return shim.writeResult(out_buf, in_out_len, body); +} + +// Tests +// --------------------------------------------------------------------------- + +test "session lifecycle" { + duckdb_mcp_reset(); + + const slot = duckdb_mcp_session_open(); + try std.testing.expect(slot >= 0); + + // Should be in open state + try std.testing.expectEqual(@as(c_int, 1), duckdb_mcp_session_state(slot)); + + // Begin query + try std.testing.expectEqual(@as(c_int, 0), duckdb_mcp_begin_query(slot)); + try std.testing.expectEqual(@as(c_int, 2), duckdb_mcp_session_state(slot)); + + // End query + try std.testing.expectEqual(@as(c_int, 0), duckdb_mcp_end_query(slot)); + try std.testing.expectEqual(@as(c_int, 1), duckdb_mcp_session_state(slot)); + + // Query count should be 1 + try std.testing.expectEqual(@as(c_int, 1), duckdb_mcp_query_count(slot)); + + // Close + try std.testing.expectEqual(@as(c_int, 0), duckdb_mcp_session_close(slot)); +} + +test "export lifecycle" { + duckdb_mcp_reset(); + + const slot = duckdb_mcp_session_open(); + try std.testing.expect(slot >= 0); + + // Begin export + try std.testing.expectEqual(@as(c_int, 0), duckdb_mcp_begin_export(slot)); + try std.testing.expectEqual(@as(c_int, 3), duckdb_mcp_session_state(slot)); + + // End export + try std.testing.expectEqual(@as(c_int, 0), duckdb_mcp_end_export(slot)); + try std.testing.expectEqual(@as(c_int, 1), duckdb_mcp_session_state(slot)); + + // Export count should be 1 + try std.testing.expectEqual(@as(c_int, 1), duckdb_mcp_export_count(slot)); + + // Close + try std.testing.expectEqual(@as(c_int, 0), duckdb_mcp_session_close(slot)); +} + +test "invalid transitions rejected" { + duckdb_mcp_reset(); + + const slot = duckdb_mcp_session_open(); + try std.testing.expect(slot >= 0); + + // Can not go open -> error (must go through query_running or exporting) + try std.testing.expectEqual(@as(c_int, -2), duckdb_mcp_signal_error(slot)); + + // Can not close while query running + try std.testing.expectEqual(@as(c_int, 0), duckdb_mcp_begin_query(slot)); + try std.testing.expectEqual(@as(c_int, -2), duckdb_mcp_session_close(slot)); + + // Can not export while query running + try std.testing.expectEqual(@as(c_int, -2), duckdb_mcp_begin_export(slot)); +} + +test "transition validator" { + // Valid transitions + try std.testing.expectEqual(@as(c_int, 1), duckdb_mcp_can_transition(0, 1)); // closed -> open + try std.testing.expectEqual(@as(c_int, 1), duckdb_mcp_can_transition(1, 2)); // open -> query_running + try std.testing.expectEqual(@as(c_int, 1), duckdb_mcp_can_transition(2, 1)); // query_running -> open + try std.testing.expectEqual(@as(c_int, 1), duckdb_mcp_can_transition(1, 3)); // open -> exporting + try std.testing.expectEqual(@as(c_int, 1), duckdb_mcp_can_transition(3, 1)); // exporting -> open + try std.testing.expectEqual(@as(c_int, 1), duckdb_mcp_can_transition(1, 0)); // open -> closed + try std.testing.expectEqual(@as(c_int, 1), duckdb_mcp_can_transition(2, 4)); // query_running -> error + try std.testing.expectEqual(@as(c_int, 1), duckdb_mcp_can_transition(3, 4)); // exporting -> error + try std.testing.expectEqual(@as(c_int, 1), duckdb_mcp_can_transition(4, 0)); // error -> closed + + // Invalid transitions + try std.testing.expectEqual(@as(c_int, 0), duckdb_mcp_can_transition(0, 2)); // closed -> query_running + try std.testing.expectEqual(@as(c_int, 0), duckdb_mcp_can_transition(1, 4)); // open -> error + try std.testing.expectEqual(@as(c_int, 0), duckdb_mcp_can_transition(4, 1)); // error -> open + try std.testing.expectEqual(@as(c_int, 0), duckdb_mcp_can_transition(2, 3)); // query_running -> exporting + try std.testing.expectEqual(@as(c_int, 0), duckdb_mcp_can_transition(99, 0)); // out of range +} + +test "slot exhaustion" { + duckdb_mcp_reset(); + + var slots: [MAX_SESSIONS]c_int = undefined; + for (&slots, 0..) |*s, i| { + _ = i; + s.* = duckdb_mcp_session_open(); + try std.testing.expect(s.* >= 0); + } + + // Next open should fail + try std.testing.expectEqual(@as(c_int, -1), duckdb_mcp_session_open()); + + // Free one and try again + try std.testing.expectEqual(@as(c_int, 0), duckdb_mcp_session_close(slots[0])); + const new_slot = duckdb_mcp_session_open(); + try std.testing.expect(new_slot >= 0); +} + +test "action requires open" { + try std.testing.expectEqual(@as(c_int, 1), duckdb_mcp_action_requires_open(3)); // query + try std.testing.expectEqual(@as(c_int, 1), duckdb_mcp_action_requires_open(4)); // export_parquet + try std.testing.expectEqual(@as(c_int, 1), duckdb_mcp_action_requires_open(5)); // export_csv + try std.testing.expectEqual(@as(c_int, 1), duckdb_mcp_action_requires_open(9)); // list_tables + try std.testing.expectEqual(@as(c_int, 0), duckdb_mcp_action_requires_open(0)); // create_database + try std.testing.expectEqual(@as(c_int, 0), duckdb_mcp_action_requires_open(15)); // load_extension + try std.testing.expectEqual(@as(c_int, 0), duckdb_mcp_action_requires_open(99)); // out of range +} + +test "error recovery" { + duckdb_mcp_reset(); + + const slot = duckdb_mcp_session_open(); + try std.testing.expect(slot >= 0); + + // Enter query, signal error + try std.testing.expectEqual(@as(c_int, 0), duckdb_mcp_begin_query(slot)); + try std.testing.expectEqual(@as(c_int, 0), duckdb_mcp_signal_error(slot)); + try std.testing.expectEqual(@as(c_int, 4), duckdb_mcp_session_state(slot)); + + // Recover by closing + try std.testing.expectEqual(@as(c_int, 0), duckdb_mcp_session_close(slot)); +} + +// ═══════════════════════════════════════════════════════════════════════ +// ADR-0006 invoke dispatch tests +// ═══════════════════════════════════════════════════════════════════════ + +test "boj_cartridge_name returns duckdb-mcp" { + const n = std.mem.span(boj_cartridge_name()); + try std.testing.expectEqualStrings("duckdb-mcp", n); +} + +test "boj_cartridge_init returns 0" { + try std.testing.expectEqual(@as(c_int, 0), boj_cartridge_init()); +} + +test "invoke: each declared tool succeeds" { + var buf: [256]u8 = undefined; + const tools = [_][]const u8{ + "duckdb_open", + "duckdb_query", + "duckdb_execute", + "duckdb_import", + "duckdb_export", + "duckdb_list_tables", + "duckdb_close", + }; + for (tools) |t| { + var len: usize = buf.len; + const rc = boj_cartridge_invoke(t.ptr, "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, 0), rc); + try std.testing.expect(std.mem.indexOf(u8, buf[0..len], "result") != null); + } +} + +test "invoke: unknown tool returns -1" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("nope", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -1), rc); +} + +test "invoke: buffer too small returns -3" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("duckdb_open", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -3), rc); + try std.testing.expect(len > 4); +} diff --git a/cartridges/domains/database/duckdb-mcp/minter.toml b/cartridges/domains/database/duckdb-mcp/minter.toml new file mode 100644 index 0000000..825cb9f --- /dev/null +++ b/cartridges/domains/database/duckdb-mcp/minter.toml @@ -0,0 +1,16 @@ +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> + +name = "duckdb-mcp" +description = "DuckDB embedded analytics MCP cartridge β€” query, export, attach, parquet/CSV" +version = "0.1.0" +domain = "Database" +protocols = [ + "MCP", + "Embedded", +] +tier = "Ayo" +backend = "universal" +generate_panel = true +auth = "none" +api_base = "embedded://duckdb" diff --git a/cartridges/domains/database/duckdb-mcp/mod.js b/cartridges/domains/database/duckdb-mcp/mod.js new file mode 100644 index 0000000..9d02717 --- /dev/null +++ b/cartridges/domains/database/duckdb-mcp/mod.js @@ -0,0 +1,73 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// duckdb-mcp/mod.js -- duckdb gateway. + +const BASE_URL = Deno.env.get("DUCKDB_MCP_BACKEND_URL") ?? "http://127.0.0.1:7723"; + +async function post(path, payload) { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), 15000); + try { + const r = await fetch(`${BASE_URL}${path}`, { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), signal: ctrl.signal, + }); + const data = await r.json().catch(() => ({ success: false, error: "non-JSON response" })); + return { status: r.status, data }; + } catch (e) { + if (e.name === "AbortError") return { status: 504, data: { success: false, error: "duckdb-mcp backend timed out" } }; + return { status: 503, data: { success: false, error: `duckdb-mcp backend unavailable: ${e.message}` } }; + } finally { clearTimeout(t); } +} + +export async function handleTool(toolName, args) { + switch (toolName) { + + case "duckdb_open": { + const { path } = args ?? {}; + if (!path) return { status: 400, data: { error: "path is required" } }; + const payload = { path }; + return post("/api/v1/open", payload); + } + case "duckdb_query": { + const { slot, sql } = args ?? {}; + if (!slot || !sql) return { status: 400, data: { error: "slot is required" } }; + const payload = { slot, sql }; + return post("/api/v1/query", payload); + } + case "duckdb_execute": { + const { slot, sql } = args ?? {}; + if (!slot || !sql) return { status: 400, data: { error: "slot is required" } }; + const payload = { slot, sql }; + return post("/api/v1/execute", payload); + } + case "duckdb_import": { + const { slot, file_path, table_name } = args ?? {}; + if (!slot || !file_path || !table_name) return { status: 400, data: { error: "slot is required" } }; + const payload = { slot, file_path, table_name }; + return post("/api/v1/import", payload); + } + case "duckdb_export": { + const { slot, query, output_path } = args ?? {}; + if (!slot || !query || !output_path) return { status: 400, data: { error: "slot is required" } }; + const payload = { slot, query, output_path }; + return post("/api/v1/export", payload); + } + case "duckdb_list_tables": { + const { slot } = args ?? {}; + if (!slot) return { status: 400, data: { error: "slot is required" } }; + const payload = { slot }; + return post("/api/v1/list-tables", payload); + } + case "duckdb_close": { + const { slot } = args ?? {}; + if (!slot) return { status: 400, data: { error: "slot is required" } }; + const payload = { slot }; + return post("/api/v1/close", payload); + } + + default: + return { status: 404, data: { error: `Unknown tool: ${toolName}` } }; + } +} diff --git a/cartridges/domains/database/duckdb-mcp/panels/manifest.json b/cartridges/domains/database/duckdb-mcp/panels/manifest.json new file mode 100644 index 0000000..097d10d --- /dev/null +++ b/cartridges/domains/database/duckdb-mcp/panels/manifest.json @@ -0,0 +1,85 @@ +{ + "$schema": "https://panll.dev/schemas/panel-manifest/v1.json", + "spdx": "MPL-2.0", + "cartridge": "duckdb-mcp", + "domain": "Database", + "version": "0.1.0", + "panels": [ + { + "id": "duckdb-active-databases", + "title": "Active Databases", + "description": "Number of DuckDB database sessions currently open", + "type": "metric", + "data_source": { + "endpoint": "/duckdb/sessions", + "method": "GET", + "refresh_interval_ms": 15000 + }, + "widgets": [ + { + "type": "counter", + "field": "active_session_count", + "label": "Databases", + "icon": "database" + } + ] + }, + { + "id": "duckdb-table-count", + "title": "Tables", + "description": "Total number of tables across all attached databases", + "type": "metric", + "data_source": { + "endpoint": "/duckdb/tables", + "method": "GET", + "refresh_interval_ms": 30000 + }, + "widgets": [ + { + "type": "counter", + "field": "table_count", + "label": "Tables", + "icon": "table" + } + ] + }, + { + "id": "duckdb-query-count", + "title": "Query Count", + "description": "Total number of SQL queries executed across all sessions", + "type": "metric", + "data_source": { + "endpoint": "/duckdb/metrics", + "method": "GET", + "refresh_interval_ms": 15000 + }, + "widgets": [ + { + "type": "counter", + "field": "total_query_count", + "label": "Queries", + "icon": "file-search" + } + ] + }, + { + "id": "duckdb-export-count", + "title": "Export Count", + "description": "Total number of Parquet/CSV export operations completed", + "type": "metric", + "data_source": { + "endpoint": "/duckdb/metrics", + "method": "GET", + "refresh_interval_ms": 30000 + }, + "widgets": [ + { + "type": "counter", + "field": "total_export_count", + "label": "Exports", + "icon": "download" + } + ] + } + ] +} diff --git a/cartridges/domains/database/duckdb-mcp/tests/integration_test.sh b/cartridges/domains/database/duckdb-mcp/tests/integration_test.sh new file mode 100755 index 0000000..8f1514f --- /dev/null +++ b/cartridges/domains/database/duckdb-mcp/tests/integration_test.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Integration tests for duckdb-mcp cartridge. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CART_DIR="$(dirname "$SCRIPT_DIR")" +FFI_DIR="$CART_DIR/ffi" + +echo "=== duckdb-mcp integration tests ===" + +# Build FFI +echo "[1/3] Building FFI..." +cd "$FFI_DIR" && zig build 2>&1 + +# Run Zig unit tests +echo "[2/3] Running FFI unit tests..." +cd "$FFI_DIR" && zig build test 2>&1 + +# Validate Idris2 ABI (if idris2 available) +echo "[3/3] Checking ABI..." +if command -v idris2 &>/dev/null; then + cd "$CART_DIR/abi" && idris2 --check DuckdbMcp.SafeDatabase 2>&1 + echo " ABI: OK" +else + echo " ABI: SKIPPED (idris2 not in PATH)" +fi + +echo "" +echo "All tests passed for duckdb-mcp!" diff --git a/cartridges/domains/database/mongodb-mcp/README.adoc b/cartridges/domains/database/mongodb-mcp/README.adoc new file mode 100644 index 0000000..338e8ee --- /dev/null +++ b/cartridges/domains/database/mongodb-mcp/README.adoc @@ -0,0 +1,77 @@ +// SPDX-License-Identifier: MPL-2.0 += mongodb-mcp +Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk> +:spdx: MPL-2.0 +:tier: Ayo +:domain: Database +:protocols: MCP, REST + +== Overview + +MongoDB database cartridge for BoJ Server. Provides 16 MCP actions covering +CRUD operations, aggregation pipelines, index management, and +collection/database introspection with BSON document handling. + +Credentials are sourced from vault-mcp via connection string +(`mongodb://user:pass@host:27017/db`). + +== State Machine + +* `Disconnected` -- no active connection +* `Connected` -- authenticated, ready for operations +* `InSession` -- inside an explicit client session (for transactions) +* `Error` -- must disconnect to recover + +Valid transitions: + + Disconnected -> Connected + Connected -> Disconnected | InSession | Error + InSession -> Connected | Error + Error -> Disconnected + +== Actions (16) + +Find, FindOne, InsertOne, InsertMany, UpdateOne, UpdateMany, DeleteOne, +DeleteMany, Aggregate, CountDocuments, CreateIndex, DropIndex, +ListCollections, CreateCollection, DropCollection, ListDatabases + +== Architecture + +[cols="1,1,2"] +|=== +| Layer | Language | Purpose + +| ABI +| Idris2 +| Dependently-typed state machine (`MongodbMcp.SafeDatabase`) + +| FFI +| Zig +| C-ABI implementation with libmongoc/libbson wire protocol stubs + +| Adapter +| zig +| REST/MCP bridge exposing all 16 actions + +| Panels +| JSON +| PanLL manifest for connection status, collection count, document count, operation metrics +|=== + +== Building + +[source,bash] +---- +# Build FFI shared library (links libmongoc, libbson) +cd ffi && zig build + +# Run FFI tests +cd ffi && zig build test + +# Type-check ABI +cd abi && idris2 --check mongodb_mcp.ipkg +---- + +== Status + +Development -- not yet ready for mounting. diff --git a/cartridges/domains/database/mongodb-mcp/abi/MongodbMcp/SafeDatabase.idr b/cartridges/domains/database/mongodb-mcp/abi/MongodbMcp/SafeDatabase.idr new file mode 100644 index 0000000..d70dc85 --- /dev/null +++ b/cartridges/domains/database/mongodb-mcp/abi/MongodbMcp/SafeDatabase.idr @@ -0,0 +1,231 @@ +-- SPDX-License-Identifier: MPL-2.0 +-- Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +-- +-- MongodbMcp.SafeDatabase -- Type-safe ABI for mongodb-mcp cartridge. +-- +-- Dependently-typed state machine modelling MongoDB connection lifecycle. +-- Transitions are proven valid at compile time. Credentials obtained from +-- vault-mcp via connection string (mongodb://user:pass@host:27017/db). +-- BSON document handling via the FFI layer. + +module MongodbMcp.SafeDatabase + +%default total + +-- --------------------------------------------------------------------------- +-- Connection state machine +-- --------------------------------------------------------------------------- + +||| MongoDB connection lifecycle states. +||| +||| @ Disconnected No active connection to the MongoDB server. +||| @ Connected Authenticated connection established; ready for operations. +||| @ InSession Inside an explicit client session (for transactions). +||| @ Error An error has occurred; must disconnect to recover. +public export +data ConnState + = Disconnected + | Connected + | InSession + | Error + +||| Proof that a state transition is valid within the MongoDB protocol. +||| +||| The transition graph: +||| Disconnected -> Connected (connect with auth) +||| Connected -> InSession (start session / begin transaction) +||| InSession -> Connected (commit / abort / end session) +||| Connected -> Error (connection or auth error) +||| InSession -> Error (session or transaction error) +||| Error -> Disconnected (disconnect after error) +||| Connected -> Disconnected (graceful disconnect) +public export +data ValidTransition : ConnState -> ConnState -> Type where + Connect : ValidTransition Disconnected Connected + Disconnect : ValidTransition Connected Disconnected + StartSession : ValidTransition Connected InSession + EndSession : ValidTransition InSession Connected + ConnError : ValidTransition Connected Error + SessionError : ValidTransition InSession Error + ErrorReset : ValidTransition Error Disconnected + +-- --------------------------------------------------------------------------- +-- C-ABI integer encoding +-- --------------------------------------------------------------------------- + +||| Encode connection state as C-compatible integer. +export +connStateToInt : ConnState -> Int +connStateToInt Disconnected = 0 +connStateToInt Connected = 1 +connStateToInt InSession = 2 +connStateToInt Error = 3 + +||| Decode integer back to connection state. +export +intToConnState : Int -> Maybe ConnState +intToConnState 0 = Just Disconnected +intToConnState 1 = Just Connected +intToConnState 2 = Just InSession +intToConnState 3 = Just Error +intToConnState _ = Nothing + +||| Check if a state transition is valid (C-ABI export). +||| Returns 1 for valid, 0 for invalid. +export +mongodb_mcp_can_transition : Int -> Int -> Int +mongodb_mcp_can_transition from to = + case (intToConnState from, intToConnState to) of + (Just Disconnected, Just Connected) => 1 + (Just Connected, Just Disconnected) => 1 + (Just Connected, Just InSession) => 1 + (Just InSession, Just Connected) => 1 + (Just Connected, Just Error) => 1 + (Just InSession, Just Error) => 1 + (Just Error, Just Disconnected) => 1 + _ => 0 + +-- --------------------------------------------------------------------------- +-- MongoDB actions +-- --------------------------------------------------------------------------- + +||| Actions exposed via the mongodb-mcp MCP protocol. +||| +||| All 16 operations supported by this cartridge, covering CRUD, +||| aggregation, index management, and collection/database introspection. +public export +data MongodbAction + = Find + | FindOne + | InsertOne + | InsertMany + | UpdateOne + | UpdateMany + | DeleteOne + | DeleteMany + | Aggregate + | CountDocuments + | CreateIndex + | DropIndex + | ListCollections + | CreateCollection + | DropCollection + | ListDatabases + +||| Encode action as C-compatible integer. +export +actionToInt : MongodbAction -> Int +actionToInt Find = 0 +actionToInt FindOne = 1 +actionToInt InsertOne = 2 +actionToInt InsertMany = 3 +actionToInt UpdateOne = 4 +actionToInt UpdateMany = 5 +actionToInt DeleteOne = 6 +actionToInt DeleteMany = 7 +actionToInt Aggregate = 8 +actionToInt CountDocuments = 9 +actionToInt CreateIndex = 10 +actionToInt DropIndex = 11 +actionToInt ListCollections = 12 +actionToInt CreateCollection = 13 +actionToInt DropCollection = 14 +actionToInt ListDatabases = 15 + +||| Decode integer back to action. +export +intToAction : Int -> Maybe MongodbAction +intToAction 0 = Just Find +intToAction 1 = Just FindOne +intToAction 2 = Just InsertOne +intToAction 3 = Just InsertMany +intToAction 4 = Just UpdateOne +intToAction 5 = Just UpdateMany +intToAction 6 = Just DeleteOne +intToAction 7 = Just DeleteMany +intToAction 8 = Just Aggregate +intToAction 9 = Just CountDocuments +intToAction 10 = Just CreateIndex +intToAction 11 = Just DropIndex +intToAction 12 = Just ListCollections +intToAction 13 = Just CreateCollection +intToAction 14 = Just DropCollection +intToAction 15 = Just ListDatabases +intToAction _ = Nothing + +||| Check whether an action requires an active connection. +export +actionRequiresConnection : MongodbAction -> Bool +actionRequiresConnection ListDatabases = False +actionRequiresConnection _ = True + +||| Check whether an action requires an active session (transaction context). +export +actionRequiresSession : MongodbAction -> Bool +actionRequiresSession _ = False + +||| Total number of actions in this cartridge. +export +actionCount : Nat +actionCount = 16 + +-- --------------------------------------------------------------------------- +-- Authentication +-- --------------------------------------------------------------------------- + +||| Authentication method for MongoDB connections. +||| Credentials are sourced from vault-mcp, never hardcoded. +public export +data AuthMethod + = ConnectionString + | VaultRef String + +-- --------------------------------------------------------------------------- +-- BSON types +-- --------------------------------------------------------------------------- + +||| BSON document field types used in wire protocol encoding. +public export +data BsonFieldType + = BsonDouble + | BsonString + | BsonDocument + | BsonArray + | BsonBinary + | BsonObjectId + | BsonBool + | BsonDateTime + | BsonNull + | BsonInt32 + | BsonInt64 + +||| Encode BSON field type as C-compatible integer. +export +bsonFieldTypeToInt : BsonFieldType -> Int +bsonFieldTypeToInt BsonDouble = 1 +bsonFieldTypeToInt BsonString = 2 +bsonFieldTypeToInt BsonDocument = 3 +bsonFieldTypeToInt BsonArray = 4 +bsonFieldTypeToInt BsonBinary = 5 +bsonFieldTypeToInt BsonObjectId = 7 +bsonFieldTypeToInt BsonBool = 8 +bsonFieldTypeToInt BsonDateTime = 9 +bsonFieldTypeToInt BsonNull = 10 +bsonFieldTypeToInt BsonInt32 = 16 +bsonFieldTypeToInt BsonInt64 = 18 + +||| Decode integer back to BSON field type. +export +intToBsonFieldType : Int -> Maybe BsonFieldType +intToBsonFieldType 1 = Just BsonDouble +intToBsonFieldType 2 = Just BsonString +intToBsonFieldType 3 = Just BsonDocument +intToBsonFieldType 4 = Just BsonArray +intToBsonFieldType 5 = Just BsonBinary +intToBsonFieldType 7 = Just BsonObjectId +intToBsonFieldType 8 = Just BsonBool +intToBsonFieldType 9 = Just BsonDateTime +intToBsonFieldType 10 = Just BsonNull +intToBsonFieldType 16 = Just BsonInt32 +intToBsonFieldType 18 = Just BsonInt64 +intToBsonFieldType _ = Nothing diff --git a/cartridges/domains/database/mongodb-mcp/abi/README.adoc b/cartridges/domains/database/mongodb-mcp/abi/README.adoc new file mode 100644 index 0000000..a41a07a --- /dev/null +++ b/cartridges/domains/database/mongodb-mcp/abi/README.adoc @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += mongodb-mcp / abi β€” Idris2 ABI layer +:orientation: shallow + +== Purpose + +Cartridge-local protocol types for `mongodb-mcp`. Defines the data, tool, and +transition vocabulary this cartridge speaks. Inherits safety lemmas and the +matrix-cell identifier scheme from the trunk ABI at `src/abi/`. + +== Files + +1 Idris2 module(s), ~231 lines total. Lead module: +`SafeDatabase.idr`. + +No `.ipkg` β€” consumed directly by wider BoJ callers; type-checks inside the +trunk `boj.ipkg`. + +== Invariants + +* `%default total` at every module top. +* Zero `believe_me`, `assert_total`, `postulate`, `Admitted`, or + `sorry` (the trunk holds the four declared `believe_me` sites; leaf + cartridges carry none). + +== Test/proof surface + +Thin module(s), no inline tests. Proof surface = type-check success under the +trunk `idris2 --check`. + +== Read-first + +. `SafeDatabase.idr` β€” cartridge-local data records and enums. +. Trunk `src/abi/Boj/Catalogue.idr` for how this cell participates in the + 2D matrix. diff --git a/cartridges/domains/database/mongodb-mcp/abi/mongodb_mcp.ipkg b/cartridges/domains/database/mongodb-mcp/abi/mongodb_mcp.ipkg new file mode 100644 index 0000000..3562ed3 --- /dev/null +++ b/cartridges/domains/database/mongodb-mcp/abi/mongodb_mcp.ipkg @@ -0,0 +1,11 @@ +-- SPDX-License-Identifier: MPL-2.0 +-- Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +package mongodb_mcp + +version = "0.1.0" +authors = "Jonathan D.A. Jewell" +brief = "Type-safe ABI for MongoDB MCP cartridge with dependently-typed state machine" + +depends = base + +modules = MongodbMcp.SafeDatabase diff --git a/cartridges/domains/database/mongodb-mcp/adapter/README.adoc b/cartridges/domains/database/mongodb-mcp/adapter/README.adoc new file mode 100644 index 0000000..4a2b82b --- /dev/null +++ b/cartridges/domains/database/mongodb-mcp/adapter/README.adoc @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += mongodb-mcp / adapter β€” protocol bridge +:orientation: shallow + +== Purpose + +Exposes this cartridge's FFI over the wire protocols declared in +`cartridge.json`. Dispatches tool invocations to the C-ABI exports at +`../ffi/` and formats responses per protocol. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” depends on the sibling `ffi` target. +| `mongodb_mcp_adapter.zig` | Protocol dispatch (115 lines). Tool catalogue matches `cartridge.json`. +| `SIDELINED-*.v.adoc` | zig predecessor(s). Not built β€” zig banned 2026-04-10. +|=== + +== Invariants + +* **Stateless** β€” all state lives behind the FFI, never in the adapter. +* **Response passthrough** β€” whatever the FFI returns goes back to the wire + unmodified (no embellishment, no silent recovery). +* **Cartridge.json is source of truth** for the tool catalogue; drift between + adapter and manifest is a CI failure. + +== Test/proof surface + +No inline tests at the adapter layer β€” protocol routing is exercised by the +trunk seam tests at `ffi/zig/src/seams.zig`. + +== Read-first + +. `mongodb_mcp_adapter.zig` β€” dispatch function and per-protocol routers. +. `../cartridge.json` β€” the tool manifest the dispatcher mirrors. diff --git a/cartridges/domains/database/mongodb-mcp/adapter/build.zig b/cartridges/domains/database/mongodb-mcp/adapter/build.zig new file mode 100644 index 0000000..4d16ba7 --- /dev/null +++ b/cartridges/domains/database/mongodb-mcp/adapter/build.zig @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// mongodb-mcp/adapter/build.zig + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); const optimize = b.standardOptimizeOption(.{}); + const ffi_mod = b.createModule(.{ .root_source_file = b.path("../ffi/mongodb_mcp_ffi.zig"), .target = target, .optimize = optimize }); + const adapter = b.addExecutable(.{ .name = "mongodb_mcp_adapter", .root_source_file = b.path("mongodb_mcp_adapter.zig"), .target = target, .optimize = optimize }); + adapter.root_module.addImport("mongodb_mcp_ffi", ffi_mod); b.installArtifact(adapter); + const rs = b.step("run", "Run mongodb-mcp adapter"); rs.dependOn(&b.addRunArtifact(adapter).step); + const tests = b.addTest(.{ .root_source_file = b.path("mongodb_mcp_adapter.zig"), .target = target, .optimize = optimize }); + tests.root_module.addImport("mongodb_mcp_ffi", ffi_mod); + const ts = b.step("test", "Test mongodb-mcp adapter"); ts.dependOn(&b.addRunArtifact(tests).step); +} diff --git a/cartridges/domains/database/mongodb-mcp/adapter/mongodb_mcp_adapter.zig b/cartridges/domains/database/mongodb-mcp/adapter/mongodb_mcp_adapter.zig new file mode 100644 index 0000000..94a40df --- /dev/null +++ b/cartridges/domains/database/mongodb-mcp/adapter/mongodb_mcp_adapter.zig @@ -0,0 +1,115 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// mongodb-mcp/adapter/mongodb_mcp_adapter.zig -- Unified three-protocol adapter. +// Replaces banned mongodb_mcp_adapter.v (zig, removed 2026-04-12). +// REST:9160 gRPC:9161 GraphQL:9162 +// Tools: mongo_connect, mongo_find, mongo_insert, mongo_update... + +const std = @import("std"); +const ffi = @import("mongodb_mcp_ffi"); + +const REST_PORT: u16 = 9160; +const GRPC_PORT: u16 = 9161; +const GQL_PORT: u16 = 9162; +const MAX_CONN_BUF: usize = 16 * 1024; + +fn okJson(buf: []u8, msg: []const u8) []u8 { + const n = std.fmt.bufPrint(buf, "{\"success\":true,\"message\":\"{s}\"}", .{msg}) catch return buf[0..0]; return n; +} +fn errJson(buf: []u8, msg: []const u8) []u8 { + const n = std.fmt.bufPrint(buf, "{\"success\":false,\"error\":\"{s}\"}", .{msg}) catch return buf[0..0]; return n; +} +fn statusJson(buf: []u8) []u8 { + const n = std.fmt.bufPrint(buf, "{\"success\":true,\"state\":\"ready\",\"service\":\"mongodb-mcp\"}", .{}) catch return buf[0..0]; return n; +} + +const Response = struct { status: u16, body: []u8 }; + +fn dispatch(tool: []const u8, body: []const u8, resp: []u8) Response { + _ = body; + if (std.mem.eql(u8, tool, "mongo_connect")) return .{ .status = 200, .body = okJson(resp, "mongo_connect forwarded") }; + if (std.mem.eql(u8, tool, "mongo_find")) return .{ .status = 200, .body = okJson(resp, "mongo_find forwarded") }; + if (std.mem.eql(u8, tool, "mongo_insert")) return .{ .status = 200, .body = okJson(resp, "mongo_insert forwarded") }; + if (std.mem.eql(u8, tool, "mongo_update")) return .{ .status = 200, .body = okJson(resp, "mongo_update forwarded") }; + if (std.mem.eql(u8, tool, "mongo_delete")) return .{ .status = 200, .body = okJson(resp, "mongo_delete forwarded") }; + if (std.mem.eql(u8, tool, "mongo_aggregate")) return .{ .status = 200, .body = okJson(resp, "mongo_aggregate forwarded") }; + if (std.mem.eql(u8, tool, "mongo_list_collections")) return .{ .status = 200, .body = okJson(resp, "mongo_list_collections forwarded") }; + if (std.mem.eql(u8, tool, "mongo_disconnect")) return .{ .status = 200, .body = okJson(resp, "mongo_disconnect forwarded") }; + if (std.mem.eql(u8, tool, "status") or std.mem.eql(u8, tool, "health")) return .{ .status = 200, .body = statusJson(resp) }; + return .{ .status = 404, .body = errJson(resp, "Unknown tool") }; +} + +fn dispatchRest(path: []const u8, body: []const u8, resp: []u8) Response { + if (std.mem.startsWith(u8, path, "/tools/")) return dispatch(path["/tools/".len..], body, resp); + if (std.mem.eql(u8, path, "/status") or std.mem.eql(u8, path, "/health")) return .{ .status = 200, .body = statusJson(resp) }; + return .{ .status = 404, .body = errJson(resp, "Not found") }; +} + +fn dispatchGrpc(path: []const u8, body: []const u8, resp: []u8) Response { + const prefix = "/MongodbMcpservice/"; + if (!std.mem.startsWith(u8, path, prefix)) return .{ .status = 404, .body = errJson(resp, "Not a recognized gRPC path") }; + const method = path[prefix.len..]; + const tool = blk: { + if (std.mem.eql(u8, method, "mongo_connect")) break :blk "mongo_connect"; + if (std.mem.eql(u8, method, "mongo_find")) break :blk "mongo_find"; + if (std.mem.eql(u8, method, "mongo_insert")) break :blk "mongo_insert"; + if (std.mem.eql(u8, method, "mongo_update")) break :blk "mongo_update"; + if (std.mem.eql(u8, method, "mongo_delete")) break :blk "mongo_delete"; + if (std.mem.eql(u8, method, "mongo_aggregate")) break :blk "mongo_aggregate"; + if (std.mem.eql(u8, method, "mongo_list_collections")) break :blk "mongo_list_collections"; + if (std.mem.eql(u8, method, "mongo_disconnect")) break :blk "mongo_disconnect"; + return .{ .status = 404, .body = errJson(resp, "Unknown gRPC method") }; + }; + return dispatch(tool, body, resp); +} + +fn dispatchGraphql(body: []const u8, resp: []u8) Response { + if (std.mem.indexOf(u8, body, "__schema") != null) return .{ .status = 200, .body = okJson(resp, "schema not supported") }; + if (std.mem.indexOf(u8, body, "connect") != null) return dispatch("mongo_connect", body, resp); + if (std.mem.indexOf(u8, body, "find") != null) return dispatch("mongo_find", body, resp); + if (std.mem.indexOf(u8, body, "insert") != null) return dispatch("mongo_insert", body, resp); + if (std.mem.indexOf(u8, body, "update") != null) return dispatch("mongo_update", body, resp); + if (std.mem.indexOf(u8, body, "delete") != null) return dispatch("mongo_delete", body, resp); + if (std.mem.indexOf(u8, body, "aggregate") != null) return dispatch("mongo_aggregate", body, resp); + if (std.mem.indexOf(u8, body, "list_collections") != null) return dispatch("mongo_list_collections", body, resp); + if (std.mem.indexOf(u8, body, "disconnect") != null) return dispatch("mongo_disconnect", body, resp); + return .{ .status = 200, .body = errJson(resp, "Unrecognised GraphQL operation") }; +} + +const Protocol = enum { rest, grpc, graphql }; + +fn handleConnection(conn: std.net.Server.Connection, proto: Protocol) void { + defer conn.stream.close(); + var in_buf: [MAX_CONN_BUF]u8 = undefined; + const n = conn.stream.read(&in_buf) catch return; + const req = in_buf[0..n]; + var path: []const u8 = "/"; var body: []const u8 = ""; + if (n > 4) { + const le = std.mem.indexOf(u8, req, "\r\n") orelse req.len; + const fl = req[0..le]; const sp1 = std.mem.indexOfScalar(u8, fl, ' ') orelse 0; + const ro = fl[sp1+1..]; const sp2 = std.mem.indexOfScalar(u8, ro, ' ') orelse ro.len; + path = ro[0..sp2]; + const bs = std.mem.indexOf(u8, req, "\r\n\r\n") orelse n; body = req[@min(bs+4,n)..]; + } + var rb: [MAX_CONN_BUF]u8 = undefined; + const result = switch (proto) { .rest => dispatchRest(path, body, &rb), .grpc => dispatchGrpc(path, body, &rb), .graphql => dispatchGraphql(body, &rb) }; + const ct: []const u8 = if (proto == .grpc) "application/grpc+json" else "application/json"; + var hb: [256]u8 = undefined; + const hdr = std.fmt.bufPrint(&hb, "HTTP/1.1 {d} OK\r\nContent-Type: {s}\r\nContent-Length: {d}\r\nConnection: close\r\n\r\n", .{ result.status, ct, result.body.len }) catch return; + _ = conn.stream.writeAll(hdr) catch return; _ = conn.stream.writeAll(result.body) catch return; +} + +fn listenLoop(port: u16, proto: Protocol) void { + const addr = std.net.Address.initIp4(.{ 127, 0, 0, 1 }, port) catch return; + var srv = addr.listen(.{ .reuse_address = true }) catch return; defer srv.deinit(); + while (true) { const conn = srv.accept() catch continue; handleConnection(conn, proto); } +} + +pub fn main() !void { + ffi.mongodb_mcp_init(); + const t1 = try std.Thread.spawn(.{}, listenLoop, .{ REST_PORT, Protocol.rest }); + const t2 = try std.Thread.spawn(.{}, listenLoop, .{ GRPC_PORT, Protocol.grpc }); + const t3 = try std.Thread.spawn(.{}, listenLoop, .{ GQL_PORT, Protocol.graphql }); + t1.join(); t2.join(); t3.join(); +} diff --git a/cartridges/domains/database/mongodb-mcp/benchmarks/quick-bench.sh b/cartridges/domains/database/mongodb-mcp/benchmarks/quick-bench.sh new file mode 100755 index 0000000..db4f04f --- /dev/null +++ b/cartridges/domains/database/mongodb-mcp/benchmarks/quick-bench.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Benchmarks for mongodb-mcp cartridge. +set -euo pipefail + +echo "=== mongodb-mcp benchmarks ===" + +cd "$(dirname "${BASH_SOURCE[0]}")/../ffi" + +# Build with ReleaseFast for benchmarking +zig build -Doptimize=ReleaseFast 2>&1 + +echo "Session open/close cycle (1000 iterations):" +time for i in $(seq 1 1000); do + # This would call the FFI benchmark binary + true +done + +echo "" +echo "Benchmark placeholder β€” implement real benchmarks in Zig test blocks" +echo "or via the zig adapter HTTP benchmark tool." diff --git a/cartridges/domains/database/mongodb-mcp/cartridge.json b/cartridges/domains/database/mongodb-mcp/cartridge.json new file mode 100644 index 0000000..05faca8 --- /dev/null +++ b/cartridges/domains/database/mongodb-mcp/cartridge.json @@ -0,0 +1,231 @@ +{ + "$schema": "https://boj.dev/schemas/cartridge/v1.json", + "spdx": "MPL-2.0", + "copyright": "Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath)", + "name": "mongodb-mcp", + "version": "0.1.0", + "description": "MongoDB gateway with collection-level CRUD, aggregation pipeline support, and session management.", + "domain": "Database", + "tier": "Ayo", + "protocols": [ + "MCP", + "REST" + ], + "auth": { + "method": "none", + "env_var": null, + "credential_source": null + }, + "api": { + "base_url": "local://mongodb-mcp", + "content_type": "application/json" + }, + "tools": [ + { + "name": "mongo_connect", + "description": "Connect to a MongoDB instance. Returns a session slot.", + "inputSchema": { + "type": "object", + "properties": { + "connection_string": { + "type": "string", + "description": "MongoDB connection string (mongodb://...)" + }, + "database": { + "type": "string", + "description": "Default database name" + } + }, + "required": [ + "connection_string", + "database" + ] + } + }, + { + "name": "mongo_find", + "description": "Find documents in a collection.", + "inputSchema": { + "type": "object", + "properties": { + "slot": { + "type": "integer", + "description": "Session slot from mongo_connect" + }, + "collection": { + "type": "string", + "description": "Collection name" + }, + "filter": { + "type": "string", + "description": "Filter as JSON object (default: {})" + }, + "limit": { + "type": "integer", + "description": "Maximum documents to return (default: 20)" + } + }, + "required": [ + "slot", + "collection" + ] + } + }, + { + "name": "mongo_insert", + "description": "Insert one or more documents.", + "inputSchema": { + "type": "object", + "properties": { + "slot": { + "type": "integer", + "description": "Session slot" + }, + "collection": { + "type": "string", + "description": "Collection name" + }, + "documents": { + "type": "string", + "description": "Document or array of documents as JSON" + } + }, + "required": [ + "slot", + "collection", + "documents" + ] + } + }, + { + "name": "mongo_update", + "description": "Update documents matching a filter.", + "inputSchema": { + "type": "object", + "properties": { + "slot": { + "type": "integer", + "description": "Session slot" + }, + "collection": { + "type": "string", + "description": "Collection name" + }, + "filter": { + "type": "string", + "description": "Filter as JSON object" + }, + "update": { + "type": "string", + "description": "Update operation as JSON" + }, + "multi": { + "type": "boolean", + "description": "Update all matching documents (default: false)" + } + }, + "required": [ + "slot", + "collection", + "filter", + "update" + ] + } + }, + { + "name": "mongo_delete", + "description": "Delete documents matching a filter.", + "inputSchema": { + "type": "object", + "properties": { + "slot": { + "type": "integer", + "description": "Session slot" + }, + "collection": { + "type": "string", + "description": "Collection name" + }, + "filter": { + "type": "string", + "description": "Filter as JSON object" + } + }, + "required": [ + "slot", + "collection", + "filter" + ] + } + }, + { + "name": "mongo_aggregate", + "description": "Run an aggregation pipeline.", + "inputSchema": { + "type": "object", + "properties": { + "slot": { + "type": "integer", + "description": "Session slot" + }, + "collection": { + "type": "string", + "description": "Collection name" + }, + "pipeline": { + "type": "string", + "description": "Aggregation pipeline as JSON array" + } + }, + "required": [ + "slot", + "collection", + "pipeline" + ] + } + }, + { + "name": "mongo_list_collections", + "description": "List collections in the current database.", + "inputSchema": { + "type": "object", + "properties": { + "slot": { + "type": "integer", + "description": "Session slot" + } + }, + "required": [ + "slot" + ] + } + }, + { + "name": "mongo_disconnect", + "description": "Disconnect and release a session slot.", + "inputSchema": { + "type": "object", + "properties": { + "slot": { + "type": "integer", + "description": "Session slot to release" + } + }, + "required": [ + "slot" + ] + } + } + ], + "ffi": { + "so_path": "ffi/zig-out/lib/libmongodb_mcp.so", + "abi_version": "ADR-0006", + "symbols": [ + "boj_cartridge_init", + "boj_cartridge_deinit", + "boj_cartridge_name", + "boj_cartridge_version", + "boj_cartridge_invoke" + ] + } +} diff --git a/cartridges/domains/database/mongodb-mcp/ffi/README.adoc b/cartridges/domains/database/mongodb-mcp/ffi/README.adoc new file mode 100644 index 0000000..c21ddde --- /dev/null +++ b/cartridges/domains/database/mongodb-mcp/ffi/README.adoc @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += mongodb-mcp / ffi β€” Zig FFI layer +:orientation: shallow + +== Purpose + +C-ABI exports bridging this cartridge's Idris2 ABI to the runtime. Consumed by +the adapter for protocol dispatch and by the trunk loader for mount. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” produces `zig-out/lib/libmongodb.so`. +| `mongodb_mcp_ffi.zig` | C-ABI exports (11 exports, 6 inline tests, 353 lines). +|=== + +== Invariants + +* **Null-pointer rejection** on `[*c]const u8` arguments before any + dereference. +* **Negative return values** carry documented error meanings; zero is + success (matching the trunk error-code convention). +* **No allocator leaks** β€” every allocation path pairs with `defer free`. + +== Test/proof surface + +6 inline `test "..."` block(s) in `mongodb_mcp_ffi.zig`. Run with +`cd ffi && zig build test`. + +== Read-first + +. `mongodb_mcp_ffi.zig` β€” C-ABI signatures and guards. +. Trunk `ffi/zig/src/catalogue.zig` for how this `.so` is loaded. diff --git a/cartridges/domains/database/mongodb-mcp/ffi/build.zig b/cartridges/domains/database/mongodb-mcp/ffi/build.zig new file mode 100644 index 0000000..0125b76 --- /dev/null +++ b/cartridges/domains/database/mongodb-mcp/ffi/build.zig @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// build.zig -- Build configuration for mongodb-mcp FFI shared library. +// +// Links against libmongoc and libbson for MongoDB wire protocol and BSON support. +// Produces libmongodb_mcp.so/.dylib/.dll for zig adapter consumption. + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + // Module + // Shared ADR-0006 invoke-shim module (relative path up to boj-server trunk). + + const shim_mod = b.addModule("cartridge_shim", .{ + + .root_source_file = b.path("../../../ffi/zig/src/cartridge_shim.zig"), + + .target = target, + + .optimize = optimize, + + }); + + const ffi_mod = b.addModule("mongodb_mcp", .{ + .root_source_file = b.path("mongodb_mcp_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + ffi_mod.addImport("cartridge_shim", shim_mod); + + // Shared library + const lib = b.addLibrary(.{ + .name = "mongodb_mcp", + .root_module = ffi_mod, + .linkage = .dynamic, + }); + lib.linkLibC(); + // NOTE: linkSystemLibrary("mongoc-1.0") and linkSystemLibrary("bson-1.0") + // removed β€” stub implementation does not actually call libmongoc/libbson yet. + // Will be re-enabled when real bindings are wired. + b.installArtifact(lib); + + // Tests + const tests = b.addTest(.{ + .root_module = ffi_mod, + }); + tests.linkLibC(); + + const run_tests = b.addRunArtifact(tests); + const test_step = b.step("test", "Run FFI tests"); + test_step.dependOn(&run_tests.step); +} diff --git a/cartridges/domains/database/mongodb-mcp/ffi/cartridge_shim.zig b/cartridges/domains/database/mongodb-mcp/ffi/cartridge_shim.zig new file mode 100644 index 0000000..0e399db --- /dev/null +++ b/cartridges/domains/database/mongodb-mcp/ffi/cartridge_shim.zig @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// cartridge_shim.zig β€” Shared helpers for the ADR-0006 five-symbol +// cartridge ABI (`boj_cartridge_init / deinit / name / version / invoke`). +// +// The shim centralises the seven-code return convention, NUL-argument +// guards, tool-name comparison, and the buffer-too-small path so each +// cartridge's `boj_cartridge_invoke` can stay short β€” typically a tool +// table plus `shim.writeResult(...)`. +// +// Cartridges import this file by relative path (no build-graph change +// needed). Example: +// +// const shim = @import("../../../ffi/zig/src/cartridge_shim.zig"); +// +// export fn boj_cartridge_invoke( +// tool_name: [*c]const u8, +// json_args: [*c]const u8, +// out_buf: [*c]u8, +// in_out_len: [*c]usize, +// ) callconv(.c) i32 { +// _ = json_args; +// if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; +// const body = if (shim.toolIs(tool_name, "foo")) "{\"result\":{}}" +// else return shim.RC_UNKNOWN_TOOL; +// return shim.writeResult(out_buf, in_out_len, body); +// } + +const std = @import("std"); + +// ── Return codes (ADR-0006 Β§Return codes) ──────────────────────────── +// +// Frozen by ADR-0006. New failure modes compose these via the error +// JSON body β€” the integer surface does not grow without a follow-up ADR. + +pub const RC_SUCCESS: i32 = 0; +pub const RC_UNKNOWN_TOOL: i32 = -1; +pub const RC_BAD_ARGS: i32 = -2; +pub const RC_BUFFER_TOO_SMALL: i32 = -3; +pub const RC_RUNTIME_ERROR: i32 = -4; +pub const RC_PANIC: i32 = -5; +pub const RC_AUTH_DENIED: i32 = -6; + +// ── Invoke-path helpers ────────────────────────────────────────────── + +/// True if any of the three mandatory `boj_cartridge_invoke` output-path +/// pointers is null. Use at the top of every invoke to short-circuit to +/// `RC_BAD_ARGS`. +pub fn invokeArgsNull( + tool_name: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) bool { + return tool_name == null or out_buf == null or in_out_len == null; +} + +/// Compare a C-NUL-terminated tool-name pointer against a Zig string +/// literal. Caller must have already verified `tool_name` is non-null +/// (usually via `invokeArgsNull`). +pub fn toolIs(tool_name: [*c]const u8, expected: []const u8) bool { + const s = std.mem.span(@as([*:0]const u8, @ptrCast(tool_name))); + return std.mem.eql(u8, s, expected); +} + +/// Copy `body` into `out_buf[0..*in_out_len]` (as a capacity) and update +/// `*in_out_len` to the number of bytes written. Returns `RC_SUCCESS`. +/// +/// If `body.len` exceeds the current capacity stored in `*in_out_len`, +/// sets `*in_out_len` to the required size and returns +/// `RC_BUFFER_TOO_SMALL` β€” the caller is then expected to re-allocate +/// and retry, per ADR-0006 Β§Memory ownership. +/// +/// Caller must have already verified that `out_buf` and `in_out_len` +/// are non-null. +pub fn writeResult( + out_buf: [*c]u8, + in_out_len: [*c]usize, + body: []const u8, +) i32 { + const cap = in_out_len.*; + if (body.len > cap) { + in_out_len.* = body.len; + return RC_BUFFER_TOO_SMALL; + } + @memcpy(out_buf[0..body.len], body); + in_out_len.* = body.len; + return RC_SUCCESS; +} + +// ── Tests ──────────────────────────────────────────────────────────── + +test "writeResult: body fits, writes and sets length" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); + try std.testing.expectEqualStrings("hello", buf[0..len]); +} + +test "writeResult: too small returns -3 and sets required length" { + var buf: [2]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_BUFFER_TOO_SMALL, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: exact-fit succeeds" { + var buf: [5]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: empty body" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, ""); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 0), len); +} + +test "toolIs: matches and rejects" { + const name: [*:0]const u8 = "foo"; + try std.testing.expect(toolIs(@ptrCast(name), "foo")); + try std.testing.expect(!toolIs(@ptrCast(name), "bar")); + try std.testing.expect(!toolIs(@ptrCast(name), "foobar")); + try std.testing.expect(!toolIs(@ptrCast(name), "fo")); +} + +test "invokeArgsNull: detects each null slot" { + var buf: [4]u8 = undefined; + var len: usize = 4; + const name: [*:0]const u8 = "x"; + try std.testing.expect(!invokeArgsNull(@ptrCast(name), &buf, &len)); + try std.testing.expect(invokeArgsNull(null, &buf, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), null, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), &buf, null)); +} diff --git a/cartridges/domains/database/mongodb-mcp/ffi/mongodb_mcp_ffi.zig b/cartridges/domains/database/mongodb-mcp/ffi/mongodb_mcp_ffi.zig new file mode 100644 index 0000000..da2db54 --- /dev/null +++ b/cartridges/domains/database/mongodb-mcp/ffi/mongodb_mcp_ffi.zig @@ -0,0 +1,477 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// mongodb_mcp_ffi.zig -- C-ABI FFI implementation for mongodb-mcp cartridge. +// +// Implements the state machine defined in MongodbMcp.SafeDatabase (Idris2 ABI). +// Thread-safe via std.Thread.Mutex. Wraps MongoDB wire protocol stubs with +// BSON document handling. Credentials via connection string from vault-mcp. +// No heap allocations for state management. + +const std = @import("std"); + +// --------------------------------------------------------------------------- +// Connection state machine (matches Idris2 ABI exactly) +// --------------------------------------------------------------------------- + +/// MongoDB connection lifecycle states. +/// Disconnected=0, Connected=1, InSession=2, Error=3 +pub const ConnState = enum(c_int) { + disconnected = 0, + connected = 1, + in_session = 2, + err = 3, +}; + +/// MongoDB actions matching the Idris2 MongodbAction type. +pub const MongodbAction = enum(c_int) { + find = 0, + find_one = 1, + insert_one = 2, + insert_many = 3, + update_one = 4, + update_many = 5, + delete_one = 6, + delete_many = 7, + aggregate = 8, + count_documents = 9, + create_index = 10, + drop_index = 11, + list_collections = 12, + create_collection = 13, + drop_collection = 14, + list_databases = 15, +}; + +/// BSON wire-protocol field type tags. Matches Idris2 +/// `MongodbMcp.SafeDatabase.BsonFieldType` + `bsonFieldTypeToInt`. +/// Integer codes follow the BSON spec β€” gaps (6, 11–15, 17) are by +/// design (reserved / deprecated BSON codes Idris2 does not expose). +/// Declared here so `iseriser abi-verify` can structurally check the +/// encoding against the Idris2 source; the wire-protocol encoder will +/// use these values when introduced. +pub const BsonFieldType = enum(c_int) { + bson_double = 1, + bson_string = 2, + bson_document = 3, + bson_array = 4, + bson_binary = 5, + bson_object_id = 7, + bson_bool = 8, + bson_date_time = 9, + bson_null = 10, + bson_int32 = 16, + bson_int64 = 18, +}; + +/// Validate a state transition against the proven Idris2 transition graph. +fn isValidTransition(from: ConnState, to: ConnState) bool { + return switch (from) { + .disconnected => to == .connected, + .connected => to == .disconnected or to == .in_session or to == .err, + .in_session => to == .connected or to == .err, + .err => to == .disconnected, + }; +} + +// --------------------------------------------------------------------------- +// Connection slots (thread-safe, fixed-size pool) +// --------------------------------------------------------------------------- + +const MAX_CONNECTIONS: usize = 16; +const CONNSTR_BUF_SIZE: usize = 1024; + +/// A single connection slot in the pool. +const ConnectionSlot = struct { + active: bool = false, + state: ConnState = .disconnected, + connstr_buf: [CONNSTR_BUF_SIZE]u8 = undefined, + connstr_len: usize = 0, + op_count: u64 = 0, + collection_count: u32 = 0, + document_count: u64 = 0, +}; + +var connections: [MAX_CONNECTIONS]ConnectionSlot = [_]ConnectionSlot{.{}} ** MAX_CONNECTIONS; +var mutex: std.Thread.Mutex = .{}; + +// --------------------------------------------------------------------------- +// MongoDB wire protocol stubs (linked at build time) +// --------------------------------------------------------------------------- + +/// Opaque MongoDB client handle. +const MongocClient = opaque {}; +/// Opaque MongoDB collection handle. +const MongocCollection = opaque {}; +/// Opaque BSON document. +const BsonT = opaque {}; + +extern fn mongoc_client_new(uri_string: [*:0]const u8) ?*MongocClient; +extern fn mongoc_client_destroy(client: *MongocClient) void; +extern fn mongoc_client_get_collection(client: *MongocClient, db: [*:0]const u8, collection: [*:0]const u8) ?*MongocCollection; +extern fn mongoc_collection_destroy(collection: *MongocCollection) void; +extern fn mongoc_collection_find_with_opts(collection: *MongocCollection, filter: *const BsonT, opts: ?*const BsonT, read_prefs: ?*anyopaque) ?*anyopaque; +extern fn bson_new() ?*BsonT; +extern fn bson_destroy(bson: *BsonT) void; + +// --------------------------------------------------------------------------- +// C-ABI exports +// --------------------------------------------------------------------------- + +/// Check if a state transition is valid. Returns 1 (valid) or 0 (invalid). +pub export fn mongodb_mcp_can_transition(from: c_int, to: c_int) c_int { + const f = std.meta.intToEnum(ConnState, from) catch return 0; + const t = std.meta.intToEnum(ConnState, to) catch return 0; + return if (isValidTransition(f, t)) 1 else 0; +} + +/// Connect to MongoDB. Returns slot index (>= 0) or -1 if pool full, -2 if bad args. +pub export fn mongodb_mcp_connect(connstr_ptr: [*]const u8, connstr_len: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const len: usize = std.math.cast(usize, connstr_len) orelse return -2; + if (len == 0 or len > CONNSTR_BUF_SIZE) return -2; + + for (&connections, 0..) |*slot, idx| { + if (!slot.active) { + @memcpy(slot.connstr_buf[0..len], connstr_ptr[0..len]); + slot.connstr_len = len; + slot.active = true; + slot.state = .connected; + slot.op_count = 0; + slot.collection_count = 0; + slot.document_count = 0; + return @intCast(idx); + } + } + return -1; +} + +/// Disconnect a connection slot. Returns 0 on success. +pub export fn mongodb_mcp_disconnect(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_CONNECTIONS) return -1; + var slot = &connections[idx]; + if (!slot.active) return -1; + if (!isValidTransition(slot.state, .disconnected)) return -2; + + slot.active = false; + slot.state = .disconnected; + slot.connstr_len = 0; + slot.op_count = 0; + slot.collection_count = 0; + slot.document_count = 0; + return 0; +} + +/// Get the current state of a connection. Returns state int or -1 if invalid. +pub export fn mongodb_mcp_connection_state(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_CONNECTIONS) return -1; + const slot = &connections[idx]; + if (!slot.active) return -1; + return @intFromEnum(slot.state); +} + +/// Start a client session. Returns 0 on success. +pub export fn mongodb_mcp_start_session(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_CONNECTIONS) return -1; + var slot = &connections[idx]; + if (!slot.active) return -1; + if (!isValidTransition(slot.state, .in_session)) return -2; + + slot.state = .in_session; + return 0; +} + +/// End a client session (commit/abort). Returns 0 on success. +pub export fn mongodb_mcp_end_session(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_CONNECTIONS) return -1; + var slot = &connections[idx]; + if (!slot.active) return -1; + if (!isValidTransition(slot.state, .connected)) return -2; + + slot.state = .connected; + return 0; +} + +/// Signal an error on a connection. Returns 0 on success. +pub export fn mongodb_mcp_signal_error(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_CONNECTIONS) return -1; + var slot = &connections[idx]; + if (!slot.active) return -1; + if (!isValidTransition(slot.state, .err)) return -2; + + slot.state = .err; + return 0; +} + +/// Record an operation (for metrics). Returns new count or -1. +pub export fn mongodb_mcp_record_operation(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_CONNECTIONS) return -1; + var slot = &connections[idx]; + if (!slot.active) return -1; + + slot.op_count += 1; + return @intCast(@min(slot.op_count, std.math.maxInt(c_int))); +} + +/// Get the operation count for a connection. +pub export fn mongodb_mcp_op_count(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_CONNECTIONS) return -1; + const slot = &connections[idx]; + if (!slot.active) return -1; + return @intCast(@min(slot.op_count, std.math.maxInt(c_int))); +} + +/// Get the number of active connections. +pub export fn mongodb_mcp_active_count() c_int { + mutex.lock(); + defer mutex.unlock(); + + var count: c_int = 0; + for (&connections) |*slot| { + if (slot.active) count += 1; + } + return count; +} + +/// Reset all connections (test/debug use only). +pub export fn mongodb_mcp_reset() void { + mutex.lock(); + defer mutex.unlock(); + connections = [_]ConnectionSlot{.{}} ** MAX_CONNECTIONS; +} + +// --------------------------------------------------------------------------- +// ═══════════════════════════════════════════════════════════════════════ +// Standard ABI (ADR-0005 four symbols + ADR-0006 invoke) +// ═══════════════════════════════════════════════════════════════════════ + +const shim = @import("cartridge_shim.zig"); + +const CARTRIDGE_NAME_PTR: [*:0]const u8 = "mongodb-mcp"; +const CARTRIDGE_VERSION_PTR: [*:0]const u8 = "0.1.0"; + +export fn boj_cartridge_init() callconv(.c) c_int { + return 0; +} + +export fn boj_cartridge_deinit() callconv(.c) void {} + +export fn boj_cartridge_name() callconv(.c) [*:0]const u8 { + return CARTRIDGE_NAME_PTR; +} + +export fn boj_cartridge_version() callconv(.c) [*:0]const u8 { + return CARTRIDGE_VERSION_PTR; +} + +/// Dispatch the cartridge.json MCP tools. Grade D Alpha stubs. +export fn boj_cartridge_invoke( + tool_name: [*c]const u8, + json_args: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) callconv(.c) i32 { + _ = json_args; + if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; + + const body: []const u8 = if (shim.toolIs(tool_name, "mongo_connect")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "mongo_find")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "mongo_insert")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "mongo_update")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "mongo_delete")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "mongo_aggregate")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "mongo_list_collections")) + "{\"result\":{\"items\":[],\"count\":0,\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "mongo_disconnect")) + "{\"result\":{\"status\":\"stub\"}}" +else + return shim.RC_UNKNOWN_TOOL; + + return shim.writeResult(out_buf, in_out_len, body); +} + +// Tests +// --------------------------------------------------------------------------- + +test "connection lifecycle" { + mongodb_mcp_reset(); + + const slot = mongodb_mcp_connect("mongodb://test:pw@localhost:27017/db", 37); + try std.testing.expect(slot >= 0); + + // Should be connected + try std.testing.expectEqual(@as(c_int, 1), mongodb_mcp_connection_state(slot)); + + // Start session + try std.testing.expectEqual(@as(c_int, 0), mongodb_mcp_start_session(slot)); + try std.testing.expectEqual(@as(c_int, 2), mongodb_mcp_connection_state(slot)); + + // End session + try std.testing.expectEqual(@as(c_int, 0), mongodb_mcp_end_session(slot)); + try std.testing.expectEqual(@as(c_int, 1), mongodb_mcp_connection_state(slot)); + + // Disconnect + try std.testing.expectEqual(@as(c_int, 0), mongodb_mcp_disconnect(slot)); +} + +test "error transitions" { + mongodb_mcp_reset(); + + const slot = mongodb_mcp_connect("mongodb://test:pw@localhost:27017/db", 37); + try std.testing.expect(slot >= 0); + + // Signal error from connected + try std.testing.expectEqual(@as(c_int, 0), mongodb_mcp_signal_error(slot)); + try std.testing.expectEqual(@as(c_int, 3), mongodb_mcp_connection_state(slot)); + + // Can only disconnect from error + try std.testing.expectEqual(@as(c_int, 0), mongodb_mcp_disconnect(slot)); +} + +test "session error" { + mongodb_mcp_reset(); + + const slot = mongodb_mcp_connect("mongodb://test:pw@localhost:27017/db", 37); + try std.testing.expect(slot >= 0); + + try std.testing.expectEqual(@as(c_int, 0), mongodb_mcp_start_session(slot)); + try std.testing.expectEqual(@as(c_int, 0), mongodb_mcp_signal_error(slot)); + try std.testing.expectEqual(@as(c_int, 3), mongodb_mcp_connection_state(slot)); + + try std.testing.expectEqual(@as(c_int, 0), mongodb_mcp_disconnect(slot)); +} + +test "transition validator" { + // Valid transitions + try std.testing.expectEqual(@as(c_int, 1), mongodb_mcp_can_transition(0, 1)); // disconn -> connected + try std.testing.expectEqual(@as(c_int, 1), mongodb_mcp_can_transition(1, 0)); // connected -> disconn + try std.testing.expectEqual(@as(c_int, 1), mongodb_mcp_can_transition(1, 2)); // connected -> in_session + try std.testing.expectEqual(@as(c_int, 1), mongodb_mcp_can_transition(2, 1)); // in_session -> connected + try std.testing.expectEqual(@as(c_int, 1), mongodb_mcp_can_transition(1, 3)); // connected -> error + try std.testing.expectEqual(@as(c_int, 1), mongodb_mcp_can_transition(2, 3)); // in_session -> error + try std.testing.expectEqual(@as(c_int, 1), mongodb_mcp_can_transition(3, 0)); // error -> disconn + + // Invalid transitions + try std.testing.expectEqual(@as(c_int, 0), mongodb_mcp_can_transition(0, 2)); // disconn -> in_session + try std.testing.expectEqual(@as(c_int, 0), mongodb_mcp_can_transition(3, 1)); // error -> connected + + // Out of range + try std.testing.expectEqual(@as(c_int, 0), mongodb_mcp_can_transition(99, 0)); +} + +test "pool exhaustion" { + mongodb_mcp_reset(); + + var slots: [MAX_CONNECTIONS]c_int = undefined; + for (&slots) |*s| { + s.* = mongodb_mcp_connect("mongodb://x:y@h:27017/d", 24); + try std.testing.expect(s.* >= 0); + } + + // Pool full + try std.testing.expectEqual(@as(c_int, -1), mongodb_mcp_connect("mongodb://x:y@h:27017/d", 24)); + try std.testing.expectEqual(@as(c_int, @intCast(MAX_CONNECTIONS)), mongodb_mcp_active_count()); + + // Free one and retry + try std.testing.expectEqual(@as(c_int, 0), mongodb_mcp_disconnect(slots[0])); + const new_slot = mongodb_mcp_connect("mongodb://x:y@h:27017/d", 24); + try std.testing.expect(new_slot >= 0); +} + +test "operation counting" { + mongodb_mcp_reset(); + + const slot = mongodb_mcp_connect("mongodb://x:y@h:27017/d", 24); + try std.testing.expect(slot >= 0); + + try std.testing.expectEqual(@as(c_int, 0), mongodb_mcp_op_count(slot)); + try std.testing.expectEqual(@as(c_int, 1), mongodb_mcp_record_operation(slot)); + try std.testing.expectEqual(@as(c_int, 2), mongodb_mcp_record_operation(slot)); + try std.testing.expectEqual(@as(c_int, 2), mongodb_mcp_op_count(slot)); + + try std.testing.expectEqual(@as(c_int, 0), mongodb_mcp_disconnect(slot)); +} + +// ═══════════════════════════════════════════════════════════════════════ +// ADR-0006 invoke dispatch tests +// ═══════════════════════════════════════════════════════════════════════ + +test "boj_cartridge_name returns mongodb-mcp" { + const n = std.mem.span(boj_cartridge_name()); + try std.testing.expectEqualStrings("mongodb-mcp", n); +} + +test "boj_cartridge_init returns 0" { + try std.testing.expectEqual(@as(c_int, 0), boj_cartridge_init()); +} + +test "invoke: each declared tool succeeds" { + var buf: [256]u8 = undefined; + const tools = [_][]const u8{ + "mongo_connect", + "mongo_find", + "mongo_insert", + "mongo_update", + "mongo_delete", + "mongo_aggregate", + "mongo_list_collections", + "mongo_disconnect", + }; + for (tools) |t| { + var len: usize = buf.len; + const rc = boj_cartridge_invoke(t.ptr, "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, 0), rc); + try std.testing.expect(std.mem.indexOf(u8, buf[0..len], "result") != null); + } +} + +test "invoke: unknown tool returns -1" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("nope", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -1), rc); +} + +test "invoke: buffer too small returns -3" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("mongo_connect", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -3), rc); + try std.testing.expect(len > 4); +} diff --git a/cartridges/domains/database/mongodb-mcp/minter.toml b/cartridges/domains/database/mongodb-mcp/minter.toml new file mode 100644 index 0000000..b25ffc0 --- /dev/null +++ b/cartridges/domains/database/mongodb-mcp/minter.toml @@ -0,0 +1,46 @@ +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +# +# minter.toml -- Cartridge metadata for mongodb-mcp. + +name = "mongodb-mcp" +description = "MongoDB database cartridge with BSON document handling, aggregation pipelines, and wire protocol integration" +version = "0.1.0" +domain = "Database" +protocols = [ + "MCP", + "REST", +] +tier = "Ayo" +backend = "universal" +generate_panel = true + +[auth] +method = "connection_string" +vault_mcp = true +format = "mongodb://user:pass@host:27017/db" + +[actions] +count = 16 +list = [ + "Find", + "FindOne", + "InsertOne", + "InsertMany", + "UpdateOne", + "UpdateMany", + "DeleteOne", + "DeleteMany", + "Aggregate", + "CountDocuments", + "CreateIndex", + "DropIndex", + "ListCollections", + "CreateCollection", + "DropCollection", + "ListDatabases", +] + +[state_machine] +states = ["Disconnected", "Connected", "InSession", "Error"] +initial = "Disconnected" diff --git a/cartridges/domains/database/mongodb-mcp/mod.js b/cartridges/domains/database/mongodb-mcp/mod.js new file mode 100644 index 0000000..eaffc26 --- /dev/null +++ b/cartridges/domains/database/mongodb-mcp/mod.js @@ -0,0 +1,82 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// mongodb-mcp/mod.js -- mongodb gateway. + +const BASE_URL = Deno.env.get("MONGODB_MCP_BACKEND_URL") ?? "http://127.0.0.1:7720"; + +async function post(path, payload) { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), 15000); + try { + const r = await fetch(`${BASE_URL}${path}`, { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), signal: ctrl.signal, + }); + const data = await r.json().catch(() => ({ success: false, error: "non-JSON response" })); + return { status: r.status, data }; + } catch (e) { + if (e.name === "AbortError") return { status: 504, data: { success: false, error: "mongodb-mcp backend timed out" } }; + return { status: 503, data: { success: false, error: `mongodb-mcp backend unavailable: ${e.message}` } }; + } finally { clearTimeout(t); } +} + +export async function handleTool(toolName, args) { + switch (toolName) { + + case "mongo_connect": { + const { connection_string, database } = args ?? {}; + if (!connection_string || !database) return { status: 400, data: { error: "connection_string is required" } }; + const payload = { connection_string, database }; + return post("/api/v1/mongo-connect", payload); + } + case "mongo_find": { + const { slot, collection, filter, limit } = args ?? {}; + if (!slot || !collection) return { status: 400, data: { error: "slot is required" } }; + const payload = { slot, collection }; + if (filter !== undefined) payload.filter = filter; + if (limit !== undefined) payload.limit = limit; + return post("/api/v1/mongo-find", payload); + } + case "mongo_insert": { + const { slot, collection, documents } = args ?? {}; + if (!slot || !collection || !documents) return { status: 400, data: { error: "slot is required" } }; + const payload = { slot, collection, documents }; + return post("/api/v1/mongo-insert", payload); + } + case "mongo_update": { + const { slot, collection, filter, update, multi } = args ?? {}; + if (!slot || !collection || !filter || !update) return { status: 400, data: { error: "slot is required" } }; + const payload = { slot, collection, filter, update }; + if (multi !== undefined) payload.multi = multi; + return post("/api/v1/mongo-update", payload); + } + case "mongo_delete": { + const { slot, collection, filter } = args ?? {}; + if (!slot || !collection || !filter) return { status: 400, data: { error: "slot is required" } }; + const payload = { slot, collection, filter }; + return post("/api/v1/mongo-delete", payload); + } + case "mongo_aggregate": { + const { slot, collection, pipeline } = args ?? {}; + if (!slot || !collection || !pipeline) return { status: 400, data: { error: "slot is required" } }; + const payload = { slot, collection, pipeline }; + return post("/api/v1/mongo-aggregate", payload); + } + case "mongo_list_collections": { + const { slot } = args ?? {}; + if (!slot) return { status: 400, data: { error: "slot is required" } }; + const payload = { slot }; + return post("/api/v1/mongo-list-collections", payload); + } + case "mongo_disconnect": { + const { slot } = args ?? {}; + if (!slot) return { status: 400, data: { error: "slot is required" } }; + const payload = { slot }; + return post("/api/v1/mongo-disconnect", payload); + } + + default: + return { status: 404, data: { error: `Unknown tool: ${toolName}` } }; + } +} diff --git a/cartridges/domains/database/mongodb-mcp/panels/manifest.json b/cartridges/domains/database/mongodb-mcp/panels/manifest.json new file mode 100644 index 0000000..2b67950 --- /dev/null +++ b/cartridges/domains/database/mongodb-mcp/panels/manifest.json @@ -0,0 +1,89 @@ +{ + "$schema": "https://panll.dev/schemas/panel-manifest/v1.json", + "spdx": "MPL-2.0", + "cartridge": "mongodb-mcp", + "domain": "Database", + "version": "0.1.0", + "panels": [ + { + "id": "mongo-connection-status", + "title": "Connection Status", + "description": "Current MongoDB connection lifecycle state (Disconnected / Connected / InSession / Error)", + "type": "status-indicator", + "data_source": { + "endpoint": "/mongodb/state", + "method": "GET", + "refresh_interval_ms": 5000 + }, + "widgets": [ + { + "type": "state-badge", + "field": "state", + "states": { + "disconnected": { "color": "#95a5a6", "icon": "database-off" }, + "connected": { "color": "#2ecc71", "icon": "database" }, + "in_session": { "color": "#f39c12", "icon": "database-lock" }, + "error": { "color": "#e74c3c", "icon": "database-alert" } + } + } + ] + }, + { + "id": "mongo-collection-count", + "title": "Collection Count", + "description": "Number of collections discovered via listCollections", + "type": "metric", + "data_source": { + "endpoint": "/mongodb/metrics", + "method": "GET", + "refresh_interval_ms": 30000 + }, + "widgets": [ + { + "type": "counter", + "field": "collection_count", + "label": "Collections", + "icon": "folders" + } + ] + }, + { + "id": "mongo-document-count", + "title": "Document Count", + "description": "Total documents counted via countDocuments across collections", + "type": "metric", + "data_source": { + "endpoint": "/mongodb/metrics", + "method": "GET", + "refresh_interval_ms": 30000 + }, + "widgets": [ + { + "type": "counter", + "field": "document_count", + "label": "Documents", + "icon": "file-text" + } + ] + }, + { + "id": "mongo-operation-metrics", + "title": "Operation Metrics", + "description": "CRUD and aggregation operation counts and throughput", + "type": "metric", + "data_source": { + "endpoint": "/mongodb/operations", + "method": "GET", + "refresh_interval_ms": 10000 + }, + "widgets": [ + { + "type": "counter", + "field": "total_op_count", + "label": "Operations", + "icon": "activity" + } + ] + } + ] +} diff --git a/cartridges/domains/database/mongodb-mcp/tests/integration_test.sh b/cartridges/domains/database/mongodb-mcp/tests/integration_test.sh new file mode 100755 index 0000000..ffbb488 --- /dev/null +++ b/cartridges/domains/database/mongodb-mcp/tests/integration_test.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Integration tests for mongodb-mcp cartridge. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CART_DIR="$(dirname "$SCRIPT_DIR")" +FFI_DIR="$CART_DIR/ffi" + +echo "=== mongodb-mcp integration tests ===" + +# Build FFI +echo "[1/3] Building FFI..." +cd "$FFI_DIR" && zig build 2>&1 + +# Run Zig unit tests +echo "[2/3] Running FFI unit tests..." +cd "$FFI_DIR" && zig build test 2>&1 + +# Validate Idris2 ABI (if idris2 available) +echo "[3/3] Checking ABI..." +if command -v idris2 &>/dev/null; then + cd "$CART_DIR/abi" && idris2 --check MongodbMcp.SafeDatabase 2>&1 + echo " ABI: OK" +else + echo " ABI: SKIPPED (idris2 not in PATH)" +fi + +echo "" +echo "All tests passed for mongodb-mcp!" diff --git a/cartridges/domains/database/neo4j-mcp/README.adoc b/cartridges/domains/database/neo4j-mcp/README.adoc new file mode 100644 index 0000000..06c2ae4 --- /dev/null +++ b/cartridges/domains/database/neo4j-mcp/README.adoc @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> + += neo4j-mcp +Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk> +:spdx: MPL-2.0 +:tier: Ayo +:domain: Database +:protocols: MCP, REST + +== Overview + +Neo4j graph database MCP cartridge. Provides type-safe access to Neo4j via +the HTTP REST API and Bolt protocol, covering databases, Cypher queries +(execute/explain/profile), nodes, relationships, labels, relationship types, +and property keys. Auth via basic auth (username:password) for self-hosted +instances or bearer token for Neo4j Aura cloud. + +=== Actions + +ListDatabases, CreateDatabase, DropDatabase, CypherQuery, ExplainQuery, +ProfileQuery, CreateNode, GetNode, UpdateNode, DeleteNode, +CreateRelationship, GetRelationship, DeleteRelationship, ListLabels, +ListRelationshipTypes, ListPropertyKeys. + +== Architecture + +[cols="1,1,2"] +|=== +| Layer | Language | Purpose + +| ABI +| Idris2 +| Formally verified state machine (Disconnected / Connected / QueryRunning / Error) + +| FFI +| Zig +| C-compatible implementation with thread-safe session pool + +| Adapter +| zig +| REST bridge to BoJ unified adapter protocol +|=== + +== Building + +[source,bash] +---- +# Build FFI shared library +cd ffi && zig build + +# Run FFI tests +cd ffi && zig build test + +# Type-check ABI +cd abi && idris2 --check Neo4jMcp.SafeDatabase +---- + +== Panels + +* Connection status (connected / disconnected / query running / error) +* Cypher query count +* Node count +* Relationship count + +== Status + +Development -- not yet ready for mounting. diff --git a/cartridges/domains/database/neo4j-mcp/abi/Neo4jMcp/SafeDatabase.idr b/cartridges/domains/database/neo4j-mcp/abi/Neo4jMcp/SafeDatabase.idr new file mode 100644 index 0000000..e4cb2da --- /dev/null +++ b/cartridges/domains/database/neo4j-mcp/abi/Neo4jMcp/SafeDatabase.idr @@ -0,0 +1,174 @@ +-- SPDX-License-Identifier: MPL-2.0 +-- Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +-- +-- Neo4jMcp.SafeDatabase β€” Type-safe ABI for the neo4j-mcp cartridge. +-- +-- Provides a formally verified state machine for Neo4j graph database +-- connections. Dependent-type proofs ensure only valid transitions can occur +-- at the FFI boundary. Neo4j actions cover the HTTP REST API and Bolt protocol +-- surface (https://{host}:7474/). Auth via basic auth (username:password) or +-- bearer token. Supports Cypher query language with EXPLAIN/PROFILE variants. + +module Neo4jMcp.SafeDatabase + +%default total + +-- --------------------------------------------------------------------------- +-- Connection state machine +-- --------------------------------------------------------------------------- + +||| Connection state for Neo4j graph database operations. +||| Disconnected is the resting state; Connected after authentication; +||| QueryRunning while a Cypher statement is executing. +public export +data ConnState = Disconnected | Connected | QueryRunning | Error + +||| Proof that a state transition is valid. +public export +data ValidTransition : ConnState -> ConnState -> Type where + Connect : ValidTransition Disconnected Connected + StartQuery : ValidTransition Connected QueryRunning + FinishQuery : ValidTransition QueryRunning Connected + Disconnect : ValidTransition Connected Disconnected + QueryFail : ValidTransition QueryRunning Error + ErrorRecover : ValidTransition Error Disconnected + +-- --------------------------------------------------------------------------- +-- C-ABI integer encoding +-- --------------------------------------------------------------------------- + +||| Encode connection state as C-compatible integer. +export +connStateToInt : ConnState -> Int +connStateToInt Disconnected = 0 +connStateToInt Connected = 1 +connStateToInt QueryRunning = 2 +connStateToInt Error = 3 + +||| Decode integer back to connection state. +export +intToConnState : Int -> Maybe ConnState +intToConnState 0 = Just Disconnected +intToConnState 1 = Just Connected +intToConnState 2 = Just QueryRunning +intToConnState 3 = Just Error +intToConnState _ = Nothing + +||| Check if a state transition is valid (C-ABI export). +||| Returns 1 for valid, 0 for invalid. +export +neo4j_mcp_can_transition : Int -> Int -> Int +neo4j_mcp_can_transition from to = + case (intToConnState from, intToConnState to) of + (Just Disconnected, Just Connected) => 1 + (Just Connected, Just QueryRunning) => 1 + (Just QueryRunning, Just Connected) => 1 + (Just Connected, Just Disconnected) => 1 + (Just QueryRunning, Just Error) => 1 + (Just Error, Just Disconnected) => 1 + _ => 0 + +-- --------------------------------------------------------------------------- +-- Neo4j actions (full API surface) +-- --------------------------------------------------------------------------- + +||| Actions supported by the Neo4j MCP cartridge. +||| Covers databases, Cypher queries (execute/explain/profile), nodes, +||| relationships, labels, relationship types, and property keys. +public export +data Neo4jAction + = ListDatabases + | CreateDatabase + | DropDatabase + | CypherQuery + | ExplainQuery + | ProfileQuery + | CreateNode + | GetNode + | UpdateNode + | DeleteNode + | CreateRelationship + | GetRelationship + | DeleteRelationship + | ListLabels + | ListRelationshipTypes + | ListPropertyKeys + +||| Encode action as C-compatible integer. +export +neo4jActionToInt : Neo4jAction -> Int +neo4jActionToInt ListDatabases = 0 +neo4jActionToInt CreateDatabase = 1 +neo4jActionToInt DropDatabase = 2 +neo4jActionToInt CypherQuery = 3 +neo4jActionToInt ExplainQuery = 4 +neo4jActionToInt ProfileQuery = 5 +neo4jActionToInt CreateNode = 6 +neo4jActionToInt GetNode = 7 +neo4jActionToInt UpdateNode = 8 +neo4jActionToInt DeleteNode = 9 +neo4jActionToInt CreateRelationship = 10 +neo4jActionToInt GetRelationship = 11 +neo4jActionToInt DeleteRelationship = 12 +neo4jActionToInt ListLabels = 13 +neo4jActionToInt ListRelationshipTypes = 14 +neo4jActionToInt ListPropertyKeys = 15 + +||| Decode integer back to action. +export +intToNeo4jAction : Int -> Maybe Neo4jAction +intToNeo4jAction 0 = Just ListDatabases +intToNeo4jAction 1 = Just CreateDatabase +intToNeo4jAction 2 = Just DropDatabase +intToNeo4jAction 3 = Just CypherQuery +intToNeo4jAction 4 = Just ExplainQuery +intToNeo4jAction 5 = Just ProfileQuery +intToNeo4jAction 6 = Just CreateNode +intToNeo4jAction 7 = Just GetNode +intToNeo4jAction 8 = Just UpdateNode +intToNeo4jAction 9 = Just DeleteNode +intToNeo4jAction 10 = Just CreateRelationship +intToNeo4jAction 11 = Just GetRelationship +intToNeo4jAction 12 = Just DeleteRelationship +intToNeo4jAction 13 = Just ListLabels +intToNeo4jAction 14 = Just ListRelationshipTypes +intToNeo4jAction 15 = Just ListPropertyKeys +intToNeo4jAction _ = Nothing + +||| Check whether an action requires an active connection. +export +actionRequiresConnection : Neo4jAction -> Bool +actionRequiresConnection CypherQuery = True +actionRequiresConnection ExplainQuery = True +actionRequiresConnection ProfileQuery = True +actionRequiresConnection CreateNode = True +actionRequiresConnection GetNode = True +actionRequiresConnection UpdateNode = True +actionRequiresConnection DeleteNode = True +actionRequiresConnection CreateRelationship = True +actionRequiresConnection GetRelationship = True +actionRequiresConnection DeleteRelationship = True +actionRequiresConnection ListLabels = True +actionRequiresConnection ListRelationshipTypes = True +actionRequiresConnection ListPropertyKeys = True +actionRequiresConnection _ = False + +||| Total number of actions exposed by this cartridge. +export +actionCount : Nat +actionCount = 16 + +-- --------------------------------------------------------------------------- +-- Auth configuration +-- --------------------------------------------------------------------------- + +||| Authentication method for Neo4j. +||| Basic auth (username:password) for self-hosted, or Bearer token for Aura. +public export +data Neo4jAuth = BasicAuth | BearerToken + +||| Base URL template for Neo4j HTTP API. +||| Replace {host} with actual hostname (self-hosted or Aura cloud). +export +neo4jApiBase : String +neo4jApiBase = "https://{host}:7474/" diff --git a/cartridges/domains/database/neo4j-mcp/abi/README.adoc b/cartridges/domains/database/neo4j-mcp/abi/README.adoc new file mode 100644 index 0000000..f8943ac --- /dev/null +++ b/cartridges/domains/database/neo4j-mcp/abi/README.adoc @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += neo4j-mcp / abi β€” Idris2 ABI layer +:orientation: shallow + +== Purpose + +Cartridge-local protocol types for `neo4j-mcp`. Defines the data, tool, and +transition vocabulary this cartridge speaks. Inherits safety lemmas and the +matrix-cell identifier scheme from the trunk ABI at `src/abi/`. + +== Files + +1 Idris2 module(s), ~174 lines total. Lead module: +`SafeDatabase.idr`. + +No `.ipkg` β€” consumed directly by wider BoJ callers; type-checks inside the +trunk `boj.ipkg`. + +== Invariants + +* `%default total` at every module top. +* Zero `believe_me`, `assert_total`, `postulate`, `Admitted`, or + `sorry` (the trunk holds the four declared `believe_me` sites; leaf + cartridges carry none). + +== Test/proof surface + +Thin module(s), no inline tests. Proof surface = type-check success under the +trunk `idris2 --check`. + +== Read-first + +. `SafeDatabase.idr` β€” cartridge-local data records and enums. +. Trunk `src/abi/Boj/Catalogue.idr` for how this cell participates in the + 2D matrix. diff --git a/cartridges/domains/database/neo4j-mcp/abi/neo4j_mcp.ipkg b/cartridges/domains/database/neo4j-mcp/abi/neo4j_mcp.ipkg new file mode 100644 index 0000000..87f17dc --- /dev/null +++ b/cartridges/domains/database/neo4j-mcp/abi/neo4j_mcp.ipkg @@ -0,0 +1,11 @@ +-- SPDX-License-Identifier: MPL-2.0 +-- Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +package neo4j_mcp + +version = "0.1.0" +authors = "Jonathan D.A. Jewell" +brief = "Neo4j graph database MCP cartridge β€” type-safe ABI layer" + +depends = base + +modules = Neo4jMcp.SafeDatabase diff --git a/cartridges/domains/database/neo4j-mcp/adapter/README.adoc b/cartridges/domains/database/neo4j-mcp/adapter/README.adoc new file mode 100644 index 0000000..c601ada --- /dev/null +++ b/cartridges/domains/database/neo4j-mcp/adapter/README.adoc @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += neo4j-mcp / adapter β€” protocol bridge +:orientation: shallow + +== Purpose + +Exposes this cartridge's FFI over the wire protocols declared in +`cartridge.json`. Dispatches tool invocations to the C-ABI exports at +`../ffi/` and formats responses per protocol. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” depends on the sibling `ffi` target. +| `neo4j_mcp_adapter.zig` | Protocol dispatch (134 lines). Tool catalogue matches `cartridge.json`. +| `SIDELINED-*.v.adoc` | zig predecessor(s). Not built β€” zig banned 2026-04-10. +|=== + +== Invariants + +* **Stateless** β€” all state lives behind the FFI, never in the adapter. +* **Response passthrough** β€” whatever the FFI returns goes back to the wire + unmodified (no embellishment, no silent recovery). +* **Cartridge.json is source of truth** for the tool catalogue; drift between + adapter and manifest is a CI failure. + +== Test/proof surface + +No inline tests at the adapter layer β€” protocol routing is exercised by the +trunk seam tests at `ffi/zig/src/seams.zig`. + +== Read-first + +. `neo4j_mcp_adapter.zig` β€” dispatch function and per-protocol routers. +. `../cartridge.json` β€” the tool manifest the dispatcher mirrors. diff --git a/cartridges/domains/database/neo4j-mcp/adapter/build.zig b/cartridges/domains/database/neo4j-mcp/adapter/build.zig new file mode 100644 index 0000000..56c11dc --- /dev/null +++ b/cartridges/domains/database/neo4j-mcp/adapter/build.zig @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// neo4j-mcp/adapter/build.zig + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const ffi_mod = b.createModule(.{ + .root_source_file = b.path("../ffi/neo4j_mcp_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + const adapter = b.addExecutable(.{ + .name = "neo4j_mcp_adapter", + .root_source_file = b.path("neo4j_mcp_adapter.zig"), + .target = target, + .optimize = optimize, + }); + adapter.root_module.addImport("neo4j_mcp_ffi", ffi_mod); + b.installArtifact(adapter); +} diff --git a/cartridges/domains/database/neo4j-mcp/adapter/neo4j_mcp_adapter.zig b/cartridges/domains/database/neo4j-mcp/adapter/neo4j_mcp_adapter.zig new file mode 100644 index 0000000..37e127d --- /dev/null +++ b/cartridges/domains/database/neo4j-mcp/adapter/neo4j_mcp_adapter.zig @@ -0,0 +1,134 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// neo4j-mcp/adapter/neo4j_mcp_adapter.zig +// +// Three-protocol BoJ adapter: REST (port 9268), gRPC-compat (port 9269), +// GraphQL (port 9270). +// Replaces the banned zig adapter (neo4j_mcp_adapter.v). + +const std = @import("std"); +const ffi = @import("neo4j_mcp_ffi"); + +const REST_PORT: u16 = 9268; +const GRPC_PORT: u16 = 9269; +const GQL_PORT: u16 = 9270; + +const Response = struct { status: u16, body: []const u8 }; + +fn okJson(buf: []u8, msg: []const u8) []u8 { + return std.fmt.bufPrint(buf, "{{\"success\":true,\"message\":\"{s}\"}}", .{msg}) catch buf[0..0]; +} + +fn errJson(buf: []u8, msg: []const u8) []u8 { + return std.fmt.bufPrint(buf, "{{\"success\":false,\"error\":\"{s}\"}}", .{msg}) catch buf[0..0]; +} + +fn dispatch(tool: []const u8, _body: []const u8, resp: []u8) Response { + _ = _body; + if (std.mem.eql(u8, tool, "neo4j_connect")) { + return .{ .status = 200, .body = okJson(resp, "neo4j_connect forwarded") }; + } + if (std.mem.eql(u8, tool, "neo4j_disconnect")) { + return .{ .status = 200, .body = okJson(resp, "neo4j_disconnect forwarded") }; + } + if (std.mem.eql(u8, tool, "neo4j_query")) { + return .{ .status = 200, .body = okJson(resp, "neo4j_query forwarded") }; + } + if (std.mem.eql(u8, tool, "neo4j_write")) { + return .{ .status = 200, .body = okJson(resp, "neo4j_write forwarded") }; + } + if (std.mem.eql(u8, tool, "neo4j_schema")) { + return .{ .status = 200, .body = okJson(resp, "neo4j_schema forwarded") }; + } + return .{ .status = 404, .body = errJson(resp, "unknown tool") }; +} + +fn dispatchRest(path: []const u8, body: []const u8, resp: []u8) Response { + // Expect /tools/<tool_name> + const prefix = "/tools/"; + if (std.mem.startsWith(u8, path, prefix)) { + const tool = path[prefix.len..]; + return dispatch(tool, body, resp); + } + return .{ .status = 404, .body = errJson(resp, "not found") }; +} + +fn dispatchGrpc(path: []const u8, body: []const u8, resp: []u8) Response { + // Expect /<Service>/<Method> β€” derive tool from Method + var it = std.mem.splitScalar(u8, path, '/'); + _ = it.next(); // leading empty + _ = it.next(); // service + const method = it.next() orelse return .{ .status = 404, .body = errJson(resp, "bad gRPC path") }; + return dispatch(method, body, resp); +} + +fn dispatchGraphql(body: []const u8, resp: []u8) Response { + if (std.mem.indexOf(u8, body, "neo4j_connect") != null) + return dispatch("neo4j_connect", body, resp); + if (std.mem.indexOf(u8, body, "neo4j_disconnect") != null) + return dispatch("neo4j_disconnect", body, resp); + if (std.mem.indexOf(u8, body, "neo4j_query") != null) + return dispatch("neo4j_query", body, resp); + if (std.mem.indexOf(u8, body, "neo4j_write") != null) + return dispatch("neo4j_write", body, resp); + if (std.mem.indexOf(u8, body, "neo4j_schema") != null) + return dispatch("neo4j_schema", body, resp); + return .{ .status = 400, .body = errJson(resp, "unrecognised GraphQL operation") }; +} + +fn handleConnection(stream: std.net.Stream, port: u16) void { + defer stream.close(); + var buf: [4096]u8 = undefined; + var resp_buf: [4096]u8 = undefined; + const n = stream.read(&buf) catch return; + const req = buf[0..n]; + const result = switch (port) { + REST_PORT => blk: { + // Parse HTTP/1.1: first line = METHOD PATH HTTP/x.y + var lines = std.mem.splitScalar(u8, req, '\n'); + const first = lines.next() orelse break :blk Response{ .status = 400, .body = "" }; + var parts = std.mem.splitScalar(u8, std.mem.trim(u8, first, "\r"), ' '); + _ = parts.next(); // method + const path = parts.next() orelse break :blk Response{ .status = 400, .body = "" }; + break :blk dispatchRest(path, req, &resp_buf); + }, + GRPC_PORT => blk: { + var lines = std.mem.splitScalar(u8, req, '\n'); + const first = lines.next() orelse break :blk Response{ .status = 400, .body = "" }; + var parts = std.mem.splitScalar(u8, std.mem.trim(u8, first, "\r"), ' '); + _ = parts.next(); + const path = parts.next() orelse break :blk Response{ .status = 400, .body = "" }; + break :blk dispatchGrpc(path, req, &resp_buf); + }, + GQL_PORT => dispatchGraphql(req, &resp_buf), + else => Response{ .status = 500, .body = "" }, + }; + var http_resp: [512]u8 = undefined; + const http = std.fmt.bufPrint(&http_resp, + "HTTP/1.1 {d} OK\r\nContent-Length: {d}\r\nContent-Type: application/json\r\n\r\n", + .{ result.status, result.body.len }) catch return; + _ = stream.write(http) catch {}; + _ = stream.write(result.body) catch {}; +} + +fn listenLoop(port: u16) !void { + const addr = std.net.Address.initIp4(.{ 127, 0, 0, 1 }, port); + var server = try addr.listen(.{ .reuse_address = true }); + defer server.deinit(); + while (true) { + const conn = try server.accept(); + const t = try std.Thread.spawn(.{}, handleConnection, .{ conn.stream, port }); + t.detach(); + } +} + +pub fn main() !void { + ffi.neo4j_mcp_init(); + const t1 = try std.Thread.spawn(.{}, listenLoop, .{REST_PORT}); + const t2 = try std.Thread.spawn(.{}, listenLoop, .{GRPC_PORT}); + const t3 = try std.Thread.spawn(.{}, listenLoop, .{GQL_PORT}); + t1.join(); + t2.join(); + t3.join(); +} diff --git a/cartridges/domains/database/neo4j-mcp/benchmarks/quick-bench.sh b/cartridges/domains/database/neo4j-mcp/benchmarks/quick-bench.sh new file mode 100755 index 0000000..b786f41 --- /dev/null +++ b/cartridges/domains/database/neo4j-mcp/benchmarks/quick-bench.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Benchmarks for neo4j-mcp cartridge. +set -euo pipefail + +echo "=== neo4j-mcp benchmarks ===" + +cd "$(dirname "${BASH_SOURCE[0]}")/../ffi" + +# Build with ReleaseFast for benchmarking +zig build -Doptimize=ReleaseFast 2>&1 + +echo "Session open/close cycle (1000 iterations):" +time for i in $(seq 1 1000); do + # This would call the FFI benchmark binary + true +done + +echo "" +echo "Benchmark placeholder β€” implement real benchmarks in Zig test blocks" +echo "or via the zig adapter HTTP benchmark tool." diff --git a/cartridges/domains/database/neo4j-mcp/cartridge.json b/cartridges/domains/database/neo4j-mcp/cartridge.json new file mode 100644 index 0000000..89aaee4 --- /dev/null +++ b/cartridges/domains/database/neo4j-mcp/cartridge.json @@ -0,0 +1,144 @@ +{ + "$schema": "https://boj.dev/schemas/cartridge/v1.json", + "spdx": "MPL-2.0", + "copyright": "Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk>", + "name": "neo4j-mcp", + "version": "0.1.0", + "description": "Neo4j graph database query and write", + "domain": "database", + "tier": "Ayo", + "protocols": [ + "MCP", + "REST" + ], + "auth": { + "method": "none", + "env_var": null, + "credential_source": null + }, + "api": { + "base_url": "local://neo4j-mcp", + "content_type": "application/json" + }, + "tools": [ + { + "name": "neo4j_connect", + "description": "Connect to a Neo4j instance", + "inputSchema": { + "type": "object", + "properties": { + "uri": { + "type": "string", + "description": "Bolt URI (bolt://host:7687)" + }, + "username": { + "type": "string", + "description": "Username" + }, + "password": { + "type": "string", + "description": "Password" + } + }, + "required": [ + "uri", + "username", + "password" + ] + } + }, + { + "name": "neo4j_disconnect", + "description": "Close Neo4j connection", + "inputSchema": { + "type": "object", + "properties": { + "session_id": { + "type": "integer", + "description": "Session index" + } + }, + "required": [ + "session_id" + ] + } + }, + { + "name": "neo4j_query", + "description": "Run a Cypher read query", + "inputSchema": { + "type": "object", + "properties": { + "session_id": { + "type": "integer", + "description": "Session index" + }, + "cypher": { + "type": "string", + "description": "Cypher query" + }, + "params": { + "type": "object", + "description": "Query parameters" + } + }, + "required": [ + "session_id", + "cypher" + ] + } + }, + { + "name": "neo4j_write", + "description": "Run a Cypher write query", + "inputSchema": { + "type": "object", + "properties": { + "session_id": { + "type": "integer", + "description": "Session index" + }, + "cypher": { + "type": "string", + "description": "Cypher write query" + }, + "params": { + "type": "object", + "description": "Query parameters" + } + }, + "required": [ + "session_id", + "cypher" + ] + } + }, + { + "name": "neo4j_schema", + "description": "Get database schema (labels, relationship types)", + "inputSchema": { + "type": "object", + "properties": { + "session_id": { + "type": "integer", + "description": "Session index" + } + }, + "required": [ + "session_id" + ] + } + } + ], + "ffi": { + "so_path": "ffi/zig-out/lib/libneo4j_mcp.so", + "abi_version": "ADR-0006", + "symbols": [ + "boj_cartridge_init", + "boj_cartridge_deinit", + "boj_cartridge_name", + "boj_cartridge_version", + "boj_cartridge_invoke" + ] + } +} diff --git a/cartridges/domains/database/neo4j-mcp/ffi/README.adoc b/cartridges/domains/database/neo4j-mcp/ffi/README.adoc new file mode 100644 index 0000000..aef50c3 --- /dev/null +++ b/cartridges/domains/database/neo4j-mcp/ffi/README.adoc @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += neo4j-mcp / ffi β€” Zig FFI layer +:orientation: shallow + +== Purpose + +C-ABI exports bridging this cartridge's Idris2 ABI to the runtime. Consumed by +the adapter for protocol dispatch and by the trunk loader for mount. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” produces `zig-out/lib/libneo4j.so`. +| `neo4j_mcp_ffi.zig` | C-ABI exports (11 exports, 6 inline tests, 350 lines). +|=== + +== Invariants + +* **Null-pointer rejection** on `[*c]const u8` arguments before any + dereference. +* **Negative return values** carry documented error meanings; zero is + success (matching the trunk error-code convention). +* **No allocator leaks** β€” every allocation path pairs with `defer free`. + +== Test/proof surface + +6 inline `test "..."` block(s) in `neo4j_mcp_ffi.zig`. Run with +`cd ffi && zig build test`. + +== Read-first + +. `neo4j_mcp_ffi.zig` β€” C-ABI signatures and guards. +. Trunk `ffi/zig/src/catalogue.zig` for how this `.so` is loaded. diff --git a/cartridges/domains/database/neo4j-mcp/ffi/build.zig b/cartridges/domains/database/neo4j-mcp/ffi/build.zig new file mode 100644 index 0000000..0dd85ff --- /dev/null +++ b/cartridges/domains/database/neo4j-mcp/ffi/build.zig @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + // Module + // Shared ADR-0006 invoke-shim module (relative path up to boj-server trunk). + + const shim_mod = b.addModule("cartridge_shim", .{ + + .root_source_file = b.path("../../../ffi/zig/src/cartridge_shim.zig"), + + .target = target, + + .optimize = optimize, + + }); + + const ffi_mod = b.addModule("neo4j_mcp", .{ + .root_source_file = b.path("neo4j_mcp_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + ffi_mod.addImport("cartridge_shim", shim_mod); + + // Shared library + const lib = b.addLibrary(.{ + .name = "neo4j_mcp", + .root_module = ffi_mod, + .linkage = .dynamic, + }); + lib.linkLibC(); + b.installArtifact(lib); + + // Tests + const tests = b.addTest(.{ + .root_module = ffi_mod, + }); + tests.linkLibC(); + + const run_tests = b.addRunArtifact(tests); + const test_step = b.step("test", "Run FFI tests"); + test_step.dependOn(&run_tests.step); +} diff --git a/cartridges/domains/database/neo4j-mcp/ffi/cartridge_shim.zig b/cartridges/domains/database/neo4j-mcp/ffi/cartridge_shim.zig new file mode 100644 index 0000000..0e399db --- /dev/null +++ b/cartridges/domains/database/neo4j-mcp/ffi/cartridge_shim.zig @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// cartridge_shim.zig β€” Shared helpers for the ADR-0006 five-symbol +// cartridge ABI (`boj_cartridge_init / deinit / name / version / invoke`). +// +// The shim centralises the seven-code return convention, NUL-argument +// guards, tool-name comparison, and the buffer-too-small path so each +// cartridge's `boj_cartridge_invoke` can stay short β€” typically a tool +// table plus `shim.writeResult(...)`. +// +// Cartridges import this file by relative path (no build-graph change +// needed). Example: +// +// const shim = @import("../../../ffi/zig/src/cartridge_shim.zig"); +// +// export fn boj_cartridge_invoke( +// tool_name: [*c]const u8, +// json_args: [*c]const u8, +// out_buf: [*c]u8, +// in_out_len: [*c]usize, +// ) callconv(.c) i32 { +// _ = json_args; +// if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; +// const body = if (shim.toolIs(tool_name, "foo")) "{\"result\":{}}" +// else return shim.RC_UNKNOWN_TOOL; +// return shim.writeResult(out_buf, in_out_len, body); +// } + +const std = @import("std"); + +// ── Return codes (ADR-0006 Β§Return codes) ──────────────────────────── +// +// Frozen by ADR-0006. New failure modes compose these via the error +// JSON body β€” the integer surface does not grow without a follow-up ADR. + +pub const RC_SUCCESS: i32 = 0; +pub const RC_UNKNOWN_TOOL: i32 = -1; +pub const RC_BAD_ARGS: i32 = -2; +pub const RC_BUFFER_TOO_SMALL: i32 = -3; +pub const RC_RUNTIME_ERROR: i32 = -4; +pub const RC_PANIC: i32 = -5; +pub const RC_AUTH_DENIED: i32 = -6; + +// ── Invoke-path helpers ────────────────────────────────────────────── + +/// True if any of the three mandatory `boj_cartridge_invoke` output-path +/// pointers is null. Use at the top of every invoke to short-circuit to +/// `RC_BAD_ARGS`. +pub fn invokeArgsNull( + tool_name: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) bool { + return tool_name == null or out_buf == null or in_out_len == null; +} + +/// Compare a C-NUL-terminated tool-name pointer against a Zig string +/// literal. Caller must have already verified `tool_name` is non-null +/// (usually via `invokeArgsNull`). +pub fn toolIs(tool_name: [*c]const u8, expected: []const u8) bool { + const s = std.mem.span(@as([*:0]const u8, @ptrCast(tool_name))); + return std.mem.eql(u8, s, expected); +} + +/// Copy `body` into `out_buf[0..*in_out_len]` (as a capacity) and update +/// `*in_out_len` to the number of bytes written. Returns `RC_SUCCESS`. +/// +/// If `body.len` exceeds the current capacity stored in `*in_out_len`, +/// sets `*in_out_len` to the required size and returns +/// `RC_BUFFER_TOO_SMALL` β€” the caller is then expected to re-allocate +/// and retry, per ADR-0006 Β§Memory ownership. +/// +/// Caller must have already verified that `out_buf` and `in_out_len` +/// are non-null. +pub fn writeResult( + out_buf: [*c]u8, + in_out_len: [*c]usize, + body: []const u8, +) i32 { + const cap = in_out_len.*; + if (body.len > cap) { + in_out_len.* = body.len; + return RC_BUFFER_TOO_SMALL; + } + @memcpy(out_buf[0..body.len], body); + in_out_len.* = body.len; + return RC_SUCCESS; +} + +// ── Tests ──────────────────────────────────────────────────────────── + +test "writeResult: body fits, writes and sets length" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); + try std.testing.expectEqualStrings("hello", buf[0..len]); +} + +test "writeResult: too small returns -3 and sets required length" { + var buf: [2]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_BUFFER_TOO_SMALL, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: exact-fit succeeds" { + var buf: [5]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: empty body" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, ""); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 0), len); +} + +test "toolIs: matches and rejects" { + const name: [*:0]const u8 = "foo"; + try std.testing.expect(toolIs(@ptrCast(name), "foo")); + try std.testing.expect(!toolIs(@ptrCast(name), "bar")); + try std.testing.expect(!toolIs(@ptrCast(name), "foobar")); + try std.testing.expect(!toolIs(@ptrCast(name), "fo")); +} + +test "invokeArgsNull: detects each null slot" { + var buf: [4]u8 = undefined; + var len: usize = 4; + const name: [*:0]const u8 = "x"; + try std.testing.expect(!invokeArgsNull(@ptrCast(name), &buf, &len)); + try std.testing.expect(invokeArgsNull(null, &buf, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), null, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), &buf, null)); +} diff --git a/cartridges/domains/database/neo4j-mcp/ffi/neo4j_mcp_ffi.zig b/cartridges/domains/database/neo4j-mcp/ffi/neo4j_mcp_ffi.zig new file mode 100644 index 0000000..7d07863 --- /dev/null +++ b/cartridges/domains/database/neo4j-mcp/ffi/neo4j_mcp_ffi.zig @@ -0,0 +1,444 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// neo4j_mcp_ffi.zig β€” C-ABI FFI implementation for the neo4j-mcp cartridge. +// +// Implements the connection state machine defined in the Idris2 ABI layer +// (Neo4jMcp.SafeDatabase). Thread-safe via std.Thread.Mutex. No heap +// allocations for results. State machine: Disconnected | Connected | +// QueryRunning | Error. Designed for Neo4j graph database operations +// over HTTP REST API and Bolt protocol. Auth: basic auth or bearer token. + +const std = @import("std"); + +// --------------------------------------------------------------------------- +// Connection state machine (matches Idris2 ABI exactly) +// --------------------------------------------------------------------------- + +/// Connection states for Neo4j graph database. +pub const ConnState = enum(c_int) { + disconnected = 0, + connected = 1, + query_running = 2, + err = 3, +}; + +/// Neo4j API actions. +pub const Neo4jAction = enum(c_int) { + list_databases = 0, + create_database = 1, + drop_database = 2, + cypher_query = 3, + explain_query = 4, + profile_query = 5, + create_node = 6, + get_node = 7, + update_node = 8, + delete_node = 9, + create_relationship = 10, + get_relationship = 11, + delete_relationship = 12, + list_labels = 13, + list_relationship_types = 14, + list_property_keys = 15, +}; + +/// Check whether a state transition is valid per the ABI specification. +fn isValidTransition(from: ConnState, to: ConnState) bool { + return switch (from) { + .disconnected => to == .connected, + .connected => to == .query_running or to == .disconnected, + .query_running => to == .connected or to == .err, + .err => to == .disconnected, + }; +} + +// --------------------------------------------------------------------------- +// Session slots (thread-safe, fixed-size pool) +// --------------------------------------------------------------------------- + +const MAX_SESSIONS: usize = 16; +const BUF_SIZE: usize = 4096; + +const SessionSlot = struct { + in_use: bool = false, + state: ConnState = .disconnected, + context_buf: [BUF_SIZE]u8 = .{0} ** BUF_SIZE, + context_len: usize = 0, + node_count: u32 = 0, + relationship_count: u32 = 0, + query_count: u32 = 0, + database_name_len: usize = 0, + database_name_buf: [256]u8 = .{0} ** 256, +}; + +var sessions: [MAX_SESSIONS]SessionSlot = .{SessionSlot{}} ** MAX_SESSIONS; +var mutex: std.Thread.Mutex = .{}; + +// --------------------------------------------------------------------------- +// C-ABI exports +// --------------------------------------------------------------------------- + +/// Check if a state transition is valid. Returns 1 (valid) or 0 (invalid). +pub export fn neo4j_mcp_can_transition(from: c_int, to: c_int) c_int { + const f = std.meta.intToEnum(ConnState, from) catch return 0; + const t = std.meta.intToEnum(ConnState, to) catch return 0; + return if (isValidTransition(f, t)) 1 else 0; +} + +/// Open a new connection session. Returns slot index (>= 0) or -1 if no slots. +pub export fn neo4j_mcp_session_open() c_int { + mutex.lock(); + defer mutex.unlock(); + + for (&sessions, 0..) |*slot, idx| { + if (!slot.in_use) { + slot.in_use = true; + slot.state = .connected; + slot.context_len = 0; + slot.node_count = 0; + slot.relationship_count = 0; + slot.query_count = 0; + slot.database_name_len = 0; + return @intCast(idx); + } + } + return -1; +} + +/// Close a connection session. Returns 0 on success, -1 if invalid slot, -2 if bad transition. +pub export fn neo4j_mcp_session_close(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.in_use) return -1; + if (!isValidTransition(slot.state, .disconnected)) return -2; + + slot.in_use = false; + slot.state = .disconnected; + slot.context_len = 0; + return 0; +} + +/// Get the current state of a session. Returns state int or -1 if invalid slot. +pub export fn neo4j_mcp_session_state(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.in_use) return -1; + return @intFromEnum(slot.state); +} + +/// Begin a Cypher query. Returns 0 on success, -1 if invalid slot, -2 if bad transition. +pub export fn neo4j_mcp_begin_query(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.in_use) return -1; + if (!isValidTransition(slot.state, .query_running)) return -2; + + slot.state = .query_running; + return 0; +} + +/// End a query (return to connected). Returns 0 on success. +pub export fn neo4j_mcp_end_query(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.in_use) return -1; + if (!isValidTransition(slot.state, .connected)) return -2; + + slot.state = .connected; + slot.query_count += 1; + return 0; +} + +/// Signal an error on a query-running session. Returns 0 on success. +pub export fn neo4j_mcp_signal_error(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.in_use) return -1; + if (!isValidTransition(slot.state, .err)) return -2; + + slot.state = .err; + return 0; +} + +/// Recover from error state (transition to disconnected). Returns 0 on success. +pub export fn neo4j_mcp_error_recover(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.in_use) return -1; + if (!isValidTransition(slot.state, .disconnected)) return -2; + + slot.state = .disconnected; + return 0; +} + +/// Get the query count for a session. Returns count or -1 if invalid slot. +pub export fn neo4j_mcp_query_count(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.in_use) return -1; + return @intCast(slot.query_count); +} + +/// Check if an action requires an active connection. Returns 1 (yes) or 0 (no). +pub export fn neo4j_mcp_action_requires_connection(action: c_int) c_int { + const a = std.meta.intToEnum(Neo4jAction, action) catch return 0; + return switch (a) { + .cypher_query, + .explain_query, + .profile_query, + .create_node, + .get_node, + .update_node, + .delete_node, + .create_relationship, + .get_relationship, + .delete_relationship, + .list_labels, + .list_relationship_types, + .list_property_keys, + => 1, + else => 0, + }; +} + +/// Reset all sessions (test/debug use only). +pub export fn neo4j_mcp_reset() void { + mutex.lock(); + defer mutex.unlock(); + sessions = .{SessionSlot{}} ** MAX_SESSIONS; +} + +// --------------------------------------------------------------------------- +// ═══════════════════════════════════════════════════════════════════════ +// Standard ABI (ADR-0005 four symbols + ADR-0006 invoke) +// ═══════════════════════════════════════════════════════════════════════ + +const shim = @import("cartridge_shim.zig"); + +const CARTRIDGE_NAME_PTR: [*:0]const u8 = "neo4j-mcp"; +const CARTRIDGE_VERSION_PTR: [*:0]const u8 = "0.1.0"; + +export fn boj_cartridge_init() callconv(.c) c_int { + return 0; +} + +export fn boj_cartridge_deinit() callconv(.c) void {} + +export fn boj_cartridge_name() callconv(.c) [*:0]const u8 { + return CARTRIDGE_NAME_PTR; +} + +export fn boj_cartridge_version() callconv(.c) [*:0]const u8 { + return CARTRIDGE_VERSION_PTR; +} + +/// Dispatch the cartridge.json MCP tools. Grade D Alpha stubs. +export fn boj_cartridge_invoke( + tool_name: [*c]const u8, + json_args: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) callconv(.c) i32 { + _ = json_args; + if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; + + const body: []const u8 = if (shim.toolIs(tool_name, "neo4j_connect")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "neo4j_disconnect")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "neo4j_query")) + "{\"result\":{\"matches\":[],\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "neo4j_write")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "neo4j_schema")) + "{\"result\":{\"status\":\"stub\"}}" +else + return shim.RC_UNKNOWN_TOOL; + + return shim.writeResult(out_buf, in_out_len, body); +} + +// Tests +// --------------------------------------------------------------------------- + +test "session lifecycle" { + neo4j_mcp_reset(); + + const slot = neo4j_mcp_session_open(); + try std.testing.expect(slot >= 0); + + // Should be in connected state + try std.testing.expectEqual(@as(c_int, 1), neo4j_mcp_session_state(slot)); + + // Begin query (Cypher) + try std.testing.expectEqual(@as(c_int, 0), neo4j_mcp_begin_query(slot)); + try std.testing.expectEqual(@as(c_int, 2), neo4j_mcp_session_state(slot)); + + // End query + try std.testing.expectEqual(@as(c_int, 0), neo4j_mcp_end_query(slot)); + try std.testing.expectEqual(@as(c_int, 1), neo4j_mcp_session_state(slot)); + + // Query count should be 1 + try std.testing.expectEqual(@as(c_int, 1), neo4j_mcp_query_count(slot)); + + // Close + try std.testing.expectEqual(@as(c_int, 0), neo4j_mcp_session_close(slot)); +} + +test "invalid transitions rejected" { + neo4j_mcp_reset(); + + const slot = neo4j_mcp_session_open(); + try std.testing.expect(slot >= 0); + + // Can not go connected -> error (must go through query_running) + try std.testing.expectEqual(@as(c_int, -2), neo4j_mcp_signal_error(slot)); + + // Can not close while query running + try std.testing.expectEqual(@as(c_int, 0), neo4j_mcp_begin_query(slot)); + try std.testing.expectEqual(@as(c_int, -2), neo4j_mcp_session_close(slot)); +} + +test "error recovery path" { + neo4j_mcp_reset(); + + const slot = neo4j_mcp_session_open(); + try std.testing.expect(slot >= 0); + + // Connected -> QueryRunning -> Error -> Disconnected + try std.testing.expectEqual(@as(c_int, 0), neo4j_mcp_begin_query(slot)); + try std.testing.expectEqual(@as(c_int, 0), neo4j_mcp_signal_error(slot)); + try std.testing.expectEqual(@as(c_int, 3), neo4j_mcp_session_state(slot)); + + // Error -> Disconnected via error_recover + try std.testing.expectEqual(@as(c_int, 0), neo4j_mcp_error_recover(slot)); + try std.testing.expectEqual(@as(c_int, 0), neo4j_mcp_session_state(slot)); +} + +test "transition validator" { + // Valid transitions + try std.testing.expectEqual(@as(c_int, 1), neo4j_mcp_can_transition(0, 1)); // disconnected -> connected + try std.testing.expectEqual(@as(c_int, 1), neo4j_mcp_can_transition(1, 2)); // connected -> query_running + try std.testing.expectEqual(@as(c_int, 1), neo4j_mcp_can_transition(2, 1)); // query_running -> connected + try std.testing.expectEqual(@as(c_int, 1), neo4j_mcp_can_transition(1, 0)); // connected -> disconnected + try std.testing.expectEqual(@as(c_int, 1), neo4j_mcp_can_transition(2, 3)); // query_running -> error + try std.testing.expectEqual(@as(c_int, 1), neo4j_mcp_can_transition(3, 0)); // error -> disconnected + + // Invalid transitions + try std.testing.expectEqual(@as(c_int, 0), neo4j_mcp_can_transition(0, 2)); + try std.testing.expectEqual(@as(c_int, 0), neo4j_mcp_can_transition(1, 3)); + try std.testing.expectEqual(@as(c_int, 0), neo4j_mcp_can_transition(3, 1)); + try std.testing.expectEqual(@as(c_int, 0), neo4j_mcp_can_transition(99, 0)); +} + +test "slot exhaustion" { + neo4j_mcp_reset(); + + var slots: [MAX_SESSIONS]c_int = undefined; + for (&slots, 0..) |*s, i| { + _ = i; + s.* = neo4j_mcp_session_open(); + try std.testing.expect(s.* >= 0); + } + + // Next open should fail + try std.testing.expectEqual(@as(c_int, -1), neo4j_mcp_session_open()); + + // Free one and try again + try std.testing.expectEqual(@as(c_int, 0), neo4j_mcp_session_close(slots[0])); + const new_slot = neo4j_mcp_session_open(); + try std.testing.expect(new_slot >= 0); +} + +test "action requires connection" { + // Actions that require connection + try std.testing.expectEqual(@as(c_int, 1), neo4j_mcp_action_requires_connection(3)); // cypher_query + try std.testing.expectEqual(@as(c_int, 1), neo4j_mcp_action_requires_connection(4)); // explain_query + try std.testing.expectEqual(@as(c_int, 1), neo4j_mcp_action_requires_connection(5)); // profile_query + try std.testing.expectEqual(@as(c_int, 1), neo4j_mcp_action_requires_connection(6)); // create_node + try std.testing.expectEqual(@as(c_int, 1), neo4j_mcp_action_requires_connection(7)); // get_node + try std.testing.expectEqual(@as(c_int, 1), neo4j_mcp_action_requires_connection(10)); // create_relationship + try std.testing.expectEqual(@as(c_int, 1), neo4j_mcp_action_requires_connection(13)); // list_labels + try std.testing.expectEqual(@as(c_int, 1), neo4j_mcp_action_requires_connection(14)); // list_relationship_types + try std.testing.expectEqual(@as(c_int, 1), neo4j_mcp_action_requires_connection(15)); // list_property_keys + + // Actions that do NOT require connection + try std.testing.expectEqual(@as(c_int, 0), neo4j_mcp_action_requires_connection(0)); // list_databases + try std.testing.expectEqual(@as(c_int, 0), neo4j_mcp_action_requires_connection(1)); // create_database + try std.testing.expectEqual(@as(c_int, 0), neo4j_mcp_action_requires_connection(2)); // drop_database + try std.testing.expectEqual(@as(c_int, 0), neo4j_mcp_action_requires_connection(99)); // out of range +} + +// ═══════════════════════════════════════════════════════════════════════ +// ADR-0006 invoke dispatch tests +// ═══════════════════════════════════════════════════════════════════════ + +test "boj_cartridge_name returns neo4j-mcp" { + const n = std.mem.span(boj_cartridge_name()); + try std.testing.expectEqualStrings("neo4j-mcp", n); +} + +test "boj_cartridge_init returns 0" { + try std.testing.expectEqual(@as(c_int, 0), boj_cartridge_init()); +} + +test "invoke: each declared tool succeeds" { + var buf: [256]u8 = undefined; + const tools = [_][]const u8{ + "neo4j_connect", + "neo4j_disconnect", + "neo4j_query", + "neo4j_write", + "neo4j_schema", + }; + for (tools) |t| { + var len: usize = buf.len; + const rc = boj_cartridge_invoke(t.ptr, "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, 0), rc); + try std.testing.expect(std.mem.indexOf(u8, buf[0..len], "result") != null); + } +} + +test "invoke: unknown tool returns -1" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("nope", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -1), rc); +} + +test "invoke: buffer too small returns -3" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("neo4j_connect", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -3), rc); + try std.testing.expect(len > 4); +} diff --git a/cartridges/domains/database/neo4j-mcp/minter.toml b/cartridges/domains/database/neo4j-mcp/minter.toml new file mode 100644 index 0000000..cf37c44 --- /dev/null +++ b/cartridges/domains/database/neo4j-mcp/minter.toml @@ -0,0 +1,16 @@ +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> + +name = "neo4j-mcp" +description = "Neo4j graph database MCP cartridge β€” Cypher queries, nodes, relationships, schema" +version = "0.1.0" +domain = "Database" +protocols = [ + "MCP", + "REST", +] +tier = "Ayo" +backend = "universal" +generate_panel = true +auth = "basic" +api_base = "https://{host}:7474/" diff --git a/cartridges/domains/database/neo4j-mcp/mod.js b/cartridges/domains/database/neo4j-mcp/mod.js new file mode 100644 index 0000000..d984e23 --- /dev/null +++ b/cartridges/domains/database/neo4j-mcp/mod.js @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// neo4j-mcp/mod.js β€” Neo4j graph database query and write +// +// Delegates to backend at http://127.0.0.1:7734 (override with NEO4J_BACKEND_URL). + +const BASE_URL = Deno.env.get("NEO4J_BACKEND_URL") ?? "http://127.0.0.1:7734"; +const TIMEOUT_MS = 15_000; + +async function post(path, payload) { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), TIMEOUT_MS); + try { + const r = await fetch(`${BASE_URL}${path}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + signal: ctrl.signal, + }); + const data = await r.json().catch(() => ({ success: false, error: "non-JSON response" })); + return { status: r.status, data }; + } catch (e) { + if (e.name === "AbortError") return { status: 504, data: { success: false, error: "neo4j-mcp backend timed out" } }; + return { status: 503, data: { success: false, error: `neo4j-mcp backend unavailable: ${e.message}` } }; + } finally { clearTimeout(t); } +} + +async function get(path) { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), TIMEOUT_MS); + try { + const r = await fetch(`${BASE_URL}${path}`, { method: "GET", signal: ctrl.signal }); + const data = await r.json().catch(() => ({ success: false, error: "non-JSON response" })); + return { status: r.status, data }; + } catch (e) { + if (e.name === "AbortError") return { status: 504, data: { success: false, error: "neo4j-mcp backend timed out" } }; + return { status: 503, data: { success: false, error: `neo4j-mcp backend unavailable: ${e.message}` } }; + } finally { clearTimeout(t); } +} + +export async function handleTool(toolName, args) { + switch (toolName) { + case "neo4j_connect": + return post("/api/v1/neo4j_connect", args ?? {}); + case "neo4j_disconnect": + return post("/api/v1/neo4j_disconnect", args ?? {}); + case "neo4j_query": + return post("/api/v1/neo4j_query", args ?? {}); + case "neo4j_write": + return post("/api/v1/neo4j_write", args ?? {}); + case "neo4j_schema": + return post("/api/v1/neo4j_schema", args ?? {}); + default: + return { status: 404, data: { error: `Unknown tool: ${toolName}` } }; + } +} diff --git a/cartridges/domains/database/neo4j-mcp/panels/manifest.json b/cartridges/domains/database/neo4j-mcp/panels/manifest.json new file mode 100644 index 0000000..7e540b3 --- /dev/null +++ b/cartridges/domains/database/neo4j-mcp/panels/manifest.json @@ -0,0 +1,89 @@ +{ + "$schema": "https://panll.dev/schemas/panel-manifest/v1.json", + "spdx": "MPL-2.0", + "cartridge": "neo4j-mcp", + "domain": "Database", + "version": "0.1.0", + "panels": [ + { + "id": "neo4j-connection-status", + "title": "Connection Status", + "description": "Current Neo4j connection state β€” disconnected, connected, query running, or error", + "type": "status-indicator", + "data_source": { + "endpoint": "/neo4j/connection/status", + "method": "GET", + "refresh_interval_ms": 10000 + }, + "widgets": [ + { + "type": "state-badge", + "field": "connection_state", + "states": { + "connected": { "color": "#2ecc71", "icon": "zap" }, + "disconnected": { "color": "#95a5a6", "icon": "unplug" }, + "query_running": { "color": "#3498db", "icon": "loader" }, + "error": { "color": "#e74c3c", "icon": "alert-triangle" } + } + } + ] + }, + { + "id": "neo4j-query-count", + "title": "Query Count", + "description": "Total number of Cypher queries executed across all sessions", + "type": "metric", + "data_source": { + "endpoint": "/neo4j/metrics", + "method": "GET", + "refresh_interval_ms": 15000 + }, + "widgets": [ + { + "type": "counter", + "field": "total_query_count", + "label": "Cypher Queries", + "icon": "file-search" + } + ] + }, + { + "id": "neo4j-node-count", + "title": "Node Count", + "description": "Total number of nodes in the active Neo4j database", + "type": "metric", + "data_source": { + "endpoint": "/neo4j/nodes/count", + "method": "GET", + "refresh_interval_ms": 30000 + }, + "widgets": [ + { + "type": "counter", + "field": "node_count", + "label": "Nodes", + "icon": "circle-dot" + } + ] + }, + { + "id": "neo4j-relationship-count", + "title": "Relationship Count", + "description": "Total number of relationships in the active Neo4j database", + "type": "metric", + "data_source": { + "endpoint": "/neo4j/relationships/count", + "method": "GET", + "refresh_interval_ms": 30000 + }, + "widgets": [ + { + "type": "counter", + "field": "relationship_count", + "label": "Relationships", + "icon": "git-branch" + } + ] + } + ] +} diff --git a/cartridges/domains/database/neo4j-mcp/tests/integration_test.sh b/cartridges/domains/database/neo4j-mcp/tests/integration_test.sh new file mode 100755 index 0000000..aaf5e31 --- /dev/null +++ b/cartridges/domains/database/neo4j-mcp/tests/integration_test.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Integration tests for neo4j-mcp cartridge. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CART_DIR="$(dirname "$SCRIPT_DIR")" +FFI_DIR="$CART_DIR/ffi" + +echo "=== neo4j-mcp integration tests ===" + +# Build FFI +echo "[1/3] Building FFI..." +cd "$FFI_DIR" && zig build 2>&1 + +# Run Zig unit tests +echo "[2/3] Running FFI unit tests..." +cd "$FFI_DIR" && zig build test 2>&1 + +# Validate Idris2 ABI (if idris2 available) +echo "[3/3] Checking ABI..." +if command -v idris2 &>/dev/null; then + cd "$CART_DIR/abi" && idris2 --check Neo4jMcp.SafeDatabase 2>&1 + echo " ABI: OK" +else + echo " ABI: SKIPPED (idris2 not in PATH)" +fi + +echo "" +echo "All tests passed for neo4j-mcp!" diff --git a/cartridges/domains/database/neon-mcp/README.adoc b/cartridges/domains/database/neon-mcp/README.adoc new file mode 100644 index 0000000..bc4c1e0 --- /dev/null +++ b/cartridges/domains/database/neon-mcp/README.adoc @@ -0,0 +1,67 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> + += neon-mcp +Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk> +:spdx: MPL-2.0 +:tier: Ayo +:domain: Database +:protocols: MCP, REST + +== Overview + +Neon serverless Postgres MCP cartridge. Provides type-safe access to the +https://console.neon.tech/api/v2/[Neon REST API] covering projects, branches, +endpoints, databases, roles, and operations. Auth via Bearer token (Neon API +key). Endpoints auto-suspend when idle (serverless Postgres semantics). + +=== Actions + +ListProjects, GetProject, CreateProject, DeleteProject, ListBranches, +CreateBranch, DeleteBranch, GetConnectionString, Query, ListDatabases, +ListRoles, GetEndpoint, StartEndpoint, SuspendEndpoint, ListOperations, +GetOperation. + +== Architecture + +[cols="1,1,2"] +|=== +| Layer | Language | Purpose + +| ABI +| Idris2 +| Formally verified state machine (Disconnected / Connected / QueryRunning / Error) + +| FFI +| Zig +| C-compatible implementation with thread-safe session pool + +| Adapter +| zig +| REST bridge to BoJ unified adapter protocol +|=== + +== Building + +[source,bash] +---- +# Build FFI shared library +cd ffi && zig build + +# Run FFI tests +cd ffi && zig build test + +# Type-check ABI +cd abi && idris2 --check NeonMcp.SafeDatabase +---- + +== Panels + +* Project count +* Branch count +* Endpoint status (active / idle / suspended) +* Query count + +== Status + +Development -- not yet ready for mounting. diff --git a/cartridges/domains/database/neon-mcp/abi/NeonMcp/SafeDatabase.idr b/cartridges/domains/database/neon-mcp/abi/NeonMcp/SafeDatabase.idr new file mode 100644 index 0000000..752c46f --- /dev/null +++ b/cartridges/domains/database/neon-mcp/abi/NeonMcp/SafeDatabase.idr @@ -0,0 +1,160 @@ +-- SPDX-License-Identifier: MPL-2.0 +-- Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +-- +-- NeonMcp.SafeDatabase β€” Type-safe ABI for the neon-mcp cartridge. +-- +-- Provides a formally verified state machine for Neon serverless Postgres +-- connections. Dependent-type proofs ensure only valid transitions can occur +-- at the FFI boundary. Neon actions cover the full REST API surface +-- (https://console.neon.tech/api/v2/). Auth via Bearer token (Neon API key). +-- Endpoints auto-suspend when idle (serverless Postgres semantics). + +module NeonMcp.SafeDatabase + +%default total + +-- --------------------------------------------------------------------------- +-- Connection state machine +-- --------------------------------------------------------------------------- + +||| Connection state for Neon serverless Postgres operations. +||| Endpoints auto-suspend, so Disconnected is the natural resting state. +public export +data ConnState = Disconnected | Connected | QueryRunning | Error + +||| Proof that a state transition is valid. +public export +data ValidTransition : ConnState -> ConnState -> Type where + Connect : ValidTransition Disconnected Connected + StartQuery : ValidTransition Connected QueryRunning + FinishQuery : ValidTransition QueryRunning Connected + Disconnect : ValidTransition Connected Disconnected + QueryFail : ValidTransition QueryRunning Error + ErrorRecover : ValidTransition Error Disconnected + +-- --------------------------------------------------------------------------- +-- C-ABI integer encoding +-- --------------------------------------------------------------------------- + +||| Encode connection state as C-compatible integer. +export +connStateToInt : ConnState -> Int +connStateToInt Disconnected = 0 +connStateToInt Connected = 1 +connStateToInt QueryRunning = 2 +connStateToInt Error = 3 + +||| Decode integer back to connection state. +export +intToConnState : Int -> Maybe ConnState +intToConnState 0 = Just Disconnected +intToConnState 1 = Just Connected +intToConnState 2 = Just QueryRunning +intToConnState 3 = Just Error +intToConnState _ = Nothing + +||| Check if a state transition is valid (C-ABI export). +||| Returns 1 for valid, 0 for invalid. +export +neon_mcp_can_transition : Int -> Int -> Int +neon_mcp_can_transition from to = + case (intToConnState from, intToConnState to) of + (Just Disconnected, Just Connected) => 1 + (Just Connected, Just QueryRunning) => 1 + (Just QueryRunning, Just Connected) => 1 + (Just Connected, Just Disconnected) => 1 + (Just QueryRunning, Just Error) => 1 + (Just Error, Just Disconnected) => 1 + _ => 0 + +-- --------------------------------------------------------------------------- +-- Neon actions (full REST API surface) +-- --------------------------------------------------------------------------- + +||| Actions supported by the Neon MCP cartridge. +||| Covers projects, branches, endpoints, databases, roles, and operations. +public export +data NeonAction + = ListProjects + | GetProject + | CreateProject + | DeleteProject + | ListBranches + | CreateBranch + | DeleteBranch + | GetConnectionString + | Query + | ListDatabases + | ListRoles + | GetEndpoint + | StartEndpoint + | SuspendEndpoint + | ListOperations + | GetOperation + +||| Encode action as C-compatible integer. +export +neonActionToInt : NeonAction -> Int +neonActionToInt ListProjects = 0 +neonActionToInt GetProject = 1 +neonActionToInt CreateProject = 2 +neonActionToInt DeleteProject = 3 +neonActionToInt ListBranches = 4 +neonActionToInt CreateBranch = 5 +neonActionToInt DeleteBranch = 6 +neonActionToInt GetConnectionString = 7 +neonActionToInt Query = 8 +neonActionToInt ListDatabases = 9 +neonActionToInt ListRoles = 10 +neonActionToInt GetEndpoint = 11 +neonActionToInt StartEndpoint = 12 +neonActionToInt SuspendEndpoint = 13 +neonActionToInt ListOperations = 14 +neonActionToInt GetOperation = 15 + +||| Decode integer back to action. +export +intToNeonAction : Int -> Maybe NeonAction +intToNeonAction 0 = Just ListProjects +intToNeonAction 1 = Just GetProject +intToNeonAction 2 = Just CreateProject +intToNeonAction 3 = Just DeleteProject +intToNeonAction 4 = Just ListBranches +intToNeonAction 5 = Just CreateBranch +intToNeonAction 6 = Just DeleteBranch +intToNeonAction 7 = Just GetConnectionString +intToNeonAction 8 = Just Query +intToNeonAction 9 = Just ListDatabases +intToNeonAction 10 = Just ListRoles +intToNeonAction 11 = Just GetEndpoint +intToNeonAction 12 = Just StartEndpoint +intToNeonAction 13 = Just SuspendEndpoint +intToNeonAction 14 = Just ListOperations +intToNeonAction 15 = Just GetOperation +intToNeonAction _ = Nothing + +||| Check whether an action requires an active connection. +export +actionRequiresConnection : NeonAction -> Bool +actionRequiresConnection Query = True +actionRequiresConnection GetConnectionString = True +actionRequiresConnection _ = False + +||| Total number of actions exposed by this cartridge. +export +actionCount : Nat +actionCount = 16 + +-- --------------------------------------------------------------------------- +-- Auth configuration +-- --------------------------------------------------------------------------- + +||| Authentication method for Neon REST API. +||| Bearer token using a Neon API key. +public export +data NeonAuth = BearerToken + +||| Base URL for Neon REST API. +export +neonApiBase : String +neonApiBase = "https://console.neon.tech/api/v2/" diff --git a/cartridges/domains/database/neon-mcp/abi/README.adoc b/cartridges/domains/database/neon-mcp/abi/README.adoc new file mode 100644 index 0000000..ac3a71f --- /dev/null +++ b/cartridges/domains/database/neon-mcp/abi/README.adoc @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += neon-mcp / abi β€” Idris2 ABI layer +:orientation: shallow + +== Purpose + +Cartridge-local protocol types for `neon-mcp`. Defines the data, tool, and +transition vocabulary this cartridge speaks. Inherits safety lemmas and the +matrix-cell identifier scheme from the trunk ABI at `src/abi/`. + +== Files + +1 Idris2 module(s), ~160 lines total. Lead module: +`SafeDatabase.idr`. + +No `.ipkg` β€” consumed directly by wider BoJ callers; type-checks inside the +trunk `boj.ipkg`. + +== Invariants + +* `%default total` at every module top. +* Zero `believe_me`, `assert_total`, `postulate`, `Admitted`, or + `sorry` (the trunk holds the four declared `believe_me` sites; leaf + cartridges carry none). + +== Test/proof surface + +Thin module(s), no inline tests. Proof surface = type-check success under the +trunk `idris2 --check`. + +== Read-first + +. `SafeDatabase.idr` β€” cartridge-local data records and enums. +. Trunk `src/abi/Boj/Catalogue.idr` for how this cell participates in the + 2D matrix. diff --git a/cartridges/domains/database/neon-mcp/abi/neon_mcp.ipkg b/cartridges/domains/database/neon-mcp/abi/neon_mcp.ipkg new file mode 100644 index 0000000..0f3aa2e --- /dev/null +++ b/cartridges/domains/database/neon-mcp/abi/neon_mcp.ipkg @@ -0,0 +1,11 @@ +-- SPDX-License-Identifier: MPL-2.0 +-- Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +package neon_mcp + +version = "0.1.0" +authors = "Jonathan D.A. Jewell" +brief = "Neon serverless Postgres MCP cartridge β€” type-safe ABI layer" + +depends = base + +modules = NeonMcp.SafeDatabase diff --git a/cartridges/domains/database/neon-mcp/adapter/README.adoc b/cartridges/domains/database/neon-mcp/adapter/README.adoc new file mode 100644 index 0000000..4b9c289 --- /dev/null +++ b/cartridges/domains/database/neon-mcp/adapter/README.adoc @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += neon-mcp / adapter β€” protocol bridge +:orientation: shallow + +== Purpose + +Exposes this cartridge's FFI over the wire protocols declared in +`cartridge.json`. Dispatches tool invocations to the C-ABI exports at +`../ffi/` and formats responses per protocol. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” depends on the sibling `ffi` target. +| `neon_mcp_adapter.zig` | Protocol dispatch (112 lines). Tool catalogue matches `cartridge.json`. +| `SIDELINED-*.v.adoc` | zig predecessor(s). Not built β€” zig banned 2026-04-10. +|=== + +== Invariants + +* **Stateless** β€” all state lives behind the FFI, never in the adapter. +* **Response passthrough** β€” whatever the FFI returns goes back to the wire + unmodified (no embellishment, no silent recovery). +* **Cartridge.json is source of truth** for the tool catalogue; drift between + adapter and manifest is a CI failure. + +== Test/proof surface + +No inline tests at the adapter layer β€” protocol routing is exercised by the +trunk seam tests at `ffi/zig/src/seams.zig`. + +== Read-first + +. `neon_mcp_adapter.zig` β€” dispatch function and per-protocol routers. +. `../cartridge.json` β€” the tool manifest the dispatcher mirrors. diff --git a/cartridges/domains/database/neon-mcp/adapter/build.zig b/cartridges/domains/database/neon-mcp/adapter/build.zig new file mode 100644 index 0000000..f955361 --- /dev/null +++ b/cartridges/domains/database/neon-mcp/adapter/build.zig @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// neon-mcp/adapter/build.zig + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); const optimize = b.standardOptimizeOption(.{}); + const ffi_mod = b.createModule(.{ .root_source_file = b.path("../ffi/neon_mcp_ffi.zig"), .target = target, .optimize = optimize }); + const adapter = b.addExecutable(.{ .name = "neon_mcp_adapter", .root_source_file = b.path("neon_mcp_adapter.zig"), .target = target, .optimize = optimize }); + adapter.root_module.addImport("neon_mcp_ffi", ffi_mod); b.installArtifact(adapter); + const rs = b.step("run", "Run neon-mcp adapter"); rs.dependOn(&b.addRunArtifact(adapter).step); + const tests = b.addTest(.{ .root_source_file = b.path("neon_mcp_adapter.zig"), .target = target, .optimize = optimize }); + tests.root_module.addImport("neon_mcp_ffi", ffi_mod); + const ts = b.step("test", "Test neon-mcp adapter"); ts.dependOn(&b.addRunArtifact(tests).step); +} diff --git a/cartridges/domains/database/neon-mcp/adapter/neon_mcp_adapter.zig b/cartridges/domains/database/neon-mcp/adapter/neon_mcp_adapter.zig new file mode 100644 index 0000000..461f75b --- /dev/null +++ b/cartridges/domains/database/neon-mcp/adapter/neon_mcp_adapter.zig @@ -0,0 +1,112 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// neon-mcp/adapter/neon_mcp_adapter.zig -- Unified three-protocol adapter. +// Replaces banned neon_mcp_adapter.v (zig, removed 2026-04-12). +// REST:9172 gRPC:9173 GraphQL:9174 +// Tools: neon_connect, neon_query, neon_execute, neon_list_branches... + +const std = @import("std"); +const ffi = @import("neon_mcp_ffi"); + +const REST_PORT: u16 = 9172; +const GRPC_PORT: u16 = 9173; +const GQL_PORT: u16 = 9174; +const MAX_CONN_BUF: usize = 16 * 1024; + +fn okJson(buf: []u8, msg: []const u8) []u8 { + const n = std.fmt.bufPrint(buf, "{\"success\":true,\"message\":\"{s}\"}", .{msg}) catch return buf[0..0]; return n; +} +fn errJson(buf: []u8, msg: []const u8) []u8 { + const n = std.fmt.bufPrint(buf, "{\"success\":false,\"error\":\"{s}\"}", .{msg}) catch return buf[0..0]; return n; +} +fn statusJson(buf: []u8) []u8 { + const n = std.fmt.bufPrint(buf, "{\"success\":true,\"state\":\"ready\",\"service\":\"neon-mcp\"}", .{}) catch return buf[0..0]; return n; +} + +const Response = struct { status: u16, body: []u8 }; + +fn dispatch(tool: []const u8, body: []const u8, resp: []u8) Response { + _ = body; + if (std.mem.eql(u8, tool, "neon_connect")) return .{ .status = 200, .body = okJson(resp, "neon_connect forwarded") }; + if (std.mem.eql(u8, tool, "neon_query")) return .{ .status = 200, .body = okJson(resp, "neon_query forwarded") }; + if (std.mem.eql(u8, tool, "neon_execute")) return .{ .status = 200, .body = okJson(resp, "neon_execute forwarded") }; + if (std.mem.eql(u8, tool, "neon_list_branches")) return .{ .status = 200, .body = okJson(resp, "neon_list_branches forwarded") }; + if (std.mem.eql(u8, tool, "neon_create_branch")) return .{ .status = 200, .body = okJson(resp, "neon_create_branch forwarded") }; + if (std.mem.eql(u8, tool, "neon_list_tables")) return .{ .status = 200, .body = okJson(resp, "neon_list_tables forwarded") }; + if (std.mem.eql(u8, tool, "neon_disconnect")) return .{ .status = 200, .body = okJson(resp, "neon_disconnect forwarded") }; + if (std.mem.eql(u8, tool, "status") or std.mem.eql(u8, tool, "health")) return .{ .status = 200, .body = statusJson(resp) }; + return .{ .status = 404, .body = errJson(resp, "Unknown tool") }; +} + +fn dispatchRest(path: []const u8, body: []const u8, resp: []u8) Response { + if (std.mem.startsWith(u8, path, "/tools/")) return dispatch(path["/tools/".len..], body, resp); + if (std.mem.eql(u8, path, "/status") or std.mem.eql(u8, path, "/health")) return .{ .status = 200, .body = statusJson(resp) }; + return .{ .status = 404, .body = errJson(resp, "Not found") }; +} + +fn dispatchGrpc(path: []const u8, body: []const u8, resp: []u8) Response { + const prefix = "/NeonMcpservice/"; + if (!std.mem.startsWith(u8, path, prefix)) return .{ .status = 404, .body = errJson(resp, "Not a recognized gRPC path") }; + const method = path[prefix.len..]; + const tool = blk: { + if (std.mem.eql(u8, method, "neon_connect")) break :blk "neon_connect"; + if (std.mem.eql(u8, method, "neon_query")) break :blk "neon_query"; + if (std.mem.eql(u8, method, "neon_execute")) break :blk "neon_execute"; + if (std.mem.eql(u8, method, "neon_list_branches")) break :blk "neon_list_branches"; + if (std.mem.eql(u8, method, "neon_create_branch")) break :blk "neon_create_branch"; + if (std.mem.eql(u8, method, "neon_list_tables")) break :blk "neon_list_tables"; + if (std.mem.eql(u8, method, "neon_disconnect")) break :blk "neon_disconnect"; + return .{ .status = 404, .body = errJson(resp, "Unknown gRPC method") }; + }; + return dispatch(tool, body, resp); +} + +fn dispatchGraphql(body: []const u8, resp: []u8) Response { + if (std.mem.indexOf(u8, body, "__schema") != null) return .{ .status = 200, .body = okJson(resp, "schema not supported") }; + if (std.mem.indexOf(u8, body, "connect") != null) return dispatch("neon_connect", body, resp); + if (std.mem.indexOf(u8, body, "query") != null) return dispatch("neon_query", body, resp); + if (std.mem.indexOf(u8, body, "execute") != null) return dispatch("neon_execute", body, resp); + if (std.mem.indexOf(u8, body, "list_branches") != null) return dispatch("neon_list_branches", body, resp); + if (std.mem.indexOf(u8, body, "create_branch") != null) return dispatch("neon_create_branch", body, resp); + if (std.mem.indexOf(u8, body, "list_tables") != null) return dispatch("neon_list_tables", body, resp); + if (std.mem.indexOf(u8, body, "disconnect") != null) return dispatch("neon_disconnect", body, resp); + return .{ .status = 200, .body = errJson(resp, "Unrecognised GraphQL operation") }; +} + +const Protocol = enum { rest, grpc, graphql }; + +fn handleConnection(conn: std.net.Server.Connection, proto: Protocol) void { + defer conn.stream.close(); + var in_buf: [MAX_CONN_BUF]u8 = undefined; + const n = conn.stream.read(&in_buf) catch return; + const req = in_buf[0..n]; + var path: []const u8 = "/"; var body: []const u8 = ""; + if (n > 4) { + const le = std.mem.indexOf(u8, req, "\r\n") orelse req.len; + const fl = req[0..le]; const sp1 = std.mem.indexOfScalar(u8, fl, ' ') orelse 0; + const ro = fl[sp1+1..]; const sp2 = std.mem.indexOfScalar(u8, ro, ' ') orelse ro.len; + path = ro[0..sp2]; + const bs = std.mem.indexOf(u8, req, "\r\n\r\n") orelse n; body = req[@min(bs+4,n)..]; + } + var rb: [MAX_CONN_BUF]u8 = undefined; + const result = switch (proto) { .rest => dispatchRest(path, body, &rb), .grpc => dispatchGrpc(path, body, &rb), .graphql => dispatchGraphql(body, &rb) }; + const ct: []const u8 = if (proto == .grpc) "application/grpc+json" else "application/json"; + var hb: [256]u8 = undefined; + const hdr = std.fmt.bufPrint(&hb, "HTTP/1.1 {d} OK\r\nContent-Type: {s}\r\nContent-Length: {d}\r\nConnection: close\r\n\r\n", .{ result.status, ct, result.body.len }) catch return; + _ = conn.stream.writeAll(hdr) catch return; _ = conn.stream.writeAll(result.body) catch return; +} + +fn listenLoop(port: u16, proto: Protocol) void { + const addr = std.net.Address.initIp4(.{ 127, 0, 0, 1 }, port) catch return; + var srv = addr.listen(.{ .reuse_address = true }) catch return; defer srv.deinit(); + while (true) { const conn = srv.accept() catch continue; handleConnection(conn, proto); } +} + +pub fn main() !void { + ffi.neon_mcp_init(); + const t1 = try std.Thread.spawn(.{}, listenLoop, .{ REST_PORT, Protocol.rest }); + const t2 = try std.Thread.spawn(.{}, listenLoop, .{ GRPC_PORT, Protocol.grpc }); + const t3 = try std.Thread.spawn(.{}, listenLoop, .{ GQL_PORT, Protocol.graphql }); + t1.join(); t2.join(); t3.join(); +} diff --git a/cartridges/domains/database/neon-mcp/benchmarks/quick-bench.sh b/cartridges/domains/database/neon-mcp/benchmarks/quick-bench.sh new file mode 100755 index 0000000..7d346d7 --- /dev/null +++ b/cartridges/domains/database/neon-mcp/benchmarks/quick-bench.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Benchmarks for neon-mcp cartridge. +set -euo pipefail + +echo "=== neon-mcp benchmarks ===" + +cd "$(dirname "${BASH_SOURCE[0]}")/../ffi" + +# Build with ReleaseFast for benchmarking +zig build -Doptimize=ReleaseFast 2>&1 + +echo "Session open/close cycle (1000 iterations):" +time for i in $(seq 1 1000); do + # This would call the FFI benchmark binary + true +done + +echo "" +echo "Benchmark placeholder β€” implement real benchmarks in Zig test blocks" +echo "or via the zig adapter HTTP benchmark tool." diff --git a/cartridges/domains/database/neon-mcp/cartridge.json b/cartridges/domains/database/neon-mcp/cartridge.json new file mode 100644 index 0000000..9e303f4 --- /dev/null +++ b/cartridges/domains/database/neon-mcp/cartridge.json @@ -0,0 +1,171 @@ +{ + "$schema": "https://boj.dev/schemas/cartridge/v1.json", + "spdx": "MPL-2.0", + "copyright": "Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath)", + "name": "neon-mcp", + "version": "0.1.0", + "description": "Neon serverless Postgres gateway. Branch management, query execution, and connection pooling for Neon projects.", + "domain": "Database", + "tier": "Ayo", + "protocols": [ + "MCP", + "REST" + ], + "auth": { + "method": "none", + "env_var": null, + "credential_source": null + }, + "api": { + "base_url": "local://neon-mcp", + "content_type": "application/json" + }, + "tools": [ + { + "name": "neon_connect", + "description": "Connect to a Neon database branch. Returns a session slot.", + "inputSchema": { + "type": "object", + "properties": { + "connection_string": { + "type": "string", + "description": "Neon connection string (postgresql://...)" + } + }, + "required": [ + "connection_string" + ] + } + }, + { + "name": "neon_query", + "description": "Execute a SQL query.", + "inputSchema": { + "type": "object", + "properties": { + "slot": { + "type": "integer", + "description": "Session slot from neon_connect" + }, + "sql": { + "type": "string", + "description": "SQL query" + }, + "params": { + "type": "string", + "description": "Query parameters as JSON array" + } + }, + "required": [ + "slot", + "sql" + ] + } + }, + { + "name": "neon_execute", + "description": "Execute a non-returning SQL statement.", + "inputSchema": { + "type": "object", + "properties": { + "slot": { + "type": "integer", + "description": "Session slot" + }, + "sql": { + "type": "string", + "description": "SQL statement" + } + }, + "required": [ + "slot", + "sql" + ] + } + }, + { + "name": "neon_list_branches", + "description": "List branches in a Neon project.", + "inputSchema": { + "type": "object", + "properties": { + "project_id": { + "type": "string", + "description": "Neon project ID" + } + }, + "required": [ + "project_id" + ] + } + }, + { + "name": "neon_create_branch", + "description": "Create a new branch in a Neon project.", + "inputSchema": { + "type": "object", + "properties": { + "project_id": { + "type": "string", + "description": "Neon project ID" + }, + "branch_name": { + "type": "string", + "description": "New branch name" + }, + "parent_branch": { + "type": "string", + "description": "Parent branch name (default: main)" + } + }, + "required": [ + "project_id", + "branch_name" + ] + } + }, + { + "name": "neon_list_tables", + "description": "List tables in the connected database.", + "inputSchema": { + "type": "object", + "properties": { + "slot": { + "type": "integer", + "description": "Session slot" + } + }, + "required": [ + "slot" + ] + } + }, + { + "name": "neon_disconnect", + "description": "Disconnect and release a session slot.", + "inputSchema": { + "type": "object", + "properties": { + "slot": { + "type": "integer", + "description": "Session slot to release" + } + }, + "required": [ + "slot" + ] + } + } + ], + "ffi": { + "so_path": "ffi/zig-out/lib/libneon_mcp.so", + "abi_version": "ADR-0006", + "symbols": [ + "boj_cartridge_init", + "boj_cartridge_deinit", + "boj_cartridge_name", + "boj_cartridge_version", + "boj_cartridge_invoke" + ] + } +} diff --git a/cartridges/domains/database/neon-mcp/ffi/README.adoc b/cartridges/domains/database/neon-mcp/ffi/README.adoc new file mode 100644 index 0000000..26b39c8 --- /dev/null +++ b/cartridges/domains/database/neon-mcp/ffi/README.adoc @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += neon-mcp / ffi β€” Zig FFI layer +:orientation: shallow + +== Purpose + +C-ABI exports bridging this cartridge's Idris2 ABI to the runtime. Consumed by +the adapter for protocol dispatch and by the trunk loader for mount. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” produces `zig-out/lib/libneon.so`. +| `neon_mcp_ffi.zig` | C-ABI exports (10 exports, 5 inline tests, 293 lines). +|=== + +== Invariants + +* **Null-pointer rejection** on `[*c]const u8` arguments before any + dereference. +* **Negative return values** carry documented error meanings; zero is + success (matching the trunk error-code convention). +* **No allocator leaks** β€” every allocation path pairs with `defer free`. + +== Test/proof surface + +5 inline `test "..."` block(s) in `neon_mcp_ffi.zig`. Run with +`cd ffi && zig build test`. + +== Read-first + +. `neon_mcp_ffi.zig` β€” C-ABI signatures and guards. +. Trunk `ffi/zig/src/catalogue.zig` for how this `.so` is loaded. diff --git a/cartridges/domains/database/neon-mcp/ffi/build.zig b/cartridges/domains/database/neon-mcp/ffi/build.zig new file mode 100644 index 0000000..3dc033c --- /dev/null +++ b/cartridges/domains/database/neon-mcp/ffi/build.zig @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + // Module + // Shared ADR-0006 invoke-shim module (relative path up to boj-server trunk). + + const shim_mod = b.addModule("cartridge_shim", .{ + + .root_source_file = b.path("../../../ffi/zig/src/cartridge_shim.zig"), + + .target = target, + + .optimize = optimize, + + }); + + const ffi_mod = b.addModule("neon_mcp", .{ + .root_source_file = b.path("neon_mcp_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + ffi_mod.addImport("cartridge_shim", shim_mod); + + // Shared library + const lib = b.addLibrary(.{ + .name = "neon_mcp", + .root_module = ffi_mod, + .linkage = .dynamic, + }); + lib.linkLibC(); + b.installArtifact(lib); + + // Tests + const tests = b.addTest(.{ + .root_module = ffi_mod, + }); + tests.linkLibC(); + + const run_tests = b.addRunArtifact(tests); + const test_step = b.step("test", "Run FFI tests"); + test_step.dependOn(&run_tests.step); +} diff --git a/cartridges/domains/database/neon-mcp/ffi/cartridge_shim.zig b/cartridges/domains/database/neon-mcp/ffi/cartridge_shim.zig new file mode 100644 index 0000000..0e399db --- /dev/null +++ b/cartridges/domains/database/neon-mcp/ffi/cartridge_shim.zig @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// cartridge_shim.zig β€” Shared helpers for the ADR-0006 five-symbol +// cartridge ABI (`boj_cartridge_init / deinit / name / version / invoke`). +// +// The shim centralises the seven-code return convention, NUL-argument +// guards, tool-name comparison, and the buffer-too-small path so each +// cartridge's `boj_cartridge_invoke` can stay short β€” typically a tool +// table plus `shim.writeResult(...)`. +// +// Cartridges import this file by relative path (no build-graph change +// needed). Example: +// +// const shim = @import("../../../ffi/zig/src/cartridge_shim.zig"); +// +// export fn boj_cartridge_invoke( +// tool_name: [*c]const u8, +// json_args: [*c]const u8, +// out_buf: [*c]u8, +// in_out_len: [*c]usize, +// ) callconv(.c) i32 { +// _ = json_args; +// if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; +// const body = if (shim.toolIs(tool_name, "foo")) "{\"result\":{}}" +// else return shim.RC_UNKNOWN_TOOL; +// return shim.writeResult(out_buf, in_out_len, body); +// } + +const std = @import("std"); + +// ── Return codes (ADR-0006 Β§Return codes) ──────────────────────────── +// +// Frozen by ADR-0006. New failure modes compose these via the error +// JSON body β€” the integer surface does not grow without a follow-up ADR. + +pub const RC_SUCCESS: i32 = 0; +pub const RC_UNKNOWN_TOOL: i32 = -1; +pub const RC_BAD_ARGS: i32 = -2; +pub const RC_BUFFER_TOO_SMALL: i32 = -3; +pub const RC_RUNTIME_ERROR: i32 = -4; +pub const RC_PANIC: i32 = -5; +pub const RC_AUTH_DENIED: i32 = -6; + +// ── Invoke-path helpers ────────────────────────────────────────────── + +/// True if any of the three mandatory `boj_cartridge_invoke` output-path +/// pointers is null. Use at the top of every invoke to short-circuit to +/// `RC_BAD_ARGS`. +pub fn invokeArgsNull( + tool_name: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) bool { + return tool_name == null or out_buf == null or in_out_len == null; +} + +/// Compare a C-NUL-terminated tool-name pointer against a Zig string +/// literal. Caller must have already verified `tool_name` is non-null +/// (usually via `invokeArgsNull`). +pub fn toolIs(tool_name: [*c]const u8, expected: []const u8) bool { + const s = std.mem.span(@as([*:0]const u8, @ptrCast(tool_name))); + return std.mem.eql(u8, s, expected); +} + +/// Copy `body` into `out_buf[0..*in_out_len]` (as a capacity) and update +/// `*in_out_len` to the number of bytes written. Returns `RC_SUCCESS`. +/// +/// If `body.len` exceeds the current capacity stored in `*in_out_len`, +/// sets `*in_out_len` to the required size and returns +/// `RC_BUFFER_TOO_SMALL` β€” the caller is then expected to re-allocate +/// and retry, per ADR-0006 Β§Memory ownership. +/// +/// Caller must have already verified that `out_buf` and `in_out_len` +/// are non-null. +pub fn writeResult( + out_buf: [*c]u8, + in_out_len: [*c]usize, + body: []const u8, +) i32 { + const cap = in_out_len.*; + if (body.len > cap) { + in_out_len.* = body.len; + return RC_BUFFER_TOO_SMALL; + } + @memcpy(out_buf[0..body.len], body); + in_out_len.* = body.len; + return RC_SUCCESS; +} + +// ── Tests ──────────────────────────────────────────────────────────── + +test "writeResult: body fits, writes and sets length" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); + try std.testing.expectEqualStrings("hello", buf[0..len]); +} + +test "writeResult: too small returns -3 and sets required length" { + var buf: [2]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_BUFFER_TOO_SMALL, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: exact-fit succeeds" { + var buf: [5]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: empty body" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, ""); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 0), len); +} + +test "toolIs: matches and rejects" { + const name: [*:0]const u8 = "foo"; + try std.testing.expect(toolIs(@ptrCast(name), "foo")); + try std.testing.expect(!toolIs(@ptrCast(name), "bar")); + try std.testing.expect(!toolIs(@ptrCast(name), "foobar")); + try std.testing.expect(!toolIs(@ptrCast(name), "fo")); +} + +test "invokeArgsNull: detects each null slot" { + var buf: [4]u8 = undefined; + var len: usize = 4; + const name: [*:0]const u8 = "x"; + try std.testing.expect(!invokeArgsNull(@ptrCast(name), &buf, &len)); + try std.testing.expect(invokeArgsNull(null, &buf, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), null, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), &buf, null)); +} diff --git a/cartridges/domains/database/neon-mcp/ffi/neon_mcp_ffi.zig b/cartridges/domains/database/neon-mcp/ffi/neon_mcp_ffi.zig new file mode 100644 index 0000000..980260e --- /dev/null +++ b/cartridges/domains/database/neon-mcp/ffi/neon_mcp_ffi.zig @@ -0,0 +1,393 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// neon_mcp_ffi.zig β€” C-ABI FFI implementation for the neon-mcp cartridge. +// +// Implements the connection state machine defined in the Idris2 ABI layer +// (NeonMcp.SafeDatabase). Thread-safe via std.Thread.Mutex. No heap +// allocations for results. State machine: Disconnected | Connected | +// QueryRunning | Error. Designed for serverless Postgres semantics where +// endpoints auto-suspend when idle. + +const std = @import("std"); + +// --------------------------------------------------------------------------- +// Connection state machine (matches Idris2 ABI exactly) +// --------------------------------------------------------------------------- + +/// Connection states for Neon serverless Postgres. +pub const ConnState = enum(c_int) { + disconnected = 0, + connected = 1, + query_running = 2, + err = 3, +}; + +/// Neon REST API actions. +pub const NeonAction = enum(c_int) { + list_projects = 0, + get_project = 1, + create_project = 2, + delete_project = 3, + list_branches = 4, + create_branch = 5, + delete_branch = 6, + get_connection_string = 7, + query = 8, + list_databases = 9, + list_roles = 10, + get_endpoint = 11, + start_endpoint = 12, + suspend_endpoint = 13, + list_operations = 14, + get_operation = 15, +}; + +/// Check whether a state transition is valid per the ABI specification. +fn isValidTransition(from: ConnState, to: ConnState) bool { + return switch (from) { + .disconnected => to == .connected, + .connected => to == .query_running or to == .disconnected, + .query_running => to == .connected or to == .err, + .err => to == .disconnected, + }; +} + +// --------------------------------------------------------------------------- +// Session slots (thread-safe, fixed-size pool) +// --------------------------------------------------------------------------- + +const MAX_SESSIONS: usize = 16; +const BUF_SIZE: usize = 4096; + +const SessionSlot = struct { + in_use: bool = false, + state: ConnState = .disconnected, + context_buf: [BUF_SIZE]u8 = .{0} ** BUF_SIZE, + context_len: usize = 0, + project_count: u32 = 0, + branch_count: u32 = 0, + query_count: u32 = 0, + endpoint_active: bool = false, +}; + +var sessions: [MAX_SESSIONS]SessionSlot = .{SessionSlot{}} ** MAX_SESSIONS; +var mutex: std.Thread.Mutex = .{}; + +// --------------------------------------------------------------------------- +// C-ABI exports +// --------------------------------------------------------------------------- + +/// Check if a state transition is valid. Returns 1 (valid) or 0 (invalid). +pub export fn neon_mcp_can_transition(from: c_int, to: c_int) c_int { + const f = std.meta.intToEnum(ConnState, from) catch return 0; + const t = std.meta.intToEnum(ConnState, to) catch return 0; + return if (isValidTransition(f, t)) 1 else 0; +} + +/// Open a new connection session. Returns slot index (>= 0) or -1 if no slots. +pub export fn neon_mcp_session_open() c_int { + mutex.lock(); + defer mutex.unlock(); + + for (&sessions, 0..) |*slot, idx| { + if (!slot.in_use) { + slot.in_use = true; + slot.state = .connected; + slot.context_len = 0; + slot.project_count = 0; + slot.branch_count = 0; + slot.query_count = 0; + slot.endpoint_active = false; + return @intCast(idx); + } + } + return -1; +} + +/// Close a connection session. Returns 0 on success, -1 if invalid slot, -2 if bad transition. +pub export fn neon_mcp_session_close(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.in_use) return -1; + if (!isValidTransition(slot.state, .disconnected)) return -2; + + slot.in_use = false; + slot.state = .disconnected; + slot.context_len = 0; + return 0; +} + +/// Get the current state of a session. Returns state int or -1 if invalid slot. +pub export fn neon_mcp_session_state(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.in_use) return -1; + return @intFromEnum(slot.state); +} + +/// Begin a query. Returns 0 on success, -1 if invalid slot, -2 if bad transition. +pub export fn neon_mcp_begin_query(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.in_use) return -1; + if (!isValidTransition(slot.state, .query_running)) return -2; + + slot.state = .query_running; + return 0; +} + +/// End a query (return to connected). Returns 0 on success. +pub export fn neon_mcp_end_query(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.in_use) return -1; + if (!isValidTransition(slot.state, .connected)) return -2; + + slot.state = .connected; + slot.query_count += 1; + return 0; +} + +/// Signal an error on a query-running session. Returns 0 on success. +pub export fn neon_mcp_signal_error(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.in_use) return -1; + if (!isValidTransition(slot.state, .err)) return -2; + + slot.state = .err; + return 0; +} + +/// Get the query count for a session. Returns count or -1 if invalid slot. +pub export fn neon_mcp_query_count(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.in_use) return -1; + return @intCast(slot.query_count); +} + +/// Check if an action requires an active connection. Returns 1 (yes) or 0 (no). +pub export fn neon_mcp_action_requires_connection(action: c_int) c_int { + const a = std.meta.intToEnum(NeonAction, action) catch return 0; + return switch (a) { + .query, .get_connection_string => 1, + else => 0, + }; +} + +/// Reset all sessions (test/debug use only). +pub export fn neon_mcp_reset() void { + mutex.lock(); + defer mutex.unlock(); + sessions = .{SessionSlot{}} ** MAX_SESSIONS; +} + +// --------------------------------------------------------------------------- +// ═══════════════════════════════════════════════════════════════════════ +// Standard ABI (ADR-0005 four symbols + ADR-0006 invoke) +// ═══════════════════════════════════════════════════════════════════════ + +const shim = @import("cartridge_shim.zig"); + +const CARTRIDGE_NAME_PTR: [*:0]const u8 = "neon-mcp"; +const CARTRIDGE_VERSION_PTR: [*:0]const u8 = "0.1.0"; + +export fn boj_cartridge_init() callconv(.c) c_int { + return 0; +} + +export fn boj_cartridge_deinit() callconv(.c) void {} + +export fn boj_cartridge_name() callconv(.c) [*:0]const u8 { + return CARTRIDGE_NAME_PTR; +} + +export fn boj_cartridge_version() callconv(.c) [*:0]const u8 { + return CARTRIDGE_VERSION_PTR; +} + +/// Dispatch the cartridge.json MCP tools. Grade D Alpha stubs. +export fn boj_cartridge_invoke( + tool_name: [*c]const u8, + json_args: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) callconv(.c) i32 { + _ = json_args; + if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; + + const body: []const u8 = if (shim.toolIs(tool_name, "neon_connect")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "neon_query")) + "{\"result\":{\"matches\":[],\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "neon_execute")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "neon_list_branches")) + "{\"result\":{\"items\":[],\"count\":0,\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "neon_create_branch")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "neon_list_tables")) + "{\"result\":{\"items\":[],\"count\":0,\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "neon_disconnect")) + "{\"result\":{\"status\":\"stub\"}}" +else + return shim.RC_UNKNOWN_TOOL; + + return shim.writeResult(out_buf, in_out_len, body); +} + +// Tests +// --------------------------------------------------------------------------- + +test "session lifecycle" { + neon_mcp_reset(); + + const slot = neon_mcp_session_open(); + try std.testing.expect(slot >= 0); + + // Should be in connected state + try std.testing.expectEqual(@as(c_int, 1), neon_mcp_session_state(slot)); + + // Begin query + try std.testing.expectEqual(@as(c_int, 0), neon_mcp_begin_query(slot)); + try std.testing.expectEqual(@as(c_int, 2), neon_mcp_session_state(slot)); + + // End query + try std.testing.expectEqual(@as(c_int, 0), neon_mcp_end_query(slot)); + try std.testing.expectEqual(@as(c_int, 1), neon_mcp_session_state(slot)); + + // Query count should be 1 + try std.testing.expectEqual(@as(c_int, 1), neon_mcp_query_count(slot)); + + // Close + try std.testing.expectEqual(@as(c_int, 0), neon_mcp_session_close(slot)); +} + +test "invalid transitions rejected" { + neon_mcp_reset(); + + const slot = neon_mcp_session_open(); + try std.testing.expect(slot >= 0); + + // Can not go connected -> error (must go through query_running) + try std.testing.expectEqual(@as(c_int, -2), neon_mcp_signal_error(slot)); + + // Can not close while query running + try std.testing.expectEqual(@as(c_int, 0), neon_mcp_begin_query(slot)); + try std.testing.expectEqual(@as(c_int, -2), neon_mcp_session_close(slot)); +} + +test "transition validator" { + // Valid transitions + try std.testing.expectEqual(@as(c_int, 1), neon_mcp_can_transition(0, 1)); // disconnected -> connected + try std.testing.expectEqual(@as(c_int, 1), neon_mcp_can_transition(1, 2)); // connected -> query_running + try std.testing.expectEqual(@as(c_int, 1), neon_mcp_can_transition(2, 1)); // query_running -> connected + try std.testing.expectEqual(@as(c_int, 1), neon_mcp_can_transition(1, 0)); // connected -> disconnected + try std.testing.expectEqual(@as(c_int, 1), neon_mcp_can_transition(2, 3)); // query_running -> error + try std.testing.expectEqual(@as(c_int, 1), neon_mcp_can_transition(3, 0)); // error -> disconnected + + // Invalid transitions + try std.testing.expectEqual(@as(c_int, 0), neon_mcp_can_transition(0, 2)); + try std.testing.expectEqual(@as(c_int, 0), neon_mcp_can_transition(1, 3)); + try std.testing.expectEqual(@as(c_int, 0), neon_mcp_can_transition(3, 1)); + try std.testing.expectEqual(@as(c_int, 0), neon_mcp_can_transition(99, 0)); +} + +test "slot exhaustion" { + neon_mcp_reset(); + + var slots: [MAX_SESSIONS]c_int = undefined; + for (&slots, 0..) |*s, i| { + _ = i; + s.* = neon_mcp_session_open(); + try std.testing.expect(s.* >= 0); + } + + // Next open should fail + try std.testing.expectEqual(@as(c_int, -1), neon_mcp_session_open()); + + // Free one and try again + try std.testing.expectEqual(@as(c_int, 0), neon_mcp_session_close(slots[0])); + const new_slot = neon_mcp_session_open(); + try std.testing.expect(new_slot >= 0); +} + +test "action requires connection" { + try std.testing.expectEqual(@as(c_int, 1), neon_mcp_action_requires_connection(8)); // query + try std.testing.expectEqual(@as(c_int, 1), neon_mcp_action_requires_connection(7)); // get_connection_string + try std.testing.expectEqual(@as(c_int, 0), neon_mcp_action_requires_connection(0)); // list_projects + try std.testing.expectEqual(@as(c_int, 0), neon_mcp_action_requires_connection(99)); // out of range +} + +// ═══════════════════════════════════════════════════════════════════════ +// ADR-0006 invoke dispatch tests +// ═══════════════════════════════════════════════════════════════════════ + +test "boj_cartridge_name returns neon-mcp" { + const n = std.mem.span(boj_cartridge_name()); + try std.testing.expectEqualStrings("neon-mcp", n); +} + +test "boj_cartridge_init returns 0" { + try std.testing.expectEqual(@as(c_int, 0), boj_cartridge_init()); +} + +test "invoke: each declared tool succeeds" { + var buf: [256]u8 = undefined; + const tools = [_][]const u8{ + "neon_connect", + "neon_query", + "neon_execute", + "neon_list_branches", + "neon_create_branch", + "neon_list_tables", + "neon_disconnect", + }; + for (tools) |t| { + var len: usize = buf.len; + const rc = boj_cartridge_invoke(t.ptr, "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, 0), rc); + try std.testing.expect(std.mem.indexOf(u8, buf[0..len], "result") != null); + } +} + +test "invoke: unknown tool returns -1" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("nope", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -1), rc); +} + +test "invoke: buffer too small returns -3" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("neon_connect", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -3), rc); + try std.testing.expect(len > 4); +} diff --git a/cartridges/domains/database/neon-mcp/minter.toml b/cartridges/domains/database/neon-mcp/minter.toml new file mode 100644 index 0000000..2e6604a --- /dev/null +++ b/cartridges/domains/database/neon-mcp/minter.toml @@ -0,0 +1,16 @@ +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> + +name = "neon-mcp" +description = "Neon serverless Postgres MCP cartridge β€” projects, branches, endpoints, queries" +version = "0.1.0" +domain = "Database" +protocols = [ + "MCP", + "REST", +] +tier = "Ayo" +backend = "universal" +generate_panel = true +auth = "bearer" +api_base = "https://console.neon.tech/api/v2/" diff --git a/cartridges/domains/database/neon-mcp/mod.js b/cartridges/domains/database/neon-mcp/mod.js new file mode 100644 index 0000000..bbdb928 --- /dev/null +++ b/cartridges/domains/database/neon-mcp/mod.js @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// neon-mcp/mod.js -- neon gateway. + +const BASE_URL = Deno.env.get("NEON_MCP_BACKEND_URL") ?? "http://127.0.0.1:7724"; + +async function post(path, payload) { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), 15000); + try { + const r = await fetch(`${BASE_URL}${path}`, { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), signal: ctrl.signal, + }); + const data = await r.json().catch(() => ({ success: false, error: "non-JSON response" })); + return { status: r.status, data }; + } catch (e) { + if (e.name === "AbortError") return { status: 504, data: { success: false, error: "neon-mcp backend timed out" } }; + return { status: 503, data: { success: false, error: `neon-mcp backend unavailable: ${e.message}` } }; + } finally { clearTimeout(t); } +} + +export async function handleTool(toolName, args) { + switch (toolName) { + + case "neon_connect": { + const { connection_string } = args ?? {}; + if (!connection_string) return { status: 400, data: { error: "connection_string is required" } }; + const payload = { connection_string }; + return post("/api/v1/connect", payload); + } + case "neon_query": { + const { slot, sql, params } = args ?? {}; + if (!slot || !sql) return { status: 400, data: { error: "slot is required" } }; + const payload = { slot, sql }; + if (params !== undefined) payload.params = params; + return post("/api/v1/query", payload); + } + case "neon_execute": { + const { slot, sql } = args ?? {}; + if (!slot || !sql) return { status: 400, data: { error: "slot is required" } }; + const payload = { slot, sql }; + return post("/api/v1/execute", payload); + } + case "neon_list_branches": { + const { project_id } = args ?? {}; + if (!project_id) return { status: 400, data: { error: "project_id is required" } }; + const payload = { project_id }; + return post("/api/v1/list-branches", payload); + } + case "neon_create_branch": { + const { project_id, branch_name, parent_branch } = args ?? {}; + if (!project_id || !branch_name) return { status: 400, data: { error: "project_id is required" } }; + const payload = { project_id, branch_name }; + if (parent_branch !== undefined) payload.parent_branch = parent_branch; + return post("/api/v1/create-branch", payload); + } + case "neon_list_tables": { + const { slot } = args ?? {}; + if (!slot) return { status: 400, data: { error: "slot is required" } }; + const payload = { slot }; + return post("/api/v1/list-tables", payload); + } + case "neon_disconnect": { + const { slot } = args ?? {}; + if (!slot) return { status: 400, data: { error: "slot is required" } }; + const payload = { slot }; + return post("/api/v1/disconnect", payload); + } + + default: + return { status: 404, data: { error: `Unknown tool: ${toolName}` } }; + } +} diff --git a/cartridges/domains/database/neon-mcp/panels/manifest.json b/cartridges/domains/database/neon-mcp/panels/manifest.json new file mode 100644 index 0000000..27fd8ea --- /dev/null +++ b/cartridges/domains/database/neon-mcp/panels/manifest.json @@ -0,0 +1,89 @@ +{ + "$schema": "https://panll.dev/schemas/panel-manifest/v1.json", + "spdx": "MPL-2.0", + "cartridge": "neon-mcp", + "domain": "Database", + "version": "0.1.0", + "panels": [ + { + "id": "neon-project-count", + "title": "Project Count", + "description": "Total number of Neon projects accessible via the configured API key", + "type": "metric", + "data_source": { + "endpoint": "/neon/projects", + "method": "GET", + "refresh_interval_ms": 30000 + }, + "widgets": [ + { + "type": "counter", + "field": "project_count", + "label": "Projects", + "icon": "folder" + } + ] + }, + { + "id": "neon-branch-count", + "title": "Branch Count", + "description": "Total number of branches across all Neon projects", + "type": "metric", + "data_source": { + "endpoint": "/neon/branches", + "method": "GET", + "refresh_interval_ms": 30000 + }, + "widgets": [ + { + "type": "counter", + "field": "branch_count", + "label": "Branches", + "icon": "git-branch" + } + ] + }, + { + "id": "neon-endpoint-status", + "title": "Endpoint Status", + "description": "Current endpoint state β€” active, idle, or suspended (auto-suspend semantics)", + "type": "status-indicator", + "data_source": { + "endpoint": "/neon/endpoint/status", + "method": "GET", + "refresh_interval_ms": 10000 + }, + "widgets": [ + { + "type": "state-badge", + "field": "endpoint_state", + "states": { + "active": { "color": "#2ecc71", "icon": "zap" }, + "idle": { "color": "#f39c12", "icon": "moon" }, + "suspended": { "color": "#95a5a6", "icon": "pause-circle" }, + "error": { "color": "#e74c3c", "icon": "alert-triangle" } + } + } + ] + }, + { + "id": "neon-query-count", + "title": "Query Count", + "description": "Total number of queries executed across all sessions", + "type": "metric", + "data_source": { + "endpoint": "/neon/metrics", + "method": "GET", + "refresh_interval_ms": 15000 + }, + "widgets": [ + { + "type": "counter", + "field": "total_query_count", + "label": "Queries", + "icon": "file-search" + } + ] + } + ] +} diff --git a/cartridges/domains/database/neon-mcp/tests/integration_test.sh b/cartridges/domains/database/neon-mcp/tests/integration_test.sh new file mode 100755 index 0000000..de02508 --- /dev/null +++ b/cartridges/domains/database/neon-mcp/tests/integration_test.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Integration tests for neon-mcp cartridge. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CART_DIR="$(dirname "$SCRIPT_DIR")" +FFI_DIR="$CART_DIR/ffi" + +echo "=== neon-mcp integration tests ===" + +# Build FFI +echo "[1/3] Building FFI..." +cd "$FFI_DIR" && zig build 2>&1 + +# Run Zig unit tests +echo "[2/3] Running FFI unit tests..." +cd "$FFI_DIR" && zig build test 2>&1 + +# Validate Idris2 ABI (if idris2 available) +echo "[3/3] Checking ABI..." +if command -v idris2 &>/dev/null; then + cd "$CART_DIR/abi" && idris2 --check NeonMcp.SafeDatabase 2>&1 + echo " ABI: OK" +else + echo " ABI: SKIPPED (idris2 not in PATH)" +fi + +echo "" +echo "All tests passed for neon-mcp!" diff --git a/cartridges/domains/database/postgresql-mcp/README.adoc b/cartridges/domains/database/postgresql-mcp/README.adoc new file mode 100644 index 0000000..b39ce47 --- /dev/null +++ b/cartridges/domains/database/postgresql-mcp/README.adoc @@ -0,0 +1,81 @@ +// SPDX-License-Identifier: MPL-2.0 += postgresql-mcp +Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk> +:spdx: MPL-2.0 +:tier: Ayo +:domain: Database +:protocols: MCP, REST + +== Overview + +PostgreSQL database cartridge for BoJ Server. Provides 16 MCP actions covering +connection management, parameterised queries (SQL injection safe), transaction +control, schema introspection, COPY operations, and NOTIFY/LISTEN support. + +Credentials are sourced from vault-mcp via connection string +(`postgres://user:pass@host:5432/db`). All queries use PQexecParams +(parameterised statements) exclusively -- string concatenation for SQL is +never permitted. + +== State Machine + +* `Disconnected` -- no active connection +* `Connected` -- authenticated, ready for queries +* `InTransaction` -- inside BEGIN block +* `QueryRunning` -- query or statement currently executing +* `Error` -- must disconnect to recover + +Valid transitions: + + Disconnected -> Connected + Connected -> Disconnected | InTransaction | QueryRunning + InTransaction -> Connected (commit/rollback) | QueryRunning + QueryRunning -> Connected | InTransaction | Error + Error -> Disconnected + +== Actions (16) + +Connect, Disconnect, Query, Execute, BeginTx, CommitTx, RollbackTx, +ListDatabases, ListSchemas, ListTables, DescribeTable, ListIndices, +Explain, CopyTo, CopyFrom, Notify + +== Architecture + +[cols="1,1,2"] +|=== +| Layer | Language | Purpose + +| ABI +| Idris2 +| Dependently-typed state machine (`PostgresqlMcp.SafeDatabase`) + +| FFI +| Zig +| C-ABI implementation with libpq stubs, thread-safe connection pool + +| Adapter +| zig +| REST/MCP bridge exposing all 16 actions + +| Panels +| JSON +| PanLL manifest for connection status, active connections, query count, transaction state +|=== + +== Building + +[source,bash] +---- +# Build FFI shared library (links libpq) +cd ffi && zig build + +# Run FFI tests +cd ffi && zig build test + +# Type-check ABI +cd abi && idris2 --check postgresql_mcp.ipkg +---- + +== Status + +Development -- not yet ready for mounting. diff --git a/cartridges/domains/database/postgresql-mcp/abi/PostgresqlMcp/SafeDatabase.idr b/cartridges/domains/database/postgresql-mcp/abi/PostgresqlMcp/SafeDatabase.idr new file mode 100644 index 0000000..abc1441 --- /dev/null +++ b/cartridges/domains/database/postgresql-mcp/abi/PostgresqlMcp/SafeDatabase.idr @@ -0,0 +1,232 @@ +-- SPDX-License-Identifier: MPL-2.0 +-- Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +-- +-- PostgresqlMcp.SafeDatabase -- Type-safe ABI for postgresql-mcp cartridge. +-- +-- Dependently-typed state machine modelling PostgreSQL connection lifecycle. +-- Transitions are proven valid at compile time. Credentials obtained from +-- vault-mcp via connection string (postgres://user:pass@host:5432/db). +-- All queries use parameterised statements to prevent SQL injection. + +module PostgresqlMcp.SafeDatabase + +%default total + +-- --------------------------------------------------------------------------- +-- Connection state machine +-- --------------------------------------------------------------------------- + +||| PostgreSQL connection lifecycle states. +||| +||| @ Disconnected No active connection to the database server. +||| @ Connected Authenticated connection established; ready for queries. +||| @ InTransaction Inside an explicit BEGIN block. +||| @ QueryRunning A query or statement is currently executing. +||| @ Error An error has occurred; must disconnect to recover. +public export +data ConnState + = Disconnected + | Connected + | InTransaction + | QueryRunning + | Error + +||| Proof that a state transition is valid within the PostgreSQL protocol. +||| +||| The transition graph: +||| Disconnected -> Connected (connect) +||| Connected -> InTransaction (begin) +||| InTransaction -> Connected (commit / rollback) +||| Connected -> QueryRunning (query / execute) +||| InTransaction -> QueryRunning (query inside transaction) +||| QueryRunning -> Connected (query completes outside tx) +||| QueryRunning -> InTransaction (query completes inside tx -- see note) +||| QueryRunning -> Error (query fails) +||| Error -> Disconnected (disconnect after error) +||| Connected -> Disconnected (graceful disconnect) +public export +data ValidTransition : ConnState -> ConnState -> Type where + Connect : ValidTransition Disconnected Connected + Disconnect : ValidTransition Connected Disconnected + BeginTx : ValidTransition Connected InTransaction + CommitTx : ValidTransition InTransaction Connected + RollbackTx : ValidTransition InTransaction Connected + StartQuery : ValidTransition Connected QueryRunning + StartTxQuery : ValidTransition InTransaction QueryRunning + QueryDone : ValidTransition QueryRunning Connected + TxQueryDone : ValidTransition QueryRunning InTransaction + QueryFailed : ValidTransition QueryRunning Error + ErrorDisconnect : ValidTransition Error Disconnected + +-- --------------------------------------------------------------------------- +-- C-ABI integer encoding +-- --------------------------------------------------------------------------- + +||| Encode connection state as C-compatible integer. +export +connStateToInt : ConnState -> Int +connStateToInt Disconnected = 0 +connStateToInt Connected = 1 +connStateToInt InTransaction = 2 +connStateToInt QueryRunning = 3 +connStateToInt Error = 4 + +||| Decode integer back to connection state. +export +intToConnState : Int -> Maybe ConnState +intToConnState 0 = Just Disconnected +intToConnState 1 = Just Connected +intToConnState 2 = Just InTransaction +intToConnState 3 = Just QueryRunning +intToConnState 4 = Just Error +intToConnState _ = Nothing + +||| Check if a state transition is valid (C-ABI export). +||| Returns 1 for valid, 0 for invalid. +export +postgresql_mcp_can_transition : Int -> Int -> Int +postgresql_mcp_can_transition from to = + case (intToConnState from, intToConnState to) of + (Just Disconnected, Just Connected) => 1 + (Just Connected, Just Disconnected) => 1 + (Just Connected, Just InTransaction) => 1 + (Just InTransaction, Just Connected) => 1 + (Just Connected, Just QueryRunning) => 1 + (Just InTransaction, Just QueryRunning) => 1 + (Just QueryRunning, Just Connected) => 1 + (Just QueryRunning, Just InTransaction) => 1 + (Just QueryRunning, Just Error) => 1 + (Just Error, Just Disconnected) => 1 + _ => 0 + +-- --------------------------------------------------------------------------- +-- PostgreSQL actions +-- --------------------------------------------------------------------------- + +||| Actions exposed via the postgresql-mcp MCP protocol. +||| +||| All 16 operations supported by this cartridge. Query and Execute use +||| parameterised statements exclusively to prevent SQL injection. +public export +data PostgresqlAction + = Connect + | Disconnect + | Query + | Execute + | BeginTransaction + | CommitTransaction + | RollbackTransaction + | ListDatabases + | ListSchemas + | ListTables + | DescribeTable + | ListIndices + | Explain + | CopyTo + | CopyFrom + | Notify + +||| Encode action as C-compatible integer. +export +actionToInt : PostgresqlAction -> Int +actionToInt Connect = 0 +actionToInt Disconnect = 1 +actionToInt Query = 2 +actionToInt Execute = 3 +actionToInt BeginTransaction = 4 +actionToInt CommitTransaction = 5 +actionToInt RollbackTransaction = 6 +actionToInt ListDatabases = 7 +actionToInt ListSchemas = 8 +actionToInt ListTables = 9 +actionToInt DescribeTable = 10 +actionToInt ListIndices = 11 +actionToInt Explain = 12 +actionToInt CopyTo = 13 +actionToInt CopyFrom = 14 +actionToInt Notify = 15 + +||| Decode integer back to action. +export +intToAction : Int -> Maybe PostgresqlAction +intToAction 0 = Just Connect +intToAction 1 = Just Disconnect +intToAction 2 = Just Query +intToAction 3 = Just Execute +intToAction 4 = Just BeginTransaction +intToAction 5 = Just CommitTransaction +intToAction 6 = Just RollbackTransaction +intToAction 7 = Just ListDatabases +intToAction 8 = Just ListSchemas +intToAction 9 = Just ListTables +intToAction 10 = Just DescribeTable +intToAction 11 = Just ListIndices +intToAction 12 = Just Explain +intToAction 13 = Just CopyTo +intToAction 14 = Just CopyFrom +intToAction 15 = Just Notify +intToAction _ = Nothing + +||| Check whether an action requires an active connection (Connected or deeper). +export +actionRequiresConnection : PostgresqlAction -> Bool +actionRequiresConnection Connect = False +actionRequiresConnection _ = True + +||| Check whether an action requires an active transaction. +export +actionRequiresTransaction : PostgresqlAction -> Bool +actionRequiresTransaction CommitTransaction = True +actionRequiresTransaction RollbackTransaction = True +actionRequiresTransaction _ = False + +||| Total number of actions in this cartridge. +export +actionCount : Nat +actionCount = 16 + +-- --------------------------------------------------------------------------- +-- Authentication +-- --------------------------------------------------------------------------- + +||| Authentication method for PostgreSQL connections. +||| Credentials are sourced from vault-mcp, never hardcoded. +public export +data AuthMethod + = ConnectionString + | VaultRef String + +-- --------------------------------------------------------------------------- +-- Result types +-- --------------------------------------------------------------------------- + +||| Query result status codes matching libpq PGresult status. +public export +data ResultStatus + = CommandOk + | TuplesOk + | CopyOut + | CopyIn + | BadResponse + | FatalError + +||| Encode result status as C-compatible integer. +export +resultStatusToInt : ResultStatus -> Int +resultStatusToInt CommandOk = 0 +resultStatusToInt TuplesOk = 1 +resultStatusToInt CopyOut = 2 +resultStatusToInt CopyIn = 3 +resultStatusToInt BadResponse = 4 +resultStatusToInt FatalError = 5 + +||| Decode integer back to result status. +export +intToResultStatus : Int -> Maybe ResultStatus +intToResultStatus 0 = Just CommandOk +intToResultStatus 1 = Just TuplesOk +intToResultStatus 2 = Just CopyOut +intToResultStatus 3 = Just CopyIn +intToResultStatus 4 = Just BadResponse +intToResultStatus 5 = Just FatalError +intToResultStatus _ = Nothing diff --git a/cartridges/domains/database/postgresql-mcp/abi/README.adoc b/cartridges/domains/database/postgresql-mcp/abi/README.adoc new file mode 100644 index 0000000..617439a --- /dev/null +++ b/cartridges/domains/database/postgresql-mcp/abi/README.adoc @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += postgresql-mcp / abi β€” Idris2 ABI layer +:orientation: shallow + +== Purpose + +Cartridge-local protocol types for `postgresql-mcp`. Defines the data, tool, and +transition vocabulary this cartridge speaks. Inherits safety lemmas and the +matrix-cell identifier scheme from the trunk ABI at `src/abi/`. + +== Files + +1 Idris2 module(s), ~232 lines total. Lead module: +`SafeDatabase.idr`. + +No `.ipkg` β€” consumed directly by wider BoJ callers; type-checks inside the +trunk `boj.ipkg`. + +== Invariants + +* `%default total` at every module top. +* Zero `believe_me`, `assert_total`, `postulate`, `Admitted`, or + `sorry` (the trunk holds the four declared `believe_me` sites; leaf + cartridges carry none). + +== Test/proof surface + +Thin module(s), no inline tests. Proof surface = type-check success under the +trunk `idris2 --check`. + +== Read-first + +. `SafeDatabase.idr` β€” cartridge-local data records and enums. +. Trunk `src/abi/Boj/Catalogue.idr` for how this cell participates in the + 2D matrix. diff --git a/cartridges/domains/database/postgresql-mcp/abi/postgresql_mcp.ipkg b/cartridges/domains/database/postgresql-mcp/abi/postgresql_mcp.ipkg new file mode 100644 index 0000000..f522364 --- /dev/null +++ b/cartridges/domains/database/postgresql-mcp/abi/postgresql_mcp.ipkg @@ -0,0 +1,11 @@ +-- SPDX-License-Identifier: MPL-2.0 +-- Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +package postgresql_mcp + +version = "0.1.0" +authors = "Jonathan D.A. Jewell" +brief = "Type-safe ABI for PostgreSQL MCP cartridge with dependently-typed state machine" + +depends = base + +modules = PostgresqlMcp.SafeDatabase diff --git a/cartridges/domains/database/postgresql-mcp/adapter/README.adoc b/cartridges/domains/database/postgresql-mcp/adapter/README.adoc new file mode 100644 index 0000000..42dcca3 --- /dev/null +++ b/cartridges/domains/database/postgresql-mcp/adapter/README.adoc @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += postgresql-mcp / adapter β€” protocol bridge +:orientation: shallow + +== Purpose + +Exposes this cartridge's FFI over the wire protocols declared in +`cartridge.json`. Dispatches tool invocations to the C-ABI exports at +`../ffi/` and formats responses per protocol. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” depends on the sibling `ffi` target. +| `postgresql_mcp_adapter.zig` | Protocol dispatch (118 lines). Tool catalogue matches `cartridge.json`. +| `SIDELINED-*.v.adoc` | zig predecessor(s). Not built β€” zig banned 2026-04-10. +|=== + +== Invariants + +* **Stateless** β€” all state lives behind the FFI, never in the adapter. +* **Response passthrough** β€” whatever the FFI returns goes back to the wire + unmodified (no embellishment, no silent recovery). +* **Cartridge.json is source of truth** for the tool catalogue; drift between + adapter and manifest is a CI failure. + +== Test/proof surface + +No inline tests at the adapter layer β€” protocol routing is exercised by the +trunk seam tests at `ffi/zig/src/seams.zig`. + +== Read-first + +. `postgresql_mcp_adapter.zig` β€” dispatch function and per-protocol routers. +. `../cartridge.json` β€” the tool manifest the dispatcher mirrors. diff --git a/cartridges/domains/database/postgresql-mcp/adapter/build.zig b/cartridges/domains/database/postgresql-mcp/adapter/build.zig new file mode 100644 index 0000000..02b0453 --- /dev/null +++ b/cartridges/domains/database/postgresql-mcp/adapter/build.zig @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// postgresql-mcp/adapter/build.zig + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); const optimize = b.standardOptimizeOption(.{}); + const ffi_mod = b.createModule(.{ .root_source_file = b.path("../ffi/postgresql_mcp_ffi.zig"), .target = target, .optimize = optimize }); + const adapter = b.addExecutable(.{ .name = "postgresql_mcp_adapter", .root_source_file = b.path("postgresql_mcp_adapter.zig"), .target = target, .optimize = optimize }); + adapter.root_module.addImport("postgresql_mcp_ffi", ffi_mod); b.installArtifact(adapter); + const rs = b.step("run", "Run postgresql-mcp adapter"); rs.dependOn(&b.addRunArtifact(adapter).step); + const tests = b.addTest(.{ .root_source_file = b.path("postgresql_mcp_adapter.zig"), .target = target, .optimize = optimize }); + tests.root_module.addImport("postgresql_mcp_ffi", ffi_mod); + const ts = b.step("test", "Test postgresql-mcp adapter"); ts.dependOn(&b.addRunArtifact(tests).step); +} diff --git a/cartridges/domains/database/postgresql-mcp/adapter/postgresql_mcp_adapter.zig b/cartridges/domains/database/postgresql-mcp/adapter/postgresql_mcp_adapter.zig new file mode 100644 index 0000000..9b7cc51 --- /dev/null +++ b/cartridges/domains/database/postgresql-mcp/adapter/postgresql_mcp_adapter.zig @@ -0,0 +1,118 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// postgresql-mcp/adapter/postgresql_mcp_adapter.zig -- Unified three-protocol adapter. +// Replaces banned postgresql_mcp_adapter.v (zig, removed 2026-04-12). +// REST:9157 gRPC:9158 GraphQL:9159 +// Tools: pg_connect, pg_query, pg_execute, pg_begin... + +const std = @import("std"); +const ffi = @import("postgresql_mcp_ffi"); + +const REST_PORT: u16 = 9157; +const GRPC_PORT: u16 = 9158; +const GQL_PORT: u16 = 9159; +const MAX_CONN_BUF: usize = 16 * 1024; + +fn okJson(buf: []u8, msg: []const u8) []u8 { + const n = std.fmt.bufPrint(buf, "{\"success\":true,\"message\":\"{s}\"}", .{msg}) catch return buf[0..0]; return n; +} +fn errJson(buf: []u8, msg: []const u8) []u8 { + const n = std.fmt.bufPrint(buf, "{\"success\":false,\"error\":\"{s}\"}", .{msg}) catch return buf[0..0]; return n; +} +fn statusJson(buf: []u8) []u8 { + const n = std.fmt.bufPrint(buf, "{\"success\":true,\"state\":\"ready\",\"service\":\"postgresql-mcp\"}", .{}) catch return buf[0..0]; return n; +} + +const Response = struct { status: u16, body: []u8 }; + +fn dispatch(tool: []const u8, body: []const u8, resp: []u8) Response { + _ = body; + if (std.mem.eql(u8, tool, "pg_connect")) return .{ .status = 200, .body = okJson(resp, "pg_connect forwarded") }; + if (std.mem.eql(u8, tool, "pg_query")) return .{ .status = 200, .body = okJson(resp, "pg_query forwarded") }; + if (std.mem.eql(u8, tool, "pg_execute")) return .{ .status = 200, .body = okJson(resp, "pg_execute forwarded") }; + if (std.mem.eql(u8, tool, "pg_begin")) return .{ .status = 200, .body = okJson(resp, "pg_begin forwarded") }; + if (std.mem.eql(u8, tool, "pg_commit")) return .{ .status = 200, .body = okJson(resp, "pg_commit forwarded") }; + if (std.mem.eql(u8, tool, "pg_rollback")) return .{ .status = 200, .body = okJson(resp, "pg_rollback forwarded") }; + if (std.mem.eql(u8, tool, "pg_list_tables")) return .{ .status = 200, .body = okJson(resp, "pg_list_tables forwarded") }; + if (std.mem.eql(u8, tool, "pg_describe")) return .{ .status = 200, .body = okJson(resp, "pg_describe forwarded") }; + if (std.mem.eql(u8, tool, "pg_disconnect")) return .{ .status = 200, .body = okJson(resp, "pg_disconnect forwarded") }; + if (std.mem.eql(u8, tool, "status") or std.mem.eql(u8, tool, "health")) return .{ .status = 200, .body = statusJson(resp) }; + return .{ .status = 404, .body = errJson(resp, "Unknown tool") }; +} + +fn dispatchRest(path: []const u8, body: []const u8, resp: []u8) Response { + if (std.mem.startsWith(u8, path, "/tools/")) return dispatch(path["/tools/".len..], body, resp); + if (std.mem.eql(u8, path, "/status") or std.mem.eql(u8, path, "/health")) return .{ .status = 200, .body = statusJson(resp) }; + return .{ .status = 404, .body = errJson(resp, "Not found") }; +} + +fn dispatchGrpc(path: []const u8, body: []const u8, resp: []u8) Response { + const prefix = "/PostgresqlMcpservice/"; + if (!std.mem.startsWith(u8, path, prefix)) return .{ .status = 404, .body = errJson(resp, "Not a recognized gRPC path") }; + const method = path[prefix.len..]; + const tool = blk: { + if (std.mem.eql(u8, method, "pg_connect")) break :blk "pg_connect"; + if (std.mem.eql(u8, method, "pg_query")) break :blk "pg_query"; + if (std.mem.eql(u8, method, "pg_execute")) break :blk "pg_execute"; + if (std.mem.eql(u8, method, "pg_begin")) break :blk "pg_begin"; + if (std.mem.eql(u8, method, "pg_commit")) break :blk "pg_commit"; + if (std.mem.eql(u8, method, "pg_rollback")) break :blk "pg_rollback"; + if (std.mem.eql(u8, method, "pg_list_tables")) break :blk "pg_list_tables"; + if (std.mem.eql(u8, method, "pg_describe")) break :blk "pg_describe"; + if (std.mem.eql(u8, method, "pg_disconnect")) break :blk "pg_disconnect"; + return .{ .status = 404, .body = errJson(resp, "Unknown gRPC method") }; + }; + return dispatch(tool, body, resp); +} + +fn dispatchGraphql(body: []const u8, resp: []u8) Response { + if (std.mem.indexOf(u8, body, "__schema") != null) return .{ .status = 200, .body = okJson(resp, "schema not supported") }; + if (std.mem.indexOf(u8, body, "connect") != null) return dispatch("pg_connect", body, resp); + if (std.mem.indexOf(u8, body, "query") != null) return dispatch("pg_query", body, resp); + if (std.mem.indexOf(u8, body, "execute") != null) return dispatch("pg_execute", body, resp); + if (std.mem.indexOf(u8, body, "begin") != null) return dispatch("pg_begin", body, resp); + if (std.mem.indexOf(u8, body, "commit") != null) return dispatch("pg_commit", body, resp); + if (std.mem.indexOf(u8, body, "rollback") != null) return dispatch("pg_rollback", body, resp); + if (std.mem.indexOf(u8, body, "list_tables") != null) return dispatch("pg_list_tables", body, resp); + if (std.mem.indexOf(u8, body, "describe") != null) return dispatch("pg_describe", body, resp); + if (std.mem.indexOf(u8, body, "disconnect") != null) return dispatch("pg_disconnect", body, resp); + return .{ .status = 200, .body = errJson(resp, "Unrecognised GraphQL operation") }; +} + +const Protocol = enum { rest, grpc, graphql }; + +fn handleConnection(conn: std.net.Server.Connection, proto: Protocol) void { + defer conn.stream.close(); + var in_buf: [MAX_CONN_BUF]u8 = undefined; + const n = conn.stream.read(&in_buf) catch return; + const req = in_buf[0..n]; + var path: []const u8 = "/"; var body: []const u8 = ""; + if (n > 4) { + const le = std.mem.indexOf(u8, req, "\r\n") orelse req.len; + const fl = req[0..le]; const sp1 = std.mem.indexOfScalar(u8, fl, ' ') orelse 0; + const ro = fl[sp1+1..]; const sp2 = std.mem.indexOfScalar(u8, ro, ' ') orelse ro.len; + path = ro[0..sp2]; + const bs = std.mem.indexOf(u8, req, "\r\n\r\n") orelse n; body = req[@min(bs+4,n)..]; + } + var rb: [MAX_CONN_BUF]u8 = undefined; + const result = switch (proto) { .rest => dispatchRest(path, body, &rb), .grpc => dispatchGrpc(path, body, &rb), .graphql => dispatchGraphql(body, &rb) }; + const ct: []const u8 = if (proto == .grpc) "application/grpc+json" else "application/json"; + var hb: [256]u8 = undefined; + const hdr = std.fmt.bufPrint(&hb, "HTTP/1.1 {d} OK\r\nContent-Type: {s}\r\nContent-Length: {d}\r\nConnection: close\r\n\r\n", .{ result.status, ct, result.body.len }) catch return; + _ = conn.stream.writeAll(hdr) catch return; _ = conn.stream.writeAll(result.body) catch return; +} + +fn listenLoop(port: u16, proto: Protocol) void { + const addr = std.net.Address.initIp4(.{ 127, 0, 0, 1 }, port) catch return; + var srv = addr.listen(.{ .reuse_address = true }) catch return; defer srv.deinit(); + while (true) { const conn = srv.accept() catch continue; handleConnection(conn, proto); } +} + +pub fn main() !void { + ffi.postgresql_mcp_init(); + const t1 = try std.Thread.spawn(.{}, listenLoop, .{ REST_PORT, Protocol.rest }); + const t2 = try std.Thread.spawn(.{}, listenLoop, .{ GRPC_PORT, Protocol.grpc }); + const t3 = try std.Thread.spawn(.{}, listenLoop, .{ GQL_PORT, Protocol.graphql }); + t1.join(); t2.join(); t3.join(); +} diff --git a/cartridges/domains/database/postgresql-mcp/benchmarks/quick-bench.sh b/cartridges/domains/database/postgresql-mcp/benchmarks/quick-bench.sh new file mode 100755 index 0000000..9c5b582 --- /dev/null +++ b/cartridges/domains/database/postgresql-mcp/benchmarks/quick-bench.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Benchmarks for postgresql-mcp cartridge. +set -euo pipefail + +echo "=== postgresql-mcp benchmarks ===" + +cd "$(dirname "${BASH_SOURCE[0]}")/../ffi" + +# Build with ReleaseFast for benchmarking +zig build -Doptimize=ReleaseFast 2>&1 + +echo "Session open/close cycle (1000 iterations):" +time for i in $(seq 1 1000); do + # This would call the FFI benchmark binary + true +done + +echo "" +echo "Benchmark placeholder β€” implement real benchmarks in Zig test blocks" +echo "or via the zig adapter HTTP benchmark tool." diff --git a/cartridges/domains/database/postgresql-mcp/cartridge.json b/cartridges/domains/database/postgresql-mcp/cartridge.json new file mode 100644 index 0000000..5536a85 --- /dev/null +++ b/cartridges/domains/database/postgresql-mcp/cartridge.json @@ -0,0 +1,207 @@ +{ + "$schema": "https://boj.dev/schemas/cartridge/v1.json", + "spdx": "MPL-2.0", + "copyright": "Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath)", + "name": "postgresql-mcp", + "version": "0.1.0", + "description": "PostgreSQL gateway with full transaction support, connection pooling, and query lifecycle management.", + "domain": "Database", + "tier": "Ayo", + "protocols": [ + "MCP", + "REST" + ], + "auth": { + "method": "none", + "env_var": null, + "credential_source": null + }, + "api": { + "base_url": "local://postgresql-mcp", + "content_type": "application/json" + }, + "tools": [ + { + "name": "pg_connect", + "description": "Connect to a PostgreSQL database. Returns a session slot.", + "inputSchema": { + "type": "object", + "properties": { + "connection_string": { + "type": "string", + "description": "PostgreSQL connection string (postgresql://user:pass@host/db)" + } + }, + "required": [ + "connection_string" + ] + } + }, + { + "name": "pg_query", + "description": "Execute a SQL query and return results.", + "inputSchema": { + "type": "object", + "properties": { + "slot": { + "type": "integer", + "description": "Session slot from pg_connect" + }, + "sql": { + "type": "string", + "description": "SQL query" + }, + "params": { + "type": "string", + "description": "Query parameters as JSON array" + } + }, + "required": [ + "slot", + "sql" + ] + } + }, + { + "name": "pg_execute", + "description": "Execute a non-returning SQL statement.", + "inputSchema": { + "type": "object", + "properties": { + "slot": { + "type": "integer", + "description": "Session slot" + }, + "sql": { + "type": "string", + "description": "SQL statement (INSERT/UPDATE/DELETE/DDL)" + }, + "params": { + "type": "string", + "description": "Parameters as JSON array" + } + }, + "required": [ + "slot", + "sql" + ] + } + }, + { + "name": "pg_begin", + "description": "Begin a transaction.", + "inputSchema": { + "type": "object", + "properties": { + "slot": { + "type": "integer", + "description": "Session slot" + } + }, + "required": [ + "slot" + ] + } + }, + { + "name": "pg_commit", + "description": "Commit the current transaction.", + "inputSchema": { + "type": "object", + "properties": { + "slot": { + "type": "integer", + "description": "Session slot" + } + }, + "required": [ + "slot" + ] + } + }, + { + "name": "pg_rollback", + "description": "Roll back the current transaction.", + "inputSchema": { + "type": "object", + "properties": { + "slot": { + "type": "integer", + "description": "Session slot" + } + }, + "required": [ + "slot" + ] + } + }, + { + "name": "pg_list_tables", + "description": "List tables in the connected database.", + "inputSchema": { + "type": "object", + "properties": { + "slot": { + "type": "integer", + "description": "Session slot" + }, + "schema": { + "type": "string", + "description": "Schema name (default: public)" + } + }, + "required": [ + "slot" + ] + } + }, + { + "name": "pg_describe", + "description": "Describe a table's columns, types, and constraints.", + "inputSchema": { + "type": "object", + "properties": { + "slot": { + "type": "integer", + "description": "Session slot" + }, + "table": { + "type": "string", + "description": "Table name" + } + }, + "required": [ + "slot", + "table" + ] + } + }, + { + "name": "pg_disconnect", + "description": "Disconnect and release a session slot.", + "inputSchema": { + "type": "object", + "properties": { + "slot": { + "type": "integer", + "description": "Session slot to release" + } + }, + "required": [ + "slot" + ] + } + } + ], + "ffi": { + "so_path": "ffi/zig-out/lib/libpostgresql_mcp.so", + "abi_version": "ADR-0006", + "symbols": [ + "boj_cartridge_init", + "boj_cartridge_deinit", + "boj_cartridge_name", + "boj_cartridge_version", + "boj_cartridge_invoke" + ] + } +} diff --git a/cartridges/domains/database/postgresql-mcp/ffi/README.adoc b/cartridges/domains/database/postgresql-mcp/ffi/README.adoc new file mode 100644 index 0000000..1e14920 --- /dev/null +++ b/cartridges/domains/database/postgresql-mcp/ffi/README.adoc @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += postgresql-mcp / ffi β€” Zig FFI layer +:orientation: shallow + +== Purpose + +C-ABI exports bridging this cartridge's Idris2 ABI to the runtime. Consumed by +the adapter for protocol dispatch and by the trunk loader for mount. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” produces `zig-out/lib/libpostgresql.so`. +| `postgresql_mcp_ffi.zig` | C-ABI exports (12 exports, 4 inline tests, 366 lines). +|=== + +== Invariants + +* **Null-pointer rejection** on `[*c]const u8` arguments before any + dereference. +* **Negative return values** carry documented error meanings; zero is + success (matching the trunk error-code convention). +* **No allocator leaks** β€” every allocation path pairs with `defer free`. + +== Test/proof surface + +4 inline `test "..."` block(s) in `postgresql_mcp_ffi.zig`. Run with +`cd ffi && zig build test`. + +== Read-first + +. `postgresql_mcp_ffi.zig` β€” C-ABI signatures and guards. +. Trunk `ffi/zig/src/catalogue.zig` for how this `.so` is loaded. diff --git a/cartridges/domains/database/postgresql-mcp/ffi/build.zig b/cartridges/domains/database/postgresql-mcp/ffi/build.zig new file mode 100644 index 0000000..9b8746c --- /dev/null +++ b/cartridges/domains/database/postgresql-mcp/ffi/build.zig @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// build.zig -- Build configuration for postgresql-mcp FFI shared library. +// +// Links against libpq for PostgreSQL wire protocol support. +// Produces libpostgresql_mcp.so/.dylib/.dll for zig adapter consumption. + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + // Module + // Shared ADR-0006 invoke-shim module (relative path up to boj-server trunk). + + const shim_mod = b.addModule("cartridge_shim", .{ + + .root_source_file = b.path("../../../ffi/zig/src/cartridge_shim.zig"), + + .target = target, + + .optimize = optimize, + + }); + + const ffi_mod = b.addModule("postgresql_mcp", .{ + .root_source_file = b.path("postgresql_mcp_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + ffi_mod.addImport("cartridge_shim", shim_mod); + + // Shared library + const lib = b.addLibrary(.{ + .name = "postgresql_mcp", + .root_module = ffi_mod, + .linkage = .dynamic, + }); + lib.linkLibC(); + // NOTE: linkSystemLibrary("pq") removed β€” stub implementation does not + // actually call libpq yet. Will be re-enabled when real bindings are wired. + b.installArtifact(lib); + + // Tests + const tests = b.addTest(.{ + .root_module = ffi_mod, + }); + tests.linkLibC(); + + const run_tests = b.addRunArtifact(tests); + const test_step = b.step("test", "Run FFI tests"); + test_step.dependOn(&run_tests.step); +} diff --git a/cartridges/domains/database/postgresql-mcp/ffi/cartridge_shim.zig b/cartridges/domains/database/postgresql-mcp/ffi/cartridge_shim.zig new file mode 100644 index 0000000..0e399db --- /dev/null +++ b/cartridges/domains/database/postgresql-mcp/ffi/cartridge_shim.zig @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// cartridge_shim.zig β€” Shared helpers for the ADR-0006 five-symbol +// cartridge ABI (`boj_cartridge_init / deinit / name / version / invoke`). +// +// The shim centralises the seven-code return convention, NUL-argument +// guards, tool-name comparison, and the buffer-too-small path so each +// cartridge's `boj_cartridge_invoke` can stay short β€” typically a tool +// table plus `shim.writeResult(...)`. +// +// Cartridges import this file by relative path (no build-graph change +// needed). Example: +// +// const shim = @import("../../../ffi/zig/src/cartridge_shim.zig"); +// +// export fn boj_cartridge_invoke( +// tool_name: [*c]const u8, +// json_args: [*c]const u8, +// out_buf: [*c]u8, +// in_out_len: [*c]usize, +// ) callconv(.c) i32 { +// _ = json_args; +// if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; +// const body = if (shim.toolIs(tool_name, "foo")) "{\"result\":{}}" +// else return shim.RC_UNKNOWN_TOOL; +// return shim.writeResult(out_buf, in_out_len, body); +// } + +const std = @import("std"); + +// ── Return codes (ADR-0006 Β§Return codes) ──────────────────────────── +// +// Frozen by ADR-0006. New failure modes compose these via the error +// JSON body β€” the integer surface does not grow without a follow-up ADR. + +pub const RC_SUCCESS: i32 = 0; +pub const RC_UNKNOWN_TOOL: i32 = -1; +pub const RC_BAD_ARGS: i32 = -2; +pub const RC_BUFFER_TOO_SMALL: i32 = -3; +pub const RC_RUNTIME_ERROR: i32 = -4; +pub const RC_PANIC: i32 = -5; +pub const RC_AUTH_DENIED: i32 = -6; + +// ── Invoke-path helpers ────────────────────────────────────────────── + +/// True if any of the three mandatory `boj_cartridge_invoke` output-path +/// pointers is null. Use at the top of every invoke to short-circuit to +/// `RC_BAD_ARGS`. +pub fn invokeArgsNull( + tool_name: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) bool { + return tool_name == null or out_buf == null or in_out_len == null; +} + +/// Compare a C-NUL-terminated tool-name pointer against a Zig string +/// literal. Caller must have already verified `tool_name` is non-null +/// (usually via `invokeArgsNull`). +pub fn toolIs(tool_name: [*c]const u8, expected: []const u8) bool { + const s = std.mem.span(@as([*:0]const u8, @ptrCast(tool_name))); + return std.mem.eql(u8, s, expected); +} + +/// Copy `body` into `out_buf[0..*in_out_len]` (as a capacity) and update +/// `*in_out_len` to the number of bytes written. Returns `RC_SUCCESS`. +/// +/// If `body.len` exceeds the current capacity stored in `*in_out_len`, +/// sets `*in_out_len` to the required size and returns +/// `RC_BUFFER_TOO_SMALL` β€” the caller is then expected to re-allocate +/// and retry, per ADR-0006 Β§Memory ownership. +/// +/// Caller must have already verified that `out_buf` and `in_out_len` +/// are non-null. +pub fn writeResult( + out_buf: [*c]u8, + in_out_len: [*c]usize, + body: []const u8, +) i32 { + const cap = in_out_len.*; + if (body.len > cap) { + in_out_len.* = body.len; + return RC_BUFFER_TOO_SMALL; + } + @memcpy(out_buf[0..body.len], body); + in_out_len.* = body.len; + return RC_SUCCESS; +} + +// ── Tests ──────────────────────────────────────────────────────────── + +test "writeResult: body fits, writes and sets length" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); + try std.testing.expectEqualStrings("hello", buf[0..len]); +} + +test "writeResult: too small returns -3 and sets required length" { + var buf: [2]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_BUFFER_TOO_SMALL, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: exact-fit succeeds" { + var buf: [5]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: empty body" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, ""); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 0), len); +} + +test "toolIs: matches and rejects" { + const name: [*:0]const u8 = "foo"; + try std.testing.expect(toolIs(@ptrCast(name), "foo")); + try std.testing.expect(!toolIs(@ptrCast(name), "bar")); + try std.testing.expect(!toolIs(@ptrCast(name), "foobar")); + try std.testing.expect(!toolIs(@ptrCast(name), "fo")); +} + +test "invokeArgsNull: detects each null slot" { + var buf: [4]u8 = undefined; + var len: usize = 4; + const name: [*:0]const u8 = "x"; + try std.testing.expect(!invokeArgsNull(@ptrCast(name), &buf, &len)); + try std.testing.expect(invokeArgsNull(null, &buf, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), null, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), &buf, null)); +} diff --git a/cartridges/domains/database/postgresql-mcp/ffi/postgresql_mcp_ffi.zig b/cartridges/domains/database/postgresql-mcp/ffi/postgresql_mcp_ffi.zig new file mode 100644 index 0000000..eecf827 --- /dev/null +++ b/cartridges/domains/database/postgresql-mcp/ffi/postgresql_mcp_ffi.zig @@ -0,0 +1,472 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// postgresql_mcp_ffi.zig -- C-ABI FFI implementation for postgresql-mcp cartridge. +// +// Implements the state machine defined in PostgresqlMcp.SafeDatabase (Idris2 ABI). +// Thread-safe via std.Thread.Mutex. Wraps libpq C-ABI stubs for PQconnectdb, +// PQexec, PQresultStatus. All queries use parameterised statements to prevent +// SQL injection. No heap allocations for state management. + +const std = @import("std"); + +// --------------------------------------------------------------------------- +// Connection state machine (matches Idris2 ABI exactly) +// --------------------------------------------------------------------------- + +/// PostgreSQL connection lifecycle states. +/// Disconnected=0, Connected=1, InTransaction=2, QueryRunning=3, Error=4 +pub const ConnState = enum(c_int) { + disconnected = 0, + connected = 1, + in_transaction = 2, + query_running = 3, + err = 4, +}; + +/// PostgreSQL actions matching the Idris2 PostgresqlAction type. +pub const PostgresqlAction = enum(c_int) { + connect = 0, + disconnect = 1, + query = 2, + execute = 3, + begin_tx = 4, + commit_tx = 5, + rollback_tx = 6, + list_databases = 7, + list_schemas = 8, + list_tables = 9, + describe_table = 10, + list_indices = 11, + explain = 12, + copy_to = 13, + copy_from = 14, + notify = 15, +}; + +/// Validate a state transition against the proven Idris2 transition graph. +fn isValidTransition(from: ConnState, to: ConnState) bool { + return switch (from) { + .disconnected => to == .connected, + .connected => to == .disconnected or to == .in_transaction or to == .query_running, + .in_transaction => to == .connected or to == .query_running, + .query_running => to == .connected or to == .in_transaction or to == .err, + .err => to == .disconnected, + }; +} + +// --------------------------------------------------------------------------- +// Connection slots (thread-safe, fixed-size pool) +// --------------------------------------------------------------------------- + +const MAX_CONNECTIONS: usize = 16; +const CONNSTR_BUF_SIZE: usize = 1024; + +/// A single connection slot in the pool. +const ConnectionSlot = struct { + active: bool = false, + state: ConnState = .disconnected, + connstr_buf: [CONNSTR_BUF_SIZE]u8 = undefined, + connstr_len: usize = 0, + query_count: u64 = 0, + tx_depth: u32 = 0, +}; + +var connections: [MAX_CONNECTIONS]ConnectionSlot = [_]ConnectionSlot{.{}} ** MAX_CONNECTIONS; +var mutex: std.Thread.Mutex = .{}; + +// --------------------------------------------------------------------------- +// libpq C-ABI stubs (linked at build time) +// --------------------------------------------------------------------------- + +/// Opaque libpq connection handle. +const PGconn = opaque {}; +/// Opaque libpq result handle. +const PGresult = opaque {}; + +extern fn PQconnectdb(conninfo: [*:0]const u8) ?*PGconn; +extern fn PQfinish(conn: *PGconn) void; +extern fn PQexecParams( + conn: *PGconn, + command: [*:0]const u8, + n_params: c_int, + param_types: ?[*]const c_uint, + param_values: ?[*]const ?[*:0]const u8, + param_lengths: ?[*]const c_int, + param_formats: ?[*]const c_int, + result_format: c_int, +) ?*PGresult; +extern fn PQresultStatus(res: *PGresult) c_int; +extern fn PQclear(res: *PGresult) void; +extern fn PQstatus(conn: *PGconn) c_int; + +// --------------------------------------------------------------------------- +// C-ABI exports +// --------------------------------------------------------------------------- + +/// Check if a state transition is valid. Returns 1 (valid) or 0 (invalid). +pub export fn postgresql_mcp_can_transition(from: c_int, to: c_int) c_int { + const f = std.meta.intToEnum(ConnState, from) catch return 0; + const t = std.meta.intToEnum(ConnState, to) catch return 0; + return if (isValidTransition(f, t)) 1 else 0; +} + +/// Open a new connection slot. Returns slot index (>= 0) or -1 if pool full. +pub export fn postgresql_mcp_connect(connstr_ptr: [*]const u8, connstr_len: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const len: usize = std.math.cast(usize, connstr_len) orelse return -2; + if (len == 0 or len > CONNSTR_BUF_SIZE) return -2; + + for (&connections, 0..) |*slot, idx| { + if (!slot.active) { + @memcpy(slot.connstr_buf[0..len], connstr_ptr[0..len]); + slot.connstr_len = len; + slot.active = true; + slot.state = .connected; + slot.query_count = 0; + slot.tx_depth = 0; + return @intCast(idx); + } + } + return -1; +} + +/// Disconnect a connection slot. Returns 0 on success, -1 invalid slot, -2 bad transition. +pub export fn postgresql_mcp_disconnect(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_CONNECTIONS) return -1; + var slot = &connections[idx]; + if (!slot.active) return -1; + if (!isValidTransition(slot.state, .disconnected)) return -2; + + slot.active = false; + slot.state = .disconnected; + slot.connstr_len = 0; + slot.query_count = 0; + slot.tx_depth = 0; + return 0; +} + +/// Get the current state of a connection. Returns state int or -1 if invalid. +pub export fn postgresql_mcp_connection_state(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_CONNECTIONS) return -1; + const slot = &connections[idx]; + if (!slot.active) return -1; + return @intFromEnum(slot.state); +} + +/// Begin a transaction. Returns 0 on success. +pub export fn postgresql_mcp_begin_tx(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_CONNECTIONS) return -1; + var slot = &connections[idx]; + if (!slot.active) return -1; + if (!isValidTransition(slot.state, .in_transaction)) return -2; + + slot.state = .in_transaction; + slot.tx_depth += 1; + return 0; +} + +/// Commit or rollback a transaction. Returns 0 on success. +pub export fn postgresql_mcp_end_tx(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_CONNECTIONS) return -1; + var slot = &connections[idx]; + if (!slot.active) return -1; + if (!isValidTransition(slot.state, .connected)) return -2; + + slot.state = .connected; + if (slot.tx_depth > 0) slot.tx_depth -= 1; + return 0; +} + +/// Begin query execution. Returns 0 on success. +pub export fn postgresql_mcp_begin_query(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_CONNECTIONS) return -1; + var slot = &connections[idx]; + if (!slot.active) return -1; + if (!isValidTransition(slot.state, .query_running)) return -2; + + slot.state = .query_running; + slot.query_count += 1; + return 0; +} + +/// Complete query execution (return to previous state). Returns 0 on success. +/// If tx_depth > 0, returns to in_transaction; otherwise returns to connected. +pub export fn postgresql_mcp_end_query(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_CONNECTIONS) return -1; + var slot = &connections[idx]; + if (!slot.active) return -1; + if (slot.state != .query_running) return -2; + + slot.state = if (slot.tx_depth > 0) .in_transaction else .connected; + return 0; +} + +/// Signal an error on a query. Returns 0 on success. +pub export fn postgresql_mcp_signal_error(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_CONNECTIONS) return -1; + var slot = &connections[idx]; + if (!slot.active) return -1; + if (!isValidTransition(slot.state, .err)) return -2; + + slot.state = .err; + return 0; +} + +/// Get the query count for a connection. Returns count or -1 if invalid. +pub export fn postgresql_mcp_query_count(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_CONNECTIONS) return -1; + const slot = &connections[idx]; + if (!slot.active) return -1; + return @intCast(@min(slot.query_count, std.math.maxInt(c_int))); +} + +/// Get the number of active connections. +pub export fn postgresql_mcp_active_count() c_int { + mutex.lock(); + defer mutex.unlock(); + + var count: c_int = 0; + for (&connections) |*slot| { + if (slot.active) count += 1; + } + return count; +} + +/// Reset all connections (test/debug use only). +pub export fn postgresql_mcp_reset() void { + mutex.lock(); + defer mutex.unlock(); + connections = [_]ConnectionSlot{.{}} ** MAX_CONNECTIONS; +} + +// --------------------------------------------------------------------------- +// ═══════════════════════════════════════════════════════════════════════ +// Standard ABI (ADR-0005 four symbols + ADR-0006 invoke) +// ═══════════════════════════════════════════════════════════════════════ + +const shim = @import("cartridge_shim.zig"); + +const CARTRIDGE_NAME_PTR: [*:0]const u8 = "postgresql-mcp"; +const CARTRIDGE_VERSION_PTR: [*:0]const u8 = "0.1.0"; + +export fn boj_cartridge_init() callconv(.c) c_int { + return 0; +} + +export fn boj_cartridge_deinit() callconv(.c) void {} + +export fn boj_cartridge_name() callconv(.c) [*:0]const u8 { + return CARTRIDGE_NAME_PTR; +} + +export fn boj_cartridge_version() callconv(.c) [*:0]const u8 { + return CARTRIDGE_VERSION_PTR; +} + +/// Dispatch the cartridge.json MCP tools. Grade D Alpha stubs. +export fn boj_cartridge_invoke( + tool_name: [*c]const u8, + json_args: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) callconv(.c) i32 { + _ = json_args; + if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; + + const body: []const u8 = if (shim.toolIs(tool_name, "pg_connect")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "pg_query")) + "{\"result\":{\"matches\":[],\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "pg_execute")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "pg_begin")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "pg_commit")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "pg_rollback")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "pg_list_tables")) + "{\"result\":{\"items\":[],\"count\":0,\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "pg_describe")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "pg_disconnect")) + "{\"result\":{\"status\":\"stub\"}}" +else + return shim.RC_UNKNOWN_TOOL; + + return shim.writeResult(out_buf, in_out_len, body); +} + +// Tests +// --------------------------------------------------------------------------- + +test "connection lifecycle" { + postgresql_mcp_reset(); + + const slot = postgresql_mcp_connect("postgres://test:pw@localhost:5432/db", 38); + try std.testing.expect(slot >= 0); + + // Should be connected + try std.testing.expectEqual(@as(c_int, 1), postgresql_mcp_connection_state(slot)); + + // Begin transaction + try std.testing.expectEqual(@as(c_int, 0), postgresql_mcp_begin_tx(slot)); + try std.testing.expectEqual(@as(c_int, 2), postgresql_mcp_connection_state(slot)); + + // Run query inside transaction + try std.testing.expectEqual(@as(c_int, 0), postgresql_mcp_begin_query(slot)); + try std.testing.expectEqual(@as(c_int, 3), postgresql_mcp_connection_state(slot)); + + // Complete query -> back to in_transaction + try std.testing.expectEqual(@as(c_int, 0), postgresql_mcp_end_query(slot)); + try std.testing.expectEqual(@as(c_int, 2), postgresql_mcp_connection_state(slot)); + + // Commit transaction + try std.testing.expectEqual(@as(c_int, 0), postgresql_mcp_end_tx(slot)); + try std.testing.expectEqual(@as(c_int, 1), postgresql_mcp_connection_state(slot)); + + // Disconnect + try std.testing.expectEqual(@as(c_int, 0), postgresql_mcp_disconnect(slot)); +} + +test "query error transitions" { + postgresql_mcp_reset(); + + const slot = postgresql_mcp_connect("postgres://test:pw@localhost:5432/db", 38); + try std.testing.expect(slot >= 0); + + // Run a query + try std.testing.expectEqual(@as(c_int, 0), postgresql_mcp_begin_query(slot)); + + // Signal error + try std.testing.expectEqual(@as(c_int, 0), postgresql_mcp_signal_error(slot)); + try std.testing.expectEqual(@as(c_int, 4), postgresql_mcp_connection_state(slot)); + + // Can only go to disconnected from error + try std.testing.expectEqual(@as(c_int, 0), postgresql_mcp_disconnect(slot)); +} + +test "transition validator" { + // Valid transitions + try std.testing.expectEqual(@as(c_int, 1), postgresql_mcp_can_transition(0, 1)); // disconn -> connected + try std.testing.expectEqual(@as(c_int, 1), postgresql_mcp_can_transition(1, 0)); // connected -> disconn + try std.testing.expectEqual(@as(c_int, 1), postgresql_mcp_can_transition(1, 2)); // connected -> in_tx + try std.testing.expectEqual(@as(c_int, 1), postgresql_mcp_can_transition(2, 1)); // in_tx -> connected + try std.testing.expectEqual(@as(c_int, 1), postgresql_mcp_can_transition(1, 3)); // connected -> query + try std.testing.expectEqual(@as(c_int, 1), postgresql_mcp_can_transition(2, 3)); // in_tx -> query + try std.testing.expectEqual(@as(c_int, 1), postgresql_mcp_can_transition(3, 1)); // query -> connected + try std.testing.expectEqual(@as(c_int, 1), postgresql_mcp_can_transition(3, 2)); // query -> in_tx + try std.testing.expectEqual(@as(c_int, 1), postgresql_mcp_can_transition(3, 4)); // query -> error + try std.testing.expectEqual(@as(c_int, 1), postgresql_mcp_can_transition(4, 0)); // error -> disconn + + // Invalid transitions + try std.testing.expectEqual(@as(c_int, 0), postgresql_mcp_can_transition(0, 3)); // disconn -> query + try std.testing.expectEqual(@as(c_int, 0), postgresql_mcp_can_transition(1, 4)); // connected -> error + try std.testing.expectEqual(@as(c_int, 0), postgresql_mcp_can_transition(4, 1)); // error -> connected + + // Out of range + try std.testing.expectEqual(@as(c_int, 0), postgresql_mcp_can_transition(99, 0)); +} + +test "pool exhaustion" { + postgresql_mcp_reset(); + + var slots: [MAX_CONNECTIONS]c_int = undefined; + for (&slots) |*s| { + s.* = postgresql_mcp_connect("postgres://x:y@h:5432/d", 23); + try std.testing.expect(s.* >= 0); + } + + // Pool full + try std.testing.expectEqual(@as(c_int, -1), postgresql_mcp_connect("postgres://x:y@h:5432/d", 23)); + try std.testing.expectEqual(@as(c_int, @intCast(MAX_CONNECTIONS)), postgresql_mcp_active_count()); + + // Free one and retry + try std.testing.expectEqual(@as(c_int, 0), postgresql_mcp_disconnect(slots[0])); + const new_slot = postgresql_mcp_connect("postgres://x:y@h:5432/d", 23); + try std.testing.expect(new_slot >= 0); +} + +// ═══════════════════════════════════════════════════════════════════════ +// ADR-0006 invoke dispatch tests +// ═══════════════════════════════════════════════════════════════════════ + +test "boj_cartridge_name returns postgresql-mcp" { + const n = std.mem.span(boj_cartridge_name()); + try std.testing.expectEqualStrings("postgresql-mcp", n); +} + +test "boj_cartridge_init returns 0" { + try std.testing.expectEqual(@as(c_int, 0), boj_cartridge_init()); +} + +test "invoke: each declared tool succeeds" { + var buf: [256]u8 = undefined; + const tools = [_][]const u8{ + "pg_connect", + "pg_query", + "pg_execute", + "pg_begin", + "pg_commit", + "pg_rollback", + "pg_list_tables", + "pg_describe", + "pg_disconnect", + }; + for (tools) |t| { + var len: usize = buf.len; + const rc = boj_cartridge_invoke(t.ptr, "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, 0), rc); + try std.testing.expect(std.mem.indexOf(u8, buf[0..len], "result") != null); + } +} + +test "invoke: unknown tool returns -1" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("nope", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -1), rc); +} + +test "invoke: buffer too small returns -3" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("pg_connect", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -3), rc); + try std.testing.expect(len > 4); +} diff --git a/cartridges/domains/database/postgresql-mcp/minter.toml b/cartridges/domains/database/postgresql-mcp/minter.toml new file mode 100644 index 0000000..62df945 --- /dev/null +++ b/cartridges/domains/database/postgresql-mcp/minter.toml @@ -0,0 +1,46 @@ +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +# +# minter.toml -- Cartridge metadata for postgresql-mcp. + +name = "postgresql-mcp" +description = "PostgreSQL database cartridge with parameterised queries, transaction support, and libpq C-ABI integration" +version = "0.1.0" +domain = "Database" +protocols = [ + "MCP", + "REST", +] +tier = "Ayo" +backend = "universal" +generate_panel = true + +[auth] +method = "connection_string" +vault_mcp = true +format = "postgres://user:pass@host:5432/db" + +[actions] +count = 16 +list = [ + "Connect", + "Disconnect", + "Query", + "Execute", + "BeginTx", + "CommitTx", + "RollbackTx", + "ListDatabases", + "ListSchemas", + "ListTables", + "DescribeTable", + "ListIndices", + "Explain", + "CopyTo", + "CopyFrom", + "Notify", +] + +[state_machine] +states = ["Disconnected", "Connected", "InTransaction", "QueryRunning", "Error"] +initial = "Disconnected" diff --git a/cartridges/domains/database/postgresql-mcp/mod.js b/cartridges/domains/database/postgresql-mcp/mod.js new file mode 100644 index 0000000..61a19a3 --- /dev/null +++ b/cartridges/domains/database/postgresql-mcp/mod.js @@ -0,0 +1,88 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// postgresql-mcp/mod.js -- postgresql gateway. + +const BASE_URL = Deno.env.get("POSTGRESQL_MCP_BACKEND_URL") ?? "http://127.0.0.1:7719"; + +async function post(path, payload) { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), 15000); + try { + const r = await fetch(`${BASE_URL}${path}`, { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), signal: ctrl.signal, + }); + const data = await r.json().catch(() => ({ success: false, error: "non-JSON response" })); + return { status: r.status, data }; + } catch (e) { + if (e.name === "AbortError") return { status: 504, data: { success: false, error: "postgresql-mcp backend timed out" } }; + return { status: 503, data: { success: false, error: `postgresql-mcp backend unavailable: ${e.message}` } }; + } finally { clearTimeout(t); } +} + +export async function handleTool(toolName, args) { + switch (toolName) { + + case "pg_connect": { + const { connection_string } = args ?? {}; + if (!connection_string) return { status: 400, data: { error: "connection_string is required" } }; + const payload = { connection_string }; + return post("/api/v1/pg-connect", payload); + } + case "pg_query": { + const { slot, sql, params } = args ?? {}; + if (!slot || !sql) return { status: 400, data: { error: "slot is required" } }; + const payload = { slot, sql }; + if (params !== undefined) payload.params = params; + return post("/api/v1/pg-query", payload); + } + case "pg_execute": { + const { slot, sql, params } = args ?? {}; + if (!slot || !sql) return { status: 400, data: { error: "slot is required" } }; + const payload = { slot, sql }; + if (params !== undefined) payload.params = params; + return post("/api/v1/pg-execute", payload); + } + case "pg_begin": { + const { slot } = args ?? {}; + if (!slot) return { status: 400, data: { error: "slot is required" } }; + const payload = { slot }; + return post("/api/v1/pg-begin", payload); + } + case "pg_commit": { + const { slot } = args ?? {}; + if (!slot) return { status: 400, data: { error: "slot is required" } }; + const payload = { slot }; + return post("/api/v1/pg-commit", payload); + } + case "pg_rollback": { + const { slot } = args ?? {}; + if (!slot) return { status: 400, data: { error: "slot is required" } }; + const payload = { slot }; + return post("/api/v1/pg-rollback", payload); + } + case "pg_list_tables": { + const { slot, schema } = args ?? {}; + if (!slot) return { status: 400, data: { error: "slot is required" } }; + const payload = { slot }; + if (schema !== undefined) payload.schema = schema; + return post("/api/v1/pg-list-tables", payload); + } + case "pg_describe": { + const { slot, table } = args ?? {}; + if (!slot || !table) return { status: 400, data: { error: "slot is required" } }; + const payload = { slot, table }; + return post("/api/v1/pg-describe", payload); + } + case "pg_disconnect": { + const { slot } = args ?? {}; + if (!slot) return { status: 400, data: { error: "slot is required" } }; + const payload = { slot }; + return post("/api/v1/pg-disconnect", payload); + } + + default: + return { status: 404, data: { error: `Unknown tool: ${toolName}` } }; + } +} diff --git a/cartridges/domains/database/postgresql-mcp/panels/manifest.json b/cartridges/domains/database/postgresql-mcp/panels/manifest.json new file mode 100644 index 0000000..371a009 --- /dev/null +++ b/cartridges/domains/database/postgresql-mcp/panels/manifest.json @@ -0,0 +1,94 @@ +{ + "$schema": "https://panll.dev/schemas/panel-manifest/v1.json", + "spdx": "MPL-2.0", + "cartridge": "postgresql-mcp", + "domain": "Database", + "version": "0.1.0", + "panels": [ + { + "id": "pg-connection-status", + "title": "Connection Status", + "description": "Current PostgreSQL connection lifecycle state (Disconnected / Connected / InTransaction / QueryRunning / Error)", + "type": "status-indicator", + "data_source": { + "endpoint": "/postgresql/state", + "method": "GET", + "refresh_interval_ms": 5000 + }, + "widgets": [ + { + "type": "state-badge", + "field": "state", + "states": { + "disconnected": { "color": "#95a5a6", "icon": "database-off" }, + "connected": { "color": "#2ecc71", "icon": "database" }, + "in_transaction": { "color": "#f39c12", "icon": "database-lock" }, + "query_running": { "color": "#3498db", "icon": "database-cog" }, + "error": { "color": "#e74c3c", "icon": "database-alert" } + } + } + ] + }, + { + "id": "pg-active-connections", + "title": "Active Connections", + "description": "Number of active PostgreSQL connections in the pool", + "type": "metric", + "data_source": { + "endpoint": "/postgresql/status", + "method": "GET", + "refresh_interval_ms": 10000 + }, + "widgets": [ + { + "type": "counter", + "field": "active_count", + "label": "Connections", + "icon": "plug" + } + ] + }, + { + "id": "pg-query-count", + "title": "Query Count", + "description": "Total number of queries executed across all connections", + "type": "metric", + "data_source": { + "endpoint": "/postgresql/metrics", + "method": "GET", + "refresh_interval_ms": 15000 + }, + "widgets": [ + { + "type": "counter", + "field": "total_query_count", + "label": "Queries", + "icon": "file-search" + } + ] + }, + { + "id": "pg-transaction-state", + "title": "Transaction State", + "description": "Current transaction depth and state for active connections", + "type": "metric", + "data_source": { + "endpoint": "/postgresql/transactions", + "method": "GET", + "refresh_interval_ms": 5000 + }, + "widgets": [ + { + "type": "state-badge", + "field": "tx_state", + "states": { + "idle": { "color": "#95a5a6", "icon": "circle-pause" }, + "active": { "color": "#f39c12", "icon": "arrow-right-left" }, + "committed": { "color": "#2ecc71", "icon": "circle-check" }, + "rolled_back": { "color": "#e74c3c", "icon": "undo" } + } + } + ] + } + ] +} diff --git a/cartridges/domains/database/postgresql-mcp/tests/integration_test.sh b/cartridges/domains/database/postgresql-mcp/tests/integration_test.sh new file mode 100755 index 0000000..7a147f1 --- /dev/null +++ b/cartridges/domains/database/postgresql-mcp/tests/integration_test.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Integration tests for postgresql-mcp cartridge. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CART_DIR="$(dirname "$SCRIPT_DIR")" +FFI_DIR="$CART_DIR/ffi" + +echo "=== postgresql-mcp integration tests ===" + +# Build FFI +echo "[1/3] Building FFI..." +cd "$FFI_DIR" && zig build 2>&1 + +# Run Zig unit tests +echo "[2/3] Running FFI unit tests..." +cd "$FFI_DIR" && zig build test 2>&1 + +# Validate Idris2 ABI (if idris2 available) +echo "[3/3] Checking ABI..." +if command -v idris2 &>/dev/null; then + cd "$CART_DIR/abi" && idris2 --check PostgresqlMcp.SafeDatabase 2>&1 + echo " ABI: OK" +else + echo " ABI: SKIPPED (idris2 not in PATH)" +fi + +echo "" +echo "All tests passed for postgresql-mcp!" diff --git a/cartridges/domains/database/redis-mcp/README.adoc b/cartridges/domains/database/redis-mcp/README.adoc new file mode 100644 index 0000000..cdb7aff --- /dev/null +++ b/cartridges/domains/database/redis-mcp/README.adoc @@ -0,0 +1,77 @@ +// SPDX-License-Identifier: MPL-2.0 += redis-mcp +Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk> +:spdx: MPL-2.0 +:tier: Ayo +:domain: Database +:protocols: MCP, REST + +== Overview + +Redis database cartridge for BoJ Server. Provides 20 MCP actions covering +strings, lists, sets, hashes, pub/sub, TTL management, server introspection, +and pipeline batching via the RESP protocol. + +Authentication via AUTH command with password sourced from vault-mcp. +Default connection to host:6379. + +== State Machine + +* `Disconnected` -- no active connection +* `Connected` -- authenticated, ready for commands +* `Subscribing` -- in pub/sub subscriber mode (limited command set) +* `Error` -- must disconnect to recover + +Valid transitions: + + Disconnected -> Connected + Connected -> Disconnected | Subscribing | Error + Subscribing -> Connected | Error + Error -> Disconnected + +== Actions (20) + +Get, Set, Del, Keys, Exists, Expire, TTL, LPush, RPush, LRange, +SAdd, SMembers, HSet, HGet, HGetAll, Publish, Subscribe, Unsubscribe, +Info, Ping + +== Architecture + +[cols="1,1,2"] +|=== +| Layer | Language | Purpose + +| ABI +| Idris2 +| Dependently-typed state machine (`RedisMcp.SafeDatabase`) + +| FFI +| Zig +| C-ABI implementation with hiredis RESP stubs, pipeline support + +| Adapter +| zig +| REST/MCP bridge exposing all 20 actions + +| Panels +| JSON +| PanLL manifest for connection status, key count, memory usage, pub/sub channels +|=== + +== Building + +[source,bash] +---- +# Build FFI shared library (links hiredis) +cd ffi && zig build + +# Run FFI tests +cd ffi && zig build test + +# Type-check ABI +cd abi && idris2 --check redis_mcp.ipkg +---- + +== Status + +Development -- not yet ready for mounting. diff --git a/cartridges/domains/database/redis-mcp/abi/README.adoc b/cartridges/domains/database/redis-mcp/abi/README.adoc new file mode 100644 index 0000000..53029c3 --- /dev/null +++ b/cartridges/domains/database/redis-mcp/abi/README.adoc @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += redis-mcp / abi β€” Idris2 ABI layer +:orientation: shallow + +== Purpose + +Cartridge-local protocol types for `redis-mcp`. Defines the data, tool, and +transition vocabulary this cartridge speaks. Inherits safety lemmas and the +matrix-cell identifier scheme from the trunk ABI at `src/abi/`. + +== Files + +1 Idris2 module(s), ~235 lines total. Lead module: +`SafeDatabase.idr`. + +No `.ipkg` β€” consumed directly by wider BoJ callers; type-checks inside the +trunk `boj.ipkg`. + +== Invariants + +* `%default total` at every module top. +* Zero `believe_me`, `assert_total`, `postulate`, `Admitted`, or + `sorry` (the trunk holds the four declared `believe_me` sites; leaf + cartridges carry none). + +== Test/proof surface + +Thin module(s), no inline tests. Proof surface = type-check success under the +trunk `idris2 --check`. + +== Read-first + +. `SafeDatabase.idr` β€” cartridge-local data records and enums. +. Trunk `src/abi/Boj/Catalogue.idr` for how this cell participates in the + 2D matrix. diff --git a/cartridges/domains/database/redis-mcp/abi/RedisMcp/SafeDatabase.idr b/cartridges/domains/database/redis-mcp/abi/RedisMcp/SafeDatabase.idr new file mode 100644 index 0000000..a3dd0a0 --- /dev/null +++ b/cartridges/domains/database/redis-mcp/abi/RedisMcp/SafeDatabase.idr @@ -0,0 +1,235 @@ +-- SPDX-License-Identifier: MPL-2.0 +-- Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +-- +-- RedisMcp.SafeDatabase -- Type-safe ABI for redis-mcp cartridge. +-- +-- Dependently-typed state machine modelling Redis connection lifecycle. +-- Transitions are proven valid at compile time. Authentication via AUTH +-- command with password sourced from vault-mcp. Supports RESP protocol +-- including pipeline and pub/sub modes. + +module RedisMcp.SafeDatabase + +%default total + +-- --------------------------------------------------------------------------- +-- Connection state machine +-- --------------------------------------------------------------------------- + +||| Redis connection lifecycle states. +||| +||| @ Disconnected No active connection to the Redis server. +||| @ Connected Authenticated connection established; ready for commands. +||| @ Subscribing In pub/sub subscriber mode (limited command set). +||| @ Error An error has occurred; must disconnect to recover. +public export +data ConnState + = Disconnected + | Connected + | Subscribing + | Error + +||| Proof that a state transition is valid within the Redis protocol. +||| +||| The transition graph: +||| Disconnected -> Connected (connect + AUTH) +||| Connected -> Subscribing (SUBSCRIBE) +||| Subscribing -> Connected (UNSUBSCRIBE all) +||| Connected -> Error (connection or protocol error) +||| Subscribing -> Error (connection error during sub) +||| Error -> Disconnected (disconnect after error) +||| Connected -> Disconnected (graceful disconnect) +public export +data ValidTransition : ConnState -> ConnState -> Type where + Connect : ValidTransition Disconnected Connected + Disconnect : ValidTransition Connected Disconnected + Subscribe : ValidTransition Connected Subscribing + Unsubscribe : ValidTransition Subscribing Connected + ConnError : ValidTransition Connected Error + SubError : ValidTransition Subscribing Error + ErrorReset : ValidTransition Error Disconnected + +-- --------------------------------------------------------------------------- +-- C-ABI integer encoding +-- --------------------------------------------------------------------------- + +||| Encode connection state as C-compatible integer. +export +connStateToInt : ConnState -> Int +connStateToInt Disconnected = 0 +connStateToInt Connected = 1 +connStateToInt Subscribing = 2 +connStateToInt Error = 3 + +||| Decode integer back to connection state. +export +intToConnState : Int -> Maybe ConnState +intToConnState 0 = Just Disconnected +intToConnState 1 = Just Connected +intToConnState 2 = Just Subscribing +intToConnState 3 = Just Error +intToConnState _ = Nothing + +||| Check if a state transition is valid (C-ABI export). +||| Returns 1 for valid, 0 for invalid. +export +redis_mcp_can_transition : Int -> Int -> Int +redis_mcp_can_transition from to = + case (intToConnState from, intToConnState to) of + (Just Disconnected, Just Connected) => 1 + (Just Connected, Just Disconnected) => 1 + (Just Connected, Just Subscribing) => 1 + (Just Subscribing, Just Connected) => 1 + (Just Connected, Just Error) => 1 + (Just Subscribing, Just Error) => 1 + (Just Error, Just Disconnected) => 1 + _ => 0 + +-- --------------------------------------------------------------------------- +-- Redis actions +-- --------------------------------------------------------------------------- + +||| Actions exposed via the redis-mcp MCP protocol. +||| +||| All 20 operations supported by this cartridge, covering strings, lists, +||| sets, hashes, pub/sub, TTL management, and server introspection. +public export +data RedisAction + = Get + | Set + | Del + | Keys + | Exists + | Expire + | TTL + | LPush + | RPush + | LRange + | SAdd + | SMembers + | HSet + | HGet + | HGetAll + | Publish + | SubscribeAction + | UnsubscribeAction + | Info + | Ping + +||| Encode action as C-compatible integer. +export +actionToInt : RedisAction -> Int +actionToInt Get = 0 +actionToInt Set = 1 +actionToInt Del = 2 +actionToInt Keys = 3 +actionToInt Exists = 4 +actionToInt Expire = 5 +actionToInt TTL = 6 +actionToInt LPush = 7 +actionToInt RPush = 8 +actionToInt LRange = 9 +actionToInt SAdd = 10 +actionToInt SMembers = 11 +actionToInt HSet = 12 +actionToInt HGet = 13 +actionToInt HGetAll = 14 +actionToInt Publish = 15 +actionToInt SubscribeAction = 16 +actionToInt UnsubscribeAction = 17 +actionToInt Info = 18 +actionToInt Ping = 19 + +||| Decode integer back to action. +export +intToAction : Int -> Maybe RedisAction +intToAction 0 = Just Get +intToAction 1 = Just Set +intToAction 2 = Just Del +intToAction 3 = Just Keys +intToAction 4 = Just Exists +intToAction 5 = Just Expire +intToAction 6 = Just TTL +intToAction 7 = Just LPush +intToAction 8 = Just RPush +intToAction 9 = Just LRange +intToAction 10 = Just SAdd +intToAction 11 = Just SMembers +intToAction 12 = Just HSet +intToAction 13 = Just HGet +intToAction 14 = Just HGetAll +intToAction 15 = Just Publish +intToAction 16 = Just SubscribeAction +intToAction 17 = Just UnsubscribeAction +intToAction 18 = Just Info +intToAction 19 = Just Ping +intToAction _ = Nothing + +||| Check whether an action requires an active connection. +export +actionRequiresConnection : RedisAction -> Bool +actionRequiresConnection Ping = False +actionRequiresConnection _ = True + +||| Check whether an action is only valid in Subscribing state. +export +actionRequiresSubscribing : RedisAction -> Bool +actionRequiresSubscribing UnsubscribeAction = True +actionRequiresSubscribing _ = False + +||| Check whether an action enters Subscribing state. +export +actionEntersSubscribing : RedisAction -> Bool +actionEntersSubscribing SubscribeAction = True +actionEntersSubscribing _ = False + +||| Total number of actions in this cartridge. +export +actionCount : Nat +actionCount = 20 + +-- --------------------------------------------------------------------------- +-- Authentication +-- --------------------------------------------------------------------------- + +||| Authentication method for Redis connections. +||| Password sourced from vault-mcp via AUTH command to host:6379. +public export +data AuthMethod + = AuthPassword + | VaultRef String + +-- --------------------------------------------------------------------------- +-- RESP protocol types +-- --------------------------------------------------------------------------- + +||| Redis RESP (REdis Serialization Protocol) wire types. +public export +data RespType + = SimpleString + | RespError + | RespInteger + | BulkString + | RespArray + | RespNull + +||| Encode RESP type as C-compatible integer. +export +respTypeToInt : RespType -> Int +respTypeToInt SimpleString = 0 +respTypeToInt RespError = 1 +respTypeToInt RespInteger = 2 +respTypeToInt BulkString = 3 +respTypeToInt RespArray = 4 +respTypeToInt RespNull = 5 + +||| Decode integer back to RESP type. +export +intToRespType : Int -> Maybe RespType +intToRespType 0 = Just SimpleString +intToRespType 1 = Just RespError +intToRespType 2 = Just RespInteger +intToRespType 3 = Just BulkString +intToRespType 4 = Just RespArray +intToRespType 5 = Just RespNull +intToRespType _ = Nothing diff --git a/cartridges/domains/database/redis-mcp/abi/redis_mcp.ipkg b/cartridges/domains/database/redis-mcp/abi/redis_mcp.ipkg new file mode 100644 index 0000000..62790c5 --- /dev/null +++ b/cartridges/domains/database/redis-mcp/abi/redis_mcp.ipkg @@ -0,0 +1,11 @@ +-- SPDX-License-Identifier: MPL-2.0 +-- Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +package redis_mcp + +version = "0.1.0" +authors = "Jonathan D.A. Jewell" +brief = "Type-safe ABI for Redis MCP cartridge with dependently-typed state machine" + +depends = base + +modules = RedisMcp.SafeDatabase diff --git a/cartridges/domains/database/redis-mcp/adapter/README.adoc b/cartridges/domains/database/redis-mcp/adapter/README.adoc new file mode 100644 index 0000000..aa7d52b --- /dev/null +++ b/cartridges/domains/database/redis-mcp/adapter/README.adoc @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += redis-mcp / adapter β€” protocol bridge +:orientation: shallow + +== Purpose + +Exposes this cartridge's FFI over the wire protocols declared in +`cartridge.json`. Dispatches tool invocations to the C-ABI exports at +`../ffi/` and formats responses per protocol. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” depends on the sibling `ffi` target. +| `redis_mcp_adapter.zig` | Protocol dispatch (118 lines). Tool catalogue matches `cartridge.json`. +| `SIDELINED-*.v.adoc` | zig predecessor(s). Not built β€” zig banned 2026-04-10. +|=== + +== Invariants + +* **Stateless** β€” all state lives behind the FFI, never in the adapter. +* **Response passthrough** β€” whatever the FFI returns goes back to the wire + unmodified (no embellishment, no silent recovery). +* **Cartridge.json is source of truth** for the tool catalogue; drift between + adapter and manifest is a CI failure. + +== Test/proof surface + +No inline tests at the adapter layer β€” protocol routing is exercised by the +trunk seam tests at `ffi/zig/src/seams.zig`. + +== Read-first + +. `redis_mcp_adapter.zig` β€” dispatch function and per-protocol routers. +. `../cartridge.json` β€” the tool manifest the dispatcher mirrors. diff --git a/cartridges/domains/database/redis-mcp/adapter/build.zig b/cartridges/domains/database/redis-mcp/adapter/build.zig new file mode 100644 index 0000000..5b1add9 --- /dev/null +++ b/cartridges/domains/database/redis-mcp/adapter/build.zig @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// redis-mcp/adapter/build.zig + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); const optimize = b.standardOptimizeOption(.{}); + const ffi_mod = b.createModule(.{ .root_source_file = b.path("../ffi/redis_mcp_ffi.zig"), .target = target, .optimize = optimize }); + const adapter = b.addExecutable(.{ .name = "redis_mcp_adapter", .root_source_file = b.path("redis_mcp_adapter.zig"), .target = target, .optimize = optimize }); + adapter.root_module.addImport("redis_mcp_ffi", ffi_mod); b.installArtifact(adapter); + const rs = b.step("run", "Run redis-mcp adapter"); rs.dependOn(&b.addRunArtifact(adapter).step); + const tests = b.addTest(.{ .root_source_file = b.path("redis_mcp_adapter.zig"), .target = target, .optimize = optimize }); + tests.root_module.addImport("redis_mcp_ffi", ffi_mod); + const ts = b.step("test", "Test redis-mcp adapter"); ts.dependOn(&b.addRunArtifact(tests).step); +} diff --git a/cartridges/domains/database/redis-mcp/adapter/redis_mcp_adapter.zig b/cartridges/domains/database/redis-mcp/adapter/redis_mcp_adapter.zig new file mode 100644 index 0000000..3d2e2b1 --- /dev/null +++ b/cartridges/domains/database/redis-mcp/adapter/redis_mcp_adapter.zig @@ -0,0 +1,118 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// redis-mcp/adapter/redis_mcp_adapter.zig -- Unified three-protocol adapter. +// Replaces banned redis_mcp_adapter.v (zig, removed 2026-04-12). +// REST:9163 gRPC:9164 GraphQL:9165 +// Tools: redis_connect, redis_get, redis_set, redis_del... + +const std = @import("std"); +const ffi = @import("redis_mcp_ffi"); + +const REST_PORT: u16 = 9163; +const GRPC_PORT: u16 = 9164; +const GQL_PORT: u16 = 9165; +const MAX_CONN_BUF: usize = 16 * 1024; + +fn okJson(buf: []u8, msg: []const u8) []u8 { + const n = std.fmt.bufPrint(buf, "{\"success\":true,\"message\":\"{s}\"}", .{msg}) catch return buf[0..0]; return n; +} +fn errJson(buf: []u8, msg: []const u8) []u8 { + const n = std.fmt.bufPrint(buf, "{\"success\":false,\"error\":\"{s}\"}", .{msg}) catch return buf[0..0]; return n; +} +fn statusJson(buf: []u8) []u8 { + const n = std.fmt.bufPrint(buf, "{\"success\":true,\"state\":\"ready\",\"service\":\"redis-mcp\"}", .{}) catch return buf[0..0]; return n; +} + +const Response = struct { status: u16, body: []u8 }; + +fn dispatch(tool: []const u8, body: []const u8, resp: []u8) Response { + _ = body; + if (std.mem.eql(u8, tool, "redis_connect")) return .{ .status = 200, .body = okJson(resp, "redis_connect forwarded") }; + if (std.mem.eql(u8, tool, "redis_get")) return .{ .status = 200, .body = okJson(resp, "redis_get forwarded") }; + if (std.mem.eql(u8, tool, "redis_set")) return .{ .status = 200, .body = okJson(resp, "redis_set forwarded") }; + if (std.mem.eql(u8, tool, "redis_del")) return .{ .status = 200, .body = okJson(resp, "redis_del forwarded") }; + if (std.mem.eql(u8, tool, "redis_keys")) return .{ .status = 200, .body = okJson(resp, "redis_keys forwarded") }; + if (std.mem.eql(u8, tool, "redis_hgetall")) return .{ .status = 200, .body = okJson(resp, "redis_hgetall forwarded") }; + if (std.mem.eql(u8, tool, "redis_lpush")) return .{ .status = 200, .body = okJson(resp, "redis_lpush forwarded") }; + if (std.mem.eql(u8, tool, "redis_publish")) return .{ .status = 200, .body = okJson(resp, "redis_publish forwarded") }; + if (std.mem.eql(u8, tool, "redis_disconnect")) return .{ .status = 200, .body = okJson(resp, "redis_disconnect forwarded") }; + if (std.mem.eql(u8, tool, "status") or std.mem.eql(u8, tool, "health")) return .{ .status = 200, .body = statusJson(resp) }; + return .{ .status = 404, .body = errJson(resp, "Unknown tool") }; +} + +fn dispatchRest(path: []const u8, body: []const u8, resp: []u8) Response { + if (std.mem.startsWith(u8, path, "/tools/")) return dispatch(path["/tools/".len..], body, resp); + if (std.mem.eql(u8, path, "/status") or std.mem.eql(u8, path, "/health")) return .{ .status = 200, .body = statusJson(resp) }; + return .{ .status = 404, .body = errJson(resp, "Not found") }; +} + +fn dispatchGrpc(path: []const u8, body: []const u8, resp: []u8) Response { + const prefix = "/RedisMcpservice/"; + if (!std.mem.startsWith(u8, path, prefix)) return .{ .status = 404, .body = errJson(resp, "Not a recognized gRPC path") }; + const method = path[prefix.len..]; + const tool = blk: { + if (std.mem.eql(u8, method, "redis_connect")) break :blk "redis_connect"; + if (std.mem.eql(u8, method, "redis_get")) break :blk "redis_get"; + if (std.mem.eql(u8, method, "redis_set")) break :blk "redis_set"; + if (std.mem.eql(u8, method, "redis_del")) break :blk "redis_del"; + if (std.mem.eql(u8, method, "redis_keys")) break :blk "redis_keys"; + if (std.mem.eql(u8, method, "redis_hgetall")) break :blk "redis_hgetall"; + if (std.mem.eql(u8, method, "redis_lpush")) break :blk "redis_lpush"; + if (std.mem.eql(u8, method, "redis_publish")) break :blk "redis_publish"; + if (std.mem.eql(u8, method, "redis_disconnect")) break :blk "redis_disconnect"; + return .{ .status = 404, .body = errJson(resp, "Unknown gRPC method") }; + }; + return dispatch(tool, body, resp); +} + +fn dispatchGraphql(body: []const u8, resp: []u8) Response { + if (std.mem.indexOf(u8, body, "__schema") != null) return .{ .status = 200, .body = okJson(resp, "schema not supported") }; + if (std.mem.indexOf(u8, body, "connect") != null) return dispatch("redis_connect", body, resp); + if (std.mem.indexOf(u8, body, "get") != null) return dispatch("redis_get", body, resp); + if (std.mem.indexOf(u8, body, "set") != null) return dispatch("redis_set", body, resp); + if (std.mem.indexOf(u8, body, "del") != null) return dispatch("redis_del", body, resp); + if (std.mem.indexOf(u8, body, "keys") != null) return dispatch("redis_keys", body, resp); + if (std.mem.indexOf(u8, body, "hgetall") != null) return dispatch("redis_hgetall", body, resp); + if (std.mem.indexOf(u8, body, "lpush") != null) return dispatch("redis_lpush", body, resp); + if (std.mem.indexOf(u8, body, "publish") != null) return dispatch("redis_publish", body, resp); + if (std.mem.indexOf(u8, body, "disconnect") != null) return dispatch("redis_disconnect", body, resp); + return .{ .status = 200, .body = errJson(resp, "Unrecognised GraphQL operation") }; +} + +const Protocol = enum { rest, grpc, graphql }; + +fn handleConnection(conn: std.net.Server.Connection, proto: Protocol) void { + defer conn.stream.close(); + var in_buf: [MAX_CONN_BUF]u8 = undefined; + const n = conn.stream.read(&in_buf) catch return; + const req = in_buf[0..n]; + var path: []const u8 = "/"; var body: []const u8 = ""; + if (n > 4) { + const le = std.mem.indexOf(u8, req, "\r\n") orelse req.len; + const fl = req[0..le]; const sp1 = std.mem.indexOfScalar(u8, fl, ' ') orelse 0; + const ro = fl[sp1+1..]; const sp2 = std.mem.indexOfScalar(u8, ro, ' ') orelse ro.len; + path = ro[0..sp2]; + const bs = std.mem.indexOf(u8, req, "\r\n\r\n") orelse n; body = req[@min(bs+4,n)..]; + } + var rb: [MAX_CONN_BUF]u8 = undefined; + const result = switch (proto) { .rest => dispatchRest(path, body, &rb), .grpc => dispatchGrpc(path, body, &rb), .graphql => dispatchGraphql(body, &rb) }; + const ct: []const u8 = if (proto == .grpc) "application/grpc+json" else "application/json"; + var hb: [256]u8 = undefined; + const hdr = std.fmt.bufPrint(&hb, "HTTP/1.1 {d} OK\r\nContent-Type: {s}\r\nContent-Length: {d}\r\nConnection: close\r\n\r\n", .{ result.status, ct, result.body.len }) catch return; + _ = conn.stream.writeAll(hdr) catch return; _ = conn.stream.writeAll(result.body) catch return; +} + +fn listenLoop(port: u16, proto: Protocol) void { + const addr = std.net.Address.initIp4(.{ 127, 0, 0, 1 }, port) catch return; + var srv = addr.listen(.{ .reuse_address = true }) catch return; defer srv.deinit(); + while (true) { const conn = srv.accept() catch continue; handleConnection(conn, proto); } +} + +pub fn main() !void { + ffi.redis_mcp_init(); + const t1 = try std.Thread.spawn(.{}, listenLoop, .{ REST_PORT, Protocol.rest }); + const t2 = try std.Thread.spawn(.{}, listenLoop, .{ GRPC_PORT, Protocol.grpc }); + const t3 = try std.Thread.spawn(.{}, listenLoop, .{ GQL_PORT, Protocol.graphql }); + t1.join(); t2.join(); t3.join(); +} diff --git a/cartridges/domains/database/redis-mcp/benchmarks/quick-bench.sh b/cartridges/domains/database/redis-mcp/benchmarks/quick-bench.sh new file mode 100755 index 0000000..e99ddf7 --- /dev/null +++ b/cartridges/domains/database/redis-mcp/benchmarks/quick-bench.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Benchmarks for redis-mcp cartridge. +set -euo pipefail + +echo "=== redis-mcp benchmarks ===" + +cd "$(dirname "${BASH_SOURCE[0]}")/../ffi" + +# Build with ReleaseFast for benchmarking +zig build -Doptimize=ReleaseFast 2>&1 + +echo "Session open/close cycle (1000 iterations):" +time for i in $(seq 1 1000); do + # This would call the FFI benchmark binary + true +done + +echo "" +echo "Benchmark placeholder β€” implement real benchmarks in Zig test blocks" +echo "or via the zig adapter HTTP benchmark tool." diff --git a/cartridges/domains/database/redis-mcp/cartridge.json b/cartridges/domains/database/redis-mcp/cartridge.json new file mode 100644 index 0000000..4043d1c --- /dev/null +++ b/cartridges/domains/database/redis-mcp/cartridge.json @@ -0,0 +1,244 @@ +{ + "$schema": "https://boj.dev/schemas/cartridge/v1.json", + "spdx": "MPL-2.0", + "copyright": "Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath)", + "name": "redis-mcp", + "version": "0.1.0", + "description": "Redis gateway. Key-value operations, sorted sets, pub/sub, streams, and Lua scripting via session slots.", + "domain": "Database", + "tier": "Ayo", + "protocols": [ + "MCP", + "REST" + ], + "auth": { + "method": "none", + "env_var": null, + "credential_source": null + }, + "api": { + "base_url": "local://redis-mcp", + "content_type": "application/json" + }, + "tools": [ + { + "name": "redis_connect", + "description": "Connect to a Redis server. Returns a session slot.", + "inputSchema": { + "type": "object", + "properties": { + "host": { + "type": "string", + "description": "Redis host (default: 127.0.0.1)" + }, + "port": { + "type": "integer", + "description": "Redis port (default: 6379)" + }, + "db": { + "type": "integer", + "description": "Database index (default: 0)" + }, + "password": { + "type": "string", + "description": "Auth password (optional)" + } + }, + "required": [] + } + }, + { + "name": "redis_get", + "description": "Get the value of a key.", + "inputSchema": { + "type": "object", + "properties": { + "slot": { + "type": "integer", + "description": "Session slot from redis_connect" + }, + "key": { + "type": "string", + "description": "Key name" + } + }, + "required": [ + "slot", + "key" + ] + } + }, + { + "name": "redis_set", + "description": "Set the value of a key.", + "inputSchema": { + "type": "object", + "properties": { + "slot": { + "type": "integer", + "description": "Session slot" + }, + "key": { + "type": "string", + "description": "Key name" + }, + "value": { + "type": "string", + "description": "Value to set" + }, + "ttl": { + "type": "integer", + "description": "TTL in seconds (no expiry if omitted)" + } + }, + "required": [ + "slot", + "key", + "value" + ] + } + }, + { + "name": "redis_del", + "description": "Delete one or more keys.", + "inputSchema": { + "type": "object", + "properties": { + "slot": { + "type": "integer", + "description": "Session slot" + }, + "keys": { + "type": "string", + "description": "Key or JSON array of keys to delete" + } + }, + "required": [ + "slot", + "keys" + ] + } + }, + { + "name": "redis_keys", + "description": "Find keys matching a pattern.", + "inputSchema": { + "type": "object", + "properties": { + "slot": { + "type": "integer", + "description": "Session slot" + }, + "pattern": { + "type": "string", + "description": "Pattern (e.g. 'user:*')" + } + }, + "required": [ + "slot", + "pattern" + ] + } + }, + { + "name": "redis_hgetall", + "description": "Get all fields and values of a hash.", + "inputSchema": { + "type": "object", + "properties": { + "slot": { + "type": "integer", + "description": "Session slot" + }, + "key": { + "type": "string", + "description": "Hash key" + } + }, + "required": [ + "slot", + "key" + ] + } + }, + { + "name": "redis_lpush", + "description": "Prepend values to a list.", + "inputSchema": { + "type": "object", + "properties": { + "slot": { + "type": "integer", + "description": "Session slot" + }, + "key": { + "type": "string", + "description": "List key" + }, + "values": { + "type": "string", + "description": "Value or JSON array of values" + } + }, + "required": [ + "slot", + "key", + "values" + ] + } + }, + { + "name": "redis_publish", + "description": "Publish a message to a channel.", + "inputSchema": { + "type": "object", + "properties": { + "slot": { + "type": "integer", + "description": "Session slot" + }, + "channel": { + "type": "string", + "description": "Channel name" + }, + "message": { + "type": "string", + "description": "Message content" + } + }, + "required": [ + "slot", + "channel", + "message" + ] + } + }, + { + "name": "redis_disconnect", + "description": "Disconnect and release a session slot.", + "inputSchema": { + "type": "object", + "properties": { + "slot": { + "type": "integer", + "description": "Session slot to release" + } + }, + "required": [ + "slot" + ] + } + } + ], + "ffi": { + "so_path": "ffi/zig-out/lib/libredis_mcp.so", + "abi_version": "ADR-0006", + "symbols": [ + "boj_cartridge_init", + "boj_cartridge_deinit", + "boj_cartridge_name", + "boj_cartridge_version", + "boj_cartridge_invoke" + ] + } +} diff --git a/cartridges/domains/database/redis-mcp/ffi/README.adoc b/cartridges/domains/database/redis-mcp/ffi/README.adoc new file mode 100644 index 0000000..c5a6635 --- /dev/null +++ b/cartridges/domains/database/redis-mcp/ffi/README.adoc @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += redis-mcp / ffi β€” Zig FFI layer +:orientation: shallow + +== Purpose + +C-ABI exports bridging this cartridge's Idris2 ABI to the runtime. Consumed by +the adapter for protocol dispatch and by the trunk loader for mount. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” produces `zig-out/lib/libredis.so`. +| `redis_mcp_ffi.zig` | C-ABI exports (12 exports, 6 inline tests, 371 lines). +|=== + +== Invariants + +* **Null-pointer rejection** on `[*c]const u8` arguments before any + dereference. +* **Negative return values** carry documented error meanings; zero is + success (matching the trunk error-code convention). +* **No allocator leaks** β€” every allocation path pairs with `defer free`. + +== Test/proof surface + +6 inline `test "..."` block(s) in `redis_mcp_ffi.zig`. Run with +`cd ffi && zig build test`. + +== Read-first + +. `redis_mcp_ffi.zig` β€” C-ABI signatures and guards. +. Trunk `ffi/zig/src/catalogue.zig` for how this `.so` is loaded. diff --git a/cartridges/domains/database/redis-mcp/ffi/build.zig b/cartridges/domains/database/redis-mcp/ffi/build.zig new file mode 100644 index 0000000..d50e8b2 --- /dev/null +++ b/cartridges/domains/database/redis-mcp/ffi/build.zig @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// build.zig -- Build configuration for redis-mcp FFI shared library. +// +// Links against hiredis for RESP protocol support. +// Produces libredis_mcp.so/.dylib/.dll for zig adapter consumption. + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + // Module + // Shared ADR-0006 invoke-shim module (relative path up to boj-server trunk). + + const shim_mod = b.addModule("cartridge_shim", .{ + + .root_source_file = b.path("../../../ffi/zig/src/cartridge_shim.zig"), + + .target = target, + + .optimize = optimize, + + }); + + const ffi_mod = b.addModule("redis_mcp", .{ + .root_source_file = b.path("redis_mcp_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + ffi_mod.addImport("cartridge_shim", shim_mod); + + // Shared library + const lib = b.addLibrary(.{ + .name = "redis_mcp", + .root_module = ffi_mod, + .linkage = .dynamic, + }); + lib.linkLibC(); + // NOTE: linkSystemLibrary("hiredis") removed β€” stub implementation does not + // actually call hiredis yet. Will be re-enabled when real bindings are wired. + b.installArtifact(lib); + + // Tests + const tests = b.addTest(.{ + .root_module = ffi_mod, + }); + tests.linkLibC(); + + const run_tests = b.addRunArtifact(tests); + const test_step = b.step("test", "Run FFI tests"); + test_step.dependOn(&run_tests.step); +} diff --git a/cartridges/domains/database/redis-mcp/ffi/cartridge_shim.zig b/cartridges/domains/database/redis-mcp/ffi/cartridge_shim.zig new file mode 100644 index 0000000..0e399db --- /dev/null +++ b/cartridges/domains/database/redis-mcp/ffi/cartridge_shim.zig @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// cartridge_shim.zig β€” Shared helpers for the ADR-0006 five-symbol +// cartridge ABI (`boj_cartridge_init / deinit / name / version / invoke`). +// +// The shim centralises the seven-code return convention, NUL-argument +// guards, tool-name comparison, and the buffer-too-small path so each +// cartridge's `boj_cartridge_invoke` can stay short β€” typically a tool +// table plus `shim.writeResult(...)`. +// +// Cartridges import this file by relative path (no build-graph change +// needed). Example: +// +// const shim = @import("../../../ffi/zig/src/cartridge_shim.zig"); +// +// export fn boj_cartridge_invoke( +// tool_name: [*c]const u8, +// json_args: [*c]const u8, +// out_buf: [*c]u8, +// in_out_len: [*c]usize, +// ) callconv(.c) i32 { +// _ = json_args; +// if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; +// const body = if (shim.toolIs(tool_name, "foo")) "{\"result\":{}}" +// else return shim.RC_UNKNOWN_TOOL; +// return shim.writeResult(out_buf, in_out_len, body); +// } + +const std = @import("std"); + +// ── Return codes (ADR-0006 Β§Return codes) ──────────────────────────── +// +// Frozen by ADR-0006. New failure modes compose these via the error +// JSON body β€” the integer surface does not grow without a follow-up ADR. + +pub const RC_SUCCESS: i32 = 0; +pub const RC_UNKNOWN_TOOL: i32 = -1; +pub const RC_BAD_ARGS: i32 = -2; +pub const RC_BUFFER_TOO_SMALL: i32 = -3; +pub const RC_RUNTIME_ERROR: i32 = -4; +pub const RC_PANIC: i32 = -5; +pub const RC_AUTH_DENIED: i32 = -6; + +// ── Invoke-path helpers ────────────────────────────────────────────── + +/// True if any of the three mandatory `boj_cartridge_invoke` output-path +/// pointers is null. Use at the top of every invoke to short-circuit to +/// `RC_BAD_ARGS`. +pub fn invokeArgsNull( + tool_name: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) bool { + return tool_name == null or out_buf == null or in_out_len == null; +} + +/// Compare a C-NUL-terminated tool-name pointer against a Zig string +/// literal. Caller must have already verified `tool_name` is non-null +/// (usually via `invokeArgsNull`). +pub fn toolIs(tool_name: [*c]const u8, expected: []const u8) bool { + const s = std.mem.span(@as([*:0]const u8, @ptrCast(tool_name))); + return std.mem.eql(u8, s, expected); +} + +/// Copy `body` into `out_buf[0..*in_out_len]` (as a capacity) and update +/// `*in_out_len` to the number of bytes written. Returns `RC_SUCCESS`. +/// +/// If `body.len` exceeds the current capacity stored in `*in_out_len`, +/// sets `*in_out_len` to the required size and returns +/// `RC_BUFFER_TOO_SMALL` β€” the caller is then expected to re-allocate +/// and retry, per ADR-0006 Β§Memory ownership. +/// +/// Caller must have already verified that `out_buf` and `in_out_len` +/// are non-null. +pub fn writeResult( + out_buf: [*c]u8, + in_out_len: [*c]usize, + body: []const u8, +) i32 { + const cap = in_out_len.*; + if (body.len > cap) { + in_out_len.* = body.len; + return RC_BUFFER_TOO_SMALL; + } + @memcpy(out_buf[0..body.len], body); + in_out_len.* = body.len; + return RC_SUCCESS; +} + +// ── Tests ──────────────────────────────────────────────────────────── + +test "writeResult: body fits, writes and sets length" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); + try std.testing.expectEqualStrings("hello", buf[0..len]); +} + +test "writeResult: too small returns -3 and sets required length" { + var buf: [2]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_BUFFER_TOO_SMALL, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: exact-fit succeeds" { + var buf: [5]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: empty body" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, ""); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 0), len); +} + +test "toolIs: matches and rejects" { + const name: [*:0]const u8 = "foo"; + try std.testing.expect(toolIs(@ptrCast(name), "foo")); + try std.testing.expect(!toolIs(@ptrCast(name), "bar")); + try std.testing.expect(!toolIs(@ptrCast(name), "foobar")); + try std.testing.expect(!toolIs(@ptrCast(name), "fo")); +} + +test "invokeArgsNull: detects each null slot" { + var buf: [4]u8 = undefined; + var len: usize = 4; + const name: [*:0]const u8 = "x"; + try std.testing.expect(!invokeArgsNull(@ptrCast(name), &buf, &len)); + try std.testing.expect(invokeArgsNull(null, &buf, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), null, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), &buf, null)); +} diff --git a/cartridges/domains/database/redis-mcp/ffi/redis_mcp_ffi.zig b/cartridges/domains/database/redis-mcp/ffi/redis_mcp_ffi.zig new file mode 100644 index 0000000..3d6af97 --- /dev/null +++ b/cartridges/domains/database/redis-mcp/ffi/redis_mcp_ffi.zig @@ -0,0 +1,477 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// redis_mcp_ffi.zig -- C-ABI FFI implementation for redis-mcp cartridge. +// +// Implements the state machine defined in RedisMcp.SafeDatabase (Idris2 ABI). +// Thread-safe via std.Thread.Mutex. Wraps RESP protocol stubs with pipeline +// support. Authentication via AUTH command with password from vault-mcp. +// No heap allocations for state management. + +const std = @import("std"); + +// --------------------------------------------------------------------------- +// Connection state machine (matches Idris2 ABI exactly) +// --------------------------------------------------------------------------- + +/// Redis connection lifecycle states. +/// Disconnected=0, Connected=1, Subscribing=2, Error=3 +pub const ConnState = enum(c_int) { + disconnected = 0, + connected = 1, + subscribing = 2, + err = 3, +}; + +/// Redis actions matching the Idris2 RedisAction type. +pub const RedisAction = enum(c_int) { + get = 0, + set = 1, + del = 2, + keys = 3, + exists = 4, + expire = 5, + ttl = 6, + lpush = 7, + rpush = 8, + lrange = 9, + sadd = 10, + smembers = 11, + hset = 12, + hget = 13, + hgetall = 14, + publish = 15, + subscribe = 16, + unsubscribe = 17, + info = 18, + ping = 19, +}; + +/// Validate a state transition against the proven Idris2 transition graph. +fn isValidTransition(from: ConnState, to: ConnState) bool { + return switch (from) { + .disconnected => to == .connected, + .connected => to == .disconnected or to == .subscribing or to == .err, + .subscribing => to == .connected or to == .err, + .err => to == .disconnected, + }; +} + +// --------------------------------------------------------------------------- +// Connection slots (thread-safe, fixed-size pool) +// --------------------------------------------------------------------------- + +const MAX_CONNECTIONS: usize = 16; +const HOST_BUF_SIZE: usize = 256; + +/// A single connection slot in the pool. +const ConnectionSlot = struct { + active: bool = false, + state: ConnState = .disconnected, + host_buf: [HOST_BUF_SIZE]u8 = undefined, + host_len: usize = 0, + port: u16 = 6379, + sub_channel_count: u32 = 0, + command_count: u64 = 0, + pipeline_depth: u32 = 0, +}; + +var connections: [MAX_CONNECTIONS]ConnectionSlot = [_]ConnectionSlot{.{}} ** MAX_CONNECTIONS; +var mutex: std.Thread.Mutex = .{}; + +// --------------------------------------------------------------------------- +// RESP protocol stubs (linked at build time) +// --------------------------------------------------------------------------- + +/// Opaque Redis connection context. +const RedisContext = opaque {}; +/// Opaque Redis reply. +const RedisReply = opaque {}; + +extern fn redisConnect(ip: [*:0]const u8, port: c_int) ?*RedisContext; +extern fn redisFree(ctx: *RedisContext) void; +extern fn redisCommand(ctx: *RedisContext, format: [*:0]const u8, ...) ?*RedisReply; +extern fn freeReplyObject(reply: *RedisReply) void; +extern fn redisAppendCommand(ctx: *RedisContext, format: [*:0]const u8, ...) c_int; +extern fn redisGetReply(ctx: *RedisContext, reply: *?*RedisReply) c_int; + +// --------------------------------------------------------------------------- +// C-ABI exports +// --------------------------------------------------------------------------- + +/// Check if a state transition is valid. Returns 1 (valid) or 0 (invalid). +pub export fn redis_mcp_can_transition(from: c_int, to: c_int) c_int { + const f = std.meta.intToEnum(ConnState, from) catch return 0; + const t = std.meta.intToEnum(ConnState, to) catch return 0; + return if (isValidTransition(f, t)) 1 else 0; +} + +/// Connect to Redis. Returns slot index (>= 0) or -1 if pool full, -2 if bad args. +pub export fn redis_mcp_connect(host_ptr: [*]const u8, host_len: c_int, port: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const len: usize = std.math.cast(usize, host_len) orelse return -2; + if (len == 0 or len > HOST_BUF_SIZE) return -2; + const p: u16 = std.math.cast(u16, port) orelse return -2; + + for (&connections, 0..) |*slot, idx| { + if (!slot.active) { + @memcpy(slot.host_buf[0..len], host_ptr[0..len]); + slot.host_len = len; + slot.port = p; + slot.active = true; + slot.state = .connected; + slot.sub_channel_count = 0; + slot.command_count = 0; + slot.pipeline_depth = 0; + return @intCast(idx); + } + } + return -1; +} + +/// Disconnect a connection slot. Returns 0 on success. +pub export fn redis_mcp_disconnect(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_CONNECTIONS) return -1; + var slot = &connections[idx]; + if (!slot.active) return -1; + if (!isValidTransition(slot.state, .disconnected)) return -2; + + slot.active = false; + slot.state = .disconnected; + slot.host_len = 0; + slot.sub_channel_count = 0; + slot.command_count = 0; + slot.pipeline_depth = 0; + return 0; +} + +/// Get the current state of a connection. Returns state int or -1 if invalid. +pub export fn redis_mcp_connection_state(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_CONNECTIONS) return -1; + const slot = &connections[idx]; + if (!slot.active) return -1; + return @intFromEnum(slot.state); +} + +/// Enter subscribing mode. Returns 0 on success. +pub export fn redis_mcp_subscribe(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_CONNECTIONS) return -1; + var slot = &connections[idx]; + if (!slot.active) return -1; + if (!isValidTransition(slot.state, .subscribing)) return -2; + + slot.state = .subscribing; + slot.sub_channel_count += 1; + return 0; +} + +/// Leave subscribing mode (all channels unsubscribed). Returns 0 on success. +pub export fn redis_mcp_unsubscribe(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_CONNECTIONS) return -1; + var slot = &connections[idx]; + if (!slot.active) return -1; + if (!isValidTransition(slot.state, .connected)) return -2; + + slot.state = .connected; + slot.sub_channel_count = 0; + return 0; +} + +/// Signal an error on a connection. Returns 0 on success. +pub export fn redis_mcp_signal_error(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_CONNECTIONS) return -1; + var slot = &connections[idx]; + if (!slot.active) return -1; + if (!isValidTransition(slot.state, .err)) return -2; + + slot.state = .err; + return 0; +} + +/// Increment the command counter (for tracking). Returns new count or -1. +pub export fn redis_mcp_record_command(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_CONNECTIONS) return -1; + var slot = &connections[idx]; + if (!slot.active) return -1; + + slot.command_count += 1; + return @intCast(@min(slot.command_count, std.math.maxInt(c_int))); +} + +/// Get the command count for a connection. +pub export fn redis_mcp_command_count(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_CONNECTIONS) return -1; + const slot = &connections[idx]; + if (!slot.active) return -1; + return @intCast(@min(slot.command_count, std.math.maxInt(c_int))); +} + +/// Get the subscription channel count. +pub export fn redis_mcp_sub_channel_count(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_CONNECTIONS) return -1; + const slot = &connections[idx]; + if (!slot.active) return -1; + return @intCast(slot.sub_channel_count); +} + +/// Get the number of active connections. +pub export fn redis_mcp_active_count() c_int { + mutex.lock(); + defer mutex.unlock(); + + var count: c_int = 0; + for (&connections) |*slot| { + if (slot.active) count += 1; + } + return count; +} + +/// Reset all connections (test/debug use only). +pub export fn redis_mcp_reset() void { + mutex.lock(); + defer mutex.unlock(); + connections = [_]ConnectionSlot{.{}} ** MAX_CONNECTIONS; +} + +// --------------------------------------------------------------------------- +// ═══════════════════════════════════════════════════════════════════════ +// Standard ABI (ADR-0005 four symbols + ADR-0006 invoke) +// ═══════════════════════════════════════════════════════════════════════ + +const shim = @import("cartridge_shim.zig"); + +const CARTRIDGE_NAME_PTR: [*:0]const u8 = "redis-mcp"; +const CARTRIDGE_VERSION_PTR: [*:0]const u8 = "0.1.0"; + +export fn boj_cartridge_init() callconv(.c) c_int { + return 0; +} + +export fn boj_cartridge_deinit() callconv(.c) void {} + +export fn boj_cartridge_name() callconv(.c) [*:0]const u8 { + return CARTRIDGE_NAME_PTR; +} + +export fn boj_cartridge_version() callconv(.c) [*:0]const u8 { + return CARTRIDGE_VERSION_PTR; +} + +/// Dispatch the cartridge.json MCP tools. Grade D Alpha stubs. +export fn boj_cartridge_invoke( + tool_name: [*c]const u8, + json_args: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) callconv(.c) i32 { + _ = json_args; + if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; + + const body: []const u8 = if (shim.toolIs(tool_name, "redis_connect")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "redis_get")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "redis_set")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "redis_del")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "redis_keys")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "redis_hgetall")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "redis_lpush")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "redis_publish")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "redis_disconnect")) + "{\"result\":{\"status\":\"stub\"}}" +else + return shim.RC_UNKNOWN_TOOL; + + return shim.writeResult(out_buf, in_out_len, body); +} + +// Tests +// --------------------------------------------------------------------------- + +test "connection lifecycle" { + redis_mcp_reset(); + + const slot = redis_mcp_connect("localhost", 9, 6379); + try std.testing.expect(slot >= 0); + + // Should be connected + try std.testing.expectEqual(@as(c_int, 1), redis_mcp_connection_state(slot)); + + // Subscribe + try std.testing.expectEqual(@as(c_int, 0), redis_mcp_subscribe(slot)); + try std.testing.expectEqual(@as(c_int, 2), redis_mcp_connection_state(slot)); + + // Unsubscribe + try std.testing.expectEqual(@as(c_int, 0), redis_mcp_unsubscribe(slot)); + try std.testing.expectEqual(@as(c_int, 1), redis_mcp_connection_state(slot)); + + // Disconnect + try std.testing.expectEqual(@as(c_int, 0), redis_mcp_disconnect(slot)); +} + +test "error transitions" { + redis_mcp_reset(); + + const slot = redis_mcp_connect("localhost", 9, 6379); + try std.testing.expect(slot >= 0); + + // Signal error from connected + try std.testing.expectEqual(@as(c_int, 0), redis_mcp_signal_error(slot)); + try std.testing.expectEqual(@as(c_int, 3), redis_mcp_connection_state(slot)); + + // Can only disconnect from error + try std.testing.expectEqual(@as(c_int, 0), redis_mcp_disconnect(slot)); +} + +test "subscribing error" { + redis_mcp_reset(); + + const slot = redis_mcp_connect("localhost", 9, 6379); + try std.testing.expect(slot >= 0); + + try std.testing.expectEqual(@as(c_int, 0), redis_mcp_subscribe(slot)); + try std.testing.expectEqual(@as(c_int, 0), redis_mcp_signal_error(slot)); + try std.testing.expectEqual(@as(c_int, 3), redis_mcp_connection_state(slot)); + + try std.testing.expectEqual(@as(c_int, 0), redis_mcp_disconnect(slot)); +} + +test "transition validator" { + // Valid transitions + try std.testing.expectEqual(@as(c_int, 1), redis_mcp_can_transition(0, 1)); // disconn -> connected + try std.testing.expectEqual(@as(c_int, 1), redis_mcp_can_transition(1, 0)); // connected -> disconn + try std.testing.expectEqual(@as(c_int, 1), redis_mcp_can_transition(1, 2)); // connected -> subscribing + try std.testing.expectEqual(@as(c_int, 1), redis_mcp_can_transition(2, 1)); // subscribing -> connected + try std.testing.expectEqual(@as(c_int, 1), redis_mcp_can_transition(1, 3)); // connected -> error + try std.testing.expectEqual(@as(c_int, 1), redis_mcp_can_transition(2, 3)); // subscribing -> error + try std.testing.expectEqual(@as(c_int, 1), redis_mcp_can_transition(3, 0)); // error -> disconn + + // Invalid transitions + try std.testing.expectEqual(@as(c_int, 0), redis_mcp_can_transition(0, 2)); // disconn -> subscribing + try std.testing.expectEqual(@as(c_int, 0), redis_mcp_can_transition(3, 1)); // error -> connected + + // Out of range + try std.testing.expectEqual(@as(c_int, 0), redis_mcp_can_transition(99, 0)); +} + +test "pool exhaustion" { + redis_mcp_reset(); + + var slots: [MAX_CONNECTIONS]c_int = undefined; + for (&slots) |*s| { + s.* = redis_mcp_connect("localhost", 9, 6379); + try std.testing.expect(s.* >= 0); + } + + // Pool full + try std.testing.expectEqual(@as(c_int, -1), redis_mcp_connect("localhost", 9, 6379)); + try std.testing.expectEqual(@as(c_int, @intCast(MAX_CONNECTIONS)), redis_mcp_active_count()); + + // Free one and retry + try std.testing.expectEqual(@as(c_int, 0), redis_mcp_disconnect(slots[0])); + const new_slot = redis_mcp_connect("localhost", 9, 6379); + try std.testing.expect(new_slot >= 0); +} + +test "command counting" { + redis_mcp_reset(); + + const slot = redis_mcp_connect("localhost", 9, 6379); + try std.testing.expect(slot >= 0); + + try std.testing.expectEqual(@as(c_int, 0), redis_mcp_command_count(slot)); + try std.testing.expectEqual(@as(c_int, 1), redis_mcp_record_command(slot)); + try std.testing.expectEqual(@as(c_int, 2), redis_mcp_record_command(slot)); + try std.testing.expectEqual(@as(c_int, 2), redis_mcp_command_count(slot)); + + try std.testing.expectEqual(@as(c_int, 0), redis_mcp_disconnect(slot)); +} + +// ═══════════════════════════════════════════════════════════════════════ +// ADR-0006 invoke dispatch tests +// ═══════════════════════════════════════════════════════════════════════ + +test "boj_cartridge_name returns redis-mcp" { + const n = std.mem.span(boj_cartridge_name()); + try std.testing.expectEqualStrings("redis-mcp", n); +} + +test "boj_cartridge_init returns 0" { + try std.testing.expectEqual(@as(c_int, 0), boj_cartridge_init()); +} + +test "invoke: each declared tool succeeds" { + var buf: [256]u8 = undefined; + const tools = [_][]const u8{ + "redis_connect", + "redis_get", + "redis_set", + "redis_del", + "redis_keys", + "redis_hgetall", + "redis_lpush", + "redis_publish", + "redis_disconnect", + }; + for (tools) |t| { + var len: usize = buf.len; + const rc = boj_cartridge_invoke(t.ptr, "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, 0), rc); + try std.testing.expect(std.mem.indexOf(u8, buf[0..len], "result") != null); + } +} + +test "invoke: unknown tool returns -1" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("nope", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -1), rc); +} + +test "invoke: buffer too small returns -3" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("redis_connect", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -3), rc); + try std.testing.expect(len > 4); +} diff --git a/cartridges/domains/database/redis-mcp/minter.toml b/cartridges/domains/database/redis-mcp/minter.toml new file mode 100644 index 0000000..51e7f32 --- /dev/null +++ b/cartridges/domains/database/redis-mcp/minter.toml @@ -0,0 +1,51 @@ +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +# +# minter.toml -- Cartridge metadata for redis-mcp. + +name = "redis-mcp" +description = "Redis database cartridge with RESP protocol, pipeline support, and pub/sub" +version = "0.1.0" +domain = "Database" +protocols = [ + "MCP", + "REST", +] +tier = "Ayo" +backend = "universal" +generate_panel = true + +[auth] +method = "auth_command" +vault_mcp = true +format = "AUTH <password>" +default_port = 6379 + +[actions] +count = 20 +list = [ + "Get", + "Set", + "Del", + "Keys", + "Exists", + "Expire", + "TTL", + "LPush", + "RPush", + "LRange", + "SAdd", + "SMembers", + "HSet", + "HGet", + "HGetAll", + "Publish", + "Subscribe", + "Unsubscribe", + "Info", + "Ping", +] + +[state_machine] +states = ["Disconnected", "Connected", "Subscribing", "Error"] +initial = "Disconnected" diff --git a/cartridges/domains/database/redis-mcp/mod.js b/cartridges/domains/database/redis-mcp/mod.js new file mode 100644 index 0000000..b454694 --- /dev/null +++ b/cartridges/domains/database/redis-mcp/mod.js @@ -0,0 +1,89 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// redis-mcp/mod.js -- redis gateway. + +const BASE_URL = Deno.env.get("REDIS_MCP_BACKEND_URL") ?? "http://127.0.0.1:7721"; + +async function post(path, payload) { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), 15000); + try { + const r = await fetch(`${BASE_URL}${path}`, { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), signal: ctrl.signal, + }); + const data = await r.json().catch(() => ({ success: false, error: "non-JSON response" })); + return { status: r.status, data }; + } catch (e) { + if (e.name === "AbortError") return { status: 504, data: { success: false, error: "redis-mcp backend timed out" } }; + return { status: 503, data: { success: false, error: `redis-mcp backend unavailable: ${e.message}` } }; + } finally { clearTimeout(t); } +} + +export async function handleTool(toolName, args) { + switch (toolName) { + + case "redis_connect": { + const { host, port, db, password } = args ?? {}; + const payload = { }; + if (host !== undefined) payload.host = host; + if (port !== undefined) payload.port = port; + if (db !== undefined) payload.db = db; + if (password !== undefined) payload.password = password; + return post("/api/v1/connect", payload); + } + case "redis_get": { + const { slot, key } = args ?? {}; + if (!slot || !key) return { status: 400, data: { error: "slot is required" } }; + const payload = { slot, key }; + return post("/api/v1/get", payload); + } + case "redis_set": { + const { slot, key, value, ttl } = args ?? {}; + if (!slot || !key || !value) return { status: 400, data: { error: "slot is required" } }; + const payload = { slot, key, value }; + if (ttl !== undefined) payload.ttl = ttl; + return post("/api/v1/set", payload); + } + case "redis_del": { + const { slot, keys } = args ?? {}; + if (!slot || !keys) return { status: 400, data: { error: "slot is required" } }; + const payload = { slot, keys }; + return post("/api/v1/del", payload); + } + case "redis_keys": { + const { slot, pattern } = args ?? {}; + if (!slot || !pattern) return { status: 400, data: { error: "slot is required" } }; + const payload = { slot, pattern }; + return post("/api/v1/keys", payload); + } + case "redis_hgetall": { + const { slot, key } = args ?? {}; + if (!slot || !key) return { status: 400, data: { error: "slot is required" } }; + const payload = { slot, key }; + return post("/api/v1/hgetall", payload); + } + case "redis_lpush": { + const { slot, key, values } = args ?? {}; + if (!slot || !key || !values) return { status: 400, data: { error: "slot is required" } }; + const payload = { slot, key, values }; + return post("/api/v1/lpush", payload); + } + case "redis_publish": { + const { slot, channel, message } = args ?? {}; + if (!slot || !channel || !message) return { status: 400, data: { error: "slot is required" } }; + const payload = { slot, channel, message }; + return post("/api/v1/publish", payload); + } + case "redis_disconnect": { + const { slot } = args ?? {}; + if (!slot) return { status: 400, data: { error: "slot is required" } }; + const payload = { slot }; + return post("/api/v1/disconnect", payload); + } + + default: + return { status: 404, data: { error: `Unknown tool: ${toolName}` } }; + } +} diff --git a/cartridges/domains/database/redis-mcp/panels/manifest.json b/cartridges/domains/database/redis-mcp/panels/manifest.json new file mode 100644 index 0000000..0094dc0 --- /dev/null +++ b/cartridges/domains/database/redis-mcp/panels/manifest.json @@ -0,0 +1,90 @@ +{ + "$schema": "https://panll.dev/schemas/panel-manifest/v1.json", + "spdx": "MPL-2.0", + "cartridge": "redis-mcp", + "domain": "Database", + "version": "0.1.0", + "panels": [ + { + "id": "redis-connection-status", + "title": "Connection Status", + "description": "Current Redis connection lifecycle state (Disconnected / Connected / Subscribing / Error)", + "type": "status-indicator", + "data_source": { + "endpoint": "/redis/state", + "method": "GET", + "refresh_interval_ms": 5000 + }, + "widgets": [ + { + "type": "state-badge", + "field": "state", + "states": { + "disconnected": { "color": "#95a5a6", "icon": "database-off" }, + "connected": { "color": "#2ecc71", "icon": "database" }, + "subscribing": { "color": "#9b59b6", "icon": "radio" }, + "error": { "color": "#e74c3c", "icon": "database-alert" } + } + } + ] + }, + { + "id": "redis-key-count", + "title": "Key Count", + "description": "Number of keys tracked via KEYS/EXISTS commands", + "type": "metric", + "data_source": { + "endpoint": "/redis/metrics", + "method": "GET", + "refresh_interval_ms": 15000 + }, + "widgets": [ + { + "type": "counter", + "field": "key_count", + "label": "Keys", + "icon": "key" + } + ] + }, + { + "id": "redis-memory-usage", + "title": "Memory Usage", + "description": "Redis server memory usage from INFO command", + "type": "metric", + "data_source": { + "endpoint": "/redis/info", + "method": "GET", + "refresh_interval_ms": 30000 + }, + "widgets": [ + { + "type": "gauge", + "field": "used_memory_bytes", + "label": "Memory", + "format": "bytes", + "icon": "memory-stick" + } + ] + }, + { + "id": "redis-pubsub-channels", + "title": "Pub/Sub Channels", + "description": "Active pub/sub subscription channels and message throughput", + "type": "metric", + "data_source": { + "endpoint": "/redis/pubsub", + "method": "GET", + "refresh_interval_ms": 10000 + }, + "widgets": [ + { + "type": "counter", + "field": "channel_count", + "label": "Channels", + "icon": "radio" + } + ] + } + ] +} diff --git a/cartridges/domains/database/redis-mcp/tests/integration_test.sh b/cartridges/domains/database/redis-mcp/tests/integration_test.sh new file mode 100755 index 0000000..339f95a --- /dev/null +++ b/cartridges/domains/database/redis-mcp/tests/integration_test.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Integration tests for redis-mcp cartridge. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CART_DIR="$(dirname "$SCRIPT_DIR")" +FFI_DIR="$CART_DIR/ffi" + +echo "=== redis-mcp integration tests ===" + +# Build FFI +echo "[1/3] Building FFI..." +cd "$FFI_DIR" && zig build 2>&1 + +# Run Zig unit tests +echo "[2/3] Running FFI unit tests..." +cd "$FFI_DIR" && zig build test 2>&1 + +# Validate Idris2 ABI (if idris2 available) +echo "[3/3] Checking ABI..." +if command -v idris2 &>/dev/null; then + cd "$CART_DIR/abi" && idris2 --check RedisMcp.SafeDatabase 2>&1 + echo " ABI: OK" +else + echo " ABI: SKIPPED (idris2 not in PATH)" +fi + +echo "" +echo "All tests passed for redis-mcp!" diff --git a/cartridges/domains/database/supabase-mcp/README.adoc b/cartridges/domains/database/supabase-mcp/README.adoc new file mode 100644 index 0000000..ac20b33 --- /dev/null +++ b/cartridges/domains/database/supabase-mcp/README.adoc @@ -0,0 +1,67 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> + += supabase-mcp +Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk> +:spdx: MPL-2.0 +:tier: Ayo +:domain: Database +:protocols: MCP, REST + +== Overview + +Supabase MCP cartridge. Provides type-safe access to Supabase project +endpoints covering PostgREST (queries, tables), Auth (users, sign-in), +Storage (buckets, files), and Functions (invoke, list). Auth via Bearer token +(service_role key or anon key). Configurable project URL: +`https://{project}.supabase.co/`. + +=== Actions + +ListProjects, GetProject, Query, ListTables, GetTable, ListFunctions, +InvokeFunction, ListBuckets, UploadFile, ListFiles, GetUser, ListUsers, +CreateUser, SignIn, ListSecrets, SetSecret. + +== Architecture + +[cols="1,1,2"] +|=== +| Layer | Language | Purpose + +| ABI +| Idris2 +| Formally verified state machine (Disconnected / Connected / QueryRunning / Error) + +| FFI +| Zig +| C-compatible implementation with thread-safe session pool + +| Adapter +| zig +| REST bridge to BoJ unified adapter protocol +|=== + +== Building + +[source,bash] +---- +# Build FFI shared library +cd ffi && zig build + +# Run FFI tests +cd ffi && zig build test + +# Type-check ABI +cd abi && idris2 --check SupabaseMcp.SafeDatabase +---- + +== Panels + +* Project status (connection state) +* Table count +* Function count +* Storage usage + +== Status + +Development -- not yet ready for mounting. diff --git a/cartridges/domains/database/supabase-mcp/abi/README.adoc b/cartridges/domains/database/supabase-mcp/abi/README.adoc new file mode 100644 index 0000000..9e7f5b5 --- /dev/null +++ b/cartridges/domains/database/supabase-mcp/abi/README.adoc @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += supabase-mcp / abi β€” Idris2 ABI layer +:orientation: shallow + +== Purpose + +Cartridge-local protocol types for `supabase-mcp`. Defines the data, tool, and +transition vocabulary this cartridge speaks. Inherits safety lemmas and the +matrix-cell identifier scheme from the trunk ABI at `src/abi/`. + +== Files + +1 Idris2 module(s), ~172 lines total. Lead module: +`SafeDatabase.idr`. + +No `.ipkg` β€” consumed directly by wider BoJ callers; type-checks inside the +trunk `boj.ipkg`. + +== Invariants + +* `%default total` at every module top. +* Zero `believe_me`, `assert_total`, `postulate`, `Admitted`, or + `sorry` (the trunk holds the four declared `believe_me` sites; leaf + cartridges carry none). + +== Test/proof surface + +Thin module(s), no inline tests. Proof surface = type-check success under the +trunk `idris2 --check`. + +== Read-first + +. `SafeDatabase.idr` β€” cartridge-local data records and enums. +. Trunk `src/abi/Boj/Catalogue.idr` for how this cell participates in the + 2D matrix. diff --git a/cartridges/domains/database/supabase-mcp/abi/SupabaseMcp/SafeDatabase.idr b/cartridges/domains/database/supabase-mcp/abi/SupabaseMcp/SafeDatabase.idr new file mode 100644 index 0000000..f05b335 --- /dev/null +++ b/cartridges/domains/database/supabase-mcp/abi/SupabaseMcp/SafeDatabase.idr @@ -0,0 +1,172 @@ +-- SPDX-License-Identifier: MPL-2.0 +-- Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +-- +-- SupabaseMcp.SafeDatabase β€” Type-safe ABI for the supabase-mcp cartridge. +-- +-- Provides a formally verified state machine for Supabase project connections. +-- Dependent-type proofs ensure only valid transitions can occur at the FFI +-- boundary. Supabase actions cover PostgREST, Auth, Storage, and Functions +-- endpoints. Auth via Bearer token (service_role key or anon key). +-- Configurable project URL: https://{project}.supabase.co/ + +module SupabaseMcp.SafeDatabase + +%default total + +-- --------------------------------------------------------------------------- +-- Connection state machine +-- --------------------------------------------------------------------------- + +||| Connection state for Supabase project operations. +||| Covers PostgREST, Auth, Storage, and Functions endpoints. +public export +data ConnState = Disconnected | Connected | QueryRunning | Error + +||| Proof that a state transition is valid. +public export +data ValidTransition : ConnState -> ConnState -> Type where + Connect : ValidTransition Disconnected Connected + StartQuery : ValidTransition Connected QueryRunning + FinishQuery : ValidTransition QueryRunning Connected + Disconnect : ValidTransition Connected Disconnected + QueryFail : ValidTransition QueryRunning Error + ErrorRecover : ValidTransition Error Disconnected + +-- --------------------------------------------------------------------------- +-- C-ABI integer encoding +-- --------------------------------------------------------------------------- + +||| Encode connection state as C-compatible integer. +export +connStateToInt : ConnState -> Int +connStateToInt Disconnected = 0 +connStateToInt Connected = 1 +connStateToInt QueryRunning = 2 +connStateToInt Error = 3 + +||| Decode integer back to connection state. +export +intToConnState : Int -> Maybe ConnState +intToConnState 0 = Just Disconnected +intToConnState 1 = Just Connected +intToConnState 2 = Just QueryRunning +intToConnState 3 = Just Error +intToConnState _ = Nothing + +||| Check if a state transition is valid (C-ABI export). +||| Returns 1 for valid, 0 for invalid. +export +supabase_mcp_can_transition : Int -> Int -> Int +supabase_mcp_can_transition from to = + case (intToConnState from, intToConnState to) of + (Just Disconnected, Just Connected) => 1 + (Just Connected, Just QueryRunning) => 1 + (Just QueryRunning, Just Connected) => 1 + (Just Connected, Just Disconnected) => 1 + (Just QueryRunning, Just Error) => 1 + (Just Error, Just Disconnected) => 1 + _ => 0 + +-- --------------------------------------------------------------------------- +-- Supabase actions (PostgREST + Auth + Storage + Functions) +-- --------------------------------------------------------------------------- + +||| Actions supported by the Supabase MCP cartridge. +||| Covers projects, PostgREST queries, tables, edge functions, storage +||| buckets, auth users, and secrets. +public export +data SupabaseAction + = ListProjects + | GetProject + | Query + | ListTables + | GetTable + | ListFunctions + | InvokeFunction + | ListBuckets + | UploadFile + | ListFiles + | GetUser + | ListUsers + | CreateUser + | SignIn + | ListSecrets + | SetSecret + +||| Encode action as C-compatible integer. +export +supabaseActionToInt : SupabaseAction -> Int +supabaseActionToInt ListProjects = 0 +supabaseActionToInt GetProject = 1 +supabaseActionToInt Query = 2 +supabaseActionToInt ListTables = 3 +supabaseActionToInt GetTable = 4 +supabaseActionToInt ListFunctions = 5 +supabaseActionToInt InvokeFunction = 6 +supabaseActionToInt ListBuckets = 7 +supabaseActionToInt UploadFile = 8 +supabaseActionToInt ListFiles = 9 +supabaseActionToInt GetUser = 10 +supabaseActionToInt ListUsers = 11 +supabaseActionToInt CreateUser = 12 +supabaseActionToInt SignIn = 13 +supabaseActionToInt ListSecrets = 14 +supabaseActionToInt SetSecret = 15 + +||| Decode integer back to action. +export +intToSupabaseAction : Int -> Maybe SupabaseAction +intToSupabaseAction 0 = Just ListProjects +intToSupabaseAction 1 = Just GetProject +intToSupabaseAction 2 = Just Query +intToSupabaseAction 3 = Just ListTables +intToSupabaseAction 4 = Just GetTable +intToSupabaseAction 5 = Just ListFunctions +intToSupabaseAction 6 = Just InvokeFunction +intToSupabaseAction 7 = Just ListBuckets +intToSupabaseAction 8 = Just UploadFile +intToSupabaseAction 9 = Just ListFiles +intToSupabaseAction 10 = Just GetUser +intToSupabaseAction 11 = Just ListUsers +intToSupabaseAction 12 = Just CreateUser +intToSupabaseAction 13 = Just SignIn +intToSupabaseAction 14 = Just ListSecrets +intToSupabaseAction 15 = Just SetSecret +intToSupabaseAction _ = Nothing + +||| Check whether an action requires an active connection. +export +actionRequiresConnection : SupabaseAction -> Bool +actionRequiresConnection Query = True +actionRequiresConnection ListTables = True +actionRequiresConnection GetTable = True +actionRequiresConnection InvokeFunction = True +actionRequiresConnection UploadFile = True +actionRequiresConnection ListFiles = True +actionRequiresConnection GetUser = True +actionRequiresConnection ListUsers = True +actionRequiresConnection CreateUser = True +actionRequiresConnection SignIn = True +actionRequiresConnection ListSecrets = True +actionRequiresConnection SetSecret = True +actionRequiresConnection _ = False + +||| Total number of actions exposed by this cartridge. +export +actionCount : Nat +actionCount = 16 + +-- --------------------------------------------------------------------------- +-- Auth configuration +-- --------------------------------------------------------------------------- + +||| Authentication method for Supabase REST API. +||| Bearer token using service_role key or anon key. +public export +data SupabaseAuth = ServiceRoleKey | AnonKey + +||| Base URL template for Supabase REST API. +||| The project ref replaces the placeholder at runtime. +export +supabaseApiBaseTemplate : String +supabaseApiBaseTemplate = "https://{project}.supabase.co/" diff --git a/cartridges/domains/database/supabase-mcp/abi/supabase_mcp.ipkg b/cartridges/domains/database/supabase-mcp/abi/supabase_mcp.ipkg new file mode 100644 index 0000000..0b2fa80 --- /dev/null +++ b/cartridges/domains/database/supabase-mcp/abi/supabase_mcp.ipkg @@ -0,0 +1,11 @@ +-- SPDX-License-Identifier: MPL-2.0 +-- Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +package supabase_mcp + +version = "0.1.0" +authors = "Jonathan D.A. Jewell" +brief = "Supabase MCP cartridge β€” type-safe ABI layer" + +depends = base + +modules = SupabaseMcp.SafeDatabase diff --git a/cartridges/domains/database/supabase-mcp/adapter/README.adoc b/cartridges/domains/database/supabase-mcp/adapter/README.adoc new file mode 100644 index 0000000..f274637 --- /dev/null +++ b/cartridges/domains/database/supabase-mcp/adapter/README.adoc @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += supabase-mcp / adapter β€” protocol bridge +:orientation: shallow + +== Purpose + +Exposes this cartridge's FFI over the wire protocols declared in +`cartridge.json`. Dispatches tool invocations to the C-ABI exports at +`../ffi/` and formats responses per protocol. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” depends on the sibling `ffi` target. +| `supabase_mcp_adapter.zig` | Protocol dispatch (112 lines). Tool catalogue matches `cartridge.json`. +| `SIDELINED-*.v.adoc` | zig predecessor(s). Not built β€” zig banned 2026-04-10. +|=== + +== Invariants + +* **Stateless** β€” all state lives behind the FFI, never in the adapter. +* **Response passthrough** β€” whatever the FFI returns goes back to the wire + unmodified (no embellishment, no silent recovery). +* **Cartridge.json is source of truth** for the tool catalogue; drift between + adapter and manifest is a CI failure. + +== Test/proof surface + +No inline tests at the adapter layer β€” protocol routing is exercised by the +trunk seam tests at `ffi/zig/src/seams.zig`. + +== Read-first + +. `supabase_mcp_adapter.zig` β€” dispatch function and per-protocol routers. +. `../cartridge.json` β€” the tool manifest the dispatcher mirrors. diff --git a/cartridges/domains/database/supabase-mcp/adapter/build.zig b/cartridges/domains/database/supabase-mcp/adapter/build.zig new file mode 100644 index 0000000..030f678 --- /dev/null +++ b/cartridges/domains/database/supabase-mcp/adapter/build.zig @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// supabase-mcp/adapter/build.zig + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); const optimize = b.standardOptimizeOption(.{}); + const ffi_mod = b.createModule(.{ .root_source_file = b.path("../ffi/supabase_mcp_ffi.zig"), .target = target, .optimize = optimize }); + const adapter = b.addExecutable(.{ .name = "supabase_mcp_adapter", .root_source_file = b.path("supabase_mcp_adapter.zig"), .target = target, .optimize = optimize }); + adapter.root_module.addImport("supabase_mcp_ffi", ffi_mod); b.installArtifact(adapter); + const rs = b.step("run", "Run supabase-mcp adapter"); rs.dependOn(&b.addRunArtifact(adapter).step); + const tests = b.addTest(.{ .root_source_file = b.path("supabase_mcp_adapter.zig"), .target = target, .optimize = optimize }); + tests.root_module.addImport("supabase_mcp_ffi", ffi_mod); + const ts = b.step("test", "Test supabase-mcp adapter"); ts.dependOn(&b.addRunArtifact(tests).step); +} diff --git a/cartridges/domains/database/supabase-mcp/adapter/supabase_mcp_adapter.zig b/cartridges/domains/database/supabase-mcp/adapter/supabase_mcp_adapter.zig new file mode 100644 index 0000000..5f3691d --- /dev/null +++ b/cartridges/domains/database/supabase-mcp/adapter/supabase_mcp_adapter.zig @@ -0,0 +1,112 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// supabase-mcp/adapter/supabase_mcp_adapter.zig -- Unified three-protocol adapter. +// Replaces banned supabase_mcp_adapter.v (zig, removed 2026-04-12). +// REST:9175 gRPC:9176 GraphQL:9177 +// Tools: supabase_connect, supabase_query, supabase_insert, supabase_update... + +const std = @import("std"); +const ffi = @import("supabase_mcp_ffi"); + +const REST_PORT: u16 = 9175; +const GRPC_PORT: u16 = 9176; +const GQL_PORT: u16 = 9177; +const MAX_CONN_BUF: usize = 16 * 1024; + +fn okJson(buf: []u8, msg: []const u8) []u8 { + const n = std.fmt.bufPrint(buf, "{\"success\":true,\"message\":\"{s}\"}", .{msg}) catch return buf[0..0]; return n; +} +fn errJson(buf: []u8, msg: []const u8) []u8 { + const n = std.fmt.bufPrint(buf, "{\"success\":false,\"error\":\"{s}\"}", .{msg}) catch return buf[0..0]; return n; +} +fn statusJson(buf: []u8) []u8 { + const n = std.fmt.bufPrint(buf, "{\"success\":true,\"state\":\"ready\",\"service\":\"supabase-mcp\"}", .{}) catch return buf[0..0]; return n; +} + +const Response = struct { status: u16, body: []u8 }; + +fn dispatch(tool: []const u8, body: []const u8, resp: []u8) Response { + _ = body; + if (std.mem.eql(u8, tool, "supabase_connect")) return .{ .status = 200, .body = okJson(resp, "supabase_connect forwarded") }; + if (std.mem.eql(u8, tool, "supabase_query")) return .{ .status = 200, .body = okJson(resp, "supabase_query forwarded") }; + if (std.mem.eql(u8, tool, "supabase_insert")) return .{ .status = 200, .body = okJson(resp, "supabase_insert forwarded") }; + if (std.mem.eql(u8, tool, "supabase_update")) return .{ .status = 200, .body = okJson(resp, "supabase_update forwarded") }; + if (std.mem.eql(u8, tool, "supabase_delete")) return .{ .status = 200, .body = okJson(resp, "supabase_delete forwarded") }; + if (std.mem.eql(u8, tool, "supabase_storage_list")) return .{ .status = 200, .body = okJson(resp, "supabase_storage_list forwarded") }; + if (std.mem.eql(u8, tool, "supabase_disconnect")) return .{ .status = 200, .body = okJson(resp, "supabase_disconnect forwarded") }; + if (std.mem.eql(u8, tool, "status") or std.mem.eql(u8, tool, "health")) return .{ .status = 200, .body = statusJson(resp) }; + return .{ .status = 404, .body = errJson(resp, "Unknown tool") }; +} + +fn dispatchRest(path: []const u8, body: []const u8, resp: []u8) Response { + if (std.mem.startsWith(u8, path, "/tools/")) return dispatch(path["/tools/".len..], body, resp); + if (std.mem.eql(u8, path, "/status") or std.mem.eql(u8, path, "/health")) return .{ .status = 200, .body = statusJson(resp) }; + return .{ .status = 404, .body = errJson(resp, "Not found") }; +} + +fn dispatchGrpc(path: []const u8, body: []const u8, resp: []u8) Response { + const prefix = "/SupabaseMcpservice/"; + if (!std.mem.startsWith(u8, path, prefix)) return .{ .status = 404, .body = errJson(resp, "Not a recognized gRPC path") }; + const method = path[prefix.len..]; + const tool = blk: { + if (std.mem.eql(u8, method, "supabase_connect")) break :blk "supabase_connect"; + if (std.mem.eql(u8, method, "supabase_query")) break :blk "supabase_query"; + if (std.mem.eql(u8, method, "supabase_insert")) break :blk "supabase_insert"; + if (std.mem.eql(u8, method, "supabase_update")) break :blk "supabase_update"; + if (std.mem.eql(u8, method, "supabase_delete")) break :blk "supabase_delete"; + if (std.mem.eql(u8, method, "supabase_storage_list")) break :blk "supabase_storage_list"; + if (std.mem.eql(u8, method, "supabase_disconnect")) break :blk "supabase_disconnect"; + return .{ .status = 404, .body = errJson(resp, "Unknown gRPC method") }; + }; + return dispatch(tool, body, resp); +} + +fn dispatchGraphql(body: []const u8, resp: []u8) Response { + if (std.mem.indexOf(u8, body, "__schema") != null) return .{ .status = 200, .body = okJson(resp, "schema not supported") }; + if (std.mem.indexOf(u8, body, "connect") != null) return dispatch("supabase_connect", body, resp); + if (std.mem.indexOf(u8, body, "query") != null) return dispatch("supabase_query", body, resp); + if (std.mem.indexOf(u8, body, "insert") != null) return dispatch("supabase_insert", body, resp); + if (std.mem.indexOf(u8, body, "update") != null) return dispatch("supabase_update", body, resp); + if (std.mem.indexOf(u8, body, "delete") != null) return dispatch("supabase_delete", body, resp); + if (std.mem.indexOf(u8, body, "storage_list") != null) return dispatch("supabase_storage_list", body, resp); + if (std.mem.indexOf(u8, body, "disconnect") != null) return dispatch("supabase_disconnect", body, resp); + return .{ .status = 200, .body = errJson(resp, "Unrecognised GraphQL operation") }; +} + +const Protocol = enum { rest, grpc, graphql }; + +fn handleConnection(conn: std.net.Server.Connection, proto: Protocol) void { + defer conn.stream.close(); + var in_buf: [MAX_CONN_BUF]u8 = undefined; + const n = conn.stream.read(&in_buf) catch return; + const req = in_buf[0..n]; + var path: []const u8 = "/"; var body: []const u8 = ""; + if (n > 4) { + const le = std.mem.indexOf(u8, req, "\r\n") orelse req.len; + const fl = req[0..le]; const sp1 = std.mem.indexOfScalar(u8, fl, ' ') orelse 0; + const ro = fl[sp1+1..]; const sp2 = std.mem.indexOfScalar(u8, ro, ' ') orelse ro.len; + path = ro[0..sp2]; + const bs = std.mem.indexOf(u8, req, "\r\n\r\n") orelse n; body = req[@min(bs+4,n)..]; + } + var rb: [MAX_CONN_BUF]u8 = undefined; + const result = switch (proto) { .rest => dispatchRest(path, body, &rb), .grpc => dispatchGrpc(path, body, &rb), .graphql => dispatchGraphql(body, &rb) }; + const ct: []const u8 = if (proto == .grpc) "application/grpc+json" else "application/json"; + var hb: [256]u8 = undefined; + const hdr = std.fmt.bufPrint(&hb, "HTTP/1.1 {d} OK\r\nContent-Type: {s}\r\nContent-Length: {d}\r\nConnection: close\r\n\r\n", .{ result.status, ct, result.body.len }) catch return; + _ = conn.stream.writeAll(hdr) catch return; _ = conn.stream.writeAll(result.body) catch return; +} + +fn listenLoop(port: u16, proto: Protocol) void { + const addr = std.net.Address.initIp4(.{ 127, 0, 0, 1 }, port) catch return; + var srv = addr.listen(.{ .reuse_address = true }) catch return; defer srv.deinit(); + while (true) { const conn = srv.accept() catch continue; handleConnection(conn, proto); } +} + +pub fn main() !void { + ffi.supabase_mcp_init(); + const t1 = try std.Thread.spawn(.{}, listenLoop, .{ REST_PORT, Protocol.rest }); + const t2 = try std.Thread.spawn(.{}, listenLoop, .{ GRPC_PORT, Protocol.grpc }); + const t3 = try std.Thread.spawn(.{}, listenLoop, .{ GQL_PORT, Protocol.graphql }); + t1.join(); t2.join(); t3.join(); +} diff --git a/cartridges/domains/database/supabase-mcp/benchmarks/quick-bench.sh b/cartridges/domains/database/supabase-mcp/benchmarks/quick-bench.sh new file mode 100755 index 0000000..3610d3c --- /dev/null +++ b/cartridges/domains/database/supabase-mcp/benchmarks/quick-bench.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Benchmarks for supabase-mcp cartridge. +set -euo pipefail + +echo "=== supabase-mcp benchmarks ===" + +cd "$(dirname "${BASH_SOURCE[0]}")/../ffi" + +# Build with ReleaseFast for benchmarking +zig build -Doptimize=ReleaseFast 2>&1 + +echo "Session open/close cycle (1000 iterations):" +time for i in $(seq 1 1000); do + # This would call the FFI benchmark binary + true +done + +echo "" +echo "Benchmark placeholder β€” implement real benchmarks in Zig test blocks" +echo "or via the zig adapter HTTP benchmark tool." diff --git a/cartridges/domains/database/supabase-mcp/cartridge.json b/cartridges/domains/database/supabase-mcp/cartridge.json new file mode 100644 index 0000000..367f68d --- /dev/null +++ b/cartridges/domains/database/supabase-mcp/cartridge.json @@ -0,0 +1,210 @@ +{ + "$schema": "https://boj.dev/schemas/cartridge/v1.json", + "spdx": "MPL-2.0", + "copyright": "Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath)", + "name": "supabase-mcp", + "version": "0.1.0", + "description": "Supabase gateway. PostgreSQL queries, Auth user management, Storage file operations, and Edge Functions.", + "domain": "Database", + "tier": "Ayo", + "protocols": [ + "MCP", + "REST" + ], + "auth": { + "method": "none", + "env_var": null, + "credential_source": null + }, + "api": { + "base_url": "local://supabase-mcp", + "content_type": "application/json" + }, + "tools": [ + { + "name": "supabase_connect", + "description": "Connect to a Supabase project. Returns a session slot.", + "inputSchema": { + "type": "object", + "properties": { + "project_url": { + "type": "string", + "description": "Supabase project URL (https://xxx.supabase.co)" + }, + "anon_key": { + "type": "string", + "description": "Supabase anon key" + } + }, + "required": [ + "project_url", + "anon_key" + ] + } + }, + { + "name": "supabase_query", + "description": "Execute a SQL query via Supabase's REST endpoint.", + "inputSchema": { + "type": "object", + "properties": { + "slot": { + "type": "integer", + "description": "Session slot from supabase_connect" + }, + "table": { + "type": "string", + "description": "Table name" + }, + "filter": { + "type": "string", + "description": "PostgREST filter string (optional)" + }, + "select": { + "type": "string", + "description": "Columns to return (default: *)" + } + }, + "required": [ + "slot", + "table" + ] + } + }, + { + "name": "supabase_insert", + "description": "Insert rows into a Supabase table.", + "inputSchema": { + "type": "object", + "properties": { + "slot": { + "type": "integer", + "description": "Session slot" + }, + "table": { + "type": "string", + "description": "Table name" + }, + "data": { + "type": "string", + "description": "Row data as JSON object or array" + } + }, + "required": [ + "slot", + "table", + "data" + ] + } + }, + { + "name": "supabase_update", + "description": "Update rows in a Supabase table.", + "inputSchema": { + "type": "object", + "properties": { + "slot": { + "type": "integer", + "description": "Session slot" + }, + "table": { + "type": "string", + "description": "Table name" + }, + "filter": { + "type": "string", + "description": "PostgREST filter string" + }, + "data": { + "type": "string", + "description": "Updated fields as JSON object" + } + }, + "required": [ + "slot", + "table", + "filter", + "data" + ] + } + }, + { + "name": "supabase_delete", + "description": "Delete rows from a Supabase table.", + "inputSchema": { + "type": "object", + "properties": { + "slot": { + "type": "integer", + "description": "Session slot" + }, + "table": { + "type": "string", + "description": "Table name" + }, + "filter": { + "type": "string", + "description": "PostgREST filter string" + } + }, + "required": [ + "slot", + "table", + "filter" + ] + } + }, + { + "name": "supabase_storage_list", + "description": "List files in a Supabase Storage bucket.", + "inputSchema": { + "type": "object", + "properties": { + "slot": { + "type": "integer", + "description": "Session slot" + }, + "bucket": { + "type": "string", + "description": "Storage bucket name" + }, + "path": { + "type": "string", + "description": "Folder path prefix (optional)" + } + }, + "required": [ + "slot", + "bucket" + ] + } + }, + { + "name": "supabase_disconnect", + "description": "Disconnect and release a session slot.", + "inputSchema": { + "type": "object", + "properties": { + "slot": { + "type": "integer", + "description": "Session slot to release" + } + }, + "required": [ + "slot" + ] + } + } + ], + "ffi": { + "so_path": "ffi/zig-out/lib/libsupabase_mcp.so", + "abi_version": "ADR-0006", + "symbols": [ + "boj_cartridge_init", + "boj_cartridge_deinit", + "boj_cartridge_name", + "boj_cartridge_version", + "boj_cartridge_invoke" + ] + } +} diff --git a/cartridges/domains/database/supabase-mcp/ffi/README.adoc b/cartridges/domains/database/supabase-mcp/ffi/README.adoc new file mode 100644 index 0000000..859fcb8 --- /dev/null +++ b/cartridges/domains/database/supabase-mcp/ffi/README.adoc @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += supabase-mcp / ffi β€” Zig FFI layer +:orientation: shallow + +== Purpose + +C-ABI exports bridging this cartridge's Idris2 ABI to the runtime. Consumed by +the adapter for protocol dispatch and by the trunk loader for mount. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” produces `zig-out/lib/libsupabase.so`. +| `supabase_mcp_ffi.zig` | C-ABI exports (10 exports, 5 inline tests, 294 lines). +|=== + +== Invariants + +* **Null-pointer rejection** on `[*c]const u8` arguments before any + dereference. +* **Negative return values** carry documented error meanings; zero is + success (matching the trunk error-code convention). +* **No allocator leaks** β€” every allocation path pairs with `defer free`. + +== Test/proof surface + +5 inline `test "..."` block(s) in `supabase_mcp_ffi.zig`. Run with +`cd ffi && zig build test`. + +== Read-first + +. `supabase_mcp_ffi.zig` β€” C-ABI signatures and guards. +. Trunk `ffi/zig/src/catalogue.zig` for how this `.so` is loaded. diff --git a/cartridges/domains/database/supabase-mcp/ffi/build.zig b/cartridges/domains/database/supabase-mcp/ffi/build.zig new file mode 100644 index 0000000..090795b --- /dev/null +++ b/cartridges/domains/database/supabase-mcp/ffi/build.zig @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + // Module + // Shared ADR-0006 invoke-shim module (relative path up to boj-server trunk). + + const shim_mod = b.addModule("cartridge_shim", .{ + + .root_source_file = b.path("../../../ffi/zig/src/cartridge_shim.zig"), + + .target = target, + + .optimize = optimize, + + }); + + const ffi_mod = b.addModule("supabase_mcp", .{ + .root_source_file = b.path("supabase_mcp_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + ffi_mod.addImport("cartridge_shim", shim_mod); + + // Shared library + const lib = b.addLibrary(.{ + .name = "supabase_mcp", + .root_module = ffi_mod, + .linkage = .dynamic, + }); + lib.linkLibC(); + b.installArtifact(lib); + + // Tests + const tests = b.addTest(.{ + .root_module = ffi_mod, + }); + tests.linkLibC(); + + const run_tests = b.addRunArtifact(tests); + const test_step = b.step("test", "Run FFI tests"); + test_step.dependOn(&run_tests.step); +} diff --git a/cartridges/domains/database/supabase-mcp/ffi/cartridge_shim.zig b/cartridges/domains/database/supabase-mcp/ffi/cartridge_shim.zig new file mode 100644 index 0000000..0e399db --- /dev/null +++ b/cartridges/domains/database/supabase-mcp/ffi/cartridge_shim.zig @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// cartridge_shim.zig β€” Shared helpers for the ADR-0006 five-symbol +// cartridge ABI (`boj_cartridge_init / deinit / name / version / invoke`). +// +// The shim centralises the seven-code return convention, NUL-argument +// guards, tool-name comparison, and the buffer-too-small path so each +// cartridge's `boj_cartridge_invoke` can stay short β€” typically a tool +// table plus `shim.writeResult(...)`. +// +// Cartridges import this file by relative path (no build-graph change +// needed). Example: +// +// const shim = @import("../../../ffi/zig/src/cartridge_shim.zig"); +// +// export fn boj_cartridge_invoke( +// tool_name: [*c]const u8, +// json_args: [*c]const u8, +// out_buf: [*c]u8, +// in_out_len: [*c]usize, +// ) callconv(.c) i32 { +// _ = json_args; +// if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; +// const body = if (shim.toolIs(tool_name, "foo")) "{\"result\":{}}" +// else return shim.RC_UNKNOWN_TOOL; +// return shim.writeResult(out_buf, in_out_len, body); +// } + +const std = @import("std"); + +// ── Return codes (ADR-0006 Β§Return codes) ──────────────────────────── +// +// Frozen by ADR-0006. New failure modes compose these via the error +// JSON body β€” the integer surface does not grow without a follow-up ADR. + +pub const RC_SUCCESS: i32 = 0; +pub const RC_UNKNOWN_TOOL: i32 = -1; +pub const RC_BAD_ARGS: i32 = -2; +pub const RC_BUFFER_TOO_SMALL: i32 = -3; +pub const RC_RUNTIME_ERROR: i32 = -4; +pub const RC_PANIC: i32 = -5; +pub const RC_AUTH_DENIED: i32 = -6; + +// ── Invoke-path helpers ────────────────────────────────────────────── + +/// True if any of the three mandatory `boj_cartridge_invoke` output-path +/// pointers is null. Use at the top of every invoke to short-circuit to +/// `RC_BAD_ARGS`. +pub fn invokeArgsNull( + tool_name: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) bool { + return tool_name == null or out_buf == null or in_out_len == null; +} + +/// Compare a C-NUL-terminated tool-name pointer against a Zig string +/// literal. Caller must have already verified `tool_name` is non-null +/// (usually via `invokeArgsNull`). +pub fn toolIs(tool_name: [*c]const u8, expected: []const u8) bool { + const s = std.mem.span(@as([*:0]const u8, @ptrCast(tool_name))); + return std.mem.eql(u8, s, expected); +} + +/// Copy `body` into `out_buf[0..*in_out_len]` (as a capacity) and update +/// `*in_out_len` to the number of bytes written. Returns `RC_SUCCESS`. +/// +/// If `body.len` exceeds the current capacity stored in `*in_out_len`, +/// sets `*in_out_len` to the required size and returns +/// `RC_BUFFER_TOO_SMALL` β€” the caller is then expected to re-allocate +/// and retry, per ADR-0006 Β§Memory ownership. +/// +/// Caller must have already verified that `out_buf` and `in_out_len` +/// are non-null. +pub fn writeResult( + out_buf: [*c]u8, + in_out_len: [*c]usize, + body: []const u8, +) i32 { + const cap = in_out_len.*; + if (body.len > cap) { + in_out_len.* = body.len; + return RC_BUFFER_TOO_SMALL; + } + @memcpy(out_buf[0..body.len], body); + in_out_len.* = body.len; + return RC_SUCCESS; +} + +// ── Tests ──────────────────────────────────────────────────────────── + +test "writeResult: body fits, writes and sets length" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); + try std.testing.expectEqualStrings("hello", buf[0..len]); +} + +test "writeResult: too small returns -3 and sets required length" { + var buf: [2]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_BUFFER_TOO_SMALL, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: exact-fit succeeds" { + var buf: [5]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: empty body" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, ""); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 0), len); +} + +test "toolIs: matches and rejects" { + const name: [*:0]const u8 = "foo"; + try std.testing.expect(toolIs(@ptrCast(name), "foo")); + try std.testing.expect(!toolIs(@ptrCast(name), "bar")); + try std.testing.expect(!toolIs(@ptrCast(name), "foobar")); + try std.testing.expect(!toolIs(@ptrCast(name), "fo")); +} + +test "invokeArgsNull: detects each null slot" { + var buf: [4]u8 = undefined; + var len: usize = 4; + const name: [*:0]const u8 = "x"; + try std.testing.expect(!invokeArgsNull(@ptrCast(name), &buf, &len)); + try std.testing.expect(invokeArgsNull(null, &buf, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), null, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), &buf, null)); +} diff --git a/cartridges/domains/database/supabase-mcp/ffi/supabase_mcp_ffi.zig b/cartridges/domains/database/supabase-mcp/ffi/supabase_mcp_ffi.zig new file mode 100644 index 0000000..0e41bbb --- /dev/null +++ b/cartridges/domains/database/supabase-mcp/ffi/supabase_mcp_ffi.zig @@ -0,0 +1,394 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// supabase_mcp_ffi.zig β€” C-ABI FFI implementation for the supabase-mcp cartridge. +// +// Implements the connection state machine defined in the Idris2 ABI layer +// (SupabaseMcp.SafeDatabase). Thread-safe via std.Thread.Mutex. No heap +// allocations for results. State machine: Disconnected | Connected | +// QueryRunning | Error. Covers PostgREST, Auth, Storage, and Functions +// endpoints. Configurable project URL. + +const std = @import("std"); + +// --------------------------------------------------------------------------- +// Connection state machine (matches Idris2 ABI exactly) +// --------------------------------------------------------------------------- + +/// Connection states for Supabase project. +pub const ConnState = enum(c_int) { + disconnected = 0, + connected = 1, + query_running = 2, + err = 3, +}; + +/// Supabase actions covering PostgREST, Auth, Storage, and Functions. +pub const SupabaseAction = enum(c_int) { + list_projects = 0, + get_project = 1, + query = 2, + list_tables = 3, + get_table = 4, + list_functions = 5, + invoke_function = 6, + list_buckets = 7, + upload_file = 8, + list_files = 9, + get_user = 10, + list_users = 11, + create_user = 12, + sign_in = 13, + list_secrets = 14, + set_secret = 15, +}; + +/// Check whether a state transition is valid per the ABI specification. +fn isValidTransition(from: ConnState, to: ConnState) bool { + return switch (from) { + .disconnected => to == .connected, + .connected => to == .query_running or to == .disconnected, + .query_running => to == .connected or to == .err, + .err => to == .disconnected, + }; +} + +// --------------------------------------------------------------------------- +// Session slots (thread-safe, fixed-size pool) +// --------------------------------------------------------------------------- + +const MAX_SESSIONS: usize = 16; +const BUF_SIZE: usize = 4096; + +const SessionSlot = struct { + in_use: bool = false, + state: ConnState = .disconnected, + context_buf: [BUF_SIZE]u8 = .{0} ** BUF_SIZE, + context_len: usize = 0, + table_count: u32 = 0, + function_count: u32 = 0, + query_count: u32 = 0, + storage_bytes: u64 = 0, +}; + +var sessions: [MAX_SESSIONS]SessionSlot = .{SessionSlot{}} ** MAX_SESSIONS; +var mutex: std.Thread.Mutex = .{}; + +// --------------------------------------------------------------------------- +// C-ABI exports +// --------------------------------------------------------------------------- + +/// Check if a state transition is valid. Returns 1 (valid) or 0 (invalid). +pub export fn supabase_mcp_can_transition(from: c_int, to: c_int) c_int { + const f = std.meta.intToEnum(ConnState, from) catch return 0; + const t = std.meta.intToEnum(ConnState, to) catch return 0; + return if (isValidTransition(f, t)) 1 else 0; +} + +/// Open a new connection session. Returns slot index (>= 0) or -1 if no slots. +pub export fn supabase_mcp_session_open() c_int { + mutex.lock(); + defer mutex.unlock(); + + for (&sessions, 0..) |*slot, idx| { + if (!slot.in_use) { + slot.in_use = true; + slot.state = .connected; + slot.context_len = 0; + slot.table_count = 0; + slot.function_count = 0; + slot.query_count = 0; + slot.storage_bytes = 0; + return @intCast(idx); + } + } + return -1; +} + +/// Close a connection session. Returns 0 on success, -1 if invalid slot, -2 if bad transition. +pub export fn supabase_mcp_session_close(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.in_use) return -1; + if (!isValidTransition(slot.state, .disconnected)) return -2; + + slot.in_use = false; + slot.state = .disconnected; + slot.context_len = 0; + return 0; +} + +/// Get the current state of a session. Returns state int or -1 if invalid slot. +pub export fn supabase_mcp_session_state(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.in_use) return -1; + return @intFromEnum(slot.state); +} + +/// Begin a query. Returns 0 on success, -1 if invalid slot, -2 if bad transition. +pub export fn supabase_mcp_begin_query(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.in_use) return -1; + if (!isValidTransition(slot.state, .query_running)) return -2; + + slot.state = .query_running; + return 0; +} + +/// End a query (return to connected). Returns 0 on success. +pub export fn supabase_mcp_end_query(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.in_use) return -1; + if (!isValidTransition(slot.state, .connected)) return -2; + + slot.state = .connected; + slot.query_count += 1; + return 0; +} + +/// Signal an error on a query-running session. Returns 0 on success. +pub export fn supabase_mcp_signal_error(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.in_use) return -1; + if (!isValidTransition(slot.state, .err)) return -2; + + slot.state = .err; + return 0; +} + +/// Get the query count for a session. Returns count or -1 if invalid slot. +pub export fn supabase_mcp_query_count(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.in_use) return -1; + return @intCast(slot.query_count); +} + +/// Check if an action requires an active connection. Returns 1 (yes) or 0 (no). +pub export fn supabase_mcp_action_requires_connection(action: c_int) c_int { + const a = std.meta.intToEnum(SupabaseAction, action) catch return 0; + return switch (a) { + .list_projects, .get_project => 0, + .list_buckets => 0, + else => 1, + }; +} + +/// Reset all sessions (test/debug use only). +pub export fn supabase_mcp_reset() void { + mutex.lock(); + defer mutex.unlock(); + sessions = .{SessionSlot{}} ** MAX_SESSIONS; +} + +// --------------------------------------------------------------------------- +// ═══════════════════════════════════════════════════════════════════════ +// Standard ABI (ADR-0005 four symbols + ADR-0006 invoke) +// ═══════════════════════════════════════════════════════════════════════ + +const shim = @import("cartridge_shim.zig"); + +const CARTRIDGE_NAME_PTR: [*:0]const u8 = "supabase-mcp"; +const CARTRIDGE_VERSION_PTR: [*:0]const u8 = "0.1.0"; + +export fn boj_cartridge_init() callconv(.c) c_int { + return 0; +} + +export fn boj_cartridge_deinit() callconv(.c) void {} + +export fn boj_cartridge_name() callconv(.c) [*:0]const u8 { + return CARTRIDGE_NAME_PTR; +} + +export fn boj_cartridge_version() callconv(.c) [*:0]const u8 { + return CARTRIDGE_VERSION_PTR; +} + +/// Dispatch the cartridge.json MCP tools. Grade D Alpha stubs. +export fn boj_cartridge_invoke( + tool_name: [*c]const u8, + json_args: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) callconv(.c) i32 { + _ = json_args; + if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; + + const body: []const u8 = if (shim.toolIs(tool_name, "supabase_connect")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "supabase_query")) + "{\"result\":{\"matches\":[],\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "supabase_insert")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "supabase_update")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "supabase_delete")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "supabase_storage_list")) + "{\"result\":{\"items\":[],\"count\":0,\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "supabase_disconnect")) + "{\"result\":{\"status\":\"stub\"}}" +else + return shim.RC_UNKNOWN_TOOL; + + return shim.writeResult(out_buf, in_out_len, body); +} + +// Tests +// --------------------------------------------------------------------------- + +test "session lifecycle" { + supabase_mcp_reset(); + + const slot = supabase_mcp_session_open(); + try std.testing.expect(slot >= 0); + + // Should be in connected state + try std.testing.expectEqual(@as(c_int, 1), supabase_mcp_session_state(slot)); + + // Begin query + try std.testing.expectEqual(@as(c_int, 0), supabase_mcp_begin_query(slot)); + try std.testing.expectEqual(@as(c_int, 2), supabase_mcp_session_state(slot)); + + // End query + try std.testing.expectEqual(@as(c_int, 0), supabase_mcp_end_query(slot)); + try std.testing.expectEqual(@as(c_int, 1), supabase_mcp_session_state(slot)); + + // Query count should be 1 + try std.testing.expectEqual(@as(c_int, 1), supabase_mcp_query_count(slot)); + + // Close + try std.testing.expectEqual(@as(c_int, 0), supabase_mcp_session_close(slot)); +} + +test "invalid transitions rejected" { + supabase_mcp_reset(); + + const slot = supabase_mcp_session_open(); + try std.testing.expect(slot >= 0); + + // Can not go connected -> error (must go through query_running) + try std.testing.expectEqual(@as(c_int, -2), supabase_mcp_signal_error(slot)); + + // Can not close while query running + try std.testing.expectEqual(@as(c_int, 0), supabase_mcp_begin_query(slot)); + try std.testing.expectEqual(@as(c_int, -2), supabase_mcp_session_close(slot)); +} + +test "transition validator" { + // Valid transitions + try std.testing.expectEqual(@as(c_int, 1), supabase_mcp_can_transition(0, 1)); // disconnected -> connected + try std.testing.expectEqual(@as(c_int, 1), supabase_mcp_can_transition(1, 2)); // connected -> query_running + try std.testing.expectEqual(@as(c_int, 1), supabase_mcp_can_transition(2, 1)); // query_running -> connected + try std.testing.expectEqual(@as(c_int, 1), supabase_mcp_can_transition(1, 0)); // connected -> disconnected + try std.testing.expectEqual(@as(c_int, 1), supabase_mcp_can_transition(2, 3)); // query_running -> error + try std.testing.expectEqual(@as(c_int, 1), supabase_mcp_can_transition(3, 0)); // error -> disconnected + + // Invalid transitions + try std.testing.expectEqual(@as(c_int, 0), supabase_mcp_can_transition(0, 2)); + try std.testing.expectEqual(@as(c_int, 0), supabase_mcp_can_transition(1, 3)); + try std.testing.expectEqual(@as(c_int, 0), supabase_mcp_can_transition(3, 1)); + try std.testing.expectEqual(@as(c_int, 0), supabase_mcp_can_transition(99, 0)); +} + +test "slot exhaustion" { + supabase_mcp_reset(); + + var slots: [MAX_SESSIONS]c_int = undefined; + for (&slots, 0..) |*s, i| { + _ = i; + s.* = supabase_mcp_session_open(); + try std.testing.expect(s.* >= 0); + } + + // Next open should fail + try std.testing.expectEqual(@as(c_int, -1), supabase_mcp_session_open()); + + // Free one and try again + try std.testing.expectEqual(@as(c_int, 0), supabase_mcp_session_close(slots[0])); + const new_slot = supabase_mcp_session_open(); + try std.testing.expect(new_slot >= 0); +} + +test "action requires connection" { + try std.testing.expectEqual(@as(c_int, 1), supabase_mcp_action_requires_connection(2)); // query + try std.testing.expectEqual(@as(c_int, 1), supabase_mcp_action_requires_connection(6)); // invoke_function + try std.testing.expectEqual(@as(c_int, 0), supabase_mcp_action_requires_connection(0)); // list_projects + try std.testing.expectEqual(@as(c_int, 0), supabase_mcp_action_requires_connection(99)); // out of range +} + +// ═══════════════════════════════════════════════════════════════════════ +// ADR-0006 invoke dispatch tests +// ═══════════════════════════════════════════════════════════════════════ + +test "boj_cartridge_name returns supabase-mcp" { + const n = std.mem.span(boj_cartridge_name()); + try std.testing.expectEqualStrings("supabase-mcp", n); +} + +test "boj_cartridge_init returns 0" { + try std.testing.expectEqual(@as(c_int, 0), boj_cartridge_init()); +} + +test "invoke: each declared tool succeeds" { + var buf: [256]u8 = undefined; + const tools = [_][]const u8{ + "supabase_connect", + "supabase_query", + "supabase_insert", + "supabase_update", + "supabase_delete", + "supabase_storage_list", + "supabase_disconnect", + }; + for (tools) |t| { + var len: usize = buf.len; + const rc = boj_cartridge_invoke(t.ptr, "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, 0), rc); + try std.testing.expect(std.mem.indexOf(u8, buf[0..len], "result") != null); + } +} + +test "invoke: unknown tool returns -1" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("nope", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -1), rc); +} + +test "invoke: buffer too small returns -3" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("supabase_connect", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -3), rc); + try std.testing.expect(len > 4); +} diff --git a/cartridges/domains/database/supabase-mcp/minter.toml b/cartridges/domains/database/supabase-mcp/minter.toml new file mode 100644 index 0000000..820ccd7 --- /dev/null +++ b/cartridges/domains/database/supabase-mcp/minter.toml @@ -0,0 +1,16 @@ +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> + +name = "supabase-mcp" +description = "Supabase MCP cartridge β€” PostgREST, Auth, Storage, Functions" +version = "0.1.0" +domain = "Database" +protocols = [ + "MCP", + "REST", +] +tier = "Ayo" +backend = "universal" +generate_panel = true +auth = "bearer" +api_base = "https://{project}.supabase.co/" diff --git a/cartridges/domains/database/supabase-mcp/mod.js b/cartridges/domains/database/supabase-mcp/mod.js new file mode 100644 index 0000000..4ccb589 --- /dev/null +++ b/cartridges/domains/database/supabase-mcp/mod.js @@ -0,0 +1,76 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// supabase-mcp/mod.js -- supabase gateway. + +const BASE_URL = Deno.env.get("SUPABASE_MCP_BACKEND_URL") ?? "http://127.0.0.1:7725"; + +async function post(path, payload) { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), 15000); + try { + const r = await fetch(`${BASE_URL}${path}`, { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), signal: ctrl.signal, + }); + const data = await r.json().catch(() => ({ success: false, error: "non-JSON response" })); + return { status: r.status, data }; + } catch (e) { + if (e.name === "AbortError") return { status: 504, data: { success: false, error: "supabase-mcp backend timed out" } }; + return { status: 503, data: { success: false, error: `supabase-mcp backend unavailable: ${e.message}` } }; + } finally { clearTimeout(t); } +} + +export async function handleTool(toolName, args) { + switch (toolName) { + + case "supabase_connect": { + const { project_url, anon_key } = args ?? {}; + if (!project_url || !anon_key) return { status: 400, data: { error: "project_url is required" } }; + const payload = { project_url, anon_key }; + return post("/api/v1/connect", payload); + } + case "supabase_query": { + const { slot, table, filter, select } = args ?? {}; + if (!slot || !table) return { status: 400, data: { error: "slot is required" } }; + const payload = { slot, table }; + if (filter !== undefined) payload.filter = filter; + if (select !== undefined) payload.select = select; + return post("/api/v1/query", payload); + } + case "supabase_insert": { + const { slot, table, data } = args ?? {}; + if (!slot || !table || !data) return { status: 400, data: { error: "slot is required" } }; + const payload = { slot, table, data }; + return post("/api/v1/insert", payload); + } + case "supabase_update": { + const { slot, table, filter, data } = args ?? {}; + if (!slot || !table || !filter || !data) return { status: 400, data: { error: "slot is required" } }; + const payload = { slot, table, filter, data }; + return post("/api/v1/update", payload); + } + case "supabase_delete": { + const { slot, table, filter } = args ?? {}; + if (!slot || !table || !filter) return { status: 400, data: { error: "slot is required" } }; + const payload = { slot, table, filter }; + return post("/api/v1/delete", payload); + } + case "supabase_storage_list": { + const { slot, bucket, path } = args ?? {}; + if (!slot || !bucket) return { status: 400, data: { error: "slot is required" } }; + const payload = { slot, bucket }; + if (path !== undefined) payload.path = path; + return post("/api/v1/storage-list", payload); + } + case "supabase_disconnect": { + const { slot } = args ?? {}; + if (!slot) return { status: 400, data: { error: "slot is required" } }; + const payload = { slot }; + return post("/api/v1/disconnect", payload); + } + + default: + return { status: 404, data: { error: `Unknown tool: ${toolName}` } }; + } +} diff --git a/cartridges/domains/database/supabase-mcp/panels/manifest.json b/cartridges/domains/database/supabase-mcp/panels/manifest.json new file mode 100644 index 0000000..c773117 --- /dev/null +++ b/cartridges/domains/database/supabase-mcp/panels/manifest.json @@ -0,0 +1,90 @@ +{ + "$schema": "https://panll.dev/schemas/panel-manifest/v1.json", + "spdx": "MPL-2.0", + "cartridge": "supabase-mcp", + "domain": "Database", + "version": "0.1.0", + "panels": [ + { + "id": "supabase-project-status", + "title": "Project Status", + "description": "Current Supabase project connection state (Disconnected / Connected / QueryRunning / Error)", + "type": "status-indicator", + "data_source": { + "endpoint": "/supabase/state", + "method": "GET", + "refresh_interval_ms": 5000 + }, + "widgets": [ + { + "type": "state-badge", + "field": "state", + "states": { + "disconnected": { "color": "#95a5a6", "icon": "database-off" }, + "connected": { "color": "#2ecc71", "icon": "database" }, + "query_running": { "color": "#3498db", "icon": "database-cog" }, + "error": { "color": "#e74c3c", "icon": "database-alert" } + } + } + ] + }, + { + "id": "supabase-table-count", + "title": "Table Count", + "description": "Number of tables in the connected Supabase project (via PostgREST)", + "type": "metric", + "data_source": { + "endpoint": "/supabase/tables", + "method": "GET", + "refresh_interval_ms": 30000 + }, + "widgets": [ + { + "type": "counter", + "field": "table_count", + "label": "Tables", + "icon": "table" + } + ] + }, + { + "id": "supabase-function-count", + "title": "Function Count", + "description": "Number of deployed edge functions in the Supabase project", + "type": "metric", + "data_source": { + "endpoint": "/supabase/functions", + "method": "GET", + "refresh_interval_ms": 30000 + }, + "widgets": [ + { + "type": "counter", + "field": "function_count", + "label": "Functions", + "icon": "terminal" + } + ] + }, + { + "id": "supabase-storage-usage", + "title": "Storage Usage", + "description": "Total storage bytes used across all buckets in the Supabase project", + "type": "metric", + "data_source": { + "endpoint": "/supabase/storage/usage", + "method": "GET", + "refresh_interval_ms": 60000 + }, + "widgets": [ + { + "type": "counter", + "field": "storage_bytes", + "label": "Storage", + "icon": "hard-drive", + "format": "bytes" + } + ] + } + ] +} diff --git a/cartridges/domains/database/supabase-mcp/tests/integration_test.sh b/cartridges/domains/database/supabase-mcp/tests/integration_test.sh new file mode 100755 index 0000000..4f91986 --- /dev/null +++ b/cartridges/domains/database/supabase-mcp/tests/integration_test.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Integration tests for supabase-mcp cartridge. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CART_DIR="$(dirname "$SCRIPT_DIR")" +FFI_DIR="$CART_DIR/ffi" + +echo "=== supabase-mcp integration tests ===" + +# Build FFI +echo "[1/3] Building FFI..." +cd "$FFI_DIR" && zig build 2>&1 + +# Run Zig unit tests +echo "[2/3] Running FFI unit tests..." +cd "$FFI_DIR" && zig build test 2>&1 + +# Validate Idris2 ABI (if idris2 available) +echo "[3/3] Checking ABI..." +if command -v idris2 &>/dev/null; then + cd "$CART_DIR/abi" && idris2 --check SupabaseMcp.SafeDatabase 2>&1 + echo " ABI: OK" +else + echo " ABI: SKIPPED (idris2 not in PATH)" +fi + +echo "" +echo "All tests passed for supabase-mcp!" diff --git a/cartridges/domains/database/turso-mcp/README.adoc b/cartridges/domains/database/turso-mcp/README.adoc new file mode 100644 index 0000000..f435f5a --- /dev/null +++ b/cartridges/domains/database/turso-mcp/README.adoc @@ -0,0 +1,66 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> + += turso-mcp +Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk> +:spdx: MPL-2.0 +:tier: Ayo +:domain: Database +:protocols: MCP, REST + +== Overview + +Turso libSQL MCP cartridge. Provides type-safe access to the +https://api.turso.tech/v1/[Turso REST API] covering databases, groups, tokens, +stats, locations, organizations, and usage. Auth via Bearer token (Turso API +token). Supports edge replicas and embedded replica sync via libSQL client URL. + +=== Actions + +ListDatabases, CreateDatabase, DeleteDatabase, GetDatabase, ListGroups, +CreateGroup, Query, BatchQuery, ListTokens, CreateToken, RevokeToken, +GetStats, ListLocations, ListOrganizations, TransferDatabase, GetUsage. + +== Architecture + +[cols="1,1,2"] +|=== +| Layer | Language | Purpose + +| ABI +| Idris2 +| Formally verified state machine (Disconnected / Connected / QueryRunning / Error) + +| FFI +| Zig +| C-compatible implementation with thread-safe session pool + +| Adapter +| zig +| REST bridge to BoJ unified adapter protocol +|=== + +== Building + +[source,bash] +---- +# Build FFI shared library +cd ffi && zig build + +# Run FFI tests +cd ffi && zig build test + +# Type-check ABI +cd abi && idris2 --check TursoMcp.SafeDatabase +---- + +== Panels + +* Database count +* Group count +* Replica locations +* Query count + +== Status + +Development -- not yet ready for mounting. diff --git a/cartridges/domains/database/turso-mcp/abi/README.adoc b/cartridges/domains/database/turso-mcp/abi/README.adoc new file mode 100644 index 0000000..6e8982b --- /dev/null +++ b/cartridges/domains/database/turso-mcp/abi/README.adoc @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += turso-mcp / abi β€” Idris2 ABI layer +:orientation: shallow + +== Purpose + +Cartridge-local protocol types for `turso-mcp`. Defines the data, tool, and +transition vocabulary this cartridge speaks. Inherits safety lemmas and the +matrix-cell identifier scheme from the trunk ABI at `src/abi/`. + +== Files + +1 Idris2 module(s), ~160 lines total. Lead module: +`SafeDatabase.idr`. + +No `.ipkg` β€” consumed directly by wider BoJ callers; type-checks inside the +trunk `boj.ipkg`. + +== Invariants + +* `%default total` at every module top. +* Zero `believe_me`, `assert_total`, `postulate`, `Admitted`, or + `sorry` (the trunk holds the four declared `believe_me` sites; leaf + cartridges carry none). + +== Test/proof surface + +Thin module(s), no inline tests. Proof surface = type-check success under the +trunk `idris2 --check`. + +== Read-first + +. `SafeDatabase.idr` β€” cartridge-local data records and enums. +. Trunk `src/abi/Boj/Catalogue.idr` for how this cell participates in the + 2D matrix. diff --git a/cartridges/domains/database/turso-mcp/abi/TursoMcp/SafeDatabase.idr b/cartridges/domains/database/turso-mcp/abi/TursoMcp/SafeDatabase.idr new file mode 100644 index 0000000..15420fa --- /dev/null +++ b/cartridges/domains/database/turso-mcp/abi/TursoMcp/SafeDatabase.idr @@ -0,0 +1,160 @@ +-- SPDX-License-Identifier: MPL-2.0 +-- Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +-- +-- TursoMcp.SafeDatabase β€” Type-safe ABI for the turso-mcp cartridge. +-- +-- Provides a formally verified state machine for Turso (libSQL) database +-- connections. Dependent-type proofs ensure only valid transitions can occur +-- at the FFI boundary. Turso actions cover the full REST API surface +-- (https://api.turso.tech/v1/). Auth via Bearer token (Turso API token). +-- Edge-replica-aware with embedded replica support. + +module TursoMcp.SafeDatabase + +%default total + +-- --------------------------------------------------------------------------- +-- Connection state machine +-- --------------------------------------------------------------------------- + +||| Connection state for Turso libSQL operations. +||| Supports primary and edge replica connections. +public export +data ConnState = Disconnected | Connected | QueryRunning | Error + +||| Proof that a state transition is valid. +public export +data ValidTransition : ConnState -> ConnState -> Type where + Connect : ValidTransition Disconnected Connected + StartQuery : ValidTransition Connected QueryRunning + FinishQuery : ValidTransition QueryRunning Connected + Disconnect : ValidTransition Connected Disconnected + QueryFail : ValidTransition QueryRunning Error + ErrorRecover : ValidTransition Error Disconnected + +-- --------------------------------------------------------------------------- +-- C-ABI integer encoding +-- --------------------------------------------------------------------------- + +||| Encode connection state as C-compatible integer. +export +connStateToInt : ConnState -> Int +connStateToInt Disconnected = 0 +connStateToInt Connected = 1 +connStateToInt QueryRunning = 2 +connStateToInt Error = 3 + +||| Decode integer back to connection state. +export +intToConnState : Int -> Maybe ConnState +intToConnState 0 = Just Disconnected +intToConnState 1 = Just Connected +intToConnState 2 = Just QueryRunning +intToConnState 3 = Just Error +intToConnState _ = Nothing + +||| Check if a state transition is valid (C-ABI export). +||| Returns 1 for valid, 0 for invalid. +export +turso_mcp_can_transition : Int -> Int -> Int +turso_mcp_can_transition from to = + case (intToConnState from, intToConnState to) of + (Just Disconnected, Just Connected) => 1 + (Just Connected, Just QueryRunning) => 1 + (Just QueryRunning, Just Connected) => 1 + (Just Connected, Just Disconnected) => 1 + (Just QueryRunning, Just Error) => 1 + (Just Error, Just Disconnected) => 1 + _ => 0 + +-- --------------------------------------------------------------------------- +-- Turso actions (full REST API surface) +-- --------------------------------------------------------------------------- + +||| Actions supported by the Turso MCP cartridge. +||| Covers databases, groups, tokens, stats, locations, organizations, and usage. +public export +data TursoAction + = ListDatabases + | CreateDatabase + | DeleteDatabase + | GetDatabase + | ListGroups + | CreateGroup + | Query + | BatchQuery + | ListTokens + | CreateToken + | RevokeToken + | GetStats + | ListLocations + | ListOrganizations + | TransferDatabase + | GetUsage + +||| Encode action as C-compatible integer. +export +tursoActionToInt : TursoAction -> Int +tursoActionToInt ListDatabases = 0 +tursoActionToInt CreateDatabase = 1 +tursoActionToInt DeleteDatabase = 2 +tursoActionToInt GetDatabase = 3 +tursoActionToInt ListGroups = 4 +tursoActionToInt CreateGroup = 5 +tursoActionToInt Query = 6 +tursoActionToInt BatchQuery = 7 +tursoActionToInt ListTokens = 8 +tursoActionToInt CreateToken = 9 +tursoActionToInt RevokeToken = 10 +tursoActionToInt GetStats = 11 +tursoActionToInt ListLocations = 12 +tursoActionToInt ListOrganizations = 13 +tursoActionToInt TransferDatabase = 14 +tursoActionToInt GetUsage = 15 + +||| Decode integer back to action. +export +intToTursoAction : Int -> Maybe TursoAction +intToTursoAction 0 = Just ListDatabases +intToTursoAction 1 = Just CreateDatabase +intToTursoAction 2 = Just DeleteDatabase +intToTursoAction 3 = Just GetDatabase +intToTursoAction 4 = Just ListGroups +intToTursoAction 5 = Just CreateGroup +intToTursoAction 6 = Just Query +intToTursoAction 7 = Just BatchQuery +intToTursoAction 8 = Just ListTokens +intToTursoAction 9 = Just CreateToken +intToTursoAction 10 = Just RevokeToken +intToTursoAction 11 = Just GetStats +intToTursoAction 12 = Just ListLocations +intToTursoAction 13 = Just ListOrganizations +intToTursoAction 14 = Just TransferDatabase +intToTursoAction 15 = Just GetUsage +intToTursoAction _ = Nothing + +||| Check whether an action requires an active connection. +export +actionRequiresConnection : TursoAction -> Bool +actionRequiresConnection Query = True +actionRequiresConnection BatchQuery = True +actionRequiresConnection _ = False + +||| Total number of actions exposed by this cartridge. +export +actionCount : Nat +actionCount = 16 + +-- --------------------------------------------------------------------------- +-- Auth configuration +-- --------------------------------------------------------------------------- + +||| Authentication method for Turso REST API. +||| Bearer token using a Turso API token. libSQL client URL used separately. +public export +data TursoAuth = BearerToken + +||| Base URL for Turso REST API. +export +tursoApiBase : String +tursoApiBase = "https://api.turso.tech/v1/" diff --git a/cartridges/domains/database/turso-mcp/abi/turso_mcp.ipkg b/cartridges/domains/database/turso-mcp/abi/turso_mcp.ipkg new file mode 100644 index 0000000..060fe9e --- /dev/null +++ b/cartridges/domains/database/turso-mcp/abi/turso_mcp.ipkg @@ -0,0 +1,11 @@ +-- SPDX-License-Identifier: MPL-2.0 +-- Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +package turso_mcp + +version = "0.1.0" +authors = "Jonathan D.A. Jewell" +brief = "Turso libSQL MCP cartridge β€” type-safe ABI layer" + +depends = base + +modules = TursoMcp.SafeDatabase diff --git a/cartridges/domains/database/turso-mcp/adapter/README.adoc b/cartridges/domains/database/turso-mcp/adapter/README.adoc new file mode 100644 index 0000000..413d5f1 --- /dev/null +++ b/cartridges/domains/database/turso-mcp/adapter/README.adoc @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += turso-mcp / adapter β€” protocol bridge +:orientation: shallow + +== Purpose + +Exposes this cartridge's FFI over the wire protocols declared in +`cartridge.json`. Dispatches tool invocations to the C-ABI exports at +`../ffi/` and formats responses per protocol. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” depends on the sibling `ffi` target. +| `turso_mcp_adapter.zig` | Protocol dispatch (112 lines). Tool catalogue matches `cartridge.json`. +| `SIDELINED-*.v.adoc` | zig predecessor(s). Not built β€” zig banned 2026-04-10. +|=== + +== Invariants + +* **Stateless** β€” all state lives behind the FFI, never in the adapter. +* **Response passthrough** β€” whatever the FFI returns goes back to the wire + unmodified (no embellishment, no silent recovery). +* **Cartridge.json is source of truth** for the tool catalogue; drift between + adapter and manifest is a CI failure. + +== Test/proof surface + +No inline tests at the adapter layer β€” protocol routing is exercised by the +trunk seam tests at `ffi/zig/src/seams.zig`. + +== Read-first + +. `turso_mcp_adapter.zig` β€” dispatch function and per-protocol routers. +. `../cartridge.json` β€” the tool manifest the dispatcher mirrors. diff --git a/cartridges/domains/database/turso-mcp/adapter/build.zig b/cartridges/domains/database/turso-mcp/adapter/build.zig new file mode 100644 index 0000000..71229db --- /dev/null +++ b/cartridges/domains/database/turso-mcp/adapter/build.zig @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// turso-mcp/adapter/build.zig + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); const optimize = b.standardOptimizeOption(.{}); + const ffi_mod = b.createModule(.{ .root_source_file = b.path("../ffi/turso_mcp_ffi.zig"), .target = target, .optimize = optimize }); + const adapter = b.addExecutable(.{ .name = "turso_mcp_adapter", .root_source_file = b.path("turso_mcp_adapter.zig"), .target = target, .optimize = optimize }); + adapter.root_module.addImport("turso_mcp_ffi", ffi_mod); b.installArtifact(adapter); + const rs = b.step("run", "Run turso-mcp adapter"); rs.dependOn(&b.addRunArtifact(adapter).step); + const tests = b.addTest(.{ .root_source_file = b.path("turso_mcp_adapter.zig"), .target = target, .optimize = optimize }); + tests.root_module.addImport("turso_mcp_ffi", ffi_mod); + const ts = b.step("test", "Test turso-mcp adapter"); ts.dependOn(&b.addRunArtifact(tests).step); +} diff --git a/cartridges/domains/database/turso-mcp/adapter/turso_mcp_adapter.zig b/cartridges/domains/database/turso-mcp/adapter/turso_mcp_adapter.zig new file mode 100644 index 0000000..ec51791 --- /dev/null +++ b/cartridges/domains/database/turso-mcp/adapter/turso_mcp_adapter.zig @@ -0,0 +1,112 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// turso-mcp/adapter/turso_mcp_adapter.zig -- Unified three-protocol adapter. +// Replaces banned turso_mcp_adapter.v (zig, removed 2026-04-12). +// REST:9178 gRPC:9179 GraphQL:9180 +// Tools: turso_connect, turso_query, turso_execute, turso_batch... + +const std = @import("std"); +const ffi = @import("turso_mcp_ffi"); + +const REST_PORT: u16 = 9178; +const GRPC_PORT: u16 = 9179; +const GQL_PORT: u16 = 9180; +const MAX_CONN_BUF: usize = 16 * 1024; + +fn okJson(buf: []u8, msg: []const u8) []u8 { + const n = std.fmt.bufPrint(buf, "{\"success\":true,\"message\":\"{s}\"}", .{msg}) catch return buf[0..0]; return n; +} +fn errJson(buf: []u8, msg: []const u8) []u8 { + const n = std.fmt.bufPrint(buf, "{\"success\":false,\"error\":\"{s}\"}", .{msg}) catch return buf[0..0]; return n; +} +fn statusJson(buf: []u8) []u8 { + const n = std.fmt.bufPrint(buf, "{\"success\":true,\"state\":\"ready\",\"service\":\"turso-mcp\"}", .{}) catch return buf[0..0]; return n; +} + +const Response = struct { status: u16, body: []u8 }; + +fn dispatch(tool: []const u8, body: []const u8, resp: []u8) Response { + _ = body; + if (std.mem.eql(u8, tool, "turso_connect")) return .{ .status = 200, .body = okJson(resp, "turso_connect forwarded") }; + if (std.mem.eql(u8, tool, "turso_query")) return .{ .status = 200, .body = okJson(resp, "turso_query forwarded") }; + if (std.mem.eql(u8, tool, "turso_execute")) return .{ .status = 200, .body = okJson(resp, "turso_execute forwarded") }; + if (std.mem.eql(u8, tool, "turso_batch")) return .{ .status = 200, .body = okJson(resp, "turso_batch forwarded") }; + if (std.mem.eql(u8, tool, "turso_list_tables")) return .{ .status = 200, .body = okJson(resp, "turso_list_tables forwarded") }; + if (std.mem.eql(u8, tool, "turso_sync")) return .{ .status = 200, .body = okJson(resp, "turso_sync forwarded") }; + if (std.mem.eql(u8, tool, "turso_disconnect")) return .{ .status = 200, .body = okJson(resp, "turso_disconnect forwarded") }; + if (std.mem.eql(u8, tool, "status") or std.mem.eql(u8, tool, "health")) return .{ .status = 200, .body = statusJson(resp) }; + return .{ .status = 404, .body = errJson(resp, "Unknown tool") }; +} + +fn dispatchRest(path: []const u8, body: []const u8, resp: []u8) Response { + if (std.mem.startsWith(u8, path, "/tools/")) return dispatch(path["/tools/".len..], body, resp); + if (std.mem.eql(u8, path, "/status") or std.mem.eql(u8, path, "/health")) return .{ .status = 200, .body = statusJson(resp) }; + return .{ .status = 404, .body = errJson(resp, "Not found") }; +} + +fn dispatchGrpc(path: []const u8, body: []const u8, resp: []u8) Response { + const prefix = "/TursoMcpservice/"; + if (!std.mem.startsWith(u8, path, prefix)) return .{ .status = 404, .body = errJson(resp, "Not a recognized gRPC path") }; + const method = path[prefix.len..]; + const tool = blk: { + if (std.mem.eql(u8, method, "turso_connect")) break :blk "turso_connect"; + if (std.mem.eql(u8, method, "turso_query")) break :blk "turso_query"; + if (std.mem.eql(u8, method, "turso_execute")) break :blk "turso_execute"; + if (std.mem.eql(u8, method, "turso_batch")) break :blk "turso_batch"; + if (std.mem.eql(u8, method, "turso_list_tables")) break :blk "turso_list_tables"; + if (std.mem.eql(u8, method, "turso_sync")) break :blk "turso_sync"; + if (std.mem.eql(u8, method, "turso_disconnect")) break :blk "turso_disconnect"; + return .{ .status = 404, .body = errJson(resp, "Unknown gRPC method") }; + }; + return dispatch(tool, body, resp); +} + +fn dispatchGraphql(body: []const u8, resp: []u8) Response { + if (std.mem.indexOf(u8, body, "__schema") != null) return .{ .status = 200, .body = okJson(resp, "schema not supported") }; + if (std.mem.indexOf(u8, body, "connect") != null) return dispatch("turso_connect", body, resp); + if (std.mem.indexOf(u8, body, "query") != null) return dispatch("turso_query", body, resp); + if (std.mem.indexOf(u8, body, "execute") != null) return dispatch("turso_execute", body, resp); + if (std.mem.indexOf(u8, body, "batch") != null) return dispatch("turso_batch", body, resp); + if (std.mem.indexOf(u8, body, "list_tables") != null) return dispatch("turso_list_tables", body, resp); + if (std.mem.indexOf(u8, body, "sync") != null) return dispatch("turso_sync", body, resp); + if (std.mem.indexOf(u8, body, "disconnect") != null) return dispatch("turso_disconnect", body, resp); + return .{ .status = 200, .body = errJson(resp, "Unrecognised GraphQL operation") }; +} + +const Protocol = enum { rest, grpc, graphql }; + +fn handleConnection(conn: std.net.Server.Connection, proto: Protocol) void { + defer conn.stream.close(); + var in_buf: [MAX_CONN_BUF]u8 = undefined; + const n = conn.stream.read(&in_buf) catch return; + const req = in_buf[0..n]; + var path: []const u8 = "/"; var body: []const u8 = ""; + if (n > 4) { + const le = std.mem.indexOf(u8, req, "\r\n") orelse req.len; + const fl = req[0..le]; const sp1 = std.mem.indexOfScalar(u8, fl, ' ') orelse 0; + const ro = fl[sp1+1..]; const sp2 = std.mem.indexOfScalar(u8, ro, ' ') orelse ro.len; + path = ro[0..sp2]; + const bs = std.mem.indexOf(u8, req, "\r\n\r\n") orelse n; body = req[@min(bs+4,n)..]; + } + var rb: [MAX_CONN_BUF]u8 = undefined; + const result = switch (proto) { .rest => dispatchRest(path, body, &rb), .grpc => dispatchGrpc(path, body, &rb), .graphql => dispatchGraphql(body, &rb) }; + const ct: []const u8 = if (proto == .grpc) "application/grpc+json" else "application/json"; + var hb: [256]u8 = undefined; + const hdr = std.fmt.bufPrint(&hb, "HTTP/1.1 {d} OK\r\nContent-Type: {s}\r\nContent-Length: {d}\r\nConnection: close\r\n\r\n", .{ result.status, ct, result.body.len }) catch return; + _ = conn.stream.writeAll(hdr) catch return; _ = conn.stream.writeAll(result.body) catch return; +} + +fn listenLoop(port: u16, proto: Protocol) void { + const addr = std.net.Address.initIp4(.{ 127, 0, 0, 1 }, port) catch return; + var srv = addr.listen(.{ .reuse_address = true }) catch return; defer srv.deinit(); + while (true) { const conn = srv.accept() catch continue; handleConnection(conn, proto); } +} + +pub fn main() !void { + ffi.turso_mcp_init(); + const t1 = try std.Thread.spawn(.{}, listenLoop, .{ REST_PORT, Protocol.rest }); + const t2 = try std.Thread.spawn(.{}, listenLoop, .{ GRPC_PORT, Protocol.grpc }); + const t3 = try std.Thread.spawn(.{}, listenLoop, .{ GQL_PORT, Protocol.graphql }); + t1.join(); t2.join(); t3.join(); +} diff --git a/cartridges/domains/database/turso-mcp/benchmarks/quick-bench.sh b/cartridges/domains/database/turso-mcp/benchmarks/quick-bench.sh new file mode 100755 index 0000000..619d273 --- /dev/null +++ b/cartridges/domains/database/turso-mcp/benchmarks/quick-bench.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Benchmarks for turso-mcp cartridge. +set -euo pipefail + +echo "=== turso-mcp benchmarks ===" + +cd "$(dirname "${BASH_SOURCE[0]}")/../ffi" + +# Build with ReleaseFast for benchmarking +zig build -Doptimize=ReleaseFast 2>&1 + +echo "Session open/close cycle (1000 iterations):" +time for i in $(seq 1 1000); do + # This would call the FFI benchmark binary + true +done + +echo "" +echo "Benchmark placeholder β€” implement real benchmarks in Zig test blocks" +echo "or via the zig adapter HTTP benchmark tool." diff --git a/cartridges/domains/database/turso-mcp/cartridge.json b/cartridges/domains/database/turso-mcp/cartridge.json new file mode 100644 index 0000000..17d524b --- /dev/null +++ b/cartridges/domains/database/turso-mcp/cartridge.json @@ -0,0 +1,171 @@ +{ + "$schema": "https://boj.dev/schemas/cartridge/v1.json", + "spdx": "MPL-2.0", + "copyright": "Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath)", + "name": "turso-mcp", + "version": "0.1.0", + "description": "Turso libSQL gateway. Edge SQLite databases with multi-database support and embedded replica sync.", + "domain": "Database", + "tier": "Ayo", + "protocols": [ + "MCP", + "REST" + ], + "auth": { + "method": "none", + "env_var": null, + "credential_source": null + }, + "api": { + "base_url": "local://turso-mcp", + "content_type": "application/json" + }, + "tools": [ + { + "name": "turso_connect", + "description": "Connect to a Turso database. Returns a session slot.", + "inputSchema": { + "type": "object", + "properties": { + "url": { + "type": "string", + "description": "Turso database URL (libsql://... or file:...)" + }, + "auth_token": { + "type": "string", + "description": "Auth token for remote databases" + } + }, + "required": [ + "url" + ] + } + }, + { + "name": "turso_query", + "description": "Execute a SQL query and return results.", + "inputSchema": { + "type": "object", + "properties": { + "slot": { + "type": "integer", + "description": "Session slot from turso_connect" + }, + "sql": { + "type": "string", + "description": "SQL query" + }, + "params": { + "type": "string", + "description": "Query parameters as JSON array" + } + }, + "required": [ + "slot", + "sql" + ] + } + }, + { + "name": "turso_execute", + "description": "Execute a non-returning SQL statement.", + "inputSchema": { + "type": "object", + "properties": { + "slot": { + "type": "integer", + "description": "Session slot" + }, + "sql": { + "type": "string", + "description": "SQL statement" + } + }, + "required": [ + "slot", + "sql" + ] + } + }, + { + "name": "turso_batch", + "description": "Execute multiple SQL statements in a batch transaction.", + "inputSchema": { + "type": "object", + "properties": { + "slot": { + "type": "integer", + "description": "Session slot" + }, + "statements": { + "type": "string", + "description": "JSON array of SQL statement strings" + } + }, + "required": [ + "slot", + "statements" + ] + } + }, + { + "name": "turso_list_tables", + "description": "List tables in the connected database.", + "inputSchema": { + "type": "object", + "properties": { + "slot": { + "type": "integer", + "description": "Session slot" + } + }, + "required": [ + "slot" + ] + } + }, + { + "name": "turso_sync", + "description": "Sync an embedded replica with the remote primary.", + "inputSchema": { + "type": "object", + "properties": { + "slot": { + "type": "integer", + "description": "Session slot" + } + }, + "required": [ + "slot" + ] + } + }, + { + "name": "turso_disconnect", + "description": "Disconnect and release a session slot.", + "inputSchema": { + "type": "object", + "properties": { + "slot": { + "type": "integer", + "description": "Session slot to release" + } + }, + "required": [ + "slot" + ] + } + } + ], + "ffi": { + "so_path": "ffi/zig-out/lib/libturso_mcp.so", + "abi_version": "ADR-0006", + "symbols": [ + "boj_cartridge_init", + "boj_cartridge_deinit", + "boj_cartridge_name", + "boj_cartridge_version", + "boj_cartridge_invoke" + ] + } +} diff --git a/cartridges/domains/database/turso-mcp/ffi/README.adoc b/cartridges/domains/database/turso-mcp/ffi/README.adoc new file mode 100644 index 0000000..bc035af --- /dev/null +++ b/cartridges/domains/database/turso-mcp/ffi/README.adoc @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += turso-mcp / ffi β€” Zig FFI layer +:orientation: shallow + +== Purpose + +C-ABI exports bridging this cartridge's Idris2 ABI to the runtime. Consumed by +the adapter for protocol dispatch and by the trunk loader for mount. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” produces `zig-out/lib/libturso.so`. +| `turso_mcp_ffi.zig` | C-ABI exports (10 exports, 5 inline tests, 292 lines). +|=== + +== Invariants + +* **Null-pointer rejection** on `[*c]const u8` arguments before any + dereference. +* **Negative return values** carry documented error meanings; zero is + success (matching the trunk error-code convention). +* **No allocator leaks** β€” every allocation path pairs with `defer free`. + +== Test/proof surface + +5 inline `test "..."` block(s) in `turso_mcp_ffi.zig`. Run with +`cd ffi && zig build test`. + +== Read-first + +. `turso_mcp_ffi.zig` β€” C-ABI signatures and guards. +. Trunk `ffi/zig/src/catalogue.zig` for how this `.so` is loaded. diff --git a/cartridges/domains/database/turso-mcp/ffi/build.zig b/cartridges/domains/database/turso-mcp/ffi/build.zig new file mode 100644 index 0000000..b8c80d5 --- /dev/null +++ b/cartridges/domains/database/turso-mcp/ffi/build.zig @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + // Module + // Shared ADR-0006 invoke-shim module (relative path up to boj-server trunk). + + const shim_mod = b.addModule("cartridge_shim", .{ + + .root_source_file = b.path("../../../ffi/zig/src/cartridge_shim.zig"), + + .target = target, + + .optimize = optimize, + + }); + + const ffi_mod = b.addModule("turso_mcp", .{ + .root_source_file = b.path("turso_mcp_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + ffi_mod.addImport("cartridge_shim", shim_mod); + + // Shared library + const lib = b.addLibrary(.{ + .name = "turso_mcp", + .root_module = ffi_mod, + .linkage = .dynamic, + }); + lib.linkLibC(); + b.installArtifact(lib); + + // Tests + const tests = b.addTest(.{ + .root_module = ffi_mod, + }); + tests.linkLibC(); + + const run_tests = b.addRunArtifact(tests); + const test_step = b.step("test", "Run FFI tests"); + test_step.dependOn(&run_tests.step); +} diff --git a/cartridges/domains/database/turso-mcp/ffi/cartridge_shim.zig b/cartridges/domains/database/turso-mcp/ffi/cartridge_shim.zig new file mode 100644 index 0000000..0e399db --- /dev/null +++ b/cartridges/domains/database/turso-mcp/ffi/cartridge_shim.zig @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// cartridge_shim.zig β€” Shared helpers for the ADR-0006 five-symbol +// cartridge ABI (`boj_cartridge_init / deinit / name / version / invoke`). +// +// The shim centralises the seven-code return convention, NUL-argument +// guards, tool-name comparison, and the buffer-too-small path so each +// cartridge's `boj_cartridge_invoke` can stay short β€” typically a tool +// table plus `shim.writeResult(...)`. +// +// Cartridges import this file by relative path (no build-graph change +// needed). Example: +// +// const shim = @import("../../../ffi/zig/src/cartridge_shim.zig"); +// +// export fn boj_cartridge_invoke( +// tool_name: [*c]const u8, +// json_args: [*c]const u8, +// out_buf: [*c]u8, +// in_out_len: [*c]usize, +// ) callconv(.c) i32 { +// _ = json_args; +// if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; +// const body = if (shim.toolIs(tool_name, "foo")) "{\"result\":{}}" +// else return shim.RC_UNKNOWN_TOOL; +// return shim.writeResult(out_buf, in_out_len, body); +// } + +const std = @import("std"); + +// ── Return codes (ADR-0006 Β§Return codes) ──────────────────────────── +// +// Frozen by ADR-0006. New failure modes compose these via the error +// JSON body β€” the integer surface does not grow without a follow-up ADR. + +pub const RC_SUCCESS: i32 = 0; +pub const RC_UNKNOWN_TOOL: i32 = -1; +pub const RC_BAD_ARGS: i32 = -2; +pub const RC_BUFFER_TOO_SMALL: i32 = -3; +pub const RC_RUNTIME_ERROR: i32 = -4; +pub const RC_PANIC: i32 = -5; +pub const RC_AUTH_DENIED: i32 = -6; + +// ── Invoke-path helpers ────────────────────────────────────────────── + +/// True if any of the three mandatory `boj_cartridge_invoke` output-path +/// pointers is null. Use at the top of every invoke to short-circuit to +/// `RC_BAD_ARGS`. +pub fn invokeArgsNull( + tool_name: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) bool { + return tool_name == null or out_buf == null or in_out_len == null; +} + +/// Compare a C-NUL-terminated tool-name pointer against a Zig string +/// literal. Caller must have already verified `tool_name` is non-null +/// (usually via `invokeArgsNull`). +pub fn toolIs(tool_name: [*c]const u8, expected: []const u8) bool { + const s = std.mem.span(@as([*:0]const u8, @ptrCast(tool_name))); + return std.mem.eql(u8, s, expected); +} + +/// Copy `body` into `out_buf[0..*in_out_len]` (as a capacity) and update +/// `*in_out_len` to the number of bytes written. Returns `RC_SUCCESS`. +/// +/// If `body.len` exceeds the current capacity stored in `*in_out_len`, +/// sets `*in_out_len` to the required size and returns +/// `RC_BUFFER_TOO_SMALL` β€” the caller is then expected to re-allocate +/// and retry, per ADR-0006 Β§Memory ownership. +/// +/// Caller must have already verified that `out_buf` and `in_out_len` +/// are non-null. +pub fn writeResult( + out_buf: [*c]u8, + in_out_len: [*c]usize, + body: []const u8, +) i32 { + const cap = in_out_len.*; + if (body.len > cap) { + in_out_len.* = body.len; + return RC_BUFFER_TOO_SMALL; + } + @memcpy(out_buf[0..body.len], body); + in_out_len.* = body.len; + return RC_SUCCESS; +} + +// ── Tests ──────────────────────────────────────────────────────────── + +test "writeResult: body fits, writes and sets length" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); + try std.testing.expectEqualStrings("hello", buf[0..len]); +} + +test "writeResult: too small returns -3 and sets required length" { + var buf: [2]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_BUFFER_TOO_SMALL, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: exact-fit succeeds" { + var buf: [5]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: empty body" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, ""); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 0), len); +} + +test "toolIs: matches and rejects" { + const name: [*:0]const u8 = "foo"; + try std.testing.expect(toolIs(@ptrCast(name), "foo")); + try std.testing.expect(!toolIs(@ptrCast(name), "bar")); + try std.testing.expect(!toolIs(@ptrCast(name), "foobar")); + try std.testing.expect(!toolIs(@ptrCast(name), "fo")); +} + +test "invokeArgsNull: detects each null slot" { + var buf: [4]u8 = undefined; + var len: usize = 4; + const name: [*:0]const u8 = "x"; + try std.testing.expect(!invokeArgsNull(@ptrCast(name), &buf, &len)); + try std.testing.expect(invokeArgsNull(null, &buf, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), null, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), &buf, null)); +} diff --git a/cartridges/domains/database/turso-mcp/ffi/turso_mcp_ffi.zig b/cartridges/domains/database/turso-mcp/ffi/turso_mcp_ffi.zig new file mode 100644 index 0000000..e24db84 --- /dev/null +++ b/cartridges/domains/database/turso-mcp/ffi/turso_mcp_ffi.zig @@ -0,0 +1,392 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// turso_mcp_ffi.zig β€” C-ABI FFI implementation for the turso-mcp cartridge. +// +// Implements the connection state machine defined in the Idris2 ABI layer +// (TursoMcp.SafeDatabase). Thread-safe via std.Thread.Mutex. No heap +// allocations for results. State machine: Disconnected | Connected | +// QueryRunning | Error. Edge-replica-aware with embedded replica support. + +const std = @import("std"); + +// --------------------------------------------------------------------------- +// Connection state machine (matches Idris2 ABI exactly) +// --------------------------------------------------------------------------- + +/// Connection states for Turso libSQL. +pub const ConnState = enum(c_int) { + disconnected = 0, + connected = 1, + query_running = 2, + err = 3, +}; + +/// Turso REST API actions. +pub const TursoAction = enum(c_int) { + list_databases = 0, + create_database = 1, + delete_database = 2, + get_database = 3, + list_groups = 4, + create_group = 5, + query = 6, + batch_query = 7, + list_tokens = 8, + create_token = 9, + revoke_token = 10, + get_stats = 11, + list_locations = 12, + list_organizations = 13, + transfer_database = 14, + get_usage = 15, +}; + +/// Check whether a state transition is valid per the ABI specification. +fn isValidTransition(from: ConnState, to: ConnState) bool { + return switch (from) { + .disconnected => to == .connected, + .connected => to == .query_running or to == .disconnected, + .query_running => to == .connected or to == .err, + .err => to == .disconnected, + }; +} + +// --------------------------------------------------------------------------- +// Session slots (thread-safe, fixed-size pool) +// --------------------------------------------------------------------------- + +const MAX_SESSIONS: usize = 16; +const BUF_SIZE: usize = 4096; + +const SessionSlot = struct { + in_use: bool = false, + state: ConnState = .disconnected, + context_buf: [BUF_SIZE]u8 = .{0} ** BUF_SIZE, + context_len: usize = 0, + database_count: u32 = 0, + group_count: u32 = 0, + query_count: u32 = 0, + replica_locations: u32 = 0, +}; + +var sessions: [MAX_SESSIONS]SessionSlot = .{SessionSlot{}} ** MAX_SESSIONS; +var mutex: std.Thread.Mutex = .{}; + +// --------------------------------------------------------------------------- +// C-ABI exports +// --------------------------------------------------------------------------- + +/// Check if a state transition is valid. Returns 1 (valid) or 0 (invalid). +pub export fn turso_mcp_can_transition(from: c_int, to: c_int) c_int { + const f = std.meta.intToEnum(ConnState, from) catch return 0; + const t = std.meta.intToEnum(ConnState, to) catch return 0; + return if (isValidTransition(f, t)) 1 else 0; +} + +/// Open a new connection session. Returns slot index (>= 0) or -1 if no slots. +pub export fn turso_mcp_session_open() c_int { + mutex.lock(); + defer mutex.unlock(); + + for (&sessions, 0..) |*slot, idx| { + if (!slot.in_use) { + slot.in_use = true; + slot.state = .connected; + slot.context_len = 0; + slot.database_count = 0; + slot.group_count = 0; + slot.query_count = 0; + slot.replica_locations = 0; + return @intCast(idx); + } + } + return -1; +} + +/// Close a connection session. Returns 0 on success, -1 if invalid slot, -2 if bad transition. +pub export fn turso_mcp_session_close(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.in_use) return -1; + if (!isValidTransition(slot.state, .disconnected)) return -2; + + slot.in_use = false; + slot.state = .disconnected; + slot.context_len = 0; + return 0; +} + +/// Get the current state of a session. Returns state int or -1 if invalid slot. +pub export fn turso_mcp_session_state(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.in_use) return -1; + return @intFromEnum(slot.state); +} + +/// Begin a query. Returns 0 on success, -1 if invalid slot, -2 if bad transition. +pub export fn turso_mcp_begin_query(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.in_use) return -1; + if (!isValidTransition(slot.state, .query_running)) return -2; + + slot.state = .query_running; + return 0; +} + +/// End a query (return to connected). Returns 0 on success. +pub export fn turso_mcp_end_query(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.in_use) return -1; + if (!isValidTransition(slot.state, .connected)) return -2; + + slot.state = .connected; + slot.query_count += 1; + return 0; +} + +/// Signal an error on a query-running session. Returns 0 on success. +pub export fn turso_mcp_signal_error(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.in_use) return -1; + if (!isValidTransition(slot.state, .err)) return -2; + + slot.state = .err; + return 0; +} + +/// Get the query count for a session. Returns count or -1 if invalid slot. +pub export fn turso_mcp_query_count(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.in_use) return -1; + return @intCast(slot.query_count); +} + +/// Check if an action requires an active connection. Returns 1 (yes) or 0 (no). +pub export fn turso_mcp_action_requires_connection(action: c_int) c_int { + const a = std.meta.intToEnum(TursoAction, action) catch return 0; + return switch (a) { + .query, .batch_query => 1, + else => 0, + }; +} + +/// Reset all sessions (test/debug use only). +pub export fn turso_mcp_reset() void { + mutex.lock(); + defer mutex.unlock(); + sessions = .{SessionSlot{}} ** MAX_SESSIONS; +} + +// --------------------------------------------------------------------------- +// ═══════════════════════════════════════════════════════════════════════ +// Standard ABI (ADR-0005 four symbols + ADR-0006 invoke) +// ═══════════════════════════════════════════════════════════════════════ + +const shim = @import("cartridge_shim.zig"); + +const CARTRIDGE_NAME_PTR: [*:0]const u8 = "turso-mcp"; +const CARTRIDGE_VERSION_PTR: [*:0]const u8 = "0.1.0"; + +export fn boj_cartridge_init() callconv(.c) c_int { + return 0; +} + +export fn boj_cartridge_deinit() callconv(.c) void {} + +export fn boj_cartridge_name() callconv(.c) [*:0]const u8 { + return CARTRIDGE_NAME_PTR; +} + +export fn boj_cartridge_version() callconv(.c) [*:0]const u8 { + return CARTRIDGE_VERSION_PTR; +} + +/// Dispatch the cartridge.json MCP tools. Grade D Alpha stubs. +export fn boj_cartridge_invoke( + tool_name: [*c]const u8, + json_args: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) callconv(.c) i32 { + _ = json_args; + if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; + + const body: []const u8 = if (shim.toolIs(tool_name, "turso_connect")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "turso_query")) + "{\"result\":{\"matches\":[],\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "turso_execute")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "turso_batch")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "turso_list_tables")) + "{\"result\":{\"items\":[],\"count\":0,\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "turso_sync")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "turso_disconnect")) + "{\"result\":{\"status\":\"stub\"}}" +else + return shim.RC_UNKNOWN_TOOL; + + return shim.writeResult(out_buf, in_out_len, body); +} + +// Tests +// --------------------------------------------------------------------------- + +test "session lifecycle" { + turso_mcp_reset(); + + const slot = turso_mcp_session_open(); + try std.testing.expect(slot >= 0); + + // Should be in connected state + try std.testing.expectEqual(@as(c_int, 1), turso_mcp_session_state(slot)); + + // Begin query + try std.testing.expectEqual(@as(c_int, 0), turso_mcp_begin_query(slot)); + try std.testing.expectEqual(@as(c_int, 2), turso_mcp_session_state(slot)); + + // End query + try std.testing.expectEqual(@as(c_int, 0), turso_mcp_end_query(slot)); + try std.testing.expectEqual(@as(c_int, 1), turso_mcp_session_state(slot)); + + // Query count should be 1 + try std.testing.expectEqual(@as(c_int, 1), turso_mcp_query_count(slot)); + + // Close + try std.testing.expectEqual(@as(c_int, 0), turso_mcp_session_close(slot)); +} + +test "invalid transitions rejected" { + turso_mcp_reset(); + + const slot = turso_mcp_session_open(); + try std.testing.expect(slot >= 0); + + // Can not go connected -> error (must go through query_running) + try std.testing.expectEqual(@as(c_int, -2), turso_mcp_signal_error(slot)); + + // Can not close while query running + try std.testing.expectEqual(@as(c_int, 0), turso_mcp_begin_query(slot)); + try std.testing.expectEqual(@as(c_int, -2), turso_mcp_session_close(slot)); +} + +test "transition validator" { + // Valid transitions + try std.testing.expectEqual(@as(c_int, 1), turso_mcp_can_transition(0, 1)); // disconnected -> connected + try std.testing.expectEqual(@as(c_int, 1), turso_mcp_can_transition(1, 2)); // connected -> query_running + try std.testing.expectEqual(@as(c_int, 1), turso_mcp_can_transition(2, 1)); // query_running -> connected + try std.testing.expectEqual(@as(c_int, 1), turso_mcp_can_transition(1, 0)); // connected -> disconnected + try std.testing.expectEqual(@as(c_int, 1), turso_mcp_can_transition(2, 3)); // query_running -> error + try std.testing.expectEqual(@as(c_int, 1), turso_mcp_can_transition(3, 0)); // error -> disconnected + + // Invalid transitions + try std.testing.expectEqual(@as(c_int, 0), turso_mcp_can_transition(0, 2)); + try std.testing.expectEqual(@as(c_int, 0), turso_mcp_can_transition(1, 3)); + try std.testing.expectEqual(@as(c_int, 0), turso_mcp_can_transition(3, 1)); + try std.testing.expectEqual(@as(c_int, 0), turso_mcp_can_transition(99, 0)); +} + +test "slot exhaustion" { + turso_mcp_reset(); + + var slots: [MAX_SESSIONS]c_int = undefined; + for (&slots, 0..) |*s, i| { + _ = i; + s.* = turso_mcp_session_open(); + try std.testing.expect(s.* >= 0); + } + + // Next open should fail + try std.testing.expectEqual(@as(c_int, -1), turso_mcp_session_open()); + + // Free one and try again + try std.testing.expectEqual(@as(c_int, 0), turso_mcp_session_close(slots[0])); + const new_slot = turso_mcp_session_open(); + try std.testing.expect(new_slot >= 0); +} + +test "action requires connection" { + try std.testing.expectEqual(@as(c_int, 1), turso_mcp_action_requires_connection(6)); // query + try std.testing.expectEqual(@as(c_int, 1), turso_mcp_action_requires_connection(7)); // batch_query + try std.testing.expectEqual(@as(c_int, 0), turso_mcp_action_requires_connection(0)); // list_databases + try std.testing.expectEqual(@as(c_int, 0), turso_mcp_action_requires_connection(99)); // out of range +} + +// ═══════════════════════════════════════════════════════════════════════ +// ADR-0006 invoke dispatch tests +// ═══════════════════════════════════════════════════════════════════════ + +test "boj_cartridge_name returns turso-mcp" { + const n = std.mem.span(boj_cartridge_name()); + try std.testing.expectEqualStrings("turso-mcp", n); +} + +test "boj_cartridge_init returns 0" { + try std.testing.expectEqual(@as(c_int, 0), boj_cartridge_init()); +} + +test "invoke: each declared tool succeeds" { + var buf: [256]u8 = undefined; + const tools = [_][]const u8{ + "turso_connect", + "turso_query", + "turso_execute", + "turso_batch", + "turso_list_tables", + "turso_sync", + "turso_disconnect", + }; + for (tools) |t| { + var len: usize = buf.len; + const rc = boj_cartridge_invoke(t.ptr, "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, 0), rc); + try std.testing.expect(std.mem.indexOf(u8, buf[0..len], "result") != null); + } +} + +test "invoke: unknown tool returns -1" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("nope", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -1), rc); +} + +test "invoke: buffer too small returns -3" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("turso_connect", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -3), rc); + try std.testing.expect(len > 4); +} diff --git a/cartridges/domains/database/turso-mcp/minter.toml b/cartridges/domains/database/turso-mcp/minter.toml new file mode 100644 index 0000000..718f3c7 --- /dev/null +++ b/cartridges/domains/database/turso-mcp/minter.toml @@ -0,0 +1,16 @@ +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> + +name = "turso-mcp" +description = "Turso libSQL MCP cartridge β€” databases, groups, replicas, queries" +version = "0.1.0" +domain = "Database" +protocols = [ + "MCP", + "REST", +] +tier = "Ayo" +backend = "universal" +generate_panel = true +auth = "bearer" +api_base = "https://api.turso.tech/v1/" diff --git a/cartridges/domains/database/turso-mcp/mod.js b/cartridges/domains/database/turso-mcp/mod.js new file mode 100644 index 0000000..6a2352b --- /dev/null +++ b/cartridges/domains/database/turso-mcp/mod.js @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// turso-mcp/mod.js -- turso gateway. + +const BASE_URL = Deno.env.get("TURSO_MCP_BACKEND_URL") ?? "http://127.0.0.1:7726"; + +async function post(path, payload) { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), 15000); + try { + const r = await fetch(`${BASE_URL}${path}`, { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), signal: ctrl.signal, + }); + const data = await r.json().catch(() => ({ success: false, error: "non-JSON response" })); + return { status: r.status, data }; + } catch (e) { + if (e.name === "AbortError") return { status: 504, data: { success: false, error: "turso-mcp backend timed out" } }; + return { status: 503, data: { success: false, error: `turso-mcp backend unavailable: ${e.message}` } }; + } finally { clearTimeout(t); } +} + +export async function handleTool(toolName, args) { + switch (toolName) { + + case "turso_connect": { + const { url, auth_token } = args ?? {}; + if (!url) return { status: 400, data: { error: "url is required" } }; + const payload = { url }; + if (auth_token !== undefined) payload.auth_token = auth_token; + return post("/api/v1/connect", payload); + } + case "turso_query": { + const { slot, sql, params } = args ?? {}; + if (!slot || !sql) return { status: 400, data: { error: "slot is required" } }; + const payload = { slot, sql }; + if (params !== undefined) payload.params = params; + return post("/api/v1/query", payload); + } + case "turso_execute": { + const { slot, sql } = args ?? {}; + if (!slot || !sql) return { status: 400, data: { error: "slot is required" } }; + const payload = { slot, sql }; + return post("/api/v1/execute", payload); + } + case "turso_batch": { + const { slot, statements } = args ?? {}; + if (!slot || !statements) return { status: 400, data: { error: "slot is required" } }; + const payload = { slot, statements }; + return post("/api/v1/batch", payload); + } + case "turso_list_tables": { + const { slot } = args ?? {}; + if (!slot) return { status: 400, data: { error: "slot is required" } }; + const payload = { slot }; + return post("/api/v1/list-tables", payload); + } + case "turso_sync": { + const { slot } = args ?? {}; + if (!slot) return { status: 400, data: { error: "slot is required" } }; + const payload = { slot }; + return post("/api/v1/sync", payload); + } + case "turso_disconnect": { + const { slot } = args ?? {}; + if (!slot) return { status: 400, data: { error: "slot is required" } }; + const payload = { slot }; + return post("/api/v1/disconnect", payload); + } + + default: + return { status: 404, data: { error: `Unknown tool: ${toolName}` } }; + } +} diff --git a/cartridges/domains/database/turso-mcp/panels/manifest.json b/cartridges/domains/database/turso-mcp/panels/manifest.json new file mode 100644 index 0000000..579c4fd --- /dev/null +++ b/cartridges/domains/database/turso-mcp/panels/manifest.json @@ -0,0 +1,85 @@ +{ + "$schema": "https://panll.dev/schemas/panel-manifest/v1.json", + "spdx": "MPL-2.0", + "cartridge": "turso-mcp", + "domain": "Database", + "version": "0.1.0", + "panels": [ + { + "id": "turso-database-count", + "title": "Database Count", + "description": "Total number of Turso databases accessible via the configured API token", + "type": "metric", + "data_source": { + "endpoint": "/turso/databases", + "method": "GET", + "refresh_interval_ms": 30000 + }, + "widgets": [ + { + "type": "counter", + "field": "database_count", + "label": "Databases", + "icon": "database" + } + ] + }, + { + "id": "turso-group-count", + "title": "Group Count", + "description": "Total number of placement groups for database replication", + "type": "metric", + "data_source": { + "endpoint": "/turso/groups", + "method": "GET", + "refresh_interval_ms": 30000 + }, + "widgets": [ + { + "type": "counter", + "field": "group_count", + "label": "Groups", + "icon": "layers" + } + ] + }, + { + "id": "turso-replica-locations", + "title": "Replica Locations", + "description": "Number of edge replica locations for the current database group", + "type": "metric", + "data_source": { + "endpoint": "/turso/locations", + "method": "GET", + "refresh_interval_ms": 60000 + }, + "widgets": [ + { + "type": "counter", + "field": "location_count", + "label": "Locations", + "icon": "globe" + } + ] + }, + { + "id": "turso-query-count", + "title": "Query Count", + "description": "Total number of queries executed across all sessions", + "type": "metric", + "data_source": { + "endpoint": "/turso/metrics", + "method": "GET", + "refresh_interval_ms": 15000 + }, + "widgets": [ + { + "type": "counter", + "field": "total_query_count", + "label": "Queries", + "icon": "file-search" + } + ] + } + ] +} diff --git a/cartridges/domains/database/turso-mcp/tests/integration_test.sh b/cartridges/domains/database/turso-mcp/tests/integration_test.sh new file mode 100755 index 0000000..417aff8 --- /dev/null +++ b/cartridges/domains/database/turso-mcp/tests/integration_test.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Integration tests for turso-mcp cartridge. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CART_DIR="$(dirname "$SCRIPT_DIR")" +FFI_DIR="$CART_DIR/ffi" + +echo "=== turso-mcp integration tests ===" + +# Build FFI +echo "[1/3] Building FFI..." +cd "$FFI_DIR" && zig build 2>&1 + +# Run Zig unit tests +echo "[2/3] Running FFI unit tests..." +cd "$FFI_DIR" && zig build test 2>&1 + +# Validate Idris2 ABI (if idris2 available) +echo "[3/3] Checking ABI..." +if command -v idris2 &>/dev/null; then + cd "$CART_DIR/abi" && idris2 --check TursoMcp.SafeDatabase 2>&1 + echo " ABI: OK" +else + echo " ABI: SKIPPED (idris2 not in PATH)" +fi + +echo "" +echo "All tests passed for turso-mcp!" diff --git a/cartridges/domains/database/verisimdb-mcp/README.adoc b/cartridges/domains/database/verisimdb-mcp/README.adoc new file mode 100644 index 0000000..b1ae21b --- /dev/null +++ b/cartridges/domains/database/verisimdb-mcp/README.adoc @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += verisimdb-mcp +Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk> +:spdx: MPL-2.0 +:tier: Ayo +:domain: Database +:protocols: MCP, REST + +== Overview + +VeriSimDB verified simulation database. Stores octadic records with formal drift detection and full audit trail. + +== Tools (4) + +[cols="2,4"] +|=== +| Tool | Description + +| `verisimdb_store_octad` | Store an octadic record under a key. +| `verisimdb_get_octad` | Retrieve the current octadic record for a key. +| `verisimdb_detect_drift` | Compare a record against its baseline and return the count of drifted fields. +| `verisimdb_query_audit` | Query the audit log for a time range. +|=== + +== Architecture + +Three-layer stack per the BoJ cartridge standard: + +[cols="1,1,3"] +|=== +| Layer | Language | Purpose + +| ABI | Idris2 | Formally verified state machine with dependent-type proofs +| FFI | Zig | C-ABI implementation: session pool, action metrics, thread safety +| Adapter| Zig | Adapter bridge to the BoJ unified adapter protocol (zig predecessors sidelined) +|=== + +== Building + +[source,bash] +---- +# ABI (Idris2) +cd abi && idris2 --check *.idr + +# FFI (Zig) +cd ffi && zig build +cd ffi && zig build test + +# Adapter (Zig) +cd adapter && zig build +---- + +== Status + +Alpha (CRG D). Cartridge metadata declared in `cartridge.json`; 4 tool(s) exposed. +Not yet dogfooded externally. See `docs/READINESS.md` at repo root for the +project-wide grade table and `docs/practice/DOGFOOD-LOG.adoc` for usage evidence. diff --git a/cartridges/domains/database/verisimdb-mcp/abi/README.adoc b/cartridges/domains/database/verisimdb-mcp/abi/README.adoc new file mode 100644 index 0000000..4b0ba14 --- /dev/null +++ b/cartridges/domains/database/verisimdb-mcp/abi/README.adoc @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += verisimdb-mcp / abi β€” Idris2 ABI layer +:orientation: deep + +== Purpose + +Protocol types for the octadic provenance database. Pins the **"exactly 8 +fields"** property of an octad at the type level (`Vect 8 String`), carries +an audit-range ordering proof, and defines the drift-detection vocabulary +(`NoDrift` vs `DriftDetected Nat` β€” the natural number is the count of +drifted fields). + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `VeriSimDB/Protocol.idr` | Type alias `Octad = Vect 8 String`. Enums `VeriSimDBOp`, `DriftStatus` (`NoDrift`, `DriftDetected Nat`). Records `AuditQuery` (`fromTs`, `toTs`, `prf : LTE fromTs toTs`) and `StoredOctad` (hash, octad). Lemma `octadLength : length o = 8`. Helper `auditRangeValid`. +|=== + +No `.ipkg`. Consumed directly by wider VeriSim callers. + +== Invariants + +* **Octad arity** β€” `Octad = Vect 8 String`. `octadLength : length o = 8` + (lines 45–46) is the witness; nothing with fewer or more fields can be + an `Octad`. +* **Audit range ordering** β€” `AuditQuery.prf : LTE fromTs toTs` (line 32). + An inverted range cannot be constructed. +* `DriftDetected` carries the count as a `Nat` β€” drift without a count is + not representable. + +== Test/proof surface + +Thin module, no inline tests. Type-checks inside any consuming `.ipkg`. + +== Read-first + +. Lines 12–42 β€” data and record definitions (`Octad`, `DriftStatus`, + `AuditQuery`, `StoredOctad`). +. Lines 45–51 β€” the two lemmas that travel with this protocol. diff --git a/cartridges/domains/database/verisimdb-mcp/abi/VeriSimDB/Protocol.idr b/cartridges/domains/database/verisimdb-mcp/abi/VeriSimDB/Protocol.idr new file mode 100644 index 0000000..f7492d7 --- /dev/null +++ b/cartridges/domains/database/verisimdb-mcp/abi/VeriSimDB/Protocol.idr @@ -0,0 +1,51 @@ +-- SPDX-License-Identifier: MPL-2.0 +-- Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +-- +-- VeriSimDB ABI β€” provenance database protocol definitions. + +module VeriSimDB.Protocol + +import Data.Nat +import Data.Vect + +||| VeriSimDB operation codes. +public export +data VeriSimDBOp + = StoreOctad + | GetOctad + | DetectDrift + | QueryAudit + +||| An octad is exactly 8 provenance fields. +public export +Octad : Type +Octad = Vect 8 String + +||| Drift detection result. +public export +data DriftStatus = NoDrift | DriftDetected Nat + +||| Audit query with timestamp bounds. +public export +record AuditQuery where + constructor MkAuditQuery + fromTs : Nat + toTs : Nat + {auto prf : LTE fromTs toTs} + +||| Stored octad with a unique hash. +public export +record StoredOctad where + constructor MkStoredOctad + hash : String + octad : Octad + +||| Proof: an Octad always has exactly 8 elements. +export +octadLength : (o : Octad) -> length o = 8 +octadLength _ = Refl + +||| Proof: audit query time range is non-negative. +export +auditRangeValid : (q : AuditQuery) -> LTE q.fromTs q.toTs +auditRangeValid q = q.prf diff --git a/cartridges/domains/database/verisimdb-mcp/adapter/README.adoc b/cartridges/domains/database/verisimdb-mcp/adapter/README.adoc new file mode 100644 index 0000000..effeb81 --- /dev/null +++ b/cartridges/domains/database/verisimdb-mcp/adapter/README.adoc @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += verisimdb-mcp / adapter β€” REST/gRPC/GraphQL bridge +:orientation: deep + +== Purpose + +Exposes the (stub) verisimdb-mcp FFI over three protocols: REST on **9115**, +gRPC-compat on **9116**, GraphQL on **9117**. Stateless. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph. +| `verisimdb_adapter.zig` | Protocol dispatch. Tools: `verisimdb_store_octad`, `verisimdb_get_octad`, `verisimdb_detect_drift`, `verisimdb_query_audit`. +| `SIDELINED-verisimdb_adapter.v.adoc` | zig predecessor. Not built. +|=== + +== Invariants + +* **Stateless.** +* Responses pass through whatever the (stub) FFI returns β€” no embellishment. + +== Test/proof surface + +No inline tests. + +== Read-first + +. Lines 35–43 β€” `dispatch`. +. Lines 46–73 β€” protocol routers. diff --git a/cartridges/domains/database/verisimdb-mcp/adapter/build.zig b/cartridges/domains/database/verisimdb-mcp/adapter/build.zig new file mode 100644 index 0000000..2926fed --- /dev/null +++ b/cartridges/domains/database/verisimdb-mcp/adapter/build.zig @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// verisimdb-mcp/adapter/build.zig + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + const ffi_mod = b.createModule(.{ + .root_source_file = b.path("../ffi/verisimdb_ffi.zig"), + .target = target, .optimize = optimize, + }); + const adapter = b.addExecutable(.{ + .name = "verisimdb_adapter", + .root_source_file = b.path("verisimdb_adapter.zig"), + .target = target, .optimize = optimize, + }); + adapter.root_module.addImport("verisimdb_ffi", ffi_mod); + b.installArtifact(adapter); + const run_step = b.step("run", "Run the verisimdb-mcp adapter"); + run_step.dependOn(&b.addRunArtifact(adapter).step); + const tests = b.addTest(.{ + .root_source_file = b.path("verisimdb_adapter.zig"), + .target = target, .optimize = optimize, + }); + tests.root_module.addImport("verisimdb_ffi", ffi_mod); + const test_step = b.step("test", "Run verisimdb-mcp adapter tests"); + test_step.dependOn(&b.addRunArtifact(tests).step); +} diff --git a/cartridges/domains/database/verisimdb-mcp/adapter/verisimdb_adapter.zig b/cartridges/domains/database/verisimdb-mcp/adapter/verisimdb_adapter.zig new file mode 100644 index 0000000..b59d1f8 --- /dev/null +++ b/cartridges/domains/database/verisimdb-mcp/adapter/verisimdb_adapter.zig @@ -0,0 +1,121 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// verisimdb-mcp/adapter/verisimdb_adapter.zig -- Unified three-protocol adapter. +// +// Replaces the banned verisimdb_adapter.v (zig, removed 2026-04-12). +// +// REST :9115 gRPC-compat :9116 GraphQL :9117 +// VeriSimDB verified simulation database. Stores octadic records with formal drift detection and full +// Tools: verisimdb_store_octad, verisimdb_get_octad, verisimdb_detect_drift, verisimdb_query_audit + +const std = @import("std"); +const ffi = @import("verisimdb_ffi"); + +const REST_PORT: u16 = 9115; +const GRPC_PORT: u16 = 9116; +const GQL_PORT: u16 = 9117; +const MAX_CONN_BUF: usize = 16 * 1024; + +fn okJson(buf: []u8, msg: []const u8) []u8 { + const n = std.fmt.bufPrint(buf, "{{\"success\":true,\"message\":\"{s}\"}}", .{msg}) catch return buf[0..0]; + return n; +} +fn errJson(buf: []u8, msg: []const u8) []u8 { + const n = std.fmt.bufPrint(buf, "{{\"success\":false,\"error\":\"{s}\"}}", .{msg}) catch return buf[0..0]; + return n; +} +fn statusJson(buf: []u8) []u8 { + const n = std.fmt.bufPrint(buf, "{{\"success\":true,\"state\":\"ready\",\"service\":\"verisimdb-mcp\"}}", .{}) catch return buf[0..0]; + return n; +} + +const Response = struct { status: u16, body: []u8 }; + +fn dispatch(tool: []const u8, body: []const u8, resp: []u8) Response { + _ = body; + if (std.mem.eql(u8, tool, "verisimdb_store_octad")) return .{ .status = 200, .body = okJson(resp, "verisimdb_store_octad forwarded") }; + if (std.mem.eql(u8, tool, "verisimdb_get_octad")) return .{ .status = 200, .body = okJson(resp, "verisimdb_get_octad forwarded") }; + if (std.mem.eql(u8, tool, "verisimdb_detect_drift")) return .{ .status = 200, .body = okJson(resp, "verisimdb_detect_drift forwarded") }; + if (std.mem.eql(u8, tool, "verisimdb_query_audit")) return .{ .status = 200, .body = okJson(resp, "verisimdb_query_audit forwarded") }; + if (std.mem.eql(u8, tool, "status") or std.mem.eql(u8, tool, "health")) + return .{ .status = 200, .body = statusJson(resp) }; + return .{ .status = 404, .body = errJson(resp, "Unknown tool") }; +} + +fn dispatchRest(path: []const u8, body: []const u8, resp: []u8) Response { + if (std.mem.startsWith(u8, path, "/tools/")) return dispatch(path["/tools/".len..], body, resp); + if (std.mem.eql(u8, path, "/status") or std.mem.eql(u8, path, "/health")) return .{ .status = 200, .body = statusJson(resp) }; + return .{ .status = 404, .body = errJson(resp, "Not found") }; +} + +fn dispatchGrpc(path: []const u8, body: []const u8, resp: []u8) Response { + const prefix = "/Verisimdbservice/"; + if (!std.mem.startsWith(u8, path, prefix)) + return .{ .status = 404, .body = errJson(resp, "Not a recognized gRPC path") }; + const method = path[prefix.len..]; + const tool = blk: { + if (std.mem.eql(u8, method, "verisimdb_store_octad")) break :blk "verisimdb_store_octad"; + if (std.mem.eql(u8, method, "verisimdb_get_octad")) break :blk "verisimdb_get_octad"; + if (std.mem.eql(u8, method, "verisimdb_detect_drift")) break :blk "verisimdb_detect_drift"; + if (std.mem.eql(u8, method, "verisimdb_query_audit")) break :blk "verisimdb_query_audit"; + return .{ .status = 404, .body = errJson(resp, "Unknown gRPC method") }; + }; + return dispatch(tool, body, resp); +} + +fn dispatchGraphql(body: []const u8, resp: []u8) Response { + if (std.mem.indexOf(u8, body, "__schema") != null) return .{ .status = 200, .body = okJson(resp, "schema not supported") }; + if (std.mem.indexOf(u8, body, "store_octad") != null) return dispatch("verisimdb_store_octad", body, resp); + if (std.mem.indexOf(u8, body, "get_octad") != null) return dispatch("verisimdb_get_octad", body, resp); + if (std.mem.indexOf(u8, body, "detect_drift") != null) return dispatch("verisimdb_detect_drift", body, resp); + if (std.mem.indexOf(u8, body, "query_audit") != null) return dispatch("verisimdb_query_audit", body, resp); + return .{ .status = 200, .body = errJson(resp, "Unrecognised GraphQL operation") }; +} + +const Protocol = enum { rest, grpc, graphql }; + +fn handleConnection(conn: std.net.Server.Connection, proto: Protocol) void { + defer conn.stream.close(); + var in_buf: [MAX_CONN_BUF]u8 = undefined; + const n = conn.stream.read(&in_buf) catch return; + const req = in_buf[0..n]; + var path: []const u8 = "/"; + var body: []const u8 = ""; + if (n > 4) { + const line_end = std.mem.indexOf(u8, req, "\r\n") orelse req.len; + const first_line = req[0..line_end]; + const sp1 = std.mem.indexOfScalar(u8, first_line, ' ') orelse 0; + const rest_of = first_line[sp1 + 1 ..]; + const sp2 = std.mem.indexOfScalar(u8, rest_of, ' ') orelse rest_of.len; + path = rest_of[0..sp2]; + const body_sep = std.mem.indexOf(u8, req, "\r\n\r\n") orelse n; + body = req[@min(body_sep + 4, n)..]; + } + var resp_buf: [MAX_CONN_BUF]u8 = undefined; + const result = switch (proto) { + .rest => dispatchRest(path, body, &resp_buf), + .grpc => dispatchGrpc(path, body, &resp_buf), + .graphql => dispatchGraphql(body, &resp_buf), + }; + const ct: []const u8 = if (proto == .grpc) "application/grpc+json" else "application/json"; + var hdr_buf: [256]u8 = undefined; + const hdr = std.fmt.bufPrint(&hdr_buf, "HTTP/1.1 {d} OK\r\nContent-Type: {s}\r\nContent-Length: {d}\r\nConnection: close\r\n\r\n", .{ result.status, ct, result.body.len }) catch return; + _ = conn.stream.writeAll(hdr) catch return; + _ = conn.stream.writeAll(result.body) catch return; +} + +fn listenLoop(port: u16, proto: Protocol) void { + const addr = std.net.Address.initIp4(.{ 127, 0, 0, 1 }, port) catch return; + var server = addr.listen(.{ .reuse_address = true }) catch return; + defer server.deinit(); + while (true) { const conn = server.accept() catch continue; handleConnection(conn, proto); } +} + +pub fn main() !void { + ffi.verisimdb_init(); + const t1 = try std.Thread.spawn(.{}, listenLoop, .{ REST_PORT, Protocol.rest }); + const t2 = try std.Thread.spawn(.{}, listenLoop, .{ GRPC_PORT, Protocol.grpc }); + const t3 = try std.Thread.spawn(.{}, listenLoop, .{ GQL_PORT, Protocol.graphql }); + t1.join(); t2.join(); t3.join(); +} diff --git a/cartridges/domains/database/verisimdb-mcp/cartridge.json b/cartridges/domains/database/verisimdb-mcp/cartridge.json new file mode 100644 index 0000000..be225f4 --- /dev/null +++ b/cartridges/domains/database/verisimdb-mcp/cartridge.json @@ -0,0 +1,110 @@ +{ + "$schema": "https://boj.dev/schemas/cartridge/v1.json", + "spdx": "MPL-2.0", + "copyright": "Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath)", + "name": "verisimdb-mcp", + "version": "0.1.0", + "description": "VeriSimDB verified simulation database. Stores octadic records with formal drift detection and full audit trail.", + "domain": "Database", + "tier": "Ayo", + "protocols": [ + "MCP", + "REST" + ], + "auth": { + "method": "none", + "env_var": null, + "credential_source": null + }, + "api": { + "base_url": "local://verisimdb-mcp", + "content_type": "application/json" + }, + "tools": [ + { + "name": "verisimdb_store_octad", + "description": "Store an octadic record under a key.", + "inputSchema": { + "type": "object", + "properties": { + "key": { + "type": "string", + "description": "Record key" + }, + "data": { + "type": "string", + "description": "Record payload (JSON string or raw value)" + } + }, + "required": [ + "key", + "data" + ] + } + }, + { + "name": "verisimdb_get_octad", + "description": "Retrieve the current octadic record for a key.", + "inputSchema": { + "type": "object", + "properties": { + "key": { + "type": "string", + "description": "Record key" + } + }, + "required": [ + "key" + ] + } + }, + { + "name": "verisimdb_detect_drift", + "description": "Compare a record against its baseline and return the count of drifted fields.", + "inputSchema": { + "type": "object", + "properties": { + "key": { + "type": "string", + "description": "Record key to check for drift" + } + }, + "required": [ + "key" + ] + } + }, + { + "name": "verisimdb_query_audit", + "description": "Query the audit log for a time range.", + "inputSchema": { + "type": "object", + "properties": { + "from_ts": { + "type": "integer", + "description": "Start timestamp (Unix epoch seconds)" + }, + "to_ts": { + "type": "integer", + "description": "End timestamp (Unix epoch seconds)" + } + }, + "required": [ + "from_ts", + "to_ts" + ] + } + } + ], + "ffi": { + "so_path": "ffi/zig-out/lib/libverisimdb_mcp.so", + "abi_version": "ADR-0006", + "symbols": [ + "boj_cartridge_init", + "boj_cartridge_deinit", + "boj_cartridge_name", + "boj_cartridge_version", + "boj_cartridge_invoke" + ] + } +} diff --git a/cartridges/domains/database/verisimdb-mcp/ffi/README.adoc b/cartridges/domains/database/verisimdb-mcp/ffi/README.adoc new file mode 100644 index 0000000..109a274 --- /dev/null +++ b/cartridges/domains/database/verisimdb-mcp/ffi/README.adoc @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += verisimdb-mcp / ffi β€” Zig FFI layer (stubs) +:orientation: deep + +== Purpose + +**Stub** implementation of the VeriSimDB protocol. All four exports are +null-guarded shells: they validate inputs, return `0` / `-1`, and do no +actual storage. The real VeriSimDB engine lives in its own repository and +is expected to be plugged in behind this FFI once the cartridge graduates +out of stub status. + +The drift-count and audit-query signatures already match the ABI exactly, +so swapping in the real backend should be a body change, not a signature +change. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph. +| `verisimdb_ffi.zig` | C-ABI exports `verisimdb_store_octad` (returns 0 success / -1 null), `verisimdb_get_octad` (returns 0 found / -1 not found β€” stub always returns -1), `verisimdb_detect_drift` (returns `u32` drifted-field count β€” stub always returns 0), `verisimdb_query_audit` (validates `fromTs ≀ toTs`, returns entry count). +| `zig-out/` | Build artefacts (gitignored). +|=== + +== Invariants + +* **Null-pointer rejection** on key/data (line 10), key (line 16). +* **Audit range validity** β€” `verisimdb_query_audit` rejects `fromTs > toTs` + (line 28), matching the ABI's `LTE fromTs toTs` proof. +* **No persistence**. Nothing here survives a process restart. + +== Test/proof surface + +3 inline `test "..."` blocks (lines 34–44): + +* store rejects null key +* get rejects null key +* audit rejects an inverted range + +Run with `cd ffi && zig build test`. + +== Read-first + +. Lines 9–30 β€” function signatures and guards. +. Lines 34–44 β€” guard-behaviour tests. + +== Promotion gate + +Wire the exports to a real VeriSimDB engine (octadic storage, drift +detection, audit log) before promoting this component past D. diff --git a/cartridges/domains/database/verisimdb-mcp/ffi/build.zig b/cartridges/domains/database/verisimdb-mcp/ffi/build.zig new file mode 100644 index 0000000..dbf915b --- /dev/null +++ b/cartridges/domains/database/verisimdb-mcp/ffi/build.zig @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + // Shared ADR-0006 invoke-shim module (relative path up to boj-server trunk). + + const shim_mod = b.addModule("cartridge_shim", .{ + + .root_source_file = b.path("../../../ffi/zig/src/cartridge_shim.zig"), + + .target = target, + + .optimize = optimize, + + }); + + const ffi_mod = b.addModule("verisimdb_mcp", .{ + .root_source_file = b.path("verisimdb_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + ffi_mod.addImport("cartridge_shim", shim_mod); + + const lib = b.addLibrary(.{ + .name = "verisimdb_mcp", + .root_module = ffi_mod, + .linkage = .dynamic, + }); + lib.linkLibC(); + b.installArtifact(lib); + + const tests = b.addTest(.{ .root_module = ffi_mod }); + tests.linkLibC(); + const run_tests = b.addRunArtifact(tests); + const test_step = b.step("test", "Run FFI tests"); + test_step.dependOn(&run_tests.step); +} + diff --git a/cartridges/domains/database/verisimdb-mcp/ffi/cartridge_shim.zig b/cartridges/domains/database/verisimdb-mcp/ffi/cartridge_shim.zig new file mode 100644 index 0000000..0e399db --- /dev/null +++ b/cartridges/domains/database/verisimdb-mcp/ffi/cartridge_shim.zig @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// cartridge_shim.zig β€” Shared helpers for the ADR-0006 five-symbol +// cartridge ABI (`boj_cartridge_init / deinit / name / version / invoke`). +// +// The shim centralises the seven-code return convention, NUL-argument +// guards, tool-name comparison, and the buffer-too-small path so each +// cartridge's `boj_cartridge_invoke` can stay short β€” typically a tool +// table plus `shim.writeResult(...)`. +// +// Cartridges import this file by relative path (no build-graph change +// needed). Example: +// +// const shim = @import("../../../ffi/zig/src/cartridge_shim.zig"); +// +// export fn boj_cartridge_invoke( +// tool_name: [*c]const u8, +// json_args: [*c]const u8, +// out_buf: [*c]u8, +// in_out_len: [*c]usize, +// ) callconv(.c) i32 { +// _ = json_args; +// if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; +// const body = if (shim.toolIs(tool_name, "foo")) "{\"result\":{}}" +// else return shim.RC_UNKNOWN_TOOL; +// return shim.writeResult(out_buf, in_out_len, body); +// } + +const std = @import("std"); + +// ── Return codes (ADR-0006 Β§Return codes) ──────────────────────────── +// +// Frozen by ADR-0006. New failure modes compose these via the error +// JSON body β€” the integer surface does not grow without a follow-up ADR. + +pub const RC_SUCCESS: i32 = 0; +pub const RC_UNKNOWN_TOOL: i32 = -1; +pub const RC_BAD_ARGS: i32 = -2; +pub const RC_BUFFER_TOO_SMALL: i32 = -3; +pub const RC_RUNTIME_ERROR: i32 = -4; +pub const RC_PANIC: i32 = -5; +pub const RC_AUTH_DENIED: i32 = -6; + +// ── Invoke-path helpers ────────────────────────────────────────────── + +/// True if any of the three mandatory `boj_cartridge_invoke` output-path +/// pointers is null. Use at the top of every invoke to short-circuit to +/// `RC_BAD_ARGS`. +pub fn invokeArgsNull( + tool_name: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) bool { + return tool_name == null or out_buf == null or in_out_len == null; +} + +/// Compare a C-NUL-terminated tool-name pointer against a Zig string +/// literal. Caller must have already verified `tool_name` is non-null +/// (usually via `invokeArgsNull`). +pub fn toolIs(tool_name: [*c]const u8, expected: []const u8) bool { + const s = std.mem.span(@as([*:0]const u8, @ptrCast(tool_name))); + return std.mem.eql(u8, s, expected); +} + +/// Copy `body` into `out_buf[0..*in_out_len]` (as a capacity) and update +/// `*in_out_len` to the number of bytes written. Returns `RC_SUCCESS`. +/// +/// If `body.len` exceeds the current capacity stored in `*in_out_len`, +/// sets `*in_out_len` to the required size and returns +/// `RC_BUFFER_TOO_SMALL` β€” the caller is then expected to re-allocate +/// and retry, per ADR-0006 Β§Memory ownership. +/// +/// Caller must have already verified that `out_buf` and `in_out_len` +/// are non-null. +pub fn writeResult( + out_buf: [*c]u8, + in_out_len: [*c]usize, + body: []const u8, +) i32 { + const cap = in_out_len.*; + if (body.len > cap) { + in_out_len.* = body.len; + return RC_BUFFER_TOO_SMALL; + } + @memcpy(out_buf[0..body.len], body); + in_out_len.* = body.len; + return RC_SUCCESS; +} + +// ── Tests ──────────────────────────────────────────────────────────── + +test "writeResult: body fits, writes and sets length" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); + try std.testing.expectEqualStrings("hello", buf[0..len]); +} + +test "writeResult: too small returns -3 and sets required length" { + var buf: [2]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_BUFFER_TOO_SMALL, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: exact-fit succeeds" { + var buf: [5]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: empty body" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, ""); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 0), len); +} + +test "toolIs: matches and rejects" { + const name: [*:0]const u8 = "foo"; + try std.testing.expect(toolIs(@ptrCast(name), "foo")); + try std.testing.expect(!toolIs(@ptrCast(name), "bar")); + try std.testing.expect(!toolIs(@ptrCast(name), "foobar")); + try std.testing.expect(!toolIs(@ptrCast(name), "fo")); +} + +test "invokeArgsNull: detects each null slot" { + var buf: [4]u8 = undefined; + var len: usize = 4; + const name: [*:0]const u8 = "x"; + try std.testing.expect(!invokeArgsNull(@ptrCast(name), &buf, &len)); + try std.testing.expect(invokeArgsNull(null, &buf, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), null, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), &buf, null)); +} diff --git a/cartridges/domains/database/verisimdb-mcp/ffi/verisimdb_ffi.zig b/cartridges/domains/database/verisimdb-mcp/ffi/verisimdb_ffi.zig new file mode 100644 index 0000000..fd61e14 --- /dev/null +++ b/cartridges/domains/database/verisimdb-mcp/ffi/verisimdb_ffi.zig @@ -0,0 +1,135 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +// +// VeriSimDB FFI β€” C-compatible exports for provenance database operations. + +const std = @import("std"); + +/// Store an octad. Returns 0 on success, -1 on error. +export fn verisimdb_store_octad(key: [*c]const u8, data: [*c]const u8) i32 { + if (key == null or data == null) return -1; + return 0; // Stub +} + +/// Get an octad by key. Returns 0 on found, -1 on not found. +export fn verisimdb_get_octad(key: [*c]const u8) i32 { + if (key == null) return -1; + return 0; // Stub +} + +/// Detect drift. Returns number of drifted fields (0 = no drift). +export fn verisimdb_detect_drift(key: [*c]const u8) u32 { + if (key == null) return 0; + return 0; // Stub +} + +/// Query audit log. Returns number of matching entries. +export fn verisimdb_query_audit(from_ts: u64, to_ts: u64) u32 { + if (to_ts < from_ts) return 0; + return 0; // Stub +} + +// ═══════════════════════════════════════════════════════════════════════ +// Standard ABI (ADR-0005 four symbols + ADR-0006 invoke) +// ═══════════════════════════════════════════════════════════════════════ + +const shim = @import("cartridge_shim.zig"); + +const CARTRIDGE_NAME_PTR: [*:0]const u8 = "verisimdb-mcp"; +const CARTRIDGE_VERSION_PTR: [*:0]const u8 = "0.1.0"; + +export fn boj_cartridge_init() callconv(.c) c_int { + return 0; +} + +export fn boj_cartridge_deinit() callconv(.c) void {} + +export fn boj_cartridge_name() callconv(.c) [*:0]const u8 { + return CARTRIDGE_NAME_PTR; +} + +export fn boj_cartridge_version() callconv(.c) [*:0]const u8 { + return CARTRIDGE_VERSION_PTR; +} + +/// Dispatch the cartridge.json MCP tools. Grade D Alpha stubs. +export fn boj_cartridge_invoke( + tool_name: [*c]const u8, + json_args: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) callconv(.c) i32 { + _ = json_args; + if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; + + const body: []const u8 = if (shim.toolIs(tool_name, "verisimdb_store_octad")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "verisimdb_get_octad")) + "{\"result\":{\"metadata\":{},\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "verisimdb_detect_drift")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "verisimdb_query_audit")) + "{\"result\":{\"matches\":[],\"status\":\"stub\"}}" +else + return shim.RC_UNKNOWN_TOOL; + + return shim.writeResult(out_buf, in_out_len, body); +} + +// ── Tests ── + +test "store rejects null key" { + try std.testing.expectEqual(@as(i32, -1), verisimdb_store_octad(null, "data")); +} + +test "get rejects null key" { + try std.testing.expectEqual(@as(i32, -1), verisimdb_get_octad(null)); +} + +test "audit rejects inverted range" { + try std.testing.expectEqual(@as(u32, 0), verisimdb_query_audit(100, 50)); +} + +// ═══════════════════════════════════════════════════════════════════════ +// ADR-0006 invoke dispatch tests +// ═══════════════════════════════════════════════════════════════════════ + +test "boj_cartridge_name returns verisimdb-mcp" { + const n = std.mem.span(boj_cartridge_name()); + try std.testing.expectEqualStrings("verisimdb-mcp", n); +} + +test "boj_cartridge_init returns 0" { + try std.testing.expectEqual(@as(c_int, 0), boj_cartridge_init()); +} + +test "invoke: each declared tool succeeds" { + var buf: [256]u8 = undefined; + const tools = [_][]const u8{ + "verisimdb_store_octad", + "verisimdb_get_octad", + "verisimdb_detect_drift", + "verisimdb_query_audit", + }; + for (tools) |t| { + var len: usize = buf.len; + const rc = boj_cartridge_invoke(t.ptr, "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, 0), rc); + try std.testing.expect(std.mem.indexOf(u8, buf[0..len], "result") != null); + } +} + +test "invoke: unknown tool returns -1" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("nope", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -1), rc); +} + +test "invoke: buffer too small returns -3" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("verisimdb_store_octad", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -3), rc); + try std.testing.expect(len > 4); +} diff --git a/cartridges/domains/database/verisimdb-mcp/mod.js b/cartridges/domains/database/verisimdb-mcp/mod.js new file mode 100644 index 0000000..52924a6 --- /dev/null +++ b/cartridges/domains/database/verisimdb-mcp/mod.js @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// verisimdb-mcp/mod.js -- verisimdb gateway + +const BASE_URL = Deno.env.get("VERISIMDB_BACKEND_URL") ?? "http://127.0.0.1:7705"; + +async function post(path, payload) { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), 15000); + try { + const r = await fetch(`${BASE_URL}${path}`, { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), signal: ctrl.signal, + }); + const data = await r.json().catch(() => ({ success: false, error: "non-JSON response" })); + return { status: r.status, data }; + } catch (e) { + if (e.name === "AbortError") return { status: 504, data: { success: false, error: "verisimdb-mcp backend timed out" } }; + return { status: 503, data: { success: false, error: `verisimdb-mcp backend unavailable: ${e.message}` } }; + } finally { clearTimeout(t); } +} + +export async function handleTool(toolName, args) { + switch (toolName) { + case "verisimdb_store_octad": { + const { key, data } = args ?? {}; + if (!key || !data) return { status: 400, data: { error: "key is required" } }; + const payload = { key, data }; + return post("/api/v1/store-octad", payload); + } + + case "verisimdb_get_octad": { + const { key } = args ?? {}; + if (!key) return { status: 400, data: { error: "key is required" } }; + const payload = { key }; + return post("/api/v1/get-octad", payload); + } + + case "verisimdb_detect_drift": { + const { key } = args ?? {}; + if (!key) return { status: 400, data: { error: "key is required" } }; + const payload = { key }; + return post("/api/v1/detect-drift", payload); + } + + case "verisimdb_query_audit": { + const { from_ts, to_ts } = args ?? {}; + if (!from_ts || !to_ts) return { status: 400, data: { error: "from_ts is required" } }; + const payload = { from_ts, to_ts }; + return post("/api/v1/query-audit", payload); + } + default: + return { status: 404, data: { error: `Unknown tool: ${toolName}` } }; + } +} diff --git a/cartridges/domains/database/verisimdb-mcp/panels/manifest.json b/cartridges/domains/database/verisimdb-mcp/panels/manifest.json new file mode 100644 index 0000000..a0cd979 --- /dev/null +++ b/cartridges/domains/database/verisimdb-mcp/panels/manifest.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://panll.dev/schemas/panel-manifest/v1.json", + "spdx": "MPL-2.0", + "cartridge": "verisimdb-mcp", + "domain": "provenance", + "version": "0.1.0", + "panels": [ + { + "id": "verisimdb-drift-dashboard", + "title": "Drift Dashboard", + "description": "Real-time provenance drift detection with field-level diff and audit timeline.", + "type": "dashboard", + "entrypoint": "panels/verisimdb-drift-dashboard.js", + "size": { "cols": 4, "rows": 3 }, + "refresh_interval_ms": 10000, + "data_sources": [ + { "op": "DetectDrift", "interval_ms": 10000 }, + { "op": "QueryAudit", "interval_ms": 30000 } + ] + } + ] +} diff --git a/cartridges/domains/development/codeseeker-mcp/README.adoc b/cartridges/domains/development/codeseeker-mcp/README.adoc new file mode 100644 index 0000000..d520b2d --- /dev/null +++ b/cartridges/domains/development/codeseeker-mcp/README.adoc @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += codeseeker-mcp +Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk> +:spdx: MPL-2.0 +:tier: Ayo +:domain: development +:protocols: MCP, REST + +== Overview + +CodeSeeker hybrid code search and graph RAG + +== Tools (5) + +[cols="2,4"] +|=== +| Tool | Description + +| `codeseeker_open` | Open a CodeSeeker session on a project path +| `codeseeker_close` | Close a CodeSeeker session +| `codeseeker_index` | Index a project for search +| `codeseeker_query` | Search code with hybrid text+vector+graph +| `codeseeker_state` | Get session state +|=== + +== Architecture + +Three-layer stack per the BoJ cartridge standard: + +[cols="1,1,3"] +|=== +| Layer | Language | Purpose + +| ABI | Idris2 | Formally verified state machine with dependent-type proofs +| FFI | Zig | C-ABI implementation: session pool, action metrics, thread safety +| Adapter| Zig | Adapter bridge to the BoJ unified adapter protocol (zig predecessors sidelined) +|=== + +== Building + +[source,bash] +---- +# ABI (Idris2) +cd abi && idris2 --check *.idr + +# FFI (Zig) +cd ffi && zig build +cd ffi && zig build test + +# Adapter (Zig) +cd adapter && zig build +---- + +== Status + +Alpha (CRG D). Cartridge metadata declared in `cartridge.json`; 5 tool(s) exposed. +Not yet dogfooded externally. See `docs/READINESS.md` at repo root for the +project-wide grade table and `docs/practice/DOGFOOD-LOG.adoc` for usage evidence. diff --git a/cartridges/domains/development/codeseeker-mcp/abi/CodeseekerMcp/SearchGraph.idr b/cartridges/domains/development/codeseeker-mcp/abi/CodeseekerMcp/SearchGraph.idr new file mode 100644 index 0000000..85963de --- /dev/null +++ b/cartridges/domains/development/codeseeker-mcp/abi/CodeseekerMcp/SearchGraph.idr @@ -0,0 +1,238 @@ +-- SPDX-License-Identifier: MPL-2.0 +-- Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +||| CodeseekerMcp.SearchGraph: Formally verified code intelligence operations. +||| +||| Cartridge: codeseeker-mcp +||| Matrix cell: Code Intelligence domain x {MCP, REST} protocols +||| +||| This module defines type-safe interfaces for CodeSeeker's local code +||| intelligence capabilities: +||| - Hybrid search (vector + text + path, fused via Reciprocal Rank Fusion) +||| - Knowledge graph traversal (imports, calls, extends, implements) +||| - Auto-detected pattern retrieval +||| - Graph RAG context retrieval +||| +||| State machine prevents: +||| - Searches before a codebase is indexed +||| - Graph traversal on uninitialised indices +||| - Concurrent index operations on the same path +||| +||| CodeSeeker stores all data locally in .codeseeker/ β€” no external services. +module CodeseekerMcp.SearchGraph + +import Data.List + +%default total + +-- ═══════════════════════════════════════════════════════════════════════════ +-- Indexer State Machine +-- ═══════════════════════════════════════════════════════════════════════════ + +||| Lifecycle states for a CodeSeeker index session. +||| Uninitialised: No index loaded for this path. +||| Indexing: Index is being built/updated (blocks queries). +||| Ready: Index is available for search and graph traversal. +||| Querying: A search or graph traversal is in flight. +||| IndexError: The indexer encountered an unrecoverable error. +public export +data IndexState + = Uninitialised + | Indexing + | Ready + | Querying + | IndexError + +||| Equality for index states. +public export +Eq IndexState where + Uninitialised == Uninitialised = True + Indexing == Indexing = True + Ready == Ready = True + Querying == Querying = True + IndexError == IndexError = True + _ == _ = False + +||| Valid state transitions (enforced at the type level). +||| Uninitialised -> Indexing : start an index build +||| Indexing -> Ready : index build succeeded +||| Indexing -> IndexError: index build failed +||| Ready -> Querying : begin a search or traversal +||| Querying -> Ready : query completed +||| Querying -> IndexError: query failed +||| IndexError -> Uninitialised : reset to try again +public export +data ValidIndexTransition : IndexState -> IndexState -> Type where + StartIndex : ValidIndexTransition Uninitialised Indexing + IndexComplete : ValidIndexTransition Indexing Ready + IndexFail : ValidIndexTransition Indexing IndexError + BeginQuery : ValidIndexTransition Ready Querying + QueryDone : ValidIndexTransition Querying Ready + QueryFail : ValidIndexTransition Querying IndexError + ResetError : ValidIndexTransition IndexError Uninitialised + +||| Runtime transition validator. +public export +canIndexTransition : IndexState -> IndexState -> Bool +canIndexTransition Uninitialised Indexing = True +canIndexTransition Indexing Ready = True +canIndexTransition Indexing IndexError = True +canIndexTransition Ready Querying = True +canIndexTransition Querying Ready = True +canIndexTransition Querying IndexError = True +canIndexTransition IndexError Uninitialised = True +canIndexTransition _ _ = False + +-- ═══════════════════════════════════════════════════════════════════════════ +-- Search Mode +-- ═══════════════════════════════════════════════════════════════════════════ + +||| Search strategies available in CodeSeeker. +||| Vector: Semantic similarity via embeddings. +||| Text: Literal/regex text matching. +||| Path: File path pattern matching. +||| Hybrid: All three fused with Reciprocal Rank Fusion. +public export +data SearchMode + = Vector -- Semantic embedding similarity + | Text -- Literal / regex text search + | Path -- File path pattern match + | Hybrid -- RRF fusion of all three + +||| C-ABI encoding for search modes. +public export +searchModeToInt : SearchMode -> Int +searchModeToInt Vector = 1 +searchModeToInt Text = 2 +searchModeToInt Path = 3 +searchModeToInt Hybrid = 4 + +||| C-ABI decoding for search modes. +public export +intToSearchMode : Int -> Maybe SearchMode +intToSearchMode 1 = Just Vector +intToSearchMode 2 = Just Text +intToSearchMode 3 = Just Path +intToSearchMode 4 = Just Hybrid +intToSearchMode _ = Nothing + +-- ═══════════════════════════════════════════════════════════════════════════ +-- Knowledge Graph Relationship Types +-- ═══════════════════════════════════════════════════════════════════════════ + +||| Edge types in CodeSeeker's knowledge graph. +||| These correspond to structural relationships in source code. +public export +data GraphRelation + = Imports -- Module/file imports another + | Calls -- Function/method calls another + | Extends -- Class extends a base class + | Implements -- Class implements an interface + | Uses -- Generic usage / reference relationship + +||| C-ABI encoding for graph relations. +public export +relationToInt : GraphRelation -> Int +relationToInt Imports = 1 +relationToInt Calls = 2 +relationToInt Extends = 3 +relationToInt Implements = 4 +relationToInt Uses = 5 + +||| C-ABI decoding for graph relations. +public export +intToRelation : Int -> Maybe GraphRelation +intToRelation 1 = Just Imports +intToRelation 2 = Just Calls +intToRelation 3 = Just Extends +intToRelation 4 = Just Implements +intToRelation 5 = Just Uses +intToRelation _ = Nothing + +-- ═══════════════════════════════════════════════════════════════════════════ +-- Index Session Record +-- ═══════════════════════════════════════════════════════════════════════════ + +||| An active CodeSeeker index session for a codebase path. +public export +record IndexSession where + constructor MkIndexSession + sessionId : String -- Unique session identifier + codebasePath : String -- Absolute path to the indexed codebase + state : IndexState + fileCount : Nat -- Number of files in the index (0 if not Ready) + +||| Proof that an index session is ready for querying. +public export +data IsReady : IndexSession -> Type where + IndexReady : (s : IndexSession) -> + (state s = Ready) -> + IsReady s + +-- ═══════════════════════════════════════════════════════════════════════════ +-- MCP Tool Definitions +-- ═══════════════════════════════════════════════════════════════════════════ + +||| MCP tools exposed by the codeseeker-mcp cartridge. +public export +data McpTool + = ToolIndex -- Index a codebase at a given path + | ToolSearch -- Hybrid search (vector + text + path) + | ToolTraverse -- Traverse knowledge graph from a symbol + | ToolPatterns -- Retrieve auto-detected coding patterns + | ToolGraphRag -- Graph RAG context for a query + | ToolStatus -- Session and index status + +||| MCP tool name (for JSON-RPC method name). +public export +toolName : McpTool -> String +toolName ToolIndex = "codeseeker/index" +toolName ToolSearch = "codeseeker/search" +toolName ToolTraverse = "codeseeker/traverse" +toolName ToolPatterns = "codeseeker/patterns" +toolName ToolGraphRag = "codeseeker/graph-rag" +toolName ToolStatus = "codeseeker/status" + +||| Which tools require a Ready index (cannot run during Indexing). +public export +requiresReadyIndex : McpTool -> Bool +requiresReadyIndex ToolIndex = False +requiresReadyIndex ToolStatus = False +requiresReadyIndex _ = True + +-- ═══════════════════════════════════════════════════════════════════════════ +-- C-ABI Exports +-- ═══════════════════════════════════════════════════════════════════════════ + +||| Index state to integer. +public export +indexStateToInt : IndexState -> Int +indexStateToInt Uninitialised = 0 +indexStateToInt Indexing = 1 +indexStateToInt Ready = 2 +indexStateToInt Querying = 3 +indexStateToInt IndexError = 4 + +||| FFI: Validate an index state transition. +export +codeseeker_can_transition : Int -> Int -> Int +codeseeker_can_transition from to = + let fromState = case from of + 0 => Uninitialised + 1 => Indexing + 2 => Ready + 3 => Querying + _ => IndexError + toState = case to of + 0 => Uninitialised + 1 => Indexing + 2 => Ready + 3 => Querying + _ => IndexError + in if canIndexTransition fromState toState then 1 else 0 + +||| FFI: Check if a tool requires a ready index. +export +codeseeker_tool_requires_ready : Int -> Int +codeseeker_tool_requires_ready 1 = 0 -- ToolIndex +codeseeker_tool_requires_ready 6 = 0 -- ToolStatus +codeseeker_tool_requires_ready _ = 1 -- All others require Ready diff --git a/cartridges/domains/development/codeseeker-mcp/abi/README.adoc b/cartridges/domains/development/codeseeker-mcp/abi/README.adoc new file mode 100644 index 0000000..cbb1864 --- /dev/null +++ b/cartridges/domains/development/codeseeker-mcp/abi/README.adoc @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += codeseeker-mcp / abi β€” Idris2 ABI layer +:orientation: shallow + +== Purpose + +Cartridge-local protocol types for `codeseeker-mcp`. Defines the data, tool, and +transition vocabulary this cartridge speaks. Inherits safety lemmas and the +matrix-cell identifier scheme from the trunk ABI at `src/abi/`. + +== Files + +1 Idris2 module(s), ~238 lines total. Lead module: +`SearchGraph.idr`. + +No `.ipkg` β€” consumed directly by wider BoJ callers; type-checks inside the +trunk `boj.ipkg`. + +== Invariants + +* `%default total` at every module top. +* Zero `believe_me`, `assert_total`, `postulate`, `Admitted`, or + `sorry` (the trunk holds the four declared `believe_me` sites; leaf + cartridges carry none). + +== Test/proof surface + +Thin module(s), no inline tests. Proof surface = type-check success under the +trunk `idris2 --check`. + +== Read-first + +. `SearchGraph.idr` β€” cartridge-local data records and enums. +. Trunk `src/abi/Boj/Catalogue.idr` for how this cell participates in the + 2D matrix. diff --git a/cartridges/domains/development/codeseeker-mcp/abi/codeseeker-mcp.ipkg b/cartridges/domains/development/codeseeker-mcp/abi/codeseeker-mcp.ipkg new file mode 100644 index 0000000..bbeb0c0 --- /dev/null +++ b/cartridges/domains/development/codeseeker-mcp/abi/codeseeker-mcp.ipkg @@ -0,0 +1,11 @@ +-- SPDX-License-Identifier: MPL-2.0 +package codeseekerMcp + +authors = "Jonathan D.A. Jewell" +version = 0.1.0 +license = "MPL-2.0" +brief = "CodeSeeker MCP cartridge β€” hybrid code search and knowledge graph traversal with index state machine safety" + +sourcedir = "." +modules = CodeseekerMcp.SearchGraph +depends = base, contrib diff --git a/cartridges/domains/development/codeseeker-mcp/adapter/README.adoc b/cartridges/domains/development/codeseeker-mcp/adapter/README.adoc new file mode 100644 index 0000000..0715bce --- /dev/null +++ b/cartridges/domains/development/codeseeker-mcp/adapter/README.adoc @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += codeseeker-mcp / adapter β€” protocol bridge +:orientation: shallow + +== Purpose + +Exposes this cartridge's FFI over the wire protocols declared in +`cartridge.json`. Dispatches tool invocations to the C-ABI exports at +`../ffi/` and formats responses per protocol. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” depends on the sibling `ffi` target. +| `codeseeker_adapter.zig` | Protocol dispatch (134 lines). Tool catalogue matches `cartridge.json`. +| `SIDELINED-*.v.adoc` | zig predecessor(s). Not built β€” zig banned 2026-04-10. +|=== + +== Invariants + +* **Stateless** β€” all state lives behind the FFI, never in the adapter. +* **Response passthrough** β€” whatever the FFI returns goes back to the wire + unmodified (no embellishment, no silent recovery). +* **Cartridge.json is source of truth** for the tool catalogue; drift between + adapter and manifest is a CI failure. + +== Test/proof surface + +No inline tests at the adapter layer β€” protocol routing is exercised by the +trunk seam tests at `ffi/zig/src/seams.zig`. + +== Read-first + +. `codeseeker_adapter.zig` β€” dispatch function and per-protocol routers. +. `../cartridge.json` β€” the tool manifest the dispatcher mirrors. diff --git a/cartridges/domains/development/codeseeker-mcp/adapter/build.zig b/cartridges/domains/development/codeseeker-mcp/adapter/build.zig new file mode 100644 index 0000000..a43d32a --- /dev/null +++ b/cartridges/domains/development/codeseeker-mcp/adapter/build.zig @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// codeseeker-mcp/adapter/build.zig + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const ffi_mod = b.createModule(.{ + .root_source_file = b.path("../ffi/codeseeker_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + const adapter = b.addExecutable(.{ + .name = "codeseeker_adapter", + .root_source_file = b.path("codeseeker_adapter.zig"), + .target = target, + .optimize = optimize, + }); + adapter.root_module.addImport("codeseeker_ffi", ffi_mod); + b.installArtifact(adapter); +} diff --git a/cartridges/domains/development/codeseeker-mcp/adapter/codeseeker_adapter.zig b/cartridges/domains/development/codeseeker-mcp/adapter/codeseeker_adapter.zig new file mode 100644 index 0000000..a50809e --- /dev/null +++ b/cartridges/domains/development/codeseeker-mcp/adapter/codeseeker_adapter.zig @@ -0,0 +1,134 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// codeseeker-mcp/adapter/codeseeker_adapter.zig +// +// Three-protocol BoJ adapter: REST (port 9214), gRPC-compat (port 9215), +// GraphQL (port 9216). +// Replaces the banned zig adapter (codeseeker_adapter.v). + +const std = @import("std"); +const ffi = @import("codeseeker_ffi"); + +const REST_PORT: u16 = 9214; +const GRPC_PORT: u16 = 9215; +const GQL_PORT: u16 = 9216; + +const Response = struct { status: u16, body: []const u8 }; + +fn okJson(buf: []u8, msg: []const u8) []u8 { + return std.fmt.bufPrint(buf, "{{\"success\":true,\"message\":\"{s}\"}}", .{msg}) catch buf[0..0]; +} + +fn errJson(buf: []u8, msg: []const u8) []u8 { + return std.fmt.bufPrint(buf, "{{\"success\":false,\"error\":\"{s}\"}}", .{msg}) catch buf[0..0]; +} + +fn dispatch(tool: []const u8, _body: []const u8, resp: []u8) Response { + _ = _body; + if (std.mem.eql(u8, tool, "codeseeker_open")) { + return .{ .status = 200, .body = okJson(resp, "codeseeker_open forwarded") }; + } + if (std.mem.eql(u8, tool, "codeseeker_close")) { + return .{ .status = 200, .body = okJson(resp, "codeseeker_close forwarded") }; + } + if (std.mem.eql(u8, tool, "codeseeker_index")) { + return .{ .status = 200, .body = okJson(resp, "codeseeker_index forwarded") }; + } + if (std.mem.eql(u8, tool, "codeseeker_query")) { + return .{ .status = 200, .body = okJson(resp, "codeseeker_query forwarded") }; + } + if (std.mem.eql(u8, tool, "codeseeker_state")) { + return .{ .status = 200, .body = okJson(resp, "codeseeker_state forwarded") }; + } + return .{ .status = 404, .body = errJson(resp, "unknown tool") }; +} + +fn dispatchRest(path: []const u8, body: []const u8, resp: []u8) Response { + // Expect /tools/<tool_name> + const prefix = "/tools/"; + if (std.mem.startsWith(u8, path, prefix)) { + const tool = path[prefix.len..]; + return dispatch(tool, body, resp); + } + return .{ .status = 404, .body = errJson(resp, "not found") }; +} + +fn dispatchGrpc(path: []const u8, body: []const u8, resp: []u8) Response { + // Expect /<Service>/<Method> β€” derive tool from Method + var it = std.mem.splitScalar(u8, path, '/'); + _ = it.next(); // leading empty + _ = it.next(); // service + const method = it.next() orelse return .{ .status = 404, .body = errJson(resp, "bad gRPC path") }; + return dispatch(method, body, resp); +} + +fn dispatchGraphql(body: []const u8, resp: []u8) Response { + if (std.mem.indexOf(u8, body, "codeseeker_open") != null) + return dispatch("codeseeker_open", body, resp); + if (std.mem.indexOf(u8, body, "codeseeker_close") != null) + return dispatch("codeseeker_close", body, resp); + if (std.mem.indexOf(u8, body, "codeseeker_index") != null) + return dispatch("codeseeker_index", body, resp); + if (std.mem.indexOf(u8, body, "codeseeker_query") != null) + return dispatch("codeseeker_query", body, resp); + if (std.mem.indexOf(u8, body, "codeseeker_state") != null) + return dispatch("codeseeker_state", body, resp); + return .{ .status = 400, .body = errJson(resp, "unrecognised GraphQL operation") }; +} + +fn handleConnection(stream: std.net.Stream, port: u16) void { + defer stream.close(); + var buf: [4096]u8 = undefined; + var resp_buf: [4096]u8 = undefined; + const n = stream.read(&buf) catch return; + const req = buf[0..n]; + const result = switch (port) { + REST_PORT => blk: { + // Parse HTTP/1.1: first line = METHOD PATH HTTP/x.y + var lines = std.mem.splitScalar(u8, req, '\n'); + const first = lines.next() orelse break :blk Response{ .status = 400, .body = "" }; + var parts = std.mem.splitScalar(u8, std.mem.trim(u8, first, "\r"), ' '); + _ = parts.next(); // method + const path = parts.next() orelse break :blk Response{ .status = 400, .body = "" }; + break :blk dispatchRest(path, req, &resp_buf); + }, + GRPC_PORT => blk: { + var lines = std.mem.splitScalar(u8, req, '\n'); + const first = lines.next() orelse break :blk Response{ .status = 400, .body = "" }; + var parts = std.mem.splitScalar(u8, std.mem.trim(u8, first, "\r"), ' '); + _ = parts.next(); + const path = parts.next() orelse break :blk Response{ .status = 400, .body = "" }; + break :blk dispatchGrpc(path, req, &resp_buf); + }, + GQL_PORT => dispatchGraphql(req, &resp_buf), + else => Response{ .status = 500, .body = "" }, + }; + var http_resp: [512]u8 = undefined; + const http = std.fmt.bufPrint(&http_resp, + "HTTP/1.1 {d} OK\r\nContent-Length: {d}\r\nContent-Type: application/json\r\n\r\n", + .{ result.status, result.body.len }) catch return; + _ = stream.write(http) catch {}; + _ = stream.write(result.body) catch {}; +} + +fn listenLoop(port: u16) !void { + const addr = std.net.Address.initIp4(.{ 127, 0, 0, 1 }, port); + var server = try addr.listen(.{ .reuse_address = true }); + defer server.deinit(); + while (true) { + const conn = try server.accept(); + const t = try std.Thread.spawn(.{}, handleConnection, .{ conn.stream, port }); + t.detach(); + } +} + +pub fn main() !void { + ffi.codeseeker_init(); + const t1 = try std.Thread.spawn(.{}, listenLoop, .{REST_PORT}); + const t2 = try std.Thread.spawn(.{}, listenLoop, .{GRPC_PORT}); + const t3 = try std.Thread.spawn(.{}, listenLoop, .{GQL_PORT}); + t1.join(); + t2.join(); + t3.join(); +} diff --git a/cartridges/domains/development/codeseeker-mcp/cartridge.json b/cartridges/domains/development/codeseeker-mcp/cartridge.json new file mode 100644 index 0000000..062b5db --- /dev/null +++ b/cartridges/domains/development/codeseeker-mcp/cartridge.json @@ -0,0 +1,129 @@ +{ + "$schema": "https://boj.dev/schemas/cartridge/v1.json", + "spdx": "MPL-2.0", + "copyright": "Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk>", + "name": "codeseeker-mcp", + "version": "0.1.0", + "description": "CodeSeeker hybrid code search and graph RAG", + "domain": "development", + "tier": "Ayo", + "protocols": [ + "MCP", + "REST" + ], + "auth": { + "method": "none", + "env_var": null, + "credential_source": null + }, + "api": { + "base_url": "local://codeseeker-mcp", + "content_type": "application/json" + }, + "tools": [ + { + "name": "codeseeker_open", + "description": "Open a CodeSeeker session on a project path", + "inputSchema": { + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "Project root path" + } + }, + "required": [ + "path" + ] + } + }, + { + "name": "codeseeker_close", + "description": "Close a CodeSeeker session", + "inputSchema": { + "type": "object", + "properties": { + "session_id": { + "type": "integer", + "description": "Session index" + } + }, + "required": [ + "session_id" + ] + } + }, + { + "name": "codeseeker_index", + "description": "Index a project for search", + "inputSchema": { + "type": "object", + "properties": { + "session_id": { + "type": "integer", + "description": "Session index" + } + }, + "required": [ + "session_id" + ] + } + }, + { + "name": "codeseeker_query", + "description": "Search code with hybrid text+vector+graph", + "inputSchema": { + "type": "object", + "properties": { + "session_id": { + "type": "integer", + "description": "Session index" + }, + "query": { + "type": "string", + "description": "Search query" + }, + "mode": { + "type": "string", + "description": "Search mode: vector|text|path|hybrid" + }, + "limit": { + "type": "integer", + "description": "Max results" + } + }, + "required": [ + "session_id", + "query" + ] + } + }, + { + "name": "codeseeker_state", + "description": "Get session state", + "inputSchema": { + "type": "object", + "properties": { + "session_id": { + "type": "integer", + "description": "Session index" + } + }, + "required": [ + "session_id" + ] + } + } + ], + "ffi": { + "so_path": "ffi/zig-out/lib/libcodeseeker_mcp.so", + "abi_version": "ADR-0006", + "symbols": [ + "boj_cartridge_init", + "boj_cartridge_deinit", + "boj_cartridge_name", + "boj_cartridge_version", + "boj_cartridge_invoke" + ] + } +} diff --git a/cartridges/domains/development/codeseeker-mcp/ffi/README.adoc b/cartridges/domains/development/codeseeker-mcp/ffi/README.adoc new file mode 100644 index 0000000..f264dbf --- /dev/null +++ b/cartridges/domains/development/codeseeker-mcp/ffi/README.adoc @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += codeseeker-mcp / ffi β€” Zig FFI layer +:orientation: shallow + +== Purpose + +C-ABI exports bridging this cartridge's Idris2 ABI to the runtime. Consumed by +the adapter for protocol dispatch and by the trunk loader for mount. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” produces `zig-out/lib/libcodeseeker.so`. +| `codeseeker_ffi.zig` | C-ABI exports (16 exports, 8 inline tests, 367 lines). +|=== + +== Invariants + +* **Null-pointer rejection** on `[*c]const u8` arguments before any + dereference. +* **Negative return values** carry documented error meanings; zero is + success (matching the trunk error-code convention). +* **No allocator leaks** β€” every allocation path pairs with `defer free`. + +== Test/proof surface + +8 inline `test "..."` block(s) in `codeseeker_ffi.zig`. Run with +`cd ffi && zig build test`. + +== Read-first + +. `codeseeker_ffi.zig` β€” C-ABI signatures and guards. +. Trunk `ffi/zig/src/catalogue.zig` for how this `.so` is loaded. diff --git a/cartridges/domains/development/codeseeker-mcp/ffi/build.zig b/cartridges/domains/development/codeseeker-mcp/ffi/build.zig new file mode 100644 index 0000000..90d833a --- /dev/null +++ b/cartridges/domains/development/codeseeker-mcp/ffi/build.zig @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// CodeSeeker-MCP Cartridge β€” Zig FFI build configuration (Zig 0.15+). + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + // Shared ADR-0006 invoke-shim module (relative path up to boj-server trunk). + + const shim_mod = b.addModule("cartridge_shim", .{ + + .root_source_file = b.path("../../../ffi/zig/src/cartridge_shim.zig"), + + .target = target, + + .optimize = optimize, + + }); + + const codeseeker_mod = b.addModule("codeseeker_ffi", .{ + .root_source_file = b.path("codeseeker_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + codeseeker_mod.addImport("cartridge_shim", shim_mod); + + // ── Tests ──────────────────────────────────────────────────────── + const codeseeker_tests = b.addTest(.{ + .root_module = codeseeker_mod, + }); + + const run_tests = b.addRunArtifact(codeseeker_tests); + + const test_step = b.step("test", "Run codeseeker-mcp FFI tests"); + test_step.dependOn(&run_tests.step); + + // ── Shared library ─────────────────────────────────────────────── + const lib_mod = b.createModule(.{ + .root_source_file = b.path("codeseeker_ffi.zig"), + .target = target, + .optimize = optimize, + }); + lib_mod.addImport("cartridge_shim", shim_mod); + + const lib = b.addLibrary(.{ + .name = "codeseeker_mcp", + .root_module = lib_mod, + .linkage = .dynamic, + }); + b.installArtifact(lib); + + const lib_step = b.step("lib", "Build shared library"); + lib_step.dependOn(&lib.step); +} diff --git a/cartridges/domains/development/codeseeker-mcp/ffi/cartridge_shim.zig b/cartridges/domains/development/codeseeker-mcp/ffi/cartridge_shim.zig new file mode 100644 index 0000000..0e399db --- /dev/null +++ b/cartridges/domains/development/codeseeker-mcp/ffi/cartridge_shim.zig @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// cartridge_shim.zig β€” Shared helpers for the ADR-0006 five-symbol +// cartridge ABI (`boj_cartridge_init / deinit / name / version / invoke`). +// +// The shim centralises the seven-code return convention, NUL-argument +// guards, tool-name comparison, and the buffer-too-small path so each +// cartridge's `boj_cartridge_invoke` can stay short β€” typically a tool +// table plus `shim.writeResult(...)`. +// +// Cartridges import this file by relative path (no build-graph change +// needed). Example: +// +// const shim = @import("../../../ffi/zig/src/cartridge_shim.zig"); +// +// export fn boj_cartridge_invoke( +// tool_name: [*c]const u8, +// json_args: [*c]const u8, +// out_buf: [*c]u8, +// in_out_len: [*c]usize, +// ) callconv(.c) i32 { +// _ = json_args; +// if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; +// const body = if (shim.toolIs(tool_name, "foo")) "{\"result\":{}}" +// else return shim.RC_UNKNOWN_TOOL; +// return shim.writeResult(out_buf, in_out_len, body); +// } + +const std = @import("std"); + +// ── Return codes (ADR-0006 Β§Return codes) ──────────────────────────── +// +// Frozen by ADR-0006. New failure modes compose these via the error +// JSON body β€” the integer surface does not grow without a follow-up ADR. + +pub const RC_SUCCESS: i32 = 0; +pub const RC_UNKNOWN_TOOL: i32 = -1; +pub const RC_BAD_ARGS: i32 = -2; +pub const RC_BUFFER_TOO_SMALL: i32 = -3; +pub const RC_RUNTIME_ERROR: i32 = -4; +pub const RC_PANIC: i32 = -5; +pub const RC_AUTH_DENIED: i32 = -6; + +// ── Invoke-path helpers ────────────────────────────────────────────── + +/// True if any of the three mandatory `boj_cartridge_invoke` output-path +/// pointers is null. Use at the top of every invoke to short-circuit to +/// `RC_BAD_ARGS`. +pub fn invokeArgsNull( + tool_name: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) bool { + return tool_name == null or out_buf == null or in_out_len == null; +} + +/// Compare a C-NUL-terminated tool-name pointer against a Zig string +/// literal. Caller must have already verified `tool_name` is non-null +/// (usually via `invokeArgsNull`). +pub fn toolIs(tool_name: [*c]const u8, expected: []const u8) bool { + const s = std.mem.span(@as([*:0]const u8, @ptrCast(tool_name))); + return std.mem.eql(u8, s, expected); +} + +/// Copy `body` into `out_buf[0..*in_out_len]` (as a capacity) and update +/// `*in_out_len` to the number of bytes written. Returns `RC_SUCCESS`. +/// +/// If `body.len` exceeds the current capacity stored in `*in_out_len`, +/// sets `*in_out_len` to the required size and returns +/// `RC_BUFFER_TOO_SMALL` β€” the caller is then expected to re-allocate +/// and retry, per ADR-0006 Β§Memory ownership. +/// +/// Caller must have already verified that `out_buf` and `in_out_len` +/// are non-null. +pub fn writeResult( + out_buf: [*c]u8, + in_out_len: [*c]usize, + body: []const u8, +) i32 { + const cap = in_out_len.*; + if (body.len > cap) { + in_out_len.* = body.len; + return RC_BUFFER_TOO_SMALL; + } + @memcpy(out_buf[0..body.len], body); + in_out_len.* = body.len; + return RC_SUCCESS; +} + +// ── Tests ──────────────────────────────────────────────────────────── + +test "writeResult: body fits, writes and sets length" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); + try std.testing.expectEqualStrings("hello", buf[0..len]); +} + +test "writeResult: too small returns -3 and sets required length" { + var buf: [2]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_BUFFER_TOO_SMALL, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: exact-fit succeeds" { + var buf: [5]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: empty body" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, ""); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 0), len); +} + +test "toolIs: matches and rejects" { + const name: [*:0]const u8 = "foo"; + try std.testing.expect(toolIs(@ptrCast(name), "foo")); + try std.testing.expect(!toolIs(@ptrCast(name), "bar")); + try std.testing.expect(!toolIs(@ptrCast(name), "foobar")); + try std.testing.expect(!toolIs(@ptrCast(name), "fo")); +} + +test "invokeArgsNull: detects each null slot" { + var buf: [4]u8 = undefined; + var len: usize = 4; + const name: [*:0]const u8 = "x"; + try std.testing.expect(!invokeArgsNull(@ptrCast(name), &buf, &len)); + try std.testing.expect(invokeArgsNull(null, &buf, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), null, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), &buf, null)); +} diff --git a/cartridges/domains/development/codeseeker-mcp/ffi/codeseeker_ffi.zig b/cartridges/domains/development/codeseeker-mcp/ffi/codeseeker_ffi.zig new file mode 100644 index 0000000..b36dd51 --- /dev/null +++ b/cartridges/domains/development/codeseeker-mcp/ffi/codeseeker_ffi.zig @@ -0,0 +1,436 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// CodeSeeker-MCP Cartridge β€” Zig FFI bridge for code intelligence operations. +// +// Implements the index state machine from CodeseekerMcp.SearchGraph.idr. +// Ensures: +// - No search or graph traversal before a codebase is indexed +// - No concurrent index builds on the same path +// - Clean session lifecycle with error recovery + +const std = @import("std"); + +// ═══════════════════════════════════════════════════════════════════════ +// Types (must match CodeseekerMcp.SearchGraph encodings) +// ═══════════════════════════════════════════════════════════════════════ + +/// Lifecycle states for a CodeSeeker index session. +pub const IndexState = enum(c_int) { + uninitialised = 0, + indexing = 1, + ready = 2, + querying = 3, + index_error = 4, +}; + +/// Search strategy modes. +pub const SearchMode = enum(c_int) { + vector = 1, + text = 2, + path = 3, + hybrid = 4, +}; + +/// Knowledge graph edge types. +pub const GraphRelation = enum(c_int) { + imports = 1, + calls = 2, + extends = 3, + implements = 4, + uses = 5, +}; + +// ═══════════════════════════════════════════════════════════════════════ +// Index Session Pool +// ═══════════════════════════════════════════════════════════════════════ + +const MAX_SESSIONS: usize = 16; +const MAX_PATH_LEN: usize = 4096; + +const IndexSlot = struct { + active: bool, + state: IndexState, + /// djb2 hash of the codebase path β€” used to detect duplicate sessions. + path_hash: u64, + file_count: u32, +}; + +var sessions: [MAX_SESSIONS]IndexSlot = [_]IndexSlot{.{ + .active = false, + .state = .uninitialised, + .path_hash = 0, + .file_count = 0, +}} ** MAX_SESSIONS; + +var mutex: std.Thread.Mutex = .{}; + +/// djb2 hash for a null-terminated C string. +fn hashPath(path: [*:0]const u8) u64 { + var h: u64 = 5381; + var i: usize = 0; + while (path[i] != 0) : (i += 1) { + h = ((h << 5) +% h) +% @as(u64, path[i]); + } + return h; +} + +/// Validate a state transition (matches Idris2 canIndexTransition). +fn isValidTransition(from: IndexState, to: IndexState) bool { + return switch (from) { + .uninitialised => to == .indexing, + .indexing => to == .ready or to == .index_error, + .ready => to == .querying, + .querying => to == .ready or to == .index_error, + .index_error => to == .uninitialised, + }; +} + +// ═══════════════════════════════════════════════════════════════════════ +// Session Lifecycle +// ═══════════════════════════════════════════════════════════════════════ + +/// Open a new index session for the given codebase path. +/// Returns slot index, or -1 if no slots available, or -2 if path already +/// has an active indexing session (prevents duplicate index builds). +pub export fn codeseeker_open_session(path: [*:0]const u8) c_int { + mutex.lock(); + defer mutex.unlock(); + const ph = hashPath(path); + // Check for duplicate active indexing session on same path. + for (&sessions) |*slot| { + if (slot.active and slot.state == .indexing and slot.path_hash == ph) { + return -2; // Already indexing this path + } + } + // Find an empty slot. + for (&sessions, 0..) |*slot, i| { + if (!slot.active) { + slot.active = true; + slot.state = .uninitialised; + slot.path_hash = ph; + slot.file_count = 0; + return @intCast(i); + } + } + return -1; // No slots available +} + +/// Close an index session and free its slot. +pub export fn codeseeker_close_session(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + if (slot_idx < 0 or slot_idx >= MAX_SESSIONS) return -1; + const idx: usize = @intCast(slot_idx); + if (!sessions[idx].active) return -1; + sessions[idx].active = false; + sessions[idx].state = .uninitialised; + sessions[idx].path_hash = 0; + sessions[idx].file_count = 0; + return 0; +} + +// ═══════════════════════════════════════════════════════════════════════ +// State Transitions +// ═══════════════════════════════════════════════════════════════════════ + +/// Begin indexing (transition Uninitialised -> Indexing). +pub export fn codeseeker_begin_index(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + if (slot_idx < 0 or slot_idx >= MAX_SESSIONS) return -1; + const idx: usize = @intCast(slot_idx); + if (!sessions[idx].active) return -1; + if (!isValidTransition(sessions[idx].state, .indexing)) return -2; + sessions[idx].state = .indexing; + return 0; +} + +/// Mark indexing complete (transition Indexing -> Ready), recording file count. +pub export fn codeseeker_finish_index(slot_idx: c_int, file_count: c_uint) c_int { + mutex.lock(); + defer mutex.unlock(); + if (slot_idx < 0 or slot_idx >= MAX_SESSIONS) return -1; + const idx: usize = @intCast(slot_idx); + if (!sessions[idx].active) return -1; + if (!isValidTransition(sessions[idx].state, .ready)) return -2; + sessions[idx].state = .ready; + sessions[idx].file_count = file_count; + return 0; +} + +/// Begin a query (transition Ready -> Querying). +pub export fn codeseeker_begin_query(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + if (slot_idx < 0 or slot_idx >= MAX_SESSIONS) return -1; + const idx: usize = @intCast(slot_idx); + if (!sessions[idx].active) return -1; + if (!isValidTransition(sessions[idx].state, .querying)) return -2; + sessions[idx].state = .querying; + return 0; +} + +/// Finish a query (transition Querying -> Ready). +pub export fn codeseeker_finish_query(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + if (slot_idx < 0 or slot_idx >= MAX_SESSIONS) return -1; + const idx: usize = @intCast(slot_idx); + if (!sessions[idx].active) return -1; + if (!isValidTransition(sessions[idx].state, .ready)) return -2; + sessions[idx].state = .ready; + return 0; +} + +/// Signal an error on a session (transition Indexing/Querying -> IndexError). +pub export fn codeseeker_signal_error(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + if (slot_idx < 0 or slot_idx >= MAX_SESSIONS) return -1; + const idx: usize = @intCast(slot_idx); + if (!sessions[idx].active) return -1; + if (!isValidTransition(sessions[idx].state, .index_error)) return -2; + sessions[idx].state = .index_error; + return 0; +} + +/// Reset an errored session (transition IndexError -> Uninitialised). +pub export fn codeseeker_reset_error(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + if (slot_idx < 0 or slot_idx >= MAX_SESSIONS) return -1; + const idx: usize = @intCast(slot_idx); + if (!sessions[idx].active) return -1; + if (!isValidTransition(sessions[idx].state, .uninitialised)) return -2; + sessions[idx].state = .uninitialised; + sessions[idx].file_count = 0; + return 0; +} + +// ═══════════════════════════════════════════════════════════════════════ +// Query Helpers +// ═══════════════════════════════════════════════════════════════════════ + +/// Get current state of a session. +pub export fn codeseeker_get_state(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + if (slot_idx < 0 or slot_idx >= MAX_SESSIONS) return -1; + const idx: usize = @intCast(slot_idx); + if (!sessions[idx].active) return @intFromEnum(IndexState.uninitialised); + return @intFromEnum(sessions[idx].state); +} + +/// Get the indexed file count for a session (0 if not Ready). +pub export fn codeseeker_get_file_count(slot_idx: c_int) c_uint { + mutex.lock(); + defer mutex.unlock(); + if (slot_idx < 0 or slot_idx >= MAX_SESSIONS) return 0; + const idx: usize = @intCast(slot_idx); + if (!sessions[idx].active) return 0; + return sessions[idx].file_count; +} + +/// Validate a state transition (C-ABI export, matches Idris2). +pub export fn codeseeker_can_transition(from: c_int, to: c_int) c_int { + const f: IndexState = @enumFromInt(from); + const t: IndexState = @enumFromInt(to); + return if (isValidTransition(f, t)) 1 else 0; +} + +/// Reset all sessions (for testing). +pub export fn codeseeker_reset_all() void { + mutex.lock(); + defer mutex.unlock(); + for (&sessions) |*slot| { + slot.active = false; + slot.state = .uninitialised; + slot.path_hash = 0; + slot.file_count = 0; + } +} + +// ═══════════════════════════════════════════════════════════════════════ +// Standard Cartridge Interface +// ═══════════════════════════════════════════════════════════════════════ + +/// Initialise the codeseeker-mcp cartridge. +pub export fn boj_cartridge_init() c_int { + codeseeker_reset_all(); + return 0; +} + +/// Deinitialise the codeseeker-mcp cartridge. +pub export fn boj_cartridge_deinit() void { + codeseeker_reset_all(); +} + +/// Return the cartridge name as a null-terminated C string. +pub export fn boj_cartridge_name() [*:0]const u8 { + return "codeseeker-mcp"; +} + +/// Return the cartridge version as a null-terminated C string. +pub export fn boj_cartridge_version() [*:0]const u8 { + return "0.1.0"; +} + +// ═══════════════════════════════════════════════════════════════════════ +// ADR-0006 dispatch (boj_cartridge_invoke, 5th standard symbol) +// ═══════════════════════════════════════════════════════════════════════ + +const shim = @import("cartridge_shim.zig"); + +/// Dispatch the cartridge.json MCP tools. Grade D Alpha β€” each arm +/// returns a stub JSON body shaped to the tool's intended response. +export fn boj_cartridge_invoke( + tool_name: [*c]const u8, + json_args: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) callconv(.c) i32 { + _ = json_args; + if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; + + const body: []const u8 = if (shim.toolIs(tool_name, "codeseeker_open")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "codeseeker_close")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "codeseeker_index")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "codeseeker_query")) + "{\"result\":{\"matches\":[],\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "codeseeker_state")) + "{\"result\":{\"status\":\"stub\"}}" +else + return shim.RC_UNKNOWN_TOOL; + + return shim.writeResult(out_buf, in_out_len, body); +} + +// ═══════════════════════════════════════════════════════════════════════ +// Tests +// ═══════════════════════════════════════════════════════════════════════ + +test "open and close session" { + codeseeker_reset_all(); + const slot = codeseeker_open_session("/home/hyper/repos/myproject"); + try std.testing.expect(slot >= 0); + try std.testing.expectEqual(@as(c_int, @intFromEnum(IndexState.uninitialised)), codeseeker_get_state(slot)); + try std.testing.expectEqual(@as(c_int, 0), codeseeker_close_session(slot)); +} + +test "full index lifecycle" { + codeseeker_reset_all(); + const slot = codeseeker_open_session("/repos/boj-server"); + try std.testing.expectEqual(@as(c_int, 0), codeseeker_begin_index(slot)); + try std.testing.expectEqual(@as(c_int, @intFromEnum(IndexState.indexing)), codeseeker_get_state(slot)); + try std.testing.expectEqual(@as(c_int, 0), codeseeker_finish_index(slot, 512)); + try std.testing.expectEqual(@as(c_int, @intFromEnum(IndexState.ready)), codeseeker_get_state(slot)); + try std.testing.expectEqual(@as(c_uint, 512), codeseeker_get_file_count(slot)); + _ = codeseeker_close_session(slot); +} + +test "query lifecycle" { + codeseeker_reset_all(); + const slot = codeseeker_open_session("/repos/test"); + _ = codeseeker_begin_index(slot); + _ = codeseeker_finish_index(slot, 100); + try std.testing.expectEqual(@as(c_int, 0), codeseeker_begin_query(slot)); + try std.testing.expectEqual(@as(c_int, @intFromEnum(IndexState.querying)), codeseeker_get_state(slot)); + try std.testing.expectEqual(@as(c_int, 0), codeseeker_finish_query(slot)); + try std.testing.expectEqual(@as(c_int, @intFromEnum(IndexState.ready)), codeseeker_get_state(slot)); + _ = codeseeker_close_session(slot); +} + +test "cannot query before indexing" { + codeseeker_reset_all(); + const slot = codeseeker_open_session("/repos/uninitialised"); + // Uninitialised -> Querying is invalid + try std.testing.expectEqual(@as(c_int, -2), codeseeker_begin_query(slot)); + _ = codeseeker_close_session(slot); +} + +test "cannot index while already indexing" { + codeseeker_reset_all(); + const slot = codeseeker_open_session("/repos/being-indexed"); + _ = codeseeker_begin_index(slot); + // Indexing -> Indexing is invalid + try std.testing.expectEqual(@as(c_int, -2), codeseeker_begin_index(slot)); + _ = codeseeker_close_session(slot); +} + +test "duplicate path rejection during indexing" { + codeseeker_reset_all(); + const path = "/repos/shared-codebase"; + const slot1 = codeseeker_open_session(path); + _ = codeseeker_begin_index(slot1); + // Opening another session for the same path while indexing is blocked + const slot2 = codeseeker_open_session(path); + try std.testing.expectEqual(@as(c_int, -2), slot2); + _ = codeseeker_close_session(slot1); +} + +test "error recovery" { + codeseeker_reset_all(); + const slot = codeseeker_open_session("/repos/failing"); + _ = codeseeker_begin_index(slot); + try std.testing.expectEqual(@as(c_int, 0), codeseeker_signal_error(slot)); + try std.testing.expectEqual(@as(c_int, @intFromEnum(IndexState.index_error)), codeseeker_get_state(slot)); + try std.testing.expectEqual(@as(c_int, 0), codeseeker_reset_error(slot)); + try std.testing.expectEqual(@as(c_int, @intFromEnum(IndexState.uninitialised)), codeseeker_get_state(slot)); + _ = codeseeker_close_session(slot); +} + +test "transition validation" { + // Valid transitions + try std.testing.expectEqual(@as(c_int, 1), codeseeker_can_transition(0, 1)); // uninit -> indexing + try std.testing.expectEqual(@as(c_int, 1), codeseeker_can_transition(1, 2)); // indexing -> ready + try std.testing.expectEqual(@as(c_int, 1), codeseeker_can_transition(1, 4)); // indexing -> error + try std.testing.expectEqual(@as(c_int, 1), codeseeker_can_transition(2, 3)); // ready -> querying + try std.testing.expectEqual(@as(c_int, 1), codeseeker_can_transition(3, 2)); // querying -> ready + try std.testing.expectEqual(@as(c_int, 1), codeseeker_can_transition(3, 4)); // querying -> error + try std.testing.expectEqual(@as(c_int, 1), codeseeker_can_transition(4, 0)); // error -> uninit + // Invalid transitions + try std.testing.expectEqual(@as(c_int, 0), codeseeker_can_transition(0, 2)); // uninit -> ready + try std.testing.expectEqual(@as(c_int, 0), codeseeker_can_transition(0, 3)); // uninit -> querying + try std.testing.expectEqual(@as(c_int, 0), codeseeker_can_transition(2, 0)); // ready -> uninit + try std.testing.expectEqual(@as(c_int, 0), codeseeker_can_transition(3, 1)); // querying -> indexing +} + +// ═══════════════════════════════════════════════════════════════════════ +// ADR-0006 invoke dispatch tests +// ═══════════════════════════════════════════════════════════════════════ + +test "invoke: each declared tool succeeds" { + var buf: [256]u8 = undefined; + const tools = [_][]const u8{ + "codeseeker_open", + "codeseeker_close", + "codeseeker_index", + "codeseeker_query", + "codeseeker_state", + }; + for (tools) |t| { + var len: usize = buf.len; + const rc = boj_cartridge_invoke(t.ptr, "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, 0), rc); + try std.testing.expect(std.mem.indexOf(u8, buf[0..len], "result") != null); + } +} + +test "invoke: unknown tool returns -1" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("nope", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -1), rc); +} + +test "invoke: buffer too small returns -3" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("codeseeker_open", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -3), rc); + try std.testing.expect(len > 4); +} diff --git a/cartridges/domains/development/codeseeker-mcp/mod.js b/cartridges/domains/development/codeseeker-mcp/mod.js new file mode 100644 index 0000000..1547066 --- /dev/null +++ b/cartridges/domains/development/codeseeker-mcp/mod.js @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// codeseeker-mcp/mod.js β€” CodeSeeker hybrid code search and graph RAG +// +// Delegates to backend at http://127.0.0.1:7716 (override with CODESEEKER_BACKEND_URL). + +const BASE_URL = Deno.env.get("CODESEEKER_BACKEND_URL") ?? "http://127.0.0.1:7716"; +const TIMEOUT_MS = 15_000; + +async function post(path, payload) { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), TIMEOUT_MS); + try { + const r = await fetch(`${BASE_URL}${path}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + signal: ctrl.signal, + }); + const data = await r.json().catch(() => ({ success: false, error: "non-JSON response" })); + return { status: r.status, data }; + } catch (e) { + if (e.name === "AbortError") return { status: 504, data: { success: false, error: "codeseeker-mcp backend timed out" } }; + return { status: 503, data: { success: false, error: `codeseeker-mcp backend unavailable: ${e.message}` } }; + } finally { clearTimeout(t); } +} + +async function get(path) { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), TIMEOUT_MS); + try { + const r = await fetch(`${BASE_URL}${path}`, { method: "GET", signal: ctrl.signal }); + const data = await r.json().catch(() => ({ success: false, error: "non-JSON response" })); + return { status: r.status, data }; + } catch (e) { + if (e.name === "AbortError") return { status: 504, data: { success: false, error: "codeseeker-mcp backend timed out" } }; + return { status: 503, data: { success: false, error: `codeseeker-mcp backend unavailable: ${e.message}` } }; + } finally { clearTimeout(t); } +} + +export async function handleTool(toolName, args) { + switch (toolName) { + case "codeseeker_open": + return post("/api/v1/codeseeker_open", args ?? {}); + case "codeseeker_close": + return post("/api/v1/codeseeker_close", args ?? {}); + case "codeseeker_index": + return post("/api/v1/codeseeker_index", args ?? {}); + case "codeseeker_query": + return post("/api/v1/codeseeker_query", args ?? {}); + case "codeseeker_state": + return post("/api/v1/codeseeker_state", args ?? {}); + default: + return { status: 404, data: { error: `Unknown tool: ${toolName}` } }; + } +} diff --git a/cartridges/domains/development/codeseeker-mcp/panels/manifest.json b/cartridges/domains/development/codeseeker-mcp/panels/manifest.json new file mode 100644 index 0000000..e02640b --- /dev/null +++ b/cartridges/domains/development/codeseeker-mcp/panels/manifest.json @@ -0,0 +1,131 @@ +{ + "$schema": "https://panll.dev/schemas/panel-manifest/v1.json", + "spdx": "MPL-2.0", + "cartridge": "codeseeker-mcp", + "domain": "Code Intelligence", + "version": "0.1.0", + "panels": [ + { + "id": "codeseeker-status", + "title": "CodeSeeker Status", + "description": "Index state and file count for active CodeSeeker sessions", + "type": "status-indicator", + "data_source": { + "endpoint": "/cartridge/codeseeker-mcp/invoke", + "method": "POST", + "body": { "tool": "codeseeker/status" }, + "refresh_interval_ms": 5000 + }, + "widgets": [ + { + "type": "state-badge", + "field": "state", + "states": { + "ready": { "color": "#2ecc71", "icon": "search" }, + "indexing": { "color": "#3498db", "icon": "loader" }, + "querying": { "color": "#9b59b6", "icon": "zap" }, + "uninitialised": { "color": "#95a5a6", "icon": "circle" }, + "index_error": { "color": "#e74c3c", "icon": "alert-triangle" } + } + }, + { "type": "counter", "field": "file_count", "label": "Indexed Files", "icon": "file-code" } + ] + }, + { + "id": "codeseeker-search", + "title": "Code Search", + "description": "Hybrid semantic + text + path search with Reciprocal Rank Fusion", + "type": "search", + "data_source": { + "endpoint": "/cartridge/codeseeker-mcp/invoke", + "method": "POST", + "body_template": { "tool": "codeseeker/search", "query": "{{query}}", "mode": "hybrid" }, + "refresh_interval_ms": 0 + }, + "widgets": [ + { "type": "search-input", "field": "query", "placeholder": "Search codebase..." }, + { + "type": "select", + "field": "mode", + "label": "Mode", + "options": [ + { "value": "hybrid", "label": "Hybrid (RRF)" }, + { "value": "vector", "label": "Semantic" }, + { "value": "text", "label": "Text" }, + { "value": "path", "label": "Path" } + ] + }, + { + "type": "table", + "field": "results", + "columns": [ + { "key": "file", "label": "File", "icon": "file" }, + { "key": "line", "label": "Line", "icon": "hash" }, + { "key": "snippet", "label": "Snippet", "icon": "code" }, + { "key": "score", "label": "Score", "icon": "bar-chart-2" }, + { "key": "mode", "label": "Mode", "icon": "layers" } + ] + } + ] + }, + { + "id": "codeseeker-graph", + "title": "Knowledge Graph", + "description": "Explore import, call, extends, and implements relationships", + "type": "graph", + "data_source": { + "endpoint": "/cartridge/codeseeker-mcp/invoke", + "method": "POST", + "body_template": { + "tool": "codeseeker/traverse", + "symbol": "{{symbol}}", + "relation": "{{relation}}", + "depth": 2 + }, + "refresh_interval_ms": 0 + }, + "widgets": [ + { "type": "search-input", "field": "symbol", "placeholder": "Symbol or file path..." }, + { + "type": "select", + "field": "relation", + "label": "Relation", + "options": [ + { "value": "imports", "label": "Imports" }, + { "value": "calls", "label": "Calls" }, + { "value": "extends", "label": "Extends" }, + { "value": "implements", "label": "Implements" }, + { "value": "uses", "label": "Uses" } + ] + }, + { + "type": "graph-view", + "field": "nodes", + "node_label": "symbol", + "edge_label": "relation" + } + ] + }, + { + "id": "codeseeker-patterns", + "title": "Coding Patterns", + "description": "Auto-detected conventions and patterns in the indexed codebase", + "type": "list", + "data_source": { + "endpoint": "/cartridge/codeseeker-mcp/invoke", + "method": "POST", + "body": { "tool": "codeseeker/patterns" }, + "refresh_interval_ms": 60000 + }, + "widgets": [ + { + "type": "accordion", + "field": "patterns", + "item_title": "name", + "item_body": "description", + "item_detail": "examples" + } + ] + } + ] +} diff --git a/cartridges/domains/development/fireflag-mcp/.editorconfig b/cartridges/domains/development/fireflag-mcp/.editorconfig new file mode 100644 index 0000000..36a608d --- /dev/null +++ b/cartridges/domains/development/fireflag-mcp/.editorconfig @@ -0,0 +1,13 @@ +# SPDX-License-Identifier: MPL-2.0 +root = true + +[*] +charset = utf-8 +indent_style = space +indent_size = 2 +end_of_line = lf +trim_trailing_whitespace = true +insert_final_newline = true + +[*.md] +trim_trailing_whitespace = false diff --git a/cartridges/domains/development/fireflag-mcp/.gitignore b/cartridges/domains/development/fireflag-mcp/.gitignore new file mode 100644 index 0000000..6f90203 --- /dev/null +++ b/cartridges/domains/development/fireflag-mcp/.gitignore @@ -0,0 +1,11 @@ +// SPDX-License-Identifier: MPL-2.0 +*.swp +*.swo +*~ +.DS_Store +node_modules/ +dist/ +build/ +target/ +.deno +deno.lock diff --git a/cartridges/domains/development/fireflag-mcp/LICENSE b/cartridges/domains/development/fireflag-mcp/LICENSE new file mode 100644 index 0000000..7f0cff1 --- /dev/null +++ b/cartridges/domains/development/fireflag-mcp/LICENSE @@ -0,0 +1,15 @@ +SPDX-License-Identifier: MPL-2.0 + +Fireflag Cartridge +Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) + +This software is licensed under the MPL-2.0 license. + +MPL-2.0 is a license supporting dual licensing with MPL-2.0 as automatic fallback. +For the full license text, see: https://hyperpolymath.dev/standards/PMPL-1.0 + +Legal Notice: +Until PMPL achieves formal recognition as a standalone license, this software is +automatically operative under the Mozilla Public License 2.0 (MPL-2.0). + +This is a legal fallback arrangement confirmed by legal counsel. diff --git a/cartridges/domains/development/fireflag-mcp/README.adoc b/cartridges/domains/development/fireflag-mcp/README.adoc new file mode 100644 index 0000000..5d17c38 --- /dev/null +++ b/cartridges/domains/development/fireflag-mcp/README.adoc @@ -0,0 +1,68 @@ += Fireflag Cartridge +:toc: preamble +:author: Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk> +:date: 2026-04-25 +:spdx: MPL-2.0 + +// SPDX-License-Identifier: MPL-2.0 + +Extension-to-MCP mapping and discovery β€” catalog extensions, validate configurations, and expose their capabilities as MCP tools. + +== Features + +- **Extension Mapping** β€” Map extension directories to available MCP tools +- **Extension Discovery** β€” Discover extensions in a directory structure +- **Tool Enumeration** β€” List available MCP tools per extension +- **Configuration Validation** β€” Validate extension structure and config +- **Capability Catalog** β€” Browse extension types (VS Code, IDE, CLI, web, LSP) + +== Architecture + +[cols="1,3"] +|=== +| Component | Purpose + +| `abi/Fireflag.idr` +| Idris2 interface with extension types (VSCodeExtension, IDEPlugin, CLITool, etc.) + and MCP capability definitions. + +| `ffi/fireflag_ffi.zig` +| Zig bindings for extension discovery and capability mapping. + +| `adapter/mod.ts` +| Deno MCP server exposing extension mapping tools. + Runs on `127.0.0.1:5177` (loopback only). + +| `cartridge.json` +| Tool manifest with 5 MCP tools for extension management. +|=== + +== MCP Tools + +=== `map_extension` +Map an extension directory to its available MCP tools and metadata. + +=== `list_mapped_extensions` +List all discovered extensions with their MCP capabilities. + +=== `get_extension_tools` +Retrieve MCP tools available for a specific extension. + +=== `validate_extension` +Validate extension configuration, structure, and compatibility. + +=== `discover_extensions` +Recursively discover extensions in a directory and catalog their tools. + +== Integration + +Connects to fireflag (extension mapper) via: +- **Directory scanning** for extension discovery +- **Manifest parsing** for capability enumeration +- **Type classification** for extension categorization + +Loopback proof pinning: `IsLoopback 5177` at compile-time. + +== License + +MPL-2.0 (MPL-2.0 legal fallback). diff --git a/cartridges/domains/development/fireflag-mcp/abi/Fireflag.idr b/cartridges/domains/development/fireflag-mcp/abi/Fireflag.idr new file mode 100644 index 0000000..8da12d9 --- /dev/null +++ b/cartridges/domains/development/fireflag-mcp/abi/Fireflag.idr @@ -0,0 +1,72 @@ +-- SPDX-License-Identifier: MPL-2.0 +-- Fireflag Cartridge ABI β€” Extension-to-MCP mapping interface + +module ABI.Fireflag + +%language ElabReflection + +-- Extension type classification +public export +data ExtensionType : Type where + VSCodeExtension : ExtensionType + IDEPlugin : ExtensionType + DesktopApp : ExtensionType + CLITool : ExtensionType + WebComponent : ExtensionType + LanguageServer : ExtensionType + Other : ExtensionType + +-- MCP capability definition +public export +record MCPCapability where + constructor MkMCPCapability + name : String + toolName : String + inputSchema : String + description : String + +-- Extension metadata +public export +record ExtensionMetadata where + constructor MkExtensionMetadata + extensionId : String + extensionType : ExtensionType + name : String + description : String + version : String + mcpTools : List MCPCapability + +-- Mapping result +public export +record MappingResult where + constructor MkMappingResult + extensionPath : String + metadata : ExtensionMetadata + isMapped : Bool + mappingStatus : String + +-- Fireflag cartridge interface +public export +interface Fireflag.Mapper where + -- Map an extension directory to available MCP tools + mapExtension : String -> IO MappingResult + + -- List all mapped extensions + listMappedExtensions : IO (List ExtensionMetadata) + + -- Get MCP tools available for an extension + getExtensionTools : String -> IO (List MCPCapability) + + -- Validate extension configuration + validateExtension : String -> IO (List String) + + -- Discover extensions in directory + discoverExtensions : String -> IO (List ExtensionMetadata) + + -- Loopback proof: cartridge runs on localhost only + IsLoopback : (port : Nat) -> Type + IsLoopback 5177 = () + +public export +Loopback.proof : IsLoopback 5177 +Loopback.proof = () diff --git a/cartridges/domains/development/fireflag-mcp/adapter/mod.ts b/cartridges/domains/development/fireflag-mcp/adapter/mod.ts new file mode 100644 index 0000000..728e891 --- /dev/null +++ b/cartridges/domains/development/fireflag-mcp/adapter/mod.ts @@ -0,0 +1,185 @@ +// SPDX-License-Identifier: MPL-2.0 +// Fireflag Cartridge β€” Extension-to-MCP mapping MCP server + +import { Server } from "https://esm.sh/@modelcontextprotocol/sdk/server/index.js"; +import { + CallToolRequestSchema, + ListToolsRequestSchema, + Tool, +} from "https://esm.sh/@modelcontextprotocol/sdk/types.js"; + +// MCP tool definitions for extension mapping +const TOOLS: Tool[] = [ + { + name: "map_extension", + description: "Map an extension directory to available MCP tools", + inputSchema: { + type: "object" as const, + properties: { + extension_path: { + type: "string", + description: "Path to extension directory", + }, + }, + required: ["extension_path"], + }, + }, + { + name: "list_mapped_extensions", + description: "List all mapped extensions with their MCP capabilities", + inputSchema: { + type: "object" as const, + properties: {}, + }, + }, + { + name: "get_extension_tools", + description: "Get available MCP tools for a specific extension", + inputSchema: { + type: "object" as const, + properties: { + extension_id: { + type: "string", + description: "Extension identifier", + }, + }, + required: ["extension_id"], + }, + }, + { + name: "validate_extension", + description: "Validate extension configuration and structure", + inputSchema: { + type: "object" as const, + properties: { + extension_path: { + type: "string", + description: "Path to extension to validate", + }, + }, + required: ["extension_path"], + }, + }, + { + name: "discover_extensions", + description: "Discover extensions in a directory and map their capabilities", + inputSchema: { + type: "object" as const, + properties: { + directory: { + type: "string", + description: "Directory to search for extensions", + }, + }, + required: ["directory"], + }, + }, +]; + +// Tool handlers +async function handleMapExtension( + args: Record<string, unknown> +): Promise<string> { + const extensionPath = String(args.extension_path); + return JSON.stringify({ + extension_path: extensionPath, + is_mapped: false, + mapping_status: "not_found", + metadata: null, + }); +} + +async function handleListMappedExtensions( + _args: Record<string, unknown> +): Promise<string> { + return JSON.stringify({ + extensions: [], + count: 0, + }); +} + +async function handleGetExtensionTools( + args: Record<string, unknown> +): Promise<string> { + const extensionId = String(args.extension_id); + return JSON.stringify({ + extension_id: extensionId, + tools: [], + count: 0, + }); +} + +async function handleValidateExtension( + args: Record<string, unknown> +): Promise<string> { + const extensionPath = String(args.extension_path); + return JSON.stringify({ + extension_path: extensionPath, + is_valid: true, + errors: [], + warnings: [], + }); +} + +async function handleDiscoverExtensions( + args: Record<string, unknown> +): Promise<string> { + const directory = String(args.directory); + return JSON.stringify({ + directory, + extensions: [], + count: 0, + }); +} + +// Initialize MCP server +const server = new Server({ + name: "fireflag-mcp", + version: "1.0.0", +}); + +// Register tool handlers +server.setRequestHandler(ListToolsRequestSchema, async () => { + return { tools: TOOLS }; +}); + +server.setRequestHandler(CallToolRequestSchema, async (request) => { + const { name, arguments: args } = request; + + let result: string; + if (name === "map_extension") { + result = await handleMapExtension(args as Record<string, unknown>); + } else if (name === "list_mapped_extensions") { + result = await handleListMappedExtensions(args as Record<string, unknown>); + } else if (name === "get_extension_tools") { + result = await handleGetExtensionTools(args as Record<string, unknown>); + } else if (name === "validate_extension") { + result = await handleValidateExtension(args as Record<string, unknown>); + } else if (name === "discover_extensions") { + result = await handleDiscoverExtensions(args as Record<string, unknown>); + } else { + return { + content: [ + { + type: "text" as const, + text: `Unknown tool: ${name}`, + }, + ], + isError: true, + }; + } + + return { + content: [ + { + type: "text" as const, + text: result, + }, + ], + }; +}); + +// Start server on loopback +const port = 5177; +await server.connect(new WebSocket(`ws://127.0.0.1:${port}`)); +console.log("Fireflag MCP server running on ws://127.0.0.1:5177"); diff --git a/cartridges/domains/development/fireflag-mcp/cartridge.json b/cartridges/domains/development/fireflag-mcp/cartridge.json new file mode 100644 index 0000000..b8d21ff --- /dev/null +++ b/cartridges/domains/development/fireflag-mcp/cartridge.json @@ -0,0 +1,70 @@ +{ + "$schema": "https://boj.dev/schemas/cartridge/v1.json", + "spdx": "MPL-2.0", + "copyright": "Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath)", + "name": "fireflag-mcp", + "version": "1.0.0", + "description": "Fireflag Cartridge \u2014 Extension-to-MCP mapping and discovery tools", + "domain": "Developer Tools", + "tier": "Ayo", + "auth": { + "method": "none" + }, + "author": "Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk>", + "license": "MPL-2.0", + "tools": [ + { + "id": "map_extension", + "name": "Map Extension", + "description": "Map an extension directory to available MCP tools" + }, + { + "id": "list_mapped_extensions", + "name": "List Mapped Extensions", + "description": "List all mapped extensions with their MCP capabilities" + }, + { + "id": "get_extension_tools", + "name": "Get Extension Tools", + "description": "Get available MCP tools for a specific extension" + }, + { + "id": "validate_extension", + "name": "Validate Extension", + "description": "Validate extension configuration and structure" + }, + { + "id": "discover_extensions", + "name": "Discover Extensions", + "description": "Discover extensions in a directory and map their capabilities" + } + ], + "abi": { + "interface": "abi/Fireflag.idr", + "loopback_proof": "IsLoopback 5177" + }, + "ffi": { + "so_path": "ffi/zig-out/lib/libfireflag_mcp.so", + "abi_version": "ADR-0006", + "symbols": [ + "boj_cartridge_init", + "boj_cartridge_deinit", + "boj_cartridge_name", + "boj_cartridge_version", + "boj_cartridge_invoke" + ] + }, + "adapter": { + "language": "typescript", + "runtime": "deno", + "entry": "adapter/mod.ts", + "permissions": [ + "net", + "env" + ] + }, + "loopback": { + "host": "127.0.0.1", + "port": 5177 + } +} diff --git a/cartridges/domains/development/fireflag-mcp/ffi/build.zig b/cartridges/domains/development/fireflag-mcp/ffi/build.zig new file mode 100644 index 0000000..badd953 --- /dev/null +++ b/cartridges/domains/development/fireflag-mcp/ffi/build.zig @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const ffi_mod = b.addModule("fireflag_mcp", .{ + .root_source_file = b.path("fireflag_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + const lib = b.addLibrary(.{ + .name = "fireflag_mcp", + .root_module = ffi_mod, + .linkage = .dynamic, + }); + lib.linkLibC(); + b.installArtifact(lib); + + const tests = b.addTest(.{ .root_module = ffi_mod }); + tests.linkLibC(); + const run_tests = b.addRunArtifact(tests); + const test_step = b.step("test", "Run FFI tests"); + test_step.dependOn(&run_tests.step); +} diff --git a/cartridges/domains/development/fireflag-mcp/ffi/cartridge_shim.zig b/cartridges/domains/development/fireflag-mcp/ffi/cartridge_shim.zig new file mode 100644 index 0000000..0e399db --- /dev/null +++ b/cartridges/domains/development/fireflag-mcp/ffi/cartridge_shim.zig @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// cartridge_shim.zig β€” Shared helpers for the ADR-0006 five-symbol +// cartridge ABI (`boj_cartridge_init / deinit / name / version / invoke`). +// +// The shim centralises the seven-code return convention, NUL-argument +// guards, tool-name comparison, and the buffer-too-small path so each +// cartridge's `boj_cartridge_invoke` can stay short β€” typically a tool +// table plus `shim.writeResult(...)`. +// +// Cartridges import this file by relative path (no build-graph change +// needed). Example: +// +// const shim = @import("../../../ffi/zig/src/cartridge_shim.zig"); +// +// export fn boj_cartridge_invoke( +// tool_name: [*c]const u8, +// json_args: [*c]const u8, +// out_buf: [*c]u8, +// in_out_len: [*c]usize, +// ) callconv(.c) i32 { +// _ = json_args; +// if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; +// const body = if (shim.toolIs(tool_name, "foo")) "{\"result\":{}}" +// else return shim.RC_UNKNOWN_TOOL; +// return shim.writeResult(out_buf, in_out_len, body); +// } + +const std = @import("std"); + +// ── Return codes (ADR-0006 Β§Return codes) ──────────────────────────── +// +// Frozen by ADR-0006. New failure modes compose these via the error +// JSON body β€” the integer surface does not grow without a follow-up ADR. + +pub const RC_SUCCESS: i32 = 0; +pub const RC_UNKNOWN_TOOL: i32 = -1; +pub const RC_BAD_ARGS: i32 = -2; +pub const RC_BUFFER_TOO_SMALL: i32 = -3; +pub const RC_RUNTIME_ERROR: i32 = -4; +pub const RC_PANIC: i32 = -5; +pub const RC_AUTH_DENIED: i32 = -6; + +// ── Invoke-path helpers ────────────────────────────────────────────── + +/// True if any of the three mandatory `boj_cartridge_invoke` output-path +/// pointers is null. Use at the top of every invoke to short-circuit to +/// `RC_BAD_ARGS`. +pub fn invokeArgsNull( + tool_name: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) bool { + return tool_name == null or out_buf == null or in_out_len == null; +} + +/// Compare a C-NUL-terminated tool-name pointer against a Zig string +/// literal. Caller must have already verified `tool_name` is non-null +/// (usually via `invokeArgsNull`). +pub fn toolIs(tool_name: [*c]const u8, expected: []const u8) bool { + const s = std.mem.span(@as([*:0]const u8, @ptrCast(tool_name))); + return std.mem.eql(u8, s, expected); +} + +/// Copy `body` into `out_buf[0..*in_out_len]` (as a capacity) and update +/// `*in_out_len` to the number of bytes written. Returns `RC_SUCCESS`. +/// +/// If `body.len` exceeds the current capacity stored in `*in_out_len`, +/// sets `*in_out_len` to the required size and returns +/// `RC_BUFFER_TOO_SMALL` β€” the caller is then expected to re-allocate +/// and retry, per ADR-0006 Β§Memory ownership. +/// +/// Caller must have already verified that `out_buf` and `in_out_len` +/// are non-null. +pub fn writeResult( + out_buf: [*c]u8, + in_out_len: [*c]usize, + body: []const u8, +) i32 { + const cap = in_out_len.*; + if (body.len > cap) { + in_out_len.* = body.len; + return RC_BUFFER_TOO_SMALL; + } + @memcpy(out_buf[0..body.len], body); + in_out_len.* = body.len; + return RC_SUCCESS; +} + +// ── Tests ──────────────────────────────────────────────────────────── + +test "writeResult: body fits, writes and sets length" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); + try std.testing.expectEqualStrings("hello", buf[0..len]); +} + +test "writeResult: too small returns -3 and sets required length" { + var buf: [2]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_BUFFER_TOO_SMALL, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: exact-fit succeeds" { + var buf: [5]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: empty body" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, ""); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 0), len); +} + +test "toolIs: matches and rejects" { + const name: [*:0]const u8 = "foo"; + try std.testing.expect(toolIs(@ptrCast(name), "foo")); + try std.testing.expect(!toolIs(@ptrCast(name), "bar")); + try std.testing.expect(!toolIs(@ptrCast(name), "foobar")); + try std.testing.expect(!toolIs(@ptrCast(name), "fo")); +} + +test "invokeArgsNull: detects each null slot" { + var buf: [4]u8 = undefined; + var len: usize = 4; + const name: [*:0]const u8 = "x"; + try std.testing.expect(!invokeArgsNull(@ptrCast(name), &buf, &len)); + try std.testing.expect(invokeArgsNull(null, &buf, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), null, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), &buf, null)); +} diff --git a/cartridges/domains/development/fireflag-mcp/ffi/fireflag_ffi.zig b/cartridges/domains/development/fireflag-mcp/ffi/fireflag_ffi.zig new file mode 100644 index 0000000..f9204f7 --- /dev/null +++ b/cartridges/domains/development/fireflag-mcp/ffi/fireflag_ffi.zig @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// fireflag-mcp FFI β€” ADR-0006 five-symbol cartridge ABI implementation. + +const std = @import("std"); +const shim = @import("cartridge_shim.zig"); + +const CARTRIDGE_NAME_PTR: [*:0]const u8 = "fireflag-mcp"; +const CARTRIDGE_VERSION_PTR: [*:0]const u8 = "0.1.0"; + +export fn boj_cartridge_init() callconv(.c) c_int { + return 0; +} + +export fn boj_cartridge_deinit() callconv(.c) void {} + +export fn boj_cartridge_name() callconv(.c) [*:0]const u8 { + return CARTRIDGE_NAME_PTR; +} + +export fn boj_cartridge_version() callconv(.c) [*:0]const u8 { + return CARTRIDGE_VERSION_PTR; +} + +export fn boj_cartridge_invoke( + tool_name: [*c]const u8, + json_args: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) callconv(.c) i32 { + _ = json_args; + if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; + const body: []const u8 = if (shim.toolIs(tool_name, "map_extension")) + "{\"result\":{}}" + else if (shim.toolIs(tool_name, "list_mapped_extensions")) + "{\"result\":{}}" + else if (shim.toolIs(tool_name, "get_extension_tools")) + "{\"result\":{}}" + else if (shim.toolIs(tool_name, "validate_extension")) + "{\"result\":{}}" + else if (shim.toolIs(tool_name, "discover_extensions")) + "{\"result\":{}}" + else + return shim.RC_UNKNOWN_TOOL; + + return shim.writeResult(out_buf, in_out_len, body); +} + +test "boj_cartridge_name returns fireflag-mcp" { + try std.testing.expectEqualStrings("fireflag-mcp", std.mem.span(boj_cartridge_name())); +} + +test "boj_cartridge_init returns 0" { + try std.testing.expectEqual(@as(c_int, 0), boj_cartridge_init()); +} + +test "invoke unknown tool returns RC_UNKNOWN_TOOL" { + var buf: [256]u8 = undefined; + var len: usize = buf.len; + try std.testing.expectEqual(@as(i32, shim.RC_UNKNOWN_TOOL), boj_cartridge_invoke("unknown_xyz", "{}", &buf, &len)); +} + +test "invoke map_extension returns 0" { + var buf: [256]u8 = undefined; + var len: usize = buf.len; + try std.testing.expectEqual(@as(i32, 0), boj_cartridge_invoke("map_extension", "{}", &buf, &len)); +} diff --git a/cartridges/domains/development/fireflag-mcp/mod.js b/cartridges/domains/development/fireflag-mcp/mod.js new file mode 100644 index 0000000..d7dd6d9 --- /dev/null +++ b/cartridges/domains/development/fireflag-mcp/mod.js @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: MPL-2.0 +export const cartridge = { + name: "fireflag-mcp", + version: "1.0.0", + description: "Fireflag cartridge β€” extension-to-MCP mapping", + tools: [ + { id: "map_extension", name: "Map Extension" }, + { id: "list_mapped_extensions", name: "List Mapped Extensions" }, + { id: "get_extension_tools", name: "Get Extension Tools" }, + { id: "validate_extension", name: "Validate Extension" }, + { id: "discover_extensions", name: "Discover Extensions" }, + ], +}; + +export async function health() { + return { status: "healthy", cartridge: "fireflag-mcp" }; +} + +export async function init() { + console.log("[fireflag-mcp] Initializing"); + return { initialized: true }; +} + +export async function cleanup() { + console.log("[fireflag-mcp] Shutting down"); + return { cleaned: true }; +} diff --git a/cartridges/domains/development/git-mcp/README.adoc b/cartridges/domains/development/git-mcp/README.adoc new file mode 100644 index 0000000..871188b --- /dev/null +++ b/cartridges/domains/development/git-mcp/README.adoc @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += git-mcp +Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk> +:spdx: MPL-2.0 +:tier: Ayo +:domain: development +:protocols: MCP, REST + +== Overview + +Multi-forge git operations (GitHub, GitLab, Gitea, Bitbucket) + +== Tools (7) + +[cols="2,4"] +|=== +| Tool | Description + +| `git_authenticate` | Authenticate with a git forge +| `git_select_repo` | Select a repository to work with +| `git_status` | Get git status of selected repo +| `git_log` | Get git log +| `git_diff` | Get git diff +| `git_create_branch` | Create a new branch +| `git_push` | Push to remote +|=== + +== Architecture + +Three-layer stack per the BoJ cartridge standard: + +[cols="1,1,3"] +|=== +| Layer | Language | Purpose + +| ABI | Idris2 | Formally verified state machine with dependent-type proofs +| FFI | Zig | C-ABI implementation: session pool, action metrics, thread safety +| Adapter| Zig | Adapter bridge to the BoJ unified adapter protocol (zig predecessors sidelined) +|=== + +== Building + +[source,bash] +---- +# ABI (Idris2) +cd abi && idris2 --check *.idr + +# FFI (Zig) +cd ffi && zig build +cd ffi && zig build test + +# Adapter (Zig) +cd adapter && zig build +---- + +== Status + +Alpha (CRG D). Cartridge metadata declared in `cartridge.json`; 7 tool(s) exposed. +Not yet dogfooded externally. See `docs/READINESS.md` at repo root for the +project-wide grade table and `docs/practice/DOGFOOD-LOG.adoc` for usage evidence. diff --git a/cartridges/domains/development/git-mcp/abi/GitMcp/SafeGit.idr b/cartridges/domains/development/git-mcp/abi/GitMcp/SafeGit.idr new file mode 100644 index 0000000..71ca3f8 --- /dev/null +++ b/cartridges/domains/development/git-mcp/abi/GitMcp/SafeGit.idr @@ -0,0 +1,182 @@ +-- SPDX-License-Identifier: MPL-2.0 +-- Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +||| GitMcp.SafeGit: Formally verified git forge operations. +||| +||| Cartridge: git-mcp +||| Matrix cell: Git forge domain x {MCP, LSP} protocols +||| +||| This module defines type-safe git forge operations with an +||| authentication state machine that prevents: +||| - Operations without authentication +||| - Cross-forge operations on wrong context +||| - PR/issue lifecycle violations +||| +||| Supports GitHub, GitLab, Gitea, and Bitbucket forges. +module GitMcp.SafeGit + +import Data.List + +%default total + +-- ═══════════════════════════════════════════════════════════════════════════ +-- Forge State Machine +-- ═══════════════════════════════════════════════════════════════════════════ + +||| Forge operation lifecycle states. +||| A session progresses: Unauthenticated -> Authenticated -> RepoSelected -> Operating -> RepoSelected +public export +data GitState = Unauthenticated | Authenticated | RepoSelected | Operating | GitError + +||| Equality for git states. +public export +Eq GitState where + Unauthenticated == Unauthenticated = True + Authenticated == Authenticated = True + RepoSelected == RepoSelected = True + Operating == Operating = True + GitError == GitError = True + _ == _ = False + +||| Valid state transitions (enforced at the type level). +public export +data ValidTransition : GitState -> GitState -> Type where + Authenticate : ValidTransition Unauthenticated Authenticated + SelectRepo : ValidTransition Authenticated RepoSelected + BeginOp : ValidTransition RepoSelected Operating + EndOp : ValidTransition Operating RepoSelected + DeselectRepo : ValidTransition RepoSelected Authenticated + Logout : ValidTransition Authenticated Unauthenticated + OpError : ValidTransition Operating GitError + Recover : ValidTransition GitError Authenticated + +||| Runtime transition validator. +public export +canTransition : GitState -> GitState -> Bool +canTransition Unauthenticated Authenticated = True +canTransition Authenticated RepoSelected = True +canTransition RepoSelected Operating = True +canTransition Operating RepoSelected = True +canTransition RepoSelected Authenticated = True +canTransition Authenticated Unauthenticated = True +canTransition Operating GitError = True +canTransition GitError Authenticated = True +canTransition _ _ = False + +-- ═══════════════════════════════════════════════════════════════════════════ +-- Git Forge Types +-- ═══════════════════════════════════════════════════════════════════════════ + +||| Supported git forge backends. +public export +data GitForge + = GitHub -- GitHub.com or GitHub Enterprise + | GitLab -- GitLab.com or self-hosted + | Gitea -- Gitea / Forgejo instances + | Bitbucket -- Bitbucket Cloud or Server + +||| C-ABI encoding for forges. +public export +forgeToInt : GitForge -> Int +forgeToInt GitHub = 1 +forgeToInt GitLab = 2 +forgeToInt Gitea = 3 +forgeToInt Bitbucket = 4 + +-- ═══════════════════════════════════════════════════════════════════════════ +-- Forge Session Record +-- ═══════════════════════════════════════════════════════════════════════════ + +||| A forge session with tracked state. +public export +record ForgeSession where + constructor MkForgeSession + sessionId : String + forge : GitForge + state : GitState + repoOwner : String + repoName : String + +||| Proof that a session has a repo selected. +public export +data HasRepo : ForgeSession -> Type where + ActiveRepo : (s : ForgeSession) -> + (state s = RepoSelected) -> + HasRepo s + +-- ═══════════════════════════════════════════════════════════════════════════ +-- MCP Tool Definitions +-- ═══════════════════════════════════════════════════════════════════════════ + +||| MCP tools exposed by this cartridge. +||| These map to MCP tool definitions that AI agents can call. +public export +data McpTool + = ToolAuthenticate -- Authenticate with a forge + | ToolSelectRepo -- Select a repository context + | ToolCreatePR -- Create a pull/merge request + | ToolMergePR -- Merge a pull/merge request + | ToolCreateIssue -- Create an issue + | ToolListBranches -- List branches in selected repo + | ToolClone -- Clone a repository + | ToolPush -- Push commits + | ToolStatus -- Session and repo status + +||| MCP tool name (for JSON-RPC method name). +public export +toolName : McpTool -> String +toolName ToolAuthenticate = "git/authenticate" +toolName ToolSelectRepo = "git/select-repo" +toolName ToolCreatePR = "git/create-pr" +toolName ToolMergePR = "git/merge-pr" +toolName ToolCreateIssue = "git/create-issue" +toolName ToolListBranches = "git/list-branches" +toolName ToolClone = "git/clone" +toolName ToolPush = "git/push" +toolName ToolStatus = "git/status" + +||| Which tools require a selected repository context. +public export +requiresRepo : McpTool -> Bool +requiresRepo ToolAuthenticate = False +requiresRepo ToolSelectRepo = False +requiresRepo ToolStatus = False +requiresRepo _ = True + +-- ═══════════════════════════════════════════════════════════════════════════ +-- C-ABI Exports +-- ═══════════════════════════════════════════════════════════════════════════ + +||| Git state to integer. +public export +gitStateToInt : GitState -> Int +gitStateToInt Unauthenticated = 0 +gitStateToInt Authenticated = 1 +gitStateToInt RepoSelected = 2 +gitStateToInt Operating = 3 +gitStateToInt GitError = 4 + +||| FFI: Validate a state transition. +export +git_can_transition : Int -> Int -> Int +git_can_transition from to = + let fromState = case from of + 0 => Unauthenticated + 1 => Authenticated + 2 => RepoSelected + 3 => Operating + _ => GitError + toState = case to of + 0 => Unauthenticated + 1 => Authenticated + 2 => RepoSelected + 3 => Operating + _ => GitError + in if canTransition fromState toState then 1 else 0 + +||| FFI: Check if a tool requires a selected repo. +export +git_tool_requires_repo : Int -> Int +git_tool_requires_repo 1 = 0 -- ToolAuthenticate +git_tool_requires_repo 2 = 0 -- ToolSelectRepo +git_tool_requires_repo 9 = 0 -- ToolStatus +git_tool_requires_repo _ = 1 -- All others require repo diff --git a/cartridges/domains/development/git-mcp/abi/README.adoc b/cartridges/domains/development/git-mcp/abi/README.adoc new file mode 100644 index 0000000..c486c9e --- /dev/null +++ b/cartridges/domains/development/git-mcp/abi/README.adoc @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += git-mcp / abi β€” Idris2 ABI layer +:orientation: deep + +== Purpose + +Encodes the forge-session state machine +(`Unauthenticated β†’ Authenticated β†’ RepoSelected β†’ Operating β†’ RepoSelected`) +as an Idris2 indexed type plus a parallel runtime predicate. The ABI is the +source-of-truth spec; the FFI implements the same graph in Zig. Tools are +tagged with their state precondition so the FFI can refuse an operation that +would require a `RepoSelected` session when no repo has been selected. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `git-mcp.ipkg` | Idris2 package descriptor. `modules = GitMcp.SafeGit`, `depends = base, contrib`. Type-check with `idris2 --check git-mcp.ipkg`. +| `GitMcp/SafeGit.idr` | 5-state `GitState`, `ValidTransition` type proof, `ForgeSession` record with state tag, `HasRepo` proof, the `McpTool` enum, `requiresRepo` predicate, and C-ABI exports `git_can_transition`, `git_tool_requires_repo`. +|=== + +== Invariants + +* Linear session pipeline: the state machine cannot jump β€” e.g. a tool that + needs `RepoSelected` cannot be called from `Authenticated` without going + through `git_select_repo`. +* Read-only ops (`ToolStatus`, `ToolLog`, `ToolDiff`) do **not** require a + selected repo; mutating ops (`ToolCreatePR`, `ToolPush`, `ToolCreateBranch`) + do. See `requiresRepo` predicate. +* Module commentary at line 26 pins the intended progression: + `Unauthenticated -> Authenticated -> RepoSelected -> Operating -> RepoSelected`. + +== Test/proof surface + +Type-check only (`idris2 --check`). No inline `test` blocks. + +== Read-first + +. Lines 22–63 β€” `GitState`, `ValidTransition` constructors, and `canTransition`. +. Lines 89–104 β€” `ForgeSession` record and `HasRepo` proof. +. Lines 113–143 β€” `McpTool` constructors and `requiresRepo` predicate. diff --git a/cartridges/domains/development/git-mcp/abi/git-mcp.ipkg b/cartridges/domains/development/git-mcp/abi/git-mcp.ipkg new file mode 100644 index 0000000..24114a1 --- /dev/null +++ b/cartridges/domains/development/git-mcp/abi/git-mcp.ipkg @@ -0,0 +1,11 @@ +-- SPDX-License-Identifier: MPL-2.0 +package gitmcp + +authors = "Jonathan D.A. Jewell" +version = 0.1.0 +license = "MPL-2.0" +brief = "Git MCP cartridge β€” forge authentication state machine with operation scope safety" + +sourcedir = "." +modules = GitMcp.SafeGit +depends = base, contrib diff --git a/cartridges/domains/development/git-mcp/adapter/README.adoc b/cartridges/domains/development/git-mcp/adapter/README.adoc new file mode 100644 index 0000000..6ce2b8a --- /dev/null +++ b/cartridges/domains/development/git-mcp/adapter/README.adoc @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += git-mcp / adapter β€” REST/gRPC/GraphQL bridge +:orientation: deep + +== Purpose + +Exposes the git-mcp FFI over three protocols: REST on **9250**, gRPC-compat on +**9251**, GraphQL on **9252**. Stateless β€” the session state lives behind the +FFI mutex in `../ffi/`. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph for the adapter binary. +| `git_adapter.zig` | Three listener threads spawned from `main`. `dispatchRest`, `dispatchGrpc`, and `dispatchGraphql` map a path or body shape to a tool name, then call the shared `dispatch` stub that forwards to the FFI. Tool set: `git_authenticate`, `git_select_repo`, `git_status`, `git_log`, `git_diff`, `git_create_branch`, `git_push`. +| `SIDELINED-git_adapter.v.adoc` | Archived zig predecessor. Kept for lineage; not built. +|=== + +== Invariants + +* **Stateless** β€” the adapter carries no session data. +* **Port binding** β€” three sibling ports, no header-multiplexing. +* Responses always `application/json`; 200 success, 404 unknown tool. + +== Test/proof surface + +No inline tests. Adapter is integration scaffolding. + +== Read-first + +. Lines 27–51 β€” `dispatch` mapping (tool name β†’ FFI call). +. Lines 53–70 β€” REST/gRPC routing. +. Lines 125–144 β€” listener loop and `main`. diff --git a/cartridges/domains/development/git-mcp/adapter/build.zig b/cartridges/domains/development/git-mcp/adapter/build.zig new file mode 100644 index 0000000..4c6dab2 --- /dev/null +++ b/cartridges/domains/development/git-mcp/adapter/build.zig @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// git-mcp/adapter/build.zig + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const ffi_mod = b.createModule(.{ + .root_source_file = b.path("../ffi/git_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + const adapter = b.addExecutable(.{ + .name = "git_adapter", + .root_source_file = b.path("git_adapter.zig"), + .target = target, + .optimize = optimize, + }); + adapter.root_module.addImport("git_ffi", ffi_mod); + b.installArtifact(adapter); +} diff --git a/cartridges/domains/development/git-mcp/adapter/git_adapter.zig b/cartridges/domains/development/git-mcp/adapter/git_adapter.zig new file mode 100644 index 0000000..7bab848 --- /dev/null +++ b/cartridges/domains/development/git-mcp/adapter/git_adapter.zig @@ -0,0 +1,144 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// git-mcp/adapter/git_adapter.zig +// +// Three-protocol BoJ adapter: REST (port 9250), gRPC-compat (port 9251), +// GraphQL (port 9252). +// Replaces the banned zig adapter (git_adapter.v). + +const std = @import("std"); +const ffi = @import("git_ffi"); + +const REST_PORT: u16 = 9250; +const GRPC_PORT: u16 = 9251; +const GQL_PORT: u16 = 9252; + +const Response = struct { status: u16, body: []const u8 }; + +fn okJson(buf: []u8, msg: []const u8) []u8 { + return std.fmt.bufPrint(buf, "{{\"success\":true,\"message\":\"{s}\"}}", .{msg}) catch buf[0..0]; +} + +fn errJson(buf: []u8, msg: []const u8) []u8 { + return std.fmt.bufPrint(buf, "{{\"success\":false,\"error\":\"{s}\"}}", .{msg}) catch buf[0..0]; +} + +fn dispatch(tool: []const u8, _body: []const u8, resp: []u8) Response { + _ = _body; + if (std.mem.eql(u8, tool, "git_authenticate")) { + return .{ .status = 200, .body = okJson(resp, "git_authenticate forwarded") }; + } + if (std.mem.eql(u8, tool, "git_select_repo")) { + return .{ .status = 200, .body = okJson(resp, "git_select_repo forwarded") }; + } + if (std.mem.eql(u8, tool, "git_status")) { + return .{ .status = 200, .body = okJson(resp, "git_status forwarded") }; + } + if (std.mem.eql(u8, tool, "git_log")) { + return .{ .status = 200, .body = okJson(resp, "git_log forwarded") }; + } + if (std.mem.eql(u8, tool, "git_diff")) { + return .{ .status = 200, .body = okJson(resp, "git_diff forwarded") }; + } + if (std.mem.eql(u8, tool, "git_create_branch")) { + return .{ .status = 200, .body = okJson(resp, "git_create_branch forwarded") }; + } + if (std.mem.eql(u8, tool, "git_push")) { + return .{ .status = 200, .body = okJson(resp, "git_push forwarded") }; + } + return .{ .status = 404, .body = errJson(resp, "unknown tool") }; +} + +fn dispatchRest(path: []const u8, body: []const u8, resp: []u8) Response { + // Expect /tools/<tool_name> + const prefix = "/tools/"; + if (std.mem.startsWith(u8, path, prefix)) { + const tool = path[prefix.len..]; + return dispatch(tool, body, resp); + } + return .{ .status = 404, .body = errJson(resp, "not found") }; +} + +fn dispatchGrpc(path: []const u8, body: []const u8, resp: []u8) Response { + // Expect /<Service>/<Method> β€” derive tool from Method + var it = std.mem.splitScalar(u8, path, '/'); + _ = it.next(); // leading empty + _ = it.next(); // service + const method = it.next() orelse return .{ .status = 404, .body = errJson(resp, "bad gRPC path") }; + return dispatch(method, body, resp); +} + +fn dispatchGraphql(body: []const u8, resp: []u8) Response { + if (std.mem.indexOf(u8, body, "git_authenticate") != null) + return dispatch("git_authenticate", body, resp); + if (std.mem.indexOf(u8, body, "git_select_repo") != null) + return dispatch("git_select_repo", body, resp); + if (std.mem.indexOf(u8, body, "git_status") != null) + return dispatch("git_status", body, resp); + if (std.mem.indexOf(u8, body, "git_log") != null) + return dispatch("git_log", body, resp); + if (std.mem.indexOf(u8, body, "git_diff") != null) + return dispatch("git_diff", body, resp); + if (std.mem.indexOf(u8, body, "git_create_branch") != null) + return dispatch("git_create_branch", body, resp); + if (std.mem.indexOf(u8, body, "git_push") != null) + return dispatch("git_push", body, resp); + return .{ .status = 400, .body = errJson(resp, "unrecognised GraphQL operation") }; +} + +fn handleConnection(stream: std.net.Stream, port: u16) void { + defer stream.close(); + var buf: [4096]u8 = undefined; + var resp_buf: [4096]u8 = undefined; + const n = stream.read(&buf) catch return; + const req = buf[0..n]; + const result = switch (port) { + REST_PORT => blk: { + // Parse HTTP/1.1: first line = METHOD PATH HTTP/x.y + var lines = std.mem.splitScalar(u8, req, '\n'); + const first = lines.next() orelse break :blk Response{ .status = 400, .body = "" }; + var parts = std.mem.splitScalar(u8, std.mem.trim(u8, first, "\r"), ' '); + _ = parts.next(); // method + const path = parts.next() orelse break :blk Response{ .status = 400, .body = "" }; + break :blk dispatchRest(path, req, &resp_buf); + }, + GRPC_PORT => blk: { + var lines = std.mem.splitScalar(u8, req, '\n'); + const first = lines.next() orelse break :blk Response{ .status = 400, .body = "" }; + var parts = std.mem.splitScalar(u8, std.mem.trim(u8, first, "\r"), ' '); + _ = parts.next(); + const path = parts.next() orelse break :blk Response{ .status = 400, .body = "" }; + break :blk dispatchGrpc(path, req, &resp_buf); + }, + GQL_PORT => dispatchGraphql(req, &resp_buf), + else => Response{ .status = 500, .body = "" }, + }; + var http_resp: [512]u8 = undefined; + const http = std.fmt.bufPrint(&http_resp, + "HTTP/1.1 {d} OK\r\nContent-Length: {d}\r\nContent-Type: application/json\r\n\r\n", + .{ result.status, result.body.len }) catch return; + _ = stream.write(http) catch {}; + _ = stream.write(result.body) catch {}; +} + +fn listenLoop(port: u16) !void { + const addr = std.net.Address.initIp4(.{ 127, 0, 0, 1 }, port); + var server = try addr.listen(.{ .reuse_address = true }); + defer server.deinit(); + while (true) { + const conn = try server.accept(); + const t = try std.Thread.spawn(.{}, handleConnection, .{ conn.stream, port }); + t.detach(); + } +} + +pub fn main() !void { + ffi.git_init(); + const t1 = try std.Thread.spawn(.{}, listenLoop, .{REST_PORT}); + const t2 = try std.Thread.spawn(.{}, listenLoop, .{GRPC_PORT}); + const t3 = try std.Thread.spawn(.{}, listenLoop, .{GQL_PORT}); + t1.join(); + t2.join(); + t3.join(); +} diff --git a/cartridges/domains/development/git-mcp/cartridge.json b/cartridges/domains/development/git-mcp/cartridge.json new file mode 100644 index 0000000..a704737 --- /dev/null +++ b/cartridges/domains/development/git-mcp/cartridge.json @@ -0,0 +1,184 @@ +{ + "$schema": "https://boj.dev/schemas/cartridge/v1.json", + "spdx": "MPL-2.0", + "copyright": "Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk>", + "name": "git-mcp", + "version": "0.1.0", + "description": "Multi-forge git operations (GitHub, GitLab, Gitea, Bitbucket)", + "domain": "development", + "tier": "Ayo", + "protocols": [ + "MCP", + "REST" + ], + "auth": { + "method": "none", + "env_var": null, + "credential_source": null + }, + "api": { + "base_url": "local://git-mcp", + "content_type": "application/json" + }, + "tools": [ + { + "name": "git_authenticate", + "description": "Authenticate with a git forge", + "inputSchema": { + "type": "object", + "properties": { + "forge": { + "type": "string", + "description": "Forge: github|gitlab|gitea|bitbucket" + }, + "token": { + "type": "string", + "description": "Personal access token" + } + }, + "required": [ + "forge", + "token" + ] + } + }, + { + "name": "git_select_repo", + "description": "Select a repository to work with", + "inputSchema": { + "type": "object", + "properties": { + "session_id": { + "type": "integer", + "description": "Session index" + }, + "owner": { + "type": "string", + "description": "Repo owner" + }, + "repo": { + "type": "string", + "description": "Repo name" + } + }, + "required": [ + "session_id", + "owner", + "repo" + ] + } + }, + { + "name": "git_status", + "description": "Get git status of selected repo", + "inputSchema": { + "type": "object", + "properties": { + "session_id": { + "type": "integer", + "description": "Session index" + } + }, + "required": [ + "session_id" + ] + } + }, + { + "name": "git_log", + "description": "Get git log", + "inputSchema": { + "type": "object", + "properties": { + "session_id": { + "type": "integer", + "description": "Session index" + }, + "limit": { + "type": "integer", + "description": "Max entries" + } + }, + "required": [ + "session_id" + ] + } + }, + { + "name": "git_diff", + "description": "Get git diff", + "inputSchema": { + "type": "object", + "properties": { + "session_id": { + "type": "integer", + "description": "Session index" + }, + "ref_a": { + "type": "string", + "description": "Base ref" + }, + "ref_b": { + "type": "string", + "description": "Compare ref" + } + }, + "required": [ + "session_id" + ] + } + }, + { + "name": "git_create_branch", + "description": "Create a new branch", + "inputSchema": { + "type": "object", + "properties": { + "session_id": { + "type": "integer", + "description": "Session index" + }, + "branch_name": { + "type": "string", + "description": "Branch name" + } + }, + "required": [ + "session_id", + "branch_name" + ] + } + }, + { + "name": "git_push", + "description": "Push to remote", + "inputSchema": { + "type": "object", + "properties": { + "session_id": { + "type": "integer", + "description": "Session index" + }, + "branch": { + "type": "string", + "description": "Branch to push" + } + }, + "required": [ + "session_id" + ] + } + } + ], + "ffi": { + "so_path": "ffi/zig-out/lib/libgit_mcp.so", + "abi_version": "ADR-0006", + "symbols": [ + "boj_cartridge_init", + "boj_cartridge_deinit", + "boj_cartridge_name", + "boj_cartridge_version", + "boj_cartridge_invoke" + ] + } +} diff --git a/cartridges/domains/development/git-mcp/ffi/README.adoc b/cartridges/domains/development/git-mcp/ffi/README.adoc new file mode 100644 index 0000000..1f3a0ae --- /dev/null +++ b/cartridges/domains/development/git-mcp/ffi/README.adoc @@ -0,0 +1,55 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += git-mcp / ffi β€” Zig FFI layer +:orientation: deep + +== Purpose + +Mutex-guarded forge-session manager. Up to 8 concurrent sessions +(`MAX_FORGES = 8`); each slot carries an authentication state, an optional +selected repo identified by a u64 hash of owner+name, and a cursor on the +ABI state machine. Implements the transitions from `SafeGit.idr` as a Zig +predicate (`isValidTransition`) used before every mutation. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph (shared library + test binary). +| `git_ffi.zig` | `ForgeSlot` struct, global `forges` array, `mutex`, `isValidTransition`, hash-based `hashRepo`, and the C-ABI exports: `git_authenticate`, `git_select_repo`, `git_begin_operation`, `git_end_operation`, `git_logout`, `git_state`, `git_can_transition`, `git_reset`, plus the four `boj_cartridge_*` symbols. +| `zig-out/` | Build artefacts (gitignored). +|=== + +== Invariants + +* **Mutex discipline.** `mutex.lock()` guards the forges array on every + state transition β€” see lines 82, 96, 110, 123, 136, 151, 161. +* **Slot bounds.** `idx` must be in `[0, MAX_FORGES)`; overflow returns `-1`. +* **Transition validation.** `isValidTransition` (lines 54–62) mirrors the + Idris2 graph exactly. Changes in one must land in the other. +* **Hash-based repo identity.** `hashRepo` combines owner+name into a u64 + key (lines 64–77). Repo selection rejects zero-length names; two selects + with the same owner+name will produce the same key. + +== Test/proof surface + +6 inline `test "..."` blocks (lines 213–272): + +* authenticate β†’ logout round-trip +* cannot operate while unauthenticated +* cannot operate without repo selected +* full lifecycle walkthrough +* slot exhaustion (9th authenticate fails) +* state-transition validation matrix + +Run with `cd ffi && zig build test`. + +== Read-first + +. Lines 35–49 β€” `ForgeSlot` struct and global state. +. Lines 54–62 β€” `isValidTransition` β€” the mirror of the Idris2 graph. +. Lines 79–93 β€” `git_authenticate` (slot allocation). +. Lines 96–107 β€” `git_select_repo` with the state-gate check. +. Lines 213–272 β€” tests. diff --git a/cartridges/domains/development/git-mcp/ffi/build.zig b/cartridges/domains/development/git-mcp/ffi/build.zig new file mode 100644 index 0000000..0b941b4 --- /dev/null +++ b/cartridges/domains/development/git-mcp/ffi/build.zig @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// Git-MCP Cartridge β€” Zig FFI build configuration (Zig 0.15+). + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + // Shared ADR-0006 invoke-shim module (relative path up to boj-server trunk). + + const shim_mod = b.addModule("cartridge_shim", .{ + + .root_source_file = b.path("../../../ffi/zig/src/cartridge_shim.zig"), + + .target = target, + + .optimize = optimize, + + }); + + const git_mod = b.addModule("git_ffi", .{ + .root_source_file = b.path("git_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + git_mod.addImport("cartridge_shim", shim_mod); + + // ── Tests ──────────────────────────────────────────────────────── + const git_tests = b.addTest(.{ + .root_module = git_mod, + }); + + const run_tests = b.addRunArtifact(git_tests); + + const test_step = b.step("test", "Run git-mcp FFI tests"); + test_step.dependOn(&run_tests.step); + + // ── Shared library ────────────────────────────────────────────── + const lib_mod = b.createModule(.{ + .root_source_file = b.path("git_ffi.zig"), + .target = target, + .optimize = optimize, + }); + lib_mod.addImport("cartridge_shim", shim_mod); + + const lib = b.addLibrary(.{ + .name = "git_mcp", + .root_module = lib_mod, + .linkage = .dynamic, + }); + b.installArtifact(lib); + + const lib_step = b.step("lib", "Build shared library"); + lib_step.dependOn(&lib.step); +} diff --git a/cartridges/domains/development/git-mcp/ffi/cartridge_shim.zig b/cartridges/domains/development/git-mcp/ffi/cartridge_shim.zig new file mode 100644 index 0000000..0e399db --- /dev/null +++ b/cartridges/domains/development/git-mcp/ffi/cartridge_shim.zig @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// cartridge_shim.zig β€” Shared helpers for the ADR-0006 five-symbol +// cartridge ABI (`boj_cartridge_init / deinit / name / version / invoke`). +// +// The shim centralises the seven-code return convention, NUL-argument +// guards, tool-name comparison, and the buffer-too-small path so each +// cartridge's `boj_cartridge_invoke` can stay short β€” typically a tool +// table plus `shim.writeResult(...)`. +// +// Cartridges import this file by relative path (no build-graph change +// needed). Example: +// +// const shim = @import("../../../ffi/zig/src/cartridge_shim.zig"); +// +// export fn boj_cartridge_invoke( +// tool_name: [*c]const u8, +// json_args: [*c]const u8, +// out_buf: [*c]u8, +// in_out_len: [*c]usize, +// ) callconv(.c) i32 { +// _ = json_args; +// if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; +// const body = if (shim.toolIs(tool_name, "foo")) "{\"result\":{}}" +// else return shim.RC_UNKNOWN_TOOL; +// return shim.writeResult(out_buf, in_out_len, body); +// } + +const std = @import("std"); + +// ── Return codes (ADR-0006 Β§Return codes) ──────────────────────────── +// +// Frozen by ADR-0006. New failure modes compose these via the error +// JSON body β€” the integer surface does not grow without a follow-up ADR. + +pub const RC_SUCCESS: i32 = 0; +pub const RC_UNKNOWN_TOOL: i32 = -1; +pub const RC_BAD_ARGS: i32 = -2; +pub const RC_BUFFER_TOO_SMALL: i32 = -3; +pub const RC_RUNTIME_ERROR: i32 = -4; +pub const RC_PANIC: i32 = -5; +pub const RC_AUTH_DENIED: i32 = -6; + +// ── Invoke-path helpers ────────────────────────────────────────────── + +/// True if any of the three mandatory `boj_cartridge_invoke` output-path +/// pointers is null. Use at the top of every invoke to short-circuit to +/// `RC_BAD_ARGS`. +pub fn invokeArgsNull( + tool_name: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) bool { + return tool_name == null or out_buf == null or in_out_len == null; +} + +/// Compare a C-NUL-terminated tool-name pointer against a Zig string +/// literal. Caller must have already verified `tool_name` is non-null +/// (usually via `invokeArgsNull`). +pub fn toolIs(tool_name: [*c]const u8, expected: []const u8) bool { + const s = std.mem.span(@as([*:0]const u8, @ptrCast(tool_name))); + return std.mem.eql(u8, s, expected); +} + +/// Copy `body` into `out_buf[0..*in_out_len]` (as a capacity) and update +/// `*in_out_len` to the number of bytes written. Returns `RC_SUCCESS`. +/// +/// If `body.len` exceeds the current capacity stored in `*in_out_len`, +/// sets `*in_out_len` to the required size and returns +/// `RC_BUFFER_TOO_SMALL` β€” the caller is then expected to re-allocate +/// and retry, per ADR-0006 Β§Memory ownership. +/// +/// Caller must have already verified that `out_buf` and `in_out_len` +/// are non-null. +pub fn writeResult( + out_buf: [*c]u8, + in_out_len: [*c]usize, + body: []const u8, +) i32 { + const cap = in_out_len.*; + if (body.len > cap) { + in_out_len.* = body.len; + return RC_BUFFER_TOO_SMALL; + } + @memcpy(out_buf[0..body.len], body); + in_out_len.* = body.len; + return RC_SUCCESS; +} + +// ── Tests ──────────────────────────────────────────────────────────── + +test "writeResult: body fits, writes and sets length" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); + try std.testing.expectEqualStrings("hello", buf[0..len]); +} + +test "writeResult: too small returns -3 and sets required length" { + var buf: [2]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_BUFFER_TOO_SMALL, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: exact-fit succeeds" { + var buf: [5]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: empty body" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, ""); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 0), len); +} + +test "toolIs: matches and rejects" { + const name: [*:0]const u8 = "foo"; + try std.testing.expect(toolIs(@ptrCast(name), "foo")); + try std.testing.expect(!toolIs(@ptrCast(name), "bar")); + try std.testing.expect(!toolIs(@ptrCast(name), "foobar")); + try std.testing.expect(!toolIs(@ptrCast(name), "fo")); +} + +test "invokeArgsNull: detects each null slot" { + var buf: [4]u8 = undefined; + var len: usize = 4; + const name: [*:0]const u8 = "x"; + try std.testing.expect(!invokeArgsNull(@ptrCast(name), &buf, &len)); + try std.testing.expect(invokeArgsNull(null, &buf, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), null, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), &buf, null)); +} diff --git a/cartridges/domains/development/git-mcp/ffi/git_ffi.zig b/cartridges/domains/development/git-mcp/ffi/git_ffi.zig new file mode 100644 index 0000000..0a1d7b4 --- /dev/null +++ b/cartridges/domains/development/git-mcp/ffi/git_ffi.zig @@ -0,0 +1,347 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// Git-MCP Cartridge β€” Zig FFI bridge for git forge operations. +// +// Implements the forge authentication state machine from SafeGit.idr. +// Ensures no forge operation can execute without authentication, +// prevents cross-forge operations, and tracks PR/issue lifecycle. + +const std = @import("std"); + +// ═══════════════════════════════════════════════════════════════════════ +// Types (must match GitMcp.SafeGit encodings) +// ═══════════════════════════════════════════════════════════════════════ + +pub const GitState = enum(c_int) { + unauthenticated = 0, + authenticated = 1, + repo_selected = 2, + operating = 3, + git_error = 4, +}; + +pub const GitForge = enum(c_int) { + github = 1, + gitlab = 2, + gitea = 3, + bitbucket = 4, +}; + +// ═══════════════════════════════════════════════════════════════════════ +// Forge State Machine +// ═══════════════════════════════════════════════════════════════════════ + +const MAX_FORGES: usize = 8; + +const ForgeSlot = struct { + active: bool, + state: GitState, + forge: GitForge, + selected_repo_hash: u64, +}; + +var forges: [MAX_FORGES]ForgeSlot = [_]ForgeSlot{.{ + .active = false, + .state = .unauthenticated, + .forge = .github, + .selected_repo_hash = 0, +}} ** MAX_FORGES; + +var mutex: std.Thread.Mutex = .{}; + +/// Validate a state transition (matches Idris2 canTransition). +fn isValidTransition(from: GitState, to: GitState) bool { + return switch (from) { + .unauthenticated => to == .authenticated, + .authenticated => to == .repo_selected or to == .unauthenticated, + .repo_selected => to == .operating or to == .authenticated, + .operating => to == .repo_selected or to == .git_error, + .git_error => to == .authenticated, + }; +} + +/// Simple string hash for repo identification. +fn hashRepo(owner: [*:0]const u8, name: [*:0]const u8) u64 { + var h: u64 = 5381; + var i: usize = 0; + while (owner[i] != 0) : (i += 1) { + h = ((h << 5) +% h) +% @as(u64, owner[i]); + } + h = ((h << 5) +% h) +% @as(u64, '/'); + i = 0; + while (name[i] != 0) : (i += 1) { + h = ((h << 5) +% h) +% @as(u64, name[i]); + } + return h; +} + +/// Authenticate with a forge. Returns slot index or -1 on failure. +pub export fn git_authenticate(forge_type: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + for (&forges, 0..) |*slot, i| { + if (!slot.active) { + slot.active = true; + slot.state = .authenticated; + slot.forge = @enumFromInt(forge_type); + slot.selected_repo_hash = 0; + return @intCast(i); + } + } + return -1; // No slots available +} + +/// Select a repository context (transition Authenticated -> RepoSelected). +pub export fn git_select_repo(slot_idx: c_int, owner: [*:0]const u8, name: [*:0]const u8) c_int { + mutex.lock(); + defer mutex.unlock(); + if (slot_idx < 0 or slot_idx >= MAX_FORGES) return -1; + const idx: usize = @intCast(slot_idx); + if (!forges[idx].active) return -1; + if (!isValidTransition(forges[idx].state, .repo_selected)) return -2; + + forges[idx].state = .repo_selected; + forges[idx].selected_repo_hash = hashRepo(owner, name); + return 0; +} + +/// Begin an operation (transition RepoSelected -> Operating). +pub export fn git_begin_operation(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + if (slot_idx < 0 or slot_idx >= MAX_FORGES) return -1; + const idx: usize = @intCast(slot_idx); + if (!forges[idx].active) return -1; + if (!isValidTransition(forges[idx].state, .operating)) return -2; + + forges[idx].state = .operating; + return 0; +} + +/// End an operation successfully (transition Operating -> RepoSelected). +pub export fn git_end_operation(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + if (slot_idx < 0 or slot_idx >= MAX_FORGES) return -1; + const idx: usize = @intCast(slot_idx); + if (!forges[idx].active) return -1; + if (!isValidTransition(forges[idx].state, .repo_selected)) return -2; + + forges[idx].state = .repo_selected; + return 0; +} + +/// Logout from forge (transition Authenticated -> Unauthenticated). +pub export fn git_logout(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + if (slot_idx < 0 or slot_idx >= MAX_FORGES) return -1; + const idx: usize = @intCast(slot_idx); + if (!forges[idx].active) return -1; + if (!isValidTransition(forges[idx].state, .unauthenticated)) return -2; + + forges[idx].active = false; + forges[idx].state = .unauthenticated; + forges[idx].selected_repo_hash = 0; + return 0; +} + +/// Get the state of a forge session. +pub export fn git_state(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + if (slot_idx < 0 or slot_idx >= MAX_FORGES) return -1; + const idx: usize = @intCast(slot_idx); + if (!forges[idx].active) return @intFromEnum(GitState.unauthenticated); + return @intFromEnum(forges[idx].state); +} + +/// Validate a state transition (C-ABI export). +pub export fn git_can_transition(from: c_int, to: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + const f: GitState = @enumFromInt(from); + const t: GitState = @enumFromInt(to); + return if (isValidTransition(f, t)) 1 else 0; +} + +/// Reset all forge sessions (for testing). +pub export fn git_reset() void { + mutex.lock(); + defer mutex.unlock(); + for (&forges) |*slot| { + slot.active = false; + slot.state = .unauthenticated; + slot.selected_repo_hash = 0; + } +} + +// ═══════════════════════════════════════════════════════════════════════ +// Standard Cartridge Interface (loader expects these 4 C-ABI symbols) +// ═══════════════════════════════════════════════════════════════════════ + +/// Initialise the git-mcp cartridge. Resets all forge sessions. +pub export fn boj_cartridge_init() c_int { + git_reset(); + return 0; +} + +/// Deinitialise the git-mcp cartridge. Resets all forge sessions. +pub export fn boj_cartridge_deinit() void { + git_reset(); +} + +/// Return the cartridge name as a null-terminated C string. +pub export fn boj_cartridge_name() [*:0]const u8 { + mutex.lock(); + defer mutex.unlock(); + return "git-mcp"; +} + +/// Return the cartridge version as a null-terminated C string. +pub export fn boj_cartridge_version() [*:0]const u8 { + mutex.lock(); + defer mutex.unlock(); + return "0.1.0"; +} + +// ═══════════════════════════════════════════════════════════════════════ +// ADR-0006 dispatch (boj_cartridge_invoke, 5th standard symbol) +// ═══════════════════════════════════════════════════════════════════════ + +const shim = @import("cartridge_shim.zig"); + +/// Dispatch the cartridge.json MCP tools. Grade D Alpha β€” each arm +/// returns a stub JSON body shaped to the tool's intended response. +export fn boj_cartridge_invoke( + tool_name: [*c]const u8, + json_args: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) callconv(.c) i32 { + _ = json_args; + if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; + + const body: []const u8 = if (shim.toolIs(tool_name, "git_authenticate")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "git_select_repo")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "git_status")) + "{\"result\":{\"metadata\":{},\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "git_log")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "git_diff")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "git_create_branch")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "git_push")) + "{\"result\":{\"status\":\"stub\"}}" +else + return shim.RC_UNKNOWN_TOOL; + + return shim.writeResult(out_buf, in_out_len, body); +} + +// ═══════════════════════════════════════════════════════════════════════ +// Tests +// ═══════════════════════════════════════════════════════════════════════ + +test "authenticate and logout" { + git_reset(); + const slot = git_authenticate(@intFromEnum(GitForge.github)); + try std.testing.expect(slot >= 0); + try std.testing.expectEqual(@as(c_int, @intFromEnum(GitState.authenticated)), git_state(slot)); + try std.testing.expectEqual(@as(c_int, 0), git_logout(slot)); + try std.testing.expectEqual(@as(c_int, @intFromEnum(GitState.unauthenticated)), git_state(slot)); +} + +test "cannot operate without authentication" { + git_reset(); + // No slot authenticated β€” begin_operation on slot 0 should fail + try std.testing.expectEqual(@as(c_int, -1), git_begin_operation(0)); +} + +test "cannot operate without repo selected" { + git_reset(); + const slot = git_authenticate(@intFromEnum(GitForge.gitlab)); + // Authenticated but no repo selected β€” should fail + try std.testing.expectEqual(@as(c_int, -2), git_begin_operation(slot)); + _ = git_logout(slot); +} + +test "full operation lifecycle" { + git_reset(); + const slot = git_authenticate(@intFromEnum(GitForge.gitea)); + try std.testing.expectEqual(@as(c_int, 0), git_select_repo(slot, "hyperpolymath", "boj-server")); + try std.testing.expectEqual(@as(c_int, @intFromEnum(GitState.repo_selected)), git_state(slot)); + try std.testing.expectEqual(@as(c_int, 0), git_begin_operation(slot)); + try std.testing.expectEqual(@as(c_int, @intFromEnum(GitState.operating)), git_state(slot)); + try std.testing.expectEqual(@as(c_int, 0), git_end_operation(slot)); + try std.testing.expectEqual(@as(c_int, @intFromEnum(GitState.repo_selected)), git_state(slot)); +} + +test "cannot double-authenticate into same slot" { + git_reset(); + // Fill all slots + var i: usize = 0; + while (i < MAX_FORGES) : (i += 1) { + const s = git_authenticate(@intFromEnum(GitForge.github)); + try std.testing.expect(s >= 0); + } + // Next authenticate should fail β€” all slots occupied + try std.testing.expectEqual(@as(c_int, -1), git_authenticate(@intFromEnum(GitForge.github))); + git_reset(); +} + +test "state transition validation" { + // Valid transitions + try std.testing.expectEqual(@as(c_int, 1), git_can_transition(0, 1)); // unauth -> auth + try std.testing.expectEqual(@as(c_int, 1), git_can_transition(1, 2)); // auth -> repo_selected + try std.testing.expectEqual(@as(c_int, 1), git_can_transition(2, 3)); // repo_selected -> operating + try std.testing.expectEqual(@as(c_int, 1), git_can_transition(3, 2)); // operating -> repo_selected + try std.testing.expectEqual(@as(c_int, 1), git_can_transition(2, 1)); // repo_selected -> auth + try std.testing.expectEqual(@as(c_int, 1), git_can_transition(1, 0)); // auth -> unauth + // Invalid transitions + try std.testing.expectEqual(@as(c_int, 0), git_can_transition(0, 2)); // unauth -> repo_selected + try std.testing.expectEqual(@as(c_int, 0), git_can_transition(0, 3)); // unauth -> operating + try std.testing.expectEqual(@as(c_int, 0), git_can_transition(3, 0)); // operating -> unauth +} + +// ═══════════════════════════════════════════════════════════════════════ +// ADR-0006 invoke dispatch tests +// ═══════════════════════════════════════════════════════════════════════ + +test "invoke: each declared tool succeeds" { + var buf: [256]u8 = undefined; + const tools = [_][]const u8{ + "git_authenticate", + "git_select_repo", + "git_status", + "git_log", + "git_diff", + "git_create_branch", + "git_push", + }; + for (tools) |t| { + var len: usize = buf.len; + const rc = boj_cartridge_invoke(t.ptr, "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, 0), rc); + try std.testing.expect(std.mem.indexOf(u8, buf[0..len], "result") != null); + } +} + +test "invoke: unknown tool returns -1" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("nope", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -1), rc); +} + +test "invoke: buffer too small returns -3" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("git_authenticate", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -3), rc); + try std.testing.expect(len > 4); +} diff --git a/cartridges/domains/development/git-mcp/mod.js b/cartridges/domains/development/git-mcp/mod.js new file mode 100644 index 0000000..d58b79f --- /dev/null +++ b/cartridges/domains/development/git-mcp/mod.js @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// git-mcp/mod.js β€” Multi-forge git operations (GitHub, GitLab, Gitea, Bitbucket) +// +// Delegates to backend at http://127.0.0.1:7728 (override with GIT_BACKEND_URL). + +const BASE_URL = Deno.env.get("GIT_BACKEND_URL") ?? "http://127.0.0.1:7728"; +const TIMEOUT_MS = 15_000; + +async function post(path, payload) { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), TIMEOUT_MS); + try { + const r = await fetch(`${BASE_URL}${path}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + signal: ctrl.signal, + }); + const data = await r.json().catch(() => ({ success: false, error: "non-JSON response" })); + return { status: r.status, data }; + } catch (e) { + if (e.name === "AbortError") return { status: 504, data: { success: false, error: "git-mcp backend timed out" } }; + return { status: 503, data: { success: false, error: `git-mcp backend unavailable: ${e.message}` } }; + } finally { clearTimeout(t); } +} + +async function get(path) { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), TIMEOUT_MS); + try { + const r = await fetch(`${BASE_URL}${path}`, { method: "GET", signal: ctrl.signal }); + const data = await r.json().catch(() => ({ success: false, error: "non-JSON response" })); + return { status: r.status, data }; + } catch (e) { + if (e.name === "AbortError") return { status: 504, data: { success: false, error: "git-mcp backend timed out" } }; + return { status: 503, data: { success: false, error: `git-mcp backend unavailable: ${e.message}` } }; + } finally { clearTimeout(t); } +} + +export async function handleTool(toolName, args) { + switch (toolName) { + case "git_authenticate": + return post("/api/v1/git_authenticate", args ?? {}); + case "git_select_repo": + return post("/api/v1/git_select_repo", args ?? {}); + case "git_status": + return post("/api/v1/git_status", args ?? {}); + case "git_log": + return post("/api/v1/git_log", args ?? {}); + case "git_diff": + return post("/api/v1/git_diff", args ?? {}); + case "git_create_branch": + return post("/api/v1/git_create_branch", args ?? {}); + case "git_push": + return post("/api/v1/git_push", args ?? {}); + default: + return { status: 404, data: { error: `Unknown tool: ${toolName}` } }; + } +} diff --git a/cartridges/domains/development/git-mcp/panels/manifest.json b/cartridges/domains/development/git-mcp/panels/manifest.json new file mode 100644 index 0000000..fa801d0 --- /dev/null +++ b/cartridges/domains/development/git-mcp/panels/manifest.json @@ -0,0 +1,67 @@ +{ + "$schema": "https://panll.dev/schemas/panel-manifest/v1.json", + "spdx": "MPL-2.0", + "cartridge": "git-mcp", + "domain": "Version Control", + "version": "0.1.0", + "panels": [ + { + "id": "git-status", + "title": "Git Service Status", + "description": "Connectivity to GitHub, GitLab, and Bitbucket APIs", + "type": "status-indicator", + "data_source": { + "endpoint": "/cartridge/git-mcp/invoke", + "method": "POST", + "body": { "tool": "status" }, + "refresh_interval_ms": 5000 + }, + "widgets": [ + { + "type": "state-badge", + "field": "state", + "states": { + "ready": { "color": "#2ecc71", "icon": "git-branch" }, + "degraded": { "color": "#f39c12", "icon": "alert-circle" }, + "error": { "color": "#e74c3c", "icon": "alert-triangle" } + } + }, + { "type": "text", "field": "version", "label": "Version" } + ] + }, + { + "id": "git-forge-summary", + "title": "Forge Summary", + "description": "Repository counts and mirror status across all forges", + "type": "metric", + "data_source": { + "endpoint": "/cartridge/git-mcp/invoke", + "method": "POST", + "body": { "tool": "forge_summary" }, + "refresh_interval_ms": 30000 + }, + "widgets": [ + { "type": "counter", "field": "github_repos", "label": "GitHub Repos", "icon": "github" }, + { "type": "counter", "field": "gitlab_repos", "label": "GitLab Repos", "icon": "gitlab" }, + { "type": "counter", "field": "mirrors_synced", "label": "Mirrors Synced", "icon": "refresh-cw" } + ] + }, + { + "id": "git-recent-activity", + "title": "Recent Activity", + "description": "Latest commits, PRs, and issues across tracked repositories", + "type": "table", + "data_source": { + "endpoint": "/cartridge/git-mcp/invoke", + "method": "POST", + "body": { "tool": "recent_activity" }, + "refresh_interval_ms": 15000 + }, + "widgets": [ + { "type": "counter", "field": "open_prs", "label": "Open PRs", "icon": "git-pull-request" }, + { "type": "counter", "field": "open_issues", "label": "Open Issues", "icon": "circle-dot" }, + { "type": "counter", "field": "commits_today", "label": "Commits Today", "icon": "git-commit" } + ] + } + ] +} diff --git a/cartridges/domains/development/github-api-mcp/README.adoc b/cartridges/domains/development/github-api-mcp/README.adoc new file mode 100644 index 0000000..f966c19 --- /dev/null +++ b/cartridges/domains/development/github-api-mcp/README.adoc @@ -0,0 +1,109 @@ += github-api-mcp +Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk> +:spdx: MPL-2.0 +:tier: Ayo +:domain: Git +:protocols: MCP, REST, GraphQL + +== Overview + +BoJ cartridge wrapping the GitHub REST and GraphQL APIs. +Replaces the standalone `npx @modelcontextprotocol/server-github` MCP server +with a formally verified, type-safe cartridge following the Idris2 ABI / Zig FFI / zig adapter architecture. + +== Supported Operations (20 actions) + +[cols="1,1,1,2"] +|=== +| # | Action | Type | REST Endpoint + +| 0 | ListRepos | read | `GET /users/{owner}/repos` +| 1 | GetRepo | read | `GET /repos/{owner}/{name}` +| 2 | CreateIssue | mutation | `POST /repos/{owner}/{repo}/issues` +| 3 | ListIssues | read | `GET /repos/{owner}/{repo}/issues` +| 4 | GetIssue | read | `GET /repos/{owner}/{repo}/issues/{number}` +| 5 | CommentIssue | mutation | `POST /repos/{owner}/{repo}/issues/{number}/comments` +| 6 | CreatePR | mutation | `POST /repos/{owner}/{repo}/pulls` +| 7 | ListPRs | read | `GET /repos/{owner}/{repo}/pulls` +| 8 | GetPR | read | `GET /repos/{owner}/{repo}/pulls/{number}` +| 9 | MergePR | mutation | `PUT /repos/{owner}/{repo}/pulls/{number}/merge` +| 10 | ReviewPR | mutation | `POST /repos/{owner}/{repo}/pulls/{number}/reviews` +| 11 | ListBranches | read | `GET /repos/{owner}/{repo}/branches` +| 12 | CreateBranch | mutation | `POST /repos/{owner}/{repo}/git/refs` +| 13 | SearchCode | read | `GET /search/code?q={query}` +| 14 | SearchIssues | read | `GET /search/issues?q={query}` +| 15 | ListActions | read | `GET /repos/{owner}/{repo}/actions/runs` +| 16 | GetRelease | read | `GET /repos/{owner}/{repo}/releases/tags/{tag}` +| 17 | CreateRelease | mutation | `POST /repos/{owner}/{repo}/releases` +| 18 | GetFileContents | read | `GET /repos/{owner}/{repo}/contents/{path}` +| 19 | PushFiles | mutation | `PUT /repos/{owner}/{repo}/contents/{path}` +|=== + +Additionally, a GraphQL passthrough endpoint accepts arbitrary queries against +`https://api.github.com/graphql`. + +== Authentication Flow + +1. **Session open** -- creates a slot in state `Unauthenticated` +2. **Authenticate** -- supplies a Bearer token (retrieved from vault-mcp zero-knowledge proxy), transitions to `Authenticated` +3. **API calls** -- REST or GraphQL requests, each decrements the rate-limit counter +4. **Rate limiting** -- when `X-RateLimit-Remaining` reaches zero, the session transitions to `RateLimited`; after cooldown (reset time elapsed), `Resume` transitions back to `Authenticated` +5. **Error handling** -- network/API faults transition to `Error`; `ResetError` returns to `Unauthenticated` for re-authentication +6. **Logout** -- explicit `Authenticated -> Unauthenticated` transition, zeroes stored token + +=== State Machine + +[source] +---- +Unauthenticated --[Authenticate]--> Authenticated +Authenticated --[Throttle]------> RateLimited +RateLimited --[Resume]--------> Authenticated +Authenticated --[Fault]---------> Error +Error --[ResetError]----> Unauthenticated +Authenticated --[Logout]--------> Unauthenticated +---- + +== Architecture + +[cols="1,1,2"] +|=== +| Layer | Language | Purpose + +| ABI +| Idris2 +| Formally verified state machine with dependent-type proofs (`GithubApiMcp.SafeGit`) + +| FFI +| Zig +| C-compatible implementation with thread-safe session pool, HTTP stubs, rate-limit tracking + +| Adapter +| zig +| REST/GraphQL bridge to BoJ unified adapter β€” typed endpoints for all 20 actions +|=== + +== PanLL Panels + +Three panels are declared in `panels/manifest.json`: + +- **Auth Status** -- real-time authentication state badge +- **Rate Limit** -- remaining calls counter and reset timestamp +- **Recent API Calls** -- log stream of recent REST/GraphQL operations + +== Building + +[source,bash] +---- +# Build FFI shared library +cd ffi && zig build + +# Run FFI tests +cd ffi && zig build test + +# Type-check ABI +cd abi && idris2 --check github_api_mcp.ipkg +---- + +== Status + +Development -- HTTP client stubs in place; production integration pending vault-mcp token retrieval and live HTTP transport. diff --git a/cartridges/domains/development/github-api-mcp/abi/GithubApiMcp/SafeGit.idr b/cartridges/domains/development/github-api-mcp/abi/GithubApiMcp/SafeGit.idr new file mode 100644 index 0000000..805ba77 --- /dev/null +++ b/cartridges/domains/development/github-api-mcp/abi/GithubApiMcp/SafeGit.idr @@ -0,0 +1,257 @@ +-- SPDX-License-Identifier: MPL-2.0 +-- Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +-- +-- GithubApiMcp.SafeGit β€” Type-safe ABI for GitHub REST & GraphQL API cartridge. +-- +-- Replaces the standalone `npx @modelcontextprotocol/server-github` MCP server. +-- State machine with dependent-type proofs ensuring only valid transitions +-- can occur at the FFI boundary. Zero unsafe escape hatches. + +module GithubApiMcp.SafeGit + +%default total + +-- --------------------------------------------------------------------------- +-- Authentication state machine +-- --------------------------------------------------------------------------- + +||| Authentication and rate-limit state for GitHub API operations. +||| +||| Unauthenticated : No token set, cannot call API +||| Authenticated : Valid Bearer token, API calls permitted +||| RateLimited : GitHub X-RateLimit-Remaining hit zero; must wait +||| Error : Unrecoverable API/network error; requires reset +public export +data AuthState = Unauthenticated | Authenticated | RateLimited | Error + +||| Proof that a state transition is valid. +||| +||| Authenticate : Unauthenticated -> Authenticated (token provided) +||| Throttle : Authenticated -> RateLimited (rate limit hit) +||| Resume : RateLimited -> Authenticated (cooldown elapsed) +||| Fault : Authenticated -> Error (API/network failure) +||| ResetError : Error -> Unauthenticated (clear error state) +||| Logout : Authenticated -> Unauthenticated (explicit logout) +public export +data ValidTransition : AuthState -> AuthState -> Type where + Authenticate : ValidTransition Unauthenticated Authenticated + Throttle : ValidTransition Authenticated RateLimited + Resume : ValidTransition RateLimited Authenticated + Fault : ValidTransition Authenticated Error + ResetError : ValidTransition Error Unauthenticated + Logout : ValidTransition Authenticated Unauthenticated + +-- --------------------------------------------------------------------------- +-- C-ABI integer encoding for AuthState +-- --------------------------------------------------------------------------- + +||| Encode AuthState as C-compatible integer. +||| +||| Unauthenticated = 0, Authenticated = 1, RateLimited = 2, Error = 3 +export +authStateToInt : AuthState -> Int +authStateToInt Unauthenticated = 0 +authStateToInt Authenticated = 1 +authStateToInt RateLimited = 2 +authStateToInt Error = 3 + +||| Decode integer back to AuthState (returns Nothing for out-of-range values). +export +intToAuthState : Int -> Maybe AuthState +intToAuthState 0 = Just Unauthenticated +intToAuthState 1 = Just Authenticated +intToAuthState 2 = Just RateLimited +intToAuthState 3 = Just Error +intToAuthState _ = Nothing + +||| Check if a state transition is valid (C-ABI export). +||| Returns 1 for valid, 0 for invalid. +export +github_api_mcp_can_transition : Int -> Int -> Int +github_api_mcp_can_transition from to = + case (intToAuthState from, intToAuthState to) of + (Just Unauthenticated, Just Authenticated) => 1 -- Authenticate + (Just Authenticated, Just RateLimited) => 1 -- Throttle + (Just RateLimited, Just Authenticated) => 1 -- Resume + (Just Authenticated, Just Error) => 1 -- Fault + (Just Error, Just Unauthenticated) => 1 -- ResetError + (Just Authenticated, Just Unauthenticated) => 1 -- Logout + _ => 0 + +-- --------------------------------------------------------------------------- +-- GitHub action types +-- --------------------------------------------------------------------------- + +||| Enumeration of all GitHub API operations this cartridge supports. +||| +||| Covers: repos, issues, PRs, branches, actions, releases, search, code review. +public export +data GitHubAction + = ListRepos + | GetRepo + | CreateIssue + | ListIssues + | GetIssue + | CommentIssue + | CreatePR + | ListPRs + | GetPR + | MergePR + | ReviewPR + | ListBranches + | CreateBranch + | SearchCode + | SearchIssues + | ListActions + | GetRelease + | CreateRelease + | GetFileContents + | PushFiles + +||| Encode GitHubAction as C-compatible integer for FFI boundary. +export +actionToInt : GitHubAction -> Int +actionToInt ListRepos = 0 +actionToInt GetRepo = 1 +actionToInt CreateIssue = 2 +actionToInt ListIssues = 3 +actionToInt GetIssue = 4 +actionToInt CommentIssue = 5 +actionToInt CreatePR = 6 +actionToInt ListPRs = 7 +actionToInt GetPR = 8 +actionToInt MergePR = 9 +actionToInt ReviewPR = 10 +actionToInt ListBranches = 11 +actionToInt CreateBranch = 12 +actionToInt SearchCode = 13 +actionToInt SearchIssues = 14 +actionToInt ListActions = 15 +actionToInt GetRelease = 16 +actionToInt CreateRelease = 17 +actionToInt GetFileContents = 18 +actionToInt PushFiles = 19 + +||| Decode integer to GitHubAction. +export +intToAction : Int -> Maybe GitHubAction +intToAction 0 = Just ListRepos +intToAction 1 = Just GetRepo +intToAction 2 = Just CreateIssue +intToAction 3 = Just ListIssues +intToAction 4 = Just GetIssue +intToAction 5 = Just CommentIssue +intToAction 6 = Just CreatePR +intToAction 7 = Just ListPRs +intToAction 8 = Just GetPR +intToAction 9 = Just MergePR +intToAction 10 = Just ReviewPR +intToAction 11 = Just ListBranches +intToAction 12 = Just CreateBranch +intToAction 13 = Just SearchCode +intToAction 14 = Just SearchIssues +intToAction 15 = Just ListActions +intToAction 16 = Just GetRelease +intToAction 17 = Just CreateRelease +intToAction 18 = Just GetFileContents +intToAction 19 = Just PushFiles +intToAction _ = Nothing + +||| Total count of supported GitHub actions. +export +actionCount : Nat +actionCount = 20 + +-- --------------------------------------------------------------------------- +-- Action requirements: which state is needed? +-- --------------------------------------------------------------------------- + +||| Every GitHubAction requires the Authenticated state. +||| (All GitHub API calls need a valid token.) +export +actionRequiresAuth : GitHubAction -> Bool +actionRequiresAuth _ = True + +||| Check if a given action performs a mutation (write operation). +export +actionIsMutation : GitHubAction -> Bool +actionIsMutation CreateIssue = True +actionIsMutation CommentIssue = True +actionIsMutation CreatePR = True +actionIsMutation MergePR = True +actionIsMutation ReviewPR = True +actionIsMutation CreateBranch = True +actionIsMutation CreateRelease = True +actionIsMutation PushFiles = True +actionIsMutation _ = False + +-- --------------------------------------------------------------------------- +-- Rate limit record +-- --------------------------------------------------------------------------- + +||| Rate limit information parsed from GitHub API response headers. +||| +||| remaining : Calls left in the current window +||| resetTime : Unix epoch seconds when the window resets +||| limit : Maximum calls permitted per window +public export +record RateLimit where + constructor MkRateLimit + remaining : Nat + resetTime : Nat + limit : Nat + +||| Check if rate-limited (zero remaining calls). +export +isRateLimited : RateLimit -> Bool +isRateLimited rl = remaining rl == 0 + +-- --------------------------------------------------------------------------- +-- C-ABI exports for action validation +-- --------------------------------------------------------------------------- + +||| Validate an action code. Returns 1 if valid, 0 if out of range. +export +github_api_mcp_valid_action : Int -> Int +github_api_mcp_valid_action code = + case intToAction code of + Just _ => 1 + Nothing => 0 + +||| Check if an action is a mutation. Returns 1 for mutation, 0 for read-only, +||| -1 for invalid action code. +export +github_api_mcp_is_mutation : Int -> Int +github_api_mcp_is_mutation code = + case intToAction code of + Just act => if actionIsMutation act then 1 else 0 + Nothing => -1 + +-- --------------------------------------------------------------------------- +-- MCP tool declarations +-- --------------------------------------------------------------------------- + +||| Tools exposed via MCP protocol for the GitHub API cartridge. +public export +data McpTool + = ToolAuthenticate + | ToolRequest + | ToolGraphQL + | ToolStatus + | ToolRateLimit + | ToolListTools + +||| Check if a tool requires an authenticated session. +export +toolRequiresAuth : McpTool -> Bool +toolRequiresAuth ToolAuthenticate = False +toolRequiresAuth ToolRequest = True +toolRequiresAuth ToolGraphQL = True +toolRequiresAuth ToolStatus = False +toolRequiresAuth ToolRateLimit = False +toolRequiresAuth ToolListTools = False + +||| Tool count for this cartridge. +export +toolCount : Nat +toolCount = 6 diff --git a/cartridges/domains/development/github-api-mcp/abi/README.adoc b/cartridges/domains/development/github-api-mcp/abi/README.adoc new file mode 100644 index 0000000..b162cec --- /dev/null +++ b/cartridges/domains/development/github-api-mcp/abi/README.adoc @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += github-api-mcp / abi β€” Idris2 ABI layer +:orientation: shallow + +== Purpose + +Cartridge-local protocol types for `github-api-mcp`. Defines the data, tool, and +transition vocabulary this cartridge speaks. Inherits safety lemmas and the +matrix-cell identifier scheme from the trunk ABI at `src/abi/`. + +== Files + +1 Idris2 module(s), ~257 lines total. Lead module: +`SafeGit.idr`. + +No `.ipkg` β€” consumed directly by wider BoJ callers; type-checks inside the +trunk `boj.ipkg`. + +== Invariants + +* `%default total` at every module top. +* Zero `believe_me`, `assert_total`, `postulate`, `Admitted`, or + `sorry` (the trunk holds the four declared `believe_me` sites; leaf + cartridges carry none). + +== Test/proof surface + +Thin module(s), no inline tests. Proof surface = type-check success under the +trunk `idris2 --check`. + +== Read-first + +. `SafeGit.idr` β€” cartridge-local data records and enums. +. Trunk `src/abi/Boj/Catalogue.idr` for how this cell participates in the + 2D matrix. diff --git a/cartridges/domains/development/github-api-mcp/abi/github_api_mcp.ipkg b/cartridges/domains/development/github-api-mcp/abi/github_api_mcp.ipkg new file mode 100644 index 0000000..da017ca --- /dev/null +++ b/cartridges/domains/development/github-api-mcp/abi/github_api_mcp.ipkg @@ -0,0 +1,10 @@ +-- SPDX-License-Identifier: MPL-2.0 +package github_api_mcp + +version = "0.2.0" +authors = "Jonathan D.A. Jewell" +brief = "GitHub REST & GraphQL API cartridge β€” type-safe ABI layer" + +depends = base + +modules = GithubApiMcp.SafeGit diff --git a/cartridges/domains/development/github-api-mcp/adapter/README.adoc b/cartridges/domains/development/github-api-mcp/adapter/README.adoc new file mode 100644 index 0000000..96731ae --- /dev/null +++ b/cartridges/domains/development/github-api-mcp/adapter/README.adoc @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += github-api-mcp / adapter β€” protocol bridge +:orientation: shallow + +== Purpose + +Exposes this cartridge's FFI over the wire protocols declared in +`cartridge.json`. Dispatches tool invocations to the C-ABI exports at +`../ffi/` and formats responses per protocol. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” depends on the sibling `ffi` target. +| `github_api_mcp_adapter.zig` | Protocol dispatch (149 lines). Tool catalogue matches `cartridge.json`. +| `SIDELINED-*.v.adoc` | zig predecessor(s). Not built β€” zig banned 2026-04-10. +|=== + +== Invariants + +* **Stateless** β€” all state lives behind the FFI, never in the adapter. +* **Response passthrough** β€” whatever the FFI returns goes back to the wire + unmodified (no embellishment, no silent recovery). +* **Cartridge.json is source of truth** for the tool catalogue; drift between + adapter and manifest is a CI failure. + +== Test/proof surface + +No inline tests at the adapter layer β€” protocol routing is exercised by the +trunk seam tests at `ffi/zig/src/seams.zig`. + +== Read-first + +. `github_api_mcp_adapter.zig` β€” dispatch function and per-protocol routers. +. `../cartridge.json` β€” the tool manifest the dispatcher mirrors. diff --git a/cartridges/domains/development/github-api-mcp/adapter/build.zig b/cartridges/domains/development/github-api-mcp/adapter/build.zig new file mode 100644 index 0000000..cb6e3cb --- /dev/null +++ b/cartridges/domains/development/github-api-mcp/adapter/build.zig @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// github-api-mcp/adapter/build.zig + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const ffi_mod = b.createModule(.{ + .root_source_file = b.path("../ffi/github_api_mcp_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + const adapter = b.addExecutable(.{ + .name = "github_api_mcp_adapter", + .root_source_file = b.path("github_api_mcp_adapter.zig"), + .target = target, + .optimize = optimize, + }); + adapter.root_module.addImport("github_api_mcp_ffi", ffi_mod); + b.installArtifact(adapter); +} diff --git a/cartridges/domains/development/github-api-mcp/adapter/github_api_mcp_adapter.zig b/cartridges/domains/development/github-api-mcp/adapter/github_api_mcp_adapter.zig new file mode 100644 index 0000000..ad5e8c0 --- /dev/null +++ b/cartridges/domains/development/github-api-mcp/adapter/github_api_mcp_adapter.zig @@ -0,0 +1,149 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// github-api-mcp/adapter/github_api_mcp_adapter.zig +// +// Three-protocol BoJ adapter: REST (port 9244), gRPC-compat (port 9245), +// GraphQL (port 9246). +// Replaces the banned zig adapter (github_api_mcp_adapter.v). + +const std = @import("std"); +const ffi = @import("github_api_mcp_ffi"); + +const REST_PORT: u16 = 9244; +const GRPC_PORT: u16 = 9245; +const GQL_PORT: u16 = 9246; + +const Response = struct { status: u16, body: []const u8 }; + +fn okJson(buf: []u8, msg: []const u8) []u8 { + return std.fmt.bufPrint(buf, "{{\"success\":true,\"message\":\"{s}\"}}", .{msg}) catch buf[0..0]; +} + +fn errJson(buf: []u8, msg: []const u8) []u8 { + return std.fmt.bufPrint(buf, "{{\"success\":false,\"error\":\"{s}\"}}", .{msg}) catch buf[0..0]; +} + +fn dispatch(tool: []const u8, _body: []const u8, resp: []u8) Response { + _ = _body; + if (std.mem.eql(u8, tool, "github_authenticate")) { + return .{ .status = 200, .body = okJson(resp, "github_authenticate forwarded") }; + } + if (std.mem.eql(u8, tool, "github_list_repos")) { + return .{ .status = 200, .body = okJson(resp, "github_list_repos forwarded") }; + } + if (std.mem.eql(u8, tool, "github_get_repo")) { + return .{ .status = 200, .body = okJson(resp, "github_get_repo forwarded") }; + } + if (std.mem.eql(u8, tool, "github_list_issues")) { + return .{ .status = 200, .body = okJson(resp, "github_list_issues forwarded") }; + } + if (std.mem.eql(u8, tool, "github_get_issue")) { + return .{ .status = 200, .body = okJson(resp, "github_get_issue forwarded") }; + } + if (std.mem.eql(u8, tool, "github_list_prs")) { + return .{ .status = 200, .body = okJson(resp, "github_list_prs forwarded") }; + } + if (std.mem.eql(u8, tool, "github_search_code")) { + return .{ .status = 200, .body = okJson(resp, "github_search_code forwarded") }; + } + if (std.mem.eql(u8, tool, "github_search_issues")) { + return .{ .status = 200, .body = okJson(resp, "github_search_issues forwarded") }; + } + return .{ .status = 404, .body = errJson(resp, "unknown tool") }; +} + +fn dispatchRest(path: []const u8, body: []const u8, resp: []u8) Response { + // Expect /tools/<tool_name> + const prefix = "/tools/"; + if (std.mem.startsWith(u8, path, prefix)) { + const tool = path[prefix.len..]; + return dispatch(tool, body, resp); + } + return .{ .status = 404, .body = errJson(resp, "not found") }; +} + +fn dispatchGrpc(path: []const u8, body: []const u8, resp: []u8) Response { + // Expect /<Service>/<Method> β€” derive tool from Method + var it = std.mem.splitScalar(u8, path, '/'); + _ = it.next(); // leading empty + _ = it.next(); // service + const method = it.next() orelse return .{ .status = 404, .body = errJson(resp, "bad gRPC path") }; + return dispatch(method, body, resp); +} + +fn dispatchGraphql(body: []const u8, resp: []u8) Response { + if (std.mem.indexOf(u8, body, "github_authenticate") != null) + return dispatch("github_authenticate", body, resp); + if (std.mem.indexOf(u8, body, "github_list_repos") != null) + return dispatch("github_list_repos", body, resp); + if (std.mem.indexOf(u8, body, "github_get_repo") != null) + return dispatch("github_get_repo", body, resp); + if (std.mem.indexOf(u8, body, "github_list_issues") != null) + return dispatch("github_list_issues", body, resp); + if (std.mem.indexOf(u8, body, "github_get_issue") != null) + return dispatch("github_get_issue", body, resp); + if (std.mem.indexOf(u8, body, "github_list_prs") != null) + return dispatch("github_list_prs", body, resp); + if (std.mem.indexOf(u8, body, "github_search_code") != null) + return dispatch("github_search_code", body, resp); + if (std.mem.indexOf(u8, body, "github_search_issues") != null) + return dispatch("github_search_issues", body, resp); + return .{ .status = 400, .body = errJson(resp, "unrecognised GraphQL operation") }; +} + +fn handleConnection(stream: std.net.Stream, port: u16) void { + defer stream.close(); + var buf: [4096]u8 = undefined; + var resp_buf: [4096]u8 = undefined; + const n = stream.read(&buf) catch return; + const req = buf[0..n]; + const result = switch (port) { + REST_PORT => blk: { + // Parse HTTP/1.1: first line = METHOD PATH HTTP/x.y + var lines = std.mem.splitScalar(u8, req, '\n'); + const first = lines.next() orelse break :blk Response{ .status = 400, .body = "" }; + var parts = std.mem.splitScalar(u8, std.mem.trim(u8, first, "\r"), ' '); + _ = parts.next(); // method + const path = parts.next() orelse break :blk Response{ .status = 400, .body = "" }; + break :blk dispatchRest(path, req, &resp_buf); + }, + GRPC_PORT => blk: { + var lines = std.mem.splitScalar(u8, req, '\n'); + const first = lines.next() orelse break :blk Response{ .status = 400, .body = "" }; + var parts = std.mem.splitScalar(u8, std.mem.trim(u8, first, "\r"), ' '); + _ = parts.next(); + const path = parts.next() orelse break :blk Response{ .status = 400, .body = "" }; + break :blk dispatchGrpc(path, req, &resp_buf); + }, + GQL_PORT => dispatchGraphql(req, &resp_buf), + else => Response{ .status = 500, .body = "" }, + }; + var http_resp: [512]u8 = undefined; + const http = std.fmt.bufPrint(&http_resp, + "HTTP/1.1 {d} OK\r\nContent-Length: {d}\r\nContent-Type: application/json\r\n\r\n", + .{ result.status, result.body.len }) catch return; + _ = stream.write(http) catch {}; + _ = stream.write(result.body) catch {}; +} + +fn listenLoop(port: u16) !void { + const addr = std.net.Address.initIp4(.{ 127, 0, 0, 1 }, port); + var server = try addr.listen(.{ .reuse_address = true }); + defer server.deinit(); + while (true) { + const conn = try server.accept(); + const t = try std.Thread.spawn(.{}, handleConnection, .{ conn.stream, port }); + t.detach(); + } +} + +pub fn main() !void { + ffi.github_api_mcp_init(); + const t1 = try std.Thread.spawn(.{}, listenLoop, .{REST_PORT}); + const t2 = try std.Thread.spawn(.{}, listenLoop, .{GRPC_PORT}); + const t3 = try std.Thread.spawn(.{}, listenLoop, .{GQL_PORT}); + t1.join(); + t2.join(); + t3.join(); +} diff --git a/cartridges/domains/development/github-api-mcp/benchmarks/quick-bench.sh b/cartridges/domains/development/github-api-mcp/benchmarks/quick-bench.sh new file mode 100755 index 0000000..09f23d6 --- /dev/null +++ b/cartridges/domains/development/github-api-mcp/benchmarks/quick-bench.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Benchmarks for github-api-mcp cartridge. +set -euo pipefail + +echo "=== github-api-mcp benchmarks ===" + +cd "$(dirname "${BASH_SOURCE[0]}")/../ffi" + +# Build with ReleaseFast for benchmarking +zig build -Doptimize=ReleaseFast 2>&1 + +echo "Session open/close cycle (1000 iterations):" +time for i in $(seq 1 1000); do + # This would call the FFI benchmark binary + true +done + +echo "" +echo "Benchmark placeholder β€” implement real benchmarks in Zig test blocks" +echo "or via the zig adapter HTTP benchmark tool." diff --git a/cartridges/domains/development/github-api-mcp/cartridge.json b/cartridges/domains/development/github-api-mcp/cartridge.json new file mode 100644 index 0000000..3a71a15 --- /dev/null +++ b/cartridges/domains/development/github-api-mcp/cartridge.json @@ -0,0 +1,232 @@ +{ + "$schema": "https://boj.dev/schemas/cartridge/v1.json", + "spdx": "MPL-2.0", + "copyright": "Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk>", + "name": "github-api-mcp", + "version": "0.1.0", + "description": "GitHub REST API β€” repos, issues, PRs, search", + "domain": "development", + "tier": "Ayo", + "protocols": [ + "MCP", + "REST" + ], + "auth": { + "method": "none", + "env_var": null, + "credential_source": null + }, + "api": { + "base_url": "local://github-api-mcp", + "content_type": "application/json" + }, + "tools": [ + { + "name": "github_authenticate", + "description": "Authenticate with GitHub PAT", + "inputSchema": { + "type": "object", + "properties": { + "token": { + "type": "string", + "description": "GitHub personal access token" + } + }, + "required": [ + "token" + ] + } + }, + { + "name": "github_list_repos", + "description": "List repositories for an owner", + "inputSchema": { + "type": "object", + "properties": { + "session_id": { + "type": "integer", + "description": "Session index" + }, + "owner": { + "type": "string", + "description": "Owner username or org" + } + }, + "required": [ + "session_id", + "owner" + ] + } + }, + { + "name": "github_get_repo", + "description": "Get repository details", + "inputSchema": { + "type": "object", + "properties": { + "session_id": { + "type": "integer", + "description": "Session index" + }, + "owner": { + "type": "string", + "description": "Owner" + }, + "repo": { + "type": "string", + "description": "Repository name" + } + }, + "required": [ + "session_id", + "owner", + "repo" + ] + } + }, + { + "name": "github_list_issues", + "description": "List issues in a repository", + "inputSchema": { + "type": "object", + "properties": { + "session_id": { + "type": "integer", + "description": "Session index" + }, + "owner": { + "type": "string", + "description": "Owner" + }, + "repo": { + "type": "string", + "description": "Repository name" + }, + "state": { + "type": "string", + "description": "Filter: open|closed|all" + } + }, + "required": [ + "session_id", + "owner", + "repo" + ] + } + }, + { + "name": "github_get_issue", + "description": "Get a specific issue", + "inputSchema": { + "type": "object", + "properties": { + "session_id": { + "type": "integer", + "description": "Session index" + }, + "owner": { + "type": "string", + "description": "Owner" + }, + "repo": { + "type": "string", + "description": "Repository name" + }, + "issue_number": { + "type": "integer", + "description": "Issue number" + } + }, + "required": [ + "session_id", + "owner", + "repo", + "issue_number" + ] + } + }, + { + "name": "github_list_prs", + "description": "List pull requests", + "inputSchema": { + "type": "object", + "properties": { + "session_id": { + "type": "integer", + "description": "Session index" + }, + "owner": { + "type": "string", + "description": "Owner" + }, + "repo": { + "type": "string", + "description": "Repository name" + }, + "state": { + "type": "string", + "description": "Filter: open|closed|all" + } + }, + "required": [ + "session_id", + "owner", + "repo" + ] + } + }, + { + "name": "github_search_code", + "description": "Search code on GitHub", + "inputSchema": { + "type": "object", + "properties": { + "session_id": { + "type": "integer", + "description": "Session index" + }, + "query": { + "type": "string", + "description": "Search query" + } + }, + "required": [ + "session_id", + "query" + ] + } + }, + { + "name": "github_search_issues", + "description": "Search issues on GitHub", + "inputSchema": { + "type": "object", + "properties": { + "session_id": { + "type": "integer", + "description": "Session index" + }, + "query": { + "type": "string", + "description": "Search query" + } + }, + "required": [ + "session_id", + "query" + ] + } + } + ], + "ffi": { + "so_path": "ffi/zig-out/lib/libgithub_api_mcp.so", + "abi_version": "ADR-0006", + "symbols": [ + "boj_cartridge_init", + "boj_cartridge_deinit", + "boj_cartridge_name", + "boj_cartridge_version", + "boj_cartridge_invoke" + ] + } +} diff --git a/cartridges/domains/development/github-api-mcp/ffi/README.adoc b/cartridges/domains/development/github-api-mcp/ffi/README.adoc new file mode 100644 index 0000000..272cb71 --- /dev/null +++ b/cartridges/domains/development/github-api-mcp/ffi/README.adoc @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += github-api-mcp / ffi β€” Zig FFI layer +:orientation: shallow + +== Purpose + +C-ABI exports bridging this cartridge's Idris2 ABI to the runtime. Consumed by +the adapter for protocol dispatch and by the trunk loader for mount. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” produces `zig-out/lib/libgithub-api.so`. +| `github_api_mcp_ffi.zig` | C-ABI exports (17 exports, 8 inline tests, 707 lines). +|=== + +== Invariants + +* **Null-pointer rejection** on `[*c]const u8` arguments before any + dereference. +* **Negative return values** carry documented error meanings; zero is + success (matching the trunk error-code convention). +* **No allocator leaks** β€” every allocation path pairs with `defer free`. + +== Test/proof surface + +8 inline `test "..."` block(s) in `github_api_mcp_ffi.zig`. Run with +`cd ffi && zig build test`. + +== Read-first + +. `github_api_mcp_ffi.zig` β€” C-ABI signatures and guards. +. Trunk `ffi/zig/src/catalogue.zig` for how this `.so` is loaded. diff --git a/cartridges/domains/development/github-api-mcp/ffi/build.zig b/cartridges/domains/development/github-api-mcp/ffi/build.zig new file mode 100644 index 0000000..c73873d --- /dev/null +++ b/cartridges/domains/development/github-api-mcp/ffi/build.zig @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + // Module + // Shared ADR-0006 invoke-shim module (relative path up to boj-server trunk). + + const shim_mod = b.addModule("cartridge_shim", .{ + + .root_source_file = b.path("../../../ffi/zig/src/cartridge_shim.zig"), + + .target = target, + + .optimize = optimize, + + }); + + const ffi_mod = b.addModule("github_api_mcp", .{ + .root_source_file = b.path("github_api_mcp_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + ffi_mod.addImport("cartridge_shim", shim_mod); + + // Shared library + const lib = b.addLibrary(.{ + .name = "github_api_mcp", + .root_module = ffi_mod, + .linkage = .dynamic, + }); + lib.linkLibC(); + b.installArtifact(lib); + + // Tests + const tests = b.addTest(.{ + .root_module = ffi_mod, + }); + tests.linkLibC(); + + const run_tests = b.addRunArtifact(tests); + const test_step = b.step("test", "Run FFI tests"); + test_step.dependOn(&run_tests.step); +} diff --git a/cartridges/domains/development/github-api-mcp/ffi/cartridge_shim.zig b/cartridges/domains/development/github-api-mcp/ffi/cartridge_shim.zig new file mode 100644 index 0000000..0e399db --- /dev/null +++ b/cartridges/domains/development/github-api-mcp/ffi/cartridge_shim.zig @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// cartridge_shim.zig β€” Shared helpers for the ADR-0006 five-symbol +// cartridge ABI (`boj_cartridge_init / deinit / name / version / invoke`). +// +// The shim centralises the seven-code return convention, NUL-argument +// guards, tool-name comparison, and the buffer-too-small path so each +// cartridge's `boj_cartridge_invoke` can stay short β€” typically a tool +// table plus `shim.writeResult(...)`. +// +// Cartridges import this file by relative path (no build-graph change +// needed). Example: +// +// const shim = @import("../../../ffi/zig/src/cartridge_shim.zig"); +// +// export fn boj_cartridge_invoke( +// tool_name: [*c]const u8, +// json_args: [*c]const u8, +// out_buf: [*c]u8, +// in_out_len: [*c]usize, +// ) callconv(.c) i32 { +// _ = json_args; +// if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; +// const body = if (shim.toolIs(tool_name, "foo")) "{\"result\":{}}" +// else return shim.RC_UNKNOWN_TOOL; +// return shim.writeResult(out_buf, in_out_len, body); +// } + +const std = @import("std"); + +// ── Return codes (ADR-0006 Β§Return codes) ──────────────────────────── +// +// Frozen by ADR-0006. New failure modes compose these via the error +// JSON body β€” the integer surface does not grow without a follow-up ADR. + +pub const RC_SUCCESS: i32 = 0; +pub const RC_UNKNOWN_TOOL: i32 = -1; +pub const RC_BAD_ARGS: i32 = -2; +pub const RC_BUFFER_TOO_SMALL: i32 = -3; +pub const RC_RUNTIME_ERROR: i32 = -4; +pub const RC_PANIC: i32 = -5; +pub const RC_AUTH_DENIED: i32 = -6; + +// ── Invoke-path helpers ────────────────────────────────────────────── + +/// True if any of the three mandatory `boj_cartridge_invoke` output-path +/// pointers is null. Use at the top of every invoke to short-circuit to +/// `RC_BAD_ARGS`. +pub fn invokeArgsNull( + tool_name: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) bool { + return tool_name == null or out_buf == null or in_out_len == null; +} + +/// Compare a C-NUL-terminated tool-name pointer against a Zig string +/// literal. Caller must have already verified `tool_name` is non-null +/// (usually via `invokeArgsNull`). +pub fn toolIs(tool_name: [*c]const u8, expected: []const u8) bool { + const s = std.mem.span(@as([*:0]const u8, @ptrCast(tool_name))); + return std.mem.eql(u8, s, expected); +} + +/// Copy `body` into `out_buf[0..*in_out_len]` (as a capacity) and update +/// `*in_out_len` to the number of bytes written. Returns `RC_SUCCESS`. +/// +/// If `body.len` exceeds the current capacity stored in `*in_out_len`, +/// sets `*in_out_len` to the required size and returns +/// `RC_BUFFER_TOO_SMALL` β€” the caller is then expected to re-allocate +/// and retry, per ADR-0006 Β§Memory ownership. +/// +/// Caller must have already verified that `out_buf` and `in_out_len` +/// are non-null. +pub fn writeResult( + out_buf: [*c]u8, + in_out_len: [*c]usize, + body: []const u8, +) i32 { + const cap = in_out_len.*; + if (body.len > cap) { + in_out_len.* = body.len; + return RC_BUFFER_TOO_SMALL; + } + @memcpy(out_buf[0..body.len], body); + in_out_len.* = body.len; + return RC_SUCCESS; +} + +// ── Tests ──────────────────────────────────────────────────────────── + +test "writeResult: body fits, writes and sets length" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); + try std.testing.expectEqualStrings("hello", buf[0..len]); +} + +test "writeResult: too small returns -3 and sets required length" { + var buf: [2]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_BUFFER_TOO_SMALL, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: exact-fit succeeds" { + var buf: [5]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: empty body" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, ""); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 0), len); +} + +test "toolIs: matches and rejects" { + const name: [*:0]const u8 = "foo"; + try std.testing.expect(toolIs(@ptrCast(name), "foo")); + try std.testing.expect(!toolIs(@ptrCast(name), "bar")); + try std.testing.expect(!toolIs(@ptrCast(name), "foobar")); + try std.testing.expect(!toolIs(@ptrCast(name), "fo")); +} + +test "invokeArgsNull: detects each null slot" { + var buf: [4]u8 = undefined; + var len: usize = 4; + const name: [*:0]const u8 = "x"; + try std.testing.expect(!invokeArgsNull(@ptrCast(name), &buf, &len)); + try std.testing.expect(invokeArgsNull(null, &buf, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), null, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), &buf, null)); +} diff --git a/cartridges/domains/development/github-api-mcp/ffi/github_api_mcp_ffi.zig b/cartridges/domains/development/github-api-mcp/ffi/github_api_mcp_ffi.zig new file mode 100644 index 0000000..31fc1d1 --- /dev/null +++ b/cartridges/domains/development/github-api-mcp/ffi/github_api_mcp_ffi.zig @@ -0,0 +1,810 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// github_api_mcp_ffi.zig β€” C-ABI FFI for GitHub REST & GraphQL API cartridge. +// +// Implements the state machine defined in GithubApiMcp.SafeGit (Idris2 ABI). +// Thread-safe via std.Thread.Mutex. Real HTTP dispatch to the GitHub REST and +// GraphQL APIs via std.http.Client. Auth tokens retrieved from vault-mcp +// zero-knowledge proxy. + +const std = @import("std"); + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +/// GitHub REST API base URL. +pub const REST_BASE: []const u8 = "https://api.github.com"; + +/// GitHub GraphQL API endpoint. +pub const GRAPHQL_ENDPOINT: []const u8 = "https://api.github.com/graphql"; + +/// Maximum concurrent sessions. +const MAX_SESSIONS: usize = 16; + +/// Output buffer size per session (64 KiB). +const BUF_SIZE: usize = 65536; + +/// Token buffer size (tokens are typically < 256 bytes). +const TOKEN_BUF_SIZE: usize = 512; + +// --------------------------------------------------------------------------- +// Auth state machine (matches Idris2 ABI exactly) +// --------------------------------------------------------------------------- + +/// Authentication and rate-limit state. +/// +/// Unauthenticated = 0, Authenticated = 1, RateLimited = 2, Error = 3 +pub const AuthState = enum(c_int) { + unauthenticated = 0, + authenticated = 1, + rate_limited = 2, + err = 3, +}; + +/// Check if a transition between two AuthStates is valid. +/// +/// Valid transitions: +/// Unauthenticated -> Authenticated (Authenticate) +/// Authenticated -> RateLimited (Throttle) +/// RateLimited -> Authenticated (Resume after cooldown) +/// Authenticated -> Error (Fault) +/// Error -> Unauthenticated (ResetError) +/// Authenticated -> Unauthenticated (Logout) +fn isValidTransition(from: AuthState, to: AuthState) bool { + return switch (from) { + .unauthenticated => to == .authenticated, + .authenticated => to == .rate_limited or to == .err or to == .unauthenticated, + .rate_limited => to == .authenticated, + .err => to == .unauthenticated, + }; +} + +// --------------------------------------------------------------------------- +// GitHub action codes (matches Idris2 GitHubAction exactly) +// --------------------------------------------------------------------------- + +/// GitHub API action identifiers. +pub const GitHubAction = enum(c_int) { + list_repos = 0, + get_repo = 1, + create_issue = 2, + list_issues = 3, + get_issue = 4, + comment_issue = 5, + create_pr = 6, + list_prs = 7, + get_pr = 8, + merge_pr = 9, + review_pr = 10, + list_branches = 11, + create_branch = 12, + search_code = 13, + search_issues = 14, + list_actions = 15, + get_release = 16, + create_release = 17, + get_file_contents = 18, + push_files = 19, +}; + +/// Check if an action is a write/mutation operation. +fn actionIsMutation(action: GitHubAction) bool { + return switch (action) { + .create_issue, .comment_issue, .create_pr, .merge_pr, .review_pr, .create_branch, .create_release, .push_files => true, + else => false, + }; +} + +// --------------------------------------------------------------------------- +// Rate limit tracking +// --------------------------------------------------------------------------- + +/// Rate limit information parsed from GitHub API response headers. +const RateLimit = struct { + /// Remaining calls in the current window. + remaining: u32 = 5000, + /// Unix epoch seconds when the window resets. + reset_time: u64 = 0, + /// Maximum calls permitted per window. + limit: u32 = 5000, +}; + +// --------------------------------------------------------------------------- +// Session slots (thread-safe, fixed-size pool) +// --------------------------------------------------------------------------- + +const SessionSlot = struct { + active: bool = false, + state: AuthState = .unauthenticated, + token_buf: [TOKEN_BUF_SIZE]u8 = undefined, + token_len: usize = 0, + out_buf: [BUF_SIZE]u8 = undefined, + out_len: usize = 0, + rate_limit: RateLimit = .{}, +}; + +var sessions: [MAX_SESSIONS]SessionSlot = [_]SessionSlot{.{}} ** MAX_SESSIONS; +var mutex: std.Thread.Mutex = .{}; + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +/// Get a mutable reference to a valid session slot. +/// Returns null if index is out of range or slot is not active. +fn getSlot(slot_idx: c_int) ?*SessionSlot { + const idx: usize = std.math.cast(usize, slot_idx) orelse return null; + if (idx >= MAX_SESSIONS) return null; + const slot = &sessions[idx]; + if (!slot.active) return null; + return slot; +} + +// Rate-limit header parsing is performed inline within doHttpRequest() +// from the std.http response headers (X-RateLimit-Remaining, +// X-RateLimit-Reset, X-RateLimit-Limit). + +// --------------------------------------------------------------------------- +// C-ABI exports β€” state machine +// --------------------------------------------------------------------------- + +/// Check if a state transition is valid. Returns 1 (valid) or 0 (invalid). +pub export fn github_api_mcp_can_transition(from: c_int, to: c_int) c_int { + const f = std.meta.intToEnum(AuthState, from) catch return 0; + const t = std.meta.intToEnum(AuthState, to) catch return 0; + return if (isValidTransition(f, t)) 1 else 0; +} + +/// Open a new session (starts Unauthenticated). +/// Returns slot index (>= 0) or -1 if no free slots. +pub export fn github_api_mcp_session_open() c_int { + mutex.lock(); + defer mutex.unlock(); + + for (&sessions, 0..) |*slot, idx| { + if (!slot.active) { + slot.active = true; + slot.state = .unauthenticated; + slot.token_len = 0; + slot.out_len = 0; + slot.rate_limit = .{}; + return @intCast(idx); + } + } + return -1; +} + +/// Close a session. Returns 0 on success, -1 if invalid slot. +/// Any state can be closed (session teardown is unconditional). +pub export fn github_api_mcp_session_close(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + var slot = &sessions[idx]; + if (!slot.active) return -1; + + // Zero-fill token for security before releasing slot + @memset(&slot.token_buf, 0); + slot.token_len = 0; + slot.active = false; + slot.state = .unauthenticated; + slot.out_len = 0; + slot.rate_limit = .{}; + return 0; +} + +/// Get the current AuthState of a session. Returns state int or -1 if invalid. +pub export fn github_api_mcp_session_state(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const slot = getSlot(slot_idx) orelse return -1; + return @intFromEnum(slot.state); +} + +// --------------------------------------------------------------------------- +// C-ABI exports β€” authentication +// --------------------------------------------------------------------------- + +/// Authenticate a session with a Bearer token (retrieved from vault-mcp). +/// Transitions Unauthenticated -> Authenticated. +/// Returns 0 on success, -1 invalid slot, -2 bad transition, -3 token too long. +pub export fn github_api_mcp_authenticate(slot_idx: c_int, token_ptr: [*]const u8, token_len: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const slot = getSlot(slot_idx) orelse return -1; + if (!isValidTransition(slot.state, .authenticated)) return -2; + + const len: usize = std.math.cast(usize, token_len) orelse return -3; + if (len == 0 or len > TOKEN_BUF_SIZE) return -3; + + @memcpy(slot.token_buf[0..len], token_ptr[0..len]); + slot.token_len = len; + slot.state = .authenticated; + slot.rate_limit = .{}; + return 0; +} + +/// Logout (Authenticated -> Unauthenticated). Zeroes the stored token. +/// Returns 0 on success, -1 invalid slot, -2 bad transition. +pub export fn github_api_mcp_logout(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const slot = getSlot(slot_idx) orelse return -1; + if (!isValidTransition(slot.state, .unauthenticated)) return -2; + + @memset(&slot.token_buf, 0); + slot.token_len = 0; + slot.state = .unauthenticated; + return 0; +} + +// --------------------------------------------------------------------------- +// C-ABI exports β€” rate limiting +// --------------------------------------------------------------------------- + +/// Get remaining rate limit for a session. Returns remaining count, or -1 on error. +pub export fn github_api_mcp_rate_limit_remaining(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const slot = getSlot(slot_idx) orelse return -1; + return @intCast(slot.rate_limit.remaining); +} + +/// Get rate limit reset time (unix epoch seconds). Returns 0 if unset, -1 on error. +pub export fn github_api_mcp_rate_limit_reset(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const slot = getSlot(slot_idx) orelse return -1; + // Truncate to c_int; callers needing full u64 use the struct directly + return @intCast(@as(u32, @truncate(slot.rate_limit.reset_time))); +} + +/// Manually transition to RateLimited state (Authenticated -> RateLimited). +/// Returns 0 on success, -1 invalid slot, -2 bad transition. +pub export fn github_api_mcp_throttle(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const slot = getSlot(slot_idx) orelse return -1; + if (!isValidTransition(slot.state, .rate_limited)) return -2; + + slot.state = .rate_limited; + return 0; +} + +/// Resume from RateLimited -> Authenticated (after cooldown elapsed). +/// Returns 0 on success, -1 invalid slot, -2 bad transition. +pub export fn github_api_mcp_resume(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const slot = getSlot(slot_idx) orelse return -1; + if (!isValidTransition(slot.state, .authenticated)) return -2; + + slot.state = .authenticated; + slot.rate_limit.remaining = slot.rate_limit.limit; + return 0; +} + +// --------------------------------------------------------------------------- +// C-ABI exports β€” error handling +// --------------------------------------------------------------------------- + +/// Signal an error (Authenticated -> Error). Returns 0 on success. +pub export fn github_api_mcp_signal_error(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const slot = getSlot(slot_idx) orelse return -1; + if (!isValidTransition(slot.state, .err)) return -2; + + slot.state = .err; + return 0; +} + +/// Reset from Error -> Unauthenticated. Returns 0 on success. +pub export fn github_api_mcp_reset_error(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const slot = getSlot(slot_idx) orelse return -1; + if (!isValidTransition(slot.state, .unauthenticated)) return -2; + + slot.state = .unauthenticated; + @memset(&slot.token_buf, 0); + slot.token_len = 0; + return 0; +} + +// --------------------------------------------------------------------------- +// HTTP dispatch helpers +// --------------------------------------------------------------------------- + +/// Parse an HTTP method string into std.http.Method. +fn parseHttpMethod(method: []const u8) std.http.Method { + if (std.ascii.eqlIgnoreCase(method, "GET")) return .GET; + if (std.ascii.eqlIgnoreCase(method, "POST")) return .POST; + if (std.ascii.eqlIgnoreCase(method, "PUT")) return .PUT; + if (std.ascii.eqlIgnoreCase(method, "PATCH")) return .PATCH; + if (std.ascii.eqlIgnoreCase(method, "DELETE")) return .DELETE; + return .GET; +} + +/// Perform a real HTTP request to the GitHub API using std.http.Client. +/// Caller must hold the mutex and pass the slot. +/// Returns bytes written to out_buf on success, or a negative error code. +fn doHttpRequest( + slot: *SessionSlot, + method: []const u8, + path: []const u8, + body: ?[]const u8, + out_buf: [*]u8, + out_cap: usize, +) c_int { + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); + defer arena.deinit(); + const allocator = arena.allocator(); + + // Build the full URL: REST_BASE + path + const url_str = std.fmt.allocPrint(allocator, "{s}{s}", .{ REST_BASE, path }) catch return -5; + + // Build Authorization header value: "Bearer <token>" + const auth_header = std.fmt.allocPrint(allocator, "Bearer {s}", .{slot.token_buf[0..slot.token_len]}) catch return -5; + + // Parse the URI + const uri = std.Uri.parse(url_str) catch return -5; + + // Create HTTP client + var client = std.http.Client{ .allocator = allocator }; + defer client.deinit(); + + // Prepare extra headers: Authorization, Accept, User-Agent + var headers_buf: [3]std.http.Header = .{ + .{ .name = "Authorization", .value = auth_header }, + .{ .name = "Accept", .value = "application/vnd.github+json" }, + .{ .name = "User-Agent", .value = "boj-server/1.0 (github-api-mcp cartridge)" }, + }; + + const http_method = parseHttpMethod(method); + + // Fetch the request (Zig 0.15 API β€” replaces open/send/wait) + var aw: std.Io.Writer.Allocating = .init(allocator); + defer aw.deinit(); + + const fetch_result = client.fetch(.{ + .method = http_method, + .location = .{ .uri = uri }, + .extra_headers = &headers_buf, + .payload = body, + .response_writer = &aw.writer, + }) catch return -5; + + // Handle rate limiting (HTTP 429 or 403 with depleted budget) + const status_code = @intFromEnum(fetch_result.status); + if (status_code == 429 or (status_code == 403 and slot.rate_limit.remaining == 0)) { + slot.state = .rate_limited; + return -3; + } + + // Transition to error on server errors (5xx) + if (status_code >= 500) { + slot.state = .err; + return -5; + } + + // Copy response body into the caller's output buffer + const response_bytes = aw.writer.buffered(); + const to_copy = @min(response_bytes.len, out_cap); + @memcpy(out_buf[0..to_copy], response_bytes[0..to_copy]); + return @intCast(to_copy); +} + +// --------------------------------------------------------------------------- +// C-ABI exports β€” GitHub API request dispatch +// --------------------------------------------------------------------------- + +/// Issue a REST API request to the GitHub API. +/// +/// Parameters: +/// slot_idx β€” session slot +/// method_ptr β€” HTTP method ("GET", "POST", "PUT", "PATCH", "DELETE") +/// method_len β€” length of method string +/// path_ptr β€” API path (e.g. "/repos/owner/name/issues") +/// path_len β€” length of path string +/// body_ptr β€” request body (JSON), may be null for GET/DELETE +/// body_len β€” length of body (0 if no body) +/// out_ptr β€” pointer to caller-provided output buffer +/// out_cap β€” capacity of output buffer +/// +/// Returns: bytes written to out_ptr on success, or negative error code. +/// -1 = invalid slot, -2 = not authenticated, -3 = rate limited, +/// -4 = buffer too small, -5 = network/HTTP error +pub export fn github_api_mcp_request( + slot_idx: c_int, + method_ptr: [*]const u8, + method_len: c_int, + path_ptr: [*]const u8, + path_len: c_int, + body_ptr: ?[*]const u8, + body_len: c_int, + out_ptr: [*]u8, + out_cap: c_int, +) c_int { + mutex.lock(); + defer mutex.unlock(); + + const slot = getSlot(slot_idx) orelse return -1; + if (slot.state != .authenticated) { + if (slot.state == .rate_limited) return -3; + return -2; + } + + const m_len: usize = std.math.cast(usize, method_len) orelse return -5; + const p_len: usize = std.math.cast(usize, path_len) orelse return -5; + const b_len: usize = std.math.cast(usize, body_len) orelse return -5; + const o_cap: usize = std.math.cast(usize, out_cap) orelse return -4; + + const method = method_ptr[0..m_len]; + const path = path_ptr[0..p_len]; + const body: ?[]const u8 = if (body_ptr) |bp| bp[0..b_len] else null; + + return doHttpRequest(slot, method, path, body, out_ptr, o_cap); +} + +/// Issue a GraphQL query to the GitHub GraphQL API. +/// +/// Parameters: +/// slot_idx β€” session slot +/// query_ptr β€” GraphQL query string +/// query_len β€” length of query string +/// variables_ptr β€” JSON variables (may be null) +/// variables_len β€” length of variables string (0 if null) +/// out_ptr β€” pointer to output buffer +/// out_cap β€” capacity of output buffer +/// +/// Returns: bytes written on success, or negative error code (same codes as request). +pub export fn github_api_mcp_graphql( + slot_idx: c_int, + query_ptr: [*]const u8, + query_len: c_int, + variables_ptr: ?[*]const u8, + variables_len: c_int, + out_ptr: [*]u8, + out_cap: c_int, +) c_int { + mutex.lock(); + defer mutex.unlock(); + + const slot = getSlot(slot_idx) orelse return -1; + if (slot.state != .authenticated) { + if (slot.state == .rate_limited) return -3; + return -2; + } + + const q_len: usize = std.math.cast(usize, query_len) orelse return -5; + const v_len: usize = std.math.cast(usize, variables_len) orelse return -5; + const o_cap: usize = std.math.cast(usize, out_cap) orelse return -4; + + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); + defer arena.deinit(); + const allocator = arena.allocator(); + + // Build the GraphQL JSON body: {"query":"...","variables":{...}} + const query = query_ptr[0..q_len]; + const vars: ?[]const u8 = if (variables_ptr) |vp| vp[0..v_len] else null; + const gql_body = if (vars) |v| + std.fmt.allocPrint(allocator, "{{\"query\":{s},\"variables\":{s}}}", .{ query, v }) catch return -5 + else + std.fmt.allocPrint(allocator, "{{\"query\":{s}}}", .{query}) catch return -5; + + // GraphQL endpoint is always POST /graphql + return doHttpRequest(slot, "POST", "/graphql", gql_body, out_ptr, o_cap); +} + +// --------------------------------------------------------------------------- +// C-ABI exports β€” action validation +// --------------------------------------------------------------------------- + +/// Check if an action code is valid. Returns 1 if valid, 0 if out of range. +pub export fn github_api_mcp_valid_action(code: c_int) c_int { + _ = std.meta.intToEnum(GitHubAction, code) catch return 0; + return 1; +} + +/// Check if an action is a mutation. Returns 1 for mutation, 0 for read-only, -1 for invalid. +pub export fn github_api_mcp_is_mutation(code: c_int) c_int { + const action = std.meta.intToEnum(GitHubAction, code) catch return -1; + return if (actionIsMutation(action)) 1 else 0; +} + +// --------------------------------------------------------------------------- +// C-ABI exports β€” reset (test/debug) +// --------------------------------------------------------------------------- + +/// Reset all sessions (test/debug use only). Zeroes all token material. +pub export fn github_api_mcp_reset() void { + mutex.lock(); + defer mutex.unlock(); + for (&sessions) |*slot| { + @memset(&slot.token_buf, 0); + } + sessions = [_]SessionSlot{.{}} ** MAX_SESSIONS; +} + +// --------------------------------------------------------------------------- +// ═══════════════════════════════════════════════════════════════════════ +// Standard ABI (ADR-0005 four symbols + ADR-0006 invoke) +// ═══════════════════════════════════════════════════════════════════════ + +const shim = @import("cartridge_shim.zig"); + +const CARTRIDGE_NAME_PTR: [*:0]const u8 = "github-api-mcp"; +const CARTRIDGE_VERSION_PTR: [*:0]const u8 = "0.1.0"; + +export fn boj_cartridge_init() callconv(.c) c_int { + return 0; +} + +export fn boj_cartridge_deinit() callconv(.c) void {} + +export fn boj_cartridge_name() callconv(.c) [*:0]const u8 { + return CARTRIDGE_NAME_PTR; +} + +export fn boj_cartridge_version() callconv(.c) [*:0]const u8 { + return CARTRIDGE_VERSION_PTR; +} + +/// Dispatch the cartridge.json MCP tools. Grade D Alpha stubs. +export fn boj_cartridge_invoke( + tool_name: [*c]const u8, + json_args: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) callconv(.c) i32 { + _ = json_args; + if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; + + const body: []const u8 = if (shim.toolIs(tool_name, "github_authenticate")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "github_list_repos")) + "{\"result\":{\"items\":[],\"count\":0,\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "github_get_repo")) + "{\"result\":{\"metadata\":{},\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "github_list_issues")) + "{\"result\":{\"items\":[],\"count\":0,\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "github_get_issue")) + "{\"result\":{\"metadata\":{},\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "github_list_prs")) + "{\"result\":{\"items\":[],\"count\":0,\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "github_search_code")) + "{\"result\":{\"matches\":[],\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "github_search_issues")) + "{\"result\":{\"matches\":[],\"status\":\"stub\"}}" +else + return shim.RC_UNKNOWN_TOOL; + + return shim.writeResult(out_buf, in_out_len, body); +} + +// Tests +// --------------------------------------------------------------------------- + +test "auth state transitions" { + // Valid transitions + try std.testing.expectEqual(@as(c_int, 1), github_api_mcp_can_transition(0, 1)); // Unauth -> Auth + try std.testing.expectEqual(@as(c_int, 1), github_api_mcp_can_transition(1, 2)); // Auth -> RateLimited + try std.testing.expectEqual(@as(c_int, 1), github_api_mcp_can_transition(2, 1)); // RateLimited -> Auth + try std.testing.expectEqual(@as(c_int, 1), github_api_mcp_can_transition(1, 3)); // Auth -> Error + try std.testing.expectEqual(@as(c_int, 1), github_api_mcp_can_transition(3, 0)); // Error -> Unauth + try std.testing.expectEqual(@as(c_int, 1), github_api_mcp_can_transition(1, 0)); // Auth -> Unauth (Logout) + + // Invalid transitions + try std.testing.expectEqual(@as(c_int, 0), github_api_mcp_can_transition(0, 2)); // Unauth -> RateLimited + try std.testing.expectEqual(@as(c_int, 0), github_api_mcp_can_transition(0, 3)); // Unauth -> Error + try std.testing.expectEqual(@as(c_int, 0), github_api_mcp_can_transition(2, 0)); // RateLimited -> Unauth + try std.testing.expectEqual(@as(c_int, 0), github_api_mcp_can_transition(2, 3)); // RateLimited -> Error + try std.testing.expectEqual(@as(c_int, 0), github_api_mcp_can_transition(3, 1)); // Error -> Auth + + // Out of range + try std.testing.expectEqual(@as(c_int, 0), github_api_mcp_can_transition(99, 0)); + try std.testing.expectEqual(@as(c_int, 0), github_api_mcp_can_transition(0, 99)); +} + +test "session lifecycle with authentication" { + github_api_mcp_reset(); + + // Open session (starts Unauthenticated) + const slot = github_api_mcp_session_open(); + try std.testing.expect(slot >= 0); + try std.testing.expectEqual(@as(c_int, 0), github_api_mcp_session_state(slot)); // Unauthenticated + + // Authenticate + const token = "ghp_test_token_12345"; + try std.testing.expectEqual(@as(c_int, 0), github_api_mcp_authenticate(slot, token.ptr, @intCast(token.len))); + try std.testing.expectEqual(@as(c_int, 1), github_api_mcp_session_state(slot)); // Authenticated + + // Check rate limit (default 5000) + try std.testing.expectEqual(@as(c_int, 5000), github_api_mcp_rate_limit_remaining(slot)); + + // Logout + try std.testing.expectEqual(@as(c_int, 0), github_api_mcp_logout(slot)); + try std.testing.expectEqual(@as(c_int, 0), github_api_mcp_session_state(slot)); // Unauthenticated + + // Close + try std.testing.expectEqual(@as(c_int, 0), github_api_mcp_session_close(slot)); +} + +test "rate limiting flow" { + github_api_mcp_reset(); + + const slot = github_api_mcp_session_open(); + try std.testing.expect(slot >= 0); + + const token = "ghp_ratelimit_test"; + try std.testing.expectEqual(@as(c_int, 0), github_api_mcp_authenticate(slot, token.ptr, @intCast(token.len))); + + // Manually throttle + try std.testing.expectEqual(@as(c_int, 0), github_api_mcp_throttle(slot)); + try std.testing.expectEqual(@as(c_int, 2), github_api_mcp_session_state(slot)); // RateLimited + + // Cannot throttle again from RateLimited + try std.testing.expectEqual(@as(c_int, -2), github_api_mcp_throttle(slot)); + + // Resume after cooldown + try std.testing.expectEqual(@as(c_int, 0), github_api_mcp_resume(slot)); + try std.testing.expectEqual(@as(c_int, 1), github_api_mcp_session_state(slot)); // Authenticated + + _ = github_api_mcp_session_close(slot); +} + +test "error handling flow" { + github_api_mcp_reset(); + + const slot = github_api_mcp_session_open(); + try std.testing.expect(slot >= 0); + + const token = "ghp_error_test"; + try std.testing.expectEqual(@as(c_int, 0), github_api_mcp_authenticate(slot, token.ptr, @intCast(token.len))); + + // Signal error + try std.testing.expectEqual(@as(c_int, 0), github_api_mcp_signal_error(slot)); + try std.testing.expectEqual(@as(c_int, 3), github_api_mcp_session_state(slot)); // Error + + // Cannot authenticate from Error (must reset first) + const token2 = "ghp_retry"; + try std.testing.expectEqual(@as(c_int, -2), github_api_mcp_authenticate(slot, token2.ptr, @intCast(token2.len))); + + // Reset error -> Unauthenticated + try std.testing.expectEqual(@as(c_int, 0), github_api_mcp_reset_error(slot)); + try std.testing.expectEqual(@as(c_int, 0), github_api_mcp_session_state(slot)); // Unauthenticated + + // Now can authenticate again + try std.testing.expectEqual(@as(c_int, 0), github_api_mcp_authenticate(slot, token.ptr, @intCast(token.len))); + try std.testing.expectEqual(@as(c_int, 1), github_api_mcp_session_state(slot)); // Authenticated + + _ = github_api_mcp_session_close(slot); +} + +test "REST request pre-auth rejection" { + github_api_mcp_reset(); + + const slot = github_api_mcp_session_open(); + try std.testing.expect(slot >= 0); + + // Cannot request before auth β€” must return negative error code + var buf: [1024]u8 = undefined; + const method = "GET"; + const path = "/repos/hyperpolymath/boj-server"; + try std.testing.expect(github_api_mcp_request(slot, method.ptr, @intCast(method.len), path.ptr, @intCast(path.len), null, 0, &buf, 1024) < 0); + + _ = github_api_mcp_session_close(slot); +} + +test "GraphQL pre-auth rejection" { + github_api_mcp_reset(); + + const slot = github_api_mcp_session_open(); + try std.testing.expect(slot >= 0); + + // Cannot issue GraphQL before auth + var buf: [1024]u8 = undefined; + const query = "{ viewer { login } }"; + try std.testing.expect(github_api_mcp_graphql(slot, query.ptr, @intCast(query.len), null, 0, &buf, 1024) < 0); + + _ = github_api_mcp_session_close(slot); +} + +test "action validation" { + // Valid actions (0..19) + var i: c_int = 0; + while (i < 20) : (i += 1) { + try std.testing.expectEqual(@as(c_int, 1), github_api_mcp_valid_action(i)); + } + // Invalid + try std.testing.expectEqual(@as(c_int, 0), github_api_mcp_valid_action(20)); + try std.testing.expectEqual(@as(c_int, 0), github_api_mcp_valid_action(-1)); + try std.testing.expectEqual(@as(c_int, 0), github_api_mcp_valid_action(99)); + + // Mutation checks + try std.testing.expectEqual(@as(c_int, 0), github_api_mcp_is_mutation(0)); // ListRepos = read + try std.testing.expectEqual(@as(c_int, 1), github_api_mcp_is_mutation(2)); // CreateIssue = mutation + try std.testing.expectEqual(@as(c_int, 1), github_api_mcp_is_mutation(9)); // MergePR = mutation + try std.testing.expectEqual(@as(c_int, 0), github_api_mcp_is_mutation(13)); // SearchCode = read + try std.testing.expectEqual(@as(c_int, -1), github_api_mcp_is_mutation(99)); // invalid +} + +test "slot exhaustion" { + github_api_mcp_reset(); + + var slots: [MAX_SESSIONS]c_int = undefined; + for (&slots) |*s| { + s.* = github_api_mcp_session_open(); + try std.testing.expect(s.* >= 0); + } + + // Next open should fail + try std.testing.expectEqual(@as(c_int, -1), github_api_mcp_session_open()); + + // Free one and try again + try std.testing.expectEqual(@as(c_int, 0), github_api_mcp_session_close(slots[0])); + const new_slot = github_api_mcp_session_open(); + try std.testing.expect(new_slot >= 0); +} + +// ═══════════════════════════════════════════════════════════════════════ +// ADR-0006 invoke dispatch tests +// ═══════════════════════════════════════════════════════════════════════ + +test "boj_cartridge_name returns github-api-mcp" { + const n = std.mem.span(boj_cartridge_name()); + try std.testing.expectEqualStrings("github-api-mcp", n); +} + +test "boj_cartridge_init returns 0" { + try std.testing.expectEqual(@as(c_int, 0), boj_cartridge_init()); +} + +test "invoke: each declared tool succeeds" { + var buf: [256]u8 = undefined; + const tools = [_][]const u8{ + "github_authenticate", + "github_list_repos", + "github_get_repo", + "github_list_issues", + "github_get_issue", + "github_list_prs", + "github_search_code", + "github_search_issues", + }; + for (tools) |t| { + var len: usize = buf.len; + const rc = boj_cartridge_invoke(t.ptr, "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, 0), rc); + try std.testing.expect(std.mem.indexOf(u8, buf[0..len], "result") != null); + } +} + +test "invoke: unknown tool returns -1" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("nope", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -1), rc); +} + +test "invoke: buffer too small returns -3" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("github_authenticate", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -3), rc); + try std.testing.expect(len > 4); +} diff --git a/cartridges/domains/development/github-api-mcp/minter.toml b/cartridges/domains/development/github-api-mcp/minter.toml new file mode 100644 index 0000000..6b4f7ae --- /dev/null +++ b/cartridges/domains/development/github-api-mcp/minter.toml @@ -0,0 +1,12 @@ +name = "github-api-mcp" +description = "GitHub REST & GraphQL API cartridge β€” replaces standalone npx @modelcontextprotocol/server-github" +version = "0.2.0" +domain = "Git" +protocols = [ + "MCP", + "REST", + "GraphQL", +] +tier = "Ayo" +backend = "universal" +generate_panel = true diff --git a/cartridges/domains/development/github-api-mcp/mod.js b/cartridges/domains/development/github-api-mcp/mod.js new file mode 100644 index 0000000..5c1364e --- /dev/null +++ b/cartridges/domains/development/github-api-mcp/mod.js @@ -0,0 +1,63 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// github-api-mcp/mod.js β€” GitHub REST API β€” repos, issues, PRs, search +// +// Delegates to backend at http://127.0.0.1:7726 (override with GITHUB_API_URL). + +const BASE_URL = Deno.env.get("GITHUB_API_URL") ?? "http://127.0.0.1:7726"; +const TIMEOUT_MS = 15_000; + +async function post(path, payload) { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), TIMEOUT_MS); + try { + const r = await fetch(`${BASE_URL}${path}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + signal: ctrl.signal, + }); + const data = await r.json().catch(() => ({ success: false, error: "non-JSON response" })); + return { status: r.status, data }; + } catch (e) { + if (e.name === "AbortError") return { status: 504, data: { success: false, error: "github-api-mcp backend timed out" } }; + return { status: 503, data: { success: false, error: `github-api-mcp backend unavailable: ${e.message}` } }; + } finally { clearTimeout(t); } +} + +async function get(path) { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), TIMEOUT_MS); + try { + const r = await fetch(`${BASE_URL}${path}`, { method: "GET", signal: ctrl.signal }); + const data = await r.json().catch(() => ({ success: false, error: "non-JSON response" })); + return { status: r.status, data }; + } catch (e) { + if (e.name === "AbortError") return { status: 504, data: { success: false, error: "github-api-mcp backend timed out" } }; + return { status: 503, data: { success: false, error: `github-api-mcp backend unavailable: ${e.message}` } }; + } finally { clearTimeout(t); } +} + +export async function handleTool(toolName, args) { + switch (toolName) { + case "github_authenticate": + return post("/api/v1/github_authenticate", args ?? {}); + case "github_list_repos": + return post("/api/v1/github_list_repos", args ?? {}); + case "github_get_repo": + return post("/api/v1/github_get_repo", args ?? {}); + case "github_list_issues": + return post("/api/v1/github_list_issues", args ?? {}); + case "github_get_issue": + return post("/api/v1/github_get_issue", args ?? {}); + case "github_list_prs": + return post("/api/v1/github_list_prs", args ?? {}); + case "github_search_code": + return post("/api/v1/github_search_code", args ?? {}); + case "github_search_issues": + return post("/api/v1/github_search_issues", args ?? {}); + default: + return { status: 404, data: { error: `Unknown tool: ${toolName}` } }; + } +} diff --git a/cartridges/domains/development/github-api-mcp/panels/manifest.json b/cartridges/domains/development/github-api-mcp/panels/manifest.json new file mode 100644 index 0000000..78e32fc --- /dev/null +++ b/cartridges/domains/development/github-api-mcp/panels/manifest.json @@ -0,0 +1,82 @@ +{ + "$schema": "https://panll.dev/schemas/panel-manifest/v1.json", + "spdx": "MPL-2.0", + "cartridge": "github-api-mcp", + "domain": "Git", + "version": "0.2.0", + "panels": [ + { + "id": "github-auth-status", + "title": "Auth Status", + "description": "Current GitHub authentication state (Unauthenticated / Authenticated / RateLimited / Error)", + "type": "status-indicator", + "data_source": { + "endpoint": "/github/state", + "method": "GET", + "refresh_interval_ms": 5000 + }, + "widgets": [ + { + "type": "state-badge", + "field": "state", + "states": { + "unauthenticated": { "color": "#95a5a6", "icon": "user-x" }, + "authenticated": { "color": "#2ecc71", "icon": "user-check" }, + "rate_limited": { "color": "#f39c12", "icon": "clock" }, + "error": { "color": "#e74c3c", "icon": "alert-triangle" } + } + } + ] + }, + { + "id": "github-rate-limit", + "title": "Rate Limit", + "description": "GitHub API rate limit remaining calls and reset time", + "type": "metric", + "data_source": { + "endpoint": "/github/rate-limit", + "method": "GET", + "refresh_interval_ms": 10000 + }, + "widgets": [ + { + "type": "counter", + "field": "remaining", + "label": "Remaining", + "icon": "activity" + }, + { + "type": "timestamp", + "field": "reset", + "label": "Resets At", + "format": "relative", + "icon": "clock" + } + ] + }, + { + "id": "github-recent-calls", + "title": "Recent API Calls", + "description": "Log of recent GitHub REST and GraphQL API calls with method, path, and result", + "type": "log-stream", + "data_source": { + "endpoint": "/github/audit", + "method": "GET", + "refresh_interval_ms": 15000, + "max_entries": 50 + }, + "widgets": [ + { + "type": "log-table", + "columns": [ + { "field": "timestamp", "label": "Time", "format": "datetime" }, + { "field": "method", "label": "Method" }, + { "field": "path", "label": "Endpoint" }, + { "field": "action", "label": "Action" }, + { "field": "status", "label": "Status" } + ] + } + ] + } + ] +} diff --git a/cartridges/domains/development/github-api-mcp/tests/integration_test.sh b/cartridges/domains/development/github-api-mcp/tests/integration_test.sh new file mode 100755 index 0000000..ece47ef --- /dev/null +++ b/cartridges/domains/development/github-api-mcp/tests/integration_test.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Integration tests for github-api-mcp cartridge. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CART_DIR="$(dirname "$SCRIPT_DIR")" +FFI_DIR="$CART_DIR/ffi" + +echo "=== github-api-mcp integration tests ===" + +# Build FFI +echo "[1/3] Building FFI..." +cd "$FFI_DIR" && zig build 2>&1 + +# Run Zig unit tests +echo "[2/3] Running FFI unit tests..." +cd "$FFI_DIR" && zig build test 2>&1 + +# Validate Idris2 ABI (if idris2 available) +echo "[3/3] Checking ABI..." +if command -v idris2 &>/dev/null; then + cd "$CART_DIR/abi" && idris2 --check GithubApiMcp.SafeCloud 2>&1 + echo " ABI: OK" +else + echo " ABI: SKIPPED (idris2 not in PATH)" +fi + +echo "" +echo "All tests passed for github-api-mcp!" diff --git a/cartridges/domains/development/gitlab-api-mcp/README.adoc b/cartridges/domains/development/gitlab-api-mcp/README.adoc new file mode 100644 index 0000000..f847f60 --- /dev/null +++ b/cartridges/domains/development/gitlab-api-mcp/README.adoc @@ -0,0 +1,120 @@ += gitlab-api-mcp +Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk> +:spdx: MPL-2.0 +:tier: Ayo +:domain: Git +:protocols: MCP, REST, GraphQL + +== Overview + +GitLab REST API v4 and GraphQL wrapper cartridge for the BoJ server. Supports +both **gitlab.com** and **self-hosted** GitLab instances. Used for repository +mirroring, project management, CI/CD pipeline control, and code search. + +Authentication uses `Private-Token` headers with tokens sourced from the +`vault-mcp` cartridge. + +== Supported Actions (20) + +[cols="1,1,2"] +|=== +| Action | Method | GitLab Endpoint + +| ListProjects | GET | `/projects` +| GetProject | GET | `/projects/:id` +| CreateIssue | POST | `/projects/:id/issues` +| ListIssues | GET | `/projects/:id/issues` +| GetIssue | GET | `/projects/:id/issues/:iid` +| CommentIssue | POST | `/projects/:id/issues/:iid/notes` +| CreateMR | POST | `/projects/:id/merge_requests` +| ListMRs | GET | `/projects/:id/merge_requests` +| GetMR | GET | `/projects/:id/merge_requests/:iid` +| MergeMR | PUT | `/projects/:id/merge_requests/:iid/merge` +| ListBranches | GET | `/projects/:id/repository/branches` +| CreateBranch | POST | `/projects/:id/repository/branches` +| SearchCode | GET | `/projects/:id/search?scope=blobs` +| ListPipelines | GET | `/projects/:id/pipelines` +| GetPipeline | GET | `/projects/:id/pipelines/:id` +| TriggerPipeline | POST | `/projects/:id/pipeline` +| ListReleases | GET | `/projects/:id/releases` +| CreateRelease | POST | `/projects/:id/releases` +| PushMirror | POST | `/projects/:id/remote_mirrors` +| GetFileContents | GET | `/projects/:id/repository/files/:path/raw` +|=== + +Additionally, arbitrary **GraphQL** queries are supported via the `/api/graphql` +endpoint. + +== Self-Hosted Instances + +Pass a custom base URL when authenticating: + +[source] +---- +authenticate(slot, token, "https://git.example.org") +---- + +The cartridge stores the base URL per session. All subsequent API calls for that +session are routed to the configured instance. Default: `https://gitlab.com`. + +== Push Mirror Setup + +The `PushMirror` action configures GitLab's built-in push mirroring to replicate +a GitLab project to a remote repository (e.g. GitHub, Bitbucket): + +[source] +---- +push_mirror(slot, project_id, "https://github.com/org/repo.git") +---- + +This calls `POST /projects/:id/remote_mirrors` under the hood. + +== Architecture + +[cols="1,1,2"] +|=== +| Layer | Language | Purpose + +| ABI +| Idris2 +| Formally verified state machine (`SafeGit`) with dependent-type proofs. + States: Unauthenticated, Authenticated, RateLimited, Error. + +| FFI +| Zig +| C-compatible implementation with thread-safe session pool (16 slots), + Private-Token auth, rate-limit tracking, configurable instance URL. + +| Adapter +| zig +| REST/GraphQL bridge mapping all 20 actions to BoJ adapter protocol. +|=== + +== State Machine + +.... +Unauthenticated ──authenticate──► Authenticated +Authenticated ──rate-limit───► RateLimited +RateLimited ──resume───────► Authenticated +Authenticated ──error────────► Error +Error ──reset────────► Unauthenticated +Authenticated ──logout───────► Unauthenticated +.... + +== Building + +[source,bash] +---- +# Build FFI shared library +cd ffi && zig build + +# Run FFI tests +cd ffi && zig build test + +# Type-check ABI +cd abi && idris2 --check gitlab_api_mcp.ipkg +---- + +== Status + +Development β€” customised for GitLab API wrapping, not yet wired to live HTTP. diff --git a/cartridges/domains/development/gitlab-api-mcp/abi/GitlabApiMcp/SafeGit.idr b/cartridges/domains/development/gitlab-api-mcp/abi/GitlabApiMcp/SafeGit.idr new file mode 100644 index 0000000..d216375 --- /dev/null +++ b/cartridges/domains/development/gitlab-api-mcp/abi/GitlabApiMcp/SafeGit.idr @@ -0,0 +1,239 @@ +-- SPDX-License-Identifier: MPL-2.0 +-- Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +-- +-- GitlabApiMcp.SafeGit β€” Type-safe ABI for the gitlab-api-mcp cartridge. +-- +-- Dependently-typed state machine ensuring only valid authentication and +-- session transitions can occur at the FFI boundary. Supports both +-- gitlab.com and self-hosted GitLab instances via InstanceConfig. +-- Zero unsafe escape hatches. + +module GitlabApiMcp.SafeGit + +%default total + +-- --------------------------------------------------------------------------- +-- State machine +-- --------------------------------------------------------------------------- + +||| Authentication/session state for GitLab API operations. +||| Unauthenticated: no valid token set. +||| Authenticated: token validated, ready to make API calls. +||| RateLimited: secondary rate limit hit, must back off before retry. +||| Error: unrecoverable error (bad token, network failure, etc.). +public export +data SessionState + = Unauthenticated + | Authenticated + | RateLimited + | Error + +||| Proof that a state transition is valid. +||| Mirrors the GitHub cartridge pattern: +||| Unauth -> Auth (successful authentication) +||| Auth -> RateLimit (rate limit hit) +||| Rate -> Auth (backoff complete, resume) +||| Auth -> Error (request/network failure) +||| Error -> Unauth (reset / re-init) +||| Auth -> Unauth (explicit logout / token revoke) +public export +data ValidTransition : SessionState -> SessionState -> Type where + Authenticate : ValidTransition Unauthenticated Authenticated + HitRateLimit : ValidTransition Authenticated RateLimited + ResumeFromRate : ValidTransition RateLimited Authenticated + RequestError : ValidTransition Authenticated Error + ResetFromError : ValidTransition Error Unauthenticated + Logout : ValidTransition Authenticated Unauthenticated + +-- --------------------------------------------------------------------------- +-- C-ABI integer encoding +-- --------------------------------------------------------------------------- + +||| Encode session state as C-compatible integer. +export +sessionStateToInt : SessionState -> Int +sessionStateToInt Unauthenticated = 0 +sessionStateToInt Authenticated = 1 +sessionStateToInt RateLimited = 2 +sessionStateToInt Error = 3 + +||| Decode integer back to session state. +export +intToSessionState : Int -> Maybe SessionState +intToSessionState 0 = Just Unauthenticated +intToSessionState 1 = Just Authenticated +intToSessionState 2 = Just RateLimited +intToSessionState 3 = Just Error +intToSessionState _ = Nothing + +||| Check if a state transition is valid (C-ABI export). +||| Returns 1 for valid, 0 for invalid. +export +gitlab_api_mcp_can_transition : Int -> Int -> Int +gitlab_api_mcp_can_transition from to = + case (intToSessionState from, intToSessionState to) of + (Just Unauthenticated, Just Authenticated) => 1 -- authenticate + (Just Authenticated, Just RateLimited) => 1 -- rate limit hit + (Just RateLimited, Just Authenticated) => 1 -- resume + (Just Authenticated, Just Error) => 1 -- request error + (Just Error, Just Unauthenticated) => 1 -- reset + (Just Authenticated, Just Unauthenticated) => 1 -- logout + _ => 0 + +-- --------------------------------------------------------------------------- +-- Instance configuration +-- --------------------------------------------------------------------------- + +||| Configuration for a GitLab instance (gitlab.com or self-hosted). +public export +record InstanceConfig where + constructor MkInstanceConfig + ||| Base URL, e.g. "https://gitlab.com" or "https://git.example.org" + baseUrl : String + ||| API version path segment, e.g. "v4" + apiVersion : String + +||| Default configuration targeting gitlab.com REST API v4. +export +defaultInstance : InstanceConfig +defaultInstance = MkInstanceConfig "https://gitlab.com" "v4" + +-- --------------------------------------------------------------------------- +-- GitLab action types +-- --------------------------------------------------------------------------- + +||| All GitLab REST/GraphQL actions exposed by this cartridge. +public export +data GitLabAction + = ListProjects + | GetProject + | CreateIssue + | ListIssues + | GetIssue + | CommentIssue + | CreateMR + | ListMRs + | GetMR + | MergeMR + | ListBranches + | CreateBranch + | SearchCode + | ListPipelines + | GetPipeline + | TriggerPipeline + | ListReleases + | CreateRelease + | PushMirror + | GetFileContents + +||| Total count of supported actions. +export +actionCount : Nat +actionCount = 20 + +||| Encode action as C-compatible integer for FFI dispatch. +export +actionToInt : GitLabAction -> Int +actionToInt ListProjects = 0 +actionToInt GetProject = 1 +actionToInt CreateIssue = 2 +actionToInt ListIssues = 3 +actionToInt GetIssue = 4 +actionToInt CommentIssue = 5 +actionToInt CreateMR = 6 +actionToInt ListMRs = 7 +actionToInt GetMR = 8 +actionToInt MergeMR = 9 +actionToInt ListBranches = 10 +actionToInt CreateBranch = 11 +actionToInt SearchCode = 12 +actionToInt ListPipelines = 13 +actionToInt GetPipeline = 14 +actionToInt TriggerPipeline = 15 +actionToInt ListReleases = 16 +actionToInt CreateRelease = 17 +actionToInt PushMirror = 18 +actionToInt GetFileContents = 19 + +||| Decode integer back to action. +export +intToAction : Int -> Maybe GitLabAction +intToAction 0 = Just ListProjects +intToAction 1 = Just GetProject +intToAction 2 = Just CreateIssue +intToAction 3 = Just ListIssues +intToAction 4 = Just GetIssue +intToAction 5 = Just CommentIssue +intToAction 6 = Just CreateMR +intToAction 7 = Just ListMRs +intToAction 8 = Just GetMR +intToAction 9 = Just MergeMR +intToAction 10 = Just ListBranches +intToAction 11 = Just CreateBranch +intToAction 12 = Just SearchCode +intToAction 13 = Just ListPipelines +intToAction 14 = Just GetPipeline +intToAction 15 = Just TriggerPipeline +intToAction 16 = Just ListReleases +intToAction 17 = Just CreateRelease +intToAction 18 = Just PushMirror +intToAction 19 = Just GetFileContents +intToAction _ = Nothing + +||| Check whether an action requires an authenticated session. +||| All GitLab API operations require authentication. +export +actionRequiresAuth : GitLabAction -> Bool +actionRequiresAuth _ = True + +-- --------------------------------------------------------------------------- +-- HTTP method mapping +-- --------------------------------------------------------------------------- + +||| HTTP method for a given action. +public export +data HttpMethod = GET | POST | PUT | DELETE + +||| Determine the HTTP method for each action. +export +actionMethod : GitLabAction -> HttpMethod +actionMethod ListProjects = GET +actionMethod GetProject = GET +actionMethod CreateIssue = POST +actionMethod ListIssues = GET +actionMethod GetIssue = GET +actionMethod CommentIssue = POST +actionMethod CreateMR = POST +actionMethod ListMRs = GET +actionMethod GetMR = GET +actionMethod MergeMR = PUT +actionMethod ListBranches = GET +actionMethod CreateBranch = POST +actionMethod SearchCode = GET +actionMethod ListPipelines = GET +actionMethod GetPipeline = GET +actionMethod TriggerPipeline = POST +actionMethod ListReleases = GET +actionMethod CreateRelease = POST +actionMethod PushMirror = POST +actionMethod GetFileContents = GET + +-- --------------------------------------------------------------------------- +-- C-ABI exports for action validation +-- --------------------------------------------------------------------------- + +||| Validate an action integer is in range. Returns 1 (valid) or 0 (invalid). +export +gitlab_api_mcp_valid_action : Int -> Int +gitlab_api_mcp_valid_action n = + case intToAction n of + Just _ => 1 + Nothing => 0 + +||| Check if an action requires authentication. Returns 1 (yes) or 0 (no). +export +gitlab_api_mcp_action_requires_auth : Int -> Int +gitlab_api_mcp_action_requires_auth n = + case intToAction n of + Just act => if actionRequiresAuth act then 1 else 0 + Nothing => -1 diff --git a/cartridges/domains/development/gitlab-api-mcp/abi/README.adoc b/cartridges/domains/development/gitlab-api-mcp/abi/README.adoc new file mode 100644 index 0000000..6a26ea1 --- /dev/null +++ b/cartridges/domains/development/gitlab-api-mcp/abi/README.adoc @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += gitlab-api-mcp / abi β€” Idris2 ABI layer +:orientation: shallow + +== Purpose + +Cartridge-local protocol types for `gitlab-api-mcp`. Defines the data, tool, and +transition vocabulary this cartridge speaks. Inherits safety lemmas and the +matrix-cell identifier scheme from the trunk ABI at `src/abi/`. + +== Files + +1 Idris2 module(s), ~239 lines total. Lead module: +`SafeGit.idr`. + +No `.ipkg` β€” consumed directly by wider BoJ callers; type-checks inside the +trunk `boj.ipkg`. + +== Invariants + +* `%default total` at every module top. +* Zero `believe_me`, `assert_total`, `postulate`, `Admitted`, or + `sorry` (the trunk holds the four declared `believe_me` sites; leaf + cartridges carry none). + +== Test/proof surface + +Thin module(s), no inline tests. Proof surface = type-check success under the +trunk `idris2 --check`. + +== Read-first + +. `SafeGit.idr` β€” cartridge-local data records and enums. +. Trunk `src/abi/Boj/Catalogue.idr` for how this cell participates in the + 2D matrix. diff --git a/cartridges/domains/development/gitlab-api-mcp/abi/gitlab_api_mcp.ipkg b/cartridges/domains/development/gitlab-api-mcp/abi/gitlab_api_mcp.ipkg new file mode 100644 index 0000000..bed5555 --- /dev/null +++ b/cartridges/domains/development/gitlab-api-mcp/abi/gitlab_api_mcp.ipkg @@ -0,0 +1,10 @@ +-- SPDX-License-Identifier: MPL-2.0 +package gitlab_api_mcp + +version = "0.1.0" +authors = "Jonathan D.A. Jewell" +brief = "gitlab-api-mcp cartridge β€” GitLab REST/GraphQL API wrapper" + +depends = base + +modules = GitlabApiMcp.SafeGit diff --git a/cartridges/domains/development/gitlab-api-mcp/adapter/README.adoc b/cartridges/domains/development/gitlab-api-mcp/adapter/README.adoc new file mode 100644 index 0000000..1204ac9 --- /dev/null +++ b/cartridges/domains/development/gitlab-api-mcp/adapter/README.adoc @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += gitlab-api-mcp / adapter β€” protocol bridge +:orientation: shallow + +== Purpose + +Exposes this cartridge's FFI over the wire protocols declared in +`cartridge.json`. Dispatches tool invocations to the C-ABI exports at +`../ffi/` and formats responses per protocol. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” depends on the sibling `ffi` target. +| `gitlab_api_mcp_adapter.zig` | Protocol dispatch (144 lines). Tool catalogue matches `cartridge.json`. +| `SIDELINED-*.v.adoc` | zig predecessor(s). Not built β€” zig banned 2026-04-10. +|=== + +== Invariants + +* **Stateless** β€” all state lives behind the FFI, never in the adapter. +* **Response passthrough** β€” whatever the FFI returns goes back to the wire + unmodified (no embellishment, no silent recovery). +* **Cartridge.json is source of truth** for the tool catalogue; drift between + adapter and manifest is a CI failure. + +== Test/proof surface + +No inline tests at the adapter layer β€” protocol routing is exercised by the +trunk seam tests at `ffi/zig/src/seams.zig`. + +== Read-first + +. `gitlab_api_mcp_adapter.zig` β€” dispatch function and per-protocol routers. +. `../cartridge.json` β€” the tool manifest the dispatcher mirrors. diff --git a/cartridges/domains/development/gitlab-api-mcp/adapter/build.zig b/cartridges/domains/development/gitlab-api-mcp/adapter/build.zig new file mode 100644 index 0000000..d56585b --- /dev/null +++ b/cartridges/domains/development/gitlab-api-mcp/adapter/build.zig @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// gitlab-api-mcp/adapter/build.zig + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const ffi_mod = b.createModule(.{ + .root_source_file = b.path("../ffi/gitlab_api_mcp_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + const adapter = b.addExecutable(.{ + .name = "gitlab_api_mcp_adapter", + .root_source_file = b.path("gitlab_api_mcp_adapter.zig"), + .target = target, + .optimize = optimize, + }); + adapter.root_module.addImport("gitlab_api_mcp_ffi", ffi_mod); + b.installArtifact(adapter); +} diff --git a/cartridges/domains/development/gitlab-api-mcp/adapter/gitlab_api_mcp_adapter.zig b/cartridges/domains/development/gitlab-api-mcp/adapter/gitlab_api_mcp_adapter.zig new file mode 100644 index 0000000..1c2a208 --- /dev/null +++ b/cartridges/domains/development/gitlab-api-mcp/adapter/gitlab_api_mcp_adapter.zig @@ -0,0 +1,144 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// gitlab-api-mcp/adapter/gitlab_api_mcp_adapter.zig +// +// Three-protocol BoJ adapter: REST (port 9247), gRPC-compat (port 9248), +// GraphQL (port 9249). +// Replaces the banned zig adapter (gitlab_api_mcp_adapter.v). + +const std = @import("std"); +const ffi = @import("gitlab_api_mcp_ffi"); + +const REST_PORT: u16 = 9247; +const GRPC_PORT: u16 = 9248; +const GQL_PORT: u16 = 9249; + +const Response = struct { status: u16, body: []const u8 }; + +fn okJson(buf: []u8, msg: []const u8) []u8 { + return std.fmt.bufPrint(buf, "{{\"success\":true,\"message\":\"{s}\"}}", .{msg}) catch buf[0..0]; +} + +fn errJson(buf: []u8, msg: []const u8) []u8 { + return std.fmt.bufPrint(buf, "{{\"success\":false,\"error\":\"{s}\"}}", .{msg}) catch buf[0..0]; +} + +fn dispatch(tool: []const u8, _body: []const u8, resp: []u8) Response { + _ = _body; + if (std.mem.eql(u8, tool, "gitlab_authenticate")) { + return .{ .status = 200, .body = okJson(resp, "gitlab_authenticate forwarded") }; + } + if (std.mem.eql(u8, tool, "gitlab_list_projects")) { + return .{ .status = 200, .body = okJson(resp, "gitlab_list_projects forwarded") }; + } + if (std.mem.eql(u8, tool, "gitlab_get_project")) { + return .{ .status = 200, .body = okJson(resp, "gitlab_get_project forwarded") }; + } + if (std.mem.eql(u8, tool, "gitlab_list_issues")) { + return .{ .status = 200, .body = okJson(resp, "gitlab_list_issues forwarded") }; + } + if (std.mem.eql(u8, tool, "gitlab_create_issue")) { + return .{ .status = 200, .body = okJson(resp, "gitlab_create_issue forwarded") }; + } + if (std.mem.eql(u8, tool, "gitlab_create_mr")) { + return .{ .status = 200, .body = okJson(resp, "gitlab_create_mr forwarded") }; + } + if (std.mem.eql(u8, tool, "gitlab_setup_mirror")) { + return .{ .status = 200, .body = okJson(resp, "gitlab_setup_mirror forwarded") }; + } + return .{ .status = 404, .body = errJson(resp, "unknown tool") }; +} + +fn dispatchRest(path: []const u8, body: []const u8, resp: []u8) Response { + // Expect /tools/<tool_name> + const prefix = "/tools/"; + if (std.mem.startsWith(u8, path, prefix)) { + const tool = path[prefix.len..]; + return dispatch(tool, body, resp); + } + return .{ .status = 404, .body = errJson(resp, "not found") }; +} + +fn dispatchGrpc(path: []const u8, body: []const u8, resp: []u8) Response { + // Expect /<Service>/<Method> β€” derive tool from Method + var it = std.mem.splitScalar(u8, path, '/'); + _ = it.next(); // leading empty + _ = it.next(); // service + const method = it.next() orelse return .{ .status = 404, .body = errJson(resp, "bad gRPC path") }; + return dispatch(method, body, resp); +} + +fn dispatchGraphql(body: []const u8, resp: []u8) Response { + if (std.mem.indexOf(u8, body, "gitlab_authenticate") != null) + return dispatch("gitlab_authenticate", body, resp); + if (std.mem.indexOf(u8, body, "gitlab_list_projects") != null) + return dispatch("gitlab_list_projects", body, resp); + if (std.mem.indexOf(u8, body, "gitlab_get_project") != null) + return dispatch("gitlab_get_project", body, resp); + if (std.mem.indexOf(u8, body, "gitlab_list_issues") != null) + return dispatch("gitlab_list_issues", body, resp); + if (std.mem.indexOf(u8, body, "gitlab_create_issue") != null) + return dispatch("gitlab_create_issue", body, resp); + if (std.mem.indexOf(u8, body, "gitlab_create_mr") != null) + return dispatch("gitlab_create_mr", body, resp); + if (std.mem.indexOf(u8, body, "gitlab_setup_mirror") != null) + return dispatch("gitlab_setup_mirror", body, resp); + return .{ .status = 400, .body = errJson(resp, "unrecognised GraphQL operation") }; +} + +fn handleConnection(stream: std.net.Stream, port: u16) void { + defer stream.close(); + var buf: [4096]u8 = undefined; + var resp_buf: [4096]u8 = undefined; + const n = stream.read(&buf) catch return; + const req = buf[0..n]; + const result = switch (port) { + REST_PORT => blk: { + // Parse HTTP/1.1: first line = METHOD PATH HTTP/x.y + var lines = std.mem.splitScalar(u8, req, '\n'); + const first = lines.next() orelse break :blk Response{ .status = 400, .body = "" }; + var parts = std.mem.splitScalar(u8, std.mem.trim(u8, first, "\r"), ' '); + _ = parts.next(); // method + const path = parts.next() orelse break :blk Response{ .status = 400, .body = "" }; + break :blk dispatchRest(path, req, &resp_buf); + }, + GRPC_PORT => blk: { + var lines = std.mem.splitScalar(u8, req, '\n'); + const first = lines.next() orelse break :blk Response{ .status = 400, .body = "" }; + var parts = std.mem.splitScalar(u8, std.mem.trim(u8, first, "\r"), ' '); + _ = parts.next(); + const path = parts.next() orelse break :blk Response{ .status = 400, .body = "" }; + break :blk dispatchGrpc(path, req, &resp_buf); + }, + GQL_PORT => dispatchGraphql(req, &resp_buf), + else => Response{ .status = 500, .body = "" }, + }; + var http_resp: [512]u8 = undefined; + const http = std.fmt.bufPrint(&http_resp, + "HTTP/1.1 {d} OK\r\nContent-Length: {d}\r\nContent-Type: application/json\r\n\r\n", + .{ result.status, result.body.len }) catch return; + _ = stream.write(http) catch {}; + _ = stream.write(result.body) catch {}; +} + +fn listenLoop(port: u16) !void { + const addr = std.net.Address.initIp4(.{ 127, 0, 0, 1 }, port); + var server = try addr.listen(.{ .reuse_address = true }); + defer server.deinit(); + while (true) { + const conn = try server.accept(); + const t = try std.Thread.spawn(.{}, handleConnection, .{ conn.stream, port }); + t.detach(); + } +} + +pub fn main() !void { + ffi.gitlab_api_mcp_init(); + const t1 = try std.Thread.spawn(.{}, listenLoop, .{REST_PORT}); + const t2 = try std.Thread.spawn(.{}, listenLoop, .{GRPC_PORT}); + const t3 = try std.Thread.spawn(.{}, listenLoop, .{GQL_PORT}); + t1.join(); + t2.join(); + t3.join(); +} diff --git a/cartridges/domains/development/gitlab-api-mcp/benchmarks/quick-bench.sh b/cartridges/domains/development/gitlab-api-mcp/benchmarks/quick-bench.sh new file mode 100755 index 0000000..cf930c9 --- /dev/null +++ b/cartridges/domains/development/gitlab-api-mcp/benchmarks/quick-bench.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Benchmarks for gitlab-api-mcp cartridge. +set -euo pipefail + +echo "=== gitlab-api-mcp benchmarks ===" + +cd "$(dirname "${BASH_SOURCE[0]}")/../ffi" + +# Build with ReleaseFast for benchmarking +zig build -Doptimize=ReleaseFast 2>&1 + +echo "Session open/close cycle (1000 iterations):" +time for i in $(seq 1 1000); do + # This would call the FFI benchmark binary + true +done + +echo "" +echo "Benchmark placeholder β€” implement real benchmarks in Zig test blocks" +echo "or via the zig adapter HTTP benchmark tool." diff --git a/cartridges/domains/development/gitlab-api-mcp/cartridge.json b/cartridges/domains/development/gitlab-api-mcp/cartridge.json new file mode 100644 index 0000000..6ba7a5f --- /dev/null +++ b/cartridges/domains/development/gitlab-api-mcp/cartridge.json @@ -0,0 +1,214 @@ +{ + "$schema": "https://boj.dev/schemas/cartridge/v1.json", + "spdx": "MPL-2.0", + "copyright": "Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk>", + "name": "gitlab-api-mcp", + "version": "0.1.0", + "description": "GitLab REST API β€” projects, issues, MRs", + "domain": "development", + "tier": "Ayo", + "protocols": [ + "MCP", + "REST" + ], + "auth": { + "method": "none", + "env_var": null, + "credential_source": null + }, + "api": { + "base_url": "local://gitlab-api-mcp", + "content_type": "application/json" + }, + "tools": [ + { + "name": "gitlab_authenticate", + "description": "Authenticate with GitLab PAT", + "inputSchema": { + "type": "object", + "properties": { + "token": { + "type": "string", + "description": "GitLab personal access token" + }, + "base_url": { + "type": "string", + "description": "GitLab instance URL" + } + }, + "required": [ + "token" + ] + } + }, + { + "name": "gitlab_list_projects", + "description": "List accessible projects", + "inputSchema": { + "type": "object", + "properties": { + "session_id": { + "type": "integer", + "description": "Session index" + }, + "search": { + "type": "string", + "description": "Search term" + } + }, + "required": [ + "session_id" + ] + } + }, + { + "name": "gitlab_get_project", + "description": "Get project details", + "inputSchema": { + "type": "object", + "properties": { + "session_id": { + "type": "integer", + "description": "Session index" + }, + "project_id": { + "type": "string", + "description": "Project ID or namespace/name" + } + }, + "required": [ + "session_id", + "project_id" + ] + } + }, + { + "name": "gitlab_list_issues", + "description": "List project issues", + "inputSchema": { + "type": "object", + "properties": { + "session_id": { + "type": "integer", + "description": "Session index" + }, + "project_id": { + "type": "string", + "description": "Project ID" + }, + "state": { + "type": "string", + "description": "Filter: opened|closed|all" + } + }, + "required": [ + "session_id", + "project_id" + ] + } + }, + { + "name": "gitlab_create_issue", + "description": "Create a project issue", + "inputSchema": { + "type": "object", + "properties": { + "session_id": { + "type": "integer", + "description": "Session index" + }, + "project_id": { + "type": "string", + "description": "Project ID" + }, + "title": { + "type": "string", + "description": "Issue title" + }, + "description": { + "type": "string", + "description": "Issue description" + } + }, + "required": [ + "session_id", + "project_id", + "title" + ] + } + }, + { + "name": "gitlab_create_mr", + "description": "Create a merge request", + "inputSchema": { + "type": "object", + "properties": { + "session_id": { + "type": "integer", + "description": "Session index" + }, + "project_id": { + "type": "string", + "description": "Project ID" + }, + "title": { + "type": "string", + "description": "MR title" + }, + "source_branch": { + "type": "string", + "description": "Source branch" + }, + "target_branch": { + "type": "string", + "description": "Target branch" + } + }, + "required": [ + "session_id", + "project_id", + "title", + "source_branch", + "target_branch" + ] + } + }, + { + "name": "gitlab_setup_mirror", + "description": "Configure downstream mirror from GitHub", + "inputSchema": { + "type": "object", + "properties": { + "session_id": { + "type": "integer", + "description": "Session index" + }, + "project_id": { + "type": "string", + "description": "Project ID" + }, + "remote_url": { + "type": "string", + "description": "GitHub remote URL" + } + }, + "required": [ + "session_id", + "project_id", + "remote_url" + ] + } + } + ], + "ffi": { + "so_path": "ffi/zig-out/lib/libgitlab_api_mcp.so", + "abi_version": "ADR-0006", + "symbols": [ + "boj_cartridge_init", + "boj_cartridge_deinit", + "boj_cartridge_name", + "boj_cartridge_version", + "boj_cartridge_invoke" + ] + } +} diff --git a/cartridges/domains/development/gitlab-api-mcp/ffi/README.adoc b/cartridges/domains/development/gitlab-api-mcp/ffi/README.adoc new file mode 100644 index 0000000..2d6db37 --- /dev/null +++ b/cartridges/domains/development/gitlab-api-mcp/ffi/README.adoc @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += gitlab-api-mcp / ffi β€” Zig FFI layer +:orientation: shallow + +== Purpose + +C-ABI exports bridging this cartridge's Idris2 ABI to the runtime. Consumed by +the adapter for protocol dispatch and by the trunk loader for mount. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” produces `zig-out/lib/libgitlab-api.so`. +| `gitlab_api_mcp_ffi.zig` | C-ABI exports (15 exports, 8 inline tests, 850 lines). +|=== + +== Invariants + +* **Null-pointer rejection** on `[*c]const u8` arguments before any + dereference. +* **Negative return values** carry documented error meanings; zero is + success (matching the trunk error-code convention). +* **No allocator leaks** β€” every allocation path pairs with `defer free`. + +== Test/proof surface + +8 inline `test "..."` block(s) in `gitlab_api_mcp_ffi.zig`. Run with +`cd ffi && zig build test`. + +== Read-first + +. `gitlab_api_mcp_ffi.zig` β€” C-ABI signatures and guards. +. Trunk `ffi/zig/src/catalogue.zig` for how this `.so` is loaded. diff --git a/cartridges/domains/development/gitlab-api-mcp/ffi/build.zig b/cartridges/domains/development/gitlab-api-mcp/ffi/build.zig new file mode 100644 index 0000000..d4f0cdb --- /dev/null +++ b/cartridges/domains/development/gitlab-api-mcp/ffi/build.zig @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + // Module + // Shared ADR-0006 invoke-shim module (relative path up to boj-server trunk). + + const shim_mod = b.addModule("cartridge_shim", .{ + + .root_source_file = b.path("../../../ffi/zig/src/cartridge_shim.zig"), + + .target = target, + + .optimize = optimize, + + }); + + const ffi_mod = b.addModule("gitlab_api_mcp", .{ + .root_source_file = b.path("gitlab_api_mcp_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + ffi_mod.addImport("cartridge_shim", shim_mod); + + // Shared library + const lib = b.addLibrary(.{ + .name = "gitlab_api_mcp", + .root_module = ffi_mod, + .linkage = .dynamic, + }); + lib.linkLibC(); + b.installArtifact(lib); + + // Tests + const tests = b.addTest(.{ + .root_module = ffi_mod, + }); + tests.linkLibC(); + + const run_tests = b.addRunArtifact(tests); + const test_step = b.step("test", "Run FFI tests"); + test_step.dependOn(&run_tests.step); +} diff --git a/cartridges/domains/development/gitlab-api-mcp/ffi/cartridge_shim.zig b/cartridges/domains/development/gitlab-api-mcp/ffi/cartridge_shim.zig new file mode 100644 index 0000000..0e399db --- /dev/null +++ b/cartridges/domains/development/gitlab-api-mcp/ffi/cartridge_shim.zig @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// cartridge_shim.zig β€” Shared helpers for the ADR-0006 five-symbol +// cartridge ABI (`boj_cartridge_init / deinit / name / version / invoke`). +// +// The shim centralises the seven-code return convention, NUL-argument +// guards, tool-name comparison, and the buffer-too-small path so each +// cartridge's `boj_cartridge_invoke` can stay short β€” typically a tool +// table plus `shim.writeResult(...)`. +// +// Cartridges import this file by relative path (no build-graph change +// needed). Example: +// +// const shim = @import("../../../ffi/zig/src/cartridge_shim.zig"); +// +// export fn boj_cartridge_invoke( +// tool_name: [*c]const u8, +// json_args: [*c]const u8, +// out_buf: [*c]u8, +// in_out_len: [*c]usize, +// ) callconv(.c) i32 { +// _ = json_args; +// if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; +// const body = if (shim.toolIs(tool_name, "foo")) "{\"result\":{}}" +// else return shim.RC_UNKNOWN_TOOL; +// return shim.writeResult(out_buf, in_out_len, body); +// } + +const std = @import("std"); + +// ── Return codes (ADR-0006 Β§Return codes) ──────────────────────────── +// +// Frozen by ADR-0006. New failure modes compose these via the error +// JSON body β€” the integer surface does not grow without a follow-up ADR. + +pub const RC_SUCCESS: i32 = 0; +pub const RC_UNKNOWN_TOOL: i32 = -1; +pub const RC_BAD_ARGS: i32 = -2; +pub const RC_BUFFER_TOO_SMALL: i32 = -3; +pub const RC_RUNTIME_ERROR: i32 = -4; +pub const RC_PANIC: i32 = -5; +pub const RC_AUTH_DENIED: i32 = -6; + +// ── Invoke-path helpers ────────────────────────────────────────────── + +/// True if any of the three mandatory `boj_cartridge_invoke` output-path +/// pointers is null. Use at the top of every invoke to short-circuit to +/// `RC_BAD_ARGS`. +pub fn invokeArgsNull( + tool_name: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) bool { + return tool_name == null or out_buf == null or in_out_len == null; +} + +/// Compare a C-NUL-terminated tool-name pointer against a Zig string +/// literal. Caller must have already verified `tool_name` is non-null +/// (usually via `invokeArgsNull`). +pub fn toolIs(tool_name: [*c]const u8, expected: []const u8) bool { + const s = std.mem.span(@as([*:0]const u8, @ptrCast(tool_name))); + return std.mem.eql(u8, s, expected); +} + +/// Copy `body` into `out_buf[0..*in_out_len]` (as a capacity) and update +/// `*in_out_len` to the number of bytes written. Returns `RC_SUCCESS`. +/// +/// If `body.len` exceeds the current capacity stored in `*in_out_len`, +/// sets `*in_out_len` to the required size and returns +/// `RC_BUFFER_TOO_SMALL` β€” the caller is then expected to re-allocate +/// and retry, per ADR-0006 Β§Memory ownership. +/// +/// Caller must have already verified that `out_buf` and `in_out_len` +/// are non-null. +pub fn writeResult( + out_buf: [*c]u8, + in_out_len: [*c]usize, + body: []const u8, +) i32 { + const cap = in_out_len.*; + if (body.len > cap) { + in_out_len.* = body.len; + return RC_BUFFER_TOO_SMALL; + } + @memcpy(out_buf[0..body.len], body); + in_out_len.* = body.len; + return RC_SUCCESS; +} + +// ── Tests ──────────────────────────────────────────────────────────── + +test "writeResult: body fits, writes and sets length" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); + try std.testing.expectEqualStrings("hello", buf[0..len]); +} + +test "writeResult: too small returns -3 and sets required length" { + var buf: [2]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_BUFFER_TOO_SMALL, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: exact-fit succeeds" { + var buf: [5]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: empty body" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, ""); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 0), len); +} + +test "toolIs: matches and rejects" { + const name: [*:0]const u8 = "foo"; + try std.testing.expect(toolIs(@ptrCast(name), "foo")); + try std.testing.expect(!toolIs(@ptrCast(name), "bar")); + try std.testing.expect(!toolIs(@ptrCast(name), "foobar")); + try std.testing.expect(!toolIs(@ptrCast(name), "fo")); +} + +test "invokeArgsNull: detects each null slot" { + var buf: [4]u8 = undefined; + var len: usize = 4; + const name: [*:0]const u8 = "x"; + try std.testing.expect(!invokeArgsNull(@ptrCast(name), &buf, &len)); + try std.testing.expect(invokeArgsNull(null, &buf, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), null, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), &buf, null)); +} diff --git a/cartridges/domains/development/gitlab-api-mcp/ffi/gitlab_api_mcp_ffi.zig b/cartridges/domains/development/gitlab-api-mcp/ffi/gitlab_api_mcp_ffi.zig new file mode 100644 index 0000000..c7ba696 --- /dev/null +++ b/cartridges/domains/development/gitlab-api-mcp/ffi/gitlab_api_mcp_ffi.zig @@ -0,0 +1,982 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// gitlab_api_mcp_ffi.zig β€” C-ABI FFI implementation for gitlab-api-mcp cartridge. +// +// Implements the authentication state machine defined in the Idris2 ABI layer +// (GitlabApiMcp.SafeGit). Provides real HTTP dispatch to GitLab REST API v4 +// and GraphQL via std.http.Client, configurable base URL for self-hosted +// instances, Private-Token authentication (token obtained from vault-mcp), +// and rate-limit tracking. +// +// Thread-safe via std.Thread.Mutex. Fixed-size session pool, no heap +// allocations for results. + +const std = @import("std"); + +// --------------------------------------------------------------------------- +// State machine (matches Idris2 ABI: SafeGit) +// --------------------------------------------------------------------------- + +/// Authentication/session state for GitLab API operations. +/// 0 = Unauthenticated β€” no valid token +/// 1 = Authenticated β€” token set, ready for requests +/// 2 = RateLimited β€” must back off +/// 3 = Error β€” unrecoverable until reset +pub const SessionState = enum(c_int) { + unauthenticated = 0, + authenticated = 1, + rate_limited = 2, + err = 3, +}; + +/// Valid state transitions (mirrors Idris2 ValidTransition): +/// Unauth -> Auth (authenticate) +/// Auth -> Rate (hit rate limit) +/// Rate -> Auth (resume after backoff) +/// Auth -> Error (request failure) +/// Error -> Unauth (reset) +/// Auth -> Unauth (logout) +fn isValidTransition(from: SessionState, to: SessionState) bool { + return switch (from) { + .unauthenticated => to == .authenticated, + .authenticated => to == .rate_limited or to == .err or to == .unauthenticated, + .rate_limited => to == .authenticated, + .err => to == .unauthenticated, + }; +} + +// --------------------------------------------------------------------------- +// HTTP method enum (matches Idris2 HttpMethod) +// --------------------------------------------------------------------------- + +pub const HttpMethod = enum(c_int) { + get = 0, + post = 1, + put = 2, + delete = 3, +}; + +// --------------------------------------------------------------------------- +// GitLab actions (matches Idris2 GitLabAction + actionToInt encoding) +// --------------------------------------------------------------------------- + +/// All GitLab REST/GraphQL actions exposed by this cartridge. +/// Encoding mirrors `GitlabApiMcp.SafeGit.actionToInt` (0–19). +/// Declared here so `iseriser abi-verify` can structurally check the +/// encoding against the Idris2 source; dispatch wiring follows the +/// same numbering when introduced. +pub const GitLabAction = enum(c_int) { + list_projects = 0, + get_project = 1, + create_issue = 2, + list_issues = 3, + get_issue = 4, + comment_issue = 5, + create_mr = 6, + list_mrs = 7, + get_mr = 8, + merge_mr = 9, + list_branches = 10, + create_branch = 11, + search_code = 12, + list_pipelines = 13, + get_pipeline = 14, + trigger_pipeline = 15, + list_releases = 16, + create_release = 17, + push_mirror = 18, + get_file_contents = 19, +}; + +// --------------------------------------------------------------------------- +// Session slots (thread-safe, fixed-size pool) +// --------------------------------------------------------------------------- + +const MAX_SESSIONS: usize = 16; +const BUF_SIZE: usize = 8192; +const TOKEN_SIZE: usize = 256; +const URL_SIZE: usize = 512; + +const SessionSlot = struct { + active: bool = false, + state: SessionState = .unauthenticated, + + /// Private-Token for GitLab authentication (from vault-mcp). + token_buf: [TOKEN_SIZE]u8 = undefined, + token_len: usize = 0, + + /// Base URL for the GitLab instance (default: https://gitlab.com). + base_url_buf: [URL_SIZE]u8 = undefined, + base_url_len: usize = 0, + + /// API version path segment (default: "v4"). + api_version_buf: [32]u8 = undefined, + api_version_len: usize = 0, + + /// Rate-limit tracking: remaining requests in current window. + rate_limit_remaining: i32 = -1, + + /// Rate-limit tracking: epoch seconds when window resets. + rate_limit_reset: i64 = 0, + + /// Response buffer for last operation. + response_buf: [BUF_SIZE]u8 = undefined, + response_len: usize = 0, +}; + +var sessions: [MAX_SESSIONS]SessionSlot = [_]SessionSlot{.{}} ** MAX_SESSIONS; +var mutex: std.Thread.Mutex = .{}; + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +/// Copy a C string (ptr + len) into a fixed buffer. Returns bytes written, or 0 +/// if the source is null or exceeds capacity. +fn copyToBuf(dest: []u8, src: [*c]const u8, len: usize) usize { + if (src == null) return 0; + if (len > dest.len) return 0; + @memcpy(dest[0..len], src[0..len]); + return len; +} + +/// Set default instance config on a slot if not already set. +fn ensureDefaults(slot: *SessionSlot) void { + if (slot.base_url_len == 0) { + const default_url = "https://gitlab.com"; + @memcpy(slot.base_url_buf[0..default_url.len], default_url); + slot.base_url_len = default_url.len; + } + if (slot.api_version_len == 0) { + const default_ver = "v4"; + @memcpy(slot.api_version_buf[0..default_ver.len], default_ver); + slot.api_version_len = default_ver.len; + } +} + +// --------------------------------------------------------------------------- +// C-ABI exports β€” state machine +// --------------------------------------------------------------------------- + +/// Check if a state transition is valid. Returns 1 (valid) or 0 (invalid). +pub export fn gitlab_api_mcp_can_transition(from: c_int, to: c_int) c_int { + const f = std.meta.intToEnum(SessionState, from) catch return 0; + const t = std.meta.intToEnum(SessionState, to) catch return 0; + return if (isValidTransition(f, t)) 1 else 0; +} + +// --------------------------------------------------------------------------- +// C-ABI exports β€” session management +// --------------------------------------------------------------------------- + +/// Open a new session in Unauthenticated state. +/// Returns slot index (>= 0) or -1 if no free slots. +pub export fn gitlab_api_mcp_session_open() c_int { + mutex.lock(); + defer mutex.unlock(); + + for (&sessions, 0..) |*slot, idx| { + if (!slot.active) { + slot.* = .{}; + slot.active = true; + slot.state = .unauthenticated; + ensureDefaults(slot); + return @intCast(idx); + } + } + return -1; +} + +/// Close a session (must be in Authenticated or Unauthenticated state). +/// Returns 0 on success, -1 if slot invalid, -2 if bad transition. +pub export fn gitlab_api_mcp_session_close(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + + // Can close from Unauthenticated (already logged out) or Authenticated (implicit logout). + if (slot.state != .unauthenticated and slot.state != .authenticated) return -2; + + // Zero the token before releasing. + @memset(&slot.token_buf, 0); + slot.* = .{}; + return 0; +} + +/// Get the current state of a session. Returns state int or -1 if invalid slot. +pub export fn gitlab_api_mcp_session_state(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + return @intFromEnum(slot.state); +} + +// --------------------------------------------------------------------------- +// C-ABI exports β€” authentication +// --------------------------------------------------------------------------- + +/// Authenticate a session with a Private-Token and optional base URL. +/// Transitions: Unauthenticated -> Authenticated. +/// +/// Parameters: +/// slot_idx β€” session slot +/// token β€” GitLab Private-Token (from vault-mcp) +/// token_len β€” length of token string +/// base_url β€” GitLab instance base URL (NULL for default https://gitlab.com) +/// url_len β€” length of base_url (ignored if base_url is NULL) +/// +/// Returns 0 on success, -1 invalid slot, -2 bad transition, -3 token too long, +/// -4 URL too long. +pub export fn gitlab_api_mcp_authenticate( + slot_idx: c_int, + token: [*c]const u8, + token_len: c_int, + base_url: [*c]const u8, + url_len: c_int, +) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + if (!isValidTransition(slot.state, .authenticated)) return -2; + + const tlen: usize = std.math.cast(usize, token_len) orelse return -3; + if (tlen == 0 or tlen > TOKEN_SIZE) return -3; + + slot.token_len = copyToBuf(&slot.token_buf, token, tlen); + if (slot.token_len == 0) return -3; + + // Optional custom base URL for self-hosted instances. + if (base_url != null and url_len > 0) { + const ulen: usize = std.math.cast(usize, url_len) orelse return -4; + if (ulen > URL_SIZE) return -4; + slot.base_url_len = copyToBuf(&slot.base_url_buf, base_url, ulen); + if (slot.base_url_len == 0) return -4; + } + + slot.state = .authenticated; + slot.rate_limit_remaining = -1; + slot.rate_limit_reset = 0; + return 0; +} + +// --------------------------------------------------------------------------- +// C-ABI exports β€” REST API request +// --------------------------------------------------------------------------- + +/// Execute a GitLab REST API request. +/// Transitions: Authenticated -> Authenticated (on success), +/// Authenticated -> RateLimited (on 429), +/// Authenticated -> Error (on failure). +/// +/// Parameters: +/// slot_idx β€” session slot (must be Authenticated) +/// method β€” HTTP method (0=GET, 1=POST, 2=PUT, 3=DELETE) +/// path β€” API path, e.g. "/projects" (appended to base_url/api/v4) +/// path_len β€” length of path +/// body β€” request body (NULL for GET/DELETE) +/// body_len β€” length of body +/// out_buf β€” caller-owned buffer for response +/// out_cap β€” capacity of out_buf +/// out_len β€” pointer to write actual response length +/// +/// Returns 0 on success, -1 invalid slot, -2 bad state, -3 rate limited, +/// -4 request error, -5 buffer too small. +/// +/// HTTP dispatch is performed via std.http.Client to <base_url>/api/<version><path> +/// with Private-Token authentication header. +pub export fn gitlab_api_mcp_request( + slot_idx: c_int, + method: c_int, + path: [*c]const u8, + path_len: c_int, + body: [*c]const u8, + body_len: c_int, + out_buf: [*c]u8, + out_cap: c_int, + out_len: *c_int, +) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + if (slot.state != .authenticated) return -2; + + const plen: usize = std.math.cast(usize, path_len) orelse return -4; + if (path == null or plen == 0) return -4; + + // Build full URL: <base_url>/api/<version><path> + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); + defer arena.deinit(); + const allocator = arena.allocator(); + + const base_url = slot.base_url_buf[0..slot.base_url_len]; + const api_version = slot.api_version_buf[0..slot.api_version_len]; + const path_slice = path[0..plen]; + const url_str = std.fmt.allocPrint(allocator, "{s}/api/{s}{s}", .{ base_url, api_version, path_slice }) catch return -4; + + // Determine HTTP method + const http_method_enum = std.meta.intToEnum(HttpMethod, method) catch return -4; + const http_method: std.http.Method = switch (http_method_enum) { + .get => .GET, + .post => .POST, + .put => .PUT, + .delete => .DELETE, + }; + + // Build auth header: Private-Token + const auth_header = std.fmt.allocPrint(allocator, "{s}", .{slot.token_buf[0..slot.token_len]}) catch return -4; + + // Parse URI + const uri = std.Uri.parse(url_str) catch return -4; + + // Create HTTP client + var client = std.http.Client{ .allocator = allocator }; + defer client.deinit(); + + var headers_buf: [3]std.http.Header = .{ + .{ .name = "PRIVATE-TOKEN", .value = auth_header }, + .{ .name = "Content-Type", .value = "application/json" }, + .{ .name = "User-Agent", .value = "boj-server/1.0 (gitlab-api-mcp cartridge)" }, + }; + + // Determine body slice + const body_slice: ?[]const u8 = if (body != null and body_len > 0) blk: { + const blen: usize = std.math.cast(usize, body_len) orelse break :blk null; + break :blk body[0..blen]; + } else null; + + const cap: usize = std.math.cast(usize, out_cap) orelse return -4; + + // Fetch the request (Zig 0.15 API β€” replaces open/send/wait) + var aw: std.Io.Writer.Allocating = .init(allocator); + defer aw.deinit(); + + const fetch_result = client.fetch(.{ + .method = http_method, + .location = .{ .uri = uri }, + .extra_headers = &headers_buf, + .payload = body_slice, + .response_writer = &aw.writer, + }) catch return -4; + + // Handle rate limiting (HTTP 429) + const status_code = @intFromEnum(fetch_result.status); + if (status_code == 429) { + slot.state = .rate_limited; + slot.rate_limit_remaining = 0; + return -3; + } + + // Copy response body into caller's buffer + const response_bytes = aw.writer.buffered(); + const bytes_read = @min(response_bytes.len, cap); + @memcpy(out_buf[0..bytes_read], response_bytes[0..bytes_read]); + out_len.* = @intCast(bytes_read); + + if (status_code >= 500) { + slot.state = .err; + return -4; + } + + return 0; +} + +// --------------------------------------------------------------------------- +// C-ABI exports β€” GraphQL +// --------------------------------------------------------------------------- + +/// Execute a GitLab GraphQL query. +/// Endpoint: POST <base_url>/api/graphql +/// +/// Parameters: +/// slot_idx β€” session slot (must be Authenticated) +/// query β€” GraphQL query string +/// query_len β€” length of query +/// variables β€” JSON variables string (NULL if none) +/// variables_len β€” length of variables +/// out_buf β€” caller-owned buffer for response +/// out_cap β€” capacity of out_buf +/// out_len β€” pointer to write actual response length +/// +/// Returns 0 on success, negative on error (same codes as gitlab_api_mcp_request). +/// +/// HTTP dispatch is performed via std.http.Client to <base_url>/api/graphql +/// with Private-Token authentication header. +pub export fn gitlab_api_mcp_graphql( + slot_idx: c_int, + query: [*c]const u8, + query_len: c_int, + variables: [*c]const u8, + variables_len: c_int, + out_buf: [*c]u8, + out_cap: c_int, + out_len: *c_int, +) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + if (slot.state != .authenticated) return -2; + + const qlen: usize = std.math.cast(usize, query_len) orelse return -4; + if (query == null or qlen == 0) return -4; + + const cap: usize = std.math.cast(usize, out_cap) orelse return -5; + + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); + defer arena.deinit(); + const allocator = arena.allocator(); + + const base_url = slot.base_url_buf[0..slot.base_url_len]; + const url_str = std.fmt.allocPrint(allocator, "{s}/api/graphql", .{base_url}) catch return -4; + + // Build GraphQL JSON body + const query_slice = query[0..qlen]; + const vlen: usize = std.math.cast(usize, variables_len) orelse 0; + const gql_body = if (variables != null and vlen > 0) + std.fmt.allocPrint(allocator, "{{\"query\":{s},\"variables\":{s}}}", .{ query_slice, variables[0..vlen] }) catch return -4 + else + std.fmt.allocPrint(allocator, "{{\"query\":{s}}}", .{query_slice}) catch return -4; + + const auth_header = std.fmt.allocPrint(allocator, "{s}", .{slot.token_buf[0..slot.token_len]}) catch return -4; + + const uri = std.Uri.parse(url_str) catch return -4; + + var client = std.http.Client{ .allocator = allocator }; + defer client.deinit(); + + var headers_buf: [3]std.http.Header = .{ + .{ .name = "PRIVATE-TOKEN", .value = auth_header }, + .{ .name = "Content-Type", .value = "application/json" }, + .{ .name = "User-Agent", .value = "boj-server/1.0 (gitlab-api-mcp cartridge)" }, + }; + + // Fetch the request (Zig 0.15 API β€” replaces open/send/wait) + var aw: std.Io.Writer.Allocating = .init(allocator); + defer aw.deinit(); + + const fetch_result = client.fetch(.{ + .method = .POST, + .location = .{ .uri = uri }, + .extra_headers = &headers_buf, + .payload = gql_body, + .response_writer = &aw.writer, + }) catch return -4; + + const status_code = @intFromEnum(fetch_result.status); + if (status_code == 429) { + slot.state = .rate_limited; + slot.rate_limit_remaining = 0; + return -3; + } + + const response_bytes = aw.writer.buffered(); + const bytes_read = @min(response_bytes.len, cap); + @memcpy(out_buf[0..bytes_read], response_bytes[0..bytes_read]); + out_len.* = @intCast(bytes_read); + return 0; +} + +// --------------------------------------------------------------------------- +// C-ABI exports β€” push mirror +// --------------------------------------------------------------------------- + +/// Set up a push mirror for a GitLab project. +/// Calls POST /projects/:id/remote_mirrors under the hood. +/// +/// Parameters: +/// slot_idx β€” session slot (must be Authenticated) +/// project_id β€” GitLab project ID +/// target_url β€” mirror target URL (e.g. "https://github.com/org/repo.git") +/// url_len β€” length of target_url +/// out_buf β€” caller-owned buffer for response +/// out_cap β€” capacity of out_buf +/// out_len β€” pointer to write actual response length +/// +/// Returns 0 on success, negative on error. +/// +/// HTTP dispatch is performed via std.http.Client to POST /projects/:id/remote_mirrors +/// with Private-Token authentication header. +pub export fn gitlab_api_mcp_setup_mirror( + slot_idx: c_int, + project_id: c_int, + target_url: [*c]const u8, + url_len: c_int, + out_buf: [*c]u8, + out_cap: c_int, + out_len: *c_int, +) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + if (slot.state != .authenticated) return -2; + + const tlen: usize = std.math.cast(usize, url_len) orelse return -4; + if (target_url == null or tlen == 0) return -4; + + const cap: usize = std.math.cast(usize, out_cap) orelse return -5; + + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); + defer arena.deinit(); + const allocator = arena.allocator(); + + const base_url = slot.base_url_buf[0..slot.base_url_len]; + const api_version = slot.api_version_buf[0..slot.api_version_len]; + const target_slice = target_url[0..tlen]; + + // POST /projects/:id/remote_mirrors + const url_str = std.fmt.allocPrint(allocator, "{s}/api/{s}/projects/{d}/remote_mirrors", .{ base_url, api_version, project_id }) catch return -4; + const mirror_body = std.fmt.allocPrint(allocator, "{{\"url\":\"{s}\",\"enabled\":true}}", .{target_slice}) catch return -4; + const auth_header = std.fmt.allocPrint(allocator, "{s}", .{slot.token_buf[0..slot.token_len]}) catch return -4; + + const uri = std.Uri.parse(url_str) catch return -4; + + var client = std.http.Client{ .allocator = allocator }; + defer client.deinit(); + + var headers_buf_mirror: [3]std.http.Header = .{ + .{ .name = "PRIVATE-TOKEN", .value = auth_header }, + .{ .name = "Content-Type", .value = "application/json" }, + .{ .name = "User-Agent", .value = "boj-server/1.0 (gitlab-api-mcp cartridge)" }, + }; + + // Fetch the request (Zig 0.15 API β€” replaces open/send/wait) + var aw: std.Io.Writer.Allocating = .init(allocator); + defer aw.deinit(); + + const fetch_result = client.fetch(.{ + .method = .POST, + .location = .{ .uri = uri }, + .extra_headers = &headers_buf_mirror, + .payload = mirror_body, + .response_writer = &aw.writer, + }) catch return -4; + + const status_code = @intFromEnum(fetch_result.status); + if (status_code == 429) { + slot.state = .rate_limited; + slot.rate_limit_remaining = 0; + return -3; + } + + const response_bytes = aw.writer.buffered(); + const bytes_read = @min(response_bytes.len, cap); + @memcpy(out_buf[0..bytes_read], response_bytes[0..bytes_read]); + out_len.* = @intCast(bytes_read); + return 0; +} + +// --------------------------------------------------------------------------- +// C-ABI exports β€” rate limit info +// --------------------------------------------------------------------------- + +/// Get the rate-limit remaining count for a session. +/// Returns the remaining count, or -1 if unknown / slot invalid. +pub export fn gitlab_api_mcp_rate_limit_remaining(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + return @intCast(slot.rate_limit_remaining); +} + +/// Transition a session to RateLimited state (Authenticated -> RateLimited). +/// Returns 0 on success. +pub export fn gitlab_api_mcp_hit_rate_limit(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + if (!isValidTransition(slot.state, .rate_limited)) return -2; + + slot.state = .rate_limited; + slot.rate_limit_remaining = 0; + return 0; +} + +/// Resume from rate-limited state (RateLimited -> Authenticated). +/// Returns 0 on success. +pub export fn gitlab_api_mcp_resume_from_rate_limit(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + if (!isValidTransition(slot.state, .authenticated)) return -2; + + slot.state = .authenticated; + slot.rate_limit_remaining = -1; + return 0; +} + +// --------------------------------------------------------------------------- +// C-ABI exports β€” error handling +// --------------------------------------------------------------------------- + +/// Signal an error on an authenticated session (Authenticated -> Error). +/// Returns 0 on success. +pub export fn gitlab_api_mcp_signal_error(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + if (!isValidTransition(slot.state, .err)) return -2; + + slot.state = .err; + return 0; +} + +/// Reset from error state (Error -> Unauthenticated). +/// Returns 0 on success. +pub export fn gitlab_api_mcp_reset_error(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + if (!isValidTransition(slot.state, .unauthenticated)) return -2; + + @memset(&slot.token_buf, 0); + slot.token_len = 0; + slot.state = .unauthenticated; + return 0; +} + +/// Logout (Authenticated -> Unauthenticated). Zeroes the token. +/// Returns 0 on success. +pub export fn gitlab_api_mcp_logout(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + if (slot.state != .authenticated) return -2; + + @memset(&slot.token_buf, 0); + slot.token_len = 0; + slot.state = .unauthenticated; + return 0; +} + +/// Reset all sessions (test/debug use only). +pub export fn gitlab_api_mcp_reset() void { + mutex.lock(); + defer mutex.unlock(); + for (&sessions) |*slot| { + @memset(&slot.token_buf, 0); + } + sessions = [_]SessionSlot{.{}} ** MAX_SESSIONS; +} + +// --------------------------------------------------------------------------- +// ═══════════════════════════════════════════════════════════════════════ +// Standard ABI (ADR-0005 four symbols + ADR-0006 invoke) +// ═══════════════════════════════════════════════════════════════════════ + +const shim = @import("cartridge_shim.zig"); + +const CARTRIDGE_NAME_PTR: [*:0]const u8 = "gitlab-api-mcp"; +const CARTRIDGE_VERSION_PTR: [*:0]const u8 = "0.1.0"; + +export fn boj_cartridge_init() callconv(.c) c_int { + return 0; +} + +export fn boj_cartridge_deinit() callconv(.c) void {} + +export fn boj_cartridge_name() callconv(.c) [*:0]const u8 { + return CARTRIDGE_NAME_PTR; +} + +export fn boj_cartridge_version() callconv(.c) [*:0]const u8 { + return CARTRIDGE_VERSION_PTR; +} + +/// Dispatch the cartridge.json MCP tools. Grade D Alpha stubs. +export fn boj_cartridge_invoke( + tool_name: [*c]const u8, + json_args: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) callconv(.c) i32 { + _ = json_args; + if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; + + const body: []const u8 = if (shim.toolIs(tool_name, "gitlab_authenticate")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "gitlab_list_projects")) + "{\"result\":{\"items\":[],\"count\":0,\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "gitlab_get_project")) + "{\"result\":{\"metadata\":{},\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "gitlab_list_issues")) + "{\"result\":{\"items\":[],\"count\":0,\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "gitlab_create_issue")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "gitlab_create_mr")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "gitlab_setup_mirror")) + "{\"result\":{\"status\":\"stub\"}}" +else + return shim.RC_UNKNOWN_TOOL; + + return shim.writeResult(out_buf, in_out_len, body); +} + +// Tests +// --------------------------------------------------------------------------- + +test "authentication lifecycle" { + gitlab_api_mcp_reset(); + + const slot = gitlab_api_mcp_session_open(); + try std.testing.expect(slot >= 0); + + // Should be unauthenticated + try std.testing.expectEqual(@as(c_int, 0), gitlab_api_mcp_session_state(slot)); + + // Authenticate with default gitlab.com + const token = "glpat-xxxxxxxxxxxxxxxxxxxx"; // hypatia-ignore: test fixture β€” placeholder format only + try std.testing.expectEqual(@as(c_int, 0), gitlab_api_mcp_authenticate( + slot, + token, + @intCast(token.len), + null, + 0, + )); + try std.testing.expectEqual(@as(c_int, 1), gitlab_api_mcp_session_state(slot)); + + // Logout + try std.testing.expectEqual(@as(c_int, 0), gitlab_api_mcp_logout(slot)); + try std.testing.expectEqual(@as(c_int, 0), gitlab_api_mcp_session_state(slot)); + + // Close + try std.testing.expectEqual(@as(c_int, 0), gitlab_api_mcp_session_close(slot)); +} + +test "self-hosted instance auth" { + gitlab_api_mcp_reset(); + + const slot = gitlab_api_mcp_session_open(); + try std.testing.expect(slot >= 0); + + const token = "glpat-selfhosted-token"; + const url = "https://git.example.org"; + try std.testing.expectEqual(@as(c_int, 0), gitlab_api_mcp_authenticate( + slot, + token, + @intCast(token.len), + url, + @intCast(url.len), + )); + try std.testing.expectEqual(@as(c_int, 1), gitlab_api_mcp_session_state(slot)); + + // Verify request requires auth (pre-auth rejection tested elsewhere). + // Real HTTP dispatch will attempt to connect to self-hosted instance. + try std.testing.expectEqual(@as(c_int, 0), gitlab_api_mcp_session_close(slot)); +} + +test "rate limit flow" { + gitlab_api_mcp_reset(); + + const slot = gitlab_api_mcp_session_open(); + const token = "glpat-ratelimit-test"; + _ = gitlab_api_mcp_authenticate(slot, token, @intCast(token.len), null, 0); + + // Hit rate limit: Auth -> RateLimited + try std.testing.expectEqual(@as(c_int, 0), gitlab_api_mcp_hit_rate_limit(slot)); + try std.testing.expectEqual(@as(c_int, 2), gitlab_api_mcp_session_state(slot)); + try std.testing.expectEqual(@as(c_int, 0), gitlab_api_mcp_rate_limit_remaining(slot)); + + // Resume: RateLimited -> Authenticated + try std.testing.expectEqual(@as(c_int, 0), gitlab_api_mcp_resume_from_rate_limit(slot)); + try std.testing.expectEqual(@as(c_int, 1), gitlab_api_mcp_session_state(slot)); + + try std.testing.expectEqual(@as(c_int, 0), gitlab_api_mcp_session_close(slot)); +} + +test "error flow" { + gitlab_api_mcp_reset(); + + const slot = gitlab_api_mcp_session_open(); + const token = "glpat-error-test"; + _ = gitlab_api_mcp_authenticate(slot, token, @intCast(token.len), null, 0); + + // Auth -> Error + try std.testing.expectEqual(@as(c_int, 0), gitlab_api_mcp_signal_error(slot)); + try std.testing.expectEqual(@as(c_int, 3), gitlab_api_mcp_session_state(slot)); + + // Error -> Unauthenticated + try std.testing.expectEqual(@as(c_int, 0), gitlab_api_mcp_reset_error(slot)); + try std.testing.expectEqual(@as(c_int, 0), gitlab_api_mcp_session_state(slot)); + + try std.testing.expectEqual(@as(c_int, 0), gitlab_api_mcp_session_close(slot)); +} + +test "graphql pre-auth rejection" { + gitlab_api_mcp_reset(); + + const slot = gitlab_api_mcp_session_open(); + try std.testing.expect(slot >= 0); + + // Cannot issue GraphQL before authentication + var buf: [1024]u8 = undefined; + var out_len: c_int = 0; + const query = "{ currentUser { name } }"; + try std.testing.expect(gitlab_api_mcp_graphql( + slot, + query, + @intCast(query.len), + null, + 0, + &buf, + 1024, + &out_len, + ) != 0); + + try std.testing.expectEqual(@as(c_int, 0), gitlab_api_mcp_session_close(slot)); +} + +test "mirror pre-auth rejection" { + gitlab_api_mcp_reset(); + + const slot = gitlab_api_mcp_session_open(); + try std.testing.expect(slot >= 0); + + // Cannot setup mirror before authentication + var buf: [1024]u8 = undefined; + var out_len: c_int = 0; + const target = "https://github.com/org/repo.git"; + try std.testing.expect(gitlab_api_mcp_setup_mirror( + slot, + 42, + target, + @intCast(target.len), + &buf, + 1024, + &out_len, + ) != 0); + + try std.testing.expectEqual(@as(c_int, 0), gitlab_api_mcp_session_close(slot)); +} + +test "transition validator" { + // Valid transitions + try std.testing.expectEqual(@as(c_int, 1), gitlab_api_mcp_can_transition(0, 1)); // unauth -> auth + try std.testing.expectEqual(@as(c_int, 1), gitlab_api_mcp_can_transition(1, 2)); // auth -> rate_limited + try std.testing.expectEqual(@as(c_int, 1), gitlab_api_mcp_can_transition(2, 1)); // rate_limited -> auth + try std.testing.expectEqual(@as(c_int, 1), gitlab_api_mcp_can_transition(1, 3)); // auth -> error + try std.testing.expectEqual(@as(c_int, 1), gitlab_api_mcp_can_transition(3, 0)); // error -> unauth + try std.testing.expectEqual(@as(c_int, 1), gitlab_api_mcp_can_transition(1, 0)); // auth -> unauth (logout) + + // Invalid transitions + try std.testing.expectEqual(@as(c_int, 0), gitlab_api_mcp_can_transition(0, 2)); // unauth -> rate_limited + try std.testing.expectEqual(@as(c_int, 0), gitlab_api_mcp_can_transition(0, 3)); // unauth -> error + try std.testing.expectEqual(@as(c_int, 0), gitlab_api_mcp_can_transition(2, 3)); // rate -> error + try std.testing.expectEqual(@as(c_int, 0), gitlab_api_mcp_can_transition(3, 1)); // error -> auth + + // Out of range + try std.testing.expectEqual(@as(c_int, 0), gitlab_api_mcp_can_transition(99, 0)); +} + +test "slot exhaustion" { + gitlab_api_mcp_reset(); + + var slots: [MAX_SESSIONS]c_int = undefined; + for (&slots) |*s| { + s.* = gitlab_api_mcp_session_open(); + try std.testing.expect(s.* >= 0); + } + + // Next open should fail + try std.testing.expectEqual(@as(c_int, -1), gitlab_api_mcp_session_open()); + + // Free one and try again + try std.testing.expectEqual(@as(c_int, 0), gitlab_api_mcp_session_close(slots[0])); + const new_slot = gitlab_api_mcp_session_open(); + try std.testing.expect(new_slot >= 0); +} + +// ═══════════════════════════════════════════════════════════════════════ +// ADR-0006 invoke dispatch tests +// ═══════════════════════════════════════════════════════════════════════ + +test "boj_cartridge_name returns gitlab-api-mcp" { + const n = std.mem.span(boj_cartridge_name()); + try std.testing.expectEqualStrings("gitlab-api-mcp", n); +} + +test "boj_cartridge_init returns 0" { + try std.testing.expectEqual(@as(c_int, 0), boj_cartridge_init()); +} + +test "invoke: each declared tool succeeds" { + var buf: [256]u8 = undefined; + const tools = [_][]const u8{ + "gitlab_authenticate", + "gitlab_list_projects", + "gitlab_get_project", + "gitlab_list_issues", + "gitlab_create_issue", + "gitlab_create_mr", + "gitlab_setup_mirror", + }; + for (tools) |t| { + var len: usize = buf.len; + const rc = boj_cartridge_invoke(t.ptr, "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, 0), rc); + try std.testing.expect(std.mem.indexOf(u8, buf[0..len], "result") != null); + } +} + +test "invoke: unknown tool returns -1" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("nope", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -1), rc); +} + +test "invoke: buffer too small returns -3" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("gitlab_authenticate", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -3), rc); + try std.testing.expect(len > 4); +} diff --git a/cartridges/domains/development/gitlab-api-mcp/minter.toml b/cartridges/domains/development/gitlab-api-mcp/minter.toml new file mode 100644 index 0000000..9bc6b0a --- /dev/null +++ b/cartridges/domains/development/gitlab-api-mcp/minter.toml @@ -0,0 +1,12 @@ +name = "gitlab-api-mcp" +description = "GitLab REST API v4 and GraphQL wrapper β€” supports gitlab.com and self-hosted instances" +version = "0.1.0" +domain = "Git" +protocols = [ + "MCP", + "REST", + "GraphQL", +] +tier = "Ayo" +backend = "universal" +generate_panel = true diff --git a/cartridges/domains/development/gitlab-api-mcp/mod.js b/cartridges/domains/development/gitlab-api-mcp/mod.js new file mode 100644 index 0000000..5bba0f9 --- /dev/null +++ b/cartridges/domains/development/gitlab-api-mcp/mod.js @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// gitlab-api-mcp/mod.js β€” GitLab REST API β€” projects, issues, MRs +// +// Delegates to backend at http://127.0.0.1:7727 (override with GITLAB_API_URL). + +const BASE_URL = Deno.env.get("GITLAB_API_URL") ?? "http://127.0.0.1:7727"; +const TIMEOUT_MS = 15_000; + +async function post(path, payload) { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), TIMEOUT_MS); + try { + const r = await fetch(`${BASE_URL}${path}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + signal: ctrl.signal, + }); + const data = await r.json().catch(() => ({ success: false, error: "non-JSON response" })); + return { status: r.status, data }; + } catch (e) { + if (e.name === "AbortError") return { status: 504, data: { success: false, error: "gitlab-api-mcp backend timed out" } }; + return { status: 503, data: { success: false, error: `gitlab-api-mcp backend unavailable: ${e.message}` } }; + } finally { clearTimeout(t); } +} + +async function get(path) { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), TIMEOUT_MS); + try { + const r = await fetch(`${BASE_URL}${path}`, { method: "GET", signal: ctrl.signal }); + const data = await r.json().catch(() => ({ success: false, error: "non-JSON response" })); + return { status: r.status, data }; + } catch (e) { + if (e.name === "AbortError") return { status: 504, data: { success: false, error: "gitlab-api-mcp backend timed out" } }; + return { status: 503, data: { success: false, error: `gitlab-api-mcp backend unavailable: ${e.message}` } }; + } finally { clearTimeout(t); } +} + +export async function handleTool(toolName, args) { + switch (toolName) { + case "gitlab_authenticate": + return post("/api/v1/gitlab_authenticate", args ?? {}); + case "gitlab_list_projects": + return post("/api/v1/gitlab_list_projects", args ?? {}); + case "gitlab_get_project": + return post("/api/v1/gitlab_get_project", args ?? {}); + case "gitlab_list_issues": + return post("/api/v1/gitlab_list_issues", args ?? {}); + case "gitlab_create_issue": + return post("/api/v1/gitlab_create_issue", args ?? {}); + case "gitlab_create_mr": + return post("/api/v1/gitlab_create_mr", args ?? {}); + case "gitlab_setup_mirror": + return post("/api/v1/gitlab_setup_mirror", args ?? {}); + default: + return { status: 404, data: { error: `Unknown tool: ${toolName}` } }; + } +} diff --git a/cartridges/domains/development/gitlab-api-mcp/panels/manifest.json b/cartridges/domains/development/gitlab-api-mcp/panels/manifest.json new file mode 100644 index 0000000..76c0a68 --- /dev/null +++ b/cartridges/domains/development/gitlab-api-mcp/panels/manifest.json @@ -0,0 +1,112 @@ +{ + "$schema": "https://panll.dev/schemas/panel-manifest/v1.json", + "spdx": "MPL-2.0", + "cartridge": "gitlab-api-mcp", + "domain": "Git", + "version": "0.1.0", + "panels": [ + { + "id": "gitlab-auth-status", + "title": "Auth Status", + "description": "Current authentication state (Unauthenticated / Authenticated / RateLimited / Error)", + "type": "status-indicator", + "data_source": { + "endpoint": "/gitlab/state", + "method": "GET", + "refresh_interval_ms": 5000 + }, + "widgets": [ + { + "type": "state-badge", + "field": "state", + "states": { + "unauthenticated": { "color": "#95a5a6", "icon": "user-x" }, + "authenticated": { "color": "#2ecc71", "icon": "user-check" }, + "rate_limited": { "color": "#f39c12", "icon": "clock" }, + "error": { "color": "#e74c3c", "icon": "alert-triangle" } + } + } + ] + }, + { + "id": "gitlab-instance-url", + "title": "Instance URL", + "description": "The GitLab instance base URL for the active session (gitlab.com or self-hosted)", + "type": "metric", + "data_source": { + "endpoint": "/gitlab/instance", + "method": "GET", + "refresh_interval_ms": 30000 + }, + "widgets": [ + { + "type": "text", + "field": "base_url", + "label": "Instance", + "icon": "globe" + } + ] + }, + { + "id": "gitlab-pipeline-status", + "title": "Pipeline Status", + "description": "Latest pipeline status for monitored projects", + "type": "status-indicator", + "data_source": { + "endpoint": "/gitlab/pipelines/latest", + "method": "GET", + "refresh_interval_ms": 15000 + }, + "widgets": [ + { + "type": "state-badge", + "field": "status", + "states": { + "success": { "color": "#2ecc71", "icon": "check-circle" }, + "failed": { "color": "#e74c3c", "icon": "x-circle" }, + "running": { "color": "#3498db", "icon": "loader" }, + "pending": { "color": "#f39c12", "icon": "clock" }, + "canceled": { "color": "#95a5a6", "icon": "slash" }, + "unknown": { "color": "#bdc3c7", "icon": "help-circle" } + } + }, + { + "type": "text", + "field": "project_name", + "label": "Project" + }, + { + "type": "text", + "field": "ref", + "label": "Branch" + } + ] + }, + { + "id": "gitlab-rate-limits", + "title": "Rate Limits", + "description": "Remaining API requests in the current rate-limit window", + "type": "metric", + "data_source": { + "endpoint": "/gitlab/rate-limit", + "method": "GET", + "refresh_interval_ms": 10000 + }, + "widgets": [ + { + "type": "counter", + "field": "remaining", + "label": "Remaining Requests", + "icon": "activity" + }, + { + "type": "timestamp", + "field": "reset_epoch", + "label": "Window Resets", + "format": "relative", + "icon": "clock" + } + ] + } + ] +} diff --git a/cartridges/domains/development/gitlab-api-mcp/tests/integration_test.sh b/cartridges/domains/development/gitlab-api-mcp/tests/integration_test.sh new file mode 100755 index 0000000..706d6e2 --- /dev/null +++ b/cartridges/domains/development/gitlab-api-mcp/tests/integration_test.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Integration tests for gitlab-api-mcp cartridge. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CART_DIR="$(dirname "$SCRIPT_DIR")" +FFI_DIR="$CART_DIR/ffi" + +echo "=== gitlab-api-mcp integration tests ===" + +# Build FFI +echo "[1/3] Building FFI..." +cd "$FFI_DIR" && zig build 2>&1 + +# Run Zig unit tests +echo "[2/3] Running FFI unit tests..." +cd "$FFI_DIR" && zig build test 2>&1 + +# Validate Idris2 ABI (if idris2 available) +echo "[3/3] Checking ABI..." +if command -v idris2 &>/dev/null; then + cd "$CART_DIR/abi" && idris2 --check GitlabApiMcp.SafeCloud 2>&1 + echo " ABI: OK" +else + echo " ABI: SKIPPED (idris2 not in PATH)" +fi + +echo "" +echo "All tests passed for gitlab-api-mcp!" diff --git a/cartridges/domains/education/kategoria-mcp/README.adoc b/cartridges/domains/education/kategoria-mcp/README.adoc new file mode 100644 index 0000000..03aa687 --- /dev/null +++ b/cartridges/domains/education/kategoria-mcp/README.adoc @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += kategoria-mcp +Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk> +:spdx: MPL-2.0 +:tier: Ayo +:domain: Education +:protocols: MCP, REST + +== Overview + +Kategoria type-theory learning system. Classifies type-theory constructs and evaluates learner challenge responses. + +== Tools (3) + +[cols="2,4"] +|=== +| Tool | Description + +| `kategoria_classify` | Classify a type-theory construct and return its level in the Kategoria hierarchy. +| `kategoria_get_levels` | Return the full Kategoria level hierarchy with descriptions. +| `kategoria_eval_challenge` | Evaluate a learner's response to a type-theory challenge. +|=== + +== Architecture + +Three-layer stack per the BoJ cartridge standard: + +[cols="1,1,3"] +|=== +| Layer | Language | Purpose + +| ABI | Idris2 | Formally verified state machine with dependent-type proofs +| FFI | Zig | C-ABI implementation: session pool, action metrics, thread safety +| Adapter| Zig | Adapter bridge to the BoJ unified adapter protocol (zig predecessors sidelined) +|=== + +== Building + +[source,bash] +---- +# ABI (Idris2) +cd abi && idris2 --check *.idr + +# FFI (Zig) +cd ffi && zig build +cd ffi && zig build test + +# Adapter (Zig) +cd adapter && zig build +---- + +== Status + +Alpha (CRG D). Cartridge metadata declared in `cartridge.json`; 3 tool(s) exposed. +Not yet dogfooded externally. See `docs/READINESS.md` at repo root for the +project-wide grade table and `docs/practice/DOGFOOD-LOG.adoc` for usage evidence. diff --git a/cartridges/domains/education/kategoria-mcp/abi/Kategoria/Protocol.idr b/cartridges/domains/education/kategoria-mcp/abi/Kategoria/Protocol.idr new file mode 100644 index 0000000..bdd68cc --- /dev/null +++ b/cartridges/domains/education/kategoria-mcp/abi/Kategoria/Protocol.idr @@ -0,0 +1,54 @@ +-- SPDX-License-Identifier: MPL-2.0 +-- Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +-- +-- Kategoria ABI β€” categorization protocol definitions. + +module Kategoria.Protocol + +import Data.Nat + +||| Kategoria operation codes. +public export +data KategoriaOp + = Classify + | GetRoutes + | GetLevels + | EvalChallenge + +||| Classification confidence (0-100). +public export +record Confidence where + constructor MkConfidence + value : Nat + {auto prf : LTE value 100} + +||| A classification result with label and confidence. +public export +record Classification where + constructor MkClassification + label : String + confidence : Confidence + route : List String + +||| Challenge level for evaluation. +public export +record ChallengeLevel where + constructor MkChallengeLevel + level : Nat + difficulty : Nat + {auto prf : LTE level 12} + +||| Route depth β€” guaranteed non-negative. +public export +routeDepth : Classification -> Nat +routeDepth c = length c.route + +||| Proof: confidence is always bounded. +export +confidenceBounded : (c : Confidence) -> LTE c.value 100 +confidenceBounded c = c.prf + +||| Proof: challenge level is within clade taxonomy bounds (12 codes). +export +challengeInBounds : (cl : ChallengeLevel) -> LTE cl.level 12 +challengeInBounds cl = cl.prf diff --git a/cartridges/domains/education/kategoria-mcp/abi/README.adoc b/cartridges/domains/education/kategoria-mcp/abi/README.adoc new file mode 100644 index 0000000..6ef4331 --- /dev/null +++ b/cartridges/domains/education/kategoria-mcp/abi/README.adoc @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += kategoria-mcp / abi β€” Idris2 ABI layer +:orientation: shallow + +== Purpose + +Cartridge-local protocol types for `kategoria-mcp`. Defines the data, tool, and +transition vocabulary this cartridge speaks. Inherits safety lemmas and the +matrix-cell identifier scheme from the trunk ABI at `src/abi/`. + +== Files + +1 Idris2 module(s), ~54 lines total. Lead module: +`Protocol.idr`. + +No `.ipkg` β€” consumed directly by wider BoJ callers; type-checks inside the +trunk `boj.ipkg`. + +== Invariants + +* `%default total` at every module top. +* Zero `believe_me`, `assert_total`, `postulate`, `Admitted`, or + `sorry` (the trunk holds the four declared `believe_me` sites; leaf + cartridges carry none). + +== Test/proof surface + +Thin module(s), no inline tests. Proof surface = type-check success under the +trunk `idris2 --check`. + +== Read-first + +. `Protocol.idr` β€” cartridge-local data records and enums. +. Trunk `src/abi/Boj/Catalogue.idr` for how this cell participates in the + 2D matrix. diff --git a/cartridges/domains/education/kategoria-mcp/adapter/README.adoc b/cartridges/domains/education/kategoria-mcp/adapter/README.adoc new file mode 100644 index 0000000..819e09d --- /dev/null +++ b/cartridges/domains/education/kategoria-mcp/adapter/README.adoc @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += kategoria-mcp / adapter β€” protocol bridge +:orientation: shallow + +== Purpose + +Exposes this cartridge's FFI over the wire protocols declared in +`cartridge.json`. Dispatches tool invocations to the C-ABI exports at +`../ffi/` and formats responses per protocol. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” depends on the sibling `ffi` target. +| `kategoria_adapter.zig` | Protocol dispatch (118 lines). Tool catalogue matches `cartridge.json`. +| `SIDELINED-*.v.adoc` | zig predecessor(s). Not built β€” zig banned 2026-04-10. +|=== + +== Invariants + +* **Stateless** β€” all state lives behind the FFI, never in the adapter. +* **Response passthrough** β€” whatever the FFI returns goes back to the wire + unmodified (no embellishment, no silent recovery). +* **Cartridge.json is source of truth** for the tool catalogue; drift between + adapter and manifest is a CI failure. + +== Test/proof surface + +No inline tests at the adapter layer β€” protocol routing is exercised by the +trunk seam tests at `ffi/zig/src/seams.zig`. + +== Read-first + +. `kategoria_adapter.zig` β€” dispatch function and per-protocol routers. +. `../cartridge.json` β€” the tool manifest the dispatcher mirrors. diff --git a/cartridges/domains/education/kategoria-mcp/adapter/build.zig b/cartridges/domains/education/kategoria-mcp/adapter/build.zig new file mode 100644 index 0000000..6a86ba5 --- /dev/null +++ b/cartridges/domains/education/kategoria-mcp/adapter/build.zig @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// kategoria-mcp/adapter/build.zig + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + const ffi_mod = b.createModule(.{ + .root_source_file = b.path("../ffi/kategoria_ffi.zig"), + .target = target, .optimize = optimize, + }); + const adapter = b.addExecutable(.{ + .name = "kategoria_adapter", + .root_source_file = b.path("kategoria_adapter.zig"), + .target = target, .optimize = optimize, + }); + adapter.root_module.addImport("kategoria_ffi", ffi_mod); + b.installArtifact(adapter); + const run_step = b.step("run", "Run the kategoria-mcp adapter"); + run_step.dependOn(&b.addRunArtifact(adapter).step); + const tests = b.addTest(.{ + .root_source_file = b.path("kategoria_adapter.zig"), + .target = target, .optimize = optimize, + }); + tests.root_module.addImport("kategoria_ffi", ffi_mod); + const test_step = b.step("test", "Run kategoria-mcp adapter tests"); + test_step.dependOn(&b.addRunArtifact(tests).step); +} diff --git a/cartridges/domains/education/kategoria-mcp/adapter/kategoria_adapter.zig b/cartridges/domains/education/kategoria-mcp/adapter/kategoria_adapter.zig new file mode 100644 index 0000000..123d647 --- /dev/null +++ b/cartridges/domains/education/kategoria-mcp/adapter/kategoria_adapter.zig @@ -0,0 +1,118 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// kategoria-mcp/adapter/kategoria_adapter.zig -- Unified three-protocol adapter. +// +// Replaces the banned kategoria_adapter.v (zig, removed 2026-04-12). +// +// REST :9127 gRPC-compat :9128 GraphQL :9129 +// Kategoria type-theory learning system. Classifies type-theory constructs and evaluates learner chall +// Tools: kategoria_classify, kategoria_get_levels, kategoria_eval_challenge + +const std = @import("std"); +const ffi = @import("kategoria_ffi"); + +const REST_PORT: u16 = 9127; +const GRPC_PORT: u16 = 9128; +const GQL_PORT: u16 = 9129; +const MAX_CONN_BUF: usize = 16 * 1024; + +fn okJson(buf: []u8, msg: []const u8) []u8 { + const n = std.fmt.bufPrint(buf, "{{\"success\":true,\"message\":\"{s}\"}}", .{msg}) catch return buf[0..0]; + return n; +} +fn errJson(buf: []u8, msg: []const u8) []u8 { + const n = std.fmt.bufPrint(buf, "{{\"success\":false,\"error\":\"{s}\"}}", .{msg}) catch return buf[0..0]; + return n; +} +fn statusJson(buf: []u8) []u8 { + const n = std.fmt.bufPrint(buf, "{{\"success\":true,\"state\":\"ready\",\"service\":\"kategoria-mcp\"}}", .{}) catch return buf[0..0]; + return n; +} + +const Response = struct { status: u16, body: []u8 }; + +fn dispatch(tool: []const u8, body: []const u8, resp: []u8) Response { + _ = body; + if (std.mem.eql(u8, tool, "kategoria_classify")) return .{ .status = 200, .body = okJson(resp, "kategoria_classify forwarded") }; + if (std.mem.eql(u8, tool, "kategoria_get_levels")) return .{ .status = 200, .body = okJson(resp, "kategoria_get_levels forwarded") }; + if (std.mem.eql(u8, tool, "kategoria_eval_challenge")) return .{ .status = 200, .body = okJson(resp, "kategoria_eval_challenge forwarded") }; + if (std.mem.eql(u8, tool, "status") or std.mem.eql(u8, tool, "health")) + return .{ .status = 200, .body = statusJson(resp) }; + return .{ .status = 404, .body = errJson(resp, "Unknown tool") }; +} + +fn dispatchRest(path: []const u8, body: []const u8, resp: []u8) Response { + if (std.mem.startsWith(u8, path, "/tools/")) return dispatch(path["/tools/".len..], body, resp); + if (std.mem.eql(u8, path, "/status") or std.mem.eql(u8, path, "/health")) return .{ .status = 200, .body = statusJson(resp) }; + return .{ .status = 404, .body = errJson(resp, "Not found") }; +} + +fn dispatchGrpc(path: []const u8, body: []const u8, resp: []u8) Response { + const prefix = "/Kategoriaservice/"; + if (!std.mem.startsWith(u8, path, prefix)) + return .{ .status = 404, .body = errJson(resp, "Not a recognized gRPC path") }; + const method = path[prefix.len..]; + const tool = blk: { + if (std.mem.eql(u8, method, "kategoria_classify")) break :blk "kategoria_classify"; + if (std.mem.eql(u8, method, "kategoria_get_levels")) break :blk "kategoria_get_levels"; + if (std.mem.eql(u8, method, "kategoria_eval_challenge")) break :blk "kategoria_eval_challenge"; + return .{ .status = 404, .body = errJson(resp, "Unknown gRPC method") }; + }; + return dispatch(tool, body, resp); +} + +fn dispatchGraphql(body: []const u8, resp: []u8) Response { + if (std.mem.indexOf(u8, body, "__schema") != null) return .{ .status = 200, .body = okJson(resp, "schema not supported") }; + if (std.mem.indexOf(u8, body, "classify") != null) return dispatch("kategoria_classify", body, resp); + if (std.mem.indexOf(u8, body, "get_levels") != null) return dispatch("kategoria_get_levels", body, resp); + if (std.mem.indexOf(u8, body, "eval_challenge") != null) return dispatch("kategoria_eval_challenge", body, resp); + return .{ .status = 200, .body = errJson(resp, "Unrecognised GraphQL operation") }; +} + +const Protocol = enum { rest, grpc, graphql }; + +fn handleConnection(conn: std.net.Server.Connection, proto: Protocol) void { + defer conn.stream.close(); + var in_buf: [MAX_CONN_BUF]u8 = undefined; + const n = conn.stream.read(&in_buf) catch return; + const req = in_buf[0..n]; + var path: []const u8 = "/"; + var body: []const u8 = ""; + if (n > 4) { + const line_end = std.mem.indexOf(u8, req, "\r\n") orelse req.len; + const first_line = req[0..line_end]; + const sp1 = std.mem.indexOfScalar(u8, first_line, ' ') orelse 0; + const rest_of = first_line[sp1 + 1 ..]; + const sp2 = std.mem.indexOfScalar(u8, rest_of, ' ') orelse rest_of.len; + path = rest_of[0..sp2]; + const body_sep = std.mem.indexOf(u8, req, "\r\n\r\n") orelse n; + body = req[@min(body_sep + 4, n)..]; + } + var resp_buf: [MAX_CONN_BUF]u8 = undefined; + const result = switch (proto) { + .rest => dispatchRest(path, body, &resp_buf), + .grpc => dispatchGrpc(path, body, &resp_buf), + .graphql => dispatchGraphql(body, &resp_buf), + }; + const ct: []const u8 = if (proto == .grpc) "application/grpc+json" else "application/json"; + var hdr_buf: [256]u8 = undefined; + const hdr = std.fmt.bufPrint(&hdr_buf, "HTTP/1.1 {d} OK\r\nContent-Type: {s}\r\nContent-Length: {d}\r\nConnection: close\r\n\r\n", .{ result.status, ct, result.body.len }) catch return; + _ = conn.stream.writeAll(hdr) catch return; + _ = conn.stream.writeAll(result.body) catch return; +} + +fn listenLoop(port: u16, proto: Protocol) void { + const addr = std.net.Address.initIp4(.{ 127, 0, 0, 1 }, port) catch return; + var server = addr.listen(.{ .reuse_address = true }) catch return; + defer server.deinit(); + while (true) { const conn = server.accept() catch continue; handleConnection(conn, proto); } +} + +pub fn main() !void { + ffi.kategoria_init(); + const t1 = try std.Thread.spawn(.{}, listenLoop, .{ REST_PORT, Protocol.rest }); + const t2 = try std.Thread.spawn(.{}, listenLoop, .{ GRPC_PORT, Protocol.grpc }); + const t3 = try std.Thread.spawn(.{}, listenLoop, .{ GQL_PORT, Protocol.graphql }); + t1.join(); t2.join(); t3.join(); +} diff --git a/cartridges/domains/education/kategoria-mcp/cartridge.json b/cartridges/domains/education/kategoria-mcp/cartridge.json new file mode 100644 index 0000000..7e27ee8 --- /dev/null +++ b/cartridges/domains/education/kategoria-mcp/cartridge.json @@ -0,0 +1,82 @@ +{ + "$schema": "https://boj.dev/schemas/cartridge/v1.json", + "spdx": "MPL-2.0", + "copyright": "Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath)", + "name": "kategoria-mcp", + "version": "0.1.0", + "description": "Kategoria type-theory learning system. Classifies type-theory constructs and evaluates learner challenge responses.", + "domain": "Education", + "tier": "Ayo", + "protocols": [ + "MCP", + "REST" + ], + "auth": { + "method": "none", + "env_var": null, + "credential_source": null + }, + "api": { + "base_url": "local://kategoria-mcp", + "content_type": "application/json" + }, + "tools": [ + { + "name": "kategoria_classify", + "description": "Classify a type-theory construct and return its level in the Kategoria hierarchy.", + "inputSchema": { + "type": "object", + "properties": { + "input": { + "type": "string", + "description": "Type-theory expression or construct to classify" + } + }, + "required": [ + "input" + ] + } + }, + { + "name": "kategoria_get_levels", + "description": "Return the full Kategoria level hierarchy with descriptions.", + "inputSchema": { + "type": "object", + "properties": {}, + "required": [] + } + }, + { + "name": "kategoria_eval_challenge", + "description": "Evaluate a learner's response to a type-theory challenge.", + "inputSchema": { + "type": "object", + "properties": { + "level": { + "type": "integer", + "description": "Challenge level (1-based)" + }, + "input": { + "type": "string", + "description": "Learner response to evaluate" + } + }, + "required": [ + "level", + "input" + ] + } + } + ], + "ffi": { + "so_path": "ffi/zig-out/lib/libkategoria_mcp.so", + "abi_version": "ADR-0006", + "symbols": [ + "boj_cartridge_init", + "boj_cartridge_deinit", + "boj_cartridge_name", + "boj_cartridge_version", + "boj_cartridge_invoke" + ] + } +} diff --git a/cartridges/domains/education/kategoria-mcp/ffi/README.adoc b/cartridges/domains/education/kategoria-mcp/ffi/README.adoc new file mode 100644 index 0000000..a2b1d0b --- /dev/null +++ b/cartridges/domains/education/kategoria-mcp/ffi/README.adoc @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += kategoria-mcp / ffi β€” Zig FFI layer **(stub)** +:orientation: shallow + +== Purpose + +C-ABI exports bridging this cartridge's Idris2 ABI to the runtime. Consumed by +the adapter for protocol dispatch and by the trunk loader for mount. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” produces `zig-out/lib/libkategoria.so`. +| `kategoria_ffi.zig` | C-ABI exports (4 exports, 3 inline tests, 44 lines). +|=== + +== Invariants + +* **Null-pointer rejection** on `[*c]const u8` arguments before any + dereference. +* **Negative return values** carry documented error meanings; zero is + success (matching the trunk error-code convention). +* **No allocator leaks** β€” every allocation path pairs with `defer free`. + +== Test/proof surface + +3 inline `test "..."` block(s) in `kategoria_ffi.zig`. Run with +`cd ffi && zig build test`. + +== Read-first + +. `kategoria_ffi.zig` β€” C-ABI signatures and guards. +. Trunk `ffi/zig/src/catalogue.zig` for how this `.so` is loaded. + +== Promotion gate + +Wire these exports to real backing state (not `return 0` / `return 1` +placeholders) before promoting past D. diff --git a/cartridges/domains/education/kategoria-mcp/ffi/build.zig b/cartridges/domains/education/kategoria-mcp/ffi/build.zig new file mode 100644 index 0000000..ae7fe76 --- /dev/null +++ b/cartridges/domains/education/kategoria-mcp/ffi/build.zig @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + // Shared ADR-0006 invoke-shim module (relative path up to boj-server trunk). + + const shim_mod = b.addModule("cartridge_shim", .{ + + .root_source_file = b.path("../../../ffi/zig/src/cartridge_shim.zig"), + + .target = target, + + .optimize = optimize, + + }); + + const ffi_mod = b.addModule("kategoria_mcp", .{ + .root_source_file = b.path("kategoria_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + ffi_mod.addImport("cartridge_shim", shim_mod); + + const lib = b.addLibrary(.{ + .name = "kategoria_mcp", + .root_module = ffi_mod, + .linkage = .dynamic, + }); + lib.linkLibC(); + b.installArtifact(lib); + + const tests = b.addTest(.{ .root_module = ffi_mod }); + tests.linkLibC(); + const run_tests = b.addRunArtifact(tests); + const test_step = b.step("test", "Run FFI tests"); + test_step.dependOn(&run_tests.step); +} + diff --git a/cartridges/domains/education/kategoria-mcp/ffi/cartridge_shim.zig b/cartridges/domains/education/kategoria-mcp/ffi/cartridge_shim.zig new file mode 100644 index 0000000..0e399db --- /dev/null +++ b/cartridges/domains/education/kategoria-mcp/ffi/cartridge_shim.zig @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// cartridge_shim.zig β€” Shared helpers for the ADR-0006 five-symbol +// cartridge ABI (`boj_cartridge_init / deinit / name / version / invoke`). +// +// The shim centralises the seven-code return convention, NUL-argument +// guards, tool-name comparison, and the buffer-too-small path so each +// cartridge's `boj_cartridge_invoke` can stay short β€” typically a tool +// table plus `shim.writeResult(...)`. +// +// Cartridges import this file by relative path (no build-graph change +// needed). Example: +// +// const shim = @import("../../../ffi/zig/src/cartridge_shim.zig"); +// +// export fn boj_cartridge_invoke( +// tool_name: [*c]const u8, +// json_args: [*c]const u8, +// out_buf: [*c]u8, +// in_out_len: [*c]usize, +// ) callconv(.c) i32 { +// _ = json_args; +// if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; +// const body = if (shim.toolIs(tool_name, "foo")) "{\"result\":{}}" +// else return shim.RC_UNKNOWN_TOOL; +// return shim.writeResult(out_buf, in_out_len, body); +// } + +const std = @import("std"); + +// ── Return codes (ADR-0006 Β§Return codes) ──────────────────────────── +// +// Frozen by ADR-0006. New failure modes compose these via the error +// JSON body β€” the integer surface does not grow without a follow-up ADR. + +pub const RC_SUCCESS: i32 = 0; +pub const RC_UNKNOWN_TOOL: i32 = -1; +pub const RC_BAD_ARGS: i32 = -2; +pub const RC_BUFFER_TOO_SMALL: i32 = -3; +pub const RC_RUNTIME_ERROR: i32 = -4; +pub const RC_PANIC: i32 = -5; +pub const RC_AUTH_DENIED: i32 = -6; + +// ── Invoke-path helpers ────────────────────────────────────────────── + +/// True if any of the three mandatory `boj_cartridge_invoke` output-path +/// pointers is null. Use at the top of every invoke to short-circuit to +/// `RC_BAD_ARGS`. +pub fn invokeArgsNull( + tool_name: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) bool { + return tool_name == null or out_buf == null or in_out_len == null; +} + +/// Compare a C-NUL-terminated tool-name pointer against a Zig string +/// literal. Caller must have already verified `tool_name` is non-null +/// (usually via `invokeArgsNull`). +pub fn toolIs(tool_name: [*c]const u8, expected: []const u8) bool { + const s = std.mem.span(@as([*:0]const u8, @ptrCast(tool_name))); + return std.mem.eql(u8, s, expected); +} + +/// Copy `body` into `out_buf[0..*in_out_len]` (as a capacity) and update +/// `*in_out_len` to the number of bytes written. Returns `RC_SUCCESS`. +/// +/// If `body.len` exceeds the current capacity stored in `*in_out_len`, +/// sets `*in_out_len` to the required size and returns +/// `RC_BUFFER_TOO_SMALL` β€” the caller is then expected to re-allocate +/// and retry, per ADR-0006 Β§Memory ownership. +/// +/// Caller must have already verified that `out_buf` and `in_out_len` +/// are non-null. +pub fn writeResult( + out_buf: [*c]u8, + in_out_len: [*c]usize, + body: []const u8, +) i32 { + const cap = in_out_len.*; + if (body.len > cap) { + in_out_len.* = body.len; + return RC_BUFFER_TOO_SMALL; + } + @memcpy(out_buf[0..body.len], body); + in_out_len.* = body.len; + return RC_SUCCESS; +} + +// ── Tests ──────────────────────────────────────────────────────────── + +test "writeResult: body fits, writes and sets length" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); + try std.testing.expectEqualStrings("hello", buf[0..len]); +} + +test "writeResult: too small returns -3 and sets required length" { + var buf: [2]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_BUFFER_TOO_SMALL, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: exact-fit succeeds" { + var buf: [5]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: empty body" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, ""); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 0), len); +} + +test "toolIs: matches and rejects" { + const name: [*:0]const u8 = "foo"; + try std.testing.expect(toolIs(@ptrCast(name), "foo")); + try std.testing.expect(!toolIs(@ptrCast(name), "bar")); + try std.testing.expect(!toolIs(@ptrCast(name), "foobar")); + try std.testing.expect(!toolIs(@ptrCast(name), "fo")); +} + +test "invokeArgsNull: detects each null slot" { + var buf: [4]u8 = undefined; + var len: usize = 4; + const name: [*:0]const u8 = "x"; + try std.testing.expect(!invokeArgsNull(@ptrCast(name), &buf, &len)); + try std.testing.expect(invokeArgsNull(null, &buf, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), null, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), &buf, null)); +} diff --git a/cartridges/domains/education/kategoria-mcp/ffi/kategoria_ffi.zig b/cartridges/domains/education/kategoria-mcp/ffi/kategoria_ffi.zig new file mode 100644 index 0000000..4a74a56 --- /dev/null +++ b/cartridges/domains/education/kategoria-mcp/ffi/kategoria_ffi.zig @@ -0,0 +1,132 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +// +// Kategoria FFI β€” C-compatible exports for categorization. + +const std = @import("std"); + +/// Classify an input. Returns confidence 0-100, or 255 on error. +export fn kategoria_classify(input: [*c]const u8) u8 { + if (input == null) return 255; + return 85; // Stub β€” high confidence +} + +/// Get route count for a classification label. +export fn kategoria_get_routes(label: [*c]const u8) u32 { + if (label == null) return 0; + return 1; // Stub +} + +/// Get available taxonomy levels. +export fn kategoria_get_levels() u32 { + return 12; // Matches clade taxonomy +} + +/// Evaluate a challenge at a given level. Returns score 0-100. +export fn kategoria_eval_challenge(level: u8, input: [*c]const u8) u8 { + if (input == null or level > 12) return 0; + return 70; // Stub +} + +// ═══════════════════════════════════════════════════════════════════════ +// Standard ABI (ADR-0005 four symbols + ADR-0006 invoke) +// ═══════════════════════════════════════════════════════════════════════ + +const shim = @import("cartridge_shim.zig"); + +const CARTRIDGE_NAME_PTR: [*:0]const u8 = "kategoria-mcp"; +const CARTRIDGE_VERSION_PTR: [*:0]const u8 = "0.1.0"; + +export fn boj_cartridge_init() callconv(.c) c_int { + return 0; +} + +export fn boj_cartridge_deinit() callconv(.c) void {} + +export fn boj_cartridge_name() callconv(.c) [*:0]const u8 { + return CARTRIDGE_NAME_PTR; +} + +export fn boj_cartridge_version() callconv(.c) [*:0]const u8 { + return CARTRIDGE_VERSION_PTR; +} + +/// Dispatch the cartridge.json MCP tools. Grade D Alpha stubs. +export fn boj_cartridge_invoke( + tool_name: [*c]const u8, + json_args: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) callconv(.c) i32 { + _ = json_args; + if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; + + const body: []const u8 = if (shim.toolIs(tool_name, "kategoria_classify")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "kategoria_get_levels")) + "{\"result\":{\"metadata\":{},\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "kategoria_eval_challenge")) + "{\"result\":{\"status\":\"stub\"}}" +else + return shim.RC_UNKNOWN_TOOL; + + return shim.writeResult(out_buf, in_out_len, body); +} + +// ── Tests ── + +test "classify rejects null input" { + try std.testing.expectEqual(@as(u8, 255), kategoria_classify(null)); +} + +test "classify returns bounded confidence" { + const conf = kategoria_classify("test input"); + try std.testing.expect(conf <= 100); +} + +test "levels matches clade taxonomy" { + try std.testing.expectEqual(@as(u32, 12), kategoria_get_levels()); +} + +// ═══════════════════════════════════════════════════════════════════════ +// ADR-0006 invoke dispatch tests +// ═══════════════════════════════════════════════════════════════════════ + +test "boj_cartridge_name returns kategoria-mcp" { + const n = std.mem.span(boj_cartridge_name()); + try std.testing.expectEqualStrings("kategoria-mcp", n); +} + +test "boj_cartridge_init returns 0" { + try std.testing.expectEqual(@as(c_int, 0), boj_cartridge_init()); +} + +test "invoke: each declared tool succeeds" { + var buf: [256]u8 = undefined; + const tools = [_][]const u8{ + "kategoria_classify", + "kategoria_get_levels", + "kategoria_eval_challenge", + }; + for (tools) |t| { + var len: usize = buf.len; + const rc = boj_cartridge_invoke(t.ptr, "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, 0), rc); + try std.testing.expect(std.mem.indexOf(u8, buf[0..len], "result") != null); + } +} + +test "invoke: unknown tool returns -1" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("nope", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -1), rc); +} + +test "invoke: buffer too small returns -3" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("kategoria_classify", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -3), rc); + try std.testing.expect(len > 4); +} diff --git a/cartridges/domains/education/kategoria-mcp/mod.js b/cartridges/domains/education/kategoria-mcp/mod.js new file mode 100644 index 0000000..32e95d3 --- /dev/null +++ b/cartridges/domains/education/kategoria-mcp/mod.js @@ -0,0 +1,48 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// kategoria-mcp/mod.js -- kategoria gateway + +const BASE_URL = Deno.env.get("KATEGORIA_BACKEND_URL") ?? "http://127.0.0.1:7709"; + +async function post(path, payload) { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), 15000); + try { + const r = await fetch(`${BASE_URL}${path}`, { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), signal: ctrl.signal, + }); + const data = await r.json().catch(() => ({ success: false, error: "non-JSON response" })); + return { status: r.status, data }; + } catch (e) { + if (e.name === "AbortError") return { status: 504, data: { success: false, error: "kategoria-mcp backend timed out" } }; + return { status: 503, data: { success: false, error: `kategoria-mcp backend unavailable: ${e.message}` } }; + } finally { clearTimeout(t); } +} + +export async function handleTool(toolName, args) { + switch (toolName) { + case "kategoria_classify": { + const { input } = args ?? {}; + if (!input) return { status: 400, data: { error: "input is required" } }; + const payload = { input }; + return post("/api/v1/classify", payload); + } + + case "kategoria_get_levels": { + const { _args } = args ?? {}; + const payload = { }; + return post("/api/v1/get-levels", payload); + } + + case "kategoria_eval_challenge": { + const { level, input } = args ?? {}; + if (!level || !input) return { status: 400, data: { error: "level is required" } }; + const payload = { level, input }; + return post("/api/v1/eval-challenge", payload); + } + default: + return { status: 404, data: { error: `Unknown tool: ${toolName}` } }; + } +} diff --git a/cartridges/domains/education/kategoria-mcp/panels/manifest.json b/cartridges/domains/education/kategoria-mcp/panels/manifest.json new file mode 100644 index 0000000..76c31a3 --- /dev/null +++ b/cartridges/domains/education/kategoria-mcp/panels/manifest.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://panll.dev/schemas/panel-manifest/v1.json", + "spdx": "MPL-2.0", + "cartridge": "kategoria-mcp", + "domain": "categorization", + "version": "0.1.0", + "panels": [ + { + "id": "kategoria-classification-results", + "title": "Classification Results", + "description": "Classification output with confidence scores, taxonomy routes, and challenge evaluations.", + "type": "data-table", + "entrypoint": "panels/kategoria-classification-results.js", + "size": { "cols": 4, "rows": 3 }, + "refresh_interval_ms": 0, + "data_sources": [ + { "op": "Classify", "on": "event" }, + { "op": "GetRoutes", "on": "event" }, + { "op": "GetLevels", "interval_ms": 60000 } + ] + } + ] +} diff --git a/cartridges/domains/formal-verification/echidna-llm-mcp/README.adoc b/cartridges/domains/formal-verification/echidna-llm-mcp/README.adoc new file mode 100644 index 0000000..75cb423 --- /dev/null +++ b/cartridges/domains/formal-verification/echidna-llm-mcp/README.adoc @@ -0,0 +1,90 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk> += echidna-llm-mcp β€” LLM Advisor Cartridge for ECHIDNA +:toc: +:toc-placement: preamble + +LLM advisor cartridge for the ECHIDNA formal verification engine. +Routes proof-guidance requests from `echidna/src/rust/llm.rs` to +Anthropic Claude via the BoJ cartridge host. + +== Operations + +=== `consult` + +Free-form Q&A. ECHIDNA calls this from `/api/v1/consult`. + +Request fields (all nested under `params` when sent as `operation`/`params` +or under `arguments` when sent as `tool`/`arguments`): + +[cols="1,1,3"] +|=== +| Field | Type | Description +| `question` | string | Natural-language question *(required)* +| `context` | string | Optional proof-state context or recent job log +| `model` | string | `opus` / `sonnet` / `haiku` or full model ID (default: `sonnet`) +| `max_tokens` | int | Maximum LLM response tokens (default: 2048) +| `temperature` | float | 0.0–1.0 (default: 0.3) +| `response_format` | string | `markdown` (default) or `text` +|=== + +Response: + +[source,json] +---- +{ "answer": "...", "model": "claude-sonnet-4-6", "latency_ms": 1234 } +---- + +=== `suggest_tactics` + +Structured tactic generation for proof search. ECHIDNA builds the prompts +via `build_system_prompt()` and `build_user_prompt()` in `llm.rs` and sends +them verbatim. + +Request fields: + +[cols="1,1,3"] +|=== +| Field | Type | Description +| `system` | string | System prompt (role + JSON schema) from `build_system_prompt()` +| `prompt` | string | User prompt (goal + hypotheses + history) from `build_user_prompt()` *(required)* +| `model` | string | `opus` / `sonnet` / `haiku` or full model ID (default: `sonnet`) +| `max_tokens` | int | Maximum tokens (default: 2048) +| `temperature` | float | 0.0–1.0 (default: 0.2) +| `response_format` | string | `json` +|=== + +Response (`TacticSuggestionResponse` shape): + +[source,json] +---- +{ + "tactics": [ + { "tactic": "induction n", "confidence": 0.9, + "target_prover": "coq", "rationale": "..." } + ], + "recommended_provers": [ + { "prover": "z3", "confidence": 0.8, "reason": "..." } + ], + "decomposition": null, + "auxiliary_lemmas": [], + "reasoning": "...", + "model": "claude-sonnet-4-6", + "latency_ms": 2345 +} +---- + +== Auth + +Set `ANTHROPIC_API_KEY` in the environment before starting BoJ. In production +this is injected by `vault-mcp`. + +== Wire protocol + +ECHIDNA sends either: + +* `{ "tool": "consult", "arguments": {...} }` β€” canonical BoJ form +* `{ "operation": "consult", "params": {...} }` β€” echidna-native form + +The BoJ router (`BojRest.Router`) normalises both: `operation` β†’ `tool`, +`params` β†’ `arguments`. diff --git a/cartridges/domains/formal-verification/echidna-llm-mcp/cartridge.json b/cartridges/domains/formal-verification/echidna-llm-mcp/cartridge.json new file mode 100644 index 0000000..869bc76 --- /dev/null +++ b/cartridges/domains/formal-verification/echidna-llm-mcp/cartridge.json @@ -0,0 +1,109 @@ +{ + "$schema": "https://boj.dev/schemas/cartridge/v1.json", + "spdx": "MPL-2.0", + "copyright": "Copyright (c) 2026 Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk>", + "name": "echidna-llm-mcp", + "version": "0.1.0", + "status": "stub", + "description": "LLM advisor cartridge for the ECHIDNA formal verification engine. Provides free-form consultation (consult) and structured proof-tactic generation (suggest_tactics) by routing to Anthropic Claude via ANTHROPIC_API_KEY.", + "domain": "Formal Verification", + "tier": "Ayo", + "protocols": [ + "REST" + ], + "auth": { + "method": "api_key_header", + "header": "x-api-key", + "env_var": "ANTHROPIC_API_KEY", + "credential_source": "vault-mcp" + }, + "api": { + "base_url": "https://api.anthropic.com/v1", + "content_type": "application/json", + "extra_headers": { + "anthropic-version": "2023-06-01" + } + }, + "tools": [ + { + "name": "consult", + "description": "Free-form Q&A with the LLM advisor. Accepts a natural-language question and optional proof-state context; returns a Markdown-formatted answer. Intended for /api/v1/consult calls from ECHIDNA.", + "inputSchema": { + "type": "object", + "properties": { + "question": { + "type": "string", + "description": "The question to answer (natural language)." + }, + "context": { + "type": "string", + "description": "Optional additional context (e.g. current proof state summary, recent job log)." + }, + "model": { + "type": "string", + "description": "Model hint: 'opus', 'sonnet', 'haiku', or a full Claude model ID. Defaults to claude-sonnet-4-6.", + "default": "sonnet" + }, + "max_tokens": { + "type": "integer", + "description": "Maximum tokens in the LLM response.", + "default": 2048 + }, + "temperature": { + "type": "number", + "description": "Sampling temperature 0.0–1.0.", + "default": 0.3 + }, + "response_format": { + "type": "string", + "description": "Output style hint: 'markdown' (default) or 'text'.", + "default": "markdown" + } + }, + "required": [ + "question" + ] + } + }, + { + "name": "suggest_tactics", + "description": "Structured tactic generation for ECHIDNA proof search. The caller supplies a fully-formed system prompt (role + JSON schema) and user prompt (goal + hypotheses + history). Returns a TacticSuggestionResponse JSON with tactics, recommended_provers, decomposition, auxiliary_lemmas, and reasoning.", + "inputSchema": { + "type": "object", + "properties": { + "system": { + "type": "string", + "description": "System prompt from echidna's build_system_prompt() β€” defines the advisor role and required JSON response schema." + }, + "prompt": { + "type": "string", + "description": "User prompt from echidna's build_user_prompt() β€” encodes the proof goal, hypotheses, history, and top-k count." + }, + "model": { + "type": "string", + "description": "Model hint: 'opus', 'sonnet', 'haiku', or a full Claude model ID. Defaults to claude-sonnet-4-6.", + "default": "sonnet" + }, + "max_tokens": { + "type": "integer", + "description": "Maximum tokens in the LLM response.", + "default": 2048 + }, + "temperature": { + "type": "number", + "description": "Sampling temperature 0.0–1.0. Lower = more deterministic tactic suggestions.", + "default": 0.2 + }, + "response_format": { + "type": "string", + "description": "Expected output format: 'json' (required for correct TacticSuggestionResponse parsing).", + "default": "json" + } + }, + "required": [ + "prompt" + ] + } + } + ] +} diff --git a/cartridges/domains/formal-verification/echidna-llm-mcp/mod.js b/cartridges/domains/formal-verification/echidna-llm-mcp/mod.js new file mode 100644 index 0000000..28610cc --- /dev/null +++ b/cartridges/domains/formal-verification/echidna-llm-mcp/mod.js @@ -0,0 +1,344 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk> +// +// echidna-llm-mcp/mod.js β€” LLM advisor cartridge for the ECHIDNA proof engine. +// +// Implements two operations called by echidna/src/rust/llm.rs: +// +// consult β€” free-form Q&A: question + context β†’ {answer, model, latency_ms} +// suggest_tactics β€” structured tactic generation for a proof goal β†’ +// {tactics, recommended_provers, decomposition, +// auxiliary_lemmas, reasoning, model, latency_ms} +// +// Both operations route to the Anthropic Claude API directly (same pattern as +// claude-ai-mcp/mod.js). Model selection: +// - If the caller specifies a model name, map it to a concrete Claude model ID. +// - Fallback: claude-sonnet-4-6. +// +// Auth: ANTHROPIC_API_KEY env var (set by BoJ credential forwarding or vault-mcp). +// +// Request shape (echidna sends either "tool"/"arguments" or "operation"/"params"): +// +// consult: +// { question: string, context: string, model: string, +// max_tokens: int, temperature: float, response_format: string } +// +// suggest_tactics: +// { system: string, prompt: string, model: string, +// max_tokens: int, temperature: float, response_format: string } +// +// Response shape: +// consult: { answer: string, model: string, latency_ms: int } +// suggest_tactics: { tactics: [...], recommended_provers: [...], +// decomposition: null|{...}, auxiliary_lemmas: [...], +// reasoning: string, model: string, latency_ms: int } + +// --------------------------------------------------------------------------- +// Config +// --------------------------------------------------------------------------- + +const ANTHROPIC_BASE = "https://api.anthropic.com/v1"; +const TIMEOUT_MS = 60_000; +const DEFAULT_MAX_TOKENS = 2048; + +// Map tier names (opus/sonnet/haiku) to concrete model IDs. +// Falls through to the literal string if it's already a full model ID. +const MODEL_MAP = { + "opus": "claude-opus-4-6", + "sonnet": "claude-sonnet-4-6", + "haiku": "claude-haiku-4-5-20251001", +}; + +/** + * Resolve a caller-supplied model hint to a concrete Anthropic model ID. + * Accepts tier names ("opus", "sonnet", "haiku"), full model IDs, or anything + * else (falls back to sonnet). + * + * @param {string|undefined} hint + * @returns {string} + */ +function resolveModel(hint) { + if (!hint) return MODEL_MAP["sonnet"]; + if (MODEL_MAP[hint]) return MODEL_MAP[hint]; + // Accept full model IDs that start with "claude-" + if (hint.startsWith("claude-")) return hint; + // Unknown β€” default to sonnet + return MODEL_MAP["sonnet"]; +} + +// --------------------------------------------------------------------------- +// HTTP helper β€” matches the pattern in claude-ai-mcp/mod.js +// --------------------------------------------------------------------------- + +/** + * POST to the Anthropic API with ANTHROPIC_API_KEY from env. + * + * @param {string} path β€” e.g. "/messages" + * @param {object} body β€” request body (will be JSON-encoded) + * @returns {{ status: number, data: object }} + */ +async function anthropicPost(path, body) { + const apiKey = Deno.env.get("ANTHROPIC_API_KEY") ?? ""; + if (!apiKey) { + return { status: 401, data: { error: "ANTHROPIC_API_KEY not set β€” configure via vault-mcp or env" } }; + } + + const ctrl = new AbortController(); + const timer = setTimeout(() => ctrl.abort(), TIMEOUT_MS); + try { + const r = await fetch(`${ANTHROPIC_BASE}${path}`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-api-key": apiKey, + "anthropic-version": "2023-06-01", + }, + body: JSON.stringify(body), + signal: ctrl.signal, + }); + const data = await r.json().catch(() => ({ error: "non-JSON response from Anthropic" })); + return { status: r.status, data }; + } catch (e) { + if (e.name === "AbortError") { + return { status: 504, data: { error: "Anthropic API timed out after 60 s" } }; + } + return { status: 503, data: { error: `Anthropic API unavailable: ${e.message}` } }; + } finally { + clearTimeout(timer); + } +} + +/** + * Extract the first text block from an Anthropic Messages API response. + * + * @param {object} apiResp β€” parsed JSON body from /v1/messages + * @returns {string} + */ +function extractText(apiResp) { + const content = apiResp.content ?? []; + return content + .filter((b) => b.type === "text") + .map((b) => b.text) + .join(""); +} + +// --------------------------------------------------------------------------- +// consult β€” free-form Q&A +// --------------------------------------------------------------------------- + +/** + * Route a free-form question + context to the LLM and return a formatted answer. + * + * The LLM receives a system prompt that primes it for formal verification Q&A + * and a user message that concatenates the question with any provided context. + * + * @param {object} args β€” { question, context, model, max_tokens, temperature, response_format } + * @returns {{ status: number, data: { answer: string, model: string, latency_ms: number } }} + */ +async function consult(args) { + const t0 = Date.now(); + + const { + question, + context = "", + model: modelHint, + max_tokens = DEFAULT_MAX_TOKENS, + temperature = 0.3, + response_format = "markdown", + } = args ?? {}; + + if (!question || typeof question !== "string" || question.trim() === "") { + return { status: 400, data: { error: "consult: 'question' is required and must be a non-empty string" } }; + } + + const resolvedModel = resolveModel(modelHint); + + // Build the user message, embedding context when provided. + let userContent = question; + if (context && context.trim() !== "") { + userContent = `Context:\n${context}\n\nQuestion:\n${question}`; + } + + // System prompt: formal-verification advisor role. + const systemPrompt = response_format === "markdown" + ? "You are ECHIDNA's formal-verification advisor. Answer concisely in Markdown. " + + "Focus on theorem-proving strategy, prover selection, and formal correctness. " + + "Your answers are advisory β€” the formal provers verify everything independently." + : "You are ECHIDNA's formal-verification advisor. Answer concisely in plain text. " + + "Focus on theorem-proving strategy, prover selection, and formal correctness. " + + "Your answers are advisory β€” the formal provers verify everything independently."; + + const payload = { + model: resolvedModel, + max_tokens, + system: systemPrompt, + messages: [{ role: "user", content: userContent }], + }; + if (typeof temperature === "number") { + payload.temperature = temperature; + } + + const { status, data: apiData } = await anthropicPost("/messages", payload); + + if (status !== 200) { + // Surface the Anthropic error upstream. + const errMsg = apiData?.error?.message ?? apiData?.error ?? `Anthropic returned HTTP ${status}`; + return { status, data: { error: `consult: LLM call failed β€” ${errMsg}` } }; + } + + const answer = extractText(apiData); + const latency_ms = Date.now() - t0; + + return { + status: 200, + data: { + answer, + model: apiData.model ?? resolvedModel, + latency_ms, + }, + }; +} + +// --------------------------------------------------------------------------- +// suggest_tactics β€” structured tactic generation +// --------------------------------------------------------------------------- + +/** + * Structured tactic advisor. The caller (echidna llm.rs) builds the system + * and user prompts (build_system_prompt / build_user_prompt) and passes them + * verbatim. We route them to the LLM and parse the JSON response back into + * the TacticSuggestionResponse shape echidna expects. + * + * Expected LLM JSON response schema (from echidna's build_system_prompt): + * { + * "tactics": [{"tactic":"...","confidence":0.0-1.0,"target_prover":"...","rationale":"..."}], + * "recommended_provers": [{"prover":"...","confidence":0.0-1.0,"reason":"..."}], + * "decomposition": null | {"strategy":"...","subgoals":[...],"recommended_order":[...]}, + * "auxiliary_lemmas": [...], + * "reasoning": "..." + * } + * + * @param {object} args β€” { system, prompt, model, max_tokens, temperature, response_format } + * @returns {{ status: number, data: TacticSuggestionResponse & { model: string, latency_ms: number } }} + */ +async function suggestTactics(args) { + const t0 = Date.now(); + + const { + system: systemPrompt, + prompt: userPrompt, + model: modelHint, + max_tokens = DEFAULT_MAX_TOKENS, + temperature = 0.2, + } = args ?? {}; + + if (!userPrompt || typeof userPrompt !== "string" || userPrompt.trim() === "") { + return { status: 400, data: { error: "suggest_tactics: 'prompt' is required and must be a non-empty string" } }; + } + + const resolvedModel = resolveModel(modelHint); + + const payload = { + model: resolvedModel, + max_tokens, + messages: [{ role: "user", content: userPrompt }], + }; + // System prompt from echidna instructs the model to return structured JSON. + if (systemPrompt && typeof systemPrompt === "string") { + payload.system = systemPrompt; + } + if (typeof temperature === "number") { + payload.temperature = temperature; + } + + const { status, data: apiData } = await anthropicPost("/messages", payload); + + if (status !== 200) { + const errMsg = apiData?.error?.message ?? apiData?.error ?? `Anthropic returned HTTP ${status}`; + return { status, data: { error: `suggest_tactics: LLM call failed β€” ${errMsg}` } }; + } + + const rawText = extractText(apiData); + const latency_ms = Date.now() - t0; + const resolvedModelId = apiData.model ?? resolvedModel; + + // Parse the structured JSON the LLM was instructed to return. + // The echidna system prompt (build_system_prompt) asks for a specific JSON + // schema. If parsing fails we return a best-effort single-tactic fallback + // so the Rust caller gets a valid TacticSuggestionResponse rather than an + // opaque error. + let structured; + try { + // The LLM sometimes wraps the JSON in a markdown code fence β€” strip it. + const trimmed = rawText.trim(); + const jsonStr = trimmed.startsWith("```") + ? trimmed.replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/, "") + : trimmed; + structured = JSON.parse(jsonStr); + } catch (_parseErr) { + // Fallback: wrap the raw text as a single "explore" tactic. + structured = { + tactics: [ + { + tactic: rawText.trim().slice(0, 200), + confidence: 0.5, + target_prover: "auto", + rationale: "LLM output was plain text, not JSON β€” wrapping as single tactic suggestion", + }, + ], + recommended_provers: [], + decomposition: null, + auxiliary_lemmas: [], + reasoning: "Structured JSON parse failed; returned raw LLM output as tactic", + }; + } + + return { + status: 200, + data: { + // Spread structured fields from the LLM β€” echidna's Rust deserialiser + // reads tactics, recommended_provers, decomposition, auxiliary_lemmas, reasoning. + tactics: structured.tactics ?? [], + recommended_provers: structured.recommended_provers ?? [], + decomposition: structured.decomposition ?? null, + auxiliary_lemmas: structured.auxiliary_lemmas ?? [], + reasoning: structured.reasoning ?? "", + // Extra fields consumed by TacticSuggestionResponse. + model: resolvedModelId, + latency_ms, + }, + }; +} + +// --------------------------------------------------------------------------- +// handleTool β€” BoJ cartridge dispatch entry point +// --------------------------------------------------------------------------- + +/** + * BoJ JS cartridge entry point, called by BojRest.JsInvoker via js_runner.js. + * + * Accepts both canonical BoJ tool names and the echidna "operation" aliases + * because the router normalises "operation" β†’ "tool" in BojRest.Router. + * + * @param {string} toolName + * @param {object} args + * @returns {{ status: number, data: object }} + */ +export async function handleTool(toolName, args) { + switch (toolName) { + // ── consult ──────────────────────────────────────────────────────────── + case "consult": + return consult(args); + + // ── suggest_tactics ─────────────────────────────────────────────────── + case "suggest_tactics": + return suggestTactics(args); + + // ── unknown ──────────────────────────────────────────────────────────── + default: + return { + status: 404, + data: { error: `echidna-llm-mcp: unknown operation '${toolName}'. Supported: consult, suggest_tactics` }, + }; + } +} diff --git a/cartridges/domains/formal-verification/ephapax-mcp/.editorconfig b/cartridges/domains/formal-verification/ephapax-mcp/.editorconfig new file mode 100644 index 0000000..36a608d --- /dev/null +++ b/cartridges/domains/formal-verification/ephapax-mcp/.editorconfig @@ -0,0 +1,13 @@ +# SPDX-License-Identifier: MPL-2.0 +root = true + +[*] +charset = utf-8 +indent_style = space +indent_size = 2 +end_of_line = lf +trim_trailing_whitespace = true +insert_final_newline = true + +[*.md] +trim_trailing_whitespace = false diff --git a/cartridges/domains/formal-verification/ephapax-mcp/.gitignore b/cartridges/domains/formal-verification/ephapax-mcp/.gitignore new file mode 100644 index 0000000..a321f24 --- /dev/null +++ b/cartridges/domains/formal-verification/ephapax-mcp/.gitignore @@ -0,0 +1,11 @@ +# SPDX-License-Identifier: MPL-2.0 +*.swp +*.swo +*~ +.DS_Store +node_modules/ +dist/ +build/ +target/ +.deno +deno.lock diff --git a/cartridges/domains/formal-verification/ephapax-mcp/LICENSE b/cartridges/domains/formal-verification/ephapax-mcp/LICENSE new file mode 100644 index 0000000..c1cb6d6 --- /dev/null +++ b/cartridges/domains/formal-verification/ephapax-mcp/LICENSE @@ -0,0 +1,15 @@ +SPDX-License-Identifier: MPL-2.0 + +Ephapax Cartridge +Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) + +This software is licensed under the MPL-2.0 license. + +MPL-2.0 is a license supporting dual licensing with MPL-2.0 as automatic fallback. +For the full license text, see: https://hyperpolymath.dev/standards/PMPL-1.0 + +Legal Notice: +Until PMPL achieves formal recognition as a standalone license, this software is +automatically operative under the Mozilla Public License 2.0 (MPL-2.0). + +This is a legal fallback arrangement confirmed by legal counsel. diff --git a/cartridges/domains/formal-verification/ephapax-mcp/README.adoc b/cartridges/domains/formal-verification/ephapax-mcp/README.adoc new file mode 100644 index 0000000..2863dd8 --- /dev/null +++ b/cartridges/domains/formal-verification/ephapax-mcp/README.adoc @@ -0,0 +1,74 @@ += Ephapax Cartridge +:toc: preamble +:author: Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk> +:date: 2026-04-25 +:spdx: MPL-2.0 + +// SPDX-License-Identifier: MPL-2.0 + +Proof-compiler query interface for formal verification workflows β€” analyze proofs, validate theorems, type-check expressions. + +== Features + +- **Proof Metadata Queries** β€” Check status, complexity, dependencies +- **Theorem Validation** β€” Verify proofs are closed (Qed, not Admitted) +- **Type Checking** β€” Validate expressions against type system +- **Proof Analysis** β€” Analyze complexity, size, dependency depth +- **Module Discovery** β€” List proven theorems per module + +== Architecture + +[cols="1,3"] +|=== +| Component | Purpose + +| `abi/Ephapax.idr` +| Idris2 interface with proof metadata types and compiler queries. + +| `ffi/ephapax_ffi.zig` +| Zig bindings for proof-compiler API calls and validation. + +| `adapter/mod.ts` +| Deno MCP server exposing proof-compiler tools. + Runs on `127.0.0.1:5175` (loopback only). + +| `cartridge.json` +| Tool manifest with 5 MCP tools for proof verification. +|=== + +== MCP Tools + +=== `query_proof` +Fetch proof metadata (status, complexity, dependencies) by theorem name. + +=== `list_proven_theorems` +List all proven theorems in a module. + +=== `type_check_expression` +Type-check an expression against the proof-compiler type system. + +=== `analyze_proof` +Analyze proof complexity, size, and dependency tree. + +=== `validate_theorem` +Validate that a theorem's proof is closed (Qed, not Admitted). + +== Integration + +Connects to formal verification infrastructure via: +- **Proof database queries** for metadata and status +- **Type system** for expression validation +- **Complexity analysis** for proof optimization + +Loopback proof pinning: `IsLoopback 5175` at compile-time. + +== High-Value Dogfooding + +This cartridge serves as primary MCP interface to ephapax proof infrastructure, enabling: +- Automated proof validation in CI/CD +- Interactive proof exploration in Claude sessions +- Complexity-driven refactoring of formal libraries + +== License + +MPL-2.0 (MPL-2.0 legal fallback). diff --git a/cartridges/domains/formal-verification/ephapax-mcp/abi/Ephapax.idr b/cartridges/domains/formal-verification/ephapax-mcp/abi/Ephapax.idr new file mode 100644 index 0000000..ae455b5 --- /dev/null +++ b/cartridges/domains/formal-verification/ephapax-mcp/abi/Ephapax.idr @@ -0,0 +1,65 @@ +-- SPDX-License-Identifier: MPL-2.0 +-- Ephapax Cartridge ABI β€” Proof-compiler query interface + +module ABI.Ephapax + +%language ElabReflection + +-- Proof status enumeration +public export +data ProofStatus : Type where + ProvenQed : ProofStatus + ProvenAdmitted : ProofStatus + ProvenPartial : ProofStatus + Unproven : ProofStatus + InvalidProof : ProofStatus + +-- Proof metadata record +public export +record ProofMetadata where + constructor MkProofMetadata + theoremName : String + status : ProofStatus + lines : Nat + complexity : Nat -- Estimate (0-100) + dependencies : List String + lastModified : String + +-- Query result wrapper +public export +record QueryResult where + constructor MkQueryResult + success : Bool + message : String + data : String + +-- Type-checking result +public export +record TypeCheckResult where + constructor MkTypeCheckResult + valid : Bool + inferredType : String + errors : List String + +-- Ephapax cartridge interface +public export +interface Ephapax.Compiler where + -- Query proof metadata by theorem name + queryProof : String -> IO ProofMetadata + + -- List all proven theorems in a module + listProvenTheorems : String -> IO (List ProofMetadata) + + -- Type-check an expression + typeCheckExpression : String -> IO TypeCheckResult + + -- Analyze proof complexity and dependencies + analyzeProof : String -> IO QueryResult + + -- Check if cartridge port is loopback-only (compile-time proof) + IsLoopback : (port : Nat) -> Type + IsLoopback 5175 = () + +public export +Loopback.proof : IsLoopback 5175 +Loopback.proof = () diff --git a/cartridges/domains/formal-verification/ephapax-mcp/adapter/mod.ts b/cartridges/domains/formal-verification/ephapax-mcp/adapter/mod.ts new file mode 100644 index 0000000..112c87b --- /dev/null +++ b/cartridges/domains/formal-verification/ephapax-mcp/adapter/mod.ts @@ -0,0 +1,198 @@ +// SPDX-License-Identifier: MPL-2.0 +// Ephapax Cartridge β€” Proof-compiler query MCP server + +import { Server } from "https://esm.sh/@modelcontextprotocol/sdk/server/index.js"; +import { + CallToolRequestSchema, + ListToolsRequestSchema, + Tool, +} from "https://esm.sh/@modelcontextprotocol/sdk/types.js"; + +// MCP tool definitions for proof-compiler queries +const TOOLS: Tool[] = [ + { + name: "query_proof", + description: "Query proof metadata by theorem name (status, complexity, dependencies)", + inputSchema: { + type: "object" as const, + properties: { + theorem_name: { + type: "string", + description: "Theorem name to query", + }, + }, + required: ["theorem_name"], + }, + }, + { + name: "list_proven_theorems", + description: "List all proven theorems in a module", + inputSchema: { + type: "object" as const, + properties: { + module_name: { + type: "string", + description: "Module name (e.g. Stdlib.Nat)", + }, + }, + required: ["module_name"], + }, + }, + { + name: "type_check_expression", + description: "Type-check an expression against the proof-compiler type system", + inputSchema: { + type: "object" as const, + properties: { + expression: { + type: "string", + description: "Expression to type-check", + }, + }, + required: ["expression"], + }, + }, + { + name: "analyze_proof", + description: "Analyze proof complexity, size, and dependency tree", + inputSchema: { + type: "object" as const, + properties: { + theorem_name: { + type: "string", + description: "Theorem name to analyze", + }, + }, + required: ["theorem_name"], + }, + }, + { + name: "validate_theorem", + description: "Validate that a theorem's proof is closed (Qed, not Admitted)", + inputSchema: { + type: "object" as const, + properties: { + theorem_name: { + type: "string", + description: "Theorem name to validate", + }, + }, + required: ["theorem_name"], + }, + }, +]; + +// Tool handlers +async function handleQueryProof( + args: Record<string, unknown> +): Promise<string> { + const theoremName = String(args.theorem_name); + return JSON.stringify({ + theorem_name: theoremName, + status: "proven_qed", + lines: 42, + complexity: 35, + dependencies: ["Stdlib.Nat", "Stdlib.List"], + last_modified: "2026-04-25", + }); +} + +async function handleListProvenTheorems( + args: Record<string, unknown> +): Promise<string> { + const moduleName = String(args.module_name); + return JSON.stringify({ + module: moduleName, + theorems: [], + count: 0, + }); +} + +async function handleTypeCheckExpression( + args: Record<string, unknown> +): Promise<string> { + const expression = String(args.expression); + return JSON.stringify({ + expression, + valid: true, + inferred_type: "Type", + errors: [], + }); +} + +async function handleAnalyzeProof( + args: Record<string, unknown> +): Promise<string> { + const theoremName = String(args.theorem_name); + return JSON.stringify({ + theorem_name: theoremName, + complexity_score: 35, + proof_size_bytes: 1024, + dependency_depth: 4, + analysis: "Proof analysis placeholder", + }); +} + +async function handleValidateTheorem( + args: Record<string, unknown> +): Promise<string> { + const theoremName = String(args.theorem_name); + return JSON.stringify({ + theorem_name: theoremName, + is_closed: true, + status: "proven_qed", + message: "Proof is properly closed", + }); +} + +// Initialize MCP server +const server = new Server({ + name: "ephapax-mcp", + version: "1.0.0", +}); + +// Register tool handlers +server.setRequestHandler(ListToolsRequestSchema, async () => { + return { tools: TOOLS }; +}); + +server.setRequestHandler(CallToolRequestSchema, async (request) => { + const { name, arguments: args } = request; + + let result: string; + if (name === "query_proof") { + result = await handleQueryProof(args as Record<string, unknown>); + } else if (name === "list_proven_theorems") { + result = await handleListProvenTheorems(args as Record<string, unknown>); + } else if (name === "type_check_expression") { + result = await handleTypeCheckExpression(args as Record<string, unknown>); + } else if (name === "analyze_proof") { + result = await handleAnalyzeProof(args as Record<string, unknown>); + } else if (name === "validate_theorem") { + result = await handleValidateTheorem(args as Record<string, unknown>); + } else { + return { + content: [ + { + type: "text" as const, + text: `Unknown tool: ${name}`, + }, + ], + isError: true, + }; + } + + return { + content: [ + { + type: "text" as const, + text: result, + }, + ], + }; +}); + +// Start server on loopback +const port = 5175; +await server.connect(new WebSocket(`ws://127.0.0.1:${port}`)); +console.log("Ephapax MCP server running on ws://127.0.0.1:5175"); diff --git a/cartridges/domains/formal-verification/ephapax-mcp/cartridge.json b/cartridges/domains/formal-verification/ephapax-mcp/cartridge.json new file mode 100644 index 0000000..acf7fbc --- /dev/null +++ b/cartridges/domains/formal-verification/ephapax-mcp/cartridge.json @@ -0,0 +1,70 @@ +{ + "$schema": "https://boj.dev/schemas/cartridge/v1.json", + "spdx": "MPL-2.0", + "copyright": "Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath)", + "name": "ephapax-mcp", + "version": "1.0.0", + "description": "Ephapax Cartridge \u2014 Proof-compiler query tools for formal verification workflows", + "domain": "Formal Verification", + "tier": "Ayo", + "auth": { + "method": "none" + }, + "author": "Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk>", + "license": "MPL-2.0", + "tools": [ + { + "id": "query_proof", + "name": "Query Proof", + "description": "Query proof metadata by theorem name (status, complexity, dependencies)" + }, + { + "id": "list_proven_theorems", + "name": "List Proven Theorems", + "description": "List all proven theorems in a module" + }, + { + "id": "type_check_expression", + "name": "Type Check Expression", + "description": "Type-check an expression against the proof-compiler type system" + }, + { + "id": "analyze_proof", + "name": "Analyze Proof", + "description": "Analyze proof complexity, size, and dependency tree" + }, + { + "id": "validate_theorem", + "name": "Validate Theorem", + "description": "Validate that a theorem's proof is closed (Qed, not Admitted)" + } + ], + "abi": { + "interface": "abi/Ephapax.idr", + "loopback_proof": "IsLoopback 5175" + }, + "ffi": { + "so_path": "ffi/zig-out/lib/libephapax_mcp.so", + "abi_version": "ADR-0006", + "symbols": [ + "boj_cartridge_init", + "boj_cartridge_deinit", + "boj_cartridge_name", + "boj_cartridge_version", + "boj_cartridge_invoke" + ] + }, + "adapter": { + "language": "typescript", + "runtime": "deno", + "entry": "adapter/mod.ts", + "permissions": [ + "net", + "env" + ] + }, + "loopback": { + "host": "127.0.0.1", + "port": 5175 + } +} diff --git a/cartridges/domains/formal-verification/ephapax-mcp/ffi/build.zig b/cartridges/domains/formal-verification/ephapax-mcp/ffi/build.zig new file mode 100644 index 0000000..5c34e10 --- /dev/null +++ b/cartridges/domains/formal-verification/ephapax-mcp/ffi/build.zig @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const ffi_mod = b.addModule("ephapax_mcp", .{ + .root_source_file = b.path("ephapax_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + const lib = b.addLibrary(.{ + .name = "ephapax_mcp", + .root_module = ffi_mod, + .linkage = .dynamic, + }); + lib.linkLibC(); + b.installArtifact(lib); + + const tests = b.addTest(.{ .root_module = ffi_mod }); + tests.linkLibC(); + const run_tests = b.addRunArtifact(tests); + const test_step = b.step("test", "Run FFI tests"); + test_step.dependOn(&run_tests.step); +} diff --git a/cartridges/domains/formal-verification/ephapax-mcp/ffi/cartridge_shim.zig b/cartridges/domains/formal-verification/ephapax-mcp/ffi/cartridge_shim.zig new file mode 100644 index 0000000..0e399db --- /dev/null +++ b/cartridges/domains/formal-verification/ephapax-mcp/ffi/cartridge_shim.zig @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// cartridge_shim.zig β€” Shared helpers for the ADR-0006 five-symbol +// cartridge ABI (`boj_cartridge_init / deinit / name / version / invoke`). +// +// The shim centralises the seven-code return convention, NUL-argument +// guards, tool-name comparison, and the buffer-too-small path so each +// cartridge's `boj_cartridge_invoke` can stay short β€” typically a tool +// table plus `shim.writeResult(...)`. +// +// Cartridges import this file by relative path (no build-graph change +// needed). Example: +// +// const shim = @import("../../../ffi/zig/src/cartridge_shim.zig"); +// +// export fn boj_cartridge_invoke( +// tool_name: [*c]const u8, +// json_args: [*c]const u8, +// out_buf: [*c]u8, +// in_out_len: [*c]usize, +// ) callconv(.c) i32 { +// _ = json_args; +// if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; +// const body = if (shim.toolIs(tool_name, "foo")) "{\"result\":{}}" +// else return shim.RC_UNKNOWN_TOOL; +// return shim.writeResult(out_buf, in_out_len, body); +// } + +const std = @import("std"); + +// ── Return codes (ADR-0006 Β§Return codes) ──────────────────────────── +// +// Frozen by ADR-0006. New failure modes compose these via the error +// JSON body β€” the integer surface does not grow without a follow-up ADR. + +pub const RC_SUCCESS: i32 = 0; +pub const RC_UNKNOWN_TOOL: i32 = -1; +pub const RC_BAD_ARGS: i32 = -2; +pub const RC_BUFFER_TOO_SMALL: i32 = -3; +pub const RC_RUNTIME_ERROR: i32 = -4; +pub const RC_PANIC: i32 = -5; +pub const RC_AUTH_DENIED: i32 = -6; + +// ── Invoke-path helpers ────────────────────────────────────────────── + +/// True if any of the three mandatory `boj_cartridge_invoke` output-path +/// pointers is null. Use at the top of every invoke to short-circuit to +/// `RC_BAD_ARGS`. +pub fn invokeArgsNull( + tool_name: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) bool { + return tool_name == null or out_buf == null or in_out_len == null; +} + +/// Compare a C-NUL-terminated tool-name pointer against a Zig string +/// literal. Caller must have already verified `tool_name` is non-null +/// (usually via `invokeArgsNull`). +pub fn toolIs(tool_name: [*c]const u8, expected: []const u8) bool { + const s = std.mem.span(@as([*:0]const u8, @ptrCast(tool_name))); + return std.mem.eql(u8, s, expected); +} + +/// Copy `body` into `out_buf[0..*in_out_len]` (as a capacity) and update +/// `*in_out_len` to the number of bytes written. Returns `RC_SUCCESS`. +/// +/// If `body.len` exceeds the current capacity stored in `*in_out_len`, +/// sets `*in_out_len` to the required size and returns +/// `RC_BUFFER_TOO_SMALL` β€” the caller is then expected to re-allocate +/// and retry, per ADR-0006 Β§Memory ownership. +/// +/// Caller must have already verified that `out_buf` and `in_out_len` +/// are non-null. +pub fn writeResult( + out_buf: [*c]u8, + in_out_len: [*c]usize, + body: []const u8, +) i32 { + const cap = in_out_len.*; + if (body.len > cap) { + in_out_len.* = body.len; + return RC_BUFFER_TOO_SMALL; + } + @memcpy(out_buf[0..body.len], body); + in_out_len.* = body.len; + return RC_SUCCESS; +} + +// ── Tests ──────────────────────────────────────────────────────────── + +test "writeResult: body fits, writes and sets length" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); + try std.testing.expectEqualStrings("hello", buf[0..len]); +} + +test "writeResult: too small returns -3 and sets required length" { + var buf: [2]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_BUFFER_TOO_SMALL, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: exact-fit succeeds" { + var buf: [5]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: empty body" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, ""); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 0), len); +} + +test "toolIs: matches and rejects" { + const name: [*:0]const u8 = "foo"; + try std.testing.expect(toolIs(@ptrCast(name), "foo")); + try std.testing.expect(!toolIs(@ptrCast(name), "bar")); + try std.testing.expect(!toolIs(@ptrCast(name), "foobar")); + try std.testing.expect(!toolIs(@ptrCast(name), "fo")); +} + +test "invokeArgsNull: detects each null slot" { + var buf: [4]u8 = undefined; + var len: usize = 4; + const name: [*:0]const u8 = "x"; + try std.testing.expect(!invokeArgsNull(@ptrCast(name), &buf, &len)); + try std.testing.expect(invokeArgsNull(null, &buf, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), null, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), &buf, null)); +} diff --git a/cartridges/domains/formal-verification/ephapax-mcp/ffi/ephapax_ffi.zig b/cartridges/domains/formal-verification/ephapax-mcp/ffi/ephapax_ffi.zig new file mode 100644 index 0000000..0f02c35 --- /dev/null +++ b/cartridges/domains/formal-verification/ephapax-mcp/ffi/ephapax_ffi.zig @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// ephapax-mcp FFI β€” ADR-0006 five-symbol cartridge ABI implementation. + +const std = @import("std"); +const shim = @import("cartridge_shim.zig"); + +const CARTRIDGE_NAME_PTR: [*:0]const u8 = "ephapax-mcp"; +const CARTRIDGE_VERSION_PTR: [*:0]const u8 = "0.1.0"; + +export fn boj_cartridge_init() callconv(.c) c_int { + return 0; +} + +export fn boj_cartridge_deinit() callconv(.c) void {} + +export fn boj_cartridge_name() callconv(.c) [*:0]const u8 { + return CARTRIDGE_NAME_PTR; +} + +export fn boj_cartridge_version() callconv(.c) [*:0]const u8 { + return CARTRIDGE_VERSION_PTR; +} + +export fn boj_cartridge_invoke( + tool_name: [*c]const u8, + json_args: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) callconv(.c) i32 { + _ = json_args; + if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; + const body: []const u8 = if (shim.toolIs(tool_name, "query_proof")) + "{\"result\":{}}" + else if (shim.toolIs(tool_name, "list_proven_theorems")) + "{\"result\":{}}" + else if (shim.toolIs(tool_name, "type_check_expression")) + "{\"result\":{}}" + else if (shim.toolIs(tool_name, "analyze_proof")) + "{\"result\":{}}" + else if (shim.toolIs(tool_name, "validate_theorem")) + "{\"result\":{}}" + else + return shim.RC_UNKNOWN_TOOL; + + return shim.writeResult(out_buf, in_out_len, body); +} + +test "boj_cartridge_name returns ephapax-mcp" { + try std.testing.expectEqualStrings("ephapax-mcp", std.mem.span(boj_cartridge_name())); +} + +test "boj_cartridge_init returns 0" { + try std.testing.expectEqual(@as(c_int, 0), boj_cartridge_init()); +} + +test "invoke unknown tool returns RC_UNKNOWN_TOOL" { + var buf: [256]u8 = undefined; + var len: usize = buf.len; + try std.testing.expectEqual(@as(i32, shim.RC_UNKNOWN_TOOL), boj_cartridge_invoke("unknown_xyz", "{}", &buf, &len)); +} + +test "invoke query_proof returns 0" { + var buf: [256]u8 = undefined; + var len: usize = buf.len; + try std.testing.expectEqual(@as(i32, 0), boj_cartridge_invoke("query_proof", "{}", &buf, &len)); +} diff --git a/cartridges/domains/formal-verification/ephapax-mcp/mod.js b/cartridges/domains/formal-verification/ephapax-mcp/mod.js new file mode 100644 index 0000000..682d65f --- /dev/null +++ b/cartridges/domains/formal-verification/ephapax-mcp/mod.js @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: MPL-2.0 +export const cartridge = { + name: "ephapax-mcp", + version: "1.0.0", + description: "Ephapax cartridge β€” proof-compiler query tools", + tools: [ + { id: "query_proof", name: "Query Proof" }, + { id: "list_proven_theorems", name: "List Proven Theorems" }, + { id: "type_check_expression", name: "Type Check Expression" }, + { id: "analyze_proof", name: "Analyze Proof" }, + { id: "validate_theorem", name: "Validate Theorem" }, + ], +}; + +export async function health() { + return { status: "healthy", cartridge: "ephapax-mcp" }; +} + +export async function init() { + console.log("[ephapax-mcp] Initializing"); + return { initialized: true }; +} + +export async function cleanup() { + console.log("[ephapax-mcp] Shutting down"); + return { cleaned: true }; +} diff --git a/cartridges/domains/formal-verification/proof-mcp/README.adoc b/cartridges/domains/formal-verification/proof-mcp/README.adoc new file mode 100644 index 0000000..b0f263c --- /dev/null +++ b/cartridges/domains/formal-verification/proof-mcp/README.adoc @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += proof-mcp +Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk> +:spdx: MPL-2.0 +:tier: Ayo +:domain: Formal Verification +:protocols: MCP, REST + +== Overview + +Proof verification lifecycle manager. Manages sessions across Lean, Coq, Agda, Isabelle, Idris2, Z3, CVC5. State machine: idle -> loading -> verifying -> verified/failed. + +== Tools (8) + +[cols="2,4"] +|=== +| Tool | Description + +| `proof_init_session` | Initialise a proof session for a backend. Returns a slot index. +| `proof_load_obligation` | Load a proof obligation into an active session slot. +| `proof_verify` | Start verification on a loaded session slot. +| `proof_get_result` | Poll the verification result for a slot. +| `proof_get_state` | Get current state: idle / loading / verifying / verified / failed. +| `proof_reset_session` | Reset a session slot back to idle. +| `proof_release_session` | Release a session slot for reuse. +| `proof_can_transition` | Check whether a state machine transition is valid. +|=== + +== Architecture + +Three-layer stack per the BoJ cartridge standard: + +[cols="1,1,3"] +|=== +| Layer | Language | Purpose + +| ABI | Idris2 | Formally verified state machine with dependent-type proofs +| FFI | Zig | C-ABI implementation: session pool, action metrics, thread safety +| Adapter| Zig | Adapter bridge to the BoJ unified adapter protocol (zig predecessors sidelined) +|=== + +== Building + +[source,bash] +---- +# ABI (Idris2) +cd abi && idris2 --check *.idr + +# FFI (Zig) +cd ffi && zig build +cd ffi && zig build test + +# Adapter (Zig) +cd adapter && zig build +---- + +== Status + +Alpha (CRG D). Cartridge metadata declared in `cartridge.json`; 8 tool(s) exposed. +Not yet dogfooded externally. See `docs/READINESS.md` at repo root for the +project-wide grade table and `docs/practice/DOGFOOD-LOG.adoc` for usage evidence. diff --git a/cartridges/domains/formal-verification/proof-mcp/abi/ProofMcp/SafeProof.idr b/cartridges/domains/formal-verification/proof-mcp/abi/ProofMcp/SafeProof.idr new file mode 100644 index 0000000..ac74eaf --- /dev/null +++ b/cartridges/domains/formal-verification/proof-mcp/abi/ProofMcp/SafeProof.idr @@ -0,0 +1,226 @@ +-- SPDX-License-Identifier: MPL-2.0 +-- Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +||| ProofMcp.SafeProof: Formally verified proof verification operations. +||| +||| Cartridge: proof-mcp +||| Matrix cell: Proof domain x {MCP, LSP} protocols +||| +||| This module defines type-safe proof verification operations with a +||| verification state machine that prevents: +||| - Verification without a loaded proof obligation +||| - Retrieving results from an incomplete verification +||| - Double-loading proof obligations +||| +||| Designed to integrate with the echidna/theorem-proving domain. +module ProofMcp.SafeProof + +import Data.List + +%default total + +-- ═══════════════════════════════════════════════════════════════════════════ +-- Proof Verification State Machine +-- ═══════════════════════════════════════════════════════════════════════════ + +||| Proof verification lifecycle states. +||| A verification progresses: Idle -> Loading -> Verifying -> Verified/Failed -> Idle +public export +data ProofState = Idle | Loading | Verifying | Verified | Failed + +||| Equality for proof states. +public export +Eq ProofState where + Idle == Idle = True + Loading == Loading = True + Verifying == Verifying = True + Verified == Verified = True + Failed == Failed = True + _ == _ = False + +||| Valid state transitions (enforced at the type level). +public export +data ValidTransition : ProofState -> ProofState -> Type where + Load : ValidTransition Idle Loading + StartProof : ValidTransition Loading Verifying + Succeed : ValidTransition Verifying Verified + Fail : ValidTransition Verifying Failed + ResetOk : ValidTransition Verified Idle + ResetFail : ValidTransition Failed Idle + CancelLoad : ValidTransition Loading Idle + +||| Runtime transition validator. +public export +canTransition : ProofState -> ProofState -> Bool +canTransition Idle Loading = True +canTransition Loading Verifying = True +canTransition Verifying Verified = True +canTransition Verifying Failed = True +canTransition Verified Idle = True +canTransition Failed Idle = True +canTransition Loading Idle = True +canTransition _ _ = False + +-- ═══════════════════════════════════════════════════════════════════════════ +-- Proof Backend Types +-- ═══════════════════════════════════════════════════════════════════════════ + +||| Supported proof backends / theorem provers. +public export +data ProofBackend + = Z3 -- SMT solver + | CVC5 -- SMT solver + | Lean -- Lean 4 theorem prover + | Coq -- Coq proof assistant + | Agda -- Agda dependently typed language + | Isabelle -- Isabelle/HOL + | Idris2 -- Idris2 (self-hosted verification) + | Custom String -- User-defined backend + +||| C-ABI encoding. +public export +backendToInt : ProofBackend -> Int +backendToInt Z3 = 1 +backendToInt CVC5 = 2 +backendToInt Lean = 3 +backendToInt Coq = 4 +backendToInt Agda = 5 +backendToInt Isabelle = 6 +backendToInt Idris2 = 7 +backendToInt (Custom _) = 99 + +-- ═══════════════════════════════════════════════════════════════════════════ +-- Proof Obligation Types +-- ═══════════════════════════════════════════════════════════════════════════ + +||| Proof obligation classification. +||| Automated obligations can be discharged by SMT solvers. +||| Interactive obligations require human-guided proof. +public export +data ObligationKind = Automated | Interactive + +||| A proof obligation with classification. +public export +record ProofObligation where + constructor MkObligation + obligationId : String + kind : ObligationKind + backend : ProofBackend + sourceFile : String + +||| Verification result status. +public export +data VerifyResult + = ProofComplete Nat -- Number of goals discharged + | ProofIncomplete Nat -- Goals remaining + | ProofError String -- Error with message + +-- ═══════════════════════════════════════════════════════════════════════════ +-- Session Record +-- ═══════════════════════════════════════════════════════════════════════════ + +||| A proof verification session with tracked state. +public export +record ProofSession where + constructor MkProofSession + sessionId : String + backend : ProofBackend + state : ProofState + +||| Proof that a session has a verified result (safe to retrieve). +public export +data IsVerified : ProofSession -> Type where + VerifiedSession : (s : ProofSession) -> + (state s = Verified) -> + IsVerified s + +||| Proof that a cartridge state machine is unbreakable. +||| Every state has at least one valid outgoing transition, +||| ensuring the system never gets stuck. +public export +data IsUnbreakable : Type where + MkUnbreakable : + (idleOut : canTransition Idle Loading = True) -> + (loadOut : canTransition Loading Verifying = True) -> + (verifyOk : canTransition Verifying Verified = True) -> + (verifyFail : canTransition Verifying Failed = True) -> + (verifiedOut : canTransition Verified Idle = True) -> + (failedOut : canTransition Failed Idle = True) -> + (cancelLoad : canTransition Loading Idle = True) -> + IsUnbreakable + +||| Witness that the proof state machine is unbreakable. +public export +proofMachineUnbreakable : IsUnbreakable +proofMachineUnbreakable = MkUnbreakable Refl Refl Refl Refl Refl Refl Refl + +-- ═══════════════════════════════════════════════════════════════════════════ +-- MCP Tool Definitions +-- ═══════════════════════════════════════════════════════════════════════════ + +||| MCP tools exposed by this cartridge. +||| These map to MCP tool definitions that AI agents can call. +public export +data McpTool + = ToolInit -- Initialise proof session + | ToolLoad -- Load a proof obligation + | ToolVerify -- Run verification + | ToolGetResult -- Retrieve verification result + | ToolReset -- Reset session to idle + | ToolListBackends -- List available proof backends + | ToolStatus -- Session health check + +||| MCP tool name (for JSON-RPC method name). +public export +toolName : McpTool -> String +toolName ToolInit = "proof/init" +toolName ToolLoad = "proof/load" +toolName ToolVerify = "proof/verify" +toolName ToolGetResult = "proof/get-result" +toolName ToolReset = "proof/reset" +toolName ToolListBackends = "proof/list-backends" +toolName ToolStatus = "proof/status" + +||| Which tools require an active session. +public export +requiresSession : McpTool -> Bool +requiresSession ToolInit = False +requiresSession ToolListBackends = False +requiresSession _ = True + +-- ═══════════════════════════════════════════════════════════════════════════ +-- C-ABI Exports +-- ═══════════════════════════════════════════════════════════════════════════ + +||| Proof state to integer. +public export +proofStateToInt : ProofState -> Int +proofStateToInt Idle = 0 +proofStateToInt Loading = 1 +proofStateToInt Verifying = 2 +proofStateToInt Verified = 3 +proofStateToInt Failed = 4 + +||| FFI: Validate a state transition. +export +proof_can_transition : Int -> Int -> Int +proof_can_transition from to = + let fromState = case from of + 0 => Idle + 1 => Loading + 2 => Verifying + 3 => Verified + _ => Failed + toState = case to of + 0 => Idle + 1 => Loading + 2 => Verifying + 3 => Verified + _ => Failed + in if canTransition fromState toState then 1 else 0 + +||| FFI: Check if a tool requires an active session. +export +proof_tool_requires_session : Int -> Int +proof_tool_requires_session 1 = 0 -- ToolInit +proof_tool_requires_session 6 = 0 -- ToolListBackends +proof_tool_requires_session _ = 1 -- All others require session diff --git a/cartridges/domains/formal-verification/proof-mcp/abi/README.adoc b/cartridges/domains/formal-verification/proof-mcp/abi/README.adoc new file mode 100644 index 0000000..86f527f --- /dev/null +++ b/cartridges/domains/formal-verification/proof-mcp/abi/README.adoc @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += proof-mcp / abi β€” Idris2 ABI layer +:orientation: shallow + +== Purpose + +Cartridge-local protocol types for `proof-mcp`. Defines the data, tool, and +transition vocabulary this cartridge speaks. Inherits safety lemmas and the +matrix-cell identifier scheme from the trunk ABI at `src/abi/`. + +== Files + +1 Idris2 module(s), ~226 lines total. Lead module: +`SafeProof.idr`. + +No `.ipkg` β€” consumed directly by wider BoJ callers; type-checks inside the +trunk `boj.ipkg`. + +== Invariants + +* `%default total` at every module top. +* Zero `believe_me`, `assert_total`, `postulate`, `Admitted`, or + `sorry` (the trunk holds the four declared `believe_me` sites; leaf + cartridges carry none). + +== Test/proof surface + +Thin module(s), no inline tests. Proof surface = type-check success under the +trunk `idris2 --check`. + +== Read-first + +. `SafeProof.idr` β€” cartridge-local data records and enums. +. Trunk `src/abi/Boj/Catalogue.idr` for how this cell participates in the + 2D matrix. diff --git a/cartridges/domains/formal-verification/proof-mcp/abi/proof-mcp.ipkg b/cartridges/domains/formal-verification/proof-mcp/abi/proof-mcp.ipkg new file mode 100644 index 0000000..b89e611 --- /dev/null +++ b/cartridges/domains/formal-verification/proof-mcp/abi/proof-mcp.ipkg @@ -0,0 +1,11 @@ +-- SPDX-License-Identifier: MPL-2.0 +package proofmcp + +authors = "Jonathan D.A. Jewell" +version = 0.1.0 +license = "MPL-2.0" +brief = "Proof MCP cartridge β€” verification state machine with formal backend management" + +sourcedir = "." +modules = ProofMcp.SafeProof +depends = base, contrib diff --git a/cartridges/domains/formal-verification/proof-mcp/adapter/README.adoc b/cartridges/domains/formal-verification/proof-mcp/adapter/README.adoc new file mode 100644 index 0000000..8b3d643 --- /dev/null +++ b/cartridges/domains/formal-verification/proof-mcp/adapter/README.adoc @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += proof-mcp / adapter β€” protocol bridge +:orientation: shallow + +== Purpose + +Exposes this cartridge's FFI over the wire protocols declared in +`cartridge.json`. Dispatches tool invocations to the C-ABI exports at +`../ffi/` and formats responses per protocol. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” depends on the sibling `ffi` target. +| `proof_adapter.zig` | Protocol dispatch (133 lines). Tool catalogue matches `cartridge.json`. +| `SIDELINED-*.v.adoc` | zig predecessor(s). Not built β€” zig banned 2026-04-10. +|=== + +== Invariants + +* **Stateless** β€” all state lives behind the FFI, never in the adapter. +* **Response passthrough** β€” whatever the FFI returns goes back to the wire + unmodified (no embellishment, no silent recovery). +* **Cartridge.json is source of truth** for the tool catalogue; drift between + adapter and manifest is a CI failure. + +== Test/proof surface + +No inline tests at the adapter layer β€” protocol routing is exercised by the +trunk seam tests at `ffi/zig/src/seams.zig`. + +== Read-first + +. `proof_adapter.zig` β€” dispatch function and per-protocol routers. +. `../cartridge.json` β€” the tool manifest the dispatcher mirrors. diff --git a/cartridges/domains/formal-verification/proof-mcp/adapter/build.zig b/cartridges/domains/formal-verification/proof-mcp/adapter/build.zig new file mode 100644 index 0000000..40dab25 --- /dev/null +++ b/cartridges/domains/formal-verification/proof-mcp/adapter/build.zig @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// proof-mcp/adapter/build.zig + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + const ffi_mod = b.createModule(.{ + .root_source_file = b.path("../ffi/proof_ffi.zig"), + .target = target, .optimize = optimize, + }); + const adapter = b.addExecutable(.{ + .name = "proof_adapter", + .root_source_file = b.path("proof_adapter.zig"), + .target = target, .optimize = optimize, + }); + adapter.root_module.addImport("proof_ffi", ffi_mod); + b.installArtifact(adapter); + const run_step = b.step("run", "Run the proof-mcp adapter"); + run_step.dependOn(&b.addRunArtifact(adapter).step); + const tests = b.addTest(.{ + .root_source_file = b.path("proof_adapter.zig"), + .target = target, .optimize = optimize, + }); + tests.root_module.addImport("proof_ffi", ffi_mod); + const test_step = b.step("test", "Run proof-mcp adapter tests"); + test_step.dependOn(&b.addRunArtifact(tests).step); +} diff --git a/cartridges/domains/formal-verification/proof-mcp/adapter/proof_adapter.zig b/cartridges/domains/formal-verification/proof-mcp/adapter/proof_adapter.zig new file mode 100644 index 0000000..20198ef --- /dev/null +++ b/cartridges/domains/formal-verification/proof-mcp/adapter/proof_adapter.zig @@ -0,0 +1,133 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// proof-mcp/adapter/proof_adapter.zig -- Unified three-protocol adapter. +// +// Replaces the banned proof_adapter.v (zig, removed 2026-04-12). +// +// REST :9121 gRPC-compat :9122 GraphQL :9123 +// Proof verification lifecycle manager. Manages sessions across Lean, Coq, Agda, Isabelle, Idris2, Z3, +// Tools: proof_init_session, proof_load_obligation, proof_verify, proof_get_result, proof_get_state, proof_reset_session, proof_release_session, proof_can_transition + +const std = @import("std"); +const ffi = @import("proof_ffi"); + +const REST_PORT: u16 = 9121; +const GRPC_PORT: u16 = 9122; +const GQL_PORT: u16 = 9123; +const MAX_CONN_BUF: usize = 16 * 1024; + +fn okJson(buf: []u8, msg: []const u8) []u8 { + const n = std.fmt.bufPrint(buf, "{{\"success\":true,\"message\":\"{s}\"}}", .{msg}) catch return buf[0..0]; + return n; +} +fn errJson(buf: []u8, msg: []const u8) []u8 { + const n = std.fmt.bufPrint(buf, "{{\"success\":false,\"error\":\"{s}\"}}", .{msg}) catch return buf[0..0]; + return n; +} +fn statusJson(buf: []u8) []u8 { + const n = std.fmt.bufPrint(buf, "{{\"success\":true,\"state\":\"ready\",\"service\":\"proof-mcp\"}}", .{}) catch return buf[0..0]; + return n; +} + +const Response = struct { status: u16, body: []u8 }; + +fn dispatch(tool: []const u8, body: []const u8, resp: []u8) Response { + _ = body; + if (std.mem.eql(u8, tool, "proof_init_session")) return .{ .status = 200, .body = okJson(resp, "proof_init_session forwarded") }; + if (std.mem.eql(u8, tool, "proof_load_obligation")) return .{ .status = 200, .body = okJson(resp, "proof_load_obligation forwarded") }; + if (std.mem.eql(u8, tool, "proof_verify")) return .{ .status = 200, .body = okJson(resp, "proof_verify forwarded") }; + if (std.mem.eql(u8, tool, "proof_get_result")) return .{ .status = 200, .body = okJson(resp, "proof_get_result forwarded") }; + if (std.mem.eql(u8, tool, "proof_get_state")) return .{ .status = 200, .body = okJson(resp, "proof_get_state forwarded") }; + if (std.mem.eql(u8, tool, "proof_reset_session")) return .{ .status = 200, .body = okJson(resp, "proof_reset_session forwarded") }; + if (std.mem.eql(u8, tool, "proof_release_session")) return .{ .status = 200, .body = okJson(resp, "proof_release_session forwarded") }; + if (std.mem.eql(u8, tool, "proof_can_transition")) return .{ .status = 200, .body = okJson(resp, "proof_can_transition forwarded") }; + if (std.mem.eql(u8, tool, "status") or std.mem.eql(u8, tool, "health")) + return .{ .status = 200, .body = statusJson(resp) }; + return .{ .status = 404, .body = errJson(resp, "Unknown tool") }; +} + +fn dispatchRest(path: []const u8, body: []const u8, resp: []u8) Response { + if (std.mem.startsWith(u8, path, "/tools/")) return dispatch(path["/tools/".len..], body, resp); + if (std.mem.eql(u8, path, "/status") or std.mem.eql(u8, path, "/health")) return .{ .status = 200, .body = statusJson(resp) }; + return .{ .status = 404, .body = errJson(resp, "Not found") }; +} + +fn dispatchGrpc(path: []const u8, body: []const u8, resp: []u8) Response { + const prefix = "/Proofservice/"; + if (!std.mem.startsWith(u8, path, prefix)) + return .{ .status = 404, .body = errJson(resp, "Not a recognized gRPC path") }; + const method = path[prefix.len..]; + const tool = blk: { + if (std.mem.eql(u8, method, "proof_init_session")) break :blk "proof_init_session"; + if (std.mem.eql(u8, method, "proof_load_obligation")) break :blk "proof_load_obligation"; + if (std.mem.eql(u8, method, "proof_verify")) break :blk "proof_verify"; + if (std.mem.eql(u8, method, "proof_get_result")) break :blk "proof_get_result"; + if (std.mem.eql(u8, method, "proof_get_state")) break :blk "proof_get_state"; + if (std.mem.eql(u8, method, "proof_reset_session")) break :blk "proof_reset_session"; + if (std.mem.eql(u8, method, "proof_release_session")) break :blk "proof_release_session"; + if (std.mem.eql(u8, method, "proof_can_transition")) break :blk "proof_can_transition"; + return .{ .status = 404, .body = errJson(resp, "Unknown gRPC method") }; + }; + return dispatch(tool, body, resp); +} + +fn dispatchGraphql(body: []const u8, resp: []u8) Response { + if (std.mem.indexOf(u8, body, "__schema") != null) return .{ .status = 200, .body = okJson(resp, "schema not supported") }; + if (std.mem.indexOf(u8, body, "init_session") != null) return dispatch("proof_init_session", body, resp); + if (std.mem.indexOf(u8, body, "load_obligation") != null) return dispatch("proof_load_obligation", body, resp); + if (std.mem.indexOf(u8, body, "verify") != null) return dispatch("proof_verify", body, resp); + if (std.mem.indexOf(u8, body, "get_result") != null) return dispatch("proof_get_result", body, resp); + if (std.mem.indexOf(u8, body, "get_state") != null) return dispatch("proof_get_state", body, resp); + if (std.mem.indexOf(u8, body, "reset_session") != null) return dispatch("proof_reset_session", body, resp); + if (std.mem.indexOf(u8, body, "release_session") != null) return dispatch("proof_release_session", body, resp); + if (std.mem.indexOf(u8, body, "can_transition") != null) return dispatch("proof_can_transition", body, resp); + return .{ .status = 200, .body = errJson(resp, "Unrecognised GraphQL operation") }; +} + +const Protocol = enum { rest, grpc, graphql }; + +fn handleConnection(conn: std.net.Server.Connection, proto: Protocol) void { + defer conn.stream.close(); + var in_buf: [MAX_CONN_BUF]u8 = undefined; + const n = conn.stream.read(&in_buf) catch return; + const req = in_buf[0..n]; + var path: []const u8 = "/"; + var body: []const u8 = ""; + if (n > 4) { + const line_end = std.mem.indexOf(u8, req, "\r\n") orelse req.len; + const first_line = req[0..line_end]; + const sp1 = std.mem.indexOfScalar(u8, first_line, ' ') orelse 0; + const rest_of = first_line[sp1 + 1 ..]; + const sp2 = std.mem.indexOfScalar(u8, rest_of, ' ') orelse rest_of.len; + path = rest_of[0..sp2]; + const body_sep = std.mem.indexOf(u8, req, "\r\n\r\n") orelse n; + body = req[@min(body_sep + 4, n)..]; + } + var resp_buf: [MAX_CONN_BUF]u8 = undefined; + const result = switch (proto) { + .rest => dispatchRest(path, body, &resp_buf), + .grpc => dispatchGrpc(path, body, &resp_buf), + .graphql => dispatchGraphql(body, &resp_buf), + }; + const ct: []const u8 = if (proto == .grpc) "application/grpc+json" else "application/json"; + var hdr_buf: [256]u8 = undefined; + const hdr = std.fmt.bufPrint(&hdr_buf, "HTTP/1.1 {d} OK\r\nContent-Type: {s}\r\nContent-Length: {d}\r\nConnection: close\r\n\r\n", .{ result.status, ct, result.body.len }) catch return; + _ = conn.stream.writeAll(hdr) catch return; + _ = conn.stream.writeAll(result.body) catch return; +} + +fn listenLoop(port: u16, proto: Protocol) void { + const addr = std.net.Address.initIp4(.{ 127, 0, 0, 1 }, port) catch return; + var server = addr.listen(.{ .reuse_address = true }) catch return; + defer server.deinit(); + while (true) { const conn = server.accept() catch continue; handleConnection(conn, proto); } +} + +pub fn main() !void { + ffi.proof_init(); + const t1 = try std.Thread.spawn(.{}, listenLoop, .{ REST_PORT, Protocol.rest }); + const t2 = try std.Thread.spawn(.{}, listenLoop, .{ GRPC_PORT, Protocol.grpc }); + const t3 = try std.Thread.spawn(.{}, listenLoop, .{ GQL_PORT, Protocol.graphql }); + t1.join(); t2.join(); t3.join(); +} diff --git a/cartridges/domains/formal-verification/proof-mcp/cartridge.json b/cartridges/domains/formal-verification/proof-mcp/cartridge.json new file mode 100644 index 0000000..d917077 --- /dev/null +++ b/cartridges/domains/formal-verification/proof-mcp/cartridge.json @@ -0,0 +1,174 @@ +{ + "$schema": "https://boj.dev/schemas/cartridge/v1.json", + "spdx": "MPL-2.0", + "copyright": "Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath)", + "name": "proof-mcp", + "version": "0.1.0", + "description": "Proof verification lifecycle manager. Manages sessions across Lean, Coq, Agda, Isabelle, Idris2, Z3, CVC5. State machine: idle -> loading -> verifying -> verified/failed.", + "domain": "Formal Verification", + "tier": "Ayo", + "protocols": [ + "MCP", + "REST" + ], + "auth": { + "method": "none", + "env_var": null, + "credential_source": null + }, + "api": { + "base_url": "local://proof-mcp", + "content_type": "application/json" + }, + "tools": [ + { + "name": "proof_init_session", + "description": "Initialise a proof session for a backend. Returns a slot index.", + "inputSchema": { + "type": "object", + "properties": { + "backend": { + "type": "string", + "description": "Backend: lean / coq / agda / isabelle / idris2 / z3 / cvc5" + } + }, + "required": [ + "backend" + ] + } + }, + { + "name": "proof_load_obligation", + "description": "Load a proof obligation into an active session slot.", + "inputSchema": { + "type": "object", + "properties": { + "slot": { + "type": "integer", + "description": "Session slot" + }, + "backend": { + "type": "string", + "description": "Backend" + } + }, + "required": [ + "slot", + "backend" + ] + } + }, + { + "name": "proof_verify", + "description": "Start verification on a loaded session slot.", + "inputSchema": { + "type": "object", + "properties": { + "slot": { + "type": "integer", + "description": "Session slot" + } + }, + "required": [ + "slot" + ] + } + }, + { + "name": "proof_get_result", + "description": "Poll the verification result for a slot.", + "inputSchema": { + "type": "object", + "properties": { + "slot": { + "type": "integer", + "description": "Session slot" + } + }, + "required": [ + "slot" + ] + } + }, + { + "name": "proof_get_state", + "description": "Get current state: idle / loading / verifying / verified / failed.", + "inputSchema": { + "type": "object", + "properties": { + "slot": { + "type": "integer", + "description": "Session slot" + } + }, + "required": [ + "slot" + ] + } + }, + { + "name": "proof_reset_session", + "description": "Reset a session slot back to idle.", + "inputSchema": { + "type": "object", + "properties": { + "slot": { + "type": "integer", + "description": "Session slot" + } + }, + "required": [ + "slot" + ] + } + }, + { + "name": "proof_release_session", + "description": "Release a session slot for reuse.", + "inputSchema": { + "type": "object", + "properties": { + "slot": { + "type": "integer", + "description": "Session slot" + } + }, + "required": [ + "slot" + ] + } + }, + { + "name": "proof_can_transition", + "description": "Check whether a state machine transition is valid.", + "inputSchema": { + "type": "object", + "properties": { + "from": { + "type": "integer", + "description": "Source state (0=idle,1=loading,2=verifying,3=verified,4=failed)" + }, + "to": { + "type": "integer", + "description": "Target state" + } + }, + "required": [ + "from", + "to" + ] + } + } + ], + "ffi": { + "so_path": "ffi/zig-out/lib/libproof_mcp.so", + "abi_version": "ADR-0006", + "symbols": [ + "boj_cartridge_init", + "boj_cartridge_deinit", + "boj_cartridge_name", + "boj_cartridge_version", + "boj_cartridge_invoke" + ] + } +} diff --git a/cartridges/domains/formal-verification/proof-mcp/ffi/README.adoc b/cartridges/domains/formal-verification/proof-mcp/ffi/README.adoc new file mode 100644 index 0000000..aa7c05d --- /dev/null +++ b/cartridges/domains/formal-verification/proof-mcp/ffi/README.adoc @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += proof-mcp / ffi β€” Zig FFI layer +:orientation: shallow + +== Purpose + +C-ABI exports bridging this cartridge's Idris2 ABI to the runtime. Consumed by +the adapter for protocol dispatch and by the trunk loader for mount. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” produces `zig-out/lib/libproof.so`. +| `proof_ffi.zig` | C-ABI exports (15 exports, 8 inline tests, 318 lines). +|=== + +== Invariants + +* **Null-pointer rejection** on `[*c]const u8` arguments before any + dereference. +* **Negative return values** carry documented error meanings; zero is + success (matching the trunk error-code convention). +* **No allocator leaks** β€” every allocation path pairs with `defer free`. + +== Test/proof surface + +8 inline `test "..."` block(s) in `proof_ffi.zig`. Run with +`cd ffi && zig build test`. + +== Read-first + +. `proof_ffi.zig` β€” C-ABI signatures and guards. +. Trunk `ffi/zig/src/catalogue.zig` for how this `.so` is loaded. diff --git a/cartridges/domains/formal-verification/proof-mcp/ffi/build.zig b/cartridges/domains/formal-verification/proof-mcp/ffi/build.zig new file mode 100644 index 0000000..7c48949 --- /dev/null +++ b/cartridges/domains/formal-verification/proof-mcp/ffi/build.zig @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// Proof-MCP Cartridge β€” Zig FFI build configuration (Zig 0.15+). + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + // Shared ADR-0006 invoke-shim module (relative path up to boj-server trunk). + + const shim_mod = b.addModule("cartridge_shim", .{ + + .root_source_file = b.path("../../../ffi/zig/src/cartridge_shim.zig"), + + .target = target, + + .optimize = optimize, + + }); + + const proof_mod = b.addModule("proof_ffi", .{ + .root_source_file = b.path("proof_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + proof_mod.addImport("cartridge_shim", shim_mod); + + // ── Tests ──────────────────────────────────────────────────────── + const proof_tests = b.addTest(.{ + .root_module = proof_mod, + }); + + const run_tests = b.addRunArtifact(proof_tests); + + const test_step = b.step("test", "Run proof-mcp FFI tests"); + test_step.dependOn(&run_tests.step); + + // ── Shared library ────────────────────────────────────────────── + const lib_mod = b.createModule(.{ + .root_source_file = b.path("proof_ffi.zig"), + .target = target, + .optimize = optimize, + }); + lib_mod.addImport("cartridge_shim", shim_mod); + + const lib = b.addLibrary(.{ + .name = "proof_mcp", + .root_module = lib_mod, + .linkage = .dynamic, + }); + b.installArtifact(lib); + + const lib_step = b.step("lib", "Build shared library"); + lib_step.dependOn(&lib.step); +} diff --git a/cartridges/domains/formal-verification/proof-mcp/ffi/cartridge_shim.zig b/cartridges/domains/formal-verification/proof-mcp/ffi/cartridge_shim.zig new file mode 100644 index 0000000..0e399db --- /dev/null +++ b/cartridges/domains/formal-verification/proof-mcp/ffi/cartridge_shim.zig @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// cartridge_shim.zig β€” Shared helpers for the ADR-0006 five-symbol +// cartridge ABI (`boj_cartridge_init / deinit / name / version / invoke`). +// +// The shim centralises the seven-code return convention, NUL-argument +// guards, tool-name comparison, and the buffer-too-small path so each +// cartridge's `boj_cartridge_invoke` can stay short β€” typically a tool +// table plus `shim.writeResult(...)`. +// +// Cartridges import this file by relative path (no build-graph change +// needed). Example: +// +// const shim = @import("../../../ffi/zig/src/cartridge_shim.zig"); +// +// export fn boj_cartridge_invoke( +// tool_name: [*c]const u8, +// json_args: [*c]const u8, +// out_buf: [*c]u8, +// in_out_len: [*c]usize, +// ) callconv(.c) i32 { +// _ = json_args; +// if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; +// const body = if (shim.toolIs(tool_name, "foo")) "{\"result\":{}}" +// else return shim.RC_UNKNOWN_TOOL; +// return shim.writeResult(out_buf, in_out_len, body); +// } + +const std = @import("std"); + +// ── Return codes (ADR-0006 Β§Return codes) ──────────────────────────── +// +// Frozen by ADR-0006. New failure modes compose these via the error +// JSON body β€” the integer surface does not grow without a follow-up ADR. + +pub const RC_SUCCESS: i32 = 0; +pub const RC_UNKNOWN_TOOL: i32 = -1; +pub const RC_BAD_ARGS: i32 = -2; +pub const RC_BUFFER_TOO_SMALL: i32 = -3; +pub const RC_RUNTIME_ERROR: i32 = -4; +pub const RC_PANIC: i32 = -5; +pub const RC_AUTH_DENIED: i32 = -6; + +// ── Invoke-path helpers ────────────────────────────────────────────── + +/// True if any of the three mandatory `boj_cartridge_invoke` output-path +/// pointers is null. Use at the top of every invoke to short-circuit to +/// `RC_BAD_ARGS`. +pub fn invokeArgsNull( + tool_name: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) bool { + return tool_name == null or out_buf == null or in_out_len == null; +} + +/// Compare a C-NUL-terminated tool-name pointer against a Zig string +/// literal. Caller must have already verified `tool_name` is non-null +/// (usually via `invokeArgsNull`). +pub fn toolIs(tool_name: [*c]const u8, expected: []const u8) bool { + const s = std.mem.span(@as([*:0]const u8, @ptrCast(tool_name))); + return std.mem.eql(u8, s, expected); +} + +/// Copy `body` into `out_buf[0..*in_out_len]` (as a capacity) and update +/// `*in_out_len` to the number of bytes written. Returns `RC_SUCCESS`. +/// +/// If `body.len` exceeds the current capacity stored in `*in_out_len`, +/// sets `*in_out_len` to the required size and returns +/// `RC_BUFFER_TOO_SMALL` β€” the caller is then expected to re-allocate +/// and retry, per ADR-0006 Β§Memory ownership. +/// +/// Caller must have already verified that `out_buf` and `in_out_len` +/// are non-null. +pub fn writeResult( + out_buf: [*c]u8, + in_out_len: [*c]usize, + body: []const u8, +) i32 { + const cap = in_out_len.*; + if (body.len > cap) { + in_out_len.* = body.len; + return RC_BUFFER_TOO_SMALL; + } + @memcpy(out_buf[0..body.len], body); + in_out_len.* = body.len; + return RC_SUCCESS; +} + +// ── Tests ──────────────────────────────────────────────────────────── + +test "writeResult: body fits, writes and sets length" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); + try std.testing.expectEqualStrings("hello", buf[0..len]); +} + +test "writeResult: too small returns -3 and sets required length" { + var buf: [2]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_BUFFER_TOO_SMALL, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: exact-fit succeeds" { + var buf: [5]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: empty body" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, ""); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 0), len); +} + +test "toolIs: matches and rejects" { + const name: [*:0]const u8 = "foo"; + try std.testing.expect(toolIs(@ptrCast(name), "foo")); + try std.testing.expect(!toolIs(@ptrCast(name), "bar")); + try std.testing.expect(!toolIs(@ptrCast(name), "foobar")); + try std.testing.expect(!toolIs(@ptrCast(name), "fo")); +} + +test "invokeArgsNull: detects each null slot" { + var buf: [4]u8 = undefined; + var len: usize = 4; + const name: [*:0]const u8 = "x"; + try std.testing.expect(!invokeArgsNull(@ptrCast(name), &buf, &len)); + try std.testing.expect(invokeArgsNull(null, &buf, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), null, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), &buf, null)); +} diff --git a/cartridges/domains/formal-verification/proof-mcp/ffi/proof_ffi.zig b/cartridges/domains/formal-verification/proof-mcp/ffi/proof_ffi.zig new file mode 100644 index 0000000..a1800bb --- /dev/null +++ b/cartridges/domains/formal-verification/proof-mcp/ffi/proof_ffi.zig @@ -0,0 +1,396 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// Proof-MCP Cartridge β€” Zig FFI bridge for formal proof verification. +// +// Implements the verification state machine from SafeProof.idr. +// Ensures no verification can run without a loaded proof obligation, +// and no result can be retrieved from an incomplete verification. + +const std = @import("std"); + +// ═══════════════════════════════════════════════════════════════════════ +// Types (must match ProofMcp.SafeProof encodings) +// ═══════════════════════════════════════════════════════════════════════ + +pub const ProofState = enum(c_int) { + idle = 0, + loading = 1, + verifying = 2, + verified = 3, + failed = 4, +}; + +pub const ProofBackend = enum(c_int) { + z3 = 1, + cvc5 = 2, + lean = 3, + coq = 4, + agda = 5, + isabelle = 6, + idris2 = 7, + custom = 99, +}; + +// ═══════════════════════════════════════════════════════════════════════ +// Proof Session State Machine +// ═══════════════════════════════════════════════════════════════════════ + +const MAX_SESSIONS: usize = 8; + +const SessionSlot = struct { + active: bool, + state: ProofState, + backend: ProofBackend, +}; + +var sessions: [MAX_SESSIONS]SessionSlot = [_]SessionSlot{.{ + .active = false, + .state = .idle, + .backend = .z3, +}} ** MAX_SESSIONS; + +var mutex: std.Thread.Mutex = .{}; + +/// Validate a state transition (matches Idris2 canTransition). +fn isValidTransition(from: ProofState, to: ProofState) bool { + return switch (from) { + .idle => to == .loading, + .loading => to == .verifying or to == .idle, + .verifying => to == .verified or to == .failed, + .verified => to == .idle, + .failed => to == .idle, + }; +} + +/// Initialise a new proof session. Returns slot index or -1 on failure. +pub export fn proof_init(backend: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + for (&sessions, 0..) |*slot, i| { + if (!slot.active) { + slot.active = true; + slot.state = .idle; + slot.backend = @enumFromInt(backend); + return @intCast(i); + } + } + return -1; // No slots available +} + +/// Load a proof obligation (transition Idle -> Loading). +pub export fn proof_load(slot_idx: c_int, backend: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + if (slot_idx < 0 or slot_idx >= MAX_SESSIONS) return -1; + const idx: usize = @intCast(slot_idx); + if (!sessions[idx].active) return -1; + if (!isValidTransition(sessions[idx].state, .loading)) return -2; + + sessions[idx].state = .loading; + sessions[idx].backend = @enumFromInt(backend); + return 0; +} + +/// Start verification (transition Loading -> Verifying). +pub export fn proof_verify(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + if (slot_idx < 0 or slot_idx >= MAX_SESSIONS) return -1; + const idx: usize = @intCast(slot_idx); + if (!sessions[idx].active) return -1; + if (!isValidTransition(sessions[idx].state, .verifying)) return -2; + + sessions[idx].state = .verifying; + return 0; +} + +/// Mark verification as successful (transition Verifying -> Verified). +pub export fn proof_succeed(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + if (slot_idx < 0 or slot_idx >= MAX_SESSIONS) return -1; + const idx: usize = @intCast(slot_idx); + if (!sessions[idx].active) return -1; + if (!isValidTransition(sessions[idx].state, .verified)) return -2; + + sessions[idx].state = .verified; + return 0; +} + +/// Mark verification as failed (transition Verifying -> Failed). +pub export fn proof_fail(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + if (slot_idx < 0 or slot_idx >= MAX_SESSIONS) return -1; + const idx: usize = @intCast(slot_idx); + if (!sessions[idx].active) return -1; + if (!isValidTransition(sessions[idx].state, .failed)) return -2; + + sessions[idx].state = .failed; + return 0; +} + +/// Get the result of a completed verification. Returns state or -1 on error. +pub export fn proof_get_result(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + if (slot_idx < 0 or slot_idx >= MAX_SESSIONS) return -1; + const idx: usize = @intCast(slot_idx); + if (!sessions[idx].active) return -1; + return @intFromEnum(sessions[idx].state); +} + +/// Reset a session to idle (from Verified, Failed, or Loading). +pub export fn proof_reset(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + if (slot_idx < 0 or slot_idx >= MAX_SESSIONS) return -1; + const idx: usize = @intCast(slot_idx); + if (!sessions[idx].active) return -1; + if (!isValidTransition(sessions[idx].state, .idle)) return -2; + + sessions[idx].state = .idle; + return 0; +} + +/// Get the state of a session. +pub export fn proof_state(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + if (slot_idx < 0 or slot_idx >= MAX_SESSIONS) return -1; + const idx: usize = @intCast(slot_idx); + if (!sessions[idx].active) return @intFromEnum(ProofState.idle); + return @intFromEnum(sessions[idx].state); +} + +/// Validate a state transition (C-ABI export). +pub export fn proof_can_transition(from: c_int, to: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + const f: ProofState = @enumFromInt(from); + const t: ProofState = @enumFromInt(to); + return if (isValidTransition(f, t)) 1 else 0; +} + +/// Release a session slot entirely. +pub export fn proof_release(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + if (slot_idx < 0 or slot_idx >= MAX_SESSIONS) return -1; + const idx: usize = @intCast(slot_idx); + if (!sessions[idx].active) return -1; + + sessions[idx].active = false; + sessions[idx].state = .idle; + return 0; +} + +/// Reset all sessions (for testing). +pub export fn proof_reset_all() void { + mutex.lock(); + defer mutex.unlock(); + for (&sessions) |*slot| { + slot.active = false; + slot.state = .idle; + } +} + +// ═══════════════════════════════════════════════════════════════════════ +// Standard Cartridge Interface (loader expects these 4 C-ABI symbols) +// ═══════════════════════════════════════════════════════════════════════ + +/// Initialise the proof-mcp cartridge. Resets all session slots. +pub export fn boj_cartridge_init() c_int { + proof_reset_all(); + return 0; +} + +/// Deinitialise the proof-mcp cartridge. Resets all session slots. +pub export fn boj_cartridge_deinit() void { + proof_reset_all(); +} + +/// Return the cartridge name as a null-terminated C string. +pub export fn boj_cartridge_name() [*:0]const u8 { + mutex.lock(); + defer mutex.unlock(); + return "proof-mcp"; +} + +/// Return the cartridge version as a null-terminated C string. +pub export fn boj_cartridge_version() [*:0]const u8 { + mutex.lock(); + defer mutex.unlock(); + return "0.1.0"; +} + +// ═══════════════════════════════════════════════════════════════════════ +// ADR-0006 dispatch (boj_cartridge_invoke, 5th standard symbol) +// ═══════════════════════════════════════════════════════════════════════ + +const shim = @import("cartridge_shim.zig"); + +/// Dispatch the cartridge.json MCP tools. Grade D Alpha β€” each arm +/// returns a stub JSON body shaped to the tool's intended response. +export fn boj_cartridge_invoke( + tool_name: [*c]const u8, + json_args: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) callconv(.c) i32 { + _ = json_args; + if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; + + const body: []const u8 = if (shim.toolIs(tool_name, "proof_init_session")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "proof_load_obligation")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "proof_verify")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "proof_get_result")) + "{\"result\":{\"metadata\":{},\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "proof_get_state")) + "{\"result\":{\"metadata\":{},\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "proof_reset_session")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "proof_release_session")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "proof_can_transition")) + "{\"result\":{\"status\":\"stub\"}}" +else + return shim.RC_UNKNOWN_TOOL; + + return shim.writeResult(out_buf, in_out_len, body); +} + +// ═══════════════════════════════════════════════════════════════════════ +// Tests +// ═══════════════════════════════════════════════════════════════════════ + +test "init and release session" { + proof_reset_all(); + const slot = proof_init(@intFromEnum(ProofBackend.z3)); + try std.testing.expect(slot >= 0); + try std.testing.expectEqual(@as(c_int, @intFromEnum(ProofState.idle)), proof_state(slot)); + try std.testing.expectEqual(@as(c_int, 0), proof_release(slot)); +} + +test "full verification lifecycle" { + proof_reset_all(); + const slot = proof_init(@intFromEnum(ProofBackend.lean)); + try std.testing.expectEqual(@as(c_int, 0), proof_load(slot, @intFromEnum(ProofBackend.lean))); + try std.testing.expectEqual(@as(c_int, @intFromEnum(ProofState.loading)), proof_state(slot)); + try std.testing.expectEqual(@as(c_int, 0), proof_verify(slot)); + try std.testing.expectEqual(@as(c_int, @intFromEnum(ProofState.verifying)), proof_state(slot)); + try std.testing.expectEqual(@as(c_int, 0), proof_succeed(slot)); + try std.testing.expectEqual(@as(c_int, @intFromEnum(ProofState.verified)), proof_state(slot)); + try std.testing.expectEqual(@as(c_int, 0), proof_reset(slot)); + try std.testing.expectEqual(@as(c_int, @intFromEnum(ProofState.idle)), proof_state(slot)); +} + +test "cannot verify without loading" { + proof_reset_all(); + const slot = proof_init(@intFromEnum(ProofBackend.coq)); + // Should fail β€” can't verify from idle state + try std.testing.expectEqual(@as(c_int, -2), proof_verify(slot)); +} + +test "cannot get result during verification" { + proof_reset_all(); + const slot = proof_init(@intFromEnum(ProofBackend.agda)); + _ = proof_load(slot, @intFromEnum(ProofBackend.agda)); + _ = proof_verify(slot); + // State should be verifying (not verified or failed) + try std.testing.expectEqual(@as(c_int, @intFromEnum(ProofState.verifying)), proof_get_result(slot)); +} + +test "failed verification lifecycle" { + proof_reset_all(); + const slot = proof_init(@intFromEnum(ProofBackend.isabelle)); + _ = proof_load(slot, @intFromEnum(ProofBackend.isabelle)); + _ = proof_verify(slot); + try std.testing.expectEqual(@as(c_int, 0), proof_fail(slot)); + try std.testing.expectEqual(@as(c_int, @intFromEnum(ProofState.failed)), proof_state(slot)); + // Can reset from failed + try std.testing.expectEqual(@as(c_int, 0), proof_reset(slot)); + try std.testing.expectEqual(@as(c_int, @intFromEnum(ProofState.idle)), proof_state(slot)); +} + +test "cancel load returns to idle" { + proof_reset_all(); + const slot = proof_init(@intFromEnum(ProofBackend.cvc5)); + _ = proof_load(slot, @intFromEnum(ProofBackend.cvc5)); + try std.testing.expectEqual(@as(c_int, @intFromEnum(ProofState.loading)), proof_state(slot)); + // Cancel load (Loading -> Idle) + try std.testing.expectEqual(@as(c_int, 0), proof_reset(slot)); + try std.testing.expectEqual(@as(c_int, @intFromEnum(ProofState.idle)), proof_state(slot)); +} + +test "state transition validation" { + // Valid transitions + try std.testing.expectEqual(@as(c_int, 1), proof_can_transition(0, 1)); // idle -> loading + try std.testing.expectEqual(@as(c_int, 1), proof_can_transition(1, 2)); // loading -> verifying + try std.testing.expectEqual(@as(c_int, 1), proof_can_transition(2, 3)); // verifying -> verified + try std.testing.expectEqual(@as(c_int, 1), proof_can_transition(2, 4)); // verifying -> failed + try std.testing.expectEqual(@as(c_int, 1), proof_can_transition(3, 0)); // verified -> idle + try std.testing.expectEqual(@as(c_int, 1), proof_can_transition(4, 0)); // failed -> idle + try std.testing.expectEqual(@as(c_int, 1), proof_can_transition(1, 0)); // loading -> idle (cancel) + // Invalid transitions + try std.testing.expectEqual(@as(c_int, 0), proof_can_transition(0, 2)); // idle -> verifying + try std.testing.expectEqual(@as(c_int, 0), proof_can_transition(0, 3)); // idle -> verified + try std.testing.expectEqual(@as(c_int, 0), proof_can_transition(3, 1)); // verified -> loading +} + +test "max sessions enforced" { + proof_reset_all(); + var slots: [MAX_SESSIONS]c_int = undefined; + for (&slots) |*s| { + s.* = proof_init(@intFromEnum(ProofBackend.idris2)); + try std.testing.expect(s.* >= 0); + } + // Next init should fail + try std.testing.expectEqual(@as(c_int, -1), proof_init(@intFromEnum(ProofBackend.idris2))); + // Free one and retry + _ = proof_release(slots[0]); + try std.testing.expect(proof_init(@intFromEnum(ProofBackend.idris2)) >= 0); +} + +// ═══════════════════════════════════════════════════════════════════════ +// ADR-0006 invoke dispatch tests +// ═══════════════════════════════════════════════════════════════════════ + +test "invoke: each declared tool succeeds" { + var buf: [256]u8 = undefined; + const tools = [_][]const u8{ + "proof_init_session", + "proof_load_obligation", + "proof_verify", + "proof_get_result", + "proof_get_state", + "proof_reset_session", + "proof_release_session", + "proof_can_transition", + }; + for (tools) |t| { + var len: usize = buf.len; + const rc = boj_cartridge_invoke(t.ptr, "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, 0), rc); + try std.testing.expect(std.mem.indexOf(u8, buf[0..len], "result") != null); + } +} + +test "invoke: unknown tool returns -1" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("nope", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -1), rc); +} + +test "invoke: buffer too small returns -3" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("proof_init_session", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -3), rc); + try std.testing.expect(len > 4); +} diff --git a/cartridges/domains/formal-verification/proof-mcp/mod.js b/cartridges/domains/formal-verification/proof-mcp/mod.js new file mode 100644 index 0000000..0ee4a9d --- /dev/null +++ b/cartridges/domains/formal-verification/proof-mcp/mod.js @@ -0,0 +1,84 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// proof-mcp/mod.js -- proof gateway + +const BASE_URL = Deno.env.get("PROOF_BACKEND_URL") ?? "http://127.0.0.1:7707"; + +async function post(path, payload) { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), 15000); + try { + const r = await fetch(`${BASE_URL}${path}`, { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), signal: ctrl.signal, + }); + const data = await r.json().catch(() => ({ success: false, error: "non-JSON response" })); + return { status: r.status, data }; + } catch (e) { + if (e.name === "AbortError") return { status: 504, data: { success: false, error: "proof-mcp backend timed out" } }; + return { status: 503, data: { success: false, error: `proof-mcp backend unavailable: ${e.message}` } }; + } finally { clearTimeout(t); } +} + +export async function handleTool(toolName, args) { + switch (toolName) { + case "proof_init_session": { + const { backend } = args ?? {}; + if (!backend) return { status: 400, data: { error: "backend is required" } }; + const payload = { backend }; + return post("/api/v1/init-session", payload); + } + + case "proof_load_obligation": { + const { slot, backend } = args ?? {}; + if (!slot || !backend) return { status: 400, data: { error: "slot is required" } }; + const payload = { slot, backend }; + return post("/api/v1/load-obligation", payload); + } + + case "proof_verify": { + const { slot } = args ?? {}; + if (!slot) return { status: 400, data: { error: "slot is required" } }; + const payload = { slot }; + return post("/api/v1/verify", payload); + } + + case "proof_get_result": { + const { slot } = args ?? {}; + if (!slot) return { status: 400, data: { error: "slot is required" } }; + const payload = { slot }; + return post("/api/v1/get-result", payload); + } + + case "proof_get_state": { + const { slot } = args ?? {}; + if (!slot) return { status: 400, data: { error: "slot is required" } }; + const payload = { slot }; + return post("/api/v1/get-state", payload); + } + + case "proof_reset_session": { + const { slot } = args ?? {}; + if (!slot) return { status: 400, data: { error: "slot is required" } }; + const payload = { slot }; + return post("/api/v1/reset-session", payload); + } + + case "proof_release_session": { + const { slot } = args ?? {}; + if (!slot) return { status: 400, data: { error: "slot is required" } }; + const payload = { slot }; + return post("/api/v1/release-session", payload); + } + + case "proof_can_transition": { + const { from, to } = args ?? {}; + if (!from || !to) return { status: 400, data: { error: "from is required" } }; + const payload = { from, to }; + return post("/api/v1/can-transition", payload); + } + default: + return { status: 404, data: { error: `Unknown tool: ${toolName}` } }; + } +} diff --git a/cartridges/domains/formal-verification/proof-mcp/panels/manifest.json b/cartridges/domains/formal-verification/proof-mcp/panels/manifest.json new file mode 100644 index 0000000..18122e3 --- /dev/null +++ b/cartridges/domains/formal-verification/proof-mcp/panels/manifest.json @@ -0,0 +1,68 @@ +{ + "$schema": "https://panll.dev/schemas/panel-manifest/v1.json", + "spdx": "MPL-2.0", + "cartridge": "proof-mcp", + "domain": "Formal Verification", + "version": "0.1.0", + "panels": [ + { + "id": "proof-status", + "title": "Proof Engine Status", + "description": "Idris2 type-checker and proof assistant readiness", + "type": "status-indicator", + "data_source": { + "endpoint": "/cartridge/proof-mcp/invoke", + "method": "POST", + "body": { "tool": "status" }, + "refresh_interval_ms": 5000 + }, + "widgets": [ + { + "type": "state-badge", + "field": "state", + "states": { + "ready": { "color": "#2ecc71", "icon": "shield-check" }, + "degraded": { "color": "#f39c12", "icon": "shield" }, + "error": { "color": "#e74c3c", "icon": "alert-triangle" } + } + }, + { "type": "text", "field": "version", "label": "Version" } + ] + }, + { + "id": "proof-verification-stats", + "title": "Verification Statistics", + "description": "Proof obligations checked, passing, and failing across the codebase", + "type": "metric", + "data_source": { + "endpoint": "/cartridge/proof-mcp/invoke", + "method": "POST", + "body": { "tool": "verification_stats" }, + "refresh_interval_ms": 15000 + }, + "widgets": [ + { "type": "counter", "field": "total_obligations", "label": "Obligations", "icon": "check-square" }, + { "type": "counter", "field": "proofs_verified", "label": "Verified", "icon": "check-circle" }, + { "type": "counter", "field": "proofs_failing", "label": "Failing", "icon": "x-circle" }, + { "type": "gauge", "field": "coverage_percent", "label": "Proof Coverage %", "min": 0, "max": 100 } + ] + }, + { + "id": "proof-abi-integrity", + "title": "ABI Integrity", + "description": "Idris2 ABI proof status for loaded cartridge shared libraries", + "type": "metric", + "data_source": { + "endpoint": "/cartridge/proof-mcp/invoke", + "method": "POST", + "body": { "tool": "abi_integrity" }, + "refresh_interval_ms": 30000 + }, + "widgets": [ + { "type": "counter", "field": "cartridges_verified", "label": "Cartridges Verified", "icon": "shield-check" }, + { "type": "counter", "field": "cartridges_unverified", "label": "Unverified", "icon": "shield-off" }, + { "type": "text", "field": "last_verification", "label": "Last Check" } + ] + } + ] +} diff --git a/cartridges/domains/gaming/game-admin-mcp/README.adoc b/cartridges/domains/gaming/game-admin-mcp/README.adoc new file mode 100644 index 0000000..4a23591 --- /dev/null +++ b/cartridges/domains/gaming/game-admin-mcp/README.adoc @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += game-admin-mcp +Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk> +:spdx: MPL-2.0 +:tier: Ayo +:domain: gaming +:protocols: MCP, REST + +== Overview + +Game server administration and configuration drift + +== Tools (7) + +[cols="2,4"] +|=== +| Tool | Description + +| `game_probe_server` | Probe a game server for status +| `game_list_servers` | List known game servers +| `game_get_config` | Get game server configuration +| `game_set_config` | Set game server configuration +| `game_server_action` | Execute a server action +| `game_drift_status` | Check configuration drift across servers +| `game_list_profiles` | List server configuration profiles +|=== + +== Architecture + +Three-layer stack per the BoJ cartridge standard: + +[cols="1,1,3"] +|=== +| Layer | Language | Purpose + +| ABI | Idris2 | Formally verified state machine with dependent-type proofs +| FFI | Zig | C-ABI implementation: session pool, action metrics, thread safety +| Adapter| Zig | Adapter bridge to the BoJ unified adapter protocol (zig predecessors sidelined) +|=== + +== Building + +[source,bash] +---- +# ABI (Idris2) +cd abi && idris2 --check *.idr + +# FFI (Zig) +cd ffi && zig build +cd ffi && zig build test + +# Adapter (Zig) +cd adapter && zig build +---- + +== Status + +Alpha (CRG D). Cartridge metadata declared in `cartridge.json`; 7 tool(s) exposed. +Not yet dogfooded externally. See `docs/READINESS.md` at repo root for the +project-wide grade table and `docs/practice/DOGFOOD-LOG.adoc` for usage evidence. diff --git a/cartridges/domains/gaming/game-admin-mcp/abi/GameAdmin/Protocol.idr b/cartridges/domains/gaming/game-admin-mcp/abi/GameAdmin/Protocol.idr new file mode 100644 index 0000000..3280962 --- /dev/null +++ b/cartridges/domains/gaming/game-admin-mcp/abi/GameAdmin/Protocol.idr @@ -0,0 +1,81 @@ +-- SPDX-License-Identifier: MPL-2.0 +-- Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +||| Protocol: Game Server Administration via BoJ MCP +||| +||| Cartridge: game-admin +||| Matrix cell: Game Servers x Administration +||| +||| Defines the formal interface for managing dedicated game servers +||| (DST, Void Expanse, etc.) through the BoJ Server. Proves that: +||| 1. Server lifecycle operations are admin-only +||| 2. Probe operations are safe (read-only) +||| 3. Config changes require explicit confirmation +module GameAdmin.Protocol + +import Data.Fin + +%default total + +-- ═══════════════════════════════════════════════════════════════════════ +-- Core Types +-- ═══════════════════════════════════════════════════════════════════════ + +||| Administrative operation on a game server +public export +data Operation + = ListServers -- List managed game servers + | GetServerStatus -- Get server health/status + | StartServer -- Start a game server + | StopServer -- Stop a game server + | RestartServer -- Restart a game server + | UpdateConfig -- Modify server configuration + | GetLogs -- Retrieve server logs + | ProbeHealth -- Quick health probe (Groove) + +||| Permission level for game admin ops +public export +data PermLevel = Viewer | Operator | Admin + +||| Operations that are read-only (safe to call without side effects) +public export +data IsReadOnly : Operation -> Type where + ListReadOnly : IsReadOnly ListServers + StatusReadOnly : IsReadOnly GetServerStatus + LogsReadOnly : IsReadOnly GetLogs + ProbeReadOnly : IsReadOnly ProbeHealth + +-- ═══════════════════════════════════════════════════════════════════════ +-- C ABI Exports +-- ═══════════════════════════════════════════════════════════════════════ + +export +operationToInt : Operation -> Int +operationToInt ListServers = 0 +operationToInt GetServerStatus = 1 +operationToInt StartServer = 2 +operationToInt StopServer = 3 +operationToInt RestartServer = 4 +operationToInt UpdateConfig = 5 +operationToInt GetLogs = 6 +operationToInt ProbeHealth = 7 + +export +game_min_perm : Int -> Int +game_min_perm 0 = 0 -- ListServers β†’ Viewer +game_min_perm 1 = 0 -- GetServerStatus β†’ Viewer +game_min_perm 2 = 1 -- StartServer β†’ Operator +game_min_perm 3 = 1 -- StopServer β†’ Operator +game_min_perm 4 = 1 -- RestartServer β†’ Operator +game_min_perm 5 = 2 -- UpdateConfig β†’ Admin +game_min_perm 6 = 0 -- GetLogs β†’ Viewer +game_min_perm 7 = 0 -- ProbeHealth β†’ Viewer +game_min_perm _ = 2 -- Unknown β†’ require Admin + +||| Check if operation is read-only (C ABI: 1=yes, 0=no) +export +game_is_readonly : Int -> Int +game_is_readonly 0 = 1 -- ListServers +game_is_readonly 1 = 1 -- GetServerStatus +game_is_readonly 6 = 1 -- GetLogs +game_is_readonly 7 = 1 -- ProbeHealth +game_is_readonly _ = 0 -- All others have side effects diff --git a/cartridges/domains/gaming/game-admin-mcp/abi/README.adoc b/cartridges/domains/gaming/game-admin-mcp/abi/README.adoc new file mode 100644 index 0000000..f6bea9f --- /dev/null +++ b/cartridges/domains/gaming/game-admin-mcp/abi/README.adoc @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += game-admin-mcp / abi β€” Idris2 ABI layer +:orientation: shallow + +== Purpose + +Cartridge-local protocol types for `game-admin-mcp`. Defines the data, tool, and +transition vocabulary this cartridge speaks. Inherits safety lemmas and the +matrix-cell identifier scheme from the trunk ABI at `src/abi/`. + +== Files + +1 Idris2 module(s), ~81 lines total. Lead module: +`Protocol.idr`. + +No `.ipkg` β€” consumed directly by wider BoJ callers; type-checks inside the +trunk `boj.ipkg`. + +== Invariants + +* `%default total` at every module top. +* Zero `believe_me`, `assert_total`, `postulate`, `Admitted`, or + `sorry` (the trunk holds the four declared `believe_me` sites; leaf + cartridges carry none). + +== Test/proof surface + +Thin module(s), no inline tests. Proof surface = type-check success under the +trunk `idris2 --check`. + +== Read-first + +. `Protocol.idr` β€” cartridge-local data records and enums. +. Trunk `src/abi/Boj/Catalogue.idr` for how this cell participates in the + 2D matrix. diff --git a/cartridges/domains/gaming/game-admin-mcp/abi/game-admin.ipkg b/cartridges/domains/gaming/game-admin-mcp/abi/game-admin.ipkg new file mode 100644 index 0000000..6d5849b --- /dev/null +++ b/cartridges/domains/gaming/game-admin-mcp/abi/game-admin.ipkg @@ -0,0 +1,6 @@ +-- SPDX-License-Identifier: MPL-2.0 +package gameAdmin + +modules = GameAdmin.Protocol + +sourcedir = "." diff --git a/cartridges/domains/gaming/game-admin-mcp/adapter/README.adoc b/cartridges/domains/gaming/game-admin-mcp/adapter/README.adoc new file mode 100644 index 0000000..5d50fc6 --- /dev/null +++ b/cartridges/domains/gaming/game-admin-mcp/adapter/README.adoc @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += game-admin-mcp / adapter β€” protocol bridge +:orientation: shallow + +== Purpose + +Exposes this cartridge's FFI over the wire protocols declared in +`cartridge.json`. Dispatches tool invocations to the C-ABI exports at +`../ffi/` and formats responses per protocol. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” depends on the sibling `ffi` target. +| `game_admin_adapter.zig` | Protocol dispatch (144 lines). Tool catalogue matches `cartridge.json`. +| `SIDELINED-*.v.adoc` | zig predecessor(s). Not built β€” zig banned 2026-04-10. +|=== + +== Invariants + +* **Stateless** β€” all state lives behind the FFI, never in the adapter. +* **Response passthrough** β€” whatever the FFI returns goes back to the wire + unmodified (no embellishment, no silent recovery). +* **Cartridge.json is source of truth** for the tool catalogue; drift between + adapter and manifest is a CI failure. + +== Test/proof surface + +No inline tests at the adapter layer β€” protocol routing is exercised by the +trunk seam tests at `ffi/zig/src/seams.zig`. + +== Read-first + +. `game_admin_adapter.zig` β€” dispatch function and per-protocol routers. +. `../cartridge.json` β€” the tool manifest the dispatcher mirrors. diff --git a/cartridges/domains/gaming/game-admin-mcp/adapter/build.zig b/cartridges/domains/gaming/game-admin-mcp/adapter/build.zig new file mode 100644 index 0000000..4663004 --- /dev/null +++ b/cartridges/domains/gaming/game-admin-mcp/adapter/build.zig @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// game-admin-mcp/adapter/build.zig + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const ffi_mod = b.createModule(.{ + .root_source_file = b.path("../ffi/game_admin_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + const adapter = b.addExecutable(.{ + .name = "game_admin_adapter", + .root_source_file = b.path("game_admin_adapter.zig"), + .target = target, + .optimize = optimize, + }); + adapter.root_module.addImport("game_admin_ffi", ffi_mod); + b.installArtifact(adapter); +} diff --git a/cartridges/domains/gaming/game-admin-mcp/adapter/game_admin_adapter.zig b/cartridges/domains/gaming/game-admin-mcp/adapter/game_admin_adapter.zig new file mode 100644 index 0000000..78862a9 --- /dev/null +++ b/cartridges/domains/gaming/game-admin-mcp/adapter/game_admin_adapter.zig @@ -0,0 +1,144 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// game-admin-mcp/adapter/game_admin_adapter.zig +// +// Three-protocol BoJ adapter: REST (port 9238), gRPC-compat (port 9239), +// GraphQL (port 9240). +// Replaces the banned zig adapter (game_admin_adapter.v). + +const std = @import("std"); +const ffi = @import("game_admin_ffi"); + +const REST_PORT: u16 = 9238; +const GRPC_PORT: u16 = 9239; +const GQL_PORT: u16 = 9240; + +const Response = struct { status: u16, body: []const u8 }; + +fn okJson(buf: []u8, msg: []const u8) []u8 { + return std.fmt.bufPrint(buf, "{{\"success\":true,\"message\":\"{s}\"}}", .{msg}) catch buf[0..0]; +} + +fn errJson(buf: []u8, msg: []const u8) []u8 { + return std.fmt.bufPrint(buf, "{{\"success\":false,\"error\":\"{s}\"}}", .{msg}) catch buf[0..0]; +} + +fn dispatch(tool: []const u8, _body: []const u8, resp: []u8) Response { + _ = _body; + if (std.mem.eql(u8, tool, "game_probe_server")) { + return .{ .status = 200, .body = okJson(resp, "game_probe_server forwarded") }; + } + if (std.mem.eql(u8, tool, "game_list_servers")) { + return .{ .status = 200, .body = okJson(resp, "game_list_servers forwarded") }; + } + if (std.mem.eql(u8, tool, "game_get_config")) { + return .{ .status = 200, .body = okJson(resp, "game_get_config forwarded") }; + } + if (std.mem.eql(u8, tool, "game_set_config")) { + return .{ .status = 200, .body = okJson(resp, "game_set_config forwarded") }; + } + if (std.mem.eql(u8, tool, "game_server_action")) { + return .{ .status = 200, .body = okJson(resp, "game_server_action forwarded") }; + } + if (std.mem.eql(u8, tool, "game_drift_status")) { + return .{ .status = 200, .body = okJson(resp, "game_drift_status forwarded") }; + } + if (std.mem.eql(u8, tool, "game_list_profiles")) { + return .{ .status = 200, .body = okJson(resp, "game_list_profiles forwarded") }; + } + return .{ .status = 404, .body = errJson(resp, "unknown tool") }; +} + +fn dispatchRest(path: []const u8, body: []const u8, resp: []u8) Response { + // Expect /tools/<tool_name> + const prefix = "/tools/"; + if (std.mem.startsWith(u8, path, prefix)) { + const tool = path[prefix.len..]; + return dispatch(tool, body, resp); + } + return .{ .status = 404, .body = errJson(resp, "not found") }; +} + +fn dispatchGrpc(path: []const u8, body: []const u8, resp: []u8) Response { + // Expect /<Service>/<Method> β€” derive tool from Method + var it = std.mem.splitScalar(u8, path, '/'); + _ = it.next(); // leading empty + _ = it.next(); // service + const method = it.next() orelse return .{ .status = 404, .body = errJson(resp, "bad gRPC path") }; + return dispatch(method, body, resp); +} + +fn dispatchGraphql(body: []const u8, resp: []u8) Response { + if (std.mem.indexOf(u8, body, "game_probe_server") != null) + return dispatch("game_probe_server", body, resp); + if (std.mem.indexOf(u8, body, "game_list_servers") != null) + return dispatch("game_list_servers", body, resp); + if (std.mem.indexOf(u8, body, "game_get_config") != null) + return dispatch("game_get_config", body, resp); + if (std.mem.indexOf(u8, body, "game_set_config") != null) + return dispatch("game_set_config", body, resp); + if (std.mem.indexOf(u8, body, "game_server_action") != null) + return dispatch("game_server_action", body, resp); + if (std.mem.indexOf(u8, body, "game_drift_status") != null) + return dispatch("game_drift_status", body, resp); + if (std.mem.indexOf(u8, body, "game_list_profiles") != null) + return dispatch("game_list_profiles", body, resp); + return .{ .status = 400, .body = errJson(resp, "unrecognised GraphQL operation") }; +} + +fn handleConnection(stream: std.net.Stream, port: u16) void { + defer stream.close(); + var buf: [4096]u8 = undefined; + var resp_buf: [4096]u8 = undefined; + const n = stream.read(&buf) catch return; + const req = buf[0..n]; + const result = switch (port) { + REST_PORT => blk: { + // Parse HTTP/1.1: first line = METHOD PATH HTTP/x.y + var lines = std.mem.splitScalar(u8, req, '\n'); + const first = lines.next() orelse break :blk Response{ .status = 400, .body = "" }; + var parts = std.mem.splitScalar(u8, std.mem.trim(u8, first, "\r"), ' '); + _ = parts.next(); // method + const path = parts.next() orelse break :blk Response{ .status = 400, .body = "" }; + break :blk dispatchRest(path, req, &resp_buf); + }, + GRPC_PORT => blk: { + var lines = std.mem.splitScalar(u8, req, '\n'); + const first = lines.next() orelse break :blk Response{ .status = 400, .body = "" }; + var parts = std.mem.splitScalar(u8, std.mem.trim(u8, first, "\r"), ' '); + _ = parts.next(); + const path = parts.next() orelse break :blk Response{ .status = 400, .body = "" }; + break :blk dispatchGrpc(path, req, &resp_buf); + }, + GQL_PORT => dispatchGraphql(req, &resp_buf), + else => Response{ .status = 500, .body = "" }, + }; + var http_resp: [512]u8 = undefined; + const http = std.fmt.bufPrint(&http_resp, + "HTTP/1.1 {d} OK\r\nContent-Length: {d}\r\nContent-Type: application/json\r\n\r\n", + .{ result.status, result.body.len }) catch return; + _ = stream.write(http) catch {}; + _ = stream.write(result.body) catch {}; +} + +fn listenLoop(port: u16) !void { + const addr = std.net.Address.initIp4(.{ 127, 0, 0, 1 }, port); + var server = try addr.listen(.{ .reuse_address = true }); + defer server.deinit(); + while (true) { + const conn = try server.accept(); + const t = try std.Thread.spawn(.{}, handleConnection, .{ conn.stream, port }); + t.detach(); + } +} + +pub fn main() !void { + ffi.game_admin_init(); + const t1 = try std.Thread.spawn(.{}, listenLoop, .{REST_PORT}); + const t2 = try std.Thread.spawn(.{}, listenLoop, .{GRPC_PORT}); + const t3 = try std.Thread.spawn(.{}, listenLoop, .{GQL_PORT}); + t1.join(); + t2.join(); + t3.join(); +} diff --git a/cartridges/domains/gaming/game-admin-mcp/cartridge.json b/cartridges/domains/gaming/game-admin-mcp/cartridge.json new file mode 100644 index 0000000..870d67e --- /dev/null +++ b/cartridges/domains/gaming/game-admin-mcp/cartridge.json @@ -0,0 +1,138 @@ +{ + "$schema": "https://boj.dev/schemas/cartridge/v1.json", + "spdx": "MPL-2.0", + "copyright": "Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk>", + "name": "game-admin-mcp", + "version": "0.1.0", + "description": "Game server administration and configuration drift", + "domain": "gaming", + "tier": "Ayo", + "protocols": [ + "MCP", + "REST" + ], + "auth": { + "method": "none", + "env_var": null, + "credential_source": null + }, + "api": { + "base_url": "local://game-admin-mcp", + "content_type": "application/json" + }, + "tools": [ + { + "name": "game_probe_server", + "description": "Probe a game server for status", + "inputSchema": { + "type": "object", + "properties": { + "host": { + "type": "string", + "description": "Server host" + }, + "port": { + "type": "integer", + "description": "Server port" + } + }, + "required": [ + "host" + ] + } + }, + { + "name": "game_list_servers", + "description": "List known game servers", + "inputSchema": { + "type": "object", + "properties": {} + } + }, + { + "name": "game_get_config", + "description": "Get game server configuration", + "inputSchema": { + "type": "object", + "properties": { + "server_id": { + "type": "string", + "description": "Server ID" + } + }, + "required": [ + "server_id" + ] + } + }, + { + "name": "game_set_config", + "description": "Set game server configuration", + "inputSchema": { + "type": "object", + "properties": { + "server_id": { + "type": "string", + "description": "Server ID" + }, + "config": { + "type": "object", + "description": "Configuration object" + } + }, + "required": [ + "server_id", + "config" + ] + } + }, + { + "name": "game_server_action", + "description": "Execute a server action", + "inputSchema": { + "type": "object", + "properties": { + "server_id": { + "type": "string", + "description": "Server ID" + }, + "action": { + "type": "string", + "description": "Action: start|stop|restart|reload" + } + }, + "required": [ + "server_id", + "action" + ] + } + }, + { + "name": "game_drift_status", + "description": "Check configuration drift across servers", + "inputSchema": { + "type": "object", + "properties": {} + } + }, + { + "name": "game_list_profiles", + "description": "List server configuration profiles", + "inputSchema": { + "type": "object", + "properties": {} + } + } + ], + "ffi": { + "so_path": "ffi/zig-out/lib/libgame_admin_mcp.so", + "abi_version": "ADR-0006", + "symbols": [ + "boj_cartridge_init", + "boj_cartridge_deinit", + "boj_cartridge_name", + "boj_cartridge_version", + "boj_cartridge_invoke" + ] + } +} diff --git a/cartridges/domains/gaming/game-admin-mcp/ffi/README.adoc b/cartridges/domains/gaming/game-admin-mcp/ffi/README.adoc new file mode 100644 index 0000000..07106d6 --- /dev/null +++ b/cartridges/domains/gaming/game-admin-mcp/ffi/README.adoc @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += game-admin-mcp / ffi β€” Zig FFI layer **(stub)** +:orientation: shallow + +== Purpose + +C-ABI exports bridging this cartridge's Idris2 ABI to the runtime. Consumed by +the adapter for protocol dispatch and by the trunk loader for mount. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” produces `zig-out/lib/libgame-admin.so`. +| `game_admin_ffi.zig` | C-ABI exports (3 exports, 2 inline tests, 62 lines). +|=== + +== Invariants + +* **Null-pointer rejection** on `[*c]const u8` arguments before any + dereference. +* **Negative return values** carry documented error meanings; zero is + success (matching the trunk error-code convention). +* **No allocator leaks** β€” every allocation path pairs with `defer free`. + +== Test/proof surface + +2 inline `test "..."` block(s) in `game_admin_ffi.zig`. Run with +`cd ffi && zig build test`. + +== Read-first + +. `game_admin_ffi.zig` β€” C-ABI signatures and guards. +. Trunk `ffi/zig/src/catalogue.zig` for how this `.so` is loaded. + +== Promotion gate + +Wire these exports to real backing state (not `return 0` / `return 1` +placeholders) before promoting past D. diff --git a/cartridges/domains/gaming/game-admin-mcp/ffi/build.zig b/cartridges/domains/gaming/game-admin-mcp/ffi/build.zig new file mode 100644 index 0000000..adaa8a8 --- /dev/null +++ b/cartridges/domains/gaming/game-admin-mcp/ffi/build.zig @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const ffi_mod = b.addModule("game_admin_mcp", .{ + .root_source_file = b.path("game_admin_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + const lib = b.addLibrary(.{ + .name = "game_admin_mcp", + .root_module = ffi_mod, + .linkage = .dynamic, + }); + lib.linkLibC(); + b.installArtifact(lib); + + const tests = b.addTest(.{ .root_module = ffi_mod }); + tests.linkLibC(); + const run_tests = b.addRunArtifact(tests); + const test_step = b.step("test", "Run FFI tests"); + test_step.dependOn(&run_tests.step); +} diff --git a/cartridges/domains/gaming/game-admin-mcp/ffi/cartridge_shim.zig b/cartridges/domains/gaming/game-admin-mcp/ffi/cartridge_shim.zig new file mode 100644 index 0000000..0e399db --- /dev/null +++ b/cartridges/domains/gaming/game-admin-mcp/ffi/cartridge_shim.zig @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// cartridge_shim.zig β€” Shared helpers for the ADR-0006 five-symbol +// cartridge ABI (`boj_cartridge_init / deinit / name / version / invoke`). +// +// The shim centralises the seven-code return convention, NUL-argument +// guards, tool-name comparison, and the buffer-too-small path so each +// cartridge's `boj_cartridge_invoke` can stay short β€” typically a tool +// table plus `shim.writeResult(...)`. +// +// Cartridges import this file by relative path (no build-graph change +// needed). Example: +// +// const shim = @import("../../../ffi/zig/src/cartridge_shim.zig"); +// +// export fn boj_cartridge_invoke( +// tool_name: [*c]const u8, +// json_args: [*c]const u8, +// out_buf: [*c]u8, +// in_out_len: [*c]usize, +// ) callconv(.c) i32 { +// _ = json_args; +// if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; +// const body = if (shim.toolIs(tool_name, "foo")) "{\"result\":{}}" +// else return shim.RC_UNKNOWN_TOOL; +// return shim.writeResult(out_buf, in_out_len, body); +// } + +const std = @import("std"); + +// ── Return codes (ADR-0006 Β§Return codes) ──────────────────────────── +// +// Frozen by ADR-0006. New failure modes compose these via the error +// JSON body β€” the integer surface does not grow without a follow-up ADR. + +pub const RC_SUCCESS: i32 = 0; +pub const RC_UNKNOWN_TOOL: i32 = -1; +pub const RC_BAD_ARGS: i32 = -2; +pub const RC_BUFFER_TOO_SMALL: i32 = -3; +pub const RC_RUNTIME_ERROR: i32 = -4; +pub const RC_PANIC: i32 = -5; +pub const RC_AUTH_DENIED: i32 = -6; + +// ── Invoke-path helpers ────────────────────────────────────────────── + +/// True if any of the three mandatory `boj_cartridge_invoke` output-path +/// pointers is null. Use at the top of every invoke to short-circuit to +/// `RC_BAD_ARGS`. +pub fn invokeArgsNull( + tool_name: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) bool { + return tool_name == null or out_buf == null or in_out_len == null; +} + +/// Compare a C-NUL-terminated tool-name pointer against a Zig string +/// literal. Caller must have already verified `tool_name` is non-null +/// (usually via `invokeArgsNull`). +pub fn toolIs(tool_name: [*c]const u8, expected: []const u8) bool { + const s = std.mem.span(@as([*:0]const u8, @ptrCast(tool_name))); + return std.mem.eql(u8, s, expected); +} + +/// Copy `body` into `out_buf[0..*in_out_len]` (as a capacity) and update +/// `*in_out_len` to the number of bytes written. Returns `RC_SUCCESS`. +/// +/// If `body.len` exceeds the current capacity stored in `*in_out_len`, +/// sets `*in_out_len` to the required size and returns +/// `RC_BUFFER_TOO_SMALL` β€” the caller is then expected to re-allocate +/// and retry, per ADR-0006 Β§Memory ownership. +/// +/// Caller must have already verified that `out_buf` and `in_out_len` +/// are non-null. +pub fn writeResult( + out_buf: [*c]u8, + in_out_len: [*c]usize, + body: []const u8, +) i32 { + const cap = in_out_len.*; + if (body.len > cap) { + in_out_len.* = body.len; + return RC_BUFFER_TOO_SMALL; + } + @memcpy(out_buf[0..body.len], body); + in_out_len.* = body.len; + return RC_SUCCESS; +} + +// ── Tests ──────────────────────────────────────────────────────────── + +test "writeResult: body fits, writes and sets length" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); + try std.testing.expectEqualStrings("hello", buf[0..len]); +} + +test "writeResult: too small returns -3 and sets required length" { + var buf: [2]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_BUFFER_TOO_SMALL, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: exact-fit succeeds" { + var buf: [5]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: empty body" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, ""); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 0), len); +} + +test "toolIs: matches and rejects" { + const name: [*:0]const u8 = "foo"; + try std.testing.expect(toolIs(@ptrCast(name), "foo")); + try std.testing.expect(!toolIs(@ptrCast(name), "bar")); + try std.testing.expect(!toolIs(@ptrCast(name), "foobar")); + try std.testing.expect(!toolIs(@ptrCast(name), "fo")); +} + +test "invokeArgsNull: detects each null slot" { + var buf: [4]u8 = undefined; + var len: usize = 4; + const name: [*:0]const u8 = "x"; + try std.testing.expect(!invokeArgsNull(@ptrCast(name), &buf, &len)); + try std.testing.expect(invokeArgsNull(null, &buf, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), null, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), &buf, null)); +} diff --git a/cartridges/domains/gaming/game-admin-mcp/ffi/game_admin_ffi.zig b/cartridges/domains/gaming/game-admin-mcp/ffi/game_admin_ffi.zig new file mode 100644 index 0000000..d5d5f30 --- /dev/null +++ b/cartridges/domains/gaming/game-admin-mcp/ffi/game_admin_ffi.zig @@ -0,0 +1,162 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// Game Admin FFI β€” C-compatible bridge for BoJ MCP cartridge. + +const std = @import("std"); + +pub const Operation = enum(i32) { + list_servers = 0, + get_server_status = 1, + start_server = 2, + stop_server = 3, + restart_server = 4, + update_config = 5, + get_logs = 6, + probe_health = 7, +}; + +pub const PermLevel = enum(i32) { + viewer = 0, + operator_ = 1, + admin = 2, +}; + +pub export fn game_admin_min_perm(op: i32) i32 { + return switch (@as(Operation, @enumFromInt(op))) { + .list_servers => 0, + .get_server_status => 0, + .start_server => 1, + .stop_server => 1, + .restart_server => 1, + .update_config => 2, + .get_logs => 0, + .probe_health => 0, + }; +} + +pub export fn game_admin_check_perm(op: i32, user_perm: i32) i32 { + const required = game_admin_min_perm(op); + return if (user_perm >= required) 1 else 0; +} + +pub export fn game_admin_is_readonly(op: i32) i32 { + return switch (@as(Operation, @enumFromInt(op))) { + .list_servers, .get_server_status, .get_logs, .probe_health => 1, + else => 0, + }; +} + +// ═══════════════════════════════════════════════════════════════════════ +// Standard ABI (ADR-0005 four symbols + ADR-0006 invoke) +// ═══════════════════════════════════════════════════════════════════════ + +const shim = @import("cartridge_shim.zig"); + +const CARTRIDGE_NAME_PTR: [*:0]const u8 = "game-admin-mcp"; +const CARTRIDGE_VERSION_PTR: [*:0]const u8 = "0.1.0"; + +export fn boj_cartridge_init() callconv(.c) c_int { + return 0; +} + +export fn boj_cartridge_deinit() callconv(.c) void {} + +export fn boj_cartridge_name() callconv(.c) [*:0]const u8 { + return CARTRIDGE_NAME_PTR; +} + +export fn boj_cartridge_version() callconv(.c) [*:0]const u8 { + return CARTRIDGE_VERSION_PTR; +} + +/// Dispatch the cartridge.json MCP tools. Grade D Alpha stubs. +export fn boj_cartridge_invoke( + tool_name: [*c]const u8, + json_args: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) callconv(.c) i32 { + _ = json_args; + if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; + + const body: []const u8 = if (shim.toolIs(tool_name, "game_probe_server")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "game_list_servers")) + "{\"result\":{\"items\":[],\"count\":0,\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "game_get_config")) + "{\"result\":{\"metadata\":{},\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "game_set_config")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "game_server_action")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "game_drift_status")) + "{\"result\":{\"metadata\":{},\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "game_list_profiles")) + "{\"result\":{\"items\":[],\"count\":0,\"status\":\"stub\"}}" +else + return shim.RC_UNKNOWN_TOOL; + + return shim.writeResult(out_buf, in_out_len, body); +} + +test "permission levels match ABI" { + try std.testing.expectEqual(@as(i32, 0), game_admin_min_perm(0)); + try std.testing.expectEqual(@as(i32, 1), game_admin_min_perm(2)); + try std.testing.expectEqual(@as(i32, 2), game_admin_min_perm(5)); +} + +test "readonly operations" { + try std.testing.expectEqual(@as(i32, 1), game_admin_is_readonly(0)); + try std.testing.expectEqual(@as(i32, 1), game_admin_is_readonly(1)); + try std.testing.expectEqual(@as(i32, 0), game_admin_is_readonly(2)); + try std.testing.expectEqual(@as(i32, 0), game_admin_is_readonly(5)); + try std.testing.expectEqual(@as(i32, 1), game_admin_is_readonly(7)); +} + +// ═══════════════════════════════════════════════════════════════════════ +// ADR-0006 invoke dispatch tests +// ═══════════════════════════════════════════════════════════════════════ + +test "boj_cartridge_name returns game-admin-mcp" { + const n = std.mem.span(boj_cartridge_name()); + try std.testing.expectEqualStrings("game-admin-mcp", n); +} + +test "boj_cartridge_init returns 0" { + try std.testing.expectEqual(@as(c_int, 0), boj_cartridge_init()); +} + +test "invoke: each declared tool succeeds" { + var buf: [256]u8 = undefined; + const tools = [_][]const u8{ + "game_probe_server", + "game_list_servers", + "game_get_config", + "game_set_config", + "game_server_action", + "game_drift_status", + "game_list_profiles", + }; + for (tools) |t| { + var len: usize = buf.len; + const rc = boj_cartridge_invoke(t.ptr, "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, 0), rc); + try std.testing.expect(std.mem.indexOf(u8, buf[0..len], "result") != null); + } +} + +test "invoke: unknown tool returns -1" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("nope", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -1), rc); +} + +test "invoke: buffer too small returns -3" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("game_probe_server", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -3), rc); + try std.testing.expect(len > 4); +} diff --git a/cartridges/domains/gaming/game-admin-mcp/mod.js b/cartridges/domains/gaming/game-admin-mcp/mod.js new file mode 100644 index 0000000..154f415 --- /dev/null +++ b/cartridges/domains/gaming/game-admin-mcp/mod.js @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// game-admin-mcp/mod.js β€” Game server administration and configuration drift +// +// Delegates to backend at http://127.0.0.1:7724 (override with GAME_ADMIN_URL). + +const BASE_URL = Deno.env.get("GAME_ADMIN_URL") ?? "http://127.0.0.1:7724"; +const TIMEOUT_MS = 15_000; + +async function post(path, payload) { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), TIMEOUT_MS); + try { + const r = await fetch(`${BASE_URL}${path}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + signal: ctrl.signal, + }); + const data = await r.json().catch(() => ({ success: false, error: "non-JSON response" })); + return { status: r.status, data }; + } catch (e) { + if (e.name === "AbortError") return { status: 504, data: { success: false, error: "game-admin-mcp backend timed out" } }; + return { status: 503, data: { success: false, error: `game-admin-mcp backend unavailable: ${e.message}` } }; + } finally { clearTimeout(t); } +} + +async function get(path) { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), TIMEOUT_MS); + try { + const r = await fetch(`${BASE_URL}${path}`, { method: "GET", signal: ctrl.signal }); + const data = await r.json().catch(() => ({ success: false, error: "non-JSON response" })); + return { status: r.status, data }; + } catch (e) { + if (e.name === "AbortError") return { status: 504, data: { success: false, error: "game-admin-mcp backend timed out" } }; + return { status: 503, data: { success: false, error: `game-admin-mcp backend unavailable: ${e.message}` } }; + } finally { clearTimeout(t); } +} + +export async function handleTool(toolName, args) { + switch (toolName) { + case "game_probe_server": + return post("/api/v1/game_probe_server", args ?? {}); + case "game_list_servers": + return post("/api/v1/game_list_servers", args ?? {}); + case "game_get_config": + return post("/api/v1/game_get_config", args ?? {}); + case "game_set_config": + return post("/api/v1/game_set_config", args ?? {}); + case "game_server_action": + return post("/api/v1/game_server_action", args ?? {}); + case "game_drift_status": + return post("/api/v1/game_drift_status", args ?? {}); + case "game_list_profiles": + return post("/api/v1/game_list_profiles", args ?? {}); + default: + return { status: 404, data: { error: `Unknown tool: ${toolName}` } }; + } +} diff --git a/cartridges/domains/gaming/game-admin-mcp/panels/manifest.json b/cartridges/domains/gaming/game-admin-mcp/panels/manifest.json new file mode 100644 index 0000000..c9aeb18 --- /dev/null +++ b/cartridges/domains/gaming/game-admin-mcp/panels/manifest.json @@ -0,0 +1,71 @@ +{ + "$schema": "https://panll.dev/schemas/panel-manifest/v1.json", + "spdx": "MPL-2.0", + "cartridge": "game-admin-mcp", + "domain": "Game Server Management", + "version": "0.1.0", + "clade": "infrastructure/game-servers", + "description": "MCP cartridge for Game Server Admin β€” probe, configure, and manage game servers via BoJ", + "panels": [ + { + "id": "gsa-server-status", + "title": "Game Server Status", + "description": "Overview of all managed game servers with health indicators", + "type": "status-indicator", + "data_source": { + "endpoint": "/cartridge/game-admin-mcp/invoke", + "method": "POST", + "body": { "tool": "list_servers" }, + "refresh_interval_ms": 15000 + }, + "widgets": [ + { "type": "counter", "field": "total", "label": "Servers", "icon": "server" }, + { "type": "counter", "field": "running", "label": "Running", "icon": "play-circle" }, + { "type": "counter", "field": "stopped", "label": "Stopped", "icon": "stop-circle" }, + { "type": "counter", "field": "drifted", "label": "Config Drift", "icon": "alert-triangle" } + ] + }, + { + "id": "gsa-probe-result", + "title": "Last Probe Result", + "description": "Most recent server probe fingerprint and detected game", + "type": "detail", + "data_source": { + "endpoint": "/cartridge/game-admin-mcp/invoke", + "method": "POST", + "body": { "tool": "last_probe" }, + "refresh_interval_ms": 0 + }, + "widgets": [ + { "type": "text", "field": "game_name", "label": "Game" }, + { "type": "text", "field": "version", "label": "Version" }, + { "type": "text", "field": "host", "label": "Host" }, + { "type": "text", "field": "fingerprint", "label": "Fingerprint" }, + { "type": "state-badge", "field": "confidence", "states": { + "high": { "color": "#2ecc71", "icon": "check-circle" }, + "medium": { "color": "#f39c12", "icon": "help-circle" }, + "low": { "color": "#e74c3c", "icon": "x-circle" } + }} + ] + }, + { + "id": "gsa-config-drift", + "title": "Config Drift Overview", + "description": "VeriSimDB drift scores across all managed servers", + "type": "metric", + "data_source": { + "endpoint": "/cartridge/game-admin-mcp/invoke", + "method": "POST", + "body": { "tool": "drift_status" }, + "refresh_interval_ms": 15000 + }, + "widgets": [ + { "type": "gauge", "field": "avg_drift", "label": "Avg Drift", "min": 0, "max": 1 }, + { "type": "gauge", "field": "max_drift", "label": "Max Drift", "min": 0, "max": 1 }, + { "type": "counter", "field": "healthy", "label": "Healthy", "icon": "heart" }, + { "type": "counter", "field": "warning", "label": "Warning", "icon": "alert-circle" }, + { "type": "counter", "field": "critical", "label": "Critical", "icon": "alert-triangle" } + ] + } + ] +} diff --git a/cartridges/domains/gaming/idaptik-admin-mcp/README.adoc b/cartridges/domains/gaming/idaptik-admin-mcp/README.adoc new file mode 100644 index 0000000..411a0d4 --- /dev/null +++ b/cartridges/domains/gaming/idaptik-admin-mcp/README.adoc @@ -0,0 +1,64 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += idaptik-admin-mcp +Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk> +:spdx: MPL-2.0 +:tier: Ayo +:domain: gaming +:protocols: MCP, REST + +== Overview + +IDApTIK game server administration + +== Tools (10) + +[cols="2,4"] +|=== +| Tool | Description + +| `idaptik_server_status` | Get IDApTIK server status +| `idaptik_list_sessions` | List active learner sessions +| `idaptik_create_session` | Create a new learner session +| `idaptik_end_session` | End a learner session +| `idaptik_get_config` | Get server configuration +| `idaptik_update_config` | Update server configuration +| `idaptik_list_level_packs` | List available level packs +| `idaptik_toggle_training` | Toggle training mode on/off +| `idaptik_player_stats` | Get player statistics +| `idaptik_server_action` | Execute a server action +|=== + +== Architecture + +Three-layer stack per the BoJ cartridge standard: + +[cols="1,1,3"] +|=== +| Layer | Language | Purpose + +| ABI | Idris2 | Formally verified state machine with dependent-type proofs +| FFI | Zig | C-ABI implementation: session pool, action metrics, thread safety +| Adapter| Zig | Adapter bridge to the BoJ unified adapter protocol (zig predecessors sidelined) +|=== + +== Building + +[source,bash] +---- +# ABI (Idris2) +cd abi && idris2 --check *.idr + +# FFI (Zig) +cd ffi && zig build +cd ffi && zig build test + +# Adapter (Zig) +cd adapter && zig build +---- + +== Status + +Alpha (CRG D). Cartridge metadata declared in `cartridge.json`; 10 tool(s) exposed. +Not yet dogfooded externally. See `docs/READINESS.md` at repo root for the +project-wide grade table and `docs/practice/DOGFOOD-LOG.adoc` for usage evidence. diff --git a/cartridges/domains/gaming/idaptik-admin-mcp/abi/IdaptikAdmin/Protocol.idr b/cartridges/domains/gaming/idaptik-admin-mcp/abi/IdaptikAdmin/Protocol.idr new file mode 100644 index 0000000..beed77a --- /dev/null +++ b/cartridges/domains/gaming/idaptik-admin-mcp/abi/IdaptikAdmin/Protocol.idr @@ -0,0 +1,72 @@ +-- SPDX-License-Identifier: MPL-2.0 +-- Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +||| Protocol: IDApTIK game administration via BoJ MCP +||| +||| Cartridge: idaptik-admin +||| Matrix cell: Game Platform x Administration +||| +||| Defines the formal interface for managing IDApTIK game instances, +||| levels, and player data through the BoJ Server. Proves that: +||| 1. Level data modifications require admin authority +||| 2. Player data access is permission-scoped +||| 3. Sync operations are idempotent +module IdaptikAdmin.Protocol + +import Data.Fin + +%default total + +-- ═══════════════════════════════════════════════════════════════════════ +-- Core Types +-- ═══════════════════════════════════════════════════════════════════════ + +||| Administrative operation on an IDApTIK instance +public export +data Operation + = ListLevels -- List available levels + | GetLevelState -- Get a level's current state + | UpdateLevel -- Modify level configuration + | ListPlayers -- List active players + | GetPlayerProgress -- Get player progression data + | SyncServer -- Trigger sync server operation + | GetDiagnostics -- Get game server diagnostics + +||| Permission level for IDApTIK admin ops +public export +data PermLevel = Observer | LevelDesigner | GameAdmin + +||| Minimum permission per operation +public export +data RequiresPermission : Operation -> PermLevel -> Type where + ListLevelsObs : RequiresPermission ListLevels Observer + GetLevelStateObs : RequiresPermission GetLevelState Observer + UpdateLevelDesigner : RequiresPermission UpdateLevel LevelDesigner + ListPlayersObs : RequiresPermission ListPlayers Observer + GetProgressObs : RequiresPermission GetPlayerProgress Observer + SyncAdmin : RequiresPermission SyncServer GameAdmin + DiagnosticsObs : RequiresPermission GetDiagnostics Observer + +-- ═══════════════════════════════════════════════════════════════════════ +-- C ABI Exports +-- ═══════════════════════════════════════════════════════════════════════ + +export +operationToInt : Operation -> Int +operationToInt ListLevels = 0 +operationToInt GetLevelState = 1 +operationToInt UpdateLevel = 2 +operationToInt ListPlayers = 3 +operationToInt GetPlayerProgress = 4 +operationToInt SyncServer = 5 +operationToInt GetDiagnostics = 6 + +export +idaptik_min_perm : Int -> Int +idaptik_min_perm 0 = 0 -- ListLevels β†’ Observer +idaptik_min_perm 1 = 0 -- GetLevelState β†’ Observer +idaptik_min_perm 2 = 1 -- UpdateLevel β†’ LevelDesigner +idaptik_min_perm 3 = 0 -- ListPlayers β†’ Observer +idaptik_min_perm 4 = 0 -- GetPlayerProgress β†’ Observer +idaptik_min_perm 5 = 2 -- SyncServer β†’ GameAdmin +idaptik_min_perm 6 = 0 -- GetDiagnostics β†’ Observer +idaptik_min_perm _ = 2 -- Unknown β†’ require Admin diff --git a/cartridges/domains/gaming/idaptik-admin-mcp/abi/README.adoc b/cartridges/domains/gaming/idaptik-admin-mcp/abi/README.adoc new file mode 100644 index 0000000..ebdb364 --- /dev/null +++ b/cartridges/domains/gaming/idaptik-admin-mcp/abi/README.adoc @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += idaptik-admin-mcp / abi β€” Idris2 ABI layer +:orientation: shallow + +== Purpose + +Cartridge-local protocol types for `idaptik-admin-mcp`. Defines the data, tool, and +transition vocabulary this cartridge speaks. Inherits safety lemmas and the +matrix-cell identifier scheme from the trunk ABI at `src/abi/`. + +== Files + +1 Idris2 module(s), ~72 lines total. Lead module: +`Protocol.idr`. + +No `.ipkg` β€” consumed directly by wider BoJ callers; type-checks inside the +trunk `boj.ipkg`. + +== Invariants + +* `%default total` at every module top. +* Zero `believe_me`, `assert_total`, `postulate`, `Admitted`, or + `sorry` (the trunk holds the four declared `believe_me` sites; leaf + cartridges carry none). + +== Test/proof surface + +Thin module(s), no inline tests. Proof surface = type-check success under the +trunk `idris2 --check`. + +== Read-first + +. `Protocol.idr` β€” cartridge-local data records and enums. +. Trunk `src/abi/Boj/Catalogue.idr` for how this cell participates in the + 2D matrix. diff --git a/cartridges/domains/gaming/idaptik-admin-mcp/abi/idaptik-admin.ipkg b/cartridges/domains/gaming/idaptik-admin-mcp/abi/idaptik-admin.ipkg new file mode 100644 index 0000000..ca8c173 --- /dev/null +++ b/cartridges/domains/gaming/idaptik-admin-mcp/abi/idaptik-admin.ipkg @@ -0,0 +1,6 @@ +-- SPDX-License-Identifier: MPL-2.0 +package idaptikAdmin + +modules = IdaptikAdmin.Protocol + +sourcedir = "." diff --git a/cartridges/domains/gaming/idaptik-admin-mcp/adapter/README.adoc b/cartridges/domains/gaming/idaptik-admin-mcp/adapter/README.adoc new file mode 100644 index 0000000..01f783c --- /dev/null +++ b/cartridges/domains/gaming/idaptik-admin-mcp/adapter/README.adoc @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += idaptik-admin-mcp / adapter β€” protocol bridge +:orientation: shallow + +== Purpose + +Exposes this cartridge's FFI over the wire protocols declared in +`cartridge.json`. Dispatches tool invocations to the C-ABI exports at +`../ffi/` and formats responses per protocol. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” depends on the sibling `ffi` target. +| `idaptik_admin_adapter.zig` | Protocol dispatch (159 lines). Tool catalogue matches `cartridge.json`. +| `SIDELINED-*.v.adoc` | zig predecessor(s). Not built β€” zig banned 2026-04-10. +|=== + +== Invariants + +* **Stateless** β€” all state lives behind the FFI, never in the adapter. +* **Response passthrough** β€” whatever the FFI returns goes back to the wire + unmodified (no embellishment, no silent recovery). +* **Cartridge.json is source of truth** for the tool catalogue; drift between + adapter and manifest is a CI failure. + +== Test/proof surface + +No inline tests at the adapter layer β€” protocol routing is exercised by the +trunk seam tests at `ffi/zig/src/seams.zig`. + +== Read-first + +. `idaptik_admin_adapter.zig` β€” dispatch function and per-protocol routers. +. `../cartridge.json` β€” the tool manifest the dispatcher mirrors. diff --git a/cartridges/domains/gaming/idaptik-admin-mcp/adapter/build.zig b/cartridges/domains/gaming/idaptik-admin-mcp/adapter/build.zig new file mode 100644 index 0000000..e9a3934 --- /dev/null +++ b/cartridges/domains/gaming/idaptik-admin-mcp/adapter/build.zig @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// idaptik-admin-mcp/adapter/build.zig + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const ffi_mod = b.createModule(.{ + .root_source_file = b.path("../ffi/idaptik_admin_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + const adapter = b.addExecutable(.{ + .name = "idaptik_admin_adapter", + .root_source_file = b.path("idaptik_admin_adapter.zig"), + .target = target, + .optimize = optimize, + }); + adapter.root_module.addImport("idaptik_admin_ffi", ffi_mod); + b.installArtifact(adapter); +} diff --git a/cartridges/domains/gaming/idaptik-admin-mcp/adapter/idaptik_admin_adapter.zig b/cartridges/domains/gaming/idaptik-admin-mcp/adapter/idaptik_admin_adapter.zig new file mode 100644 index 0000000..4485b75 --- /dev/null +++ b/cartridges/domains/gaming/idaptik-admin-mcp/adapter/idaptik_admin_adapter.zig @@ -0,0 +1,159 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// idaptik-admin-mcp/adapter/idaptik_admin_adapter.zig +// +// Three-protocol BoJ adapter: REST (port 9253), gRPC-compat (port 9254), +// GraphQL (port 9255). +// Replaces the banned zig adapter (idaptik_admin_adapter.v). + +const std = @import("std"); +const ffi = @import("idaptik_admin_ffi"); + +const REST_PORT: u16 = 9253; +const GRPC_PORT: u16 = 9254; +const GQL_PORT: u16 = 9255; + +const Response = struct { status: u16, body: []const u8 }; + +fn okJson(buf: []u8, msg: []const u8) []u8 { + return std.fmt.bufPrint(buf, "{{\"success\":true,\"message\":\"{s}\"}}", .{msg}) catch buf[0..0]; +} + +fn errJson(buf: []u8, msg: []const u8) []u8 { + return std.fmt.bufPrint(buf, "{{\"success\":false,\"error\":\"{s}\"}}", .{msg}) catch buf[0..0]; +} + +fn dispatch(tool: []const u8, _body: []const u8, resp: []u8) Response { + _ = _body; + if (std.mem.eql(u8, tool, "idaptik_server_status")) { + return .{ .status = 200, .body = okJson(resp, "idaptik_server_status forwarded") }; + } + if (std.mem.eql(u8, tool, "idaptik_list_sessions")) { + return .{ .status = 200, .body = okJson(resp, "idaptik_list_sessions forwarded") }; + } + if (std.mem.eql(u8, tool, "idaptik_create_session")) { + return .{ .status = 200, .body = okJson(resp, "idaptik_create_session forwarded") }; + } + if (std.mem.eql(u8, tool, "idaptik_end_session")) { + return .{ .status = 200, .body = okJson(resp, "idaptik_end_session forwarded") }; + } + if (std.mem.eql(u8, tool, "idaptik_get_config")) { + return .{ .status = 200, .body = okJson(resp, "idaptik_get_config forwarded") }; + } + if (std.mem.eql(u8, tool, "idaptik_update_config")) { + return .{ .status = 200, .body = okJson(resp, "idaptik_update_config forwarded") }; + } + if (std.mem.eql(u8, tool, "idaptik_list_level_packs")) { + return .{ .status = 200, .body = okJson(resp, "idaptik_list_level_packs forwarded") }; + } + if (std.mem.eql(u8, tool, "idaptik_toggle_training")) { + return .{ .status = 200, .body = okJson(resp, "idaptik_toggle_training forwarded") }; + } + if (std.mem.eql(u8, tool, "idaptik_player_stats")) { + return .{ .status = 200, .body = okJson(resp, "idaptik_player_stats forwarded") }; + } + if (std.mem.eql(u8, tool, "idaptik_server_action")) { + return .{ .status = 200, .body = okJson(resp, "idaptik_server_action forwarded") }; + } + return .{ .status = 404, .body = errJson(resp, "unknown tool") }; +} + +fn dispatchRest(path: []const u8, body: []const u8, resp: []u8) Response { + // Expect /tools/<tool_name> + const prefix = "/tools/"; + if (std.mem.startsWith(u8, path, prefix)) { + const tool = path[prefix.len..]; + return dispatch(tool, body, resp); + } + return .{ .status = 404, .body = errJson(resp, "not found") }; +} + +fn dispatchGrpc(path: []const u8, body: []const u8, resp: []u8) Response { + // Expect /<Service>/<Method> β€” derive tool from Method + var it = std.mem.splitScalar(u8, path, '/'); + _ = it.next(); // leading empty + _ = it.next(); // service + const method = it.next() orelse return .{ .status = 404, .body = errJson(resp, "bad gRPC path") }; + return dispatch(method, body, resp); +} + +fn dispatchGraphql(body: []const u8, resp: []u8) Response { + if (std.mem.indexOf(u8, body, "idaptik_server_status") != null) + return dispatch("idaptik_server_status", body, resp); + if (std.mem.indexOf(u8, body, "idaptik_list_sessions") != null) + return dispatch("idaptik_list_sessions", body, resp); + if (std.mem.indexOf(u8, body, "idaptik_create_session") != null) + return dispatch("idaptik_create_session", body, resp); + if (std.mem.indexOf(u8, body, "idaptik_end_session") != null) + return dispatch("idaptik_end_session", body, resp); + if (std.mem.indexOf(u8, body, "idaptik_get_config") != null) + return dispatch("idaptik_get_config", body, resp); + if (std.mem.indexOf(u8, body, "idaptik_update_config") != null) + return dispatch("idaptik_update_config", body, resp); + if (std.mem.indexOf(u8, body, "idaptik_list_level_packs") != null) + return dispatch("idaptik_list_level_packs", body, resp); + if (std.mem.indexOf(u8, body, "idaptik_toggle_training") != null) + return dispatch("idaptik_toggle_training", body, resp); + if (std.mem.indexOf(u8, body, "idaptik_player_stats") != null) + return dispatch("idaptik_player_stats", body, resp); + if (std.mem.indexOf(u8, body, "idaptik_server_action") != null) + return dispatch("idaptik_server_action", body, resp); + return .{ .status = 400, .body = errJson(resp, "unrecognised GraphQL operation") }; +} + +fn handleConnection(stream: std.net.Stream, port: u16) void { + defer stream.close(); + var buf: [4096]u8 = undefined; + var resp_buf: [4096]u8 = undefined; + const n = stream.read(&buf) catch return; + const req = buf[0..n]; + const result = switch (port) { + REST_PORT => blk: { + // Parse HTTP/1.1: first line = METHOD PATH HTTP/x.y + var lines = std.mem.splitScalar(u8, req, '\n'); + const first = lines.next() orelse break :blk Response{ .status = 400, .body = "" }; + var parts = std.mem.splitScalar(u8, std.mem.trim(u8, first, "\r"), ' '); + _ = parts.next(); // method + const path = parts.next() orelse break :blk Response{ .status = 400, .body = "" }; + break :blk dispatchRest(path, req, &resp_buf); + }, + GRPC_PORT => blk: { + var lines = std.mem.splitScalar(u8, req, '\n'); + const first = lines.next() orelse break :blk Response{ .status = 400, .body = "" }; + var parts = std.mem.splitScalar(u8, std.mem.trim(u8, first, "\r"), ' '); + _ = parts.next(); + const path = parts.next() orelse break :blk Response{ .status = 400, .body = "" }; + break :blk dispatchGrpc(path, req, &resp_buf); + }, + GQL_PORT => dispatchGraphql(req, &resp_buf), + else => Response{ .status = 500, .body = "" }, + }; + var http_resp: [512]u8 = undefined; + const http = std.fmt.bufPrint(&http_resp, + "HTTP/1.1 {d} OK\r\nContent-Length: {d}\r\nContent-Type: application/json\r\n\r\n", + .{ result.status, result.body.len }) catch return; + _ = stream.write(http) catch {}; + _ = stream.write(result.body) catch {}; +} + +fn listenLoop(port: u16) !void { + const addr = std.net.Address.initIp4(.{ 127, 0, 0, 1 }, port); + var server = try addr.listen(.{ .reuse_address = true }); + defer server.deinit(); + while (true) { + const conn = try server.accept(); + const t = try std.Thread.spawn(.{}, handleConnection, .{ conn.stream, port }); + t.detach(); + } +} + +pub fn main() !void { + ffi.idaptik_admin_init(); + const t1 = try std.Thread.spawn(.{}, listenLoop, .{REST_PORT}); + const t2 = try std.Thread.spawn(.{}, listenLoop, .{GRPC_PORT}); + const t3 = try std.Thread.spawn(.{}, listenLoop, .{GQL_PORT}); + t1.join(); + t2.join(); + t3.join(); +} diff --git a/cartridges/domains/gaming/idaptik-admin-mcp/cartridge.json b/cartridges/domains/gaming/idaptik-admin-mcp/cartridge.json new file mode 100644 index 0000000..df2f6af --- /dev/null +++ b/cartridges/domains/gaming/idaptik-admin-mcp/cartridge.json @@ -0,0 +1,168 @@ +{ + "$schema": "https://boj.dev/schemas/cartridge/v1.json", + "spdx": "MPL-2.0", + "copyright": "Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk>", + "name": "idaptik-admin-mcp", + "version": "0.1.0", + "description": "IDApTIK game server administration", + "domain": "gaming", + "tier": "Ayo", + "protocols": [ + "MCP", + "REST" + ], + "auth": { + "method": "none", + "env_var": null, + "credential_source": null + }, + "api": { + "base_url": "local://idaptik-admin-mcp", + "content_type": "application/json" + }, + "tools": [ + { + "name": "idaptik_server_status", + "description": "Get IDApTIK server status", + "inputSchema": { + "type": "object", + "properties": {} + } + }, + { + "name": "idaptik_list_sessions", + "description": "List active learner sessions", + "inputSchema": { + "type": "object", + "properties": {} + } + }, + { + "name": "idaptik_create_session", + "description": "Create a new learner session", + "inputSchema": { + "type": "object", + "properties": { + "player_id": { + "type": "string", + "description": "Player identifier" + }, + "level_pack": { + "type": "string", + "description": "Level pack name" + } + }, + "required": [ + "player_id" + ] + } + }, + { + "name": "idaptik_end_session", + "description": "End a learner session", + "inputSchema": { + "type": "object", + "properties": { + "session_id": { + "type": "string", + "description": "Session ID" + } + }, + "required": [ + "session_id" + ] + } + }, + { + "name": "idaptik_get_config", + "description": "Get server configuration", + "inputSchema": { + "type": "object", + "properties": {} + } + }, + { + "name": "idaptik_update_config", + "description": "Update server configuration", + "inputSchema": { + "type": "object", + "properties": { + "config": { + "type": "object", + "description": "Configuration updates" + } + }, + "required": [ + "config" + ] + } + }, + { + "name": "idaptik_list_level_packs", + "description": "List available level packs", + "inputSchema": { + "type": "object", + "properties": {} + } + }, + { + "name": "idaptik_toggle_training", + "description": "Toggle training mode on/off", + "inputSchema": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Enable training mode" + } + }, + "required": [ + "enabled" + ] + } + }, + { + "name": "idaptik_player_stats", + "description": "Get player statistics", + "inputSchema": { + "type": "object", + "properties": { + "player_id": { + "type": "string", + "description": "Player identifier" + } + }, + "required": [ + "player_id" + ] + } + }, + { + "name": "idaptik_server_action", + "description": "Execute a server action", + "inputSchema": { + "type": "object", + "properties": { + "action": { + "type": "string", + "description": "Action: start|stop|restart|reload_packs" + } + }, + "required": [ + "action" + ] + } + } + ], + "ffi": { + "so_path": "ffi/zig-out/lib/libidaptik_admin_mcp.so", + "abi_version": "ADR-0006", + "symbols": [ + "boj_cartridge_init", + "boj_cartridge_deinit", + "boj_cartridge_name", + "boj_cartridge_version", + "boj_cartridge_invoke" + ] + } +} diff --git a/cartridges/domains/gaming/idaptik-admin-mcp/ffi/README.adoc b/cartridges/domains/gaming/idaptik-admin-mcp/ffi/README.adoc new file mode 100644 index 0000000..eb9d4e7 --- /dev/null +++ b/cartridges/domains/gaming/idaptik-admin-mcp/ffi/README.adoc @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += idaptik-admin-mcp / ffi β€” Zig FFI layer **(stub)** +:orientation: shallow + +== Purpose + +C-ABI exports bridging this cartridge's Idris2 ABI to the runtime. Consumed by +the adapter for protocol dispatch and by the trunk loader for mount. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” produces `zig-out/lib/libidaptik-admin.so`. +| `idaptik_admin_ffi.zig` | C-ABI exports (2 exports, 2 inline tests, 51 lines). +|=== + +== Invariants + +* **Null-pointer rejection** on `[*c]const u8` arguments before any + dereference. +* **Negative return values** carry documented error meanings; zero is + success (matching the trunk error-code convention). +* **No allocator leaks** β€” every allocation path pairs with `defer free`. + +== Test/proof surface + +2 inline `test "..."` block(s) in `idaptik_admin_ffi.zig`. Run with +`cd ffi && zig build test`. + +== Read-first + +. `idaptik_admin_ffi.zig` β€” C-ABI signatures and guards. +. Trunk `ffi/zig/src/catalogue.zig` for how this `.so` is loaded. + +== Promotion gate + +Wire these exports to real backing state (not `return 0` / `return 1` +placeholders) before promoting past D. diff --git a/cartridges/domains/gaming/idaptik-admin-mcp/ffi/build.zig b/cartridges/domains/gaming/idaptik-admin-mcp/ffi/build.zig new file mode 100644 index 0000000..3d7fc22 --- /dev/null +++ b/cartridges/domains/gaming/idaptik-admin-mcp/ffi/build.zig @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const ffi_mod = b.addModule("idaptik_admin_mcp", .{ + .root_source_file = b.path("idaptik_admin_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + const lib = b.addLibrary(.{ + .name = "idaptik_admin_mcp", + .root_module = ffi_mod, + .linkage = .dynamic, + }); + lib.linkLibC(); + b.installArtifact(lib); + + const tests = b.addTest(.{ .root_module = ffi_mod }); + tests.linkLibC(); + const run_tests = b.addRunArtifact(tests); + const test_step = b.step("test", "Run FFI tests"); + test_step.dependOn(&run_tests.step); +} diff --git a/cartridges/domains/gaming/idaptik-admin-mcp/ffi/cartridge_shim.zig b/cartridges/domains/gaming/idaptik-admin-mcp/ffi/cartridge_shim.zig new file mode 100644 index 0000000..0e399db --- /dev/null +++ b/cartridges/domains/gaming/idaptik-admin-mcp/ffi/cartridge_shim.zig @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// cartridge_shim.zig β€” Shared helpers for the ADR-0006 five-symbol +// cartridge ABI (`boj_cartridge_init / deinit / name / version / invoke`). +// +// The shim centralises the seven-code return convention, NUL-argument +// guards, tool-name comparison, and the buffer-too-small path so each +// cartridge's `boj_cartridge_invoke` can stay short β€” typically a tool +// table plus `shim.writeResult(...)`. +// +// Cartridges import this file by relative path (no build-graph change +// needed). Example: +// +// const shim = @import("../../../ffi/zig/src/cartridge_shim.zig"); +// +// export fn boj_cartridge_invoke( +// tool_name: [*c]const u8, +// json_args: [*c]const u8, +// out_buf: [*c]u8, +// in_out_len: [*c]usize, +// ) callconv(.c) i32 { +// _ = json_args; +// if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; +// const body = if (shim.toolIs(tool_name, "foo")) "{\"result\":{}}" +// else return shim.RC_UNKNOWN_TOOL; +// return shim.writeResult(out_buf, in_out_len, body); +// } + +const std = @import("std"); + +// ── Return codes (ADR-0006 Β§Return codes) ──────────────────────────── +// +// Frozen by ADR-0006. New failure modes compose these via the error +// JSON body β€” the integer surface does not grow without a follow-up ADR. + +pub const RC_SUCCESS: i32 = 0; +pub const RC_UNKNOWN_TOOL: i32 = -1; +pub const RC_BAD_ARGS: i32 = -2; +pub const RC_BUFFER_TOO_SMALL: i32 = -3; +pub const RC_RUNTIME_ERROR: i32 = -4; +pub const RC_PANIC: i32 = -5; +pub const RC_AUTH_DENIED: i32 = -6; + +// ── Invoke-path helpers ────────────────────────────────────────────── + +/// True if any of the three mandatory `boj_cartridge_invoke` output-path +/// pointers is null. Use at the top of every invoke to short-circuit to +/// `RC_BAD_ARGS`. +pub fn invokeArgsNull( + tool_name: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) bool { + return tool_name == null or out_buf == null or in_out_len == null; +} + +/// Compare a C-NUL-terminated tool-name pointer against a Zig string +/// literal. Caller must have already verified `tool_name` is non-null +/// (usually via `invokeArgsNull`). +pub fn toolIs(tool_name: [*c]const u8, expected: []const u8) bool { + const s = std.mem.span(@as([*:0]const u8, @ptrCast(tool_name))); + return std.mem.eql(u8, s, expected); +} + +/// Copy `body` into `out_buf[0..*in_out_len]` (as a capacity) and update +/// `*in_out_len` to the number of bytes written. Returns `RC_SUCCESS`. +/// +/// If `body.len` exceeds the current capacity stored in `*in_out_len`, +/// sets `*in_out_len` to the required size and returns +/// `RC_BUFFER_TOO_SMALL` β€” the caller is then expected to re-allocate +/// and retry, per ADR-0006 Β§Memory ownership. +/// +/// Caller must have already verified that `out_buf` and `in_out_len` +/// are non-null. +pub fn writeResult( + out_buf: [*c]u8, + in_out_len: [*c]usize, + body: []const u8, +) i32 { + const cap = in_out_len.*; + if (body.len > cap) { + in_out_len.* = body.len; + return RC_BUFFER_TOO_SMALL; + } + @memcpy(out_buf[0..body.len], body); + in_out_len.* = body.len; + return RC_SUCCESS; +} + +// ── Tests ──────────────────────────────────────────────────────────── + +test "writeResult: body fits, writes and sets length" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); + try std.testing.expectEqualStrings("hello", buf[0..len]); +} + +test "writeResult: too small returns -3 and sets required length" { + var buf: [2]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_BUFFER_TOO_SMALL, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: exact-fit succeeds" { + var buf: [5]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: empty body" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, ""); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 0), len); +} + +test "toolIs: matches and rejects" { + const name: [*:0]const u8 = "foo"; + try std.testing.expect(toolIs(@ptrCast(name), "foo")); + try std.testing.expect(!toolIs(@ptrCast(name), "bar")); + try std.testing.expect(!toolIs(@ptrCast(name), "foobar")); + try std.testing.expect(!toolIs(@ptrCast(name), "fo")); +} + +test "invokeArgsNull: detects each null slot" { + var buf: [4]u8 = undefined; + var len: usize = 4; + const name: [*:0]const u8 = "x"; + try std.testing.expect(!invokeArgsNull(@ptrCast(name), &buf, &len)); + try std.testing.expect(invokeArgsNull(null, &buf, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), null, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), &buf, null)); +} diff --git a/cartridges/domains/gaming/idaptik-admin-mcp/ffi/idaptik_admin_ffi.zig b/cartridges/domains/gaming/idaptik-admin-mcp/ffi/idaptik_admin_ffi.zig new file mode 100644 index 0000000..e0b2ba4 --- /dev/null +++ b/cartridges/domains/gaming/idaptik-admin-mcp/ffi/idaptik_admin_ffi.zig @@ -0,0 +1,160 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// IDApTIK Admin FFI β€” C-compatible bridge for BoJ MCP cartridge. + +const std = @import("std"); + +pub const Operation = enum(i32) { + list_levels = 0, + get_level_state = 1, + update_level = 2, + list_players = 3, + get_player_progress = 4, + sync_server = 5, + get_diagnostics = 6, +}; + +pub const PermLevel = enum(i32) { + observer = 0, + level_designer = 1, + game_admin = 2, +}; + +pub export fn idaptik_admin_min_perm(op: i32) i32 { + return switch (@as(Operation, @enumFromInt(op))) { + .list_levels => 0, + .get_level_state => 0, + .update_level => 1, + .list_players => 0, + .get_player_progress => 0, + .sync_server => 2, + .get_diagnostics => 0, + }; +} + +pub export fn idaptik_admin_check_perm(op: i32, user_perm: i32) i32 { + const required = idaptik_admin_min_perm(op); + return if (user_perm >= required) 1 else 0; +} + +// ═══════════════════════════════════════════════════════════════════════ +// Standard ABI (ADR-0005 four symbols + ADR-0006 invoke) +// ═══════════════════════════════════════════════════════════════════════ + +const shim = @import("cartridge_shim.zig"); + +const CARTRIDGE_NAME_PTR: [*:0]const u8 = "idaptik-admin-mcp"; +const CARTRIDGE_VERSION_PTR: [*:0]const u8 = "0.1.0"; + +export fn boj_cartridge_init() callconv(.c) c_int { + return 0; +} + +export fn boj_cartridge_deinit() callconv(.c) void {} + +export fn boj_cartridge_name() callconv(.c) [*:0]const u8 { + return CARTRIDGE_NAME_PTR; +} + +export fn boj_cartridge_version() callconv(.c) [*:0]const u8 { + return CARTRIDGE_VERSION_PTR; +} + +/// Dispatch the cartridge.json MCP tools. Grade D Alpha stubs. +export fn boj_cartridge_invoke( + tool_name: [*c]const u8, + json_args: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) callconv(.c) i32 { + _ = json_args; + if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; + + const body: []const u8 = if (shim.toolIs(tool_name, "idaptik_server_status")) + "{\"result\":{\"metadata\":{},\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "idaptik_list_sessions")) + "{\"result\":{\"items\":[],\"count\":0,\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "idaptik_create_session")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "idaptik_end_session")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "idaptik_get_config")) + "{\"result\":{\"metadata\":{},\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "idaptik_update_config")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "idaptik_list_level_packs")) + "{\"result\":{\"items\":[],\"count\":0,\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "idaptik_toggle_training")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "idaptik_player_stats")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "idaptik_server_action")) + "{\"result\":{\"status\":\"stub\"}}" +else + return shim.RC_UNKNOWN_TOOL; + + return shim.writeResult(out_buf, in_out_len, body); +} + +test "permission levels match ABI" { + try std.testing.expectEqual(@as(i32, 0), idaptik_admin_min_perm(0)); + try std.testing.expectEqual(@as(i32, 1), idaptik_admin_min_perm(2)); + try std.testing.expectEqual(@as(i32, 2), idaptik_admin_min_perm(5)); +} + +test "permission check" { + try std.testing.expectEqual(@as(i32, 1), idaptik_admin_check_perm(0, 0)); + try std.testing.expectEqual(@as(i32, 0), idaptik_admin_check_perm(5, 0)); + try std.testing.expectEqual(@as(i32, 1), idaptik_admin_check_perm(5, 2)); +} + +// ═══════════════════════════════════════════════════════════════════════ +// ADR-0006 invoke dispatch tests +// ═══════════════════════════════════════════════════════════════════════ + +test "boj_cartridge_name returns idaptik-admin-mcp" { + const n = std.mem.span(boj_cartridge_name()); + try std.testing.expectEqualStrings("idaptik-admin-mcp", n); +} + +test "boj_cartridge_init returns 0" { + try std.testing.expectEqual(@as(c_int, 0), boj_cartridge_init()); +} + +test "invoke: each declared tool succeeds" { + var buf: [256]u8 = undefined; + const tools = [_][]const u8{ + "idaptik_server_status", + "idaptik_list_sessions", + "idaptik_create_session", + "idaptik_end_session", + "idaptik_get_config", + "idaptik_update_config", + "idaptik_list_level_packs", + "idaptik_toggle_training", + "idaptik_player_stats", + "idaptik_server_action", + }; + for (tools) |t| { + var len: usize = buf.len; + const rc = boj_cartridge_invoke(t.ptr, "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, 0), rc); + try std.testing.expect(std.mem.indexOf(u8, buf[0..len], "result") != null); + } +} + +test "invoke: unknown tool returns -1" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("nope", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -1), rc); +} + +test "invoke: buffer too small returns -3" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("idaptik_server_status", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -3), rc); + try std.testing.expect(len > 4); +} diff --git a/cartridges/domains/gaming/idaptik-admin-mcp/mod.js b/cartridges/domains/gaming/idaptik-admin-mcp/mod.js new file mode 100644 index 0000000..eff2737 --- /dev/null +++ b/cartridges/domains/gaming/idaptik-admin-mcp/mod.js @@ -0,0 +1,67 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// idaptik-admin-mcp/mod.js β€” IDApTIK game server administration +// +// Delegates to backend at http://127.0.0.1:7729 (override with IDAPTIK_ADMIN_URL). + +const BASE_URL = Deno.env.get("IDAPTIK_ADMIN_URL") ?? "http://127.0.0.1:7729"; +const TIMEOUT_MS = 15_000; + +async function post(path, payload) { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), TIMEOUT_MS); + try { + const r = await fetch(`${BASE_URL}${path}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + signal: ctrl.signal, + }); + const data = await r.json().catch(() => ({ success: false, error: "non-JSON response" })); + return { status: r.status, data }; + } catch (e) { + if (e.name === "AbortError") return { status: 504, data: { success: false, error: "idaptik-admin-mcp backend timed out" } }; + return { status: 503, data: { success: false, error: `idaptik-admin-mcp backend unavailable: ${e.message}` } }; + } finally { clearTimeout(t); } +} + +async function get(path) { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), TIMEOUT_MS); + try { + const r = await fetch(`${BASE_URL}${path}`, { method: "GET", signal: ctrl.signal }); + const data = await r.json().catch(() => ({ success: false, error: "non-JSON response" })); + return { status: r.status, data }; + } catch (e) { + if (e.name === "AbortError") return { status: 504, data: { success: false, error: "idaptik-admin-mcp backend timed out" } }; + return { status: 503, data: { success: false, error: `idaptik-admin-mcp backend unavailable: ${e.message}` } }; + } finally { clearTimeout(t); } +} + +export async function handleTool(toolName, args) { + switch (toolName) { + case "idaptik_server_status": + return post("/api/v1/idaptik_server_status", args ?? {}); + case "idaptik_list_sessions": + return post("/api/v1/idaptik_list_sessions", args ?? {}); + case "idaptik_create_session": + return post("/api/v1/idaptik_create_session", args ?? {}); + case "idaptik_end_session": + return post("/api/v1/idaptik_end_session", args ?? {}); + case "idaptik_get_config": + return post("/api/v1/idaptik_get_config", args ?? {}); + case "idaptik_update_config": + return post("/api/v1/idaptik_update_config", args ?? {}); + case "idaptik_list_level_packs": + return post("/api/v1/idaptik_list_level_packs", args ?? {}); + case "idaptik_toggle_training": + return post("/api/v1/idaptik_toggle_training", args ?? {}); + case "idaptik_player_stats": + return post("/api/v1/idaptik_player_stats", args ?? {}); + case "idaptik_server_action": + return post("/api/v1/idaptik_server_action", args ?? {}); + default: + return { status: 404, data: { error: `Unknown tool: ${toolName}` } }; + } +} diff --git a/cartridges/domains/gaming/idaptik-admin-mcp/panels/manifest.json b/cartridges/domains/gaming/idaptik-admin-mcp/panels/manifest.json new file mode 100644 index 0000000..60764b2 --- /dev/null +++ b/cartridges/domains/gaming/idaptik-admin-mcp/panels/manifest.json @@ -0,0 +1,72 @@ +{ + "$schema": "https://panll.dev/schemas/panel-manifest/v1.json", + "spdx": "AGPL-3.0-or-later", + "cartridge": "idaptik-admin-mcp", + "domain": "Game Server", + "version": "0.1.0", + "clade": "games/idaptik", + "description": "MCP cartridge for IDApTIK game server administration β€” monitor sessions, manage players, and control the asymmetric co-op stealth server via BoJ", + "panels": [ + { + "id": "idaptik-server-status", + "title": "IDApTIK Server Status", + "description": "Server state, active sessions, player count, and current difficulty", + "type": "status-indicator", + "data_source": { + "endpoint": "/cartridge/idaptik-admin-mcp/invoke", + "method": "POST", + "body": { "tool": "server_status" }, + "refresh_interval_ms": 10000 + }, + "widgets": [ + { "type": "state-badge", "field": "state", "label": "Server State", "states": { + "running": { "color": "#2ecc71", "icon": "play-circle" }, + "stopped": { "color": "#e74c3c", "icon": "stop-circle" }, + "starting": { "color": "#f39c12", "icon": "loader" } + }}, + { "type": "counter", "field": "active_sessions", "label": "Active Sessions", "icon": "users" }, + { "type": "counter", "field": "player_count", "label": "Players", "icon": "user" }, + { "type": "text", "field": "current_difficulty", "label": "Difficulty" } + ] + }, + { + "id": "idaptik-session-monitor", + "title": "IDApTIK Session Monitor", + "description": "Active session metrics, average duration, level completion rate, and training mode status", + "type": "metric", + "data_source": { + "endpoint": "/cartridge/idaptik-admin-mcp/invoke", + "method": "POST", + "body": { "tool": "list_sessions" }, + "refresh_interval_ms": 15000 + }, + "widgets": [ + { "type": "counter", "field": "active_sessions", "label": "Active Sessions", "icon": "activity" }, + { "type": "gauge", "field": "avg_session_duration_min", "label": "Avg Duration (min)", "min": 0, "max": 120 }, + { "type": "gauge", "field": "level_completion_rate", "label": "Level Completion Rate", "min": 0, "max": 1 }, + { "type": "state-badge", "field": "training_mode", "label": "Training Mode", "states": { + "active": { "color": "#2ecc71", "icon": "check-circle" }, + "inactive": { "color": "#95a5a6", "icon": "minus-circle" } + }} + ] + }, + { + "id": "idaptik-player-overview", + "title": "IDApTIK Player Overview", + "description": "Jessica and Q player statistics, asymmetric balance ratio, and spectator count", + "type": "metric", + "data_source": { + "endpoint": "/cartridge/idaptik-admin-mcp/invoke", + "method": "POST", + "body": { "tool": "server_status" }, + "refresh_interval_ms": 15000 + }, + "widgets": [ + { "type": "counter", "field": "jessica_players", "label": "Jessica Players", "icon": "user" }, + { "type": "counter", "field": "q_players", "label": "Q Players", "icon": "eye" }, + { "type": "gauge", "field": "asymmetric_balance_ratio", "label": "Balance Ratio (J:Q)", "min": 0, "max": 2 }, + { "type": "counter", "field": "spectator_count", "label": "Spectators", "icon": "monitor" } + ] + } + ] +} diff --git a/cartridges/domains/gaming/ums-mcp/README.adoc b/cartridges/domains/gaming/ums-mcp/README.adoc new file mode 100644 index 0000000..c29ce86 --- /dev/null +++ b/cartridges/domains/gaming/ums-mcp/README.adoc @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += ums-mcp +Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk> +:spdx: MPL-2.0 +:tier: Ayo +:domain: gaming +:protocols: MCP, REST + +== Overview + +Universal Map Specification β€” level editor and validator + +== Tools (7) + +[cols="2,4"] +|=== +| Tool | Description + +| `ums_create_project` | Create a new UMS project +| `ums_open_project` | Open an existing UMS project +| `ums_close_project` | Close the current project +| `ums_load_level` | Load a level into the editor +| `ums_save_level` | Save the current level +| `ums_validate_level` | Validate level against UMS schema +| `ums_list_profiles` | List available game profiles +|=== + +== Architecture + +Three-layer stack per the BoJ cartridge standard: + +[cols="1,1,3"] +|=== +| Layer | Language | Purpose + +| ABI | Idris2 | Formally verified state machine with dependent-type proofs +| FFI | Zig | C-ABI implementation: session pool, action metrics, thread safety +| Adapter| Zig | Adapter bridge to the BoJ unified adapter protocol (zig predecessors sidelined) +|=== + +== Building + +[source,bash] +---- +# ABI (Idris2) +cd abi && idris2 --check *.idr + +# FFI (Zig) +cd ffi && zig build +cd ffi && zig build test + +# Adapter (Zig) +cd adapter && zig build +---- + +== Status + +Alpha (CRG D). Cartridge metadata declared in `cartridge.json`; 7 tool(s) exposed. +Not yet dogfooded externally. See `docs/READINESS.md` at repo root for the +project-wide grade table and `docs/practice/DOGFOOD-LOG.adoc` for usage evidence. diff --git a/cartridges/domains/gaming/ums-mcp/abi/README.adoc b/cartridges/domains/gaming/ums-mcp/abi/README.adoc new file mode 100644 index 0000000..951f2b3 --- /dev/null +++ b/cartridges/domains/gaming/ums-mcp/abi/README.adoc @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += ums-mcp / abi β€” Idris2 ABI layer +:orientation: shallow + +== Purpose + +Cartridge-local protocol types for `ums-mcp`. Defines the data, tool, and +transition vocabulary this cartridge speaks. Inherits safety lemmas and the +matrix-cell identifier scheme from the trunk ABI at `src/abi/`. + +== Files + +1 Idris2 module(s), ~256 lines total. Lead module: +`SafeUms.idr`. + +No `.ipkg` β€” consumed directly by wider BoJ callers; type-checks inside the +trunk `boj.ipkg`. + +== Invariants + +* `%default total` at every module top. +* Zero `believe_me`, `assert_total`, `postulate`, `Admitted`, or + `sorry` (the trunk holds the four declared `believe_me` sites; leaf + cartridges carry none). + +== Test/proof surface + +Thin module(s), no inline tests. Proof surface = type-check success under the +trunk `idris2 --check`. + +== Read-first + +. `SafeUms.idr` β€” cartridge-local data records and enums. +. Trunk `src/abi/Boj/Catalogue.idr` for how this cell participates in the + 2D matrix. diff --git a/cartridges/domains/gaming/ums-mcp/abi/UmsMcp/SafeUms.idr b/cartridges/domains/gaming/ums-mcp/abi/UmsMcp/SafeUms.idr new file mode 100644 index 0000000..f833e9c --- /dev/null +++ b/cartridges/domains/gaming/ums-mcp/abi/UmsMcp/SafeUms.idr @@ -0,0 +1,256 @@ +-- SPDX-License-Identifier: MPL-2.0 +-- Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +||| UmsMcp.SafeUms: Formally verified level lifecycle operations for IDApTIK +||| Level Architect (UMS β€” Universal Map System). +||| +||| Cartridge: ums-mcp +||| Matrix cell: Cloud domain x {MCP, REST} protocols +||| +||| This module defines type-safe level architect operations with a +||| session state machine that prevents: +||| - Level operations without an open project +||| - Saves without prior validation +||| - Validation on unloaded levels +||| - Project deletion while a level is loaded +||| +||| State machine: +||| Idle -> ProjectOpen -> LevelLoaded -> Validating -> Valid | Invalid +||| Valid -> Saved +||| Any -> Idle (close project) +module UmsMcp.SafeUms + +import Data.List + +%default total + +-- ═══════════════════════════════════════════════════════════════════════════ +-- Session State Machine +-- ═══════════════════════════════════════════════════════════════════════════ + +||| UMS session lifecycle states. +||| A session progresses: Idle -> ProjectOpen -> LevelLoaded -> Validating -> Valid/Invalid +||| Valid -> Saved. Any state can transition back to Idle (close project). +public export +data UmsState + = Idle -- No project open + | ProjectOpen -- Project loaded, no level selected + | LevelLoaded -- A level is loaded into the editor + | Validating -- ABI validation in progress + | Valid -- Level passed ABI validation + | Invalid -- Level failed ABI validation + | Saved -- Level saved to disk after validation + +||| Equality for UMS states. +public export +Eq UmsState where + Idle == Idle = True + ProjectOpen == ProjectOpen = True + LevelLoaded == LevelLoaded = True + Validating == Validating = True + Valid == Valid = True + Invalid == Invalid = True + Saved == Saved = True + _ == _ = False + +||| Valid state transitions (enforced at the type level). +public export +data ValidTransition : UmsState -> UmsState -> Type where + OpenProject : ValidTransition Idle ProjectOpen + LoadLevel : ValidTransition ProjectOpen LevelLoaded + BeginValidation : ValidTransition LevelLoaded Validating + PassValidation : ValidTransition Validating Valid + FailValidation : ValidTransition Validating Invalid + SaveLevel : ValidTransition Valid Saved + ReloadAfterSave : ValidTransition Saved LevelLoaded + ReloadInvalid : ValidTransition Invalid LevelLoaded + UnloadLevel : ValidTransition LevelLoaded ProjectOpen + CloseProject : ValidTransition ProjectOpen Idle + -- Emergency close from any loaded state + ForceCloseLoad : ValidTransition LevelLoaded Idle + ForceCloseValid : ValidTransition Validating Idle + ForceCloseOk : ValidTransition Valid Idle + ForceCloseFail : ValidTransition Invalid Idle + ForceCloseSaved : ValidTransition Saved Idle + +||| Runtime transition validator. +public export +canTransition : UmsState -> UmsState -> Bool +canTransition Idle ProjectOpen = True +canTransition ProjectOpen LevelLoaded = True +canTransition LevelLoaded Validating = True +canTransition Validating Valid = True +canTransition Validating Invalid = True +canTransition Valid Saved = True +canTransition Saved LevelLoaded = True +canTransition Invalid LevelLoaded = True +canTransition LevelLoaded ProjectOpen = True +canTransition ProjectOpen Idle = True +-- Force close from any loaded state +canTransition LevelLoaded Idle = True +canTransition Validating Idle = True +canTransition Valid Idle = True +canTransition Invalid Idle = True +canTransition Saved Idle = True +canTransition _ _ = False + +-- ═══════════════════════════════════════════════════════════════════════════ +-- UMS Resource Types +-- ═══════════════════════════════════════════════════════════════════════════ + +||| Resources managed by UMS. +public export +data UmsResource + = UmsProject -- A level-architect project (contains levels) + | UmsLevel -- A single level within a project + | UmsTemplate -- A reusable level template + | UmsConfig -- Level configuration / export data + +||| C-ABI encoding for UMS resource types. +public export +umsResourceToInt : UmsResource -> Int +umsResourceToInt UmsProject = 1 +umsResourceToInt UmsLevel = 2 +umsResourceToInt UmsTemplate = 3 +umsResourceToInt UmsConfig = 4 + +-- ═══════════════════════════════════════════════════════════════════════════ +-- Validation Result +-- ═══════════════════════════════════════════════════════════════════════════ + +||| ABI validation result detail. +public export +data ValidationSeverity = VError | VWarning | VInfo + +||| C-ABI encoding for validation severity. +public export +severityToInt : ValidationSeverity -> Int +severityToInt VError = 0 +severityToInt VWarning = 1 +severityToInt VInfo = 2 + +-- ═══════════════════════════════════════════════════════════════════════════ +-- Session Record +-- ═══════════════════════════════════════════════════════════════════════════ + +||| A UMS session with tracked state. +public export +record UmsSession where + constructor MkUmsSession + sessionId : String + state : UmsState + projectName : String + levelName : String + +||| Proof that a session has a project open (ready for level operations). +public export +data HasProject : UmsSession -> Type where + ActiveProject : (s : UmsSession) -> + (state s = ProjectOpen) -> + HasProject s + +||| Proof that a session has a level loaded (ready for validation). +public export +data HasLevel : UmsSession -> Type where + ActiveLevel : (s : UmsSession) -> + (state s = LevelLoaded) -> + HasLevel s + +-- ═══════════════════════════════════════════════════════════════════════════ +-- MCP Tool Definitions +-- ═══════════════════════════════════════════════════════════════════════════ + +||| MCP tools exposed by this cartridge. +||| These map to the 10 MCP tools that AI agents can call. +public export +data McpTool + = ToolLoadLevel -- Load a level into the editor + | ToolSaveLevel -- Save level (requires Valid state) + | ToolValidateLevelAbi -- Run Idris2 ABI validation on level data + | ToolListLevels -- List levels in current project + | ToolExportLevelConfig -- Export level configuration + | ToolCreateProject -- Create a new project + | ToolOpenProject -- Open an existing project + | ToolDeleteProject -- Delete a project (requires Idle or ProjectOpen) + | ToolLoadTemplates -- Load available templates + | ToolInstantiateTemplate -- Create a level from a template + +||| MCP tool name (for JSON-RPC method name). +public export +toolName : McpTool -> String +toolName ToolLoadLevel = "ums/load_level" +toolName ToolSaveLevel = "ums/save_level" +toolName ToolValidateLevelAbi = "ums/validate_level_abi" +toolName ToolListLevels = "ums/list_levels" +toolName ToolExportLevelConfig = "ums/export_level_config" +toolName ToolCreateProject = "ums/create_project" +toolName ToolOpenProject = "ums/open_project" +toolName ToolDeleteProject = "ums/delete_project" +toolName ToolLoadTemplates = "ums/load_templates" +toolName ToolInstantiateTemplate = "ums/instantiate_template" + +||| Which tools require a project to be open. +public export +requiresProject : McpTool -> Bool +requiresProject ToolCreateProject = False +requiresProject ToolOpenProject = False +requiresProject ToolDeleteProject = False +requiresProject ToolLoadTemplates = False +requiresProject _ = True + +||| Which tools require a level to be loaded. +public export +requiresLevel : McpTool -> Bool +requiresLevel ToolSaveLevel = True +requiresLevel ToolValidateLevelAbi = True +requiresLevel ToolExportLevelConfig = True +requiresLevel _ = False + +-- ═══════════════════════════════════════════════════════════════════════════ +-- C-ABI Exports +-- ═══════════════════════════════════════════════════════════════════════════ + +||| Session state to integer. +public export +umsStateToInt : UmsState -> Int +umsStateToInt Idle = 0 +umsStateToInt ProjectOpen = 1 +umsStateToInt LevelLoaded = 2 +umsStateToInt Validating = 3 +umsStateToInt Valid = 4 +umsStateToInt Invalid = 5 +umsStateToInt Saved = 6 + +||| Integer to session state. +public export +intToUmsState : Int -> UmsState +intToUmsState 0 = Idle +intToUmsState 1 = ProjectOpen +intToUmsState 2 = LevelLoaded +intToUmsState 3 = Validating +intToUmsState 4 = Valid +intToUmsState 5 = Invalid +intToUmsState 6 = Saved +intToUmsState _ = Idle + +||| FFI: Validate a state transition. +export +ums_can_transition : Int -> Int -> Int +ums_can_transition from to = + if canTransition (intToUmsState from) (intToUmsState to) then 1 else 0 + +||| FFI: Check if a tool requires a project. +export +ums_tool_requires_project : Int -> Int +ums_tool_requires_project 5 = 0 -- ToolCreateProject +ums_tool_requires_project 6 = 0 -- ToolOpenProject +ums_tool_requires_project 7 = 0 -- ToolDeleteProject +ums_tool_requires_project 8 = 0 -- ToolLoadTemplates +ums_tool_requires_project _ = 1 -- All others require project + +||| FFI: Check if a tool requires a level. +export +ums_tool_requires_level : Int -> Int +ums_tool_requires_level 1 = 1 -- ToolSaveLevel +ums_tool_requires_level 2 = 1 -- ToolValidateLevelAbi +ums_tool_requires_level 4 = 1 -- ToolExportLevelConfig +ums_tool_requires_level _ = 0 diff --git a/cartridges/domains/gaming/ums-mcp/abi/ums-mcp.ipkg b/cartridges/domains/gaming/ums-mcp/abi/ums-mcp.ipkg new file mode 100644 index 0000000..3905c03 --- /dev/null +++ b/cartridges/domains/gaming/ums-mcp/abi/ums-mcp.ipkg @@ -0,0 +1,11 @@ +-- SPDX-License-Identifier: MPL-2.0 +package umsmcp + +authors = "Jonathan D.A. Jewell" +version = 0.1.0 +license = "MPL-2.0" +brief = "UMS MCP cartridge β€” level lifecycle state machine with ABI validation" + +sourcedir = "." +modules = UmsMcp.SafeUms +depends = base, contrib diff --git a/cartridges/domains/gaming/ums-mcp/adapter/README.adoc b/cartridges/domains/gaming/ums-mcp/adapter/README.adoc new file mode 100644 index 0000000..e949d5d --- /dev/null +++ b/cartridges/domains/gaming/ums-mcp/adapter/README.adoc @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += ums-mcp / adapter β€” protocol bridge +:orientation: shallow + +== Purpose + +Exposes this cartridge's FFI over the wire protocols declared in +`cartridge.json`. Dispatches tool invocations to the C-ABI exports at +`../ffi/` and formats responses per protocol. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” depends on the sibling `ffi` target. +| `ums_adapter.zig` | Protocol dispatch (144 lines). Tool catalogue matches `cartridge.json`. +| `SIDELINED-*.v.adoc` | zig predecessor(s). Not built β€” zig banned 2026-04-10. +|=== + +== Invariants + +* **Stateless** β€” all state lives behind the FFI, never in the adapter. +* **Response passthrough** β€” whatever the FFI returns goes back to the wire + unmodified (no embellishment, no silent recovery). +* **Cartridge.json is source of truth** for the tool catalogue; drift between + adapter and manifest is a CI failure. + +== Test/proof surface + +No inline tests at the adapter layer β€” protocol routing is exercised by the +trunk seam tests at `ffi/zig/src/seams.zig`. + +== Read-first + +. `ums_adapter.zig` β€” dispatch function and per-protocol routers. +. `../cartridge.json` β€” the tool manifest the dispatcher mirrors. diff --git a/cartridges/domains/gaming/ums-mcp/adapter/build.zig b/cartridges/domains/gaming/ums-mcp/adapter/build.zig new file mode 100644 index 0000000..fda785b --- /dev/null +++ b/cartridges/domains/gaming/ums-mcp/adapter/build.zig @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// ums-mcp/adapter/build.zig + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const ffi_mod = b.createModule(.{ + .root_source_file = b.path("../ffi/ums_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + const adapter = b.addExecutable(.{ + .name = "ums_adapter", + .root_source_file = b.path("ums_adapter.zig"), + .target = target, + .optimize = optimize, + }); + adapter.root_module.addImport("ums_ffi", ffi_mod); + b.installArtifact(adapter); +} diff --git a/cartridges/domains/gaming/ums-mcp/adapter/ums_adapter.zig b/cartridges/domains/gaming/ums-mcp/adapter/ums_adapter.zig new file mode 100644 index 0000000..ba2d8c1 --- /dev/null +++ b/cartridges/domains/gaming/ums-mcp/adapter/ums_adapter.zig @@ -0,0 +1,144 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// ums-mcp/adapter/ums_adapter.zig +// +// Three-protocol BoJ adapter: REST (port 9295), gRPC-compat (port 9296), +// GraphQL (port 9297). +// Replaces the banned zig adapter (ums_adapter.v). + +const std = @import("std"); +const ffi = @import("ums_ffi"); + +const REST_PORT: u16 = 9295; +const GRPC_PORT: u16 = 9296; +const GQL_PORT: u16 = 9297; + +const Response = struct { status: u16, body: []const u8 }; + +fn okJson(buf: []u8, msg: []const u8) []u8 { + return std.fmt.bufPrint(buf, "{{\"success\":true,\"message\":\"{s}\"}}", .{msg}) catch buf[0..0]; +} + +fn errJson(buf: []u8, msg: []const u8) []u8 { + return std.fmt.bufPrint(buf, "{{\"success\":false,\"error\":\"{s}\"}}", .{msg}) catch buf[0..0]; +} + +fn dispatch(tool: []const u8, _body: []const u8, resp: []u8) Response { + _ = _body; + if (std.mem.eql(u8, tool, "ums_create_project")) { + return .{ .status = 200, .body = okJson(resp, "ums_create_project forwarded") }; + } + if (std.mem.eql(u8, tool, "ums_open_project")) { + return .{ .status = 200, .body = okJson(resp, "ums_open_project forwarded") }; + } + if (std.mem.eql(u8, tool, "ums_close_project")) { + return .{ .status = 200, .body = okJson(resp, "ums_close_project forwarded") }; + } + if (std.mem.eql(u8, tool, "ums_load_level")) { + return .{ .status = 200, .body = okJson(resp, "ums_load_level forwarded") }; + } + if (std.mem.eql(u8, tool, "ums_save_level")) { + return .{ .status = 200, .body = okJson(resp, "ums_save_level forwarded") }; + } + if (std.mem.eql(u8, tool, "ums_validate_level")) { + return .{ .status = 200, .body = okJson(resp, "ums_validate_level forwarded") }; + } + if (std.mem.eql(u8, tool, "ums_list_profiles")) { + return .{ .status = 200, .body = okJson(resp, "ums_list_profiles forwarded") }; + } + return .{ .status = 404, .body = errJson(resp, "unknown tool") }; +} + +fn dispatchRest(path: []const u8, body: []const u8, resp: []u8) Response { + // Expect /tools/<tool_name> + const prefix = "/tools/"; + if (std.mem.startsWith(u8, path, prefix)) { + const tool = path[prefix.len..]; + return dispatch(tool, body, resp); + } + return .{ .status = 404, .body = errJson(resp, "not found") }; +} + +fn dispatchGrpc(path: []const u8, body: []const u8, resp: []u8) Response { + // Expect /<Service>/<Method> β€” derive tool from Method + var it = std.mem.splitScalar(u8, path, '/'); + _ = it.next(); // leading empty + _ = it.next(); // service + const method = it.next() orelse return .{ .status = 404, .body = errJson(resp, "bad gRPC path") }; + return dispatch(method, body, resp); +} + +fn dispatchGraphql(body: []const u8, resp: []u8) Response { + if (std.mem.indexOf(u8, body, "ums_create_project") != null) + return dispatch("ums_create_project", body, resp); + if (std.mem.indexOf(u8, body, "ums_open_project") != null) + return dispatch("ums_open_project", body, resp); + if (std.mem.indexOf(u8, body, "ums_close_project") != null) + return dispatch("ums_close_project", body, resp); + if (std.mem.indexOf(u8, body, "ums_load_level") != null) + return dispatch("ums_load_level", body, resp); + if (std.mem.indexOf(u8, body, "ums_save_level") != null) + return dispatch("ums_save_level", body, resp); + if (std.mem.indexOf(u8, body, "ums_validate_level") != null) + return dispatch("ums_validate_level", body, resp); + if (std.mem.indexOf(u8, body, "ums_list_profiles") != null) + return dispatch("ums_list_profiles", body, resp); + return .{ .status = 400, .body = errJson(resp, "unrecognised GraphQL operation") }; +} + +fn handleConnection(stream: std.net.Stream, port: u16) void { + defer stream.close(); + var buf: [4096]u8 = undefined; + var resp_buf: [4096]u8 = undefined; + const n = stream.read(&buf) catch return; + const req = buf[0..n]; + const result = switch (port) { + REST_PORT => blk: { + // Parse HTTP/1.1: first line = METHOD PATH HTTP/x.y + var lines = std.mem.splitScalar(u8, req, '\n'); + const first = lines.next() orelse break :blk Response{ .status = 400, .body = "" }; + var parts = std.mem.splitScalar(u8, std.mem.trim(u8, first, "\r"), ' '); + _ = parts.next(); // method + const path = parts.next() orelse break :blk Response{ .status = 400, .body = "" }; + break :blk dispatchRest(path, req, &resp_buf); + }, + GRPC_PORT => blk: { + var lines = std.mem.splitScalar(u8, req, '\n'); + const first = lines.next() orelse break :blk Response{ .status = 400, .body = "" }; + var parts = std.mem.splitScalar(u8, std.mem.trim(u8, first, "\r"), ' '); + _ = parts.next(); + const path = parts.next() orelse break :blk Response{ .status = 400, .body = "" }; + break :blk dispatchGrpc(path, req, &resp_buf); + }, + GQL_PORT => dispatchGraphql(req, &resp_buf), + else => Response{ .status = 500, .body = "" }, + }; + var http_resp: [512]u8 = undefined; + const http = std.fmt.bufPrint(&http_resp, + "HTTP/1.1 {d} OK\r\nContent-Length: {d}\r\nContent-Type: application/json\r\n\r\n", + .{ result.status, result.body.len }) catch return; + _ = stream.write(http) catch {}; + _ = stream.write(result.body) catch {}; +} + +fn listenLoop(port: u16) !void { + const addr = std.net.Address.initIp4(.{ 127, 0, 0, 1 }, port); + var server = try addr.listen(.{ .reuse_address = true }); + defer server.deinit(); + while (true) { + const conn = try server.accept(); + const t = try std.Thread.spawn(.{}, handleConnection, .{ conn.stream, port }); + t.detach(); + } +} + +pub fn main() !void { + ffi.ums_init(); + const t1 = try std.Thread.spawn(.{}, listenLoop, .{REST_PORT}); + const t2 = try std.Thread.spawn(.{}, listenLoop, .{GRPC_PORT}); + const t3 = try std.Thread.spawn(.{}, listenLoop, .{GQL_PORT}); + t1.join(); + t2.join(); + t3.join(); +} diff --git a/cartridges/domains/gaming/ums-mcp/cartridge.json b/cartridges/domains/gaming/ums-mcp/cartridge.json new file mode 100644 index 0000000..c7a0f3a --- /dev/null +++ b/cartridges/domains/gaming/ums-mcp/cartridge.json @@ -0,0 +1,149 @@ +{ + "$schema": "https://boj.dev/schemas/cartridge/v1.json", + "spdx": "MPL-2.0", + "copyright": "Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk>", + "name": "ums-mcp", + "version": "0.1.0", + "description": "Universal Map Specification β€” level editor and validator", + "domain": "gaming", + "tier": "Ayo", + "protocols": [ + "MCP", + "REST" + ], + "auth": { + "method": "none", + "env_var": null, + "credential_source": null + }, + "api": { + "base_url": "local://ums-mcp", + "content_type": "application/json" + }, + "tools": [ + { + "name": "ums_create_project", + "description": "Create a new UMS project", + "inputSchema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Project name" + }, + "profile": { + "type": "string", + "description": "Game profile" + } + }, + "required": [ + "name" + ] + } + }, + { + "name": "ums_open_project", + "description": "Open an existing UMS project", + "inputSchema": { + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "Project directory path" + } + }, + "required": [ + "path" + ] + } + }, + { + "name": "ums_close_project", + "description": "Close the current project", + "inputSchema": { + "type": "object", + "properties": { + "session_id": { + "type": "integer", + "description": "Session index" + } + }, + "required": [ + "session_id" + ] + } + }, + { + "name": "ums_load_level", + "description": "Load a level into the editor", + "inputSchema": { + "type": "object", + "properties": { + "session_id": { + "type": "integer", + "description": "Session index" + }, + "level_name": { + "type": "string", + "description": "Level name" + } + }, + "required": [ + "session_id", + "level_name" + ] + } + }, + { + "name": "ums_save_level", + "description": "Save the current level", + "inputSchema": { + "type": "object", + "properties": { + "session_id": { + "type": "integer", + "description": "Session index" + } + }, + "required": [ + "session_id" + ] + } + }, + { + "name": "ums_validate_level", + "description": "Validate level against UMS schema", + "inputSchema": { + "type": "object", + "properties": { + "session_id": { + "type": "integer", + "description": "Session index" + } + }, + "required": [ + "session_id" + ] + } + }, + { + "name": "ums_list_profiles", + "description": "List available game profiles", + "inputSchema": { + "type": "object", + "properties": {} + } + } + ], + "ffi": { + "so_path": "ffi/zig-out/lib/libums_mcp.so", + "abi_version": "ADR-0006", + "symbols": [ + "boj_cartridge_init", + "boj_cartridge_deinit", + "boj_cartridge_name", + "boj_cartridge_version", + "boj_cartridge_invoke" + ] + } +} diff --git a/cartridges/domains/gaming/ums-mcp/ffi/README.adoc b/cartridges/domains/gaming/ums-mcp/ffi/README.adoc new file mode 100644 index 0000000..5990d65 --- /dev/null +++ b/cartridges/domains/gaming/ums-mcp/ffi/README.adoc @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += ums-mcp / ffi β€” Zig FFI layer **(stub)** +:orientation: shallow + +== Purpose + +C-ABI exports bridging this cartridge's Idris2 ABI to the runtime. Consumed by +the adapter for protocol dispatch and by the trunk loader for mount. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” produces `zig-out/lib/libums.so`. +| `ums_ffi.zig` | C-ABI exports (15 exports, 6 inline tests, 360 lines). +|=== + +== Invariants + +* **Null-pointer rejection** on `[*c]const u8` arguments before any + dereference. +* **Negative return values** carry documented error meanings; zero is + success (matching the trunk error-code convention). +* **No allocator leaks** β€” every allocation path pairs with `defer free`. + +== Test/proof surface + +6 inline `test "..."` block(s) in `ums_ffi.zig`. Run with +`cd ffi && zig build test`. + +== Read-first + +. `ums_ffi.zig` β€” C-ABI signatures and guards. +. Trunk `ffi/zig/src/catalogue.zig` for how this `.so` is loaded. + +== Promotion gate + +Wire these exports to real backing state (not `return 0` / `return 1` +placeholders) before promoting past D. diff --git a/cartridges/domains/gaming/ums-mcp/ffi/build.zig b/cartridges/domains/gaming/ums-mcp/ffi/build.zig new file mode 100644 index 0000000..b465d33 --- /dev/null +++ b/cartridges/domains/gaming/ums-mcp/ffi/build.zig @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// UMS-MCP Cartridge β€” Zig FFI build configuration (Zig 0.15+). + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + // Shared ADR-0006 invoke-shim module (relative path up to boj-server trunk). + + const shim_mod = b.addModule("cartridge_shim", .{ + + .root_source_file = b.path("../../../ffi/zig/src/cartridge_shim.zig"), + + .target = target, + + .optimize = optimize, + + }); + + const ums_mod = b.addModule("ums_ffi", .{ + .root_source_file = b.path("ums_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + ums_mod.addImport("cartridge_shim", shim_mod); + + // ── Tests ──────────────────────────────────────────────────────── + const ums_tests = b.addTest(.{ + .root_module = ums_mod, + }); + + const run_tests = b.addRunArtifact(ums_tests); + + const test_step = b.step("test", "Run ums-mcp FFI tests"); + test_step.dependOn(&run_tests.step); + + // ── Shared library ────────────────────────────────────────────── + const lib_mod = b.createModule(.{ + .root_source_file = b.path("ums_ffi.zig"), + .target = target, + .optimize = optimize, + }); + lib_mod.addImport("cartridge_shim", shim_mod); + + const lib = b.addLibrary(.{ + .name = "ums_mcp", + .root_module = lib_mod, + .linkage = .dynamic, + }); + b.installArtifact(lib); + + const lib_step = b.step("lib", "Build shared library"); + lib_step.dependOn(&lib.step); +} diff --git a/cartridges/domains/gaming/ums-mcp/ffi/cartridge_shim.zig b/cartridges/domains/gaming/ums-mcp/ffi/cartridge_shim.zig new file mode 100644 index 0000000..0e399db --- /dev/null +++ b/cartridges/domains/gaming/ums-mcp/ffi/cartridge_shim.zig @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// cartridge_shim.zig β€” Shared helpers for the ADR-0006 five-symbol +// cartridge ABI (`boj_cartridge_init / deinit / name / version / invoke`). +// +// The shim centralises the seven-code return convention, NUL-argument +// guards, tool-name comparison, and the buffer-too-small path so each +// cartridge's `boj_cartridge_invoke` can stay short β€” typically a tool +// table plus `shim.writeResult(...)`. +// +// Cartridges import this file by relative path (no build-graph change +// needed). Example: +// +// const shim = @import("../../../ffi/zig/src/cartridge_shim.zig"); +// +// export fn boj_cartridge_invoke( +// tool_name: [*c]const u8, +// json_args: [*c]const u8, +// out_buf: [*c]u8, +// in_out_len: [*c]usize, +// ) callconv(.c) i32 { +// _ = json_args; +// if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; +// const body = if (shim.toolIs(tool_name, "foo")) "{\"result\":{}}" +// else return shim.RC_UNKNOWN_TOOL; +// return shim.writeResult(out_buf, in_out_len, body); +// } + +const std = @import("std"); + +// ── Return codes (ADR-0006 Β§Return codes) ──────────────────────────── +// +// Frozen by ADR-0006. New failure modes compose these via the error +// JSON body β€” the integer surface does not grow without a follow-up ADR. + +pub const RC_SUCCESS: i32 = 0; +pub const RC_UNKNOWN_TOOL: i32 = -1; +pub const RC_BAD_ARGS: i32 = -2; +pub const RC_BUFFER_TOO_SMALL: i32 = -3; +pub const RC_RUNTIME_ERROR: i32 = -4; +pub const RC_PANIC: i32 = -5; +pub const RC_AUTH_DENIED: i32 = -6; + +// ── Invoke-path helpers ────────────────────────────────────────────── + +/// True if any of the three mandatory `boj_cartridge_invoke` output-path +/// pointers is null. Use at the top of every invoke to short-circuit to +/// `RC_BAD_ARGS`. +pub fn invokeArgsNull( + tool_name: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) bool { + return tool_name == null or out_buf == null or in_out_len == null; +} + +/// Compare a C-NUL-terminated tool-name pointer against a Zig string +/// literal. Caller must have already verified `tool_name` is non-null +/// (usually via `invokeArgsNull`). +pub fn toolIs(tool_name: [*c]const u8, expected: []const u8) bool { + const s = std.mem.span(@as([*:0]const u8, @ptrCast(tool_name))); + return std.mem.eql(u8, s, expected); +} + +/// Copy `body` into `out_buf[0..*in_out_len]` (as a capacity) and update +/// `*in_out_len` to the number of bytes written. Returns `RC_SUCCESS`. +/// +/// If `body.len` exceeds the current capacity stored in `*in_out_len`, +/// sets `*in_out_len` to the required size and returns +/// `RC_BUFFER_TOO_SMALL` β€” the caller is then expected to re-allocate +/// and retry, per ADR-0006 Β§Memory ownership. +/// +/// Caller must have already verified that `out_buf` and `in_out_len` +/// are non-null. +pub fn writeResult( + out_buf: [*c]u8, + in_out_len: [*c]usize, + body: []const u8, +) i32 { + const cap = in_out_len.*; + if (body.len > cap) { + in_out_len.* = body.len; + return RC_BUFFER_TOO_SMALL; + } + @memcpy(out_buf[0..body.len], body); + in_out_len.* = body.len; + return RC_SUCCESS; +} + +// ── Tests ──────────────────────────────────────────────────────────── + +test "writeResult: body fits, writes and sets length" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); + try std.testing.expectEqualStrings("hello", buf[0..len]); +} + +test "writeResult: too small returns -3 and sets required length" { + var buf: [2]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_BUFFER_TOO_SMALL, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: exact-fit succeeds" { + var buf: [5]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: empty body" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, ""); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 0), len); +} + +test "toolIs: matches and rejects" { + const name: [*:0]const u8 = "foo"; + try std.testing.expect(toolIs(@ptrCast(name), "foo")); + try std.testing.expect(!toolIs(@ptrCast(name), "bar")); + try std.testing.expect(!toolIs(@ptrCast(name), "foobar")); + try std.testing.expect(!toolIs(@ptrCast(name), "fo")); +} + +test "invokeArgsNull: detects each null slot" { + var buf: [4]u8 = undefined; + var len: usize = 4; + const name: [*:0]const u8 = "x"; + try std.testing.expect(!invokeArgsNull(@ptrCast(name), &buf, &len)); + try std.testing.expect(invokeArgsNull(null, &buf, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), null, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), &buf, null)); +} diff --git a/cartridges/domains/gaming/ums-mcp/ffi/ums_ffi.zig b/cartridges/domains/gaming/ums-mcp/ffi/ums_ffi.zig new file mode 100644 index 0000000..6bdab6c --- /dev/null +++ b/cartridges/domains/gaming/ums-mcp/ffi/ums_ffi.zig @@ -0,0 +1,495 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// UMS-MCP Cartridge β€” Zig FFI bridge for level architect operations. +// +// Implements the level lifecycle state machine from SafeUms.idr. +// Ensures no level operation can execute without an open project, +// no save without validation, and tracks the full lifecycle: +// Idle -> ProjectOpen -> LevelLoaded -> Validating -> Valid/Invalid -> Saved + +const std = @import("std"); + +// ═══════════════════════════════════════════════════════════════════════ +// Types (must match UmsMcp.SafeUms encodings) +// ═══════════════════════════════════════════════════════════════════════ + +pub const UmsState = enum(c_int) { + idle = 0, + project_open = 1, + level_loaded = 2, + validating = 3, + valid = 4, + invalid = 5, + saved = 6, +}; + +pub const UmsResource = enum(c_int) { + project = 1, + level = 2, + template = 3, + config = 4, +}; + +pub const ValidationSeverity = enum(c_int) { + err = 0, + warning = 1, + info = 2, +}; + +// ═══════════════════════════════════════════════════════════════════════ +// Session State Machine +// ═══════════════════════════════════════════════════════════════════════ + +const MAX_SESSIONS: usize = 8; +const RESULT_BUF_SIZE: usize = 8192; +const MAX_NAME_LEN: usize = 256; + +const SessionSlot = struct { + active: bool = false, + state: UmsState = .idle, + project_name: [MAX_NAME_LEN]u8 = [_]u8{0} ** MAX_NAME_LEN, + project_name_len: usize = 0, + level_name: [MAX_NAME_LEN]u8 = [_]u8{0} ** MAX_NAME_LEN, + level_name_len: usize = 0, + validation_errors: u32 = 0, + validation_warnings: u32 = 0, + result_buf: [RESULT_BUF_SIZE]u8 = [_]u8{0} ** RESULT_BUF_SIZE, + result_len: usize = 0, +}; + +var sessions: [MAX_SESSIONS]SessionSlot = [_]SessionSlot{.{}} ** MAX_SESSIONS; +/// Single coarse-grained mutex over the `sessions` table. The C-ABI +/// boundary is the concurrency boundary: every exported function takes +/// this lock for the duration of its slot access, so callers from any +/// thread (including the MCP bridge's tokio-style task pool) see a +/// linearised view of session state. +var sessions_mu: std.Thread.Mutex = .{}; + +/// Find an inactive session slot and activate it. Caller must hold +/// `sessions_mu`. +fn alloc_slot() ?usize { + for (&sessions, 0..) |*slot, i| { + if (!slot.active) { + slot.* = .{}; + slot.active = true; + return i; + } + } + return null; +} + +/// Write a JSON string into a session's result buffer. +fn write_result(slot: *SessionSlot, data: []const u8) void { + const len = @min(data.len, RESULT_BUF_SIZE); + @memcpy(slot.result_buf[0..len], data[0..len]); + slot.result_len = len; +} + +/// Format a simple JSON object into the result buffer. +fn write_json_status(slot: *SessionSlot, status: []const u8, message: []const u8) void { + var buf: [RESULT_BUF_SIZE]u8 = undefined; + const result = std.fmt.bufPrint(&buf, "{{\"status\":\"{s}\",\"message\":\"{s}\"}}", .{ status, message }) catch { + write_result(slot, "{\"status\":\"error\",\"message\":\"format overflow\"}"); + return; + }; + write_result(slot, result); +} + +// ═══════════════════════════════════════════════════════════════════════ +// State Transition Validation +// ═══════════════════════════════════════════════════════════════════════ + +fn can_transition(from: UmsState, to: UmsState) bool { + return switch (from) { + .idle => to == .project_open, + .project_open => to == .level_loaded or to == .idle, + .level_loaded => to == .validating or to == .project_open or to == .idle, + .validating => to == .valid or to == .invalid or to == .idle, + .valid => to == .saved or to == .idle, + .invalid => to == .level_loaded or to == .idle, + .saved => to == .level_loaded or to == .idle, + }; +} + +fn try_transition(slot: *SessionSlot, target: UmsState) bool { + if (can_transition(slot.state, target)) { + slot.state = target; + return true; + } + return false; +} + +// ═══════════════════════════════════════════════════════════════════════ +// C-ABI Exported Functions +// ═══════════════════════════════════════════════════════════════════════ + +/// Validate a state transition (mirrors Idris2 canTransition). +pub export fn ums_can_transition(from: c_int, to: c_int) callconv(.c) c_int { + const from_state: UmsState = @enumFromInt(from); + const to_state: UmsState = @enumFromInt(to); + return if (can_transition(from_state, to_state)) 1 else 0; +} + +/// Create a new project. Returns slot index or -1 on failure. +pub export fn ums_create_project(name_ptr: [*]const u8, name_len: usize) callconv(.c) c_int { + sessions_mu.lock(); + defer sessions_mu.unlock(); + const idx = alloc_slot() orelse return -1; + const slot = &sessions[idx]; + const copy_len = @min(name_len, MAX_NAME_LEN); + @memcpy(slot.project_name[0..copy_len], name_ptr[0..copy_len]); + slot.project_name_len = copy_len; + slot.state = .project_open; + write_json_status(slot, "ok", "project created"); + return @intCast(idx); +} + +/// Open an existing project. Returns slot index or -1 on failure. +pub export fn ums_open_project(name_ptr: [*]const u8, name_len: usize) callconv(.c) c_int { + sessions_mu.lock(); + defer sessions_mu.unlock(); + const idx = alloc_slot() orelse return -1; + const slot = &sessions[idx]; + const copy_len = @min(name_len, MAX_NAME_LEN); + @memcpy(slot.project_name[0..copy_len], name_ptr[0..copy_len]); + slot.project_name_len = copy_len; + slot.state = .project_open; + write_json_status(slot, "ok", "project opened"); + return @intCast(idx); +} + +/// Delete a project. Requires idle or project_open state. Returns 0 on success. +pub export fn ums_delete_project(slot_idx: c_int) callconv(.c) c_int { + sessions_mu.lock(); + defer sessions_mu.unlock(); + if (slot_idx < 0 or slot_idx >= MAX_SESSIONS) return -1; + const idx: usize = @intCast(slot_idx); + const slot = &sessions[idx]; + if (!slot.active) return -1; + if (slot.state != .idle and slot.state != .project_open) return -2; + slot.* = .{}; + return 0; +} + +/// Load a level in the current project. Returns 0 on success. +pub export fn ums_load_level(slot_idx: c_int, name_ptr: [*]const u8, name_len: usize) callconv(.c) c_int { + sessions_mu.lock(); + defer sessions_mu.unlock(); + if (slot_idx < 0 or slot_idx >= MAX_SESSIONS) return -1; + const idx: usize = @intCast(slot_idx); + const slot = &sessions[idx]; + if (!slot.active) return -1; + if (!try_transition(slot, .level_loaded)) return -2; + const copy_len = @min(name_len, MAX_NAME_LEN); + @memcpy(slot.level_name[0..copy_len], name_ptr[0..copy_len]); + slot.level_name_len = copy_len; + write_json_status(slot, "ok", "level loaded"); + return 0; +} + +/// Save the current level. Requires Valid state. Returns 0 on success. +pub export fn ums_save_level(slot_idx: c_int) callconv(.c) c_int { + sessions_mu.lock(); + defer sessions_mu.unlock(); + if (slot_idx < 0 or slot_idx >= MAX_SESSIONS) return -1; + const idx: usize = @intCast(slot_idx); + const slot = &sessions[idx]; + if (!slot.active) return -1; + if (!try_transition(slot, .saved)) return -2; + write_json_status(slot, "ok", "level saved"); + return 0; +} + +/// Run ABI validation on the loaded level. Returns 0 (valid) or 1 (invalid). +pub export fn ums_validate_level_abi(slot_idx: c_int) callconv(.c) c_int { + sessions_mu.lock(); + defer sessions_mu.unlock(); + if (slot_idx < 0 or slot_idx >= MAX_SESSIONS) return -1; + const idx: usize = @intCast(slot_idx); + const slot = &sessions[idx]; + if (!slot.active) return -1; + if (!try_transition(slot, .validating)) return -2; + // Stub validation: always passes. Real implementation would check + // level data against the Idris2 ABI specification. + slot.validation_errors = 0; + slot.validation_warnings = 0; + _ = try_transition(slot, .valid); + write_json_status(slot, "valid", "ABI validation passed (0 errors, 0 warnings)"); + return 0; +} + +/// List levels in the current project. Returns count or -1 on error. +pub export fn ums_list_levels(slot_idx: c_int) callconv(.c) c_int { + sessions_mu.lock(); + defer sessions_mu.unlock(); + if (slot_idx < 0 or slot_idx >= MAX_SESSIONS) return -1; + const idx: usize = @intCast(slot_idx); + const slot = &sessions[idx]; + if (!slot.active) return -1; + if (slot.state == .idle) return -2; + write_result(slot, "{\"levels\":[],\"count\":0}"); + return 0; +} + +/// Export the level configuration as JSON. Returns 0 on success. +pub export fn ums_export_level_config(slot_idx: c_int) callconv(.c) c_int { + sessions_mu.lock(); + defer sessions_mu.unlock(); + if (slot_idx < 0 or slot_idx >= MAX_SESSIONS) return -1; + const idx: usize = @intCast(slot_idx); + const slot = &sessions[idx]; + if (!slot.active) return -1; + if (slot.state != .level_loaded and slot.state != .valid and + slot.state != .invalid and slot.state != .saved) return -2; + const level_name = slot.level_name[0..slot.level_name_len]; + var buf: [RESULT_BUF_SIZE]u8 = undefined; + const result = std.fmt.bufPrint(&buf, "{{\"level\":\"{s}\",\"config\":{{}}}}", .{level_name}) catch { + write_json_status(slot, "error", "format overflow"); + return -3; + }; + write_result(slot, result); + return 0; +} + +/// Load available templates. Returns count or -1 on error. +pub export fn ums_load_templates(slot_idx: c_int) callconv(.c) c_int { + sessions_mu.lock(); + defer sessions_mu.unlock(); + _ = slot_idx; + // Templates are global, no session state required. + return 0; +} + +/// Instantiate a level from a template. Returns 0 on success. +pub export fn ums_instantiate_template(slot_idx: c_int, tmpl_ptr: [*]const u8, tmpl_len: usize, name_ptr: [*]const u8, name_len: usize) callconv(.c) c_int { + sessions_mu.lock(); + defer sessions_mu.unlock(); + if (slot_idx < 0 or slot_idx >= MAX_SESSIONS) return -1; + const idx: usize = @intCast(slot_idx); + const slot = &sessions[idx]; + if (!slot.active) return -1; + if (slot.state != .project_open) return -2; + _ = tmpl_ptr; + _ = tmpl_len; + // Transition to level_loaded with the new level name + slot.state = .level_loaded; + const copy_len = @min(name_len, MAX_NAME_LEN); + @memcpy(slot.level_name[0..copy_len], name_ptr[0..copy_len]); + slot.level_name_len = copy_len; + write_json_status(slot, "ok", "template instantiated"); + return 0; +} + +/// Get the current session state. Returns state int or -1 if inactive. +pub export fn ums_state(slot_idx: c_int) callconv(.c) c_int { + sessions_mu.lock(); + defer sessions_mu.unlock(); + if (slot_idx < 0 or slot_idx >= MAX_SESSIONS) return -1; + const idx: usize = @intCast(slot_idx); + const slot = &sessions[idx]; + if (!slot.active) return -1; + return @intFromEnum(slot.state); +} + +/// Read the result buffer. Returns bytes written or 0. +pub export fn ums_read_result(slot_idx: c_int, out_ptr: [*]u8, out_cap: usize) callconv(.c) c_int { + sessions_mu.lock(); + defer sessions_mu.unlock(); + if (slot_idx < 0 or slot_idx >= MAX_SESSIONS) return 0; + const idx: usize = @intCast(slot_idx); + const slot = &sessions[idx]; + if (!slot.active) return 0; + const len = @min(slot.result_len, out_cap); + @memcpy(out_ptr[0..len], slot.result_buf[0..len]); + return @intCast(len); +} + +/// Close a session (force to idle and deactivate). +pub export fn ums_close(slot_idx: c_int) callconv(.c) c_int { + sessions_mu.lock(); + defer sessions_mu.unlock(); + if (slot_idx < 0 or slot_idx >= MAX_SESSIONS) return -1; + const idx: usize = @intCast(slot_idx); + sessions[idx] = .{}; + return 0; +} + +/// Reset all sessions (testing/teardown). +pub export fn ums_reset() callconv(.c) void { + sessions_mu.lock(); + defer sessions_mu.unlock(); + sessions = [_]SessionSlot{.{}} ** MAX_SESSIONS; +} + +// ═══════════════════════════════════════════════════════════════════════ +// Standard ABI (ADR-0005 four symbols + ADR-0006 invoke) +// ═══════════════════════════════════════════════════════════════════════ + +const shim = @import("cartridge_shim.zig"); + +const CARTRIDGE_NAME_PTR: [*:0]const u8 = "ums-mcp"; +const CARTRIDGE_VERSION_PTR: [*:0]const u8 = "0.1.0"; + +export fn boj_cartridge_init() callconv(.c) c_int { + return 0; +} + +export fn boj_cartridge_deinit() callconv(.c) void {} + +export fn boj_cartridge_name() callconv(.c) [*:0]const u8 { + return CARTRIDGE_NAME_PTR; +} + +export fn boj_cartridge_version() callconv(.c) [*:0]const u8 { + return CARTRIDGE_VERSION_PTR; +} + +/// Dispatch the cartridge.json MCP tools. Grade D Alpha stubs. +export fn boj_cartridge_invoke( + tool_name: [*c]const u8, + json_args: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) callconv(.c) i32 { + _ = json_args; + if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; + + const body: []const u8 = if (shim.toolIs(tool_name, "ums_create_project")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "ums_open_project")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "ums_close_project")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "ums_load_level")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "ums_save_level")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "ums_validate_level")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "ums_list_profiles")) + "{\"result\":{\"items\":[],\"count\":0,\"status\":\"stub\"}}" +else + return shim.RC_UNKNOWN_TOOL; + + return shim.writeResult(out_buf, in_out_len, body); +} + +// ═══════════════════════════════════════════════════════════════════════ +// Tests +// ═══════════════════════════════════════════════════════════════════════ + +test "state machine: full happy path" { + ums_reset(); + // Create project + const slot = ums_create_project("test-proj", 9); + try std.testing.expect(slot >= 0); + try std.testing.expectEqual(@as(c_int, 1), ums_state(slot)); // project_open + + // Load level + try std.testing.expectEqual(@as(c_int, 0), ums_load_level(slot, "level-01", 8)); + try std.testing.expectEqual(@as(c_int, 2), ums_state(slot)); // level_loaded + + // Validate + try std.testing.expectEqual(@as(c_int, 0), ums_validate_level_abi(slot)); + try std.testing.expectEqual(@as(c_int, 4), ums_state(slot)); // valid + + // Save + try std.testing.expectEqual(@as(c_int, 0), ums_save_level(slot)); + try std.testing.expectEqual(@as(c_int, 6), ums_state(slot)); // saved + + // Close + try std.testing.expectEqual(@as(c_int, 0), ums_close(slot)); +} + +test "state machine: cannot save without validation" { + ums_reset(); + const slot = ums_create_project("proj", 4); + _ = ums_load_level(slot, "lvl", 3); + // Try to save from level_loaded (should fail β€” must validate first) + try std.testing.expectEqual(@as(c_int, -2), ums_save_level(slot)); +} + +test "state machine: cannot load level without project" { + ums_reset(); + // Slot 0 is not active + try std.testing.expectEqual(@as(c_int, -1), ums_load_level(0, "lvl", 3)); +} + +test "transition validator" { + // Idle -> ProjectOpen: allowed + try std.testing.expectEqual(@as(c_int, 1), ums_can_transition(0, 1)); + // Idle -> LevelLoaded: not allowed + try std.testing.expectEqual(@as(c_int, 0), ums_can_transition(0, 2)); + // Valid -> Saved: allowed + try std.testing.expectEqual(@as(c_int, 1), ums_can_transition(4, 6)); + // LevelLoaded -> Saved: not allowed (must validate first) + try std.testing.expectEqual(@as(c_int, 0), ums_can_transition(2, 6)); +} + +test "read result buffer" { + ums_reset(); + const slot = ums_create_project("rp", 2); + var buf: [256]u8 = undefined; + const len = ums_read_result(slot, &buf, 256); + try std.testing.expect(len > 0); + const result = buf[0..@intCast(len)]; + try std.testing.expect(std.mem.indexOf(u8, result, "project created") != null); +} + +test "delete project only when idle or project_open" { + ums_reset(); + const slot = ums_create_project("dp", 2); + _ = ums_load_level(slot, "lv", 2); + // Cannot delete while level is loaded + try std.testing.expectEqual(@as(c_int, -2), ums_delete_project(slot)); + // Close and reopen + _ = ums_close(slot); +} + +// ═══════════════════════════════════════════════════════════════════════ +// ADR-0006 invoke dispatch tests +// ═══════════════════════════════════════════════════════════════════════ + +test "boj_cartridge_name returns ums-mcp" { + const n = std.mem.span(boj_cartridge_name()); + try std.testing.expectEqualStrings("ums-mcp", n); +} + +test "boj_cartridge_init returns 0" { + try std.testing.expectEqual(@as(c_int, 0), boj_cartridge_init()); +} + +test "invoke: each declared tool succeeds" { + var buf: [256]u8 = undefined; + const tools = [_][]const u8{ + "ums_create_project", + "ums_open_project", + "ums_close_project", + "ums_load_level", + "ums_save_level", + "ums_validate_level", + "ums_list_profiles", + }; + for (tools) |t| { + var len: usize = buf.len; + const rc = boj_cartridge_invoke(t.ptr, "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, 0), rc); + try std.testing.expect(std.mem.indexOf(u8, buf[0..len], "result") != null); + } +} + +test "invoke: unknown tool returns -1" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("nope", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -1), rc); +} + +test "invoke: buffer too small returns -3" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("ums_create_project", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -3), rc); + try std.testing.expect(len > 4); +} diff --git a/cartridges/domains/gaming/ums-mcp/mod.js b/cartridges/domains/gaming/ums-mcp/mod.js new file mode 100644 index 0000000..8fe74c9 --- /dev/null +++ b/cartridges/domains/gaming/ums-mcp/mod.js @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// ums-mcp/mod.js β€” Universal Map Specification β€” level editor and validator +// +// Delegates to backend at http://127.0.0.1:7743 (override with UMS_BACKEND_URL). + +const BASE_URL = Deno.env.get("UMS_BACKEND_URL") ?? "http://127.0.0.1:7743"; +const TIMEOUT_MS = 15_000; + +async function post(path, payload) { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), TIMEOUT_MS); + try { + const r = await fetch(`${BASE_URL}${path}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + signal: ctrl.signal, + }); + const data = await r.json().catch(() => ({ success: false, error: "non-JSON response" })); + return { status: r.status, data }; + } catch (e) { + if (e.name === "AbortError") return { status: 504, data: { success: false, error: "ums-mcp backend timed out" } }; + return { status: 503, data: { success: false, error: `ums-mcp backend unavailable: ${e.message}` } }; + } finally { clearTimeout(t); } +} + +async function get(path) { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), TIMEOUT_MS); + try { + const r = await fetch(`${BASE_URL}${path}`, { method: "GET", signal: ctrl.signal }); + const data = await r.json().catch(() => ({ success: false, error: "non-JSON response" })); + return { status: r.status, data }; + } catch (e) { + if (e.name === "AbortError") return { status: 504, data: { success: false, error: "ums-mcp backend timed out" } }; + return { status: 503, data: { success: false, error: `ums-mcp backend unavailable: ${e.message}` } }; + } finally { clearTimeout(t); } +} + +export async function handleTool(toolName, args) { + switch (toolName) { + case "ums_create_project": + return post("/api/v1/ums_create_project", args ?? {}); + case "ums_open_project": + return post("/api/v1/ums_open_project", args ?? {}); + case "ums_close_project": + return post("/api/v1/ums_close_project", args ?? {}); + case "ums_load_level": + return post("/api/v1/ums_load_level", args ?? {}); + case "ums_save_level": + return post("/api/v1/ums_save_level", args ?? {}); + case "ums_validate_level": + return post("/api/v1/ums_validate_level", args ?? {}); + case "ums_list_profiles": + return post("/api/v1/ums_list_profiles", args ?? {}); + default: + return { status: 404, data: { error: `Unknown tool: ${toolName}` } }; + } +} diff --git a/cartridges/domains/gaming/ums-mcp/panels/manifest.json b/cartridges/domains/gaming/ums-mcp/panels/manifest.json new file mode 100644 index 0000000..d76bcbf --- /dev/null +++ b/cartridges/domains/gaming/ums-mcp/panels/manifest.json @@ -0,0 +1,67 @@ +{ + "$schema": "https://panll.dev/schemas/panel-manifest/v1.json", + "spdx": "MPL-2.0", + "cartridge": "ums-mcp", + "domain": "Unified Messaging", + "version": "0.1.0", + "panels": [ + { + "id": "ums-status", + "title": "UMS Gateway Status", + "description": "Unified messaging service health across Slack, Discord, Matrix, Telegram bridges", + "type": "status-indicator", + "data_source": { + "endpoint": "/cartridge/ums-mcp/invoke", + "method": "POST", + "body": { "tool": "status" }, + "refresh_interval_ms": 5000 + }, + "widgets": [ + { + "type": "state-badge", + "field": "state", + "states": { + "ready": { "color": "#2ecc71", "icon": "message-circle" }, + "degraded": { "color": "#f39c12", "icon": "message-circle-x" }, + "error": { "color": "#e74c3c", "icon": "alert-triangle" } + } + }, + { "type": "text", "field": "version", "label": "Version" } + ] + }, + { + "id": "ums-bridges", + "title": "Platform Bridges", + "description": "Connected messaging platforms and their bridge status", + "type": "metric", + "data_source": { + "endpoint": "/cartridge/ums-mcp/invoke", + "method": "POST", + "body": { "tool": "bridge_status" }, + "refresh_interval_ms": 15000 + }, + "widgets": [ + { "type": "counter", "field": "connected_bridges", "label": "Connected", "icon": "link" }, + { "type": "counter", "field": "total_bridges", "label": "Total Bridges", "icon": "share-2" }, + { "type": "counter", "field": "active_channels", "label": "Active Channels", "icon": "hash" } + ] + }, + { + "id": "ums-message-stats", + "title": "Message Throughput", + "description": "Message volume, delivery success rate, and queue depth", + "type": "metric", + "data_source": { + "endpoint": "/cartridge/ums-mcp/invoke", + "method": "POST", + "body": { "tool": "message_stats" }, + "refresh_interval_ms": 10000 + }, + "widgets": [ + { "type": "counter", "field": "messages_today", "label": "Messages Today", "icon": "mail" }, + { "type": "gauge", "field": "delivery_rate", "label": "Delivery Rate %", "min": 0, "max": 100 }, + { "type": "counter", "field": "queue_depth", "label": "Queue Depth", "icon": "list" } + ] + } + ] +} diff --git a/cartridges/domains/infrastructure/aerie-mcp/README.adoc b/cartridges/domains/infrastructure/aerie-mcp/README.adoc new file mode 100644 index 0000000..384a7f3 --- /dev/null +++ b/cartridges/domains/infrastructure/aerie-mcp/README.adoc @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += aerie-mcp +Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk> +:spdx: MPL-2.0 +:tier: Ayo +:domain: infrastructure +:protocols: MCP, REST + +== Overview + +Aerie environment lifecycle manager + +== Tools (4) + +[cols="2,4"] +|=== +| Tool | Description + +| `aerie_list_envs` | List all Aerie environments +| `aerie_create_env` | Create a new Aerie environment +| `aerie_destroy_env` | Destroy an Aerie environment +| `aerie_get_status` | Get status of an Aerie environment +|=== + +== Architecture + +Three-layer stack per the BoJ cartridge standard: + +[cols="1,1,3"] +|=== +| Layer | Language | Purpose + +| ABI | Idris2 | Formally verified state machine with dependent-type proofs +| FFI | Zig | C-ABI implementation: session pool, action metrics, thread safety +| Adapter| Zig | Adapter bridge to the BoJ unified adapter protocol (zig predecessors sidelined) +|=== + +== Building + +[source,bash] +---- +# ABI (Idris2) +cd abi && idris2 --check *.idr + +# FFI (Zig) +cd ffi && zig build +cd ffi && zig build test + +# Adapter (Zig) +cd adapter && zig build +---- + +== Status + +Alpha (CRG D). Cartridge metadata declared in `cartridge.json`; 4 tool(s) exposed. +Not yet dogfooded externally. See `docs/READINESS.md` at repo root for the +project-wide grade table and `docs/practice/DOGFOOD-LOG.adoc` for usage evidence. diff --git a/cartridges/domains/infrastructure/aerie-mcp/abi/Aerie/Protocol.idr b/cartridges/domains/infrastructure/aerie-mcp/abi/Aerie/Protocol.idr new file mode 100644 index 0000000..6617b14 --- /dev/null +++ b/cartridges/domains/infrastructure/aerie-mcp/abi/Aerie/Protocol.idr @@ -0,0 +1,47 @@ +-- SPDX-License-Identifier: MPL-2.0 +-- Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +-- +-- Aerie ABI β€” environment management protocol definitions. + +module Aerie.Protocol + +import Data.Nat + +||| Aerie operation codes. +public export +data AerieOp + = ListEnvs + | CreateEnv + | DestroyEnv + | GetStatus + +||| Environment lifecycle status. +public export +data EnvStatus = Provisioning | Ready | Destroying | Destroyed | Error + +||| Environment with unique name and status. +public export +record Env where + constructor MkEnv + name : String + status : EnvStatus + age : Nat + +||| Create request with resource limits. +public export +record CreateReq where + constructor MkCreateReq + name : String + cpuLimit : Nat + memMB : Nat + {auto prf : IsSucc memMB} + +||| Proof: memory limit is always positive by construction. +export +memLimitPositive : (r : CreateReq) -> IsSucc r.memMB +memLimitPositive r = r.prf + +||| Proof: a destroyed environment cannot be ready. +export +destroyedNotReady : Not (Destroyed = Ready) +destroyedNotReady Refl impossible diff --git a/cartridges/domains/infrastructure/aerie-mcp/abi/README.adoc b/cartridges/domains/infrastructure/aerie-mcp/abi/README.adoc new file mode 100644 index 0000000..5d5f857 --- /dev/null +++ b/cartridges/domains/infrastructure/aerie-mcp/abi/README.adoc @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += aerie-mcp / abi β€” Idris2 ABI layer +:orientation: shallow + +== Purpose + +Cartridge-local protocol types for `aerie-mcp`. Defines the data, tool, and +transition vocabulary this cartridge speaks. Inherits safety lemmas and the +matrix-cell identifier scheme from the trunk ABI at `src/abi/`. + +== Files + +1 Idris2 module(s), ~47 lines total. Lead module: +`Protocol.idr`. + +No `.ipkg` β€” consumed directly by wider BoJ callers; type-checks inside the +trunk `boj.ipkg`. + +== Invariants + +* `%default total` at every module top. +* Zero `believe_me`, `assert_total`, `postulate`, `Admitted`, or + `sorry` (the trunk holds the four declared `believe_me` sites; leaf + cartridges carry none). + +== Test/proof surface + +Thin module(s), no inline tests. Proof surface = type-check success under the +trunk `idris2 --check`. + +== Read-first + +. `Protocol.idr` β€” cartridge-local data records and enums. +. Trunk `src/abi/Boj/Catalogue.idr` for how this cell participates in the + 2D matrix. diff --git a/cartridges/domains/infrastructure/aerie-mcp/adapter/README.adoc b/cartridges/domains/infrastructure/aerie-mcp/adapter/README.adoc new file mode 100644 index 0000000..ad814c4 --- /dev/null +++ b/cartridges/domains/infrastructure/aerie-mcp/adapter/README.adoc @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += aerie-mcp / adapter β€” protocol bridge +:orientation: shallow + +== Purpose + +Exposes this cartridge's FFI over the wire protocols declared in +`cartridge.json`. Dispatches tool invocations to the C-ABI exports at +`../ffi/` and formats responses per protocol. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” depends on the sibling `ffi` target. +| `aerie_adapter.zig` | Protocol dispatch (129 lines). Tool catalogue matches `cartridge.json`. +| `SIDELINED-*.v.adoc` | zig predecessor(s). Not built β€” zig banned 2026-04-10. +|=== + +== Invariants + +* **Stateless** β€” all state lives behind the FFI, never in the adapter. +* **Response passthrough** β€” whatever the FFI returns goes back to the wire + unmodified (no embellishment, no silent recovery). +* **Cartridge.json is source of truth** for the tool catalogue; drift between + adapter and manifest is a CI failure. + +== Test/proof surface + +No inline tests at the adapter layer β€” protocol routing is exercised by the +trunk seam tests at `ffi/zig/src/seams.zig`. + +== Read-first + +. `aerie_adapter.zig` β€” dispatch function and per-protocol routers. +. `../cartridge.json` β€” the tool manifest the dispatcher mirrors. diff --git a/cartridges/domains/infrastructure/aerie-mcp/adapter/aerie_adapter.zig b/cartridges/domains/infrastructure/aerie-mcp/adapter/aerie_adapter.zig new file mode 100644 index 0000000..e181e37 --- /dev/null +++ b/cartridges/domains/infrastructure/aerie-mcp/adapter/aerie_adapter.zig @@ -0,0 +1,129 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// aerie-mcp/adapter/aerie_adapter.zig +// +// Three-protocol BoJ adapter: REST (port 9196), gRPC-compat (port 9197), +// GraphQL (port 9198). +// Replaces the banned zig adapter (aerie_adapter.v). + +const std = @import("std"); +const ffi = @import("aerie_ffi"); + +const REST_PORT: u16 = 9196; +const GRPC_PORT: u16 = 9197; +const GQL_PORT: u16 = 9198; + +const Response = struct { status: u16, body: []const u8 }; + +fn okJson(buf: []u8, msg: []const u8) []u8 { + return std.fmt.bufPrint(buf, "{{\"success\":true,\"message\":\"{s}\"}}", .{msg}) catch buf[0..0]; +} + +fn errJson(buf: []u8, msg: []const u8) []u8 { + return std.fmt.bufPrint(buf, "{{\"success\":false,\"error\":\"{s}\"}}", .{msg}) catch buf[0..0]; +} + +fn dispatch(tool: []const u8, _body: []const u8, resp: []u8) Response { + _ = _body; + if (std.mem.eql(u8, tool, "aerie_list_envs")) { + return .{ .status = 200, .body = okJson(resp, "aerie_list_envs forwarded") }; + } + if (std.mem.eql(u8, tool, "aerie_create_env")) { + return .{ .status = 200, .body = okJson(resp, "aerie_create_env forwarded") }; + } + if (std.mem.eql(u8, tool, "aerie_destroy_env")) { + return .{ .status = 200, .body = okJson(resp, "aerie_destroy_env forwarded") }; + } + if (std.mem.eql(u8, tool, "aerie_get_status")) { + return .{ .status = 200, .body = okJson(resp, "aerie_get_status forwarded") }; + } + return .{ .status = 404, .body = errJson(resp, "unknown tool") }; +} + +fn dispatchRest(path: []const u8, body: []const u8, resp: []u8) Response { + // Expect /tools/<tool_name> + const prefix = "/tools/"; + if (std.mem.startsWith(u8, path, prefix)) { + const tool = path[prefix.len..]; + return dispatch(tool, body, resp); + } + return .{ .status = 404, .body = errJson(resp, "not found") }; +} + +fn dispatchGrpc(path: []const u8, body: []const u8, resp: []u8) Response { + // Expect /<Service>/<Method> β€” derive tool from Method + var it = std.mem.splitScalar(u8, path, '/'); + _ = it.next(); // leading empty + _ = it.next(); // service + const method = it.next() orelse return .{ .status = 404, .body = errJson(resp, "bad gRPC path") }; + return dispatch(method, body, resp); +} + +fn dispatchGraphql(body: []const u8, resp: []u8) Response { + if (std.mem.indexOf(u8, body, "aerie_list_envs") != null) + return dispatch("aerie_list_envs", body, resp); + if (std.mem.indexOf(u8, body, "aerie_create_env") != null) + return dispatch("aerie_create_env", body, resp); + if (std.mem.indexOf(u8, body, "aerie_destroy_env") != null) + return dispatch("aerie_destroy_env", body, resp); + if (std.mem.indexOf(u8, body, "aerie_get_status") != null) + return dispatch("aerie_get_status", body, resp); + return .{ .status = 400, .body = errJson(resp, "unrecognised GraphQL operation") }; +} + +fn handleConnection(stream: std.net.Stream, port: u16) void { + defer stream.close(); + var buf: [4096]u8 = undefined; + var resp_buf: [4096]u8 = undefined; + const n = stream.read(&buf) catch return; + const req = buf[0..n]; + const result = switch (port) { + REST_PORT => blk: { + // Parse HTTP/1.1: first line = METHOD PATH HTTP/x.y + var lines = std.mem.splitScalar(u8, req, '\n'); + const first = lines.next() orelse break :blk Response{ .status = 400, .body = "" }; + var parts = std.mem.splitScalar(u8, std.mem.trim(u8, first, "\r"), ' '); + _ = parts.next(); // method + const path = parts.next() orelse break :blk Response{ .status = 400, .body = "" }; + break :blk dispatchRest(path, req, &resp_buf); + }, + GRPC_PORT => blk: { + var lines = std.mem.splitScalar(u8, req, '\n'); + const first = lines.next() orelse break :blk Response{ .status = 400, .body = "" }; + var parts = std.mem.splitScalar(u8, std.mem.trim(u8, first, "\r"), ' '); + _ = parts.next(); + const path = parts.next() orelse break :blk Response{ .status = 400, .body = "" }; + break :blk dispatchGrpc(path, req, &resp_buf); + }, + GQL_PORT => dispatchGraphql(req, &resp_buf), + else => Response{ .status = 500, .body = "" }, + }; + var http_resp: [512]u8 = undefined; + const http = std.fmt.bufPrint(&http_resp, + "HTTP/1.1 {d} OK\r\nContent-Length: {d}\r\nContent-Type: application/json\r\n\r\n", + .{ result.status, result.body.len }) catch return; + _ = stream.write(http) catch {}; + _ = stream.write(result.body) catch {}; +} + +fn listenLoop(port: u16) !void { + const addr = std.net.Address.initIp4(.{ 127, 0, 0, 1 }, port); + var server = try addr.listen(.{ .reuse_address = true }); + defer server.deinit(); + while (true) { + const conn = try server.accept(); + const t = try std.Thread.spawn(.{}, handleConnection, .{ conn.stream, port }); + t.detach(); + } +} + +pub fn main() !void { + ffi.aerie_init(); + const t1 = try std.Thread.spawn(.{}, listenLoop, .{REST_PORT}); + const t2 = try std.Thread.spawn(.{}, listenLoop, .{GRPC_PORT}); + const t3 = try std.Thread.spawn(.{}, listenLoop, .{GQL_PORT}); + t1.join(); + t2.join(); + t3.join(); +} diff --git a/cartridges/domains/infrastructure/aerie-mcp/adapter/build.zig b/cartridges/domains/infrastructure/aerie-mcp/adapter/build.zig new file mode 100644 index 0000000..c554efe --- /dev/null +++ b/cartridges/domains/infrastructure/aerie-mcp/adapter/build.zig @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// aerie-mcp/adapter/build.zig + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const ffi_mod = b.createModule(.{ + .root_source_file = b.path("../ffi/aerie_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + const adapter = b.addExecutable(.{ + .name = "aerie_adapter", + .root_source_file = b.path("aerie_adapter.zig"), + .target = target, + .optimize = optimize, + }); + adapter.root_module.addImport("aerie_ffi", ffi_mod); + b.installArtifact(adapter); +} diff --git a/cartridges/domains/infrastructure/aerie-mcp/cartridge.json b/cartridges/domains/infrastructure/aerie-mcp/cartridge.json new file mode 100644 index 0000000..1687e44 --- /dev/null +++ b/cartridges/domains/infrastructure/aerie-mcp/cartridge.json @@ -0,0 +1,96 @@ +{ + "$schema": "https://boj.dev/schemas/cartridge/v1.json", + "spdx": "MPL-2.0", + "copyright": "Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk>", + "name": "aerie-mcp", + "version": "0.1.0", + "description": "Aerie environment lifecycle manager", + "domain": "infrastructure", + "tier": "Ayo", + "protocols": [ + "MCP", + "REST" + ], + "auth": { + "method": "none", + "env_var": null, + "credential_source": null + }, + "api": { + "base_url": "local://aerie-mcp", + "content_type": "application/json" + }, + "tools": [ + { + "name": "aerie_list_envs", + "description": "List all Aerie environments", + "inputSchema": { + "type": "object", + "properties": {} + } + }, + { + "name": "aerie_create_env", + "description": "Create a new Aerie environment", + "inputSchema": { + "type": "object", + "properties": { + "env_name": { + "type": "string", + "description": "Name for the new environment" + }, + "mem_mb": { + "type": "integer", + "description": "Memory in MB" + } + }, + "required": [ + "env_name" + ] + } + }, + { + "name": "aerie_destroy_env", + "description": "Destroy an Aerie environment", + "inputSchema": { + "type": "object", + "properties": { + "env_id": { + "type": "integer", + "description": "Environment ID" + } + }, + "required": [ + "env_id" + ] + } + }, + { + "name": "aerie_get_status", + "description": "Get status of an Aerie environment", + "inputSchema": { + "type": "object", + "properties": { + "env_id": { + "type": "integer", + "description": "Environment ID" + } + }, + "required": [ + "env_id" + ] + } + } + ], + "ffi": { + "so_path": "ffi/zig-out/lib/libaerie_mcp.so", + "abi_version": "ADR-0006", + "symbols": [ + "boj_cartridge_init", + "boj_cartridge_deinit", + "boj_cartridge_name", + "boj_cartridge_version", + "boj_cartridge_invoke" + ] + } +} diff --git a/cartridges/domains/infrastructure/aerie-mcp/ffi/README.adoc b/cartridges/domains/infrastructure/aerie-mcp/ffi/README.adoc new file mode 100644 index 0000000..a537022 --- /dev/null +++ b/cartridges/domains/infrastructure/aerie-mcp/ffi/README.adoc @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += aerie-mcp / ffi β€” Zig FFI layer **(stub)** +:orientation: shallow + +== Purpose + +C-ABI exports bridging this cartridge's Idris2 ABI to the runtime. Consumed by +the adapter for protocol dispatch and by the trunk loader for mount. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” produces `zig-out/lib/libaerie.so`. +| `aerie_ffi.zig` | C-ABI exports (4 exports, 3 inline tests, 43 lines). +|=== + +== Invariants + +* **Null-pointer rejection** on `[*c]const u8` arguments before any + dereference. +* **Negative return values** carry documented error meanings; zero is + success (matching the trunk error-code convention). +* **No allocator leaks** β€” every allocation path pairs with `defer free`. + +== Test/proof surface + +3 inline `test "..."` block(s) in `aerie_ffi.zig`. Run with +`cd ffi && zig build test`. + +== Read-first + +. `aerie_ffi.zig` β€” C-ABI signatures and guards. +. Trunk `ffi/zig/src/catalogue.zig` for how this `.so` is loaded. + +== Promotion gate + +Wire these exports to real backing state (not `return 0` / `return 1` +placeholders) before promoting past D. diff --git a/cartridges/domains/infrastructure/aerie-mcp/ffi/aerie_ffi.zig b/cartridges/domains/infrastructure/aerie-mcp/ffi/aerie_ffi.zig new file mode 100644 index 0000000..304dc04 --- /dev/null +++ b/cartridges/domains/infrastructure/aerie-mcp/ffi/aerie_ffi.zig @@ -0,0 +1,137 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +// +// Aerie FFI β€” C-compatible exports for environment management. +// +// Reference implementation of the 5-symbol cartridge ABI (ADR-0006): +// boj_cartridge_init / deinit / name / version / invoke. +// Other cartridges should follow this file's shape. + +const std = @import("std"); + +// ── Bespoke tool exports (kept for backward compat during migration) ── + +/// List active environment count. +export fn aerie_list_envs_count() u32 { + return 0; // Stub +} + +/// Create an environment. Returns env ID or 0 on failure. +export fn aerie_create_env(name: [*c]const u8, mem_mb: u32) u32 { + if (name == null or mem_mb == 0) return 0; + return 1; // Stub +} + +/// Destroy an environment. Returns 0 on success. +export fn aerie_destroy_env(env_id: u32) i32 { + if (env_id == 0) return -1; + return 0; // Stub +} + +/// Get env status: 0=provisioning, 1=ready, 2=destroying, 3=destroyed, 4=error. +export fn aerie_get_status(env_id: u32) u8 { + if (env_id == 0) return 4; // Error + return 1; // Stub β€” ready +} + +// ── Standard ABI symbols (ADR-0005 + ADR-0006) ───────────────────────── + +const shim = @import("cartridge_shim.zig"); + +// String literals in Zig are already NUL-terminated sentinel arrays; hold +// their addresses in module-level constants so the exported pointers have +// stable lifetime. +const CARTRIDGE_NAME_PTR: [*:0]const u8 = "aerie-mcp"; +const CARTRIDGE_VERSION_PTR: [*:0]const u8 = "0.1.0"; + +export fn boj_cartridge_init() callconv(.c) c_int { + return 0; +} + +export fn boj_cartridge_deinit() callconv(.c) void {} + +export fn boj_cartridge_name() callconv(.c) [*:0]const u8 { + return CARTRIDGE_NAME_PTR; +} + +export fn boj_cartridge_version() callconv(.c) [*:0]const u8 { + return CARTRIDGE_VERSION_PTR; +} + +/// ADR-0006 reference: dispatch `tool_name` with `json_args`, write result +/// into `out_buf`/`*in_out_len`. Return codes documented in ADR-0006. +export fn boj_cartridge_invoke( + tool_name: [*c]const u8, + json_args: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) callconv(.c) i32 { + _ = json_args; // reference implementation ignores args + + if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; + + // Tool table β€” extend this for every tool the cartridge exposes. + const body: []const u8 = if (shim.toolIs(tool_name, "list_envs_count")) + "{\"result\":{\"count\":0}}" + else if (shim.toolIs(tool_name, "create_env")) + "{\"result\":{\"env_id\":1}}" + else if (shim.toolIs(tool_name, "destroy_env")) + "{\"result\":{\"ok\":true}}" + else if (shim.toolIs(tool_name, "get_status")) + "{\"result\":{\"status\":\"ready\"}}" + else + return shim.RC_UNKNOWN_TOOL; + + return shim.writeResult(out_buf, in_out_len, body); +} + +// ── Tests ── + +test "create rejects null name" { + try std.testing.expectEqual(@as(u32, 0), aerie_create_env(null, 512)); +} + +test "create rejects zero memory" { + try std.testing.expectEqual(@as(u32, 0), aerie_create_env("dev", 0)); +} + +test "destroy rejects zero id" { + try std.testing.expectEqual(@as(i32, -1), aerie_destroy_env(0)); +} + +test "boj_cartridge_name returns aerie-mcp" { + const n = std.mem.span(boj_cartridge_name()); + try std.testing.expectEqualStrings("aerie-mcp", n); +} + +test "boj_cartridge_version returns semver" { + const v = std.mem.span(boj_cartridge_version()); + try std.testing.expectEqualStrings("0.1.0", v); +} + +test "boj_cartridge_init returns 0" { + try std.testing.expectEqual(@as(c_int, 0), boj_cartridge_init()); +} + +test "invoke unknown tool returns -1" { + var buf: [256]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("nope", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -1), rc); +} + +test "invoke known tool writes JSON and returns 0" { + var buf: [256]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("list_envs_count", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, 0), rc); + try std.testing.expect(std.mem.indexOf(u8, buf[0..len], "count") != null); +} + +test "invoke with too-small buffer returns -3 and sets required length" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("list_envs_count", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -3), rc); + try std.testing.expect(len > 4); +} diff --git a/cartridges/domains/infrastructure/aerie-mcp/ffi/build.zig b/cartridges/domains/infrastructure/aerie-mcp/ffi/build.zig new file mode 100644 index 0000000..f728ff4 --- /dev/null +++ b/cartridges/domains/infrastructure/aerie-mcp/ffi/build.zig @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const ffi_mod = b.addModule("aerie_mcp", .{ + .root_source_file = b.path("aerie_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + const lib = b.addLibrary(.{ + .name = "aerie_mcp", + .root_module = ffi_mod, + .linkage = .dynamic, + }); + lib.linkLibC(); + b.installArtifact(lib); + + const tests = b.addTest(.{ .root_module = ffi_mod }); + tests.linkLibC(); + const run_tests = b.addRunArtifact(tests); + const test_step = b.step("test", "Run FFI tests"); + test_step.dependOn(&run_tests.step); +} + diff --git a/cartridges/domains/infrastructure/aerie-mcp/ffi/cartridge_shim.zig b/cartridges/domains/infrastructure/aerie-mcp/ffi/cartridge_shim.zig new file mode 100644 index 0000000..0e399db --- /dev/null +++ b/cartridges/domains/infrastructure/aerie-mcp/ffi/cartridge_shim.zig @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// cartridge_shim.zig β€” Shared helpers for the ADR-0006 five-symbol +// cartridge ABI (`boj_cartridge_init / deinit / name / version / invoke`). +// +// The shim centralises the seven-code return convention, NUL-argument +// guards, tool-name comparison, and the buffer-too-small path so each +// cartridge's `boj_cartridge_invoke` can stay short β€” typically a tool +// table plus `shim.writeResult(...)`. +// +// Cartridges import this file by relative path (no build-graph change +// needed). Example: +// +// const shim = @import("../../../ffi/zig/src/cartridge_shim.zig"); +// +// export fn boj_cartridge_invoke( +// tool_name: [*c]const u8, +// json_args: [*c]const u8, +// out_buf: [*c]u8, +// in_out_len: [*c]usize, +// ) callconv(.c) i32 { +// _ = json_args; +// if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; +// const body = if (shim.toolIs(tool_name, "foo")) "{\"result\":{}}" +// else return shim.RC_UNKNOWN_TOOL; +// return shim.writeResult(out_buf, in_out_len, body); +// } + +const std = @import("std"); + +// ── Return codes (ADR-0006 Β§Return codes) ──────────────────────────── +// +// Frozen by ADR-0006. New failure modes compose these via the error +// JSON body β€” the integer surface does not grow without a follow-up ADR. + +pub const RC_SUCCESS: i32 = 0; +pub const RC_UNKNOWN_TOOL: i32 = -1; +pub const RC_BAD_ARGS: i32 = -2; +pub const RC_BUFFER_TOO_SMALL: i32 = -3; +pub const RC_RUNTIME_ERROR: i32 = -4; +pub const RC_PANIC: i32 = -5; +pub const RC_AUTH_DENIED: i32 = -6; + +// ── Invoke-path helpers ────────────────────────────────────────────── + +/// True if any of the three mandatory `boj_cartridge_invoke` output-path +/// pointers is null. Use at the top of every invoke to short-circuit to +/// `RC_BAD_ARGS`. +pub fn invokeArgsNull( + tool_name: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) bool { + return tool_name == null or out_buf == null or in_out_len == null; +} + +/// Compare a C-NUL-terminated tool-name pointer against a Zig string +/// literal. Caller must have already verified `tool_name` is non-null +/// (usually via `invokeArgsNull`). +pub fn toolIs(tool_name: [*c]const u8, expected: []const u8) bool { + const s = std.mem.span(@as([*:0]const u8, @ptrCast(tool_name))); + return std.mem.eql(u8, s, expected); +} + +/// Copy `body` into `out_buf[0..*in_out_len]` (as a capacity) and update +/// `*in_out_len` to the number of bytes written. Returns `RC_SUCCESS`. +/// +/// If `body.len` exceeds the current capacity stored in `*in_out_len`, +/// sets `*in_out_len` to the required size and returns +/// `RC_BUFFER_TOO_SMALL` β€” the caller is then expected to re-allocate +/// and retry, per ADR-0006 Β§Memory ownership. +/// +/// Caller must have already verified that `out_buf` and `in_out_len` +/// are non-null. +pub fn writeResult( + out_buf: [*c]u8, + in_out_len: [*c]usize, + body: []const u8, +) i32 { + const cap = in_out_len.*; + if (body.len > cap) { + in_out_len.* = body.len; + return RC_BUFFER_TOO_SMALL; + } + @memcpy(out_buf[0..body.len], body); + in_out_len.* = body.len; + return RC_SUCCESS; +} + +// ── Tests ──────────────────────────────────────────────────────────── + +test "writeResult: body fits, writes and sets length" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); + try std.testing.expectEqualStrings("hello", buf[0..len]); +} + +test "writeResult: too small returns -3 and sets required length" { + var buf: [2]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_BUFFER_TOO_SMALL, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: exact-fit succeeds" { + var buf: [5]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: empty body" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, ""); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 0), len); +} + +test "toolIs: matches and rejects" { + const name: [*:0]const u8 = "foo"; + try std.testing.expect(toolIs(@ptrCast(name), "foo")); + try std.testing.expect(!toolIs(@ptrCast(name), "bar")); + try std.testing.expect(!toolIs(@ptrCast(name), "foobar")); + try std.testing.expect(!toolIs(@ptrCast(name), "fo")); +} + +test "invokeArgsNull: detects each null slot" { + var buf: [4]u8 = undefined; + var len: usize = 4; + const name: [*:0]const u8 = "x"; + try std.testing.expect(!invokeArgsNull(@ptrCast(name), &buf, &len)); + try std.testing.expect(invokeArgsNull(null, &buf, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), null, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), &buf, null)); +} diff --git a/cartridges/domains/infrastructure/aerie-mcp/mod.js b/cartridges/domains/infrastructure/aerie-mcp/mod.js new file mode 100644 index 0000000..c2c2636 --- /dev/null +++ b/cartridges/domains/infrastructure/aerie-mcp/mod.js @@ -0,0 +1,55 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// aerie-mcp/mod.js β€” Aerie environment lifecycle manager +// +// Delegates to backend at http://127.0.0.1:7710 (override with AERIE_BACKEND_URL). + +const BASE_URL = Deno.env.get("AERIE_BACKEND_URL") ?? "http://127.0.0.1:7710"; +const TIMEOUT_MS = 15_000; + +async function post(path, payload) { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), TIMEOUT_MS); + try { + const r = await fetch(`${BASE_URL}${path}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + signal: ctrl.signal, + }); + const data = await r.json().catch(() => ({ success: false, error: "non-JSON response" })); + return { status: r.status, data }; + } catch (e) { + if (e.name === "AbortError") return { status: 504, data: { success: false, error: "aerie-mcp backend timed out" } }; + return { status: 503, data: { success: false, error: `aerie-mcp backend unavailable: ${e.message}` } }; + } finally { clearTimeout(t); } +} + +async function get(path) { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), TIMEOUT_MS); + try { + const r = await fetch(`${BASE_URL}${path}`, { method: "GET", signal: ctrl.signal }); + const data = await r.json().catch(() => ({ success: false, error: "non-JSON response" })); + return { status: r.status, data }; + } catch (e) { + if (e.name === "AbortError") return { status: 504, data: { success: false, error: "aerie-mcp backend timed out" } }; + return { status: 503, data: { success: false, error: `aerie-mcp backend unavailable: ${e.message}` } }; + } finally { clearTimeout(t); } +} + +export async function handleTool(toolName, args) { + switch (toolName) { + case "aerie_list_envs": + return post("/api/v1/aerie_list_envs", args ?? {}); + case "aerie_create_env": + return post("/api/v1/aerie_create_env", args ?? {}); + case "aerie_destroy_env": + return post("/api/v1/aerie_destroy_env", args ?? {}); + case "aerie_get_status": + return post("/api/v1/aerie_get_status", args ?? {}); + default: + return { status: 404, data: { error: `Unknown tool: ${toolName}` } }; + } +} diff --git a/cartridges/domains/infrastructure/aerie-mcp/panels/manifest.json b/cartridges/domains/infrastructure/aerie-mcp/panels/manifest.json new file mode 100644 index 0000000..8b28c8f --- /dev/null +++ b/cartridges/domains/infrastructure/aerie-mcp/panels/manifest.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://panll.dev/schemas/panel-manifest/v1.json", + "spdx": "MPL-2.0", + "cartridge": "aerie-mcp", + "domain": "environments", + "version": "0.1.0", + "panels": [ + { + "id": "aerie-environment-table", + "title": "Environment Table", + "description": "Active environments with status, resource usage, and lifecycle controls.", + "type": "data-table", + "entrypoint": "panels/aerie-environment-table.js", + "size": { "cols": 4, "rows": 3 }, + "refresh_interval_ms": 5000, + "data_sources": [ + { "op": "ListEnvs", "interval_ms": 5000 }, + { "op": "GetStatus", "interval_ms": 5000 } + ] + } + ] +} diff --git a/cartridges/domains/infrastructure/hesiod-mcp/.editorconfig b/cartridges/domains/infrastructure/hesiod-mcp/.editorconfig new file mode 100644 index 0000000..80660e7 --- /dev/null +++ b/cartridges/domains/infrastructure/hesiod-mcp/.editorconfig @@ -0,0 +1,24 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_size = 2 +indent_style = space +insert_final_newline = true +trim_trailing_whitespace = true + +[*.idr] +indent_size = 2 + +[*.zig] +indent_size = 2 + +[*.ts] +indent_size = 2 + +[*.json] +indent_size = 2 + +[*.adoc] +trim_trailing_whitespace = false diff --git a/cartridges/domains/infrastructure/hesiod-mcp/.gitignore b/cartridges/domains/infrastructure/hesiod-mcp/.gitignore new file mode 100644 index 0000000..59899a7 --- /dev/null +++ b/cartridges/domains/infrastructure/hesiod-mcp/.gitignore @@ -0,0 +1,27 @@ +# Build artifacts +/zig-cache/ +/zig-cache/ +*.o +*.a +*.so +*.dylib +*.wasm + +# Deno +deno.lock +.deno/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Node/npm (fallback, if used) +node_modules/ +package-lock.json diff --git a/cartridges/domains/infrastructure/hesiod-mcp/LICENSE b/cartridges/domains/infrastructure/hesiod-mcp/LICENSE new file mode 100644 index 0000000..fe33ebc --- /dev/null +++ b/cartridges/domains/infrastructure/hesiod-mcp/LICENSE @@ -0,0 +1,9 @@ +Mozilla Public License Version 2.0 +================================== + +This hesiod-mcp cartridge is licensed under the Mozilla Public License, Version 2.0 +as the legal fallback. The preferred license is MPL-2.0. + +See the SPDX-License-Identifier comment headers in source files. + +For the full text of MPL-2.0, visit: https://opensource.org/licenses/MPL-2.0 diff --git a/cartridges/domains/infrastructure/hesiod-mcp/README.adoc b/cartridges/domains/infrastructure/hesiod-mcp/README.adoc new file mode 100644 index 0000000..13c5b09 --- /dev/null +++ b/cartridges/domains/infrastructure/hesiod-mcp/README.adoc @@ -0,0 +1,162 @@ += Hesiod DNS Lookup Cartridge +:toc: preamble +:author: Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk> +:date: 2026-04-25 +:spdx: MPL-2.0 + +// SPDX-License-Identifier: MPL-2.0 + +DNS record lookup and reverse DNS resolution exposed as MCP tools. + +== Features + +- **DNS Lookups** β€” Query A, AAAA, CNAME, MX, NS, SOA, TXT, SRV records +- **Reverse DNS** β€” Resolve IP addresses to hostnames +- **Bulk Queries** β€” Efficient batch lookups for multiple domains +- **Type Safety** β€” Idris2 ABI ensures only valid record types are queried +- **Proof Pinning** β€” Loopback invariant: `IsLoopback 5173` at compile-time + +== Architecture + +[cols="1,3"] +|=== +| Component | Purpose + +| `abi/Hesiod.idr` +| Idris2 interface with proof-indexed record types and loopback pinning. + Ensures at compile-time that only queryable types are passed to FFI. + +| `ffi/hesiod_ffi.zig` +| C-compatible Zig bindings calling system DNS resolver (`libresolv`, `getaddrinfo`). + Five exported functions: `lookup`, `reverse_lookup`, `bulk_lookup`, `validate_hostname`, `error_message`. + +| `adapter/mod.ts` +| Deno TypeScript bridge. Implements MCP server interface, validates input, calls Zig FFI. + Runs on `127.0.0.1:5173` (loopback only, per Idris2 proof). + +| `mod.js` +| BoJ cartridge entry point. Dispatches MCP tool calls, health checks, lifecycle hooks. + +| `cartridge.json` +| Tool manifest defining three MCP tools: `dns_lookup`, `dns_reverse_lookup`, `dns_bulk_lookup`. + Lists ABI interface, FFI exports, adapter config, loopback proof. +|=== + +== MCP Tools + +=== `dns_lookup` + +Query DNS records for a hostname. + +[source,json] +---- +{ + "hostname": "example.com", + "record_type": "A", + "timeout_seconds": 5 +} +---- + +Returns list of matching DNS records (name, type, TTL, value). + +=== `dns_reverse_lookup` + +Resolve an IP address to hostname(s). + +[source,json] +---- +{ + "address": "93.184.216.34", + "timeout_seconds": 5 +} +---- + +=== `dns_bulk_lookup` + +Batch query multiple hostnames with the same record type. + +[source,json] +---- +{ + "hostnames": ["example.com", "example.org", "example.net"], + "record_type": "A", + "timeout_seconds": 5 +} +---- + +Returns results for each hostname in order. + +== Building + +[source,bash] +---- +# Compile Zig FFI +zig build -Doptimize=ReleaseFast -Dtarget=x86_64-linux + +# Run Deno adapter (development) +deno run --allow-net adapter/mod.ts + +# Build within BoJ (uses cartridge.json manifest) +just build-cartridge hesiod-mcp +---- + +== Type Safety + +All DNS record type queries are proven queryable at compile-time: + +[source,idris] +---- +lookup : {rectype : DNSRecordType} -> + (hostname : String) -> + Queryable rectype -> -- Proof required + m LookupResult +---- + +The Idris2 proof system prevents querying unrecognized record types. Only these are provably valid: + +- `A` (IPv4) +- `AAAA` (IPv6) +- `CNAME` (canonical name) +- `MX` (mail exchange) +- `NS` (name server) +- `SOA` (start of authority) +- `TXT` (text) +- `SRV` (service) + +== Loopback Invariant + +Proof-pinned at compile-time: + +[source,idris] +---- +loopbackInvariant : IsLoopback 5173 +loopbackInvariant = LoopbackProof +---- + +The cartridge ONLY listens on `127.0.0.1:5173`. No external network exposure. + +== Testing + +[source,bash] +---- +# Unit tests (Zig FFI) +zig test ffi/hesiod_ffi.zig + +# Integration tests (MCP protocol) +deno test --allow-net tests/hesiod_test.ts + +# Proof verification +idris2 --check abi/Hesiod.idr +---- + +== Integration with BoJ + +Cartridge is loaded by BoJ's MCP dispatcher. Available as tool in Claude's context: + +1. BoJ calls `boj_cartridge_invoke("hesiod-mcp", tool_name, json_args, ...)` +2. FFI adapter routes to Zig implementation +3. Result returned as JSON + +== License + +MPL-2.0 (MPL-2.0 legal fallback). diff --git a/cartridges/domains/infrastructure/hesiod-mcp/abi/Hesiod.idr b/cartridges/domains/infrastructure/hesiod-mcp/abi/Hesiod.idr new file mode 100644 index 0000000..7c97a72 --- /dev/null +++ b/cartridges/domains/infrastructure/hesiod-mcp/abi/Hesiod.idr @@ -0,0 +1,67 @@ +-- SPDX-License-Identifier: MPL-2.0 +-- Hesiod DNS Cartridge ABI β€” Type-safe DNS lookup interface + +module Hesiod + +||| DNS record type enumeration (proof-indexed) +public data DNSRecordType = + | A -- IPv4 address + | AAAA -- IPv6 address + | CNAME -- Canonical name + | MX -- Mail exchange + | NS -- Name server + | SOA -- Start of authority + | TXT -- Text record + | SRV -- Service record + +||| Proof that a record type is queryable +public data Queryable : DNSRecordType -> Type where + QueryableA : Queryable A + QueryableAAAA : Queryable AAAA + QueryableCNAME : Queryable CNAME + QueryableMX : Queryable MX + QueryableNS : Queryable NS + QueryableSOA : Queryable SOA + QueryableTXT : Queryable TXT + QueryableSRV : Queryable SRV + +||| DNS response record +public record DNSRecord where + constructor MkDNSRecord + name : String + type : DNSRecordType + ttl : Nat + value : String + +||| Lookup result type (success or failure) +public data LookupResult : Type where + Success : (records : List DNSRecord) -> LookupResult + NotFound : (hostname : String) -> LookupResult + NetworkError : (message : String) -> LookupResult + Timeout : (hostname : String) -> (seconds : Nat) -> LookupResult + +||| Type-safe DNS lookup interface +||| Proof ensures only valid record types are queried +public interface Hesiod.Lookup (m : Type -> Type) where + ||| Query DNS records for a hostname + ||| @hostname The domain to query + ||| @rectype The record type to look up + ||| @queryable Proof that this record type is queryable + lookup : {rectype : DNSRecordType} -> (hostname : String) -> + Queryable rectype -> m LookupResult + + ||| Reverse DNS lookup (address -> hostname) + reverseLookup : (address : String) -> m LookupResult + + ||| Bulk lookup multiple hostnames + bulkLookup : {rectype : DNSRecordType} -> + (hostnames : List String) -> + Queryable rectype -> m (List LookupResult) + +||| Loopback proof: hesiod-mcp only runs on localhost:5173 +public data IsLoopback : (port : Nat) -> Type where + LoopbackProof : IsLoopback 5173 + +export +loopbackInvariant : IsLoopback 5173 +loopbackInvariant = LoopbackProof diff --git a/cartridges/domains/infrastructure/hesiod-mcp/adapter/mod.ts b/cartridges/domains/infrastructure/hesiod-mcp/adapter/mod.ts new file mode 100644 index 0000000..6ed4477 --- /dev/null +++ b/cartridges/domains/infrastructure/hesiod-mcp/adapter/mod.ts @@ -0,0 +1,191 @@ +// SPDX-License-Identifier: MPL-2.0 +// Hesiod DNS Cartridge β€” MCP Server adapter for DNS lookups + +import { Server } from "https://esm.sh/@modelcontextprotocol/sdk/server/index.js"; +import { + CallToolRequestSchema, + ListToolsRequestSchema, + Tool, + TextContent, + ToolUseBlock, +} from "https://esm.sh/@modelcontextprotocol/sdk/types.js"; + +// Import Zig FFI bindings +// In real implementation, would load compiled .wasm or .so +const FFI = { + lookup: async (hostname: string, rectype: number): Promise<any> => { + // Placeholder: would call Zig FFI via Deno.ffi + return { success: true, records: [] }; + }, + reverseLookup: async (address: string): Promise<any> => { + return { success: true, hostname: "" }; + }, + bulkLookup: async ( + hostnames: string[], + rectype: number + ): Promise<any[]> => { + return hostnames.map(() => ({ success: true, records: [] })); + }, +}; + +// MCP tool definitions +const TOOLS: Tool[] = [ + { + name: "dns_lookup", + description: + "Query DNS records for a hostname (A, AAAA, CNAME, MX, NS, TXT, SRV)", + inputSchema: { + type: "object" as const, + properties: { + hostname: { + type: "string", + description: "Domain name to query", + }, + record_type: { + type: "string", + enum: ["A", "AAAA", "CNAME", "MX", "NS", "SOA", "TXT", "SRV"], + description: "DNS record type to retrieve", + }, + timeout_seconds: { + type: "number", + description: "Query timeout in seconds (default: 5)", + default: 5, + }, + }, + required: ["hostname", "record_type"], + }, + }, + { + name: "dns_reverse_lookup", + description: "Reverse DNS lookup β€” resolve IP address to hostname", + inputSchema: { + type: "object" as const, + properties: { + address: { + type: "string", + description: "IPv4 or IPv6 address to reverse lookup", + }, + timeout_seconds: { + type: "number", + description: "Query timeout in seconds (default: 5)", + default: 5, + }, + }, + required: ["address"], + }, + }, + { + name: "dns_bulk_lookup", + description: + "Batch DNS lookups for multiple hostnames (same record type)", + inputSchema: { + type: "object" as const, + properties: { + hostnames: { + type: "array", + items: { type: "string" }, + description: "List of domains to query", + }, + record_type: { + type: "string", + enum: ["A", "AAAA", "CNAME", "MX", "NS", "SOA", "TXT", "SRV"], + description: "DNS record type for all lookups", + }, + timeout_seconds: { + type: "number", + description: "Per-query timeout in seconds (default: 5)", + default: 5, + }, + }, + required: ["hostnames", "record_type"], + }, + }, +]; + +async function handleDNSLookup(args: Record<string, unknown>): Promise<string> { + const hostname = String(args.hostname); + const recordType = String(args.record_type || "A"); + + const result = await FFI.lookup(hostname, recordTypeToCode(recordType)); + return JSON.stringify(result, null, 2); +} + +async function handleReverseLookup( + args: Record<string, unknown> +): Promise<string> { + const address = String(args.address); + const result = await FFI.reverseLookup(address); + return JSON.stringify(result, null, 2); +} + +async function handleBulkLookup(args: Record<string, unknown>): Promise<string> { + const hostnames = Array.isArray(args.hostnames) + ? args.hostnames.map(String) + : []; + const recordType = String(args.record_type || "A"); + + const results = await FFI.bulkLookup(hostnames, recordTypeToCode(recordType)); + return JSON.stringify(results, null, 2); +} + +function recordTypeToCode(type: string): number { + const codes: Record<string, number> = { + A: 0, + AAAA: 1, + CNAME: 2, + MX: 3, + NS: 4, + SOA: 5, + TXT: 6, + SRV: 7, + }; + return codes[type] || 0; +} + +// Initialize MCP server +const server = new Server({ + name: "hesiod-mcp", + version: "1.0.0", +}); + +// Register tool handlers +server.setRequestHandler(ListToolsRequestSchema, async () => { + return { tools: TOOLS }; +}); + +server.setRequestHandler(CallToolRequestSchema, async (request) => { + const { name, arguments: args } = request; + + let result: string; + if (name === "dns_lookup") { + result = await handleDNSLookup(args as Record<string, unknown>); + } else if (name === "dns_reverse_lookup") { + result = await handleReverseLookup(args as Record<string, unknown>); + } else if (name === "dns_bulk_lookup") { + result = await handleBulkLookup(args as Record<string, unknown>); + } else { + return { + content: [ + { + type: "text" as const, + text: `Unknown tool: ${name}`, + }, + ], + isError: true, + }; + } + + return { + content: [ + { + type: "text" as const, + text: result, + }, + ], + }; +}); + +// Start server on loopback +const port = 5173; +await server.connect(new WebSocket(`ws://127.0.0.1:${port}`)); +console.log("Hesiod DNS MCP server running on ws://127.0.0.1:5173"); diff --git a/cartridges/domains/infrastructure/hesiod-mcp/cartridge.json b/cartridges/domains/infrastructure/hesiod-mcp/cartridge.json new file mode 100644 index 0000000..384a897 --- /dev/null +++ b/cartridges/domains/infrastructure/hesiod-mcp/cartridge.json @@ -0,0 +1,143 @@ +{ + "$schema": "https://boj.dev/schemas/cartridge/v1.json", + "spdx": "MPL-2.0", + "copyright": "Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk>", + "name": "hesiod-mcp", + "version": "1.0.0", + "description": "DNS lookup cartridge \u2014 query DNS records via MCP tools", + "domain": "Infrastructure", + "tier": "Ayo", + "auth": { + "method": "none" + }, + "author": "Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk>", + "license": "MPL-2.0", + "tools": [ + { + "id": "dns_lookup", + "name": "DNS Lookup", + "description": "Query DNS records for a hostname", + "inputSchema": { + "type": "object", + "properties": { + "hostname": { + "type": "string", + "description": "Domain name to query" + }, + "record_type": { + "type": "string", + "enum": [ + "A", + "AAAA", + "CNAME", + "MX", + "NS", + "SOA", + "TXT", + "SRV" + ], + "description": "DNS record type" + }, + "timeout_seconds": { + "type": "number", + "description": "Query timeout (default: 5)", + "default": 5 + } + }, + "required": [ + "hostname", + "record_type" + ] + } + }, + { + "id": "dns_reverse_lookup", + "name": "Reverse DNS Lookup", + "description": "Resolve IP address to hostname", + "inputSchema": { + "type": "object", + "properties": { + "address": { + "type": "string", + "description": "IPv4 or IPv6 address" + }, + "timeout_seconds": { + "type": "number", + "description": "Query timeout (default: 5)", + "default": 5 + } + }, + "required": [ + "address" + ] + } + }, + { + "id": "dns_bulk_lookup", + "name": "Bulk DNS Lookup", + "description": "Query multiple hostnames at once", + "inputSchema": { + "type": "object", + "properties": { + "hostnames": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of domains" + }, + "record_type": { + "type": "string", + "enum": [ + "A", + "AAAA", + "CNAME", + "MX", + "NS", + "SOA", + "TXT", + "SRV" + ], + "description": "Record type for all lookups" + }, + "timeout_seconds": { + "type": "number", + "description": "Per-query timeout (default: 5)", + "default": 5 + } + }, + "required": [ + "hostnames", + "record_type" + ] + } + } + ], + "abi": { + "interface": "abi/Hesiod.idr", + "loopback_proof": "IsLoopback 5173" + }, + "ffi": { + "so_path": "ffi/zig-out/lib/libhesiod_mcp.so", + "abi_version": "ADR-0006", + "symbols": [ + "boj_cartridge_init", + "boj_cartridge_deinit", + "boj_cartridge_name", + "boj_cartridge_version", + "boj_cartridge_invoke" + ] + }, + "adapter": { + "language": "typescript", + "runtime": "deno", + "entry": "adapter/mod.ts", + "permissions": [ + "net" + ] + }, + "loopback": { + "host": "127.0.0.1", + "port": 5173 + } +} diff --git a/cartridges/domains/infrastructure/hesiod-mcp/ffi/build.zig b/cartridges/domains/infrastructure/hesiod-mcp/ffi/build.zig new file mode 100644 index 0000000..d2241a5 --- /dev/null +++ b/cartridges/domains/infrastructure/hesiod-mcp/ffi/build.zig @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const ffi_mod = b.addModule("hesiod_mcp", .{ + .root_source_file = b.path("hesiod_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + const lib = b.addLibrary(.{ + .name = "hesiod_mcp", + .root_module = ffi_mod, + .linkage = .dynamic, + }); + lib.linkLibC(); + b.installArtifact(lib); + + const tests = b.addTest(.{ .root_module = ffi_mod }); + tests.linkLibC(); + const run_tests = b.addRunArtifact(tests); + const test_step = b.step("test", "Run FFI tests"); + test_step.dependOn(&run_tests.step); +} diff --git a/cartridges/domains/infrastructure/hesiod-mcp/ffi/cartridge_shim.zig b/cartridges/domains/infrastructure/hesiod-mcp/ffi/cartridge_shim.zig new file mode 100644 index 0000000..0e399db --- /dev/null +++ b/cartridges/domains/infrastructure/hesiod-mcp/ffi/cartridge_shim.zig @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// cartridge_shim.zig β€” Shared helpers for the ADR-0006 five-symbol +// cartridge ABI (`boj_cartridge_init / deinit / name / version / invoke`). +// +// The shim centralises the seven-code return convention, NUL-argument +// guards, tool-name comparison, and the buffer-too-small path so each +// cartridge's `boj_cartridge_invoke` can stay short β€” typically a tool +// table plus `shim.writeResult(...)`. +// +// Cartridges import this file by relative path (no build-graph change +// needed). Example: +// +// const shim = @import("../../../ffi/zig/src/cartridge_shim.zig"); +// +// export fn boj_cartridge_invoke( +// tool_name: [*c]const u8, +// json_args: [*c]const u8, +// out_buf: [*c]u8, +// in_out_len: [*c]usize, +// ) callconv(.c) i32 { +// _ = json_args; +// if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; +// const body = if (shim.toolIs(tool_name, "foo")) "{\"result\":{}}" +// else return shim.RC_UNKNOWN_TOOL; +// return shim.writeResult(out_buf, in_out_len, body); +// } + +const std = @import("std"); + +// ── Return codes (ADR-0006 Β§Return codes) ──────────────────────────── +// +// Frozen by ADR-0006. New failure modes compose these via the error +// JSON body β€” the integer surface does not grow without a follow-up ADR. + +pub const RC_SUCCESS: i32 = 0; +pub const RC_UNKNOWN_TOOL: i32 = -1; +pub const RC_BAD_ARGS: i32 = -2; +pub const RC_BUFFER_TOO_SMALL: i32 = -3; +pub const RC_RUNTIME_ERROR: i32 = -4; +pub const RC_PANIC: i32 = -5; +pub const RC_AUTH_DENIED: i32 = -6; + +// ── Invoke-path helpers ────────────────────────────────────────────── + +/// True if any of the three mandatory `boj_cartridge_invoke` output-path +/// pointers is null. Use at the top of every invoke to short-circuit to +/// `RC_BAD_ARGS`. +pub fn invokeArgsNull( + tool_name: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) bool { + return tool_name == null or out_buf == null or in_out_len == null; +} + +/// Compare a C-NUL-terminated tool-name pointer against a Zig string +/// literal. Caller must have already verified `tool_name` is non-null +/// (usually via `invokeArgsNull`). +pub fn toolIs(tool_name: [*c]const u8, expected: []const u8) bool { + const s = std.mem.span(@as([*:0]const u8, @ptrCast(tool_name))); + return std.mem.eql(u8, s, expected); +} + +/// Copy `body` into `out_buf[0..*in_out_len]` (as a capacity) and update +/// `*in_out_len` to the number of bytes written. Returns `RC_SUCCESS`. +/// +/// If `body.len` exceeds the current capacity stored in `*in_out_len`, +/// sets `*in_out_len` to the required size and returns +/// `RC_BUFFER_TOO_SMALL` β€” the caller is then expected to re-allocate +/// and retry, per ADR-0006 Β§Memory ownership. +/// +/// Caller must have already verified that `out_buf` and `in_out_len` +/// are non-null. +pub fn writeResult( + out_buf: [*c]u8, + in_out_len: [*c]usize, + body: []const u8, +) i32 { + const cap = in_out_len.*; + if (body.len > cap) { + in_out_len.* = body.len; + return RC_BUFFER_TOO_SMALL; + } + @memcpy(out_buf[0..body.len], body); + in_out_len.* = body.len; + return RC_SUCCESS; +} + +// ── Tests ──────────────────────────────────────────────────────────── + +test "writeResult: body fits, writes and sets length" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); + try std.testing.expectEqualStrings("hello", buf[0..len]); +} + +test "writeResult: too small returns -3 and sets required length" { + var buf: [2]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_BUFFER_TOO_SMALL, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: exact-fit succeeds" { + var buf: [5]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: empty body" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, ""); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 0), len); +} + +test "toolIs: matches and rejects" { + const name: [*:0]const u8 = "foo"; + try std.testing.expect(toolIs(@ptrCast(name), "foo")); + try std.testing.expect(!toolIs(@ptrCast(name), "bar")); + try std.testing.expect(!toolIs(@ptrCast(name), "foobar")); + try std.testing.expect(!toolIs(@ptrCast(name), "fo")); +} + +test "invokeArgsNull: detects each null slot" { + var buf: [4]u8 = undefined; + var len: usize = 4; + const name: [*:0]const u8 = "x"; + try std.testing.expect(!invokeArgsNull(@ptrCast(name), &buf, &len)); + try std.testing.expect(invokeArgsNull(null, &buf, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), null, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), &buf, null)); +} diff --git a/cartridges/domains/infrastructure/hesiod-mcp/ffi/hesiod_ffi.zig b/cartridges/domains/infrastructure/hesiod-mcp/ffi/hesiod_ffi.zig new file mode 100644 index 0000000..bb96b08 --- /dev/null +++ b/cartridges/domains/infrastructure/hesiod-mcp/ffi/hesiod_ffi.zig @@ -0,0 +1,64 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// hesiod-mcp FFI β€” ADR-0006 five-symbol cartridge ABI implementation. + +const std = @import("std"); +const shim = @import("cartridge_shim.zig"); + +const CARTRIDGE_NAME_PTR: [*:0]const u8 = "hesiod-mcp"; +const CARTRIDGE_VERSION_PTR: [*:0]const u8 = "0.1.0"; + +export fn boj_cartridge_init() callconv(.c) c_int { + return 0; +} + +export fn boj_cartridge_deinit() callconv(.c) void {} + +export fn boj_cartridge_name() callconv(.c) [*:0]const u8 { + return CARTRIDGE_NAME_PTR; +} + +export fn boj_cartridge_version() callconv(.c) [*:0]const u8 { + return CARTRIDGE_VERSION_PTR; +} + +export fn boj_cartridge_invoke( + tool_name: [*c]const u8, + json_args: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) callconv(.c) i32 { + _ = json_args; + if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; + const body: []const u8 = if (shim.toolIs(tool_name, "dns_lookup")) + "{\"result\":{}}" + else if (shim.toolIs(tool_name, "dns_reverse_lookup")) + "{\"result\":{}}" + else if (shim.toolIs(tool_name, "dns_bulk_lookup")) + "{\"result\":{}}" + else + return shim.RC_UNKNOWN_TOOL; + + return shim.writeResult(out_buf, in_out_len, body); +} + +test "boj_cartridge_name returns hesiod-mcp" { + try std.testing.expectEqualStrings("hesiod-mcp", std.mem.span(boj_cartridge_name())); +} + +test "boj_cartridge_init returns 0" { + try std.testing.expectEqual(@as(c_int, 0), boj_cartridge_init()); +} + +test "invoke unknown tool returns RC_UNKNOWN_TOOL" { + var buf: [256]u8 = undefined; + var len: usize = buf.len; + try std.testing.expectEqual(@as(i32, shim.RC_UNKNOWN_TOOL), boj_cartridge_invoke("unknown_xyz", "{}", &buf, &len)); +} + +test "invoke dns_lookup returns 0" { + var buf: [256]u8 = undefined; + var len: usize = buf.len; + try std.testing.expectEqual(@as(i32, 0), boj_cartridge_invoke("dns_lookup", "{}", &buf, &len)); +} diff --git a/cartridges/domains/infrastructure/hesiod-mcp/mod.js b/cartridges/domains/infrastructure/hesiod-mcp/mod.js new file mode 100644 index 0000000..1ea476a --- /dev/null +++ b/cartridges/domains/infrastructure/hesiod-mcp/mod.js @@ -0,0 +1,88 @@ +// SPDX-License-Identifier: MPL-2.0 +// Hesiod DNS Cartridge β€” Entry point for BoJ integration + +/** + * Cartridge metadata (populated from cartridge.json at runtime) + */ +export const cartridge = { + name: "hesiod-mcp", + version: "1.0.0", + description: "DNS lookup cartridge", + tools: [ + { + id: "dns_lookup", + name: "DNS Lookup", + invoke: async (args) => { + // Calls adapter/mod.ts which bridges to Zig FFI + return await invokeTool("dns_lookup", args); + }, + }, + { + id: "dns_reverse_lookup", + name: "Reverse DNS Lookup", + invoke: async (args) => { + return await invokeTool("dns_reverse_lookup", args); + }, + }, + { + id: "dns_bulk_lookup", + name: "Bulk DNS Lookup", + invoke: async (args) => { + return await invokeTool("dns_bulk_lookup", args); + }, + }, + ], +}; + +/** + * Tool invocation handler + * Routes MCP tool calls to the appropriate handler via the Deno adapter + */ +async function invokeTool(toolId, args) { + try { + // In BoJ context, this would call the actual Zig FFI + // For now, returns a stub response + return { + success: true, + tool: toolId, + arguments: args, + results: { + message: `DNS lookup tool ${toolId} called`, + }, + }; + } catch (error) { + return { + success: false, + error: error.message, + }; + } +} + +/** + * Cartridge health check + */ +export async function health() { + return { + status: "healthy", + cartridge: "hesiod-mcp", + loopback: "127.0.0.1:5173", + tools: cartridge.tools.length, + }; +} + +/** + * Cartridge initialization hook + */ +export async function init() { + console.log(`[hesiod-mcp] Initializing DNS lookup cartridge`); + // Would load Zig FFI, validate loopback proof, etc. + return { initialized: true }; +} + +/** + * Cartridge teardown hook + */ +export async function cleanup() { + console.log(`[hesiod-mcp] Shutting down`); + return { cleaned: true }; +} diff --git a/cartridges/domains/infrastructure/iac-mcp/README.adoc b/cartridges/domains/infrastructure/iac-mcp/README.adoc new file mode 100644 index 0000000..6340c82 --- /dev/null +++ b/cartridges/domains/infrastructure/iac-mcp/README.adoc @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += iac-mcp +Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk> +:spdx: MPL-2.0 +:tier: Ayo +:domain: Infrastructure +:protocols: MCP, REST + +== Overview + +Infrastructure-as-Code gateway. Manages Terraform/OpenTofu planβ†’applyβ†’destroy lifecycle with state machine enforcement. + +== Tools (7) + +[cols="2,4"] +|=== +| Tool | Description + +| `iac_init` | Initialise an IaC session for a working directory. +| `iac_plan` | Generate an execution plan. Returns a plan summary. +| `iac_apply` | Apply the most recent plan. +| `iac_destroy` | Destroy all managed infrastructure. +| `iac_state` | Get the current state machine state for a session slot. +| `iac_output` | Get output values from the current state. +| `iac_release` | Release a session slot. +|=== + +== Architecture + +Three-layer stack per the BoJ cartridge standard: + +[cols="1,1,3"] +|=== +| Layer | Language | Purpose + +| ABI | Idris2 | Formally verified state machine with dependent-type proofs +| FFI | Zig | C-ABI implementation: session pool, action metrics, thread safety +| Adapter| Zig | Adapter bridge to the BoJ unified adapter protocol (zig predecessors sidelined) +|=== + +== Building + +[source,bash] +---- +# ABI (Idris2) +cd abi && idris2 --check *.idr + +# FFI (Zig) +cd ffi && zig build +cd ffi && zig build test + +# Adapter (Zig) +cd adapter && zig build +---- + +== Status + +Alpha (CRG D). Cartridge metadata declared in `cartridge.json`; 7 tool(s) exposed. +Not yet dogfooded externally. See `docs/READINESS.md` at repo root for the +project-wide grade table and `docs/practice/DOGFOOD-LOG.adoc` for usage evidence. diff --git a/cartridges/domains/infrastructure/iac-mcp/abi/IacMcp/SafeIac.idr b/cartridges/domains/infrastructure/iac-mcp/abi/IacMcp/SafeIac.idr new file mode 100644 index 0000000..0414ce9 --- /dev/null +++ b/cartridges/domains/infrastructure/iac-mcp/abi/IacMcp/SafeIac.idr @@ -0,0 +1,160 @@ +-- SPDX-License-Identifier: MPL-2.0 +-- Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +||| IacMcp.SafeIac: Formally verified Infrastructure-as-Code operations. +||| +||| Cartridge: iac-mcp +||| Matrix cell: IaC domain x {MCP, LSP} protocols +||| +||| This module defines a plan-before-apply state machine that prevents: +||| - Applying infrastructure changes without a plan +||| - Skipping plan review before destructive operations +||| - Destroying resources from an uninitialised workspace +||| +||| State machine: Uninitialized -> Initialized -> Planned -> Applying -> Applied -> Initialized +module IacMcp.SafeIac + +import Data.List + +%default total + +-- ═══════════════════════════════════════════════════════════════════════════ +-- IaC State Machine +-- ═══════════════════════════════════════════════════════════════════════════ + +||| IaC workspace lifecycle states. +||| A workspace progresses: Uninitialized -> Initialized -> Planned -> Applying -> Applied -> Initialized +public export +data IacState = Uninitialized | Initialized | Planned | Applying | Applied | IacError + +||| Equality for IaC states. +public export +Eq IacState where + Uninitialized == Uninitialized = True + Initialized == Initialized = True + Planned == Planned = True + Applying == Applying = True + Applied == Applied = True + IacError == IacError = True + _ == _ = False + +||| Valid state transitions (enforced at the type level). +||| Critically, Initialized -> Applying is NOT a valid transition. +||| You MUST go through Planned first. +public export +data ValidTransition : IacState -> IacState -> Type where + Init : ValidTransition Uninitialized Initialized + Plan : ValidTransition Initialized Planned + Replan : ValidTransition Planned Planned + StartApply : ValidTransition Planned Applying + FinishApply : ValidTransition Applying Applied + Reset : ValidTransition Applied Initialized + Destroy : ValidTransition Initialized Uninitialized + ApplyError : ValidTransition Applying IacError + Recover : ValidTransition IacError Initialized + +||| Runtime transition validator. +public export +canTransition : IacState -> IacState -> Bool +canTransition Uninitialized Initialized = True +canTransition Initialized Planned = True +canTransition Planned Planned = True -- re-plan +canTransition Planned Applying = True +canTransition Applying Applied = True +canTransition Applied Initialized = True -- reset for next cycle +canTransition Initialized Uninitialized = True -- destroy +canTransition Applying IacError = True +canTransition IacError Initialized = True -- recover +canTransition _ _ = False + +-- ═══════════════════════════════════════════════════════════════════════════ +-- IaC Tool Types +-- ═══════════════════════════════════════════════════════════════════════════ + +||| Supported IaC tools. +public export +data IacTool + = Terraform -- HashiCorp Terraform + | Pulumi -- Pulumi + | Custom String -- User-defined IaC tool + +||| C-ABI encoding. +public export +iacToolToInt : IacTool -> Int +iacToolToInt Terraform = 1 +iacToolToInt Pulumi = 2 +iacToolToInt (Custom _) = 99 + +-- ═══════════════════════════════════════════════════════════════════════════ +-- MCP Tool Definitions +-- ═══════════════════════════════════════════════════════════════════════════ + +||| MCP tools exposed by this cartridge. +||| These map to MCP tool definitions that AI agents can call. +public export +data McpTool + = ToolInit -- Initialise a workspace (terraform init / pulumi up --yes=false) + | ToolPlan -- Generate an execution plan + | ToolApply -- Apply the planned changes + | ToolDestroy -- Tear down all resources + | ToolOutput -- Retrieve outputs from applied state + | ToolState -- Inspect current state machine position + | ToolImport -- Import existing resources into state + | ToolValidate -- Validate configuration files + +||| MCP tool name (for JSON-RPC method name). +public export +toolName : McpTool -> String +toolName ToolInit = "iac/init" +toolName ToolPlan = "iac/plan" +toolName ToolApply = "iac/apply" +toolName ToolDestroy = "iac/destroy" +toolName ToolOutput = "iac/output" +toolName ToolState = "iac/state" +toolName ToolImport = "iac/import" +toolName ToolValidate = "iac/validate" + +||| Which tools require a plan to have been generated first. +public export +toolRequiresPlan : McpTool -> Bool +toolRequiresPlan ToolApply = True +toolRequiresPlan _ = False + +-- ═══════════════════════════════════════════════════════════════════════════ +-- C-ABI Exports +-- ═══════════════════════════════════════════════════════════════════════════ + +||| IaC state to integer. +public export +iacStateToInt : IacState -> Int +iacStateToInt Uninitialized = 0 +iacStateToInt Initialized = 1 +iacStateToInt Planned = 2 +iacStateToInt Applying = 3 +iacStateToInt Applied = 4 +iacStateToInt IacError = 5 + +||| FFI: Validate a state transition. +export +iac_can_transition : Int -> Int -> Int +iac_can_transition from to = + let fromState = case from of + 0 => Uninitialized + 1 => Initialized + 2 => Planned + 3 => Applying + 4 => Applied + _ => IacError + toState = case to of + 0 => Uninitialized + 1 => Initialized + 2 => Planned + 3 => Applying + 4 => Applied + _ => IacError + in if canTransition fromState toState then 1 else 0 + +||| FFI: Check if a tool requires a plan to have been generated. +export +iac_tool_requires_plan : Int -> Int +iac_tool_requires_plan 3 = 1 -- ToolApply +iac_tool_requires_plan _ = 0 -- All others do not require a plan diff --git a/cartridges/domains/infrastructure/iac-mcp/abi/README.adoc b/cartridges/domains/infrastructure/iac-mcp/abi/README.adoc new file mode 100644 index 0000000..84184be --- /dev/null +++ b/cartridges/domains/infrastructure/iac-mcp/abi/README.adoc @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += iac-mcp / abi β€” Idris2 ABI layer +:orientation: shallow + +== Purpose + +Cartridge-local protocol types for `iac-mcp`. Defines the data, tool, and +transition vocabulary this cartridge speaks. Inherits safety lemmas and the +matrix-cell identifier scheme from the trunk ABI at `src/abi/`. + +== Files + +1 Idris2 module(s), ~160 lines total. Lead module: +`SafeIac.idr`. + +No `.ipkg` β€” consumed directly by wider BoJ callers; type-checks inside the +trunk `boj.ipkg`. + +== Invariants + +* `%default total` at every module top. +* Zero `believe_me`, `assert_total`, `postulate`, `Admitted`, or + `sorry` (the trunk holds the four declared `believe_me` sites; leaf + cartridges carry none). + +== Test/proof surface + +Thin module(s), no inline tests. Proof surface = type-check success under the +trunk `idris2 --check`. + +== Read-first + +. `SafeIac.idr` β€” cartridge-local data records and enums. +. Trunk `src/abi/Boj/Catalogue.idr` for how this cell participates in the + 2D matrix. diff --git a/cartridges/domains/infrastructure/iac-mcp/abi/iac-mcp.ipkg b/cartridges/domains/infrastructure/iac-mcp/abi/iac-mcp.ipkg new file mode 100644 index 0000000..c9a4569 --- /dev/null +++ b/cartridges/domains/infrastructure/iac-mcp/abi/iac-mcp.ipkg @@ -0,0 +1,11 @@ +-- SPDX-License-Identifier: MPL-2.0 +package iacmcp + +authors = "Jonathan D.A. Jewell" +version = 0.1.0 +license = "MPL-2.0" +brief = "IaC MCP cartridge β€” plan-before-apply state machine for Terraform/Pulumi" + +sourcedir = "." +modules = IacMcp.SafeIac +depends = base, contrib diff --git a/cartridges/domains/infrastructure/iac-mcp/adapter/README.adoc b/cartridges/domains/infrastructure/iac-mcp/adapter/README.adoc new file mode 100644 index 0000000..ed39e18 --- /dev/null +++ b/cartridges/domains/infrastructure/iac-mcp/adapter/README.adoc @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += iac-mcp / adapter β€” protocol bridge +:orientation: shallow + +== Purpose + +Exposes this cartridge's FFI over the wire protocols declared in +`cartridge.json`. Dispatches tool invocations to the C-ABI exports at +`../ffi/` and formats responses per protocol. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” depends on the sibling `ffi` target. +| `iac_adapter.zig` | Protocol dispatch (112 lines). Tool catalogue matches `cartridge.json`. +| `SIDELINED-*.v.adoc` | zig predecessor(s). Not built β€” zig banned 2026-04-10. +|=== + +== Invariants + +* **Stateless** β€” all state lives behind the FFI, never in the adapter. +* **Response passthrough** β€” whatever the FFI returns goes back to the wire + unmodified (no embellishment, no silent recovery). +* **Cartridge.json is source of truth** for the tool catalogue; drift between + adapter and manifest is a CI failure. + +== Test/proof surface + +No inline tests at the adapter layer β€” protocol routing is exercised by the +trunk seam tests at `ffi/zig/src/seams.zig`. + +== Read-first + +. `iac_adapter.zig` β€” dispatch function and per-protocol routers. +. `../cartridge.json` β€” the tool manifest the dispatcher mirrors. diff --git a/cartridges/domains/infrastructure/iac-mcp/adapter/build.zig b/cartridges/domains/infrastructure/iac-mcp/adapter/build.zig new file mode 100644 index 0000000..3151787 --- /dev/null +++ b/cartridges/domains/infrastructure/iac-mcp/adapter/build.zig @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// iac-mcp/adapter/build.zig + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); const optimize = b.standardOptimizeOption(.{}); + const ffi_mod = b.createModule(.{ .root_source_file = b.path("../ffi/iac_ffi.zig"), .target = target, .optimize = optimize }); + const adapter = b.addExecutable(.{ .name = "iac_adapter", .root_source_file = b.path("iac_adapter.zig"), .target = target, .optimize = optimize }); + adapter.root_module.addImport("iac_ffi", ffi_mod); b.installArtifact(adapter); + const rs = b.step("run", "Run iac-mcp adapter"); rs.dependOn(&b.addRunArtifact(adapter).step); + const tests = b.addTest(.{ .root_source_file = b.path("iac_adapter.zig"), .target = target, .optimize = optimize }); + tests.root_module.addImport("iac_ffi", ffi_mod); + const ts = b.step("test", "Test iac-mcp adapter"); ts.dependOn(&b.addRunArtifact(tests).step); +} diff --git a/cartridges/domains/infrastructure/iac-mcp/adapter/iac_adapter.zig b/cartridges/domains/infrastructure/iac-mcp/adapter/iac_adapter.zig new file mode 100644 index 0000000..ed38044 --- /dev/null +++ b/cartridges/domains/infrastructure/iac-mcp/adapter/iac_adapter.zig @@ -0,0 +1,112 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// iac-mcp/adapter/iac_adapter.zig -- Unified three-protocol adapter. +// Replaces banned iac_adapter.v (zig, removed 2026-04-12). +// REST:9151 gRPC:9152 GraphQL:9153 +// Tools: iac_init, iac_plan, iac_apply, iac_destroy... + +const std = @import("std"); +const ffi = @import("iac_ffi"); + +const REST_PORT: u16 = 9151; +const GRPC_PORT: u16 = 9152; +const GQL_PORT: u16 = 9153; +const MAX_CONN_BUF: usize = 16 * 1024; + +fn okJson(buf: []u8, msg: []const u8) []u8 { + const n = std.fmt.bufPrint(buf, "{\"success\":true,\"message\":\"{s}\"}", .{msg}) catch return buf[0..0]; return n; +} +fn errJson(buf: []u8, msg: []const u8) []u8 { + const n = std.fmt.bufPrint(buf, "{\"success\":false,\"error\":\"{s}\"}", .{msg}) catch return buf[0..0]; return n; +} +fn statusJson(buf: []u8) []u8 { + const n = std.fmt.bufPrint(buf, "{\"success\":true,\"state\":\"ready\",\"service\":\"iac-mcp\"}", .{}) catch return buf[0..0]; return n; +} + +const Response = struct { status: u16, body: []u8 }; + +fn dispatch(tool: []const u8, body: []const u8, resp: []u8) Response { + _ = body; + if (std.mem.eql(u8, tool, "iac_init")) return .{ .status = 200, .body = okJson(resp, "iac_init forwarded") }; + if (std.mem.eql(u8, tool, "iac_plan")) return .{ .status = 200, .body = okJson(resp, "iac_plan forwarded") }; + if (std.mem.eql(u8, tool, "iac_apply")) return .{ .status = 200, .body = okJson(resp, "iac_apply forwarded") }; + if (std.mem.eql(u8, tool, "iac_destroy")) return .{ .status = 200, .body = okJson(resp, "iac_destroy forwarded") }; + if (std.mem.eql(u8, tool, "iac_state")) return .{ .status = 200, .body = okJson(resp, "iac_state forwarded") }; + if (std.mem.eql(u8, tool, "iac_output")) return .{ .status = 200, .body = okJson(resp, "iac_output forwarded") }; + if (std.mem.eql(u8, tool, "iac_release")) return .{ .status = 200, .body = okJson(resp, "iac_release forwarded") }; + if (std.mem.eql(u8, tool, "status") or std.mem.eql(u8, tool, "health")) return .{ .status = 200, .body = statusJson(resp) }; + return .{ .status = 404, .body = errJson(resp, "Unknown tool") }; +} + +fn dispatchRest(path: []const u8, body: []const u8, resp: []u8) Response { + if (std.mem.startsWith(u8, path, "/tools/")) return dispatch(path["/tools/".len..], body, resp); + if (std.mem.eql(u8, path, "/status") or std.mem.eql(u8, path, "/health")) return .{ .status = 200, .body = statusJson(resp) }; + return .{ .status = 404, .body = errJson(resp, "Not found") }; +} + +fn dispatchGrpc(path: []const u8, body: []const u8, resp: []u8) Response { + const prefix = "/Iacservice/"; + if (!std.mem.startsWith(u8, path, prefix)) return .{ .status = 404, .body = errJson(resp, "Not a recognized gRPC path") }; + const method = path[prefix.len..]; + const tool = blk: { + if (std.mem.eql(u8, method, "iac_init")) break :blk "iac_init"; + if (std.mem.eql(u8, method, "iac_plan")) break :blk "iac_plan"; + if (std.mem.eql(u8, method, "iac_apply")) break :blk "iac_apply"; + if (std.mem.eql(u8, method, "iac_destroy")) break :blk "iac_destroy"; + if (std.mem.eql(u8, method, "iac_state")) break :blk "iac_state"; + if (std.mem.eql(u8, method, "iac_output")) break :blk "iac_output"; + if (std.mem.eql(u8, method, "iac_release")) break :blk "iac_release"; + return .{ .status = 404, .body = errJson(resp, "Unknown gRPC method") }; + }; + return dispatch(tool, body, resp); +} + +fn dispatchGraphql(body: []const u8, resp: []u8) Response { + if (std.mem.indexOf(u8, body, "__schema") != null) return .{ .status = 200, .body = okJson(resp, "schema not supported") }; + if (std.mem.indexOf(u8, body, "init") != null) return dispatch("iac_init", body, resp); + if (std.mem.indexOf(u8, body, "plan") != null) return dispatch("iac_plan", body, resp); + if (std.mem.indexOf(u8, body, "apply") != null) return dispatch("iac_apply", body, resp); + if (std.mem.indexOf(u8, body, "destroy") != null) return dispatch("iac_destroy", body, resp); + if (std.mem.indexOf(u8, body, "state") != null) return dispatch("iac_state", body, resp); + if (std.mem.indexOf(u8, body, "output") != null) return dispatch("iac_output", body, resp); + if (std.mem.indexOf(u8, body, "release") != null) return dispatch("iac_release", body, resp); + return .{ .status = 200, .body = errJson(resp, "Unrecognised GraphQL operation") }; +} + +const Protocol = enum { rest, grpc, graphql }; + +fn handleConnection(conn: std.net.Server.Connection, proto: Protocol) void { + defer conn.stream.close(); + var in_buf: [MAX_CONN_BUF]u8 = undefined; + const n = conn.stream.read(&in_buf) catch return; + const req = in_buf[0..n]; + var path: []const u8 = "/"; var body: []const u8 = ""; + if (n > 4) { + const le = std.mem.indexOf(u8, req, "\r\n") orelse req.len; + const fl = req[0..le]; const sp1 = std.mem.indexOfScalar(u8, fl, ' ') orelse 0; + const ro = fl[sp1+1..]; const sp2 = std.mem.indexOfScalar(u8, ro, ' ') orelse ro.len; + path = ro[0..sp2]; + const bs = std.mem.indexOf(u8, req, "\r\n\r\n") orelse n; body = req[@min(bs+4,n)..]; + } + var rb: [MAX_CONN_BUF]u8 = undefined; + const result = switch (proto) { .rest => dispatchRest(path, body, &rb), .grpc => dispatchGrpc(path, body, &rb), .graphql => dispatchGraphql(body, &rb) }; + const ct: []const u8 = if (proto == .grpc) "application/grpc+json" else "application/json"; + var hb: [256]u8 = undefined; + const hdr = std.fmt.bufPrint(&hb, "HTTP/1.1 {d} OK\r\nContent-Type: {s}\r\nContent-Length: {d}\r\nConnection: close\r\n\r\n", .{ result.status, ct, result.body.len }) catch return; + _ = conn.stream.writeAll(hdr) catch return; _ = conn.stream.writeAll(result.body) catch return; +} + +fn listenLoop(port: u16, proto: Protocol) void { + const addr = std.net.Address.initIp4(.{ 127, 0, 0, 1 }, port) catch return; + var srv = addr.listen(.{ .reuse_address = true }) catch return; defer srv.deinit(); + while (true) { const conn = srv.accept() catch continue; handleConnection(conn, proto); } +} + +pub fn main() !void { + ffi.iac_init(); + const t1 = try std.Thread.spawn(.{}, listenLoop, .{ REST_PORT, Protocol.rest }); + const t2 = try std.Thread.spawn(.{}, listenLoop, .{ GRPC_PORT, Protocol.grpc }); + const t3 = try std.Thread.spawn(.{}, listenLoop, .{ GQL_PORT, Protocol.graphql }); + t1.join(); t2.join(); t3.join(); +} diff --git a/cartridges/domains/infrastructure/iac-mcp/cartridge.json b/cartridges/domains/infrastructure/iac-mcp/cartridge.json new file mode 100644 index 0000000..cb8c21b --- /dev/null +++ b/cartridges/domains/infrastructure/iac-mcp/cartridge.json @@ -0,0 +1,168 @@ +{ + "$schema": "https://boj.dev/schemas/cartridge/v1.json", + "spdx": "MPL-2.0", + "copyright": "Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath)", + "name": "iac-mcp", + "version": "0.1.0", + "description": "Infrastructure-as-Code gateway. Manages Terraform/OpenTofu planβ†’applyβ†’destroy lifecycle with state machine enforcement.", + "domain": "Infrastructure", + "tier": "Ayo", + "protocols": [ + "MCP", + "REST" + ], + "auth": { + "method": "none", + "env_var": null, + "credential_source": null + }, + "api": { + "base_url": "local://iac-mcp", + "content_type": "application/json" + }, + "tools": [ + { + "name": "iac_init", + "description": "Initialise an IaC session for a working directory.", + "inputSchema": { + "type": "object", + "properties": { + "tool": { + "type": "string", + "description": "IaC tool: terraform / opentofu / pulumi (default: opentofu)" + }, + "working_dir": { + "type": "string", + "description": "Working directory containing IaC configuration" + } + }, + "required": [ + "working_dir" + ] + } + }, + { + "name": "iac_plan", + "description": "Generate an execution plan. Returns a plan summary.", + "inputSchema": { + "type": "object", + "properties": { + "slot": { + "type": "integer", + "description": "Session slot from iac_init" + }, + "target": { + "type": "string", + "description": "Limit plan to a specific resource (optional)" + } + }, + "required": [ + "slot" + ] + } + }, + { + "name": "iac_apply", + "description": "Apply the most recent plan.", + "inputSchema": { + "type": "object", + "properties": { + "slot": { + "type": "integer", + "description": "Session slot" + }, + "auto_approve": { + "type": "boolean", + "description": "Skip interactive approval (default: false)" + } + }, + "required": [ + "slot" + ] + } + }, + { + "name": "iac_destroy", + "description": "Destroy all managed infrastructure.", + "inputSchema": { + "type": "object", + "properties": { + "slot": { + "type": "integer", + "description": "Session slot" + }, + "auto_approve": { + "type": "boolean", + "description": "Skip interactive approval (default: false)" + } + }, + "required": [ + "slot" + ] + } + }, + { + "name": "iac_state", + "description": "Get the current state machine state for a session slot.", + "inputSchema": { + "type": "object", + "properties": { + "slot": { + "type": "integer", + "description": "Session slot" + } + }, + "required": [ + "slot" + ] + } + }, + { + "name": "iac_output", + "description": "Get output values from the current state.", + "inputSchema": { + "type": "object", + "properties": { + "slot": { + "type": "integer", + "description": "Session slot" + }, + "name": { + "type": "string", + "description": "Specific output name (all outputs if omitted)" + } + }, + "required": [ + "slot" + ] + } + }, + { + "name": "iac_release", + "description": "Release a session slot.", + "inputSchema": { + "type": "object", + "properties": { + "slot": { + "type": "integer", + "description": "Session slot to release" + } + }, + "required": [ + "slot" + ] + } + } + ], + "ffi": { + "so_path": "ffi/zig-out/lib/libiac_mcp.so", + "abi_version": "ADR-0006", + "symbols": [ + "boj_cartridge_init", + "boj_cartridge_deinit", + "boj_cartridge_name", + "boj_cartridge_version", + "boj_cartridge_invoke" + ] + } +} diff --git a/cartridges/domains/infrastructure/iac-mcp/ffi/README.adoc b/cartridges/domains/infrastructure/iac-mcp/ffi/README.adoc new file mode 100644 index 0000000..96280fc --- /dev/null +++ b/cartridges/domains/infrastructure/iac-mcp/ffi/README.adoc @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += iac-mcp / ffi β€” Zig FFI layer +:orientation: shallow + +== Purpose + +C-ABI exports bridging this cartridge's Idris2 ABI to the runtime. Consumed by +the adapter for protocol dispatch and by the trunk loader for mount. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” produces `zig-out/lib/libiac.so`. +| `iac_ffi.zig` | C-ABI exports (12 exports, 7 inline tests, 264 lines). +|=== + +== Invariants + +* **Null-pointer rejection** on `[*c]const u8` arguments before any + dereference. +* **Negative return values** carry documented error meanings; zero is + success (matching the trunk error-code convention). +* **No allocator leaks** β€” every allocation path pairs with `defer free`. + +== Test/proof surface + +7 inline `test "..."` block(s) in `iac_ffi.zig`. Run with +`cd ffi && zig build test`. + +== Read-first + +. `iac_ffi.zig` β€” C-ABI signatures and guards. +. Trunk `ffi/zig/src/catalogue.zig` for how this `.so` is loaded. diff --git a/cartridges/domains/infrastructure/iac-mcp/ffi/build.zig b/cartridges/domains/infrastructure/iac-mcp/ffi/build.zig new file mode 100644 index 0000000..d57e8e1 --- /dev/null +++ b/cartridges/domains/infrastructure/iac-mcp/ffi/build.zig @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// IaC-MCP Cartridge β€” Zig FFI build configuration (Zig 0.15+). + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + // Shared ADR-0006 invoke-shim module (relative path up to boj-server trunk). + + const shim_mod = b.addModule("cartridge_shim", .{ + + .root_source_file = b.path("../../../ffi/zig/src/cartridge_shim.zig"), + + .target = target, + + .optimize = optimize, + + }); + + const iac_mod = b.addModule("iac_ffi", .{ + .root_source_file = b.path("iac_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + iac_mod.addImport("cartridge_shim", shim_mod); + + // ── Tests ──────────────────────────────────────────────────────── + const iac_tests = b.addTest(.{ + .root_module = iac_mod, + }); + + const run_tests = b.addRunArtifact(iac_tests); + + const test_step = b.step("test", "Run iac-mcp FFI tests"); + test_step.dependOn(&run_tests.step); + + // ── Shared library ────────────────────────────────────────────── + const lib_mod = b.createModule(.{ + .root_source_file = b.path("iac_ffi.zig"), + .target = target, + .optimize = optimize, + }); + lib_mod.addImport("cartridge_shim", shim_mod); + + const lib = b.addLibrary(.{ + .name = "iac_mcp", + .root_module = lib_mod, + .linkage = .dynamic, + }); + b.installArtifact(lib); + + const lib_step = b.step("lib", "Build shared library"); + lib_step.dependOn(&lib.step); +} diff --git a/cartridges/domains/infrastructure/iac-mcp/ffi/cartridge_shim.zig b/cartridges/domains/infrastructure/iac-mcp/ffi/cartridge_shim.zig new file mode 100644 index 0000000..0e399db --- /dev/null +++ b/cartridges/domains/infrastructure/iac-mcp/ffi/cartridge_shim.zig @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// cartridge_shim.zig β€” Shared helpers for the ADR-0006 five-symbol +// cartridge ABI (`boj_cartridge_init / deinit / name / version / invoke`). +// +// The shim centralises the seven-code return convention, NUL-argument +// guards, tool-name comparison, and the buffer-too-small path so each +// cartridge's `boj_cartridge_invoke` can stay short β€” typically a tool +// table plus `shim.writeResult(...)`. +// +// Cartridges import this file by relative path (no build-graph change +// needed). Example: +// +// const shim = @import("../../../ffi/zig/src/cartridge_shim.zig"); +// +// export fn boj_cartridge_invoke( +// tool_name: [*c]const u8, +// json_args: [*c]const u8, +// out_buf: [*c]u8, +// in_out_len: [*c]usize, +// ) callconv(.c) i32 { +// _ = json_args; +// if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; +// const body = if (shim.toolIs(tool_name, "foo")) "{\"result\":{}}" +// else return shim.RC_UNKNOWN_TOOL; +// return shim.writeResult(out_buf, in_out_len, body); +// } + +const std = @import("std"); + +// ── Return codes (ADR-0006 Β§Return codes) ──────────────────────────── +// +// Frozen by ADR-0006. New failure modes compose these via the error +// JSON body β€” the integer surface does not grow without a follow-up ADR. + +pub const RC_SUCCESS: i32 = 0; +pub const RC_UNKNOWN_TOOL: i32 = -1; +pub const RC_BAD_ARGS: i32 = -2; +pub const RC_BUFFER_TOO_SMALL: i32 = -3; +pub const RC_RUNTIME_ERROR: i32 = -4; +pub const RC_PANIC: i32 = -5; +pub const RC_AUTH_DENIED: i32 = -6; + +// ── Invoke-path helpers ────────────────────────────────────────────── + +/// True if any of the three mandatory `boj_cartridge_invoke` output-path +/// pointers is null. Use at the top of every invoke to short-circuit to +/// `RC_BAD_ARGS`. +pub fn invokeArgsNull( + tool_name: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) bool { + return tool_name == null or out_buf == null or in_out_len == null; +} + +/// Compare a C-NUL-terminated tool-name pointer against a Zig string +/// literal. Caller must have already verified `tool_name` is non-null +/// (usually via `invokeArgsNull`). +pub fn toolIs(tool_name: [*c]const u8, expected: []const u8) bool { + const s = std.mem.span(@as([*:0]const u8, @ptrCast(tool_name))); + return std.mem.eql(u8, s, expected); +} + +/// Copy `body` into `out_buf[0..*in_out_len]` (as a capacity) and update +/// `*in_out_len` to the number of bytes written. Returns `RC_SUCCESS`. +/// +/// If `body.len` exceeds the current capacity stored in `*in_out_len`, +/// sets `*in_out_len` to the required size and returns +/// `RC_BUFFER_TOO_SMALL` β€” the caller is then expected to re-allocate +/// and retry, per ADR-0006 Β§Memory ownership. +/// +/// Caller must have already verified that `out_buf` and `in_out_len` +/// are non-null. +pub fn writeResult( + out_buf: [*c]u8, + in_out_len: [*c]usize, + body: []const u8, +) i32 { + const cap = in_out_len.*; + if (body.len > cap) { + in_out_len.* = body.len; + return RC_BUFFER_TOO_SMALL; + } + @memcpy(out_buf[0..body.len], body); + in_out_len.* = body.len; + return RC_SUCCESS; +} + +// ── Tests ──────────────────────────────────────────────────────────── + +test "writeResult: body fits, writes and sets length" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); + try std.testing.expectEqualStrings("hello", buf[0..len]); +} + +test "writeResult: too small returns -3 and sets required length" { + var buf: [2]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_BUFFER_TOO_SMALL, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: exact-fit succeeds" { + var buf: [5]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: empty body" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, ""); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 0), len); +} + +test "toolIs: matches and rejects" { + const name: [*:0]const u8 = "foo"; + try std.testing.expect(toolIs(@ptrCast(name), "foo")); + try std.testing.expect(!toolIs(@ptrCast(name), "bar")); + try std.testing.expect(!toolIs(@ptrCast(name), "foobar")); + try std.testing.expect(!toolIs(@ptrCast(name), "fo")); +} + +test "invokeArgsNull: detects each null slot" { + var buf: [4]u8 = undefined; + var len: usize = 4; + const name: [*:0]const u8 = "x"; + try std.testing.expect(!invokeArgsNull(@ptrCast(name), &buf, &len)); + try std.testing.expect(invokeArgsNull(null, &buf, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), null, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), &buf, null)); +} diff --git a/cartridges/domains/infrastructure/iac-mcp/ffi/iac_ffi.zig b/cartridges/domains/infrastructure/iac-mcp/ffi/iac_ffi.zig new file mode 100644 index 0000000..b099bf7 --- /dev/null +++ b/cartridges/domains/infrastructure/iac-mcp/ffi/iac_ffi.zig @@ -0,0 +1,339 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// IaC-MCP Cartridge β€” Zig FFI bridge for infrastructure-as-code operations. +// +// Implements the plan-before-apply state machine from SafeIac.idr. +// Ensures no infrastructure apply can execute without a preceding plan, +// and no destroy can run from an uninitialised workspace. + +const std = @import("std"); + +// ═══════════════════════════════════════════════════════════════════════ +// Types (must match IacMcp.SafeIac encodings) +// ═══════════════════════════════════════════════════════════════════════ + +pub const IacState = enum(c_int) { + uninitialized = 0, + initialized = 1, + planned = 2, + applying = 3, + applied = 4, + iac_error = 5, +}; + +pub const IacTool = enum(c_int) { + terraform = 1, + pulumi = 2, + custom = 99, +}; + +// ═══════════════════════════════════════════════════════════════════════ +// Plan-Before-Apply State Machine +// ═══════════════════════════════════════════════════════════════════════ + +const MAX_WORKSPACES: usize = 8; + +const WorkspaceSlot = struct { + active: bool, + tool: IacTool, + state: IacState, + plan_hash: u32, // Hash of plan for verification on apply +}; + +var workspaces: [MAX_WORKSPACES]WorkspaceSlot = [_]WorkspaceSlot{.{ + .active = false, + .tool = .terraform, + .state = .uninitialized, + .plan_hash = 0, +}} ** MAX_WORKSPACES; + +var mutex: std.Thread.Mutex = .{}; + +/// Validate a state transition (matches Idris2 canTransition). +fn isValidTransition(from: IacState, to: IacState) bool { + return switch (from) { + .uninitialized => to == .initialized, + .initialized => to == .planned or to == .uninitialized, + .planned => to == .applying or to == .planned, // re-plan allowed + .applying => to == .applied or to == .iac_error, + .applied => to == .initialized, // reset for next cycle + .iac_error => to == .initialized, // recover + }; +} + +/// Initialise a new workspace. Returns slot index or -1 on failure. +pub export fn iac_init(tool: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + for (&workspaces, 0..) |*slot, i| { + if (!slot.active) { + slot.active = true; + slot.tool = @enumFromInt(tool); + slot.state = .initialized; + slot.plan_hash = 0; + return @intCast(i); + } + } + return -1; // No slots available +} + +/// Generate a plan for a workspace. +pub export fn iac_plan(slot_idx: c_int, plan_hash: u32) c_int { + mutex.lock(); + defer mutex.unlock(); + if (slot_idx < 0 or slot_idx >= MAX_WORKSPACES) return -1; + const idx: usize = @intCast(slot_idx); + if (!workspaces[idx].active) return -1; + if (!isValidTransition(workspaces[idx].state, .planned)) return -2; + + workspaces[idx].state = .planned; + workspaces[idx].plan_hash = plan_hash; + return 0; +} + +/// Apply the planned changes. REQUIRES state to be Planned (not Initialized). +/// This is the key safety invariant: you cannot apply without planning first. +pub export fn iac_apply(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + if (slot_idx < 0 or slot_idx >= MAX_WORKSPACES) return -1; + const idx: usize = @intCast(slot_idx); + if (!workspaces[idx].active) return -1; + // SAFETY: Must be in Planned state β€” cannot skip from Initialized + if (workspaces[idx].state != .planned) return -2; + if (!isValidTransition(workspaces[idx].state, .applying)) return -2; + + workspaces[idx].state = .applying; + // Simulate immediate completion for the state machine + workspaces[idx].state = .applied; + return 0; +} + +/// Destroy all resources and return to Uninitialized. +pub export fn iac_destroy(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + if (slot_idx < 0 or slot_idx >= MAX_WORKSPACES) return -1; + const idx: usize = @intCast(slot_idx); + if (!workspaces[idx].active) return -1; + if (!isValidTransition(workspaces[idx].state, .uninitialized)) return -2; + + workspaces[idx].active = false; + workspaces[idx].state = .uninitialized; + workspaces[idx].plan_hash = 0; + return 0; +} + +/// Get the state of a workspace. +pub export fn iac_state(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + if (slot_idx < 0 or slot_idx >= MAX_WORKSPACES) return -1; + const idx: usize = @intCast(slot_idx); + if (!workspaces[idx].active) return @intFromEnum(IacState.uninitialized); + return @intFromEnum(workspaces[idx].state); +} + +/// Check whether a workspace has an active plan. +pub export fn iac_has_plan(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + if (slot_idx < 0 or slot_idx >= MAX_WORKSPACES) return 0; + const idx: usize = @intCast(slot_idx); + if (!workspaces[idx].active) return 0; + return if (workspaces[idx].state == .planned and workspaces[idx].plan_hash != 0) 1 else 0; +} + +/// Validate a state transition (C-ABI export). +pub export fn iac_can_transition(from: c_int, to: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + const f: IacState = @enumFromInt(from); + const t: IacState = @enumFromInt(to); + return if (isValidTransition(f, t)) 1 else 0; +} + +/// Reset all workspaces (for testing). +pub export fn iac_reset() void { + mutex.lock(); + defer mutex.unlock(); + for (&workspaces) |*slot| { + slot.active = false; + slot.state = .uninitialized; + slot.plan_hash = 0; + } +} + +// ═══════════════════════════════════════════════════════════════════════ +// Standard Cartridge Interface (loader expects these 4 C-ABI symbols) +// ═══════════════════════════════════════════════════════════════════════ + +/// Initialise the iac-mcp cartridge. Resets all workspace slots. +pub export fn boj_cartridge_init() c_int { + iac_reset(); + return 0; +} + +/// Deinitialise the iac-mcp cartridge. Resets all workspace slots. +pub export fn boj_cartridge_deinit() void { + iac_reset(); +} + +/// Return the cartridge name as a null-terminated C string. +pub export fn boj_cartridge_name() [*:0]const u8 { + mutex.lock(); + defer mutex.unlock(); + return "iac-mcp"; +} + +/// Return the cartridge version as a null-terminated C string. +pub export fn boj_cartridge_version() [*:0]const u8 { + mutex.lock(); + defer mutex.unlock(); + return "0.1.0"; +} + +// ═══════════════════════════════════════════════════════════════════════ +// ADR-0006 dispatch (boj_cartridge_invoke, 5th standard symbol) +// ═══════════════════════════════════════════════════════════════════════ + +const shim = @import("cartridge_shim.zig"); + +/// Dispatch the cartridge.json MCP tools. Grade D Alpha β€” each arm +/// returns a stub JSON body shaped to the tool's intended response. +export fn boj_cartridge_invoke( + tool_name: [*c]const u8, + json_args: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) callconv(.c) i32 { + _ = json_args; + if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; + + const body: []const u8 = if (shim.toolIs(tool_name, "iac_init")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "iac_plan")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "iac_apply")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "iac_destroy")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "iac_state")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "iac_output")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "iac_release")) + "{\"result\":{\"status\":\"stub\"}}" +else + return shim.RC_UNKNOWN_TOOL; + + return shim.writeResult(out_buf, in_out_len, body); +} + +// ═══════════════════════════════════════════════════════════════════════ +// Tests +// ═══════════════════════════════════════════════════════════════════════ + +test "init and destroy" { + iac_reset(); + const slot = iac_init(@intFromEnum(IacTool.terraform)); + try std.testing.expect(slot >= 0); + try std.testing.expectEqual(@as(c_int, @intFromEnum(IacState.initialized)), iac_state(slot)); + try std.testing.expectEqual(@as(c_int, 0), iac_destroy(slot)); +} + +test "cannot apply without plan" { + iac_reset(); + const slot = iac_init(@intFromEnum(IacTool.terraform)); + // Attempt to apply directly from initialized β€” MUST fail + try std.testing.expectEqual(@as(c_int, -2), iac_apply(slot)); + // State should remain initialized + try std.testing.expectEqual(@as(c_int, @intFromEnum(IacState.initialized)), iac_state(slot)); +} + +test "plan then apply succeeds" { + iac_reset(); + const slot = iac_init(@intFromEnum(IacTool.pulumi)); + try std.testing.expectEqual(@as(c_int, 0), iac_plan(slot, 0xDEAD)); + try std.testing.expectEqual(@as(c_int, @intFromEnum(IacState.planned)), iac_state(slot)); + try std.testing.expectEqual(@as(c_int, 1), iac_has_plan(slot)); + try std.testing.expectEqual(@as(c_int, 0), iac_apply(slot)); + try std.testing.expectEqual(@as(c_int, @intFromEnum(IacState.applied)), iac_state(slot)); +} + +test "re-plan allowed" { + iac_reset(); + const slot = iac_init(@intFromEnum(IacTool.terraform)); + try std.testing.expectEqual(@as(c_int, 0), iac_plan(slot, 0xAAAA)); + try std.testing.expectEqual(@as(c_int, 0), iac_plan(slot, 0xBBBB)); + try std.testing.expectEqual(@as(c_int, @intFromEnum(IacState.planned)), iac_state(slot)); +} + +test "cannot destroy from planned" { + iac_reset(); + const slot = iac_init(@intFromEnum(IacTool.terraform)); + _ = iac_plan(slot, 0x1234); + // Destroy only valid from initialized, not planned + try std.testing.expectEqual(@as(c_int, -2), iac_destroy(slot)); +} + +test "full lifecycle" { + iac_reset(); + const slot = iac_init(@intFromEnum(IacTool.terraform)); + _ = iac_plan(slot, 0xCAFE); + _ = iac_apply(slot); + // After applied, must go back to initialized + try std.testing.expectEqual(@as(c_int, @intFromEnum(IacState.applied)), iac_state(slot)); +} + +test "state transition validation" { + // Valid transitions + try std.testing.expectEqual(@as(c_int, 1), iac_can_transition(0, 1)); // uninit -> init + try std.testing.expectEqual(@as(c_int, 1), iac_can_transition(1, 2)); // init -> planned + try std.testing.expectEqual(@as(c_int, 1), iac_can_transition(2, 3)); // planned -> applying + try std.testing.expectEqual(@as(c_int, 1), iac_can_transition(3, 4)); // applying -> applied + try std.testing.expectEqual(@as(c_int, 1), iac_can_transition(4, 1)); // applied -> init + // Invalid transitions β€” the key safety invariant + try std.testing.expectEqual(@as(c_int, 0), iac_can_transition(1, 3)); // init -> applying (BLOCKED) + try std.testing.expectEqual(@as(c_int, 0), iac_can_transition(0, 2)); // uninit -> planned + try std.testing.expectEqual(@as(c_int, 0), iac_can_transition(2, 0)); // planned -> uninit +} + +// ═══════════════════════════════════════════════════════════════════════ +// ADR-0006 invoke dispatch tests +// ═══════════════════════════════════════════════════════════════════════ + +test "invoke: each declared tool succeeds" { + var buf: [256]u8 = undefined; + const tools = [_][]const u8{ + "iac_init", + "iac_plan", + "iac_apply", + "iac_destroy", + "iac_state", + "iac_output", + "iac_release", + }; + for (tools) |t| { + var len: usize = buf.len; + const rc = boj_cartridge_invoke(t.ptr, "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, 0), rc); + try std.testing.expect(std.mem.indexOf(u8, buf[0..len], "result") != null); + } +} + +test "invoke: unknown tool returns -1" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("nope", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -1), rc); +} + +test "invoke: buffer too small returns -3" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("iac_init", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -3), rc); + try std.testing.expect(len > 4); +} diff --git a/cartridges/domains/infrastructure/iac-mcp/mod.js b/cartridges/domains/infrastructure/iac-mcp/mod.js new file mode 100644 index 0000000..86748f2 --- /dev/null +++ b/cartridges/domains/infrastructure/iac-mcp/mod.js @@ -0,0 +1,78 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// iac-mcp/mod.js -- iac gateway. + +const BASE_URL = Deno.env.get("IAC_MCP_BACKEND_URL") ?? "http://127.0.0.1:7717"; + +async function post(path, payload) { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), 15000); + try { + const r = await fetch(`${BASE_URL}${path}`, { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), signal: ctrl.signal, + }); + const data = await r.json().catch(() => ({ success: false, error: "non-JSON response" })); + return { status: r.status, data }; + } catch (e) { + if (e.name === "AbortError") return { status: 504, data: { success: false, error: "iac-mcp backend timed out" } }; + return { status: 503, data: { success: false, error: `iac-mcp backend unavailable: ${e.message}` } }; + } finally { clearTimeout(t); } +} + +export async function handleTool(toolName, args) { + switch (toolName) { + + case "iac_init": { + const { tool, working_dir } = args ?? {}; + if (!working_dir) return { status: 400, data: { error: "working_dir is required" } }; + const payload = { working_dir }; + if (tool !== undefined) payload.tool = tool; + return post("/api/v1/init", payload); + } + case "iac_plan": { + const { slot, target } = args ?? {}; + if (!slot) return { status: 400, data: { error: "slot is required" } }; + const payload = { slot }; + if (target !== undefined) payload.target = target; + return post("/api/v1/plan", payload); + } + case "iac_apply": { + const { slot, auto_approve } = args ?? {}; + if (!slot) return { status: 400, data: { error: "slot is required" } }; + const payload = { slot }; + if (auto_approve !== undefined) payload.auto_approve = auto_approve; + return post("/api/v1/apply", payload); + } + case "iac_destroy": { + const { slot, auto_approve } = args ?? {}; + if (!slot) return { status: 400, data: { error: "slot is required" } }; + const payload = { slot }; + if (auto_approve !== undefined) payload.auto_approve = auto_approve; + return post("/api/v1/destroy", payload); + } + case "iac_state": { + const { slot } = args ?? {}; + if (!slot) return { status: 400, data: { error: "slot is required" } }; + const payload = { slot }; + return post("/api/v1/state", payload); + } + case "iac_output": { + const { slot, name } = args ?? {}; + if (!slot) return { status: 400, data: { error: "slot is required" } }; + const payload = { slot }; + if (name !== undefined) payload.name = name; + return post("/api/v1/output", payload); + } + case "iac_release": { + const { slot } = args ?? {}; + if (!slot) return { status: 400, data: { error: "slot is required" } }; + const payload = { slot }; + return post("/api/v1/release", payload); + } + + default: + return { status: 404, data: { error: `Unknown tool: ${toolName}` } }; + } +} diff --git a/cartridges/domains/infrastructure/iac-mcp/panels/manifest.json b/cartridges/domains/infrastructure/iac-mcp/panels/manifest.json new file mode 100644 index 0000000..83e8525 --- /dev/null +++ b/cartridges/domains/infrastructure/iac-mcp/panels/manifest.json @@ -0,0 +1,67 @@ +{ + "$schema": "https://panll.dev/schemas/panel-manifest/v1.json", + "spdx": "MPL-2.0", + "cartridge": "iac-mcp", + "domain": "Infrastructure as Code", + "version": "0.1.0", + "panels": [ + { + "id": "iac-status", + "title": "IaC Engine Status", + "description": "Terraform/OpenTofu and Pulumi provider readiness", + "type": "status-indicator", + "data_source": { + "endpoint": "/cartridge/iac-mcp/invoke", + "method": "POST", + "body": { "tool": "status" }, + "refresh_interval_ms": 5000 + }, + "widgets": [ + { + "type": "state-badge", + "field": "state", + "states": { + "ready": { "color": "#2ecc71", "icon": "settings" }, + "degraded": { "color": "#f39c12", "icon": "alert-circle" }, + "error": { "color": "#e74c3c", "icon": "alert-triangle" } + } + }, + { "type": "text", "field": "version", "label": "Version" } + ] + }, + { + "id": "iac-state-summary", + "title": "State Summary", + "description": "Managed resources, drift detection, and last apply timestamp", + "type": "metric", + "data_source": { + "endpoint": "/cartridge/iac-mcp/invoke", + "method": "POST", + "body": { "tool": "state_summary" }, + "refresh_interval_ms": 30000 + }, + "widgets": [ + { "type": "counter", "field": "managed_resources", "label": "Managed Resources", "icon": "layers" }, + { "type": "counter", "field": "drift_detected", "label": "Drifted", "icon": "alert-circle" }, + { "type": "text", "field": "last_apply", "label": "Last Apply" } + ] + }, + { + "id": "iac-plan-queue", + "title": "Plan Queue", + "description": "Pending and running IaC plans with resource change counts", + "type": "table", + "data_source": { + "endpoint": "/cartridge/iac-mcp/invoke", + "method": "POST", + "body": { "tool": "plan_queue" }, + "refresh_interval_ms": 10000 + }, + "widgets": [ + { "type": "counter", "field": "plans_pending", "label": "Pending Plans", "icon": "clock" }, + { "type": "counter", "field": "plans_running", "label": "Running Plans", "icon": "play-circle" }, + { "type": "counter", "field": "resources_to_change", "label": "Resources Changing", "icon": "edit" } + ] + } + ] +} diff --git a/cartridges/domains/knowledge/local-memory-mcp/README.adoc b/cartridges/domains/knowledge/local-memory-mcp/README.adoc new file mode 100644 index 0000000..7f9df3b --- /dev/null +++ b/cartridges/domains/knowledge/local-memory-mcp/README.adoc @@ -0,0 +1,81 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += local-memory-mcp +Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk> +:spdx: MPL-2.0 +:tier: Ayo +:domain: Knowledge & Memory +:protocols: MCP, REST + +== Overview + +Persistent local memory for Claude, Cursor & Codex. 13 tools. No cloud. No API keys. Stores learnings, decisions, people, projects in a single SQLite file on your machine. + +== Tools (13) + +[cols="2,4"] +|=== +| Tool | Description + +| `memory_session_start` | Start a memory session. Loads context from last 3 sessions. +| `memory_session_end` | End a memory session and save a summary. +| `memory_learn` | Store a piece of knowledge with category and content. +| `memory_recall` | Search learnings by query or get recent learnings. +| `memory_search` | Unified search across learnings, decisions, entities, and observations. +| `memory_decide` | Record a decision with structured context. +| `memory_entity_observe` | Record a fact about an entity (person, project, company, tool). +| `memory_entity_search` | Fuzzy search across entity names and observations. +| `memory_entity_open` | Load full entity view with observations and relations. +| `memory_entity_relate` | Create a typed, directed edge between two entities. +| `memory_insights` | Get overview stats about your memory. +| `memory_profile_set` | Store personal info locally. +| `memory_profile_get` | Retrieve personal info. +|=== + +== Architecture + +[cols="1,1,3"] +|=== +| Layer | Language | Purpose + +| ABI | Idris2 | Formally verified state machine with dependent-type proofs +| FFI | Zig | C-ABI implementation: SQLite integration, FTS5 search, knowledge graph traversal +| Adapter| Zig | Adapter bridge to the BoJ unified adapter protocol +|=== + +== Building + +[source,bash] +---- +# ABI (Idris2) +cd abi && idris2 --check *.idr + +# FFI (Zig) +cd ffi && zig build +cd ffi && zig build test + +# Adapter (Zig) +cd adapter && zig build +---- + +== Status + +Alpha (CRG D). Cartridge metadata declared in `cartridge.json`; 13 tool(s) exposed. +Not yet dogfooded externally. See `docs/READINESS.md` at repo root for the +project-wide grade table and `docs/practice/DOGFOOD-LOG.adoc` for usage evidence. + +== Ports + +Allowed:: None (local SQLite file only) +Denied:: 22 (SSH), 23 (Telnet), 25 (SMTP), 135 (RPC), 139 (NetBIOS), 445 (SMB) + +== Privacy + +- Data never leaves your machine +- No telemetry, no analytics +- Single SQLite file: `~/Library/Application Support/local-memory-mcp/memory.sqlite` (macOS) + +== References + +- [Local Memory MCP GitHub](https://github.com/studiomeyer-io/local-memory-mcp) +- [StudioMeyer Memory (hosted)](https://memory.studiomeyer.io) diff --git a/cartridges/domains/knowledge/local-memory-mcp/abi/README.adoc b/cartridges/domains/knowledge/local-memory-mcp/abi/README.adoc new file mode 100644 index 0000000..8828b0c --- /dev/null +++ b/cartridges/domains/knowledge/local-memory-mcp/abi/README.adoc @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) += local-memory-mcp / abi β€” Idris2 ABI layer +:orientation: deep + +== Purpose + +Encodes the local-memory-mcp connection state machine and the MCP tool catalogue. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| local-memory.ipkg | Idris2 package descriptor. +| local-memory/Safelocal-memory.idr | State machine and tool definitions. +|=== + +== Invariants + +* at module top. +* Zero . + +== Test/proof surface + +Type-check only β€” + Error loading file "local-memory.ipkg": File Not Found. + +== Read-first + +. Safelocal-memory.idr β€” state machine and tool definitions. + diff --git a/cartridges/domains/knowledge/local-memory-mcp/adapter/README.adoc b/cartridges/domains/knowledge/local-memory-mcp/adapter/README.adoc new file mode 100644 index 0000000..e0b0928 --- /dev/null +++ b/cartridges/domains/knowledge/local-memory-mcp/adapter/README.adoc @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) += local-memory-mcp / adapter β€” REST/gRPC/GraphQL bridge +:orientation: deep + +== Purpose + +Exposes the FFI layer over three protocols. Stateless β€” all state lives behind the FFI mutex. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| build.zig | Zig build graph. +| local-memory_adapter.zig | Three-protocol dispatcher. +|=== + +== Invariants + +* Stateless β€” no global mutable state. +* One tool call per HTTP request. +* JSON content type for all responses. + +== Test/proof surface + +No inline tests β€” FFI tests cover correctness. Integration tested via cartridge-matrix tests. + +== Read-first + +. local-memory_adapter.zig β€” tool-name β†’ FFI-call mapping. diff --git a/cartridges/domains/knowledge/local-memory-mcp/cartridge.json b/cartridges/domains/knowledge/local-memory-mcp/cartridge.json new file mode 100644 index 0000000..63468ab --- /dev/null +++ b/cartridges/domains/knowledge/local-memory-mcp/cartridge.json @@ -0,0 +1,356 @@ +{ + "$schema": "https://boj.dev/schemas/cartridge/v1.json", + "spdx": "MPL-2.0", + "copyright": "Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath)", + "name": "local-memory-mcp", + "version": "0.1.0", + "description": "Persistent local memory for Claude, Cursor & Codex. 13 tools. No cloud. No API keys. Stores learnings, decisions, people, projects in a single SQLite file on your machine.", + "domain": "Knowledge & Memory", + "tier": "Ayo", + "protocols": [ + "MCP", + "REST" + ], + "auth": { + "method": "none", + "env_var": null, + "credential_source": null + }, + "api": { + "base_url": "local://local-memory-mcp", + "content_type": "application/json" + }, + "ports": { + "allowed": [], + "denied": [ + 22, + 23, + 25, + 135, + 139, + 445 + ] + }, + "tools": [ + { + "name": "memory_session_start", + "description": "Start a memory session. Loads context from last 3 sessions.", + "inputSchema": { + "type": "object", + "properties": { + "project": { + "type": "string", + "description": "Optional project name to scope the session" + } + } + } + }, + { + "name": "memory_session_end", + "description": "End a memory session and save a summary.", + "inputSchema": { + "type": "object", + "properties": { + "summary": { + "type": "string", + "description": "Summary of what was accomplished" + } + } + } + }, + { + "name": "memory_learn", + "description": "Store a piece of knowledge with category and content.", + "inputSchema": { + "type": "object", + "properties": { + "category": { + "type": "string", + "enum": [ + "pattern", + "mistake", + "insight", + "research", + "architecture", + "infrastructure", + "tool", + "workflow", + "performance", + "security" + ], + "description": "Category of the learning" + }, + "content": { + "type": "string", + "description": "The knowledge to store" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Optional tags" + }, + "confidence": { + "type": "number", + "description": "Confidence score (0-1)" + }, + "project": { + "type": "string", + "description": "Optional project name" + } + }, + "required": [ + "category", + "content" + ] + } + }, + { + "name": "memory_recall", + "description": "Search learnings by query or get recent learnings.", + "inputSchema": { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "Optional search query" + }, + "limit": { + "type": "integer", + "description": "Maximum number of results (default: 10)" + } + } + } + }, + { + "name": "memory_search", + "description": "Unified search across learnings, decisions, entities, and observations.", + "inputSchema": { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "Search query" + }, + "types": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "learning", + "decision", + "entity", + "observation" + ] + }, + "description": "Optional filter by types" + }, + "limit": { + "type": "integer", + "description": "Maximum number of results (default: 10)" + } + }, + "required": [ + "query" + ] + } + }, + { + "name": "memory_decide", + "description": "Record a decision with structured context.", + "inputSchema": { + "type": "object", + "properties": { + "title": { + "type": "string", + "description": "What was decided" + }, + "decision": { + "type": "string", + "description": "The choice made" + }, + "reasoning": { + "type": "string", + "description": "Why this decision was made" + }, + "alternatives": { + "type": "string", + "description": "What else was considered" + }, + "confidence": { + "type": "number", + "description": "Confidence score (0-1)" + }, + "project": { + "type": "string", + "description": "Optional project name" + } + }, + "required": [ + "title", + "decision", + "reasoning" + ] + } + }, + { + "name": "memory_entity_observe", + "description": "Record a fact about an entity (person, project, company, tool).", + "inputSchema": { + "type": "object", + "properties": { + "entityName": { + "type": "string", + "description": "Name of the entity" + }, + "entityType": { + "type": "string", + "description": "Type of entity (person, project, company, tool, concept, etc.)" + }, + "content": { + "type": "string", + "description": "The fact to record" + } + }, + "required": [ + "entityName", + "entityType", + "content" + ] + } + }, + { + "name": "memory_entity_search", + "description": "Fuzzy search across entity names and observations.", + "inputSchema": { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "Search query" + }, + "entityType": { + "type": "string", + "description": "Optional filter by entity type" + }, + "limit": { + "type": "integer", + "description": "Maximum number of results (default: 10)" + } + }, + "required": [ + "query" + ] + } + }, + { + "name": "memory_entity_open", + "description": "Load full entity view with observations and relations.", + "inputSchema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Entity name" + }, + "id": { + "type": "string", + "description": "Entity ID (alternative to name)" + } + } + } + }, + { + "name": "memory_entity_relate", + "description": "Create a typed, directed edge between two entities.", + "inputSchema": { + "type": "object", + "properties": { + "fromEntityId": { + "type": "string", + "description": "Source entity ID" + }, + "toEntityId": { + "type": "string", + "description": "Target entity ID" + }, + "relationType": { + "type": "string", + "description": "Type of relation (e.g., works_at, uses, created, depends_on)" + }, + "weight": { + "type": "number", + "description": "Relation weight (0-1)" + } + }, + "required": [ + "fromEntityId", + "toEntityId", + "relationType" + ] + } + }, + { + "name": "memory_insights", + "description": "Get overview stats about your memory.", + "inputSchema": { + "type": "object", + "properties": { + "project": { + "type": "string", + "description": "Optional project filter" + } + } + } + }, + { + "name": "memory_profile_set", + "description": "Store personal info locally.", + "inputSchema": { + "type": "object", + "properties": { + "field": { + "type": "string", + "description": "Field name (e.g., name, role, language)" + }, + "value": { + "type": "string", + "description": "Field value" + } + }, + "required": [ + "field", + "value" + ] + } + }, + { + "name": "memory_profile_get", + "description": "Retrieve personal info.", + "inputSchema": { + "type": "object", + "properties": { + "field": { + "type": "string", + "description": "Field name to retrieve" + } + }, + "required": [ + "field" + ] + } + } + ], + "ffi": { + "so_path": "ffi/zig-out/lib/liblocal_memory_mcp.so", + "abi_version": "ADR-0006", + "symbols": [ + "boj_cartridge_init", + "boj_cartridge_deinit", + "boj_cartridge_name", + "boj_cartridge_version", + "boj_cartridge_invoke" + ] + } +} diff --git a/cartridges/domains/knowledge/local-memory-mcp/ffi/README.adoc b/cartridges/domains/knowledge/local-memory-mcp/ffi/README.adoc new file mode 100644 index 0000000..801f4ef --- /dev/null +++ b/cartridges/domains/knowledge/local-memory-mcp/ffi/README.adoc @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) += local-memory-mcp / ffi β€” Zig FFI layer +:orientation: deep + +== Purpose + +Zig implementation of the connection state machine from . + +== Files + +[cols="1,4"] +|=== +| File | Role + +| build.zig | Zig build graph. +| local-memory_ffi.zig | FFI implementation. +|=== + +== Invariants + +* Mutex discipline for all shared state. +* Bounds checks before dereference. +* State-machine mirror of ABI. + +== Test/proof surface + +Inline blocks in local-memory_ffi.zig. Run with . + +== Read-first + +. local-memory_ffi.zig β€” FFI exports and guards. + diff --git a/cartridges/domains/knowledge/local-memory-mcp/ffi/build.zig b/cartridges/domains/knowledge/local-memory-mcp/ffi/build.zig new file mode 100644 index 0000000..7bbe711 --- /dev/null +++ b/cartridges/domains/knowledge/local-memory-mcp/ffi/build.zig @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const ffi_mod = b.addModule("local_memory_mcp", .{ + .root_source_file = b.path("local_memory_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + const lib = b.addLibrary(.{ + .name = "local_memory_mcp", + .root_module = ffi_mod, + .linkage = .dynamic, + }); + lib.linkLibC(); + b.installArtifact(lib); + + const tests = b.addTest(.{ .root_module = ffi_mod }); + tests.linkLibC(); + const run_tests = b.addRunArtifact(tests); + const test_step = b.step("test", "Run FFI tests"); + test_step.dependOn(&run_tests.step); +} diff --git a/cartridges/domains/knowledge/local-memory-mcp/ffi/cartridge_shim.zig b/cartridges/domains/knowledge/local-memory-mcp/ffi/cartridge_shim.zig new file mode 100644 index 0000000..0e399db --- /dev/null +++ b/cartridges/domains/knowledge/local-memory-mcp/ffi/cartridge_shim.zig @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// cartridge_shim.zig β€” Shared helpers for the ADR-0006 five-symbol +// cartridge ABI (`boj_cartridge_init / deinit / name / version / invoke`). +// +// The shim centralises the seven-code return convention, NUL-argument +// guards, tool-name comparison, and the buffer-too-small path so each +// cartridge's `boj_cartridge_invoke` can stay short β€” typically a tool +// table plus `shim.writeResult(...)`. +// +// Cartridges import this file by relative path (no build-graph change +// needed). Example: +// +// const shim = @import("../../../ffi/zig/src/cartridge_shim.zig"); +// +// export fn boj_cartridge_invoke( +// tool_name: [*c]const u8, +// json_args: [*c]const u8, +// out_buf: [*c]u8, +// in_out_len: [*c]usize, +// ) callconv(.c) i32 { +// _ = json_args; +// if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; +// const body = if (shim.toolIs(tool_name, "foo")) "{\"result\":{}}" +// else return shim.RC_UNKNOWN_TOOL; +// return shim.writeResult(out_buf, in_out_len, body); +// } + +const std = @import("std"); + +// ── Return codes (ADR-0006 Β§Return codes) ──────────────────────────── +// +// Frozen by ADR-0006. New failure modes compose these via the error +// JSON body β€” the integer surface does not grow without a follow-up ADR. + +pub const RC_SUCCESS: i32 = 0; +pub const RC_UNKNOWN_TOOL: i32 = -1; +pub const RC_BAD_ARGS: i32 = -2; +pub const RC_BUFFER_TOO_SMALL: i32 = -3; +pub const RC_RUNTIME_ERROR: i32 = -4; +pub const RC_PANIC: i32 = -5; +pub const RC_AUTH_DENIED: i32 = -6; + +// ── Invoke-path helpers ────────────────────────────────────────────── + +/// True if any of the three mandatory `boj_cartridge_invoke` output-path +/// pointers is null. Use at the top of every invoke to short-circuit to +/// `RC_BAD_ARGS`. +pub fn invokeArgsNull( + tool_name: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) bool { + return tool_name == null or out_buf == null or in_out_len == null; +} + +/// Compare a C-NUL-terminated tool-name pointer against a Zig string +/// literal. Caller must have already verified `tool_name` is non-null +/// (usually via `invokeArgsNull`). +pub fn toolIs(tool_name: [*c]const u8, expected: []const u8) bool { + const s = std.mem.span(@as([*:0]const u8, @ptrCast(tool_name))); + return std.mem.eql(u8, s, expected); +} + +/// Copy `body` into `out_buf[0..*in_out_len]` (as a capacity) and update +/// `*in_out_len` to the number of bytes written. Returns `RC_SUCCESS`. +/// +/// If `body.len` exceeds the current capacity stored in `*in_out_len`, +/// sets `*in_out_len` to the required size and returns +/// `RC_BUFFER_TOO_SMALL` β€” the caller is then expected to re-allocate +/// and retry, per ADR-0006 Β§Memory ownership. +/// +/// Caller must have already verified that `out_buf` and `in_out_len` +/// are non-null. +pub fn writeResult( + out_buf: [*c]u8, + in_out_len: [*c]usize, + body: []const u8, +) i32 { + const cap = in_out_len.*; + if (body.len > cap) { + in_out_len.* = body.len; + return RC_BUFFER_TOO_SMALL; + } + @memcpy(out_buf[0..body.len], body); + in_out_len.* = body.len; + return RC_SUCCESS; +} + +// ── Tests ──────────────────────────────────────────────────────────── + +test "writeResult: body fits, writes and sets length" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); + try std.testing.expectEqualStrings("hello", buf[0..len]); +} + +test "writeResult: too small returns -3 and sets required length" { + var buf: [2]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_BUFFER_TOO_SMALL, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: exact-fit succeeds" { + var buf: [5]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: empty body" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, ""); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 0), len); +} + +test "toolIs: matches and rejects" { + const name: [*:0]const u8 = "foo"; + try std.testing.expect(toolIs(@ptrCast(name), "foo")); + try std.testing.expect(!toolIs(@ptrCast(name), "bar")); + try std.testing.expect(!toolIs(@ptrCast(name), "foobar")); + try std.testing.expect(!toolIs(@ptrCast(name), "fo")); +} + +test "invokeArgsNull: detects each null slot" { + var buf: [4]u8 = undefined; + var len: usize = 4; + const name: [*:0]const u8 = "x"; + try std.testing.expect(!invokeArgsNull(@ptrCast(name), &buf, &len)); + try std.testing.expect(invokeArgsNull(null, &buf, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), null, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), &buf, null)); +} diff --git a/cartridges/domains/knowledge/local-memory-mcp/ffi/local_memory_ffi.zig b/cartridges/domains/knowledge/local-memory-mcp/ffi/local_memory_ffi.zig new file mode 100644 index 0000000..652e078 --- /dev/null +++ b/cartridges/domains/knowledge/local-memory-mcp/ffi/local_memory_ffi.zig @@ -0,0 +1,84 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// local-memory-mcp FFI β€” ADR-0006 five-symbol cartridge ABI implementation. + +const std = @import("std"); +const shim = @import("cartridge_shim.zig"); + +const CARTRIDGE_NAME_PTR: [*:0]const u8 = "local-memory-mcp"; +const CARTRIDGE_VERSION_PTR: [*:0]const u8 = "0.1.0"; + +export fn boj_cartridge_init() callconv(.c) c_int { + return 0; +} + +export fn boj_cartridge_deinit() callconv(.c) void {} + +export fn boj_cartridge_name() callconv(.c) [*:0]const u8 { + return CARTRIDGE_NAME_PTR; +} + +export fn boj_cartridge_version() callconv(.c) [*:0]const u8 { + return CARTRIDGE_VERSION_PTR; +} + +export fn boj_cartridge_invoke( + tool_name: [*c]const u8, + json_args: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) callconv(.c) i32 { + _ = json_args; + if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; + const body: []const u8 = if (shim.toolIs(tool_name, "memory_session_start")) + "{\"result\":{}}" + else if (shim.toolIs(tool_name, "memory_session_end")) + "{\"result\":{}}" + else if (shim.toolIs(tool_name, "memory_learn")) + "{\"result\":{}}" + else if (shim.toolIs(tool_name, "memory_recall")) + "{\"result\":{}}" + else if (shim.toolIs(tool_name, "memory_search")) + "{\"result\":{}}" + else if (shim.toolIs(tool_name, "memory_decide")) + "{\"result\":{}}" + else if (shim.toolIs(tool_name, "memory_entity_observe")) + "{\"result\":{}}" + else if (shim.toolIs(tool_name, "memory_entity_search")) + "{\"result\":{}}" + else if (shim.toolIs(tool_name, "memory_entity_open")) + "{\"result\":{}}" + else if (shim.toolIs(tool_name, "memory_entity_relate")) + "{\"result\":{}}" + else if (shim.toolIs(tool_name, "memory_insights")) + "{\"result\":{}}" + else if (shim.toolIs(tool_name, "memory_profile_set")) + "{\"result\":{}}" + else if (shim.toolIs(tool_name, "memory_profile_get")) + "{\"result\":{}}" + else + return shim.RC_UNKNOWN_TOOL; + + return shim.writeResult(out_buf, in_out_len, body); +} + +test "boj_cartridge_name returns local-memory-mcp" { + try std.testing.expectEqualStrings("local-memory-mcp", std.mem.span(boj_cartridge_name())); +} + +test "boj_cartridge_init returns 0" { + try std.testing.expectEqual(@as(c_int, 0), boj_cartridge_init()); +} + +test "invoke unknown tool returns RC_UNKNOWN_TOOL" { + var buf: [256]u8 = undefined; + var len: usize = buf.len; + try std.testing.expectEqual(@as(i32, shim.RC_UNKNOWN_TOOL), boj_cartridge_invoke("unknown_xyz", "{}", &buf, &len)); +} + +test "invoke memory_session_start returns 0" { + var buf: [256]u8 = undefined; + var len: usize = buf.len; + try std.testing.expectEqual(@as(i32, 0), boj_cartridge_invoke("memory_session_start", "{}", &buf, &len)); +} diff --git a/cartridges/domains/knowledge/local-memory-mcp/mod.js b/cartridges/domains/knowledge/local-memory-mcp/mod.js new file mode 100644 index 0000000..efa319f --- /dev/null +++ b/cartridges/domains/knowledge/local-memory-mcp/mod.js @@ -0,0 +1,141 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// local-memory-mcp/mod.js β€” Persistent local memory cartridge (SQLite-backed). +// +// Delegates to backend at http://127.0.0.1:7750 (override with LOCAL_MEMORY_URL). +// No auth required. All data stays local β€” no cloud, no API keys. + +const BASE_URL = Deno.env.get("LOCAL_MEMORY_URL") ?? "http://127.0.0.1:7750"; +const TIMEOUT_MS = 15_000; + +async function post(path, payload) { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), TIMEOUT_MS); + try { + const r = await fetch(`${BASE_URL}${path}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + signal: ctrl.signal, + }); + const data = await r.json().catch(() => ({ success: false, error: "non-JSON response" })); + return { status: r.status, data }; + } catch (e) { + if (e.name === "AbortError") + return { status: 504, data: { success: false, error: "local-memory-mcp backend timed out" } }; + return { status: 503, data: { success: false, error: `local-memory-mcp backend unavailable: ${e.message}` } }; + } finally { clearTimeout(t); } +} + +export async function handleTool(toolName, args) { + switch (toolName) { + case "memory_session_start": { + const { project } = args ?? {}; + const payload = {}; + if (project !== undefined) payload.project = project; + return post("/api/v1/session/start", payload); + } + + case "memory_session_end": { + const { summary } = args ?? {}; + const payload = {}; + if (summary !== undefined) payload.summary = summary; + return post("/api/v1/session/end", payload); + } + + case "memory_learn": { + const { category, content, tags, confidence, project } = args ?? {}; + if (!category || !content) + return { status: 400, data: { error: "category and content are required" } }; + const payload = { category, content }; + if (tags !== undefined) payload.tags = tags; + if (confidence !== undefined) payload.confidence = confidence; + if (project !== undefined) payload.project = project; + return post("/api/v1/learnings", payload); + } + + case "memory_recall": { + const { query, limit } = args ?? {}; + const payload = {}; + if (query !== undefined) payload.query = query; + if (limit !== undefined) payload.limit = limit; + return post("/api/v1/learnings/recall", payload); + } + + case "memory_search": { + const { query, types, limit } = args ?? {}; + if (!query) return { status: 400, data: { error: "query is required" } }; + const payload = { query }; + if (types !== undefined) payload.types = types; + if (limit !== undefined) payload.limit = limit; + return post("/api/v1/search", payload); + } + + case "memory_decide": { + const { title, decision, reasoning, alternatives, confidence, project } = args ?? {}; + if (!title || !decision || !reasoning) + return { status: 400, data: { error: "title, decision, and reasoning are required" } }; + const payload = { title, decision, reasoning }; + if (alternatives !== undefined) payload.alternatives = alternatives; + if (confidence !== undefined) payload.confidence = confidence; + if (project !== undefined) payload.project = project; + return post("/api/v1/decisions", payload); + } + + case "memory_entity_observe": { + const { entityName, entityType, content } = args ?? {}; + if (!entityName || !entityType || !content) + return { status: 400, data: { error: "entityName, entityType, and content are required" } }; + return post("/api/v1/entities/observe", { entityName, entityType, content }); + } + + case "memory_entity_search": { + const { query, entityType, limit } = args ?? {}; + if (!query) return { status: 400, data: { error: "query is required" } }; + const payload = { query }; + if (entityType !== undefined) payload.entityType = entityType; + if (limit !== undefined) payload.limit = limit; + return post("/api/v1/entities/search", payload); + } + + case "memory_entity_open": { + const { name, id } = args ?? {}; + const payload = {}; + if (name !== undefined) payload.name = name; + if (id !== undefined) payload.id = id; + return post("/api/v1/entities/open", payload); + } + + case "memory_entity_relate": { + const { fromEntityId, toEntityId, relationType, weight } = args ?? {}; + if (!fromEntityId || !toEntityId || !relationType) + return { status: 400, data: { error: "fromEntityId, toEntityId, and relationType are required" } }; + const payload = { fromEntityId, toEntityId, relationType }; + if (weight !== undefined) payload.weight = weight; + return post("/api/v1/entities/relate", payload); + } + + case "memory_insights": { + const { project } = args ?? {}; + const payload = {}; + if (project !== undefined) payload.project = project; + return post("/api/v1/insights", payload); + } + + case "memory_profile_set": { + const { field, value } = args ?? {}; + if (!field || !value) return { status: 400, data: { error: "field and value are required" } }; + return post("/api/v1/profile/set", { field, value }); + } + + case "memory_profile_get": { + const { field } = args ?? {}; + if (!field) return { status: 400, data: { error: "field is required" } }; + return post("/api/v1/profile/get", { field }); + } + + default: + return { status: 404, data: { error: `Unknown tool: ${toolName}` } }; + } +} diff --git a/cartridges/domains/knowledge/obsidian-mcp/README.adoc b/cartridges/domains/knowledge/obsidian-mcp/README.adoc new file mode 100644 index 0000000..e2ac113 --- /dev/null +++ b/cartridges/domains/knowledge/obsidian-mcp/README.adoc @@ -0,0 +1,98 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += obsidian-mcp +Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk> +:spdx: MPL-2.0 +:tier: Ayo +:domain: Knowledge +:protocols: MCP, REST + +== Overview + +Obsidian vault cartridge for the BoJ server. Provides type-safe access to +the Obsidian Local REST API for note search, content retrieval, backlink +navigation, tag browsing, graph analysis, Dataview query execution, YAML +frontmatter extraction, daily note management, template listing, and vault +statistics. + +Connects to the Obsidian Local REST API at `127.0.0.1:27124`. Bearer token +auth is always required. + +=== State Machine + +`Disconnected -> Connected` (authenticated connection to Obsidian) + +`Connected -> RateLimited -> Connected` (normal flow) + +`Connected -> Error -> Disconnected` (error recovery) + +=== Actions (12) + +[cols="1,1"] +|=== +| Category | Actions + +| Search +| SearchNotes + +| Note Content +| GetNote, ListNotes, GetFrontmatter, GetDailyNote + +| Link Analysis +| GetBacklinks, GetOutgoingLinks + +| Tags +| ListTags, GetNotesByTag + +| Vault +| VaultStats + +| Plugins +| DataviewQuery + +| Templates +| ListTemplates +|=== + +== Architecture + +[cols="1,1,2"] +|=== +| Layer | Language | Purpose + +| ABI +| Idris2 +| Formally verified state machine with dependent-type proofs (ObsidianMcp.SafeRegistry) + +| FFI +| Zig +| C-compatible implementation with thread-safe session pool, action recording, category counting + +| Adapter +| zig +| REST bridge to BoJ unified adapter protocol +|=== + +== Building + +[source,bash] +---- +# Build FFI shared library +cd ffi && zig build + +# Run FFI tests +cd ffi && zig build test + +# Type-check ABI +cd abi && idris2 --check ObsidianMcp.SafeRegistry +---- + +== Prerequisites + +1. Install the https://github.com/coddingtonbear/obsidian-local-rest-api[Obsidian Local REST API] plugin. +2. Set `OBSIDIAN_REST_API_KEY` to the plugin's API key. +3. Obsidian must be running for the cartridge to function. + +== Status + +Development -- customised with Obsidian-specific search, backlinks, Dataview, frontmatter, and daily note APIs. diff --git a/cartridges/domains/knowledge/obsidian-mcp/abi/ObsidianMcp/SafeRegistry.idr b/cartridges/domains/knowledge/obsidian-mcp/abi/ObsidianMcp/SafeRegistry.idr new file mode 100644 index 0000000..548ecab --- /dev/null +++ b/cartridges/domains/knowledge/obsidian-mcp/abi/ObsidianMcp/SafeRegistry.idr @@ -0,0 +1,186 @@ +-- SPDX-License-Identifier: MPL-2.0 +-- Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +-- +-- ObsidianMcp.SafeRegistry β€” Type-safe ABI for obsidian-mcp cartridge. +-- +-- Dependent-type state machine governing Obsidian Local REST API access. +-- Encodes mandatory Bearer token auth, note search, content retrieval, +-- backlink navigation, tag browsing, graph analysis, dataview queries, +-- frontmatter extraction, daily notes, template listing, and vault +-- statistics as compile-time invariants. +-- REST API: https://127.0.0.1:27124 +-- No unsafe escape hatches. + +module ObsidianMcp.SafeRegistry + +%default total + +-- --------------------------------------------------------------------------- +-- Authentication state machine +-- --------------------------------------------------------------------------- + +||| Session state for Obsidian MCP operations. +||| Disconnected: no connection to Obsidian REST API. +||| Connected: authenticated and connected to Obsidian instance. +||| RateLimited: rate limit hit; must wait. +||| Error: unrecoverable error (Obsidian not running, bad key). +||| Note: Obsidian REST API always requires auth (no anonymous mode). +public export +data SessionState + = Disconnected + | Connected + | RateLimited + | Error + +||| Proof that a state transition is valid. +||| Obsidian requires auth β€” no anonymous sessions. +public export +data ValidTransition : SessionState -> SessionState -> Type where + Connect : ValidTransition Disconnected Connected + Disconnect : ValidTransition Connected Disconnected + Throttle : ValidTransition Connected RateLimited + Unthrottle : ValidTransition RateLimited Connected + ConnectError : ValidTransition Connected Error + DisconnError : ValidTransition Disconnected Error + RecoverConnect : ValidTransition Error Connected + RecoverDisconn : ValidTransition Error Disconnected + +-- --------------------------------------------------------------------------- +-- C-ABI integer encoding +-- --------------------------------------------------------------------------- + +||| Encode session state as C-compatible integer for FFI boundary. +export +sessionStateToInt : SessionState -> Int +sessionStateToInt Disconnected = 0 +sessionStateToInt Connected = 1 +sessionStateToInt RateLimited = 2 +sessionStateToInt Error = 3 + +||| Decode integer back to session state. Returns Nothing for out-of-range. +export +intToSessionState : Int -> Maybe SessionState +intToSessionState 0 = Just Disconnected +intToSessionState 1 = Just Connected +intToSessionState 2 = Just RateLimited +intToSessionState 3 = Just Error +intToSessionState _ = Nothing + +||| Check if a state transition is valid (C-ABI export). +export +obsidian_mcp_can_transition : Int -> Int -> Int +obsidian_mcp_can_transition from to = + case (intToSessionState from, intToSessionState to) of + (Just Disconnected, Just Connected) => 1 + (Just Connected, Just Disconnected) => 1 + (Just Connected, Just RateLimited) => 1 + (Just RateLimited, Just Connected) => 1 + (Just Connected, Just Error) => 1 + (Just Disconnected, Just Error) => 1 + (Just Error, Just Connected) => 1 + (Just Error, Just Disconnected) => 1 + _ => 0 + +-- --------------------------------------------------------------------------- +-- Obsidian actions +-- --------------------------------------------------------------------------- + +||| Actions available through the Obsidian MCP cartridge. +||| Grouped: Search, Content, Listing, Backlinks, OutgoingLinks, +||| Tags, NotesByTag, Frontmatter, DailyNotes, VaultStats, +||| Dataview, Templates. +public export +data ObsidianAction + = SearchNotes + | GetNote + | ListNotes + | GetBacklinks + | GetOutgoingLinks + | ListTags + | GetNotesByTag + | GetFrontmatter + | GetDailyNote + | VaultStats + | DataviewQuery + | ListTemplates + +||| Whether an action requires Connected state. +||| All Obsidian operations require an active connection. +export +actionRequiresAuth : ObsidianAction -> Bool +actionRequiresAuth _ = True + +||| Whether an action is a write/mutating operation. +||| All obsidian-mcp actions are read-only queries. +export +actionIsMutating : ObsidianAction -> Bool +actionIsMutating _ = False + +||| Encode action as C-compatible integer for FFI. +export +actionToInt : ObsidianAction -> Int +actionToInt SearchNotes = 0 +actionToInt GetNote = 1 +actionToInt ListNotes = 2 +actionToInt GetBacklinks = 3 +actionToInt GetOutgoingLinks = 4 +actionToInt ListTags = 5 +actionToInt GetNotesByTag = 6 +actionToInt GetFrontmatter = 7 +actionToInt GetDailyNote = 8 +actionToInt VaultStats = 9 +actionToInt DataviewQuery = 10 +actionToInt ListTemplates = 11 + +||| Decode integer to Obsidian action. +export +intToAction : Int -> Maybe ObsidianAction +intToAction 0 = Just SearchNotes +intToAction 1 = Just GetNote +intToAction 2 = Just ListNotes +intToAction 3 = Just GetBacklinks +intToAction 4 = Just GetOutgoingLinks +intToAction 5 = Just ListTags +intToAction 6 = Just GetNotesByTag +intToAction 7 = Just GetFrontmatter +intToAction 8 = Just GetDailyNote +intToAction 9 = Just VaultStats +intToAction 10 = Just DataviewQuery +intToAction 11 = Just ListTemplates +intToAction _ = Nothing + +-- --------------------------------------------------------------------------- +-- MCP tool declarations +-- --------------------------------------------------------------------------- + +||| Tools exposed via MCP protocol for this cartridge. +public export +data McpTool + = ToolSearchNotes + | ToolGetNote + | ToolListNotes + | ToolGetBacklinks + | ToolGetOutgoingLinks + | ToolListTags + | ToolGetNotesByTag + | ToolGetFrontmatter + | ToolGetDailyNote + | ToolVaultStats + | ToolDataviewQuery + | ToolListTemplates + +||| Check if a tool requires a connected session. +||| All Obsidian tools require an active authenticated connection. +export +toolRequiresSession : McpTool -> Bool +toolRequiresSession _ = True + +||| Total tool count for this cartridge. +export +toolCount : Nat +toolCount = 12 + +||| Total action count for this cartridge. +export +actionCount : Nat +actionCount = 12 diff --git a/cartridges/domains/knowledge/obsidian-mcp/abi/README.adoc b/cartridges/domains/knowledge/obsidian-mcp/abi/README.adoc new file mode 100644 index 0000000..562d057 --- /dev/null +++ b/cartridges/domains/knowledge/obsidian-mcp/abi/README.adoc @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += obsidian-mcp / abi β€” Idris2 ABI layer +:orientation: shallow + +== Purpose + +Cartridge-local protocol types for `obsidian-mcp`. Defines the data, tool, and +transition vocabulary this cartridge speaks. Inherits safety lemmas and the +matrix-cell identifier scheme from the trunk ABI at `src/abi/`. + +== Files + +1 Idris2 module(s), ~186 lines total. Lead module: +`SafeRegistry.idr`. + +No `.ipkg` β€” consumed directly by wider BoJ callers; type-checks inside the +trunk `boj.ipkg`. + +== Invariants + +* `%default total` at every module top. +* Zero `believe_me`, `assert_total`, `postulate`, `Admitted`, or + `sorry` (the trunk holds the four declared `believe_me` sites; leaf + cartridges carry none). + +== Test/proof surface + +Thin module(s), no inline tests. Proof surface = type-check success under the +trunk `idris2 --check`. + +== Read-first + +. `SafeRegistry.idr` β€” cartridge-local data records and enums. +. Trunk `src/abi/Boj/Catalogue.idr` for how this cell participates in the + 2D matrix. diff --git a/cartridges/domains/knowledge/obsidian-mcp/adapter/README.adoc b/cartridges/domains/knowledge/obsidian-mcp/adapter/README.adoc new file mode 100644 index 0000000..d395bdb --- /dev/null +++ b/cartridges/domains/knowledge/obsidian-mcp/adapter/README.adoc @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += obsidian-mcp / adapter β€” protocol bridge +:orientation: shallow + +== Purpose + +Exposes this cartridge's FFI over the wire protocols declared in +`cartridge.json`. Dispatches tool invocations to the C-ABI exports at +`../ffi/` and formats responses per protocol. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” depends on the sibling `ffi` target. +| `obsidian_adapter.zig` | Protocol dispatch (212 lines). Tool catalogue matches `cartridge.json`. +| `SIDELINED-*.v.adoc` | zig predecessor(s). Not built β€” zig banned 2026-04-10. +|=== + +== Invariants + +* **Stateless** β€” all state lives behind the FFI, never in the adapter. +* **Response passthrough** β€” whatever the FFI returns goes back to the wire + unmodified (no embellishment, no silent recovery). +* **Cartridge.json is source of truth** for the tool catalogue; drift between + adapter and manifest is a CI failure. + +== Test/proof surface + +No inline tests at the adapter layer β€” protocol routing is exercised by the +trunk seam tests at `ffi/zig/src/seams.zig`. + +== Read-first + +. `obsidian_adapter.zig` β€” dispatch function and per-protocol routers. +. `../cartridge.json` β€” the tool manifest the dispatcher mirrors. diff --git a/cartridges/domains/knowledge/obsidian-mcp/adapter/build.zig b/cartridges/domains/knowledge/obsidian-mcp/adapter/build.zig new file mode 100644 index 0000000..e3a6bbc --- /dev/null +++ b/cartridges/domains/knowledge/obsidian-mcp/adapter/build.zig @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// obsidian-mcp/adapter/build.zig + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const ffi_mod = b.createModule(.{ + .root_source_file = b.path("../ffi/obsidian_mcp_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + const adapter = b.addExecutable(.{ + .name = "obsidian_adapter", + .root_source_file = b.path("obsidian_adapter.zig"), + .target = target, + .optimize = optimize, + }); + adapter.root_module.addImport("obsidian_mcp_ffi", ffi_mod); + b.installArtifact(adapter); + + const run_artifact = b.addRunArtifact(adapter); + const run_step = b.step("run", "Run the obsidian-mcp adapter"); + run_step.dependOn(&run_artifact.step); + + const tests = b.addTest(.{ + .root_source_file = b.path("obsidian_adapter.zig"), + .target = target, + .optimize = optimize, + }); + tests.root_module.addImport("obsidian_mcp_ffi", ffi_mod); + const run_tests = b.addRunArtifact(tests); + const test_step = b.step("test", "Run obsidian-mcp adapter tests"); + test_step.dependOn(&run_tests.step); +} diff --git a/cartridges/domains/knowledge/obsidian-mcp/adapter/obsidian_adapter.zig b/cartridges/domains/knowledge/obsidian-mcp/adapter/obsidian_adapter.zig new file mode 100644 index 0000000..2a43935 --- /dev/null +++ b/cartridges/domains/knowledge/obsidian-mcp/adapter/obsidian_adapter.zig @@ -0,0 +1,212 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// obsidian-mcp/adapter/obsidian_adapter.zig -- Unified three-protocol adapter. +// +// Replaces the banned obsidian_adapter.v (zig, removed 2026-04-12). +// +// Bridges the Zig FFI (obsidian_mcp_ffi.zig) to three network protocols: +// REST :9076 POST /tools/<tool> +// gRPC-compat :9077 /ObsidianMcpService/<Method> +// GraphQL :9078 POST /graphql { query: "..." } +// +// Obsidian vault: search notes, backlinks, tags, dataview queries +// Tools: +// obsidian_search_notes +// obsidian_get_note +// obsidian_list_notes +// obsidian_get_backlinks +// obsidian_get_outgoing_links +// obsidian_list_tags +// obsidian_get_notes_by_tag +// obsidian_get_frontmatter +// obsidian_get_daily_note +// obsidian_vault_stats +// obsidian_dataview_query +// obsidian_list_templates + +const std = @import("std"); +const ffi = @import("obsidian_mcp_ffi"); + +const REST_PORT: u16 = 9076; +const GRPC_PORT: u16 = 9077; +const GQL_PORT: u16 = 9078; + +const MAX_CONN_BUF: usize = 16 * 1024; + +// ============================================================================ +// JSON response builders +// ============================================================================ + +fn okJson(buf: []u8, msg: []const u8) []u8 { + return std.fmt.bufPrint(buf, + \{{"success":true,"message":"{}"}} + , .{msg}) catch buf[0..0]; +} + +fn errJson(buf: []u8, msg: []const u8) []u8 { + return std.fmt.bufPrint(buf, + \{{"success":false,"error":"{}"}} + , .{msg}) catch buf[0..0]; +} + +fn statusJson(buf: []u8) []u8 { + return std.fmt.bufPrint(buf, + \{{"success":true,"state":"ready","service":"obsidian-mcp"}} + , .{}) catch buf[0..0]; +} + +// ============================================================================ +// Tool dispatcher +// ============================================================================ + +const Response = struct { status: u16, body: []u8 }; + +fn dispatch(tool: []const u8, body: []const u8, resp: []u8) Response { + _ = body; + if (std.mem.eql(u8, tool, "obsidian_search_notes")) return .{ .status = 200, .body = okJson(resp, "obsidian_search_notes forwarded to backend") }; + if (std.mem.eql(u8, tool, "obsidian_get_note")) return .{ .status = 200, .body = okJson(resp, "obsidian_get_note forwarded to backend") }; + if (std.mem.eql(u8, tool, "obsidian_list_notes")) return .{ .status = 200, .body = okJson(resp, "obsidian_list_notes forwarded to backend") }; + if (std.mem.eql(u8, tool, "obsidian_get_backlinks")) return .{ .status = 200, .body = okJson(resp, "obsidian_get_backlinks forwarded to backend") }; + if (std.mem.eql(u8, tool, "obsidian_get_outgoing_links")) return .{ .status = 200, .body = okJson(resp, "obsidian_get_outgoing_links forwarded to backend") }; + if (std.mem.eql(u8, tool, "obsidian_list_tags")) return .{ .status = 200, .body = okJson(resp, "obsidian_list_tags forwarded to backend") }; + if (std.mem.eql(u8, tool, "obsidian_get_notes_by_tag")) return .{ .status = 200, .body = okJson(resp, "obsidian_get_notes_by_tag forwarded to backend") }; + if (std.mem.eql(u8, tool, "obsidian_get_frontmatter")) return .{ .status = 200, .body = okJson(resp, "obsidian_get_frontmatter forwarded to backend") }; + if (std.mem.eql(u8, tool, "obsidian_get_daily_note")) return .{ .status = 200, .body = okJson(resp, "obsidian_get_daily_note forwarded to backend") }; + if (std.mem.eql(u8, tool, "obsidian_vault_stats")) return .{ .status = 200, .body = okJson(resp, "obsidian_vault_stats forwarded to backend") }; + if (std.mem.eql(u8, tool, "obsidian_dataview_query")) return .{ .status = 200, .body = okJson(resp, "obsidian_dataview_query forwarded to backend") }; + if (std.mem.eql(u8, tool, "obsidian_list_templates")) return .{ .status = 200, .body = okJson(resp, "obsidian_list_templates forwarded to backend") }; + if (std.mem.eql(u8, tool, "status") or std.mem.eql(u8, tool, "health")) + return .{ .status = 200, .body = statusJson(resp) }; + return .{ .status = 404, .body = errJson(resp, "Unknown tool") }; +} + +// ============================================================================ +// REST handler +// ============================================================================ + +fn dispatchRest(path: []const u8, body: []const u8, resp: []u8) Response { + if (std.mem.startsWith(u8, path, "/tools/")) { + return dispatch(path["/tools/".len..], body, resp); + } + if (std.mem.eql(u8, path, "/status") or std.mem.eql(u8, path, "/health")) { + return .{ .status = 200, .body = statusJson(resp) }; + } + return .{ .status = 404, .body = errJson(resp, "Not found") }; +} + +// ============================================================================ +// gRPC-compat handler +// ============================================================================ + +fn dispatchGrpc(path: []const u8, body: []const u8, resp: []u8) Response { + const prefix = "/ObsidianMcpService/"; + if (!std.mem.startsWith(u8, path, prefix)) + return .{ .status = 404, .body = errJson(resp, "Not a recognized gRPC path") }; + const method = path[prefix.len..]; + const tool = blk: { + if (std.mem.eql(u8, method, "ObsidianSearchNotes")) break :blk "obsidian_search_notes"; + if (std.mem.eql(u8, method, "ObsidianGetNote")) break :blk "obsidian_get_note"; + if (std.mem.eql(u8, method, "ObsidianListNotes")) break :blk "obsidian_list_notes"; + if (std.mem.eql(u8, method, "ObsidianGetBacklinks")) break :blk "obsidian_get_backlinks"; + if (std.mem.eql(u8, method, "ObsidianGetOutgoingLinks")) break :blk "obsidian_get_outgoing_links"; + if (std.mem.eql(u8, method, "ObsidianListTags")) break :blk "obsidian_list_tags"; + if (std.mem.eql(u8, method, "ObsidianGetNotesByTag")) break :blk "obsidian_get_notes_by_tag"; + if (std.mem.eql(u8, method, "ObsidianGetFrontmatter")) break :blk "obsidian_get_frontmatter"; + if (std.mem.eql(u8, method, "ObsidianGetDailyNote")) break :blk "obsidian_get_daily_note"; + if (std.mem.eql(u8, method, "ObsidianVaultStats")) break :blk "obsidian_vault_stats"; + if (std.mem.eql(u8, method, "ObsidianDataviewQuery")) break :blk "obsidian_dataview_query"; + if (std.mem.eql(u8, method, "ObsidianListTemplates")) break :blk "obsidian_list_templates"; + return .{ .status = 404, .body = errJson(resp, "Unknown gRPC method") }; + }; + return dispatch(tool, body, resp); +} + +// ============================================================================ +// GraphQL handler +// ============================================================================ + +fn dispatchGraphql(body: []const u8, resp: []u8) Response { + if (std.mem.indexOf(u8, body, "__schema") != null) + return .{ .status = 200, .body = okJson(resp, "schema introspection not yet supported") }; + if (std.mem.indexOf(u8, body, "search_notes") != null) return dispatch("obsidian_search_notes", body, resp); + if (std.mem.indexOf(u8, body, "get_note") != null) return dispatch("obsidian_get_note", body, resp); + if (std.mem.indexOf(u8, body, "list_notes") != null) return dispatch("obsidian_list_notes", body, resp); + if (std.mem.indexOf(u8, body, "get_backlinks") != null) return dispatch("obsidian_get_backlinks", body, resp); + if (std.mem.indexOf(u8, body, "get_outgoing_links") != null) return dispatch("obsidian_get_outgoing_links", body, resp); + if (std.mem.indexOf(u8, body, "list_tags") != null) return dispatch("obsidian_list_tags", body, resp); + if (std.mem.indexOf(u8, body, "get_notes_by_tag") != null) return dispatch("obsidian_get_notes_by_tag", body, resp); + if (std.mem.indexOf(u8, body, "get_frontmatter") != null) return dispatch("obsidian_get_frontmatter", body, resp); + if (std.mem.indexOf(u8, body, "get_daily_note") != null) return dispatch("obsidian_get_daily_note", body, resp); + if (std.mem.indexOf(u8, body, "vault_stats") != null) return dispatch("obsidian_vault_stats", body, resp); + if (std.mem.indexOf(u8, body, "dataview_query") != null) return dispatch("obsidian_dataview_query", body, resp); + if (std.mem.indexOf(u8, body, "list_templates") != null) return dispatch("obsidian_list_templates", body, resp); + return .{ .status = 200, .body = errJson(resp, "Unrecognised GraphQL operation") }; +} + +// ============================================================================ +// HTTP/1.1 connection handler +// ============================================================================ + +const Protocol = enum { rest, grpc, graphql }; + +fn handleConnection(conn: std.net.Server.Connection, proto: Protocol) void { + defer conn.stream.close(); + var in_buf: [MAX_CONN_BUF]u8 = undefined; + const n = conn.stream.read(&in_buf) catch return; + const req = in_buf[0..n]; + + var path: []const u8 = "/"; + var body: []const u8 = ""; + if (n > 4) { + const line_end = std.mem.indexOf(u8, req, "\r\n") orelse req.len; + const first_line = req[0..line_end]; + const sp1 = std.mem.indexOfScalar(u8, first_line, ' ') orelse 0; + const rest_of = first_line[sp1 + 1 ..]; + const sp2 = std.mem.indexOfScalar(u8, rest_of, ' ') orelse rest_of.len; + path = rest_of[0..sp2]; + const body_sep = std.mem.indexOf(u8, req, "\r\n\r\n") orelse n; + body = req[@min(body_sep + 4, n)..]; + } + + var resp_buf: [MAX_CONN_BUF]u8 = undefined; + const result = switch (proto) { + .rest => dispatchRest(path, body, &resp_buf), + .grpc => dispatchGrpc(path, body, &resp_buf), + .graphql => dispatchGraphql(body, &resp_buf), + }; + + const content_type = switch (proto) { + .rest => "application/json", + .grpc => "application/grpc+json", + .graphql => "application/json", + }; + + var hdr_buf: [256]u8 = undefined; + const hdr = std.fmt.bufPrint(&hdr_buf, + "HTTP/1.1 {d} OK\r\nContent-Type: {s}\r\nContent-Length: {d}\r\nConnection: close\r\n\r\n", + .{ result.status, content_type, result.body.len }, + ) catch return; + _ = conn.stream.writeAll(hdr) catch return; + _ = conn.stream.writeAll(result.body) catch return; +} + +fn listenLoop(port: u16, proto: Protocol) void { + const addr = std.net.Address.initIp4(.{ 127, 0, 0, 1 }, port) catch return; + var server = addr.listen(.{ .reuse_address = true }) catch return; + defer server.deinit(); + while (true) { + const conn = server.accept() catch continue; + handleConnection(conn, proto); + } +} + +pub fn main() !void { + ffi.obsidian_init(); + const rest_thread = try std.Thread.spawn(.{}, listenLoop, .{ REST_PORT, Protocol.rest }); + const grpc_thread = try std.Thread.spawn(.{}, listenLoop, .{ GRPC_PORT, Protocol.grpc }); + const gql_thread = try std.Thread.spawn(.{}, listenLoop, .{ GQL_PORT, Protocol.graphql }); + rest_thread.join(); + grpc_thread.join(); + gql_thread.join(); +} diff --git a/cartridges/domains/knowledge/obsidian-mcp/benchmarks/quick-bench.sh b/cartridges/domains/knowledge/obsidian-mcp/benchmarks/quick-bench.sh new file mode 100755 index 0000000..ac8dd18 --- /dev/null +++ b/cartridges/domains/knowledge/obsidian-mcp/benchmarks/quick-bench.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Benchmarks for obsidian-mcp cartridge. +set -euo pipefail + +echo "=== obsidian-mcp benchmarks ===" + +cd "$(dirname "${BASH_SOURCE[0]}")/../ffi" + +# Build with ReleaseFast for benchmarking +zig build -Doptimize=ReleaseFast 2>&1 + +echo "Session connect/disconnect cycle (1000 iterations):" +time for i in $(seq 1 1000); do + true +done + +echo "" +echo "Benchmark placeholder -- implement real benchmarks in Zig test blocks" +echo "or via the zig adapter HTTP benchmark tool." diff --git a/cartridges/domains/knowledge/obsidian-mcp/cartridge.json b/cartridges/domains/knowledge/obsidian-mcp/cartridge.json new file mode 100644 index 0000000..f638ee3 --- /dev/null +++ b/cartridges/domains/knowledge/obsidian-mcp/cartridge.json @@ -0,0 +1,206 @@ +{ + "$schema": "https://boj.dev/schemas/cartridge/v1.json", + "spdx": "MPL-2.0", + "copyright": "Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath)", + "name": "obsidian-mcp", + "version": "0.2.0", + "description": "Obsidian vault cartridge -- note search, content retrieval, backlink navigation, tag browsing, graph analysis, dataview queries, frontmatter extraction, daily notes, template listing, and vault statistics via the Obsidian Local REST API", + "domain": "Knowledge", + "tier": "Ayo", + "protocols": [ + "MCP", + "REST" + ], + "auth": { + "method": "bearer_token", + "env_var": "OBSIDIAN_REST_API_KEY", + "credential_source": "vault-mcp" + }, + "api": { + "base_url": "https://127.0.0.1:27124", + "content_type": "application/json" + }, + "tools": [ + { + "name": "obsidian_search_notes", + "description": "Search Obsidian vault for notes by text query across titles and content", + "inputSchema": { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "Search query (note title, content, tags)" + }, + "context_length": { + "type": "number", + "description": "Characters of context around matches (default 100)" + } + }, + "required": [ + "query" + ] + } + }, + { + "name": "obsidian_get_note", + "description": "Get the full content of an Obsidian note by path (relative to vault root)", + "inputSchema": { + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "Note path relative to vault root (e.g. 'Projects/my-note.md')" + } + }, + "required": [ + "path" + ] + } + }, + { + "name": "obsidian_list_notes", + "description": "List all notes in the vault or a specific folder, with optional filtering", + "inputSchema": { + "type": "object", + "properties": { + "folder": { + "type": "string", + "description": "Folder path to list (omit for vault root)" + }, + "recursive": { + "type": "boolean", + "description": "Include subfolders (default true)" + } + } + } + }, + { + "name": "obsidian_get_backlinks", + "description": "Get all notes that link to a given note (backlink/incoming link analysis)", + "inputSchema": { + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "Note path to find backlinks for" + } + }, + "required": [ + "path" + ] + } + }, + { + "name": "obsidian_get_outgoing_links", + "description": "Get all notes that a given note links to (outgoing link analysis)", + "inputSchema": { + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "Note path to find outgoing links for" + } + }, + "required": [ + "path" + ] + } + }, + { + "name": "obsidian_list_tags", + "description": "List all tags used in the vault with note counts", + "inputSchema": { + "type": "object", + "properties": {} + } + }, + { + "name": "obsidian_get_notes_by_tag", + "description": "Get all notes tagged with a specific tag", + "inputSchema": { + "type": "object", + "properties": { + "tag": { + "type": "string", + "description": "Tag name (with or without '#' prefix)" + } + }, + "required": [ + "tag" + ] + } + }, + { + "name": "obsidian_get_frontmatter", + "description": "Get YAML frontmatter metadata for a specific note", + "inputSchema": { + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "Note path relative to vault root" + } + }, + "required": [ + "path" + ] + } + }, + { + "name": "obsidian_get_daily_note", + "description": "Get the daily note for a specific date (or today if no date given)", + "inputSchema": { + "type": "object", + "properties": { + "date": { + "type": "string", + "description": "Date in YYYY-MM-DD format (omit for today)" + } + } + } + }, + { + "name": "obsidian_vault_stats", + "description": "Get vault statistics (total notes, folders, tags, links, word count)", + "inputSchema": { + "type": "object", + "properties": {} + } + }, + { + "name": "obsidian_dataview_query", + "description": "Execute a Dataview query against the vault (requires Dataview plugin)", + "inputSchema": { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "Dataview query (DQL syntax)" + } + }, + "required": [ + "query" + ] + } + }, + { + "name": "obsidian_list_templates", + "description": "List available note templates in the vault's template folder", + "inputSchema": { + "type": "object", + "properties": {} + } + } + ], + "ffi": { + "so_path": "ffi/zig-out/lib/libobsidian_mcp.so", + "abi_version": "ADR-0006", + "symbols": [ + "boj_cartridge_init", + "boj_cartridge_deinit", + "boj_cartridge_name", + "boj_cartridge_version", + "boj_cartridge_invoke" + ] + } +} diff --git a/cartridges/domains/knowledge/obsidian-mcp/ffi/README.adoc b/cartridges/domains/knowledge/obsidian-mcp/ffi/README.adoc new file mode 100644 index 0000000..3987c1b --- /dev/null +++ b/cartridges/domains/knowledge/obsidian-mcp/ffi/README.adoc @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += obsidian-mcp / ffi β€” Zig FFI layer +:orientation: shallow + +== Purpose + +C-ABI exports bridging this cartridge's Idris2 ABI to the runtime. Consumed by +the adapter for protocol dispatch and by the trunk loader for mount. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” produces `zig-out/lib/libobsidian.so`. +| `obsidian_mcp_ffi.zig` | C-ABI exports (15 exports, 6 inline tests, 373 lines). +|=== + +== Invariants + +* **Null-pointer rejection** on `[*c]const u8` arguments before any + dereference. +* **Negative return values** carry documented error meanings; zero is + success (matching the trunk error-code convention). +* **No allocator leaks** β€” every allocation path pairs with `defer free`. + +== Test/proof surface + +6 inline `test "..."` block(s) in `obsidian_mcp_ffi.zig`. Run with +`cd ffi && zig build test`. + +== Read-first + +. `obsidian_mcp_ffi.zig` β€” C-ABI signatures and guards. +. Trunk `ffi/zig/src/catalogue.zig` for how this `.so` is loaded. diff --git a/cartridges/domains/knowledge/obsidian-mcp/ffi/build.zig b/cartridges/domains/knowledge/obsidian-mcp/ffi/build.zig new file mode 100644 index 0000000..a1bbde5 --- /dev/null +++ b/cartridges/domains/knowledge/obsidian-mcp/ffi/build.zig @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + // Shared ADR-0006 invoke-shim module (relative path up to boj-server trunk). + + const shim_mod = b.addModule("cartridge_shim", .{ + + .root_source_file = b.path("../../../ffi/zig/src/cartridge_shim.zig"), + + .target = target, + + .optimize = optimize, + + }); + + const ffi_mod = b.addModule("obsidian_mcp", .{ + .root_source_file = b.path("obsidian_mcp_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + ffi_mod.addImport("cartridge_shim", shim_mod); + + const lib = b.addLibrary(.{ + .name = "obsidian_mcp", + .root_module = ffi_mod, + .linkage = .dynamic, + }); + lib.linkLibC(); + b.installArtifact(lib); + + const tests = b.addTest(.{ .root_module = ffi_mod }); + tests.linkLibC(); + const run_tests = b.addRunArtifact(tests); + const test_step = b.step("test", "Run FFI tests"); + test_step.dependOn(&run_tests.step); +} + diff --git a/cartridges/domains/knowledge/obsidian-mcp/ffi/cartridge_shim.zig b/cartridges/domains/knowledge/obsidian-mcp/ffi/cartridge_shim.zig new file mode 100644 index 0000000..0e399db --- /dev/null +++ b/cartridges/domains/knowledge/obsidian-mcp/ffi/cartridge_shim.zig @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// cartridge_shim.zig β€” Shared helpers for the ADR-0006 five-symbol +// cartridge ABI (`boj_cartridge_init / deinit / name / version / invoke`). +// +// The shim centralises the seven-code return convention, NUL-argument +// guards, tool-name comparison, and the buffer-too-small path so each +// cartridge's `boj_cartridge_invoke` can stay short β€” typically a tool +// table plus `shim.writeResult(...)`. +// +// Cartridges import this file by relative path (no build-graph change +// needed). Example: +// +// const shim = @import("../../../ffi/zig/src/cartridge_shim.zig"); +// +// export fn boj_cartridge_invoke( +// tool_name: [*c]const u8, +// json_args: [*c]const u8, +// out_buf: [*c]u8, +// in_out_len: [*c]usize, +// ) callconv(.c) i32 { +// _ = json_args; +// if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; +// const body = if (shim.toolIs(tool_name, "foo")) "{\"result\":{}}" +// else return shim.RC_UNKNOWN_TOOL; +// return shim.writeResult(out_buf, in_out_len, body); +// } + +const std = @import("std"); + +// ── Return codes (ADR-0006 Β§Return codes) ──────────────────────────── +// +// Frozen by ADR-0006. New failure modes compose these via the error +// JSON body β€” the integer surface does not grow without a follow-up ADR. + +pub const RC_SUCCESS: i32 = 0; +pub const RC_UNKNOWN_TOOL: i32 = -1; +pub const RC_BAD_ARGS: i32 = -2; +pub const RC_BUFFER_TOO_SMALL: i32 = -3; +pub const RC_RUNTIME_ERROR: i32 = -4; +pub const RC_PANIC: i32 = -5; +pub const RC_AUTH_DENIED: i32 = -6; + +// ── Invoke-path helpers ────────────────────────────────────────────── + +/// True if any of the three mandatory `boj_cartridge_invoke` output-path +/// pointers is null. Use at the top of every invoke to short-circuit to +/// `RC_BAD_ARGS`. +pub fn invokeArgsNull( + tool_name: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) bool { + return tool_name == null or out_buf == null or in_out_len == null; +} + +/// Compare a C-NUL-terminated tool-name pointer against a Zig string +/// literal. Caller must have already verified `tool_name` is non-null +/// (usually via `invokeArgsNull`). +pub fn toolIs(tool_name: [*c]const u8, expected: []const u8) bool { + const s = std.mem.span(@as([*:0]const u8, @ptrCast(tool_name))); + return std.mem.eql(u8, s, expected); +} + +/// Copy `body` into `out_buf[0..*in_out_len]` (as a capacity) and update +/// `*in_out_len` to the number of bytes written. Returns `RC_SUCCESS`. +/// +/// If `body.len` exceeds the current capacity stored in `*in_out_len`, +/// sets `*in_out_len` to the required size and returns +/// `RC_BUFFER_TOO_SMALL` β€” the caller is then expected to re-allocate +/// and retry, per ADR-0006 Β§Memory ownership. +/// +/// Caller must have already verified that `out_buf` and `in_out_len` +/// are non-null. +pub fn writeResult( + out_buf: [*c]u8, + in_out_len: [*c]usize, + body: []const u8, +) i32 { + const cap = in_out_len.*; + if (body.len > cap) { + in_out_len.* = body.len; + return RC_BUFFER_TOO_SMALL; + } + @memcpy(out_buf[0..body.len], body); + in_out_len.* = body.len; + return RC_SUCCESS; +} + +// ── Tests ──────────────────────────────────────────────────────────── + +test "writeResult: body fits, writes and sets length" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); + try std.testing.expectEqualStrings("hello", buf[0..len]); +} + +test "writeResult: too small returns -3 and sets required length" { + var buf: [2]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_BUFFER_TOO_SMALL, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: exact-fit succeeds" { + var buf: [5]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: empty body" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, ""); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 0), len); +} + +test "toolIs: matches and rejects" { + const name: [*:0]const u8 = "foo"; + try std.testing.expect(toolIs(@ptrCast(name), "foo")); + try std.testing.expect(!toolIs(@ptrCast(name), "bar")); + try std.testing.expect(!toolIs(@ptrCast(name), "foobar")); + try std.testing.expect(!toolIs(@ptrCast(name), "fo")); +} + +test "invokeArgsNull: detects each null slot" { + var buf: [4]u8 = undefined; + var len: usize = 4; + const name: [*:0]const u8 = "x"; + try std.testing.expect(!invokeArgsNull(@ptrCast(name), &buf, &len)); + try std.testing.expect(invokeArgsNull(null, &buf, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), null, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), &buf, null)); +} diff --git a/cartridges/domains/knowledge/obsidian-mcp/ffi/obsidian_mcp_ffi.zig b/cartridges/domains/knowledge/obsidian-mcp/ffi/obsidian_mcp_ffi.zig new file mode 100644 index 0000000..3583e87 --- /dev/null +++ b/cartridges/domains/knowledge/obsidian-mcp/ffi/obsidian_mcp_ffi.zig @@ -0,0 +1,488 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// obsidian_mcp_ffi.zig β€” C-ABI FFI implementation for obsidian-mcp cartridge. +// +// Implements the state machine defined in ObsidianMcp.SafeRegistry (Idris2 ABI). +// State machine: Disconnected | Connected | RateLimited | Error +// Auth: Required Bearer token β€” Obsidian REST API is local and always gated. +// REST API: https://127.0.0.1:27124 +// Actions: SearchNotes, GetNote, ListNotes, GetBacklinks, GetOutgoingLinks, +// ListTags, GetNotesByTag, GetFrontmatter, GetDailyNote, VaultStats, +// DataviewQuery, ListTemplates +// Thread-safe via std.Thread.Mutex. Fixed-size session pool, no heap allocations. + +const std = @import("std"); + +// --------------------------------------------------------------------------- +// State machine (matches Idris2 ABI SessionState exactly) +// --------------------------------------------------------------------------- + +/// Session connection/lifecycle state. +/// 0 = Disconnected, 1 = Connected, 2 = RateLimited, 3 = Error. +pub const SessionState = enum(c_int) { + disconnected = 0, + connected = 1, + rate_limited = 2, + err = 3, +}; + +/// Obsidian action identifiers matching Idris2 ObsidianAction encoding. +pub const ObsidianAction = enum(c_int) { + search_notes = 0, + get_note = 1, + list_notes = 2, + get_backlinks = 3, + get_outgoing_links = 4, + list_tags = 5, + get_notes_by_tag = 6, + get_frontmatter = 7, + get_daily_note = 8, + vault_stats = 9, + dataview_query = 10, + list_templates = 11, +}; + +/// Check valid state transitions per the Idris2 ValidTransition proof. +/// Obsidian always requires auth β€” no anonymous sessions. +fn isValidTransition(from: SessionState, to: SessionState) bool { + return switch (from) { + .disconnected => to == .connected or to == .err, + .connected => to == .disconnected or to == .rate_limited or to == .err, + .rate_limited => to == .connected, + .err => to == .connected or to == .disconnected, + }; +} + +// --------------------------------------------------------------------------- +// Session slots (thread-safe, fixed-size pool) +// --------------------------------------------------------------------------- + +const MAX_SESSIONS: usize = 16; + +const SessionSlot = struct { + active: bool = false, + state: SessionState = .disconnected, + api_call_count: u64 = 0, + last_action: c_int = -1, + search_count: u32 = 0, + note_reads: u32 = 0, + link_queries: u32 = 0, + tag_queries: u32 = 0, +}; + +var sessions: [MAX_SESSIONS]SessionSlot = .{SessionSlot{}} ** MAX_SESSIONS; +var mutex: std.Thread.Mutex = .{}; + +// --------------------------------------------------------------------------- +// C-ABI exports β€” state machine +// --------------------------------------------------------------------------- + +/// Check if a state transition is valid. Returns 1 (valid) or 0 (invalid). +pub export fn obsidian_mcp_can_transition(from: c_int, to: c_int) c_int { + const f = std.meta.intToEnum(SessionState, from) catch return 0; + const t = std.meta.intToEnum(SessionState, to) catch return 0; + return if (isValidTransition(f, t)) 1 else 0; +} + +/// Connect to Obsidian (authenticated session). Returns slot index (>= 0) or error (< 0). +pub export fn obsidian_mcp_connect(dummy: c_int) c_int { + _ = dummy; + mutex.lock(); + defer mutex.unlock(); + + for (&sessions, 0..) |*slot, idx| { + if (!slot.active) { + slot.active = true; + slot.state = .connected; + slot.api_call_count = 0; + slot.last_action = -1; + slot.search_count = 0; + slot.note_reads = 0; + slot.link_queries = 0; + slot.tag_queries = 0; + return @intCast(idx); + } + } + return -1; +} + +/// Disconnect from Obsidian. Returns 0 on success. +pub export fn obsidian_mcp_disconnect(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + + sessions[idx] = SessionSlot{}; + return 0; +} + +/// Get current state of a session. Returns state int or -1 if invalid. +pub export fn obsidian_mcp_session_state(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + return @intFromEnum(slot.state); +} + +/// Signal rate limiting on a session. Returns 0 on success. +pub export fn obsidian_mcp_throttle(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + if (!isValidTransition(slot.state, .rate_limited)) return -2; + + sessions[idx].state = .rate_limited; + return 0; +} + +/// Clear rate limiting. Returns 0 on success. +pub export fn obsidian_mcp_unthrottle(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + if (!isValidTransition(slot.state, .connected)) return -2; + + sessions[idx].state = .connected; + return 0; +} + +/// Signal an error on a session. Returns 0 on success. +pub export fn obsidian_mcp_signal_error(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + if (!isValidTransition(slot.state, .err)) return -2; + + sessions[idx].state = .err; + return 0; +} + +// --------------------------------------------------------------------------- +// C-ABI exports β€” action recording and metrics +// --------------------------------------------------------------------------- + +/// Record an API call on a session. Returns 0 on success. +pub export fn obsidian_mcp_record_call(slot_idx: c_int, action: c_int) c_int { + const act = std.meta.intToEnum(ObsidianAction, action) catch return -3; + + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + if (slot.state != .connected) return -2; + + sessions[idx].api_call_count += 1; + sessions[idx].last_action = action; + + switch (act) { + .search_notes => sessions[idx].search_count += 1, + .get_note, .list_notes, .get_frontmatter, .get_daily_note => sessions[idx].note_reads += 1, + .get_backlinks, .get_outgoing_links => sessions[idx].link_queries += 1, + .list_tags, .get_notes_by_tag => sessions[idx].tag_queries += 1, + else => {}, + } + + return 0; +} + +/// Get API call count for a session. +pub export fn obsidian_mcp_call_count(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + return @intCast(slot.api_call_count); +} + +/// Get search query count. +pub export fn obsidian_mcp_search_count(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + return @intCast(slot.search_count); +} + +/// Get note read count. +pub export fn obsidian_mcp_note_read_count(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + return @intCast(slot.note_reads); +} + +/// Get link query count. +pub export fn obsidian_mcp_link_query_count(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + return @intCast(slot.link_queries); +} + +/// Get tag query count. +pub export fn obsidian_mcp_tag_query_count(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + return @intCast(slot.tag_queries); +} + +/// Get total action count. Always returns 12. +pub export fn obsidian_mcp_action_count() c_int { + return 12; +} + +/// Reset all sessions (test/debug use only). +pub export fn obsidian_mcp_reset() void { + mutex.lock(); + defer mutex.unlock(); + sessions = .{SessionSlot{}} ** MAX_SESSIONS; +} + +// --------------------------------------------------------------------------- +// ═══════════════════════════════════════════════════════════════════════ +// Standard ABI (ADR-0005 four symbols + ADR-0006 invoke) +// ═══════════════════════════════════════════════════════════════════════ + +const shim = @import("cartridge_shim.zig"); + +const CARTRIDGE_NAME_PTR: [*:0]const u8 = "obsidian-mcp"; +const CARTRIDGE_VERSION_PTR: [*:0]const u8 = "0.1.0"; + +export fn boj_cartridge_init() callconv(.c) c_int { + return 0; +} + +export fn boj_cartridge_deinit() callconv(.c) void {} + +export fn boj_cartridge_name() callconv(.c) [*:0]const u8 { + return CARTRIDGE_NAME_PTR; +} + +export fn boj_cartridge_version() callconv(.c) [*:0]const u8 { + return CARTRIDGE_VERSION_PTR; +} + +/// Dispatch the cartridge.json MCP tools. Grade D Alpha stubs. +export fn boj_cartridge_invoke( + tool_name: [*c]const u8, + json_args: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) callconv(.c) i32 { + _ = json_args; + if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; + + const body: []const u8 = if (shim.toolIs(tool_name, "obsidian_search_notes")) + "{\"result\":{\"matches\":[],\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "obsidian_get_note")) + "{\"result\":{\"metadata\":{},\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "obsidian_list_notes")) + "{\"result\":{\"items\":[],\"count\":0,\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "obsidian_get_backlinks")) + "{\"result\":{\"metadata\":{},\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "obsidian_get_outgoing_links")) + "{\"result\":{\"metadata\":{},\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "obsidian_list_tags")) + "{\"result\":{\"items\":[],\"count\":0,\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "obsidian_get_notes_by_tag")) + "{\"result\":{\"metadata\":{},\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "obsidian_get_frontmatter")) + "{\"result\":{\"metadata\":{},\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "obsidian_get_daily_note")) + "{\"result\":{\"metadata\":{},\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "obsidian_vault_stats")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "obsidian_dataview_query")) + "{\"result\":{\"matches\":[],\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "obsidian_list_templates")) + "{\"result\":{\"items\":[],\"count\":0,\"status\":\"stub\"}}" +else + return shim.RC_UNKNOWN_TOOL; + + return shim.writeResult(out_buf, in_out_len, body); +} + +// Tests +// --------------------------------------------------------------------------- + +test "connected session lifecycle" { + obsidian_mcp_reset(); + + const slot = obsidian_mcp_connect(0); + try std.testing.expect(slot >= 0); + try std.testing.expectEqual(@as(c_int, 1), obsidian_mcp_session_state(slot)); + + try std.testing.expectEqual(@as(c_int, 0), obsidian_mcp_record_call(slot, 0)); + try std.testing.expectEqual(@as(c_int, 1), obsidian_mcp_call_count(slot)); + try std.testing.expectEqual(@as(c_int, 1), obsidian_mcp_search_count(slot)); + + try std.testing.expectEqual(@as(c_int, 0), obsidian_mcp_disconnect(slot)); +} + +test "requires connected state for actions" { + obsidian_mcp_reset(); + + const slot = obsidian_mcp_connect(0); + try std.testing.expect(slot >= 0); + + // Throttle β€” cannot invoke while rate limited + try std.testing.expectEqual(@as(c_int, 0), obsidian_mcp_throttle(slot)); + try std.testing.expectEqual(@as(c_int, -2), obsidian_mcp_record_call(slot, 0)); + + // Unthrottle β€” can invoke again + try std.testing.expectEqual(@as(c_int, 0), obsidian_mcp_unthrottle(slot)); + try std.testing.expectEqual(@as(c_int, 0), obsidian_mcp_record_call(slot, 0)); +} + +test "error and recovery" { + obsidian_mcp_reset(); + + const slot = obsidian_mcp_connect(0); + try std.testing.expect(slot >= 0); + + try std.testing.expectEqual(@as(c_int, 0), obsidian_mcp_signal_error(slot)); + try std.testing.expectEqual(@as(c_int, 3), obsidian_mcp_session_state(slot)); + try std.testing.expectEqual(@as(c_int, -2), obsidian_mcp_record_call(slot, 0)); + + try std.testing.expectEqual(@as(c_int, 0), obsidian_mcp_disconnect(slot)); +} + +test "category counting" { + obsidian_mcp_reset(); + + const slot = obsidian_mcp_connect(0); + try std.testing.expect(slot >= 0); + + try std.testing.expectEqual(@as(c_int, 0), obsidian_mcp_record_call(slot, 0)); // SearchNotes + try std.testing.expectEqual(@as(c_int, 0), obsidian_mcp_record_call(slot, 1)); // GetNote + try std.testing.expectEqual(@as(c_int, 0), obsidian_mcp_record_call(slot, 3)); // GetBacklinks + try std.testing.expectEqual(@as(c_int, 0), obsidian_mcp_record_call(slot, 5)); // ListTags + + try std.testing.expectEqual(@as(c_int, 4), obsidian_mcp_call_count(slot)); + try std.testing.expectEqual(@as(c_int, 1), obsidian_mcp_search_count(slot)); + try std.testing.expectEqual(@as(c_int, 1), obsidian_mcp_note_read_count(slot)); + try std.testing.expectEqual(@as(c_int, 1), obsidian_mcp_link_query_count(slot)); + try std.testing.expectEqual(@as(c_int, 1), obsidian_mcp_tag_query_count(slot)); +} + +test "transition validator" { + try std.testing.expectEqual(@as(c_int, 1), obsidian_mcp_can_transition(0, 1)); // Disconnected -> Connected + try std.testing.expectEqual(@as(c_int, 1), obsidian_mcp_can_transition(1, 0)); // Connected -> Disconnected + try std.testing.expectEqual(@as(c_int, 1), obsidian_mcp_can_transition(1, 2)); // Connected -> RateLimited + try std.testing.expectEqual(@as(c_int, 1), obsidian_mcp_can_transition(2, 1)); // RateLimited -> Connected + try std.testing.expectEqual(@as(c_int, 1), obsidian_mcp_can_transition(1, 3)); // Connected -> Error + try std.testing.expectEqual(@as(c_int, 1), obsidian_mcp_can_transition(3, 0)); // Error -> Disconnected + try std.testing.expectEqual(@as(c_int, 0), obsidian_mcp_can_transition(2, 3)); // RateLimited -> Error (invalid) + try std.testing.expectEqual(@as(c_int, 0), obsidian_mcp_can_transition(0, 2)); // Disconnected -> RateLimited (invalid) +} + +test "slot exhaustion" { + obsidian_mcp_reset(); + + var slots: [MAX_SESSIONS]c_int = undefined; + for (&slots) |*s| { + s.* = obsidian_mcp_connect(0); + try std.testing.expect(s.* >= 0); + } + + try std.testing.expectEqual(@as(c_int, -1), obsidian_mcp_connect(0)); + + try std.testing.expectEqual(@as(c_int, 0), obsidian_mcp_disconnect(slots[0])); + const new_slot = obsidian_mcp_connect(0); + try std.testing.expect(new_slot >= 0); +} + +// ═══════════════════════════════════════════════════════════════════════ +// ADR-0006 invoke dispatch tests +// ═══════════════════════════════════════════════════════════════════════ + +test "boj_cartridge_name returns obsidian-mcp" { + const n = std.mem.span(boj_cartridge_name()); + try std.testing.expectEqualStrings("obsidian-mcp", n); +} + +test "boj_cartridge_init returns 0" { + try std.testing.expectEqual(@as(c_int, 0), boj_cartridge_init()); +} + +test "invoke: each declared tool succeeds" { + var buf: [256]u8 = undefined; + const tools = [_][]const u8{ + "obsidian_search_notes", + "obsidian_get_note", + "obsidian_list_notes", + "obsidian_get_backlinks", + "obsidian_get_outgoing_links", + "obsidian_list_tags", + "obsidian_get_notes_by_tag", + "obsidian_get_frontmatter", + "obsidian_get_daily_note", + "obsidian_vault_stats", + "obsidian_dataview_query", + "obsidian_list_templates", + }; + for (tools) |t| { + var len: usize = buf.len; + const rc = boj_cartridge_invoke(t.ptr, "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, 0), rc); + try std.testing.expect(std.mem.indexOf(u8, buf[0..len], "result") != null); + } +} + +test "invoke: unknown tool returns -1" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("nope", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -1), rc); +} + +test "invoke: buffer too small returns -3" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("obsidian_search_notes", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -3), rc); + try std.testing.expect(len > 4); +} diff --git a/cartridges/domains/knowledge/obsidian-mcp/minter.toml b/cartridges/domains/knowledge/obsidian-mcp/minter.toml new file mode 100644 index 0000000..02bce1b --- /dev/null +++ b/cartridges/domains/knowledge/obsidian-mcp/minter.toml @@ -0,0 +1,23 @@ +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> + +name = "obsidian-mcp" +description = "Obsidian vault cartridge β€” note search, content retrieval, backlink navigation, tag browsing, dataview queries, frontmatter extraction, daily notes, templates, vault stats" +version = "0.2.0" +domain = "Knowledge" +protocols = [ + "MCP", + "REST", +] +tier = "Ayo" +backend = "universal" +generate_panel = true + +[auth] +method = "bearer_token" +credential_source = "vault-mcp" +fields = ["obsidian_rest_api_key"] + +[api] +base_url = "https://127.0.0.1:27124" +local_only = true diff --git a/cartridges/domains/knowledge/obsidian-mcp/mod.js b/cartridges/domains/knowledge/obsidian-mcp/mod.js new file mode 100644 index 0000000..187eaf1 --- /dev/null +++ b/cartridges/domains/knowledge/obsidian-mcp/mod.js @@ -0,0 +1,225 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// obsidian-mcp/mod.js -- Obsidian vault cartridge implementation. +// +// Provides MCP tool handlers for the Obsidian Local REST API: +// - Note search (full-text across titles and content) +// - Note content retrieval by path +// - Note listing (folder, recursive) +// - Backlink analysis (incoming links) +// - Outgoing link analysis +// - Tag listing with counts +// - Tag-based note filtering +// - YAML frontmatter extraction +// - Daily note retrieval +// - Vault statistics +// - Dataview query execution +// - Template listing +// +// Auth: Bearer token via OBSIDIAN_REST_API_KEY (required β€” local API). +// API docs: https://github.com/coddingtonbear/obsidian-local-rest-api +// Note: Connects to localhost (127.0.0.1:27124) with self-signed HTTPS. +// +// Usage: import { handleTool } from "./mod.js"; +// or: deno run --allow-net --allow-env --unsafely-ignore-certificate-errors mod.js + +const API_BASE = "https://127.0.0.1:27124"; + +// --------------------------------------------------------------------------- +// Auth helper β€” retrieves the Obsidian REST API key from environment. +// This is a local API, so auth is always required. +// In production, vault-mcp provides zero-knowledge credential proxying. +// --------------------------------------------------------------------------- + +function getToken() { + const token = typeof Deno !== "undefined" + ? Deno.env.get("OBSIDIAN_REST_API_KEY") + : process.env.OBSIDIAN_REST_API_KEY; + return token || null; +} + +// --------------------------------------------------------------------------- +// HTTP request helper β€” wraps fetch with Obsidian REST API headers, +// bearer auth, self-signed cert handling, and error normalization. +// --------------------------------------------------------------------------- + +async function obsidianFetch(path, queryParams, acceptText) { + const url = new URL(`${API_BASE}${path}`); + + if (queryParams) { + for (const [key, value] of Object.entries(queryParams)) { + if (value !== undefined && value !== null && value !== "") { + url.searchParams.set(key, String(value)); + } + } + } + + const headers = { + "Accept": acceptText ? "text/markdown" : "application/json", + "User-Agent": "boj-server/obsidian-mcp/0.2.0", + }; + + const token = getToken(); + if (!token) { + return { status: 401, error: "OBSIDIAN_REST_API_KEY not set. Local REST API requires auth." }; + } + headers["Authorization"] = `Bearer ${token}`; + + const response = await fetch(url.toString(), { method: "GET", headers }); + + if (response.status === 429) { + const retryAfter = response.headers.get("retry-after"); + return { + status: 429, + error: `Rate limited. Retry after ${retryAfter || "unknown"} seconds.`, + retryAfter, + }; + } + + if (acceptText) { + const text = await response.text().catch(() => ""); + if (!response.ok) { + return { status: response.status, error: `HTTP ${response.status}`, data: text }; + } + return { status: response.status, data: text }; + } + + const data = await response.json().catch(() => ({})); + + if (!response.ok) { + const errorMessage = data.message || data.error || `HTTP ${response.status}`; + return { status: response.status, error: errorMessage, data }; + } + + return { status: response.status, data }; +} + +// --------------------------------------------------------------------------- +// Tool handler dispatch β€” maps MCP tool names to Obsidian REST API operations. +// --------------------------------------------------------------------------- + +export async function handleTool(toolName, args) { + switch (toolName) { + + // --- Search --- + + case "obsidian_search_notes": { + if (!args.query) return { error: "Missing required field: query" }; + return obsidianFetch("/search/simple/", { + query: args.query, + contextLength: args.context_length, + }); + } + + // --- Note content --- + + case "obsidian_get_note": { + if (!args.path) return { error: "Missing required field: path" }; + return obsidianFetch(`/vault/${encodeURIComponent(args.path)}`, null, true); + } + + // --- Note listing --- + + case "obsidian_list_notes": { + const folder = args.folder || "/"; + return obsidianFetch(`/vault/${encodeURIComponent(folder)}`, { + recursive: args.recursive !== false ? "true" : "false", + }); + } + + // --- Backlinks --- + + case "obsidian_get_backlinks": { + if (!args.path) return { error: "Missing required field: path" }; + return obsidianFetch(`/vault/${encodeURIComponent(args.path)}/backlinks`); + } + + // --- Outgoing links --- + + case "obsidian_get_outgoing_links": { + if (!args.path) return { error: "Missing required field: path" }; + return obsidianFetch(`/vault/${encodeURIComponent(args.path)}/outgoing-links`); + } + + // --- Tags --- + + case "obsidian_list_tags": { + return obsidianFetch("/tags/"); + } + + case "obsidian_get_notes_by_tag": { + if (!args.tag) return { error: "Missing required field: tag" }; + const tag = args.tag.startsWith("#") ? args.tag.slice(1) : args.tag; + return obsidianFetch(`/tags/${encodeURIComponent(tag)}`); + } + + // --- Frontmatter --- + + case "obsidian_get_frontmatter": { + if (!args.path) return { error: "Missing required field: path" }; + return obsidianFetch(`/vault/${encodeURIComponent(args.path)}/frontmatter`); + } + + // --- Daily notes --- + + case "obsidian_get_daily_note": { + const date = args.date || new Date().toISOString().split("T")[0]; + return obsidianFetch(`/periodic/daily/${date}`, null, true); + } + + // --- Vault stats --- + + case "obsidian_vault_stats": { + return obsidianFetch("/vault/stats"); + } + + // --- Dataview --- + + case "obsidian_dataview_query": { + if (!args.query) return { error: "Missing required field: query" }; + // Dataview queries use POST + const token = getToken(); + if (!token) { + return { status: 401, error: "OBSIDIAN_REST_API_KEY not set." }; + } + const response = await fetch(`${API_BASE}/dataview/`, { + method: "POST", + headers: { + "Authorization": `Bearer ${token}`, + "Content-Type": "application/json", + "Accept": "application/json", + }, + body: JSON.stringify({ query: args.query }), + }); + const data = await response.json().catch(() => ({})); + if (!response.ok) { + return { status: response.status, error: data.message || `HTTP ${response.status}`, data }; + } + return { status: response.status, data }; + } + + // --- Templates --- + + case "obsidian_list_templates": { + return obsidianFetch("/templates/"); + } + + default: + return { error: `Unknown obsidian-mcp tool: ${toolName}` }; + } +} + +// --------------------------------------------------------------------------- +// Cartridge metadata export β€” used by the BoJ cartridge loader to register +// this cartridge's tools without reading cartridge.json separately. +// --------------------------------------------------------------------------- + +export const metadata = { + name: "obsidian-mcp", + version: "0.2.0", + domain: "Knowledge", + tier: "Ayo", + protocols: ["MCP", "REST"], + toolCount: 12, +}; diff --git a/cartridges/domains/knowledge/obsidian-mcp/panels/manifest.json b/cartridges/domains/knowledge/obsidian-mcp/panels/manifest.json new file mode 100644 index 0000000..6ebc0b9 --- /dev/null +++ b/cartridges/domains/knowledge/obsidian-mcp/panels/manifest.json @@ -0,0 +1,90 @@ +{ + "$schema": "https://panll.dev/schemas/panel-manifest/v1.json", + "spdx": "MPL-2.0", + "copyright": "Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath)", + "cartridge": "obsidian-mcp", + "domain": "Knowledge", + "version": "0.2.0", + "panels": [ + { + "id": "obsidian-connection-status", + "title": "Connection Status", + "description": "Obsidian session state (Disconnected / Connected / RateLimited / Error)", + "type": "status-indicator", + "data_source": { + "endpoint": "/obsidian/state", + "method": "GET", + "refresh_interval_ms": 5000 + }, + "widgets": [ + { + "type": "state-badge", + "field": "state", + "states": { + "disconnected": { "color": "#95a5a6", "icon": "wifi-off" }, + "connected": { "color": "#2ecc71", "icon": "wifi" }, + "rate_limited": { "color": "#f39c12", "icon": "clock" }, + "error": { "color": "#e74c3c", "icon": "alert-triangle" } + } + } + ] + }, + { + "id": "obsidian-search-count", + "title": "Search Queries", + "description": "Number of vault search queries executed in the current session", + "type": "metric", + "data_source": { + "endpoint": "/obsidian/status", + "method": "GET", + "refresh_interval_ms": 10000 + }, + "widgets": [ + { + "type": "counter", + "field": "search_count", + "label": "Searches", + "icon": "search" + } + ] + }, + { + "id": "obsidian-note-reads", + "title": "Note Reads", + "description": "Number of note content reads in the current session", + "type": "metric", + "data_source": { + "endpoint": "/obsidian/status", + "method": "GET", + "refresh_interval_ms": 10000 + }, + "widgets": [ + { + "type": "counter", + "field": "note_reads", + "label": "Note Reads", + "icon": "file-text" + } + ] + }, + { + "id": "obsidian-link-queries", + "title": "Link Queries", + "description": "Number of backlink/outgoing link queries in the current session", + "type": "metric", + "data_source": { + "endpoint": "/obsidian/status", + "method": "GET", + "refresh_interval_ms": 10000 + }, + "widgets": [ + { + "type": "counter", + "field": "link_queries", + "label": "Link Queries", + "icon": "link" + } + ] + } + ] +} diff --git a/cartridges/domains/knowledge/obsidian-mcp/tests/integration_test.sh b/cartridges/domains/knowledge/obsidian-mcp/tests/integration_test.sh new file mode 100755 index 0000000..68d955a --- /dev/null +++ b/cartridges/domains/knowledge/obsidian-mcp/tests/integration_test.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Integration tests for obsidian-mcp cartridge. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CART_DIR="$(dirname "$SCRIPT_DIR")" +FFI_DIR="$CART_DIR/ffi" + +echo "=== obsidian-mcp integration tests ===" + +# Build FFI +echo "[1/3] Building FFI..." +cd "$FFI_DIR" && zig build 2>&1 + +# Run Zig unit tests +echo "[2/3] Running FFI unit tests..." +cd "$FFI_DIR" && zig build test 2>&1 + +# Validate Idris2 ABI (if idris2 available) +echo "[3/3] Checking ABI..." +if command -v idris2 &>/dev/null; then + cd "$CART_DIR/abi" && idris2 --check ObsidianMcp.SafeRegistry 2>&1 + echo " ABI: OK" +else + echo " ABI: SKIPPED (idris2 not in PATH)" +fi + +echo "" +echo "All tests passed for obsidian-mcp!" diff --git a/cartridges/domains/languages/007-mcp/README.adoc b/cartridges/domains/languages/007-mcp/README.adoc new file mode 100644 index 0000000..18eeb34 --- /dev/null +++ b/cartridges/domains/languages/007-mcp/README.adoc @@ -0,0 +1,137 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> + += 007-mcp β€” 007 agent-meta-language BoJ cartridge +:toc: left +:source-highlighter: rouge + +The `007-mcp` cartridge surfaces the full `oo7` CLI + 007-lang methodology +as MCP tools, so any AI session entering 007-lang automatically gets the +right context without having to recall it. + +== Why this lives here (not in boj-server) + +Per design-log decision DD-24, cartridge source stays next to the code +it exposes. The install hook (see top-level `Justfile` recipe +`cartridge-install`) builds the adapter and FFI, copies the artefacts +into `boj-server/cartridges/007-mcp/`, and commits in the boj-server +repo separately. Two repos, two commits, one seam. + +== Design-log anchors + +Anything here tracks these entries in `Desktop/COORD-MCP-DESIGN-LOG.md`: + +* *DD-24* β€” source location + install hook +* *DD-25* β€” expose the full CLI (no curated subset) +* *DD-26* β€” memory auto-lift via dynamic tag-indexed lookup +* *DD-31* β€” VeriSimDB FFI is stubs today; memory auto-lift uses the + static-mapping fallback option (b) and is wired for an in-place swap + when VeriSimDB lands. + +== Loopback-only bind + +The adapter binds `127.0.0.1:1066`. The port is locked at the Idris2 +ABI layer (`abi/Oo7Mcp/SafeCli.idr :: cartridgeBind`), where +`BindAddress` carries an `IsLoopback` proof. The sum type has exactly +two constructors β€” `Ipv4Loopback` and `Ipv6Loopback` β€” so naming any +non-loopback address is type-impossible. + +== Layout + +[source] +---- +007-mcp/ +β”œβ”€β”€ abi/Oo7Mcp/SafeCli.idr # Idris2 ABI β€” loopback proof + state machine + risk table +β”œβ”€β”€ ffi/ +β”‚ β”œβ”€β”€ oo7_mcp_ffi.zig # Zig FFI β€” recipe dispatch, lifecycle state, coord client +β”‚ └── build.zig # Zig 0.15+ build (self-contained) +β”œβ”€β”€ adapter/ +β”‚ β”œβ”€β”€ oo7_adapter.zig # HTTP-REST surface at 127.0.0.1:1066 +β”‚ └── build.zig +β”œβ”€β”€ schemas/ +β”‚ └── memory-tag-map.a2ml # Static tag β†’ memory-file index (DD-26 option b) +β”œβ”€β”€ docs/ # Rationale + per-tool usage notes +β”œβ”€β”€ cartridge.ncl # Nickel source-of-truth manifest +└── README.adoc # You are here +---- + +== Tool surface + +Seventy-two MCP tools: + +* 2 lifecycle β€” `oo7_on_enter`, `oo7_on_exit` +* 5 runtime β€” `oo7_parse`, `oo7_run`, `oo7_trace`, `oo7_demo`, `oo7_run_examples` +* 7 build β€” `oo7_build`, `oo7_build_cli{,_release}`, `oo7_build_watch`, + `oo7_release`, `oo7_clean{,_all}` +* 7 test β€” `oo7_test{,_parser,_eval,_trace,_aspect,_filter,_verbose}` +* 6 quality gates β€” `oo7_check`, `oo7_ci`, `oo7_preflight`, `oo7_fmt`, + `oo7_fmt_check`, `oo7_lint` +* 3 dependency audits β€” `oo7_audit`, `oo7_deny`, `oo7_outdated` +* 1 pre-commit scan β€” `oo7_assail` +* 2 toolchain β€” `oo7_doctor`, `oo7_heal` +* 17 contractile β€” `oo7_contractile_check`, `oo7_must_*`, `oo7_trust_*`, + `oo7_intend_list*`, `oo7_dust_*` +* 3 verification β€” `oo7_verify{,_harvard,_template}` +* 2 grammar/spec β€” `oo7_grammar_check`, `oo7_spec_check` +* 4 proof suite β€” `oo7_canonical_proof_suite`, `oo7_v{0,1}_differential{,_full}` +* 2 Groove β€” `oo7_groove_{daemon,setup}` +* 3 containers β€” `oo7_container_{build,run,verify}` +* 8 meta β€” `oo7_docs`, `oo7_cookbook`, `oo7_info`, `oo7_tour`, + `oo7_help{,_me}`, `oo7_self_assess`, plus `oo7_crg_{badge,grade}` + +Each tool maps 1:1 to a Justfile recipe. Lifecycle tools are handled +natively by the adapter (coord-peer handshake + 6a2 load + memory +auto-lift on enter; drift check + deregister on exit). + +== Risk table (DD-5 ladder) + +Most tools default to Tier 1 (logged, no gate). Promotions: + +[cols="1,1,2",options="header"] +|=== +| Tool | Tier | Why +| `oo7_dust_source_rollback` | 3 | Destructive β€” reverts uncommitted work +| `oo7_clean_all` | 2 | Drops generated artefacts outside target/ +| `oo7_container_run` | 3 | Long-running with host mounts +| `oo7_groove_daemon` | 3 | Starts a daemon with a network socket +|=== + +`supervised` peers never reach the Tier-3 path β€” the adapter rejects +before invoking FFI. + +== Running locally + +[source,bash] +---- +# Build FFI + adapter. +cd cartridges/007-mcp/ffi && zig build test && zig build +cd ../adapter && zig build + +# Start the adapter (loopback 127.0.0.1:1066). +OO7_WORKTREE=/var/mnt/eclipse/repos/007-lang ./zig-out/bin/oo7_adapter + +# Probe health. +curl -s http://127.0.0.1:1066/health + +# Fire the OnEnter hook. +curl -s -X POST http://127.0.0.1:1066/tools/oo7_on_enter \ + -d '{"session_hint":"afternoon m3-dispatch work"}' +---- + +== Install into boj-server + +From the 007-lang repo root: + +[source,bash] +---- +just cartridge-install # build + deploy to ../boj-server/cartridges/007-mcp/ +---- + +See the top-level `Justfile` for the install-hook recipe. + +== Swap-in path for VeriSimDB + +When `verisimdb-mcp`'s FFI is real (design-log Task #7b), replace the +body of `memoryAutolift` in `ffi/oo7_mcp_ffi.zig` with a VeriSimDB +tag-index query. The cartridge contract (return shape, tag set) stays +constant β€” only the lookup backend changes. diff --git a/cartridges/domains/languages/007-mcp/abi/Oo7Mcp/SafeCli.idr b/cartridges/domains/languages/007-mcp/abi/Oo7Mcp/SafeCli.idr new file mode 100644 index 0000000..5c2e060 --- /dev/null +++ b/cartridges/domains/languages/007-mcp/abi/Oo7Mcp/SafeCli.idr @@ -0,0 +1,241 @@ +-- SPDX-License-Identifier: MPL-2.0 +-- Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +-- +-- Oo7Mcp.SafeCli β€” Type-safe ABI for the 007-mcp cartridge. +-- +-- Three compile-time invariants: +-- +-- 1. Loopback-only bind. The `IsLoopback` sum type has exactly two +-- constructors (Ipv4Loopback, Ipv6Loopback). Every BindAddress +-- value threads an `IsLoopback` proof, so constructing a bind to +-- any non-loopback address is type-impossible. Mirrors the same +-- pattern used by local-coord-mcp. +-- +-- 2. Lifecycle ordering. The `SessionState` state machine forces +-- `OnEnter` before any tool dispatch and `OnExit` before +-- deregistration. `ValidTransition` enumerates exactly the legal +-- edges β€” anything else is a type error in well-typed callers. +-- +-- 3. Risk-gated destructive tools. `ToolRisk` lifts the DD-5 risk +-- ladder into Idris2. The `dust_source_rollback` tool is Tier 3 +-- (hard gate); the adapter rejects invocations that lack a +-- supervisor-approval witness. + +module Oo7Mcp.SafeCli + +%default total + +-- --------------------------------------------------------------------------- +-- Loopback-only bind proof +-- --------------------------------------------------------------------------- + +||| Witness that a bind address is on the local loopback interface. +||| The only two constructors are IPv4 127.0.0.1 and IPv6 ::1. +||| There is intentionally no constructor for any other address, so +||| `BindAddress` values cannot name a non-loopback target. +public export +data IsLoopback : Type where + Ipv4Loopback : IsLoopback -- 127.0.0.1 + Ipv6Loopback : IsLoopback -- ::1 + +||| A bind address carrying proof of loopback-ness. +||| The Idris2 ABI never constructs a BindAddress without this proof, +||| so the adapter's runtime bind inherits the compile-time guarantee. +public export +record BindAddress where + constructor MkBindAddress + host : String + port : Int + proof : IsLoopback + +||| The one and only production bind β€” 127.0.0.1:1066. +||| Port 1066 chosen in design-log decision-log 2026-04-20. +public export +cartridgeBind : BindAddress +cartridgeBind = MkBindAddress "127.0.0.1" 1066 Ipv4Loopback + +||| C-ABI export: fetch the host as a cstring. +||| The FFI wrapper reads this at server-start time. +export +oo7_mcp_bind_host : String +oo7_mcp_bind_host = cartridgeBind.host + +export +oo7_mcp_bind_port : Int +oo7_mcp_bind_port = cartridgeBind.port + +||| Encode the loopback proof tag for C consumption: 4 or 6. +||| Lets the adapter sanity-check that the proof travelled across FFI. +export +oo7_mcp_bind_family : Int +oo7_mcp_bind_family = case cartridgeBind.proof of + Ipv4Loopback => 4 + Ipv6Loopback => 6 + +-- --------------------------------------------------------------------------- +-- Session state machine +-- --------------------------------------------------------------------------- + +||| Session states for a 007-mcp cartridge instance. +||| +||| Fresh β€” constructed, no lifecycle hook run yet +||| Registered β€” OnEnter has registered this session with local-coord-mcp +||| and loaded the 6a2 methodology pack +||| InvokingTool β€” a tool dispatch is in flight (serialised) +||| Deregistered β€” OnExit has released claims and deregistered +||| Degraded β€” local-coord-mcp was unreachable during OnEnter; the +||| cartridge operates in local-only mode (no coord ops) +public export +data SessionState + = Fresh + | Registered + | InvokingTool + | Deregistered + | Degraded + +||| Proof that a state transition is valid. +public export +data ValidTransition : SessionState -> SessionState -> Type where + EnterOk : ValidTransition Fresh Registered + EnterDegrade : ValidTransition Fresh Degraded + BeginDispatch : ValidTransition Registered InvokingTool + BeginDispatchD : ValidTransition Degraded InvokingTool -- local-only tools still run + EndDispatchR : ValidTransition InvokingTool Registered + EndDispatchD : ValidTransition InvokingTool Degraded + ExitR : ValidTransition Registered Deregistered + ExitD : ValidTransition Degraded Deregistered + +||| Encode session state as C-compatible integer for FFI boundary. +export +sessionStateToInt : SessionState -> Int +sessionStateToInt Fresh = 0 +sessionStateToInt Registered = 1 +sessionStateToInt InvokingTool = 2 +sessionStateToInt Deregistered = 3 +sessionStateToInt Degraded = 4 + +||| Decode integer back to session state. +export +intToSessionState : Int -> Maybe SessionState +intToSessionState 0 = Just Fresh +intToSessionState 1 = Just Registered +intToSessionState 2 = Just InvokingTool +intToSessionState 3 = Just Deregistered +intToSessionState 4 = Just Degraded +intToSessionState _ = Nothing + +||| C-ABI export: is a transition permitted? +||| Returns 1 if yes, 0 if no β€” consumed by the Zig adapter's guard +||| before flipping its own state atomic. +export +oo7_mcp_can_transition : Int -> Int -> Int +oo7_mcp_can_transition from to = + case (intToSessionState from, intToSessionState to) of + (Just Fresh, Just Registered) => 1 + (Just Fresh, Just Degraded) => 1 + (Just Registered, Just InvokingTool) => 1 + (Just Degraded, Just InvokingTool) => 1 + (Just InvokingTool, Just Registered) => 1 + (Just InvokingTool, Just Degraded) => 1 + (Just Registered, Just Deregistered) => 1 + (Just Degraded, Just Deregistered) => 1 + _ => 0 + +-- --------------------------------------------------------------------------- +-- Tool risk classification (DD-5 risk ladder) +-- --------------------------------------------------------------------------- + +||| Risk tier for a tool invocation. +||| Tier 0 β€” free (status, reads) +||| Tier 1 β€” logged (runtime reads, tests, builds, lint) +||| Tier 2 β€” light gate (container builds, docs generate, heal) +||| Tier 3 β€” hard gate (rollback, destructive clean, container-run w/ privileged) +||| Tier 4 β€” forbidden for supervised role (NONE on 007-mcp β€” no public-repo writes, +||| no license touches here; Tier 4 is reserved for future tools only) +public export +data ToolRisk + = Tier0 + | Tier1 + | Tier2 + | Tier3 + | Tier4 + +export +tierToInt : ToolRisk -> Int +tierToInt Tier0 = 0 +tierToInt Tier1 = 1 +tierToInt Tier2 = 2 +tierToInt Tier3 = 3 +tierToInt Tier4 = 4 + +-- --------------------------------------------------------------------------- +-- Tool taxonomy +-- --------------------------------------------------------------------------- + +||| High-level categories for the 007-mcp tool surface. Finer-grained +||| names live in the Zig dispatcher; this enum is the compile-time +||| coarse classification used for risk inference. +public export +data ToolCategory + = Lifecycle -- on-enter, on-exit + | Runtime -- parse, run, trace, demo + | BuildArtifact -- build, clean, docs, release + | Test -- test*, check, ci, preflight + | Quality -- lint, fmt, audit, deny, outdated, assail + | Contractile -- must/trust/intend/dust/bust/adjust + | Verification -- verify*, grammar-check, spec-check + | ProofSuite -- canonical-proof-suite, v0/v1 differential + | Groove -- groove-daemon, groove-setup + | Container -- container-build, container-run, container-verify + | Meta -- info, tour, help, self-assess, cookbook, crg-* + | ToolchainMgmt -- doctor, heal + +||| Default risk tier per category. The dispatcher can promote +||| individual tools (see riskPromotion below) for destructive variants. +export +categoryDefaultRisk : ToolCategory -> ToolRisk +categoryDefaultRisk Lifecycle = Tier0 -- lifecycle hooks are idempotent reads +categoryDefaultRisk Runtime = Tier1 +categoryDefaultRisk BuildArtifact = Tier1 +categoryDefaultRisk Test = Tier1 +categoryDefaultRisk Quality = Tier1 +categoryDefaultRisk Contractile = Tier1 +categoryDefaultRisk Verification = Tier0 +categoryDefaultRisk ProofSuite = Tier1 +categoryDefaultRisk Groove = Tier2 -- starts a daemon, long-running +categoryDefaultRisk Container = Tier2 +categoryDefaultRisk Meta = Tier0 +categoryDefaultRisk ToolchainMgmt = Tier2 -- `heal` mutates system state + +||| Promotion table for specific tools whose risk exceeds their +||| category default. Names are the canonical MCP tool names from +||| cartridge.ncl. +export +riskPromotion : String -> Maybe ToolRisk +riskPromotion "oo7_dust_source_rollback" = Just Tier3 +riskPromotion "oo7_clean_all" = Just Tier2 +riskPromotion "oo7_container_run" = Just Tier3 +riskPromotion "oo7_groove_daemon" = Just Tier3 +riskPromotion _ = Nothing + +-- --------------------------------------------------------------------------- +-- Counts for the FFI header generator +-- --------------------------------------------------------------------------- + +||| Total tool count β€” kept in sync with cartridge.ncl. +||| 2 lifecycle + 5 runtime + 7 build + 7 test + 6 quality + 3 audits +||| + 1 assail + 2 toolchain + 17 contractile + 3 verification +||| + 2 grammar/spec + 4 proof suite + 2 groove + 3 container +||| + 8 meta + 2 crg = 72 (re-derived each version bump) +export +toolCount : Nat +toolCount = 72 + +||| Fixed-width wire counts (useful for FFI buffer sizing). +export +maxToolNameLen : Nat +maxToolNameLen = 48 + +export +maxArgStringLen : Nat +maxArgStringLen = 4096 diff --git a/cartridges/domains/languages/007-mcp/adapter/build.zig b/cartridges/domains/languages/007-mcp/adapter/build.zig new file mode 100644 index 0000000..a733a3a --- /dev/null +++ b/cartridges/domains/languages/007-mcp/adapter/build.zig @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// 007-mcp / adapter / build.zig β€” Zig 0.15+. + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const ffi_mod = b.createModule(.{ + .root_source_file = b.path("../ffi/oo7_mcp_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + const adapter_mod = b.createModule(.{ + .root_source_file = b.path("oo7_adapter.zig"), + .target = target, + .optimize = optimize, + }); + adapter_mod.addImport("oo7_mcp_ffi", ffi_mod); + + const adapter = b.addExecutable(.{ + .name = "oo7_adapter", + .root_module = adapter_mod, + }); + b.installArtifact(adapter); + + const adapter_tests = b.addTest(.{ + .root_module = adapter_mod, + }); + const run_tests = b.addRunArtifact(adapter_tests); + const test_step = b.step("test", "Run 007-mcp adapter tests"); + test_step.dependOn(&run_tests.step); +} diff --git a/cartridges/domains/languages/007-mcp/adapter/oo7_adapter.zig b/cartridges/domains/languages/007-mcp/adapter/oo7_adapter.zig new file mode 100644 index 0000000..d414b42 --- /dev/null +++ b/cartridges/domains/languages/007-mcp/adapter/oo7_adapter.zig @@ -0,0 +1,379 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// 007-mcp / adapter / oo7_adapter.zig +// +// HTTP-REST bridge for the 007-mcp cartridge. Binds 127.0.0.1:1066 β€” +// loopback only; the Idris2 IsLoopback proof in abi/Oo7Mcp/SafeCli.idr +// is the source of truth and we honour it at runtime. +// +// Routes: +// GET /health β†’ 200 if ready/degraded, 503 if unreachable state +// GET /status β†’ current SessionState + peer_id +// POST /on-enter β†’ run OnEnter lifecycle +// POST /on-exit β†’ run OnExit lifecycle +// POST /tools/<tool_name> β†’ invoke oo7 CLI recipe (body = JSON args) +// +// Body schema for /tools/<name>: +// { "file": "…", "args": "…", "recipe": "…", … } +// Only keys matching the cartridge.ncl inputSchema for that tool are +// honoured; unknown keys are ignored. + +const std = @import("std"); +const ffi = @import("oo7_mcp_ffi"); + +const BIND_ADDR = ffi.BIND_ADDR; +const BIND_PORT = ffi.BIND_PORT; + +const MAX_HEADER_BYTES: usize = 8 * 1024; +const MAX_BODY_BYTES: usize = 64 * 1024; + +// ═══════════════════════════════════════════════════════════════════════ +// JSON helpers +// ═══════════════════════════════════════════════════════════════════════ + +fn writeJsonString(writer: anytype, s: []const u8) !void { + try writer.writeByte('"'); + for (s) |c| { + switch (c) { + '"' => try writer.writeAll("\\\""), + '\\' => try writer.writeAll("\\\\"), + '\n' => try writer.writeAll("\\n"), + '\r' => try writer.writeAll("\\r"), + '\t' => try writer.writeAll("\\t"), + 0...8, 11, 12, 14...0x1f => try writer.print("\\u{x:0>4}", .{c}), + else => try writer.writeByte(c), + } + } + try writer.writeByte('"'); +} + +fn writeJsonArray(writer: anytype, items: []const []const u8) !void { + try writer.writeByte('['); + for (items, 0..) |item, i| { + if (i != 0) try writer.writeByte(','); + try writeJsonString(writer, item); + } + try writer.writeByte(']'); +} + +// ═══════════════════════════════════════════════════════════════════════ +// Lifecycle response builders +// ═══════════════════════════════════════════════════════════════════════ + +fn respondOnEnter( + allocator: std.mem.Allocator, + worktree: []const u8, + session_hint: []const u8, + out: *std.ArrayList(u8), +) !u16 { + var result = ffi.onEnter(allocator, worktree, session_hint) catch |e| { + try out.writer(allocator).print( + "{{\"success\":false,\"error\":\"on_enter_failed\",\"detail\":\"{s}\"}}", + .{@errorName(e)}, + ); + return 500; + }; + defer result.deinit(allocator); + + var w = out.writer(allocator); + try w.writeAll("{\"success\":true,\"peer_id\":"); + try writeJsonString(w, result.peer_id); + try w.writeAll(",\"coord_state\":"); + try writeJsonString(w, result.coord_state); + try w.writeAll(",\"methodology_files\":"); + try writeJsonArray(w, result.methodology_files); + try w.writeAll(",\"memory_hits\":"); + try writeJsonArray(w, result.memory_hits); + try w.writeByte('}'); + return 200; +} + +fn respondOnExit( + allocator: std.mem.Allocator, + worktree: []const u8, + reason: []const u8, + out: *std.ArrayList(u8), +) !u16 { + var result = ffi.onExit(allocator, worktree, reason) catch |e| { + try out.writer(allocator).print( + "{{\"success\":false,\"error\":\"on_exit_failed\",\"detail\":\"{s}\"}}", + .{@errorName(e)}, + ); + return 500; + }; + defer result.deinit(allocator); + + var w = out.writer(allocator); + try w.writeAll("{\"success\":true,\"coord_state\":"); + try writeJsonString(w, result.coord_state); + try w.writeAll(",\"drift_findings\":"); + try writeJsonArray(w, result.drift_findings); + try w.writeByte('}'); + return 200; +} + +// ═══════════════════════════════════════════════════════════════════════ +// Tool dispatch +// ═══════════════════════════════════════════════════════════════════════ + +/// Parse the `args` and `file` keys out of a minimal JSON body without +/// pulling in a full JSON parser. The body is trusted (loopback only). +fn extractKey(body: []const u8, key: []const u8, out: *[ffi.MAX_ARG_STRING]u8) ?[]const u8 { + // Look for "<key>":"<value>" with simple escape handling. + var needle_buf: [64]u8 = undefined; + const needle = std.fmt.bufPrint(&needle_buf, "\"{s}\"", .{key}) catch return null; + const i = std.mem.indexOf(u8, body, needle) orelse return null; + var p = i + needle.len; + while (p < body.len and (body[p] == ' ' or body[p] == ':')) : (p += 1) {} + if (p >= body.len or body[p] != '"') return null; + p += 1; + var q = p; + var len: usize = 0; + while (q < body.len and body[q] != '"') : (q += 1) { + if (len >= out.len) return null; + if (body[q] == '\\' and q + 1 < body.len) { + out[len] = switch (body[q + 1]) { + 'n' => '\n', + 'r' => '\r', + 't' => '\t', + '\\' => '\\', + '"' => '"', + else => body[q + 1], + }; + q += 1; + } else { + out[len] = body[q]; + } + len += 1; + } + return out[0..len]; +} + +fn dispatchTool( + allocator: std.mem.Allocator, + worktree: []const u8, + tool: []const u8, + body: []const u8, + out: *std.ArrayList(u8), +) !u16 { + // Lifecycle tools route to their dedicated handlers. + if (std.mem.eql(u8, tool, "oo7_on_enter")) { + var hint_buf: [ffi.MAX_ARG_STRING]u8 = undefined; + const hint = extractKey(body, "session_hint", &hint_buf) orelse ""; + return respondOnEnter(allocator, worktree, hint, out); + } + if (std.mem.eql(u8, tool, "oo7_on_exit")) { + var reason_buf: [ffi.MAX_ARG_STRING]u8 = undefined; + const reason = extractKey(body, "reason", &reason_buf) orelse ""; + return respondOnExit(allocator, worktree, reason, out); + } + + const recipe = ffi.recipeFor(tool) orelse { + try out.writer(allocator).print( + "{{\"success\":false,\"error\":\"unknown_tool\",\"tool\":\"{s}\"}}", + .{tool}, + ); + return 404; + }; + + // Extract the two universal optional inputs: `file`, `args`. + var file_buf: [ffi.MAX_ARG_STRING]u8 = undefined; + var args_buf: [ffi.MAX_ARG_STRING]u8 = undefined; + var pattern_buf: [ffi.MAX_ARG_STRING]u8 = undefined; + var recipe_buf: [ffi.MAX_ARG_STRING]u8 = undefined; + + var extra_list = std.ArrayList([]const u8).initCapacity(allocator, 4) catch return error.OutOfMemory; + defer extra_list.deinit(allocator); + if (extractKey(body, "file", &file_buf)) |v| extra_list.appendAssumeCapacity(v); + if (extractKey(body, "pattern", &pattern_buf)) |v| extra_list.appendAssumeCapacity(v); + if (extractKey(body, "recipe", &recipe_buf)) |v| extra_list.appendAssumeCapacity(v); + if (extractKey(body, "args", &args_buf)) |v| extra_list.appendAssumeCapacity(v); + + var inv = ffi.invokeRecipe(allocator, recipe, extra_list.items, worktree) catch |e| { + try out.writer(allocator).print( + "{{\"success\":false,\"error\":\"invoke_failed\",\"recipe\":\"{s}\",\"detail\":\"{s}\"}}", + .{ recipe, @errorName(e) }, + ); + return 500; + }; + defer inv.deinit(allocator); + + const cleaned_stderr = try stripKnownNoise(allocator, inv.stderr); + defer if (cleaned_stderr.ptr != inv.stderr.ptr) allocator.free(cleaned_stderr); + + var w = out.writer(allocator); + try w.writeAll("{\"success\":"); + try w.writeAll(if (inv.exit_code == 0) "true" else "false"); + try w.print(",\"exit_code\":{d},\"recipe\":", .{inv.exit_code}); + try writeJsonString(w, recipe); + try w.writeAll(",\"stdout\":"); + try writeJsonString(w, inv.stdout); + try w.writeAll(",\"stderr\":"); + try writeJsonString(w, cleaned_stderr); + try w.writeByte('}'); + return 200; +} + +fn isKnownNoiseLine(line: []const u8) bool { + if (std.mem.eql(u8, line, "warning: profiles for the non root package will be ignored, specify profiles at the workspace root:")) { + return true; + } + if (std.mem.startsWith(u8, line, "package:") and std.mem.indexOf(u8, line, "/linker/Cargo.toml") != null) { + return true; + } + if (std.mem.startsWith(u8, line, "workspace:") and std.mem.indexOf(u8, line, "/Cargo.toml") != null) { + return true; + } + return false; +} + +/// Strip repetitive low-signal Cargo workspace-profile warnings from stderr. +/// Returns the original slice when no filtering was applied. +fn stripKnownNoise(allocator: std.mem.Allocator, stderr: []const u8) ![]const u8 { + if (stderr.len == 0) return stderr; + + var out = std.ArrayList(u8).initCapacity(allocator, stderr.len) catch return stderr; + defer out.deinit(allocator); + + var changed = false; + var line_it = std.mem.splitScalar(u8, stderr, '\n'); + while (line_it.next()) |line| { + if (isKnownNoiseLine(line)) { + changed = true; + continue; + } + try out.appendSlice(allocator, line); + try out.append(allocator, '\n'); + } + + if (!changed) return stderr; + return try out.toOwnedSlice(allocator); +} + +// ═══════════════════════════════════════════════════════════════════════ +// Main β€” HTTP server +// ═══════════════════════════════════════════════════════════════════════ + +pub fn main() !void { + var gpa: std.heap.GeneralPurposeAllocator(.{}) = .{}; + defer _ = gpa.deinit(); + const allocator = gpa.allocator(); + + // Worktree: cwd by default, or $OO7_WORKTREE if set. + var env_map = try std.process.getEnvMap(allocator); + defer env_map.deinit(); + const worktree = env_map.get("OO7_WORKTREE") orelse "."; + + const addr = std.net.Address.initIp4(BIND_ADDR, BIND_PORT); + var listener = try addr.listen(.{ .reuse_address = true }); + defer listener.deinit(); + + std.log.info("007-mcp adapter listening on 127.0.0.1:{d} (worktree={s})", .{ BIND_PORT, worktree }); + + while (true) { + var conn = listener.accept() catch |e| { + std.log.warn("accept failed: {}", .{e}); + continue; + }; + handleConn(allocator, worktree, &conn) catch |e| { + std.log.warn("connection handler failed: {}", .{e}); + }; + conn.stream.close(); + } +} + +fn handleConn( + allocator: std.mem.Allocator, + worktree: []const u8, + conn: *std.net.Server.Connection, +) !void { + var header_buf: [MAX_HEADER_BYTES]u8 = undefined; + var total: usize = 0; + // Read until we see the end of headers (\r\n\r\n). + while (total < header_buf.len) { + const n = try conn.stream.read(header_buf[total..]); + if (n == 0) break; + total += n; + if (std.mem.indexOf(u8, header_buf[0..total], "\r\n\r\n") != null) break; + } + const buf = header_buf[0..total]; + const eoh = std.mem.indexOf(u8, buf, "\r\n\r\n") orelse return; + const head = buf[0..eoh]; + + // Parse request line: METHOD PATH HTTP/1.1 + const first_crlf = std.mem.indexOf(u8, head, "\r\n") orelse return; + const line = head[0..first_crlf]; + var it = std.mem.splitScalar(u8, line, ' '); + const method = it.next() orelse return; + const path = it.next() orelse return; + + // Extract body (already partly read; read more if Content-Length says so). + const body_start = eoh + 4; + + var resp: std.ArrayList(u8) = .empty; + defer resp.deinit(allocator); + var status: u16 = 404; + + if (std.mem.eql(u8, method, "GET") and std.mem.eql(u8, path, "/health")) { + try resp.writer(allocator).writeAll("{\"success\":true,\"health\":\"ok\"}"); + status = 200; + } else if (std.mem.eql(u8, method, "GET") and std.mem.eql(u8, path, "/status")) { + try resp.writer(allocator).print( + "{{\"success\":true,\"state\":{d},\"peer_id\":\"{s}\"}}", + .{ @intFromEnum(ffi.state()), ffi.peerId() }, + ); + status = 200; + } else if (std.mem.eql(u8, method, "POST") and std.mem.startsWith(u8, path, "/tools/")) { + const tool = path[7..]; + // Read full body if any. + var body_buf: [MAX_BODY_BYTES]u8 = undefined; + const body_len = buf.len - body_start; + var bbuf_len: usize = 0; + if (body_len > 0) { + @memcpy(body_buf[0..body_len], buf[body_start..]); + bbuf_len = body_len; + } + status = try dispatchTool(allocator, worktree, tool, body_buf[0..bbuf_len], &resp); + } else { + try resp.writer(allocator).writeAll("{\"success\":false,\"error\":\"not_found\"}"); + } + + try writeHttpResponse(conn.stream, status, resp.items); +} + +fn writeHttpResponse(stream: std.net.Stream, status: u16, body: []const u8) !void { + var header: [256]u8 = undefined; + const phrase = statusPhrase(status); + const hdr = try std.fmt.bufPrint( + &header, + "HTTP/1.1 {d} {s}\r\nContent-Type: application/json\r\nContent-Length: {d}\r\nConnection: close\r\n\r\n", + .{ status, phrase, body.len }, + ); + _ = try stream.writeAll(hdr); + _ = try stream.writeAll(body); +} + +fn statusPhrase(s: u16) []const u8 { + return switch (s) { + 200 => "OK", + 400 => "Bad Request", + 404 => "Not Found", + 500 => "Internal Server Error", + 503 => "Service Unavailable", + else => "OK", + }; +} + +test "extractKey pulls a simple string value" { + var buf: [ffi.MAX_ARG_STRING]u8 = undefined; + const body = "{\"file\":\"examples/hello.007\",\"args\":\"--verbose\"}"; + const v = extractKey(body, "file", &buf).?; + try std.testing.expectEqualStrings("examples/hello.007", v); +} + +test "statusPhrase maps common codes" { + try std.testing.expectEqualStrings("OK", statusPhrase(200)); + try std.testing.expectEqualStrings("Not Found", statusPhrase(404)); + try std.testing.expectEqualStrings("Internal Server Error", statusPhrase(500)); +} diff --git a/cartridges/domains/languages/007-mcp/cartridge.json b/cartridges/domains/languages/007-mcp/cartridge.json new file mode 100644 index 0000000..58a4250 --- /dev/null +++ b/cartridges/domains/languages/007-mcp/cartridge.json @@ -0,0 +1,796 @@ +{ + "$schema": "https://boj.dev/schemas/cartridge/v1.json", + "api": { + "base_url": "local://007-mcp", + "content_type": "application/json" + }, + "auth": { + "credential_source": null, + "description": "No auth for local-loopback; the Idris2 ABI proves bind is loopback-only so the port is unreachable from off-box.", + "env_var": null, + "method": "none" + }, + "bind": { + "address": "127.0.0.1", + "loopback_only": true, + "port": 1066 + }, + "coord": { + "affinity_tags": [ + "007", + "oo7", + "language-implementation", + "proof-analysis", + "canonical-proof-suite", + "coquelicot", + "idris2", + "rust" + ], + "client_kind": "claude", + "context": "007-lang", + "coord_url": "http://127.0.0.1:7745", + "default_role": "journeyman", + "graceful_degrade": true + }, + "copyright": "Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk>", + "description": "007 agent meta-language cartridge β€” exposes the full oo7 CLI surface (parse/run/trace/build/test/lint/verify/contractile verbs/canonical-proof-suite/groove/self-assess) plus on-enter and on-exit lifecycle hooks that register the session as a coord peer, load the 6a2 methodology pack (STATE, META, ECOSYSTEM, AGENTIC, NEUROSYM, PLAYBOOK), and perform drift checks on exit.", + "domain": "dezig", + "federation": "none", + "memory_autolift": { + "base_tags": [ + "007", + "oo7", + "canonical-proof-suite", + "coquelicot", + "m3", + "dogfooding" + ], + "enabled": true, + "max_hits": 8, + "tag_map_path": "schemas/memory-tag-map.a2ml" + }, + "name": "007-mcp", + "protocols": [ + "MCP", + "Agentic" + ], + "spdx": "MPL-2.0", + "tier": "Ayo", + "tools": [ + { + "description": "Lifecycle hook β€” invoke when entering the 007-lang context. Registers this session as a coord peer (graceful degrade if local-coord-mcp is down), loads the 6a2 methodology pack (STATE, META, ECOSYSTEM, AGENTIC, NEUROSYM, PLAYBOOK), runs the memory auto-lift using repo-derived tags, and returns a structured A2ML payload with peer_id, session_token, methodology digests, and memory hits. Idempotent: calling twice returns the same peer_id/token.", + "inputSchema": { + "properties": { + "session_hint": { + "description": "Optional free-form label for the session (e.g. 'afternoon m3-dispatch work')", + "type": "string" + } + }, + "required": [], + "type": "object" + }, + "name": "oo7_on_enter" + }, + { + "description": "Lifecycle hook β€” invoke before leaving the 007-lang context. Updates STATE.a2ml's last-session fields, runs a drift check (open claims? uncommitted contractile edits? stale 6a2 files?), releases any coord task claims held by this peer, and deregisters from local-coord-mcp. Returns A2ML summary with drift findings. Safe to call even if on_enter was never invoked.", + "inputSchema": { + "properties": { + "reason": { + "description": "Optional reason (e.g. 'context switch', 'session close', 'SAFE TO CLOSE')", + "type": "string" + } + }, + "required": [], + "type": "object" + }, + "name": "oo7_on_exit" + }, + { + "description": "Parse a .007 source file and return the AST as JSON. Wraps `just parse <file>`.", + "inputSchema": { + "properties": { + "file": { + "description": "Path to the .007 source file (relative to repo root)", + "type": "string" + } + }, + "required": [ + "file" + ], + "type": "object" + }, + "name": "oo7_parse" + }, + { + "description": "Run a .007 program and return stdout/stderr + exit code. Wraps `just run <file> <args>`.", + "inputSchema": { + "properties": { + "args": { + "description": "Optional extra arguments forwarded to the recipe", + "type": "string" + }, + "file": { + "description": "Path to the .007 source file", + "type": "string" + } + }, + "required": [ + "file" + ], + "type": "object" + }, + "name": "oo7_run" + }, + { + "description": "Run a .007 program and return its decision trace (branch strategy, agent moves, exchanges). Wraps `just trace <file>`.", + "inputSchema": { + "properties": { + "file": { + "description": "Path to the .007 source file", + "type": "string" + } + }, + "required": [ + "file" + ], + "type": "object" + }, + "name": "oo7_trace" + }, + { + "description": "Run the built-in 007 demo (produces a decision trace). Wraps `just demo`.", + "inputSchema": { + "properties": {}, + "required": [], + "type": "object" + }, + "name": "oo7_demo" + }, + { + "description": "Run every program in the examples/ directory. Wraps `just run-examples`.", + "inputSchema": { + "properties": {}, + "required": [], + "type": "object" + }, + "name": "oo7_run_examples" + }, + { + "description": "Build the entire workspace in debug mode. Wraps `just build <args>`.", + "inputSchema": { + "properties": { + "args": { + "description": "Optional extra arguments forwarded to the recipe", + "type": "string" + } + }, + "required": [], + "type": "object" + }, + "name": "oo7_build" + }, + { + "description": "Build only the CLI binary (debug). Wraps `just build-cli`.", + "inputSchema": { + "properties": {}, + "required": [], + "type": "object" + }, + "name": "oo7_build_cli" + }, + { + "description": "Build only the CLI binary (release). Wraps `just build-cli-release`.", + "inputSchema": { + "properties": {}, + "required": [], + "type": "object" + }, + "name": "oo7_build_cli_release" + }, + { + "description": "Build and watch for changes (requires cargo-watch). Wraps `just build-watch`. Long-running; the adapter spawns + captures initial output then returns a handle.", + "inputSchema": { + "properties": {}, + "required": [], + "type": "object" + }, + "name": "oo7_build_watch" + }, + { + "description": "Build in release mode with optimizations. Wraps `just release <args>`.", + "inputSchema": { + "properties": { + "args": { + "description": "Optional extra arguments forwarded to the recipe", + "type": "string" + } + }, + "required": [], + "type": "object" + }, + "name": "oo7_release" + }, + { + "description": "Remove all build artifacts. Reversible via a subsequent build. Wraps `just clean`.", + "inputSchema": { + "properties": {}, + "required": [], + "type": "object" + }, + "name": "oo7_clean" + }, + { + "description": "Deep clean, including any generated files. Reversible via a subsequent build. Wraps `just clean-all`.", + "inputSchema": { + "properties": {}, + "required": [], + "type": "object" + }, + "name": "oo7_clean_all" + }, + { + "description": "Run all tests across the workspace. Wraps `just test <args>`.", + "inputSchema": { + "properties": { + "args": { + "description": "Optional extra arguments forwarded to the recipe", + "type": "string" + } + }, + "required": [], + "type": "object" + }, + "name": "oo7_test" + }, + { + "description": "Run only parser tests (grammar and AST). Wraps `just test-parser`.", + "inputSchema": { + "properties": {}, + "required": [], + "type": "object" + }, + "name": "oo7_test_parser" + }, + { + "description": "Run only evaluator tests. Wraps `just test-eval`.", + "inputSchema": { + "properties": {}, + "required": [], + "type": "object" + }, + "name": "oo7_test_eval" + }, + { + "description": "Run only trace tests (decision trace output). Wraps `just test-trace`.", + "inputSchema": { + "properties": {}, + "required": [], + "type": "object" + }, + "name": "oo7_test_trace" + }, + { + "description": "Run aspect-focused integration tests. Wraps `just test-aspect`.", + "inputSchema": { + "properties": {}, + "required": [], + "type": "object" + }, + "name": "oo7_test_aspect" + }, + { + "description": "Run tests matching a name pattern. Wraps `just test-filter <pattern>`.", + "inputSchema": { + "properties": { + "pattern": { + "description": "Test name substring pattern", + "type": "string" + } + }, + "required": [ + "pattern" + ], + "type": "object" + }, + "name": "oo7_test_filter" + }, + { + "description": "Run tests with full output (no capture). Wraps `just test-verbose`.", + "inputSchema": { + "properties": {}, + "required": [], + "type": "object" + }, + "name": "oo7_test_verbose" + }, + { + "description": "Pre-commit quality gate: format check + lint + test. Wraps `just check`.", + "inputSchema": { + "properties": {}, + "required": [], + "type": "object" + }, + "name": "oo7_check" + }, + { + "description": "Full CI simulation: build + test + lint + verify + audit. Wraps `just ci`.", + "inputSchema": { + "properties": {}, + "required": [], + "type": "object" + }, + "name": "oo7_ci" + }, + { + "description": "Full pre-release check: all quality gates + verification + spec sync. Wraps `just preflight`.", + "inputSchema": { + "properties": {}, + "required": [], + "type": "object" + }, + "name": "oo7_preflight" + }, + { + "description": "Format all Rust source files. Wraps `just fmt`.", + "inputSchema": { + "properties": {}, + "required": [], + "type": "object" + }, + "name": "oo7_fmt" + }, + { + "description": "Check formatting without modifying files. Wraps `just fmt-check`.", + "inputSchema": { + "properties": {}, + "required": [], + "type": "object" + }, + "name": "oo7_fmt_check" + }, + { + "description": "Run clippy lints with warnings as errors. Wraps `just lint`.", + "inputSchema": { + "properties": {}, + "required": [], + "type": "object" + }, + "name": "oo7_lint" + }, + { + "description": "Audit dependencies for known vulnerabilities. Wraps `just audit`.", + "inputSchema": { + "properties": {}, + "required": [], + "type": "object" + }, + "name": "oo7_audit" + }, + { + "description": "Check dependency licenses and bans. Wraps `just deny`.", + "inputSchema": { + "properties": {}, + "required": [], + "type": "object" + }, + "name": "oo7_deny" + }, + { + "description": "List outdated dependencies. Wraps `just outdated`.", + "inputSchema": { + "properties": {}, + "required": [], + "type": "object" + }, + "name": "oo7_outdated" + }, + { + "description": "Run panic-attacker pre-commit scan (RSR mandatory). Wraps `just assail`.", + "inputSchema": { + "properties": {}, + "required": [], + "type": "object" + }, + "name": "oo7_assail" + }, + { + "description": "Check all required toolchain dependencies and report health. Wraps `just doctor`.", + "inputSchema": { + "properties": {}, + "required": [], + "type": "object" + }, + "name": "oo7_doctor" + }, + { + "description": "Attempt to automatically install missing tools. Wraps `just heal`.", + "inputSchema": { + "properties": {}, + "required": [], + "type": "object" + }, + "name": "oo7_heal" + }, + { + "description": "Run all contractile checks (must + trust + intend + adjust + bust + dust). Wraps `just contractile-check`.", + "inputSchema": { + "properties": {}, + "required": [], + "type": "object" + }, + "name": "oo7_contractile_check" + }, + { + "description": "Run all must checks (hard requirements that block release). Wraps `just must-check`.", + "inputSchema": { + "properties": {}, + "required": [], + "type": "object" + }, + "name": "oo7_must_check" + }, + { + "description": "Run local must-contract checks only. Wraps `just must-check-local`.", + "inputSchema": { + "properties": {}, + "required": [], + "type": "object" + }, + "name": "oo7_must_check_local" + }, + { + "description": "Assert LICENSE file exists. Wraps `just must-license-present`.", + "inputSchema": { + "properties": {}, + "required": [], + "type": "object" + }, + "name": "oo7_must_license_present" + }, + { + "description": "Assert no Dockerfiles or Makefiles. Wraps `just must-no-banned-files`.", + "inputSchema": { + "properties": {}, + "required": [], + "type": "object" + }, + "name": "oo7_must_no_banned_files" + }, + { + "description": "Assert README exists. Wraps `just must-readme-present`.", + "inputSchema": { + "properties": {}, + "required": [], + "type": "object" + }, + "name": "oo7_must_readme_present" + }, + { + "description": "Assert source files have SPDX license headers. Wraps `just must-spdx-headers`.", + "inputSchema": { + "properties": {}, + "required": [], + "type": "object" + }, + "name": "oo7_must_spdx_headers" + }, + { + "description": "Run all trust verifications. Wraps `just trust-verify`.", + "inputSchema": { + "properties": {}, + "required": [], + "type": "object" + }, + "name": "oo7_trust_verify" + }, + { + "description": "Verify trust-contracts (delegated quality expectations). Wraps `just trust-verify-local`.", + "inputSchema": { + "properties": {}, + "required": [], + "type": "object" + }, + "name": "oo7_trust_verify_local" + }, + { + "description": "Assert no .env or credential files in repo. Wraps `just trust-no-secrets-committed`.", + "inputSchema": { + "properties": {}, + "required": [], + "type": "object" + }, + "name": "oo7_trust_no_secrets_committed" + }, + { + "description": "Assert Containerfile base images use pinned digests. Wraps `just trust-container-images-pinned`.", + "inputSchema": { + "properties": {}, + "required": [], + "type": "object" + }, + "name": "oo7_trust_container_images_pinned" + }, + { + "description": "Assert LICENSE contains the expected SPDX identifier. Wraps `just trust-license-content`.", + "inputSchema": { + "properties": {}, + "required": [], + "type": "object" + }, + "name": "oo7_trust_license_content" + }, + { + "description": "Display declared future intents. Wraps `just intend-list`.", + "inputSchema": { + "properties": {}, + "required": [], + "type": "object" + }, + "name": "oo7_intend_list" + }, + { + "description": "List all intend-contracts (planned but not yet implemented). Wraps `just intend-list-local`.", + "inputSchema": { + "properties": {}, + "required": [], + "type": "object" + }, + "name": "oo7_intend_list_local" + }, + { + "description": "List available dust recovery actions. Wraps `just dust-status`.", + "inputSchema": { + "properties": {}, + "required": [], + "type": "object" + }, + "name": "oo7_dust_status" + }, + { + "description": "Show dust-contract status (deprecated or decaying items). Wraps `just dust-status-local`.", + "inputSchema": { + "properties": {}, + "required": [], + "type": "object" + }, + "name": "oo7_dust_status_local" + }, + { + "description": "Revert all source changes to last commit. DESTRUCTIVE β€” the adapter rejects this tool unless the caller holds a Tier-3 master approval. Wraps `just dust-source-rollback`.", + "inputSchema": { + "properties": {}, + "required": [], + "type": "object" + }, + "name": "oo7_dust_source_rollback" + }, + { + "description": "Verify OpenSSF Best Practices prerequisites β€” fails if required files are missing. Wraps `just verify`.", + "inputSchema": { + "properties": {}, + "required": [], + "type": "object" + }, + "name": "oo7_verify" + }, + { + "description": "Verify the Harvard architecture invariant β€” data expressions must not contain control flow. Wraps `just verify-harvard`.", + "inputSchema": { + "properties": {}, + "required": [], + "type": "object" + }, + "name": "oo7_verify_harvard" + }, + { + "description": "Check for unreplaced template placeholders. Wraps `just verify-template`.", + "inputSchema": { + "properties": {}, + "required": [], + "type": "object" + }, + "name": "oo7_verify_template" + }, + { + "description": "Validate Pest grammar consistency (unreachable/undefined rules). Wraps `just grammar-check`.", + "inputSchema": { + "properties": {}, + "required": [], + "type": "object" + }, + "name": "oo7_grammar_check" + }, + { + "description": "Compare EBNF and Pest rule counts to identify specification drift. Wraps `just spec-check`.", + "inputSchema": { + "properties": {}, + "required": [], + "type": "object" + }, + "name": "oo7_spec_check" + }, + { + "description": "Aggregate canonical-proof-suite report to audits/canonical-proof-suite/REPORT.a2ml. Wraps `just canonical-proof-suite`.", + "inputSchema": { + "properties": {}, + "required": [], + "type": "object" + }, + "name": "oo7_canonical_proof_suite" + }, + { + "description": "Run the M3-v0 differential (v0 Rust oracle vs production). Floor: 17/17. Wraps `just v0-differential`.", + "inputSchema": { + "properties": {}, + "required": [], + "type": "object" + }, + "name": "oo7_v0_differential" + }, + { + "description": "Run the v1 differential corpus check (v0-vs-production agreement). Wraps `just v1-differential`.", + "inputSchema": { + "properties": {}, + "required": [], + "type": "object" + }, + "name": "oo7_v1_differential" + }, + { + "description": "Run the full v1 differential (nightly-grade). Wraps `just v1-differential-full`.", + "inputSchema": { + "properties": {}, + "required": [], + "type": "object" + }, + "name": "oo7_v1_differential_full" + }, + { + "description": "Start the 007 Groove Daemon (control plane). Long-running; the adapter spawns + returns a handle. Wraps `just groove-daemon`.", + "inputSchema": { + "properties": {}, + "required": [], + "type": "object" + }, + "name": "oo7_groove_daemon" + }, + { + "description": "Configure the Groove protocol manifest and build the control plane. Wraps `just groove-setup`.", + "inputSchema": { + "properties": {}, + "required": [], + "type": "object" + }, + "name": "oo7_groove_setup" + }, + { + "description": "Build the container image with Podman and Chainguard base. Wraps `just container-build <args>`.", + "inputSchema": { + "properties": { + "args": { + "description": "Optional extra arguments forwarded to the recipe", + "type": "string" + } + }, + "required": [], + "type": "object" + }, + "name": "oo7_container_build" + }, + { + "description": "Run the container image interactively. Wraps `just container-run <args>`.", + "inputSchema": { + "properties": { + "args": { + "description": "Optional extra arguments forwarded to the recipe", + "type": "string" + } + }, + "required": [], + "type": "object" + }, + "name": "oo7_container_run" + }, + { + "description": "Verify the container image builds and runs a basic health check. Wraps `just container-verify`.", + "inputSchema": { + "properties": {}, + "required": [], + "type": "object" + }, + "name": "oo7_container_verify" + }, + { + "description": "Generate Rust API documentation. Wraps `just docs <args>`.", + "inputSchema": { + "properties": { + "args": { + "description": "Optional extra arguments forwarded to the recipe", + "type": "string" + } + }, + "required": [], + "type": "object" + }, + "name": "oo7_docs" + }, + { + "description": "Generate the Justfile cookbook documentation in AsciiDoc. Wraps `just cookbook`.", + "inputSchema": { + "properties": {}, + "required": [], + "type": "object" + }, + "name": "oo7_cookbook" + }, + { + "description": "Show this project's metadata and build status. Wraps `just info`.", + "inputSchema": { + "properties": {}, + "required": [], + "type": "object" + }, + "name": "oo7_info" + }, + { + "description": "Guided tour of the project structure and key concepts. Wraps `just tour`.", + "inputSchema": { + "properties": {}, + "required": [], + "type": "object" + }, + "name": "oo7_tour" + }, + { + "description": "Show help for common workflows. Wraps `just help-me`.", + "inputSchema": { + "properties": {}, + "required": [], + "type": "object" + }, + "name": "oo7_help_me" + }, + { + "description": "Show detailed help for a specific recipe, or list all if no recipe given. Wraps `just help <recipe>`.", + "inputSchema": { + "properties": { + "recipe": { + "description": "Recipe name (empty string for the full list)", + "type": "string" + } + }, + "required": [], + "type": "object" + }, + "name": "oo7_help" + }, + { + "description": "Analyse this project and advise what is present, missing, or needs attention. Wraps `just self-assess`.", + "inputSchema": { + "properties": {}, + "required": [], + "type": "object" + }, + "name": "oo7_self_assess" + }, + { + "description": "Print the CRG badge (looks for '**Current Grade:** X' in READINESS.md). Wraps `just crg-badge`.", + "inputSchema": { + "properties": {}, + "required": [], + "type": "object" + }, + "name": "oo7_crg_badge" + }, + { + "description": "Print the current CRG grade. Wraps `just crg-grade`.", + "inputSchema": { + "properties": {}, + "required": [], + "type": "object" + }, + "name": "oo7_crg_grade" + } + ], + "version": "0.1.0" +} diff --git a/cartridges/domains/languages/007-mcp/cartridge.ncl b/cartridges/domains/languages/007-mcp/cartridge.ncl new file mode 100644 index 0000000..7f639b0 --- /dev/null +++ b/cartridges/domains/languages/007-mcp/cartridge.ncl @@ -0,0 +1,500 @@ +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +# +# 007-mcp / cartridge.ncl +# +# Nickel source-of-truth manifest for the 007 language cartridge. +# Export JSON for the MCP bridge: +# nickel export --format json --output cartridge.json cartridge.ncl +# +# Cartridge source lives IN this repo (007-lang) per design-log DD-24. +# `just cartridge-install` builds it and deploys to boj-server/cartridges/007-mcp/. +# +# Exposed surface: the FULL oo7 CLI (every Justfile recipe) + two lifecycle +# hooks (on-enter, on-exit) that drive the coord-peer handshake and the +# 6a2 methodology pack load on context entry. +# +# Loopback bind: 127.0.0.1:1066. The Idris2 ABI (abi/Oo7Mcp/SafeCli.idr) +# proves bind-address is loopback-only at compile time via the IsLoopback +# sum type β€” the only constructors are Ipv4Loopback and Ipv6Loopback, so +# binding to any other address is type-impossible. + +let mk_tool = fun n d input_props req => { + name = n, + description = d, + inputSchema = { + type = "object", + properties = input_props, + required = req, + }, +} in + +let str_prop = fun desc => { type = "string", description = desc } in +let bool_prop = fun desc => { type = "boolean", description = desc } in +let int_prop = fun desc => { type = "integer", description = desc } in + +# Optional args slot β€” recipes that accept `*args` get this. +let args_prop = str_prop "Optional extra arguments forwarded to the recipe" in + +{ + "$schema" = "https://boj.dev/schemas/cartridge/v1.json", + spdx = "MPL-2.0", + copyright = "Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk>", + name = "007-mcp", + version = "0.1.0", + description = "007 agent meta-language cartridge β€” exposes the full oo7 CLI surface (parse/run/trace/build/test/lint/verify/contractile verbs/canonical-proof-suite/groove/self-assess) plus on-enter and on-exit lifecycle hooks that register the session as a coord peer, load the 6a2 methodology pack (STATE, META, ECOSYSTEM, AGENTIC, NEUROSYM, PLAYBOOK), and perform drift checks on exit.", + domain = "dezig", + tier = "Ayo", + protocols = ["MCP", "Agentic"], + federation = "none", + bind = { + address = "127.0.0.1", + port = 1066, + loopback_only = true, + }, + auth = { + method = "none", + env_var | optional = null, + credential_source | optional = null, + description = "No auth for local-loopback; the Idris2 ABI proves bind is loopback-only so the port is unreachable from off-box.", + }, + api = { + base_url = "local://007-mcp", + content_type = "application/json", + }, + + # Coord integration β€” the cartridge acts as a *client* to local-coord-mcp + # during its on-enter hook. Used only for peer registration + status. + coord = { + client_kind = "claude", + context = "007-lang", + default_role = "journeyman", # per task #32 (role rename 2026-04-20): master/journeyman/apprentice + affinity_tags = [ + "007", + "oo7", + "language-implementation", + "proof-analysis", + "canonical-proof-suite", + "coquelicot", + "idris2", + "rust", + ], + graceful_degrade = true, # If coord server is down on enter, log a warning and continue + coord_url = "http://127.0.0.1:7745", # loopback-only; TLS not needed or used on localhost + }, + + # Memory auto-lift (task #12) β€” static-mapping fallback per DD-26 option (b). + # When VeriSimDB FFI lands (DD-31 Task #7b), swap the adapter's lookup + # function to a VeriSimDB query; the on-enter contract and the tag-map + # schema stay stable. + memory_autolift = { + enabled = true, + tag_map_path = "schemas/memory-tag-map.a2ml", + # Repo-derived tags that trigger lookups on on-enter. + base_tags = ["007", "oo7", "canonical-proof-suite", "coquelicot", "m3", "dogfooding"], + # Max memory files returned per enter β€” prevents flooding the context. + max_hits = 8, + }, + + tools = [ + # ─── Lifecycle hooks ────────────────────────────────────────────── + mk_tool + "oo7_on_enter" + "Lifecycle hook β€” invoke when entering the 007-lang context. Registers this session as a coord peer (graceful degrade if local-coord-mcp is down), loads the 6a2 methodology pack (STATE, META, ECOSYSTEM, AGENTIC, NEUROSYM, PLAYBOOK), runs the memory auto-lift using repo-derived tags, and returns a structured A2ML payload with peer_id, session_token, methodology digests, and memory hits. Idempotent: calling twice returns the same peer_id/token." + { session_hint = str_prop "Optional free-form label for the session (e.g. 'afternoon m3-dispatch work')" } + [], + mk_tool + "oo7_on_exit" + "Lifecycle hook β€” invoke before leaving the 007-lang context. Updates STATE.a2ml's last-session fields, runs a drift check (open claims? uncommitted contractile edits? stale 6a2 files?), releases any coord task claims held by this peer, and deregisters from local-coord-mcp. Returns A2ML summary with drift findings. Safe to call even if on_enter was never invoked." + { reason = str_prop "Optional reason (e.g. 'context switch', 'session close', 'SAFE TO CLOSE')" } + [], + + # ─── Core language runtime ──────────────────────────────────────── + mk_tool + "oo7_parse" + "Parse a .007 source file and return the AST as JSON. Wraps `just parse <file>`." + { file = str_prop "Path to the .007 source file (relative to repo root)" } + ["file"], + mk_tool + "oo7_run" + "Run a .007 program and return stdout/stderr + exit code. Wraps `just run <file> <args>`." + { + file = str_prop "Path to the .007 source file", + args = args_prop, + } + ["file"], + mk_tool + "oo7_trace" + "Run a .007 program and return its decision trace (branch strategy, agent moves, exchanges). Wraps `just trace <file>`." + { file = str_prop "Path to the .007 source file" } + ["file"], + mk_tool + "oo7_demo" + "Run the built-in 007 demo (produces a decision trace). Wraps `just demo`." + {} + [], + mk_tool + "oo7_run_examples" + "Run every program in the examples/ directory. Wraps `just run-examples`." + {} + [], + + # ─── Build ──────────────────────────────────────────────────────── + mk_tool + "oo7_build" + "Build the entire workspace in debug mode. Wraps `just build <args>`." + { args = args_prop } + [], + mk_tool + "oo7_build_cli" + "Build only the CLI binary (debug). Wraps `just build-cli`." + {} + [], + mk_tool + "oo7_build_cli_release" + "Build only the CLI binary (release). Wraps `just build-cli-release`." + {} + [], + mk_tool + "oo7_build_watch" + "Build and watch for changes (requires cargo-watch). Wraps `just build-watch`. Long-running; the adapter spawns + captures initial output then returns a handle." + {} + [], + mk_tool + "oo7_release" + "Build in release mode with optimizations. Wraps `just release <args>`." + { args = args_prop } + [], + mk_tool + "oo7_clean" + "Remove all build artifacts. Reversible via a subsequent build. Wraps `just clean`." + {} + [], + mk_tool + "oo7_clean_all" + "Deep clean, including any generated files. Reversible via a subsequent build. Wraps `just clean-all`." + {} + [], + + # ─── Tests ──────────────────────────────────────────────────────── + mk_tool + "oo7_test" + "Run all tests across the workspace. Wraps `just test <args>`." + { args = args_prop } + [], + mk_tool + "oo7_test_parser" + "Run only parser tests (grammar and AST). Wraps `just test-parser`." + {} + [], + mk_tool + "oo7_test_eval" + "Run only evaluator tests. Wraps `just test-eval`." + {} + [], + mk_tool + "oo7_test_trace" + "Run only trace tests (decision trace output). Wraps `just test-trace`." + {} + [], + mk_tool + "oo7_test_aspect" + "Run aspect-focused integration tests. Wraps `just test-aspect`." + {} + [], + mk_tool + "oo7_test_filter" + "Run tests matching a name pattern. Wraps `just test-filter <pattern>`." + { pattern = str_prop "Test name substring pattern" } + ["pattern"], + mk_tool + "oo7_test_verbose" + "Run tests with full output (no capture). Wraps `just test-verbose`." + {} + [], + + # ─── Quality gates ──────────────────────────────────────────────── + mk_tool + "oo7_check" + "Pre-commit quality gate: format check + lint + test. Wraps `just check`." + {} + [], + mk_tool + "oo7_ci" + "Full CI simulation: build + test + lint + verify + audit. Wraps `just ci`." + {} + [], + mk_tool + "oo7_preflight" + "Full pre-release check: all quality gates + verification + spec sync. Wraps `just preflight`." + {} + [], + mk_tool + "oo7_fmt" + "Format all Rust source files. Wraps `just fmt`." + {} + [], + mk_tool + "oo7_fmt_check" + "Check formatting without modifying files. Wraps `just fmt-check`." + {} + [], + mk_tool + "oo7_lint" + "Run clippy lints with warnings as errors. Wraps `just lint`." + {} + [], + + # ─── Dependency audits ──────────────────────────────────────────── + mk_tool + "oo7_audit" + "Audit dependencies for known vulnerabilities. Wraps `just audit`." + {} + [], + mk_tool + "oo7_deny" + "Check dependency licenses and bans. Wraps `just deny`." + {} + [], + mk_tool + "oo7_outdated" + "List outdated dependencies. Wraps `just outdated`." + {} + [], + + # ─── Pre-commit / security scan ─────────────────────────────────── + mk_tool + "oo7_assail" + "Run panic-attacker pre-commit scan (RSR mandatory). Wraps `just assail`." + {} + [], + + # ─── Toolchain / healing ────────────────────────────────────────── + mk_tool + "oo7_doctor" + "Check all required toolchain dependencies and report health. Wraps `just doctor`." + {} + [], + mk_tool + "oo7_heal" + "Attempt to automatically install missing tools. Wraps `just heal`." + {} + [], + + # ─── Contractile suite ──────────────────────────────────────────── + mk_tool + "oo7_contractile_check" + "Run all contractile checks (must + trust + intend + adjust + bust + dust). Wraps `just contractile-check`." + {} + [], + mk_tool + "oo7_must_check" + "Run all must checks (hard requirements that block release). Wraps `just must-check`." + {} + [], + mk_tool + "oo7_must_check_local" + "Run local must-contract checks only. Wraps `just must-check-local`." + {} + [], + mk_tool + "oo7_must_license_present" + "Assert LICENSE file exists. Wraps `just must-license-present`." + {} + [], + mk_tool + "oo7_must_no_banned_files" + "Assert no Dockerfiles or Makefiles. Wraps `just must-no-banned-files`." + {} + [], + mk_tool + "oo7_must_readme_present" + "Assert README exists. Wraps `just must-readme-present`." + {} + [], + mk_tool + "oo7_must_spdx_headers" + "Assert source files have SPDX license headers. Wraps `just must-spdx-headers`." + {} + [], + mk_tool + "oo7_trust_verify" + "Run all trust verifications. Wraps `just trust-verify`." + {} + [], + mk_tool + "oo7_trust_verify_local" + "Verify trust-contracts (delegated quality expectations). Wraps `just trust-verify-local`." + {} + [], + mk_tool + "oo7_trust_no_secrets_committed" + "Assert no .env or credential files in repo. Wraps `just trust-no-secrets-committed`." + {} + [], + mk_tool + "oo7_trust_container_images_pinned" + "Assert Containerfile base images use pinned digests. Wraps `just trust-container-images-pinned`." + {} + [], + mk_tool + "oo7_trust_license_content" + "Assert LICENSE contains the expected SPDX identifier. Wraps `just trust-license-content`." + {} + [], + mk_tool + "oo7_intend_list" + "Display declared future intents. Wraps `just intend-list`." + {} + [], + mk_tool + "oo7_intend_list_local" + "List all intend-contracts (planned but not yet implemented). Wraps `just intend-list-local`." + {} + [], + mk_tool + "oo7_dust_status" + "List available dust recovery actions. Wraps `just dust-status`." + {} + [], + mk_tool + "oo7_dust_status_local" + "Show dust-contract status (deprecated or decaying items). Wraps `just dust-status-local`." + {} + [], + mk_tool + "oo7_dust_source_rollback" + "Revert all source changes to last commit. DESTRUCTIVE β€” the adapter rejects this tool unless the caller holds a Tier-3 master approval. Wraps `just dust-source-rollback`." + {} + [], + + # ─── Verification ───────────────────────────────────────────────── + mk_tool + "oo7_verify" + "Verify OpenSSF Best Practices prerequisites β€” fails if required files are missing. Wraps `just verify`." + {} + [], + mk_tool + "oo7_verify_harvard" + "Verify the Harvard architecture invariant β€” data expressions must not contain control flow. Wraps `just verify-harvard`." + {} + [], + mk_tool + "oo7_verify_template" + "Check for unreplaced template placeholders. Wraps `just verify-template`." + {} + [], + + # ─── Grammar / spec ─────────────────────────────────────────────── + mk_tool + "oo7_grammar_check" + "Validate Pest grammar consistency (unreachable/undefined rules). Wraps `just grammar-check`." + {} + [], + mk_tool + "oo7_spec_check" + "Compare EBNF and Pest rule counts to identify specification drift. Wraps `just spec-check`." + {} + [], + + # ─── Canonical proof suite + differentials ──────────────────────── + mk_tool + "oo7_canonical_proof_suite" + "Aggregate canonical-proof-suite report to audits/canonical-proof-suite/REPORT.a2ml. Wraps `just canonical-proof-suite`." + {} + [], + mk_tool + "oo7_v0_differential" + "Run the M3-v0 differential (v0 Rust oracle vs production). Floor: 17/17. Wraps `just v0-differential`." + {} + [], + mk_tool + "oo7_v1_differential" + "Run the v1 differential corpus check (v0-vs-production agreement). Wraps `just v1-differential`." + {} + [], + mk_tool + "oo7_v1_differential_full" + "Run the full v1 differential (nightly-grade). Wraps `just v1-differential-full`." + {} + [], + + # ─── Groove / control plane ─────────────────────────────────────── + mk_tool + "oo7_groove_daemon" + "Start the 007 Groove Daemon (control plane). Long-running; the adapter spawns + returns a handle. Wraps `just groove-daemon`." + {} + [], + mk_tool + "oo7_groove_setup" + "Configure the Groove protocol manifest and build the control plane. Wraps `just groove-setup`." + {} + [], + + # ─── Containers ─────────────────────────────────────────────────── + mk_tool + "oo7_container_build" + "Build the container image with Podman and Chainguard base. Wraps `just container-build <args>`." + { args = args_prop } + [], + mk_tool + "oo7_container_run" + "Run the container image interactively. Wraps `just container-run <args>`." + { args = args_prop } + [], + mk_tool + "oo7_container_verify" + "Verify the container image builds and runs a basic health check. Wraps `just container-verify`." + {} + [], + + # ─── Docs / help / meta ─────────────────────────────────────────── + mk_tool + "oo7_docs" + "Generate Rust API documentation. Wraps `just docs <args>`." + { args = args_prop } + [], + mk_tool + "oo7_cookbook" + "Generate the Justfile cookbook documentation in AsciiDoc. Wraps `just cookbook`." + {} + [], + mk_tool + "oo7_info" + "Show this project's metadata and build status. Wraps `just info`." + {} + [], + mk_tool + "oo7_tour" + "Guided tour of the project structure and key concepts. Wraps `just tour`." + {} + [], + mk_tool + "oo7_help_me" + "Show help for common workflows. Wraps `just help-me`." + {} + [], + mk_tool + "oo7_help" + "Show detailed help for a specific recipe, or list all if no recipe given. Wraps `just help <recipe>`." + { recipe = str_prop "Recipe name (empty string for the full list)" } + [], + mk_tool + "oo7_self_assess" + "Analyse this project and advise what is present, missing, or needs attention. Wraps `just self-assess`." + {} + [], + + # ─── CRG grading ────────────────────────────────────────────────── + mk_tool + "oo7_crg_badge" + "Print the CRG badge (looks for '**Current Grade:** X' in READINESS.md). Wraps `just crg-badge`." + {} + [], + mk_tool + "oo7_crg_grade" + "Print the current CRG grade. Wraps `just crg-grade`." + {} + [], + ], +} diff --git a/cartridges/domains/languages/007-mcp/docs/memory-autolift.adoc b/cartridges/domains/languages/007-mcp/docs/memory-autolift.adoc new file mode 100644 index 0000000..44fbf14 --- /dev/null +++ b/cartridges/domains/languages/007-mcp/docs/memory-autolift.adoc @@ -0,0 +1,78 @@ +// SPDX-License-Identifier: MPL-2.0 += Memory Auto-Lift β€” 007-mcp +:toc: left +:source-highlighter: rouge + +== Purpose + +When an AI session enters `007-lang`, the `oo7_on_enter` tool returns +a curated set of memory-file names that are relevant to the task. +The agent gets the right context without having to `grep` its own +memory store. + +This is retrieval-*timing* β€” not a context-window expander. The +memory files still live at +`~/.claude/projects/-var-mnt-eclipse-repos/memory/`; auto-lift just +surfaces the relevant ones up front so the agent doesn't miss them +mid-session. + +== Today's implementation (v0.1.0, 2026-04-20) + +Per design-log DD-26 option (b): **static-mapping fallback**. + +* Tag map lives at `schemas/memory-tag-map.a2ml`. +* Base tags (`007`, `oo7`, `canonical-proof-suite`, `coquelicot`, `m3`, + `dogfooding`) are declared in `cartridge.ncl :: memory_autolift. + base_tags` and mirrored in `ffi/oo7_mcp_ffi.zig :: BASE_TAGS`. +* On entry, `memoryAutolift` reads the tag map, finds every memory + file whose tag matches a base tag, dedupes, caps at `MAX_HITS = 8`, + and returns the filenames. +* Graceful degrade: if the tag map is missing, `memoryAutolift` + returns an empty slice β€” the cartridge still functions, auto-lift + just yields zero hits. + +Five Zig unit tests cover the parser (single-line + multi-line sexp, +dedupe, cap, non-matching tags). + +== Swap-in path for VeriSimDB (DD-31 Task #7b) + +When `cartridges/verisimdb-mcp/ffi/verisimdb_ffi.zig` gets a real +implementation (today it's `return 0 // Stub`), the swap is: + +. Replace `readTagMap` in `ffi/oo7_mcp_ffi.zig` with a query against a + VeriSimDB table indexed by tag. +. Leave `matchMemories` in place β€” it is the mechanical + tag-to-filenames logic and works against any byte buffer. +. Leave the return shape (`[][]const u8`) unchanged β€” the adapter and + its JSON envelope keep working unmodified. + +The `schemas/memory-tag-map.a2ml` file is retained as the **seed** for +the initial VeriSimDB index + the fallback when VeriSimDB is +unreachable. + +== Maintenance + +When the user records a new memory relevant to 007-lang work, add the +filename under the appropriate `(tag "…")` block in +`schemas/memory-tag-map.a2ml`. When a memory becomes irrelevant (e.g. +a closed incident whose pattern is no longer active), remove the +reference. + +Tag taxonomy currently in the map: + +|=== +| Tag | Purpose +| `007` / `oo7` | Baseline repo rules (dogfood, access control, active ports) +| `canonical-proof-suite` | v1.0/v1.1 closure rules + full-headline ports +| `coquelicot` | In-repo switch contents + port gotchas +| `mathcomp` | mathcomp subset available in this repo +| `m3` | M3 dispatch / oracle work +| `idris2` | Postulate rewrite pattern + banned-pattern scan +| `banned-patterns` | believe_me / assert_total / Admitted / sorry +| `dogfooding` | 007 as pilot for dogfooding methodology +| `rust` | Rust conventions for oo7-core / oo7-cli +| `contractiles` | Layout rules + known drift + accountability pledge +| `6a2` | Six-file schema + contractile-split +| `language-implementation` | Thesis-is-authoritative + SA caution +| `proof-analysis` | Proof-analysis task affinity pool +|=== diff --git a/cartridges/domains/languages/007-mcp/ffi/build.zig b/cartridges/domains/languages/007-mcp/ffi/build.zig new file mode 100644 index 0000000..a3fd99f --- /dev/null +++ b/cartridges/domains/languages/007-mcp/ffi/build.zig @@ -0,0 +1,48 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// 007-mcp / ffi / build.zig β€” Zig 0.15+ build configuration. +// +// This build is self-contained on purpose: the cartridge source lives +// in 007-lang (DD-24) and must compile without depending on the +// boj-server tree. The install hook (see 007-lang's Justfile recipe +// `cartridge-install`) deploys built artefacts into +// boj-server/cartridges/007-mcp/ and boj-server handles its own +// ADR-0006 invoke-shim wiring there. + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const ffi_mod = b.addModule("oo7_mcp_ffi", .{ + .root_source_file = b.path("oo7_mcp_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + // ── Tests ──────────────────────────────────────────────────────── + const ffi_tests = b.addTest(.{ + .root_module = ffi_mod, + }); + const run_tests = b.addRunArtifact(ffi_tests); + const test_step = b.step("test", "Run 007-mcp FFI tests"); + test_step.dependOn(&run_tests.step); + + // ── Shared library (for future MCP-host embedding) ─────────────── + const lib_mod = b.createModule(.{ + .root_source_file = b.path("oo7_mcp_ffi.zig"), + .target = target, + .optimize = optimize, + }); + const lib = b.addLibrary(.{ + .name = "oo7_mcp", + .root_module = lib_mod, + .linkage = .dynamic, + }); + b.installArtifact(lib); + + const lib_step = b.step("lib", "Build the 007-mcp FFI shared library"); + lib_step.dependOn(&lib.step); +} diff --git a/cartridges/domains/languages/007-mcp/ffi/oo7_mcp_ffi.zig b/cartridges/domains/languages/007-mcp/ffi/oo7_mcp_ffi.zig new file mode 100644 index 0000000..bf6163b --- /dev/null +++ b/cartridges/domains/languages/007-mcp/ffi/oo7_mcp_ffi.zig @@ -0,0 +1,845 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// 007-mcp / ffi / oo7_mcp_ffi.zig +// +// Zig FFI that backs the 007-mcp cartridge. Three responsibilities: +// +// 1. Spawn `just <recipe> <args>` inside the 007-lang worktree, +// capture stdout+stderr+exit code, return a structured result. +// 2. Track session lifecycle (Fresh β†’ Registered|Degraded β†’ Deregistered) +// matching the Idris2 ABI in abi/Oo7Mcp/SafeCli.idr. +// 3. Drive the coord-peer handshake on OnEnter and the deregister on +// OnExit by posting to http://127.0.0.1:7745 (local-coord-mcp). +// If coord is unreachable the cartridge flips to Degraded and +// continues to work β€” recipes still dispatch; only coord ops are +// suppressed. +// +// The adapter (adapter/oo7_adapter.zig) layers an HTTP REST surface on +// top of this FFI at 127.0.0.1:1066. Bind-address is loopback-only; the +// Idris2 IsLoopback proof is the source of truth. + +const std = @import("std"); +const builtin = @import("builtin"); + +// ═══════════════════════════════════════════════════════════════════════ +// Constants (must match abi/Oo7Mcp/SafeCli.idr) +// ═══════════════════════════════════════════════════════════════════════ + +/// CRITICAL: loopback only. The Idris2 ABI IsLoopback proof pins this. +pub const BIND_ADDR = [4]u8{ 127, 0, 0, 1 }; +pub const BIND_PORT: u16 = 1066; + +/// Coord server URL β€” cartridge acts as a client during OnEnter/OnExit. +pub const COORD_URL = "http://127.0.0.1:7745"; +pub const COORD_REGISTER_URL = COORD_URL ++ "/tools/coord_register"; + +pub const MAX_TOOL_NAME: usize = 48; +pub const MAX_ARG_STRING: usize = 4096; +pub const MAX_CAPTURE: usize = 1 << 20; // 1 MiB stdout/stderr cap +pub const DEFAULT_TIMEOUT_MS: u32 = 120_000; // 2 minutes + +// ═══════════════════════════════════════════════════════════════════════ +// Lifecycle state (matches Oo7Mcp.SafeCli.SessionState) +// ═══════════════════════════════════════════════════════════════════════ + +pub const SessionState = enum(c_int) { + fresh = 0, + registered = 1, + invoking_tool = 2, + deregistered = 3, + degraded = 4, +}; + +// ═══════════════════════════════════════════════════════════════════════ +// Tool risk tier (matches Oo7Mcp.SafeCli.ToolRisk + tierToInt encoding) +// ═══════════════════════════════════════════════════════════════════════ +// +// Tier 0 β€” pure read (status, list) +// Tier 1 β€” logged (runtime reads, tests, builds, lint) +// Tier 2 β€” light gate (container builds, docs generate, heal) +// Tier 3 β€” hard gate (rollback, destructive clean, container-run w/ privileged) +// Tier 4 β€” forbidden for supervised role (none on 007-mcp; reserved) +// +// Declared here so iseriser's `abi-verify` can check the encoding stays +// in sync with `SafeCli.ToolRisk`. Risk enforcement (categoryDefaultRisk +// / riskPromotion) is currently Idris2-only β€” the Zig dispatcher does +// not yet gate on this enum; wiring is a separate follow-up. + +pub const ToolRisk = enum(c_int) { + tier0 = 0, + tier1 = 1, + tier2 = 2, + tier3 = 3, + tier4 = 4, +}; + +/// Session-wide state. Single-instance per adapter process. +var g_state: SessionState = .fresh; +var g_state_mu: std.Thread.Mutex = .{}; + +/// Coord-peer identity captured on OnEnter. Empty string when Degraded +/// or Fresh. `token` is the session token returned by coord_register. +var g_peer_id_buf: [64]u8 = undefined; +var g_peer_id_len: usize = 0; +var g_coord_token_buf: [64]u8 = undefined; +var g_coord_token_len: usize = 0; + +pub fn peerId() []const u8 { + return g_peer_id_buf[0..g_peer_id_len]; +} + +pub fn coordToken() []const u8 { + return g_coord_token_buf[0..g_coord_token_len]; +} + +pub fn state() SessionState { + g_state_mu.lock(); + defer g_state_mu.unlock(); + return g_state; +} + +fn setState(next: SessionState) void { + g_state_mu.lock(); + defer g_state_mu.unlock(); + g_state = next; +} + +// ═══════════════════════════════════════════════════════════════════════ +// Tool-invocation result +// ═══════════════════════════════════════════════════════════════════════ + +pub const InvokeResult = struct { + exit_code: i32, + stdout: []u8, + stderr: []u8, + /// Caller owns these slices. Call `deinit(allocator)` to free them. + pub fn deinit(self: *InvokeResult, allocator: std.mem.Allocator) void { + allocator.free(self.stdout); + allocator.free(self.stderr); + self.stdout = &.{}; + self.stderr = &.{}; + } +}; + +pub const InvokeError = error{ + SpawnFailed, + OutputTooLarge, + ToolUnknown, + ArgTooLong, + BadState, + OutOfMemory, +}; + +/// Map an MCP tool name to its Justfile recipe name. +/// The mapping is intentionally verbose (no clever regex) so the +/// surface is obvious at review time and easy to extend. +pub fn recipeFor(tool: []const u8) ?[]const u8 { + // Lifecycle hooks don't map to just recipes β€” the adapter handles + // them directly via onEnter / onExit below. + if (std.mem.eql(u8, tool, "oo7_parse")) return "parse"; + if (std.mem.eql(u8, tool, "oo7_run")) return "run"; + if (std.mem.eql(u8, tool, "oo7_trace")) return "trace"; + if (std.mem.eql(u8, tool, "oo7_demo")) return "demo"; + if (std.mem.eql(u8, tool, "oo7_run_examples")) return "run-examples"; + + if (std.mem.eql(u8, tool, "oo7_build")) return "build"; + if (std.mem.eql(u8, tool, "oo7_build_cli")) return "build-cli"; + if (std.mem.eql(u8, tool, "oo7_build_cli_release")) return "build-cli-release"; + if (std.mem.eql(u8, tool, "oo7_build_watch")) return "build-watch"; + if (std.mem.eql(u8, tool, "oo7_release")) return "release"; + if (std.mem.eql(u8, tool, "oo7_clean")) return "clean"; + if (std.mem.eql(u8, tool, "oo7_clean_all")) return "clean-all"; + + if (std.mem.eql(u8, tool, "oo7_test")) return "test"; + if (std.mem.eql(u8, tool, "oo7_test_parser")) return "test-parser"; + if (std.mem.eql(u8, tool, "oo7_test_eval")) return "test-eval"; + if (std.mem.eql(u8, tool, "oo7_test_trace")) return "test-trace"; + if (std.mem.eql(u8, tool, "oo7_test_aspect")) return "test-aspect"; + if (std.mem.eql(u8, tool, "oo7_test_filter")) return "test-filter"; + if (std.mem.eql(u8, tool, "oo7_test_verbose")) return "test-verbose"; + + if (std.mem.eql(u8, tool, "oo7_check")) return "check"; + if (std.mem.eql(u8, tool, "oo7_ci")) return "ci"; + if (std.mem.eql(u8, tool, "oo7_preflight")) return "preflight"; + if (std.mem.eql(u8, tool, "oo7_fmt")) return "fmt"; + if (std.mem.eql(u8, tool, "oo7_fmt_check")) return "fmt-check"; + if (std.mem.eql(u8, tool, "oo7_lint")) return "lint"; + + if (std.mem.eql(u8, tool, "oo7_audit")) return "audit"; + if (std.mem.eql(u8, tool, "oo7_deny")) return "deny"; + if (std.mem.eql(u8, tool, "oo7_outdated")) return "outdated"; + if (std.mem.eql(u8, tool, "oo7_assail")) return "assail"; + if (std.mem.eql(u8, tool, "oo7_doctor")) return "doctor"; + if (std.mem.eql(u8, tool, "oo7_heal")) return "heal"; + + if (std.mem.eql(u8, tool, "oo7_contractile_check")) return "contractile-check"; + if (std.mem.eql(u8, tool, "oo7_must_check")) return "must-check"; + if (std.mem.eql(u8, tool, "oo7_must_check_local")) return "must-check-local"; + if (std.mem.eql(u8, tool, "oo7_must_license_present")) return "must-license-present"; + if (std.mem.eql(u8, tool, "oo7_must_no_banned_files")) return "must-no-banned-files"; + if (std.mem.eql(u8, tool, "oo7_must_readme_present")) return "must-readme-present"; + if (std.mem.eql(u8, tool, "oo7_must_spdx_headers")) return "must-spdx-headers"; + if (std.mem.eql(u8, tool, "oo7_trust_verify")) return "trust-verify"; + if (std.mem.eql(u8, tool, "oo7_trust_verify_local")) return "trust-verify-local"; + if (std.mem.eql(u8, tool, "oo7_trust_no_secrets_committed")) return "trust-no-secrets-committed"; + if (std.mem.eql(u8, tool, "oo7_trust_container_images_pinned")) return "trust-container-images-pinned"; + if (std.mem.eql(u8, tool, "oo7_trust_license_content")) return "trust-license-content"; + if (std.mem.eql(u8, tool, "oo7_intend_list")) return "intend-list"; + if (std.mem.eql(u8, tool, "oo7_intend_list_local")) return "intend-list-local"; + if (std.mem.eql(u8, tool, "oo7_dust_status")) return "dust-status"; + if (std.mem.eql(u8, tool, "oo7_dust_status_local")) return "dust-status-local"; + if (std.mem.eql(u8, tool, "oo7_dust_source_rollback")) return "dust-source-rollback"; + + if (std.mem.eql(u8, tool, "oo7_verify")) return "verify"; + if (std.mem.eql(u8, tool, "oo7_verify_harvard")) return "verify-harvard"; + if (std.mem.eql(u8, tool, "oo7_verify_template")) return "verify-template"; + + if (std.mem.eql(u8, tool, "oo7_grammar_check")) return "grammar-check"; + if (std.mem.eql(u8, tool, "oo7_spec_check")) return "spec-check"; + + if (std.mem.eql(u8, tool, "oo7_canonical_proof_suite")) return "canonical-proof-suite"; + if (std.mem.eql(u8, tool, "oo7_v0_differential")) return "v0-differential"; + if (std.mem.eql(u8, tool, "oo7_v1_differential")) return "v1-differential"; + if (std.mem.eql(u8, tool, "oo7_v1_differential_full")) return "v1-differential-full"; + + if (std.mem.eql(u8, tool, "oo7_groove_daemon")) return "groove-daemon"; + if (std.mem.eql(u8, tool, "oo7_groove_setup")) return "groove-setup"; + + if (std.mem.eql(u8, tool, "oo7_container_build")) return "container-build"; + if (std.mem.eql(u8, tool, "oo7_container_run")) return "container-run"; + if (std.mem.eql(u8, tool, "oo7_container_verify")) return "container-verify"; + + if (std.mem.eql(u8, tool, "oo7_docs")) return "docs"; + if (std.mem.eql(u8, tool, "oo7_cookbook")) return "cookbook"; + if (std.mem.eql(u8, tool, "oo7_info")) return "info"; + if (std.mem.eql(u8, tool, "oo7_tour")) return "tour"; + if (std.mem.eql(u8, tool, "oo7_help_me")) return "help-me"; + if (std.mem.eql(u8, tool, "oo7_help")) return "help"; + if (std.mem.eql(u8, tool, "oo7_self_assess")) return "self-assess"; + + if (std.mem.eql(u8, tool, "oo7_crg_badge")) return "crg-badge"; + if (std.mem.eql(u8, tool, "oo7_crg_grade")) return "crg-grade"; + + return null; +} + +// ═══════════════════════════════════════════════════════════════════════ +// `just <recipe> <args>` invocation +// ═══════════════════════════════════════════════════════════════════════ + +/// Run a Justfile recipe in the 007-lang worktree. +/// Caller owns the returned slices β€” call result.deinit(allocator). +pub fn invokeRecipe( + allocator: std.mem.Allocator, + recipe: []const u8, + argv_extra: []const []const u8, + worktree: []const u8, +) InvokeError!InvokeResult { + if (recipe.len == 0 or recipe.len > MAX_TOOL_NAME) return error.ToolUnknown; + + // argv: just <recipe> <...args> + var argv_list = std.ArrayList([]const u8).initCapacity(allocator, 2 + argv_extra.len) catch return error.OutOfMemory; + defer argv_list.deinit(allocator); + argv_list.appendAssumeCapacity("just"); + argv_list.appendAssumeCapacity(recipe); + for (argv_extra) |a| argv_list.appendAssumeCapacity(a); + + var child = std.process.Child.init(argv_list.items, allocator); + child.cwd = worktree; + child.stdin_behavior = .Ignore; + child.stdout_behavior = .Pipe; + child.stderr_behavior = .Pipe; + + child.spawn() catch return error.SpawnFailed; + + var stdout_buf: std.ArrayList(u8) = .empty; + defer stdout_buf.deinit(allocator); + var stderr_buf: std.ArrayList(u8) = .empty; + defer stderr_buf.deinit(allocator); + + child.collectOutput(allocator, &stdout_buf, &stderr_buf, MAX_CAPTURE) catch { + _ = child.kill() catch {}; + return error.OutputTooLarge; + }; + + const term = child.wait() catch return error.SpawnFailed; + const exit_code: i32 = switch (term) { + .Exited => |c| @intCast(c), + .Signal => |s| -@as(i32, @intCast(s)), + .Stopped => -1, + .Unknown => -1, + }; + + return .{ + .exit_code = exit_code, + .stdout = stdout_buf.toOwnedSlice(allocator) catch return error.OutOfMemory, + .stderr = stderr_buf.toOwnedSlice(allocator) catch return error.OutOfMemory, + }; +} + +// ═══════════════════════════════════════════════════════════════════════ +// Lifecycle β€” OnEnter / OnExit +// ═══════════════════════════════════════════════════════════════════════ + +pub const EnterResult = struct { + peer_id: []const u8, + coord_state: []const u8, // "registered" or "degraded" + methodology_files: []const []const u8, + memory_hits: []const []const u8, + pub fn deinit(self: *EnterResult, allocator: std.mem.Allocator) void { + for (self.methodology_files) |p| allocator.free(p); + allocator.free(self.methodology_files); + for (self.memory_hits) |p| allocator.free(p); + allocator.free(self.memory_hits); + } +}; + +/// OnEnter: register as coord peer (soft-fail to Degraded), load 6a2, +/// run memory auto-lift. Idempotent: re-calling from Registered or +/// Degraded is a no-op that returns the cached peer_id. Re-entering +/// after Deregistered starts a new registration attempt. +/// +/// The adapter handles the HTTP framing; this function only does the +/// state transition + the coord POST + the file-system reads. +pub fn onEnter( + allocator: std.mem.Allocator, + worktree: []const u8, + session_hint: []const u8, +) !EnterResult { + _ = session_hint; // surfaced in future; no state effect today + + const cur = state(); + if (cur == .registered) { + // Idempotent β€” return the existing identity and a re-read methodology pack. + const methodology = try readMethodologyPack(allocator, worktree); + const memory = try memoryAutolift(allocator, worktree); + return .{ + .peer_id = peerId(), + .coord_state = "registered", + .methodology_files = methodology, + .memory_hits = memory, + }; + } + if (cur == .degraded) { + // Retry registration so degraded sessions can self-heal when + // local-coord-mcp becomes reachable again. + const upgraded = coordRegister(allocator) catch false; + if (upgraded) { + setState(.registered); + } else if (g_peer_id_len == 0) { + const local_id = "claude-0000@007-lang-local"; + @memcpy(g_peer_id_buf[0..local_id.len], local_id); + g_peer_id_len = local_id.len; + g_coord_token_len = 0; + } + + const methodology = try readMethodologyPack(allocator, worktree); + const memory = try memoryAutolift(allocator, worktree); + return .{ + .peer_id = peerId(), + .coord_state = if (state() == .registered) "registered" else "degraded", + .methodology_files = methodology, + .memory_hits = memory, + }; + } + if (cur != .fresh and cur != .deregistered) return error.BadState; + + // Try coord_register. Soft-fail to Degraded on any error. + const registered = coordRegister(allocator) catch |e| blk: { + std.log.warn("007-mcp: coord_register failed ({}); continuing in Degraded mode", .{e}); + break :blk false; + }; + + if (registered) { + setState(.registered); + } else { + setState(.degraded); + // Synthesise a local-only peer_id so callers still have a handle. + const local_id = "claude-0000@007-lang-local"; + @memcpy(g_peer_id_buf[0..local_id.len], local_id); + g_peer_id_len = local_id.len; + g_coord_token_len = 0; + } + + const methodology = try readMethodologyPack(allocator, worktree); + const memory = try memoryAutolift(allocator, worktree); + + return .{ + .peer_id = peerId(), + .coord_state = if (state() == .registered) "registered" else "degraded", + .methodology_files = methodology, + .memory_hits = memory, + }; +} + +pub const ExitResult = struct { + drift_findings: []const []const u8, + coord_state: []const u8, + pub fn deinit(self: *ExitResult, allocator: std.mem.Allocator) void { + for (self.drift_findings) |p| allocator.free(p); + allocator.free(self.drift_findings); + } +}; + +/// OnExit: drift check, release claims, deregister. Safe from any state +/// (Fresh/Degraded/Registered all honoured). +pub fn onExit( + allocator: std.mem.Allocator, + worktree: []const u8, + reason: []const u8, +) !ExitResult { + _ = reason; + const cur = state(); + + const findings = try driftCheck(allocator, worktree); + + // Best-effort coord teardown; failure is non-fatal. + coordDeregister(allocator) catch |e| { + if (cur == .registered) { + std.log.warn("007-mcp: coord_deregister failed ({}); proceeding to Deregistered", .{e}); + } + }; + + setState(.deregistered); + return .{ + .drift_findings = findings, + .coord_state = "deregistered", + }; +} + +// ─── Methodology pack (6a2) ──────────────────────────────────────────── + +/// Paths relative to the 007-lang worktree root for the 6a2 pack. +/// Kept in sync with MEMORY.md rule: "6a2 = six a2ml files". +pub const METHODOLOGY_PATHS = [_][]const u8{ + ".machine_readable/STATE.a2ml", + ".machine_readable/META.a2ml", + ".machine_readable/ECOSYSTEM.a2ml", + ".machine_readable/6a2/AGENTIC.a2ml", + ".machine_readable/6a2/NEUROSYM.a2ml", + ".machine_readable/6a2/PLAYBOOK.a2ml", +}; + +fn readMethodologyPack( + allocator: std.mem.Allocator, + worktree: []const u8, +) ![][]const u8 { + var out = try allocator.alloc([]const u8, METHODOLOGY_PATHS.len); + errdefer allocator.free(out); + + for (METHODOLOGY_PATHS, 0..) |rel, i| { + // We return the relative path; the adapter returns digests rather + // than embedding the full file so a pack-load does not balloon + // the MCP response. If the file is missing, the entry is an + // explicit sentinel like "MISSING::<path>". + var path_buf: [std.fs.max_path_bytes]u8 = undefined; + const full = std.fmt.bufPrint(&path_buf, "{s}/{s}", .{ worktree, rel }) catch rel; + if (std.fs.path.isAbsolute(full)) { + std.fs.accessAbsolute(full, .{}) catch { + const missing = try std.fmt.allocPrint(allocator, "MISSING::{s}", .{rel}); + out[i] = missing; + continue; + }; + } else { + std.fs.cwd().access(full, .{}) catch { + const missing = try std.fmt.allocPrint(allocator, "MISSING::{s}", .{rel}); + out[i] = missing; + continue; + }; + } + out[i] = try allocator.dupe(u8, rel); + } + return out; +} + +// ─── Memory auto-lift ────────────────────────────────────────────────── +// +// DD-26 option (b): static-mapping fallback. Reads the tag map A2ML +// (schemas/memory-tag-map.a2ml) at runtime, matches base_tags (repo- +// derived) against the map's tag entries, collects the memory file +// names, dedupes + caps at MAX_HITS. +// +// Swap-in for VeriSimDB (DD-31 Task #7b): replace `readTagMap` with a +// VeriSimDB index query. Everything else (match / dedupe / cap) stays +// the same because the return shape is identical. + +pub const MAX_HITS: usize = 8; + +/// Repo-derived tags that drive lookups on OnEnter. Mirrors +/// cartridge.ncl :: memory_autolift.base_tags. +pub const BASE_TAGS = [_][]const u8{ + "007", + "oo7", + "canonical-proof-suite", + "coquelicot", + "m3", + "dogfooding", +}; + +/// Candidate locations for the tag-map A2ML file. +/// The first path is the source location in-repo; the second is the +/// installed location in boj-server/cartridges/007-mcp/schemas/. +pub const TAG_MAP_PATHS = [_][]const u8{ + "cartridges/007-mcp/schemas/memory-tag-map.a2ml", + "schemas/memory-tag-map.a2ml", + "/var/mnt/eclipse/repos/boj-server/cartridges/007-mcp/schemas/memory-tag-map.a2ml", +}; + +fn openMaybeAbsolute(path: []const u8) !std.fs.File { + if (std.fs.path.isAbsolute(path)) { + return try std.fs.openFileAbsolute(path, .{}); + } + return try std.fs.cwd().openFile(path, .{}); +} + +/// Read the tag map from disk (first candidate that exists wins). +/// Caller owns the returned slice. +fn readTagMap(allocator: std.mem.Allocator, worktree: []const u8) ![]u8 { + for (TAG_MAP_PATHS) |rel| { + var path_buf: [std.fs.max_path_bytes]u8 = undefined; + const full: []const u8 = if (std.fs.path.isAbsolute(rel)) + rel + else + std.fmt.bufPrint(&path_buf, "{s}/{s}", .{ worktree, rel }) catch continue; + const file = openMaybeAbsolute(full) catch continue; + defer file.close(); + const stat = try file.stat(); + const buf = try allocator.alloc(u8, stat.size); + errdefer allocator.free(buf); + _ = try file.readAll(buf); + return buf; + } + return error.FileNotFound; +} + +/// Parse the tag map and return memory filenames whose tag appears in +/// `tags`. The map format is: +/// (tag "<tag-name>" +/// (memories "<file>" "<file>" ...)) +/// We scan line-by-line: +/// * a `(tag "<name>")` line opens a block; we record whether `<name>` +/// matches any of the requested tags +/// * subsequent `"<file>"` string literals on `(memories ...)` lines +/// are collected for matching tags +/// * any other `(` at column 0 closes the block +/// +/// Deduplicates the output, preserving first-seen order; caps at MAX_HITS. +pub fn matchMemories( + allocator: std.mem.Allocator, + tag_map: []const u8, + tags: []const []const u8, +) ![][]const u8 { + var out_list = try std.ArrayList([]const u8).initCapacity(allocator, 16); + defer out_list.deinit(allocator); + + var in_matching_block: bool = false; + var in_memories: bool = false; + + var line_it = std.mem.splitScalar(u8, tag_map, '\n'); + while (line_it.next()) |raw| { + const line = std.mem.trim(u8, raw, " \t\r"); + if (line.len == 0) continue; + if (std.mem.startsWith(u8, line, ";")) continue; + + // Token: payload of the line after optional leading `(tag "name"`. + // When a `(tag "name"` is present we reset block state and update + // `in_matching_block` from the name. The remaining tail of the + // line may ALSO carry a `(memories …)` for the single-line form; + // we continue processing it in the same iteration. + var payload = line; + if (std.mem.startsWith(u8, line, "(tag ")) { + in_memories = false; + const open_q = std.mem.indexOfScalar(u8, line, '"') orelse continue; + const rest = line[open_q + 1 ..]; + const close_q = std.mem.indexOfScalar(u8, rest, '"') orelse continue; + const name = rest[0..close_q]; + in_matching_block = tagMatches(name, tags); + payload = rest[close_q + 1 ..]; + } + + // Enter memories section if a `(memories` appears anywhere in the + // payload of a matching block. + if (in_matching_block) { + if (std.mem.indexOf(u8, payload, "(memories")) |_| { + in_memories = true; + } + } + + if (!in_memories) continue; + + // Collect every double-quoted substring on the payload as a filename. + var p: usize = 0; + while (p < payload.len) : (p += 1) { + if (payload[p] != '"') continue; + const start = p + 1; + const end_rel = std.mem.indexOfScalar(u8, payload[start..], '"') orelse break; + const file = payload[start .. start + end_rel]; + p = start + end_rel; + if (file.len == 0) continue; + // Dedupe. + var exists = false; + for (out_list.items) |prev| { + if (std.mem.eql(u8, prev, file)) { + exists = true; + break; + } + } + if (!exists and out_list.items.len < MAX_HITS) { + const dup = try allocator.dupe(u8, file); + try out_list.append(allocator, dup); + } + } + // Two or more trailing ')' close the memories sexp. + if (std.mem.endsWith(u8, payload, "))")) in_memories = false; + } + + return try out_list.toOwnedSlice(allocator); +} + +fn tagMatches(name: []const u8, tags: []const []const u8) bool { + for (tags) |t| { + if (std.mem.eql(u8, name, t)) return true; + } + return false; +} + +/// OnEnter memory auto-lift entry point. Graceful-degrade: if the tag +/// map is missing, return an empty slice β€” the cartridge still works, +/// the caller just gets zero hits. +fn memoryAutolift( + allocator: std.mem.Allocator, + worktree: []const u8, +) ![][]const u8 { + const map = readTagMap(allocator, worktree) catch { + return try allocator.alloc([]const u8, 0); + }; + defer allocator.free(map); + return try matchMemories(allocator, map, &BASE_TAGS); +} + +// ─── Drift check ─────────────────────────────────────────────────────── + +fn driftCheck( + allocator: std.mem.Allocator, + worktree: []const u8, +) ![][]const u8 { + _ = worktree; + // Skeleton: the adapter runs `just contractile-check` and surfaces + // failing items as findings. For now we return an empty slice so + // OnExit is always successful; the wiring is in place for the richer + // check to replace this body without contract change. + var out = try allocator.alloc([]const u8, 0); + _ = &out; + return out; +} + +// ─── Coord client ────────────────────────────────────────────────────── + +/// POST coord_register to local-coord-mcp. +/// Returns true only when an HTTP 200 response carries +/// {"success":true,"peer_id":"...","token":"..."}. +fn coordRegister(allocator: std.mem.Allocator) !bool { + g_peer_id_len = 0; + g_coord_token_len = 0; + + var client = std.http.Client{ .allocator = allocator }; + defer client.deinit(); + + const uri = std.Uri.parse(COORD_REGISTER_URL) catch return false; + const payload = "{\"client_kind\":\"claude\",\"context\":\"007-lang\"}"; + + var headers_buf: [2]std.http.Header = .{ + .{ .name = "Content-Type", .value = "application/json" }, + .{ .name = "User-Agent", .value = "007-mcp/0.1 coord-register" }, + }; + + var aw: std.Io.Writer.Allocating = .init(allocator); + defer aw.deinit(); + + const fetch_result = client.fetch(.{ + .method = .POST, + .location = .{ .uri = uri }, + .extra_headers = &headers_buf, + .payload = payload, + .response_writer = &aw.writer, + }) catch return false; + + if (@intFromEnum(fetch_result.status) != 200) return false; + + const body = aw.writer.buffered(); + const parsed = std.json.parseFromSlice(std.json.Value, allocator, body, .{}) catch return false; + defer parsed.deinit(); + + const obj = if (parsed.value == .object) parsed.value.object else return false; + const ok_val = obj.get("success") orelse return false; + if (ok_val != .bool or !ok_val.bool) return false; + const peer_id_val = obj.get("peer_id") orelse return false; + const token_val = obj.get("token") orelse return false; + if (peer_id_val != .string or token_val != .string) return false; + + const peer_id = peer_id_val.string; + const token = token_val.string; + if (peer_id.len == 0 or peer_id.len > g_peer_id_buf.len) return false; + if (token.len == 0 or token.len > g_coord_token_buf.len) return false; + + @memcpy(g_peer_id_buf[0..peer_id.len], peer_id); + g_peer_id_len = peer_id.len; + @memcpy(g_coord_token_buf[0..token.len], token); + g_coord_token_len = token.len; + return true; +} + +/// POST coord_report_outcome to signal a clean session exit. +/// Best-effort: any HTTP or spawn failure is silently ignored β€” the +/// coord watchdog will expire the peer if this call is lost. +/// Always clears peer_id / token on return regardless of HTTP outcome. +fn coordDeregister(allocator: std.mem.Allocator) !void { + defer { + g_peer_id_len = 0; + g_coord_token_len = 0; + } + + const token = coordToken(); + if (token.len == 0) return; // nothing was registered + + // Payload: report the session as a successful outcome. + // Tag "007-mcp-session" is the session's self-reported task label. + var payload_buf: [256]u8 = undefined; + const payload = std.fmt.bufPrint( + &payload_buf, + "{{\"token\":\"{s}\"," ++ + "\"tag\":\"007-mcp-session\"," ++ + "\"outcome\":\"success\"," ++ + "\"risk_tier\":1," ++ + "\"duration_ms\":0}}", + .{token}, + ) catch return; + + const report_url = COORD_URL ++ "/tools/coord_report_outcome"; + + var client = std.http.Client{ .allocator = allocator }; + defer client.deinit(); + + const uri = std.Uri.parse(report_url) catch return; + const header_buf = [1]std.http.Header{ + .{ .name = "Content-Type", .value = "application/json" }, + }; + + // Fire-and-forget: ignore the response body; we only care that the POST + // was sent before the session tears down. + _ = client.fetch(.{ + .method = .POST, + .location = .{ .uri = uri }, + .extra_headers = &header_buf, + .payload = payload, + .response_storage = .ignore, + }) catch {}; +} + +// ═══════════════════════════════════════════════════════════════════════ +// Tests +// ═══════════════════════════════════════════════════════════════════════ + +test "recipeFor handles a sampling of tool names" { + try std.testing.expectEqualStrings("parse", recipeFor("oo7_parse").?); + try std.testing.expectEqualStrings("canonical-proof-suite", recipeFor("oo7_canonical_proof_suite").?); + try std.testing.expectEqualStrings("dust-source-rollback", recipeFor("oo7_dust_source_rollback").?); + try std.testing.expect(recipeFor("not_a_real_tool") == null); + try std.testing.expect(recipeFor("oo7_on_enter") == null); // lifecycle, handled directly +} + +test "bind address is loopback" { + try std.testing.expectEqual(@as(u8, 127), BIND_ADDR[0]); + try std.testing.expectEqual(@as(u16, 1066), BIND_PORT); +} + +test "lifecycle state round-trip" { + setState(.fresh); + try std.testing.expectEqual(SessionState.fresh, state()); + setState(.registered); + try std.testing.expectEqual(SessionState.registered, state()); + setState(.deregistered); + try std.testing.expectEqual(SessionState.deregistered, state()); + setState(.fresh); // reset for other tests +} + +test "onEnter accepts re-entry from deregistered state" { + setState(.deregistered); + var enter = try onEnter(std.testing.allocator, ".", ""); + defer enter.deinit(std.testing.allocator); + + try std.testing.expect(enter.peer_id.len > 0); + try std.testing.expect(std.mem.eql(u8, enter.coord_state, "registered") or std.mem.eql(u8, enter.coord_state, "degraded")); +} + +test "matchMemories picks memories from matching tag blocks only" { + const map = + \\;; comment line + \\(memory-tag-map + \\ (tag "007" + \\ (memories "feedback_007_dogfood.md" "feedback_007_access_control.md")) + \\ (tag "coquelicot" + \\ (memories "reference_coquelicot_and_mathcomp.md")) + \\ (tag "irrelevant" + \\ (memories "some_other.md"))) + \\ + ; + const tags = [_][]const u8{ "007", "coquelicot" }; + const hits = try matchMemories(std.testing.allocator, map, &tags); + defer { + for (hits) |h| std.testing.allocator.free(h); + std.testing.allocator.free(hits); + } + try std.testing.expectEqual(@as(usize, 3), hits.len); + try std.testing.expectEqualStrings("feedback_007_dogfood.md", hits[0]); + try std.testing.expectEqualStrings("feedback_007_access_control.md", hits[1]); + try std.testing.expectEqualStrings("reference_coquelicot_and_mathcomp.md", hits[2]); +} + +test "matchMemories deduplicates filenames" { + const map = + \\(memory-tag-map + \\ (tag "007" (memories "a.md" "b.md")) + \\ (tag "oo7" (memories "a.md" "c.md"))) + \\ + ; + const tags = [_][]const u8{ "007", "oo7" }; + const hits = try matchMemories(std.testing.allocator, map, &tags); + defer { + for (hits) |h| std.testing.allocator.free(h); + std.testing.allocator.free(hits); + } + try std.testing.expectEqual(@as(usize, 3), hits.len); +} + +test "matchMemories caps at MAX_HITS" { + const map = + \\(memory-tag-map + \\ (tag "flood" (memories "1.md" "2.md" "3.md" "4.md" "5.md" "6.md" "7.md" "8.md" "9.md" "10.md"))) + \\ + ; + const tags = [_][]const u8{"flood"}; + const hits = try matchMemories(std.testing.allocator, map, &tags); + defer { + for (hits) |h| std.testing.allocator.free(h); + std.testing.allocator.free(hits); + } + try std.testing.expectEqual(MAX_HITS, hits.len); +} + +test "matchMemories ignores non-matching tag blocks" { + const map = + \\(memory-tag-map + \\ (tag "foo" (memories "foo.md")) + \\ (tag "bar" (memories "bar.md"))) + \\ + ; + const tags = [_][]const u8{"nonexistent"}; + const hits = try matchMemories(std.testing.allocator, map, &tags); + defer { + for (hits) |h| std.testing.allocator.free(h); + std.testing.allocator.free(hits); + } + try std.testing.expectEqual(@as(usize, 0), hits.len); +} diff --git a/cartridges/domains/languages/007-mcp/schemas/memory-tag-map.a2ml b/cartridges/domains/languages/007-mcp/schemas/memory-tag-map.a2ml new file mode 100644 index 0000000..fcf4d56 --- /dev/null +++ b/cartridges/domains/languages/007-mcp/schemas/memory-tag-map.a2ml @@ -0,0 +1,141 @@ +;; SPDX-License-Identifier: MPL-2.0 +;; Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +;; +;; 007-mcp / schemas / memory-tag-map.a2ml +;; +;; Static tag β†’ memory-file map for the 007-mcp OnEnter auto-lift hook. +;; Per design-log DD-26 option (b): this is the v1 fallback while the +;; verisimdb-mcp FFI is still stubs. When VeriSimDB lands (DD-31 Task +;; #7b) this map becomes the seed for a dynamic index; the adapter's +;; lookup function is the only code that needs to change. +;; +;; Schema +;; ------ +;; (memory-tag-map +;; (version "…") +;; (tag <tag-name> +;; (memories "<file-basename>" …) +;; (reason "<one-line description of why this tag pulls these>")) +;; …) +;; +;; File basenames are relative to: +;; /home/hyper/.claude/projects/-var-mnt-eclipse-repos/memory/ +;; +;; The adapter resolves those against $HOME at OnEnter time. + +;; ── Identity (required by hyperpolymath/a2ml-validate-action) ───────── +;; TOML-shape preamble exposes name/version to the line-regex validator. +;; The Zig consumer (cartridges/007-mcp/ffi/oo7_mcp_ffi.zig matchMemories) +;; only honours `(tag …)` / `(memories …)` blocks, so non-`(` lines here +;; are skipped by `if (!in_memories) continue;` at the parse loop. +[identity] +name = "memory-tag-map" +project = "007-mcp" +version = "0.1.0" +schema_version = "1.0.0" + +(memory-tag-map + (version "0.1.0") + (generated-for "007-lang") + (last-reviewed "2026-04-20") + + ;; ─── Core 007 work ──────────────────────────────────────────────── + (tag "007" + (memories + "feedback_007_dogfood.md" + "feedback_007_access_control.md" + "project_007_full_headline_ports_2026-04-18.md") + (reason "Baseline 007-repo rules: dogfooding, access control, active ports.")) + + (tag "oo7" + (memories + "feedback_007_dogfood.md" + "project_007_full_headline_ports_2026-04-18.md") + (reason "Alias for 007 CLI work.")) + + ;; ─── Canonical Proof Suite (v1.0 / v1.1) ────────────────────────── + (tag "canonical-proof-suite" + (memories + "feedback_canonical_proof_suite_v1_v1_1_closure_standards.md" + "project_007_full_headline_ports_2026-04-18.md" + "feedback_panic_attack_proofdrift_parameter_pattern.md") + (reason "v1.0 vs v1.1 closure rules; full-headline port state; ProofDrift gotchas.")) + + ;; ─── Coquelicot / Rocq stdlib tactics ───────────────────────────── + (tag "coquelicot" + (memories + "reference_coquelicot_and_mathcomp_in_007_switch.md" + "feedback_coquelicot_proof_gotchas.md") + (reason "What ships with the in-repo switch; gotchas burned during M2/M3/M4 ports.")) + + (tag "mathcomp" + (memories + "reference_coquelicot_and_mathcomp_in_007_switch.md") + (reason "mathcomp subset available in this repo.")) + + ;; ─── M3 dispatch / oracle ───────────────────────────────────────── + (tag "m3" + (memories + "project_007_full_headline_ports_2026-04-18.md") + (reason "Full-headline ports (E1/S3/S4/E5) touch the M3 surface.")) + + ;; ─── Idris2 proof hygiene ───────────────────────────────────────── + (tag "idris2" + (memories + "project_cerro_torre_postulate_conversion.md" + "feedback_idris_over_ephapax.md" + "feedback_code_only_grep_for_banned_patterns.md") + (reason "Postulate rewrite pattern, Idris > Ephapax when in conflict, banned-pattern scan method.")) + + (tag "banned-patterns" + (memories + "feedback_code_only_grep_for_banned_patterns.md") + (reason "believe_me/assert_total/Admitted/sorry: how to scan without false positives.")) + + ;; ─── Dogfooding ─────────────────────────────────────────────────── + (tag "dogfooding" + (memories + "user_dogfooding_philosophy.md" + "feedback_007_dogfood.md" + "feedback_dogfood_feedback_loops.md") + (reason "007 is a pilot for the dogfooding methodology itself.")) + + ;; ─── Rust / workspace hygiene ───────────────────────────────────── + (tag "rust" + (memories + "feedback_rust_means_rust_spark.md" + "feedback_unwrap_to_expect_antipattern.md" + "feedback_panic_attack_unsafe_blocks_meaning.md") + (reason "Rust conventions the user applies to 007-core/oo7-cli.")) + + ;; ─── Contractile trident ────────────────────────────────────────── + (tag "contractiles" + (memories + "feedback_contractile_layout_rules.md" + "user_6a2_is_contractile_ought.md" + "feedback_stale_lust_hook_150_repos.md" + "user_contract_negotiation_and_accountability_pledge.md") + (reason "Contractile layout, 6a2/contractile split, known drift sources, accountability pledge.")) + + (tag "6a2" + (memories + "reference_6a2_file_schema.md" + "user_6a2_is_contractile_ought.md") + (reason "Six-file schema and its relation to the contractile layer.")) + + ;; ─── Language-implementation general ────────────────────────────── + (tag "language-implementation" + (memories + "feedback_language_scope_in_thesis.md" + "feedback_semantic_analyser_caution.md") + (reason "Thesis is authoritative over code; SA should not be naively rewritten.")) + + ;; ─── Proof-analysis affinity pool ───────────────────────────────── + (tag "proof-analysis" + (memories + "feedback_canonical_proof_suite_v1_v1_1_closure_standards.md" + "reference_coquelicot_and_mathcomp_in_007_switch.md" + "feedback_coquelicot_proof_gotchas.md" + "project_cerro_torre_postulate_conversion.md" + "feedback_panic_attack_proofdrift_parameter_pattern.md") + (reason "What Opus needs when claiming 'proof-analysis' tasks in 007-lang."))) diff --git a/cartridges/domains/languages/affinescript-mcp/README.adoc b/cartridges/domains/languages/affinescript-mcp/README.adoc new file mode 100644 index 0000000..722dbe5 --- /dev/null +++ b/cartridges/domains/languages/affinescript-mcp/README.adoc @@ -0,0 +1,77 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) += affinescript-mcp +:toc: + +AffineScript language cartridge for the BoJ server. + +== Overview + +Provides MCP tools for interacting with the AffineScript compiler and language reference: + +* *Type checking* β€” invoke the compiler's check mode on source code +* *Parsing* β€” parse source and return structured AST summary +* *Formatting* β€” format source according to standard style +* *Error explanation* β€” look up error codes with descriptions and fix suggestions +* *Stdlib browsing* β€” search standard library types, functions, effects, traits +* *Syntax reference* β€” language construct documentation +* *Snippet evaluation* β€” evaluate expressions in the interpreter + +== Architecture + +Three-layer stack following the BoJ cartridge standard: + +[cols="1,1,3"] +|=== +| Layer | Language | Purpose + +| ABI | Idris2 | `SafeCompiler` state machine with dependent-type proofs +| FFI | Zig | C-ABI implementation: session pool, action metrics, thread safety +| Adapter | zig | REST bridge connecting FFI exports to BoJ adapter protocol +|=== + +== Tools (7) + +[cols="2,4"] +|=== +| Tool | Description + +| `affinescript_check` | Type-check source code, return diagnostics +| `affinescript_parse` | Parse source, return AST summary +| `affinescript_format` | Format source code +| `affinescript_explain_error` | Explain error code (E0-E6, W, L) +| `affinescript_stdlib` | Browse standard library +| `affinescript_syntax_ref` | Language construct reference +| `affinescript_snippet` | Evaluate expression in interpreter +|=== + +== Error Code Categories + +[cols="1,2"] +|=== +| Prefix | Category + +| E0xxx | Parse errors +| E1xxx | Type errors +| E2xxx | Borrow/affine errors +| E3xxx | Effect errors +| E4xxx | Quantity errors +| E5xxx | Name resolution errors +| E6xxx | Refinement type errors +| Wxxx | Warnings +| Lxxx | Lint +|=== + +== Building + +[source,bash] +---- +# ABI (Idris2) +cd abi && idris2 --check AffinescriptMcp/SafeCompiler.idr + +# FFI (Zig) +cd ffi && zig build + +# Tests +bash tests/integration_test.sh +---- diff --git a/cartridges/domains/languages/affinescript-mcp/abi/AffinescriptMcp/SafeCompiler.idr b/cartridges/domains/languages/affinescript-mcp/abi/AffinescriptMcp/SafeCompiler.idr new file mode 100644 index 0000000..9d65420 --- /dev/null +++ b/cartridges/domains/languages/affinescript-mcp/abi/AffinescriptMcp/SafeCompiler.idr @@ -0,0 +1,165 @@ +-- SPDX-License-Identifier: MPL-2.0 +-- Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +-- +-- AffinescriptMcp.SafeCompiler β€” Type-safe ABI for affinescript-mcp cartridge. +-- +-- Dependent-type state machine governing AffineScript compiler invocations. +-- Encodes type checking, parsing, formatting, error explanation, +-- stdlib browsing, syntax reference, and snippet evaluation +-- as compile-time invariants. +-- Local compiler: `affinescript` CLI (OCaml) +-- No auth required β€” local tool invocation. + +module AffinescriptMcp.SafeCompiler + +%default total + +-- --------------------------------------------------------------------------- +-- Session state machine +-- --------------------------------------------------------------------------- + +||| Session state for AffineScript MCP operations. +||| Ready: compiler available, ready for invocations. +||| Busy: compiler invocation in progress. +||| Error: compiler not found or crashed. +public export +data SessionState + = Ready + | Busy + | Error + +||| Proof that a state transition is valid. +public export +data ValidTransition : SessionState -> SessionState -> Type where + StartInvocation : ValidTransition Ready Busy + FinishSuccess : ValidTransition Busy Ready + FinishError : ValidTransition Busy Error + Recover : ValidTransition Error Ready + +-- --------------------------------------------------------------------------- +-- C-ABI integer encoding +-- --------------------------------------------------------------------------- + +||| Encode session state as C-compatible integer for FFI boundary. +export +sessionStateToInt : SessionState -> Int +sessionStateToInt Ready = 0 +sessionStateToInt Busy = 1 +sessionStateToInt Error = 2 + +||| Decode integer back to session state. +export +intToSessionState : Int -> Maybe SessionState +intToSessionState 0 = Just Ready +intToSessionState 1 = Just Busy +intToSessionState 2 = Just Error +intToSessionState _ = Nothing + +||| Check if a state transition is valid (C-ABI export). +export +afs_mcp_can_transition : Int -> Int -> Int +afs_mcp_can_transition from to = + case (intToSessionState from, intToSessionState to) of + (Just Ready, Just Busy) => 1 + (Just Busy, Just Ready) => 1 + (Just Busy, Just Error) => 1 + (Just Error, Just Ready) => 1 + _ => 0 + +-- --------------------------------------------------------------------------- +-- Compiler actions +-- --------------------------------------------------------------------------- + +||| Actions available through the AffineScript MCP cartridge. +public export +data CompilerAction + = Check + | Parse + | Format + | ExplainError + | StdlibSearch + | SyntaxRef + | EvalSnippet + +||| Whether an action requires authentication. +||| Local compiler β€” no auth needed. +export +actionRequiresAuth : CompilerAction -> Bool +actionRequiresAuth _ = False + +||| Whether an action mutates state. +||| All actions are read-only compiler queries. +export +actionIsMutating : CompilerAction -> Bool +actionIsMutating _ = False + +||| Whether an action invokes the external compiler. +||| Some actions (ExplainError, StdlibSearch, SyntaxRef) are pure lookups. +export +actionNeedsCompiler : CompilerAction -> Bool +actionNeedsCompiler Check = True +actionNeedsCompiler Parse = True +actionNeedsCompiler Format = False +actionNeedsCompiler ExplainError = False +actionNeedsCompiler StdlibSearch = False +actionNeedsCompiler SyntaxRef = False +actionNeedsCompiler EvalSnippet = True + +||| Encode action as C-compatible integer for FFI. +export +actionToInt : CompilerAction -> Int +actionToInt Check = 0 +actionToInt Parse = 1 +actionToInt Format = 2 +actionToInt ExplainError = 3 +actionToInt StdlibSearch = 4 +actionToInt SyntaxRef = 5 +actionToInt EvalSnippet = 6 + +||| Decode integer to compiler action. +export +intToAction : Int -> Maybe CompilerAction +intToAction 0 = Just Check +intToAction 1 = Just Parse +intToAction 2 = Just Format +intToAction 3 = Just ExplainError +intToAction 4 = Just StdlibSearch +intToAction 5 = Just SyntaxRef +intToAction 6 = Just EvalSnippet +intToAction _ = Nothing + +-- --------------------------------------------------------------------------- +-- MCP tool declarations +-- --------------------------------------------------------------------------- + +||| Tools exposed via MCP protocol. +public export +data McpTool + = ToolCheck + | ToolParse + | ToolFormat + | ToolExplainError + | ToolStdlib + | ToolSyntaxRef + | ToolSnippet + +||| Check if a tool needs the compiler subprocess. +export +toolNeedsCompiler : McpTool -> Bool +toolNeedsCompiler ToolCheck = True +toolNeedsCompiler ToolParse = True +toolNeedsCompiler ToolFormat = False +toolNeedsCompiler ToolExplainError = False +toolNeedsCompiler ToolStdlib = False +toolNeedsCompiler ToolSyntaxRef = False +toolNeedsCompiler ToolSnippet = True + +||| Total tool count for this cartridge. +export +toolCount : Nat +toolCount = 7 + +||| Total action count. +export +actionCount : Nat +actionCount = 7 diff --git a/cartridges/domains/languages/affinescript-mcp/abi/README.adoc b/cartridges/domains/languages/affinescript-mcp/abi/README.adoc new file mode 100644 index 0000000..cff0502 --- /dev/null +++ b/cartridges/domains/languages/affinescript-mcp/abi/README.adoc @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += affinescript-mcp / abi β€” Idris2 ABI layer +:orientation: shallow + +== Purpose + +Cartridge-local protocol types for `affinescript-mcp`. Defines the data, tool, and +transition vocabulary this cartridge speaks. Inherits safety lemmas and the +matrix-cell identifier scheme from the trunk ABI at `src/abi/`. + +== Files + +1 Idris2 module(s), ~165 lines total. Lead module: +`SafeCompiler.idr`. + +No `.ipkg` β€” consumed directly by wider BoJ callers; type-checks inside the +trunk `boj.ipkg`. + +== Invariants + +* `%default total` at every module top. +* Zero `believe_me`, `assert_total`, `postulate`, `Admitted`, or + `sorry` (the trunk holds the four declared `believe_me` sites; leaf + cartridges carry none). + +== Test/proof surface + +Thin module(s), no inline tests. Proof surface = type-check success under the +trunk `idris2 --check`. + +== Read-first + +. `SafeCompiler.idr` β€” cartridge-local data records and enums. +. Trunk `src/abi/Boj/Catalogue.idr` for how this cell participates in the + 2D matrix. diff --git a/cartridges/domains/languages/affinescript-mcp/adapter/README.adoc b/cartridges/domains/languages/affinescript-mcp/adapter/README.adoc new file mode 100644 index 0000000..826b0bb --- /dev/null +++ b/cartridges/domains/languages/affinescript-mcp/adapter/README.adoc @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += affinescript-mcp / adapter β€” protocol bridge +:orientation: shallow + +== Purpose + +Exposes this cartridge's FFI over the wire protocols declared in +`cartridge.json`. Dispatches tool invocations to the C-ABI exports at +`../ffi/` and formats responses per protocol. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” depends on the sibling `ffi` target. +| `affinescript_adapter.zig` | Protocol dispatch (212 lines). Tool catalogue matches `cartridge.json`. +| `SIDELINED-*.v.adoc` | zig predecessor(s). Not built β€” zig banned 2026-04-10. +|=== + +== Invariants + +* **Stateless** β€” all state lives behind the FFI, never in the adapter. +* **Response passthrough** β€” whatever the FFI returns goes back to the wire + unmodified (no embellishment, no silent recovery). +* **Cartridge.json is source of truth** for the tool catalogue; drift between + adapter and manifest is a CI failure. + +== Test/proof surface + +No inline tests at the adapter layer β€” protocol routing is exercised by the +trunk seam tests at `ffi/zig/src/seams.zig`. + +== Read-first + +. `affinescript_adapter.zig` β€” dispatch function and per-protocol routers. +. `../cartridge.json` β€” the tool manifest the dispatcher mirrors. diff --git a/cartridges/domains/languages/affinescript-mcp/adapter/affinescript_adapter.zig b/cartridges/domains/languages/affinescript-mcp/adapter/affinescript_adapter.zig new file mode 100644 index 0000000..db7d82f --- /dev/null +++ b/cartridges/domains/languages/affinescript-mcp/adapter/affinescript_adapter.zig @@ -0,0 +1,212 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// affinescript-mcp/adapter/affinescript_adapter.zig -- Unified three-protocol adapter. +// +// Replaces the banned affinescript_adapter.v (zig, removed 2026-04-12). +// +// Bridges the Zig FFI (affinescript_mcp_ffi.zig) to three network protocols: +// REST :9031 POST /tools/<tool> +// gRPC-compat :9032 /AffinescriptMcpService/<Method> +// GraphQL :9033 POST /graphql { query: "..." } +// +// AffineScript compiler tools: type-check, parse, format, explain errors, compile, hover, complete, goto-def +// Tools: +// affinescript_check +// affinescript_parse +// affinescript_format +// affinescript_explain_error +// affinescript_stdlib +// affinescript_syntax_ref +// affinescript_snippet +// affinescript_lint +// affinescript_compile +// affinescript_hover +// affinescript_goto_def +// affinescript_complete + +const std = @import("std"); +const ffi = @import("affinescript_mcp_ffi"); + +const REST_PORT: u16 = 9031; +const GRPC_PORT: u16 = 9032; +const GQL_PORT: u16 = 9033; + +const MAX_CONN_BUF: usize = 16 * 1024; + +// ============================================================================ +// JSON response builders +// ============================================================================ + +fn okJson(buf: []u8, msg: []const u8) []u8 { + return std.fmt.bufPrint(buf, + \{{"success":true,"message":"{}"}} + , .{msg}) catch buf[0..0]; +} + +fn errJson(buf: []u8, msg: []const u8) []u8 { + return std.fmt.bufPrint(buf, + \{{"success":false,"error":"{}"}} + , .{msg}) catch buf[0..0]; +} + +fn statusJson(buf: []u8) []u8 { + return std.fmt.bufPrint(buf, + \{{"success":true,"state":"ready","service":"affinescript-mcp"}} + , .{}) catch buf[0..0]; +} + +// ============================================================================ +// Tool dispatcher +// ============================================================================ + +const Response = struct { status: u16, body: []u8 }; + +fn dispatch(tool: []const u8, body: []const u8, resp: []u8) Response { + _ = body; + if (std.mem.eql(u8, tool, "affinescript_check")) return .{ .status = 200, .body = okJson(resp, "affinescript_check forwarded to backend") }; + if (std.mem.eql(u8, tool, "affinescript_parse")) return .{ .status = 200, .body = okJson(resp, "affinescript_parse forwarded to backend") }; + if (std.mem.eql(u8, tool, "affinescript_format")) return .{ .status = 200, .body = okJson(resp, "affinescript_format forwarded to backend") }; + if (std.mem.eql(u8, tool, "affinescript_explain_error")) return .{ .status = 200, .body = okJson(resp, "affinescript_explain_error forwarded to backend") }; + if (std.mem.eql(u8, tool, "affinescript_stdlib")) return .{ .status = 200, .body = okJson(resp, "affinescript_stdlib forwarded to backend") }; + if (std.mem.eql(u8, tool, "affinescript_syntax_ref")) return .{ .status = 200, .body = okJson(resp, "affinescript_syntax_ref forwarded to backend") }; + if (std.mem.eql(u8, tool, "affinescript_snippet")) return .{ .status = 200, .body = okJson(resp, "affinescript_snippet forwarded to backend") }; + if (std.mem.eql(u8, tool, "affinescript_lint")) return .{ .status = 200, .body = okJson(resp, "affinescript_lint forwarded to backend") }; + if (std.mem.eql(u8, tool, "affinescript_compile")) return .{ .status = 200, .body = okJson(resp, "affinescript_compile forwarded to backend") }; + if (std.mem.eql(u8, tool, "affinescript_hover")) return .{ .status = 200, .body = okJson(resp, "affinescript_hover forwarded to backend") }; + if (std.mem.eql(u8, tool, "affinescript_goto_def")) return .{ .status = 200, .body = okJson(resp, "affinescript_goto_def forwarded to backend") }; + if (std.mem.eql(u8, tool, "affinescript_complete")) return .{ .status = 200, .body = okJson(resp, "affinescript_complete forwarded to backend") }; + if (std.mem.eql(u8, tool, "status") or std.mem.eql(u8, tool, "health")) + return .{ .status = 200, .body = statusJson(resp) }; + return .{ .status = 404, .body = errJson(resp, "Unknown tool") }; +} + +// ============================================================================ +// REST handler +// ============================================================================ + +fn dispatchRest(path: []const u8, body: []const u8, resp: []u8) Response { + if (std.mem.startsWith(u8, path, "/tools/")) { + return dispatch(path["/tools/".len..], body, resp); + } + if (std.mem.eql(u8, path, "/status") or std.mem.eql(u8, path, "/health")) { + return .{ .status = 200, .body = statusJson(resp) }; + } + return .{ .status = 404, .body = errJson(resp, "Not found") }; +} + +// ============================================================================ +// gRPC-compat handler +// ============================================================================ + +fn dispatchGrpc(path: []const u8, body: []const u8, resp: []u8) Response { + const prefix = "/AffinescriptMcpService/"; + if (!std.mem.startsWith(u8, path, prefix)) + return .{ .status = 404, .body = errJson(resp, "Not a recognized gRPC path") }; + const method = path[prefix.len..]; + const tool = blk: { + if (std.mem.eql(u8, method, "AffinescriptCheck")) break :blk "affinescript_check"; + if (std.mem.eql(u8, method, "AffinescriptParse")) break :blk "affinescript_parse"; + if (std.mem.eql(u8, method, "AffinescriptFormat")) break :blk "affinescript_format"; + if (std.mem.eql(u8, method, "AffinescriptExplainError")) break :blk "affinescript_explain_error"; + if (std.mem.eql(u8, method, "AffinescriptStdlib")) break :blk "affinescript_stdlib"; + if (std.mem.eql(u8, method, "AffinescriptSyntaxRef")) break :blk "affinescript_syntax_ref"; + if (std.mem.eql(u8, method, "AffinescriptSnippet")) break :blk "affinescript_snippet"; + if (std.mem.eql(u8, method, "AffinescriptLint")) break :blk "affinescript_lint"; + if (std.mem.eql(u8, method, "AffinescriptCompile")) break :blk "affinescript_compile"; + if (std.mem.eql(u8, method, "AffinescriptHover")) break :blk "affinescript_hover"; + if (std.mem.eql(u8, method, "AffinescriptGotoDef")) break :blk "affinescript_goto_def"; + if (std.mem.eql(u8, method, "AffinescriptComplete")) break :blk "affinescript_complete"; + return .{ .status = 404, .body = errJson(resp, "Unknown gRPC method") }; + }; + return dispatch(tool, body, resp); +} + +// ============================================================================ +// GraphQL handler +// ============================================================================ + +fn dispatchGraphql(body: []const u8, resp: []u8) Response { + if (std.mem.indexOf(u8, body, "__schema") != null) + return .{ .status = 200, .body = okJson(resp, "schema introspection not yet supported") }; + if (std.mem.indexOf(u8, body, "check") != null) return dispatch("affinescript_check", body, resp); + if (std.mem.indexOf(u8, body, "parse") != null) return dispatch("affinescript_parse", body, resp); + if (std.mem.indexOf(u8, body, "format") != null) return dispatch("affinescript_format", body, resp); + if (std.mem.indexOf(u8, body, "explain_error") != null) return dispatch("affinescript_explain_error", body, resp); + if (std.mem.indexOf(u8, body, "stdlib") != null) return dispatch("affinescript_stdlib", body, resp); + if (std.mem.indexOf(u8, body, "syntax_ref") != null) return dispatch("affinescript_syntax_ref", body, resp); + if (std.mem.indexOf(u8, body, "snippet") != null) return dispatch("affinescript_snippet", body, resp); + if (std.mem.indexOf(u8, body, "lint") != null) return dispatch("affinescript_lint", body, resp); + if (std.mem.indexOf(u8, body, "compile") != null) return dispatch("affinescript_compile", body, resp); + if (std.mem.indexOf(u8, body, "hover") != null) return dispatch("affinescript_hover", body, resp); + if (std.mem.indexOf(u8, body, "goto_def") != null) return dispatch("affinescript_goto_def", body, resp); + if (std.mem.indexOf(u8, body, "complete") != null) return dispatch("affinescript_complete", body, resp); + return .{ .status = 200, .body = errJson(resp, "Unrecognised GraphQL operation") }; +} + +// ============================================================================ +// HTTP/1.1 connection handler +// ============================================================================ + +const Protocol = enum { rest, grpc, graphql }; + +fn handleConnection(conn: std.net.Server.Connection, proto: Protocol) void { + defer conn.stream.close(); + var in_buf: [MAX_CONN_BUF]u8 = undefined; + const n = conn.stream.read(&in_buf) catch return; + const req = in_buf[0..n]; + + var path: []const u8 = "/"; + var body: []const u8 = ""; + if (n > 4) { + const line_end = std.mem.indexOf(u8, req, "\r\n") orelse req.len; + const first_line = req[0..line_end]; + const sp1 = std.mem.indexOfScalar(u8, first_line, ' ') orelse 0; + const rest_of = first_line[sp1 + 1 ..]; + const sp2 = std.mem.indexOfScalar(u8, rest_of, ' ') orelse rest_of.len; + path = rest_of[0..sp2]; + const body_sep = std.mem.indexOf(u8, req, "\r\n\r\n") orelse n; + body = req[@min(body_sep + 4, n)..]; + } + + var resp_buf: [MAX_CONN_BUF]u8 = undefined; + const result = switch (proto) { + .rest => dispatchRest(path, body, &resp_buf), + .grpc => dispatchGrpc(path, body, &resp_buf), + .graphql => dispatchGraphql(body, &resp_buf), + }; + + const content_type = switch (proto) { + .rest => "application/json", + .grpc => "application/grpc+json", + .graphql => "application/json", + }; + + var hdr_buf: [256]u8 = undefined; + const hdr = std.fmt.bufPrint(&hdr_buf, + "HTTP/1.1 {d} OK\r\nContent-Type: {s}\r\nContent-Length: {d}\r\nConnection: close\r\n\r\n", + .{ result.status, content_type, result.body.len }, + ) catch return; + _ = conn.stream.writeAll(hdr) catch return; + _ = conn.stream.writeAll(result.body) catch return; +} + +fn listenLoop(port: u16, proto: Protocol) void { + const addr = std.net.Address.initIp4(.{ 127, 0, 0, 1 }, port) catch return; + var server = addr.listen(.{ .reuse_address = true }) catch return; + defer server.deinit(); + while (true) { + const conn = server.accept() catch continue; + handleConnection(conn, proto); + } +} + +pub fn main() !void { + ffi.affinescript_init(); + const rest_thread = try std.Thread.spawn(.{}, listenLoop, .{ REST_PORT, Protocol.rest }); + const grpc_thread = try std.Thread.spawn(.{}, listenLoop, .{ GRPC_PORT, Protocol.grpc }); + const gql_thread = try std.Thread.spawn(.{}, listenLoop, .{ GQL_PORT, Protocol.graphql }); + rest_thread.join(); + grpc_thread.join(); + gql_thread.join(); +} diff --git a/cartridges/domains/languages/affinescript-mcp/adapter/build.zig b/cartridges/domains/languages/affinescript-mcp/adapter/build.zig new file mode 100644 index 0000000..b922614 --- /dev/null +++ b/cartridges/domains/languages/affinescript-mcp/adapter/build.zig @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// affinescript-mcp/adapter/build.zig + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const ffi_mod = b.createModule(.{ + .root_source_file = b.path("../ffi/affinescript_mcp_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + const adapter = b.addExecutable(.{ + .name = "affinescript_adapter", + .root_source_file = b.path("affinescript_adapter.zig"), + .target = target, + .optimize = optimize, + }); + adapter.root_module.addImport("affinescript_mcp_ffi", ffi_mod); + b.installArtifact(adapter); + + const run_artifact = b.addRunArtifact(adapter); + const run_step = b.step("run", "Run the affinescript-mcp adapter"); + run_step.dependOn(&run_artifact.step); + + const tests = b.addTest(.{ + .root_source_file = b.path("affinescript_adapter.zig"), + .target = target, + .optimize = optimize, + }); + tests.root_module.addImport("affinescript_mcp_ffi", ffi_mod); + const run_tests = b.addRunArtifact(tests); + const test_step = b.step("test", "Run affinescript-mcp adapter tests"); + test_step.dependOn(&run_tests.step); +} diff --git a/cartridges/domains/languages/affinescript-mcp/benchmarks/quick-bench.sh b/cartridges/domains/languages/affinescript-mcp/benchmarks/quick-bench.sh new file mode 100755 index 0000000..8e13d5b --- /dev/null +++ b/cartridges/domains/languages/affinescript-mcp/benchmarks/quick-bench.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +# +# Quick benchmark for affinescript-mcp cartridge. +# Measures latency for pure lookup tools (no compiler dependency). + +set -euo pipefail + +BOJ_URL="${BOJ_URL:-http://localhost:7700}" +ITERATIONS=100 + +echo "=== affinescript-mcp quick benchmark ===" +echo "Target: ${BOJ_URL}" +echo "Iterations: ${ITERATIONS}" +echo "" + +bench() { + local desc="$1" tool="$2" args="$3" + local start end elapsed avg + + start=$(date +%s%N) + for _ in $(seq 1 "$ITERATIONS"); do + curl -s -X POST "${BOJ_URL}/mcp/affinescript-mcp" \ + -H "Content-Type: application/json" \ + -d "{\"tool\": \"${tool}\", \"args\": ${args}}" > /dev/null 2>&1 + done + end=$(date +%s%N) + elapsed=$(( (end - start) / 1000000 )) + avg=$(( elapsed / ITERATIONS )) + echo "${desc}: ${elapsed}ms total, ${avg}ms avg" +} + +bench "syntax_ref (pure lookup)" "affinescript_syntax_ref" '{"construct": "fn"}' +bench "explain_error (pure lookup)" "affinescript_explain_error" '{"code": "E2001"}' +bench "stdlib_search (pure lookup)" "affinescript_stdlib" '{"query": "Option"}' +bench "format (pure transform)" "affinescript_format" '{"source": "fn main() {\nlet x = 1\n}"}' + +echo "" +echo "Done." diff --git a/cartridges/domains/languages/affinescript-mcp/cartridge.json b/cartridges/domains/languages/affinescript-mcp/cartridge.json new file mode 100644 index 0000000..b40a02d --- /dev/null +++ b/cartridges/domains/languages/affinescript-mcp/cartridge.json @@ -0,0 +1,286 @@ +{ + "$schema": "https://boj.dev/schemas/cartridge/v1.json", + "spdx": "MPL-2.0", + "copyright": "Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath)", + "name": "affinescript-mcp", + "version": "0.1.0", + "description": "AffineScript language cartridge -- type checking, parsing, formatting, linting, compiling, hover/goto-def/completion queries, error explanation, stdlib browsing, and syntax reference for the AffineScript language (substructural type system with affine/linear types, algebraic effects)", + "domain": "Languages", + "tier": "Ayo", + "protocols": [ + "MCP", + "REST" + ], + "auth": { + "method": "none", + "env_var": null, + "credential_source": null + }, + "api": { + "base_url": "local://affinescript", + "content_type": "application/json" + }, + "tools": [ + { + "name": "affinescript_check", + "description": "Type-check AffineScript source code and return structured JSON diagnostics (errors, warnings, hints). Invokes the compiler's check mode with --json output.", + "inputSchema": { + "type": "object", + "properties": { + "source": { + "type": "string", + "description": "AffineScript source code to check" + }, + "filename": { + "type": "string", + "description": "Virtual filename for error reporting (default: input.as)" + } + }, + "required": [ + "source" + ] + } + }, + { + "name": "affinescript_parse", + "description": "Parse AffineScript source and return a structured AST summary (top-level declarations, types, functions, effects)", + "inputSchema": { + "type": "object", + "properties": { + "source": { + "type": "string", + "description": "AffineScript source code to parse" + } + }, + "required": [ + "source" + ] + } + }, + { + "name": "affinescript_format", + "description": "Format AffineScript source code according to the standard style", + "inputSchema": { + "type": "object", + "properties": { + "source": { + "type": "string", + "description": "AffineScript source code to format" + }, + "tab_size": { + "type": "number", + "description": "Indentation width (default: 2)" + }, + "use_tabs": { + "type": "boolean", + "description": "Use tabs instead of spaces (default: false)" + } + }, + "required": [ + "source" + ] + } + }, + { + "name": "affinescript_explain_error", + "description": "Explain an AffineScript error code with description, example, and fix suggestions. Error codes: E0xxx (parse), E1xxx (type), E2xxx (borrow), E3xxx (effect), E4xxx (quantity), E5xxx (name), E6xxx (refinement), Wxxx (warning), Lxxx (lint)", + "inputSchema": { + "type": "object", + "properties": { + "code": { + "type": "string", + "description": "Error code (e.g. 'E0301', 'E2001', 'W0001')" + } + }, + "required": [ + "code" + ] + } + }, + { + "name": "affinescript_stdlib", + "description": "Browse AffineScript standard library types and functions. Search by name or category.", + "inputSchema": { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "Search query (type name, function name, or category like 'collections', 'io', 'effects')" + }, + "category": { + "type": "string", + "description": "Filter by category: types, functions, effects, traits" + } + }, + "required": [ + "query" + ] + } + }, + { + "name": "affinescript_syntax_ref", + "description": "Get syntax reference for an AffineScript language construct (keywords, operators, declarations, patterns, effects, quantities)", + "inputSchema": { + "type": "object", + "properties": { + "construct": { + "type": "string", + "description": "Language construct to look up (e.g. 'fn', 'match', 'effect', 'linear', 'borrow', 'handler')" + } + }, + "required": [ + "construct" + ] + } + }, + { + "name": "affinescript_snippet", + "description": "Evaluate a small AffineScript expression or snippet in the interpreter and return the result", + "inputSchema": { + "type": "object", + "properties": { + "source": { + "type": "string", + "description": "AffineScript expression or snippet to evaluate" + } + }, + "required": [ + "source" + ] + } + }, + { + "name": "affinescript_lint", + "description": "Lint AffineScript source code for code quality issues (naming conventions, dead code, style). Returns structured JSON diagnostics.", + "inputSchema": { + "type": "object", + "properties": { + "source": { + "type": "string", + "description": "AffineScript source code to lint" + }, + "filename": { + "type": "string", + "description": "Virtual filename for diagnostic reporting (default: input.as)" + } + }, + "required": [ + "source" + ] + } + }, + { + "name": "affinescript_compile", + "description": "Compile AffineScript source to a target (wasm, wasm-gc, julia). Returns structured JSON diagnostics and reports whether compilation succeeded.", + "inputSchema": { + "type": "object", + "properties": { + "source": { + "type": "string", + "description": "AffineScript source code to compile" + }, + "target": { + "type": "string", + "description": "Compilation target: 'wasm' (default), 'wasm-gc', or 'julia'" + }, + "filename": { + "type": "string", + "description": "Virtual filename for diagnostic reporting (default: input.as)" + } + }, + "required": [ + "source" + ] + } + }, + { + "name": "affinescript_hover", + "description": "Return type and symbol information for the symbol at a cursor position in AffineScript source code. Runs the full pipeline (parse β†’ resolve β†’ typecheck) and returns JSON hover info.", + "inputSchema": { + "type": "object", + "properties": { + "source": { + "type": "string", + "description": "AffineScript source code" + }, + "line": { + "type": "number", + "description": "1-based line number" + }, + "col": { + "type": "number", + "description": "1-based column number" + } + }, + "required": [ + "source", + "line", + "col" + ] + } + }, + { + "name": "affinescript_goto_def", + "description": "Return the definition location of the symbol at a cursor position in AffineScript source code. Returns JSON with file, line, and column of the definition site.", + "inputSchema": { + "type": "object", + "properties": { + "source": { + "type": "string", + "description": "AffineScript source code" + }, + "line": { + "type": "number", + "description": "1-based line number" + }, + "col": { + "type": "number", + "description": "1-based column number" + } + }, + "required": [ + "source", + "line", + "col" + ] + } + }, + { + "name": "affinescript_complete", + "description": "Return completion candidates at a cursor position in AffineScript source code. Returns a JSON array of items with name, kind, type, and detail fields.", + "inputSchema": { + "type": "object", + "properties": { + "source": { + "type": "string", + "description": "AffineScript source code" + }, + "line": { + "type": "number", + "description": "1-based line number" + }, + "col": { + "type": "number", + "description": "1-based column number" + } + }, + "required": [ + "source", + "line", + "col" + ] + } + } + ], + "ffi": { + "so_path": "ffi/zig-out/lib/libaffinescript_mcp.so", + "abi_version": "ADR-0006", + "symbols": [ + "boj_cartridge_init", + "boj_cartridge_deinit", + "boj_cartridge_name", + "boj_cartridge_version", + "boj_cartridge_invoke" + ] + } +} diff --git a/cartridges/domains/languages/affinescript-mcp/ffi/README.adoc b/cartridges/domains/languages/affinescript-mcp/ffi/README.adoc new file mode 100644 index 0000000..9693b17 --- /dev/null +++ b/cartridges/domains/languages/affinescript-mcp/ffi/README.adoc @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += affinescript-mcp / ffi β€” Zig FFI layer +:orientation: shallow + +== Purpose + +C-ABI exports bridging this cartridge's Idris2 ABI to the runtime. Consumed by +the adapter for protocol dispatch and by the trunk loader for mount. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” produces `zig-out/lib/libaffinescript.so`. +| `affinescript_mcp_ffi.zig` | C-ABI exports (15 exports, 6 inline tests, 369 lines). +|=== + +== Invariants + +* **Null-pointer rejection** on `[*c]const u8` arguments before any + dereference. +* **Negative return values** carry documented error meanings; zero is + success (matching the trunk error-code convention). +* **No allocator leaks** β€” every allocation path pairs with `defer free`. + +== Test/proof surface + +6 inline `test "..."` block(s) in `affinescript_mcp_ffi.zig`. Run with +`cd ffi && zig build test`. + +== Read-first + +. `affinescript_mcp_ffi.zig` β€” C-ABI signatures and guards. +. Trunk `ffi/zig/src/catalogue.zig` for how this `.so` is loaded. diff --git a/cartridges/domains/languages/affinescript-mcp/ffi/affinescript_mcp_ffi.zig b/cartridges/domains/languages/affinescript-mcp/ffi/affinescript_mcp_ffi.zig new file mode 100644 index 0000000..cffcfd6 --- /dev/null +++ b/cartridges/domains/languages/affinescript-mcp/ffi/affinescript_mcp_ffi.zig @@ -0,0 +1,484 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// affinescript_mcp_ffi.zig β€” C-ABI FFI implementation for affinescript-mcp cartridge. +// +// Implements the state machine defined in AffinescriptMcp.SafeCompiler (Idris2 ABI). +// State machine: Ready | Busy | Error (local compiler, no auth) +// Actions: Check, Parse, Format, ExplainError, StdlibSearch, SyntaxRef, EvalSnippet +// Thread-safe via std.Thread.Mutex. Fixed-size session pool, no heap allocations. + +const std = @import("std"); + +// --------------------------------------------------------------------------- +// State machine (matches Idris2 ABI SessionState exactly) +// --------------------------------------------------------------------------- + +/// Session lifecycle state. +/// 0 = Ready, 1 = Busy, 2 = Error. +pub const SessionState = enum(c_int) { + ready = 0, + busy = 1, + err = 2, +}; + +/// Compiler action identifiers matching Idris2 CompilerAction encoding. +pub const CompilerAction = enum(c_int) { + check = 0, + parse = 1, + format = 2, + explain_error = 3, + stdlib_search = 4, + syntax_ref = 5, + eval_snippet = 6, +}; + +/// Check valid state transitions per the Idris2 ValidTransition proof. +fn isValidTransition(from: SessionState, to: SessionState) bool { + return switch (from) { + .ready => to == .busy, + .busy => to == .ready or to == .err, + .err => to == .ready, + }; +} + +/// Check if an action requires the compiler subprocess. +fn actionNeedsCompiler(action: CompilerAction) bool { + return switch (action) { + .check, .parse, .eval_snippet => true, + .format, .explain_error, .stdlib_search, .syntax_ref => false, + }; +} + +// --------------------------------------------------------------------------- +// Session slots (thread-safe, fixed-size pool) +// --------------------------------------------------------------------------- + +const MAX_SESSIONS: usize = 16; + +const SessionSlot = struct { + active: bool = false, + state: SessionState = .ready, + invocation_count: u64 = 0, + last_action: c_int = -1, + check_count: u32 = 0, + parse_count: u32 = 0, + format_count: u32 = 0, + eval_count: u32 = 0, + lookup_count: u32 = 0, +}; + +var sessions: [MAX_SESSIONS]SessionSlot = .{SessionSlot{}} ** MAX_SESSIONS; +var mutex: std.Thread.Mutex = .{}; + +// --------------------------------------------------------------------------- +// C-ABI exports β€” state machine +// --------------------------------------------------------------------------- + +/// Check if a state transition is valid. Returns 1 (valid) or 0 (invalid). +pub export fn afs_mcp_can_transition(from: c_int, to: c_int) c_int { + const f = std.meta.intToEnum(SessionState, from) catch return 0; + const t = std.meta.intToEnum(SessionState, to) catch return 0; + return if (isValidTransition(f, t)) 1 else 0; +} + +/// Open a session. Returns slot index (>= 0) or error (< 0). +pub export fn afs_mcp_open(dummy: c_int) c_int { + _ = dummy; + mutex.lock(); + defer mutex.unlock(); + + for (&sessions, 0..) |*slot, idx| { + if (!slot.active) { + slot.active = true; + slot.state = .ready; + slot.invocation_count = 0; + slot.last_action = -1; + slot.check_count = 0; + slot.parse_count = 0; + slot.format_count = 0; + slot.eval_count = 0; + slot.lookup_count = 0; + return @intCast(idx); + } + } + return -1; +} + +/// Close a session. Returns 0 on success. +pub export fn afs_mcp_close(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + + sessions[idx] = SessionSlot{}; + return 0; +} + +/// Get current state of a session. Returns state int or -1 if invalid. +pub export fn afs_mcp_session_state(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + return @intFromEnum(slot.state); +} + +/// Mark session as busy (compiler invocation started). Returns 0 on success. +pub export fn afs_mcp_start_invocation(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + if (!isValidTransition(slot.state, .busy)) return -2; + + sessions[idx].state = .busy; + return 0; +} + +/// Mark invocation as complete (success). Returns 0 on success. +pub export fn afs_mcp_finish_success(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + if (!isValidTransition(slot.state, .ready)) return -2; + + sessions[idx].state = .ready; + return 0; +} + +/// Signal a compiler error. Returns 0 on success. +pub export fn afs_mcp_signal_error(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + if (!isValidTransition(slot.state, .err)) return -2; + + sessions[idx].state = .err; + return 0; +} + +/// Recover from error state. Returns 0 on success. +pub export fn afs_mcp_recover(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + if (!isValidTransition(slot.state, .ready)) return -2; + + sessions[idx].state = .ready; + return 0; +} + +// --------------------------------------------------------------------------- +// C-ABI exports β€” action recording and metrics +// --------------------------------------------------------------------------- + +/// Record a compiler action. Returns 0 on success. +pub export fn afs_mcp_record_action(slot_idx: c_int, action: c_int) c_int { + const act = std.meta.intToEnum(CompilerAction, action) catch return -3; + + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + + sessions[idx].invocation_count += 1; + sessions[idx].last_action = action; + + switch (act) { + .check => sessions[idx].check_count += 1, + .parse => sessions[idx].parse_count += 1, + .format => sessions[idx].format_count += 1, + .eval_snippet => sessions[idx].eval_count += 1, + .explain_error, .stdlib_search, .syntax_ref => sessions[idx].lookup_count += 1, + } + + return 0; +} + +/// Get total invocation count for a session. +pub export fn afs_mcp_invocation_count(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + return @intCast(slot.invocation_count); +} + +/// Get check count. +pub export fn afs_mcp_check_count(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + return @intCast(slot.check_count); +} + +/// Get parse count. +pub export fn afs_mcp_parse_count(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + return @intCast(slot.parse_count); +} + +/// Get format count. +pub export fn afs_mcp_format_count(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + return @intCast(slot.format_count); +} + +/// Get total action count. Always returns 7. +pub export fn afs_mcp_action_count() c_int { + return 7; +} + +/// Reset all sessions (test/debug use only). +pub export fn afs_mcp_reset() void { + mutex.lock(); + defer mutex.unlock(); + sessions = .{SessionSlot{}} ** MAX_SESSIONS; +} + +// --------------------------------------------------------------------------- +// ═══════════════════════════════════════════════════════════════════════ +// Standard ABI (ADR-0005 four symbols + ADR-0006 invoke) +// ═══════════════════════════════════════════════════════════════════════ + +const shim = @import("cartridge_shim.zig"); + +const CARTRIDGE_NAME_PTR: [*:0]const u8 = "affinescript-mcp"; +const CARTRIDGE_VERSION_PTR: [*:0]const u8 = "0.1.0"; + +export fn boj_cartridge_init() callconv(.c) c_int { + return 0; +} + +export fn boj_cartridge_deinit() callconv(.c) void {} + +export fn boj_cartridge_name() callconv(.c) [*:0]const u8 { + return CARTRIDGE_NAME_PTR; +} + +export fn boj_cartridge_version() callconv(.c) [*:0]const u8 { + return CARTRIDGE_VERSION_PTR; +} + +/// Dispatch the cartridge.json MCP tools. Grade D Alpha stubs. +export fn boj_cartridge_invoke( + tool_name: [*c]const u8, + json_args: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) callconv(.c) i32 { + _ = json_args; + if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; + + const body: []const u8 = if (shim.toolIs(tool_name, "affinescript_check")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "affinescript_parse")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "affinescript_format")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "affinescript_explain_error")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "affinescript_stdlib")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "affinescript_syntax_ref")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "affinescript_snippet")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "affinescript_lint")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "affinescript_compile")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "affinescript_hover")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "affinescript_goto_def")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "affinescript_complete")) + "{\"result\":{\"status\":\"stub\"}}" +else + return shim.RC_UNKNOWN_TOOL; + + return shim.writeResult(out_buf, in_out_len, body); +} + +// Tests +// --------------------------------------------------------------------------- + +test "session lifecycle" { + afs_mcp_reset(); + + const slot = afs_mcp_open(0); + try std.testing.expect(slot >= 0); + try std.testing.expectEqual(@as(c_int, 0), afs_mcp_session_state(slot)); + + try std.testing.expectEqual(@as(c_int, 0), afs_mcp_record_action(slot, 0)); // Check + try std.testing.expectEqual(@as(c_int, 1), afs_mcp_invocation_count(slot)); + try std.testing.expectEqual(@as(c_int, 1), afs_mcp_check_count(slot)); + + try std.testing.expectEqual(@as(c_int, 0), afs_mcp_close(slot)); +} + +test "busy/ready transitions" { + afs_mcp_reset(); + + const slot = afs_mcp_open(0); + try std.testing.expect(slot >= 0); + + try std.testing.expectEqual(@as(c_int, 0), afs_mcp_start_invocation(slot)); + try std.testing.expectEqual(@as(c_int, 1), afs_mcp_session_state(slot)); // busy + + try std.testing.expectEqual(@as(c_int, 0), afs_mcp_finish_success(slot)); + try std.testing.expectEqual(@as(c_int, 0), afs_mcp_session_state(slot)); // ready +} + +test "error and recovery" { + afs_mcp_reset(); + + const slot = afs_mcp_open(0); + try std.testing.expect(slot >= 0); + + try std.testing.expectEqual(@as(c_int, 0), afs_mcp_start_invocation(slot)); + try std.testing.expectEqual(@as(c_int, 0), afs_mcp_signal_error(slot)); + try std.testing.expectEqual(@as(c_int, 2), afs_mcp_session_state(slot)); // error + + try std.testing.expectEqual(@as(c_int, 0), afs_mcp_recover(slot)); + try std.testing.expectEqual(@as(c_int, 0), afs_mcp_session_state(slot)); // ready +} + +test "transition validator" { + try std.testing.expectEqual(@as(c_int, 1), afs_mcp_can_transition(0, 1)); // Ready -> Busy + try std.testing.expectEqual(@as(c_int, 1), afs_mcp_can_transition(1, 0)); // Busy -> Ready + try std.testing.expectEqual(@as(c_int, 1), afs_mcp_can_transition(1, 2)); // Busy -> Error + try std.testing.expectEqual(@as(c_int, 1), afs_mcp_can_transition(2, 0)); // Error -> Ready + try std.testing.expectEqual(@as(c_int, 0), afs_mcp_can_transition(0, 2)); // Ready -> Error (invalid) + try std.testing.expectEqual(@as(c_int, 0), afs_mcp_can_transition(2, 1)); // Error -> Busy (invalid) +} + +test "action category counting" { + afs_mcp_reset(); + + const slot = afs_mcp_open(0); + try std.testing.expect(slot >= 0); + + try std.testing.expectEqual(@as(c_int, 0), afs_mcp_record_action(slot, 0)); // Check + try std.testing.expectEqual(@as(c_int, 0), afs_mcp_record_action(slot, 1)); // Parse + try std.testing.expectEqual(@as(c_int, 0), afs_mcp_record_action(slot, 2)); // Format + try std.testing.expectEqual(@as(c_int, 0), afs_mcp_record_action(slot, 6)); // EvalSnippet + + try std.testing.expectEqual(@as(c_int, 4), afs_mcp_invocation_count(slot)); + try std.testing.expectEqual(@as(c_int, 1), afs_mcp_check_count(slot)); + try std.testing.expectEqual(@as(c_int, 1), afs_mcp_parse_count(slot)); + try std.testing.expectEqual(@as(c_int, 1), afs_mcp_format_count(slot)); +} + +test "slot exhaustion" { + afs_mcp_reset(); + + var slots: [MAX_SESSIONS]c_int = undefined; + for (&slots) |*s| { + s.* = afs_mcp_open(0); + try std.testing.expect(s.* >= 0); + } + + try std.testing.expectEqual(@as(c_int, -1), afs_mcp_open(0)); + + try std.testing.expectEqual(@as(c_int, 0), afs_mcp_close(slots[0])); + const new_slot = afs_mcp_open(0); + try std.testing.expect(new_slot >= 0); +} + +// ═══════════════════════════════════════════════════════════════════════ +// ADR-0006 invoke dispatch tests +// ═══════════════════════════════════════════════════════════════════════ + +test "boj_cartridge_name returns affinescript-mcp" { + const n = std.mem.span(boj_cartridge_name()); + try std.testing.expectEqualStrings("affinescript-mcp", n); +} + +test "boj_cartridge_init returns 0" { + try std.testing.expectEqual(@as(c_int, 0), boj_cartridge_init()); +} + +test "invoke: each declared tool succeeds" { + var buf: [256]u8 = undefined; + const tools = [_][]const u8{ + "affinescript_check", + "affinescript_parse", + "affinescript_format", + "affinescript_explain_error", + "affinescript_stdlib", + "affinescript_syntax_ref", + "affinescript_snippet", + "affinescript_lint", + "affinescript_compile", + "affinescript_hover", + "affinescript_goto_def", + "affinescript_complete", + }; + for (tools) |t| { + var len: usize = buf.len; + const rc = boj_cartridge_invoke(t.ptr, "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, 0), rc); + try std.testing.expect(std.mem.indexOf(u8, buf[0..len], "result") != null); + } +} + +test "invoke: unknown tool returns -1" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("nope", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -1), rc); +} + +test "invoke: buffer too small returns -3" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("affinescript_check", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -3), rc); + try std.testing.expect(len > 4); +} diff --git a/cartridges/domains/languages/affinescript-mcp/ffi/build.zig b/cartridges/domains/languages/affinescript-mcp/ffi/build.zig new file mode 100644 index 0000000..596114b --- /dev/null +++ b/cartridges/domains/languages/affinescript-mcp/ffi/build.zig @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + // Shared ADR-0006 invoke-shim module (relative path up to boj-server trunk). + + const shim_mod = b.addModule("cartridge_shim", .{ + + .root_source_file = b.path("../../../ffi/zig/src/cartridge_shim.zig"), + + .target = target, + + .optimize = optimize, + + }); + + const ffi_mod = b.addModule("affinescript_mcp", .{ + .root_source_file = b.path("affinescript_mcp_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + ffi_mod.addImport("cartridge_shim", shim_mod); + + const lib = b.addLibrary(.{ + .name = "affinescript_mcp", + .root_module = ffi_mod, + .linkage = .dynamic, + }); + lib.linkLibC(); + b.installArtifact(lib); + + const tests = b.addTest(.{ .root_module = ffi_mod }); + tests.linkLibC(); + const run_tests = b.addRunArtifact(tests); + const test_step = b.step("test", "Run FFI tests"); + test_step.dependOn(&run_tests.step); +} + diff --git a/cartridges/domains/languages/affinescript-mcp/ffi/cartridge_shim.zig b/cartridges/domains/languages/affinescript-mcp/ffi/cartridge_shim.zig new file mode 100644 index 0000000..0e399db --- /dev/null +++ b/cartridges/domains/languages/affinescript-mcp/ffi/cartridge_shim.zig @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// cartridge_shim.zig β€” Shared helpers for the ADR-0006 five-symbol +// cartridge ABI (`boj_cartridge_init / deinit / name / version / invoke`). +// +// The shim centralises the seven-code return convention, NUL-argument +// guards, tool-name comparison, and the buffer-too-small path so each +// cartridge's `boj_cartridge_invoke` can stay short β€” typically a tool +// table plus `shim.writeResult(...)`. +// +// Cartridges import this file by relative path (no build-graph change +// needed). Example: +// +// const shim = @import("../../../ffi/zig/src/cartridge_shim.zig"); +// +// export fn boj_cartridge_invoke( +// tool_name: [*c]const u8, +// json_args: [*c]const u8, +// out_buf: [*c]u8, +// in_out_len: [*c]usize, +// ) callconv(.c) i32 { +// _ = json_args; +// if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; +// const body = if (shim.toolIs(tool_name, "foo")) "{\"result\":{}}" +// else return shim.RC_UNKNOWN_TOOL; +// return shim.writeResult(out_buf, in_out_len, body); +// } + +const std = @import("std"); + +// ── Return codes (ADR-0006 Β§Return codes) ──────────────────────────── +// +// Frozen by ADR-0006. New failure modes compose these via the error +// JSON body β€” the integer surface does not grow without a follow-up ADR. + +pub const RC_SUCCESS: i32 = 0; +pub const RC_UNKNOWN_TOOL: i32 = -1; +pub const RC_BAD_ARGS: i32 = -2; +pub const RC_BUFFER_TOO_SMALL: i32 = -3; +pub const RC_RUNTIME_ERROR: i32 = -4; +pub const RC_PANIC: i32 = -5; +pub const RC_AUTH_DENIED: i32 = -6; + +// ── Invoke-path helpers ────────────────────────────────────────────── + +/// True if any of the three mandatory `boj_cartridge_invoke` output-path +/// pointers is null. Use at the top of every invoke to short-circuit to +/// `RC_BAD_ARGS`. +pub fn invokeArgsNull( + tool_name: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) bool { + return tool_name == null or out_buf == null or in_out_len == null; +} + +/// Compare a C-NUL-terminated tool-name pointer against a Zig string +/// literal. Caller must have already verified `tool_name` is non-null +/// (usually via `invokeArgsNull`). +pub fn toolIs(tool_name: [*c]const u8, expected: []const u8) bool { + const s = std.mem.span(@as([*:0]const u8, @ptrCast(tool_name))); + return std.mem.eql(u8, s, expected); +} + +/// Copy `body` into `out_buf[0..*in_out_len]` (as a capacity) and update +/// `*in_out_len` to the number of bytes written. Returns `RC_SUCCESS`. +/// +/// If `body.len` exceeds the current capacity stored in `*in_out_len`, +/// sets `*in_out_len` to the required size and returns +/// `RC_BUFFER_TOO_SMALL` β€” the caller is then expected to re-allocate +/// and retry, per ADR-0006 Β§Memory ownership. +/// +/// Caller must have already verified that `out_buf` and `in_out_len` +/// are non-null. +pub fn writeResult( + out_buf: [*c]u8, + in_out_len: [*c]usize, + body: []const u8, +) i32 { + const cap = in_out_len.*; + if (body.len > cap) { + in_out_len.* = body.len; + return RC_BUFFER_TOO_SMALL; + } + @memcpy(out_buf[0..body.len], body); + in_out_len.* = body.len; + return RC_SUCCESS; +} + +// ── Tests ──────────────────────────────────────────────────────────── + +test "writeResult: body fits, writes and sets length" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); + try std.testing.expectEqualStrings("hello", buf[0..len]); +} + +test "writeResult: too small returns -3 and sets required length" { + var buf: [2]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_BUFFER_TOO_SMALL, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: exact-fit succeeds" { + var buf: [5]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: empty body" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, ""); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 0), len); +} + +test "toolIs: matches and rejects" { + const name: [*:0]const u8 = "foo"; + try std.testing.expect(toolIs(@ptrCast(name), "foo")); + try std.testing.expect(!toolIs(@ptrCast(name), "bar")); + try std.testing.expect(!toolIs(@ptrCast(name), "foobar")); + try std.testing.expect(!toolIs(@ptrCast(name), "fo")); +} + +test "invokeArgsNull: detects each null slot" { + var buf: [4]u8 = undefined; + var len: usize = 4; + const name: [*:0]const u8 = "x"; + try std.testing.expect(!invokeArgsNull(@ptrCast(name), &buf, &len)); + try std.testing.expect(invokeArgsNull(null, &buf, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), null, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), &buf, null)); +} diff --git a/cartridges/domains/languages/affinescript-mcp/minter.toml b/cartridges/domains/languages/affinescript-mcp/minter.toml new file mode 100644 index 0000000..48af0af --- /dev/null +++ b/cartridges/domains/languages/affinescript-mcp/minter.toml @@ -0,0 +1,20 @@ +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> + +name = "affinescript-mcp" +description = "AffineScript language cartridge β€” type checking, parsing, formatting, error explanation, stdlib browsing, syntax reference, snippet evaluation" +version = "0.1.0" +domain = "Languages" +protocols = [ + "MCP", + "REST", +] +tier = "Ayo" +backend = "universal" +generate_panel = true + +[auth] +method = "none" + +[api] +base_url = "local://affinescript" diff --git a/cartridges/domains/languages/affinescript-mcp/mod.js b/cartridges/domains/languages/affinescript-mcp/mod.js new file mode 100644 index 0000000..5925abd --- /dev/null +++ b/cartridges/domains/languages/affinescript-mcp/mod.js @@ -0,0 +1,581 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// affinescript-mcp/mod.js -- AffineScript language cartridge implementation. +// +// Provides MCP tool handlers for AffineScript compiler operations: +// - Type checking (via `affinescript check --json`) +// - Parsing (via `affinescript parse`) +// - Formatting (built-in indentation formatter) +// - Linting (via `affinescript lint --json`) +// - Compilation (via `affinescript compile --json`) +// - Hover (via `affinescript hover FILE LINE COL`) +// - Goto-definition (via `affinescript goto-def FILE LINE COL`) +// - Completion (via `affinescript complete FILE LINE COL`) +// - Error explanation (static lookup) +// - Standard library browsing (static reference) +// - Syntax reference (static lookup) +// - Snippet evaluation (via `affinescript eval`) +// +// Auth: None required β€” local compiler invocation. +// Compiler: OCaml-based, installed at `affinescript` on PATH. +// +// Usage: import { handleTool } from "./mod.js"; + +// --------------------------------------------------------------------------- +// Subprocess helper β€” invokes the affinescript compiler CLI. +// --------------------------------------------------------------------------- + +async function runCompiler(args, input) { + try { + const cmd = new Deno.Command("affinescript", { + args, + stdin: input ? "piped" : "null", + stdout: "piped", + stderr: "piped", + }); + + const proc = cmd.spawn(); + + if (input) { + const writer = proc.stdin.getWriter(); + await writer.write(new TextEncoder().encode(input)); + await writer.close(); + } + + const { code, stdout, stderr } = await proc.output(); + const dec = new TextDecoder(); + + return { + exitCode: code, + stdout: dec.decode(stdout), + stderr: dec.decode(stderr), + }; + } catch (e) { + return { + exitCode: -1, + stdout: "", + stderr: `Failed to invoke affinescript: ${e.message}. Is the compiler installed?`, + }; + } +} + +// --------------------------------------------------------------------------- +// Error code reference β€” maps codes to explanations. +// Categories: E0 (parse), E1 (type), E2 (borrow/affine), E3 (effect), +// E4 (quantity), E5 (name), E6 (refinement), W (warning), L (lint). +// --------------------------------------------------------------------------- + +const ERROR_CODES = { + // Parse errors + "E0001": { category: "Parse", title: "Unexpected token", description: "The parser encountered a token it did not expect at this position.", fix: "Check for missing semicolons, unmatched braces, or typos in keywords." }, + "E0002": { category: "Parse", title: "Unterminated string literal", description: "A string literal was opened but never closed.", fix: "Add the closing quote character." }, + "E0003": { category: "Parse", title: "Invalid numeric literal", description: "A number literal has invalid format.", fix: "Check for stray decimal points, invalid suffixes, or mixed radix digits." }, + + // Type errors + "E1001": { category: "Type", title: "Type mismatch", description: "Expected one type but found another.", fix: "Check that the expression's type matches the expected type in context." }, + "E1002": { category: "Type", title: "Undefined type", description: "Referenced a type that has not been defined.", fix: "Declare the type or import the module that defines it." }, + "E1003": { category: "Type", title: "Infinite type", description: "Type inference produced a recursive type without an explicit recursive wrapper.", fix: "Add an explicit type annotation or use a named recursive type." }, + + // Borrow/affine errors + "E2001": { category: "Borrow", title: "Use after move", description: "A value with affine or linear type was used after being moved.", fix: "Clone the value before moving, or restructure to avoid the second use." }, + "E2002": { category: "Borrow", title: "Double move", description: "A linear/affine value was moved more than once.", fix: "Ensure each value flows through exactly one consumer." }, + "E2003": { category: "Borrow", title: "Linear value not consumed", description: "A linear value went out of scope without being consumed.", fix: "Use the value or explicitly drop it with a handler." }, + "E2004": { category: "Borrow", title: "Invalid borrow", description: "Attempted to borrow a value that is not available for borrowing.", fix: "Check that the value has not been moved and the borrow lifetime is valid." }, + + // Effect errors + "E3001": { category: "Effect", title: "Unhandled effect", description: "A computation performs an effect that is not handled in the current scope.", fix: "Wrap the computation in an effect handler or propagate the effect in the type signature." }, + "E3002": { category: "Effect", title: "Effect mismatch", description: "Handler does not match the expected effect signature.", fix: "Ensure handler operations match the effect declaration." }, + + // Quantity errors + "E4001": { category: "Quantity", title: "Quantity violation", description: "A value was used in a way that violates its quantity annotation (linear, affine, unrestricted).", fix: "Check the quantity qualifier and adjust usage accordingly." }, + + // Name resolution errors + "E5001": { category: "Name", title: "Undefined variable", description: "Referenced a variable that has not been defined in scope.", fix: "Define the variable or check for typos." }, + "E5002": { category: "Name", title: "Duplicate definition", description: "A name was defined more than once in the same scope.", fix: "Rename one of the definitions or use a different scope." }, + + // Refinement type errors + "E6001": { category: "Refinement", title: "Refinement type violation", description: "A value does not satisfy the refinement predicate on its type.", fix: "Ensure the value meets the declared constraints." }, + + // Warnings + "W0001": { category: "Warning", title: "Unused variable", description: "A variable was declared but never used.", fix: "Remove the variable or prefix with underscore (_) to mark as intentionally unused." }, + "W0002": { category: "Warning", title: "Unreachable code", description: "Code after this point can never be executed.", fix: "Remove the unreachable code or fix the control flow." }, + "W0003": { category: "Warning", title: "Unnecessary qualification", description: "An explicitly unrestricted variable could be inferred automatically.", fix: "Remove the explicit qualifier β€” it will be inferred." }, + + // Lint + "L0001": { category: "Lint", title: "Non-standard naming", description: "Name does not follow AffineScript naming conventions (snake_case for values, PascalCase for types).", fix: "Rename to follow conventions." }, +}; + +// --------------------------------------------------------------------------- +// Standard library reference +// --------------------------------------------------------------------------- + +const STDLIB = { + types: [ + { name: "Int", description: "Arbitrary-precision integer", category: "types" }, + { name: "Float", description: "64-bit IEEE 754 floating-point", category: "types" }, + { name: "Bool", description: "Boolean (true/false)", category: "types" }, + { name: "String", description: "UTF-8 string (immutable)", category: "types" }, + { name: "Unit", description: "Unit type (single value ())", category: "types" }, + { name: "List", description: "Immutable linked list", category: "collections" }, + { name: "Array", description: "Mutable contiguous array", category: "collections" }, + { name: "Map", description: "Immutable hash map", category: "collections" }, + { name: "Set", description: "Immutable hash set", category: "collections" }, + { name: "Option", description: "Optional value (Some(x) | None)", category: "types" }, + { name: "Result", description: "Error handling (Ok(x) | Err(e))", category: "types" }, + { name: "Channel", description: "Typed channel for effect-based concurrency", category: "effects" }, + ], + functions: [ + { name: "print", signature: "fn print(x: String) -> Unit / IO", category: "io" }, + { name: "println", signature: "fn println(x: String) -> Unit / IO", category: "io" }, + { name: "read_line", signature: "fn read_line() -> String / IO", category: "io" }, + { name: "read_file", signature: "fn read_file(path: String) -> Result[String, IOError] / IO", category: "io" }, + { name: "map", signature: "fn map[A, B](xs: List[A], f: fn(A) -> B) -> List[B]", category: "collections" }, + { name: "filter", signature: "fn filter[A](xs: List[A], f: fn(A) -> Bool) -> List[A]", category: "collections" }, + { name: "fold", signature: "fn fold[A, B](xs: List[A], init: B, f: fn(B, A) -> B) -> B", category: "collections" }, + { name: "length", signature: "fn length[A](xs: List[A]) -> Int", category: "collections" }, + { name: "to_string", signature: "fn to_string[A: Show](x: A) -> String", category: "types" }, + { name: "clone", signature: "fn clone[A: Clone](x: &A) -> A", category: "types" }, + ], + effects: [ + { name: "IO", description: "Input/output side effects", category: "effects" }, + { name: "State", description: "Mutable state effect: get, put, modify", category: "effects" }, + { name: "Exception", description: "Exception effect: raise, catch", category: "effects" }, + { name: "Async", description: "Asynchronous computation effect: await, spawn", category: "effects" }, + { name: "NonDet", description: "Non-deterministic choice effect", category: "effects" }, + ], + traits: [ + { name: "Show", description: "Convert to string representation", category: "traits" }, + { name: "Eq", description: "Structural equality comparison", category: "traits" }, + { name: "Ord", description: "Total ordering", category: "traits" }, + { name: "Clone", description: "Deep copy (unrestricted types only)", category: "traits" }, + { name: "Hash", description: "Hash value computation", category: "traits" }, + { name: "Default", description: "Default value construction", category: "traits" }, + ], +}; + +// --------------------------------------------------------------------------- +// Syntax reference +// --------------------------------------------------------------------------- + +const SYNTAX_REF = { + "fn": { title: "Function definition", syntax: "fn name(param: Type) -> ReturnType / Effects { body }", example: "fn add(x: Int, y: Int) -> Int {\n x + y\n}", notes: "Functions are first-class values. Effect annotations after `/` are optional." }, + "let": { title: "Variable binding", syntax: "let name: Type = expr", example: "let x: Int = 42\nlet y = \"hello\" // type inferred", notes: "Bindings are immutable by default. Type annotations are optional when inferable." }, + "type": { title: "Type alias", syntax: "type Name = ExistingType", example: "type Pair[A, B] = (A, B)\ntype Predicate[A] = fn(A) -> Bool", notes: "Creates a transparent alias β€” no new nominal type." }, + "struct": { title: "Structure type", syntax: "struct Name { field: Type, ... }", example: "struct Point {\n x: Float,\n y: Float,\n}", notes: "Product type with named fields. Supports pattern matching." }, + "enum": { title: "Enumeration type", syntax: "enum Name { Variant1(Type), Variant2, ... }", example: "enum Shape {\n Circle(Float),\n Rectangle(Float, Float),\n Point,\n}", notes: "Sum type (tagged union). Each variant can carry data." }, + "effect": { title: "Effect declaration", syntax: "effect Name { op(Type) -> Type }", example: "effect State[S] {\n get() -> S\n put(S) -> Unit\n}", notes: "Algebraic effects declare operations that can be handled by effect handlers." }, + "handler": { title: "Effect handler", syntax: "handler name { op(args) -> resume(result) }", example: "handler state_handler[S](init: S) {\n get() -> resume(current_state)\n put(s) -> resume(())\n}", notes: "Handlers provide implementations for effect operations. `resume` is the continuation." }, + "match": { title: "Pattern matching", syntax: "match expr { pattern => body, ... }", example: "match opt {\n Some(x) => x + 1,\n None => 0,\n}", notes: "Exhaustive pattern matching. Compiler checks completeness." }, + "linear": { title: "Linear type qualifier", syntax: "linear Type", example: "let handle: linear FileHandle = open(\"data.txt\")\n// handle MUST be used exactly once", notes: "Linear values must be consumed exactly once. Prevents resource leaks." }, + "affine": { title: "Affine type qualifier", syntax: "affine Type", example: "let token: affine AuthToken = authenticate()\n// token can be used at most once", notes: "Affine values can be used zero or one times. Dropped values are safe." }, + "unrestricted": { title: "Unrestricted type qualifier", syntax: "unrestricted Type", example: "let x: unrestricted Int = 42\n// x can be used any number of times", notes: "Default qualifier for most types. No usage restrictions." }, + "borrow": { title: "Borrow expression", syntax: "borrow value", example: "fn peek(list: &List[Int]) -> Int {\n // list is borrowed β€” not consumed\n list.head()\n}", notes: "Creates a temporary reference without consuming the value." }, + "move": { title: "Move expression", syntax: "move value", example: "let a: linear Channel = create_channel()\nlet b = move a // ownership transferred\n// a is no longer valid", notes: "Explicitly transfers ownership of a value." }, + "if": { title: "Conditional", syntax: "if condition { then } else { otherwise }", example: "if x > 0 {\n \"positive\"\n} else {\n \"non-positive\"\n}", notes: "If-else is an expression β€” both branches must have compatible types." }, + "for": { title: "For loop", syntax: "for var in iterable { body }", example: "for item in list {\n println(to_string(item))\n}", notes: "Iterates over any type implementing the Iterator trait." }, + "return": { title: "Return statement", syntax: "return expr", example: "fn early_exit(x: Int) -> String {\n if x < 0 { return \"negative\" }\n \"non-negative\"\n}", notes: "Explicit early return. The last expression in a block is the implicit return." }, +}; + +// --------------------------------------------------------------------------- +// Tool handler dispatch +// --------------------------------------------------------------------------- + +export async function handleTool(toolName, args) { + switch (toolName) { + + // --- Type checking --- + + case "affinescript_check": { + if (!args.source) return { error: "Missing required field: source" }; + + const tmpFile = `/tmp/boj_afs_${crypto.randomUUID()}.as`; + + try { + await Deno.writeTextFile(tmpFile, args.source); + // --json emits a structured JSON object on stderr + const result = await runCompiler(["check", "--json", tmpFile], null); + + let report; + try { + report = JSON.parse(result.stderr.trim()); + } catch { + // Fallback: compiler stderr was not JSON (unexpected) + report = { success: result.exitCode === 0, diagnostics: [], raw: result.stderr }; + } + + return { + status: result.exitCode === 0 ? 200 : 422, + data: report, + }; + } finally { + try { await Deno.remove(tmpFile); } catch { /* ignore */ } + } + } + + // --- Parsing --- + + case "affinescript_parse": { + if (!args.source) return { error: "Missing required field: source" }; + + const tmpFile = `/tmp/boj_afs_${crypto.randomUUID()}.as`; + + try { + await Deno.writeTextFile(tmpFile, args.source); + const result = await runCompiler(["parse", tmpFile], null); + + return { + status: result.exitCode === 0 ? 200 : 422, + data: { + success: result.exitCode === 0, + ast: result.stdout || undefined, + errors: result.stderr || undefined, + }, + }; + } finally { + try { await Deno.remove(tmpFile); } catch { /* ignore */ } + } + } + + // --- Formatting --- + + case "affinescript_format": { + if (!args.source) return { error: "Missing required field: source" }; + + const tabSize = args.tab_size || 2; + const useTabs = args.use_tabs || false; + const indent = useTabs ? "\t" : " ".repeat(tabSize); + + const lines = args.source.split("\n"); + const formatted = []; + let indentLevel = 0; + + for (const line of lines) { + const trimmed = line.trim(); + + if (trimmed.startsWith("}") || trimmed.startsWith("]") || trimmed.startsWith(")")) { + indentLevel = Math.max(0, indentLevel - 1); + } + + formatted.push(trimmed.length > 0 ? indent.repeat(indentLevel) + trimmed : ""); + + if (trimmed.endsWith("{") || trimmed.endsWith("[") || trimmed.endsWith("(")) { + indentLevel += 1; + } + } + + return { + status: 200, + data: { + formatted: formatted.join("\n"), + changed: formatted.join("\n") !== args.source, + }, + }; + } + + // --- Error explanation --- + + case "affinescript_explain_error": { + if (!args.code) return { error: "Missing required field: code" }; + + const code = args.code.toUpperCase(); + const entry = ERROR_CODES[code]; + + if (!entry) { + return { + status: 404, + data: { + code, + error: `Unknown error code: ${code}. Valid prefixes: E0 (parse), E1 (type), E2 (borrow), E3 (effect), E4 (quantity), E5 (name), E6 (refinement), W (warning), L (lint).`, + }, + }; + } + + return { + status: 200, + data: { code, ...entry }, + }; + } + + // --- Stdlib browsing --- + + case "affinescript_stdlib": { + if (!args.query) return { error: "Missing required field: query" }; + + const query = args.query.toLowerCase(); + const category = args.category?.toLowerCase(); + const results = []; + + for (const [section, items] of Object.entries(STDLIB)) { + if (category && section !== category) continue; + + for (const item of items) { + const nameMatch = item.name.toLowerCase().includes(query); + const descMatch = (item.description || "").toLowerCase().includes(query); + const catMatch = (item.category || "").toLowerCase().includes(query); + + if (nameMatch || descMatch || catMatch) { + results.push({ section, ...item }); + } + } + } + + return { + status: 200, + data: { query: args.query, results, count: results.length }, + }; + } + + // --- Syntax reference --- + + case "affinescript_syntax_ref": { + if (!args.construct) return { error: "Missing required field: construct" }; + + const key = args.construct.toLowerCase(); + const entry = SYNTAX_REF[key]; + + if (!entry) { + const available = Object.keys(SYNTAX_REF).join(", "); + return { + status: 404, + data: { + construct: args.construct, + error: `Unknown construct: ${args.construct}. Available: ${available}`, + }, + }; + } + + return { + status: 200, + data: { construct: args.construct, ...entry }, + }; + } + + // --- Snippet evaluation --- + + case "affinescript_snippet": { + if (!args.source) return { error: "Missing required field: source" }; + + const tmpFile = `/tmp/boj_afs_${crypto.randomUUID()}.as`; + + try { + await Deno.writeTextFile(tmpFile, args.source); + const result = await runCompiler(["eval", tmpFile], null); + + return { + status: result.exitCode === 0 ? 200 : 422, + data: { + success: result.exitCode === 0, + result: result.stdout?.trim() || undefined, + errors: result.stderr || undefined, + }, + }; + } finally { + try { await Deno.remove(tmpFile); } catch { /* ignore */ } + } + } + + // --- Linting --- + + case "affinescript_lint": { + if (!args.source) return { error: "Missing required field: source" }; + + const tmpFile = `/tmp/boj_afs_${crypto.randomUUID()}.as`; + + try { + await Deno.writeTextFile(tmpFile, args.source); + const result = await runCompiler(["lint", "--json", tmpFile], null); + + let report; + try { + report = JSON.parse(result.stderr.trim()); + } catch { + report = { success: result.exitCode === 0, diagnostics: [], raw: result.stderr }; + } + + return { + status: result.exitCode === 0 ? 200 : 422, + data: report, + }; + } finally { + try { await Deno.remove(tmpFile); } catch { /* ignore */ } + } + } + + // --- Compilation --- + + case "affinescript_compile": { + if (!args.source) return { error: "Missing required field: source" }; + + const target = args.target || "wasm"; + const tmpSrc = `/tmp/boj_afs_${crypto.randomUUID()}.as`; + const ext = target === "julia" ? "jl" : "wasm"; + const tmpOut = `/tmp/boj_afs_out_${crypto.randomUUID()}.${ext}`; + + const compileArgs = ["compile", "--json"]; + if (target === "wasm-gc") compileArgs.push("--wasm-gc"); + compileArgs.push("-o", tmpOut, tmpSrc); + + try { + await Deno.writeTextFile(tmpSrc, args.source); + const result = await runCompiler(compileArgs, null); + + let report; + try { + report = JSON.parse(result.stderr.trim()); + } catch { + report = { success: result.exitCode === 0, diagnostics: [], raw: result.stderr }; + } + + return { + status: result.exitCode === 0 ? 200 : 422, + data: { ...report, target }, + }; + } finally { + try { await Deno.remove(tmpSrc); } catch { /* ignore */ } + try { await Deno.remove(tmpOut); } catch { /* ignore */ } + } + } + + // --- Hover --- + + case "affinescript_hover": { + if (!args.source) return { error: "Missing required field: source" }; + if (args.line == null) return { error: "Missing required field: line" }; + if (args.col == null) return { error: "Missing required field: col" }; + + const tmpFile = `/tmp/boj_afs_${crypto.randomUUID()}.as`; + + try { + await Deno.writeTextFile(tmpFile, args.source); + // hover outputs JSON on stdout; line/col are 1-based + const result = await runCompiler( + ["hover", tmpFile, String(args.line), String(args.col)], + null + ); + + let info; + try { + info = JSON.parse(result.stdout.trim()); + } catch { + info = { found: false, raw: result.stdout }; + } + + return { + status: 200, + data: info, + }; + } finally { + try { await Deno.remove(tmpFile); } catch { /* ignore */ } + } + } + + // --- Goto-definition --- + + case "affinescript_goto_def": { + if (!args.source) return { error: "Missing required field: source" }; + if (args.line == null) return { error: "Missing required field: line" }; + if (args.col == null) return { error: "Missing required field: col" }; + + const tmpFile = `/tmp/boj_afs_${crypto.randomUUID()}.as`; + + try { + await Deno.writeTextFile(tmpFile, args.source); + // goto-def outputs JSON on stdout; line/col are 1-based + const result = await runCompiler( + ["goto-def", tmpFile, String(args.line), String(args.col)], + null + ); + + let info; + try { + info = JSON.parse(result.stdout.trim()); + } catch { + info = { found: false, raw: result.stdout }; + } + + return { + status: 200, + data: info, + }; + } finally { + try { await Deno.remove(tmpFile); } catch { /* ignore */ } + } + } + + // --- Completion --- + + case "affinescript_complete": { + if (!args.source) return { error: "Missing required field: source" }; + if (args.line == null) return { error: "Missing required field: line" }; + if (args.col == null) return { error: "Missing required field: col" }; + + const tmpFile = `/tmp/boj_afs_${crypto.randomUUID()}.as`; + + try { + await Deno.writeTextFile(tmpFile, args.source); + // complete outputs a JSON array on stdout; line/col are 1-based + const result = await runCompiler( + ["complete", tmpFile, String(args.line), String(args.col)], + null + ); + + let items; + try { + items = JSON.parse(result.stdout.trim()); + } catch { + items = []; + } + + return { + status: 200, + data: { items, count: Array.isArray(items) ? items.length : 0 }, + }; + } finally { + try { await Deno.remove(tmpFile); } catch { /* ignore */ } + } + } + + default: + return { error: `Unknown affinescript-mcp tool: ${toolName}` }; + } +} + +// --------------------------------------------------------------------------- +// Diagnostic parser β€” converts compiler stderr to structured diagnostics. +// Format: "file:line:col: severity [CODE]: message" +// --------------------------------------------------------------------------- + +function parseDiagnostics(stderr, filename) { + if (!stderr) return []; + + const diagnostics = []; + const re = /(.+):(\d+):(\d+):\s*(error|warning|hint|info|note)\s*(?:\[([A-Z]\d+)\])?:\s*(.+)/g; + let match; + + while ((match = re.exec(stderr)) !== null) { + diagnostics.push({ + file: filename, + line: parseInt(match[2], 10), + column: parseInt(match[3], 10), + severity: match[4], + code: match[5] || null, + message: match[6], + }); + } + + return diagnostics; +} + +// --------------------------------------------------------------------------- +// Cartridge metadata export +// --------------------------------------------------------------------------- + +export const metadata = { + name: "affinescript-mcp", + version: "0.1.0", + domain: "Languages", + tier: "Ayo", + protocols: ["MCP", "REST"], + toolCount: 12, +}; diff --git a/cartridges/domains/languages/affinescript-mcp/panels/manifest.json b/cartridges/domains/languages/affinescript-mcp/panels/manifest.json new file mode 100644 index 0000000..bc8839e --- /dev/null +++ b/cartridges/domains/languages/affinescript-mcp/panels/manifest.json @@ -0,0 +1,89 @@ +{ + "$schema": "https://panll.dev/schemas/panel-manifest/v1.json", + "spdx": "MPL-2.0", + "copyright": "Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath)", + "cartridge": "affinescript-mcp", + "domain": "Languages", + "version": "0.1.0", + "panels": [ + { + "id": "afs-session-status", + "title": "Compiler Status", + "description": "AffineScript compiler session state (Ready / Busy / Error)", + "type": "status-indicator", + "data_source": { + "endpoint": "/affinescript/state", + "method": "GET", + "refresh_interval_ms": 5000 + }, + "widgets": [ + { + "type": "state-badge", + "field": "state", + "states": { + "ready": { "color": "#2ecc71", "icon": "check-circle" }, + "busy": { "color": "#3498db", "icon": "loader" }, + "error": { "color": "#e74c3c", "icon": "alert-triangle" } + } + } + ] + }, + { + "id": "afs-check-count", + "title": "Type Checks", + "description": "Number of type-check invocations in the current session", + "type": "metric", + "data_source": { + "endpoint": "/affinescript/status", + "method": "GET", + "refresh_interval_ms": 10000 + }, + "widgets": [ + { + "type": "counter", + "field": "check_count", + "label": "Checks", + "icon": "shield-check" + } + ] + }, + { + "id": "afs-parse-count", + "title": "Parse Operations", + "description": "Number of parse invocations in the current session", + "type": "metric", + "data_source": { + "endpoint": "/affinescript/status", + "method": "GET", + "refresh_interval_ms": 10000 + }, + "widgets": [ + { + "type": "counter", + "field": "parse_count", + "label": "Parses", + "icon": "file-text" + } + ] + }, + { + "id": "afs-format-count", + "title": "Format Operations", + "description": "Number of format invocations in the current session", + "type": "metric", + "data_source": { + "endpoint": "/affinescript/status", + "method": "GET", + "refresh_interval_ms": 10000 + }, + "widgets": [ + { + "type": "counter", + "field": "format_count", + "label": "Formats", + "icon": "align-left" + } + ] + } + ] +} diff --git a/cartridges/domains/languages/affinescript-mcp/tests/integration_test.sh b/cartridges/domains/languages/affinescript-mcp/tests/integration_test.sh new file mode 100755 index 0000000..ec2d399 --- /dev/null +++ b/cartridges/domains/languages/affinescript-mcp/tests/integration_test.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +# +# Integration test for affinescript-mcp cartridge. +# Tests MCP tool invocations via the BoJ server REST API. + +set -euo pipefail + +BOJ_URL="${BOJ_URL:-http://localhost:7700}" +PASS=0 +FAIL=0 + +check() { + local desc="$1" tool="$2" args="$3" expect="$4" + local response + response=$(curl -s -X POST "${BOJ_URL}/mcp/affinescript-mcp" \ + -H "Content-Type: application/json" \ + -d "{\"tool\": \"${tool}\", \"args\": ${args}}" 2>/dev/null || echo '{"error":"connection_failed"}') + + if echo "$response" | grep -q "$expect"; then + echo "PASS: $desc" + PASS=$((PASS + 1)) + else + echo "FAIL: $desc (expected '$expect' in response)" + echo " Got: $response" + FAIL=$((FAIL + 1)) + fi +} + +echo "=== affinescript-mcp integration tests ===" +echo "" + +# Syntax reference (pure lookup β€” no compiler needed) +check "syntax_ref: fn keyword" \ + "affinescript_syntax_ref" \ + '{"construct": "fn"}' \ + "Function definition" + +check "syntax_ref: linear keyword" \ + "affinescript_syntax_ref" \ + '{"construct": "linear"}' \ + "Linear type qualifier" + +check "syntax_ref: unknown keyword" \ + "affinescript_syntax_ref" \ + '{"construct": "foobar"}' \ + "Unknown construct" + +# Error explanation (pure lookup) +check "explain_error: E2001 use after move" \ + "affinescript_explain_error" \ + '{"code": "E2001"}' \ + "Use after move" + +check "explain_error: unknown code" \ + "affinescript_explain_error" \ + '{"code": "E9999"}' \ + "Unknown error code" + +# Stdlib search (pure lookup) +check "stdlib: search for Option" \ + "affinescript_stdlib" \ + '{"query": "Option"}' \ + "Optional value" + +check "stdlib: search for IO effect" \ + "affinescript_stdlib" \ + '{"query": "IO"}' \ + "Input/output" + +# Format (pure β€” no compiler needed) +check "format: basic indentation" \ + "affinescript_format" \ + '{"source": "fn main() {\nlet x = 1\n}"}' \ + "formatted" + +echo "" +echo "=== Results: ${PASS} passed, ${FAIL} failed ===" +exit $FAIL diff --git a/cartridges/domains/languages/lang-mcp/README.adoc b/cartridges/domains/languages/lang-mcp/README.adoc new file mode 100644 index 0000000..7b3e2f1 --- /dev/null +++ b/cartridges/domains/languages/lang-mcp/README.adoc @@ -0,0 +1,55 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += lang-mcp +Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk> +:spdx: MPL-2.0 +:tier: Ayo +:domain: Languages +:protocols: MCP, REST + +== Overview + +Multi-language session manager for the nextgen-languages family: Eclexia, AffineScript, BetLang, Ephapax, MyLang, WokeLang, Anvomidav, Phronesis, Error-lang, Julia-the-Viper, Me-dialect, Oblibeny. Tracks per-language sessions, delegates type-checking and evaluation to each language's CLI tool, and provides a unified interface across all dialects. + +== Tools (9) + +[cols="2,4"] +|=== +| Tool | Description + +| `lang_list` | List all supported languages with their IDs, status (installed/missing), and available operations. Checks whether each language's CLI binary is on PATH. +| `lang_session_create` | Create a language session. Returns a session ID used in subsequent lang_check, lang_eval, and lang_compile calls. The dialect_mode controls whether Julia-the-Viper syntax extensions are injected. +| `lang_session_status` | Get the current state of a language session (idle, compiling, checked, evaluating, error) and its language/dialect. +| `lang_check` | Type-check source code in the session's language. Returns structured diagnostics (errors, warnings, hints). +| `lang_eval` | Evaluate source code in the session's language using the interpreter/REPL. Returns the evaluation result or error output. +| `lang_compile` | Compile source code in the session's language to the default compilation target. Returns diagnostics and indicates whether compilation succeeded. +| `lang_hover` | Request type and documentation information at a cursor position in the session's language. Delegates to the language's LSP server or compiler hover command if available. +| `lang_complete` | Request completion candidates at a cursor position in the session's language. +| `lang_session_close` | Close a language session and free its resources. +|=== + +== Architecture + +Two-layer stack (ABI layer not yet formalised): + +[cols="1,1,3"] +|=== +| Layer | Language | Purpose + +| FFI | Zig | C-ABI implementation +| Adapter | Zig | Adapter bridge to the BoJ unified adapter protocol +|=== + +== Building + +[source,bash] +---- +cd ffi && zig build && zig build test +cd adapter && zig build +---- + +== Status + +Alpha (CRG D). Cartridge metadata declared in `cartridge.json`; 9 tool(s) exposed. +Not yet dogfooded externally. See `docs/READINESS.md` at repo root for the +project-wide grade table and `docs/practice/DOGFOOD-LOG.adoc` for usage evidence. diff --git a/cartridges/domains/languages/lang-mcp/adapter/README.adoc b/cartridges/domains/languages/lang-mcp/adapter/README.adoc new file mode 100644 index 0000000..7983524 --- /dev/null +++ b/cartridges/domains/languages/lang-mcp/adapter/README.adoc @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += lang-mcp / adapter β€” protocol bridge +:orientation: shallow + +== Purpose + +Exposes this cartridge's FFI over the wire protocols declared in +`cartridge.json`. Dispatches tool invocations to the C-ABI exports at +`../ffi/` and formats responses per protocol. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” depends on the sibling `ffi` target. +| `lang_adapter.zig` | Protocol dispatch (499 lines). Tool catalogue matches `cartridge.json`. +| `SIDELINED-*.v.adoc` | zig predecessor(s). Not built β€” zig banned 2026-04-10. +|=== + +== Invariants + +* **Stateless** β€” all state lives behind the FFI, never in the adapter. +* **Response passthrough** β€” whatever the FFI returns goes back to the wire + unmodified (no embellishment, no silent recovery). +* **Cartridge.json is source of truth** for the tool catalogue; drift between + adapter and manifest is a CI failure. + +== Test/proof surface + +No inline tests at the adapter layer β€” protocol routing is exercised by the +trunk seam tests at `ffi/zig/src/seams.zig`. + +== Read-first + +. `lang_adapter.zig` β€” dispatch function and per-protocol routers. +. `../cartridge.json` β€” the tool manifest the dispatcher mirrors. diff --git a/cartridges/domains/languages/lang-mcp/adapter/build.zig b/cartridges/domains/languages/lang-mcp/adapter/build.zig new file mode 100644 index 0000000..5b60b11 --- /dev/null +++ b/cartridges/domains/languages/lang-mcp/adapter/build.zig @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// Lang-MCP Cartridge β€” adapter build configuration (Zig 0.14+). + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const ffi_mod = b.createModule(.{ + .root_source_file = b.path("../ffi/lang_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + const adapter = b.addExecutable(.{ + .name = "lang-adapter", + .root_source_file = b.path("lang_adapter.zig"), + .target = target, + .optimize = optimize, + }); + adapter.root_module.addImport("lang_ffi", ffi_mod); + b.installArtifact(adapter); + + const run_cmd = b.addRunArtifact(adapter); + const run_step = b.step("run", "Run lang-adapter (REST :9022, gRPC :9023, GraphQL :9024)"); + run_step.dependOn(&run_cmd.step); + + const tests = b.addTest(.{ + .root_source_file = b.path("lang_adapter.zig"), + .target = target, + .optimize = optimize, + }); + tests.root_module.addImport("lang_ffi", ffi_mod); + const run_tests = b.addRunArtifact(tests); + const test_step = b.step("test", "Run adapter unit tests"); + test_step.dependOn(&run_tests.step); +} diff --git a/cartridges/domains/languages/lang-mcp/adapter/lang_adapter.zig b/cartridges/domains/languages/lang-mcp/adapter/lang_adapter.zig new file mode 100644 index 0000000..ecbe2d0 --- /dev/null +++ b/cartridges/domains/languages/lang-mcp/adapter/lang_adapter.zig @@ -0,0 +1,499 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// Lang-MCP Cartridge β€” Unified Zig adapter. +// Replaces the banned lang_adapter.v (zig removed 2026-04-10). +// +// Manages language runtime sessions for all nextgen-languages: +// Eclexia, AffineScript, BetLang, Ephapax, MyLang, WokeLang, +// Anvomidav, Phronesis, Error-lang, Julia-the-Viper, Me-dialect, Oblibeny. +// +// Exposes the lang_ffi.zig state machine via: +// REST port 9022 HTTP/1.1, JSON responses +// gRPC-compat port 9023 HTTP/1.1 + proto-style paths, JSON bodies +// GraphQL port 9024 HTTP/1.1 POST /graphql, keyword dispatch +// +// Note: lang_typecheck and lang_eval pass caller source via pointer+len; +// their output is written to a stack-allocated buffer and returned +// in the JSON response body. +// +// Build: zig build (build.zig in this directory) + +const std = @import("std"); +const ffi = @import("lang_ffi"); + +// ═══════════════════════════════════════════════════════════════════════════ +// Constants +// ═══════════════════════════════════════════════════════════════════════════ + +const VERSION = "0.1.0"; +const CARTRIDGE = "lang-mcp"; +const REST_PORT: u16 = 9022; +const GRPC_PORT: u16 = 9023; +const GQL_PORT: u16 = 9024; + +const STATE_LABELS = [_][]const u8{ "idle", "compiling", "checked", "evaluating", "err" }; + +/// Language ID labels, 1-indexed (0 unused). +const LANG_LABELS = [_][]const u8{ + "", + "eclexia", // 1 + "affinescript", // 2 + "betlang", // 3 + "ephapax", // 4 + "mylang", // 5 + "wokelang", // 6 + "anvomidav", // 7 + "phronesis", // 8 + "error_lang", // 9 + "julia_the_viper", // 10 + "me_dialect", // 11 + "oblibeny", // 12 + // 13..98 reserved + // 99 = custom +}; + +const DIALECT_MODE_LABELS = [_][]const u8{ "pure", "jtv" }; + +const GRPC_PROTO = + \\syntax = "proto3"; + \\package lang_mcp; + \\ + \\service LangService { + \\ rpc CreateSession (CreateSessionRequest) returns (SessionResponse); + \\ rpc SetUrl (SetUrlRequest) returns (SessionResponse); + \\ rpc GetState (SlotRequest) returns (SessionResponse); + \\ rpc Typecheck (SourceRequest) returns (SourceResponse); + \\ rpc Eval (SourceRequest) returns (SourceResponse); + \\ rpc EndSession (SlotRequest) returns (ReleaseResponse); + \\ rpc Health (Empty) returns (HealthResponse); + \\ rpc Types (Empty) returns (TypeInfoResponse); + \\} + \\ + \\message Empty {} + \\message SlotRequest { int32 slot = 1; } + \\message CreateSessionRequest { int32 lang_id = 1; int32 dialect_mode = 2; string name = 3; } + \\message SetUrlRequest { int32 slot = 1; string url = 2; } + \\message SourceRequest { int32 slot = 1; string source = 2; } + \\message SessionResponse { int32 slot = 1; string state = 2; string language = 3; string dialect = 4; } + \\message SourceResponse { int32 slot = 1; bool success = 2; string output = 3; } + \\message ReleaseResponse { int32 slot = 1; bool released = 2; } + \\message HealthResponse { string status = 1; string cartridge = 2; string version = 3; } + \\message TypeInfoResponse { repeated string states = 1; repeated string languages = 2; repeated string dialect_modes = 3; } +; + +const GRAPHQL_SCHEMA = + \\type Query { + \\ health: Health! + \\ session(slot: Int!): Session + \\ types: TypeInfo! + \\} + \\type Mutation { + \\ createSession(langId: Int!, dialectMode: Int, name: String!): Session! + \\ setUrl(slot: Int!, url: String!): Session! + \\ typecheck(slot: Int!, source: String!): SourceResult! + \\ eval(slot: Int!, source: String!): SourceResult! + \\ endSession(slot: Int!): ReleaseResult! + \\} + \\type Health { status: String! cartridge: String! version: String! } + \\type Session { slot: Int! state: String! language: String! dialect: String! } + \\type SourceResult { slot: Int! success: Boolean! output: String! } + \\type ReleaseResult { slot: Int! released: Boolean! } + \\type TypeInfo { states: [String!]! languages: [String!]! dialectModes: [String!]! } +; + +// ═══════════════════════════════════════════════════════════════════════════ +// JSON helpers +// ═══════════════════════════════════════════════════════════════════════════ + +fn stateLabel(s: c_int) []const u8 { + if (s >= 0 and s < @as(c_int, STATE_LABELS.len)) return STATE_LABELS[@intCast(s)]; + return "unknown"; +} + +fn langLabel(l: c_int) []const u8 { + if (l > 0 and l < @as(c_int, LANG_LABELS.len)) return LANG_LABELS[@intCast(l)]; + if (l == 99) return "custom"; + return "unknown"; +} + +fn dialectLabel(d: c_int) []const u8 { + if (d >= 0 and d < @as(c_int, DIALECT_MODE_LABELS.len)) return DIALECT_MODE_LABELS[@intCast(d)]; + return "pure"; +} + +fn sessionJson(slot: c_int, buf: []u8) []const u8 { + const state = ffi.lang_session_state(slot); + const lang = ffi.lang_session_language(slot); + const dialect = ffi.lang_session_dialect(slot); + return std.fmt.bufPrint(buf, + \\{{"slot":{d},"state":"{s}","language":"{s}","dialect":"{s}"}} + , .{ slot, stateLabel(state), langLabel(lang), dialectLabel(dialect) }) catch buf[0..0]; +} + +fn sourceResponseJson(slot: c_int, success: bool, output: []const u8, buf: []u8) []const u8 { + return std.fmt.bufPrint(buf, + \\{{"slot":{d},"success":{s},"output":{s}}} + , .{ slot, if (success) "true" else "false", output }) catch buf[0..0]; +} + +fn errorJson(buf: []u8, msg: []const u8, code: c_int) []const u8 { + return std.fmt.bufPrint(buf, \\{{"error":"{s}","code":{d}}}, .{ msg, code }) catch buf[0..0]; +} + +fn healthJson(buf: []u8) []const u8 { + return std.fmt.bufPrint(buf, \\{{"status":"ok","cartridge":"{s}","version":"{s}"}}, .{ CARTRIDGE, VERSION }) catch buf[0..0]; +} + +const TYPES_JSON = + \\{"states":["idle","compiling","checked","evaluating","err"], + \\"languages":["eclexia","affinescript","betlang","ephapax","mylang","wokelang","anvomidav","phronesis","error_lang","julia_the_viper","me_dialect","oblibeny","custom"], + \\"dialect_modes":["pure","jtv"]} +; + +// ═══════════════════════════════════════════════════════════════════════════ +// JSON body parsers +// ═══════════════════════════════════════════════════════════════════════════ + +fn parseIntField(body: []const u8, field: []const u8) ?c_int { + var buf: [32]u8 = undefined; + const needle = std.fmt.bufPrint(&buf, "\"{s}\":", .{field}) catch return null; + const idx = std.mem.indexOf(u8, body, needle) orelse return null; + const rest = std.mem.trimLeft(u8, body[idx + needle.len ..], " \t\r\n"); + var end: usize = 0; + while (end < rest.len and rest[end] >= '0' and rest[end] <= '9') : (end += 1) {} + if (end == 0) return null; + return @intCast(std.fmt.parseInt(i32, rest[0..end], 10) catch return null); +} + +/// Extract a JSON string value from body: "field":"value" β†’ value slice. +/// Returns a slice into `body`. +fn parseStringField(body: []const u8, field: []const u8, key_buf: []u8) ?[]const u8 { + const needle = std.fmt.bufPrint(key_buf, "\"{s}\":\"", .{field}) catch return null; + const idx = std.mem.indexOf(u8, body, needle) orelse return null; + const start = idx + needle.len; + var end = start; + while (end < body.len and body[end] != '"') : (end += 1) { + if (body[end] == '\\') end += 1; // skip escaped char + } + return body[start..end]; +} + +fn pathSlot(target: []const u8) ?c_int { + var it = std.mem.splitBackwardsScalar(u8, target, '/'); + while (it.next()) |seg| { + if (seg.len == 0) continue; + var all_digits = true; + for (seg) |c| { if (c < '0' or c > '9') { all_digits = false; break; } } + if (all_digits) return @intCast(std.fmt.parseInt(i32, seg, 10) catch return null); + } + return null; +} + +/// Quote a raw output string into a JSON string literal, writing into `out`. +/// Escapes backslashes and double-quotes; newlines are preserved as \n sequences. +fn jsonQuote(s: []const u8, out: []u8) []const u8 { + var pos: usize = 0; + if (pos < out.len) { out[pos] = '"'; pos += 1; } + for (s) |c| { + if (pos + 2 > out.len) break; + switch (c) { + '"' => { out[pos] = '\\'; pos += 1; out[pos] = '"'; pos += 1; }, + '\\' => { out[pos] = '\\'; pos += 1; out[pos] = '\\'; pos += 1; }, + '\n' => { out[pos] = '\\'; pos += 1; out[pos] = 'n'; pos += 1; }, + '\r' => { out[pos] = '\\'; pos += 1; out[pos] = 'r'; pos += 1; }, + else => { out[pos] = c; pos += 1; }, + } + } + if (pos < out.len) { out[pos] = '"'; pos += 1; } + return out[0..pos]; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Source operation helper β€” calls lang_typecheck or lang_eval, +// returns a JSON SourceResponse. +// ═══════════════════════════════════════════════════════════════════════════ + +fn runSourceOp( + slot: c_int, + source: []const u8, + comptime op: enum { typecheck, eval }, + resp: []u8, +) Response { + var out_raw: [131072]u8 = undefined; // 128 KiB output buffer + var quoted: [196608]u8 = undefined; // worst-case quoted: 1.5x + + const written: i32 = switch (op) { + .typecheck => ffi.lang_typecheck(slot, source.ptr, source.len, &out_raw, out_raw.len), + .eval => ffi.lang_eval(slot, source.ptr, source.len, &out_raw, out_raw.len), + }; + + if (written < 0) { + return .{ .status = std.http.Status.bad_request, .body = errorJson(resp, "operation failed", written) }; + } + + const output = out_raw[0..@intCast(written)]; + const quoted_output = jsonQuote(output, "ed); + const body = sourceResponseJson(slot, written >= 0, quoted_output, resp); + return .{ .status = std.http.Status.ok, .body = body }; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Response type +// ═══════════════════════════════════════════════════════════════════════════ + +const Response = struct { + status: std.http.Status, + body: []const u8, + content_type: []const u8 = "application/json", +}; + +// ═══════════════════════════════════════════════════════════════════════════ +// REST dispatch +// ═══════════════════════════════════════════════════════════════════════════ + +fn dispatchRest(method: std.http.Method, target: []const u8, body: []const u8, resp: []u8) Response { + const ok = std.http.Status.ok; + const bad = std.http.Status.bad_request; + + if (method == .GET and std.mem.eql(u8, target, "/health")) return .{ .status = ok, .body = healthJson(resp) }; + if (method == .GET and std.mem.eql(u8, target, "/types")) return .{ .status = ok, .body = TYPES_JSON }; + + // POST /sessions β€” create language session + if (method == .POST and std.mem.eql(u8, target, "/sessions")) { + const lang_id = parseIntField(body, "lang_id") orelse 2; // default: affinescript + const dialect = parseIntField(body, "dialect_mode") orelse 0; // default: pure + + var key_buf: [32]u8 = undefined; + const name = parseStringField(body, "name", &key_buf) orelse "session"; + + const slot = ffi.lang_session_start_dialect(lang_id, dialect, name.ptr, name.len); + if (slot < 0) return .{ .status = bad, .body = errorJson(resp, "no slots available", slot) }; + return .{ .status = ok, .body = sessionJson(slot, resp) }; + } + + if (std.mem.startsWith(u8, target, "/sessions/")) { + const slot_opt = pathSlot(std.mem.trimRight(u8, target, "/")); + + if (method == .GET and std.mem.endsWith(u8, target, "/state")) { + const slot = slot_opt orelse return .{ .status = bad, .body = errorJson(resp, "missing slot", -1) }; + return .{ .status = ok, .body = sessionJson(slot, resp) }; + } + if (method == .POST and std.mem.endsWith(u8, target, "/url")) { + const slot = slot_opt orelse return .{ .status = bad, .body = errorJson(resp, "missing slot", -1) }; + var key_buf: [16]u8 = undefined; + const url = parseStringField(body, "url", &key_buf) orelse return .{ .status = bad, .body = errorJson(resp, "missing url", -1) }; + const r = ffi.lang_session_set_url(slot, url.ptr, url.len); + if (r < 0) return .{ .status = bad, .body = errorJson(resp, "set url failed", r) }; + return .{ .status = ok, .body = sessionJson(slot, resp) }; + } + if (method == .POST and std.mem.endsWith(u8, target, "/typecheck")) { + const slot = slot_opt orelse return .{ .status = bad, .body = errorJson(resp, "missing slot", -1) }; + var key_buf: [16]u8 = undefined; + const source = parseStringField(body, "source", &key_buf) orelse body; // body IS source if no JSON wrapper + return runSourceOp(slot, source, .typecheck, resp); + } + if (method == .POST and std.mem.endsWith(u8, target, "/eval")) { + const slot = slot_opt orelse return .{ .status = bad, .body = errorJson(resp, "missing slot", -1) }; + var key_buf: [16]u8 = undefined; + const source = parseStringField(body, "source", &key_buf) orelse body; + return runSourceOp(slot, source, .eval, resp); + } + if (method == .DELETE) { + const slot = slot_opt orelse return .{ .status = bad, .body = errorJson(resp, "missing slot", -1) }; + const r = ffi.lang_session_end(slot); + if (r < 0) return .{ .status = bad, .body = errorJson(resp, "end session failed", r) }; + return .{ .status = ok, .body = std.fmt.bufPrint(resp, \\{{"slot":{d},"released":true}}, .{slot}) catch resp[0..0] }; + } + } + + return .{ .status = std.http.Status.not_found, .body = errorJson(resp, "not found", -404) }; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// gRPC-compat dispatch +// ═══════════════════════════════════════════════════════════════════════════ + +const GRPC_PREFIX = "/lang_mcp.LangService/"; + +fn dispatchGrpc(target: []const u8, body: []const u8, resp: []u8) Response { + const ok = std.http.Status.ok; + const bad = std.http.Status.bad_request; + + if (std.mem.eql(u8, target, "/proto")) return .{ .status = ok, .body = GRPC_PROTO, .content_type = "text/plain" }; + if (!std.mem.startsWith(u8, target, GRPC_PREFIX)) return .{ .status = std.http.Status.not_found, .body = errorJson(resp, "unknown gRPC path", -1) }; + + const rpc = target[GRPC_PREFIX.len..]; + + if (std.mem.eql(u8, rpc, "Health")) return .{ .status = ok, .body = healthJson(resp) }; + if (std.mem.eql(u8, rpc, "Types")) return .{ .status = ok, .body = TYPES_JSON }; + + if (std.mem.eql(u8, rpc, "CreateSession")) { + const lang_id = parseIntField(body, "lang_id") orelse 2; + const dialect = parseIntField(body, "dialect_mode") orelse 0; + var key_buf: [32]u8 = undefined; + const name = parseStringField(body, "name", &key_buf) orelse "session"; + const slot = ffi.lang_session_start_dialect(lang_id, dialect, name.ptr, name.len); + if (slot < 0) return .{ .status = bad, .body = errorJson(resp, "no slots available", slot) }; + return .{ .status = ok, .body = sessionJson(slot, resp) }; + } + + const slot = parseIntField(body, "slot") orelse return .{ .status = bad, .body = errorJson(resp, "missing slot", -1) }; + + if (std.mem.eql(u8, rpc, "GetState")) return .{ .status = ok, .body = sessionJson(slot, resp) }; + + if (std.mem.eql(u8, rpc, "SetUrl")) { + var key_buf: [16]u8 = undefined; + const url = parseStringField(body, "url", &key_buf) orelse return .{ .status = bad, .body = errorJson(resp, "missing url", -1) }; + const r = ffi.lang_session_set_url(slot, url.ptr, url.len); + if (r < 0) return .{ .status = bad, .body = errorJson(resp, "set url failed", r) }; + return .{ .status = ok, .body = sessionJson(slot, resp) }; + } + if (std.mem.eql(u8, rpc, "Typecheck")) { + var key_buf: [16]u8 = undefined; + const source = parseStringField(body, "source", &key_buf) orelse ""; + return runSourceOp(slot, source, .typecheck, resp); + } + if (std.mem.eql(u8, rpc, "Eval")) { + var key_buf: [16]u8 = undefined; + const source = parseStringField(body, "source", &key_buf) orelse ""; + return runSourceOp(slot, source, .eval, resp); + } + if (std.mem.eql(u8, rpc, "EndSession")) { + const r = ffi.lang_session_end(slot); + if (r < 0) return .{ .status = bad, .body = errorJson(resp, "end session failed", r) }; + return .{ .status = ok, .body = std.fmt.bufPrint(resp, \\{{"slot":{d},"released":true}}, .{slot}) catch resp[0..0] }; + } + + return .{ .status = std.http.Status.not_found, .body = errorJson(resp, "unknown gRPC method", -1) }; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// GraphQL dispatch +// ═══════════════════════════════════════════════════════════════════════════ + +fn dispatchGraphql(q: []const u8, resp: []u8) Response { + const ok = std.http.Status.ok; + const bad = std.http.Status.bad_request; + const has = std.mem.indexOf; + + if (has(u8, q, "__schema") != null) return .{ .status = ok, .body = std.fmt.bufPrint(resp, \\{{"data":{{"__schema":{{"sdl":"{s}"}}}}}}, .{GRAPHQL_SCHEMA}) catch resp[0..0] }; + if (has(u8, q, "health") != null and has(u8, q, "mutation") == null) return .{ .status = ok, .body = std.fmt.bufPrint(resp, \\{{"data":{{"health":{s}}}}}, .{healthJson(resp)}) catch resp[0..0] }; + if (has(u8, q, "types") != null and has(u8, q, "mutation") == null) return .{ .status = ok, .body = std.fmt.bufPrint(resp, \\{{"data":{{"types":{s}}}}}, .{TYPES_JSON}) catch resp[0..0] }; + + if (has(u8, q, "createSession") != null) { + const lang_id = parseIntField(q, "langId") orelse 2; + const dialect = parseIntField(q, "dialectMode") orelse 0; + var key_buf: [32]u8 = undefined; + const name = parseStringField(q, "name", &key_buf) orelse "session"; + const slot = ffi.lang_session_start_dialect(lang_id, dialect, name.ptr, name.len); + if (slot < 0) return .{ .status = bad, .body = errorJson(resp, "no slots available", slot) }; + return .{ .status = ok, .body = std.fmt.bufPrint(resp, \\{{"data":{{"createSession":{s}}}}}, .{sessionJson(slot, resp)}) catch resp[0..0] }; + } + + const slot = parseIntField(q, "slot") orelse return .{ .status = bad, .body = errorJson(resp, "could not determine slot", -1) }; + + if (has(u8, q, "setUrl") != null) { + var key_buf: [16]u8 = undefined; + const url = parseStringField(q, "url", &key_buf) orelse return .{ .status = bad, .body = errorJson(resp, "missing url", -1) }; + const r = ffi.lang_session_set_url(slot, url.ptr, url.len); + if (r < 0) return .{ .status = bad, .body = errorJson(resp, "set url failed", r) }; + return .{ .status = ok, .body = std.fmt.bufPrint(resp, \\{{"data":{{"setUrl":{s}}}}}, .{sessionJson(slot, resp)}) catch resp[0..0] }; + } + if (has(u8, q, "typecheck") != null) { + var key_buf: [16]u8 = undefined; + const source = parseStringField(q, "source", &key_buf) orelse ""; + const r = runSourceOp(slot, source, .typecheck, resp); + return .{ .status = r.status, .body = std.fmt.bufPrint(resp, \\{{"data":{{"typecheck":{s}}}}}, .{r.body}) catch resp[0..0] }; + } + if (has(u8, q, "eval") != null) { + var key_buf: [16]u8 = undefined; + const source = parseStringField(q, "source", &key_buf) orelse ""; + const r = runSourceOp(slot, source, .eval, resp); + return .{ .status = r.status, .body = std.fmt.bufPrint(resp, \\{{"data":{{"eval":{s}}}}}, .{r.body}) catch resp[0..0] }; + } + if (has(u8, q, "endSession") != null) { + const r = ffi.lang_session_end(slot); + if (r < 0) return .{ .status = bad, .body = errorJson(resp, "end session failed", r) }; + return .{ .status = ok, .body = std.fmt.bufPrint(resp, \\{{"data":{{"endSession":{{"slot":{d},"released":true}}}}}}, .{slot}) catch resp[0..0] }; + } + if (has(u8, q, "session") != null) return .{ .status = ok, .body = std.fmt.bufPrint(resp, \\{{"data":{{"session":{s}}}}}, .{sessionJson(slot, resp)}) catch resp[0..0] }; + + return .{ .status = bad, .body = errorJson(resp, "unrecognised GraphQL operation", -1) }; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// HTTP connection handler + listener loop +// ═══════════════════════════════════════════════════════════════════════════ + +const Protocol = enum { rest, grpc, graphql }; +const ListenerCtx = struct { listener: *std.net.Server, protocol: Protocol }; + +fn handleConnection(conn: std.net.Server.Connection, protocol: Protocol) void { + defer conn.stream.close(); + var read_buf: [8192]u8 = undefined; + var http_srv = std.http.Server.init(conn, &read_buf); + var request = http_srv.receiveHead() catch return; + + var body_buf: [262144]u8 = undefined; + var body_len: usize = 0; + if (request.head.content_length) |cl| { + const to_read: usize = @min(cl, body_buf.len); + var reader = request.reader() catch return; + body_len = reader.readAll(body_buf[0..to_read]) catch 0; + } + + var resp_buf: [4096]u8 = undefined; + const body = body_buf[0..body_len]; + + const resp: Response = switch (protocol) { + .rest => dispatchRest(request.head.method, request.head.target, body, &resp_buf), + .grpc => dispatchGrpc(request.head.target, body, &resp_buf), + .graphql => blk: { + if (request.head.method == .GET and std.mem.eql(u8, request.head.target, "/graphql/schema")) + break :blk .{ .status = .ok, .body = GRAPHQL_SCHEMA, .content_type = "text/plain" }; + break :blk dispatchGraphql(body, &resp_buf); + }, + }; + + const ct: std.http.Header = .{ .name = "content-type", .value = resp.content_type }; + const cors: std.http.Header = .{ .name = "access-control-allow-origin", .value = "*" }; + const gs: std.http.Header = .{ .name = "grpc-status", .value = if (resp.status == .ok) "0" else "2" }; + + if (protocol == .grpc) { + request.respond(resp.body, .{ .status = resp.status, .extra_headers = &.{ ct, gs } }) catch {}; + } else { + request.respond(resp.body, .{ .status = resp.status, .extra_headers = &.{ ct, cors } }) catch {}; + } +} + +fn listenLoop(ctx: ListenerCtx) void { + while (true) { + const conn = ctx.listener.accept() catch |err| { + std.log.err("accept error on {s}: {}", .{ @tagName(ctx.protocol), err }); + continue; + }; + handleConnection(conn, ctx.protocol); + } +} + +pub fn main() !void { + _ = ffi.boj_cartridge_init(); + + var rest_listener = try (try std.net.Address.parseIp4("0.0.0.0", REST_PORT)).listen(.{ .reuse_address = true }); + defer rest_listener.deinit(); + var grpc_listener = try (try std.net.Address.parseIp4("0.0.0.0", GRPC_PORT)).listen(.{ .reuse_address = true }); + defer grpc_listener.deinit(); + var gql_listener = try (try std.net.Address.parseIp4("0.0.0.0", GQL_PORT)).listen(.{ .reuse_address = true }); + defer gql_listener.deinit(); + + std.log.info("{s} REST :{d} gRPC :{d} GraphQL :{d}", .{ CARTRIDGE, REST_PORT, GRPC_PORT, GQL_PORT }); + + const t_rest = try std.Thread.spawn(.{}, listenLoop, .{ ListenerCtx{ .listener = &rest_listener, .protocol = .rest } }); + const t_grpc = try std.Thread.spawn(.{}, listenLoop, .{ ListenerCtx{ .listener = &grpc_listener, .protocol = .grpc } }); + const t_gql = try std.Thread.spawn(.{}, listenLoop, .{ ListenerCtx{ .listener = &gql_listener, .protocol = .graphql } }); + + t_rest.join(); + t_grpc.join(); + t_gql.join(); +} diff --git a/cartridges/domains/languages/lang-mcp/cartridge.json b/cartridges/domains/languages/lang-mcp/cartridge.json new file mode 100644 index 0000000..07c2766 --- /dev/null +++ b/cartridges/domains/languages/lang-mcp/cartridge.json @@ -0,0 +1,244 @@ +{ + "$schema": "https://boj.dev/schemas/cartridge/v1.json", + "spdx": "MPL-2.0", + "copyright": "Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath)", + "name": "lang-mcp", + "version": "0.1.0", + "status": "ffi_only", + "description": "Multi-language session manager for the nextgen-languages family: Eclexia, AffineScript, BetLang, Ephapax, MyLang, WokeLang, Anvomidav, Phronesis, Error-lang, Julia-the-Viper, Me-dialect, Oblibeny. Tracks per-language sessions, delegates type-checking and evaluation to each language's CLI tool, and provides a unified interface across all dialects.", + "domain": "Languages", + "tier": "Ayo", + "protocols": [ + "MCP", + "REST" + ], + "auth": { + "method": "none", + "env_var": null, + "credential_source": null + }, + "api": { + "base_url": "local://lang-mcp", + "content_type": "application/json" + }, + "tools": [ + { + "name": "lang_list", + "description": "List all supported languages with their IDs, status (installed/missing), and available operations. Checks whether each language's CLI binary is on PATH.", + "inputSchema": { + "type": "object", + "properties": { + "check_installed": { + "type": "boolean", + "description": "Probe PATH for each CLI binary (default: true)" + } + }, + "required": [] + } + }, + { + "name": "lang_session_create", + "description": "Create a language session. Returns a session ID used in subsequent lang_check, lang_eval, and lang_compile calls. The dialect_mode controls whether Julia-the-Viper syntax extensions are injected.", + "inputSchema": { + "type": "object", + "properties": { + "language": { + "type": "string", + "description": "Language name: eclexia | affinescript | betlang | ephapax | mylang | wokelang | anvomidav | phronesis | error_lang | julia_the_viper | me_dialect | oblibeny" + }, + "dialect_mode": { + "type": "string", + "enum": [ + "pure", + "jtv" + ], + "description": "Dialect mode: pure (default) or jtv (Julia-the-Viper injected)" + }, + "name": { + "type": "string", + "description": "Human-readable session name" + } + }, + "required": [ + "language" + ] + } + }, + { + "name": "lang_session_status", + "description": "Get the current state of a language session (idle, compiling, checked, evaluating, error) and its language/dialect.", + "inputSchema": { + "type": "object", + "properties": { + "session_id": { + "type": "string", + "description": "Session ID from lang_session_create" + } + }, + "required": [ + "session_id" + ] + } + }, + { + "name": "lang_check", + "description": "Type-check source code in the session's language. Returns structured diagnostics (errors, warnings, hints).", + "inputSchema": { + "type": "object", + "properties": { + "session_id": { + "type": "string", + "description": "Session ID" + }, + "source": { + "type": "string", + "description": "Source code to type-check" + }, + "filename": { + "type": "string", + "description": "Virtual filename for diagnostic reporting (default: input.<ext>)" + } + }, + "required": [ + "session_id", + "source" + ] + } + }, + { + "name": "lang_eval", + "description": "Evaluate source code in the session's language using the interpreter/REPL. Returns the evaluation result or error output.", + "inputSchema": { + "type": "object", + "properties": { + "session_id": { + "type": "string", + "description": "Session ID" + }, + "source": { + "type": "string", + "description": "Source code to evaluate" + } + }, + "required": [ + "session_id", + "source" + ] + } + }, + { + "name": "lang_compile", + "description": "Compile source code in the session's language to the default compilation target. Returns diagnostics and indicates whether compilation succeeded.", + "inputSchema": { + "type": "object", + "properties": { + "session_id": { + "type": "string", + "description": "Session ID" + }, + "source": { + "type": "string", + "description": "Source code to compile" + }, + "target": { + "type": "string", + "description": "Compilation target (language-specific; default: wasm for AffineScript, native for others)" + } + }, + "required": [ + "session_id", + "source" + ] + } + }, + { + "name": "lang_hover", + "description": "Request type and documentation information at a cursor position in the session's language. Delegates to the language's LSP server or compiler hover command if available.", + "inputSchema": { + "type": "object", + "properties": { + "session_id": { + "type": "string", + "description": "Session ID" + }, + "source": { + "type": "string", + "description": "Source code" + }, + "line": { + "type": "number", + "description": "1-based line number" + }, + "col": { + "type": "number", + "description": "1-based column number" + } + }, + "required": [ + "session_id", + "source", + "line", + "col" + ] + } + }, + { + "name": "lang_complete", + "description": "Request completion candidates at a cursor position in the session's language.", + "inputSchema": { + "type": "object", + "properties": { + "session_id": { + "type": "string", + "description": "Session ID" + }, + "source": { + "type": "string", + "description": "Source code" + }, + "line": { + "type": "number", + "description": "1-based line number" + }, + "col": { + "type": "number", + "description": "1-based column number" + } + }, + "required": [ + "session_id", + "source", + "line", + "col" + ] + } + }, + { + "name": "lang_session_close", + "description": "Close a language session and free its resources.", + "inputSchema": { + "type": "object", + "properties": { + "session_id": { + "type": "string", + "description": "Session ID to close" + } + }, + "required": [ + "session_id" + ] + } + } + ], + "ffi": { + "so_path": "ffi/zig-out/lib/liblang_mcp.so", + "abi_version": "ADR-0006", + "symbols": [ + "boj_cartridge_init", + "boj_cartridge_deinit", + "boj_cartridge_name", + "boj_cartridge_version", + "boj_cartridge_invoke" + ] + } +} diff --git a/cartridges/domains/languages/lang-mcp/ffi/README.adoc b/cartridges/domains/languages/lang-mcp/ffi/README.adoc new file mode 100644 index 0000000..60e28d4 --- /dev/null +++ b/cartridges/domains/languages/lang-mcp/ffi/README.adoc @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += lang-mcp / ffi β€” Zig FFI layer +:orientation: shallow + +== Purpose + +C-ABI exports bridging this cartridge's Idris2 ABI to the runtime. Consumed by +the adapter for protocol dispatch and by the trunk loader for mount. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” produces `zig-out/lib/liblang.so`. +| `lang_ffi.zig` | C-ABI exports (14 exports, 8 inline tests, 462 lines). +|=== + +== Invariants + +* **Null-pointer rejection** on `[*c]const u8` arguments before any + dereference. +* **Negative return values** carry documented error meanings; zero is + success (matching the trunk error-code convention). +* **No allocator leaks** β€” every allocation path pairs with `defer free`. + +== Test/proof surface + +8 inline `test "..."` block(s) in `lang_ffi.zig`. Run with +`cd ffi && zig build test`. + +== Read-first + +. `lang_ffi.zig` β€” C-ABI signatures and guards. +. Trunk `ffi/zig/src/catalogue.zig` for how this `.so` is loaded. diff --git a/cartridges/domains/languages/lang-mcp/ffi/build.zig b/cartridges/domains/languages/lang-mcp/ffi/build.zig new file mode 100644 index 0000000..ee35cf9 --- /dev/null +++ b/cartridges/domains/languages/lang-mcp/ffi/build.zig @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// Lang-MCP Cartridge β€” Zig FFI build configuration (Zig 0.14+). + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + // Shared ADR-0006 invoke-shim module (relative path up to boj-server trunk). + + const shim_mod = b.addModule("cartridge_shim", .{ + + .root_source_file = b.path("../../../ffi/zig/src/cartridge_shim.zig"), + + .target = target, + + .optimize = optimize, + + }); + + const lang_mod = b.addModule("lang_ffi", .{ + .root_source_file = b.path("lang_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + lang_mod.addImport("cartridge_shim", shim_mod); + + const lang_tests = b.addTest(.{ .root_module = lang_mod }); + const run_tests = b.addRunArtifact(lang_tests); + const test_step = b.step("test", "Run lang-mcp FFI tests"); + test_step.dependOn(&run_tests.step); + + const lib_mod = b.createModule(.{ + .root_source_file = b.path("lang_ffi.zig"), + .target = target, + .optimize = optimize, + }); + lib_mod.addImport("cartridge_shim", shim_mod); + + const lib = b.addLibrary(.{ + .name = "lang_mcp", + .root_module = lib_mod, + .linkage = .dynamic, + }); + b.installArtifact(lib); + + const lib_step = b.step("lib", "Build shared library"); + lib_step.dependOn(&lib.step); +} diff --git a/cartridges/domains/languages/lang-mcp/ffi/cartridge_shim.zig b/cartridges/domains/languages/lang-mcp/ffi/cartridge_shim.zig new file mode 100644 index 0000000..0e399db --- /dev/null +++ b/cartridges/domains/languages/lang-mcp/ffi/cartridge_shim.zig @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// cartridge_shim.zig β€” Shared helpers for the ADR-0006 five-symbol +// cartridge ABI (`boj_cartridge_init / deinit / name / version / invoke`). +// +// The shim centralises the seven-code return convention, NUL-argument +// guards, tool-name comparison, and the buffer-too-small path so each +// cartridge's `boj_cartridge_invoke` can stay short β€” typically a tool +// table plus `shim.writeResult(...)`. +// +// Cartridges import this file by relative path (no build-graph change +// needed). Example: +// +// const shim = @import("../../../ffi/zig/src/cartridge_shim.zig"); +// +// export fn boj_cartridge_invoke( +// tool_name: [*c]const u8, +// json_args: [*c]const u8, +// out_buf: [*c]u8, +// in_out_len: [*c]usize, +// ) callconv(.c) i32 { +// _ = json_args; +// if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; +// const body = if (shim.toolIs(tool_name, "foo")) "{\"result\":{}}" +// else return shim.RC_UNKNOWN_TOOL; +// return shim.writeResult(out_buf, in_out_len, body); +// } + +const std = @import("std"); + +// ── Return codes (ADR-0006 Β§Return codes) ──────────────────────────── +// +// Frozen by ADR-0006. New failure modes compose these via the error +// JSON body β€” the integer surface does not grow without a follow-up ADR. + +pub const RC_SUCCESS: i32 = 0; +pub const RC_UNKNOWN_TOOL: i32 = -1; +pub const RC_BAD_ARGS: i32 = -2; +pub const RC_BUFFER_TOO_SMALL: i32 = -3; +pub const RC_RUNTIME_ERROR: i32 = -4; +pub const RC_PANIC: i32 = -5; +pub const RC_AUTH_DENIED: i32 = -6; + +// ── Invoke-path helpers ────────────────────────────────────────────── + +/// True if any of the three mandatory `boj_cartridge_invoke` output-path +/// pointers is null. Use at the top of every invoke to short-circuit to +/// `RC_BAD_ARGS`. +pub fn invokeArgsNull( + tool_name: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) bool { + return tool_name == null or out_buf == null or in_out_len == null; +} + +/// Compare a C-NUL-terminated tool-name pointer against a Zig string +/// literal. Caller must have already verified `tool_name` is non-null +/// (usually via `invokeArgsNull`). +pub fn toolIs(tool_name: [*c]const u8, expected: []const u8) bool { + const s = std.mem.span(@as([*:0]const u8, @ptrCast(tool_name))); + return std.mem.eql(u8, s, expected); +} + +/// Copy `body` into `out_buf[0..*in_out_len]` (as a capacity) and update +/// `*in_out_len` to the number of bytes written. Returns `RC_SUCCESS`. +/// +/// If `body.len` exceeds the current capacity stored in `*in_out_len`, +/// sets `*in_out_len` to the required size and returns +/// `RC_BUFFER_TOO_SMALL` β€” the caller is then expected to re-allocate +/// and retry, per ADR-0006 Β§Memory ownership. +/// +/// Caller must have already verified that `out_buf` and `in_out_len` +/// are non-null. +pub fn writeResult( + out_buf: [*c]u8, + in_out_len: [*c]usize, + body: []const u8, +) i32 { + const cap = in_out_len.*; + if (body.len > cap) { + in_out_len.* = body.len; + return RC_BUFFER_TOO_SMALL; + } + @memcpy(out_buf[0..body.len], body); + in_out_len.* = body.len; + return RC_SUCCESS; +} + +// ── Tests ──────────────────────────────────────────────────────────── + +test "writeResult: body fits, writes and sets length" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); + try std.testing.expectEqualStrings("hello", buf[0..len]); +} + +test "writeResult: too small returns -3 and sets required length" { + var buf: [2]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_BUFFER_TOO_SMALL, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: exact-fit succeeds" { + var buf: [5]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: empty body" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, ""); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 0), len); +} + +test "toolIs: matches and rejects" { + const name: [*:0]const u8 = "foo"; + try std.testing.expect(toolIs(@ptrCast(name), "foo")); + try std.testing.expect(!toolIs(@ptrCast(name), "bar")); + try std.testing.expect(!toolIs(@ptrCast(name), "foobar")); + try std.testing.expect(!toolIs(@ptrCast(name), "fo")); +} + +test "invokeArgsNull: detects each null slot" { + var buf: [4]u8 = undefined; + var len: usize = 4; + const name: [*:0]const u8 = "x"; + try std.testing.expect(!invokeArgsNull(@ptrCast(name), &buf, &len)); + try std.testing.expect(invokeArgsNull(null, &buf, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), null, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), &buf, null)); +} diff --git a/cartridges/domains/languages/lang-mcp/ffi/lang_ffi.zig b/cartridges/domains/languages/lang-mcp/ffi/lang_ffi.zig new file mode 100644 index 0000000..63cf688 --- /dev/null +++ b/cartridges/domains/languages/lang-mcp/ffi/lang_ffi.zig @@ -0,0 +1,543 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// Lang-MCP Cartridge β€” Zig FFI bridge for nextgen-languages operations. +// +// Manages language runtime sessions for the hyperpolymath nextgen-languages +// family: Eclexia, AffineScript, BetLang, Ephapax, MyLang, WokeLang, +// Anvomidav, Phronesis, Error-lang, Julia-the-Viper, Me-dialect, Oblibeny. +// +// Each language session tracks: state machine (idle β†’ compiling β†’ checked β†’ error), +// the language identity, and a URL endpoint for the language's compile/eval service. + +const std = @import("std"); + +// ═══════════════════════════════════════════════════════════════════════ +// Types +// ═══════════════════════════════════════════════════════════════════════ + +/// Language session state machine. +pub const LangState = enum(c_int) { + idle = 0, + compiling = 1, + checked = 2, + evaluating = 3, + err = 4, +}; + +/// Supported nextgen-languages. Enum values are stable ABI identifiers. +pub const Language = enum(c_int) { + eclexia = 1, + affinescript = 2, + betlang = 3, + ephapax = 4, + mylang = 5, + wokelang = 6, + anvomidav = 7, + phronesis = 8, + error_lang = 9, + julia_the_viper = 10, + me_dialect = 11, + oblibeny = 12, + custom = 99, +}; + +/// Dialect mode: pure grammar or JtV-injected. +/// Julia-the-Viper (JtV) is injectable into any other language, +/// augmenting it with JtV's syntax extensions. Each language can +/// be requested as pure (original grammar) or jtv (JtV-injected). +pub const DialectMode = enum(c_int) { + pure = 0, + jtv = 1, +}; + +// ═══════════════════════════════════════════════════════════════════════ +// Session State Machine +// ═══════════════════════════════════════════════════════════════════════ + +const MAX_SESSIONS: usize = 8; +const URL_BUF_SIZE: usize = 512; +const NAME_BUF_SIZE: usize = 64; + +const LangSession = struct { + active: bool, + state: LangState, + language: Language, + dialect: DialectMode, + url_buf: [URL_BUF_SIZE]u8, + url_len: usize, + name_buf: [NAME_BUF_SIZE]u8, + name_len: usize, +}; + +var sessions: [MAX_SESSIONS]LangSession = [_]LangSession{.{ + .active = false, + .state = .idle, + .language = .custom, + .dialect = .pure, + .url_buf = [_]u8{0} ** URL_BUF_SIZE, + .url_len = 0, + .name_buf = [_]u8{0} ** NAME_BUF_SIZE, + .name_len = 0, +}} ** MAX_SESSIONS; + +var mutex: std.Thread.Mutex = .{}; + +/// Validate a state transition. +fn isValidTransition(from: LangState, to: LangState) bool { + return switch (from) { + .idle => to == .compiling or to == .evaluating, + .compiling => to == .checked or to == .err, + .checked => to == .idle or to == .evaluating, + .evaluating => to == .idle or to == .err, + .err => to == .idle, + }; +} + +/// Start a language session. Returns session index or -1. +/// dialect_mode: 0 = pure grammar, 1 = JtV-injected. +pub export fn lang_session_start(lang_id: c_int, name_ptr: [*]const u8, name_len: usize) c_int { + return lang_session_start_dialect(lang_id, 0, name_ptr, name_len); +} + +/// Start a language session with explicit dialect mode. +/// dialect_mode: 0 = pure grammar, 1 = JtV-injected. +/// When JtV mode is active, the language service URL path gains a /jtv suffix +/// (e.g. /typecheck/jtv, /eval/jtv) so the backend can apply JtV grammar injection. +pub export fn lang_session_start_dialect(lang_id: c_int, dialect_mode: c_int, name_ptr: [*]const u8, name_len: usize) c_int { + mutex.lock(); + defer mutex.unlock(); + + if (name_len == 0 or name_len >= NAME_BUF_SIZE) return -2; + + for (&sessions, 0..) |*sess, i| { + if (!sess.active) { + sess.active = true; + sess.state = .idle; + sess.language = @enumFromInt(lang_id); + sess.dialect = if (dialect_mode == 1) .jtv else .pure; + sess.url_len = 0; + @memcpy(sess.name_buf[0..name_len], name_ptr[0..name_len]); + sess.name_len = name_len; + return @intCast(i); + } + } + return -1; // No sessions available +} + +/// Get the dialect mode of a session (0 = pure, 1 = jtv). +pub export fn lang_session_dialect(sess_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + if (sess_idx < 0 or sess_idx >= MAX_SESSIONS) return -1; + const idx: usize = @intCast(sess_idx); + if (!sessions[idx].active) return -1; + return @intFromEnum(sessions[idx].dialect); +} + +/// Set the language service URL for a session (for remote compilation/eval). +pub export fn lang_session_set_url(sess_idx: c_int, url_ptr: [*]const u8, url_len: usize) c_int { + mutex.lock(); + defer mutex.unlock(); + + if (sess_idx < 0 or sess_idx >= MAX_SESSIONS) return -1; + const idx: usize = @intCast(sess_idx); + if (!sessions[idx].active) return -1; + if (url_len == 0 or url_len >= URL_BUF_SIZE) return -6; + + @memcpy(sessions[idx].url_buf[0..url_len], url_ptr[0..url_len]); + sessions[idx].url_len = url_len; + return 0; +} + +/// End a language session. +pub export fn lang_session_end(sess_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + if (sess_idx < 0 or sess_idx >= MAX_SESSIONS) return -1; + const idx: usize = @intCast(sess_idx); + if (!sessions[idx].active) return -1; + + sessions[idx].active = false; + sessions[idx].state = .idle; + sessions[idx].url_len = 0; + sessions[idx].name_len = 0; + return 0; +} + +/// Get the state of a session. +pub export fn lang_session_state(sess_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + if (sess_idx < 0 or sess_idx >= MAX_SESSIONS) return -1; + const idx: usize = @intCast(sess_idx); + if (!sessions[idx].active) return @intFromEnum(LangState.idle); + return @intFromEnum(sessions[idx].state); +} + +/// Get the language ID of a session. +pub export fn lang_session_language(sess_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + if (sess_idx < 0 or sess_idx >= MAX_SESSIONS) return -1; + const idx: usize = @intCast(sess_idx); + if (!sessions[idx].active) return -1; + return @intFromEnum(sessions[idx].language); +} + +/// Type-check source code via the language service. +/// POSTs to {url}/typecheck with the source as JSON body. +pub export fn lang_typecheck(sess_idx: c_int, src_ptr: [*]const u8, src_len: usize, out_ptr: [*]u8, out_len: usize) callconv(.c) i32 { + var endpoint_buf: [600]u8 = undefined; + var endpoint_total: usize = 0; + var src_buf: [16384]u8 = undefined; + var safe_src_len: usize = 0; + + { + mutex.lock(); + defer mutex.unlock(); + + if (sess_idx < 0 or sess_idx >= MAX_SESSIONS) return -1; + const idx: usize = @intCast(sess_idx); + if (!sessions[idx].active) return -1; + if (sessions[idx].state != .idle and sessions[idx].state != .checked) return -2; + if (sessions[idx].url_len == 0) return -6; + + const url_slice = sessions[idx].url_buf[0..sessions[idx].url_len]; + const suffix = if (sessions[idx].dialect == .jtv) "/typecheck/jtv" else "/typecheck"; + if (url_slice.len + suffix.len >= endpoint_buf.len) return -6; + @memcpy(endpoint_buf[0..url_slice.len], url_slice); + @memcpy(endpoint_buf[url_slice.len..][0..suffix.len], suffix); + endpoint_total = url_slice.len + suffix.len; + endpoint_buf[endpoint_total] = 0; + + safe_src_len = @min(src_len, src_buf.len - 1); + @memcpy(src_buf[0..safe_src_len], src_ptr[0..safe_src_len]); + src_buf[safe_src_len] = 0; + + sessions[idx].state = .compiling; + } + + const child_result = runCurlPost( + endpoint_buf[0..endpoint_total :0], + src_buf[0..safe_src_len :0], + ); + + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = @intCast(sess_idx); + if (!sessions[idx].active) return -1; + + if (child_result) |result| { + defer std.heap.page_allocator.free(result); + const written = result.len; + if (written > out_len) { + sessions[idx].state = .err; + return -5; + } + @memcpy(out_ptr[0..written], result[0..written]); + sessions[idx].state = .checked; + return @intCast(written); + } else |_| { + sessions[idx].state = .err; + return -7; + } +} + +/// Evaluate/run source code via the language service. +/// POSTs to {url}/eval with the source as JSON body. +pub export fn lang_eval(sess_idx: c_int, src_ptr: [*]const u8, src_len: usize, out_ptr: [*]u8, out_len: usize) callconv(.c) i32 { + var endpoint_buf: [600]u8 = undefined; + var endpoint_total: usize = 0; + var src_buf: [16384]u8 = undefined; + var safe_src_len: usize = 0; + + { + mutex.lock(); + defer mutex.unlock(); + + if (sess_idx < 0 or sess_idx >= MAX_SESSIONS) return -1; + const idx: usize = @intCast(sess_idx); + if (!sessions[idx].active) return -1; + if (sessions[idx].state != .idle and sessions[idx].state != .checked) return -2; + if (sessions[idx].url_len == 0) return -6; + + const url_slice = sessions[idx].url_buf[0..sessions[idx].url_len]; + const suffix = if (sessions[idx].dialect == .jtv) "/eval/jtv" else "/eval"; + if (url_slice.len + suffix.len >= endpoint_buf.len) return -6; + @memcpy(endpoint_buf[0..url_slice.len], url_slice); + @memcpy(endpoint_buf[url_slice.len..][0..suffix.len], suffix); + endpoint_total = url_slice.len + suffix.len; + endpoint_buf[endpoint_total] = 0; + + safe_src_len = @min(src_len, src_buf.len - 1); + @memcpy(src_buf[0..safe_src_len], src_ptr[0..safe_src_len]); + src_buf[safe_src_len] = 0; + + sessions[idx].state = .evaluating; + } + + const child_result = runCurlPost( + endpoint_buf[0..endpoint_total :0], + src_buf[0..safe_src_len :0], + ); + + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = @intCast(sess_idx); + if (!sessions[idx].active) return -1; + + if (child_result) |result| { + defer std.heap.page_allocator.free(result); + const written = result.len; + if (written > out_len) { + sessions[idx].state = .err; + return -5; + } + @memcpy(out_ptr[0..written], result[0..written]); + sessions[idx].state = .idle; + return @intCast(written); + } else |_| { + sessions[idx].state = .err; + return -7; + } +} + +/// Reset all sessions (for testing). +pub export fn lang_reset() void { + mutex.lock(); + defer mutex.unlock(); + for (&sessions) |*sess| { + sess.active = false; + sess.state = .idle; + sess.url_len = 0; + sess.name_len = 0; + } +} + +/// Run curl as a child process for an HTTP POST with JSON body. +fn runCurlPost(endpoint: [:0]const u8, body: [:0]const u8) ![]u8 { + const argv = [_][]const u8{ + "curl", "-sf", "--max-time", "10", + "-X", "POST", "-H", "Content-Type: application/json", + "-d", body, endpoint, + }; + var child = std.process.Child.init(&argv, std.heap.page_allocator); + child.stdout_behavior = .Pipe; + child.stderr_behavior = .Pipe; + try child.spawn(); + + const alloc = std.heap.page_allocator; + var stdout_list: std.ArrayList(u8) = .empty; + var stderr_list: std.ArrayList(u8) = .empty; + defer stderr_list.deinit(alloc); + + try child.collectOutput(alloc, &stdout_list, &stderr_list, 65536); + const term = try child.wait(); + + if (term.Exited != 0) { + stdout_list.deinit(alloc); + return error.CurlFailed; + } + + return stdout_list.toOwnedSlice(alloc); +} + +// ═══════════════════════════════════════════════════════════════════════ +// Standard Cartridge Interface +// ═══════════════════════════════════════════════════════════════════════ + +pub export fn boj_cartridge_init() c_int { + lang_reset(); + return 0; +} + +pub export fn boj_cartridge_deinit() void { + lang_reset(); +} + +pub export fn boj_cartridge_name() [*:0]const u8 { + return "lang-mcp"; +} + +pub export fn boj_cartridge_version() [*:0]const u8 { + return "0.1.0"; +} + +// ═══════════════════════════════════════════════════════════════════════ +// ADR-0006 dispatch (boj_cartridge_invoke, 5th standard symbol) +// ═══════════════════════════════════════════════════════════════════════ + +const shim = @import("cartridge_shim.zig"); + +/// Dispatch the cartridge.json MCP tools. Grade D Alpha β€” each arm +/// returns a stub JSON body shaped to the tool's intended response. +export fn boj_cartridge_invoke( + tool_name: [*c]const u8, + json_args: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) callconv(.c) i32 { + _ = json_args; + if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; + + const body: []const u8 = if (shim.toolIs(tool_name, "lang_list")) + "{\"result\":{\"items\":[],\"count\":0,\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "lang_session_create")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "lang_session_status")) + "{\"result\":{\"metadata\":{},\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "lang_check")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "lang_eval")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "lang_compile")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "lang_hover")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "lang_complete")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "lang_session_close")) + "{\"result\":{\"status\":\"stub\"}}" +else + return shim.RC_UNKNOWN_TOOL; + + return shim.writeResult(out_buf, in_out_len, body); +} + +// ═══════════════════════════════════════════════════════════════════════ +// Tests +// ═══════════════════════════════════════════════════════════════════════ + +test "session start and end" { + lang_reset(); + const name = "test-session"; + const sess = lang_session_start(@intFromEnum(Language.eclexia), name, name.len); + try std.testing.expect(sess >= 0); + try std.testing.expectEqual(@as(c_int, @intFromEnum(LangState.idle)), lang_session_state(sess)); + try std.testing.expectEqual(@as(c_int, @intFromEnum(Language.eclexia)), lang_session_language(sess)); + try std.testing.expectEqual(@as(c_int, 0), lang_session_end(sess)); +} + +test "session set URL" { + lang_reset(); + const name = "url-test"; + const sess = lang_session_start(@intFromEnum(Language.affinescript), name, name.len); + try std.testing.expect(sess >= 0); + const url = "http://localhost:9100"; + try std.testing.expectEqual(@as(c_int, 0), lang_session_set_url(sess, url, url.len)); + try std.testing.expectEqual(@as(c_int, 0), lang_session_end(sess)); +} + +test "cannot double-end session" { + lang_reset(); + const name = "double-end"; + const sess = lang_session_start(@intFromEnum(Language.betlang), name, name.len); + _ = lang_session_end(sess); + try std.testing.expectEqual(@as(c_int, -1), lang_session_end(sess)); +} + +test "session rejects empty name" { + lang_reset(); + const sess = lang_session_start(@intFromEnum(Language.mylang), "", 0); + try std.testing.expectEqual(@as(c_int, -2), sess); +} + +test "all 12 languages can start sessions" { + lang_reset(); + const langs = [_]c_int{ 1, 2, 3, 4, 5, 6, 7, 8 }; + for (langs, 0..) |lang_id, i| { + _ = i; + const name = "lang-test"; + const sess = lang_session_start(lang_id, name, name.len); + try std.testing.expect(sess >= 0); + } + // All 8 slots used β€” next should fail + const name = "overflow"; + const overflow = lang_session_start(9, name, name.len); + try std.testing.expectEqual(@as(c_int, -1), overflow); +} + +test "jtv dialect mode on session" { + lang_reset(); + const name = "jtv-test"; + // Pure mode (default) + const pure_sess = lang_session_start(@intFromEnum(Language.eclexia), name, name.len); + try std.testing.expect(pure_sess >= 0); + try std.testing.expectEqual(@as(c_int, 0), lang_session_dialect(pure_sess)); + _ = lang_session_end(pure_sess); + + // JtV-injected mode + const jtv_sess = lang_session_start_dialect(@intFromEnum(Language.eclexia), 1, name, name.len); + try std.testing.expect(jtv_sess >= 0); + try std.testing.expectEqual(@as(c_int, 1), lang_session_dialect(jtv_sess)); + _ = lang_session_end(jtv_sess); +} + +test "jtv mode works for all languages" { + lang_reset(); + const name = "jtv-all"; + // Start eclexia+jtv + const sess = lang_session_start_dialect(@intFromEnum(Language.affinescript), 1, name, name.len); + try std.testing.expect(sess >= 0); + try std.testing.expectEqual(@as(c_int, 1), lang_session_dialect(sess)); + try std.testing.expectEqual(@as(c_int, @intFromEnum(Language.affinescript)), lang_session_language(sess)); + _ = lang_session_end(sess); +} + +test "session URL rejects empty and overlong" { + lang_reset(); + const name = "url-reject"; + const sess = lang_session_start(@intFromEnum(Language.phronesis), name, name.len); + try std.testing.expect(sess >= 0); + try std.testing.expectEqual(@as(c_int, -6), lang_session_set_url(sess, "", 0)); + var long_url: [URL_BUF_SIZE]u8 = [_]u8{'x'} ** URL_BUF_SIZE; + try std.testing.expectEqual(@as(c_int, -6), lang_session_set_url(sess, &long_url, long_url.len)); + _ = lang_session_end(sess); +} + +// ═══════════════════════════════════════════════════════════════════════ +// ADR-0006 invoke dispatch tests +// ═══════════════════════════════════════════════════════════════════════ + +test "invoke: each declared tool succeeds" { + var buf: [256]u8 = undefined; + const tools = [_][]const u8{ + "lang_list", + "lang_session_create", + "lang_session_status", + "lang_check", + "lang_eval", + "lang_compile", + "lang_hover", + "lang_complete", + "lang_session_close", + }; + for (tools) |t| { + var len: usize = buf.len; + const rc = boj_cartridge_invoke(t.ptr, "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, 0), rc); + try std.testing.expect(std.mem.indexOf(u8, buf[0..len], "result") != null); + } +} + +test "invoke: unknown tool returns -1" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("nope", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -1), rc); +} + +test "invoke: buffer too small returns -3" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("lang_list", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -3), rc); + try std.testing.expect(len > 4); +} diff --git a/cartridges/domains/languages/lang-mcp/mod.js b/cartridges/domains/languages/lang-mcp/mod.js new file mode 100644 index 0000000..d1b7352 --- /dev/null +++ b/cartridges/domains/languages/lang-mcp/mod.js @@ -0,0 +1,270 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// lang-mcp/mod.js -- Multi-language session manager cartridge. +// +// Supports: Eclexia, AffineScript, BetLang, Ephapax, MyLang, WokeLang, +// Anvomidav, Phronesis, Error-lang, Julia-the-Viper, Me-dialect, +// Oblibeny (12 languages + custom). +// +// Unlike lsp-mcp/dap-mcp/bsp-mcp, this cartridge does NOT maintain a persistent +// server subprocess. Instead it delegates each operation to the language's CLI +// tool via a short-lived subprocess call. Session state is maintained in-process +// (language choice, dialect mode, open file state). +// +// Auth: None required β€” local CLI tools. +// +// Usage: import { handleTool } from "./mod.js"; + +// --------------------------------------------------------------------------- +// Language registry +// --------------------------------------------------------------------------- + +// Each entry: { binary, checkArgs, evalArgs, compileArgs, hoverArgs, completeArgs } +// Arg templates may include "%file%" (tmp file path), "%line%", "%col%", "%target%" +const LANGUAGE_COMMANDS = { + eclexia: { binary: "eclexia", checkArgs: ["check", "--json", "%file%"], evalArgs: ["eval", "%file%"], compileArgs: ["compile", "%file%"], hoverArgs: ["hover", "%file%", "%line%", "%col%"], completeArgs: ["complete", "%file%", "%line%", "%col%"] }, + affinescript: { binary: "affinescript", checkArgs: ["check", "--json", "%file%"], evalArgs: ["eval", "%file%"], compileArgs: ["compile", "--target", "%target%", "--json", "%file%"], hoverArgs: ["hover", "%file%", "%line%", "%col%"], completeArgs: ["complete", "%file%", "%line%", "%col%"] }, + betlang: { binary: "betlang", checkArgs: ["check", "--json", "%file%"], evalArgs: ["eval", "%file%"], compileArgs: ["compile", "%file%"], hoverArgs: ["hover", "%file%", "%line%", "%col%"], completeArgs: ["complete", "%file%", "%line%", "%col%"] }, + ephapax: { binary: "ephapax", checkArgs: ["check", "--json", "%file%"], evalArgs: ["eval", "%file%"], compileArgs: ["compile", "%file%"], hoverArgs: ["hover", "%file%", "%line%", "%col%"], completeArgs: ["complete", "%file%", "%line%", "%col%"] }, + mylang: { binary: "mylang", checkArgs: ["check", "--json", "%file%"], evalArgs: ["eval", "%file%"], compileArgs: ["compile", "%file%"], hoverArgs: ["hover", "%file%", "%line%", "%col%"], completeArgs: ["complete", "%file%", "%line%", "%col%"] }, + wokelang: { binary: "wokelang", checkArgs: ["check", "--json", "%file%"], evalArgs: ["eval", "%file%"], compileArgs: ["compile", "%file%"], hoverArgs: ["hover", "%file%", "%line%", "%col%"], completeArgs: ["complete", "%file%", "%line%", "%col%"] }, + anvomidav: { binary: "anvomidav", checkArgs: ["check", "--json", "%file%"], evalArgs: ["eval", "%file%"], compileArgs: ["compile", "%file%"], hoverArgs: ["hover", "%file%", "%line%", "%col%"], completeArgs: ["complete", "%file%", "%line%", "%col%"] }, + phronesis: { binary: "phronesis", checkArgs: ["check", "--json", "%file%"], evalArgs: ["eval", "%file%"], compileArgs: ["compile", "%file%"], hoverArgs: ["hover", "%file%", "%line%", "%col%"], completeArgs: ["complete", "%file%", "%line%", "%col%"] }, + error_lang: { binary: "error-lang", checkArgs: ["check", "--json", "%file%"], evalArgs: ["eval", "%file%"], compileArgs: ["compile", "%file%"], hoverArgs: ["hover", "%file%", "%line%", "%col%"], completeArgs: ["complete", "%file%", "%line%", "%col%"] }, + julia_the_viper:{ binary: "julia-the-viper", checkArgs: ["check", "--json", "%file%"], evalArgs: ["eval", "%file%"], compileArgs: ["compile", "%file%"], hoverArgs: ["hover", "%file%", "%line%", "%col%"], completeArgs: ["complete", "%file%", "%line%", "%col%"] }, + me_dialect: { binary: "me-dialect", checkArgs: ["check", "--json", "%file%"], evalArgs: ["eval", "%file%"], compileArgs: ["compile", "%file%"], hoverArgs: ["hover", "%file%", "%line%", "%col%"], completeArgs: ["complete", "%file%", "%line%", "%col%"] }, + oblibeny: { binary: "oblibeny", checkArgs: ["check", "--json", "%file%"], evalArgs: ["eval", "%file%"], compileArgs: ["compile", "%file%"], hoverArgs: ["hover", "%file%", "%line%", "%col%"], completeArgs: ["complete", "%file%", "%line%", "%col%"] }, +}; + +// Default compilation targets per language +const DEFAULT_TARGETS = { + affinescript: "wasm", + eclexia: "native", + ephapax: "native", +}; + +// File extensions per language +const EXTENSIONS = { + eclexia: "ecl", affinescript: "as", betlang: "bet", ephapax: "eph", + mylang: "my", wokelang: "woke", anvomidav: "anv", phronesis: "phr", + error_lang: "err", julia_the_viper: "jtv", me_dialect: "me", oblibeny: "obl", +}; + +// --------------------------------------------------------------------------- +// Session registry +// --------------------------------------------------------------------------- + +/** @type {Map<string, {language: string, dialect_mode: string, name: string, state: string}>} */ +const SESSIONS = new Map(); + +function newSessionId() { + return `lang_${crypto.randomUUID().replace(/-/g, "").slice(0, 12)}`; +} + +function getSession(session_id) { + const s = SESSIONS.get(session_id); + if (!s) throw Object.assign(new Error(`Unknown lang session: ${session_id}`), { status: 404 }); + return s; +} + +// --------------------------------------------------------------------------- +// Subprocess helper +// --------------------------------------------------------------------------- + +async function runCLI(binary, cliArgs, input = null) { + try { + const proc = new Deno.Command(binary, { + args: cliArgs, + stdin: input != null ? "piped" : "null", + stdout: "piped", + stderr: "piped", + }).spawn(); + + if (input != null) { + const writer = proc.stdin.getWriter(); + await writer.write(new TextEncoder().encode(input)); + await writer.close(); + } + + const { code, stdout, stderr } = await proc.output(); + const dec = new TextDecoder(); + return { exitCode: code, stdout: dec.decode(stdout), stderr: dec.decode(stderr) }; + } catch (e) { + return { exitCode: -1, stdout: "", stderr: `Failed to invoke ${binary}: ${e.message}` }; + } +} + +/** Write source to a tmp file, run the CLI, then remove the file. */ +async function withTmpFile(language, source, fn) { + const ext = EXTENSIONS[language] ?? "txt"; + const path = `/tmp/boj_lang_${crypto.randomUUID().replace(/-/g, "").slice(0, 8)}.${ext}`; + try { + await Deno.writeTextFile(path, source); + return await fn(path); + } finally { + try { await Deno.remove(path); } catch { /* ignore */ } + } +} + +/** Substitute template variables in an arg array. */ +function renderArgs(template, vars) { + return template.map(a => { + let r = a; + for (const [k, v] of Object.entries(vars)) r = r.replace(k, v); + return r; + }); +} + +/** Try to probe whether a binary is installed (exits cleanly or with usage error). */ +async function isInstalled(binary) { + try { + const r = await new Deno.Command(binary, { args: ["--version"], stdout: "null", stderr: "null" }).output(); + return r.code === 0; + } catch { return false; } +} + +// --------------------------------------------------------------------------- +// Tool handlers +// --------------------------------------------------------------------------- + +export async function handleTool(toolName, args) { + switch (toolName) { + + // -- lang_list ----------------------------------------------------------- + case "lang_list": { + const { check_installed = true } = args ?? {}; + const languages = await Promise.all( + Object.entries(LANGUAGE_COMMANDS).map(async ([id, cfg]) => { + const installed = check_installed ? await isInstalled(cfg.binary) : null; + return { id, binary: cfg.binary, installed, operations: ["check", "eval", "compile", "hover", "complete"] }; + }) + ); + return { status: 200, data: { languages, count: languages.length } }; + } + + // -- lang_session_create ------------------------------------------------- + case "lang_session_create": { + const { language, dialect_mode = "pure", name } = args; + if (!LANGUAGE_COMMANDS[language]) { + return { status: 400, data: { error: `Unknown language: ${language}. Known: ${Object.keys(LANGUAGE_COMMANDS).join(", ")}` } }; + } + const session_id = newSessionId(); + SESSIONS.set(session_id, { language, dialect_mode, name: name ?? `${language}-session`, state: "idle" }); + return { status: 200, data: { session_id, language, dialect_mode, name: name ?? `${language}-session` } }; + } + + // -- lang_session_status ------------------------------------------------- + case "lang_session_status": { + const { session_id } = args; + const s = getSession(session_id); + return { status: 200, data: { session_id, language: s.language, dialect_mode: s.dialect_mode, name: s.name, state: s.state } }; + } + + // -- lang_check ---------------------------------------------------------- + case "lang_check": { + const { session_id, source, filename } = args; + const s = getSession(session_id); + const cfg = LANGUAGE_COMMANDS[s.language]; + s.state = "compiling"; + try { + const result = await withTmpFile(s.language, source, async (path) => { + const cliArgs = renderArgs(cfg.checkArgs, { "%file%": path }); + return runCLI(cfg.binary, cliArgs); + }); + let diagnostics; + try { diagnostics = JSON.parse(result.stderr || result.stdout); } + catch { diagnostics = { success: result.exitCode === 0, raw: result.stderr || result.stdout }; } + s.state = result.exitCode === 0 ? "checked" : "error"; + return { status: result.exitCode === 0 ? 200 : 422, data: diagnostics }; + } finally { + if (s.state === "compiling") s.state = "error"; + } + } + + // -- lang_eval ----------------------------------------------------------- + case "lang_eval": { + const { session_id, source } = args; + const s = getSession(session_id); + const cfg = LANGUAGE_COMMANDS[s.language]; + s.state = "evaluating"; + try { + const result = await withTmpFile(s.language, source, async (path) => { + const cliArgs = renderArgs(cfg.evalArgs, { "%file%": path }); + return runCLI(cfg.binary, cliArgs); + }); + s.state = result.exitCode === 0 ? "idle" : "error"; + return { + status: result.exitCode === 0 ? 200 : 422, + data: { output: result.stdout, stderr: result.stderr, exit_code: result.exitCode }, + }; + } finally { + if (s.state === "evaluating") s.state = "error"; + } + } + + // -- lang_compile -------------------------------------------------------- + case "lang_compile": { + const { session_id, source, target } = args; + const s = getSession(session_id); + const cfg = LANGUAGE_COMMANDS[s.language]; + const compilationTarget = target ?? DEFAULT_TARGETS[s.language] ?? "native"; + s.state = "compiling"; + try { + const result = await withTmpFile(s.language, source, async (path) => { + const cliArgs = renderArgs(cfg.compileArgs, { "%file%": path, "%target%": compilationTarget }); + return runCLI(cfg.binary, cliArgs); + }); + let report; + try { report = JSON.parse(result.stderr || result.stdout); } + catch { report = { success: result.exitCode === 0, raw: result.stderr || result.stdout }; } + s.state = result.exitCode === 0 ? "idle" : "error"; + return { status: result.exitCode === 0 ? 200 : 422, data: { target: compilationTarget, ...report } }; + } finally { + if (s.state === "compiling") s.state = "error"; + } + } + + // -- lang_hover ---------------------------------------------------------- + case "lang_hover": { + const { session_id, source, line, col } = args; + const s = getSession(session_id); + const cfg = LANGUAGE_COMMANDS[s.language]; + const result = await withTmpFile(s.language, source, async (path) => { + const cliArgs = renderArgs(cfg.hoverArgs, { "%file%": path, "%line%": String(line), "%col%": String(col) }); + return runCLI(cfg.binary, cliArgs); + }); + let hover; + try { hover = JSON.parse(result.stdout); } + catch { hover = { text: result.stdout.trim() || null }; } + return { status: result.exitCode === 0 ? 200 : 422, data: hover }; + } + + // -- lang_complete ------------------------------------------------------- + case "lang_complete": { + const { session_id, source, line, col } = args; + const s = getSession(session_id); + const cfg = LANGUAGE_COMMANDS[s.language]; + const result = await withTmpFile(s.language, source, async (path) => { + const cliArgs = renderArgs(cfg.completeArgs, { "%file%": path, "%line%": String(line), "%col%": String(col) }); + return runCLI(cfg.binary, cliArgs); + }); + let items; + try { items = JSON.parse(result.stdout); } + catch { items = []; } + if (!Array.isArray(items)) items = items.items ?? []; + return { status: result.exitCode === 0 ? 200 : 422, data: { items, count: items.length } }; + } + + // -- lang_session_close -------------------------------------------------- + case "lang_session_close": { + const { session_id } = args; + if (!SESSIONS.has(session_id)) return { status: 404, data: { error: `Unknown session: ${session_id}` } }; + SESSIONS.delete(session_id); + return { status: 200, data: { closed: session_id } }; + } + + default: + return { status: 404, data: { error: `Unknown tool: ${toolName}` } }; + } +} diff --git a/cartridges/domains/languages/lang-mcp/panels/manifest.json b/cartridges/domains/languages/lang-mcp/panels/manifest.json new file mode 100644 index 0000000..6b2c6fd --- /dev/null +++ b/cartridges/domains/languages/lang-mcp/panels/manifest.json @@ -0,0 +1,67 @@ +{ + "$schema": "https://panll.dev/schemas/panel-manifest/v1.json", + "spdx": "MPL-2.0", + "cartridge": "lang-mcp", + "domain": "Language Toolchains", + "version": "0.1.0", + "panels": [ + { + "id": "lang-status", + "title": "Language Toolchain Status", + "description": "Compiler, formatter, and linter availability across managed toolchains", + "type": "status-indicator", + "data_source": { + "endpoint": "/cartridge/lang-mcp/invoke", + "method": "POST", + "body": { "tool": "status" }, + "refresh_interval_ms": 5000 + }, + "widgets": [ + { + "type": "state-badge", + "field": "state", + "states": { + "ready": { "color": "#2ecc71", "icon": "terminal" }, + "degraded": { "color": "#f39c12", "icon": "alert-circle" }, + "error": { "color": "#e74c3c", "icon": "alert-triangle" } + } + }, + { "type": "text", "field": "version", "label": "Version" } + ] + }, + { + "id": "lang-toolchains", + "title": "Installed Toolchains", + "description": "Available language runtimes and their versions (via asdf)", + "type": "metric", + "data_source": { + "endpoint": "/cartridge/lang-mcp/invoke", + "method": "POST", + "body": { "tool": "toolchain_list" }, + "refresh_interval_ms": 60000 + }, + "widgets": [ + { "type": "counter", "field": "installed_toolchains", "label": "Toolchains", "icon": "wrench" }, + { "type": "counter", "field": "outdated_count", "label": "Outdated", "icon": "alert-circle" }, + { "type": "counter", "field": "managed_by_asdf", "label": "asdf-managed", "icon": "package" } + ] + }, + { + "id": "lang-compilation-stats", + "title": "Compilation Activity", + "description": "Recent compilation jobs, success rates, and average build times", + "type": "metric", + "data_source": { + "endpoint": "/cartridge/lang-mcp/invoke", + "method": "POST", + "body": { "tool": "compilation_stats" }, + "refresh_interval_ms": 15000 + }, + "widgets": [ + { "type": "counter", "field": "jobs_today", "label": "Jobs Today", "icon": "activity" }, + { "type": "gauge", "field": "success_rate", "label": "Success Rate %", "min": 0, "max": 100 }, + { "type": "text", "field": "avg_build_time", "label": "Avg Build Time" } + ] + } + ] +} diff --git a/cartridges/domains/languages/lsp-mcp/README.adoc b/cartridges/domains/languages/lsp-mcp/README.adoc new file mode 100644 index 0000000..3cfa178 --- /dev/null +++ b/cartridges/domains/languages/lsp-mcp/README.adoc @@ -0,0 +1,66 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += lsp-mcp +Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk> +:spdx: MPL-2.0 +:tier: Ayo +:domain: Language Tools +:protocols: MCP, REST + +== Overview + +Generic Language Server Protocol (LSP 3.17) gateway. Spawns any LSP server as a persistent subprocess, manages the JSON-RPC 2.0 over stdio session, and exposes text document operations (open, change, hover, completion, goto-definition, references, diagnostics, formatting) as MCP tools. + +== Tools (12) + +[cols="2,4"] +|=== +| Tool | Description + +| `lsp_start` | Start a language server subprocess and return a session ID. The session stays alive until lsp_stop is called. Multiple concurrent sessions (different language servers or workspaces) are supported. +| `lsp_initialize` | Send the LSP initialize handshake and return the server's declared capabilities. Must be called once after lsp_start before any text document operations. +| `lsp_open` | Notify the language server that a file has been opened (textDocument/didOpen). The server begins tracking the document and may publish diagnostics. +| `lsp_change` | Notify the language server of a document content change (textDocument/didChange, full sync). The server updates its internal model. +| `lsp_close` | Notify the language server that a file has been closed (textDocument/didClose). The server may discard diagnostics for the file. +| `lsp_hover` | Request type and documentation information for the symbol at a cursor position (textDocument/hover). Returns null if no symbol is found. +| `lsp_complete` | Request completion candidates at a cursor position (textDocument/completion). Returns an array of completion items. +| `lsp_goto_def` | Request the definition location of the symbol at a cursor position (textDocument/definition). Returns location(s) or null. +| `lsp_references` | Find all references to the symbol at a cursor position (textDocument/references). Returns an array of locations. +| `lsp_diagnostics` | Return the latest diagnostics published by the server for a document (from textDocument/publishDiagnostics notifications). Returns an empty array if no diagnostics have been received yet. +| `lsp_format` | Request full-document formatting (textDocument/formatting). Returns an array of LSP TextEdit objects to apply. +| `lsp_stop` | Send the LSP shutdown + exit sequence and terminate the language server subprocess. The session ID is invalidated. +|=== + +== Architecture + +Three-layer stack per the BoJ cartridge standard: + +[cols="1,1,3"] +|=== +| Layer | Language | Purpose + +| ABI | Idris2 | Formally verified state machine with dependent-type proofs +| FFI | Zig | C-ABI implementation: session pool, action metrics, thread safety +| Adapter| Zig | Adapter bridge to the BoJ unified adapter protocol (zig predecessors sidelined) +|=== + +== Building + +[source,bash] +---- +# ABI (Idris2) +cd abi && idris2 --check *.idr + +# FFI (Zig) +cd ffi && zig build +cd ffi && zig build test + +# Adapter (Zig) +cd adapter && zig build +---- + +== Status + +Alpha (CRG D). Cartridge metadata declared in `cartridge.json`; 12 tool(s) exposed. +Not yet dogfooded externally. See `docs/READINESS.md` at repo root for the +project-wide grade table and `docs/practice/DOGFOOD-LOG.adoc` for usage evidence. diff --git a/cartridges/domains/languages/lsp-mcp/abi/LspMcp/SafeLsp.idr b/cartridges/domains/languages/lsp-mcp/abi/LspMcp/SafeLsp.idr new file mode 100644 index 0000000..718eab4 --- /dev/null +++ b/cartridges/domains/languages/lsp-mcp/abi/LspMcp/SafeLsp.idr @@ -0,0 +1,187 @@ +-- SPDX-License-Identifier: MPL-2.0 +-- Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +||| LspMcp.SafeLsp: Formally verified Language Server Protocol operations. +||| +||| Cartridge: lsp-mcp +||| Matrix cell: LSP protocol column +||| +||| Models the LSP server lifecycle (initialize β†’ initialized β†’ running β†’ shutdown) +||| with type-level guarantees that: +||| - Requests cannot be sent before initialization completes +||| - Shutdown must occur before exit +||| - Capabilities are negotiated during handshake only +module LspMcp.SafeLsp + +%default total + +-- ═══════════════════════════════════════════════════════════════════════════ +-- LSP Server Lifecycle State Machine +-- ═══════════════════════════════════════════════════════════════════════════ + +||| LSP server lifecycle states per LSP specification 3.17. +public export +data LspState + = Uninitialized -- Before initialize request + | Initializing -- Initialize request sent, awaiting response + | Running -- Fully operational, processing requests + | ShuttingDown -- Shutdown requested, rejecting new work + | Exited -- Exit notification sent + +||| Equality for LSP states. +public export +Eq LspState where + Uninitialized == Uninitialized = True + Initializing == Initializing = True + Running == Running = True + ShuttingDown == ShuttingDown = True + Exited == Exited = True + _ == _ = False + +||| Valid state transitions (enforced at the type level). +public export +data ValidTransition : LspState -> LspState -> Type where + Initialize : ValidTransition Uninitialized Initializing + Initialized : ValidTransition Initializing Running + ShutdownReq : ValidTransition Running ShuttingDown + ExitClean : ValidTransition ShuttingDown Exited + ExitDirty : ValidTransition Running Exited -- crash/forced exit + CancelInit : ValidTransition Initializing Exited + +||| Runtime transition validator. +public export +canTransition : LspState -> LspState -> Bool +canTransition Uninitialized Initializing = True +canTransition Initializing Running = True +canTransition Running ShuttingDown = True +canTransition ShuttingDown Exited = True +canTransition Running Exited = True +canTransition Initializing Exited = True +canTransition _ _ = False + +-- ═══════════════════════════════════════════════════════════════════════════ +-- LSP Capabilities +-- ═══════════════════════════════════════════════════════════════════════════ + +||| Server capabilities that can be registered. +public export +data ServerCapability + = TextDocSync -- textDocument/didOpen, didChange, didClose + | Completion -- textDocument/completion + | Hover -- textDocument/hover + | SignatureHelp -- textDocument/signatureHelp + | Definition -- textDocument/definition + | References -- textDocument/references + | DocumentSymbol -- textDocument/documentSymbol + | CodeAction -- textDocument/codeAction + | Diagnostics -- textDocument/publishDiagnostics + | Formatting -- textDocument/formatting + | Rename -- textDocument/rename + | SemanticTokens -- textDocument/semanticTokens + +||| C-ABI encoding for server capabilities. +public export +capabilityToInt : ServerCapability -> Int +capabilityToInt TextDocSync = 1 +capabilityToInt Completion = 2 +capabilityToInt Hover = 3 +capabilityToInt SignatureHelp = 4 +capabilityToInt Definition = 5 +capabilityToInt References = 6 +capabilityToInt DocumentSymbol = 7 +capabilityToInt CodeAction = 8 +capabilityToInt Diagnostics = 9 +capabilityToInt Formatting = 10 +capabilityToInt Rename = 11 +capabilityToInt SemanticTokens = 12 + +-- ═══════════════════════════════════════════════════════════════════════════ +-- LSP Message Types +-- ═══════════════════════════════════════════════════════════════════════════ + +||| Diagnostic severity per LSP spec. +public export +data DiagnosticSeverity = SevError | SevWarning | SevInformation | SevHint + +||| C-ABI encoding. +public export +severityToInt : DiagnosticSeverity -> Int +severityToInt SevError = 1 +severityToInt SevWarning = 2 +severityToInt SevInformation = 3 +severityToInt SevHint = 4 + +||| Completion item kind (subset of the 25 LSP kinds). +public export +data CompletionKind + = CKText | CKMethod | CKFunction | CKConstructor + | CKField | CKVariable | CKClass | CKInterface + | CKModule | CKProperty | CKKeyword | CKSnippet + +||| C-ABI encoding. +public export +completionKindToInt : CompletionKind -> Int +completionKindToInt CKText = 1 +completionKindToInt CKMethod = 2 +completionKindToInt CKFunction = 3 +completionKindToInt CKConstructor = 4 +completionKindToInt CKField = 5 +completionKindToInt CKVariable = 6 +completionKindToInt CKClass = 7 +completionKindToInt CKInterface = 8 +completionKindToInt CKModule = 9 +completionKindToInt CKProperty = 10 +completionKindToInt CKKeyword = 14 +completionKindToInt CKSnippet = 15 + +-- ═══════════════════════════════════════════════════════════════════════════ +-- State Machine Proofs +-- ═══════════════════════════════════════════════════════════════════════════ + +||| Proof that the LSP lifecycle is well-formed: every non-terminal state +||| has at least one valid outgoing transition. +public export +data LspLifecycleWellFormed : Type where + MkWellFormed : + (uninitOut : canTransition Uninitialized Initializing = True) -> + (initOut : canTransition Initializing Running = True) -> + (runShut : canTransition Running ShuttingDown = True) -> + (shutExit : canTransition ShuttingDown Exited = True) -> + (runExit : canTransition Running Exited = True) -> + (initExit : canTransition Initializing Exited = True) -> + LspLifecycleWellFormed + +||| Witness: the LSP lifecycle is well-formed. +public export +lspLifecycleOk : LspLifecycleWellFormed +lspLifecycleOk = MkWellFormed Refl Refl Refl Refl Refl Refl + +-- ═══════════════════════════════════════════════════════════════════════════ +-- C-ABI Exports +-- ═══════════════════════════════════════════════════════════════════════════ + +||| LSP state to integer. +public export +lspStateToInt : LspState -> Int +lspStateToInt Uninitialized = 0 +lspStateToInt Initializing = 1 +lspStateToInt Running = 2 +lspStateToInt ShuttingDown = 3 +lspStateToInt Exited = 4 + +||| FFI: Validate a state transition. +export +lsp_can_transition : Int -> Int -> Int +lsp_can_transition from to = + let fromState = case from of + 0 => Uninitialized + 1 => Initializing + 2 => Running + 3 => ShuttingDown + _ => Exited + toState = case to of + 0 => Uninitialized + 1 => Initializing + 2 => Running + 3 => ShuttingDown + _ => Exited + in if canTransition fromState toState then 1 else 0 diff --git a/cartridges/domains/languages/lsp-mcp/abi/README.adoc b/cartridges/domains/languages/lsp-mcp/abi/README.adoc new file mode 100644 index 0000000..491935d --- /dev/null +++ b/cartridges/domains/languages/lsp-mcp/abi/README.adoc @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += lsp-mcp / abi β€” Idris2 ABI layer +:orientation: shallow + +== Purpose + +Cartridge-local protocol types for `lsp-mcp`. Defines the data, tool, and +transition vocabulary this cartridge speaks. Inherits safety lemmas and the +matrix-cell identifier scheme from the trunk ABI at `src/abi/`. + +== Files + +1 Idris2 module(s), ~187 lines total. Lead module: +`SafeLsp.idr`. + +No `.ipkg` β€” consumed directly by wider BoJ callers; type-checks inside the +trunk `boj.ipkg`. + +== Invariants + +* `%default total` at every module top. +* Zero `believe_me`, `assert_total`, `postulate`, `Admitted`, or + `sorry` (the trunk holds the four declared `believe_me` sites; leaf + cartridges carry none). + +== Test/proof surface + +Thin module(s), no inline tests. Proof surface = type-check success under the +trunk `idris2 --check`. + +== Read-first + +. `SafeLsp.idr` β€” cartridge-local data records and enums. +. Trunk `src/abi/Boj/Catalogue.idr` for how this cell participates in the + 2D matrix. diff --git a/cartridges/domains/languages/lsp-mcp/abi/lsp-mcp.ipkg b/cartridges/domains/languages/lsp-mcp/abi/lsp-mcp.ipkg new file mode 100644 index 0000000..89b2307 --- /dev/null +++ b/cartridges/domains/languages/lsp-mcp/abi/lsp-mcp.ipkg @@ -0,0 +1,11 @@ +-- SPDX-License-Identifier: MPL-2.0 +package lspmcp + +authors = "Jonathan D.A. Jewell" +version = 0.1.0 +license = "MPL-2.0" +brief = "LSP MCP cartridge β€” Language Server Protocol lifecycle with formal state guarantees" + +sourcedir = "." +modules = LspMcp.SafeLsp +depends = base, contrib diff --git a/cartridges/domains/languages/lsp-mcp/adapter/README.adoc b/cartridges/domains/languages/lsp-mcp/adapter/README.adoc new file mode 100644 index 0000000..c7603c2 --- /dev/null +++ b/cartridges/domains/languages/lsp-mcp/adapter/README.adoc @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += lsp-mcp / adapter β€” protocol bridge +:orientation: shallow + +== Purpose + +Exposes this cartridge's FFI over the wire protocols declared in +`cartridge.json`. Dispatches tool invocations to the C-ABI exports at +`../ffi/` and formats responses per protocol. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” depends on the sibling `ffi` target. +| `lsp_adapter.zig` | Protocol dispatch (720 lines). Tool catalogue matches `cartridge.json`. +| `SIDELINED-*.v.adoc` | zig predecessor(s). Not built β€” zig banned 2026-04-10. +|=== + +== Invariants + +* **Stateless** β€” all state lives behind the FFI, never in the adapter. +* **Response passthrough** β€” whatever the FFI returns goes back to the wire + unmodified (no embellishment, no silent recovery). +* **Cartridge.json is source of truth** for the tool catalogue; drift between + adapter and manifest is a CI failure. + +== Test/proof surface + +No inline tests at the adapter layer β€” protocol routing is exercised by the +trunk seam tests at `ffi/zig/src/seams.zig`. + +== Read-first + +. `lsp_adapter.zig` β€” dispatch function and per-protocol routers. +. `../cartridge.json` β€” the tool manifest the dispatcher mirrors. diff --git a/cartridges/domains/languages/lsp-mcp/adapter/build.zig b/cartridges/domains/languages/lsp-mcp/adapter/build.zig new file mode 100644 index 0000000..fbe3de6 --- /dev/null +++ b/cartridges/domains/languages/lsp-mcp/adapter/build.zig @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// LSP-MCP Cartridge β€” adapter build configuration (Zig 0.14+). +// +// Builds the `lsp-adapter` binary: the unified REST/gRPC-compat/GraphQL server +// that wraps the lsp_ffi.zig session state machine. +// +// Usage: +// zig build -- build lsp-adapter binary +// zig build run -- build and run (REST :9016, gRPC-compat :9017, GraphQL :9018) +// zig build test -- run unit tests + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + // ── lsp_ffi module (state machine from sibling directory) ────────────── + // Imported directly by the adapter β€” no shared-library linking needed. + const ffi_mod = b.createModule(.{ + .root_source_file = b.path("../ffi/lsp_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + // ── lsp-adapter executable ───────────────────────────────────────────── + const adapter = b.addExecutable(.{ + .name = "lsp-adapter", + .root_source_file = b.path("lsp_adapter.zig"), + .target = target, + .optimize = optimize, + }); + adapter.root_module.addImport("lsp_ffi", ffi_mod); + b.installArtifact(adapter); + + // ── run step ─────────────────────────────────────────────────────────── + const run_cmd = b.addRunArtifact(adapter); + const run_step = b.step("run", "Run lsp-adapter (REST :9016, gRPC :9017, GraphQL :9018)"); + run_step.dependOn(&run_cmd.step); + + // ── tests ────────────────────────────────────────────────────────────── + const tests = b.addTest(.{ + .root_source_file = b.path("lsp_adapter.zig"), + .target = target, + .optimize = optimize, + }); + tests.root_module.addImport("lsp_ffi", ffi_mod); + + const run_tests = b.addRunArtifact(tests); + const test_step = b.step("test", "Run adapter unit tests"); + test_step.dependOn(&run_tests.step); +} diff --git a/cartridges/domains/languages/lsp-mcp/adapter/lsp_adapter.zig b/cartridges/domains/languages/lsp-mcp/adapter/lsp_adapter.zig new file mode 100644 index 0000000..5d04948 --- /dev/null +++ b/cartridges/domains/languages/lsp-mcp/adapter/lsp_adapter.zig @@ -0,0 +1,720 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// LSP-MCP Cartridge β€” Unified Zig adapter. +// Replaces the banned lsp_adapter.v (zig removed 2026-04-10). +// +// Exposes the LSP lifecycle state machine from lsp_ffi.zig via three protocols +// on separate threads: +// +// REST port 9016 HTTP/1.1, JSON responses +// gRPC-compat port 9017 HTTP/1.1 transcoding β€” gRPC method paths, JSON bodies +// GraphQL port 9018 HTTP/1.1 POST /graphql, keyword-dispatched +// +// The adapter is a standalone binary; build with: +// zig build -p zig-out (from this directory's build.zig) +// +// The lsp_ffi module (../ffi/lsp_ffi.zig) is imported directly via Zig's +// module system β€” no shared-library linking required. + +const std = @import("std"); + +// ffi module alias β€” re-exported functions from lsp_ffi.zig +const ffi = @import("lsp_ffi"); + +// ═══════════════════════════════════════════════════════════════════════════ +// Constants +// ═══════════════════════════════════════════════════════════════════════════ + +const VERSION = "0.1.0"; +const CARTRIDGE = "lsp-mcp"; + +const REST_PORT: u16 = 9016; +const GRPC_PORT: u16 = 9017; +const GQL_PORT: u16 = 9018; + +/// State labels ordered by LspState integer encoding. +const STATE_LABELS = [_][]const u8{ + "uninitialized", "initializing", "running", "shutting_down", "exited", +}; + +/// Capability labels, 1-indexed (slot 0 unused to match FFI encoding). +const CAP_LABELS = [_][]const u8{ + "", // 0 β€” unused + "text_doc_sync", // 1 + "completion", // 2 + "hover", // 3 + "signature_help", // 4 + "definition", // 5 + "references", // 6 + "document_symbol",// 7 + "code_action", // 8 + "diagnostics", // 9 + "formatting", // 10 + "rename", // 11 + "semantic_tokens",// 12 +}; + +/// Proto schema (for gRPC introspection endpoint). +const GRPC_PROTO = + \\syntax = "proto3"; + \\package lsp_mcp; + \\ + \\service LspService { + \\ rpc CreateSession (Empty) returns (SessionResponse); + \\ rpc Initialize (SlotRequest) returns (SessionResponse); + \\ rpc Initialized (SlotRequest) returns (SessionResponse); + \\ rpc Shutdown (SlotRequest) returns (SessionResponse); + \\ rpc Exit (SlotRequest) returns (SessionResponse); + \\ rpc GetState (SlotRequest) returns (SessionResponse); + \\ rpc RegisterCapability (CapabilityRequest) returns (SessionResponse); + \\ rpc ReleaseSession (SlotRequest) returns (ReleaseResponse); + \\ rpc Health (Empty) returns (HealthResponse); + \\ rpc Types (Empty) returns (TypeInfoResponse); + \\} + \\ + \\message Empty {} + \\message SlotRequest { int32 slot = 1; } + \\message CapabilityRequest { int32 slot = 1; int32 capability = 2; } + \\message SessionResponse { int32 slot = 1; string state = 2; repeated string capabilities = 3; } + \\message ReleaseResponse { int32 slot = 1; bool released = 2; } + \\message HealthResponse { string status = 1; string cartridge = 2; string version = 3; } + \\message TypeInfoResponse { repeated string states = 1; repeated string capabilities = 2; } +; + +/// GraphQL SDL schema (for /graphql/schema endpoint). +const GRAPHQL_SCHEMA = + \\type Query { + \\ health: Health! + \\ session(slot: Int!): Session + \\ types: TypeInfo! + \\} + \\type Mutation { + \\ createSession: Session! + \\ initialize(slot: Int!): Session! + \\ initialized(slot: Int!): Session! + \\ shutdown(slot: Int!): Session! + \\ exit(slot: Int!): Session! + \\ registerCapability(slot: Int!, capability: Int!): Session! + \\ releaseSession(slot: Int!): ReleaseResult! + \\} + \\type Health { status: String! cartridge: String! version: String! } + \\type Session { slot: Int! state: String! capabilities: [String!]! } + \\type ReleaseResult { slot: Int! released: Boolean! } + \\type TypeInfo { states: [String!]! capabilities: [String!]! } +; + +// ═══════════════════════════════════════════════════════════════════════════ +// JSON serialisation helpers +// All helpers write into caller-owned fixed buffers β€” no heap allocation. +// ═══════════════════════════════════════════════════════════════════════════ + +fn stateLabel(s: c_int) []const u8 { + if (s >= 0 and s < @as(c_int, STATE_LABELS.len)) { + return STATE_LABELS[@intCast(s)]; + } + return "unknown"; +} + +/// Write a JSON array of capability strings for the given slot into `buf`. +/// Returns a slice into `buf`. +fn capabilitiesJson(slot: c_int, buf: []u8) []const u8 { + var pos: usize = 0; + buf[pos] = '['; + pos += 1; + var first = true; + var cap: c_int = 1; + while (cap <= 12) : (cap += 1) { + if (ffi.lsp_has_capability(slot, cap) == 1) { + if (!first) { + buf[pos] = ','; + pos += 1; + } + buf[pos] = '"'; + pos += 1; + const label = CAP_LABELS[@intCast(cap)]; + @memcpy(buf[pos..][0..label.len], label); + pos += label.len; + buf[pos] = '"'; + pos += 1; + first = false; + } + } + buf[pos] = ']'; + pos += 1; + return buf[0..pos]; +} + +/// Serialise a full session object: {slot, state, capabilities}. +/// `resp_buf` and `cap_buf` must be distinct slices. +fn sessionJson(slot: c_int, resp_buf: []u8, cap_buf: []u8) []const u8 { + const state = ffi.lsp_state(slot); + const caps = capabilitiesJson(slot, cap_buf); + return std.fmt.bufPrint( + resp_buf, + \\{{"slot":{d},"state":"{s}","capabilities":{s}}} + , + .{ slot, stateLabel(state), caps }, + ) catch resp_buf[0..0]; +} + +fn errorJson(buf: []u8, msg: []const u8, code: c_int) []const u8 { + return std.fmt.bufPrint(buf, + \\{{"error":"{s}","code":{d}}} + , .{ msg, code }) catch buf[0..0]; +} + +fn healthJson(buf: []u8) []const u8 { + return std.fmt.bufPrint(buf, + \\{{"status":"ok","cartridge":"{s}","version":"{s}"}} + , .{ CARTRIDGE, VERSION }) catch buf[0..0]; +} + +/// Static types response (no slot required). +const TYPES_JSON = + \\{"states":["uninitialized","initializing","running","shutting_down","exited"], + \\"capabilities":["text_doc_sync","completion","hover","signature_help","definition", + \\"references","document_symbol","code_action","diagnostics","formatting","rename","semantic_tokens"]} +; + +// ═══════════════════════════════════════════════════════════════════════════ +// Minimal JSON body field parsers +// ═══════════════════════════════════════════════════════════════════════════ + +/// Extract the integer value of `"slot":N` from a JSON body. +fn parseSlot(body: []const u8) ?c_int { + const needle = "\"slot\":"; + const idx = std.mem.indexOf(u8, body, needle) orelse return null; + const rest = std.mem.trimLeft(u8, body[idx + needle.len ..], " \t\r\n"); + var end: usize = 0; + while (end < rest.len and rest[end] >= '0' and rest[end] <= '9') : (end += 1) {} + if (end == 0) return null; + return @intCast(std.fmt.parseInt(i32, rest[0..end], 10) catch return null); +} + +/// Extract the integer value of `"capability":N` from a JSON body. +fn parseCapability(body: []const u8) ?c_int { + const needle = "\"capability\":"; + const idx = std.mem.indexOf(u8, body, needle) orelse return null; + const rest = std.mem.trimLeft(u8, body[idx + needle.len ..], " \t\r\n"); + var end: usize = 0; + while (end < rest.len and rest[end] >= '0' and rest[end] <= '9') : (end += 1) {} + if (end == 0) return null; + return @intCast(std.fmt.parseInt(i32, rest[0..end], 10) catch return null); +} + +/// Extract the integer segment immediately before the last named path segment. +/// E.g. "/sessions/3/state" β†’ 3. "/sessions/3" β†’ 3. +fn pathSlot(target: []const u8) ?c_int { + // Walk path segments from right; return the first all-digit segment found. + var it = std.mem.splitBackwardsScalar(u8, target, '/'); + while (it.next()) |seg| { + if (seg.len == 0) continue; + var all_digits = true; + for (seg) |c| { + if (c < '0' or c > '9') { + all_digits = false; + break; + } + } + if (all_digits) { + return @intCast(std.fmt.parseInt(i32, seg, 10) catch return null); + } + } + return null; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Response type +// ═══════════════════════════════════════════════════════════════════════════ + +const Response = struct { + status: std.http.Status, + body: []const u8, + content_type: []const u8 = "application/json", +}; + +// ═══════════════════════════════════════════════════════════════════════════ +// REST dispatch β€” method + path routing for port 9016 +// ═══════════════════════════════════════════════════════════════════════════ + +fn dispatchRest( + method: std.http.Method, + target: []const u8, + body: []const u8, + resp: []u8, + cap: []u8, +) Response { + const ok = std.http.Status.ok; + const bad = std.http.Status.bad_request; + + // GET /health + if (method == .GET and std.mem.eql(u8, target, "/health")) { + return .{ .status = ok, .body = healthJson(resp) }; + } + // GET /types + if (method == .GET and std.mem.eql(u8, target, "/types")) { + return .{ .status = ok, .body = TYPES_JSON }; + } + // POST /sessions β€” allocate a new session slot + if (method == .POST and std.mem.eql(u8, target, "/sessions")) { + const slot = ffi.lsp_init(); + if (slot < 0) return .{ .status = bad, .body = errorJson(resp, "no slots available", slot) }; + return .{ .status = ok, .body = sessionJson(slot, resp, cap) }; + } + + if (std.mem.startsWith(u8, target, "/sessions/")) { + const slot_opt = pathSlot(std.mem.trimRight(u8, target, "/")); + + // GET /sessions/:slot/state + if (method == .GET and std.mem.endsWith(u8, target, "/state")) { + const slot = slot_opt orelse return .{ .status = bad, .body = errorJson(resp, "missing slot", -1) }; + return .{ .status = ok, .body = sessionJson(slot, resp, cap) }; + } + // POST /sessions/:slot/initialize + if (method == .POST and std.mem.endsWith(u8, target, "/initialize")) { + const slot = slot_opt orelse return .{ .status = bad, .body = errorJson(resp, "missing slot", -1) }; + const r = ffi.lsp_start_init(slot); + if (r < 0) return .{ .status = bad, .body = errorJson(resp, "invalid transition", r) }; + return .{ .status = ok, .body = sessionJson(slot, resp, cap) }; + } + // POST /sessions/:slot/initialized + if (method == .POST and std.mem.endsWith(u8, target, "/initialized")) { + const slot = slot_opt orelse return .{ .status = bad, .body = errorJson(resp, "missing slot", -1) }; + const r = ffi.lsp_initialized(slot); + if (r < 0) return .{ .status = bad, .body = errorJson(resp, "invalid transition", r) }; + return .{ .status = ok, .body = sessionJson(slot, resp, cap) }; + } + // POST /sessions/:slot/shutdown + if (method == .POST and std.mem.endsWith(u8, target, "/shutdown")) { + const slot = slot_opt orelse return .{ .status = bad, .body = errorJson(resp, "missing slot", -1) }; + const r = ffi.lsp_shutdown(slot); + if (r < 0) return .{ .status = bad, .body = errorJson(resp, "invalid transition", r) }; + return .{ .status = ok, .body = sessionJson(slot, resp, cap) }; + } + // POST /sessions/:slot/exit + if (method == .POST and std.mem.endsWith(u8, target, "/exit")) { + const slot = slot_opt orelse return .{ .status = bad, .body = errorJson(resp, "missing slot", -1) }; + const r = ffi.lsp_exit(slot); + if (r < 0) return .{ .status = bad, .body = errorJson(resp, "invalid transition", r) }; + return .{ .status = ok, .body = sessionJson(slot, resp, cap) }; + } + // POST /sessions/:slot/capability + if (method == .POST and std.mem.endsWith(u8, target, "/capability")) { + const slot = slot_opt orelse return .{ .status = bad, .body = errorJson(resp, "missing slot", -1) }; + const c = parseCapability(body) orelse return .{ .status = bad, .body = errorJson(resp, "missing capability", -1) }; + const r = ffi.lsp_register_capability(slot, c); + if (r < 0) return .{ .status = bad, .body = errorJson(resp, "invalid capability or state", r) }; + return .{ .status = ok, .body = sessionJson(slot, resp, cap) }; + } + // DELETE /sessions/:slot + if (method == .DELETE) { + const slot = slot_opt orelse return .{ .status = bad, .body = errorJson(resp, "missing slot", -1) }; + const r = ffi.lsp_release(slot); + if (r < 0) return .{ .status = bad, .body = errorJson(resp, "release failed", r) }; + return .{ + .status = ok, + .body = std.fmt.bufPrint(resp, + \\{{"slot":{d},"released":true}} + , .{slot}) catch resp[0..0], + }; + } + } + + return .{ + .status = std.http.Status.not_found, + .body = errorJson(resp, "not found", -404), + }; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// gRPC-compat dispatch β€” HTTP/1.1 + gRPC method paths for port 9017 +// +// Each RPC maps to POST /lsp_mcp.LspService/<MethodName>. +// Bodies and responses are JSON (not protobuf wire format); the proto schema +// is served at GET /proto for documentation and client generation tooling. +// ═══════════════════════════════════════════════════════════════════════════ + +const GRPC_PREFIX = "/lsp_mcp.LspService/"; + +fn dispatchGrpc( + target: []const u8, + body: []const u8, + resp: []u8, + cap: []u8, +) Response { + const ok = std.http.Status.ok; + const bad = std.http.Status.bad_request; + + // Schema introspection + if (std.mem.eql(u8, target, "/proto")) { + return .{ .status = ok, .body = GRPC_PROTO, .content_type = "text/plain" }; + } + + if (!std.mem.startsWith(u8, target, GRPC_PREFIX)) { + return .{ + .status = std.http.Status.not_found, + .body = errorJson(resp, "unknown gRPC path", -1), + }; + } + + const rpc = target[GRPC_PREFIX.len..]; + + // No-argument RPCs + if (std.mem.eql(u8, rpc, "Health")) return .{ .status = ok, .body = healthJson(resp) }; + if (std.mem.eql(u8, rpc, "Types")) return .{ .status = ok, .body = TYPES_JSON }; + if (std.mem.eql(u8, rpc, "CreateSession")) { + const slot = ffi.lsp_init(); + if (slot < 0) return .{ .status = bad, .body = errorJson(resp, "no slots available", slot) }; + return .{ .status = ok, .body = sessionJson(slot, resp, cap) }; + } + + // Slot-required RPCs + const slot = parseSlot(body) orelse + return .{ .status = bad, .body = errorJson(resp, "missing slot in body", -1) }; + + if (std.mem.eql(u8, rpc, "GetState")) { + return .{ .status = ok, .body = sessionJson(slot, resp, cap) }; + } + if (std.mem.eql(u8, rpc, "Initialize")) { + const r = ffi.lsp_start_init(slot); + if (r < 0) return .{ .status = bad, .body = errorJson(resp, "invalid transition", r) }; + return .{ .status = ok, .body = sessionJson(slot, resp, cap) }; + } + if (std.mem.eql(u8, rpc, "Initialized")) { + const r = ffi.lsp_initialized(slot); + if (r < 0) return .{ .status = bad, .body = errorJson(resp, "invalid transition", r) }; + return .{ .status = ok, .body = sessionJson(slot, resp, cap) }; + } + if (std.mem.eql(u8, rpc, "Shutdown")) { + const r = ffi.lsp_shutdown(slot); + if (r < 0) return .{ .status = bad, .body = errorJson(resp, "invalid transition", r) }; + return .{ .status = ok, .body = sessionJson(slot, resp, cap) }; + } + if (std.mem.eql(u8, rpc, "Exit")) { + const r = ffi.lsp_exit(slot); + if (r < 0) return .{ .status = bad, .body = errorJson(resp, "invalid transition", r) }; + return .{ .status = ok, .body = sessionJson(slot, resp, cap) }; + } + if (std.mem.eql(u8, rpc, "RegisterCapability")) { + const c = parseCapability(body) orelse + return .{ .status = bad, .body = errorJson(resp, "missing capability in body", -1) }; + const r = ffi.lsp_register_capability(slot, c); + if (r < 0) return .{ .status = bad, .body = errorJson(resp, "invalid capability or state", r) }; + return .{ .status = ok, .body = sessionJson(slot, resp, cap) }; + } + if (std.mem.eql(u8, rpc, "ReleaseSession")) { + const r = ffi.lsp_release(slot); + if (r < 0) return .{ .status = bad, .body = errorJson(resp, "release failed", r) }; + return .{ + .status = ok, + .body = std.fmt.bufPrint(resp, + \\{{"slot":{d},"released":true}} + , .{slot}) catch resp[0..0], + }; + } + + return .{ + .status = std.http.Status.not_found, + .body = errorJson(resp, "unknown gRPC method", -1), + }; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// GraphQL dispatch β€” POST /graphql on port 9018 +// +// Production-quality GraphQL parsing would require a full parser. This +// implementation uses keyword detection on the raw query string, which is +// sufficient for the small fixed schema. The slot/capability values are +// parsed from the variables object (same JSON field names as REST). +// +// Supported: +// query { health { ... } } +// query { types { ... } } +// query { session(slot: N) { ... } } +// mutation { createSession { ... } } +// mutation { initialize(slot: N) { ... } } +// mutation { initialized(slot: N) { ... } } +// mutation { shutdown(slot: N) { ... } } +// mutation { exit(slot: N) { ... } } +// mutation { registerCapability(slot: N, capability: N) { ... } } +// mutation { releaseSession(slot: N) { ... } } +// introspection: __schema / __type +// +// GET /graphql/schema returns the SDL. +// ═══════════════════════════════════════════════════════════════════════════ + +fn dispatchGraphql( + query_body: []const u8, + resp: []u8, + cap: []u8, +) Response { + const ok = std.http.Status.ok; + const bad = std.http.Status.bad_request; + const has = std.mem.indexOf; + + // Introspection + if (has(u8, query_body, "__schema") != null or has(u8, query_body, "__type") != null) { + return .{ + .status = ok, + .body = std.fmt.bufPrint(resp, + \\{{"data":{{"__schema":{{"description":"{s}"}}}}}} + , .{GRAPHQL_SCHEMA}) catch resp[0..0], + }; + } + + // health query (no slot) + if (has(u8, query_body, "health") != null and has(u8, query_body, "mutation") == null) { + const h = healthJson(cap); + return .{ + .status = ok, + .body = std.fmt.bufPrint(resp, + \\{{"data":{{"health":{s}}}}} + , .{h}) catch resp[0..0], + }; + } + + // types query (no slot) + if (has(u8, query_body, "types") != null and has(u8, query_body, "mutation") == null) { + return .{ + .status = ok, + .body = std.fmt.bufPrint(resp, + \\{{"data":{{"types":{s}}}}} + , .{TYPES_JSON}) catch resp[0..0], + }; + } + + // createSession mutation (no slot) + if (has(u8, query_body, "createSession") != null) { + const slot = ffi.lsp_init(); + if (slot < 0) return .{ .status = bad, .body = errorJson(resp, "no slots available", slot) }; + return .{ + .status = ok, + .body = std.fmt.bufPrint(resp, + \\{{"data":{{"createSession":{s}}}}} + , .{sessionJson(slot, cap, cap)}) catch resp[0..0], + }; + } + + // All remaining operations need a slot + const slot = parseSlot(query_body) orelse + return .{ .status = bad, .body = errorJson(resp, "could not determine slot from query", -1) }; + + // registerCapability β€” check before "initialize" to avoid prefix confusion + if (has(u8, query_body, "registerCapability") != null) { + const c = parseCapability(query_body) orelse + return .{ .status = bad, .body = errorJson(resp, "missing capability in query", -1) }; + const r = ffi.lsp_register_capability(slot, c); + if (r < 0) return .{ .status = bad, .body = errorJson(resp, "invalid capability or state", r) }; + return .{ + .status = ok, + .body = std.fmt.bufPrint(resp, + \\{{"data":{{"registerCapability":{s}}}}} + , .{sessionJson(slot, cap, cap)}) catch resp[0..0], + }; + } + + // initialized β€” check before "initialize" (prefix ordering) + if (has(u8, query_body, "initialized") != null) { + const r = ffi.lsp_initialized(slot); + if (r < 0) return .{ .status = bad, .body = errorJson(resp, "invalid transition", r) }; + return .{ + .status = ok, + .body = std.fmt.bufPrint(resp, + \\{{"data":{{"initialized":{s}}}}} + , .{sessionJson(slot, cap, cap)}) catch resp[0..0], + }; + } + + if (has(u8, query_body, "initialize") != null) { + const r = ffi.lsp_start_init(slot); + if (r < 0) return .{ .status = bad, .body = errorJson(resp, "invalid transition", r) }; + return .{ + .status = ok, + .body = std.fmt.bufPrint(resp, + \\{{"data":{{"initialize":{s}}}}} + , .{sessionJson(slot, cap, cap)}) catch resp[0..0], + }; + } + + if (has(u8, query_body, "shutdown") != null) { + const r = ffi.lsp_shutdown(slot); + if (r < 0) return .{ .status = bad, .body = errorJson(resp, "invalid transition", r) }; + return .{ + .status = ok, + .body = std.fmt.bufPrint(resp, + \\{{"data":{{"shutdown":{s}}}}} + , .{sessionJson(slot, cap, cap)}) catch resp[0..0], + }; + } + + if (has(u8, query_body, "exit") != null) { + const r = ffi.lsp_exit(slot); + if (r < 0) return .{ .status = bad, .body = errorJson(resp, "invalid transition", r) }; + return .{ + .status = ok, + .body = std.fmt.bufPrint(resp, + \\{{"data":{{"exit":{s}}}}} + , .{sessionJson(slot, cap, cap)}) catch resp[0..0], + }; + } + + if (has(u8, query_body, "releaseSession") != null) { + const r = ffi.lsp_release(slot); + if (r < 0) return .{ .status = bad, .body = errorJson(resp, "release failed", r) }; + return .{ + .status = ok, + .body = std.fmt.bufPrint(resp, + \\{{"data":{{"releaseSession":{{"slot":{d},"released":true}}}}}} + , .{slot}) catch resp[0..0], + }; + } + + if (has(u8, query_body, "session") != null) { + return .{ + .status = ok, + .body = std.fmt.bufPrint(resp, + \\{{"data":{{"session":{s}}}}} + , .{sessionJson(slot, cap, cap)}) catch resp[0..0], + }; + } + + return .{ .status = bad, .body = errorJson(resp, "unrecognised GraphQL operation", -1) }; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// HTTP connection handler β€” shared by all three listeners. +// Reads one request from `conn`, dispatches, writes response, closes. +// ═══════════════════════════════════════════════════════════════════════════ + +const Protocol = enum { rest, grpc, graphql }; + +fn handleConnection(conn: std.net.Server.Connection, protocol: Protocol) void { + defer conn.stream.close(); + + var read_buf: [8192]u8 = undefined; + var http_srv = std.http.Server.init(conn, &read_buf); + + var request = http_srv.receiveHead() catch return; + + // Read body up to 256 KiB + var body_buf: [262144]u8 = undefined; + var body_len: usize = 0; + if (request.head.content_length) |cl| { + const to_read: usize = @min(cl, body_buf.len); + var reader = request.reader() catch return; + body_len = reader.readAll(body_buf[0..to_read]) catch 0; + } + + var resp_buf: [4096]u8 = undefined; + var cap_buf: [512]u8 = undefined; + + const resp: Response = switch (protocol) { + .rest => blk: { + // Schema SDL shortcut for REST + if (request.head.method == .GET and + std.mem.eql(u8, request.head.target, "/graphql/schema")) + { + break :blk .{ .status = .ok, .body = GRAPHQL_SCHEMA, .content_type = "text/plain" }; + } + break :blk dispatchRest( + request.head.method, + request.head.target, + body_buf[0..body_len], + &resp_buf, + &cap_buf, + ); + }, + .grpc => dispatchGrpc( + request.head.target, + body_buf[0..body_len], + &resp_buf, + &cap_buf, + ), + .graphql => blk: { + if (request.head.method == .GET and + std.mem.eql(u8, request.head.target, "/graphql/schema")) + { + break :blk .{ .status = .ok, .body = GRAPHQL_SCHEMA, .content_type = "text/plain" }; + } + break :blk dispatchGraphql(body_buf[0..body_len], &resp_buf, &cap_buf); + }, + }; + + // gRPC adds grpc-status header; others just content-type + CORS + const grpc_status: std.http.Header = .{ + .name = "grpc-status", + .value = if (resp.status == .ok) "0" else "2", + }; + const ct_header: std.http.Header = .{ .name = "content-type", .value = resp.content_type }; + const cors_header: std.http.Header = .{ .name = "access-control-allow-origin", .value = "*" }; + + if (protocol == .grpc) { + request.respond(resp.body, .{ + .status = resp.status, + .extra_headers = &.{ ct_header, grpc_status }, + }) catch {}; + } else { + request.respond(resp.body, .{ + .status = resp.status, + .extra_headers = &.{ ct_header, cors_header }, + }) catch {}; + } +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Per-protocol listener loops (run on separate threads) +// ═══════════════════════════════════════════════════════════════════════════ + +const ListenerCtx = struct { + listener: *std.net.Server, + protocol: Protocol, +}; + +fn listenLoop(ctx: ListenerCtx) void { + while (true) { + const conn = ctx.listener.accept() catch |err| { + std.log.err("accept error on {s} listener: {}", .{ @tagName(ctx.protocol), err }); + continue; + }; + handleConnection(conn, ctx.protocol); + } +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Main +// ═══════════════════════════════════════════════════════════════════════════ + +pub fn main() !void { + // Initialise the FFI session table + _ = ffi.boj_cartridge_init(); + + const rest_addr = try std.net.Address.parseIp4("0.0.0.0", REST_PORT); + const grpc_addr = try std.net.Address.parseIp4("0.0.0.0", GRPC_PORT); + const gql_addr = try std.net.Address.parseIp4("0.0.0.0", GQL_PORT); + + var rest_listener = try rest_addr.listen(.{ .reuse_address = true }); + defer rest_listener.deinit(); + var grpc_listener = try grpc_addr.listen(.{ .reuse_address = true }); + defer grpc_listener.deinit(); + var gql_listener = try gql_addr.listen(.{ .reuse_address = true }); + defer gql_listener.deinit(); + + std.log.info("{s} REST :{d}", .{ CARTRIDGE, REST_PORT }); + std.log.info("{s} gRPC-compat :{d}", .{ CARTRIDGE, GRPC_PORT }); + std.log.info("{s} GraphQL :{d}", .{ CARTRIDGE, GQL_PORT }); + + const t_rest = try std.Thread.spawn(.{}, listenLoop, .{ + ListenerCtx{ .listener = &rest_listener, .protocol = .rest }, + }); + const t_grpc = try std.Thread.spawn(.{}, listenLoop, .{ + ListenerCtx{ .listener = &grpc_listener, .protocol = .grpc }, + }); + const t_gql = try std.Thread.spawn(.{}, listenLoop, .{ + ListenerCtx{ .listener = &gql_listener, .protocol = .graphql }, + }); + + t_rest.join(); + t_grpc.join(); + t_gql.join(); +} diff --git a/cartridges/domains/languages/lsp-mcp/cartridge.json b/cartridges/domains/languages/lsp-mcp/cartridge.json new file mode 100644 index 0000000..c7505d9 --- /dev/null +++ b/cartridges/domains/languages/lsp-mcp/cartridge.json @@ -0,0 +1,369 @@ +{ + "$schema": "https://boj.dev/schemas/cartridge/v1.json", + "spdx": "MPL-2.0", + "copyright": "Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath)", + "name": "lsp-mcp", + "version": "0.1.0", + "description": "Generic Language Server Protocol (LSP 3.17) gateway. Spawns any LSP server as a persistent subprocess, manages the JSON-RPC 2.0 over stdio session, and exposes text document operations (open, change, hover, completion, goto-definition, references, diagnostics, formatting) as MCP tools.", + "domain": "Language Tools", + "tier": "Ayo", + "protocols": [ + "MCP", + "REST" + ], + "auth": { + "method": "none", + "env_var": null, + "credential_source": null + }, + "api": { + "base_url": "local://lsp-mcp", + "content_type": "application/json" + }, + "tools": [ + { + "name": "lsp_start", + "description": "Start a language server subprocess and return a session ID. The session stays alive until lsp_stop is called. Multiple concurrent sessions (different language servers or workspaces) are supported.", + "inputSchema": { + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "Language server executable (e.g. 'rust-analyzer', 'affinescript server --stdio', 'clangd'). Optional when `preset` is given." + }, + "preset": { + "type": "string", + "description": "Permanent pinned toolchain preset from presets.json (e.g. 'rust' β†’ the canonical rust-analyzer at /dev/tools). Use this instead of re-specifying the command/path every session. One of `command` or `preset` is required; an explicit `command` overrides the preset." + }, + "args": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Additional command arguments (appended after any preset args)" + }, + "workspace_root": { + "type": "string", + "description": "Workspace root directory path" + } + }, + "required": [] + } + }, + { + "name": "lsp_initialize", + "description": "Send the LSP initialize handshake and return the server's declared capabilities. Must be called once after lsp_start before any text document operations.", + "inputSchema": { + "type": "object", + "properties": { + "session_id": { + "type": "string", + "description": "Session ID from lsp_start" + }, + "root_uri": { + "type": "string", + "description": "Workspace root as a file:// URI" + }, + "client_name": { + "type": "string", + "description": "Client name reported to server (default: boj-lsp-mcp)" + } + }, + "required": [ + "session_id", + "root_uri" + ] + } + }, + { + "name": "lsp_open", + "description": "Notify the language server that a file has been opened (textDocument/didOpen). The server begins tracking the document and may publish diagnostics.", + "inputSchema": { + "type": "object", + "properties": { + "session_id": { + "type": "string", + "description": "Session ID" + }, + "uri": { + "type": "string", + "description": "Document URI (file://...)" + }, + "language_id": { + "type": "string", + "description": "Language ID (e.g. 'affinescript', 'rust', 'ocaml')" + }, + "text": { + "type": "string", + "description": "Full document text" + } + }, + "required": [ + "session_id", + "uri", + "language_id", + "text" + ] + } + }, + { + "name": "lsp_change", + "description": "Notify the language server of a document content change (textDocument/didChange, full sync). The server updates its internal model.", + "inputSchema": { + "type": "object", + "properties": { + "session_id": { + "type": "string", + "description": "Session ID" + }, + "uri": { + "type": "string", + "description": "Document URI" + }, + "text": { + "type": "string", + "description": "Full updated document text" + }, + "version": { + "type": "number", + "description": "Document version counter (increment each change)" + } + }, + "required": [ + "session_id", + "uri", + "text" + ] + } + }, + { + "name": "lsp_close", + "description": "Notify the language server that a file has been closed (textDocument/didClose). The server may discard diagnostics for the file.", + "inputSchema": { + "type": "object", + "properties": { + "session_id": { + "type": "string", + "description": "Session ID" + }, + "uri": { + "type": "string", + "description": "Document URI" + } + }, + "required": [ + "session_id", + "uri" + ] + } + }, + { + "name": "lsp_hover", + "description": "Request type and documentation information for the symbol at a cursor position (textDocument/hover). Returns null if no symbol is found.", + "inputSchema": { + "type": "object", + "properties": { + "session_id": { + "type": "string", + "description": "Session ID" + }, + "uri": { + "type": "string", + "description": "Document URI" + }, + "line": { + "type": "number", + "description": "0-based line number" + }, + "col": { + "type": "number", + "description": "0-based character offset" + } + }, + "required": [ + "session_id", + "uri", + "line", + "col" + ] + } + }, + { + "name": "lsp_complete", + "description": "Request completion candidates at a cursor position (textDocument/completion). Returns an array of completion items.", + "inputSchema": { + "type": "object", + "properties": { + "session_id": { + "type": "string", + "description": "Session ID" + }, + "uri": { + "type": "string", + "description": "Document URI" + }, + "line": { + "type": "number", + "description": "0-based line number" + }, + "col": { + "type": "number", + "description": "0-based character offset" + }, + "trigger_char": { + "type": "string", + "description": "Optional trigger character (e.g. '.' or ':')" + } + }, + "required": [ + "session_id", + "uri", + "line", + "col" + ] + } + }, + { + "name": "lsp_goto_def", + "description": "Request the definition location of the symbol at a cursor position (textDocument/definition). Returns location(s) or null.", + "inputSchema": { + "type": "object", + "properties": { + "session_id": { + "type": "string", + "description": "Session ID" + }, + "uri": { + "type": "string", + "description": "Document URI" + }, + "line": { + "type": "number", + "description": "0-based line number" + }, + "col": { + "type": "number", + "description": "0-based character offset" + } + }, + "required": [ + "session_id", + "uri", + "line", + "col" + ] + } + }, + { + "name": "lsp_references", + "description": "Find all references to the symbol at a cursor position (textDocument/references). Returns an array of locations.", + "inputSchema": { + "type": "object", + "properties": { + "session_id": { + "type": "string", + "description": "Session ID" + }, + "uri": { + "type": "string", + "description": "Document URI" + }, + "line": { + "type": "number", + "description": "0-based line number" + }, + "col": { + "type": "number", + "description": "0-based character offset" + }, + "include_declaration": { + "type": "boolean", + "description": "Include the declaration site (default: true)" + } + }, + "required": [ + "session_id", + "uri", + "line", + "col" + ] + } + }, + { + "name": "lsp_diagnostics", + "description": "Return the latest diagnostics published by the server for a document (from textDocument/publishDiagnostics notifications). Returns an empty array if no diagnostics have been received yet.", + "inputSchema": { + "type": "object", + "properties": { + "session_id": { + "type": "string", + "description": "Session ID" + }, + "uri": { + "type": "string", + "description": "Document URI (optional β€” omit for all documents)" + } + }, + "required": [ + "session_id" + ] + } + }, + { + "name": "lsp_format", + "description": "Request full-document formatting (textDocument/formatting). Returns an array of LSP TextEdit objects to apply.", + "inputSchema": { + "type": "object", + "properties": { + "session_id": { + "type": "string", + "description": "Session ID" + }, + "uri": { + "type": "string", + "description": "Document URI" + }, + "tab_size": { + "type": "number", + "description": "Indentation width (default: 2)" + }, + "insert_spaces": { + "type": "boolean", + "description": "Use spaces instead of tabs (default: true)" + } + }, + "required": [ + "session_id", + "uri" + ] + } + }, + { + "name": "lsp_stop", + "description": "Send the LSP shutdown + exit sequence and terminate the language server subprocess. The session ID is invalidated.", + "inputSchema": { + "type": "object", + "properties": { + "session_id": { + "type": "string", + "description": "Session ID to stop" + } + }, + "required": [ + "session_id" + ] + } + } + ], + "ffi": { + "so_path": "ffi/zig-out/lib/liblsp_mcp.so", + "abi_version": "ADR-0006", + "symbols": [ + "boj_cartridge_init", + "boj_cartridge_deinit", + "boj_cartridge_name", + "boj_cartridge_version", + "boj_cartridge_invoke" + ] + } +} diff --git a/cartridges/domains/languages/lsp-mcp/ffi/README.adoc b/cartridges/domains/languages/lsp-mcp/ffi/README.adoc new file mode 100644 index 0000000..dacadaa --- /dev/null +++ b/cartridges/domains/languages/lsp-mcp/ffi/README.adoc @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += lsp-mcp / ffi β€” Zig FFI layer +:orientation: shallow + +== Purpose + +C-ABI exports bridging this cartridge's Idris2 ABI to the runtime. Consumed by +the adapter for protocol dispatch and by the trunk loader for mount. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” produces `zig-out/lib/liblsp.so`. +| `lsp_ffi.zig` | C-ABI exports (15 exports, 5 inline tests, 302 lines). +|=== + +== Invariants + +* **Null-pointer rejection** on `[*c]const u8` arguments before any + dereference. +* **Negative return values** carry documented error meanings; zero is + success (matching the trunk error-code convention). +* **No allocator leaks** β€” every allocation path pairs with `defer free`. + +== Test/proof surface + +5 inline `test "..."` block(s) in `lsp_ffi.zig`. Run with +`cd ffi && zig build test`. + +== Read-first + +. `lsp_ffi.zig` β€” C-ABI signatures and guards. +. Trunk `ffi/zig/src/catalogue.zig` for how this `.so` is loaded. diff --git a/cartridges/domains/languages/lsp-mcp/ffi/build.zig b/cartridges/domains/languages/lsp-mcp/ffi/build.zig new file mode 100644 index 0000000..1d1a0e3 --- /dev/null +++ b/cartridges/domains/languages/lsp-mcp/ffi/build.zig @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// LSP-MCP Cartridge β€” Zig FFI build configuration (Zig 0.15+). + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + // Shared ADR-0006 invoke-shim module (relative path up to boj-server trunk). + + const shim_mod = b.addModule("cartridge_shim", .{ + + .root_source_file = b.path("../../../ffi/zig/src/cartridge_shim.zig"), + + .target = target, + + .optimize = optimize, + + }); + + const lsp_mod = b.addModule("lsp_ffi", .{ + .root_source_file = b.path("lsp_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + lsp_mod.addImport("cartridge_shim", shim_mod); + + // ── Tests ──────────────────────────────────────────────────────── + const lsp_tests = b.addTest(.{ + .root_module = lsp_mod, + }); + + const run_tests = b.addRunArtifact(lsp_tests); + + const test_step = b.step("test", "Run lsp-mcp FFI tests"); + test_step.dependOn(&run_tests.step); + + // ── Shared library ────────────────────────────────────────────── + const lib_mod = b.createModule(.{ + .root_source_file = b.path("lsp_ffi.zig"), + .target = target, + .optimize = optimize, + }); + lib_mod.addImport("cartridge_shim", shim_mod); + + const lib = b.addLibrary(.{ + .name = "lsp_mcp", + .root_module = lib_mod, + .linkage = .dynamic, + }); + b.installArtifact(lib); + + const lib_step = b.step("lib", "Build shared library"); + lib_step.dependOn(&lib.step); +} diff --git a/cartridges/domains/languages/lsp-mcp/ffi/cartridge_shim.zig b/cartridges/domains/languages/lsp-mcp/ffi/cartridge_shim.zig new file mode 100644 index 0000000..0e399db --- /dev/null +++ b/cartridges/domains/languages/lsp-mcp/ffi/cartridge_shim.zig @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// cartridge_shim.zig β€” Shared helpers for the ADR-0006 five-symbol +// cartridge ABI (`boj_cartridge_init / deinit / name / version / invoke`). +// +// The shim centralises the seven-code return convention, NUL-argument +// guards, tool-name comparison, and the buffer-too-small path so each +// cartridge's `boj_cartridge_invoke` can stay short β€” typically a tool +// table plus `shim.writeResult(...)`. +// +// Cartridges import this file by relative path (no build-graph change +// needed). Example: +// +// const shim = @import("../../../ffi/zig/src/cartridge_shim.zig"); +// +// export fn boj_cartridge_invoke( +// tool_name: [*c]const u8, +// json_args: [*c]const u8, +// out_buf: [*c]u8, +// in_out_len: [*c]usize, +// ) callconv(.c) i32 { +// _ = json_args; +// if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; +// const body = if (shim.toolIs(tool_name, "foo")) "{\"result\":{}}" +// else return shim.RC_UNKNOWN_TOOL; +// return shim.writeResult(out_buf, in_out_len, body); +// } + +const std = @import("std"); + +// ── Return codes (ADR-0006 Β§Return codes) ──────────────────────────── +// +// Frozen by ADR-0006. New failure modes compose these via the error +// JSON body β€” the integer surface does not grow without a follow-up ADR. + +pub const RC_SUCCESS: i32 = 0; +pub const RC_UNKNOWN_TOOL: i32 = -1; +pub const RC_BAD_ARGS: i32 = -2; +pub const RC_BUFFER_TOO_SMALL: i32 = -3; +pub const RC_RUNTIME_ERROR: i32 = -4; +pub const RC_PANIC: i32 = -5; +pub const RC_AUTH_DENIED: i32 = -6; + +// ── Invoke-path helpers ────────────────────────────────────────────── + +/// True if any of the three mandatory `boj_cartridge_invoke` output-path +/// pointers is null. Use at the top of every invoke to short-circuit to +/// `RC_BAD_ARGS`. +pub fn invokeArgsNull( + tool_name: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) bool { + return tool_name == null or out_buf == null or in_out_len == null; +} + +/// Compare a C-NUL-terminated tool-name pointer against a Zig string +/// literal. Caller must have already verified `tool_name` is non-null +/// (usually via `invokeArgsNull`). +pub fn toolIs(tool_name: [*c]const u8, expected: []const u8) bool { + const s = std.mem.span(@as([*:0]const u8, @ptrCast(tool_name))); + return std.mem.eql(u8, s, expected); +} + +/// Copy `body` into `out_buf[0..*in_out_len]` (as a capacity) and update +/// `*in_out_len` to the number of bytes written. Returns `RC_SUCCESS`. +/// +/// If `body.len` exceeds the current capacity stored in `*in_out_len`, +/// sets `*in_out_len` to the required size and returns +/// `RC_BUFFER_TOO_SMALL` β€” the caller is then expected to re-allocate +/// and retry, per ADR-0006 Β§Memory ownership. +/// +/// Caller must have already verified that `out_buf` and `in_out_len` +/// are non-null. +pub fn writeResult( + out_buf: [*c]u8, + in_out_len: [*c]usize, + body: []const u8, +) i32 { + const cap = in_out_len.*; + if (body.len > cap) { + in_out_len.* = body.len; + return RC_BUFFER_TOO_SMALL; + } + @memcpy(out_buf[0..body.len], body); + in_out_len.* = body.len; + return RC_SUCCESS; +} + +// ── Tests ──────────────────────────────────────────────────────────── + +test "writeResult: body fits, writes and sets length" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); + try std.testing.expectEqualStrings("hello", buf[0..len]); +} + +test "writeResult: too small returns -3 and sets required length" { + var buf: [2]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_BUFFER_TOO_SMALL, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: exact-fit succeeds" { + var buf: [5]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: empty body" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, ""); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 0), len); +} + +test "toolIs: matches and rejects" { + const name: [*:0]const u8 = "foo"; + try std.testing.expect(toolIs(@ptrCast(name), "foo")); + try std.testing.expect(!toolIs(@ptrCast(name), "bar")); + try std.testing.expect(!toolIs(@ptrCast(name), "foobar")); + try std.testing.expect(!toolIs(@ptrCast(name), "fo")); +} + +test "invokeArgsNull: detects each null slot" { + var buf: [4]u8 = undefined; + var len: usize = 4; + const name: [*:0]const u8 = "x"; + try std.testing.expect(!invokeArgsNull(@ptrCast(name), &buf, &len)); + try std.testing.expect(invokeArgsNull(null, &buf, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), null, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), &buf, null)); +} diff --git a/cartridges/domains/languages/lsp-mcp/ffi/lsp_ffi.zig b/cartridges/domains/languages/lsp-mcp/ffi/lsp_ffi.zig new file mode 100644 index 0000000..7a5721a --- /dev/null +++ b/cartridges/domains/languages/lsp-mcp/ffi/lsp_ffi.zig @@ -0,0 +1,392 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// LSP-MCP Cartridge β€” Zig FFI bridge for Language Server Protocol management. +// +// Implements the LSP lifecycle state machine from SafeLsp.idr. +// Tracks up to 8 concurrent LSP server sessions with capability negotiation. + +const std = @import("std"); + +// ═══════════════════════════════════════════════════════════════════════ +// Types (must match LspMcp.SafeLsp encodings) +// ═══════════════════════════════════════════════════════════════════════ + +pub const LspState = enum(c_int) { + uninitialized = 0, + initializing = 1, + running = 2, + shutting_down = 3, + exited = 4, +}; + +pub const ServerCapability = enum(c_int) { + text_doc_sync = 1, + completion = 2, + hover = 3, + signature_help = 4, + definition = 5, + references = 6, + document_symbol = 7, + code_action = 8, + diagnostics = 9, + formatting = 10, + rename = 11, + semantic_tokens = 12, +}; + +pub const DiagnosticSeverity = enum(c_int) { + err = 1, + warning = 2, + information = 3, + hint = 4, +}; + +pub const CompletionKind = enum(c_int) { + text = 1, + method = 2, + function = 3, + constructor = 4, + field = 5, + variable = 6, + class = 7, + interface = 8, + module = 9, + property = 10, + keyword = 14, + snippet = 15, +}; + +// ═══════════════════════════════════════════════════════════════════════ +// LSP Session State Machine +// ═══════════════════════════════════════════════════════════════════════ + +const MAX_SESSIONS: usize = 8; +const MAX_CAPABILITIES: usize = 12; + +const SessionSlot = struct { + active: bool = false, + state: LspState = .uninitialized, + capabilities: [MAX_CAPABILITIES]bool = [_]bool{false} ** MAX_CAPABILITIES, + capability_count: usize = 0, +}; + +var sessions: [MAX_SESSIONS]SessionSlot = [_]SessionSlot{.{}} ** MAX_SESSIONS; + +var mutex: std.Thread.Mutex = .{}; + +/// Validate a state transition (matches Idris2 canTransition). +fn isValidTransition(from: LspState, to: LspState) bool { + return switch (from) { + .uninitialized => to == .initializing, + .initializing => to == .running or to == .exited, + .running => to == .shutting_down or to == .exited, + .shutting_down => to == .exited, + .exited => false, + }; +} + +/// Initialise a new LSP session. Returns slot index or -1 on failure. +pub export fn lsp_init() c_int { + mutex.lock(); + defer mutex.unlock(); + for (&sessions, 0..) |*slot, i| { + if (!slot.active) { + slot.* = SessionSlot{}; + slot.active = true; + slot.state = .uninitialized; + return @intCast(i); + } + } + return -1; +} + +/// Transition to initializing (Uninitialized -> Initializing). +pub export fn lsp_start_init(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + if (slot_idx < 0 or slot_idx >= MAX_SESSIONS) return -1; + const idx: usize = @intCast(slot_idx); + if (!sessions[idx].active) return -1; + if (!isValidTransition(sessions[idx].state, .initializing)) return -2; + sessions[idx].state = .initializing; + return 0; +} + +/// Register a capability during initialization. +pub export fn lsp_register_capability(slot_idx: c_int, cap: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + if (slot_idx < 0 or slot_idx >= MAX_SESSIONS) return -1; + const idx: usize = @intCast(slot_idx); + if (!sessions[idx].active) return -1; + if (sessions[idx].state != .initializing) return -2; + if (cap < 1 or cap > MAX_CAPABILITIES) return -3; + const cap_idx: usize = @intCast(cap - 1); + sessions[idx].capabilities[cap_idx] = true; + sessions[idx].capability_count += 1; + return 0; +} + +/// Mark initialization complete (Initializing -> Running). +pub export fn lsp_initialized(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + if (slot_idx < 0 or slot_idx >= MAX_SESSIONS) return -1; + const idx: usize = @intCast(slot_idx); + if (!sessions[idx].active) return -1; + if (!isValidTransition(sessions[idx].state, .running)) return -2; + sessions[idx].state = .running; + return 0; +} + +/// Request shutdown (Running -> ShuttingDown). +pub export fn lsp_shutdown(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + if (slot_idx < 0 or slot_idx >= MAX_SESSIONS) return -1; + const idx: usize = @intCast(slot_idx); + if (!sessions[idx].active) return -1; + if (!isValidTransition(sessions[idx].state, .shutting_down)) return -2; + sessions[idx].state = .shutting_down; + return 0; +} + +/// Exit (ShuttingDown/Running/Initializing -> Exited). +pub export fn lsp_exit(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + if (slot_idx < 0 or slot_idx >= MAX_SESSIONS) return -1; + const idx: usize = @intCast(slot_idx); + if (!sessions[idx].active) return -1; + if (!isValidTransition(sessions[idx].state, .exited)) return -2; + sessions[idx].state = .exited; + return 0; +} + +/// Get the state of a session. +pub export fn lsp_state(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + if (slot_idx < 0 or slot_idx >= MAX_SESSIONS) return -1; + const idx: usize = @intCast(slot_idx); + if (!sessions[idx].active) return @intFromEnum(LspState.uninitialized); + return @intFromEnum(sessions[idx].state); +} + +/// Check if a session has a specific capability. +pub export fn lsp_has_capability(slot_idx: c_int, cap: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + if (slot_idx < 0 or slot_idx >= MAX_SESSIONS) return -1; + const idx: usize = @intCast(slot_idx); + if (!sessions[idx].active) return 0; + if (cap < 1 or cap > MAX_CAPABILITIES) return 0; + const cap_idx: usize = @intCast(cap - 1); + return if (sessions[idx].capabilities[cap_idx]) 1 else 0; +} + +/// Validate a state transition (C-ABI export). +pub export fn lsp_can_transition(from: c_int, to: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + const f: LspState = @enumFromInt(from); + const t: LspState = @enumFromInt(to); + return if (isValidTransition(f, t)) 1 else 0; +} + +/// Release a session slot. +pub export fn lsp_release(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + if (slot_idx < 0 or slot_idx >= MAX_SESSIONS) return -1; + const idx: usize = @intCast(slot_idx); + if (!sessions[idx].active) return -1; + sessions[idx] = SessionSlot{}; + return 0; +} + +/// Reset all sessions (for testing). +pub export fn lsp_reset_all() void { + mutex.lock(); + defer mutex.unlock(); + for (&sessions) |*slot| { + slot.* = SessionSlot{}; + } +} + +// ═══════════════════════════════════════════════════════════════════════ +// Standard Cartridge Interface +// ═══════════════════════════════════════════════════════════════════════ + +pub export fn boj_cartridge_init() c_int { + lsp_reset_all(); + return 0; +} + +pub export fn boj_cartridge_deinit() void { + lsp_reset_all(); +} + +pub export fn boj_cartridge_name() [*:0]const u8 { + mutex.lock(); + defer mutex.unlock(); + return "lsp-mcp"; +} + +pub export fn boj_cartridge_version() [*:0]const u8 { + mutex.lock(); + defer mutex.unlock(); + return "0.1.0"; +} + +// ═══════════════════════════════════════════════════════════════════════ +// ADR-0006 dispatch (boj_cartridge_invoke, 5th standard symbol) +// ═══════════════════════════════════════════════════════════════════════ + +const shim = @import("cartridge_shim.zig"); + +/// Dispatch the cartridge.json MCP tools. Grade D Alpha β€” each arm +/// returns a stub JSON body shaped to the tool's intended response. +export fn boj_cartridge_invoke( + tool_name: [*c]const u8, + json_args: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) callconv(.c) i32 { + _ = json_args; + if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; + + const body: []const u8 = if (shim.toolIs(tool_name, "lsp_start")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "lsp_initialize")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "lsp_open")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "lsp_change")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "lsp_close")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "lsp_hover")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "lsp_complete")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "lsp_goto_def")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "lsp_references")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "lsp_diagnostics")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "lsp_format")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "lsp_stop")) + "{\"result\":{\"status\":\"stub\"}}" +else + return shim.RC_UNKNOWN_TOOL; + + return shim.writeResult(out_buf, in_out_len, body); +} + +// ═══════════════════════════════════════════════════════════════════════ +// Tests +// ═══════════════════════════════════════════════════════════════════════ + +test "init and release LSP session" { + lsp_reset_all(); + const slot = lsp_init(); + try std.testing.expect(slot >= 0); + try std.testing.expectEqual(@as(c_int, @intFromEnum(LspState.uninitialized)), lsp_state(slot)); + try std.testing.expectEqual(@as(c_int, 0), lsp_release(slot)); +} + +test "full LSP lifecycle" { + lsp_reset_all(); + const slot = lsp_init(); + try std.testing.expectEqual(@as(c_int, 0), lsp_start_init(slot)); + try std.testing.expectEqual(@as(c_int, @intFromEnum(LspState.initializing)), lsp_state(slot)); + // Register capabilities during init + try std.testing.expectEqual(@as(c_int, 0), lsp_register_capability(slot, @intFromEnum(ServerCapability.completion))); + try std.testing.expectEqual(@as(c_int, 0), lsp_register_capability(slot, @intFromEnum(ServerCapability.hover))); + try std.testing.expectEqual(@as(c_int, 0), lsp_initialized(slot)); + try std.testing.expectEqual(@as(c_int, @intFromEnum(LspState.running)), lsp_state(slot)); + // Check capabilities + try std.testing.expectEqual(@as(c_int, 1), lsp_has_capability(slot, @intFromEnum(ServerCapability.completion))); + try std.testing.expectEqual(@as(c_int, 1), lsp_has_capability(slot, @intFromEnum(ServerCapability.hover))); + try std.testing.expectEqual(@as(c_int, 0), lsp_has_capability(slot, @intFromEnum(ServerCapability.rename))); + // Shutdown and exit + try std.testing.expectEqual(@as(c_int, 0), lsp_shutdown(slot)); + try std.testing.expectEqual(@as(c_int, @intFromEnum(LspState.shutting_down)), lsp_state(slot)); + try std.testing.expectEqual(@as(c_int, 0), lsp_exit(slot)); + try std.testing.expectEqual(@as(c_int, @intFromEnum(LspState.exited)), lsp_state(slot)); +} + +test "cannot register capability after init" { + lsp_reset_all(); + const slot = lsp_init(); + _ = lsp_start_init(slot); + _ = lsp_initialized(slot); + // Should fail β€” not in initializing state + try std.testing.expectEqual(@as(c_int, -2), lsp_register_capability(slot, @intFromEnum(ServerCapability.definition))); +} + +test "LSP state transitions" { + try std.testing.expectEqual(@as(c_int, 1), lsp_can_transition(0, 1)); // uninit -> initializing + try std.testing.expectEqual(@as(c_int, 1), lsp_can_transition(1, 2)); // initializing -> running + try std.testing.expectEqual(@as(c_int, 1), lsp_can_transition(2, 3)); // running -> shutting_down + try std.testing.expectEqual(@as(c_int, 1), lsp_can_transition(3, 4)); // shutting_down -> exited + try std.testing.expectEqual(@as(c_int, 1), lsp_can_transition(2, 4)); // running -> exited (dirty) + try std.testing.expectEqual(@as(c_int, 0), lsp_can_transition(0, 2)); // uninit -> running (invalid) + try std.testing.expectEqual(@as(c_int, 0), lsp_can_transition(4, 0)); // exited -> uninit (invalid) +} + +test "max LSP sessions enforced" { + lsp_reset_all(); + var i: usize = 0; + while (i < MAX_SESSIONS) : (i += 1) { + try std.testing.expect(lsp_init() >= 0); + } + try std.testing.expectEqual(@as(c_int, -1), lsp_init()); +} + +// ═══════════════════════════════════════════════════════════════════════ +// ADR-0006 invoke dispatch tests +// ═══════════════════════════════════════════════════════════════════════ + +test "invoke: each declared tool succeeds" { + var buf: [256]u8 = undefined; + const tools = [_][]const u8{ + "lsp_start", + "lsp_initialize", + "lsp_open", + "lsp_change", + "lsp_close", + "lsp_hover", + "lsp_complete", + "lsp_goto_def", + "lsp_references", + "lsp_diagnostics", + "lsp_format", + "lsp_stop", + }; + for (tools) |t| { + var len: usize = buf.len; + const rc = boj_cartridge_invoke(t.ptr, "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, 0), rc); + try std.testing.expect(std.mem.indexOf(u8, buf[0..len], "result") != null); + } +} + +test "invoke: unknown tool returns -1" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("nope", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -1), rc); +} + +test "invoke: buffer too small returns -3" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("lsp_start", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -3), rc); + try std.testing.expect(len > 4); +} diff --git a/cartridges/domains/languages/lsp-mcp/mod.js b/cartridges/domains/languages/lsp-mcp/mod.js new file mode 100644 index 0000000..8c7a61a --- /dev/null +++ b/cartridges/domains/languages/lsp-mcp/mod.js @@ -0,0 +1,376 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// lsp-mcp/mod.js -- Generic LSP 3.17 gateway cartridge implementation. +// +// Manages persistent LSP server subprocesses communicating over JSON-RPC 2.0 +// with Content-Length framing (stdio transport). Each session runs one server +// subprocess that stays alive until lsp_stop is called. Up to 64 concurrent +// sessions are supported. +// +// Session lifecycle: +// lsp_start β†’ lsp_initialize β†’ (lsp_open / lsp_change / lsp_hover / …) β†’ lsp_stop +// +// Auth: None required β€” local subprocess. +// +// Usage: import { handleTool } from "./mod.js"; + +// --------------------------------------------------------------------------- +// JsonRpcSession β€” persistent subprocess + JSON-RPC 2.0 over stdio (LSP framing) +// --------------------------------------------------------------------------- + +class JsonRpcSession { + /** @type {Deno.ChildProcess} */ #proc; + /** @type {Map<number, {resolve: Function, reject: Function, timer: number}>} */ + #pending = new Map(); + /** @type {Array<object>} */ #notifications = []; + #nextId = 1; + #buf = new Uint8Array(0); + #closed = false; + static #MAX_NOTIFICATIONS = 100; + static #REQUEST_TIMEOUT_MS = 30_000; + + constructor(proc) { + this.#proc = proc; + this.#drainLoop(); + } + + // -- Public API ----------------------------------------------------------- + + /** Send a JSON-RPC request and await the result (or error). */ + request(method, params) { + return new Promise((resolve, reject) => { + if (this.#closed) { reject(new Error("session closed")); return; } + const id = this.#nextId++; + const msg = JSON.stringify({ jsonrpc: "2.0", id, method, params }); + const header = `Content-Length: ${new TextEncoder().encode(msg).byteLength}\r\n\r\n`; + const timer = setTimeout(() => { + this.#pending.delete(id); + reject(new Error(`Request ${method} timed out after ${JsonRpcSession.#REQUEST_TIMEOUT_MS}ms`)); + }, JsonRpcSession.#REQUEST_TIMEOUT_MS); + this.#pending.set(id, { resolve, reject, timer }); + this.#write(header + msg); + }); + } + + /** Send a JSON-RPC notification (no id, no response expected). */ + notify(method, params) { + if (this.#closed) return; + const msg = JSON.stringify({ jsonrpc: "2.0", method, params }); + const header = `Content-Length: ${new TextEncoder().encode(msg).byteLength}\r\n\r\n`; + this.#write(header + msg); + } + + /** Return buffered notifications, optionally filtered by method. */ + getNotifications(method) { + return method + ? this.#notifications.filter(n => n.method === method) + : [...this.#notifications]; + } + + async close() { + if (this.#closed) return; + this.#closed = true; + for (const { reject, timer } of this.#pending.values()) { + clearTimeout(timer); + reject(new Error("session closed")); + } + this.#pending.clear(); + try { this.#proc.stdin.close(); } catch { /* ignore */ } + try { await this.#proc.status; } catch { /* ignore */ } + } + + // -- Private helpers ------------------------------------------------------ + + #write(text) { + try { + const writer = this.#proc.stdin.getWriter(); + writer.write(new TextEncoder().encode(text)).finally(() => writer.releaseLock()); + } catch { /* process may have died */ } + } + + async #drainLoop() { + const reader = this.#proc.stdout.getReader(); + const dec = new TextDecoder(); + try { + while (true) { + const { value, done } = await reader.read(); + if (done) break; + // Append to buffer + const combined = new Uint8Array(this.#buf.length + value.length); + combined.set(this.#buf); + combined.set(value, this.#buf.length); + this.#buf = combined; + // Parse as many complete messages as possible + this.#parseMessages(dec); + } + } catch { /* read error β€” session is dying */ } + this.#closed = true; + } + + #parseMessages(dec) { + while (true) { + const text = dec.decode(this.#buf); + // Find Content-Length header + const sep = text.indexOf("\r\n\r\n"); + if (sep === -1) return; + const headerSection = text.slice(0, sep); + const match = /Content-Length:\s*(\d+)/i.exec(headerSection); + if (!match) { this.#buf = new Uint8Array(0); return; } // malformed + const bodyLen = parseInt(match[1], 10); + const headerBytes = new TextEncoder().encode(headerSection + "\r\n\r\n").byteLength; + if (this.#buf.length < headerBytes + bodyLen) return; // incomplete + const bodyBytes = this.#buf.slice(headerBytes, headerBytes + bodyLen); + this.#buf = this.#buf.slice(headerBytes + bodyLen); + let msg; + try { msg = JSON.parse(dec.decode(bodyBytes)); } catch { continue; } + if ("id" in msg && msg.id !== null) { + // Response + const entry = this.#pending.get(msg.id); + if (entry) { + clearTimeout(entry.timer); + this.#pending.delete(msg.id); + if (msg.error) entry.reject(Object.assign(new Error(msg.error.message ?? "RPC error"), { code: msg.error.code })); + else entry.resolve(msg.result); + } + } else if ("method" in msg) { + // Notification + if (this.#notifications.length >= JsonRpcSession.#MAX_NOTIFICATIONS) this.#notifications.shift(); + this.#notifications.push(msg); + } + } + } +} + +// --------------------------------------------------------------------------- +// Session registry +// --------------------------------------------------------------------------- + +/** @type {Map<string, {session: JsonRpcSession, meta: object}>} */ +const SESSIONS = new Map(); + +function newSessionId() { + return `lsp_${crypto.randomUUID().replace(/-/g, "").slice(0, 12)}`; +} + +function getSession(session_id) { + const entry = SESSIONS.get(session_id); + if (!entry) throw Object.assign(new Error(`Unknown LSP session: ${session_id}`), { status: 404 }); + return entry; +} + +// Permanent pinned toolchain presets (presets.json, co-located). Lets callers +// pass {preset:"rust"} instead of re-specifying the rust-analyzer path every +// time. Loaded once, lazily; missing/invalid file degrades to "no presets". +let _presets = null; +async function loadPresets() { + if (_presets) return _presets; + try { + const txt = await Deno.readTextFile(new URL("./presets.json", import.meta.url)); + _presets = JSON.parse(txt).presets ?? {}; + } catch { + _presets = {}; + } + return _presets; +} + +// --------------------------------------------------------------------------- +// Tool handlers +// --------------------------------------------------------------------------- + +export async function handleTool(toolName, args) { + switch (toolName) { + + // -- lsp_start ----------------------------------------------------------- + case "lsp_start": { + let { command, preset, args: extraArgs = [], workspace_root } = args; + + // Resolve a permanent pinned preset (e.g. "rust" β†’ rust-analyzer) so + // callers never re-assemble the toolchain. Explicit `command` still + // wins and stays fully backward-compatible. + if (!command && preset) { + const presets = await loadPresets(); + const p = presets[preset]; + if (!p) { + return { status: 400, data: { error: `Unknown preset '${preset}'. Available: ${Object.keys(presets).join(", ") || "(none)"}` } }; + } + command = p.command; + if (Array.isArray(p.args) && p.args.length) extraArgs = [...p.args, ...extraArgs]; + } + if (!command) return { status: 400, data: { error: "command or preset is required" } }; + + const cmdParts = command.trim().split(/\s+/); + const proc = new Deno.Command(cmdParts[0], { + args: [...cmdParts.slice(1), ...extraArgs], + cwd: workspace_root, + stdin: "piped", + stdout: "piped", + stderr: "null", + }).spawn(); + + const session = new JsonRpcSession(proc); + const session_id = newSessionId(); + SESSIONS.set(session_id, { + session, + meta: { command, workspace_root: workspace_root ?? null, initialized: false, openDocs: new Set() }, + }); + return { status: 200, data: { session_id, message: `LSP server started: ${command}` } }; + } + + // -- lsp_initialize ------------------------------------------------------ + case "lsp_initialize": { + const { session_id, root_uri, client_name = "boj-lsp-mcp" } = args; + const { session, meta } = getSession(session_id); + const result = await session.request("initialize", { + processId: null, + clientInfo: { name: client_name, version: "0.1.0" }, + rootUri: root_uri, + capabilities: { + textDocument: { + synchronization: { dynamicRegistration: false, willSave: false, didSave: false, willSaveWaitUntil: false }, + hover: { dynamicRegistration: false, contentFormat: ["plaintext", "markdown"] }, + completion: { dynamicRegistration: false, completionItem: { snippetSupport: false } }, + definition: { dynamicRegistration: false }, + references: { dynamicRegistration: false }, + publishDiagnostics: { relatedInformation: true }, + formatting: { dynamicRegistration: false }, + }, + workspace: { workspaceFolders: true }, + }, + }); + session.notify("initialized", {}); + meta.initialized = true; + meta.capabilities = result.capabilities; + return { status: 200, data: { capabilities: result.capabilities, serverInfo: result.serverInfo ?? null } }; + } + + // -- lsp_open ------------------------------------------------------------ + case "lsp_open": { + const { session_id, uri, language_id, text } = args; + const { session, meta } = getSession(session_id); + session.notify("textDocument/didOpen", { + textDocument: { uri, languageId: language_id, version: 1, text }, + }); + meta.openDocs.add(uri); + return { status: 200, data: { opened: uri } }; + } + + // -- lsp_change ---------------------------------------------------------- + case "lsp_change": { + const { session_id, uri, text, version = 2 } = args; + const { session } = getSession(session_id); + session.notify("textDocument/didChange", { + textDocument: { uri, version }, + contentChanges: [{ text }], + }); + return { status: 200, data: { changed: uri, version } }; + } + + // -- lsp_close ----------------------------------------------------------- + case "lsp_close": { + const { session_id, uri } = args; + const { session, meta } = getSession(session_id); + session.notify("textDocument/didClose", { textDocument: { uri } }); + meta.openDocs.delete(uri); + return { status: 200, data: { closed: uri } }; + } + + // -- lsp_hover ----------------------------------------------------------- + case "lsp_hover": { + const { session_id, uri, line, col } = args; + const { session } = getSession(session_id); + const result = await session.request("textDocument/hover", { + textDocument: { uri }, + position: { line, character: col }, + }); + return { status: 200, data: result ?? { contents: null } }; + } + + // -- lsp_complete -------------------------------------------------------- + case "lsp_complete": { + const { session_id, uri, line, col, trigger_char } = args; + const { session } = getSession(session_id); + const context = trigger_char + ? { triggerKind: 2, triggerCharacter: trigger_char } + : { triggerKind: 1 }; + const result = await session.request("textDocument/completion", { + textDocument: { uri }, + position: { line, character: col }, + context, + }); + // result may be CompletionList or CompletionItem[] + const items = Array.isArray(result) ? result : (result?.items ?? []); + return { status: 200, data: { items, count: items.length } }; + } + + // -- lsp_goto_def -------------------------------------------------------- + case "lsp_goto_def": { + const { session_id, uri, line, col } = args; + const { session } = getSession(session_id); + const result = await session.request("textDocument/definition", { + textDocument: { uri }, + position: { line, character: col }, + }); + return { status: 200, data: { locations: Array.isArray(result) ? result : (result ? [result] : []) } }; + } + + // -- lsp_references ------------------------------------------------------ + case "lsp_references": { + const { session_id, uri, line, col, include_declaration = true } = args; + const { session } = getSession(session_id); + const result = await session.request("textDocument/references", { + textDocument: { uri }, + position: { line, character: col }, + context: { includeDeclaration: include_declaration }, + }); + return { status: 200, data: { locations: result ?? [] } }; + } + + // -- lsp_diagnostics ----------------------------------------------------- + case "lsp_diagnostics": { + const { session_id, uri } = args; + const { session } = getSession(session_id); + const notifications = session.getNotifications("textDocument/publishDiagnostics"); + let result; + if (uri) { + const latest = notifications.filter(n => n.params?.uri === uri).at(-1); + result = latest ? { [uri]: latest.params.diagnostics } : { [uri]: [] }; + } else { + // Collect latest per-uri + const map = {}; + for (const n of notifications) { + if (n.params?.uri) map[n.params.uri] = n.params.diagnostics; + } + result = map; + } + return { status: 200, data: result }; + } + + // -- lsp_format ---------------------------------------------------------- + case "lsp_format": { + const { session_id, uri, tab_size = 2, insert_spaces = true } = args; + const { session } = getSession(session_id); + const result = await session.request("textDocument/formatting", { + textDocument: { uri }, + options: { tabSize: tab_size, insertSpaces: insert_spaces }, + }); + return { status: 200, data: { edits: result ?? [] } }; + } + + // -- lsp_stop ------------------------------------------------------------ + case "lsp_stop": { + const { session_id } = args; + const entry = SESSIONS.get(session_id); + if (!entry) return { status: 404, data: { error: `Unknown session: ${session_id}` } }; + const { session } = entry; + try { await session.request("shutdown", null); } catch { /* ignore if server died */ } + session.notify("exit", null); + await session.close(); + SESSIONS.delete(session_id); + return { status: 200, data: { stopped: session_id } }; + } + + default: + return { status: 404, data: { error: `Unknown tool: ${toolName}` } }; + } +} diff --git a/cartridges/domains/languages/lsp-mcp/panels/manifest.json b/cartridges/domains/languages/lsp-mcp/panels/manifest.json new file mode 100644 index 0000000..81c5060 --- /dev/null +++ b/cartridges/domains/languages/lsp-mcp/panels/manifest.json @@ -0,0 +1,67 @@ +{ + "$schema": "https://panll.dev/schemas/panel-manifest/v1.json", + "spdx": "MPL-2.0", + "cartridge": "lsp-mcp", + "domain": "Language Server Protocol", + "version": "0.1.0", + "panels": [ + { + "id": "lsp-status", + "title": "LSP Gateway Status", + "description": "Language server proxy health and active server connections", + "type": "status-indicator", + "data_source": { + "endpoint": "/cartridge/lsp-mcp/invoke", + "method": "POST", + "body": { "tool": "status" }, + "refresh_interval_ms": 5000 + }, + "widgets": [ + { + "type": "state-badge", + "field": "state", + "states": { + "ready": { "color": "#2ecc71", "icon": "code" }, + "degraded": { "color": "#f39c12", "icon": "alert-circle" }, + "error": { "color": "#e74c3c", "icon": "alert-triangle" } + } + }, + { "type": "text", "field": "version", "label": "Version" } + ] + }, + { + "id": "lsp-servers", + "title": "Active Language Servers", + "description": "Running language servers with their language IDs and capabilities", + "type": "metric", + "data_source": { + "endpoint": "/cartridge/lsp-mcp/invoke", + "method": "POST", + "body": { "tool": "server_list" }, + "refresh_interval_ms": 10000 + }, + "widgets": [ + { "type": "counter", "field": "active_servers", "label": "Active Servers", "icon": "cpu" }, + { "type": "counter", "field": "open_files", "label": "Open Files", "icon": "file" }, + { "type": "counter", "field": "pending_requests", "label": "Pending Requests", "icon": "clock" } + ] + }, + { + "id": "lsp-diagnostics", + "title": "Diagnostics Summary", + "description": "Aggregate error, warning, and hint counts from all language servers", + "type": "metric", + "data_source": { + "endpoint": "/cartridge/lsp-mcp/invoke", + "method": "POST", + "body": { "tool": "diagnostics_summary" }, + "refresh_interval_ms": 10000 + }, + "widgets": [ + { "type": "counter", "field": "errors", "label": "Errors", "icon": "x-circle" }, + { "type": "counter", "field": "warnings", "label": "Warnings", "icon": "alert-triangle" }, + { "type": "counter", "field": "hints", "label": "Hints", "icon": "info" } + ] + } + ] +} diff --git a/cartridges/domains/languages/lsp-mcp/presets.json b/cartridges/domains/languages/lsp-mcp/presets.json new file mode 100644 index 0000000..fa0fb17 --- /dev/null +++ b/cartridges/domains/languages/lsp-mcp/presets.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://boj.dev/schemas/lsp-presets/v1.json", + "spdx": "MPL-2.0", + "copyright": "Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk>", + "_doc": "Permanent, pinned LSP server presets. lsp_start accepts {\"preset\":\"rust\"} instead of re-specifying the command/path every time. Absolute paths point at the canonical /dev/tools toolchain. Only presets whose server binary is actually installed and verified are listed here β€” do NOT add a preset for a server that is not present (an unresolved preset is worse than no preset).", + "presets": { + "rust": { + "command": "/home/hyperpolymath/dev/tools/languages/rust/cargo-home/bin/rust-analyzer", + "args": [], + "language_id": "rust", + "verified": "rust-analyzer 1.95.0 (rustup component, stable toolchain)" + } + } +} diff --git a/cartridges/domains/languages/orchestrator-lsp-mcp/README.adoc b/cartridges/domains/languages/orchestrator-lsp-mcp/README.adoc new file mode 100644 index 0000000..e3128b8 --- /dev/null +++ b/cartridges/domains/languages/orchestrator-lsp-mcp/README.adoc @@ -0,0 +1,65 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += orchestrator-lsp-mcp +:description: Cross-domain LSP orchestrator for the BoJ cartridge matrix +:status: Ready + +Status: *Ready* β€” Elixir GenLSP adapter, Zig FFI (ADR-0006), ReScript VSCode extension all implemented. + +== What it does + +Routes LSP requests across all 12 domain-specific poly-*-lsp servers +(cloud, container, IAC, Kubernetes, database, queue, secrets, git, SSG, +proof, observability, browser) via a single GenLSP supervisor. Exposes a +unified `textDocument/*` interface to editors and AI agents β€” they connect +once and get cross-domain intelligence. + +== Architecture + +---- + Editor / AI agent + β”‚ LSP JSON-RPC + β–Ό + OrchestratorLSP.Server ← GenLSP supervisor + β”‚ routes by domain + β”œβ”€β”€β†’ poly-cloud-lsp (AWS/GCP/Azure) + β”œβ”€β”€β†’ poly-container-lsp (Podman/OCI) + β”œβ”€β”€β†’ poly-iac-lsp (OpenTofu/Pulumi) + β”œβ”€β”€β†’ poly-k8s-lsp (Kubernetes) + β”œβ”€β”€β†’ poly-db-lsp (21 databases) + β”œβ”€β”€β†’ poly-queue-lsp (NATS/RabbitMQ) + β”œβ”€β”€β†’ poly-secret-lsp (Vault/SOPS) + β”œβ”€β”€β†’ poly-git-lsp (GitHub/GitLab) + β”œβ”€β”€β†’ poly-ssg-lsp (60+ SSGs) + β”œβ”€β”€β†’ poly-proof-lsp (Coq/Lean/Isabelle) + β”œβ”€β”€β†’ poly-observability-lsp (Prometheus/Grafana) + └──→ claude-firefox-lsp (browser) + β”‚ session history + β–Ό + VeriSimDB +---- + +== Design reference + +`polystack/poly-orchestrator-lsp` (archived 2026-04-27) β€” the 13-file Elixir +implementation is the design reference. Key modules to port: + +* `lib/lsp/server.ex` β€” GenLSP application entry point +* `lib/orchestrator/planner.ex` β€” domain-routing decision logic +* `lib/orchestrator/executor.ex` β€” fan-out + response merge +* `lib/orchestrator/lsp_client.ex` β€” per-domain LSP client pool +* `lib/orchestrator/stack_parser.ex` β€” reads `.machine_readable/integrations/` +* `lib/verisimdb/client.ex` β€” session history persistence + +== MCP tools + +`lsp_orchestrate_start` β†’ `lsp_orchestrate_request` β†’ `lsp_orchestrate_stop` +`lsp_orchestrate_status` β€” see `cartridge.json` for full schemas. + +== Next steps + +. Implement `lib/` Elixir modules (port from poly-orchestrator-lsp design) +. Write Zig FFI stub (`ffi/zig/src/main.zig`) for `boj_cartridge_invoke` +. Add ReScript VSCode extension (`panels/`) +. Connect to VeriSimDB session tracking +. Set status β†’ Ready in `Boj.CartridgeData` diff --git a/cartridges/domains/languages/orchestrator-lsp-mcp/adapter/lib/lsp/handlers/completion.ex b/cartridges/domains/languages/orchestrator-lsp-mcp/adapter/lib/lsp/handlers/completion.ex new file mode 100644 index 0000000..ec5011a --- /dev/null +++ b/cartridges/domains/languages/orchestrator-lsp-mcp/adapter/lib/lsp/handlers/completion.ex @@ -0,0 +1,43 @@ +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +# +# Merges completion item lists from multiple domain LSP servers. +# +# Strategy: +# 1. Flatten all items from all domains into a single list. +# 2. Prefix the item's "detail" field with "[<domain>]" so the user can +# see which domain contributed the suggestion. +# 3. Deduplicate by "label" β€” first occurrence wins (domain order from +# Planner.route/2 acts as implicit priority). + +defmodule OrchestratorLspMcp.LSP.Handlers.Completion do + @moduledoc """ + Merges completion item lists from multiple domain LSP servers. + + Each domain returns a (possibly nil) list of LSP CompletionItem maps. + This module tags each item's `detail` field with the originating domain + and deduplicates by `label`, keeping the first occurrence. + """ + + @doc """ + Merge a keyword list of `{domain, items}` pairs into a single + deduplicated completion list. + + ## Examples + + iex> Completion.merge([{"k8s", [%{"label" => "Pod"}]}, {"db", nil}]) + [%{"label" => "Pod", "detail" => "[k8s] "}] + """ + @spec merge([{String.t(), list(map()) | nil}]) :: list(map()) + def merge(results) do + results + |> Enum.flat_map(fn {domain, items} -> + # Treat nil (domain unavailable or no results) as an empty list. + Enum.map(items || [], fn item -> + detail = Map.get(item, "detail", "") + Map.put(item, "detail", "[#{domain}] #{detail}") + end) + end) + |> Enum.uniq_by(& &1["label"]) + end +end diff --git a/cartridges/domains/languages/orchestrator-lsp-mcp/adapter/lib/lsp/handlers/diagnostics.ex b/cartridges/domains/languages/orchestrator-lsp-mcp/adapter/lib/lsp/handlers/diagnostics.ex new file mode 100644 index 0000000..3199109 --- /dev/null +++ b/cartridges/domains/languages/orchestrator-lsp-mcp/adapter/lib/lsp/handlers/diagnostics.ex @@ -0,0 +1,45 @@ +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +# +# Merges diagnostic lists from multiple domain LSP servers for a single +# document URI, to be published back to the editor via +# textDocument/publishDiagnostics. +# +# Strategy: +# - Flatten all diagnostics from all domains. +# - Prefix each diagnostic's "source" field with "<domain>:" so the +# editor's Problems panel shows which server raised each issue. +# - Preserve the full LSP Diagnostic object otherwise (range, severity, +# message, code, etc.) so editors can render it correctly. + +defmodule OrchestratorLspMcp.LSP.Handlers.Diagnostics do + @moduledoc """ + Merges diagnostic lists from multiple domain LSP servers. + + Each domain returns a (possibly nil) list of LSP Diagnostic maps for a + given document URI. This module tags each diagnostic's `source` field + with the originating domain name and returns a single + publishDiagnostics payload map. + """ + + @doc """ + Merge diagnostics from multiple domains for `uri`. + + Returns a map ready to pass to GenLSP as a publishDiagnostics notification: + + %{"uri" => uri, "diagnostics" => [...]} + """ + @spec merge(String.t(), [{String.t(), list(map()) | nil}]) :: map() + def merge(uri, results) do + diagnostics = + Enum.flat_map(results, fn {domain, diags} -> + Enum.map(diags || [], fn d -> + # Prepend "<domain>:" to existing source (may be empty string). + existing_source = Map.get(d, "source", "") + Map.put(d, "source", "#{domain}:#{existing_source}") + end) + end) + + %{"uri" => uri, "diagnostics" => diagnostics} + end +end diff --git a/cartridges/domains/languages/orchestrator-lsp-mcp/adapter/lib/lsp/handlers/hover.ex b/cartridges/domains/languages/orchestrator-lsp-mcp/adapter/lib/lsp/handlers/hover.ex new file mode 100644 index 0000000..1772ce8 --- /dev/null +++ b/cartridges/domains/languages/orchestrator-lsp-mcp/adapter/lib/lsp/handlers/hover.ex @@ -0,0 +1,59 @@ +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +# +# Merges hover responses from multiple domain LSP servers into a single +# MarkupContent block presented to the editor. +# +# Strategy: +# - Filter out nil responses (domain had nothing to say). +# - Extract the "value" string from each domain's "contents" map. +# - Emit one markdown section per domain headed by "### <domain>". +# - Separate sections with a horizontal rule ("---"). +# - Return nil when no domain produced a result (editor hides the hover). + +defmodule OrchestratorLspMcp.LSP.Handlers.Hover do + @moduledoc """ + Merges hover responses from multiple domain LSP servers. + + Each domain may return nil (no hover for this position) or a map + containing a `"contents"` key (MarkupContent or plain string). + Results are concatenated into a single markdown document with a + per-domain heading, or nil when all domains return nil. + """ + + @doc """ + Merge a keyword list of `{domain, hover_result}` pairs. + + Returns a merged LSP Hover map (`%{"contents" => %{"kind" => "markdown", ...}}`) + or `nil` if every domain returned nil. + """ + @spec merge([{String.t(), map() | nil}]) :: map() | nil + def merge(results) do + contents = + results + |> Enum.reject(fn {_domain, r} -> is_nil(r) end) + |> Enum.map(fn {domain, r} -> + # Support both MarkupContent (%{"contents" => %{"value" => ...}}) + # and plain string contents (%{"contents" => "..."}). + body = + get_in(r, ["contents", "value"]) || + get_in(r, ["contents"]) || + "" + + "### #{domain}\n\n#{body}" + end) + + case contents do + [] -> + nil + + sections -> + %{ + "contents" => %{ + "kind" => "markdown", + "value" => Enum.join(sections, "\n\n---\n\n") + } + } + end + end +end diff --git a/cartridges/domains/languages/orchestrator-lsp-mcp/adapter/lib/lsp/server.ex b/cartridges/domains/languages/orchestrator-lsp-mcp/adapter/lib/lsp/server.ex new file mode 100644 index 0000000..b43c6e1 --- /dev/null +++ b/cartridges/domains/languages/orchestrator-lsp-mcp/adapter/lib/lsp/server.ex @@ -0,0 +1,101 @@ +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +# +# GenLSP server entry-point for the orchestrator cartridge. +# +# Responsibilities: +# - Negotiate LSP capabilities with the editor client (initialize handshake) +# - Delegate textDocument/completion and textDocument/hover requests to +# Executor.fan_out/3, which fans them out to the active domain servers in +# parallel and merges the results. +# - Broadcast open/change/close notifications to all active domain servers. +# - Relay publishDiagnostics push-notifications from domain servers back to +# the editor (wired via Executor, not handled directly here). +# +# State (lsp.assigns): +# :domains – list of %{domain: string, port: integer} maps from Planner +# :session_id – opaque reference used for VeriSimDB session tracking + +defmodule OrchestratorLspMcp.LSP.Server do + use GenLSP + + alias OrchestratorLspMcp.Orchestrator.{Planner, Executor} + alias OrchestratorLspMcp.VeriSimDB.Client, as: DB + + # ────────────────────────────────────────────────────────────────────── + # Lifecycle + # ────────────────────────────────────────────────────────────────────── + + @impl true + def init(lsp, _args) do + {:ok, assign(lsp, domains: [], session_id: nil)} + end + + # ────────────────────────────────────────────────────────────────────── + # Request handlers + # ────────────────────────────────────────────────────────────────────── + + @impl true + def handle_request(%GenLSP.Requests.Initialize{} = req, lsp) do + root = get_in(req, [:params, :rootUri]) + domains = Planner.active_domains(root) + session_id = make_ref() + + # Persist session asynchronously β€” VeriSimDB unavailability must not + # block the initialize handshake. + Task.start(fn -> DB.record_session(session_id, domains, root || "") end) + + caps = Planner.merged_capabilities(domains) + + {:reply, %{capabilities: caps}, + assign(lsp, domains: domains, session_id: session_id)} + end + + @impl true + def handle_request(%GenLSP.Requests.TextDocumentCompletion{} = req, lsp) do + result = Executor.fan_out(:completion, req.params, lsp.assigns.domains) + {:reply, result, lsp} + end + + @impl true + def handle_request(%GenLSP.Requests.TextDocumentHover{} = req, lsp) do + result = Executor.fan_out(:hover, req.params, lsp.assigns.domains) + {:reply, result, lsp} + end + + # ────────────────────────────────────────────────────────────────────── + # Notification handlers + # ────────────────────────────────────────────────────────────────────── + + @impl true + def handle_notification(%GenLSP.Notifications.Initialized{}, lsp) do + # Nothing to do beyond acknowledging the handshake is complete. + {:noreply, lsp} + end + + @impl true + def handle_notification(%GenLSP.Notifications.TextDocumentDidOpen{} = notif, lsp) do + Executor.broadcast_notification(:did_open, notif.params, lsp.assigns.domains) + {:noreply, lsp} + end + + @impl true + def handle_notification(%GenLSP.Notifications.TextDocumentDidChange{} = notif, lsp) do + Executor.broadcast_notification(:did_change, notif.params, lsp.assigns.domains) + {:noreply, lsp} + end + + @impl true + def handle_notification(%GenLSP.Notifications.TextDocumentDidClose{} = notif, lsp) do + Executor.broadcast_notification(:did_close, notif.params, lsp.assigns.domains) + + # Close VeriSimDB session when the last document is closed (best-effort). + Task.start(fn -> DB.close_session(lsp.assigns.session_id) end) + + {:noreply, lsp} + end + + # Catch-all: ignore unknown notifications silently. + @impl true + def handle_notification(_, lsp), do: {:noreply, lsp} +end diff --git a/cartridges/domains/languages/orchestrator-lsp-mcp/adapter/lib/orchestrator/executor.ex b/cartridges/domains/languages/orchestrator-lsp-mcp/adapter/lib/orchestrator/executor.ex new file mode 100644 index 0000000..37b80e7 --- /dev/null +++ b/cartridges/domains/languages/orchestrator-lsp-mcp/adapter/lib/orchestrator/executor.ex @@ -0,0 +1,119 @@ +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +# +# Fans out LSP requests to the selected domain servers in parallel and +# merges their responses via the appropriate Handlers module. +# +# fan_out/3 – parallel request + merge for :completion and :hover +# broadcast_notification/3 – fire-and-forget notification relay to all domains +# +# Failure isolation: +# - Per-domain tasks that time out or crash are silently dropped (domain +# unavailability must never crash the orchestrator). +# - The @timeout is intentionally conservative (5 s) to avoid stacking +# slow domains into a cascade that blocks the editor. + +defmodule OrchestratorLspMcp.Orchestrator.Executor do + @moduledoc """ + Fans out LSP requests to domain servers in parallel and merges results. + + Uses `Task.async_stream/3` for parallel request dispatch with a hard + per-domain timeout. Timed-out or crashed tasks are silently dropped so + that a single offline domain never blocks completion/hover for the rest. + """ + + alias OrchestratorLspMcp.Orchestrator.{Planner, LSPClientPool} + alias OrchestratorLspMcp.LSP.Handlers.{Completion, Hover} + + # Per-domain request timeout in milliseconds. + @timeout 5_000 + + # ────────────────────────────────────────────────────────────────────── + # Public API + # ────────────────────────────────────────────────────────────────────── + + @doc """ + Fan out `method` (:completion | :hover) to the domains selected by + Planner.route/2 and merge the results. + + Returns the merged LSP response (list for completion, map/nil for hover). + """ + @spec fan_out(:completion | :hover, map(), [map()]) :: term() + def fan_out(:completion, params, domains) do + uri = get_in(params, ["textDocument", "uri"]) || "" + routed = Planner.route(uri, domains) + results = parallel_request(routed, :completion, params) + Completion.merge(results) + end + + def fan_out(:hover, params, domains) do + uri = get_in(params, ["textDocument", "uri"]) || "" + routed = Planner.route(uri, domains) + results = parallel_request(routed, :hover, params) + Hover.merge(results) + end + + @doc """ + Broadcast a notification to all active domain servers. + + Fire-and-forget: errors and timeouts per domain are silently swallowed. + Uses `Task.Supervisor.async_stream_nolink/4` to avoid linking the + broadcast tasks to the calling process. + """ + @spec broadcast_notification(atom(), map(), [map()]) :: :ok + def broadcast_notification(method, params, domains) do + Task.Supervisor.async_stream_nolink( + OrchestratorLspMcp.ExecutionSupervisor, + domains, + fn d -> send_notification(d, method, params) end, + timeout: @timeout, + on_timeout: :kill_task + ) + |> Stream.run() + + :ok + end + + # ────────────────────────────────────────────────────────────────────── + # Private helpers + # ────────────────────────────────────────────────────────────────────── + + # Dispatch `method` to each domain in parallel; collect {domain, result} pairs. + # Tasks that exit (timeout or crash) produce no result entry. + defp parallel_request(domains, method, params) do + domains + |> Task.async_stream( + fn d -> {d.domain, send_request(d, method, params)} end, + timeout: @timeout, + on_timeout: :kill_task + ) + |> Enum.flat_map(fn + {:ok, result} -> [result] + # Drop timed-out or crashed tasks without propagating the error. + {:exit, _reason} -> [] + end) + end + + # Send a synchronous LSP request to a single domain client process. + # Returns nil if the domain is not registered in the pool, or on any error. + defp send_request(domain_info, method, params) do + case LSPClientPool.get(domain_info.domain) do + nil -> + nil + + pid -> + GenServer.call(pid, {:request, method, params}, @timeout) + end + rescue + # Catch exit/timeout from GenServer.call so callers always get a value. + _ -> nil + end + + # Send a fire-and-forget cast to a single domain client process. + defp send_notification(domain_info, method, params) do + case LSPClientPool.get(domain_info.domain) do + nil -> :noop + pid -> GenServer.cast(pid, {:notification, method, params}) + end + end +end diff --git a/cartridges/domains/languages/orchestrator-lsp-mcp/adapter/lib/orchestrator/lsp_client_pool.ex b/cartridges/domains/languages/orchestrator-lsp-mcp/adapter/lib/orchestrator/lsp_client_pool.ex new file mode 100644 index 0000000..c1c3fa8 --- /dev/null +++ b/cartridges/domains/languages/orchestrator-lsp-mcp/adapter/lib/orchestrator/lsp_client_pool.ex @@ -0,0 +1,69 @@ +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +# +# Registry of live connections to individual domain LSP server processes. +# +# Domain LSP clients (one per domain) register themselves here via put/2 +# when they successfully connect, and deregister via remove/1 when they +# disconnect or crash. +# +# The Executor queries this pool before fanning out requests; a nil return +# from get/1 means the domain server is offline and the request for that +# domain is silently skipped. + +defmodule OrchestratorLspMcp.Orchestrator.LSPClientPool do + use GenServer + require Logger + + @name __MODULE__ + + # ────────────────────────────────────────────────────────────────────── + # Public API + # ────────────────────────────────────────────────────────────────────── + + @doc "Start the pool under its module name." + def start_link(opts), do: GenServer.start_link(__MODULE__, opts, name: @name) + + @doc "Look up the client PID for `domain`. Returns nil if not registered." + @spec get(String.t()) :: pid() | nil + def get(domain), do: GenServer.call(@name, {:get, domain}) + + @doc "Register `pid` as the client for `domain`." + @spec put(String.t(), pid()) :: :ok + def put(domain, pid), do: GenServer.cast(@name, {:put, domain, pid}) + + @doc "Deregister the client for `domain` (e.g. on disconnect)." + @spec remove(String.t()) :: :ok + def remove(domain), do: GenServer.cast(@name, {:remove, domain}) + + @doc "Return the full map of %{domain => pid} registrations." + @spec all() :: %{String.t() => pid()} + def all, do: GenServer.call(@name, :all) + + # ────────────────────────────────────────────────────────────────────── + # GenServer callbacks + # ────────────────────────────────────────────────────────────────────── + + @impl true + def init(_opts), do: {:ok, %{}} + + @impl true + def handle_call({:get, domain}, _from, state) do + {:reply, Map.get(state, domain), state} + end + + def handle_call(:all, _from, state) do + {:reply, state, state} + end + + @impl true + def handle_cast({:put, domain, pid}, state) do + Logger.info("[orchestrator-lsp-mcp] registered domain #{domain} β†’ #{inspect(pid)}") + {:noreply, Map.put(state, domain, pid)} + end + + def handle_cast({:remove, domain}, state) do + Logger.info("[orchestrator-lsp-mcp] removed domain #{domain} from pool") + {:noreply, Map.delete(state, domain)} + end +end diff --git a/cartridges/domains/languages/orchestrator-lsp-mcp/adapter/lib/orchestrator/planner.ex b/cartridges/domains/languages/orchestrator-lsp-mcp/adapter/lib/orchestrator/planner.ex new file mode 100644 index 0000000..4b24506 --- /dev/null +++ b/cartridges/domains/languages/orchestrator-lsp-mcp/adapter/lib/orchestrator/planner.ex @@ -0,0 +1,128 @@ +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +# +# Plans which domain LSP servers are relevant for a workspace and which +# should handle each individual request. +# +# active_domains/1 – reads the workspace's integration manifests via StackParser +# merged_capabilities/1 – produces the union capability set to advertise to the editor +# route/2 – selects a subset of domains for a given document URI + +defmodule OrchestratorLspMcp.Orchestrator.Planner do + @moduledoc """ + Determines which domain LSP servers are relevant for a given workspace + and which should handle each individual request. + + ## Routing heuristics + + Routing is file-extension and path-pattern based. Examples: + - `*.tf`, `*.hcl`, `*.ncl` β†’ `iac` + - `*.yaml` paths containing "k8s" or "helm" β†’ `k8s` + - `*.yaml` paths containing "docker" or "compose" β†’ `container` + - `*.sql`, `*.prisma`, `*.ecto` β†’ `db` + - `*.ex`, `*.exs` β†’ `queue`, `observe` + - `Dockerfile`, `Containerfile` β†’ `container` + - `*.gitignore`, `*.gitmodules` β†’ `git` + - paths containing "secrets", "vault", or "sops" β†’ `secret` + - `*.md`, `*.adoc`, `*.rst` β†’ `ssg` + - Unknown / catch-all β†’ all active domains + + The routing table is intentionally heuristic and conservative. + False-positives (routing to extra domains) are safe; false-negatives + (missing a relevant domain) reduce feature coverage. + """ + + alias OrchestratorLspMcp.Orchestrator.StackParser + + # ────────────────────────────────────────────────────────────────────── + # Public API + # ────────────────────────────────────────────────────────────────────── + + @doc """ + Return the list of active domain maps for `workspace_root`. + + Falls back to the full default domain set when the root is nil or when + no integration manifests are found. + """ + @spec active_domains(String.t() | nil) :: [map()] + def active_domains(workspace_root) when is_binary(workspace_root) do + StackParser.parse(workspace_root) + end + + def active_domains(_), do: StackParser.default_domains() + + @doc """ + Produce a merged LSP ServerCapabilities map to advertise to the editor + during the initialize handshake. + + The base set is universal; individual domains may extend it in future + versions by returning their own capability hints from their own + initialize responses. + """ + @spec merged_capabilities([map()]) :: map() + def merged_capabilities(domains) do + base = %{ + "textDocumentSync" => 1, + "completionProvider" => %{"triggerCharacters" => [".", ":", "/", "-"]}, + "hoverProvider" => true, + "diagnosticProvider" => %{ + "interFileDependencies" => false, + "workspaceDiagnostics" => false + } + } + + # Domains could contribute additional capabilities in future; for now the + # base set is the universal minimum and the reduce is a no-op extension point. + Enum.reduce(domains, base, fn _domain, acc -> acc end) + end + + @doc """ + Select which domains from `domains` should handle a request for `uri`. + + Returns a (possibly empty) sub-list of `domains`. Returns the full + `domains` list for URIs that do not match any specific heuristic. + """ + @spec route(String.t(), [map()]) :: [map()] + def route(uri, domains) do + cond do + uri =~ ~r/\.(tf|hcl|toml|ncl)$/ -> + filter_domains(domains, ~w[iac]) + + uri =~ ~r/\.(yaml|yml)$/ and uri =~ ~r/k8s|kubernetes|helm/ -> + filter_domains(domains, ~w[k8s]) + + uri =~ ~r/\.(yaml|yml)$/ and uri =~ ~r/docker|compose/ -> + filter_domains(domains, ~w[container]) + + uri =~ ~r/\.(sql|prisma|ecto)$/ -> + filter_domains(domains, ~w[db]) + + uri =~ ~r/\.(ex|exs)$/ -> + filter_domains(domains, ~w[queue observe]) + + uri =~ ~r/Dockerfile|Containerfile/ -> + filter_domains(domains, ~w[container]) + + uri =~ ~r/\.gitignore|\.gitmodules/ -> + filter_domains(domains, ~w[git]) + + uri =~ ~r/secrets?|vault|sops/ -> + filter_domains(domains, ~w[secret]) + + uri =~ ~r/\.(md|adoc|rst)$/ -> + filter_domains(domains, ~w[ssg]) + + true -> + # Unknown file type: fan out to all active domains (safe over-inclusion). + domains + end + end + + # ────────────────────────────────────────────────────────────────────── + # Private helpers + # ────────────────────────────────────────────────────────────────────── + + defp filter_domains(domains, wanted) do + Enum.filter(domains, &(&1.domain in wanted)) + end +end diff --git a/cartridges/domains/languages/orchestrator-lsp-mcp/adapter/lib/orchestrator/stack_parser.ex b/cartridges/domains/languages/orchestrator-lsp-mcp/adapter/lib/orchestrator/stack_parser.ex new file mode 100644 index 0000000..c555ae6 --- /dev/null +++ b/cartridges/domains/languages/orchestrator-lsp-mcp/adapter/lib/orchestrator/stack_parser.ex @@ -0,0 +1,110 @@ +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +# +# Discovers which domain LSP servers are active for a given workspace. +# +# Discovery order (first that succeeds wins): +# 1. Read `.machine_readable/integrations/*.a2ml` files from the workspace root. +# Each file declares at minimum: +# [metadata] +# domain = "k8s" +# lsp_port = 9004 +# 2. Fall back to the full 12-domain default set when the integrations +# directory is absent or yields no parseable files. +# +# Domain β†’ default port mapping: +# cloud=9001, container=9002, iac=9003, k8s=9004, db=9005, +# queue=9006, secret=9007, git=9008, ssg=9009, proof=9010, +# observe=9011, browser=9012 + +defmodule OrchestratorLspMcp.Orchestrator.StackParser do + @moduledoc """ + Reads `.machine_readable/integrations/*.a2ml` files from a workspace root + to discover which domain LSP servers are active. + + Falls back to the full 12-domain default set when no integration files + are found. + """ + + # The 11 standard non-browser domains shipped with every orchestrator deployment. + @default_domains ~w[git k8s db secret queue iac cloud container ssg proof observe] + + @doc """ + Parse the workspace at `workspace_root` and return a list of domain maps: + + [%{domain: "k8s", port: 9004}, ...] + """ + @spec parse(String.t()) :: [%{domain: String.t(), port: non_neg_integer()}] + def parse(workspace_root) when is_binary(workspace_root) do + integrations_path = + Path.join([workspace_root, ".machine_readable", "integrations"]) + + if File.dir?(integrations_path) do + parsed = + integrations_path + |> File.ls!() + |> Enum.filter(&String.ends_with?(&1, ".a2ml")) + |> Enum.map(&parse_integration(Path.join(integrations_path, &1))) + |> Enum.reject(&is_nil/1) + + # If files exist but none parsed successfully, fall back to defaults. + if parsed == [], do: default_domains(), else: parsed + else + default_domains() + end + end + + @doc "Return the full 12-domain default set with canonical port assignments." + @spec default_domains() :: [%{domain: String.t(), port: non_neg_integer()}] + def default_domains do + Enum.map(@default_domains, &%{domain: &1, port: default_port(&1)}) + end + + # ────────────────────────────────────────────────────────────────────── + # Private helpers + # ────────────────────────────────────────────────────────────────────── + + # Parse a single .a2ml integration file. + # Returns %{domain: _, port: _} or nil if required fields are missing. + defp parse_integration(path) do + content = File.read!(path) + + with domain when not is_nil(domain) <- extract_field(content, "domain"), + port_str <- extract_field(content, "lsp_port"), + {port, ""} <- Integer.parse(port_str || "0") do + %{ + domain: domain, + port: if(port > 0, do: port, else: default_port(domain)) + } + else + _ -> nil + end + end + + # Extract a single-line field value from A2ML content. + # Handles both quoted ("value") and unquoted (value) forms. + defp extract_field(content, field) do + case Regex.run(~r/^\s*#{field}\s*=\s*"?([^"\n]+)"?/m, content) do + [_, value] -> String.trim(value) + _ -> nil + end + end + + # Canonical default port assignments for the 12 standard domain servers. + defp default_port(domain) do + %{ + "cloud" => 9001, + "container" => 9002, + "iac" => 9003, + "k8s" => 9004, + "db" => 9005, + "queue" => 9006, + "secret" => 9007, + "git" => 9008, + "ssg" => 9009, + "proof" => 9010, + "observe" => 9011, + "browser" => 9012 + }[domain] || 9099 + end +end diff --git a/cartridges/domains/languages/orchestrator-lsp-mcp/adapter/lib/orchestrator_lsp_mcp.ex b/cartridges/domains/languages/orchestrator-lsp-mcp/adapter/lib/orchestrator_lsp_mcp.ex new file mode 100644 index 0000000..0efa03c --- /dev/null +++ b/cartridges/domains/languages/orchestrator-lsp-mcp/adapter/lib/orchestrator_lsp_mcp.ex @@ -0,0 +1,41 @@ +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +# +# Application supervisor for the OrchestratorLspMcp BoJ cartridge. +# +# Starts the following processes: +# - GenLSP server (omitted in :test env to avoid stdio contention) +# - LSPClientPool: registry of connected domain LSP server PIDs +# - ExecutionRegistry: unique-key Registry for in-flight execution tracking +# - ExecutionSupervisor: DynamicSupervisor for fan-out Task workers + +defmodule OrchestratorLspMcp.Application do + use Application + + @impl true + def start(_type, _args) do + children = + if Mix.env() == :test do + # In test, omit the GenLSP server (it binds stdio) and the pool + # (tests instantiate domain stubs directly). + [ + {Registry, keys: :unique, name: OrchestratorLspMcp.ExecutionRegistry}, + {DynamicSupervisor, + strategy: :one_for_one, name: OrchestratorLspMcp.ExecutionSupervisor} + ] + else + [ + {OrchestratorLspMcp.LSP.Server, []}, + {OrchestratorLspMcp.Orchestrator.LSPClientPool, []}, + {Registry, keys: :unique, name: OrchestratorLspMcp.ExecutionRegistry}, + {DynamicSupervisor, + strategy: :one_for_one, name: OrchestratorLspMcp.ExecutionSupervisor} + ] + end + + Supervisor.start_link(children, + strategy: :one_for_one, + name: OrchestratorLspMcp.Supervisor + ) + end +end diff --git a/cartridges/domains/languages/orchestrator-lsp-mcp/adapter/lib/verisimdb/client.ex b/cartridges/domains/languages/orchestrator-lsp-mcp/adapter/lib/verisimdb/client.ex new file mode 100644 index 0000000..eaf0d24 --- /dev/null +++ b/cartridges/domains/languages/orchestrator-lsp-mcp/adapter/lib/verisimdb/client.ex @@ -0,0 +1,106 @@ +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +# +# Thin VeriSimDB client for persisting orchestration session history. +# +# VeriSimDB is the estate-wide mandatory database layer. This module records: +# - Session open: session_id, active domains, workspace_root, started_at +# - Session close: closed_at timestamp update +# +# Unavailability contract: +# All calls return :unavailable (not an error) when VeriSimDB is offline. +# Callers must handle this gracefully β€” the orchestrator continues normally. +# +# Configuration: +# config :orchestrator_lsp_mcp, :verisimdb_url, "http://localhost:5440" +# +# The HTTP transport uses Erlang's built-in :httpc to avoid adding a dep. + +defmodule OrchestratorLspMcp.VeriSimDB.Client do + @moduledoc """ + Thin VeriSimDB client for persisting orchestration session history. + + Connects to the local VeriSimDB instance (default: http://localhost:5440). + All functions degrade gracefully: :unavailable is returned (never raised) + when the VeriSimDB instance cannot be reached. + """ + + require Logger + + @table "orchestrator_sessions" + @default_url "http://localhost:5440" + + # ────────────────────────────────────────────────────────────────────── + # Public API + # ────────────────────────────────────────────────────────────────────── + + @doc """ + Insert a new session record into VeriSimDB. + + ## Fields persisted + - `session_id` – opaque reference (inspect-serialised) + - `domains` – list of domain name strings + - `workspace_root` – absolute path or URI string + - `started_at` – ISO 8601 UTC timestamp + """ + @spec record_session(reference(), [map()], String.t()) :: :ok | :unavailable + def record_session(session_id, domains, workspace_root) do + payload = + Jason.encode!(%{ + table: @table, + op: "insert", + row: %{ + session_id: inspect(session_id), + domains: Enum.map(domains, & &1.domain), + workspace_root: workspace_root, + started_at: utc_now_iso8601() + } + }) + + post("/q", payload) + end + + @doc """ + Update the `closed_at` timestamp for an existing session record. + """ + @spec close_session(reference()) :: :ok | :unavailable + def close_session(session_id) do + payload = + Jason.encode!(%{ + table: @table, + op: "update", + where: %{session_id: inspect(session_id)}, + set: %{closed_at: utc_now_iso8601()} + }) + + post("/q", payload) + end + + # ────────────────────────────────────────────────────────────────────── + # Private helpers + # ────────────────────────────────────────────────────────────────────── + + defp post(path, body) do + url = Application.get_env(:orchestrator_lsp_mcp, :verisimdb_url, @default_url) + full_url = String.to_charlist(url <> path) + + case :httpc.request( + :post, + {full_url, [], ~c"application/json", body}, + [], + [] + ) do + {:ok, _response} -> + :ok + + {:error, reason} -> + Logger.warning( + "[orchestrator-lsp-mcp] VeriSimDB unavailable at #{url}: #{inspect(reason)}" + ) + + :unavailable + end + end + + defp utc_now_iso8601, do: DateTime.utc_now() |> DateTime.to_iso8601() +end diff --git a/cartridges/domains/languages/orchestrator-lsp-mcp/adapter/mix.exs b/cartridges/domains/languages/orchestrator-lsp-mcp/adapter/mix.exs new file mode 100644 index 0000000..85378bb --- /dev/null +++ b/cartridges/domains/languages/orchestrator-lsp-mcp/adapter/mix.exs @@ -0,0 +1,38 @@ +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +defmodule OrchestratorLspMcp.MixProject do + use Mix.Project + + def project do + [ + app: :orchestrator_lsp_mcp, + version: "0.1.0", + elixir: "~> 1.16", + start_permanent: Mix.env() == :prod, + deps: deps(), + aliases: aliases() + ] + end + + def application do + [ + extra_applications: [:logger], + mod: {OrchestratorLspMcp.Application, []} + ] + end + + defp deps do + [ + {:gen_lsp, "~> 0.6"}, + {:jason, "~> 1.4"}, + {:credo, "~> 1.7", only: [:dev, :test], runtime: false}, + {:dialyxir, "~> 1.4", only: [:dev, :test], runtime: false} + ] + end + + defp aliases do + [ + lint: ["credo --strict", "dialyzer"] + ] + end +end diff --git a/cartridges/domains/languages/orchestrator-lsp-mcp/adapter/test/lsp/handlers/completion_test.exs b/cartridges/domains/languages/orchestrator-lsp-mcp/adapter/test/lsp/handlers/completion_test.exs new file mode 100644 index 0000000..46fae20 --- /dev/null +++ b/cartridges/domains/languages/orchestrator-lsp-mcp/adapter/test/lsp/handlers/completion_test.exs @@ -0,0 +1,98 @@ +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +defmodule OrchestratorLspMcp.LSP.Handlers.CompletionTest do + use ExUnit.Case, async: true + + alias OrchestratorLspMcp.LSP.Handlers.Completion + + # ────────────────────────────────────────────────────────────────────── + # Basic merging + # ────────────────────────────────────────────────────────────────────── + + test "merges completion items from multiple domains" do + results = [ + {"k8s", [%{"label" => "Pod", "detail" => "resource"}]}, + {"db", [%{"label" => "SELECT", "detail" => "keyword"}]} + ] + + merged = Completion.merge(results) + assert length(merged) == 2 + end + + test "prefixes detail field with domain tag" do + results = [{"k8s", [%{"label" => "Pod", "detail" => "resource"}]}] + [item] = Completion.merge(results) + assert String.starts_with?(item["detail"], "[k8s]") + end + + test "preserves all non-detail fields on items" do + results = [ + {"iac", [%{"label" => "resource", "detail" => "block", "kind" => 15, "insertText" => "resource"}]} + ] + + [item] = Completion.merge(results) + assert item["kind"] == 15 + assert item["insertText"] == "resource" + end + + # ────────────────────────────────────────────────────────────────────── + # Deduplication + # ────────────────────────────────────────────────────────────────────── + + test "deduplicates items by label, keeping first occurrence" do + results = [ + {"k8s", [%{"label" => "name", "detail" => "k8s metadata"}]}, + {"db", [%{"label" => "name", "detail" => "db column"}]} + ] + + merged = Completion.merge(results) + assert length(merged) == 1 + # First domain's tag should win. + assert String.contains?(merged |> hd() |> Map.get("detail"), "[k8s]") + end + + test "does not deduplicate items with different labels" do + results = [ + {"k8s", [%{"label" => "Pod"}]}, + {"k8s", [%{"label" => "Service"}]} + ] + + assert length(Completion.merge(results)) == 2 + end + + # ────────────────────────────────────────────────────────────────────── + # Nil / empty domain handling + # ────────────────────────────────────────────────────────────────────── + + test "handles nil result from a domain gracefully" do + results = [{"k8s", nil}, {"db", [%{"label" => "SELECT"}]}] + merged = Completion.merge(results) + assert length(merged) == 1 + assert hd(merged)["label"] == "SELECT" + end + + test "returns empty list when all domains return nil" do + results = [{"k8s", nil}, {"db", nil}] + assert Completion.merge(results) == [] + end + + test "returns empty list when results list is empty" do + assert Completion.merge([]) == [] + end + + test "handles domain returning empty list" do + results = [{"k8s", []}, {"db", [%{"label" => "INSERT"}]}] + assert length(Completion.merge(results)) == 1 + end + + # ────────────────────────────────────────────────────────────────────── + # Missing detail field + # ────────────────────────────────────────────────────────────────────── + + test "handles items with no detail field" do + results = [{"secret", [%{"label" => "AWS_SECRET_ACCESS_KEY"}]}] + [item] = Completion.merge(results) + # Should still tag even though original detail was absent. + assert String.starts_with?(item["detail"], "[secret]") + end +end diff --git a/cartridges/domains/languages/orchestrator-lsp-mcp/adapter/test/lsp/handlers/hover_test.exs b/cartridges/domains/languages/orchestrator-lsp-mcp/adapter/test/lsp/handlers/hover_test.exs new file mode 100644 index 0000000..7b5356b --- /dev/null +++ b/cartridges/domains/languages/orchestrator-lsp-mcp/adapter/test/lsp/handlers/hover_test.exs @@ -0,0 +1,87 @@ +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +defmodule OrchestratorLspMcp.LSP.Handlers.HoverTest do + use ExUnit.Case, async: true + + alias OrchestratorLspMcp.LSP.Handlers.Hover + + # ────────────────────────────────────────────────────────────────────── + # Nil / empty cases + # ────────────────────────────────────────────────────────────────────── + + test "returns nil when all domains return nil" do + assert Hover.merge([{"k8s", nil}, {"db", nil}]) == nil + end + + test "returns nil for empty results list" do + assert Hover.merge([]) == nil + end + + # ────────────────────────────────────────────────────────────────────── + # Single domain with MarkupContent + # ────────────────────────────────────────────────────────────────────── + + test "returns merged map when one domain has a result" do + result = Hover.merge([{"k8s", %{"contents" => %{"value" => "A Pod is..."}}}]) + assert is_map(result) + assert get_in(result, ["contents", "kind"]) == "markdown" + end + + test "single domain: content includes domain heading" do + result = Hover.merge([{"k8s", %{"contents" => %{"value" => "Pod definition"}}}]) + value = get_in(result, ["contents", "value"]) + assert String.contains?(value, "### k8s") + assert String.contains?(value, "Pod definition") + end + + # ────────────────────────────────────────────────────────────────────── + # Multiple domains + # ────────────────────────────────────────────────────────────────────── + + test "merges multiple domain results with separator" do + results = [ + {"k8s", %{"contents" => %{"value" => "Pod info"}}}, + {"iac", %{"contents" => %{"value" => "resource block"}}} + ] + + value = Hover.merge(results) |> get_in(["contents", "value"]) + assert String.contains?(value, "### k8s") + assert String.contains?(value, "### iac") + assert String.contains?(value, "---") + end + + test "skips nil domain results and merges the rest" do + results = [ + {"k8s", nil}, + {"db", %{"contents" => %{"value" => "Column type: TEXT"}}} + ] + + value = Hover.merge(results) |> get_in(["contents", "value"]) + assert String.contains?(value, "### db") + refute String.contains?(value, "### k8s") + end + + # ────────────────────────────────────────────────────────────────────── + # Plain string contents (non-MarkupContent) + # ────────────────────────────────────────────────────────────────────── + + test "handles plain string contents field" do + result = Hover.merge([{"ssg", %{"contents" => "A heading element"}}]) + value = get_in(result, ["contents", "value"]) + assert String.contains?(value, "A heading element") + end + + # ────────────────────────────────────────────────────────────────────── + # Output shape + # ────────────────────────────────────────────────────────────────────── + + test "output always uses markdown kind" do + result = Hover.merge([{"secret", %{"contents" => %{"value" => "env var"}}}]) + assert get_in(result, ["contents", "kind"]) == "markdown" + end + + test "output contents value is a string" do + result = Hover.merge([{"cloud", %{"contents" => %{"value" => "region"}}}]) + assert is_binary(get_in(result, ["contents", "value"])) + end +end diff --git a/cartridges/domains/languages/orchestrator-lsp-mcp/adapter/test/orchestrator/planner_test.exs b/cartridges/domains/languages/orchestrator-lsp-mcp/adapter/test/orchestrator/planner_test.exs new file mode 100644 index 0000000..5668dbf --- /dev/null +++ b/cartridges/domains/languages/orchestrator-lsp-mcp/adapter/test/orchestrator/planner_test.exs @@ -0,0 +1,131 @@ +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +defmodule OrchestratorLspMcp.Orchestrator.PlannerTest do + use ExUnit.Case, async: true + + alias OrchestratorLspMcp.Orchestrator.Planner + + # A representative subset of domains used across routing tests. + @domains [ + %{domain: "k8s", port: 9004}, + %{domain: "db", port: 9005}, + %{domain: "iac", port: 9003}, + %{domain: "container", port: 9002}, + %{domain: "git", port: 9008}, + %{domain: "secret", port: 9007}, + %{domain: "ssg", port: 9009}, + %{domain: "queue", port: 9006}, + %{domain: "observe", port: 9011} + ] + + # ────────────────────────────────────────────────────────────────────── + # route/2 β€” file-type routing heuristics + # ────────────────────────────────────────────────────────────────────── + + test "routes terraform (.tf) files to iac domain" do + result = Planner.route("file:///workspace/main.tf", @domains) + assert [%{domain: "iac"}] = result + end + + test "routes HCL files to iac domain" do + result = Planner.route("file:///workspace/vars.hcl", @domains) + assert [%{domain: "iac"}] = result + end + + test "routes Nickel (.ncl) files to iac domain" do + result = Planner.route("file:///workspace/config.ncl", @domains) + assert [%{domain: "iac"}] = result + end + + test "routes k8s YAML to k8s domain" do + result = Planner.route("file:///workspace/k8s/deployment.yaml", @domains) + assert [%{domain: "k8s"}] = result + end + + test "routes helm YAML to k8s domain" do + result = Planner.route("file:///workspace/helm/values.yaml", @domains) + assert [%{domain: "k8s"}] = result + end + + test "routes docker-compose YAML to container domain" do + result = Planner.route("file:///workspace/docker-compose.yaml", @domains) + assert [%{domain: "container"}] = result + end + + test "routes SQL files to db domain" do + result = Planner.route("file:///workspace/schema.sql", @domains) + assert [%{domain: "db"}] = result + end + + test "routes Elixir files to queue and observe domains" do + result = Planner.route("file:///workspace/lib/worker.ex", @domains) + domain_names = Enum.map(result, & &1.domain) + assert "queue" in domain_names + assert "observe" in domain_names + end + + test "routes Containerfile to container domain" do + result = Planner.route("file:///workspace/Containerfile", @domains) + assert [%{domain: "container"}] = result + end + + test "routes .gitignore to git domain" do + result = Planner.route("file:///workspace/.gitignore", @domains) + assert [%{domain: "git"}] = result + end + + test "routes secrets path to secret domain" do + result = Planner.route("file:///workspace/secrets/vault.yaml", @domains) + assert [%{domain: "secret"}] = result + end + + test "routes .adoc files to ssg domain" do + result = Planner.route("file:///workspace/docs/README.adoc", @domains) + assert [%{domain: "ssg"}] = result + end + + test "routes unknown file types to all domains" do + result = Planner.route("file:///workspace/unknown.xyz", @domains) + assert result == @domains + end + + # ────────────────────────────────────────────────────────────────────── + # merged_capabilities/1 + # ────────────────────────────────────────────────────────────────────── + + test "merged_capabilities returns a capability map" do + caps = Planner.merged_capabilities(@domains) + assert is_map(caps) + end + + test "merged_capabilities includes completionProvider" do + caps = Planner.merged_capabilities(@domains) + assert Map.has_key?(caps, "completionProvider") + end + + test "merged_capabilities includes hoverProvider" do + caps = Planner.merged_capabilities(@domains) + assert caps["hoverProvider"] == true + end + + test "merged_capabilities includes textDocumentSync" do + caps = Planner.merged_capabilities(@domains) + assert Map.has_key?(caps, "textDocumentSync") + end + + # ────────────────────────────────────────────────────────────────────── + # active_domains/1 + # ────────────────────────────────────────────────────────────────────── + + test "active_domains falls back to defaults for nil root" do + domains = Planner.active_domains(nil) + assert is_list(domains) + assert length(domains) > 0 + end + + test "active_domains falls back to defaults for nonexistent root" do + domains = Planner.active_domains("/tmp/nonexistent_xyz_workspace") + assert is_list(domains) + assert Enum.all?(domains, &Map.has_key?(&1, :domain)) + end +end diff --git a/cartridges/domains/languages/orchestrator-lsp-mcp/adapter/test/orchestrator/stack_parser_test.exs b/cartridges/domains/languages/orchestrator-lsp-mcp/adapter/test/orchestrator/stack_parser_test.exs new file mode 100644 index 0000000..d3a9ec9 --- /dev/null +++ b/cartridges/domains/languages/orchestrator-lsp-mcp/adapter/test/orchestrator/stack_parser_test.exs @@ -0,0 +1,123 @@ +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +defmodule OrchestratorLspMcp.Orchestrator.StackParserTest do + use ExUnit.Case, async: true + + alias OrchestratorLspMcp.Orchestrator.StackParser + + # ────────────────────────────────────────────────────────────────────── + # default_domains/0 + # ────────────────────────────────────────────────────────────────────── + + test "default_domains returns a non-empty list" do + assert StackParser.default_domains() != [] + end + + test "default_domains entries all have :domain and :port keys" do + for d <- StackParser.default_domains() do + assert Map.has_key?(d, :domain), "missing :domain in #{inspect(d)}" + assert Map.has_key?(d, :port), "missing :port in #{inspect(d)}" + end + end + + test "default_domains covers all 11 standard non-browser LSP servers" do + domain_names = StackParser.default_domains() |> Enum.map(& &1.domain) + + for expected <- ~w[git k8s db secret queue iac cloud container ssg proof observe] do + assert expected in domain_names, "missing domain: #{expected}" + end + end + + test "default_domains assigns distinct ports" do + ports = StackParser.default_domains() |> Enum.map(& &1.port) + assert ports == Enum.uniq(ports), "duplicate ports in default domain list" + end + + # ────────────────────────────────────────────────────────────────────── + # parse/1 β€” missing integrations directory + # ────────────────────────────────────────────────────────────────────── + + test "parse/1 returns default domains when workspace does not exist" do + result = StackParser.parse("/tmp/nonexistent_workspace_zzz_xyz") + assert is_list(result) + assert length(result) > 0 + assert Enum.all?(result, &Map.has_key?(&1, :domain)) + end + + test "parse/1 returns default domains when no integrations directory present" do + # Use /tmp itself β€” it exists but has no .machine_readable/integrations subdir. + result = StackParser.parse("/tmp") + assert is_list(result) + assert length(result) > 0 + end + + # ────────────────────────────────────────────────────────────────────── + # parse/1 β€” with synthetic integration files + # ────────────────────────────────────────────────────────────────────── + + test "parse/1 reads domain and port from a valid .a2ml integration file" do + tmp = System.tmp_dir!() + workspace = Path.join(tmp, "test_ws_#{:erlang.unique_integer([:positive])}") + integrations = Path.join([workspace, ".machine_readable", "integrations"]) + File.mkdir_p!(integrations) + + File.write!(Path.join(integrations, "k8s.a2ml"), """ + [metadata] + domain = "k8s" + lsp_port = 9004 + """) + + result = StackParser.parse(workspace) + assert [%{domain: "k8s", port: 9004}] = result + after + # Best-effort cleanup + File.rm_rf(Path.join(System.tmp_dir!(), "test_ws_*")) + end + + test "parse/1 falls back to default port when lsp_port is absent" do + tmp = System.tmp_dir!() + workspace = Path.join(tmp, "test_ws_noport_#{:erlang.unique_integer([:positive])}") + integrations = Path.join([workspace, ".machine_readable", "integrations"]) + File.mkdir_p!(integrations) + + File.write!(Path.join(integrations, "db.a2ml"), """ + [metadata] + domain = "db" + """) + + result = StackParser.parse(workspace) + assert [%{domain: "db", port: 9005}] = result + end + + test "parse/1 ignores non-.a2ml files in integrations directory" do + tmp = System.tmp_dir!() + workspace = Path.join(tmp, "test_ws_mixed_#{:erlang.unique_integer([:positive])}") + integrations = Path.join([workspace, ".machine_readable", "integrations"]) + File.mkdir_p!(integrations) + + File.write!(Path.join(integrations, "README.md"), "not an integration file") + + File.write!(Path.join(integrations, "cloud.a2ml"), """ + [metadata] + domain = "cloud" + lsp_port = 9001 + """) + + result = StackParser.parse(workspace) + assert [%{domain: "cloud", port: 9001}] = result + end + + test "parse/1 falls back to defaults when all .a2ml files are unparseable" do + tmp = System.tmp_dir!() + workspace = Path.join(tmp, "test_ws_bad_#{:erlang.unique_integer([:positive])}") + integrations = Path.join([workspace, ".machine_readable", "integrations"]) + File.mkdir_p!(integrations) + + File.write!(Path.join(integrations, "broken.a2ml"), "no fields here at all") + + result = StackParser.parse(workspace) + # Should fall back to the full default set. + domain_names = Enum.map(result, & &1.domain) + assert "k8s" in domain_names + end +end diff --git a/cartridges/domains/languages/orchestrator-lsp-mcp/adapter/test/test_helper.exs b/cartridges/domains/languages/orchestrator-lsp-mcp/adapter/test/test_helper.exs new file mode 100644 index 0000000..d3ff193 --- /dev/null +++ b/cartridges/domains/languages/orchestrator-lsp-mcp/adapter/test/test_helper.exs @@ -0,0 +1,3 @@ +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +ExUnit.start() diff --git a/cartridges/domains/languages/orchestrator-lsp-mcp/cartridge.json b/cartridges/domains/languages/orchestrator-lsp-mcp/cartridge.json new file mode 100644 index 0000000..325d21e --- /dev/null +++ b/cartridges/domains/languages/orchestrator-lsp-mcp/cartridge.json @@ -0,0 +1,117 @@ +{ + "$schema": "https://boj.dev/schemas/cartridge/v1.json", + "spdx": "MPL-2.0", + "copyright": "Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk>", + "name": "orchestrator-lsp-mcp", + "version": "0.1.0", + "status": "ffi_only", + "status": "ready", + "description": "Cross-domain LSP orchestrator. Routes LSP requests across all 12 poly-*-lsp servers (cloud, container, iac, k8s, db, queue, secret, git, ssg, proof, observability, browser) via a single GenLSP supervisor. Inspired by poly-orchestrator-lsp (polystack, archived). Wraps the 12 domain servers into one unified textDocument interface with domain-routing based on workspace root and file type.", + "domain": "LSP", + "tier": "Teranga", + "protocols": [ + "MCP", + "LSP" + ], + "runtime": "elixir", + "auth": { + "method": "none", + "env_var": null, + "credential_source": null + }, + "api": { + "base_url": "local://orchestrator-lsp-mcp", + "content_type": "application/json" + }, + "design_notes": [ + "Supervisor tree: LSPServer + LSPClientPool (Γ—12) + ExecutionRegistry + ExecutionSupervisor", + "Stack parser reads .machine_readable/integrations/*.a2ml to discover active poly-*-lsp endpoints", + "Planner routes each LSP request to the correct domain server(s) β€” cross-domain queries fan-out", + "VeriSimDB client tracks orchestration session history and capability advertisement", + "VSCode extension (ReScript, not TypeScript) exposes this as a single multi-domain language server" + ], + "tools": [ + { + "name": "lsp_orchestrate_start", + "description": "Start an orchestrated LSP session spanning one or more domain servers. Returns a session ID that routes LSP requests to the appropriate domain server(s) based on workspace context.", + "inputSchema": { + "type": "object", + "properties": { + "workspace_root": { + "type": "string", + "description": "Workspace root directory path" + }, + "domains": { + "type": "array", + "items": { "type": "string" }, + "description": "Domain LSP servers to activate (e.g. ['git', 'k8s', 'db']). Empty = all." + } + }, + "required": ["workspace_root"] + } + }, + { + "name": "lsp_orchestrate_request", + "description": "Forward a raw LSP JSON-RPC request through the orchestrator. The orchestrator routes it to the correct domain server(s) and merges responses.", + "inputSchema": { + "type": "object", + "properties": { + "session_id": { + "type": "string", + "description": "Session ID from lsp_orchestrate_start" + }, + "method": { + "type": "string", + "description": "LSP method (e.g. textDocument/completion, textDocument/hover)" + }, + "params": { + "type": "object", + "description": "LSP method parameters (JSON)" + } + }, + "required": ["session_id", "method", "params"] + } + }, + { + "name": "lsp_orchestrate_stop", + "description": "Stop an orchestrated session and shut down all domain LSP servers gracefully.", + "inputSchema": { + "type": "object", + "properties": { + "session_id": { + "type": "string", + "description": "Session ID to stop" + } + }, + "required": ["session_id"] + } + }, + { + "name": "lsp_orchestrate_status", + "description": "Return the health and capability advertisement of all active domain servers in a session.", + "inputSchema": { + "type": "object", + "properties": { + "session_id": { + "type": "string", + "description": "Session ID" + } + }, + "required": ["session_id"] + } + } + ], + "ffi": { + "so_path": "ffi/zig-out/lib/liborchestrator_lsp_mcp.so", + "abi_version": "ADR-0006", + "symbols": [ + "boj_cartridge_init", + "boj_cartridge_deinit", + "boj_cartridge_name", + "boj_cartridge_version", + "boj_cartridge_invoke" + ], + "note": "Zig FFI stub delegates to Elixir GenLSP application via port protocol" + }, + "upstream_reference": "polystack/poly-orchestrator-lsp (archived 2026) β€” design reference only, not copied" +} diff --git a/cartridges/domains/languages/orchestrator-lsp-mcp/ffi/zig/build.zig b/cartridges/domains/languages/orchestrator-lsp-mcp/ffi/zig/build.zig new file mode 100644 index 0000000..5210ff7 --- /dev/null +++ b/cartridges/domains/languages/orchestrator-lsp-mcp/ffi/zig/build.zig @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// orchestrator-lsp-mcp β€” Zig FFI build configuration (Zig 0.15+). +// +// Produces: zig-out/lib/liborchestrator_lsp_mcp.so (matches cartridge.json so_path) +// +// Usage: +// zig build β€” build the shared library +// zig build test β€” run unit tests +// zig build lib β€” alias for the default step + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + // ── Shared shim module (ADR-0006 invoke helpers) ────────────────── + // Relative path: cartridges/orchestrator-lsp-mcp/ffi/zig/ β†’ src/abi/ + const shim_mod = b.addModule("cartridge_shim", .{ + .root_source_file = b.path("../../../../ffi/zig/src/cartridge_shim.zig"), + .target = target, + .optimize = optimize, + }); + + // ── Main FFI module ─────────────────────────────────────────────── + const ffi_mod = b.addModule("orchestrator_lsp_ffi", .{ + .root_source_file = b.path("src/main.zig"), + .target = target, + .optimize = optimize, + }); + ffi_mod.addImport("cartridge_shim", shim_mod); + + // ── Tests ───────────────────────────────────────────────────────── + const tests = b.addTest(.{ + .root_module = ffi_mod, + }); + const run_tests = b.addRunArtifact(tests); + const test_step = b.step("test", "Run orchestrator-lsp-mcp FFI tests"); + test_step.dependOn(&run_tests.step); + + // ── Shared library β†’ zig-out/lib/liborchestrator_lsp_mcp.so ────── + const lib_mod = b.createModule(.{ + .root_source_file = b.path("src/main.zig"), + .target = target, + .optimize = optimize, + }); + lib_mod.addImport("cartridge_shim", shim_mod); + + const lib = b.addLibrary(.{ + .name = "orchestrator_lsp_mcp", + .root_module = lib_mod, + .linkage = .dynamic, + }); + b.installArtifact(lib); + + const lib_step = b.step("lib", "Build liborchestrator_lsp_mcp.so"); + lib_step.dependOn(&lib.step); +} diff --git a/cartridges/domains/languages/orchestrator-lsp-mcp/ffi/zig/src/main.zig b/cartridges/domains/languages/orchestrator-lsp-mcp/ffi/zig/src/main.zig new file mode 100644 index 0000000..9eef6ac --- /dev/null +++ b/cartridges/domains/languages/orchestrator-lsp-mcp/ffi/zig/src/main.zig @@ -0,0 +1,582 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// orchestrator-lsp-mcp β€” Zig FFI bridge for the cross-domain LSP orchestrator. +// +// Implements the ADR-0006 five-symbol cartridge ABI. The MCP tool layer +// manages a local session table (workspace root + active domain set) and +// delegates actual LSP routing to the Elixir GenLSP adapter via the Erlang +// port protocol (4-byte big-endian length framing over stdin/stdout). +// +// Port lifecycle: +// boj_cartridge_init β†’ resets session table; adapter spawned lazily +// boj_cartridge_invoke β†’ sessions are managed in Zig; lsp_orchestrate_request +// forwards to the Elixir adapter via port protocol +// boj_cartridge_deinit β†’ sends {"cmd":"shutdown"} to adapter and waits +// +// Env vars: +// BOJ_ORCHESTRATOR_LSP_DIR β€” path to the adapter/ Mix project root +// BOJ_ORCHESTRATOR_LSP_CMD β€” override Mix command (default: "mix run --no-halt") +// +// Domains (indices 0-11, matching poly-*-lsp default ports 9001-9012): +// 0=cloud 1=container 2=iac 3=k8s 4=db 5=queue +// 6=secret 7=git 8=ssg 9=proof 10=observability 11=browser + +const std = @import("std"); +const shim = @import("cartridge_shim.zig"); + +// ─── Constants ─────────────────────────────────────────────────────────────── + +const MAX_SESSIONS: usize = 8; +const MAX_DOMAINS: usize = 12; +const WS_PATH_CAP: usize = 512; +const SESSION_ID_LEN: usize = 13; // "sess-00000001" +const PORT_BUF_CAP: usize = 65536; + +const DOMAIN_NAMES = [MAX_DOMAINS][]const u8{ + "cloud", "container", "iac", "k8s", "db", "queue", + "secret", "git", "ssg", "proof", "observability", "browser", +}; + +// ─── Session table ─────────────────────────────────────────────────────────── + +const Session = struct { + active: bool = false, + id: [SESSION_ID_LEN]u8 = [_]u8{0} ** SESSION_ID_LEN, + workspace_root: [WS_PATH_CAP]u8 = [_]u8{0} ** WS_PATH_CAP, + ws_len: usize = 0, + /// domains[i] == true when poly-<domain>-lsp is active for this session. + domains: [MAX_DOMAINS]bool = [_]bool{false} ** MAX_DOMAINS, +}; + +var sessions: [MAX_SESSIONS]Session = [_]Session{.{}} ** MAX_SESSIONS; +var session_counter: u32 = 0; +var session_mutex: std.Thread.Mutex = .{}; + +fn nextSessionId(out: *[SESSION_ID_LEN]u8) void { + session_counter +%= 1; + _ = std.fmt.bufPrint(out, "sess-{X:0>8}", .{session_counter}) catch {}; +} + +/// Find session by ID. Caller must hold `session_mutex`. +fn findSession(id: []const u8) ?*Session { + const cmp_len = @min(id.len, SESSION_ID_LEN); + for (&sessions) |*s| { + if (s.active and std.mem.eql(u8, s.id[0..cmp_len], id[0..cmp_len])) + return s; + } + return null; +} + +// ─── Minimal JSON field extraction ─────────────────────────────────────────── +// +// Stack-only scanning β€” no heap allocation, no escape handling. +// Sufficient for the unescaped paths and IDs in our tool schemas. + +fn extractString(json: []const u8, field: []const u8, out: []u8) ?[]u8 { + var key_buf: [128]u8 = undefined; + const key = std.fmt.bufPrint(&key_buf, "\"{s}\"", .{field}) catch return null; + + const pos = std.mem.indexOf(u8, json, key) orelse return null; + var tail = json[pos + key.len ..]; + + tail = std.mem.trimLeft(u8, tail, " \t\r\n"); + if (tail.len == 0 or tail[0] != ':') return null; + tail = std.mem.trimLeft(u8, tail[1..], " \t\r\n"); + + if (tail.len == 0 or tail[0] != '"') return null; + tail = tail[1..]; + + const end = std.mem.indexOf(u8, tail, "\"") orelse return null; + if (end > out.len) return null; + @memcpy(out[0..end], tail[0..end]); + return out[0..end]; +} + +/// Return true when a domain name string appears in the JSON `domains` array. +fn domainInJson(json: []const u8, domain: []const u8) bool { + return std.mem.indexOf(u8, json, domain) != null; +} + +// ─── Port process ───────────────────────────────────────────────────────────── +// +// A single Elixir adapter process handles all sessions. +// `port_mutex` serialises both spawn and the full sendβ†’receive round-trip so +// concurrent invoke calls cannot interleave their messages on the pipe. + +var port_child: ?std.process.Child = null; +var port_mutex: std.Thread.Mutex = .{}; + +/// Spawn the Elixir adapter if not already running. +/// Uses std.heap.page_allocator so the argv slice outlives this call. +/// The tiny allocation (< 512 bytes) is intentionally not freed β€” it persists +/// for the lifetime of the .so and is reclaimed by the OS when the process exits. +fn ensurePort() void { + port_mutex.lock(); + defer port_mutex.unlock(); + if (port_child != null) return; + + const dir = std.posix.getenv("BOJ_ORCHESTRATOR_LSP_DIR") orelse + "cartridges/orchestrator-lsp-mcp/adapter"; + const cmd_str = std.posix.getenv("BOJ_ORCHESTRATOR_LSP_CMD") orelse + "mix run --no-halt"; + + const alloc = std.heap.page_allocator; + var argv = std.ArrayList([]const u8).init(alloc); + var tok = std.mem.tokenizeScalar(u8, cmd_str, ' '); + while (tok.next()) |part| argv.append(part) catch return; + // argv.toOwnedSlice() would also work; we intentionally leave the ArrayList + // allocated so proc.argv remains valid for the child's lifetime. + + var proc = std.process.Child.init(argv.items, alloc); + proc.cwd = dir; + proc.stdin_behavior = .Pipe; + proc.stdout_behavior = .Pipe; + proc.stderr_behavior = .Inherit; + + proc.spawn() catch |err| { + std.debug.print( + "[orchestrator-lsp-mcp] warn: adapter spawn failed ({s}): {}\n", + .{ dir, err }, + ); + return; + }; + port_child = proc; +} + +/// Send a request and receive the response in a single locked operation. +/// Holding the lock for the full round-trip prevents request interleaving from +/// concurrent invoke calls. +/// +/// `std.fs.File` is a thin wrapper around an OS file descriptor (an integer), +/// so copying it from the `Child` struct is safe β€” both copies access the same fd. +fn portRoundTrip(request_json: []const u8, buf: []u8) ![]u8 { + port_mutex.lock(); + defer port_mutex.unlock(); + + // Copy the Child struct; File handles are fd integers β€” safe to copy. + const pc = port_child orelse return error.NotConnected; + var len_buf: [4]u8 = undefined; + + // Send: 4-byte big-endian length + payload (Erlang port protocol). + const stdin = pc.stdin orelse return error.NotConnected; + std.mem.writeInt(u32, &len_buf, @intCast(request_json.len), .big); + try stdin.writeAll(&len_buf); + try stdin.writeAll(request_json); + + // Receive: 4-byte length + response. + const stdout = pc.stdout orelse return error.NotConnected; + try stdout.readNoEof(&len_buf); + const msg_len = std.mem.readInt(u32, &len_buf, .big); + if (msg_len > buf.len) return error.ResponseTooLarge; + try stdout.readNoEof(buf[0..msg_len]); + return buf[0..msg_len]; +} + +// ─── Standard ABI symbols ──────────────────────────────────────────────────── + +export fn boj_cartridge_init() callconv(.c) c_int { + session_mutex.lock(); + for (&sessions) |*s| s.* = Session{}; + session_counter = 0; + session_mutex.unlock(); + return 0; +} + +export fn boj_cartridge_deinit() callconv(.c) void { + port_mutex.lock(); + defer port_mutex.unlock(); + + // Use `|*c|` so `c` is a pointer to the Child inside the optional. + // This lets `c.wait()` operate on the actual global state, not a copy. + if (port_child) |*c| { + if (c.stdin) |stdin| { + const msg = "{\"cmd\":\"shutdown\"}"; + var len_buf: [4]u8 = undefined; + std.mem.writeInt(u32, &len_buf, @intCast(msg.len), .big); + stdin.writeAll(&len_buf) catch {}; + stdin.writeAll(msg) catch {}; + } + _ = c.wait() catch {}; + } + port_child = null; +} + +export fn boj_cartridge_name() callconv(.c) [*:0]const u8 { + return "orchestrator-lsp-mcp"; +} + +export fn boj_cartridge_version() callconv(.c) [*:0]const u8 { + return "0.1.0"; +} + +// ─── Tool: lsp_orchestrate_start ───────────────────────────────────────────── +// +// Input: {"workspace_root": "/path", "domains": ["git","k8s"]} +// `domains` is optional β€” absent or empty means all 12 are active. +// Output: {"session_id":"sess-XXXXXXXX","workspace_root":"/path","domain_count":N} + +fn toolStart(args: []const u8, out_buf: [*c]u8, in_out_len: [*c]usize) i32 { + var ws_buf: [WS_PATH_CAP]u8 = undefined; + const ws = extractString(args, "workspace_root", &ws_buf) orelse { + return shim.writeResult(out_buf, in_out_len, "{\"error\":\"missing workspace_root\"}"); + }; + + session_mutex.lock(); + defer session_mutex.unlock(); + + var slot: ?*Session = null; + for (&sessions) |*s| if (!s.active) { slot = s; break; }; + const s = slot orelse + return shim.writeResult(out_buf, in_out_len, + "{\"error\":\"session_limit_reached\",\"limit\":8}"); + + s.* = Session{}; + s.active = true; + nextSessionId(&s.id); + + const wl = @min(ws.len, WS_PATH_CAP - 1); + @memcpy(s.workspace_root[0..wl], ws[0..wl]); + s.ws_len = wl; + + const has_domains_key = std.mem.indexOf(u8, args, "\"domains\"") != null; + if (!has_domains_key) { + for (&s.domains) |*d| d.* = true; + } else { + for (DOMAIN_NAMES, 0..) |dn, i| s.domains[i] = domainInJson(args, dn); + } + + var count: usize = 0; + for (s.domains) |d| if (d) count += 1; + + var resp_buf: [512]u8 = undefined; + const resp = std.fmt.bufPrint(&resp_buf, + "{{\"session_id\":\"{s}\",\"workspace_root\":\"{s}\",\"domain_count\":{d}}}", + .{ s.id, s.workspace_root[0..s.ws_len], count }, + ) catch return shim.RC_RUNTIME_ERROR; + return shim.writeResult(out_buf, in_out_len, resp); +} + +// ─── Tool: lsp_orchestrate_stop ────────────────────────────────────────────── +// +// Input: {"session_id":"sess-XXXXXXXX"} +// Output: {"session_id":"sess-XXXXXXXX","stopped":true} + +fn toolStop(args: []const u8, out_buf: [*c]u8, in_out_len: [*c]usize) i32 { + var id_buf: [SESSION_ID_LEN + 4]u8 = undefined; + const id_slice = extractString(args, "session_id", &id_buf) orelse + return shim.writeResult(out_buf, in_out_len, "{\"error\":\"missing session_id\"}"); + + session_mutex.lock(); + defer session_mutex.unlock(); + + const s = findSession(id_slice) orelse { + var eb: [128]u8 = undefined; + const err = std.fmt.bufPrint(&eb, + "{{\"error\":\"session_not_found\",\"session_id\":\"{s}\"}}", + .{id_slice}, + ) catch return shim.RC_RUNTIME_ERROR; + return shim.writeResult(out_buf, in_out_len, err); + }; + + var resp_buf: [128]u8 = undefined; + const resp = std.fmt.bufPrint(&resp_buf, + "{{\"session_id\":\"{s}\",\"stopped\":true}}", .{s.id}, + ) catch return shim.RC_RUNTIME_ERROR; + + s.* = Session{}; + return shim.writeResult(out_buf, in_out_len, resp); +} + +// ─── Tool: lsp_orchestrate_status ──────────────────────────────────────────── +// +// Input: {"session_id":"sess-XXXXXXXX"} +// Output: {"session_id":"...","workspace_root":"...","domains":{<name>:<state>,...}} +// +// Domain state is "active" or "inactive". Live health checks from the adapter +// are a future enhancement (requires the port to be running). + +fn toolStatus(args: []const u8, out_buf: [*c]u8, in_out_len: [*c]usize) i32 { + var id_buf: [SESSION_ID_LEN + 4]u8 = undefined; + const id_slice = extractString(args, "session_id", &id_buf) orelse + return shim.writeResult(out_buf, in_out_len, "{\"error\":\"missing session_id\"}"); + + session_mutex.lock(); + defer session_mutex.unlock(); + + const s = findSession(id_slice) orelse { + var eb: [128]u8 = undefined; + const err = std.fmt.bufPrint(&eb, + "{{\"error\":\"session_not_found\",\"session_id\":\"{s}\"}}", + .{id_slice}, + ) catch return shim.RC_RUNTIME_ERROR; + return shim.writeResult(out_buf, in_out_len, err); + }; + + var domain_json: [512]u8 = undefined; + var off: usize = 0; + domain_json[off] = '{'; off += 1; + for (DOMAIN_NAMES, 0..) |dn, i| { + if (i > 0) { domain_json[off] = ','; off += 1; } + const state = if (s.domains[i]) "active" else "inactive"; + const seg = std.fmt.bufPrint(domain_json[off..], "\"{s}\":\"{s}\"", .{ dn, state }) + catch return shim.RC_RUNTIME_ERROR; + off += seg.len; + } + domain_json[off] = '}'; off += 1; + + var resp_buf: [1024]u8 = undefined; + const resp = std.fmt.bufPrint(&resp_buf, + "{{\"session_id\":\"{s}\",\"workspace_root\":\"{s}\",\"domains\":{s}}}", + .{ s.id, s.workspace_root[0..s.ws_len], domain_json[0..off] }, + ) catch return shim.RC_RUNTIME_ERROR; + return shim.writeResult(out_buf, in_out_len, resp); +} + +// ─── Tool: lsp_orchestrate_request ─────────────────────────────────────────── +// +// Input: {"session_id":"...","method":"textDocument/completion","params":{...}} +// Output: merged LSP response from the Elixir adapter (or structured error). +// +// The full LSP JSON-RPC round-trip is forwarded to the adapter so it can fan +// the request out to the correct domain server(s) and merge the responses. +// If the adapter is not running, returns an explicit offline error. + +fn toolRequest(args: []const u8, out_buf: [*c]u8, in_out_len: [*c]usize) i32 { + // Validate session exists before touching the port. + var id_buf: [SESSION_ID_LEN + 4]u8 = undefined; + const id_slice = extractString(args, "session_id", &id_buf) orelse + return shim.writeResult(out_buf, in_out_len, "{\"error\":\"missing session_id\"}"); + + { + session_mutex.lock(); + defer session_mutex.unlock(); + _ = findSession(id_slice) orelse { + var eb: [128]u8 = undefined; + const err = std.fmt.bufPrint(&eb, + "{{\"error\":\"session_not_found\",\"session_id\":\"{s}\"}}", + .{id_slice}, + ) catch return shim.RC_RUNTIME_ERROR; + return shim.writeResult(out_buf, in_out_len, err); + }; + } + + // Wrap the MCP args in the adapter's port envelope. + var req_buf: [PORT_BUF_CAP]u8 = undefined; + const request = std.fmt.bufPrint(&req_buf, + "{{\"cmd\":\"lsp_orchestrate_request\",\"args\":{s}}}", .{args}, + ) catch return shim.RC_RUNTIME_ERROR; + + // Lazily spawn the Elixir adapter. + ensurePort(); + + // Forward to the adapter. On transport failure, return a structured error + // so the caller can display a meaningful message rather than a crash. + var resp_buf: [PORT_BUF_CAP]u8 = undefined; + const resp = portRoundTrip(request, &resp_buf) catch |err| { + var eb: [256]u8 = undefined; + const msg = std.fmt.bufPrint(&eb, + "{{\"error\":\"adapter_unavailable\",\"detail\":\"{s}\"," ++ + "\"hint\":\"set BOJ_ORCHESTRATOR_LSP_DIR and start the adapter\"}}", + .{@errorName(err)}, + ) catch return shim.RC_RUNTIME_ERROR; + return shim.writeResult(out_buf, in_out_len, msg); + }; + + return shim.writeResult(out_buf, in_out_len, resp); +} + +// ─── ADR-0006 dispatch ──────────────────────────────────────────────────────── + +export fn boj_cartridge_invoke( + tool_name: [*c]const u8, + json_args: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) callconv(.c) i32 { + if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; + + const args: []const u8 = if (json_args != null) + std.mem.span(@as([*:0]const u8, @ptrCast(json_args))) + else + "{}"; + + if (shim.toolIs(tool_name, "lsp_orchestrate_start")) return toolStart(args, out_buf, in_out_len); + if (shim.toolIs(tool_name, "lsp_orchestrate_stop")) return toolStop(args, out_buf, in_out_len); + if (shim.toolIs(tool_name, "lsp_orchestrate_status")) return toolStatus(args, out_buf, in_out_len); + if (shim.toolIs(tool_name, "lsp_orchestrate_request")) return toolRequest(args, out_buf, in_out_len); + + return shim.RC_UNKNOWN_TOOL; +} + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +test "session start returns session_id and domain_count" { + _ = boj_cartridge_init(); + var buf: [512]u8 = undefined; + var len: usize = buf.len; + + const rc = boj_cartridge_invoke( + "lsp_orchestrate_start", + "{\"workspace_root\":\"/tmp/ws\"}", + &buf, &len, + ); + try std.testing.expectEqual(@as(i32, 0), rc); + const resp = buf[0..len]; + try std.testing.expect(std.mem.indexOf(u8, resp, "session_id") != null); + try std.testing.expect(std.mem.indexOf(u8, resp, "domain_count") != null); +} + +test "start with explicit domains respects selection" { + _ = boj_cartridge_init(); + var buf: [512]u8 = undefined; + var len: usize = buf.len; + + const rc = boj_cartridge_invoke( + "lsp_orchestrate_start", + "{\"workspace_root\":\"/ws\",\"domains\":[\"git\",\"k8s\"]}", + &buf, &len, + ); + try std.testing.expectEqual(@as(i32, 0), rc); + try std.testing.expect(std.mem.indexOf(u8, buf[0..len], "\"domain_count\":2") != null); +} + +test "status returns all domain states for a valid session" { + _ = boj_cartridge_init(); + var buf: [2048]u8 = undefined; + var len: usize = buf.len; + + _ = boj_cartridge_invoke( + "lsp_orchestrate_start", + "{\"workspace_root\":\"/ws\"}", + &buf, &len, + ); + var id_buf: [SESSION_ID_LEN + 4]u8 = undefined; + const sid = extractString(buf[0..len], "session_id", &id_buf).?; + + var args_buf: [128]u8 = undefined; + const status_args = try std.fmt.bufPrint( + &args_buf, "{{\"session_id\":\"{s}\"}}", .{sid}); + + var len2: usize = buf.len; + const rc = boj_cartridge_invoke("lsp_orchestrate_status", status_args.ptr, &buf, &len2); + try std.testing.expectEqual(@as(i32, 0), rc); + const resp = buf[0..len2]; + try std.testing.expect(std.mem.indexOf(u8, resp, "\"cloud\"") != null); + try std.testing.expect(std.mem.indexOf(u8, resp, "\"browser\"") != null); + try std.testing.expect(std.mem.indexOf(u8, resp, "\"active\"") != null); +} + +test "start then stop releases the slot" { + _ = boj_cartridge_init(); + var buf: [512]u8 = undefined; + var len: usize = buf.len; + + _ = boj_cartridge_invoke( + "lsp_orchestrate_start", + "{\"workspace_root\":\"/ws\"}", + &buf, &len, + ); + var id_buf: [SESSION_ID_LEN + 4]u8 = undefined; + const sid = extractString(buf[0..len], "session_id", &id_buf).?; + + var stop_args_buf: [128]u8 = undefined; + const stop_args = try std.fmt.bufPrint( + &stop_args_buf, "{{\"session_id\":\"{s}\"}}", .{sid}); + + var len2: usize = buf.len; + const rc = boj_cartridge_invoke("lsp_orchestrate_stop", stop_args.ptr, &buf, &len2); + try std.testing.expectEqual(@as(i32, 0), rc); + try std.testing.expect(std.mem.indexOf(u8, buf[0..len2], "\"stopped\":true") != null); +} + +test "stop with unknown session_id returns error" { + _ = boj_cartridge_init(); + var buf: [256]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke( + "lsp_orchestrate_stop", + "{\"session_id\":\"sess-DEADBEEF\"}", + &buf, &len, + ); + try std.testing.expectEqual(@as(i32, 0), rc); + try std.testing.expect(std.mem.indexOf(u8, buf[0..len], "\"error\"") != null); +} + +test "request without adapter returns structured offline error" { + _ = boj_cartridge_init(); + var buf: [512]u8 = undefined; + var len: usize = buf.len; + + _ = boj_cartridge_invoke( + "lsp_orchestrate_start", + "{\"workspace_root\":\"/ws\"}", + &buf, &len, + ); + var id_buf: [SESSION_ID_LEN + 4]u8 = undefined; + const sid = extractString(buf[0..len], "session_id", &id_buf).?; + + var args_buf: [256]u8 = undefined; + const req_args = try std.fmt.bufPrint( + &args_buf, + "{{\"session_id\":\"{s}\",\"method\":\"textDocument/hover\",\"params\":{{}}}}", + .{sid}); + + var len2: usize = buf.len; + const rc = boj_cartridge_invoke("lsp_orchestrate_request", req_args.ptr, &buf, &len2); + // Returns RC_SUCCESS with an error payload β€” not RC_RUNTIME_ERROR. + try std.testing.expectEqual(@as(i32, 0), rc); + try std.testing.expect(std.mem.indexOf(u8, buf[0..len2], "adapter_unavailable") != null); +} + +test "session table enforces MAX_SESSIONS limit" { + _ = boj_cartridge_init(); + var buf: [256]u8 = undefined; + + for (0..MAX_SESSIONS) |_| { + var len: usize = buf.len; + const rc = boj_cartridge_invoke( + "lsp_orchestrate_start", "{\"workspace_root\":\"/ws\"}", &buf, &len); + try std.testing.expectEqual(@as(i32, 0), rc); + } + // One more should hit the limit. + var len: usize = buf.len; + const rc = boj_cartridge_invoke( + "lsp_orchestrate_start", "{\"workspace_root\":\"/ws\"}", &buf, &len); + try std.testing.expectEqual(@as(i32, 0), rc); + try std.testing.expect(std.mem.indexOf(u8, buf[0..len], "session_limit_reached") != null); +} + +test "unknown tool returns RC_UNKNOWN_TOOL" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("nonexistent_tool", "{}", &buf, &len); + try std.testing.expectEqual(shim.RC_UNKNOWN_TOOL, rc); +} + +test "null tool_name returns RC_BAD_ARGS" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke(null, "{}", &buf, &len); + try std.testing.expectEqual(shim.RC_BAD_ARGS, rc); +} + +test "extractString: basic case" { + const json = "{\"key\":\"value\",\"other\":\"x\"}"; + var out: [64]u8 = undefined; + const r = extractString(json, "key", &out).?; + try std.testing.expectEqualStrings("value", r); +} + +test "extractString: absent field returns null" { + const json = "{\"other\":\"x\"}"; + var out: [64]u8 = undefined; + try std.testing.expect(extractString(json, "key", &out) == null); +} + +test "domainInJson: present and absent" { + const json = "{\"domains\":[\"git\",\"k8s\"]}"; + try std.testing.expect(domainInJson(json, "git")); + try std.testing.expect(domainInJson(json, "k8s")); + try std.testing.expect(!domainInJson(json, "cloud")); +} diff --git a/cartridges/domains/languages/orchestrator-lsp-mcp/panels/package.json b/cartridges/domains/languages/orchestrator-lsp-mcp/panels/package.json new file mode 100644 index 0000000..15fdcd2 --- /dev/null +++ b/cartridges/domains/languages/orchestrator-lsp-mcp/panels/package.json @@ -0,0 +1,60 @@ +{ + "_comment": "VSCode extension manifest. npm is required here by the VSCode platform β€” this is a documented exception to the no-npm policy. vscode-languageclient is a VSCode platform dependency, not a general runtime dep.", + "name": "orchestrator-lsp-mcp", + "displayName": "BoJ Orchestrator LSP", + "description": "Cross-domain LSP orchestrator β€” routes textDocument/* requests across all 12 poly-*-lsp servers via a single connection", + "version": "0.1.0", + "publisher": "hyperpolymath", + "license": "MPL-2.0", + "repository": { + "type": "git", + "url": "https://github.com/hyperpolymath/boj-server" + }, + "engines": { + "vscode": "^1.85.0" + }, + "categories": ["Programming Languages", "Linters"], + "activationEvents": ["onStartupFinished"], + "main": "./lib/js/src/Extension.js", + "contributes": { + "configuration": { + "title": "BoJ Orchestrator LSP", + "properties": { + "boj.orchestratorLsp.adapterDir": { + "type": "string", + "default": "", + "description": "Path to the orchestrator-lsp-mcp adapter/ Mix project. Defaults to the cartridge directory at BOJ_ORCHESTRATOR_LSP_DIR." + }, + "boj.orchestratorLsp.command": { + "type": "string", + "default": "mix", + "description": "Command used to start the Elixir GenLSP adapter." + }, + "boj.orchestratorLsp.args": { + "type": "array", + "items": {"type": "string"}, + "default": ["run", "--no-halt"], + "description": "Arguments passed to the start command." + }, + "boj.orchestratorLsp.trace.server": { + "type": "string", + "enum": ["off", "messages", "verbose"], + "default": "off", + "description": "Trace LSP communication between the extension and the adapter." + } + } + } + }, + "dependencies": { + "vscode-languageclient": "^9.0.1" + }, + "devDependencies": { + "@types/vscode": "^1.85.0", + "rescript": "^11.0.0" + }, + "scripts": { + "build": "rescript build", + "clean": "rescript clean", + "watch": "rescript build -w" + } +} diff --git a/cartridges/domains/languages/orchestrator-lsp-mcp/panels/rescript.json b/cartridges/domains/languages/orchestrator-lsp-mcp/panels/rescript.json new file mode 100644 index 0000000..f015179 --- /dev/null +++ b/cartridges/domains/languages/orchestrator-lsp-mcp/panels/rescript.json @@ -0,0 +1,19 @@ +{ + "name": "orchestrator-lsp-mcp-extension", + "sources": [ + { + "dir": "src", + "subdirs": true + } + ], + "package-specs": [ + { + "module": "commonjs", + "in-source": false + } + ], + "suffix": ".js", + "bs-dependencies": [], + "bsc-flags": [], + "namespace": false +} diff --git a/cartridges/domains/languages/orchestrator-lsp-mcp/panels/src/Extension.res b/cartridges/domains/languages/orchestrator-lsp-mcp/panels/src/Extension.res new file mode 100644 index 0000000..e070f27 --- /dev/null +++ b/cartridges/domains/languages/orchestrator-lsp-mcp/panels/src/Extension.res @@ -0,0 +1,143 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// Extension.res β€” BoJ Orchestrator LSP β€” VSCode extension entry point. +// +// Registers the cross-domain GenLSP orchestrator as a single language server +// that editors and AI agents connect to once, receiving cross-domain +// intelligence from all 12 poly-*-lsp servers. +// +// Architecture: +// VSCode / AI agent (LanguageClient) ←→ this extension ←→ Elixir adapter +// ↕ routes by domain +// 12 poly-*-lsp servers +// +// Configuration keys (boj.orchestratorLsp.*): +// adapterDir β€” path to the adapter/ Mix project root +// command β€” executable to start the adapter (default: "mix") +// args β€” argument array (default: ["run", "--no-halt"]) +// trace.server β€” LSP trace level: "off" | "messages" | "verbose" + +// The active LanguageClient, held for graceful shutdown on deactivation. +let client: ref<option<LanguageClient.t>> = ref(None) + +// ── Helpers ────────────────────────────────────────────────────────────────── + +// Wrap a JS array push to keep ReScript bindings local. +@val @scope("Array.prototype") +external arrayPush: (array<'a>, 'a) => int = "push" + +// Retrieve the adapter directory from VSCode config or the BOJ env var. +let resolveAdapterDir = (): string => { + let cfg = VscodeApi.Workspace2.getConfiguration("boj.orchestratorLsp") + switch VscodeApi.Configuration.get(cfg, "adapterDir") { + | Some(dir) if dir !== "" => dir + | _ => + let envDir: Js.nullable<string> = %raw(`process.env.BOJ_ORCHESTRATOR_LSP_DIR ?? null`) + switch Js.Nullable.toOption(envDir) { + | Some(d) => d + | None => "cartridges/orchestrator-lsp-mcp/adapter" + } + } +} + +// Workspace root: first open folder, or "." as fallback. +let resolveWorkspaceRoot = (): string => { + switch VscodeApi.Workspace.workspaceFolders { + | Some(folders) if Array.length(folders) > 0 => + let uri = folders->Array.getUnsafe(0)->VscodeApi.WorkspaceFolder.uri + VscodeApi.Uri.fsPath(uri) + | _ => "." + } +} + +// Build the ServerOptions record for vscode-languageclient. +let buildServerOptions = (adapterDir: string): LanguageClient.serverOptions => { + let cfg = VscodeApi.Workspace2.getConfiguration("boj.orchestratorLsp") + + let cmd: string = switch VscodeApi.Configuration.get(cfg, "command") { + | Some(c) => c + | None => "mix" + } + + let args: array<string> = switch VscodeApi.Configuration.get(cfg, "args") { + | Some(a) => a + | None => ["run", "--no-halt"] + } + + let opts = LanguageClient.executableOptions(~cwd=adapterDir, ()) + let exe = LanguageClient.executable(~command=cmd, ~args, ~options=opts, ()) + LanguageClient.serverOptions(~run=exe, ~debug=exe) +} + +// Build the ClientOptions for vscode-languageclient. +// The orchestrator accepts all document types β€” routing happens inside the adapter. +let buildClientOptions = (channel: VscodeApi.OutputChannel.t): LanguageClient.clientOptions => { + let selector = [ + LanguageClient.documentSelectorItem(~scheme="file", ()), + LanguageClient.documentSelectorItem(~scheme="untitled", ()), + ] + LanguageClient.clientOptions(~documentSelector=selector, ~outputChannel=channel, ()) +} + +// Wrap an OutputChannel as a disposable for VSCode subscriptions. +let channelAsDisposable = (ch: VscodeApi.OutputChannel.t): VscodeApi.disposable => { + {dispose: () => VscodeApi.OutputChannel.dispose(ch)} +} + +// ── Extension lifecycle ─────────────────────────────────────────────────────── + +// Called by VSCode when the extension activates. +// Exported as a top-level binding β€” ReScript CommonJS output exposes it as +// `module.exports.activate`, which is what VSCode requires. +let activate = (context: VscodeApi.extensionContext): unit => { + let channel = VscodeApi.Window.createOutputChannel("BoJ Orchestrator LSP") + let subscriptions = VscodeApi.ExtensionContext.subscriptions(context) + + let log = msg => VscodeApi.OutputChannel.appendLine(channel, "[BoJ Orchestrator LSP] " ++ msg) + + let adapterDir = resolveAdapterDir() + let wsRoot = resolveWorkspaceRoot() + log("adapter dir: " ++ adapterDir) + log("workspace root: " ++ wsRoot) + + let serverOpts = buildServerOptions(adapterDir) + let clientOpts = buildClientOptions(channel) + + let lspClient = LanguageClient.make( + "boj-orchestrator-lsp", + "BoJ Orchestrator LSP", + serverOpts, + clientOpts, + ) + client := Some(lspClient) + + let _ = + LanguageClient.start(lspClient) + ->Js.Promise.then_(_ => { + log("adapter ready β€” connected to 12 domain LSP servers") + Js.Promise.resolve() + }, _) + ->Js.Promise.catch(err => { + let detail = Js.String.make(err) + log("adapter failed to start: " ++ detail) + VscodeApi.Window.showErrorMessage("BoJ Orchestrator LSP failed to start: " ++ detail) + Js.Promise.resolve() + }, _) + + // Register for automatic cleanup when the extension deactivates. + let _ = arrayPush(subscriptions, LanguageClient.asDisposable(lspClient)) + let _ = arrayPush(subscriptions, channelAsDisposable(channel)) + () +} + +// Called by VSCode before the extension host is torn down. +// Returns a promise so VSCode waits for graceful shutdown. +let deactivate = (): Js.Promise.t<unit> => { + switch client.contents { + | Some(c) => + client := None + LanguageClient.stop(c) + | None => Js.Promise.resolve() + } +} diff --git a/cartridges/domains/languages/orchestrator-lsp-mcp/panels/src/LanguageClient.res b/cartridges/domains/languages/orchestrator-lsp-mcp/panels/src/LanguageClient.res new file mode 100644 index 0000000..1a2dd02 --- /dev/null +++ b/cartridges/domains/languages/orchestrator-lsp-mcp/panels/src/LanguageClient.res @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// LanguageClient.res β€” ReScript bindings for vscode-languageclient/node. +// +// Binds just the subset needed to start, stop, and dispose an LSP client +// from within the VSCode extension host. + +// Opaque LanguageClient instance. +type t + +// ── ServerOptions ──────────────────────────────────────────────────────────── + +// Environment variable overrides for the server process. +type processEnv = Js.Dict.t<string> + +// Options for the spawned server process. +@deriving(abstract) +type executableOptions = { + @optional cwd: string, + @optional env: processEnv, +} + +// A runnable executable (command + args + optional process options). +@deriving(abstract) +type executable = { + command: string, + @optional args: array<string>, + @optional options: executableOptions, +} + +// Run/debug variants β€” vscode-languageclient picks `run` in production. +@deriving(abstract) +type serverOptions = { + run: executable, + debug: executable, +} + +// ── ClientOptions ───────────────────────────────────────────────────────────── + +// A document selector entry β€” any combination of scheme, language, pattern. +@deriving(abstract) +type documentSelectorItem = { + @optional scheme: string, + @optional language: string, + @optional pattern: string, +} + +// Options for the LanguageClient. +@deriving(abstract) +type clientOptions = { + documentSelector: array<documentSelectorItem>, + @optional outputChannel: VscodeApi.OutputChannel.t, +} + +// ── LanguageClient constructor ──────────────────────────────────────────────── + +// `new LanguageClient(id, name, serverOptions, clientOptions)` +@module("vscode-languageclient/node") @new +external make: (string, string, serverOptions, clientOptions) => t = "LanguageClient" + +// ── Lifecycle ───────────────────────────────────────────────────────────────── + +// Start the language client and server. Returns a promise that resolves when +// the server has initialised. +@send +external start: t => promise<unit> = "start" + +// Gracefully stop the language client and server. +@send +external stop: t => promise<unit> = "stop" + +// Cast the client as a disposable for subscription registration. +// LanguageClient implements the disposable protocol β€” this is a safe identity cast. +external asDisposable: t => VscodeApi.disposable = "%identity" diff --git a/cartridges/domains/languages/orchestrator-lsp-mcp/panels/src/VscodeApi.res b/cartridges/domains/languages/orchestrator-lsp-mcp/panels/src/VscodeApi.res new file mode 100644 index 0000000..7f70d2c --- /dev/null +++ b/cartridges/domains/languages/orchestrator-lsp-mcp/panels/src/VscodeApi.res @@ -0,0 +1,73 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// VscodeApi.res β€” Minimal ReScript external bindings for the VSCode extension API. +// +// Only the surface used by the orchestrator-lsp-mcp extension is bound here. +// Extend as needed rather than importing a full binding library. + +// A VSCode disposable β€” anything with a `dispose()` method can be pushed into +// ExtensionContext.subscriptions so VSCode cleans it up on extension deactivation. +type disposable = {dispose: unit => unit} + +// Opaque handle passed to `activate`. Never constructed by extension code. +type extensionContext + +module ExtensionContext = { + // Array of disposables to clean up when the extension deactivates. + @get + external subscriptions: extensionContext => array<disposable> = "subscriptions" +} + +module OutputChannel = { + type t + + @send + external appendLine: (t, string) => unit = "appendLine" + + @send + external show: t => unit = "show" + + @send + external dispose: t => unit = "dispose" +} + +module Uri = { + type t + @get external fsPath: t => string = "fsPath" +} + +module WorkspaceFolder = { + type t + @get external uri: t => Uri.t = "uri" + @get external name: t => string = "name" +} + +module Window = { + @module("vscode") @scope("window") + external createOutputChannel: string => OutputChannel.t = "createOutputChannel" + + @module("vscode") @scope("window") + external showErrorMessage: string => unit = "showErrorMessage" + + @module("vscode") @scope("window") + external showInformationMessage: string => unit = "showInformationMessage" +} + +module Workspace = { + // May be undefined if no folder is open β€” binds as nullable array. + @module("vscode") @scope("workspace") @return(nullable) + external workspaceFolders: option<array<WorkspaceFolder.t>> = "workspaceFolders" +} + +module Configuration = { + type t + + @send @return(nullable) + external get: (t, string) => option<'a> = "get" +} + +module Workspace2 = { + @module("vscode") @scope("workspace") + external getConfiguration: string => Configuration.t = "getConfiguration" +} diff --git a/cartridges/domains/languages/toolchain-mcp/README.adoc b/cartridges/domains/languages/toolchain-mcp/README.adoc new file mode 100644 index 0000000..e864eea --- /dev/null +++ b/cartridges/domains/languages/toolchain-mcp/README.adoc @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += toolchain-mcp +Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk> +:spdx: MPL-2.0 +:tier: Ayo +:domain: Language Tools +:protocols: MCP, REST + +== Overview + +Toolchain orchestrator. Mints, provisions, and configures language toolchains composed from lsp-mcp, dap-mcp, lang-mcp, and bsp-mcp. Integrates with PanLL panels via Groove and supports collaborative Burble sessions for pair-debugging and pair-programming workflows. + +== Tools (8) + +[cols="2,4"] +|=== +| Tool | Description + +| `toolchain_mint` | Mint a new toolchain configuration for a language workspace. Discovers available servers (LSP/DAP/BSP/lang) and returns a toolchain ID. Does not start any servers β€” call toolchain_provision to do that. +| `toolchain_provision` | Provision a minted toolchain: start all configured servers (LSP, DAP, BSP, lang), run their initialize handshakes, and return the ready state with each server's capabilities. +| `toolchain_configure` | Apply configuration settings to a provisioned toolchain. Supports per-server options and cross-cutting settings (formatting, diagnostics throttle, telemetry). +| `toolchain_status` | Get the current status of a toolchain or all toolchains. Returns server states, capabilities, and Groove/Burble binding info. +| `toolchain_list` | List all active toolchains with their language, workspace, server states, and any bound Groove endpoints or Burble rooms. +| `toolchain_groove_bind` | Bind a provisioned toolchain to a Groove endpoint so PanLL panels can discover and connect to it. Returns the Groove binding descriptor. +| `toolchain_burble_session` | Create or join a Burble collaborative session for a toolchain. Enables real-time shared debugging and pair-programming over the toolchain's LSP/DAP/BSP connections. +| `toolchain_stop` | Gracefully stop all servers in a toolchain (LSP shutdown/exit, DAP disconnect, BSP build/shutdown) and release the toolchain ID. +|=== + +== Architecture + +Three-layer stack per the BoJ cartridge standard: + +[cols="1,1,3"] +|=== +| Layer | Language | Purpose + +| ABI | Idris2 | Formally verified state machine with dependent-type proofs +| FFI | Zig | C-ABI implementation: session pool, action metrics, thread safety +| Adapter| Zig | Adapter bridge to the BoJ unified adapter protocol (zig predecessors sidelined) +|=== + +== Building + +[source,bash] +---- +# ABI (Idris2) +cd abi && idris2 --check *.idr + +# FFI (Zig) +cd ffi && zig build +cd ffi && zig build test + +# Adapter (Zig) +cd adapter && zig build +---- + +== Status + +Alpha (CRG D). Cartridge metadata declared in `cartridge.json`; 8 tool(s) exposed. +Not yet dogfooded externally. See `docs/READINESS.md` at repo root for the +project-wide grade table and `docs/practice/DOGFOOD-LOG.adoc` for usage evidence. diff --git a/cartridges/domains/languages/toolchain-mcp/cartridge.json b/cartridges/domains/languages/toolchain-mcp/cartridge.json new file mode 100644 index 0000000..bf227f6 --- /dev/null +++ b/cartridges/domains/languages/toolchain-mcp/cartridge.json @@ -0,0 +1,216 @@ +{ + "$schema": "https://boj.dev/schemas/cartridge/v1.json", + "spdx": "MPL-2.0", + "copyright": "Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath)", + "name": "toolchain-mcp", + "version": "0.1.0", + "status": "ffi_only", + "description": "Toolchain orchestrator. Mints, provisions, and configures language toolchains composed from lsp-mcp, dap-mcp, lang-mcp, and bsp-mcp. Integrates with PanLL panels via Groove and supports collaborative Burble sessions for pair-debugging and pair-programming workflows.", + "domain": "Language Tools", + "tier": "Ayo", + "protocols": [ + "MCP", + "REST" + ], + "auth": { + "method": "none", + "env_var": null, + "credential_source": null + }, + "api": { + "base_url": "local://toolchain-mcp", + "content_type": "application/json" + }, + "tools": [ + { + "name": "toolchain_mint", + "description": "Mint a new toolchain configuration for a language workspace. Discovers available servers (LSP/DAP/BSP/lang) and returns a toolchain ID. Does not start any servers β€” call toolchain_provision to do that.", + "inputSchema": { + "type": "object", + "properties": { + "language": { + "type": "string", + "description": "Primary language for the toolchain (e.g. 'affinescript', 'rust', 'ocaml')" + }, + "workspace_root": { + "type": "string", + "description": "Workspace root directory path" + }, + "servers": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "lsp", + "dap", + "bsp", + "lang" + ] + }, + "description": "Toolserver types to include (default: all available)" + }, + "name": { + "type": "string", + "description": "Human-readable toolchain name" + } + }, + "required": [ + "language", + "workspace_root" + ] + } + }, + { + "name": "toolchain_provision", + "description": "Provision a minted toolchain: start all configured servers (LSP, DAP, BSP, lang), run their initialize handshakes, and return the ready state with each server's capabilities.", + "inputSchema": { + "type": "object", + "properties": { + "toolchain_id": { + "type": "string", + "description": "Toolchain ID from toolchain_mint" + }, + "lsp_command": { + "type": "string", + "description": "LSP server command override (default: derived from language)" + }, + "dap_command": { + "type": "string", + "description": "DAP adapter command override" + }, + "bsp_command": { + "type": "string", + "description": "BSP server command override" + } + }, + "required": [ + "toolchain_id" + ] + } + }, + { + "name": "toolchain_configure", + "description": "Apply configuration settings to a provisioned toolchain. Supports per-server options and cross-cutting settings (formatting, diagnostics throttle, telemetry).", + "inputSchema": { + "type": "object", + "properties": { + "toolchain_id": { + "type": "string", + "description": "Toolchain ID" + }, + "settings": { + "type": "object", + "description": "Configuration map. Keys: lsp, dap, bsp, lang (server-specific sub-objects), plus top-level: format_on_save (bool), diagnostics_debounce_ms (number), telemetry (bool)" + } + }, + "required": [ + "toolchain_id", + "settings" + ] + } + }, + { + "name": "toolchain_status", + "description": "Get the current status of a toolchain or all toolchains. Returns server states, capabilities, and Groove/Burble binding info.", + "inputSchema": { + "type": "object", + "properties": { + "toolchain_id": { + "type": "string", + "description": "Toolchain ID (omit to list all)" + } + }, + "required": [] + } + }, + { + "name": "toolchain_list", + "description": "List all active toolchains with their language, workspace, server states, and any bound Groove endpoints or Burble rooms.", + "inputSchema": { + "type": "object", + "properties": {}, + "required": [] + } + }, + { + "name": "toolchain_groove_bind", + "description": "Bind a provisioned toolchain to a Groove endpoint so PanLL panels can discover and connect to it. Returns the Groove binding descriptor.", + "inputSchema": { + "type": "object", + "properties": { + "toolchain_id": { + "type": "string", + "description": "Toolchain ID to bind" + }, + "groove_endpoint": { + "type": "string", + "description": "Groove endpoint URL (e.g. groove://panel.local/dev/toolchain)" + }, + "panel_id": { + "type": "string", + "description": "Target PanLL panel ID (optional β€” omit to broadcast)" + } + }, + "required": [ + "toolchain_id", + "groove_endpoint" + ] + } + }, + { + "name": "toolchain_burble_session", + "description": "Create or join a Burble collaborative session for a toolchain. Enables real-time shared debugging and pair-programming over the toolchain's LSP/DAP/BSP connections.", + "inputSchema": { + "type": "object", + "properties": { + "toolchain_id": { + "type": "string", + "description": "Toolchain ID" + }, + "room": { + "type": "string", + "description": "Burble room name to join (creates a new room if omitted)" + }, + "role": { + "type": "string", + "enum": [ + "host", + "guest" + ], + "description": "Session role (default: host for new rooms, guest for existing)" + } + }, + "required": [ + "toolchain_id" + ] + } + }, + { + "name": "toolchain_stop", + "description": "Gracefully stop all servers in a toolchain (LSP shutdown/exit, DAP disconnect, BSP build/shutdown) and release the toolchain ID.", + "inputSchema": { + "type": "object", + "properties": { + "toolchain_id": { + "type": "string", + "description": "Toolchain ID to stop" + } + }, + "required": [ + "toolchain_id" + ] + } + } + ], + "ffi": { + "so_path": "ffi/zig-out/lib/libtoolchain_mcp.so", + "abi_version": "ADR-0006", + "symbols": [ + "boj_cartridge_init", + "boj_cartridge_deinit", + "boj_cartridge_name", + "boj_cartridge_version", + "boj_cartridge_invoke" + ] + } +} diff --git a/cartridges/domains/languages/toolchain-mcp/ffi/README.adoc b/cartridges/domains/languages/toolchain-mcp/ffi/README.adoc new file mode 100644 index 0000000..8556a03 --- /dev/null +++ b/cartridges/domains/languages/toolchain-mcp/ffi/README.adoc @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += toolchain-mcp / ffi β€” Zig FFI layer **(stub)** +:orientation: shallow + +== Purpose + +C-ABI exports bridging this cartridge's Idris2 ABI to the runtime. Consumed by +the adapter for protocol dispatch and by the trunk loader for mount. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” produces `zig-out/lib/libtoolchain.so`. +| `toolchain_mcp_ffi.zig` | C-ABI exports (2 exports, 0 inline tests, 15 lines). +|=== + +== Invariants + +* **Null-pointer rejection** on `[*c]const u8` arguments before any + dereference. +* **Negative return values** carry documented error meanings; zero is + success (matching the trunk error-code convention). +* **No allocator leaks** β€” every allocation path pairs with `defer free`. + +== Test/proof surface + +0 inline `test "..."` block(s) in `toolchain_mcp_ffi.zig`. Run with +`cd ffi && zig build test`. + +== Read-first + +. `toolchain_mcp_ffi.zig` β€” C-ABI signatures and guards. +. Trunk `ffi/zig/src/catalogue.zig` for how this `.so` is loaded. + +== Promotion gate + +Wire these exports to real backing state (not `return 0` / `return 1` +placeholders) before promoting past D. diff --git a/cartridges/domains/languages/toolchain-mcp/ffi/build.zig b/cartridges/domains/languages/toolchain-mcp/ffi/build.zig new file mode 100644 index 0000000..9ab3db1 --- /dev/null +++ b/cartridges/domains/languages/toolchain-mcp/ffi/build.zig @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + // Shared ADR-0006 invoke-shim module (relative path up to boj-server trunk). + + const shim_mod = b.addModule("cartridge_shim", .{ + + .root_source_file = b.path("../../../ffi/zig/src/cartridge_shim.zig"), + + .target = target, + + .optimize = optimize, + + }); + + const ffi_mod = b.addModule("toolchain_mcp", .{ + .root_source_file = b.path("toolchain_mcp_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + ffi_mod.addImport("cartridge_shim", shim_mod); + + const lib = b.addLibrary(.{ + .name = "toolchain_mcp", + .root_module = ffi_mod, + .linkage = .dynamic, + }); + lib.linkLibC(); + b.installArtifact(lib); + + const tests = b.addTest(.{ .root_module = ffi_mod }); + tests.linkLibC(); + const run_tests = b.addRunArtifact(tests); + const test_step = b.step("test", "Run FFI tests"); + test_step.dependOn(&run_tests.step); +} diff --git a/cartridges/domains/languages/toolchain-mcp/ffi/cartridge_shim.zig b/cartridges/domains/languages/toolchain-mcp/ffi/cartridge_shim.zig new file mode 100644 index 0000000..0e399db --- /dev/null +++ b/cartridges/domains/languages/toolchain-mcp/ffi/cartridge_shim.zig @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// cartridge_shim.zig β€” Shared helpers for the ADR-0006 five-symbol +// cartridge ABI (`boj_cartridge_init / deinit / name / version / invoke`). +// +// The shim centralises the seven-code return convention, NUL-argument +// guards, tool-name comparison, and the buffer-too-small path so each +// cartridge's `boj_cartridge_invoke` can stay short β€” typically a tool +// table plus `shim.writeResult(...)`. +// +// Cartridges import this file by relative path (no build-graph change +// needed). Example: +// +// const shim = @import("../../../ffi/zig/src/cartridge_shim.zig"); +// +// export fn boj_cartridge_invoke( +// tool_name: [*c]const u8, +// json_args: [*c]const u8, +// out_buf: [*c]u8, +// in_out_len: [*c]usize, +// ) callconv(.c) i32 { +// _ = json_args; +// if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; +// const body = if (shim.toolIs(tool_name, "foo")) "{\"result\":{}}" +// else return shim.RC_UNKNOWN_TOOL; +// return shim.writeResult(out_buf, in_out_len, body); +// } + +const std = @import("std"); + +// ── Return codes (ADR-0006 Β§Return codes) ──────────────────────────── +// +// Frozen by ADR-0006. New failure modes compose these via the error +// JSON body β€” the integer surface does not grow without a follow-up ADR. + +pub const RC_SUCCESS: i32 = 0; +pub const RC_UNKNOWN_TOOL: i32 = -1; +pub const RC_BAD_ARGS: i32 = -2; +pub const RC_BUFFER_TOO_SMALL: i32 = -3; +pub const RC_RUNTIME_ERROR: i32 = -4; +pub const RC_PANIC: i32 = -5; +pub const RC_AUTH_DENIED: i32 = -6; + +// ── Invoke-path helpers ────────────────────────────────────────────── + +/// True if any of the three mandatory `boj_cartridge_invoke` output-path +/// pointers is null. Use at the top of every invoke to short-circuit to +/// `RC_BAD_ARGS`. +pub fn invokeArgsNull( + tool_name: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) bool { + return tool_name == null or out_buf == null or in_out_len == null; +} + +/// Compare a C-NUL-terminated tool-name pointer against a Zig string +/// literal. Caller must have already verified `tool_name` is non-null +/// (usually via `invokeArgsNull`). +pub fn toolIs(tool_name: [*c]const u8, expected: []const u8) bool { + const s = std.mem.span(@as([*:0]const u8, @ptrCast(tool_name))); + return std.mem.eql(u8, s, expected); +} + +/// Copy `body` into `out_buf[0..*in_out_len]` (as a capacity) and update +/// `*in_out_len` to the number of bytes written. Returns `RC_SUCCESS`. +/// +/// If `body.len` exceeds the current capacity stored in `*in_out_len`, +/// sets `*in_out_len` to the required size and returns +/// `RC_BUFFER_TOO_SMALL` β€” the caller is then expected to re-allocate +/// and retry, per ADR-0006 Β§Memory ownership. +/// +/// Caller must have already verified that `out_buf` and `in_out_len` +/// are non-null. +pub fn writeResult( + out_buf: [*c]u8, + in_out_len: [*c]usize, + body: []const u8, +) i32 { + const cap = in_out_len.*; + if (body.len > cap) { + in_out_len.* = body.len; + return RC_BUFFER_TOO_SMALL; + } + @memcpy(out_buf[0..body.len], body); + in_out_len.* = body.len; + return RC_SUCCESS; +} + +// ── Tests ──────────────────────────────────────────────────────────── + +test "writeResult: body fits, writes and sets length" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); + try std.testing.expectEqualStrings("hello", buf[0..len]); +} + +test "writeResult: too small returns -3 and sets required length" { + var buf: [2]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_BUFFER_TOO_SMALL, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: exact-fit succeeds" { + var buf: [5]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: empty body" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, ""); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 0), len); +} + +test "toolIs: matches and rejects" { + const name: [*:0]const u8 = "foo"; + try std.testing.expect(toolIs(@ptrCast(name), "foo")); + try std.testing.expect(!toolIs(@ptrCast(name), "bar")); + try std.testing.expect(!toolIs(@ptrCast(name), "foobar")); + try std.testing.expect(!toolIs(@ptrCast(name), "fo")); +} + +test "invokeArgsNull: detects each null slot" { + var buf: [4]u8 = undefined; + var len: usize = 4; + const name: [*:0]const u8 = "x"; + try std.testing.expect(!invokeArgsNull(@ptrCast(name), &buf, &len)); + try std.testing.expect(invokeArgsNull(null, &buf, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), null, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), &buf, null)); +} diff --git a/cartridges/domains/languages/toolchain-mcp/ffi/toolchain_mcp_ffi.zig b/cartridges/domains/languages/toolchain-mcp/ffi/toolchain_mcp_ffi.zig new file mode 100644 index 0000000..1f4eb3b --- /dev/null +++ b/cartridges/domains/languages/toolchain-mcp/ffi/toolchain_mcp_ffi.zig @@ -0,0 +1,118 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// Toolchain MCP cartridge FFI β€” stub implementation +// Provides language toolchain management (install, configure, switch). + +const std = @import("std"); + +pub export fn toolchain_mcp_version() [*:0]const u8 { + return "0.1.0"; +} + +pub export fn toolchain_mcp_health() c_int { + return 0; +} + +// ═══════════════════════════════════════════════════════════════════════ +// Standard ABI (ADR-0005 four symbols + ADR-0006 invoke) +// ═══════════════════════════════════════════════════════════════════════ + +const shim = @import("cartridge_shim.zig"); + +const CARTRIDGE_NAME_PTR: [*:0]const u8 = "toolchain-mcp"; +const CARTRIDGE_VERSION_PTR: [*:0]const u8 = "0.1.0"; + +export fn boj_cartridge_init() callconv(.c) c_int { + return 0; +} + +export fn boj_cartridge_deinit() callconv(.c) void {} + +export fn boj_cartridge_name() callconv(.c) [*:0]const u8 { + return CARTRIDGE_NAME_PTR; +} + +export fn boj_cartridge_version() callconv(.c) [*:0]const u8 { + return CARTRIDGE_VERSION_PTR; +} + +/// Dispatch the cartridge.json MCP tools. Grade D Alpha stubs. +export fn boj_cartridge_invoke( + tool_name: [*c]const u8, + json_args: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) callconv(.c) i32 { + _ = json_args; + if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; + + const body: []const u8 = if (shim.toolIs(tool_name, "toolchain_mint")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "toolchain_provision")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "toolchain_configure")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "toolchain_status")) + "{\"result\":{\"metadata\":{},\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "toolchain_list")) + "{\"result\":{\"items\":[],\"count\":0,\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "toolchain_groove_bind")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "toolchain_burble_session")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "toolchain_stop")) + "{\"result\":{\"status\":\"stub\"}}" +else + return shim.RC_UNKNOWN_TOOL; + + return shim.writeResult(out_buf, in_out_len, body); +} + +// ═══════════════════════════════════════════════════════════════════════ +// ADR-0006 invoke dispatch tests +// ═══════════════════════════════════════════════════════════════════════ + +test "boj_cartridge_name returns toolchain-mcp" { + const n = std.mem.span(boj_cartridge_name()); + try std.testing.expectEqualStrings("toolchain-mcp", n); +} + +test "boj_cartridge_init returns 0" { + try std.testing.expectEqual(@as(c_int, 0), boj_cartridge_init()); +} + +test "invoke: each declared tool succeeds" { + var buf: [256]u8 = undefined; + const tools = [_][]const u8{ + "toolchain_mint", + "toolchain_provision", + "toolchain_configure", + "toolchain_status", + "toolchain_list", + "toolchain_groove_bind", + "toolchain_burble_session", + "toolchain_stop", + }; + for (tools) |t| { + var len: usize = buf.len; + const rc = boj_cartridge_invoke(t.ptr, "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, 0), rc); + try std.testing.expect(std.mem.indexOf(u8, buf[0..len], "result") != null); + } +} + +test "invoke: unknown tool returns -1" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("nope", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -1), rc); +} + +test "invoke: buffer too small returns -3" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("toolchain_mint", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -3), rc); + try std.testing.expect(len > 4); +} diff --git a/cartridges/domains/languages/toolchain-mcp/mod.js b/cartridges/domains/languages/toolchain-mcp/mod.js new file mode 100644 index 0000000..608db90 --- /dev/null +++ b/cartridges/domains/languages/toolchain-mcp/mod.js @@ -0,0 +1,353 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// toolchain-mcp/mod.js -- Toolchain orchestrator cartridge. +// +// Mints, provisions, and configures language toolchains composed from the +// generic toolserver cartridges: lsp-mcp, dap-mcp, lang-mcp, bsp-mcp. +// Integrates with PanLL panels via Groove and supports collaborative Burble +// sessions for pair-debugging and pair-programming workflows. +// +// Toolchain lifecycle: +// toolchain_mint β†’ toolchain_provision β†’ toolchain_configure +// β†’ (work: LSP/DAP/BSP/lang operations via their own cartridges) +// β†’ toolchain_groove_bind (optional β€” connects to a PanLL panel) +// β†’ toolchain_burble_session (optional β€” starts collaborative session) +// β†’ toolchain_stop +// +// Auth: None required (Groove/Burble auth delegated to those systems). +// +// Usage: import { handleTool } from "./mod.js"; + +// --------------------------------------------------------------------------- +// Language β†’ default server command map +// Operators can override any of these in toolchain_provision. +// --------------------------------------------------------------------------- + +const DEFAULT_COMMANDS = { + affinescript: { lsp: "affinescript server --stdio", dap: null, bsp: null, lang: "affinescript" }, + eclexia: { lsp: "eclexia server --stdio", dap: null, bsp: null, lang: "eclexia" }, + rust: { lsp: "rust-analyzer", dap: "codelldb", bsp: "cargo-bsp",lang: null }, + ocaml: { lsp: "ocamllsp", dap: null, bsp: null, lang: null }, + ephapax: { lsp: "ephapax server --stdio", dap: null, bsp: null, lang: "ephapax" }, + mylang: { lsp: null, dap: null, bsp: null, lang: "mylang" }, + wokelang: { lsp: null, dap: null, bsp: null, lang: "wokelang" }, + julia_the_viper: { lsp: "julia-the-viper server", dap: null, bsp: null, lang: "julia_the_viper" }, +}; + +// --------------------------------------------------------------------------- +// Toolchain registry +// --------------------------------------------------------------------------- + +/** + * Toolchain entry: + * id, language, workspace_root, servers (lsp/dap/bsp/lang flags), + * sessions { lsp_session_id, dap_session_id, bsp_session_id, lang_session_id }, + * state: minted | provisioning | ready | error | stopped, + * config, groove_binding, burble_room + */ +const TOOLCHAINS = new Map(); + +function newToolchainId() { + return `tc_${crypto.randomUUID().replace(/-/g, "").slice(0, 12)}`; +} + +function getToolchain(toolchain_id) { + const tc = TOOLCHAINS.get(toolchain_id); + if (!tc) throw Object.assign(new Error(`Unknown toolchain: ${toolchain_id}`), { status: 404 }); + return tc; +} + +// --------------------------------------------------------------------------- +// BoJ cartridge call helper +// Calls sibling cartridge handlers by importing them dynamically. +// In production the BoJ server routes these; here we simulate via dynamic import. +// --------------------------------------------------------------------------- + +async function callCartridge(cartridgeName, toolName, args) { + try { + const mod = await import(`../${cartridgeName}/mod.js`); + return await mod.handleTool(toolName, args); + } catch (e) { + return { status: 500, data: { error: `Cartridge ${cartridgeName} error: ${e.message}` } }; + } +} + +// --------------------------------------------------------------------------- +// Tool handlers +// --------------------------------------------------------------------------- + +export async function handleTool(toolName, args) { + switch (toolName) { + + // -- toolchain_mint ------------------------------------------------------ + case "toolchain_mint": { + const { language, workspace_root, servers = ["lsp", "dap", "bsp", "lang"], name } = args; + const defaults = DEFAULT_COMMANDS[language] ?? { lsp: null, dap: null, bsp: null, lang: null }; + // Discover which servers are actually available for this language + const available = servers.filter(s => defaults[s] !== null || s === "lang"); + const toolchain_id = newToolchainId(); + TOOLCHAINS.set(toolchain_id, { + id: toolchain_id, + name: name ?? `${language}-toolchain`, + language, + workspace_root, + servers: available, + sessions: {}, + state: "minted", + config: {}, + groove_binding: null, + burble_room: null, + defaults, + }); + return { + status: 200, + data: { + toolchain_id, + language, + workspace_root, + available_servers: available, + message: `Toolchain minted. Call toolchain_provision to start servers.`, + }, + }; + } + + // -- toolchain_provision ------------------------------------------------- + case "toolchain_provision": { + const { toolchain_id, lsp_command, dap_command, bsp_command } = args; + const tc = getToolchain(toolchain_id); + if (tc.state === "ready") return { status: 200, data: { message: "Already provisioned", toolchain_id } }; + tc.state = "provisioning"; + const results = {}; + const errors = []; + + // -- LSP + if (tc.servers.includes("lsp")) { + const cmd = lsp_command ?? tc.defaults.lsp; + if (cmd) { + const startRes = await callCartridge("lsp-mcp", "lsp_start", { command: cmd, workspace_root: tc.workspace_root }); + if (startRes.status === 200) { + const sid = startRes.data.session_id; + const rootUri = tc.workspace_root ? `file://${tc.workspace_root}` : "file:///"; + await callCartridge("lsp-mcp", "lsp_initialize", { session_id: sid, root_uri: rootUri }); + tc.sessions.lsp = sid; + results.lsp = { session_id: sid, status: "started" }; + } else { + errors.push(`lsp: ${startRes.data.error}`); + results.lsp = { status: "failed", error: startRes.data.error }; + } + } + } + + // -- DAP + if (tc.servers.includes("dap")) { + const cmd = dap_command ?? tc.defaults.dap; + if (cmd) { + const startRes = await callCartridge("dap-mcp", "dap_start", { command: cmd, cwd: tc.workspace_root }); + if (startRes.status === 200) { + const sid = startRes.data.session_id; + await callCartridge("dap-mcp", "dap_initialize", { session_id: sid, adapter_id: tc.language }); + tc.sessions.dap = sid; + results.dap = { session_id: sid, status: "started" }; + } else { + errors.push(`dap: ${startRes.data.error}`); + results.dap = { status: "failed", error: startRes.data.error }; + } + } + } + + // -- BSP + if (tc.servers.includes("bsp")) { + const cmd = bsp_command ?? tc.defaults.bsp; + if (cmd) { + const startRes = await callCartridge("bsp-mcp", "bsp_start", { command: cmd, workspace_root: tc.workspace_root }); + if (startRes.status === 200) { + const sid = startRes.data.session_id; + await callCartridge("bsp-mcp", "bsp_initialize", { session_id: sid }); + tc.sessions.bsp = sid; + results.bsp = { session_id: sid, status: "started" }; + } else { + errors.push(`bsp: ${startRes.data.error}`); + results.bsp = { status: "failed", error: startRes.data.error }; + } + } + } + + // -- lang + if (tc.servers.includes("lang") && tc.defaults.lang) { + const createRes = await callCartridge("lang-mcp", "lang_session_create", { + language: tc.defaults.lang, + dialect_mode: "pure", + name: tc.name, + }); + if (createRes.status === 200) { + tc.sessions.lang = createRes.data.session_id; + results.lang = { session_id: createRes.data.session_id, status: "started" }; + } else { + errors.push(`lang: ${createRes.data.error}`); + results.lang = { status: "failed", error: createRes.data.error }; + } + } + + tc.state = errors.length === 0 ? "ready" : (Object.keys(results).length > 0 ? "ready" : "error"); + return { + status: errors.length === 0 ? 200 : 207, + data: { toolchain_id, state: tc.state, servers: results, errors }, + }; + } + + // -- toolchain_configure ------------------------------------------------- + case "toolchain_configure": { + const { toolchain_id, settings } = args; + const tc = getToolchain(toolchain_id); + // Merge settings into config β€” deep merge one level + for (const [k, v] of Object.entries(settings)) { + tc.config[k] = typeof v === "object" && !Array.isArray(v) + ? { ...(tc.config[k] ?? {}), ...v } + : v; + } + return { status: 200, data: { toolchain_id, config: tc.config } }; + } + + // -- toolchain_status ---------------------------------------------------- + case "toolchain_status": { + const { toolchain_id } = args; + if (toolchain_id) { + const tc = getToolchain(toolchain_id); + return { + status: 200, + data: { + toolchain_id: tc.id, + name: tc.name, + language: tc.language, + workspace_root: tc.workspace_root, + state: tc.state, + sessions: tc.sessions, + groove_binding: tc.groove_binding, + burble_room: tc.burble_room, + config: tc.config, + }, + }; + } + // All toolchains + const all = [...TOOLCHAINS.values()].map(tc => ({ + toolchain_id: tc.id, + name: tc.name, + language: tc.language, + state: tc.state, + groove_binding: tc.groove_binding, + burble_room: tc.burble_room, + })); + return { status: 200, data: { toolchains: all, count: all.length } }; + } + + // -- toolchain_list ------------------------------------------------------ + case "toolchain_list": { + const all = [...TOOLCHAINS.values()].map(tc => ({ + toolchain_id: tc.id, + name: tc.name, + language: tc.language, + workspace_root: tc.workspace_root, + state: tc.state, + servers: tc.servers, + groove_binding: tc.groove_binding, + burble_room: tc.burble_room, + })); + return { status: 200, data: { toolchains: all, count: all.length } }; + } + + // -- toolchain_groove_bind ----------------------------------------------- + case "toolchain_groove_bind": { + const { toolchain_id, groove_endpoint, panel_id } = args; + const tc = getToolchain(toolchain_id); + // Register the toolchain with the Groove endpoint. + // The Groove protocol is universal plug-and-play; we emit a binding descriptor + // that Groove-aware panels can discover. Actual Groove handshake is handled + // by the Groove cartridge when it is present. + tc.groove_binding = { + endpoint: groove_endpoint, + panel_id: panel_id ?? null, + bound_at: new Date().toISOString(), + descriptor: { + type: "toolchain", + id: toolchain_id, + language: tc.language, + capabilities: Object.keys(tc.sessions), + workspace_root: tc.workspace_root, + }, + }; + return { + status: 200, + data: { + toolchain_id, + groove_binding: tc.groove_binding, + message: `Toolchain bound to Groove endpoint: ${groove_endpoint}`, + }, + }; + } + + // -- toolchain_burble_session -------------------------------------------- + case "toolchain_burble_session": { + const { toolchain_id, room, role } = args; + const tc = getToolchain(toolchain_id); + // Burble sessions enable collaborative pair-debugging / pair-programming. + // We generate a room name and record the binding. The Burble cartridge + // (when present) handles the actual WebRTC signalling. + const roomName = room ?? `toolchain-${toolchain_id.slice(0, 8)}-${Date.now()}`; + const sessionRole = role ?? (room ? "guest" : "host"); + tc.burble_room = { + room: roomName, + role: sessionRole, + created_at: new Date().toISOString(), + toolchain_id, + language: tc.language, + shared_sessions: { ...tc.sessions }, + }; + return { + status: 200, + data: { + toolchain_id, + room: roomName, + role: sessionRole, + message: `Burble ${sessionRole === "host" ? "room created" : "room joined"}: ${roomName}`, + burble_binding: tc.burble_room, + }, + }; + } + + // -- toolchain_stop ------------------------------------------------------ + case "toolchain_stop": { + const { toolchain_id } = args; + const tc = getToolchain(toolchain_id); + const results = {}; + + if (tc.sessions.lsp) { + const r = await callCartridge("lsp-mcp", "lsp_stop", { session_id: tc.sessions.lsp }); + results.lsp = r.status === 200 ? "stopped" : r.data.error; + delete tc.sessions.lsp; + } + if (tc.sessions.dap) { + const r = await callCartridge("dap-mcp", "dap_stop", { session_id: tc.sessions.dap, terminate_debuggee: true }); + results.dap = r.status === 200 ? "stopped" : r.data.error; + delete tc.sessions.dap; + } + if (tc.sessions.bsp) { + const r = await callCartridge("bsp-mcp", "bsp_stop", { session_id: tc.sessions.bsp }); + results.bsp = r.status === 200 ? "stopped" : r.data.error; + delete tc.sessions.bsp; + } + if (tc.sessions.lang) { + const r = await callCartridge("lang-mcp", "lang_session_close", { session_id: tc.sessions.lang }); + results.lang = r.status === 200 ? "stopped" : r.data.error; + delete tc.sessions.lang; + } + + tc.state = "stopped"; + TOOLCHAINS.delete(toolchain_id); + return { status: 200, data: { toolchain_id, stopped: true, servers: results } }; + } + + default: + return { status: 404, data: { error: `Unknown tool: ${toolName}` } }; + } +} diff --git a/cartridges/domains/languages/typed-wasm-mcp/README.adoc b/cartridges/domains/languages/typed-wasm-mcp/README.adoc new file mode 100644 index 0000000..d327f16 --- /dev/null +++ b/cartridges/domains/languages/typed-wasm-mcp/README.adoc @@ -0,0 +1,70 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += typed-wasm-mcp +Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk> +:spdx: MPL-2.0 +:tier: Ayo +:domain: Compiler +:protocols: MCP, REST + +== Overview + +AffineScript typed-wasm gateway. Compiles `.affine` sources to Wasm with the +typed-wasm Level 7/10 ownership contract, runs the intra-module verifier +(`Tw_verify`), and runs the cross-module Level 10 boundary verifier +(`Tw_interface`) between two compiled modules. + +Inputs are always `.affine` source paths β€” there is no path that ingests +arbitrary `.wasm`, because the verifier is meaningful only against a Wasm +module that the AffineScript compiler emitted with an +`affinescript.ownership` custom section. + +== Tools (4) + +[cols="2,4"] +|=== +| Tool | Description + +| `typed_wasm_validate_module` | Compile a `.affine` source and run the intra-module Level 7/10 verifier against the emitted Wasm. Returns `valid=true` when every `[own]` param is consumed exactly once on every path and every `[mut]` param is exclusively held. +| `typed_wasm_check_types` | Run the AffineScript type checker on a `.affine` source. Reports type errors only β€” no Wasm emission, no ownership verification. +| `typed_wasm_compile_module` | Compile a `.affine` source to `.wasm`. The emitted module carries the `[affinescript.ownership]` custom section that downstream verifiers consume. Optional `output_path`; defaults to the source path with `.wasm` extension. +| `typed_wasm_verify_boundary` | Cross-module Level 10 boundary check. Compiles a callee/caller pair and verifies that the caller's local functions invoke each Linear-param import exactly once per execution path. +|=== + +The gateway shells out to the `affinescript` binary on `PATH` (override via +the `AFFINESCRIPT_BIN` env var). Boundary verification depends on the +cross-module import emission landed in affinescript on 2026-05-03. + +== Architecture + +Three-layer stack per the BoJ cartridge standard: + +[cols="1,1,3"] +|=== +| Layer | Language | Purpose + +| ABI | Idris2 | Formally verified state machine with dependent-type proofs +| FFI | Zig | C-ABI implementation: session pool, action metrics, thread safety +| Adapter| Zig | Adapter bridge to the BoJ unified adapter protocol (zig predecessors sidelined) +|=== + +== Building + +[source,bash] +---- +# ABI (Idris2) +cd abi && idris2 --check *.idr + +# FFI (Zig) +cd ffi && zig build +cd ffi && zig build test + +# Adapter (Zig) +cd adapter && zig build +---- + +== Status + +Alpha (CRG D). Cartridge metadata declared in `cartridge.json`; 3 tool(s) exposed. +Not yet dogfooded externally. See `docs/READINESS.md` at repo root for the +project-wide grade table and `docs/practice/DOGFOOD-LOG.adoc` for usage evidence. diff --git a/cartridges/domains/languages/typed-wasm-mcp/abi/README.adoc b/cartridges/domains/languages/typed-wasm-mcp/abi/README.adoc new file mode 100644 index 0000000..79a61a2 --- /dev/null +++ b/cartridges/domains/languages/typed-wasm-mcp/abi/README.adoc @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += typed-wasm-mcp / abi β€” Idris2 ABI layer +:orientation: shallow + +== Purpose + +Cartridge-local protocol types for `typed-wasm-mcp`. Defines the data, tool, and +transition vocabulary this cartridge speaks. Inherits safety lemmas and the +matrix-cell identifier scheme from the trunk ABI at `src/abi/`. + +== Files + +1 Idris2 module(s), ~50 lines total. Lead module: +`Protocol.idr`. + +No `.ipkg` β€” consumed directly by wider BoJ callers; type-checks inside the +trunk `boj.ipkg`. + +== Invariants + +* `%default total` at every module top. +* Zero `believe_me`, `assert_total`, `postulate`, `Admitted`, or + `sorry` (the trunk holds the four declared `believe_me` sites; leaf + cartridges carry none). + +== Test/proof surface + +Thin module(s), no inline tests. Proof surface = type-check success under the +trunk `idris2 --check`. + +== Read-first + +. `Protocol.idr` β€” cartridge-local data records and enums. +. Trunk `src/abi/Boj/Catalogue.idr` for how this cell participates in the + 2D matrix. diff --git a/cartridges/domains/languages/typed-wasm-mcp/abi/TypedWasm/Protocol.idr b/cartridges/domains/languages/typed-wasm-mcp/abi/TypedWasm/Protocol.idr new file mode 100644 index 0000000..ba36514 --- /dev/null +++ b/cartridges/domains/languages/typed-wasm-mcp/abi/TypedWasm/Protocol.idr @@ -0,0 +1,50 @@ +-- SPDX-License-Identifier: MPL-2.0 +-- Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +-- +-- TypedWasm ABI β€” WASM type safety protocol definitions. + +module TypedWasm.Protocol + +import Data.Nat + +||| TypedWasm operation codes. +public export +data TypedWasmOp + = ValidateModule + | CheckTypes + | CompileModule + +||| Type safety levels (0-10 scale from TypeLL). +public export +record SafetyLevel where + constructor MkSafetyLevel + level : Nat + {auto prf : LTE level 10} + +||| Module validation result. +public export +data ValidationResult + = ModuleValid SafetyLevel + | ModuleInvalid (List String) + +||| Type check result with error count. +public export +record TypeCheckResult where + constructor MkTypeCheckResult + errors : Nat + warnings : Nat + level : SafetyLevel + +||| Compilation target. +public export +data CompileTarget = WasmMVP | WasmSIMD | WasmThreads + +||| Proof: safety level is always within the 0-10 range. +export +safetyBounded : (s : SafetyLevel) -> LTE s.level 10 +safetyBounded s = s.prf + +||| Proof: a module with zero errors at max level is maximally safe. +export +maxSafety : SafetyLevel +maxSafety = MkSafetyLevel 10 diff --git a/cartridges/domains/languages/typed-wasm-mcp/adapter/README.adoc b/cartridges/domains/languages/typed-wasm-mcp/adapter/README.adoc new file mode 100644 index 0000000..64b29b0 --- /dev/null +++ b/cartridges/domains/languages/typed-wasm-mcp/adapter/README.adoc @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += typed-wasm-mcp / adapter β€” protocol bridge +:orientation: shallow + +== Purpose + +Exposes this cartridge's FFI over the wire protocols declared in +`cartridge.json`. Dispatches tool invocations to the C-ABI exports at +`../ffi/` and formats responses per protocol. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” depends on the sibling `ffi` target. +| `typed_wasm_adapter.zig` | Protocol dispatch (118 lines). Tool catalogue matches `cartridge.json`. +| `SIDELINED-*.v.adoc` | zig predecessor(s). Not built β€” zig banned 2026-04-10. +|=== + +== Invariants + +* **Stateless** β€” all state lives behind the FFI, never in the adapter. +* **Response passthrough** β€” whatever the FFI returns goes back to the wire + unmodified (no embellishment, no silent recovery). +* **Cartridge.json is source of truth** for the tool catalogue; drift between + adapter and manifest is a CI failure. + +== Test/proof surface + +No inline tests at the adapter layer β€” protocol routing is exercised by the +trunk seam tests at `ffi/zig/src/seams.zig`. + +== Read-first + +. `typed_wasm_adapter.zig` β€” dispatch function and per-protocol routers. +. `../cartridge.json` β€” the tool manifest the dispatcher mirrors. diff --git a/cartridges/domains/languages/typed-wasm-mcp/adapter/build.zig b/cartridges/domains/languages/typed-wasm-mcp/adapter/build.zig new file mode 100644 index 0000000..7728dae --- /dev/null +++ b/cartridges/domains/languages/typed-wasm-mcp/adapter/build.zig @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// typed-wasm-mcp/adapter/build.zig + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + const ffi_mod = b.createModule(.{ + .root_source_file = b.path("../ffi/typed_wasm_ffi.zig"), + .target = target, .optimize = optimize, + }); + const adapter = b.addExecutable(.{ + .name = "typed_wasm_adapter", + .root_source_file = b.path("typed_wasm_adapter.zig"), + .target = target, .optimize = optimize, + }); + adapter.root_module.addImport("typed_wasm_ffi", ffi_mod); + b.installArtifact(adapter); + const run_step = b.step("run", "Run the typed-wasm-mcp adapter"); + run_step.dependOn(&b.addRunArtifact(adapter).step); + const tests = b.addTest(.{ + .root_source_file = b.path("typed_wasm_adapter.zig"), + .target = target, .optimize = optimize, + }); + tests.root_module.addImport("typed_wasm_ffi", ffi_mod); + const test_step = b.step("test", "Run typed-wasm-mcp adapter tests"); + test_step.dependOn(&b.addRunArtifact(tests).step); +} diff --git a/cartridges/domains/languages/typed-wasm-mcp/adapter/typed_wasm_adapter.zig b/cartridges/domains/languages/typed-wasm-mcp/adapter/typed_wasm_adapter.zig new file mode 100644 index 0000000..a9899ab --- /dev/null +++ b/cartridges/domains/languages/typed-wasm-mcp/adapter/typed_wasm_adapter.zig @@ -0,0 +1,118 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// typed-wasm-mcp/adapter/typed_wasm_adapter.zig -- Unified three-protocol adapter. +// +// Replaces the banned typed_wasm_adapter.v (zig, removed 2026-04-12). +// +// REST :9124 gRPC-compat :9125 GraphQL :9126 +// Typed WASM module verifier and compiler. Validates WebAssembly type safety and compiles with type-le +// Tools: typed_wasm_validate_module, typed_wasm_check_types, typed_wasm_compile_module + +const std = @import("std"); +const ffi = @import("typed_wasm_ffi"); + +const REST_PORT: u16 = 9124; +const GRPC_PORT: u16 = 9125; +const GQL_PORT: u16 = 9126; +const MAX_CONN_BUF: usize = 16 * 1024; + +fn okJson(buf: []u8, msg: []const u8) []u8 { + const n = std.fmt.bufPrint(buf, "{{\"success\":true,\"message\":\"{s}\"}}", .{msg}) catch return buf[0..0]; + return n; +} +fn errJson(buf: []u8, msg: []const u8) []u8 { + const n = std.fmt.bufPrint(buf, "{{\"success\":false,\"error\":\"{s}\"}}", .{msg}) catch return buf[0..0]; + return n; +} +fn statusJson(buf: []u8) []u8 { + const n = std.fmt.bufPrint(buf, "{{\"success\":true,\"state\":\"ready\",\"service\":\"typed-wasm-mcp\"}}", .{}) catch return buf[0..0]; + return n; +} + +const Response = struct { status: u16, body: []u8 }; + +fn dispatch(tool: []const u8, body: []const u8, resp: []u8) Response { + _ = body; + if (std.mem.eql(u8, tool, "typed_wasm_validate_module")) return .{ .status = 200, .body = okJson(resp, "typed_wasm_validate_module forwarded") }; + if (std.mem.eql(u8, tool, "typed_wasm_check_types")) return .{ .status = 200, .body = okJson(resp, "typed_wasm_check_types forwarded") }; + if (std.mem.eql(u8, tool, "typed_wasm_compile_module")) return .{ .status = 200, .body = okJson(resp, "typed_wasm_compile_module forwarded") }; + if (std.mem.eql(u8, tool, "status") or std.mem.eql(u8, tool, "health")) + return .{ .status = 200, .body = statusJson(resp) }; + return .{ .status = 404, .body = errJson(resp, "Unknown tool") }; +} + +fn dispatchRest(path: []const u8, body: []const u8, resp: []u8) Response { + if (std.mem.startsWith(u8, path, "/tools/")) return dispatch(path["/tools/".len..], body, resp); + if (std.mem.eql(u8, path, "/status") or std.mem.eql(u8, path, "/health")) return .{ .status = 200, .body = statusJson(resp) }; + return .{ .status = 404, .body = errJson(resp, "Not found") }; +} + +fn dispatchGrpc(path: []const u8, body: []const u8, resp: []u8) Response { + const prefix = "/TypedWasmservice/"; + if (!std.mem.startsWith(u8, path, prefix)) + return .{ .status = 404, .body = errJson(resp, "Not a recognized gRPC path") }; + const method = path[prefix.len..]; + const tool = blk: { + if (std.mem.eql(u8, method, "typed_wasm_validate_module")) break :blk "typed_wasm_validate_module"; + if (std.mem.eql(u8, method, "typed_wasm_check_types")) break :blk "typed_wasm_check_types"; + if (std.mem.eql(u8, method, "typed_wasm_compile_module")) break :blk "typed_wasm_compile_module"; + return .{ .status = 404, .body = errJson(resp, "Unknown gRPC method") }; + }; + return dispatch(tool, body, resp); +} + +fn dispatchGraphql(body: []const u8, resp: []u8) Response { + if (std.mem.indexOf(u8, body, "__schema") != null) return .{ .status = 200, .body = okJson(resp, "schema not supported") }; + if (std.mem.indexOf(u8, body, "wasm_validate_module") != null) return dispatch("typed_wasm_validate_module", body, resp); + if (std.mem.indexOf(u8, body, "wasm_check_types") != null) return dispatch("typed_wasm_check_types", body, resp); + if (std.mem.indexOf(u8, body, "wasm_compile_module") != null) return dispatch("typed_wasm_compile_module", body, resp); + return .{ .status = 200, .body = errJson(resp, "Unrecognised GraphQL operation") }; +} + +const Protocol = enum { rest, grpc, graphql }; + +fn handleConnection(conn: std.net.Server.Connection, proto: Protocol) void { + defer conn.stream.close(); + var in_buf: [MAX_CONN_BUF]u8 = undefined; + const n = conn.stream.read(&in_buf) catch return; + const req = in_buf[0..n]; + var path: []const u8 = "/"; + var body: []const u8 = ""; + if (n > 4) { + const line_end = std.mem.indexOf(u8, req, "\r\n") orelse req.len; + const first_line = req[0..line_end]; + const sp1 = std.mem.indexOfScalar(u8, first_line, ' ') orelse 0; + const rest_of = first_line[sp1 + 1 ..]; + const sp2 = std.mem.indexOfScalar(u8, rest_of, ' ') orelse rest_of.len; + path = rest_of[0..sp2]; + const body_sep = std.mem.indexOf(u8, req, "\r\n\r\n") orelse n; + body = req[@min(body_sep + 4, n)..]; + } + var resp_buf: [MAX_CONN_BUF]u8 = undefined; + const result = switch (proto) { + .rest => dispatchRest(path, body, &resp_buf), + .grpc => dispatchGrpc(path, body, &resp_buf), + .graphql => dispatchGraphql(body, &resp_buf), + }; + const ct: []const u8 = if (proto == .grpc) "application/grpc+json" else "application/json"; + var hdr_buf: [256]u8 = undefined; + const hdr = std.fmt.bufPrint(&hdr_buf, "HTTP/1.1 {d} OK\r\nContent-Type: {s}\r\nContent-Length: {d}\r\nConnection: close\r\n\r\n", .{ result.status, ct, result.body.len }) catch return; + _ = conn.stream.writeAll(hdr) catch return; + _ = conn.stream.writeAll(result.body) catch return; +} + +fn listenLoop(port: u16, proto: Protocol) void { + const addr = std.net.Address.initIp4(.{ 127, 0, 0, 1 }, port) catch return; + var server = addr.listen(.{ .reuse_address = true }) catch return; + defer server.deinit(); + while (true) { const conn = server.accept() catch continue; handleConnection(conn, proto); } +} + +pub fn main() !void { + ffi.typed_wasm_init(); + const t1 = try std.Thread.spawn(.{}, listenLoop, .{ REST_PORT, Protocol.rest }); + const t2 = try std.Thread.spawn(.{}, listenLoop, .{ GRPC_PORT, Protocol.grpc }); + const t3 = try std.Thread.spawn(.{}, listenLoop, .{ GQL_PORT, Protocol.graphql }); + t1.join(); t2.join(); t3.join(); +} diff --git a/cartridges/domains/languages/typed-wasm-mcp/cartridge.json b/cartridges/domains/languages/typed-wasm-mcp/cartridge.json new file mode 100644 index 0000000..499f0b5 --- /dev/null +++ b/cartridges/domains/languages/typed-wasm-mcp/cartridge.json @@ -0,0 +1,109 @@ +{ + "$schema": "https://boj.dev/schemas/cartridge/v1.json", + "spdx": "MPL-2.0", + "copyright": "Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath)", + "name": "typed-wasm-mcp", + "version": "0.2.0", + "description": "AffineScript typed-wasm gateway. Compiles .affine source to Wasm with the typed-wasm Level 7/10 ownership contract, runs the intra-module verifier, and runs the cross-module boundary verifier between two compiled modules.", + "domain": "Compiler", + "tier": "Ayo", + "protocols": [ + "MCP", + "REST" + ], + "auth": { + "method": "none", + "env_var": null, + "credential_source": null + }, + "api": { + "base_url": "local://typed-wasm-mcp", + "content_type": "application/json" + }, + "tools": [ + { + "name": "typed_wasm_validate_module", + "description": "Compile a .affine source and run the intra-module typed-wasm Level 7/10 verifier (Tw_verify) against the emitted Wasm. Returns valid=true when every [own] param is consumed exactly once on every path and every [mut] param is exclusively held; otherwise valid=false with a per-violation report.", + "inputSchema": { + "type": "object", + "properties": { + "module_path": { + "type": "string", + "description": "Path to the .affine source file" + } + }, + "required": [ + "module_path" + ] + } + }, + { + "name": "typed_wasm_check_types", + "description": "Run the AffineScript type checker on a .affine source. Reports type errors without emitting Wasm or running the ownership verifier.", + "inputSchema": { + "type": "object", + "properties": { + "module_path": { + "type": "string", + "description": "Path to the .affine source file" + } + }, + "required": [ + "module_path" + ] + } + }, + { + "name": "typed_wasm_compile_module", + "description": "Compile a .affine source to Wasm. The emitted module carries the [affinescript.ownership] custom section that downstream typed-wasm verifiers consume.", + "inputSchema": { + "type": "object", + "properties": { + "module_path": { + "type": "string", + "description": "Path to the .affine source file" + }, + "output_path": { + "type": "string", + "description": "Optional path for the .wasm output. Defaults to <module_path with .wasm extension>." + } + }, + "required": [ + "module_path" + ] + } + }, + { + "name": "typed_wasm_verify_boundary", + "description": "Run the cross-module typed-wasm Level 10 boundary verifier on a callee/caller pair. Compiles both .affine sources, extracts the callee's ownership-annotated export interface, and checks that every local function in the caller invokes each Linear-param import exactly once per execution path. Returns clean=true on success; otherwise clean=false with the boundary violations.", + "inputSchema": { + "type": "object", + "properties": { + "callee_path": { + "type": "string", + "description": "Path to the .affine source for the exporter (whose pub fns define the contract)" + }, + "caller_path": { + "type": "string", + "description": "Path to the .affine source for the importer (whose function bodies are checked against the callee's contract)" + } + }, + "required": [ + "callee_path", + "caller_path" + ] + } + } + ], + "ffi": { + "so_path": "ffi/zig-out/lib/libtyped_wasm_mcp.so", + "abi_version": "ADR-0006", + "symbols": [ + "boj_cartridge_init", + "boj_cartridge_deinit", + "boj_cartridge_name", + "boj_cartridge_version", + "boj_cartridge_invoke" + ] + } +} diff --git a/cartridges/domains/languages/typed-wasm-mcp/ffi/README.adoc b/cartridges/domains/languages/typed-wasm-mcp/ffi/README.adoc new file mode 100644 index 0000000..849128f --- /dev/null +++ b/cartridges/domains/languages/typed-wasm-mcp/ffi/README.adoc @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += typed-wasm-mcp / ffi β€” Zig FFI layer **(stub)** +:orientation: shallow + +== Purpose + +C-ABI exports bridging this cartridge's Idris2 ABI to the runtime. Consumed by +the adapter for protocol dispatch and by the trunk loader for mount. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” produces `zig-out/lib/libtyped-wasm.so`. +| `typed_wasm_ffi.zig` | C-ABI exports (3 exports, 3 inline tests, 40 lines). +|=== + +== Invariants + +* **Null-pointer rejection** on `[*c]const u8` arguments before any + dereference. +* **Negative return values** carry documented error meanings; zero is + success (matching the trunk error-code convention). +* **No allocator leaks** β€” every allocation path pairs with `defer free`. + +== Test/proof surface + +3 inline `test "..."` block(s) in `typed_wasm_ffi.zig`. Run with +`cd ffi && zig build test`. + +== Read-first + +. `typed_wasm_ffi.zig` β€” C-ABI signatures and guards. +. Trunk `ffi/zig/src/catalogue.zig` for how this `.so` is loaded. + +== Promotion gate + +Wire these exports to real backing state (not `return 0` / `return 1` +placeholders) before promoting past D. diff --git a/cartridges/domains/languages/typed-wasm-mcp/ffi/build.zig b/cartridges/domains/languages/typed-wasm-mcp/ffi/build.zig new file mode 100644 index 0000000..c89d2a3 --- /dev/null +++ b/cartridges/domains/languages/typed-wasm-mcp/ffi/build.zig @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + // Shared ADR-0006 invoke-shim module (relative path up to boj-server trunk). + + const shim_mod = b.addModule("cartridge_shim", .{ + + .root_source_file = b.path("../../../ffi/zig/src/cartridge_shim.zig"), + + .target = target, + + .optimize = optimize, + + }); + + const ffi_mod = b.addModule("typed_wasm_mcp", .{ + .root_source_file = b.path("typed_wasm_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + ffi_mod.addImport("cartridge_shim", shim_mod); + + const lib = b.addLibrary(.{ + .name = "typed_wasm_mcp", + .root_module = ffi_mod, + .linkage = .dynamic, + }); + lib.linkLibC(); + b.installArtifact(lib); + + const tests = b.addTest(.{ .root_module = ffi_mod }); + tests.linkLibC(); + const run_tests = b.addRunArtifact(tests); + const test_step = b.step("test", "Run FFI tests"); + test_step.dependOn(&run_tests.step); +} + diff --git a/cartridges/domains/languages/typed-wasm-mcp/ffi/cartridge_shim.zig b/cartridges/domains/languages/typed-wasm-mcp/ffi/cartridge_shim.zig new file mode 100644 index 0000000..0e399db --- /dev/null +++ b/cartridges/domains/languages/typed-wasm-mcp/ffi/cartridge_shim.zig @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// cartridge_shim.zig β€” Shared helpers for the ADR-0006 five-symbol +// cartridge ABI (`boj_cartridge_init / deinit / name / version / invoke`). +// +// The shim centralises the seven-code return convention, NUL-argument +// guards, tool-name comparison, and the buffer-too-small path so each +// cartridge's `boj_cartridge_invoke` can stay short β€” typically a tool +// table plus `shim.writeResult(...)`. +// +// Cartridges import this file by relative path (no build-graph change +// needed). Example: +// +// const shim = @import("../../../ffi/zig/src/cartridge_shim.zig"); +// +// export fn boj_cartridge_invoke( +// tool_name: [*c]const u8, +// json_args: [*c]const u8, +// out_buf: [*c]u8, +// in_out_len: [*c]usize, +// ) callconv(.c) i32 { +// _ = json_args; +// if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; +// const body = if (shim.toolIs(tool_name, "foo")) "{\"result\":{}}" +// else return shim.RC_UNKNOWN_TOOL; +// return shim.writeResult(out_buf, in_out_len, body); +// } + +const std = @import("std"); + +// ── Return codes (ADR-0006 Β§Return codes) ──────────────────────────── +// +// Frozen by ADR-0006. New failure modes compose these via the error +// JSON body β€” the integer surface does not grow without a follow-up ADR. + +pub const RC_SUCCESS: i32 = 0; +pub const RC_UNKNOWN_TOOL: i32 = -1; +pub const RC_BAD_ARGS: i32 = -2; +pub const RC_BUFFER_TOO_SMALL: i32 = -3; +pub const RC_RUNTIME_ERROR: i32 = -4; +pub const RC_PANIC: i32 = -5; +pub const RC_AUTH_DENIED: i32 = -6; + +// ── Invoke-path helpers ────────────────────────────────────────────── + +/// True if any of the three mandatory `boj_cartridge_invoke` output-path +/// pointers is null. Use at the top of every invoke to short-circuit to +/// `RC_BAD_ARGS`. +pub fn invokeArgsNull( + tool_name: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) bool { + return tool_name == null or out_buf == null or in_out_len == null; +} + +/// Compare a C-NUL-terminated tool-name pointer against a Zig string +/// literal. Caller must have already verified `tool_name` is non-null +/// (usually via `invokeArgsNull`). +pub fn toolIs(tool_name: [*c]const u8, expected: []const u8) bool { + const s = std.mem.span(@as([*:0]const u8, @ptrCast(tool_name))); + return std.mem.eql(u8, s, expected); +} + +/// Copy `body` into `out_buf[0..*in_out_len]` (as a capacity) and update +/// `*in_out_len` to the number of bytes written. Returns `RC_SUCCESS`. +/// +/// If `body.len` exceeds the current capacity stored in `*in_out_len`, +/// sets `*in_out_len` to the required size and returns +/// `RC_BUFFER_TOO_SMALL` β€” the caller is then expected to re-allocate +/// and retry, per ADR-0006 Β§Memory ownership. +/// +/// Caller must have already verified that `out_buf` and `in_out_len` +/// are non-null. +pub fn writeResult( + out_buf: [*c]u8, + in_out_len: [*c]usize, + body: []const u8, +) i32 { + const cap = in_out_len.*; + if (body.len > cap) { + in_out_len.* = body.len; + return RC_BUFFER_TOO_SMALL; + } + @memcpy(out_buf[0..body.len], body); + in_out_len.* = body.len; + return RC_SUCCESS; +} + +// ── Tests ──────────────────────────────────────────────────────────── + +test "writeResult: body fits, writes and sets length" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); + try std.testing.expectEqualStrings("hello", buf[0..len]); +} + +test "writeResult: too small returns -3 and sets required length" { + var buf: [2]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_BUFFER_TOO_SMALL, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: exact-fit succeeds" { + var buf: [5]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: empty body" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, ""); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 0), len); +} + +test "toolIs: matches and rejects" { + const name: [*:0]const u8 = "foo"; + try std.testing.expect(toolIs(@ptrCast(name), "foo")); + try std.testing.expect(!toolIs(@ptrCast(name), "bar")); + try std.testing.expect(!toolIs(@ptrCast(name), "foobar")); + try std.testing.expect(!toolIs(@ptrCast(name), "fo")); +} + +test "invokeArgsNull: detects each null slot" { + var buf: [4]u8 = undefined; + var len: usize = 4; + const name: [*:0]const u8 = "x"; + try std.testing.expect(!invokeArgsNull(@ptrCast(name), &buf, &len)); + try std.testing.expect(invokeArgsNull(null, &buf, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), null, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), &buf, null)); +} diff --git a/cartridges/domains/languages/typed-wasm-mcp/ffi/typed_wasm_ffi.zig b/cartridges/domains/languages/typed-wasm-mcp/ffi/typed_wasm_ffi.zig new file mode 100644 index 0000000..01eb619 --- /dev/null +++ b/cartridges/domains/languages/typed-wasm-mcp/ffi/typed_wasm_ffi.zig @@ -0,0 +1,128 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +// +// TypedWasm FFI β€” C-compatible exports for WASM type safety validation. + +const std = @import("std"); + +/// Validate a WASM module. Returns safety level 0-10, or 255 on error. +export fn typed_wasm_validate_module(module_path: [*c]const u8) u8 { + if (module_path == null) return 255; + return 5; // Stub β€” mid-level safety +} + +/// Check types in a module. Returns error count. +export fn typed_wasm_check_types(module_path: [*c]const u8) u32 { + if (module_path == null) return 1; + return 0; // Stub β€” no errors +} + +/// Compile a module. Returns 0 on success, -1 on error. +export fn typed_wasm_compile_module(module_path: [*c]const u8, target: u8) i32 { + if (module_path == null) return -1; + if (target > 2) return -1; // Invalid target + return 0; // Stub +} + +// ═══════════════════════════════════════════════════════════════════════ +// Standard ABI (ADR-0005 four symbols + ADR-0006 invoke) +// ═══════════════════════════════════════════════════════════════════════ + +const shim = @import("cartridge_shim.zig"); + +const CARTRIDGE_NAME_PTR: [*:0]const u8 = "typed-wasm-mcp"; +const CARTRIDGE_VERSION_PTR: [*:0]const u8 = "0.1.0"; + +export fn boj_cartridge_init() callconv(.c) c_int { + return 0; +} + +export fn boj_cartridge_deinit() callconv(.c) void {} + +export fn boj_cartridge_name() callconv(.c) [*:0]const u8 { + return CARTRIDGE_NAME_PTR; +} + +export fn boj_cartridge_version() callconv(.c) [*:0]const u8 { + return CARTRIDGE_VERSION_PTR; +} + +/// Dispatch the cartridge.json MCP tools. Grade D Alpha stubs. +export fn boj_cartridge_invoke( + tool_name: [*c]const u8, + json_args: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) callconv(.c) i32 { + _ = json_args; + if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; + + const body: []const u8 = if (shim.toolIs(tool_name, "typed_wasm_validate_module")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "typed_wasm_check_types")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "typed_wasm_compile_module")) + "{\"result\":{\"status\":\"stub\"}}" +else + return shim.RC_UNKNOWN_TOOL; + + return shim.writeResult(out_buf, in_out_len, body); +} + +// ── Tests ── + +test "validate rejects null path" { + try std.testing.expectEqual(@as(u8, 255), typed_wasm_validate_module(null)); +} + +test "validate returns bounded level" { + const level = typed_wasm_validate_module("test.wasm"); + try std.testing.expect(level <= 10); +} + +test "compile rejects invalid target" { + try std.testing.expectEqual(@as(i32, -1), typed_wasm_compile_module("test.wasm", 99)); +} + +// ═══════════════════════════════════════════════════════════════════════ +// ADR-0006 invoke dispatch tests +// ═══════════════════════════════════════════════════════════════════════ + +test "boj_cartridge_name returns typed-wasm-mcp" { + const n = std.mem.span(boj_cartridge_name()); + try std.testing.expectEqualStrings("typed-wasm-mcp", n); +} + +test "boj_cartridge_init returns 0" { + try std.testing.expectEqual(@as(c_int, 0), boj_cartridge_init()); +} + +test "invoke: each declared tool succeeds" { + var buf: [256]u8 = undefined; + const tools = [_][]const u8{ + "typed_wasm_validate_module", + "typed_wasm_check_types", + "typed_wasm_compile_module", + }; + for (tools) |t| { + var len: usize = buf.len; + const rc = boj_cartridge_invoke(t.ptr, "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, 0), rc); + try std.testing.expect(std.mem.indexOf(u8, buf[0..len], "result") != null); + } +} + +test "invoke: unknown tool returns -1" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("nope", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -1), rc); +} + +test "invoke: buffer too small returns -3" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("typed_wasm_validate_module", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -3), rc); + try std.testing.expect(len > 4); +} diff --git a/cartridges/domains/languages/typed-wasm-mcp/mod.js b/cartridges/domains/languages/typed-wasm-mcp/mod.js new file mode 100644 index 0000000..1b5ab90 --- /dev/null +++ b/cartridges/domains/languages/typed-wasm-mcp/mod.js @@ -0,0 +1,147 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// typed-wasm-mcp/mod.js β€” MCP gateway. Delegates to the `affinescript` CLI, +// which since 2026-05-03 carries the full typed-wasm Level 7/10 verifier +// (intra-module ownership) and the Level 10 cross-module boundary verifier. +// +// Tools: +// typed_wasm_validate_module β†’ affinescript verify FILE.affine +// typed_wasm_check_types β†’ affinescript check FILE.affine +// typed_wasm_compile_module β†’ affinescript compile FILE.affine -o OUT.wasm +// +// All three accept .affine source paths. The verifier runs over the freshly +// emitted Wasm so there is no separate .wasm-input path: shipping unchecked +// .wasm and asking us to validate it would defeat the typed-wasm contract. +// +// Pass an explicit `output_path` to typed_wasm_compile_module; otherwise the +// .wasm is written next to the source. + +const AFFINESCRIPT_BIN = Deno.env.get("AFFINESCRIPT_BIN") ?? "affinescript"; + +function dirOf(path) { + const i = path.lastIndexOf("/"); + return i > 0 ? path.slice(0, i) : "."; +} + +// `cwd` is set to the source's directory because affinescript's Module_loader +// resolves `use Foo.Bar` against the current working directory, not the source +// file's location. +async function runAffinescript(args, cwd) { + try { + const cmd = new Deno.Command(AFFINESCRIPT_BIN, { + args, + cwd, + stdout: "piped", + stderr: "piped", + }); + const out = await cmd.output(); + const stdout = new TextDecoder().decode(out.stdout); + const stderr = new TextDecoder().decode(out.stderr); + return { code: out.code, stdout, stderr }; + } catch (e) { + return { code: -1, stdout: "", stderr: `failed to spawn ${AFFINESCRIPT_BIN}: ${e.message}` }; + } +} + +function requireString(args, key) { + const v = args?.[key]; + if (typeof v !== "string" || v.length === 0) { + return { error: `${key} (string) is required` }; + } + return { value: v }; +} + +function deriveWasmPath(srcPath) { + return srcPath.endsWith(".affine") + ? srcPath.slice(0, -".affine".length) + ".wasm" + : srcPath + ".wasm"; +} + +export async function handleTool(toolName, args) { + switch (toolName) { + case "typed_wasm_validate_module": { + // Compiles + runs the intra-module Level 7/10 verifier (Tw_verify). + // "Validation" = the emitted Wasm satisfies the typed-wasm ownership + // contract declared in the source. + const r = requireString(args, "module_path"); + if (r.error) return { status: 400, data: { error: r.error } }; + const out = await runAffinescript(["verify", r.value], dirOf(r.value)); + const valid = out.code === 0; + return { + status: valid ? 200 : 200, + data: { + valid, + report: out.stdout.trim(), + diagnostics: out.stderr.trim() || undefined, + }, + }; + } + + case "typed_wasm_check_types": { + // Runs only the type checker (no Wasm emission). + const r = requireString(args, "module_path"); + if (r.error) return { status: 400, data: { error: r.error } }; + const out = await runAffinescript(["check", r.value], dirOf(r.value)); + const ok = out.code === 0; + return { + status: 200, + data: { + ok, + report: out.stdout.trim(), + errors: ok ? [] : (out.stderr.trim() ? [out.stderr.trim()] : []), + }, + }; + } + + case "typed_wasm_compile_module": { + // Compiles .affine β†’ .wasm. The emitted module carries the + // [affinescript.ownership] custom section consumed by typed-wasm + // intra- and cross-module verifiers. + const r = requireString(args, "module_path"); + if (r.error) return { status: 400, data: { error: r.error } }; + const outputPath = (typeof args?.output_path === "string" && args.output_path) + ? args.output_path + : deriveWasmPath(r.value); + const out = await runAffinescript(["compile", r.value, "-o", outputPath], dirOf(r.value)); + const success = out.code === 0; + return { + status: success ? 200 : 200, + data: { + success, + output_path: success ? outputPath : undefined, + report: out.stdout.trim(), + diagnostics: out.stderr.trim() || undefined, + }, + }; + } + + case "typed_wasm_verify_boundary": { + // Cross-module Level 10 boundary check. Compiles both files, then + // verifies that the caller's local funcs respect the ownership + // contract declared by the callee's Wasm exports. + const callee = requireString(args, "callee_path"); + if (callee.error) return { status: 400, data: { error: callee.error } }; + const caller = requireString(args, "caller_path"); + if (caller.error) return { status: 400, data: { error: caller.error } }; + // Verify-boundary needs to find both files' imports β€” run from the + // caller's directory (most likely the place where Callee lives too). + const out = await runAffinescript( + ["verify-boundary", callee.value, caller.value], + dirOf(caller.value), + ); + const clean = out.code === 0; + return { + status: 200, + data: { + clean, + report: out.stdout.trim(), + diagnostics: out.stderr.trim() || undefined, + }, + }; + } + + default: + return { status: 404, data: { error: `Unknown tool: ${toolName}` } }; + } +} diff --git a/cartridges/domains/languages/typed-wasm-mcp/panels/manifest.json b/cartridges/domains/languages/typed-wasm-mcp/panels/manifest.json new file mode 100644 index 0000000..a6ee595 --- /dev/null +++ b/cartridges/domains/languages/typed-wasm-mcp/panels/manifest.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://panll.dev/schemas/panel-manifest/v1.json", + "spdx": "MPL-2.0", + "cartridge": "typed-wasm-mcp", + "domain": "wasm", + "version": "0.1.0", + "panels": [ + { + "id": "typed-wasm-validation-status", + "title": "Module Validation", + "description": "WASM module type safety validation with TypeLL 0-10 level display and error listing.", + "type": "status-indicator", + "entrypoint": "panels/typed-wasm-validation-status.js", + "size": { "cols": 3, "rows": 2 }, + "refresh_interval_ms": 0, + "data_sources": [ + { "op": "ValidateModule", "on": "event" }, + { "op": "CheckTypes", "on": "event" } + ] + } + ] +} diff --git a/cartridges/domains/legal/pmpl-mcp/README.adoc b/cartridges/domains/legal/pmpl-mcp/README.adoc new file mode 100644 index 0000000..c234ce6 --- /dev/null +++ b/cartridges/domains/legal/pmpl-mcp/README.adoc @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += pmpl-mcp +Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk> +:spdx: MPL-2.0 +:tier: Ayo +:domain: legal +:protocols: MCP, REST + +== Overview + +PMPL licence chain verification and artefact hashing + +== Tools (5) + +[cols="2,4"] +|=== +| Tool | Description + +| `pmpl_create_chain` | Create a new PMPL provenance chain +| `pmpl_extend_chain` | Extend a provenance chain with a new link +| `pmpl_verify_chain` | Verify integrity of a provenance chain +| `pmpl_hash_artefact` | Hash an artefact for provenance anchoring +| `pmpl_check_compatible` | Check SPDX licence compatibility +|=== + +== Architecture + +Three-layer stack per the BoJ cartridge standard: + +[cols="1,1,3"] +|=== +| Layer | Language | Purpose + +| ABI | Idris2 | Formally verified state machine with dependent-type proofs +| FFI | Zig | C-ABI implementation: session pool, action metrics, thread safety +| Adapter| Zig | Adapter bridge to the BoJ unified adapter protocol (zig predecessors sidelined) +|=== + +== Building + +[source,bash] +---- +# ABI (Idris2) +cd abi && idris2 --check *.idr + +# FFI (Zig) +cd ffi && zig build +cd ffi && zig build test + +# Adapter (Zig) +cd adapter && zig build +---- + +== Status + +Alpha (CRG D). Cartridge metadata declared in `cartridge.json`; 5 tool(s) exposed. +Not yet dogfooded externally. See `docs/READINESS.md` at repo root for the +project-wide grade table and `docs/practice/DOGFOOD-LOG.adoc` for usage evidence. diff --git a/cartridges/domains/legal/pmpl-mcp/abi/PmplMcp/SafeProvenance.idr b/cartridges/domains/legal/pmpl-mcp/abi/PmplMcp/SafeProvenance.idr new file mode 100644 index 0000000..971acbc --- /dev/null +++ b/cartridges/domains/legal/pmpl-mcp/abi/PmplMcp/SafeProvenance.idr @@ -0,0 +1,80 @@ +-- SPDX-License-Identifier: MPL-2.0 +-- Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +||| PmplMcp.SafeProvenance: Formally verified PMPL license provenance chain. +||| +||| Cartridge: pmpl-mcp (v0.5 Shield) +||| Tracks and verifies the provenance chain for PMPL-licensed artifacts: +||| - Every artifact has a BLAKE3 content hash +||| - Provenance chains are append-only (no retroactive modification) +||| - Author attribution is cryptographically bound to content +||| - License compatibility is checked at chain construction time +||| +||| This implements the Palimpsest License (PMPL) requirement that +||| derivative works maintain a verifiable chain back to the original. +module PmplMcp.SafeProvenance + +%default total + +-- ═══════════════════════════════════════════════════════════════════════════ +-- Provenance Chain (Append-Only) +-- ═══════════════════════════════════════════════════════════════════════════ + +||| SPDX license identifier β€” restricted to PMPL-compatible licenses. +public export +data License = PMPL | MPL2 | MIT | Apache2 | BSD2 | BSD3 + +||| A single entry in the provenance chain. +public export +record ProvenanceEntry where + constructor MkEntry + contentHash : String -- BLAKE3 digest of the artifact + author : String -- Author identity (name + email) + license : License -- SPDX license at this point in the chain + timestamp : Nat -- Unix timestamp + parentHash : String -- BLAKE3 of the previous entry (empty for root) + +||| The provenance chain β€” a non-empty list of entries. +||| The head is the most recent entry, the last is the root. +public export +data ProvenanceChain : Type where + Root : ProvenanceEntry -> ProvenanceChain + Link : ProvenanceEntry -> ProvenanceChain -> ProvenanceChain + +||| Chain length β€” always >= 1. +public export +chainLength : ProvenanceChain -> Nat +chainLength (Root _) = 1 +chainLength (Link _ rest) = 1 + chainLength rest + +||| The chain is never empty β€” by construction. +public export +chainNonEmpty : (chain : ProvenanceChain) -> LTE 1 (chainLength chain) +chainNonEmpty (Root _) = LTESucc LTEZero +chainNonEmpty (Link _ _) = LTESucc LTEZero + +-- ═══════════════════════════════════════════════════════════════════════════ +-- License Compatibility +-- ═══════════════════════════════════════════════════════════════════════════ + +||| Check if a license is compatible with PMPL for derivative works. +||| PMPL is compatible with: MIT, Apache-2.0, BSD-2, BSD-3, MPL-2.0. +public export +pmplCompatible : License -> Bool +pmplCompatible PMPL = True +pmplCompatible MPL2 = True +pmplCompatible MIT = True +pmplCompatible Apache2 = True +pmplCompatible BSD2 = True +pmplCompatible BSD3 = True + +-- ═══════════════════════════════════════════════════════════════════════════ +-- FFI Interface +-- ═══════════════════════════════════════════════════════════════════════════ + +public export +interface ProvenanceFFI where + createChain : ProvenanceEntry -> IO ProvenanceChain + extendChain : ProvenanceChain -> ProvenanceEntry -> IO (Either String ProvenanceChain) + verifyChain : ProvenanceChain -> IO Bool + hashArtifact : String -> IO String -- BLAKE3 hash of file content + lookupChain : String -> IO (Maybe ProvenanceChain) -- lookup by content hash diff --git a/cartridges/domains/legal/pmpl-mcp/abi/README.adoc b/cartridges/domains/legal/pmpl-mcp/abi/README.adoc new file mode 100644 index 0000000..1c73340 --- /dev/null +++ b/cartridges/domains/legal/pmpl-mcp/abi/README.adoc @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += pmpl-mcp / abi β€” Idris2 ABI layer +:orientation: shallow + +== Purpose + +Cartridge-local protocol types for `pmpl-mcp`. Defines the data, tool, and +transition vocabulary this cartridge speaks. Inherits safety lemmas and the +matrix-cell identifier scheme from the trunk ABI at `src/abi/`. + +== Files + +1 Idris2 module(s), ~80 lines total. Lead module: +`SafeProvenance.idr`. + +No `.ipkg` β€” consumed directly by wider BoJ callers; type-checks inside the +trunk `boj.ipkg`. + +== Invariants + +* `%default total` at every module top. +* Zero `believe_me`, `assert_total`, `postulate`, `Admitted`, or + `sorry` (the trunk holds the four declared `believe_me` sites; leaf + cartridges carry none). + +== Test/proof surface + +Thin module(s), no inline tests. Proof surface = type-check success under the +trunk `idris2 --check`. + +== Read-first + +. `SafeProvenance.idr` β€” cartridge-local data records and enums. +. Trunk `src/abi/Boj/Catalogue.idr` for how this cell participates in the + 2D matrix. diff --git a/cartridges/domains/legal/pmpl-mcp/adapter/README.adoc b/cartridges/domains/legal/pmpl-mcp/adapter/README.adoc new file mode 100644 index 0000000..1fe43bd --- /dev/null +++ b/cartridges/domains/legal/pmpl-mcp/adapter/README.adoc @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += pmpl-mcp / adapter β€” protocol bridge +:orientation: shallow + +== Purpose + +Exposes this cartridge's FFI over the wire protocols declared in +`cartridge.json`. Dispatches tool invocations to the C-ABI exports at +`../ffi/` and formats responses per protocol. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” depends on the sibling `ffi` target. +| `pmpl_adapter.zig` | Protocol dispatch (134 lines). Tool catalogue matches `cartridge.json`. +| `SIDELINED-*.v.adoc` | zig predecessor(s). Not built β€” zig banned 2026-04-10. +|=== + +== Invariants + +* **Stateless** β€” all state lives behind the FFI, never in the adapter. +* **Response passthrough** β€” whatever the FFI returns goes back to the wire + unmodified (no embellishment, no silent recovery). +* **Cartridge.json is source of truth** for the tool catalogue; drift between + adapter and manifest is a CI failure. + +== Test/proof surface + +No inline tests at the adapter layer β€” protocol routing is exercised by the +trunk seam tests at `ffi/zig/src/seams.zig`. + +== Read-first + +. `pmpl_adapter.zig` β€” dispatch function and per-protocol routers. +. `../cartridge.json` β€” the tool manifest the dispatcher mirrors. diff --git a/cartridges/domains/legal/pmpl-mcp/adapter/build.zig b/cartridges/domains/legal/pmpl-mcp/adapter/build.zig new file mode 100644 index 0000000..21c113c --- /dev/null +++ b/cartridges/domains/legal/pmpl-mcp/adapter/build.zig @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// pmpl-mcp/adapter/build.zig + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const ffi_mod = b.createModule(.{ + .root_source_file = b.path("../ffi/pmpl_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + const adapter = b.addExecutable(.{ + .name = "pmpl_adapter", + .root_source_file = b.path("pmpl_adapter.zig"), + .target = target, + .optimize = optimize, + }); + adapter.root_module.addImport("pmpl_ffi", ffi_mod); + b.installArtifact(adapter); +} diff --git a/cartridges/domains/legal/pmpl-mcp/adapter/pmpl_adapter.zig b/cartridges/domains/legal/pmpl-mcp/adapter/pmpl_adapter.zig new file mode 100644 index 0000000..15f3e18 --- /dev/null +++ b/cartridges/domains/legal/pmpl-mcp/adapter/pmpl_adapter.zig @@ -0,0 +1,134 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// pmpl-mcp/adapter/pmpl_adapter.zig +// +// Three-protocol BoJ adapter: REST (port 9277), gRPC-compat (port 9278), +// GraphQL (port 9279). +// Replaces the banned zig adapter (pmpl_adapter.v). + +const std = @import("std"); +const ffi = @import("pmpl_ffi"); + +const REST_PORT: u16 = 9277; +const GRPC_PORT: u16 = 9278; +const GQL_PORT: u16 = 9279; + +const Response = struct { status: u16, body: []const u8 }; + +fn okJson(buf: []u8, msg: []const u8) []u8 { + return std.fmt.bufPrint(buf, "{{\"success\":true,\"message\":\"{s}\"}}", .{msg}) catch buf[0..0]; +} + +fn errJson(buf: []u8, msg: []const u8) []u8 { + return std.fmt.bufPrint(buf, "{{\"success\":false,\"error\":\"{s}\"}}", .{msg}) catch buf[0..0]; +} + +fn dispatch(tool: []const u8, _body: []const u8, resp: []u8) Response { + _ = _body; + if (std.mem.eql(u8, tool, "pmpl_create_chain")) { + return .{ .status = 200, .body = okJson(resp, "pmpl_create_chain forwarded") }; + } + if (std.mem.eql(u8, tool, "pmpl_extend_chain")) { + return .{ .status = 200, .body = okJson(resp, "pmpl_extend_chain forwarded") }; + } + if (std.mem.eql(u8, tool, "pmpl_verify_chain")) { + return .{ .status = 200, .body = okJson(resp, "pmpl_verify_chain forwarded") }; + } + if (std.mem.eql(u8, tool, "pmpl_hash_artefact")) { + return .{ .status = 200, .body = okJson(resp, "pmpl_hash_artefact forwarded") }; + } + if (std.mem.eql(u8, tool, "pmpl_check_compatible")) { + return .{ .status = 200, .body = okJson(resp, "pmpl_check_compatible forwarded") }; + } + return .{ .status = 404, .body = errJson(resp, "unknown tool") }; +} + +fn dispatchRest(path: []const u8, body: []const u8, resp: []u8) Response { + // Expect /tools/<tool_name> + const prefix = "/tools/"; + if (std.mem.startsWith(u8, path, prefix)) { + const tool = path[prefix.len..]; + return dispatch(tool, body, resp); + } + return .{ .status = 404, .body = errJson(resp, "not found") }; +} + +fn dispatchGrpc(path: []const u8, body: []const u8, resp: []u8) Response { + // Expect /<Service>/<Method> β€” derive tool from Method + var it = std.mem.splitScalar(u8, path, '/'); + _ = it.next(); // leading empty + _ = it.next(); // service + const method = it.next() orelse return .{ .status = 404, .body = errJson(resp, "bad gRPC path") }; + return dispatch(method, body, resp); +} + +fn dispatchGraphql(body: []const u8, resp: []u8) Response { + if (std.mem.indexOf(u8, body, "pmpl_create_chain") != null) + return dispatch("pmpl_create_chain", body, resp); + if (std.mem.indexOf(u8, body, "pmpl_extend_chain") != null) + return dispatch("pmpl_extend_chain", body, resp); + if (std.mem.indexOf(u8, body, "pmpl_verify_chain") != null) + return dispatch("pmpl_verify_chain", body, resp); + if (std.mem.indexOf(u8, body, "pmpl_hash_artefact") != null) + return dispatch("pmpl_hash_artefact", body, resp); + if (std.mem.indexOf(u8, body, "pmpl_check_compatible") != null) + return dispatch("pmpl_check_compatible", body, resp); + return .{ .status = 400, .body = errJson(resp, "unrecognised GraphQL operation") }; +} + +fn handleConnection(stream: std.net.Stream, port: u16) void { + defer stream.close(); + var buf: [4096]u8 = undefined; + var resp_buf: [4096]u8 = undefined; + const n = stream.read(&buf) catch return; + const req = buf[0..n]; + const result = switch (port) { + REST_PORT => blk: { + // Parse HTTP/1.1: first line = METHOD PATH HTTP/x.y + var lines = std.mem.splitScalar(u8, req, '\n'); + const first = lines.next() orelse break :blk Response{ .status = 400, .body = "" }; + var parts = std.mem.splitScalar(u8, std.mem.trim(u8, first, "\r"), ' '); + _ = parts.next(); // method + const path = parts.next() orelse break :blk Response{ .status = 400, .body = "" }; + break :blk dispatchRest(path, req, &resp_buf); + }, + GRPC_PORT => blk: { + var lines = std.mem.splitScalar(u8, req, '\n'); + const first = lines.next() orelse break :blk Response{ .status = 400, .body = "" }; + var parts = std.mem.splitScalar(u8, std.mem.trim(u8, first, "\r"), ' '); + _ = parts.next(); + const path = parts.next() orelse break :blk Response{ .status = 400, .body = "" }; + break :blk dispatchGrpc(path, req, &resp_buf); + }, + GQL_PORT => dispatchGraphql(req, &resp_buf), + else => Response{ .status = 500, .body = "" }, + }; + var http_resp: [512]u8 = undefined; + const http = std.fmt.bufPrint(&http_resp, + "HTTP/1.1 {d} OK\r\nContent-Length: {d}\r\nContent-Type: application/json\r\n\r\n", + .{ result.status, result.body.len }) catch return; + _ = stream.write(http) catch {}; + _ = stream.write(result.body) catch {}; +} + +fn listenLoop(port: u16) !void { + const addr = std.net.Address.initIp4(.{ 127, 0, 0, 1 }, port); + var server = try addr.listen(.{ .reuse_address = true }); + defer server.deinit(); + while (true) { + const conn = try server.accept(); + const t = try std.Thread.spawn(.{}, handleConnection, .{ conn.stream, port }); + t.detach(); + } +} + +pub fn main() !void { + ffi.pmpl_init(); + const t1 = try std.Thread.spawn(.{}, listenLoop, .{REST_PORT}); + const t2 = try std.Thread.spawn(.{}, listenLoop, .{GRPC_PORT}); + const t3 = try std.Thread.spawn(.{}, listenLoop, .{GQL_PORT}); + t1.join(); + t2.join(); + t3.join(); +} diff --git a/cartridges/domains/legal/pmpl-mcp/cartridge.json b/cartridges/domains/legal/pmpl-mcp/cartridge.json new file mode 100644 index 0000000..069ff75 --- /dev/null +++ b/cartridges/domains/legal/pmpl-mcp/cartridge.json @@ -0,0 +1,140 @@ +{ + "$schema": "https://boj.dev/schemas/cartridge/v1.json", + "spdx": "MPL-2.0", + "copyright": "Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk>", + "name": "pmpl-mcp", + "version": "0.1.0", + "description": "PMPL licence chain verification and artefact hashing", + "domain": "legal", + "tier": "Ayo", + "protocols": [ + "MCP", + "REST" + ], + "auth": { + "method": "none", + "env_var": null, + "credential_source": null + }, + "api": { + "base_url": "local://pmpl-mcp", + "content_type": "application/json" + }, + "tools": [ + { + "name": "pmpl_create_chain", + "description": "Create a new PMPL provenance chain", + "inputSchema": { + "type": "object", + "properties": { + "artefact_id": { + "type": "string", + "description": "Artefact identifier" + }, + "origin": { + "type": "string", + "description": "Origin description" + } + }, + "required": [ + "artefact_id", + "origin" + ] + } + }, + { + "name": "pmpl_extend_chain", + "description": "Extend a provenance chain with a new link", + "inputSchema": { + "type": "object", + "properties": { + "chain_id": { + "type": "string", + "description": "Chain ID" + }, + "event": { + "type": "string", + "description": "Event description" + }, + "actor": { + "type": "string", + "description": "Actor identifier" + } + }, + "required": [ + "chain_id", + "event", + "actor" + ] + } + }, + { + "name": "pmpl_verify_chain", + "description": "Verify integrity of a provenance chain", + "inputSchema": { + "type": "object", + "properties": { + "chain_id": { + "type": "string", + "description": "Chain ID" + } + }, + "required": [ + "chain_id" + ] + } + }, + { + "name": "pmpl_hash_artefact", + "description": "Hash an artefact for provenance anchoring", + "inputSchema": { + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "Artefact file path" + }, + "algorithm": { + "type": "string", + "description": "Hash algorithm: sha256|sha512|blake3" + } + }, + "required": [ + "path" + ] + } + }, + { + "name": "pmpl_check_compatible", + "description": "Check SPDX licence compatibility", + "inputSchema": { + "type": "object", + "properties": { + "licence_a": { + "type": "string", + "description": "First SPDX expression" + }, + "licence_b": { + "type": "string", + "description": "Second SPDX expression" + } + }, + "required": [ + "licence_a", + "licence_b" + ] + } + } + ], + "ffi": { + "so_path": "ffi/zig-out/lib/libpmpl_mcp.so", + "abi_version": "ADR-0006", + "symbols": [ + "boj_cartridge_init", + "boj_cartridge_deinit", + "boj_cartridge_name", + "boj_cartridge_version", + "boj_cartridge_invoke" + ] + } +} diff --git a/cartridges/domains/legal/pmpl-mcp/ffi/README.adoc b/cartridges/domains/legal/pmpl-mcp/ffi/README.adoc new file mode 100644 index 0000000..0720d45 --- /dev/null +++ b/cartridges/domains/legal/pmpl-mcp/ffi/README.adoc @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += pmpl-mcp / ffi β€” Zig FFI layer **(stub)** +:orientation: shallow + +== Purpose + +C-ABI exports bridging this cartridge's Idris2 ABI to the runtime. Consumed by +the adapter for protocol dispatch and by the trunk loader for mount. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” produces `zig-out/lib/libpmpl.so`. +| `pmpl_ffi.zig` | C-ABI exports (6 exports, 2 inline tests, 75 lines). +|=== + +== Invariants + +* **Null-pointer rejection** on `[*c]const u8` arguments before any + dereference. +* **Negative return values** carry documented error meanings; zero is + success (matching the trunk error-code convention). +* **No allocator leaks** β€” every allocation path pairs with `defer free`. + +== Test/proof surface + +2 inline `test "..."` block(s) in `pmpl_ffi.zig`. Run with +`cd ffi && zig build test`. + +== Read-first + +. `pmpl_ffi.zig` β€” C-ABI signatures and guards. +. Trunk `ffi/zig/src/catalogue.zig` for how this `.so` is loaded. + +== Promotion gate + +Wire these exports to real backing state (not `return 0` / `return 1` +placeholders) before promoting past D. diff --git a/cartridges/domains/legal/pmpl-mcp/ffi/build.zig b/cartridges/domains/legal/pmpl-mcp/ffi/build.zig new file mode 100644 index 0000000..eb8472e --- /dev/null +++ b/cartridges/domains/legal/pmpl-mcp/ffi/build.zig @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + // Shared ADR-0006 invoke-shim module (relative path up to boj-server trunk). + + const shim_mod = b.addModule("cartridge_shim", .{ + + .root_source_file = b.path("../../../ffi/zig/src/cartridge_shim.zig"), + + .target = target, + + .optimize = optimize, + + }); + + const ffi_mod = b.addModule("pmpl_mcp", .{ + .root_source_file = b.path("pmpl_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + ffi_mod.addImport("cartridge_shim", shim_mod); + + const lib = b.addLibrary(.{ + .name = "pmpl_mcp", + .root_module = ffi_mod, + .linkage = .dynamic, + }); + lib.linkLibC(); + b.installArtifact(lib); + + const tests = b.addTest(.{ .root_module = ffi_mod }); + tests.linkLibC(); + const run_tests = b.addRunArtifact(tests); + const test_step = b.step("test", "Run FFI tests"); + test_step.dependOn(&run_tests.step); +} + diff --git a/cartridges/domains/legal/pmpl-mcp/ffi/cartridge_shim.zig b/cartridges/domains/legal/pmpl-mcp/ffi/cartridge_shim.zig new file mode 100644 index 0000000..0e399db --- /dev/null +++ b/cartridges/domains/legal/pmpl-mcp/ffi/cartridge_shim.zig @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// cartridge_shim.zig β€” Shared helpers for the ADR-0006 five-symbol +// cartridge ABI (`boj_cartridge_init / deinit / name / version / invoke`). +// +// The shim centralises the seven-code return convention, NUL-argument +// guards, tool-name comparison, and the buffer-too-small path so each +// cartridge's `boj_cartridge_invoke` can stay short β€” typically a tool +// table plus `shim.writeResult(...)`. +// +// Cartridges import this file by relative path (no build-graph change +// needed). Example: +// +// const shim = @import("../../../ffi/zig/src/cartridge_shim.zig"); +// +// export fn boj_cartridge_invoke( +// tool_name: [*c]const u8, +// json_args: [*c]const u8, +// out_buf: [*c]u8, +// in_out_len: [*c]usize, +// ) callconv(.c) i32 { +// _ = json_args; +// if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; +// const body = if (shim.toolIs(tool_name, "foo")) "{\"result\":{}}" +// else return shim.RC_UNKNOWN_TOOL; +// return shim.writeResult(out_buf, in_out_len, body); +// } + +const std = @import("std"); + +// ── Return codes (ADR-0006 Β§Return codes) ──────────────────────────── +// +// Frozen by ADR-0006. New failure modes compose these via the error +// JSON body β€” the integer surface does not grow without a follow-up ADR. + +pub const RC_SUCCESS: i32 = 0; +pub const RC_UNKNOWN_TOOL: i32 = -1; +pub const RC_BAD_ARGS: i32 = -2; +pub const RC_BUFFER_TOO_SMALL: i32 = -3; +pub const RC_RUNTIME_ERROR: i32 = -4; +pub const RC_PANIC: i32 = -5; +pub const RC_AUTH_DENIED: i32 = -6; + +// ── Invoke-path helpers ────────────────────────────────────────────── + +/// True if any of the three mandatory `boj_cartridge_invoke` output-path +/// pointers is null. Use at the top of every invoke to short-circuit to +/// `RC_BAD_ARGS`. +pub fn invokeArgsNull( + tool_name: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) bool { + return tool_name == null or out_buf == null or in_out_len == null; +} + +/// Compare a C-NUL-terminated tool-name pointer against a Zig string +/// literal. Caller must have already verified `tool_name` is non-null +/// (usually via `invokeArgsNull`). +pub fn toolIs(tool_name: [*c]const u8, expected: []const u8) bool { + const s = std.mem.span(@as([*:0]const u8, @ptrCast(tool_name))); + return std.mem.eql(u8, s, expected); +} + +/// Copy `body` into `out_buf[0..*in_out_len]` (as a capacity) and update +/// `*in_out_len` to the number of bytes written. Returns `RC_SUCCESS`. +/// +/// If `body.len` exceeds the current capacity stored in `*in_out_len`, +/// sets `*in_out_len` to the required size and returns +/// `RC_BUFFER_TOO_SMALL` β€” the caller is then expected to re-allocate +/// and retry, per ADR-0006 Β§Memory ownership. +/// +/// Caller must have already verified that `out_buf` and `in_out_len` +/// are non-null. +pub fn writeResult( + out_buf: [*c]u8, + in_out_len: [*c]usize, + body: []const u8, +) i32 { + const cap = in_out_len.*; + if (body.len > cap) { + in_out_len.* = body.len; + return RC_BUFFER_TOO_SMALL; + } + @memcpy(out_buf[0..body.len], body); + in_out_len.* = body.len; + return RC_SUCCESS; +} + +// ── Tests ──────────────────────────────────────────────────────────── + +test "writeResult: body fits, writes and sets length" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); + try std.testing.expectEqualStrings("hello", buf[0..len]); +} + +test "writeResult: too small returns -3 and sets required length" { + var buf: [2]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_BUFFER_TOO_SMALL, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: exact-fit succeeds" { + var buf: [5]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: empty body" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, ""); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 0), len); +} + +test "toolIs: matches and rejects" { + const name: [*:0]const u8 = "foo"; + try std.testing.expect(toolIs(@ptrCast(name), "foo")); + try std.testing.expect(!toolIs(@ptrCast(name), "bar")); + try std.testing.expect(!toolIs(@ptrCast(name), "foobar")); + try std.testing.expect(!toolIs(@ptrCast(name), "fo")); +} + +test "invokeArgsNull: detects each null slot" { + var buf: [4]u8 = undefined; + var len: usize = 4; + const name: [*:0]const u8 = "x"; + try std.testing.expect(!invokeArgsNull(@ptrCast(name), &buf, &len)); + try std.testing.expect(invokeArgsNull(null, &buf, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), null, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), &buf, null)); +} diff --git a/cartridges/domains/legal/pmpl-mcp/ffi/pmpl_ffi.zig b/cartridges/domains/legal/pmpl-mcp/ffi/pmpl_ffi.zig new file mode 100644 index 0000000..d9bda4e --- /dev/null +++ b/cartridges/domains/legal/pmpl-mcp/ffi/pmpl_ffi.zig @@ -0,0 +1,169 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// pmpl_ffi.zig β€” PMPL provenance chain verification via BLAKE3. + +const std = @import("std"); + +pub const License = enum(u8) { pmpl = 0, mpl2 = 1, mit = 2, apache2 = 3, bsd2 = 4, bsd3 = 5 }; + +pub const ProvenanceEntry = extern struct { + content_hash: [*:0]const u8, + author: [*:0]const u8, + license: License, + timestamp: u64, + parent_hash: [*:0]const u8, +}; + +/// Create a new provenance chain root from an entry. +export fn pmpl_create_chain(entry: *const ProvenanceEntry) i32 { + _ = entry; + return 0; // Success +} + +/// Extend a chain with a new entry. Validates license compatibility and parent hash. +export fn pmpl_extend_chain(parent_hash: [*:0]const u8, entry: *const ProvenanceEntry) i32 { + _ = parent_hash; + _ = entry; + return 0; +} + +/// Verify the integrity of a provenance chain by checking all BLAKE3 hashes. +export fn pmpl_verify_chain(root_hash: [*:0]const u8) i32 { + _ = root_hash; + return 0; // Chain valid +} + +/// Hash a file's content using BLAKE3. +export fn pmpl_hash_artifact(path: [*:0]const u8, out_hash: [*]u8, out_len: *u32) i32 { + _ = path; + // Return a placeholder BLAKE3 hash (64 hex chars). + const placeholder = "0000000000000000000000000000000000000000000000000000000000000000"; + @memcpy(out_hash[0..64], placeholder); + out_len.* = 64; + return 0; +} + +/// Check if a license is PMPL-compatible. +export fn pmpl_compatible(license: License) bool { + return switch (license) { + .pmpl, .mpl2, .mit, .apache2, .bsd2, .bsd3 => true, + }; +} + +export fn pmpl_version() [*:0]const u8 { + return "0.5.0"; +} + +// ═══════════════════════════════════════════════════════════════════════ +// Standard ABI (ADR-0005 four symbols + ADR-0006 invoke) +// ═══════════════════════════════════════════════════════════════════════ + +const shim = @import("cartridge_shim.zig"); + +const CARTRIDGE_NAME_PTR: [*:0]const u8 = "pmpl-mcp"; +const CARTRIDGE_VERSION_PTR: [*:0]const u8 = "0.1.0"; + +export fn boj_cartridge_init() callconv(.c) c_int { + return 0; +} + +export fn boj_cartridge_deinit() callconv(.c) void {} + +export fn boj_cartridge_name() callconv(.c) [*:0]const u8 { + return CARTRIDGE_NAME_PTR; +} + +export fn boj_cartridge_version() callconv(.c) [*:0]const u8 { + return CARTRIDGE_VERSION_PTR; +} + +/// Dispatch the cartridge.json MCP tools. Grade D Alpha stubs. +export fn boj_cartridge_invoke( + tool_name: [*c]const u8, + json_args: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) callconv(.c) i32 { + _ = json_args; + if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; + + const body: []const u8 = if (shim.toolIs(tool_name, "pmpl_create_chain")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "pmpl_extend_chain")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "pmpl_verify_chain")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "pmpl_hash_artefact")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "pmpl_check_compatible")) + "{\"result\":{\"status\":\"stub\"}}" +else + return shim.RC_UNKNOWN_TOOL; + + return shim.writeResult(out_buf, in_out_len, body); +} + +test "create chain succeeds" { + const entry = ProvenanceEntry{ + .content_hash = "abc123", + .author = "Jonathan D.A. Jewell", + .license = .pmpl, + .timestamp = 1711728000, + .parent_hash = "", + }; + const status = pmpl_create_chain(&entry); + try std.testing.expectEqual(@as(i32, 0), status); +} + +test "all licenses are pmpl compatible" { + try std.testing.expect(pmpl_compatible(.pmpl)); + try std.testing.expect(pmpl_compatible(.mit)); + try std.testing.expect(pmpl_compatible(.apache2)); + try std.testing.expect(pmpl_compatible(.mpl2)); +} + +// ═══════════════════════════════════════════════════════════════════════ +// ADR-0006 invoke dispatch tests +// ═══════════════════════════════════════════════════════════════════════ + +test "boj_cartridge_name returns pmpl-mcp" { + const n = std.mem.span(boj_cartridge_name()); + try std.testing.expectEqualStrings("pmpl-mcp", n); +} + +test "boj_cartridge_init returns 0" { + try std.testing.expectEqual(@as(c_int, 0), boj_cartridge_init()); +} + +test "invoke: each declared tool succeeds" { + var buf: [256]u8 = undefined; + const tools = [_][]const u8{ + "pmpl_create_chain", + "pmpl_extend_chain", + "pmpl_verify_chain", + "pmpl_hash_artefact", + "pmpl_check_compatible", + }; + for (tools) |t| { + var len: usize = buf.len; + const rc = boj_cartridge_invoke(t.ptr, "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, 0), rc); + try std.testing.expect(std.mem.indexOf(u8, buf[0..len], "result") != null); + } +} + +test "invoke: unknown tool returns -1" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("nope", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -1), rc); +} + +test "invoke: buffer too small returns -3" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("pmpl_create_chain", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -3), rc); + try std.testing.expect(len > 4); +} diff --git a/cartridges/domains/legal/pmpl-mcp/mod.js b/cartridges/domains/legal/pmpl-mcp/mod.js new file mode 100644 index 0000000..a8c3c84 --- /dev/null +++ b/cartridges/domains/legal/pmpl-mcp/mod.js @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// pmpl-mcp/mod.js β€” PMPL licence chain verification and artefact hashing +// +// Delegates to backend at http://127.0.0.1:7737 (override with PMPL_BACKEND_URL). + +const BASE_URL = Deno.env.get("PMPL_BACKEND_URL") ?? "http://127.0.0.1:7737"; +const TIMEOUT_MS = 15_000; + +async function post(path, payload) { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), TIMEOUT_MS); + try { + const r = await fetch(`${BASE_URL}${path}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + signal: ctrl.signal, + }); + const data = await r.json().catch(() => ({ success: false, error: "non-JSON response" })); + return { status: r.status, data }; + } catch (e) { + if (e.name === "AbortError") return { status: 504, data: { success: false, error: "pmpl-mcp backend timed out" } }; + return { status: 503, data: { success: false, error: `pmpl-mcp backend unavailable: ${e.message}` } }; + } finally { clearTimeout(t); } +} + +async function get(path) { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), TIMEOUT_MS); + try { + const r = await fetch(`${BASE_URL}${path}`, { method: "GET", signal: ctrl.signal }); + const data = await r.json().catch(() => ({ success: false, error: "non-JSON response" })); + return { status: r.status, data }; + } catch (e) { + if (e.name === "AbortError") return { status: 504, data: { success: false, error: "pmpl-mcp backend timed out" } }; + return { status: 503, data: { success: false, error: `pmpl-mcp backend unavailable: ${e.message}` } }; + } finally { clearTimeout(t); } +} + +export async function handleTool(toolName, args) { + switch (toolName) { + case "pmpl_create_chain": + return post("/api/v1/pmpl_create_chain", args ?? {}); + case "pmpl_extend_chain": + return post("/api/v1/pmpl_extend_chain", args ?? {}); + case "pmpl_verify_chain": + return post("/api/v1/pmpl_verify_chain", args ?? {}); + case "pmpl_hash_artefact": + return post("/api/v1/pmpl_hash_artefact", args ?? {}); + case "pmpl_check_compatible": + return post("/api/v1/pmpl_check_compatible", args ?? {}); + default: + return { status: 404, data: { error: `Unknown tool: ${toolName}` } }; + } +} diff --git a/cartridges/domains/messaging/queues-mcp/README.adoc b/cartridges/domains/messaging/queues-mcp/README.adoc new file mode 100644 index 0000000..eae7bee --- /dev/null +++ b/cartridges/domains/messaging/queues-mcp/README.adoc @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += queues-mcp +Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk> +:spdx: MPL-2.0 +:tier: Ayo +:domain: messaging +:protocols: MCP, REST + +== Overview + +Message queue bridge (Redis Streams, RabbitMQ, NATS) + +== Tools (6) + +[cols="2,4"] +|=== +| Tool | Description + +| `queue_connect` | Connect to a message queue backend +| `queue_publish` | Publish a message to a queue/topic +| `queue_subscribe` | Subscribe to a queue/topic +| `queue_consume` | Consume messages from subscribed topic +| `queue_ack` | Acknowledge a message +| `queue_disconnect` | Disconnect from queue backend +|=== + +== Architecture + +Three-layer stack per the BoJ cartridge standard: + +[cols="1,1,3"] +|=== +| Layer | Language | Purpose + +| ABI | Idris2 | Formally verified state machine with dependent-type proofs +| FFI | Zig | C-ABI implementation: session pool, action metrics, thread safety +| Adapter| Zig | Adapter bridge to the BoJ unified adapter protocol (zig predecessors sidelined) +|=== + +== Building + +[source,bash] +---- +# ABI (Idris2) +cd abi && idris2 --check *.idr + +# FFI (Zig) +cd ffi && zig build +cd ffi && zig build test + +# Adapter (Zig) +cd adapter && zig build +---- + +== Status + +Alpha (CRG D). Cartridge metadata declared in `cartridge.json`; 6 tool(s) exposed. +Not yet dogfooded externally. See `docs/READINESS.md` at repo root for the +project-wide grade table and `docs/practice/DOGFOOD-LOG.adoc` for usage evidence. diff --git a/cartridges/domains/messaging/queues-mcp/abi/QueuesMcp/SafeQueues.idr b/cartridges/domains/messaging/queues-mcp/abi/QueuesMcp/SafeQueues.idr new file mode 100644 index 0000000..2a01c76 --- /dev/null +++ b/cartridges/domains/messaging/queues-mcp/abi/QueuesMcp/SafeQueues.idr @@ -0,0 +1,197 @@ +-- SPDX-License-Identifier: MPL-2.0 +-- Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +||| QueuesMcp.SafeQueues: Formally verified message queue operations. +||| +||| Cartridge: queues-mcp +||| Matrix cell: Queue domain x {MCP, LSP} protocols +||| +||| This module defines type-safe queue operations with a +||| connection/subscription state machine that prevents: +||| - Publishing to unconnected queues +||| - Double-subscribing to the same queue +||| - Consuming without acknowledging previous messages +||| +||| Supports Redis Streams, RabbitMQ, and NATS backends. +module QueuesMcp.SafeQueues + +import Data.List + +%default total + +-- ═══════════════════════════════════════════════════════════════════════════ +-- Queue State Machine +-- ═══════════════════════════════════════════════════════════════════════════ + +||| Queue lifecycle states. +||| A queue progresses: Disconnected -> Connected -> Subscribed -> Consuming -> Subscribed -> Connected -> Disconnected +public export +data QueueState = Disconnected | Connected | Subscribed | Consuming | QueueError + +||| Equality for queue states. +public export +Eq QueueState where + Disconnected == Disconnected = True + Connected == Connected = True + Subscribed == Subscribed = True + Consuming == Consuming = True + QueueError == QueueError = True + _ == _ = False + +||| Valid state transitions (enforced at the type level). +public export +data ValidTransition : QueueState -> QueueState -> Type where + Connect : ValidTransition Disconnected Connected + Subscribe : ValidTransition Connected Subscribed + BeginConsume : ValidTransition Subscribed Consuming + Ack : ValidTransition Consuming Subscribed + Unsubscribe : ValidTransition Subscribed Connected + Disconnect : ValidTransition Connected Disconnected + ConsumeError : ValidTransition Consuming QueueError + Recover : ValidTransition QueueError Subscribed + +||| Runtime transition validator. +public export +canTransition : QueueState -> QueueState -> Bool +canTransition Disconnected Connected = True +canTransition Connected Subscribed = True +canTransition Subscribed Consuming = True +canTransition Consuming Subscribed = True +canTransition Subscribed Connected = True +canTransition Connected Disconnected = True +canTransition Consuming QueueError = True +canTransition QueueError Subscribed = True +canTransition _ _ = False + +-- ═══════════════════════════════════════════════════════════════════════════ +-- Queue Backend Types +-- ═══════════════════════════════════════════════════════════════════════════ + +||| Supported message queue backends. +public export +data QueueBackend + = RedisStream -- Redis Streams (pub/sub + persistence) + | RabbitMQ -- RabbitMQ (AMQP) + | NATS -- NATS (lightweight messaging) + | Custom String -- User-defined backend + +||| C-ABI encoding for backends. +public export +backendToInt : QueueBackend -> Int +backendToInt RedisStream = 1 +backendToInt RabbitMQ = 2 +backendToInt NATS = 3 +backendToInt (Custom _) = 99 + +-- ═══════════════════════════════════════════════════════════════════════════ +-- Message Types +-- ═══════════════════════════════════════════════════════════════════════════ + +||| Message delivery guarantee level. +public export +data DeliveryGuarantee + = AtMostOnce -- Fire and forget + | AtLeastOnce -- Retry until acknowledged + | ExactlyOnce -- Transactional delivery + +||| A queue message with tracking metadata. +public export +record QueueMessage where + constructor MkQueueMessage + topic : String + payload : String + delivery : DeliveryGuarantee + seqNum : Nat -- Sequence number for ordering + +-- ═══════════════════════════════════════════════════════════════════════════ +-- Queue Record +-- ═══════════════════════════════════════════════════════════════════════════ + +||| A queue connection with tracked state. +public export +record QueueConnection where + constructor MkQueueConnection + connId : String + backend : QueueBackend + state : QueueState + msgCount : Nat -- Total messages processed + +||| Proof that a queue has an active subscription. +public export +data IsSubscribed : QueueConnection -> Type where + ActiveSubscription : (q : QueueConnection) -> + (state q = Subscribed) -> + IsSubscribed q + +-- ═══════════════════════════════════════════════════════════════════════════ +-- MCP Tool Definitions +-- ═══════════════════════════════════════════════════════════════════════════ + +||| MCP tools exposed by this cartridge. +||| These map to MCP tool definitions that AI agents can call. +public export +data McpTool + = ToolConnect -- Connect to a queue backend + | ToolSubscribe -- Subscribe to a topic/queue + | ToolPublish -- Publish a message (requires Connected) + | ToolConsume -- Begin consuming messages (requires Subscribed) + | ToolAck -- Acknowledge a consumed message + | ToolUnsubscribe -- Unsubscribe from a topic/queue + | ToolDisconnect -- Disconnect from the backend + | ToolQueueStats -- Get queue statistics + +||| MCP tool name (for JSON-RPC method name). +public export +toolName : McpTool -> String +toolName ToolConnect = "queues/connect" +toolName ToolSubscribe = "queues/subscribe" +toolName ToolPublish = "queues/publish" +toolName ToolConsume = "queues/consume" +toolName ToolAck = "queues/ack" +toolName ToolUnsubscribe = "queues/unsubscribe" +toolName ToolDisconnect = "queues/disconnect" +toolName ToolQueueStats = "queues/stats" + +||| Which tools require an active subscription. +public export +requiresSubscription : McpTool -> Bool +requiresSubscription ToolConsume = True +requiresSubscription ToolAck = True +requiresSubscription _ = False + +-- ═══════════════════════════════════════════════════════════════════════════ +-- C-ABI Exports +-- ═══════════════════════════════════════════════════════════════════════════ + +||| Queue state to integer. +public export +queueStateToInt : QueueState -> Int +queueStateToInt Disconnected = 0 +queueStateToInt Connected = 1 +queueStateToInt Subscribed = 2 +queueStateToInt Consuming = 3 +queueStateToInt QueueError = 4 + +||| FFI: Validate a state transition. +export +queue_can_transition : Int -> Int -> Int +queue_can_transition from to = + let fromState = case from of + 0 => Disconnected + 1 => Connected + 2 => Subscribed + 3 => Consuming + _ => QueueError + toState = case to of + 0 => Disconnected + 1 => Connected + 2 => Subscribed + 3 => Consuming + _ => QueueError + in if canTransition fromState toState then 1 else 0 + +||| FFI: Check if a tool requires an active subscription. +export +queue_tool_requires_subscription : Int -> Int +queue_tool_requires_subscription 4 = 1 -- ToolConsume +queue_tool_requires_subscription 5 = 1 -- ToolAck +queue_tool_requires_subscription _ = 0 -- Others do not require subscription diff --git a/cartridges/domains/messaging/queues-mcp/abi/README.adoc b/cartridges/domains/messaging/queues-mcp/abi/README.adoc new file mode 100644 index 0000000..39ad0e7 --- /dev/null +++ b/cartridges/domains/messaging/queues-mcp/abi/README.adoc @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += queues-mcp / abi β€” Idris2 ABI layer +:orientation: shallow + +== Purpose + +Cartridge-local protocol types for `queues-mcp`. Defines the data, tool, and +transition vocabulary this cartridge speaks. Inherits safety lemmas and the +matrix-cell identifier scheme from the trunk ABI at `src/abi/`. + +== Files + +1 Idris2 module(s), ~197 lines total. Lead module: +`SafeQueues.idr`. + +No `.ipkg` β€” consumed directly by wider BoJ callers; type-checks inside the +trunk `boj.ipkg`. + +== Invariants + +* `%default total` at every module top. +* Zero `believe_me`, `assert_total`, `postulate`, `Admitted`, or + `sorry` (the trunk holds the four declared `believe_me` sites; leaf + cartridges carry none). + +== Test/proof surface + +Thin module(s), no inline tests. Proof surface = type-check success under the +trunk `idris2 --check`. + +== Read-first + +. `SafeQueues.idr` β€” cartridge-local data records and enums. +. Trunk `src/abi/Boj/Catalogue.idr` for how this cell participates in the + 2D matrix. diff --git a/cartridges/domains/messaging/queues-mcp/abi/queues-mcp.ipkg b/cartridges/domains/messaging/queues-mcp/abi/queues-mcp.ipkg new file mode 100644 index 0000000..2307121 --- /dev/null +++ b/cartridges/domains/messaging/queues-mcp/abi/queues-mcp.ipkg @@ -0,0 +1,11 @@ +-- SPDX-License-Identifier: MPL-2.0 +package queuesmcp + +authors = "Jonathan D.A. Jewell" +version = 0.1.0 +license = "MPL-2.0" +brief = "Queues MCP cartridge β€” connection/subscription state machine with ack-before-next enforcement" + +sourcedir = "." +modules = QueuesMcp.SafeQueues +depends = base, contrib diff --git a/cartridges/domains/messaging/queues-mcp/adapter/README.adoc b/cartridges/domains/messaging/queues-mcp/adapter/README.adoc new file mode 100644 index 0000000..45c3875 --- /dev/null +++ b/cartridges/domains/messaging/queues-mcp/adapter/README.adoc @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += queues-mcp / adapter β€” protocol bridge +:orientation: shallow + +== Purpose + +Exposes this cartridge's FFI over the wire protocols declared in +`cartridge.json`. Dispatches tool invocations to the C-ABI exports at +`../ffi/` and formats responses per protocol. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” depends on the sibling `ffi` target. +| `queues_adapter.zig` | Protocol dispatch (139 lines). Tool catalogue matches `cartridge.json`. +| `SIDELINED-*.v.adoc` | zig predecessor(s). Not built β€” zig banned 2026-04-10. +|=== + +== Invariants + +* **Stateless** β€” all state lives behind the FFI, never in the adapter. +* **Response passthrough** β€” whatever the FFI returns goes back to the wire + unmodified (no embellishment, no silent recovery). +* **Cartridge.json is source of truth** for the tool catalogue; drift between + adapter and manifest is a CI failure. + +== Test/proof surface + +No inline tests at the adapter layer β€” protocol routing is exercised by the +trunk seam tests at `ffi/zig/src/seams.zig`. + +== Read-first + +. `queues_adapter.zig` β€” dispatch function and per-protocol routers. +. `../cartridge.json` β€” the tool manifest the dispatcher mirrors. diff --git a/cartridges/domains/messaging/queues-mcp/adapter/build.zig b/cartridges/domains/messaging/queues-mcp/adapter/build.zig new file mode 100644 index 0000000..674e672 --- /dev/null +++ b/cartridges/domains/messaging/queues-mcp/adapter/build.zig @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// queues-mcp/adapter/build.zig + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const ffi_mod = b.createModule(.{ + .root_source_file = b.path("../ffi/queues_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + const adapter = b.addExecutable(.{ + .name = "queues_adapter", + .root_source_file = b.path("queues_adapter.zig"), + .target = target, + .optimize = optimize, + }); + adapter.root_module.addImport("queues_ffi", ffi_mod); + b.installArtifact(adapter); +} diff --git a/cartridges/domains/messaging/queues-mcp/adapter/queues_adapter.zig b/cartridges/domains/messaging/queues-mcp/adapter/queues_adapter.zig new file mode 100644 index 0000000..f3807b9 --- /dev/null +++ b/cartridges/domains/messaging/queues-mcp/adapter/queues_adapter.zig @@ -0,0 +1,139 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// queues-mcp/adapter/queues_adapter.zig +// +// Three-protocol BoJ adapter: REST (port 9280), gRPC-compat (port 9281), +// GraphQL (port 9282). +// Replaces the banned zig adapter (queues_adapter.v). + +const std = @import("std"); +const ffi = @import("queues_ffi"); + +const REST_PORT: u16 = 9280; +const GRPC_PORT: u16 = 9281; +const GQL_PORT: u16 = 9282; + +const Response = struct { status: u16, body: []const u8 }; + +fn okJson(buf: []u8, msg: []const u8) []u8 { + return std.fmt.bufPrint(buf, "{{\"success\":true,\"message\":\"{s}\"}}", .{msg}) catch buf[0..0]; +} + +fn errJson(buf: []u8, msg: []const u8) []u8 { + return std.fmt.bufPrint(buf, "{{\"success\":false,\"error\":\"{s}\"}}", .{msg}) catch buf[0..0]; +} + +fn dispatch(tool: []const u8, _body: []const u8, resp: []u8) Response { + _ = _body; + if (std.mem.eql(u8, tool, "queue_connect")) { + return .{ .status = 200, .body = okJson(resp, "queue_connect forwarded") }; + } + if (std.mem.eql(u8, tool, "queue_publish")) { + return .{ .status = 200, .body = okJson(resp, "queue_publish forwarded") }; + } + if (std.mem.eql(u8, tool, "queue_subscribe")) { + return .{ .status = 200, .body = okJson(resp, "queue_subscribe forwarded") }; + } + if (std.mem.eql(u8, tool, "queue_consume")) { + return .{ .status = 200, .body = okJson(resp, "queue_consume forwarded") }; + } + if (std.mem.eql(u8, tool, "queue_ack")) { + return .{ .status = 200, .body = okJson(resp, "queue_ack forwarded") }; + } + if (std.mem.eql(u8, tool, "queue_disconnect")) { + return .{ .status = 200, .body = okJson(resp, "queue_disconnect forwarded") }; + } + return .{ .status = 404, .body = errJson(resp, "unknown tool") }; +} + +fn dispatchRest(path: []const u8, body: []const u8, resp: []u8) Response { + // Expect /tools/<tool_name> + const prefix = "/tools/"; + if (std.mem.startsWith(u8, path, prefix)) { + const tool = path[prefix.len..]; + return dispatch(tool, body, resp); + } + return .{ .status = 404, .body = errJson(resp, "not found") }; +} + +fn dispatchGrpc(path: []const u8, body: []const u8, resp: []u8) Response { + // Expect /<Service>/<Method> β€” derive tool from Method + var it = std.mem.splitScalar(u8, path, '/'); + _ = it.next(); // leading empty + _ = it.next(); // service + const method = it.next() orelse return .{ .status = 404, .body = errJson(resp, "bad gRPC path") }; + return dispatch(method, body, resp); +} + +fn dispatchGraphql(body: []const u8, resp: []u8) Response { + if (std.mem.indexOf(u8, body, "queue_connect") != null) + return dispatch("queue_connect", body, resp); + if (std.mem.indexOf(u8, body, "queue_publish") != null) + return dispatch("queue_publish", body, resp); + if (std.mem.indexOf(u8, body, "queue_subscribe") != null) + return dispatch("queue_subscribe", body, resp); + if (std.mem.indexOf(u8, body, "queue_consume") != null) + return dispatch("queue_consume", body, resp); + if (std.mem.indexOf(u8, body, "queue_ack") != null) + return dispatch("queue_ack", body, resp); + if (std.mem.indexOf(u8, body, "queue_disconnect") != null) + return dispatch("queue_disconnect", body, resp); + return .{ .status = 400, .body = errJson(resp, "unrecognised GraphQL operation") }; +} + +fn handleConnection(stream: std.net.Stream, port: u16) void { + defer stream.close(); + var buf: [4096]u8 = undefined; + var resp_buf: [4096]u8 = undefined; + const n = stream.read(&buf) catch return; + const req = buf[0..n]; + const result = switch (port) { + REST_PORT => blk: { + // Parse HTTP/1.1: first line = METHOD PATH HTTP/x.y + var lines = std.mem.splitScalar(u8, req, '\n'); + const first = lines.next() orelse break :blk Response{ .status = 400, .body = "" }; + var parts = std.mem.splitScalar(u8, std.mem.trim(u8, first, "\r"), ' '); + _ = parts.next(); // method + const path = parts.next() orelse break :blk Response{ .status = 400, .body = "" }; + break :blk dispatchRest(path, req, &resp_buf); + }, + GRPC_PORT => blk: { + var lines = std.mem.splitScalar(u8, req, '\n'); + const first = lines.next() orelse break :blk Response{ .status = 400, .body = "" }; + var parts = std.mem.splitScalar(u8, std.mem.trim(u8, first, "\r"), ' '); + _ = parts.next(); + const path = parts.next() orelse break :blk Response{ .status = 400, .body = "" }; + break :blk dispatchGrpc(path, req, &resp_buf); + }, + GQL_PORT => dispatchGraphql(req, &resp_buf), + else => Response{ .status = 500, .body = "" }, + }; + var http_resp: [512]u8 = undefined; + const http = std.fmt.bufPrint(&http_resp, + "HTTP/1.1 {d} OK\r\nContent-Length: {d}\r\nContent-Type: application/json\r\n\r\n", + .{ result.status, result.body.len }) catch return; + _ = stream.write(http) catch {}; + _ = stream.write(result.body) catch {}; +} + +fn listenLoop(port: u16) !void { + const addr = std.net.Address.initIp4(.{ 127, 0, 0, 1 }, port); + var server = try addr.listen(.{ .reuse_address = true }); + defer server.deinit(); + while (true) { + const conn = try server.accept(); + const t = try std.Thread.spawn(.{}, handleConnection, .{ conn.stream, port }); + t.detach(); + } +} + +pub fn main() !void { + ffi.queues_init(); + const t1 = try std.Thread.spawn(.{}, listenLoop, .{REST_PORT}); + const t2 = try std.Thread.spawn(.{}, listenLoop, .{GRPC_PORT}); + const t3 = try std.Thread.spawn(.{}, listenLoop, .{GQL_PORT}); + t1.join(); + t2.join(); + t3.join(); +} diff --git a/cartridges/domains/messaging/queues-mcp/cartridge.json b/cartridges/domains/messaging/queues-mcp/cartridge.json new file mode 100644 index 0000000..faa1163 --- /dev/null +++ b/cartridges/domains/messaging/queues-mcp/cartridge.json @@ -0,0 +1,161 @@ +{ + "$schema": "https://boj.dev/schemas/cartridge/v1.json", + "spdx": "MPL-2.0", + "copyright": "Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk>", + "name": "queues-mcp", + "version": "0.1.0", + "description": "Message queue bridge (Redis Streams, RabbitMQ, NATS)", + "domain": "messaging", + "tier": "Ayo", + "protocols": [ + "MCP", + "REST" + ], + "auth": { + "method": "none", + "env_var": null, + "credential_source": null + }, + "api": { + "base_url": "local://queues-mcp", + "content_type": "application/json" + }, + "tools": [ + { + "name": "queue_connect", + "description": "Connect to a message queue backend", + "inputSchema": { + "type": "object", + "properties": { + "backend": { + "type": "string", + "description": "Backend: redis_stream|rabbitmq|nats" + }, + "url": { + "type": "string", + "description": "Connection URL" + } + }, + "required": [ + "backend", + "url" + ] + } + }, + { + "name": "queue_publish", + "description": "Publish a message to a queue/topic", + "inputSchema": { + "type": "object", + "properties": { + "session_id": { + "type": "integer", + "description": "Session index" + }, + "topic": { + "type": "string", + "description": "Queue or topic name" + }, + "payload": { + "type": "string", + "description": "Message payload (JSON string)" + } + }, + "required": [ + "session_id", + "topic", + "payload" + ] + } + }, + { + "name": "queue_subscribe", + "description": "Subscribe to a queue/topic", + "inputSchema": { + "type": "object", + "properties": { + "session_id": { + "type": "integer", + "description": "Session index" + }, + "topic": { + "type": "string", + "description": "Queue or topic name" + } + }, + "required": [ + "session_id", + "topic" + ] + } + }, + { + "name": "queue_consume", + "description": "Consume messages from subscribed topic", + "inputSchema": { + "type": "object", + "properties": { + "session_id": { + "type": "integer", + "description": "Session index" + }, + "max_messages": { + "type": "integer", + "description": "Max messages to consume" + } + }, + "required": [ + "session_id" + ] + } + }, + { + "name": "queue_ack", + "description": "Acknowledge a message", + "inputSchema": { + "type": "object", + "properties": { + "session_id": { + "type": "integer", + "description": "Session index" + }, + "message_id": { + "type": "string", + "description": "Message ID to acknowledge" + } + }, + "required": [ + "session_id", + "message_id" + ] + } + }, + { + "name": "queue_disconnect", + "description": "Disconnect from queue backend", + "inputSchema": { + "type": "object", + "properties": { + "session_id": { + "type": "integer", + "description": "Session index" + } + }, + "required": [ + "session_id" + ] + } + } + ], + "ffi": { + "so_path": "ffi/zig-out/lib/libqueues_mcp.so", + "abi_version": "ADR-0006", + "symbols": [ + "boj_cartridge_init", + "boj_cartridge_deinit", + "boj_cartridge_name", + "boj_cartridge_version", + "boj_cartridge_invoke" + ] + } +} diff --git a/cartridges/domains/messaging/queues-mcp/ffi/README.adoc b/cartridges/domains/messaging/queues-mcp/ffi/README.adoc new file mode 100644 index 0000000..62876bd --- /dev/null +++ b/cartridges/domains/messaging/queues-mcp/ffi/README.adoc @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += queues-mcp / ffi β€” Zig FFI layer +:orientation: shallow + +== Purpose + +C-ABI exports bridging this cartridge's Idris2 ABI to the runtime. Consumed by +the adapter for protocol dispatch and by the trunk loader for mount. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” produces `zig-out/lib/libqueues.so`. +| `queues_ffi.zig` | C-ABI exports (15 exports, 7 inline tests, 310 lines). +|=== + +== Invariants + +* **Null-pointer rejection** on `[*c]const u8` arguments before any + dereference. +* **Negative return values** carry documented error meanings; zero is + success (matching the trunk error-code convention). +* **No allocator leaks** β€” every allocation path pairs with `defer free`. + +== Test/proof surface + +7 inline `test "..."` block(s) in `queues_ffi.zig`. Run with +`cd ffi && zig build test`. + +== Read-first + +. `queues_ffi.zig` β€” C-ABI signatures and guards. +. Trunk `ffi/zig/src/catalogue.zig` for how this `.so` is loaded. diff --git a/cartridges/domains/messaging/queues-mcp/ffi/build.zig b/cartridges/domains/messaging/queues-mcp/ffi/build.zig new file mode 100644 index 0000000..2dc75b4 --- /dev/null +++ b/cartridges/domains/messaging/queues-mcp/ffi/build.zig @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// Queues-MCP Cartridge β€” Zig FFI build configuration (Zig 0.15+). + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + // Shared ADR-0006 invoke-shim module (relative path up to boj-server trunk). + + const shim_mod = b.addModule("cartridge_shim", .{ + + .root_source_file = b.path("../../../ffi/zig/src/cartridge_shim.zig"), + + .target = target, + + .optimize = optimize, + + }); + + const queue_mod = b.addModule("queues_ffi", .{ + .root_source_file = b.path("queues_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + queue_mod.addImport("cartridge_shim", shim_mod); + + // ── Tests ──────────────────────────────────────────────────────── + const queue_tests = b.addTest(.{ + .root_module = queue_mod, + }); + + const run_tests = b.addRunArtifact(queue_tests); + + const test_step = b.step("test", "Run queues-mcp FFI tests"); + test_step.dependOn(&run_tests.step); + + // ── Shared library ────────────────────────────────────────────── + const lib_mod = b.createModule(.{ + .root_source_file = b.path("queues_ffi.zig"), + .target = target, + .optimize = optimize, + }); + lib_mod.addImport("cartridge_shim", shim_mod); + + const lib = b.addLibrary(.{ + .name = "queues_mcp", + .root_module = lib_mod, + .linkage = .dynamic, + }); + b.installArtifact(lib); + + const lib_step = b.step("lib", "Build shared library"); + lib_step.dependOn(&lib.step); +} diff --git a/cartridges/domains/messaging/queues-mcp/ffi/cartridge_shim.zig b/cartridges/domains/messaging/queues-mcp/ffi/cartridge_shim.zig new file mode 100644 index 0000000..0e399db --- /dev/null +++ b/cartridges/domains/messaging/queues-mcp/ffi/cartridge_shim.zig @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// cartridge_shim.zig β€” Shared helpers for the ADR-0006 five-symbol +// cartridge ABI (`boj_cartridge_init / deinit / name / version / invoke`). +// +// The shim centralises the seven-code return convention, NUL-argument +// guards, tool-name comparison, and the buffer-too-small path so each +// cartridge's `boj_cartridge_invoke` can stay short β€” typically a tool +// table plus `shim.writeResult(...)`. +// +// Cartridges import this file by relative path (no build-graph change +// needed). Example: +// +// const shim = @import("../../../ffi/zig/src/cartridge_shim.zig"); +// +// export fn boj_cartridge_invoke( +// tool_name: [*c]const u8, +// json_args: [*c]const u8, +// out_buf: [*c]u8, +// in_out_len: [*c]usize, +// ) callconv(.c) i32 { +// _ = json_args; +// if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; +// const body = if (shim.toolIs(tool_name, "foo")) "{\"result\":{}}" +// else return shim.RC_UNKNOWN_TOOL; +// return shim.writeResult(out_buf, in_out_len, body); +// } + +const std = @import("std"); + +// ── Return codes (ADR-0006 Β§Return codes) ──────────────────────────── +// +// Frozen by ADR-0006. New failure modes compose these via the error +// JSON body β€” the integer surface does not grow without a follow-up ADR. + +pub const RC_SUCCESS: i32 = 0; +pub const RC_UNKNOWN_TOOL: i32 = -1; +pub const RC_BAD_ARGS: i32 = -2; +pub const RC_BUFFER_TOO_SMALL: i32 = -3; +pub const RC_RUNTIME_ERROR: i32 = -4; +pub const RC_PANIC: i32 = -5; +pub const RC_AUTH_DENIED: i32 = -6; + +// ── Invoke-path helpers ────────────────────────────────────────────── + +/// True if any of the three mandatory `boj_cartridge_invoke` output-path +/// pointers is null. Use at the top of every invoke to short-circuit to +/// `RC_BAD_ARGS`. +pub fn invokeArgsNull( + tool_name: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) bool { + return tool_name == null or out_buf == null or in_out_len == null; +} + +/// Compare a C-NUL-terminated tool-name pointer against a Zig string +/// literal. Caller must have already verified `tool_name` is non-null +/// (usually via `invokeArgsNull`). +pub fn toolIs(tool_name: [*c]const u8, expected: []const u8) bool { + const s = std.mem.span(@as([*:0]const u8, @ptrCast(tool_name))); + return std.mem.eql(u8, s, expected); +} + +/// Copy `body` into `out_buf[0..*in_out_len]` (as a capacity) and update +/// `*in_out_len` to the number of bytes written. Returns `RC_SUCCESS`. +/// +/// If `body.len` exceeds the current capacity stored in `*in_out_len`, +/// sets `*in_out_len` to the required size and returns +/// `RC_BUFFER_TOO_SMALL` β€” the caller is then expected to re-allocate +/// and retry, per ADR-0006 Β§Memory ownership. +/// +/// Caller must have already verified that `out_buf` and `in_out_len` +/// are non-null. +pub fn writeResult( + out_buf: [*c]u8, + in_out_len: [*c]usize, + body: []const u8, +) i32 { + const cap = in_out_len.*; + if (body.len > cap) { + in_out_len.* = body.len; + return RC_BUFFER_TOO_SMALL; + } + @memcpy(out_buf[0..body.len], body); + in_out_len.* = body.len; + return RC_SUCCESS; +} + +// ── Tests ──────────────────────────────────────────────────────────── + +test "writeResult: body fits, writes and sets length" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); + try std.testing.expectEqualStrings("hello", buf[0..len]); +} + +test "writeResult: too small returns -3 and sets required length" { + var buf: [2]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_BUFFER_TOO_SMALL, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: exact-fit succeeds" { + var buf: [5]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: empty body" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, ""); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 0), len); +} + +test "toolIs: matches and rejects" { + const name: [*:0]const u8 = "foo"; + try std.testing.expect(toolIs(@ptrCast(name), "foo")); + try std.testing.expect(!toolIs(@ptrCast(name), "bar")); + try std.testing.expect(!toolIs(@ptrCast(name), "foobar")); + try std.testing.expect(!toolIs(@ptrCast(name), "fo")); +} + +test "invokeArgsNull: detects each null slot" { + var buf: [4]u8 = undefined; + var len: usize = 4; + const name: [*:0]const u8 = "x"; + try std.testing.expect(!invokeArgsNull(@ptrCast(name), &buf, &len)); + try std.testing.expect(invokeArgsNull(null, &buf, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), null, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), &buf, null)); +} diff --git a/cartridges/domains/messaging/queues-mcp/ffi/queues_ffi.zig b/cartridges/domains/messaging/queues-mcp/ffi/queues_ffi.zig new file mode 100644 index 0000000..f0452a1 --- /dev/null +++ b/cartridges/domains/messaging/queues-mcp/ffi/queues_ffi.zig @@ -0,0 +1,382 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// Queues-MCP Cartridge β€” Zig FFI bridge for message queue operations. +// +// Implements the connection/subscription state machine from SafeQueues.idr. +// Ensures no publishing to unconnected queues, prevents double-subscribe, +// and enforces ack-before-next-message consumption. + +const std = @import("std"); + +// ═══════════════════════════════════════════════════════════════════════ +// Types (must match QueuesMcp.SafeQueues encodings) +// ═══════════════════════════════════════════════════════════════════════ + +pub const QueueState = enum(c_int) { + disconnected = 0, + connected = 1, + subscribed = 2, + consuming = 3, + queue_error = 4, +}; + +pub const QueueBackend = enum(c_int) { + redis_stream = 1, + rabbitmq = 2, + nats = 3, + custom = 99, +}; + +// ═══════════════════════════════════════════════════════════════════════ +// Queue State Machine +// ═══════════════════════════════════════════════════════════════════════ + +const MAX_QUEUES: usize = 16; + +const QueueSlot = struct { + active: bool, + state: QueueState, + backend: QueueBackend, + msg_count: u64, +}; + +var queues: [MAX_QUEUES]QueueSlot = [_]QueueSlot{.{ + .active = false, + .state = .disconnected, + .backend = .redis_stream, + .msg_count = 0, +}} ** MAX_QUEUES; + +var mutex: std.Thread.Mutex = .{}; + +/// Validate a state transition (matches Idris2 canTransition). +fn isValidTransition(from: QueueState, to: QueueState) bool { + return switch (from) { + .disconnected => to == .connected, + .connected => to == .subscribed or to == .disconnected, + .subscribed => to == .consuming or to == .connected, + .consuming => to == .subscribed or to == .queue_error, + .queue_error => to == .subscribed, + }; +} + +/// Connect to a queue backend. Returns slot index or -1 on failure. +pub export fn queue_connect(backend: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + for (&queues, 0..) |*slot, i| { + if (!slot.active) { + slot.active = true; + slot.state = .connected; + slot.backend = @enumFromInt(backend); + slot.msg_count = 0; + return @intCast(i); + } + } + return -1; // No slots available +} + +/// Subscribe to a topic (transition Connected -> Subscribed). +pub export fn queue_subscribe(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + if (slot_idx < 0 or slot_idx >= MAX_QUEUES) return -1; + const idx: usize = @intCast(slot_idx); + if (!queues[idx].active) return -1; + if (!isValidTransition(queues[idx].state, .subscribed)) return -2; + + queues[idx].state = .subscribed; + return 0; +} + +/// Begin consuming a message (transition Subscribed -> Consuming). +pub export fn queue_begin_consume(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + if (slot_idx < 0 or slot_idx >= MAX_QUEUES) return -1; + const idx: usize = @intCast(slot_idx); + if (!queues[idx].active) return -1; + if (!isValidTransition(queues[idx].state, .consuming)) return -2; + + queues[idx].state = .consuming; + return 0; +} + +/// Acknowledge a consumed message (transition Consuming -> Subscribed). +/// Increments the message count. +pub export fn queue_ack(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + if (slot_idx < 0 or slot_idx >= MAX_QUEUES) return -1; + const idx: usize = @intCast(slot_idx); + if (!queues[idx].active) return -1; + if (!isValidTransition(queues[idx].state, .subscribed)) return -2; + + queues[idx].state = .subscribed; + queues[idx].msg_count += 1; + return 0; +} + +/// Publish a message (requires at least Connected state). +/// Does not change state β€” publish is a side-effect operation. +pub export fn queue_publish(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + if (slot_idx < 0 or slot_idx >= MAX_QUEUES) return -1; + const idx: usize = @intCast(slot_idx); + if (!queues[idx].active) return -1; + // Publishing requires at least connected state + if (queues[idx].state == .disconnected or queues[idx].state == .queue_error) return -2; + + queues[idx].msg_count += 1; + return 0; +} + +/// Unsubscribe from a topic (transition Subscribed -> Connected). +pub export fn queue_unsubscribe(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + if (slot_idx < 0 or slot_idx >= MAX_QUEUES) return -1; + const idx: usize = @intCast(slot_idx); + if (!queues[idx].active) return -1; + if (!isValidTransition(queues[idx].state, .connected)) return -2; + + queues[idx].state = .connected; + return 0; +} + +/// Disconnect from the queue backend (transition Connected -> Disconnected). +pub export fn queue_disconnect(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + if (slot_idx < 0 or slot_idx >= MAX_QUEUES) return -1; + const idx: usize = @intCast(slot_idx); + if (!queues[idx].active) return -1; + if (!isValidTransition(queues[idx].state, .disconnected)) return -2; + + queues[idx].active = false; + queues[idx].state = .disconnected; + queues[idx].msg_count = 0; + return 0; +} + +/// Get the state of a queue connection. +pub export fn queue_state(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + if (slot_idx < 0 or slot_idx >= MAX_QUEUES) return -1; + const idx: usize = @intCast(slot_idx); + if (!queues[idx].active) return @intFromEnum(QueueState.disconnected); + return @intFromEnum(queues[idx].state); +} + +/// Get the message count for a queue connection. +pub export fn queue_msg_count(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + if (slot_idx < 0 or slot_idx >= MAX_QUEUES) return -1; + const idx: usize = @intCast(slot_idx); + if (!queues[idx].active) return 0; + return @intCast(queues[idx].msg_count); +} + +/// Validate a state transition (C-ABI export). +pub export fn queue_can_transition(from: c_int, to: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + const f: QueueState = @enumFromInt(from); + const t: QueueState = @enumFromInt(to); + return if (isValidTransition(f, t)) 1 else 0; +} + +/// Reset all queue connections (for testing). +pub export fn queue_reset() void { + mutex.lock(); + defer mutex.unlock(); + for (&queues) |*slot| { + slot.active = false; + slot.state = .disconnected; + slot.msg_count = 0; + } +} + +// ═══════════════════════════════════════════════════════════════════════ +// Standard Cartridge Interface (loader expects these 4 C-ABI symbols) +// ═══════════════════════════════════════════════════════════════════════ + +/// Initialise the queues-mcp cartridge. Resets all queue slots. +pub export fn boj_cartridge_init() c_int { + queue_reset(); + return 0; +} + +/// Deinitialise the queues-mcp cartridge. Resets all queue slots. +pub export fn boj_cartridge_deinit() void { + queue_reset(); +} + +/// Return the cartridge name as a null-terminated C string. +pub export fn boj_cartridge_name() [*:0]const u8 { + mutex.lock(); + defer mutex.unlock(); + return "queues-mcp"; +} + +/// Return the cartridge version as a null-terminated C string. +pub export fn boj_cartridge_version() [*:0]const u8 { + mutex.lock(); + defer mutex.unlock(); + return "0.1.0"; +} + +// ═══════════════════════════════════════════════════════════════════════ +// ADR-0006 dispatch (boj_cartridge_invoke, 5th standard symbol) +// ═══════════════════════════════════════════════════════════════════════ + +const shim = @import("cartridge_shim.zig"); + +/// Dispatch the cartridge.json MCP tools. Grade D Alpha β€” each arm +/// returns a stub JSON body shaped to the tool's intended response. +export fn boj_cartridge_invoke( + tool_name: [*c]const u8, + json_args: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) callconv(.c) i32 { + _ = json_args; + if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; + + const body: []const u8 = if (shim.toolIs(tool_name, "queue_connect")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "queue_publish")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "queue_subscribe")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "queue_consume")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "queue_ack")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "queue_disconnect")) + "{\"result\":{\"status\":\"stub\"}}" +else + return shim.RC_UNKNOWN_TOOL; + + return shim.writeResult(out_buf, in_out_len, body); +} + +// ═══════════════════════════════════════════════════════════════════════ +// Tests +// ═══════════════════════════════════════════════════════════════════════ + +test "connect and disconnect" { + queue_reset(); + const slot = queue_connect(@intFromEnum(QueueBackend.redis_stream)); + try std.testing.expect(slot >= 0); + try std.testing.expectEqual(@as(c_int, @intFromEnum(QueueState.connected)), queue_state(slot)); + try std.testing.expectEqual(@as(c_int, 0), queue_disconnect(slot)); + try std.testing.expectEqual(@as(c_int, @intFromEnum(QueueState.disconnected)), queue_state(slot)); +} + +test "cannot publish to disconnected queue" { + queue_reset(); + // No queue connected β€” publish on slot 0 should fail + try std.testing.expectEqual(@as(c_int, -1), queue_publish(0)); +} + +test "cannot consume without subscription" { + queue_reset(); + const slot = queue_connect(@intFromEnum(QueueBackend.rabbitmq)); + // Connected but not subscribed β€” should fail + try std.testing.expectEqual(@as(c_int, -2), queue_begin_consume(slot)); + _ = queue_disconnect(slot); +} + +test "full consume lifecycle with message count" { + queue_reset(); + const slot = queue_connect(@intFromEnum(QueueBackend.nats)); + try std.testing.expectEqual(@as(c_int, 0), queue_subscribe(slot)); + try std.testing.expectEqual(@as(c_int, 0), queue_msg_count(slot)); + + // First consume cycle + try std.testing.expectEqual(@as(c_int, 0), queue_begin_consume(slot)); + try std.testing.expectEqual(@as(c_int, @intFromEnum(QueueState.consuming)), queue_state(slot)); + try std.testing.expectEqual(@as(c_int, 0), queue_ack(slot)); + try std.testing.expectEqual(@as(c_int, 1), queue_msg_count(slot)); + + // Second consume cycle + try std.testing.expectEqual(@as(c_int, 0), queue_begin_consume(slot)); + try std.testing.expectEqual(@as(c_int, 0), queue_ack(slot)); + try std.testing.expectEqual(@as(c_int, 2), queue_msg_count(slot)); +} + +test "publish increments message count" { + queue_reset(); + const slot = queue_connect(@intFromEnum(QueueBackend.redis_stream)); + try std.testing.expectEqual(@as(c_int, 0), queue_publish(slot)); + try std.testing.expectEqual(@as(c_int, 1), queue_msg_count(slot)); + try std.testing.expectEqual(@as(c_int, 0), queue_publish(slot)); + try std.testing.expectEqual(@as(c_int, 2), queue_msg_count(slot)); +} + +test "cannot disconnect while subscribed" { + queue_reset(); + const slot = queue_connect(@intFromEnum(QueueBackend.rabbitmq)); + _ = queue_subscribe(slot); + // Must unsubscribe before disconnect + try std.testing.expectEqual(@as(c_int, -2), queue_disconnect(slot)); + // Unsubscribe then disconnect works + try std.testing.expectEqual(@as(c_int, 0), queue_unsubscribe(slot)); + try std.testing.expectEqual(@as(c_int, 0), queue_disconnect(slot)); +} + +test "state transition validation" { + // Valid transitions + try std.testing.expectEqual(@as(c_int, 1), queue_can_transition(0, 1)); // disconnected -> connected + try std.testing.expectEqual(@as(c_int, 1), queue_can_transition(1, 2)); // connected -> subscribed + try std.testing.expectEqual(@as(c_int, 1), queue_can_transition(2, 3)); // subscribed -> consuming + try std.testing.expectEqual(@as(c_int, 1), queue_can_transition(3, 2)); // consuming -> subscribed + try std.testing.expectEqual(@as(c_int, 1), queue_can_transition(2, 1)); // subscribed -> connected + try std.testing.expectEqual(@as(c_int, 1), queue_can_transition(1, 0)); // connected -> disconnected + // Invalid transitions + try std.testing.expectEqual(@as(c_int, 0), queue_can_transition(0, 2)); // disconnected -> subscribed + try std.testing.expectEqual(@as(c_int, 0), queue_can_transition(0, 3)); // disconnected -> consuming + try std.testing.expectEqual(@as(c_int, 0), queue_can_transition(3, 0)); // consuming -> disconnected +} + +// ═══════════════════════════════════════════════════════════════════════ +// ADR-0006 invoke dispatch tests +// ═══════════════════════════════════════════════════════════════════════ + +test "invoke: each declared tool succeeds" { + var buf: [256]u8 = undefined; + const tools = [_][]const u8{ + "queue_connect", + "queue_publish", + "queue_subscribe", + "queue_consume", + "queue_ack", + "queue_disconnect", + }; + for (tools) |t| { + var len: usize = buf.len; + const rc = boj_cartridge_invoke(t.ptr, "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, 0), rc); + try std.testing.expect(std.mem.indexOf(u8, buf[0..len], "result") != null); + } +} + +test "invoke: unknown tool returns -1" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("nope", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -1), rc); +} + +test "invoke: buffer too small returns -3" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("queue_connect", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -3), rc); + try std.testing.expect(len > 4); +} diff --git a/cartridges/domains/messaging/queues-mcp/mod.js b/cartridges/domains/messaging/queues-mcp/mod.js new file mode 100644 index 0000000..2dc8268 --- /dev/null +++ b/cartridges/domains/messaging/queues-mcp/mod.js @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// queues-mcp/mod.js β€” Message queue bridge (Redis Streams, RabbitMQ, NATS) +// +// Delegates to backend at http://127.0.0.1:7738 (override with QUEUES_BACKEND_URL). + +const BASE_URL = Deno.env.get("QUEUES_BACKEND_URL") ?? "http://127.0.0.1:7738"; +const TIMEOUT_MS = 15_000; + +async function post(path, payload) { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), TIMEOUT_MS); + try { + const r = await fetch(`${BASE_URL}${path}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + signal: ctrl.signal, + }); + const data = await r.json().catch(() => ({ success: false, error: "non-JSON response" })); + return { status: r.status, data }; + } catch (e) { + if (e.name === "AbortError") return { status: 504, data: { success: false, error: "queues-mcp backend timed out" } }; + return { status: 503, data: { success: false, error: `queues-mcp backend unavailable: ${e.message}` } }; + } finally { clearTimeout(t); } +} + +async function get(path) { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), TIMEOUT_MS); + try { + const r = await fetch(`${BASE_URL}${path}`, { method: "GET", signal: ctrl.signal }); + const data = await r.json().catch(() => ({ success: false, error: "non-JSON response" })); + return { status: r.status, data }; + } catch (e) { + if (e.name === "AbortError") return { status: 504, data: { success: false, error: "queues-mcp backend timed out" } }; + return { status: 503, data: { success: false, error: `queues-mcp backend unavailable: ${e.message}` } }; + } finally { clearTimeout(t); } +} + +export async function handleTool(toolName, args) { + switch (toolName) { + case "queue_connect": + return post("/api/v1/queue_connect", args ?? {}); + case "queue_publish": + return post("/api/v1/queue_publish", args ?? {}); + case "queue_subscribe": + return post("/api/v1/queue_subscribe", args ?? {}); + case "queue_consume": + return post("/api/v1/queue_consume", args ?? {}); + case "queue_ack": + return post("/api/v1/queue_ack", args ?? {}); + case "queue_disconnect": + return post("/api/v1/queue_disconnect", args ?? {}); + default: + return { status: 404, data: { error: `Unknown tool: ${toolName}` } }; + } +} diff --git a/cartridges/domains/messaging/queues-mcp/panels/manifest.json b/cartridges/domains/messaging/queues-mcp/panels/manifest.json new file mode 100644 index 0000000..af807ca --- /dev/null +++ b/cartridges/domains/messaging/queues-mcp/panels/manifest.json @@ -0,0 +1,67 @@ +{ + "$schema": "https://panll.dev/schemas/panel-manifest/v1.json", + "spdx": "MPL-2.0", + "cartridge": "queues-mcp", + "domain": "Message Queues", + "version": "0.1.0", + "panels": [ + { + "id": "queues-status", + "title": "Queue Broker Status", + "description": "Connectivity to message brokers (RabbitMQ, NATS, Kafka, etc.)", + "type": "status-indicator", + "data_source": { + "endpoint": "/cartridge/queues-mcp/invoke", + "method": "POST", + "body": { "tool": "status" }, + "refresh_interval_ms": 5000 + }, + "widgets": [ + { + "type": "state-badge", + "field": "state", + "states": { + "ready": { "color": "#2ecc71", "icon": "inbox" }, + "degraded": { "color": "#f39c12", "icon": "alert-circle" }, + "error": { "color": "#e74c3c", "icon": "alert-triangle" } + } + }, + { "type": "text", "field": "version", "label": "Version" } + ] + }, + { + "id": "queues-overview", + "title": "Queue Overview", + "description": "Total queues, messages in flight, and consumer counts", + "type": "metric", + "data_source": { + "endpoint": "/cartridge/queues-mcp/invoke", + "method": "POST", + "body": { "tool": "queue_overview" }, + "refresh_interval_ms": 10000 + }, + "widgets": [ + { "type": "counter", "field": "total_queues", "label": "Queues", "icon": "list" }, + { "type": "counter", "field": "messages_in_flight", "label": "In Flight", "icon": "send" }, + { "type": "counter", "field": "consumers", "label": "Consumers", "icon": "users" } + ] + }, + { + "id": "queues-throughput", + "title": "Throughput", + "description": "Message publish and consume rates across all queues", + "type": "metric", + "data_source": { + "endpoint": "/cartridge/queues-mcp/invoke", + "method": "POST", + "body": { "tool": "throughput" }, + "refresh_interval_ms": 10000 + }, + "widgets": [ + { "type": "counter", "field": "publish_rate", "label": "Publish/sec", "icon": "arrow-up" }, + { "type": "counter", "field": "consume_rate", "label": "Consume/sec", "icon": "arrow-down" }, + { "type": "counter", "field": "dead_letter_count", "label": "Dead Letters", "icon": "trash-2" } + ] + } + ] +} diff --git a/cartridges/domains/multimodal/elevenlabs-mcp/cartridge.json b/cartridges/domains/multimodal/elevenlabs-mcp/cartridge.json new file mode 100644 index 0000000..5ab536f --- /dev/null +++ b/cartridges/domains/multimodal/elevenlabs-mcp/cartridge.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://boj.dev/schemas/cartridge/v1.json", + "spdx": "MPL-2.0", + "copyright": "Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk>", + "name": "elevenlabs-mcp", + "version": "0.1.0", + "status": "stub", + "description": "Text-to-speech via ElevenLabs API β€” high-quality voices, multilingual, voice cloning (premium tier), streaming output.", + "domain": "multimodal", + "tier": "Teranga", + "protocols": ["MCP", "REST"], + "auth": { "method": "api_key", "env_var": "ELEVENLABS_API_KEY", "credential_source": "ElevenLabs account β†’ Profile β†’ API Keys." }, + "api": { "base_url": "local://elevenlabs-mcp", "content_type": "application/json" }, + "tools": [ + { "name": "audio_authenticate", "description": "Store ElevenLabs API key.", "inputSchema": { "type": "object", "properties": { "api_key": { "type": "string" } }, "required": ["api_key"] } }, + { "name": "audio_synthesize", "description": "Generate speech from text. Returns audio (mp3 / pcm / opus).", "inputSchema": { "type": "object", "properties": { "text": { "type": "string" }, "voice_id": { "type": "string" }, "model_id": { "type": "string", "enum": ["eleven_multilingual_v2", "eleven_turbo_v2", "eleven_monolingual_v1"] }, "output_format": { "type": "string", "enum": ["mp3_44100_128", "pcm_44100", "opus_48000_128"] }, "stability": { "type": "number" }, "similarity_boost": { "type": "number" } }, "required": ["text", "voice_id"] } }, + { "name": "audio_list_voices", "description": "List available voices on the account (preset + custom).", "inputSchema": { "type": "object", "properties": {} } }, + { "name": "audio_clone_voice", "description": "Create a custom voice from sample audio (premium tier only).", "inputSchema": { "type": "object", "properties": { "name": { "type": "string" }, "samples": { "type": "array" }, "description": { "type": "string" } }, "required": ["name", "samples"] } } + ] +} diff --git a/cartridges/domains/multimodal/ffmpeg-mcp/cartridge.json b/cartridges/domains/multimodal/ffmpeg-mcp/cartridge.json new file mode 100644 index 0000000..f08661f --- /dev/null +++ b/cartridges/domains/multimodal/ffmpeg-mcp/cartridge.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://boj.dev/schemas/cartridge/v1.json", + "spdx": "MPL-2.0", + "copyright": "Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk>", + "name": "ffmpeg-mcp", + "version": "0.1.0", + "status": "stub", + "description": "Local FFmpeg gateway β€” probe metadata, transcode formats, extract audio, extract frames, concatenate, trim. Glue between whisper / replicate / browser screenshots. Local-only β€” requires host ffmpeg binary; not Worker-compatible.", + "domain": "multimodal", + "tier": "Teranga", + "protocols": ["MCP", "REST"], + "auth": { "method": "none", "env_var": null, "credential_source": "No auth β€” operates on local files. Sandbox via FFMPEG_ALLOWED_PATHS env if exposing to multi-tenant deployments." }, + "api": { "base_url": "local://ffmpeg-mcp", "content_type": "application/json" }, + "tools": [ + { "name": "media_probe", "description": "Probe metadata (codec, duration, streams, dimensions). Read-only.", "inputSchema": { "type": "object", "properties": { "input": { "type": "string" } }, "required": ["input"] } }, + { "name": "media_transcode", "description": "Convert between formats with optional bitrate / resolution / codec overrides.", "inputSchema": { "type": "object", "properties": { "input": { "type": "string" }, "output": { "type": "string" }, "video_codec": { "type": "string" }, "audio_codec": { "type": "string" }, "video_bitrate": { "type": "string" }, "resolution": { "type": "string" } }, "required": ["input", "output"] } }, + { "name": "media_extract_audio", "description": "Extract audio track to its own file (mp3 / wav / opus). Useful as a Whisper input prep step.", "inputSchema": { "type": "object", "properties": { "input": { "type": "string" }, "output": { "type": "string" }, "format": { "type": "string", "enum": ["mp3", "wav", "opus", "aac"] } }, "required": ["input", "output"] } }, + { "name": "media_extract_frames", "description": "Extract frames at a given interval or specific timestamps. Useful as Replicate vision-model input prep.", "inputSchema": { "type": "object", "properties": { "input": { "type": "string" }, "output_pattern": { "type": "string" }, "fps": { "type": "number" }, "timestamps": { "type": "array" } }, "required": ["input", "output_pattern"] } }, + { "name": "media_concat", "description": "Concatenate multiple files into one (same codec required, or transcode-then-concat).", "inputSchema": { "type": "object", "properties": { "inputs": { "type": "array" }, "output": { "type": "string" } }, "required": ["inputs", "output"] } }, + { "name": "media_trim", "description": "Trim/cut to a specific time range.", "inputSchema": { "type": "object", "properties": { "input": { "type": "string" }, "output": { "type": "string" }, "start": { "type": "string" }, "end": { "type": "string" }, "duration": { "type": "string" } }, "required": ["input", "output", "start"] } } + ] +} diff --git a/cartridges/domains/multimodal/replicate-mcp/cartridge.json b/cartridges/domains/multimodal/replicate-mcp/cartridge.json new file mode 100644 index 0000000..3b2ee50 --- /dev/null +++ b/cartridges/domains/multimodal/replicate-mcp/cartridge.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://boj.dev/schemas/cartridge/v1.json", + "spdx": "MPL-2.0", + "copyright": "Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk>", + "name": "replicate-mcp", + "version": "0.1.0", + "status": "stub", + "description": "Replicate hosted ML models β€” image generation (Stable Diffusion, FLUX), video (Veo, Kling), upscaling, vision (LLaVA), audio (MusicGen). Async prediction model with polling.", + "domain": "multimodal", + "tier": "Teranga", + "protocols": ["MCP", "REST"], + "auth": { "method": "api_token", "env_var": "REPLICATE_API_TOKEN", "credential_source": "Replicate account β†’ API tokens." }, + "api": { "base_url": "local://replicate-mcp", "content_type": "application/json" }, + "tools": [ + { "name": "media_authenticate", "description": "Store Replicate API token.", "inputSchema": { "type": "object", "properties": { "api_token": { "type": "string" } }, "required": ["api_token"] } }, + { "name": "media_run_model", "description": "Run a model by owner/name:version with inputs. Returns prediction_id; poll via media_get_prediction.", "inputSchema": { "type": "object", "properties": { "model": { "type": "string", "description": "Format owner/name or owner/name:version" }, "inputs": { "type": "object" }, "webhook": { "type": "string" } }, "required": ["model", "inputs"] } }, + { "name": "media_list_models", "description": "Search or list models in the Replicate catalog by collection/owner/name.", "inputSchema": { "type": "object", "properties": { "query": { "type": "string" }, "owner": { "type": "string" }, "collection": { "type": "string" } } } }, + { "name": "media_get_prediction", "description": "Get status + output of a prediction.", "inputSchema": { "type": "object", "properties": { "prediction_id": { "type": "string" } }, "required": ["prediction_id"] } }, + { "name": "media_cancel_prediction", "description": "Cancel a running prediction.", "inputSchema": { "type": "object", "properties": { "prediction_id": { "type": "string" } }, "required": ["prediction_id"] } } + ] +} diff --git a/cartridges/domains/multimodal/whisper-mcp/cartridge.json b/cartridges/domains/multimodal/whisper-mcp/cartridge.json new file mode 100644 index 0000000..464e363 --- /dev/null +++ b/cartridges/domains/multimodal/whisper-mcp/cartridge.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://boj.dev/schemas/cartridge/v1.json", + "spdx": "MPL-2.0", + "copyright": "Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk>", + "name": "whisper-mcp", + "version": "0.1.0", + "status": "stub", + "description": "Speech-to-text via OpenAI Whisper API + local whisper.cpp fallback. Transcription, language detection, optional translation to English.", + "domain": "multimodal", + "tier": "Teranga", + "protocols": ["MCP", "REST"], + "auth": { "method": "optional_api_key", "env_var": "OPENAI_API_KEY", "credential_source": "Required for API backend; local whisper.cpp backend needs no auth." }, + "api": { "base_url": "local://whisper-mcp", "content_type": "application/json" }, + "tools": [ + { "name": "audio_authenticate", "description": "Store OpenAI key (for API backend) or path to whisper.cpp binary (for local).", "inputSchema": { "type": "object", "properties": { "backend": { "type": "string", "enum": ["openai", "local"] }, "api_key": { "type": "string" }, "model_path": { "type": "string" } }, "required": ["backend"] } }, + { "name": "audio_transcribe", "description": "Transcribe audio file or URL to text. Word-level timestamps optional.", "inputSchema": { "type": "object", "properties": { "source": { "type": "string" }, "language": { "type": "string" }, "timestamps": { "type": "boolean" }, "model": { "type": "string", "enum": ["whisper-1", "tiny", "base", "small", "medium", "large", "large-v3"] } }, "required": ["source"] } }, + { "name": "audio_detect_language", "description": "Detect spoken language from a short audio sample.", "inputSchema": { "type": "object", "properties": { "source": { "type": "string" } }, "required": ["source"] } }, + { "name": "audio_translate", "description": "Transcribe and translate any source language to English.", "inputSchema": { "type": "object", "properties": { "source": { "type": "string" }, "model": { "type": "string" } }, "required": ["source"] } } + ] +} diff --git a/cartridges/domains/observability/grafana-mcp/README.adoc b/cartridges/domains/observability/grafana-mcp/README.adoc new file mode 100644 index 0000000..06731d9 --- /dev/null +++ b/cartridges/domains/observability/grafana-mcp/README.adoc @@ -0,0 +1,81 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += grafana-mcp +Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk> +:spdx: MPL-2.0 +:tier: Ayo +:domain: Monitoring +:protocols: MCP, REST + +== Overview + +Grafana monitoring cartridge for the BoJ server. Provides type-safe access to +the Grafana HTTP API for dashboard CRUD, datasource queries, alert rule listing, +annotation creation, folder browsing, and instance health checks. + +=== State Machine + +`Unauthenticated -> Authenticated` (required for all operations) + +`Authenticated -> RateLimited -> Authenticated` (normal flow) + +`Authenticated -> Error -> Unauthenticated` (error recovery) + +=== Actions (10) + +[cols="1,1"] +|=== +| Category | Actions + +| Dashboard +| SearchDashboards, GetDashboard, CreateDashboard, DeleteDashboard + +| Queries +| QueryDatasource + +| Alerts +| ListAlerts + +| Annotations +| CreateAnnotation + +| Infrastructure +| ListDatasources, ListFolders, Health +|=== + +== Architecture + +[cols="1,1,2"] +|=== +| Layer | Language | Purpose + +| ABI +| Idris2 +| Formally verified state machine with dependent-type proofs (GrafanaMcp.SafeRegistry) + +| FFI +| Zig +| C-compatible implementation with thread-safe session pool, action recording, category counting + +| Adapter +| zig +| REST bridge to BoJ unified adapter protocol +|=== + +== Building + +[source,bash] +---- +# Build FFI shared library +cd ffi && zig build + +# Run FFI tests +cd ffi && zig build test + +# Type-check ABI +cd abi && idris2 --check GrafanaMcp.SafeRegistry +---- + +== Status + +Development -- Tier 6 monitoring cartridge with dashboard, query, alert, and annotation support. diff --git a/cartridges/domains/observability/grafana-mcp/abi/GrafanaMcp/SafeRegistry.idr b/cartridges/domains/observability/grafana-mcp/abi/GrafanaMcp/SafeRegistry.idr new file mode 100644 index 0000000..015cb32 --- /dev/null +++ b/cartridges/domains/observability/grafana-mcp/abi/GrafanaMcp/SafeRegistry.idr @@ -0,0 +1,177 @@ +-- SPDX-License-Identifier: MPL-2.0 +-- Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +-- +-- GrafanaMcp.SafeRegistry β€” Type-safe ABI for grafana-mcp cartridge. +-- +-- Dependent-type state machine governing Grafana HTTP API access. +-- Encodes Bearer token auth, dashboard CRUD, datasource queries, +-- alert rule listing, annotation creation, folder browsing, and +-- health checks as compile-time invariants. +-- API: Grafana HTTP API (/api/*) +-- No unsafe escape hatches. + +module GrafanaMcp.SafeRegistry + +%default total + +-- --------------------------------------------------------------------------- +-- Authentication state machine +-- --------------------------------------------------------------------------- + +||| Session state for Grafana MCP operations. +||| Unauthenticated: no API token; no operations available. +||| Authenticated: Grafana API token active, full access. +||| RateLimited: API rate limit hit; must wait. +||| Error: unrecoverable error (invalid token, network failure). +public export +data SessionState + = Unauthenticated + | Authenticated + | RateLimited + | Error + +||| Proof that a state transition is valid. +||| Grafana requires authentication for all operations. +public export +data ValidTransition : SessionState -> SessionState -> Type where + Authenticate : ValidTransition Unauthenticated Authenticated + Deauthenticate : ValidTransition Authenticated Unauthenticated + Throttle : ValidTransition Authenticated RateLimited + Unthrottle : ValidTransition RateLimited Authenticated + AuthError : ValidTransition Authenticated Error + AnonError : ValidTransition Unauthenticated Error + RecoverToAuth : ValidTransition Error Authenticated + RecoverToAnon : ValidTransition Error Unauthenticated + +-- --------------------------------------------------------------------------- +-- C-ABI integer encoding +-- --------------------------------------------------------------------------- + +||| Encode session state as C-compatible integer for FFI boundary. +export +sessionStateToInt : SessionState -> Int +sessionStateToInt Unauthenticated = 0 +sessionStateToInt Authenticated = 1 +sessionStateToInt RateLimited = 2 +sessionStateToInt Error = 3 + +||| Decode integer back to session state. Returns Nothing for out-of-range. +export +intToSessionState : Int -> Maybe SessionState +intToSessionState 0 = Just Unauthenticated +intToSessionState 1 = Just Authenticated +intToSessionState 2 = Just RateLimited +intToSessionState 3 = Just Error +intToSessionState _ = Nothing + +||| Check if a state transition is valid (C-ABI export). +export +grafana_mcp_can_transition : Int -> Int -> Int +grafana_mcp_can_transition from to = + case (intToSessionState from, intToSessionState to) of + (Just Unauthenticated, Just Authenticated) => 1 + (Just Authenticated, Just Unauthenticated) => 1 + (Just Authenticated, Just RateLimited) => 1 + (Just RateLimited, Just Authenticated) => 1 + (Just Authenticated, Just Error) => 1 + (Just Unauthenticated, Just Error) => 1 + (Just Error, Just Authenticated) => 1 + (Just Error, Just Unauthenticated) => 1 + _ => 0 + +-- --------------------------------------------------------------------------- +-- Grafana actions +-- --------------------------------------------------------------------------- + +||| Actions available through the Grafana MCP cartridge. +||| Grouped: Search, Dashboard CRUD, Queries, Alerts, Annotations, +||| Datasources, Folders, Health. +public export +data GrafanaAction + = SearchDashboards + | GetDashboard + | CreateDashboard + | DeleteDashboard + | QueryDatasource + | ListAlerts + | CreateAnnotation + | ListDatasources + | ListFolders + | Health + +||| Whether an action requires Authenticated state. +||| All Grafana API operations require authentication. +export +actionRequiresAuth : GrafanaAction -> Bool +actionRequiresAuth _ = True + +||| Whether an action is a write/mutating operation. +export +actionIsMutating : GrafanaAction -> Bool +actionIsMutating CreateDashboard = True +actionIsMutating DeleteDashboard = True +actionIsMutating CreateAnnotation = True +actionIsMutating _ = False + +||| Encode action as C-compatible integer for FFI. +export +actionToInt : GrafanaAction -> Int +actionToInt SearchDashboards = 0 +actionToInt GetDashboard = 1 +actionToInt CreateDashboard = 2 +actionToInt DeleteDashboard = 3 +actionToInt QueryDatasource = 4 +actionToInt ListAlerts = 5 +actionToInt CreateAnnotation = 6 +actionToInt ListDatasources = 7 +actionToInt ListFolders = 8 +actionToInt Health = 9 + +||| Decode integer to Grafana action. +export +intToAction : Int -> Maybe GrafanaAction +intToAction 0 = Just SearchDashboards +intToAction 1 = Just GetDashboard +intToAction 2 = Just CreateDashboard +intToAction 3 = Just DeleteDashboard +intToAction 4 = Just QueryDatasource +intToAction 5 = Just ListAlerts +intToAction 6 = Just CreateAnnotation +intToAction 7 = Just ListDatasources +intToAction 8 = Just ListFolders +intToAction 9 = Just Health +intToAction _ = Nothing + +-- --------------------------------------------------------------------------- +-- MCP tool declarations +-- --------------------------------------------------------------------------- + +||| Tools exposed via MCP protocol for this cartridge. +public export +data McpTool + = ToolSearchDashboards + | ToolGetDashboard + | ToolCreateDashboard + | ToolDeleteDashboard + | ToolQueryDatasource + | ToolListAlerts + | ToolCreateAnnotation + | ToolListDatasources + | ToolListFolders + | ToolHealth + +||| Check if a tool requires an authenticated session. +||| All Grafana operations require authentication. +export +toolRequiresSession : McpTool -> Bool +toolRequiresSession _ = True + +||| Total tool count for this cartridge. +export +toolCount : Nat +toolCount = 10 + +||| Total action count for this cartridge. +export +actionCount : Nat +actionCount = 10 diff --git a/cartridges/domains/observability/grafana-mcp/abi/README.adoc b/cartridges/domains/observability/grafana-mcp/abi/README.adoc new file mode 100644 index 0000000..0f3345d --- /dev/null +++ b/cartridges/domains/observability/grafana-mcp/abi/README.adoc @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += grafana-mcp / abi β€” Idris2 ABI layer +:orientation: shallow + +== Purpose + +Cartridge-local protocol types for `grafana-mcp`. Defines the data, tool, and +transition vocabulary this cartridge speaks. Inherits safety lemmas and the +matrix-cell identifier scheme from the trunk ABI at `src/abi/`. + +== Files + +1 Idris2 module(s), ~177 lines total. Lead module: +`SafeRegistry.idr`. + +No `.ipkg` β€” consumed directly by wider BoJ callers; type-checks inside the +trunk `boj.ipkg`. + +== Invariants + +* `%default total` at every module top. +* Zero `believe_me`, `assert_total`, `postulate`, `Admitted`, or + `sorry` (the trunk holds the four declared `believe_me` sites; leaf + cartridges carry none). + +== Test/proof surface + +Thin module(s), no inline tests. Proof surface = type-check success under the +trunk `idris2 --check`. + +== Read-first + +. `SafeRegistry.idr` β€” cartridge-local data records and enums. +. Trunk `src/abi/Boj/Catalogue.idr` for how this cell participates in the + 2D matrix. diff --git a/cartridges/domains/observability/grafana-mcp/adapter/README.adoc b/cartridges/domains/observability/grafana-mcp/adapter/README.adoc new file mode 100644 index 0000000..41dabad --- /dev/null +++ b/cartridges/domains/observability/grafana-mcp/adapter/README.adoc @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += grafana-mcp / adapter β€” protocol bridge +:orientation: shallow + +== Purpose + +Exposes this cartridge's FFI over the wire protocols declared in +`cartridge.json`. Dispatches tool invocations to the C-ABI exports at +`../ffi/` and formats responses per protocol. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” depends on the sibling `ffi` target. +| `grafana_adapter.zig` | Protocol dispatch (204 lines). Tool catalogue matches `cartridge.json`. +| `SIDELINED-*.v.adoc` | zig predecessor(s). Not built β€” zig banned 2026-04-10. +|=== + +== Invariants + +* **Stateless** β€” all state lives behind the FFI, never in the adapter. +* **Response passthrough** β€” whatever the FFI returns goes back to the wire + unmodified (no embellishment, no silent recovery). +* **Cartridge.json is source of truth** for the tool catalogue; drift between + adapter and manifest is a CI failure. + +== Test/proof surface + +No inline tests at the adapter layer β€” protocol routing is exercised by the +trunk seam tests at `ffi/zig/src/seams.zig`. + +== Read-first + +. `grafana_adapter.zig` β€” dispatch function and per-protocol routers. +. `../cartridge.json` β€” the tool manifest the dispatcher mirrors. diff --git a/cartridges/domains/observability/grafana-mcp/adapter/build.zig b/cartridges/domains/observability/grafana-mcp/adapter/build.zig new file mode 100644 index 0000000..1e42f17 --- /dev/null +++ b/cartridges/domains/observability/grafana-mcp/adapter/build.zig @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// grafana-mcp/adapter/build.zig + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const ffi_mod = b.createModule(.{ + .root_source_file = b.path("../ffi/grafana_mcp_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + const adapter = b.addExecutable(.{ + .name = "grafana_adapter", + .root_source_file = b.path("grafana_adapter.zig"), + .target = target, + .optimize = optimize, + }); + adapter.root_module.addImport("grafana_mcp_ffi", ffi_mod); + b.installArtifact(adapter); + + const run_artifact = b.addRunArtifact(adapter); + const run_step = b.step("run", "Run the grafana-mcp adapter"); + run_step.dependOn(&run_artifact.step); + + const tests = b.addTest(.{ + .root_source_file = b.path("grafana_adapter.zig"), + .target = target, + .optimize = optimize, + }); + tests.root_module.addImport("grafana_mcp_ffi", ffi_mod); + const run_tests = b.addRunArtifact(tests); + const test_step = b.step("test", "Run grafana-mcp adapter tests"); + test_step.dependOn(&run_tests.step); +} diff --git a/cartridges/domains/observability/grafana-mcp/adapter/grafana_adapter.zig b/cartridges/domains/observability/grafana-mcp/adapter/grafana_adapter.zig new file mode 100644 index 0000000..a4026ac --- /dev/null +++ b/cartridges/domains/observability/grafana-mcp/adapter/grafana_adapter.zig @@ -0,0 +1,204 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// grafana-mcp/adapter/grafana_adapter.zig -- Unified three-protocol adapter. +// +// Replaces the banned grafana_adapter.v (zig, removed 2026-04-12). +// +// Bridges the Zig FFI (grafana_mcp_ffi.zig) to three network protocols: +// REST :9058 POST /tools/<tool> +// gRPC-compat :9059 /GrafanaMcpService/<Method> +// GraphQL :9060 POST /graphql { query: "..." } +// +// Grafana dashboards, datasources, alerts, annotations, health +// Tools: +// grafana_search_dashboards +// grafana_get_dashboard +// grafana_create_dashboard +// grafana_delete_dashboard +// grafana_query_datasource +// grafana_list_alerts +// grafana_create_annotation +// grafana_list_datasources +// grafana_list_folders +// grafana_health + +const std = @import("std"); +const ffi = @import("grafana_mcp_ffi"); + +const REST_PORT: u16 = 9058; +const GRPC_PORT: u16 = 9059; +const GQL_PORT: u16 = 9060; + +const MAX_CONN_BUF: usize = 16 * 1024; + +// ============================================================================ +// JSON response builders +// ============================================================================ + +fn okJson(buf: []u8, msg: []const u8) []u8 { + return std.fmt.bufPrint(buf, + \{{"success":true,"message":"{}"}} + , .{msg}) catch buf[0..0]; +} + +fn errJson(buf: []u8, msg: []const u8) []u8 { + return std.fmt.bufPrint(buf, + \{{"success":false,"error":"{}"}} + , .{msg}) catch buf[0..0]; +} + +fn statusJson(buf: []u8) []u8 { + return std.fmt.bufPrint(buf, + \{{"success":true,"state":"ready","service":"grafana-mcp"}} + , .{}) catch buf[0..0]; +} + +// ============================================================================ +// Tool dispatcher +// ============================================================================ + +const Response = struct { status: u16, body: []u8 }; + +fn dispatch(tool: []const u8, body: []const u8, resp: []u8) Response { + _ = body; + if (std.mem.eql(u8, tool, "grafana_search_dashboards")) return .{ .status = 200, .body = okJson(resp, "grafana_search_dashboards forwarded to backend") }; + if (std.mem.eql(u8, tool, "grafana_get_dashboard")) return .{ .status = 200, .body = okJson(resp, "grafana_get_dashboard forwarded to backend") }; + if (std.mem.eql(u8, tool, "grafana_create_dashboard")) return .{ .status = 200, .body = okJson(resp, "grafana_create_dashboard forwarded to backend") }; + if (std.mem.eql(u8, tool, "grafana_delete_dashboard")) return .{ .status = 200, .body = okJson(resp, "grafana_delete_dashboard forwarded to backend") }; + if (std.mem.eql(u8, tool, "grafana_query_datasource")) return .{ .status = 200, .body = okJson(resp, "grafana_query_datasource forwarded to backend") }; + if (std.mem.eql(u8, tool, "grafana_list_alerts")) return .{ .status = 200, .body = okJson(resp, "grafana_list_alerts forwarded to backend") }; + if (std.mem.eql(u8, tool, "grafana_create_annotation")) return .{ .status = 200, .body = okJson(resp, "grafana_create_annotation forwarded to backend") }; + if (std.mem.eql(u8, tool, "grafana_list_datasources")) return .{ .status = 200, .body = okJson(resp, "grafana_list_datasources forwarded to backend") }; + if (std.mem.eql(u8, tool, "grafana_list_folders")) return .{ .status = 200, .body = okJson(resp, "grafana_list_folders forwarded to backend") }; + if (std.mem.eql(u8, tool, "grafana_health")) return .{ .status = 200, .body = okJson(resp, "grafana_health forwarded to backend") }; + if (std.mem.eql(u8, tool, "status") or std.mem.eql(u8, tool, "health")) + return .{ .status = 200, .body = statusJson(resp) }; + return .{ .status = 404, .body = errJson(resp, "Unknown tool") }; +} + +// ============================================================================ +// REST handler +// ============================================================================ + +fn dispatchRest(path: []const u8, body: []const u8, resp: []u8) Response { + if (std.mem.startsWith(u8, path, "/tools/")) { + return dispatch(path["/tools/".len..], body, resp); + } + if (std.mem.eql(u8, path, "/status") or std.mem.eql(u8, path, "/health")) { + return .{ .status = 200, .body = statusJson(resp) }; + } + return .{ .status = 404, .body = errJson(resp, "Not found") }; +} + +// ============================================================================ +// gRPC-compat handler +// ============================================================================ + +fn dispatchGrpc(path: []const u8, body: []const u8, resp: []u8) Response { + const prefix = "/GrafanaMcpService/"; + if (!std.mem.startsWith(u8, path, prefix)) + return .{ .status = 404, .body = errJson(resp, "Not a recognized gRPC path") }; + const method = path[prefix.len..]; + const tool = blk: { + if (std.mem.eql(u8, method, "GrafanaSearchDashboards")) break :blk "grafana_search_dashboards"; + if (std.mem.eql(u8, method, "GrafanaGetDashboard")) break :blk "grafana_get_dashboard"; + if (std.mem.eql(u8, method, "GrafanaCreateDashboard")) break :blk "grafana_create_dashboard"; + if (std.mem.eql(u8, method, "GrafanaDeleteDashboard")) break :blk "grafana_delete_dashboard"; + if (std.mem.eql(u8, method, "GrafanaQueryDatasource")) break :blk "grafana_query_datasource"; + if (std.mem.eql(u8, method, "GrafanaListAlerts")) break :blk "grafana_list_alerts"; + if (std.mem.eql(u8, method, "GrafanaCreateAnnotation")) break :blk "grafana_create_annotation"; + if (std.mem.eql(u8, method, "GrafanaListDatasources")) break :blk "grafana_list_datasources"; + if (std.mem.eql(u8, method, "GrafanaListFolders")) break :blk "grafana_list_folders"; + if (std.mem.eql(u8, method, "GrafanaHealth")) break :blk "grafana_health"; + return .{ .status = 404, .body = errJson(resp, "Unknown gRPC method") }; + }; + return dispatch(tool, body, resp); +} + +// ============================================================================ +// GraphQL handler +// ============================================================================ + +fn dispatchGraphql(body: []const u8, resp: []u8) Response { + if (std.mem.indexOf(u8, body, "__schema") != null) + return .{ .status = 200, .body = okJson(resp, "schema introspection not yet supported") }; + if (std.mem.indexOf(u8, body, "search_dashboards") != null) return dispatch("grafana_search_dashboards", body, resp); + if (std.mem.indexOf(u8, body, "get_dashboard") != null) return dispatch("grafana_get_dashboard", body, resp); + if (std.mem.indexOf(u8, body, "create_dashboard") != null) return dispatch("grafana_create_dashboard", body, resp); + if (std.mem.indexOf(u8, body, "delete_dashboard") != null) return dispatch("grafana_delete_dashboard", body, resp); + if (std.mem.indexOf(u8, body, "query_datasource") != null) return dispatch("grafana_query_datasource", body, resp); + if (std.mem.indexOf(u8, body, "list_alerts") != null) return dispatch("grafana_list_alerts", body, resp); + if (std.mem.indexOf(u8, body, "create_annotation") != null) return dispatch("grafana_create_annotation", body, resp); + if (std.mem.indexOf(u8, body, "list_datasources") != null) return dispatch("grafana_list_datasources", body, resp); + if (std.mem.indexOf(u8, body, "list_folders") != null) return dispatch("grafana_list_folders", body, resp); + if (std.mem.indexOf(u8, body, "health") != null) return dispatch("grafana_health", body, resp); + return .{ .status = 200, .body = errJson(resp, "Unrecognised GraphQL operation") }; +} + +// ============================================================================ +// HTTP/1.1 connection handler +// ============================================================================ + +const Protocol = enum { rest, grpc, graphql }; + +fn handleConnection(conn: std.net.Server.Connection, proto: Protocol) void { + defer conn.stream.close(); + var in_buf: [MAX_CONN_BUF]u8 = undefined; + const n = conn.stream.read(&in_buf) catch return; + const req = in_buf[0..n]; + + var path: []const u8 = "/"; + var body: []const u8 = ""; + if (n > 4) { + const line_end = std.mem.indexOf(u8, req, "\r\n") orelse req.len; + const first_line = req[0..line_end]; + const sp1 = std.mem.indexOfScalar(u8, first_line, ' ') orelse 0; + const rest_of = first_line[sp1 + 1 ..]; + const sp2 = std.mem.indexOfScalar(u8, rest_of, ' ') orelse rest_of.len; + path = rest_of[0..sp2]; + const body_sep = std.mem.indexOf(u8, req, "\r\n\r\n") orelse n; + body = req[@min(body_sep + 4, n)..]; + } + + var resp_buf: [MAX_CONN_BUF]u8 = undefined; + const result = switch (proto) { + .rest => dispatchRest(path, body, &resp_buf), + .grpc => dispatchGrpc(path, body, &resp_buf), + .graphql => dispatchGraphql(body, &resp_buf), + }; + + const content_type = switch (proto) { + .rest => "application/json", + .grpc => "application/grpc+json", + .graphql => "application/json", + }; + + var hdr_buf: [256]u8 = undefined; + const hdr = std.fmt.bufPrint(&hdr_buf, + "HTTP/1.1 {d} OK\r\nContent-Type: {s}\r\nContent-Length: {d}\r\nConnection: close\r\n\r\n", + .{ result.status, content_type, result.body.len }, + ) catch return; + _ = conn.stream.writeAll(hdr) catch return; + _ = conn.stream.writeAll(result.body) catch return; +} + +fn listenLoop(port: u16, proto: Protocol) void { + const addr = std.net.Address.initIp4(.{ 127, 0, 0, 1 }, port) catch return; + var server = addr.listen(.{ .reuse_address = true }) catch return; + defer server.deinit(); + while (true) { + const conn = server.accept() catch continue; + handleConnection(conn, proto); + } +} + +pub fn main() !void { + ffi.grafana_init(); + const rest_thread = try std.Thread.spawn(.{}, listenLoop, .{ REST_PORT, Protocol.rest }); + const grpc_thread = try std.Thread.spawn(.{}, listenLoop, .{ GRPC_PORT, Protocol.grpc }); + const gql_thread = try std.Thread.spawn(.{}, listenLoop, .{ GQL_PORT, Protocol.graphql }); + rest_thread.join(); + grpc_thread.join(); + gql_thread.join(); +} diff --git a/cartridges/domains/observability/grafana-mcp/benchmarks/quick-bench.sh b/cartridges/domains/observability/grafana-mcp/benchmarks/quick-bench.sh new file mode 100755 index 0000000..863c616 --- /dev/null +++ b/cartridges/domains/observability/grafana-mcp/benchmarks/quick-bench.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Benchmarks for grafana-mcp cartridge. +set -euo pipefail + +echo "=== grafana-mcp benchmarks ===" + +cd "$(dirname "${BASH_SOURCE[0]}")/../ffi" + +# Build with ReleaseFast for benchmarking +zig build -Doptimize=ReleaseFast 2>&1 + +echo "Session open/close cycle (1000 iterations):" +time for i in $(seq 1 1000); do + true +done + +echo "" +echo "Benchmark placeholder -- implement real benchmarks in Zig test blocks" +echo "or via the zig adapter HTTP benchmark tool." diff --git a/cartridges/domains/observability/grafana-mcp/cartridge.json b/cartridges/domains/observability/grafana-mcp/cartridge.json new file mode 100644 index 0000000..96933ab --- /dev/null +++ b/cartridges/domains/observability/grafana-mcp/cartridge.json @@ -0,0 +1,231 @@ +{ + "$schema": "https://boj.dev/schemas/cartridge/v1.json", + "spdx": "MPL-2.0", + "copyright": "Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath)", + "name": "grafana-mcp", + "version": "0.1.0", + "description": "Grafana monitoring cartridge -- dashboard CRUD, panel queries, alert rule management, annotation creation, datasource listing, folder browsing, org info, snapshot management, health checks, and search via the Grafana HTTP API", + "domain": "Monitoring", + "tier": "Ayo", + "protocols": [ + "MCP", + "REST" + ], + "auth": { + "method": "bearer_token", + "env_var": "GRAFANA_API_TOKEN", + "credential_source": "vault-mcp" + }, + "api": { + "base_url": "https://grafana.example.com/api", + "content_type": "application/json" + }, + "tools": [ + { + "name": "grafana_search_dashboards", + "description": "Search Grafana dashboards by query string, tag, or folder", + "inputSchema": { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "Search query (dashboard title, description)" + }, + "tag": { + "type": "string", + "description": "Filter by tag" + }, + "folder_id": { + "type": "number", + "description": "Filter by folder ID" + }, + "limit": { + "type": "number", + "description": "Max results (default 25)" + } + } + } + }, + { + "name": "grafana_get_dashboard", + "description": "Get a Grafana dashboard by UID including all panels and settings", + "inputSchema": { + "type": "object", + "properties": { + "uid": { + "type": "string", + "description": "Dashboard UID" + } + }, + "required": [ + "uid" + ] + } + }, + { + "name": "grafana_create_dashboard", + "description": "Create or update a Grafana dashboard from a JSON model", + "inputSchema": { + "type": "object", + "properties": { + "dashboard": { + "type": "object", + "description": "Dashboard JSON model" + }, + "folder_uid": { + "type": "string", + "description": "Target folder UID" + }, + "overwrite": { + "type": "boolean", + "description": "Overwrite if exists (default false)" + } + }, + "required": [ + "dashboard" + ] + } + }, + { + "name": "grafana_delete_dashboard", + "description": "Delete a Grafana dashboard by UID", + "inputSchema": { + "type": "object", + "properties": { + "uid": { + "type": "string", + "description": "Dashboard UID to delete" + } + }, + "required": [ + "uid" + ] + } + }, + { + "name": "grafana_query_datasource", + "description": "Execute a query against a Grafana datasource (Prometheus, InfluxDB, etc.)", + "inputSchema": { + "type": "object", + "properties": { + "datasource_uid": { + "type": "string", + "description": "Datasource UID" + }, + "query": { + "type": "string", + "description": "Query expression (PromQL, InfluxQL, etc.)" + }, + "from": { + "type": "string", + "description": "Start time (ISO 8601 or relative e.g. 'now-1h')" + }, + "to": { + "type": "string", + "description": "End time (ISO 8601 or relative e.g. 'now')" + } + }, + "required": [ + "datasource_uid", + "query" + ] + } + }, + { + "name": "grafana_list_alerts", + "description": "List Grafana alert rules with optional state filter", + "inputSchema": { + "type": "object", + "properties": { + "state": { + "type": "string", + "description": "Filter by state: alerting, pending, ok, no_data" + }, + "folder_uid": { + "type": "string", + "description": "Filter by folder UID" + }, + "limit": { + "type": "number", + "description": "Max results" + } + } + } + }, + { + "name": "grafana_create_annotation", + "description": "Create a Grafana annotation on a dashboard or globally", + "inputSchema": { + "type": "object", + "properties": { + "text": { + "type": "string", + "description": "Annotation text" + }, + "dashboard_uid": { + "type": "string", + "description": "Dashboard UID (optional, global if omitted)" + }, + "panel_id": { + "type": "number", + "description": "Panel ID within dashboard" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Tags for the annotation" + }, + "time": { + "type": "number", + "description": "Epoch milliseconds (default: now)" + } + }, + "required": [ + "text" + ] + } + }, + { + "name": "grafana_list_datasources", + "description": "List all configured Grafana datasources with type and status", + "inputSchema": { + "type": "object", + "properties": {} + } + }, + { + "name": "grafana_list_folders", + "description": "List Grafana dashboard folders", + "inputSchema": { + "type": "object", + "properties": { + "limit": { + "type": "number", + "description": "Max results" + } + } + } + }, + { + "name": "grafana_health", + "description": "Check Grafana instance health and version information", + "inputSchema": { + "type": "object", + "properties": {} + } + } + ], + "ffi": { + "so_path": "ffi/zig-out/lib/libgrafana_mcp.so", + "abi_version": "ADR-0006", + "symbols": [ + "boj_cartridge_init", + "boj_cartridge_deinit", + "boj_cartridge_name", + "boj_cartridge_version", + "boj_cartridge_invoke" + ] + } +} diff --git a/cartridges/domains/observability/grafana-mcp/ffi/README.adoc b/cartridges/domains/observability/grafana-mcp/ffi/README.adoc new file mode 100644 index 0000000..9cb2be2 --- /dev/null +++ b/cartridges/domains/observability/grafana-mcp/ffi/README.adoc @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += grafana-mcp / ffi β€” Zig FFI layer +:orientation: shallow + +== Purpose + +C-ABI exports bridging this cartridge's Idris2 ABI to the runtime. Consumed by +the adapter for protocol dispatch and by the trunk loader for mount. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” produces `zig-out/lib/libgrafana.so`. +| `grafana_mcp_ffi.zig` | C-ABI exports (14 exports, 6 inline tests, 374 lines). +|=== + +== Invariants + +* **Null-pointer rejection** on `[*c]const u8` arguments before any + dereference. +* **Negative return values** carry documented error meanings; zero is + success (matching the trunk error-code convention). +* **No allocator leaks** β€” every allocation path pairs with `defer free`. + +== Test/proof surface + +6 inline `test "..."` block(s) in `grafana_mcp_ffi.zig`. Run with +`cd ffi && zig build test`. + +== Read-first + +. `grafana_mcp_ffi.zig` β€” C-ABI signatures and guards. +. Trunk `ffi/zig/src/catalogue.zig` for how this `.so` is loaded. diff --git a/cartridges/domains/observability/grafana-mcp/ffi/build.zig b/cartridges/domains/observability/grafana-mcp/ffi/build.zig new file mode 100644 index 0000000..1ec3df2 --- /dev/null +++ b/cartridges/domains/observability/grafana-mcp/ffi/build.zig @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + // Shared ADR-0006 invoke-shim module (relative path up to boj-server trunk). + + const shim_mod = b.addModule("cartridge_shim", .{ + + .root_source_file = b.path("../../../ffi/zig/src/cartridge_shim.zig"), + + .target = target, + + .optimize = optimize, + + }); + + const ffi_mod = b.addModule("grafana_mcp", .{ + .root_source_file = b.path("grafana_mcp_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + ffi_mod.addImport("cartridge_shim", shim_mod); + + const lib = b.addLibrary(.{ + .name = "grafana_mcp", + .root_module = ffi_mod, + .linkage = .dynamic, + }); + lib.linkLibC(); + b.installArtifact(lib); + + const tests = b.addTest(.{ .root_module = ffi_mod }); + tests.linkLibC(); + const run_tests = b.addRunArtifact(tests); + const test_step = b.step("test", "Run FFI tests"); + test_step.dependOn(&run_tests.step); +} + diff --git a/cartridges/domains/observability/grafana-mcp/ffi/cartridge_shim.zig b/cartridges/domains/observability/grafana-mcp/ffi/cartridge_shim.zig new file mode 100644 index 0000000..0e399db --- /dev/null +++ b/cartridges/domains/observability/grafana-mcp/ffi/cartridge_shim.zig @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// cartridge_shim.zig β€” Shared helpers for the ADR-0006 five-symbol +// cartridge ABI (`boj_cartridge_init / deinit / name / version / invoke`). +// +// The shim centralises the seven-code return convention, NUL-argument +// guards, tool-name comparison, and the buffer-too-small path so each +// cartridge's `boj_cartridge_invoke` can stay short β€” typically a tool +// table plus `shim.writeResult(...)`. +// +// Cartridges import this file by relative path (no build-graph change +// needed). Example: +// +// const shim = @import("../../../ffi/zig/src/cartridge_shim.zig"); +// +// export fn boj_cartridge_invoke( +// tool_name: [*c]const u8, +// json_args: [*c]const u8, +// out_buf: [*c]u8, +// in_out_len: [*c]usize, +// ) callconv(.c) i32 { +// _ = json_args; +// if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; +// const body = if (shim.toolIs(tool_name, "foo")) "{\"result\":{}}" +// else return shim.RC_UNKNOWN_TOOL; +// return shim.writeResult(out_buf, in_out_len, body); +// } + +const std = @import("std"); + +// ── Return codes (ADR-0006 Β§Return codes) ──────────────────────────── +// +// Frozen by ADR-0006. New failure modes compose these via the error +// JSON body β€” the integer surface does not grow without a follow-up ADR. + +pub const RC_SUCCESS: i32 = 0; +pub const RC_UNKNOWN_TOOL: i32 = -1; +pub const RC_BAD_ARGS: i32 = -2; +pub const RC_BUFFER_TOO_SMALL: i32 = -3; +pub const RC_RUNTIME_ERROR: i32 = -4; +pub const RC_PANIC: i32 = -5; +pub const RC_AUTH_DENIED: i32 = -6; + +// ── Invoke-path helpers ────────────────────────────────────────────── + +/// True if any of the three mandatory `boj_cartridge_invoke` output-path +/// pointers is null. Use at the top of every invoke to short-circuit to +/// `RC_BAD_ARGS`. +pub fn invokeArgsNull( + tool_name: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) bool { + return tool_name == null or out_buf == null or in_out_len == null; +} + +/// Compare a C-NUL-terminated tool-name pointer against a Zig string +/// literal. Caller must have already verified `tool_name` is non-null +/// (usually via `invokeArgsNull`). +pub fn toolIs(tool_name: [*c]const u8, expected: []const u8) bool { + const s = std.mem.span(@as([*:0]const u8, @ptrCast(tool_name))); + return std.mem.eql(u8, s, expected); +} + +/// Copy `body` into `out_buf[0..*in_out_len]` (as a capacity) and update +/// `*in_out_len` to the number of bytes written. Returns `RC_SUCCESS`. +/// +/// If `body.len` exceeds the current capacity stored in `*in_out_len`, +/// sets `*in_out_len` to the required size and returns +/// `RC_BUFFER_TOO_SMALL` β€” the caller is then expected to re-allocate +/// and retry, per ADR-0006 Β§Memory ownership. +/// +/// Caller must have already verified that `out_buf` and `in_out_len` +/// are non-null. +pub fn writeResult( + out_buf: [*c]u8, + in_out_len: [*c]usize, + body: []const u8, +) i32 { + const cap = in_out_len.*; + if (body.len > cap) { + in_out_len.* = body.len; + return RC_BUFFER_TOO_SMALL; + } + @memcpy(out_buf[0..body.len], body); + in_out_len.* = body.len; + return RC_SUCCESS; +} + +// ── Tests ──────────────────────────────────────────────────────────── + +test "writeResult: body fits, writes and sets length" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); + try std.testing.expectEqualStrings("hello", buf[0..len]); +} + +test "writeResult: too small returns -3 and sets required length" { + var buf: [2]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_BUFFER_TOO_SMALL, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: exact-fit succeeds" { + var buf: [5]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: empty body" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, ""); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 0), len); +} + +test "toolIs: matches and rejects" { + const name: [*:0]const u8 = "foo"; + try std.testing.expect(toolIs(@ptrCast(name), "foo")); + try std.testing.expect(!toolIs(@ptrCast(name), "bar")); + try std.testing.expect(!toolIs(@ptrCast(name), "foobar")); + try std.testing.expect(!toolIs(@ptrCast(name), "fo")); +} + +test "invokeArgsNull: detects each null slot" { + var buf: [4]u8 = undefined; + var len: usize = 4; + const name: [*:0]const u8 = "x"; + try std.testing.expect(!invokeArgsNull(@ptrCast(name), &buf, &len)); + try std.testing.expect(invokeArgsNull(null, &buf, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), null, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), &buf, null)); +} diff --git a/cartridges/domains/observability/grafana-mcp/ffi/grafana_mcp_ffi.zig b/cartridges/domains/observability/grafana-mcp/ffi/grafana_mcp_ffi.zig new file mode 100644 index 0000000..d6b4d2a --- /dev/null +++ b/cartridges/domains/observability/grafana-mcp/ffi/grafana_mcp_ffi.zig @@ -0,0 +1,483 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// grafana_mcp_ffi.zig β€” C-ABI FFI implementation for grafana-mcp cartridge. +// +// Implements the state machine defined in GrafanaMcp.SafeRegistry (Idris2 ABI). +// State machine: Unauthenticated | Authenticated | RateLimited | Error +// Auth: Bearer token required for all Grafana API operations. +// Actions: SearchDashboards, GetDashboard, CreateDashboard, DeleteDashboard, +// QueryDatasource, ListAlerts, CreateAnnotation, ListDatasources, +// ListFolders, Health +// Thread-safe via std.Thread.Mutex. Fixed-size session pool, no heap allocations. + +const std = @import("std"); + +// --------------------------------------------------------------------------- +// State machine (matches Idris2 ABI SessionState exactly) +// --------------------------------------------------------------------------- + +/// Session authentication/lifecycle state. +/// 0 = Unauthenticated, 1 = Authenticated, 2 = RateLimited, 3 = Error. +pub const SessionState = enum(c_int) { + unauthenticated = 0, + authenticated = 1, + rate_limited = 2, + err = 3, +}; + +/// Grafana action identifiers matching Idris2 GrafanaAction encoding. +pub const GrafanaAction = enum(c_int) { + search_dashboards = 0, + get_dashboard = 1, + create_dashboard = 2, + delete_dashboard = 3, + query_datasource = 4, + list_alerts = 5, + create_annotation = 6, + list_datasources = 7, + list_folders = 8, + health = 9, +}; + +/// Check valid state transitions per the Idris2 ValidTransition proof. +/// Grafana requires auth for all ops, but we allow anonymous -> auth transitions. +fn isValidTransition(from: SessionState, to: SessionState) bool { + return switch (from) { + .unauthenticated => to == .authenticated or to == .err, + .authenticated => to == .unauthenticated or to == .rate_limited or to == .err, + .rate_limited => to == .authenticated, + .err => to == .authenticated or to == .unauthenticated, + }; +} + +// --------------------------------------------------------------------------- +// Session slots (thread-safe, fixed-size pool) +// --------------------------------------------------------------------------- + +const MAX_SESSIONS: usize = 16; + +const SessionSlot = struct { + active: bool = false, + state: SessionState = .unauthenticated, + api_call_count: u64 = 0, + last_action: c_int = -1, + dashboard_ops: u32 = 0, + query_count: u32 = 0, + alert_checks: u32 = 0, +}; + +var sessions: [MAX_SESSIONS]SessionSlot = .{SessionSlot{}} ** MAX_SESSIONS; +var mutex: std.Thread.Mutex = .{}; + +// --------------------------------------------------------------------------- +// C-ABI exports β€” state machine +// --------------------------------------------------------------------------- + +/// Check if a state transition is valid. Returns 1 (valid) or 0 (invalid). +pub export fn grafana_mcp_can_transition(from: c_int, to: c_int) c_int { + const f = std.meta.intToEnum(SessionState, from) catch return 0; + const t = std.meta.intToEnum(SessionState, to) catch return 0; + return if (isValidTransition(f, t)) 1 else 0; +} + +/// Open an authenticated session. Returns slot index (>= 0) or error (< 0). +/// Error codes: -1 = no free slots. +pub export fn grafana_mcp_authenticate(dummy: c_int) c_int { + _ = dummy; + mutex.lock(); + defer mutex.unlock(); + + for (&sessions, 0..) |*slot, idx| { + if (!slot.active) { + slot.active = true; + slot.state = .authenticated; + slot.api_call_count = 0; + slot.last_action = -1; + slot.dashboard_ops = 0; + slot.query_count = 0; + slot.alert_checks = 0; + return @intCast(idx); + } + } + return -1; +} + +/// Close a session. Returns 0 on success. +/// Error codes: -1 = invalid slot. +pub export fn grafana_mcp_close(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + + sessions[idx] = SessionSlot{}; + return 0; +} + +/// Get current state of a session. Returns state int or -1 if invalid. +pub export fn grafana_mcp_session_state(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + return @intFromEnum(slot.state); +} + +/// Signal rate limiting on a session. Returns 0 on success. +pub export fn grafana_mcp_throttle(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + if (!isValidTransition(slot.state, .rate_limited)) return -2; + + sessions[idx].state = .rate_limited; + return 0; +} + +/// Clear rate limiting. Returns 0 on success. +pub export fn grafana_mcp_unthrottle(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + if (!isValidTransition(slot.state, .authenticated)) return -2; + + sessions[idx].state = .authenticated; + return 0; +} + +/// Signal an error on a session. Returns 0 on success. +pub export fn grafana_mcp_signal_error(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + if (!isValidTransition(slot.state, .err)) return -2; + + sessions[idx].state = .err; + return 0; +} + +// --------------------------------------------------------------------------- +// C-ABI exports β€” action recording and metrics +// --------------------------------------------------------------------------- + +/// Record an API call on a session. Returns 0 on success. +/// Error codes: -1 = invalid slot, -2 = rate limited/error state, -3 = invalid action. +pub export fn grafana_mcp_record_call(slot_idx: c_int, action: c_int) c_int { + const act = std.meta.intToEnum(GrafanaAction, action) catch return -3; + + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + if (slot.state == .rate_limited) return -2; + if (slot.state == .err) return -2; + if (slot.state == .unauthenticated) return -2; + + sessions[idx].api_call_count += 1; + sessions[idx].last_action = action; + + // Track category-specific counts + switch (act) { + .search_dashboards, .get_dashboard, .create_dashboard, .delete_dashboard => sessions[idx].dashboard_ops += 1, + .query_datasource => sessions[idx].query_count += 1, + .list_alerts => sessions[idx].alert_checks += 1, + else => {}, + } + + return 0; +} + +/// Get API call count for a session. Returns count or -1 if invalid. +pub export fn grafana_mcp_call_count(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + return @intCast(slot.api_call_count); +} + +/// Get dashboard operation count. Returns count or -1 if invalid. +pub export fn grafana_mcp_dashboard_op_count(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + return @intCast(slot.dashboard_ops); +} + +/// Get datasource query count. Returns count or -1 if invalid. +pub export fn grafana_mcp_query_count(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + return @intCast(slot.query_count); +} + +/// Get alert check count. Returns count or -1 if invalid. +pub export fn grafana_mcp_alert_check_count(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + return @intCast(slot.alert_checks); +} + +/// Get total action count. Always returns 10. +pub export fn grafana_mcp_action_count() c_int { + return 10; +} + +/// Reset all sessions (test/debug use only). +pub export fn grafana_mcp_reset() void { + mutex.lock(); + defer mutex.unlock(); + sessions = .{SessionSlot{}} ** MAX_SESSIONS; +} + +// --------------------------------------------------------------------------- +// ═══════════════════════════════════════════════════════════════════════ +// Standard ABI (ADR-0005 four symbols + ADR-0006 invoke) +// ═══════════════════════════════════════════════════════════════════════ + +const shim = @import("cartridge_shim.zig"); + +const CARTRIDGE_NAME_PTR: [*:0]const u8 = "grafana-mcp"; +const CARTRIDGE_VERSION_PTR: [*:0]const u8 = "0.1.0"; + +export fn boj_cartridge_init() callconv(.c) c_int { + return 0; +} + +export fn boj_cartridge_deinit() callconv(.c) void {} + +export fn boj_cartridge_name() callconv(.c) [*:0]const u8 { + return CARTRIDGE_NAME_PTR; +} + +export fn boj_cartridge_version() callconv(.c) [*:0]const u8 { + return CARTRIDGE_VERSION_PTR; +} + +/// Dispatch the cartridge.json MCP tools. Grade D Alpha stubs. +export fn boj_cartridge_invoke( + tool_name: [*c]const u8, + json_args: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) callconv(.c) i32 { + _ = json_args; + if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; + + const body: []const u8 = if (shim.toolIs(tool_name, "grafana_search_dashboards")) + "{\"result\":{\"matches\":[],\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "grafana_get_dashboard")) + "{\"result\":{\"metadata\":{},\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "grafana_create_dashboard")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "grafana_delete_dashboard")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "grafana_query_datasource")) + "{\"result\":{\"matches\":[],\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "grafana_list_alerts")) + "{\"result\":{\"items\":[],\"count\":0,\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "grafana_create_annotation")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "grafana_list_datasources")) + "{\"result\":{\"items\":[],\"count\":0,\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "grafana_list_folders")) + "{\"result\":{\"items\":[],\"count\":0,\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "grafana_health")) + "{\"result\":{\"health\":\"healthy\",\"status\":\"stub\"}}" +else + return shim.RC_UNKNOWN_TOOL; + + return shim.writeResult(out_buf, in_out_len, body); +} + +// Tests +// --------------------------------------------------------------------------- + +test "authenticated session lifecycle" { + grafana_mcp_reset(); + + const slot = grafana_mcp_authenticate(0); + try std.testing.expect(slot >= 0); + + // Should be authenticated (1) + try std.testing.expectEqual(@as(c_int, 1), grafana_mcp_session_state(slot)); + + // Record a dashboard search (action 0) + try std.testing.expectEqual(@as(c_int, 0), grafana_mcp_record_call(slot, 0)); + try std.testing.expectEqual(@as(c_int, 1), grafana_mcp_call_count(slot)); + try std.testing.expectEqual(@as(c_int, 1), grafana_mcp_dashboard_op_count(slot)); + + // Close + try std.testing.expectEqual(@as(c_int, 0), grafana_mcp_close(slot)); +} + +test "rate limiting flow" { + grafana_mcp_reset(); + + const slot = grafana_mcp_authenticate(0); + try std.testing.expect(slot >= 0); + + // Throttle + try std.testing.expectEqual(@as(c_int, 0), grafana_mcp_throttle(slot)); + try std.testing.expectEqual(@as(c_int, 2), grafana_mcp_session_state(slot)); + + // Cannot invoke while rate limited + try std.testing.expectEqual(@as(c_int, -2), grafana_mcp_record_call(slot, 0)); + + // Unthrottle + try std.testing.expectEqual(@as(c_int, 0), grafana_mcp_unthrottle(slot)); + try std.testing.expectEqual(@as(c_int, 1), grafana_mcp_session_state(slot)); +} + +test "error and recovery" { + grafana_mcp_reset(); + + const slot = grafana_mcp_authenticate(0); + try std.testing.expect(slot >= 0); + + try std.testing.expectEqual(@as(c_int, 0), grafana_mcp_signal_error(slot)); + try std.testing.expectEqual(@as(c_int, 3), grafana_mcp_session_state(slot)); + + try std.testing.expectEqual(@as(c_int, -2), grafana_mcp_record_call(slot, 0)); + + try std.testing.expectEqual(@as(c_int, 0), grafana_mcp_close(slot)); +} + +test "category counting" { + grafana_mcp_reset(); + + const slot = grafana_mcp_authenticate(0); + try std.testing.expect(slot >= 0); + + // SearchDashboards (0) + try std.testing.expectEqual(@as(c_int, 0), grafana_mcp_record_call(slot, 0)); + // GetDashboard (1) + try std.testing.expectEqual(@as(c_int, 0), grafana_mcp_record_call(slot, 1)); + // QueryDatasource (4) + try std.testing.expectEqual(@as(c_int, 0), grafana_mcp_record_call(slot, 4)); + // ListAlerts (5) + try std.testing.expectEqual(@as(c_int, 0), grafana_mcp_record_call(slot, 5)); + + try std.testing.expectEqual(@as(c_int, 4), grafana_mcp_call_count(slot)); + try std.testing.expectEqual(@as(c_int, 2), grafana_mcp_dashboard_op_count(slot)); + try std.testing.expectEqual(@as(c_int, 1), grafana_mcp_query_count(slot)); + try std.testing.expectEqual(@as(c_int, 1), grafana_mcp_alert_check_count(slot)); +} + +test "transition validator" { + // Unauthenticated -> Authenticated + try std.testing.expectEqual(@as(c_int, 1), grafana_mcp_can_transition(0, 1)); + // Authenticated -> Unauthenticated + try std.testing.expectEqual(@as(c_int, 1), grafana_mcp_can_transition(1, 0)); + // Authenticated -> RateLimited + try std.testing.expectEqual(@as(c_int, 1), grafana_mcp_can_transition(1, 2)); + // RateLimited -> Authenticated + try std.testing.expectEqual(@as(c_int, 1), grafana_mcp_can_transition(2, 1)); + // Invalid: Unauthenticated -> RateLimited (Grafana requires auth) + try std.testing.expectEqual(@as(c_int, 0), grafana_mcp_can_transition(0, 2)); +} + +test "slot exhaustion" { + grafana_mcp_reset(); + + var slots: [MAX_SESSIONS]c_int = undefined; + for (&slots) |*s| { + s.* = grafana_mcp_authenticate(0); + try std.testing.expect(s.* >= 0); + } + + try std.testing.expectEqual(@as(c_int, -1), grafana_mcp_authenticate(0)); + + try std.testing.expectEqual(@as(c_int, 0), grafana_mcp_close(slots[0])); + const new_slot = grafana_mcp_authenticate(0); + try std.testing.expect(new_slot >= 0); +} + +// ═══════════════════════════════════════════════════════════════════════ +// ADR-0006 invoke dispatch tests +// ═══════════════════════════════════════════════════════════════════════ + +test "boj_cartridge_name returns grafana-mcp" { + const n = std.mem.span(boj_cartridge_name()); + try std.testing.expectEqualStrings("grafana-mcp", n); +} + +test "boj_cartridge_init returns 0" { + try std.testing.expectEqual(@as(c_int, 0), boj_cartridge_init()); +} + +test "invoke: each declared tool succeeds" { + var buf: [256]u8 = undefined; + const tools = [_][]const u8{ + "grafana_search_dashboards", + "grafana_get_dashboard", + "grafana_create_dashboard", + "grafana_delete_dashboard", + "grafana_query_datasource", + "grafana_list_alerts", + "grafana_create_annotation", + "grafana_list_datasources", + "grafana_list_folders", + "grafana_health", + }; + for (tools) |t| { + var len: usize = buf.len; + const rc = boj_cartridge_invoke(t.ptr, "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, 0), rc); + try std.testing.expect(std.mem.indexOf(u8, buf[0..len], "result") != null); + } +} + +test "invoke: unknown tool returns -1" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("nope", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -1), rc); +} + +test "invoke: buffer too small returns -3" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("grafana_search_dashboards", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -3), rc); + try std.testing.expect(len > 4); +} diff --git a/cartridges/domains/observability/grafana-mcp/minter.toml b/cartridges/domains/observability/grafana-mcp/minter.toml new file mode 100644 index 0000000..7c40653 --- /dev/null +++ b/cartridges/domains/observability/grafana-mcp/minter.toml @@ -0,0 +1,22 @@ +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> + +name = "grafana-mcp" +description = "Grafana monitoring cartridge β€” dashboard CRUD, datasource queries, alert management, annotations, folder browsing, health checks" +version = "0.1.0" +domain = "Monitoring" +protocols = [ + "MCP", + "REST", +] +tier = "Ayo" +backend = "universal" +generate_panel = true + +[auth] +method = "bearer_token" +credential_source = "vault-mcp" +fields = ["grafana_api_token"] + +[api] +base_url = "https://grafana.example.com/api" diff --git a/cartridges/domains/observability/grafana-mcp/mod.js b/cartridges/domains/observability/grafana-mcp/mod.js new file mode 100644 index 0000000..e7e9158 --- /dev/null +++ b/cartridges/domains/observability/grafana-mcp/mod.js @@ -0,0 +1,216 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// grafana-mcp/mod.js -- Grafana monitoring cartridge implementation. +// +// Provides MCP tool handlers for the Grafana HTTP API: +// - Dashboard search, retrieval, creation, deletion +// - Datasource query execution (PromQL, InfluxQL, etc.) +// - Alert rule listing with state filters +// - Annotation creation (global or per-dashboard) +// - Datasource and folder listing +// - Instance health checks +// +// Auth: Bearer token via GRAFANA_API_TOKEN (required for all operations). +// API docs: https://grafana.com/docs/grafana/latest/developers/http_api/ +// +// Usage: import { handleTool } from "./mod.js"; +// or: deno run --allow-net --allow-env mod.js + +// --------------------------------------------------------------------------- +// Configuration β€” base URL is instance-specific, read from environment. +// --------------------------------------------------------------------------- + +function getBaseUrl() { + const url = typeof Deno !== "undefined" + ? Deno.env.get("GRAFANA_BASE_URL") + : process.env.GRAFANA_BASE_URL; + return url || "http://localhost:3000/api"; +} + +// --------------------------------------------------------------------------- +// Auth helper β€” retrieves the Grafana API token from environment. +// In production, vault-mcp provides zero-knowledge credential proxying. +// --------------------------------------------------------------------------- + +function getToken() { + const token = typeof Deno !== "undefined" + ? Deno.env.get("GRAFANA_API_TOKEN") + : process.env.GRAFANA_API_TOKEN; + return token || null; +} + +// --------------------------------------------------------------------------- +// HTTP request helper β€” wraps fetch with Grafana auth headers and error +// handling. Supports GET, POST, DELETE methods. +// --------------------------------------------------------------------------- + +async function grafanaFetch(path, { method = "GET", queryParams, body } = {}) { + const baseUrl = getBaseUrl(); + const url = new URL(`${baseUrl}${path}`); + + if (queryParams) { + for (const [key, value] of Object.entries(queryParams)) { + if (value !== undefined && value !== null && value !== "") { + url.searchParams.set(key, String(value)); + } + } + } + + const headers = { + "Accept": "application/json", + "Content-Type": "application/json", + }; + + const token = getToken(); + if (token) { + headers["Authorization"] = `Bearer ${token}`; + } + + const options = { method, headers }; + if (body) { + options.body = JSON.stringify(body); + } + + const response = await fetch(url.toString(), options); + + // Handle rate limiting + if (response.status === 429) { + const retryAfter = response.headers.get("retry-after"); + return { + status: 429, + error: `Rate limited by Grafana. Retry after ${retryAfter || "unknown"} seconds.`, + retryAfter, + }; + } + + const data = await response.json().catch(() => ({})); + + if (!response.ok) { + const errorMessage = data.message || `HTTP ${response.status}`; + return { status: response.status, error: errorMessage, data }; + } + + return { status: response.status, data }; +} + +// --------------------------------------------------------------------------- +// Tool handler dispatch β€” maps MCP tool names to Grafana API operations. +// --------------------------------------------------------------------------- + +export async function handleTool(toolName, args) { + switch (toolName) { + + // --- Dashboard search --- + + case "grafana_search_dashboards": { + const query = { + query: args.query, + tag: args.tag, + folderIds: args.folder_id, + limit: args.limit, + type: "dash-db", + }; + return grafanaFetch("/search", { queryParams: query }); + } + + // --- Dashboard CRUD --- + + case "grafana_get_dashboard": { + if (!args.uid) return { error: "Missing required field: uid" }; + return grafanaFetch(`/dashboards/uid/${encodeURIComponent(args.uid)}`); + } + + case "grafana_create_dashboard": { + if (!args.dashboard) return { error: "Missing required field: dashboard" }; + const body = { + dashboard: args.dashboard, + folderUid: args.folder_uid || "", + overwrite: args.overwrite || false, + }; + return grafanaFetch("/dashboards/db", { method: "POST", body }); + } + + case "grafana_delete_dashboard": { + if (!args.uid) return { error: "Missing required field: uid" }; + return grafanaFetch(`/dashboards/uid/${encodeURIComponent(args.uid)}`, { method: "DELETE" }); + } + + // --- Datasource queries --- + + case "grafana_query_datasource": { + if (!args.datasource_uid) return { error: "Missing required field: datasource_uid" }; + if (!args.query) return { error: "Missing required field: query" }; + const body = { + queries: [{ + refId: "A", + datasource: { uid: args.datasource_uid }, + expr: args.query, + }], + from: args.from || "now-1h", + to: args.to || "now", + }; + return grafanaFetch("/ds/query", { method: "POST", body }); + } + + // --- Alerts --- + + case "grafana_list_alerts": { + const query = { + state: args.state, + folderUID: args.folder_uid, + limit: args.limit, + }; + return grafanaFetch("/v1/provisioning/alert-rules", { queryParams: query }); + } + + // --- Annotations --- + + case "grafana_create_annotation": { + if (!args.text) return { error: "Missing required field: text" }; + const body = { + text: args.text, + dashboardUID: args.dashboard_uid, + panelId: args.panel_id, + tags: args.tags || [], + time: args.time || Date.now(), + }; + return grafanaFetch("/annotations", { method: "POST", body }); + } + + // --- Datasources --- + + case "grafana_list_datasources": { + return grafanaFetch("/datasources"); + } + + // --- Folders --- + + case "grafana_list_folders": { + const query = { limit: args.limit }; + return grafanaFetch("/folders", { queryParams: query }); + } + + // --- Health --- + + case "grafana_health": { + return grafanaFetch("/health"); + } + + default: + return { error: `Unknown grafana-mcp tool: ${toolName}` }; + } +} + +// --------------------------------------------------------------------------- +// Cartridge metadata export β€” used by the BoJ cartridge loader. +// --------------------------------------------------------------------------- + +export const metadata = { + name: "grafana-mcp", + version: "0.1.0", + domain: "Monitoring", + tier: "Ayo", + protocols: ["MCP", "REST"], + toolCount: 10, +}; diff --git a/cartridges/domains/observability/grafana-mcp/panels/manifest.json b/cartridges/domains/observability/grafana-mcp/panels/manifest.json new file mode 100644 index 0000000..6cd2847 --- /dev/null +++ b/cartridges/domains/observability/grafana-mcp/panels/manifest.json @@ -0,0 +1,90 @@ +{ + "$schema": "https://panll.dev/schemas/panel-manifest/v1.json", + "spdx": "MPL-2.0", + "copyright": "Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath)", + "cartridge": "grafana-mcp", + "domain": "Monitoring", + "version": "0.1.0", + "panels": [ + { + "id": "grafana-auth-status", + "title": "Auth Status", + "description": "Grafana session state (Unauthenticated / Authenticated / RateLimited / Error)", + "type": "status-indicator", + "data_source": { + "endpoint": "/grafana/state", + "method": "GET", + "refresh_interval_ms": 5000 + }, + "widgets": [ + { + "type": "state-badge", + "field": "state", + "states": { + "unauthenticated": { "color": "#95a5a6", "icon": "user-x" }, + "authenticated": { "color": "#2ecc71", "icon": "user-check" }, + "rate_limited": { "color": "#f39c12", "icon": "clock" }, + "error": { "color": "#e74c3c", "icon": "alert-triangle" } + } + } + ] + }, + { + "id": "grafana-dashboard-ops", + "title": "Dashboard Operations", + "description": "Number of dashboard CRUD operations in the current session", + "type": "metric", + "data_source": { + "endpoint": "/grafana/status", + "method": "GET", + "refresh_interval_ms": 10000 + }, + "widgets": [ + { + "type": "counter", + "field": "dashboard_ops", + "label": "Dashboard Ops", + "icon": "layout-dashboard" + } + ] + }, + { + "id": "grafana-query-count", + "title": "Datasource Queries", + "description": "Number of datasource queries executed in the current session", + "type": "metric", + "data_source": { + "endpoint": "/grafana/status", + "method": "GET", + "refresh_interval_ms": 10000 + }, + "widgets": [ + { + "type": "counter", + "field": "query_count", + "label": "Queries", + "icon": "database" + } + ] + }, + { + "id": "grafana-alert-checks", + "title": "Alert Checks", + "description": "Number of alert rule checks in the current session", + "type": "metric", + "data_source": { + "endpoint": "/grafana/status", + "method": "GET", + "refresh_interval_ms": 10000 + }, + "widgets": [ + { + "type": "counter", + "field": "alert_checks", + "label": "Alert Checks", + "icon": "bell" + } + ] + } + ] +} diff --git a/cartridges/domains/observability/grafana-mcp/tests/integration_test.sh b/cartridges/domains/observability/grafana-mcp/tests/integration_test.sh new file mode 100755 index 0000000..5293786 --- /dev/null +++ b/cartridges/domains/observability/grafana-mcp/tests/integration_test.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Integration tests for grafana-mcp cartridge. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CART_DIR="$(dirname "$SCRIPT_DIR")" +FFI_DIR="$CART_DIR/ffi" + +echo "=== grafana-mcp integration tests ===" + +# Build FFI +echo "[1/3] Building FFI..." +cd "$FFI_DIR" && zig build 2>&1 + +# Run Zig unit tests +echo "[2/3] Running FFI unit tests..." +cd "$FFI_DIR" && zig build test 2>&1 + +# Validate Idris2 ABI (if idris2 available) +echo "[3/3] Checking ABI..." +if command -v idris2 &>/dev/null; then + cd "$CART_DIR/abi" && idris2 --check GrafanaMcp.SafeRegistry 2>&1 + echo " ABI: OK" +else + echo " ABI: SKIPPED (idris2 not in PATH)" +fi + +echo "" +echo "All tests passed for grafana-mcp!" diff --git a/cartridges/domains/observability/observe-mcp/README.adoc b/cartridges/domains/observability/observe-mcp/README.adoc new file mode 100644 index 0000000..254a81b --- /dev/null +++ b/cartridges/domains/observability/observe-mcp/README.adoc @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += observe-mcp +Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk> +:spdx: MPL-2.0 +:tier: Ayo +:domain: observability +:protocols: MCP, REST + +== Overview + +Unified observability β€” metrics, logs, traces (Prometheus, Grafana, Loki, Jaeger) + +== Tools (4) + +[cols="2,4"] +|=== +| Tool | Description + +| `observe_register` | Register an observability backend +| `observe_query_metrics` | Query metrics (PromQL) +| `observe_query_logs` | Query logs (LogQL) +| `observe_query_traces` | Query traces by service or trace ID +|=== + +== Architecture + +Three-layer stack per the BoJ cartridge standard: + +[cols="1,1,3"] +|=== +| Layer | Language | Purpose + +| ABI | Idris2 | Formally verified state machine with dependent-type proofs +| FFI | Zig | C-ABI implementation: session pool, action metrics, thread safety +| Adapter| Zig | Adapter bridge to the BoJ unified adapter protocol (zig predecessors sidelined) +|=== + +== Building + +[source,bash] +---- +# ABI (Idris2) +cd abi && idris2 --check *.idr + +# FFI (Zig) +cd ffi && zig build +cd ffi && zig build test + +# Adapter (Zig) +cd adapter && zig build +---- + +== Status + +Alpha (CRG D). Cartridge metadata declared in `cartridge.json`; 4 tool(s) exposed. +Not yet dogfooded externally. See `docs/READINESS.md` at repo root for the +project-wide grade table and `docs/practice/DOGFOOD-LOG.adoc` for usage evidence. diff --git a/cartridges/domains/observability/observe-mcp/abi/ObserveMcp/SafeObserve.idr b/cartridges/domains/observability/observe-mcp/abi/ObserveMcp/SafeObserve.idr new file mode 100644 index 0000000..9ce7244 --- /dev/null +++ b/cartridges/domains/observability/observe-mcp/abi/ObserveMcp/SafeObserve.idr @@ -0,0 +1,156 @@ +-- SPDX-License-Identifier: MPL-2.0 +-- Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +||| ObserveMcp.SafeObserve: Formally verified observability operations. +||| +||| Cartridge: observe-mcp +||| Matrix cell: Observability domain x {MCP, LSP} protocols +||| +||| This module defines a metrics pipeline state machine that prevents: +||| - Querying unconfigured observability sources +||| - Executing queries without source registration +||| - Unbounded query rates (backpressure tracking) +||| +||| State machine: Unconfigured -> SourceRegistered -> QueryReady -> Querying -> QueryReady +module ObserveMcp.SafeObserve + +import Data.List + +%default total + +-- ═══════════════════════════════════════════════════════════════════════════ +-- Observability State Machine +-- ═══════════════════════════════════════════════════════════════════════════ + +||| Observability source lifecycle states. +||| A source progresses: Unconfigured -> SourceRegistered -> QueryReady -> Querying -> QueryReady +public export +data ObserveState = Unconfigured | SourceRegistered | QueryReady | Querying | ObserveError + +||| Equality for observability states. +public export +Eq ObserveState where + Unconfigured == Unconfigured = True + SourceRegistered == SourceRegistered = True + QueryReady == QueryReady = True + Querying == Querying = True + ObserveError == ObserveError = True + _ == _ = False + +||| Valid state transitions (enforced at the type level). +||| Critically, Unconfigured -> Querying is NOT a valid transition. +||| You MUST register a source first. +public export +data ValidTransition : ObserveState -> ObserveState -> Type where + Register : ValidTransition Unconfigured SourceRegistered + Ready : ValidTransition SourceRegistered QueryReady + StartQuery : ValidTransition QueryReady Querying + EndQuery : ValidTransition Querying QueryReady + Unregister : ValidTransition QueryReady Unconfigured + QueryError : ValidTransition Querying ObserveError + Recover : ValidTransition ObserveError QueryReady + +||| Runtime transition validator. +public export +canTransition : ObserveState -> ObserveState -> Bool +canTransition Unconfigured SourceRegistered = True +canTransition SourceRegistered QueryReady = True +canTransition QueryReady Querying = True +canTransition Querying QueryReady = True +canTransition QueryReady Unconfigured = True -- unregister +canTransition Querying ObserveError = True +canTransition ObserveError QueryReady = True -- recover +canTransition _ _ = False + +-- ═══════════════════════════════════════════════════════════════════════════ +-- Observability Backend Types +-- ═══════════════════════════════════════════════════════════════════════════ + +||| Supported observability backends. +public export +data ObserveBackend + = Prometheus -- Metrics collection and querying + | Grafana -- Dashboard and visualisation + | Loki -- Log aggregation + | Jaeger -- Distributed tracing + | Custom String -- User-defined backend + +||| C-ABI encoding. +public export +backendToInt : ObserveBackend -> Int +backendToInt Prometheus = 1 +backendToInt Grafana = 2 +backendToInt Loki = 3 +backendToInt Jaeger = 4 +backendToInt (Custom _) = 99 + +-- ═══════════════════════════════════════════════════════════════════════════ +-- MCP Tool Definitions +-- ═══════════════════════════════════════════════════════════════════════════ + +||| MCP tools exposed by this cartridge. +||| These map to MCP tool definitions that AI agents can call. +public export +data McpTool + = ToolRegisterSource -- Register an observability source + | ToolQueryMetrics -- Query metrics (Prometheus/PromQL) + | ToolQueryLogs -- Query logs (Loki/LogQL) + | ToolQueryTraces -- Query traces (Jaeger) + | ToolCreateDashboard -- Create a Grafana dashboard + | ToolSetAlert -- Configure an alert rule + | ToolStatus -- Source health check + | ToolUnregister -- Unregister a source + +||| MCP tool name (for JSON-RPC method name). +public export +toolName : McpTool -> String +toolName ToolRegisterSource = "observe/register" +toolName ToolQueryMetrics = "observe/metrics" +toolName ToolQueryLogs = "observe/logs" +toolName ToolQueryTraces = "observe/traces" +toolName ToolCreateDashboard = "observe/dashboard" +toolName ToolSetAlert = "observe/alert" +toolName ToolStatus = "observe/status" +toolName ToolUnregister = "observe/unregister" + +||| Which tools require a source to be registered first. +public export +toolRequiresSource : McpTool -> Bool +toolRequiresSource ToolRegisterSource = False +toolRequiresSource _ = True + +-- ═══════════════════════════════════════════════════════════════════════════ +-- C-ABI Exports +-- ═══════════════════════════════════════════════════════════════════════════ + +||| Observability state to integer. +public export +observeStateToInt : ObserveState -> Int +observeStateToInt Unconfigured = 0 +observeStateToInt SourceRegistered = 1 +observeStateToInt QueryReady = 2 +observeStateToInt Querying = 3 +observeStateToInt ObserveError = 4 + +||| FFI: Validate a state transition. +export +obs_can_transition : Int -> Int -> Int +obs_can_transition from to = + let fromState = case from of + 0 => Unconfigured + 1 => SourceRegistered + 2 => QueryReady + 3 => Querying + _ => ObserveError + toState = case to of + 0 => Unconfigured + 1 => SourceRegistered + 2 => QueryReady + 3 => Querying + _ => ObserveError + in if canTransition fromState toState then 1 else 0 + +||| FFI: Check if a tool requires a registered source. +export +obs_tool_requires_source : Int -> Int +obs_tool_requires_source 1 = 0 -- ToolRegisterSource +obs_tool_requires_source _ = 1 -- All others require a source diff --git a/cartridges/domains/observability/observe-mcp/abi/README.adoc b/cartridges/domains/observability/observe-mcp/abi/README.adoc new file mode 100644 index 0000000..1e8a2fd --- /dev/null +++ b/cartridges/domains/observability/observe-mcp/abi/README.adoc @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += observe-mcp / abi β€” Idris2 ABI layer +:orientation: shallow + +== Purpose + +Cartridge-local protocol types for `observe-mcp`. Defines the data, tool, and +transition vocabulary this cartridge speaks. Inherits safety lemmas and the +matrix-cell identifier scheme from the trunk ABI at `src/abi/`. + +== Files + +1 Idris2 module(s), ~156 lines total. Lead module: +`SafeObserve.idr`. + +No `.ipkg` β€” consumed directly by wider BoJ callers; type-checks inside the +trunk `boj.ipkg`. + +== Invariants + +* `%default total` at every module top. +* Zero `believe_me`, `assert_total`, `postulate`, `Admitted`, or + `sorry` (the trunk holds the four declared `believe_me` sites; leaf + cartridges carry none). + +== Test/proof surface + +Thin module(s), no inline tests. Proof surface = type-check success under the +trunk `idris2 --check`. + +== Read-first + +. `SafeObserve.idr` β€” cartridge-local data records and enums. +. Trunk `src/abi/Boj/Catalogue.idr` for how this cell participates in the + 2D matrix. diff --git a/cartridges/domains/observability/observe-mcp/abi/observe-mcp.ipkg b/cartridges/domains/observability/observe-mcp/abi/observe-mcp.ipkg new file mode 100644 index 0000000..cc4a2f9 --- /dev/null +++ b/cartridges/domains/observability/observe-mcp/abi/observe-mcp.ipkg @@ -0,0 +1,11 @@ +-- SPDX-License-Identifier: MPL-2.0 +package observemcp + +authors = "Jonathan D.A. Jewell" +version = 0.1.0 +license = "MPL-2.0" +brief = "Observe MCP cartridge β€” metrics pipeline state machine with backpressure tracking" + +sourcedir = "." +modules = ObserveMcp.SafeObserve +depends = base, contrib diff --git a/cartridges/domains/observability/observe-mcp/adapter/README.adoc b/cartridges/domains/observability/observe-mcp/adapter/README.adoc new file mode 100644 index 0000000..74d2673 --- /dev/null +++ b/cartridges/domains/observability/observe-mcp/adapter/README.adoc @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += observe-mcp / adapter β€” protocol bridge +:orientation: shallow + +== Purpose + +Exposes this cartridge's FFI over the wire protocols declared in +`cartridge.json`. Dispatches tool invocations to the C-ABI exports at +`../ffi/` and formats responses per protocol. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” depends on the sibling `ffi` target. +| `observe_adapter.zig` | Protocol dispatch (129 lines). Tool catalogue matches `cartridge.json`. +| `SIDELINED-*.v.adoc` | zig predecessor(s). Not built β€” zig banned 2026-04-10. +|=== + +== Invariants + +* **Stateless** β€” all state lives behind the FFI, never in the adapter. +* **Response passthrough** β€” whatever the FFI returns goes back to the wire + unmodified (no embellishment, no silent recovery). +* **Cartridge.json is source of truth** for the tool catalogue; drift between + adapter and manifest is a CI failure. + +== Test/proof surface + +No inline tests at the adapter layer β€” protocol routing is exercised by the +trunk seam tests at `ffi/zig/src/seams.zig`. + +== Read-first + +. `observe_adapter.zig` β€” dispatch function and per-protocol routers. +. `../cartridge.json` β€” the tool manifest the dispatcher mirrors. diff --git a/cartridges/domains/observability/observe-mcp/adapter/build.zig b/cartridges/domains/observability/observe-mcp/adapter/build.zig new file mode 100644 index 0000000..bc763ff --- /dev/null +++ b/cartridges/domains/observability/observe-mcp/adapter/build.zig @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// observe-mcp/adapter/build.zig + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const ffi_mod = b.createModule(.{ + .root_source_file = b.path("../ffi/observe_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + const adapter = b.addExecutable(.{ + .name = "observe_adapter", + .root_source_file = b.path("observe_adapter.zig"), + .target = target, + .optimize = optimize, + }); + adapter.root_module.addImport("observe_ffi", ffi_mod); + b.installArtifact(adapter); +} diff --git a/cartridges/domains/observability/observe-mcp/adapter/observe_adapter.zig b/cartridges/domains/observability/observe-mcp/adapter/observe_adapter.zig new file mode 100644 index 0000000..317a27c --- /dev/null +++ b/cartridges/domains/observability/observe-mcp/adapter/observe_adapter.zig @@ -0,0 +1,129 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// observe-mcp/adapter/observe_adapter.zig +// +// Three-protocol BoJ adapter: REST (port 9274), gRPC-compat (port 9275), +// GraphQL (port 9276). +// Replaces the banned zig adapter (observe_adapter.v). + +const std = @import("std"); +const ffi = @import("observe_ffi"); + +const REST_PORT: u16 = 9274; +const GRPC_PORT: u16 = 9275; +const GQL_PORT: u16 = 9276; + +const Response = struct { status: u16, body: []const u8 }; + +fn okJson(buf: []u8, msg: []const u8) []u8 { + return std.fmt.bufPrint(buf, "{{\"success\":true,\"message\":\"{s}\"}}", .{msg}) catch buf[0..0]; +} + +fn errJson(buf: []u8, msg: []const u8) []u8 { + return std.fmt.bufPrint(buf, "{{\"success\":false,\"error\":\"{s}\"}}", .{msg}) catch buf[0..0]; +} + +fn dispatch(tool: []const u8, _body: []const u8, resp: []u8) Response { + _ = _body; + if (std.mem.eql(u8, tool, "observe_register")) { + return .{ .status = 200, .body = okJson(resp, "observe_register forwarded") }; + } + if (std.mem.eql(u8, tool, "observe_query_metrics")) { + return .{ .status = 200, .body = okJson(resp, "observe_query_metrics forwarded") }; + } + if (std.mem.eql(u8, tool, "observe_query_logs")) { + return .{ .status = 200, .body = okJson(resp, "observe_query_logs forwarded") }; + } + if (std.mem.eql(u8, tool, "observe_query_traces")) { + return .{ .status = 200, .body = okJson(resp, "observe_query_traces forwarded") }; + } + return .{ .status = 404, .body = errJson(resp, "unknown tool") }; +} + +fn dispatchRest(path: []const u8, body: []const u8, resp: []u8) Response { + // Expect /tools/<tool_name> + const prefix = "/tools/"; + if (std.mem.startsWith(u8, path, prefix)) { + const tool = path[prefix.len..]; + return dispatch(tool, body, resp); + } + return .{ .status = 404, .body = errJson(resp, "not found") }; +} + +fn dispatchGrpc(path: []const u8, body: []const u8, resp: []u8) Response { + // Expect /<Service>/<Method> β€” derive tool from Method + var it = std.mem.splitScalar(u8, path, '/'); + _ = it.next(); // leading empty + _ = it.next(); // service + const method = it.next() orelse return .{ .status = 404, .body = errJson(resp, "bad gRPC path") }; + return dispatch(method, body, resp); +} + +fn dispatchGraphql(body: []const u8, resp: []u8) Response { + if (std.mem.indexOf(u8, body, "observe_register") != null) + return dispatch("observe_register", body, resp); + if (std.mem.indexOf(u8, body, "observe_query_metrics") != null) + return dispatch("observe_query_metrics", body, resp); + if (std.mem.indexOf(u8, body, "observe_query_logs") != null) + return dispatch("observe_query_logs", body, resp); + if (std.mem.indexOf(u8, body, "observe_query_traces") != null) + return dispatch("observe_query_traces", body, resp); + return .{ .status = 400, .body = errJson(resp, "unrecognised GraphQL operation") }; +} + +fn handleConnection(stream: std.net.Stream, port: u16) void { + defer stream.close(); + var buf: [4096]u8 = undefined; + var resp_buf: [4096]u8 = undefined; + const n = stream.read(&buf) catch return; + const req = buf[0..n]; + const result = switch (port) { + REST_PORT => blk: { + // Parse HTTP/1.1: first line = METHOD PATH HTTP/x.y + var lines = std.mem.splitScalar(u8, req, '\n'); + const first = lines.next() orelse break :blk Response{ .status = 400, .body = "" }; + var parts = std.mem.splitScalar(u8, std.mem.trim(u8, first, "\r"), ' '); + _ = parts.next(); // method + const path = parts.next() orelse break :blk Response{ .status = 400, .body = "" }; + break :blk dispatchRest(path, req, &resp_buf); + }, + GRPC_PORT => blk: { + var lines = std.mem.splitScalar(u8, req, '\n'); + const first = lines.next() orelse break :blk Response{ .status = 400, .body = "" }; + var parts = std.mem.splitScalar(u8, std.mem.trim(u8, first, "\r"), ' '); + _ = parts.next(); + const path = parts.next() orelse break :blk Response{ .status = 400, .body = "" }; + break :blk dispatchGrpc(path, req, &resp_buf); + }, + GQL_PORT => dispatchGraphql(req, &resp_buf), + else => Response{ .status = 500, .body = "" }, + }; + var http_resp: [512]u8 = undefined; + const http = std.fmt.bufPrint(&http_resp, + "HTTP/1.1 {d} OK\r\nContent-Length: {d}\r\nContent-Type: application/json\r\n\r\n", + .{ result.status, result.body.len }) catch return; + _ = stream.write(http) catch {}; + _ = stream.write(result.body) catch {}; +} + +fn listenLoop(port: u16) !void { + const addr = std.net.Address.initIp4(.{ 127, 0, 0, 1 }, port); + var server = try addr.listen(.{ .reuse_address = true }); + defer server.deinit(); + while (true) { + const conn = try server.accept(); + const t = try std.Thread.spawn(.{}, handleConnection, .{ conn.stream, port }); + t.detach(); + } +} + +pub fn main() !void { + ffi.observe_init(); + const t1 = try std.Thread.spawn(.{}, listenLoop, .{REST_PORT}); + const t2 = try std.Thread.spawn(.{}, listenLoop, .{GRPC_PORT}); + const t3 = try std.Thread.spawn(.{}, listenLoop, .{GQL_PORT}); + t1.join(); + t2.join(); + t3.join(); +} diff --git a/cartridges/domains/observability/observe-mcp/cartridge.json b/cartridges/domains/observability/observe-mcp/cartridge.json new file mode 100644 index 0000000..38dda03 --- /dev/null +++ b/cartridges/domains/observability/observe-mcp/cartridge.json @@ -0,0 +1,135 @@ +{ + "$schema": "https://boj.dev/schemas/cartridge/v1.json", + "spdx": "MPL-2.0", + "copyright": "Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk>", + "name": "observe-mcp", + "version": "0.1.0", + "description": "Unified observability β€” metrics, logs, traces (Prometheus, Grafana, Loki, Jaeger)", + "domain": "observability", + "tier": "Ayo", + "protocols": [ + "MCP", + "REST" + ], + "auth": { + "method": "none", + "env_var": null, + "credential_source": null + }, + "api": { + "base_url": "local://observe-mcp", + "content_type": "application/json" + }, + "tools": [ + { + "name": "observe_register", + "description": "Register an observability backend", + "inputSchema": { + "type": "object", + "properties": { + "backend": { + "type": "string", + "description": "Backend: prometheus|grafana|loki|jaeger" + }, + "endpoint": { + "type": "string", + "description": "Backend endpoint URL" + } + }, + "required": [ + "backend", + "endpoint" + ] + } + }, + { + "name": "observe_query_metrics", + "description": "Query metrics (PromQL)", + "inputSchema": { + "type": "object", + "properties": { + "session_id": { + "type": "integer", + "description": "Session index" + }, + "query": { + "type": "string", + "description": "PromQL query" + }, + "start": { + "type": "string", + "description": "Start time (RFC3339 or relative)" + }, + "end": { + "type": "string", + "description": "End time" + } + }, + "required": [ + "session_id", + "query" + ] + } + }, + { + "name": "observe_query_logs", + "description": "Query logs (LogQL)", + "inputSchema": { + "type": "object", + "properties": { + "session_id": { + "type": "integer", + "description": "Session index" + }, + "query": { + "type": "string", + "description": "LogQL query" + }, + "limit": { + "type": "integer", + "description": "Max log lines" + } + }, + "required": [ + "session_id", + "query" + ] + } + }, + { + "name": "observe_query_traces", + "description": "Query traces by service or trace ID", + "inputSchema": { + "type": "object", + "properties": { + "session_id": { + "type": "integer", + "description": "Session index" + }, + "service": { + "type": "string", + "description": "Service name" + }, + "trace_id": { + "type": "string", + "description": "Trace ID" + } + }, + "required": [ + "session_id" + ] + } + } + ], + "ffi": { + "so_path": "ffi/zig-out/lib/libobserve_mcp.so", + "abi_version": "ADR-0006", + "symbols": [ + "boj_cartridge_init", + "boj_cartridge_deinit", + "boj_cartridge_name", + "boj_cartridge_version", + "boj_cartridge_invoke" + ] + } +} diff --git a/cartridges/domains/observability/observe-mcp/ffi/README.adoc b/cartridges/domains/observability/observe-mcp/ffi/README.adoc new file mode 100644 index 0000000..25e52d6 --- /dev/null +++ b/cartridges/domains/observability/observe-mcp/ffi/README.adoc @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += observe-mcp / ffi β€” Zig FFI layer +:orientation: shallow + +== Purpose + +C-ABI exports bridging this cartridge's Idris2 ABI to the runtime. Consumed by +the adapter for protocol dispatch and by the trunk loader for mount. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” produces `zig-out/lib/libobserve.so`. +| `observe_ffi.zig` | C-ABI exports (12 exports, 6 inline tests, 268 lines). +|=== + +== Invariants + +* **Null-pointer rejection** on `[*c]const u8` arguments before any + dereference. +* **Negative return values** carry documented error meanings; zero is + success (matching the trunk error-code convention). +* **No allocator leaks** β€” every allocation path pairs with `defer free`. + +== Test/proof surface + +6 inline `test "..."` block(s) in `observe_ffi.zig`. Run with +`cd ffi && zig build test`. + +== Read-first + +. `observe_ffi.zig` β€” C-ABI signatures and guards. +. Trunk `ffi/zig/src/catalogue.zig` for how this `.so` is loaded. diff --git a/cartridges/domains/observability/observe-mcp/ffi/build.zig b/cartridges/domains/observability/observe-mcp/ffi/build.zig new file mode 100644 index 0000000..caf7487 --- /dev/null +++ b/cartridges/domains/observability/observe-mcp/ffi/build.zig @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// Observe-MCP Cartridge β€” Zig FFI build configuration (Zig 0.15+). + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + // Shared ADR-0006 invoke-shim module (relative path up to boj-server trunk). + + const shim_mod = b.addModule("cartridge_shim", .{ + + .root_source_file = b.path("../../../ffi/zig/src/cartridge_shim.zig"), + + .target = target, + + .optimize = optimize, + + }); + + const obs_mod = b.addModule("observe_ffi", .{ + .root_source_file = b.path("observe_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + obs_mod.addImport("cartridge_shim", shim_mod); + + // ── Tests ──────────────────────────────────────────────────────── + const obs_tests = b.addTest(.{ + .root_module = obs_mod, + }); + + const run_tests = b.addRunArtifact(obs_tests); + + const test_step = b.step("test", "Run observe-mcp FFI tests"); + test_step.dependOn(&run_tests.step); + + // ── Shared library ────────────────────────────────────────────── + const lib_mod = b.createModule(.{ + .root_source_file = b.path("observe_ffi.zig"), + .target = target, + .optimize = optimize, + }); + lib_mod.addImport("cartridge_shim", shim_mod); + + const lib = b.addLibrary(.{ + .name = "observe_mcp", + .root_module = lib_mod, + .linkage = .dynamic, + }); + b.installArtifact(lib); + + const lib_step = b.step("lib", "Build shared library"); + lib_step.dependOn(&lib.step); +} diff --git a/cartridges/domains/observability/observe-mcp/ffi/cartridge_shim.zig b/cartridges/domains/observability/observe-mcp/ffi/cartridge_shim.zig new file mode 100644 index 0000000..0e399db --- /dev/null +++ b/cartridges/domains/observability/observe-mcp/ffi/cartridge_shim.zig @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// cartridge_shim.zig β€” Shared helpers for the ADR-0006 five-symbol +// cartridge ABI (`boj_cartridge_init / deinit / name / version / invoke`). +// +// The shim centralises the seven-code return convention, NUL-argument +// guards, tool-name comparison, and the buffer-too-small path so each +// cartridge's `boj_cartridge_invoke` can stay short β€” typically a tool +// table plus `shim.writeResult(...)`. +// +// Cartridges import this file by relative path (no build-graph change +// needed). Example: +// +// const shim = @import("../../../ffi/zig/src/cartridge_shim.zig"); +// +// export fn boj_cartridge_invoke( +// tool_name: [*c]const u8, +// json_args: [*c]const u8, +// out_buf: [*c]u8, +// in_out_len: [*c]usize, +// ) callconv(.c) i32 { +// _ = json_args; +// if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; +// const body = if (shim.toolIs(tool_name, "foo")) "{\"result\":{}}" +// else return shim.RC_UNKNOWN_TOOL; +// return shim.writeResult(out_buf, in_out_len, body); +// } + +const std = @import("std"); + +// ── Return codes (ADR-0006 Β§Return codes) ──────────────────────────── +// +// Frozen by ADR-0006. New failure modes compose these via the error +// JSON body β€” the integer surface does not grow without a follow-up ADR. + +pub const RC_SUCCESS: i32 = 0; +pub const RC_UNKNOWN_TOOL: i32 = -1; +pub const RC_BAD_ARGS: i32 = -2; +pub const RC_BUFFER_TOO_SMALL: i32 = -3; +pub const RC_RUNTIME_ERROR: i32 = -4; +pub const RC_PANIC: i32 = -5; +pub const RC_AUTH_DENIED: i32 = -6; + +// ── Invoke-path helpers ────────────────────────────────────────────── + +/// True if any of the three mandatory `boj_cartridge_invoke` output-path +/// pointers is null. Use at the top of every invoke to short-circuit to +/// `RC_BAD_ARGS`. +pub fn invokeArgsNull( + tool_name: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) bool { + return tool_name == null or out_buf == null or in_out_len == null; +} + +/// Compare a C-NUL-terminated tool-name pointer against a Zig string +/// literal. Caller must have already verified `tool_name` is non-null +/// (usually via `invokeArgsNull`). +pub fn toolIs(tool_name: [*c]const u8, expected: []const u8) bool { + const s = std.mem.span(@as([*:0]const u8, @ptrCast(tool_name))); + return std.mem.eql(u8, s, expected); +} + +/// Copy `body` into `out_buf[0..*in_out_len]` (as a capacity) and update +/// `*in_out_len` to the number of bytes written. Returns `RC_SUCCESS`. +/// +/// If `body.len` exceeds the current capacity stored in `*in_out_len`, +/// sets `*in_out_len` to the required size and returns +/// `RC_BUFFER_TOO_SMALL` β€” the caller is then expected to re-allocate +/// and retry, per ADR-0006 Β§Memory ownership. +/// +/// Caller must have already verified that `out_buf` and `in_out_len` +/// are non-null. +pub fn writeResult( + out_buf: [*c]u8, + in_out_len: [*c]usize, + body: []const u8, +) i32 { + const cap = in_out_len.*; + if (body.len > cap) { + in_out_len.* = body.len; + return RC_BUFFER_TOO_SMALL; + } + @memcpy(out_buf[0..body.len], body); + in_out_len.* = body.len; + return RC_SUCCESS; +} + +// ── Tests ──────────────────────────────────────────────────────────── + +test "writeResult: body fits, writes and sets length" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); + try std.testing.expectEqualStrings("hello", buf[0..len]); +} + +test "writeResult: too small returns -3 and sets required length" { + var buf: [2]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_BUFFER_TOO_SMALL, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: exact-fit succeeds" { + var buf: [5]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: empty body" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, ""); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 0), len); +} + +test "toolIs: matches and rejects" { + const name: [*:0]const u8 = "foo"; + try std.testing.expect(toolIs(@ptrCast(name), "foo")); + try std.testing.expect(!toolIs(@ptrCast(name), "bar")); + try std.testing.expect(!toolIs(@ptrCast(name), "foobar")); + try std.testing.expect(!toolIs(@ptrCast(name), "fo")); +} + +test "invokeArgsNull: detects each null slot" { + var buf: [4]u8 = undefined; + var len: usize = 4; + const name: [*:0]const u8 = "x"; + try std.testing.expect(!invokeArgsNull(@ptrCast(name), &buf, &len)); + try std.testing.expect(invokeArgsNull(null, &buf, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), null, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), &buf, null)); +} diff --git a/cartridges/domains/observability/observe-mcp/ffi/observe_ffi.zig b/cartridges/domains/observability/observe-mcp/ffi/observe_ffi.zig new file mode 100644 index 0000000..d596801 --- /dev/null +++ b/cartridges/domains/observability/observe-mcp/ffi/observe_ffi.zig @@ -0,0 +1,334 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// Observe-MCP Cartridge β€” Zig FFI bridge for observability operations. +// +// Implements the metrics pipeline state machine from SafeObserve.idr. +// Ensures no query can execute on an unconfigured source, and tracks +// query counts for backpressure management. + +const std = @import("std"); + +// ═══════════════════════════════════════════════════════════════════════ +// Types (must match ObserveMcp.SafeObserve encodings) +// ═══════════════════════════════════════════════════════════════════════ + +pub const ObserveState = enum(c_int) { + unconfigured = 0, + source_registered = 1, + query_ready = 2, + querying = 3, + observe_error = 4, +}; + +pub const ObserveBackend = enum(c_int) { + prometheus = 1, + grafana = 2, + loki = 3, + jaeger = 4, + custom = 99, +}; + +// ═══════════════════════════════════════════════════════════════════════ +// Metrics Pipeline State Machine +// ═══════════════════════════════════════════════════════════════════════ + +const MAX_SOURCES: usize = 16; + +const SourceSlot = struct { + active: bool, + backend: ObserveBackend, + state: ObserveState, + query_count: u32, // Tracks total queries for rate/backpressure +}; + +var sources: [MAX_SOURCES]SourceSlot = [_]SourceSlot{.{ + .active = false, + .backend = .prometheus, + .state = .unconfigured, + .query_count = 0, +}} ** MAX_SOURCES; + +var mutex: std.Thread.Mutex = .{}; + +/// Validate a state transition (matches Idris2 canTransition). +fn isValidTransition(from: ObserveState, to: ObserveState) bool { + return switch (from) { + .unconfigured => to == .source_registered, + .source_registered => to == .query_ready, + .query_ready => to == .querying or to == .unconfigured, + .querying => to == .query_ready or to == .observe_error, + .observe_error => to == .query_ready, + }; +} + +/// Register a new observability source. Returns slot index or -1 on failure. +pub export fn obs_register(backend: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + for (&sources, 0..) |*slot, i| { + if (!slot.active) { + slot.active = true; + slot.backend = @enumFromInt(backend); + slot.state = .source_registered; + slot.query_count = 0; + return @intCast(i); + } + } + return -1; // No slots available +} + +/// Transition a source to query-ready state. +fn readySource(idx: usize) c_int { + if (!isValidTransition(sources[idx].state, .query_ready)) return -2; + sources[idx].state = .query_ready; + return 0; +} + +/// Begin a query (transition QueryReady -> Querying). +pub export fn obs_begin_query(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + if (slot_idx < 0 or slot_idx >= MAX_SOURCES) return -1; + const idx: usize = @intCast(slot_idx); + if (!sources[idx].active) return -1; + + // Auto-transition from source_registered to query_ready if needed + if (sources[idx].state == .source_registered) { + const ready_result = readySource(idx); + if (ready_result != 0) return ready_result; + } + + if (!isValidTransition(sources[idx].state, .querying)) return -2; + + sources[idx].state = .querying; + return 0; +} + +/// End a query successfully (transition Querying -> QueryReady). +pub export fn obs_end_query(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + if (slot_idx < 0 or slot_idx >= MAX_SOURCES) return -1; + const idx: usize = @intCast(slot_idx); + if (!sources[idx].active) return -1; + if (!isValidTransition(sources[idx].state, .query_ready)) return -2; + + sources[idx].state = .query_ready; + sources[idx].query_count += 1; + return 0; +} + +/// Unregister a source (transition QueryReady -> Unconfigured). +pub export fn obs_unregister(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + if (slot_idx < 0 or slot_idx >= MAX_SOURCES) return -1; + const idx: usize = @intCast(slot_idx); + if (!sources[idx].active) return -1; + if (!isValidTransition(sources[idx].state, .unconfigured)) return -2; + + sources[idx].active = false; + sources[idx].state = .unconfigured; + sources[idx].query_count = 0; + return 0; +} + +/// Get the state of a source. +pub export fn obs_state(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + if (slot_idx < 0 or slot_idx >= MAX_SOURCES) return -1; + const idx: usize = @intCast(slot_idx); + if (!sources[idx].active) return @intFromEnum(ObserveState.unconfigured); + return @intFromEnum(sources[idx].state); +} + +/// Get the query count for a source (for backpressure tracking). +pub export fn obs_query_count(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + if (slot_idx < 0 or slot_idx >= MAX_SOURCES) return -1; + const idx: usize = @intCast(slot_idx); + if (!sources[idx].active) return 0; + return @intCast(sources[idx].query_count); +} + +/// Validate a state transition (C-ABI export). +pub export fn obs_can_transition(from: c_int, to: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + const f: ObserveState = @enumFromInt(from); + const t: ObserveState = @enumFromInt(to); + return if (isValidTransition(f, t)) 1 else 0; +} + +/// Reset all sources (for testing). +pub export fn obs_reset() void { + mutex.lock(); + defer mutex.unlock(); + for (&sources) |*slot| { + slot.active = false; + slot.state = .unconfigured; + slot.query_count = 0; + } +} + +// ═══════════════════════════════════════════════════════════════════════ +// Standard Cartridge Interface (loader expects these 4 C-ABI symbols) +// ═══════════════════════════════════════════════════════════════════════ + +/// Initialise the observe-mcp cartridge. Resets all source slots. +pub export fn boj_cartridge_init() c_int { + obs_reset(); + return 0; +} + +/// Deinitialise the observe-mcp cartridge. Resets all source slots. +pub export fn boj_cartridge_deinit() void { + obs_reset(); +} + +/// Return the cartridge name as a null-terminated C string. +pub export fn boj_cartridge_name() [*:0]const u8 { + mutex.lock(); + defer mutex.unlock(); + return "observe-mcp"; +} + +/// Return the cartridge version as a null-terminated C string. +pub export fn boj_cartridge_version() [*:0]const u8 { + mutex.lock(); + defer mutex.unlock(); + return "0.1.0"; +} + +// ═══════════════════════════════════════════════════════════════════════ +// ADR-0006 dispatch (boj_cartridge_invoke, 5th standard symbol) +// ═══════════════════════════════════════════════════════════════════════ + +const shim = @import("cartridge_shim.zig"); + +/// Dispatch the cartridge.json MCP tools. Grade D Alpha β€” each arm +/// returns a stub JSON body shaped to the tool's intended response. +export fn boj_cartridge_invoke( + tool_name: [*c]const u8, + json_args: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) callconv(.c) i32 { + _ = json_args; + if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; + + const body: []const u8 = if (shim.toolIs(tool_name, "observe_register")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "observe_query_metrics")) + "{\"result\":{\"matches\":[],\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "observe_query_logs")) + "{\"result\":{\"matches\":[],\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "observe_query_traces")) + "{\"result\":{\"matches\":[],\"status\":\"stub\"}}" +else + return shim.RC_UNKNOWN_TOOL; + + return shim.writeResult(out_buf, in_out_len, body); +} + +// ═══════════════════════════════════════════════════════════════════════ +// Tests +// ═══════════════════════════════════════════════════════════════════════ + +test "register and unregister" { + obs_reset(); + const slot = obs_register(@intFromEnum(ObserveBackend.prometheus)); + try std.testing.expect(slot >= 0); + try std.testing.expectEqual(@as(c_int, @intFromEnum(ObserveState.source_registered)), obs_state(slot)); + // Must go through query_ready before unregister + _ = obs_begin_query(slot); // auto-transitions to query_ready then querying + _ = obs_end_query(slot); // back to query_ready + try std.testing.expectEqual(@as(c_int, 0), obs_unregister(slot)); +} + +test "cannot query unconfigured source" { + obs_reset(); + // Slot 0 is not active β€” should fail + try std.testing.expectEqual(@as(c_int, -1), obs_begin_query(0)); +} + +test "query lifecycle with count tracking" { + obs_reset(); + const slot = obs_register(@intFromEnum(ObserveBackend.loki)); + try std.testing.expectEqual(@as(c_int, 0), obs_query_count(slot)); + try std.testing.expectEqual(@as(c_int, 0), obs_begin_query(slot)); + try std.testing.expectEqual(@as(c_int, @intFromEnum(ObserveState.querying)), obs_state(slot)); + try std.testing.expectEqual(@as(c_int, 0), obs_end_query(slot)); + try std.testing.expectEqual(@as(c_int, 1), obs_query_count(slot)); +} + +test "multiple queries increment count" { + obs_reset(); + const slot = obs_register(@intFromEnum(ObserveBackend.jaeger)); + _ = obs_begin_query(slot); + _ = obs_end_query(slot); + _ = obs_begin_query(slot); + _ = obs_end_query(slot); + _ = obs_begin_query(slot); + _ = obs_end_query(slot); + try std.testing.expectEqual(@as(c_int, 3), obs_query_count(slot)); +} + +test "cannot unregister while querying" { + obs_reset(); + const slot = obs_register(@intFromEnum(ObserveBackend.grafana)); + _ = obs_begin_query(slot); + // Should fail β€” can only unregister from query_ready + try std.testing.expectEqual(@as(c_int, -2), obs_unregister(slot)); +} + +test "state transition validation" { + // Valid transitions + try std.testing.expectEqual(@as(c_int, 1), obs_can_transition(0, 1)); // unconfigured -> registered + try std.testing.expectEqual(@as(c_int, 1), obs_can_transition(1, 2)); // registered -> query_ready + try std.testing.expectEqual(@as(c_int, 1), obs_can_transition(2, 3)); // query_ready -> querying + try std.testing.expectEqual(@as(c_int, 1), obs_can_transition(3, 2)); // querying -> query_ready + try std.testing.expectEqual(@as(c_int, 1), obs_can_transition(2, 0)); // query_ready -> unconfigured + // Invalid transitions β€” the key safety invariant + try std.testing.expectEqual(@as(c_int, 0), obs_can_transition(0, 3)); // unconfigured -> querying (BLOCKED) + try std.testing.expectEqual(@as(c_int, 0), obs_can_transition(0, 2)); // unconfigured -> query_ready + try std.testing.expectEqual(@as(c_int, 0), obs_can_transition(3, 0)); // querying -> unconfigured +} + +// ═══════════════════════════════════════════════════════════════════════ +// ADR-0006 invoke dispatch tests +// ═══════════════════════════════════════════════════════════════════════ + +test "invoke: each declared tool succeeds" { + var buf: [256]u8 = undefined; + const tools = [_][]const u8{ + "observe_register", + "observe_query_metrics", + "observe_query_logs", + "observe_query_traces", + }; + for (tools) |t| { + var len: usize = buf.len; + const rc = boj_cartridge_invoke(t.ptr, "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, 0), rc); + try std.testing.expect(std.mem.indexOf(u8, buf[0..len], "result") != null); + } +} + +test "invoke: unknown tool returns -1" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("nope", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -1), rc); +} + +test "invoke: buffer too small returns -3" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("observe_register", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -3), rc); + try std.testing.expect(len > 4); +} diff --git a/cartridges/domains/observability/observe-mcp/mod.js b/cartridges/domains/observability/observe-mcp/mod.js new file mode 100644 index 0000000..14beb76 --- /dev/null +++ b/cartridges/domains/observability/observe-mcp/mod.js @@ -0,0 +1,55 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// observe-mcp/mod.js β€” Unified observability β€” metrics, logs, traces (Prometheus, Grafana, Loki, Jaeger) +// +// Delegates to backend at http://127.0.0.1:7736 (override with OBSERVE_BACKEND_URL). + +const BASE_URL = Deno.env.get("OBSERVE_BACKEND_URL") ?? "http://127.0.0.1:7736"; +const TIMEOUT_MS = 15_000; + +async function post(path, payload) { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), TIMEOUT_MS); + try { + const r = await fetch(`${BASE_URL}${path}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + signal: ctrl.signal, + }); + const data = await r.json().catch(() => ({ success: false, error: "non-JSON response" })); + return { status: r.status, data }; + } catch (e) { + if (e.name === "AbortError") return { status: 504, data: { success: false, error: "observe-mcp backend timed out" } }; + return { status: 503, data: { success: false, error: `observe-mcp backend unavailable: ${e.message}` } }; + } finally { clearTimeout(t); } +} + +async function get(path) { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), TIMEOUT_MS); + try { + const r = await fetch(`${BASE_URL}${path}`, { method: "GET", signal: ctrl.signal }); + const data = await r.json().catch(() => ({ success: false, error: "non-JSON response" })); + return { status: r.status, data }; + } catch (e) { + if (e.name === "AbortError") return { status: 504, data: { success: false, error: "observe-mcp backend timed out" } }; + return { status: 503, data: { success: false, error: `observe-mcp backend unavailable: ${e.message}` } }; + } finally { clearTimeout(t); } +} + +export async function handleTool(toolName, args) { + switch (toolName) { + case "observe_register": + return post("/api/v1/observe_register", args ?? {}); + case "observe_query_metrics": + return post("/api/v1/observe_query_metrics", args ?? {}); + case "observe_query_logs": + return post("/api/v1/observe_query_logs", args ?? {}); + case "observe_query_traces": + return post("/api/v1/observe_query_traces", args ?? {}); + default: + return { status: 404, data: { error: `Unknown tool: ${toolName}` } }; + } +} diff --git a/cartridges/domains/observability/observe-mcp/panels/manifest.json b/cartridges/domains/observability/observe-mcp/panels/manifest.json new file mode 100644 index 0000000..37b4513 --- /dev/null +++ b/cartridges/domains/observability/observe-mcp/panels/manifest.json @@ -0,0 +1,84 @@ +{ + "$schema": "https://panll.dev/schemas/panel-manifest/v1.json", + "spdx": "MPL-2.0", + "cartridge": "observe-mcp", + "domain": "Observability", + "version": "0.1.0", + "panels": [ + { + "id": "observe-status", + "title": "Observability Status", + "description": "Health of metrics, logging, and tracing backends", + "type": "status-indicator", + "data_source": { + "endpoint": "/cartridge/observe-mcp/invoke", + "method": "POST", + "body": { "tool": "status" }, + "refresh_interval_ms": 5000 + }, + "widgets": [ + { + "type": "state-badge", + "field": "state", + "states": { + "ready": { "color": "#2ecc71", "icon": "eye" }, + "degraded": { "color": "#f39c12", "icon": "eye-off" }, + "error": { "color": "#e74c3c", "icon": "alert-triangle" } + } + }, + { "type": "text", "field": "version", "label": "Version" } + ] + }, + { + "id": "observe-metrics", + "title": "Metrics Pipeline", + "description": "Active metric series, scrape targets, and ingestion rate", + "type": "metric", + "data_source": { + "endpoint": "/cartridge/observe-mcp/invoke", + "method": "POST", + "body": { "tool": "metrics_summary" }, + "refresh_interval_ms": 10000 + }, + "widgets": [ + { "type": "counter", "field": "active_series", "label": "Active Series", "icon": "bar-chart-2" }, + { "type": "counter", "field": "scrape_targets", "label": "Scrape Targets", "icon": "target" }, + { "type": "text", "field": "ingestion_rate", "label": "Ingestion Rate" } + ] + }, + { + "id": "observe-logs", + "title": "Log Pipeline", + "description": "Log volume, error rate, and active log sources", + "type": "metric", + "data_source": { + "endpoint": "/cartridge/observe-mcp/invoke", + "method": "POST", + "body": { "tool": "logs_summary" }, + "refresh_interval_ms": 10000 + }, + "widgets": [ + { "type": "counter", "field": "log_sources", "label": "Log Sources", "icon": "file-text" }, + { "type": "counter", "field": "logs_per_second", "label": "Logs/sec", "icon": "activity" }, + { "type": "counter", "field": "error_rate_percent", "label": "Error Rate %", "icon": "alert-circle" } + ] + }, + { + "id": "observe-traces", + "title": "Trace Pipeline", + "description": "Active traces, span throughput, and trace storage utilization", + "type": "metric", + "data_source": { + "endpoint": "/cartridge/observe-mcp/invoke", + "method": "POST", + "body": { "tool": "traces_summary" }, + "refresh_interval_ms": 15000 + }, + "widgets": [ + { "type": "counter", "field": "active_traces", "label": "Active Traces", "icon": "git-merge" }, + { "type": "counter", "field": "spans_per_second", "label": "Spans/sec", "icon": "zap" }, + { "type": "gauge", "field": "storage_utilization", "label": "Storage %", "min": 0, "max": 100 } + ] + } + ] +} diff --git a/cartridges/domains/observability/prometheus-mcp/README.adoc b/cartridges/domains/observability/prometheus-mcp/README.adoc new file mode 100644 index 0000000..4d68696 --- /dev/null +++ b/cartridges/domains/observability/prometheus-mcp/README.adoc @@ -0,0 +1,84 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += prometheus-mcp +Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk> +:spdx: MPL-2.0 +:tier: Ayo +:domain: Monitoring +:protocols: MCP, REST + +== Overview + +Prometheus monitoring cartridge for the BoJ server. Provides type-safe access to +the Prometheus HTTP API v1 for instant and range PromQL queries, target discovery, +alert rule listing, label/value browsing, metric metadata, and series listing. + +=== State Machine + +`Unauthenticated -> Authenticated` (optional, reads work without auth) + +`Authenticated -> RateLimited -> Authenticated` (normal flow) + +`Authenticated -> Error -> Unauthenticated` (error recovery) + +=== Actions (8) + +[cols="1,1"] +|=== +| Category | Actions + +| Queries +| InstantQuery, RangeQuery + +| Targets +| ListTargets + +| Alerts +| ListAlerts + +| Labels +| ListLabels, LabelValues + +| Metadata +| GetMetadata + +| Series +| ListSeries +|=== + +== Architecture + +[cols="1,1,2"] +|=== +| Layer | Language | Purpose + +| ABI +| Idris2 +| Formally verified state machine with dependent-type proofs (PrometheusMcp.SafeRegistry) + +| FFI +| Zig +| C-compatible implementation with thread-safe session pool, query counting + +| Adapter +| zig +| REST bridge to BoJ unified adapter protocol +|=== + +== Building + +[source,bash] +---- +# Build FFI shared library +cd ffi && zig build + +# Run FFI tests +cd ffi && zig build test + +# Type-check ABI +cd abi && idris2 --check PrometheusMcp.SafeRegistry +---- + +== Status + +Development -- Tier 6 monitoring cartridge with PromQL query and discovery support. diff --git a/cartridges/domains/observability/prometheus-mcp/abi/PrometheusMcp/SafeRegistry.idr b/cartridges/domains/observability/prometheus-mcp/abi/PrometheusMcp/SafeRegistry.idr new file mode 100644 index 0000000..6a4f15e --- /dev/null +++ b/cartridges/domains/observability/prometheus-mcp/abi/PrometheusMcp/SafeRegistry.idr @@ -0,0 +1,169 @@ +-- SPDX-License-Identifier: MPL-2.0 +-- Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +-- +-- PrometheusMcp.SafeRegistry β€” Type-safe ABI for prometheus-mcp cartridge. +-- +-- Dependent-type state machine governing Prometheus HTTP API v1 access. +-- Encodes optional Bearer token auth, instant/range PromQL queries, +-- target discovery, alert listing, label browsing, metadata retrieval, +-- and series listing as compile-time invariants. +-- API: https://prometheus.io/docs/prometheus/latest/querying/api/ +-- No unsafe escape hatches. + +module PrometheusMcp.SafeRegistry + +%default total + +-- --------------------------------------------------------------------------- +-- Authentication state machine +-- --------------------------------------------------------------------------- + +||| Session state for Prometheus MCP operations. +||| Unauthenticated: no API token; public read access available. +||| Authenticated: Prometheus API token active. +||| RateLimited: query rate limit hit; must wait. +||| Error: unrecoverable error (network failure, bad query). +public export +data SessionState + = Unauthenticated + | Authenticated + | RateLimited + | Error + +||| Proof that a state transition is valid. +||| Prometheus supports both authenticated and unauthenticated access. +public export +data ValidTransition : SessionState -> SessionState -> Type where + Authenticate : ValidTransition Unauthenticated Authenticated + Deauthenticate : ValidTransition Authenticated Unauthenticated + Throttle : ValidTransition Authenticated RateLimited + ThrottleAnon : ValidTransition Unauthenticated RateLimited + Unthrottle : ValidTransition RateLimited Authenticated + UnthrottleAnon : ValidTransition RateLimited Unauthenticated + AuthError : ValidTransition Authenticated Error + AnonError : ValidTransition Unauthenticated Error + RecoverToAuth : ValidTransition Error Authenticated + RecoverToAnon : ValidTransition Error Unauthenticated + +-- --------------------------------------------------------------------------- +-- C-ABI integer encoding +-- --------------------------------------------------------------------------- + +||| Encode session state as C-compatible integer for FFI boundary. +export +sessionStateToInt : SessionState -> Int +sessionStateToInt Unauthenticated = 0 +sessionStateToInt Authenticated = 1 +sessionStateToInt RateLimited = 2 +sessionStateToInt Error = 3 + +||| Decode integer back to session state. Returns Nothing for out-of-range. +export +intToSessionState : Int -> Maybe SessionState +intToSessionState 0 = Just Unauthenticated +intToSessionState 1 = Just Authenticated +intToSessionState 2 = Just RateLimited +intToSessionState 3 = Just Error +intToSessionState _ = Nothing + +||| Check if a state transition is valid (C-ABI export). +export +prometheus_mcp_can_transition : Int -> Int -> Int +prometheus_mcp_can_transition from to = + case (intToSessionState from, intToSessionState to) of + (Just Unauthenticated, Just Authenticated) => 1 + (Just Authenticated, Just Unauthenticated) => 1 + (Just Authenticated, Just RateLimited) => 1 + (Just Unauthenticated, Just RateLimited) => 1 + (Just RateLimited, Just Authenticated) => 1 + (Just RateLimited, Just Unauthenticated) => 1 + (Just Authenticated, Just Error) => 1 + (Just Unauthenticated, Just Error) => 1 + (Just Error, Just Authenticated) => 1 + (Just Error, Just Unauthenticated) => 1 + _ => 0 + +-- --------------------------------------------------------------------------- +-- Prometheus actions +-- --------------------------------------------------------------------------- + +||| Actions available through the Prometheus MCP cartridge. +||| Grouped: Queries, Targets, Alerts, Labels, Metadata, Series. +public export +data PrometheusAction + = InstantQuery + | RangeQuery + | ListTargets + | ListAlerts + | ListLabels + | LabelValues + | GetMetadata + | ListSeries + +||| Whether an action requires Authenticated state. +||| Prometheus allows unauthenticated read access by default. +export +actionRequiresAuth : PrometheusAction -> Bool +actionRequiresAuth _ = False + +||| Whether an action is a write/mutating operation. +||| All Prometheus MCP actions are read-only queries. +export +actionIsMutating : PrometheusAction -> Bool +actionIsMutating _ = False + +||| Encode action as C-compatible integer for FFI. +export +actionToInt : PrometheusAction -> Int +actionToInt InstantQuery = 0 +actionToInt RangeQuery = 1 +actionToInt ListTargets = 2 +actionToInt ListAlerts = 3 +actionToInt ListLabels = 4 +actionToInt LabelValues = 5 +actionToInt GetMetadata = 6 +actionToInt ListSeries = 7 + +||| Decode integer to Prometheus action. +export +intToAction : Int -> Maybe PrometheusAction +intToAction 0 = Just InstantQuery +intToAction 1 = Just RangeQuery +intToAction 2 = Just ListTargets +intToAction 3 = Just ListAlerts +intToAction 4 = Just ListLabels +intToAction 5 = Just LabelValues +intToAction 6 = Just GetMetadata +intToAction 7 = Just ListSeries +intToAction _ = Nothing + +-- --------------------------------------------------------------------------- +-- MCP tool declarations +-- --------------------------------------------------------------------------- + +||| Tools exposed via MCP protocol for this cartridge. +public export +data McpTool + = ToolInstantQuery + | ToolRangeQuery + | ToolListTargets + | ToolListAlerts + | ToolListLabels + | ToolLabelValues + | ToolGetMetadata + | ToolListSeries + +||| Check if a tool requires an authenticated session. +export +toolRequiresSession : McpTool -> Bool +toolRequiresSession _ = False + +||| Total tool count for this cartridge. +export +toolCount : Nat +toolCount = 8 + +||| Total action count for this cartridge. +export +actionCount : Nat +actionCount = 8 diff --git a/cartridges/domains/observability/prometheus-mcp/abi/README.adoc b/cartridges/domains/observability/prometheus-mcp/abi/README.adoc new file mode 100644 index 0000000..2973114 --- /dev/null +++ b/cartridges/domains/observability/prometheus-mcp/abi/README.adoc @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += prometheus-mcp / abi β€” Idris2 ABI layer +:orientation: shallow + +== Purpose + +Cartridge-local protocol types for `prometheus-mcp`. Defines the data, tool, and +transition vocabulary this cartridge speaks. Inherits safety lemmas and the +matrix-cell identifier scheme from the trunk ABI at `src/abi/`. + +== Files + +1 Idris2 module(s), ~169 lines total. Lead module: +`SafeRegistry.idr`. + +No `.ipkg` β€” consumed directly by wider BoJ callers; type-checks inside the +trunk `boj.ipkg`. + +== Invariants + +* `%default total` at every module top. +* Zero `believe_me`, `assert_total`, `postulate`, `Admitted`, or + `sorry` (the trunk holds the four declared `believe_me` sites; leaf + cartridges carry none). + +== Test/proof surface + +Thin module(s), no inline tests. Proof surface = type-check success under the +trunk `idris2 --check`. + +== Read-first + +. `SafeRegistry.idr` β€” cartridge-local data records and enums. +. Trunk `src/abi/Boj/Catalogue.idr` for how this cell participates in the + 2D matrix. diff --git a/cartridges/domains/observability/prometheus-mcp/adapter/README.adoc b/cartridges/domains/observability/prometheus-mcp/adapter/README.adoc new file mode 100644 index 0000000..156c04c --- /dev/null +++ b/cartridges/domains/observability/prometheus-mcp/adapter/README.adoc @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += prometheus-mcp / adapter β€” protocol bridge +:orientation: shallow + +== Purpose + +Exposes this cartridge's FFI over the wire protocols declared in +`cartridge.json`. Dispatches tool invocations to the C-ABI exports at +`../ffi/` and formats responses per protocol. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” depends on the sibling `ffi` target. +| `prometheus_adapter.zig` | Protocol dispatch (196 lines). Tool catalogue matches `cartridge.json`. +| `SIDELINED-*.v.adoc` | zig predecessor(s). Not built β€” zig banned 2026-04-10. +|=== + +== Invariants + +* **Stateless** β€” all state lives behind the FFI, never in the adapter. +* **Response passthrough** β€” whatever the FFI returns goes back to the wire + unmodified (no embellishment, no silent recovery). +* **Cartridge.json is source of truth** for the tool catalogue; drift between + adapter and manifest is a CI failure. + +== Test/proof surface + +No inline tests at the adapter layer β€” protocol routing is exercised by the +trunk seam tests at `ffi/zig/src/seams.zig`. + +== Read-first + +. `prometheus_adapter.zig` β€” dispatch function and per-protocol routers. +. `../cartridge.json` β€” the tool manifest the dispatcher mirrors. diff --git a/cartridges/domains/observability/prometheus-mcp/adapter/build.zig b/cartridges/domains/observability/prometheus-mcp/adapter/build.zig new file mode 100644 index 0000000..391459d --- /dev/null +++ b/cartridges/domains/observability/prometheus-mcp/adapter/build.zig @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// prometheus-mcp/adapter/build.zig + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const ffi_mod = b.createModule(.{ + .root_source_file = b.path("../ffi/prometheus_mcp_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + const adapter = b.addExecutable(.{ + .name = "prometheus_adapter", + .root_source_file = b.path("prometheus_adapter.zig"), + .target = target, + .optimize = optimize, + }); + adapter.root_module.addImport("prometheus_mcp_ffi", ffi_mod); + b.installArtifact(adapter); + + const run_artifact = b.addRunArtifact(adapter); + const run_step = b.step("run", "Run the prometheus-mcp adapter"); + run_step.dependOn(&run_artifact.step); + + const tests = b.addTest(.{ + .root_source_file = b.path("prometheus_adapter.zig"), + .target = target, + .optimize = optimize, + }); + tests.root_module.addImport("prometheus_mcp_ffi", ffi_mod); + const run_tests = b.addRunArtifact(tests); + const test_step = b.step("test", "Run prometheus-mcp adapter tests"); + test_step.dependOn(&run_tests.step); +} diff --git a/cartridges/domains/observability/prometheus-mcp/adapter/prometheus_adapter.zig b/cartridges/domains/observability/prometheus-mcp/adapter/prometheus_adapter.zig new file mode 100644 index 0000000..bd6f034 --- /dev/null +++ b/cartridges/domains/observability/prometheus-mcp/adapter/prometheus_adapter.zig @@ -0,0 +1,196 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// prometheus-mcp/adapter/prometheus_adapter.zig -- Unified three-protocol adapter. +// +// Replaces the banned prometheus_adapter.v (zig, removed 2026-04-12). +// +// Bridges the Zig FFI (prometheus_mcp_ffi.zig) to three network protocols: +// REST :9082 POST /tools/<tool> +// gRPC-compat :9083 /PrometheusMcpService/<Method> +// GraphQL :9084 POST /graphql { query: "..." } +// +// Prometheus metrics: instant/range queries, targets, alerts, labels +// Tools: +// prometheus_query +// prometheus_query_range +// prometheus_list_targets +// prometheus_list_alerts +// prometheus_list_labels +// prometheus_label_values +// prometheus_metadata +// prometheus_series + +const std = @import("std"); +const ffi = @import("prometheus_mcp_ffi"); + +const REST_PORT: u16 = 9082; +const GRPC_PORT: u16 = 9083; +const GQL_PORT: u16 = 9084; + +const MAX_CONN_BUF: usize = 16 * 1024; + +// ============================================================================ +// JSON response builders +// ============================================================================ + +fn okJson(buf: []u8, msg: []const u8) []u8 { + return std.fmt.bufPrint(buf, + \{{"success":true,"message":"{}"}} + , .{msg}) catch buf[0..0]; +} + +fn errJson(buf: []u8, msg: []const u8) []u8 { + return std.fmt.bufPrint(buf, + \{{"success":false,"error":"{}"}} + , .{msg}) catch buf[0..0]; +} + +fn statusJson(buf: []u8) []u8 { + return std.fmt.bufPrint(buf, + \{{"success":true,"state":"ready","service":"prometheus-mcp"}} + , .{}) catch buf[0..0]; +} + +// ============================================================================ +// Tool dispatcher +// ============================================================================ + +const Response = struct { status: u16, body: []u8 }; + +fn dispatch(tool: []const u8, body: []const u8, resp: []u8) Response { + _ = body; + if (std.mem.eql(u8, tool, "prometheus_query")) return .{ .status = 200, .body = okJson(resp, "prometheus_query forwarded to backend") }; + if (std.mem.eql(u8, tool, "prometheus_query_range")) return .{ .status = 200, .body = okJson(resp, "prometheus_query_range forwarded to backend") }; + if (std.mem.eql(u8, tool, "prometheus_list_targets")) return .{ .status = 200, .body = okJson(resp, "prometheus_list_targets forwarded to backend") }; + if (std.mem.eql(u8, tool, "prometheus_list_alerts")) return .{ .status = 200, .body = okJson(resp, "prometheus_list_alerts forwarded to backend") }; + if (std.mem.eql(u8, tool, "prometheus_list_labels")) return .{ .status = 200, .body = okJson(resp, "prometheus_list_labels forwarded to backend") }; + if (std.mem.eql(u8, tool, "prometheus_label_values")) return .{ .status = 200, .body = okJson(resp, "prometheus_label_values forwarded to backend") }; + if (std.mem.eql(u8, tool, "prometheus_metadata")) return .{ .status = 200, .body = okJson(resp, "prometheus_metadata forwarded to backend") }; + if (std.mem.eql(u8, tool, "prometheus_series")) return .{ .status = 200, .body = okJson(resp, "prometheus_series forwarded to backend") }; + if (std.mem.eql(u8, tool, "status") or std.mem.eql(u8, tool, "health")) + return .{ .status = 200, .body = statusJson(resp) }; + return .{ .status = 404, .body = errJson(resp, "Unknown tool") }; +} + +// ============================================================================ +// REST handler +// ============================================================================ + +fn dispatchRest(path: []const u8, body: []const u8, resp: []u8) Response { + if (std.mem.startsWith(u8, path, "/tools/")) { + return dispatch(path["/tools/".len..], body, resp); + } + if (std.mem.eql(u8, path, "/status") or std.mem.eql(u8, path, "/health")) { + return .{ .status = 200, .body = statusJson(resp) }; + } + return .{ .status = 404, .body = errJson(resp, "Not found") }; +} + +// ============================================================================ +// gRPC-compat handler +// ============================================================================ + +fn dispatchGrpc(path: []const u8, body: []const u8, resp: []u8) Response { + const prefix = "/PrometheusMcpService/"; + if (!std.mem.startsWith(u8, path, prefix)) + return .{ .status = 404, .body = errJson(resp, "Not a recognized gRPC path") }; + const method = path[prefix.len..]; + const tool = blk: { + if (std.mem.eql(u8, method, "PrometheusQuery")) break :blk "prometheus_query"; + if (std.mem.eql(u8, method, "PrometheusQueryRange")) break :blk "prometheus_query_range"; + if (std.mem.eql(u8, method, "PrometheusListTargets")) break :blk "prometheus_list_targets"; + if (std.mem.eql(u8, method, "PrometheusListAlerts")) break :blk "prometheus_list_alerts"; + if (std.mem.eql(u8, method, "PrometheusListLabels")) break :blk "prometheus_list_labels"; + if (std.mem.eql(u8, method, "PrometheusLabelValues")) break :blk "prometheus_label_values"; + if (std.mem.eql(u8, method, "PrometheusMetadata")) break :blk "prometheus_metadata"; + if (std.mem.eql(u8, method, "PrometheusSeries")) break :blk "prometheus_series"; + return .{ .status = 404, .body = errJson(resp, "Unknown gRPC method") }; + }; + return dispatch(tool, body, resp); +} + +// ============================================================================ +// GraphQL handler +// ============================================================================ + +fn dispatchGraphql(body: []const u8, resp: []u8) Response { + if (std.mem.indexOf(u8, body, "__schema") != null) + return .{ .status = 200, .body = okJson(resp, "schema introspection not yet supported") }; + if (std.mem.indexOf(u8, body, "query") != null) return dispatch("prometheus_query", body, resp); + if (std.mem.indexOf(u8, body, "query_range") != null) return dispatch("prometheus_query_range", body, resp); + if (std.mem.indexOf(u8, body, "list_targets") != null) return dispatch("prometheus_list_targets", body, resp); + if (std.mem.indexOf(u8, body, "list_alerts") != null) return dispatch("prometheus_list_alerts", body, resp); + if (std.mem.indexOf(u8, body, "list_labels") != null) return dispatch("prometheus_list_labels", body, resp); + if (std.mem.indexOf(u8, body, "label_values") != null) return dispatch("prometheus_label_values", body, resp); + if (std.mem.indexOf(u8, body, "metadata") != null) return dispatch("prometheus_metadata", body, resp); + if (std.mem.indexOf(u8, body, "series") != null) return dispatch("prometheus_series", body, resp); + return .{ .status = 200, .body = errJson(resp, "Unrecognised GraphQL operation") }; +} + +// ============================================================================ +// HTTP/1.1 connection handler +// ============================================================================ + +const Protocol = enum { rest, grpc, graphql }; + +fn handleConnection(conn: std.net.Server.Connection, proto: Protocol) void { + defer conn.stream.close(); + var in_buf: [MAX_CONN_BUF]u8 = undefined; + const n = conn.stream.read(&in_buf) catch return; + const req = in_buf[0..n]; + + var path: []const u8 = "/"; + var body: []const u8 = ""; + if (n > 4) { + const line_end = std.mem.indexOf(u8, req, "\r\n") orelse req.len; + const first_line = req[0..line_end]; + const sp1 = std.mem.indexOfScalar(u8, first_line, ' ') orelse 0; + const rest_of = first_line[sp1 + 1 ..]; + const sp2 = std.mem.indexOfScalar(u8, rest_of, ' ') orelse rest_of.len; + path = rest_of[0..sp2]; + const body_sep = std.mem.indexOf(u8, req, "\r\n\r\n") orelse n; + body = req[@min(body_sep + 4, n)..]; + } + + var resp_buf: [MAX_CONN_BUF]u8 = undefined; + const result = switch (proto) { + .rest => dispatchRest(path, body, &resp_buf), + .grpc => dispatchGrpc(path, body, &resp_buf), + .graphql => dispatchGraphql(body, &resp_buf), + }; + + const content_type = switch (proto) { + .rest => "application/json", + .grpc => "application/grpc+json", + .graphql => "application/json", + }; + + var hdr_buf: [256]u8 = undefined; + const hdr = std.fmt.bufPrint(&hdr_buf, + "HTTP/1.1 {d} OK\r\nContent-Type: {s}\r\nContent-Length: {d}\r\nConnection: close\r\n\r\n", + .{ result.status, content_type, result.body.len }, + ) catch return; + _ = conn.stream.writeAll(hdr) catch return; + _ = conn.stream.writeAll(result.body) catch return; +} + +fn listenLoop(port: u16, proto: Protocol) void { + const addr = std.net.Address.initIp4(.{ 127, 0, 0, 1 }, port) catch return; + var server = addr.listen(.{ .reuse_address = true }) catch return; + defer server.deinit(); + while (true) { + const conn = server.accept() catch continue; + handleConnection(conn, proto); + } +} + +pub fn main() !void { + ffi.prometheus_init(); + const rest_thread = try std.Thread.spawn(.{}, listenLoop, .{ REST_PORT, Protocol.rest }); + const grpc_thread = try std.Thread.spawn(.{}, listenLoop, .{ GRPC_PORT, Protocol.grpc }); + const gql_thread = try std.Thread.spawn(.{}, listenLoop, .{ GQL_PORT, Protocol.graphql }); + rest_thread.join(); + grpc_thread.join(); + gql_thread.join(); +} diff --git a/cartridges/domains/observability/prometheus-mcp/benchmarks/quick-bench.sh b/cartridges/domains/observability/prometheus-mcp/benchmarks/quick-bench.sh new file mode 100755 index 0000000..09ac28c --- /dev/null +++ b/cartridges/domains/observability/prometheus-mcp/benchmarks/quick-bench.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Benchmarks for prometheus-mcp cartridge. +set -euo pipefail + +echo "=== prometheus-mcp benchmarks ===" + +cd "$(dirname "${BASH_SOURCE[0]}")/../ffi" + +# Build with ReleaseFast for benchmarking +zig build -Doptimize=ReleaseFast 2>&1 + +echo "Session open/close cycle (1000 iterations):" +time for i in $(seq 1 1000); do + true +done + +echo "" +echo "Benchmark placeholder -- implement real benchmarks in Zig test blocks" +echo "or via the zig adapter HTTP benchmark tool." diff --git a/cartridges/domains/observability/prometheus-mcp/cartridge.json b/cartridges/domains/observability/prometheus-mcp/cartridge.json new file mode 100644 index 0000000..97b107e --- /dev/null +++ b/cartridges/domains/observability/prometheus-mcp/cartridge.json @@ -0,0 +1,190 @@ +{ + "$schema": "https://boj.dev/schemas/cartridge/v1.json", + "spdx": "MPL-2.0", + "copyright": "Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath)", + "name": "prometheus-mcp", + "version": "0.1.0", + "description": "Prometheus monitoring cartridge -- instant and range queries (PromQL), target discovery, alert rule listing, label/value browsing, metric metadata, series listing, and runtime/build info via the Prometheus HTTP API v1", + "domain": "Monitoring", + "tier": "Ayo", + "protocols": [ + "MCP", + "REST" + ], + "auth": { + "method": "bearer_token", + "env_var": "PROMETHEUS_TOKEN", + "credential_source": "vault-mcp" + }, + "api": { + "base_url": "https://prometheus.example.com/api/v1", + "content_type": "application/json" + }, + "tools": [ + { + "name": "prometheus_query", + "description": "Execute an instant PromQL query at a single point in time", + "inputSchema": { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "PromQL expression" + }, + "time": { + "type": "string", + "description": "Evaluation timestamp (ISO 8601 or Unix, default: now)" + } + }, + "required": [ + "query" + ] + } + }, + { + "name": "prometheus_query_range", + "description": "Execute a range PromQL query over a time window with step interval", + "inputSchema": { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "PromQL expression" + }, + "start": { + "type": "string", + "description": "Start timestamp (ISO 8601 or Unix)" + }, + "end": { + "type": "string", + "description": "End timestamp (ISO 8601 or Unix)" + }, + "step": { + "type": "string", + "description": "Query resolution step (e.g. '15s', '1m', '5m')" + } + }, + "required": [ + "query", + "start", + "end", + "step" + ] + } + }, + { + "name": "prometheus_list_targets", + "description": "List all Prometheus scrape targets with health status and labels", + "inputSchema": { + "type": "object", + "properties": { + "state": { + "type": "string", + "description": "Filter by state: active, dropped, any" + } + } + } + }, + { + "name": "prometheus_list_alerts", + "description": "List active Prometheus alert rules with state and annotations", + "inputSchema": { + "type": "object", + "properties": {} + } + }, + { + "name": "prometheus_list_labels", + "description": "List all label names known to Prometheus", + "inputSchema": { + "type": "object", + "properties": { + "start": { + "type": "string", + "description": "Start timestamp for label window" + }, + "end": { + "type": "string", + "description": "End timestamp for label window" + } + } + } + }, + { + "name": "prometheus_label_values", + "description": "List all values for a specific label name", + "inputSchema": { + "type": "object", + "properties": { + "label": { + "type": "string", + "description": "Label name (e.g. 'job', 'instance', '__name__')" + }, + "start": { + "type": "string", + "description": "Start timestamp" + }, + "end": { + "type": "string", + "description": "End timestamp" + } + }, + "required": [ + "label" + ] + } + }, + { + "name": "prometheus_metadata", + "description": "Get metric metadata (type, help, unit) for metrics matching a name", + "inputSchema": { + "type": "object", + "properties": { + "metric": { + "type": "string", + "description": "Metric name or prefix" + }, + "limit": { + "type": "number", + "description": "Max metadata entries" + } + } + } + }, + { + "name": "prometheus_series", + "description": "List time series matching a set of label matchers", + "inputSchema": { + "type": "object", + "properties": { + "match": { + "type": "string", + "description": "Series selector (e.g. '{job=\"node\"}')" + }, + "start": { + "type": "string", + "description": "Start timestamp" + }, + "end": { + "type": "string", + "description": "End timestamp" + } + }, + "required": [ + "match" + ] + } + } + ], + "ffi": { + "so_path": "ffi/zig-out/lib/libprometheus_mcp.so", + "abi_version": "ADR-0006", + "symbols": [ + "boj_cartridge_init", + "boj_cartridge_deinit", + "boj_cartridge_name", + "boj_cartridge_version", + "boj_cartridge_invoke" + ] + } +} diff --git a/cartridges/domains/observability/prometheus-mcp/ffi/README.adoc b/cartridges/domains/observability/prometheus-mcp/ffi/README.adoc new file mode 100644 index 0000000..3703737 --- /dev/null +++ b/cartridges/domains/observability/prometheus-mcp/ffi/README.adoc @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += prometheus-mcp / ffi β€” Zig FFI layer +:orientation: shallow + +== Purpose + +C-ABI exports bridging this cartridge's Idris2 ABI to the runtime. Consumed by +the adapter for protocol dispatch and by the trunk loader for mount. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” produces `zig-out/lib/libprometheus.so`. +| `prometheus_mcp_ffi.zig` | C-ABI exports (15 exports, 6 inline tests, 356 lines). +|=== + +== Invariants + +* **Null-pointer rejection** on `[*c]const u8` arguments before any + dereference. +* **Negative return values** carry documented error meanings; zero is + success (matching the trunk error-code convention). +* **No allocator leaks** β€” every allocation path pairs with `defer free`. + +== Test/proof surface + +6 inline `test "..."` block(s) in `prometheus_mcp_ffi.zig`. Run with +`cd ffi && zig build test`. + +== Read-first + +. `prometheus_mcp_ffi.zig` β€” C-ABI signatures and guards. +. Trunk `ffi/zig/src/catalogue.zig` for how this `.so` is loaded. diff --git a/cartridges/domains/observability/prometheus-mcp/ffi/build.zig b/cartridges/domains/observability/prometheus-mcp/ffi/build.zig new file mode 100644 index 0000000..6377f99 --- /dev/null +++ b/cartridges/domains/observability/prometheus-mcp/ffi/build.zig @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + // Shared ADR-0006 invoke-shim module (relative path up to boj-server trunk). + + const shim_mod = b.addModule("cartridge_shim", .{ + + .root_source_file = b.path("../../../ffi/zig/src/cartridge_shim.zig"), + + .target = target, + + .optimize = optimize, + + }); + + const ffi_mod = b.addModule("prometheus_mcp", .{ + .root_source_file = b.path("prometheus_mcp_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + ffi_mod.addImport("cartridge_shim", shim_mod); + + const lib = b.addLibrary(.{ + .name = "prometheus_mcp", + .root_module = ffi_mod, + .linkage = .dynamic, + }); + lib.linkLibC(); + b.installArtifact(lib); + + const tests = b.addTest(.{ .root_module = ffi_mod }); + tests.linkLibC(); + const run_tests = b.addRunArtifact(tests); + const test_step = b.step("test", "Run FFI tests"); + test_step.dependOn(&run_tests.step); +} + diff --git a/cartridges/domains/observability/prometheus-mcp/ffi/cartridge_shim.zig b/cartridges/domains/observability/prometheus-mcp/ffi/cartridge_shim.zig new file mode 100644 index 0000000..0e399db --- /dev/null +++ b/cartridges/domains/observability/prometheus-mcp/ffi/cartridge_shim.zig @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// cartridge_shim.zig β€” Shared helpers for the ADR-0006 five-symbol +// cartridge ABI (`boj_cartridge_init / deinit / name / version / invoke`). +// +// The shim centralises the seven-code return convention, NUL-argument +// guards, tool-name comparison, and the buffer-too-small path so each +// cartridge's `boj_cartridge_invoke` can stay short β€” typically a tool +// table plus `shim.writeResult(...)`. +// +// Cartridges import this file by relative path (no build-graph change +// needed). Example: +// +// const shim = @import("../../../ffi/zig/src/cartridge_shim.zig"); +// +// export fn boj_cartridge_invoke( +// tool_name: [*c]const u8, +// json_args: [*c]const u8, +// out_buf: [*c]u8, +// in_out_len: [*c]usize, +// ) callconv(.c) i32 { +// _ = json_args; +// if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; +// const body = if (shim.toolIs(tool_name, "foo")) "{\"result\":{}}" +// else return shim.RC_UNKNOWN_TOOL; +// return shim.writeResult(out_buf, in_out_len, body); +// } + +const std = @import("std"); + +// ── Return codes (ADR-0006 Β§Return codes) ──────────────────────────── +// +// Frozen by ADR-0006. New failure modes compose these via the error +// JSON body β€” the integer surface does not grow without a follow-up ADR. + +pub const RC_SUCCESS: i32 = 0; +pub const RC_UNKNOWN_TOOL: i32 = -1; +pub const RC_BAD_ARGS: i32 = -2; +pub const RC_BUFFER_TOO_SMALL: i32 = -3; +pub const RC_RUNTIME_ERROR: i32 = -4; +pub const RC_PANIC: i32 = -5; +pub const RC_AUTH_DENIED: i32 = -6; + +// ── Invoke-path helpers ────────────────────────────────────────────── + +/// True if any of the three mandatory `boj_cartridge_invoke` output-path +/// pointers is null. Use at the top of every invoke to short-circuit to +/// `RC_BAD_ARGS`. +pub fn invokeArgsNull( + tool_name: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) bool { + return tool_name == null or out_buf == null or in_out_len == null; +} + +/// Compare a C-NUL-terminated tool-name pointer against a Zig string +/// literal. Caller must have already verified `tool_name` is non-null +/// (usually via `invokeArgsNull`). +pub fn toolIs(tool_name: [*c]const u8, expected: []const u8) bool { + const s = std.mem.span(@as([*:0]const u8, @ptrCast(tool_name))); + return std.mem.eql(u8, s, expected); +} + +/// Copy `body` into `out_buf[0..*in_out_len]` (as a capacity) and update +/// `*in_out_len` to the number of bytes written. Returns `RC_SUCCESS`. +/// +/// If `body.len` exceeds the current capacity stored in `*in_out_len`, +/// sets `*in_out_len` to the required size and returns +/// `RC_BUFFER_TOO_SMALL` β€” the caller is then expected to re-allocate +/// and retry, per ADR-0006 Β§Memory ownership. +/// +/// Caller must have already verified that `out_buf` and `in_out_len` +/// are non-null. +pub fn writeResult( + out_buf: [*c]u8, + in_out_len: [*c]usize, + body: []const u8, +) i32 { + const cap = in_out_len.*; + if (body.len > cap) { + in_out_len.* = body.len; + return RC_BUFFER_TOO_SMALL; + } + @memcpy(out_buf[0..body.len], body); + in_out_len.* = body.len; + return RC_SUCCESS; +} + +// ── Tests ──────────────────────────────────────────────────────────── + +test "writeResult: body fits, writes and sets length" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); + try std.testing.expectEqualStrings("hello", buf[0..len]); +} + +test "writeResult: too small returns -3 and sets required length" { + var buf: [2]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_BUFFER_TOO_SMALL, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: exact-fit succeeds" { + var buf: [5]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: empty body" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, ""); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 0), len); +} + +test "toolIs: matches and rejects" { + const name: [*:0]const u8 = "foo"; + try std.testing.expect(toolIs(@ptrCast(name), "foo")); + try std.testing.expect(!toolIs(@ptrCast(name), "bar")); + try std.testing.expect(!toolIs(@ptrCast(name), "foobar")); + try std.testing.expect(!toolIs(@ptrCast(name), "fo")); +} + +test "invokeArgsNull: detects each null slot" { + var buf: [4]u8 = undefined; + var len: usize = 4; + const name: [*:0]const u8 = "x"; + try std.testing.expect(!invokeArgsNull(@ptrCast(name), &buf, &len)); + try std.testing.expect(invokeArgsNull(null, &buf, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), null, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), &buf, null)); +} diff --git a/cartridges/domains/observability/prometheus-mcp/ffi/prometheus_mcp_ffi.zig b/cartridges/domains/observability/prometheus-mcp/ffi/prometheus_mcp_ffi.zig new file mode 100644 index 0000000..20a97ee --- /dev/null +++ b/cartridges/domains/observability/prometheus-mcp/ffi/prometheus_mcp_ffi.zig @@ -0,0 +1,459 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// prometheus_mcp_ffi.zig β€” C-ABI FFI implementation for prometheus-mcp cartridge. +// +// Implements the state machine defined in PrometheusMcp.SafeRegistry (Idris2 ABI). +// State machine: Unauthenticated | Authenticated | RateLimited | Error +// Auth: Optional Bearer token β€” Prometheus reads are typically public. +// Actions: InstantQuery, RangeQuery, ListTargets, ListAlerts, ListLabels, +// LabelValues, GetMetadata, ListSeries +// Thread-safe via std.Thread.Mutex. Fixed-size session pool, no heap allocations. + +const std = @import("std"); + +// --------------------------------------------------------------------------- +// State machine (matches Idris2 ABI SessionState exactly) +// --------------------------------------------------------------------------- + +/// Session authentication/lifecycle state. +pub const SessionState = enum(c_int) { + unauthenticated = 0, + authenticated = 1, + rate_limited = 2, + err = 3, +}; + +/// Prometheus action identifiers matching Idris2 PrometheusAction encoding. +pub const PrometheusAction = enum(c_int) { + instant_query = 0, + range_query = 1, + list_targets = 2, + list_alerts = 3, + list_labels = 4, + label_values = 5, + get_metadata = 6, + list_series = 7, +}; + +/// Check valid state transitions per the Idris2 ValidTransition proof. +fn isValidTransition(from: SessionState, to: SessionState) bool { + return switch (from) { + .unauthenticated => to == .authenticated or to == .rate_limited or to == .err, + .authenticated => to == .unauthenticated or to == .rate_limited or to == .err, + .rate_limited => to == .authenticated or to == .unauthenticated, + .err => to == .authenticated or to == .unauthenticated, + }; +} + +// --------------------------------------------------------------------------- +// Session slots (thread-safe, fixed-size pool) +// --------------------------------------------------------------------------- + +const MAX_SESSIONS: usize = 16; + +const SessionSlot = struct { + active: bool = false, + state: SessionState = .unauthenticated, + api_call_count: u64 = 0, + last_action: c_int = -1, + instant_queries: u32 = 0, + range_queries: u32 = 0, + discovery_ops: u32 = 0, +}; + +var sessions: [MAX_SESSIONS]SessionSlot = .{SessionSlot{}} ** MAX_SESSIONS; +var mutex: std.Thread.Mutex = .{}; + +// --------------------------------------------------------------------------- +// C-ABI exports β€” state machine +// --------------------------------------------------------------------------- + +pub export fn prometheus_mcp_can_transition(from: c_int, to: c_int) c_int { + const f = std.meta.intToEnum(SessionState, from) catch return 0; + const t = std.meta.intToEnum(SessionState, to) catch return 0; + return if (isValidTransition(f, t)) 1 else 0; +} + +pub export fn prometheus_mcp_authenticate(dummy: c_int) c_int { + _ = dummy; + mutex.lock(); + defer mutex.unlock(); + + for (&sessions, 0..) |*slot, idx| { + if (!slot.active) { + slot.active = true; + slot.state = .authenticated; + slot.api_call_count = 0; + slot.last_action = -1; + slot.instant_queries = 0; + slot.range_queries = 0; + slot.discovery_ops = 0; + return @intCast(idx); + } + } + return -1; +} + +pub export fn prometheus_mcp_open_anonymous(dummy: c_int) c_int { + _ = dummy; + mutex.lock(); + defer mutex.unlock(); + + for (&sessions, 0..) |*slot, idx| { + if (!slot.active) { + slot.active = true; + slot.state = .unauthenticated; + slot.api_call_count = 0; + slot.last_action = -1; + slot.instant_queries = 0; + slot.range_queries = 0; + slot.discovery_ops = 0; + return @intCast(idx); + } + } + return -1; +} + +pub export fn prometheus_mcp_close(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + + sessions[idx] = SessionSlot{}; + return 0; +} + +pub export fn prometheus_mcp_session_state(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + return @intFromEnum(slot.state); +} + +pub export fn prometheus_mcp_throttle(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + if (!isValidTransition(slot.state, .rate_limited)) return -2; + + sessions[idx].state = .rate_limited; + return 0; +} + +pub export fn prometheus_mcp_unthrottle(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + if (!isValidTransition(slot.state, .authenticated) and !isValidTransition(slot.state, .unauthenticated)) return -2; + + sessions[idx].state = .authenticated; + return 0; +} + +pub export fn prometheus_mcp_signal_error(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + if (!isValidTransition(slot.state, .err)) return -2; + + sessions[idx].state = .err; + return 0; +} + +// --------------------------------------------------------------------------- +// C-ABI exports β€” action recording and metrics +// --------------------------------------------------------------------------- + +pub export fn prometheus_mcp_record_call(slot_idx: c_int, action: c_int) c_int { + const act = std.meta.intToEnum(PrometheusAction, action) catch return -3; + + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + if (slot.state == .rate_limited) return -2; + if (slot.state == .err) return -2; + + sessions[idx].api_call_count += 1; + sessions[idx].last_action = action; + + switch (act) { + .instant_query => sessions[idx].instant_queries += 1, + .range_query => sessions[idx].range_queries += 1, + .list_targets, .list_alerts, .list_labels, .label_values, .get_metadata, .list_series => sessions[idx].discovery_ops += 1, + } + + return 0; +} + +pub export fn prometheus_mcp_call_count(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + return @intCast(slot.api_call_count); +} + +pub export fn prometheus_mcp_instant_query_count(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + return @intCast(slot.instant_queries); +} + +pub export fn prometheus_mcp_range_query_count(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + return @intCast(slot.range_queries); +} + +pub export fn prometheus_mcp_discovery_count(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + return @intCast(slot.discovery_ops); +} + +pub export fn prometheus_mcp_action_count() c_int { + return 8; +} + +pub export fn prometheus_mcp_reset() void { + mutex.lock(); + defer mutex.unlock(); + sessions = .{SessionSlot{}} ** MAX_SESSIONS; +} + +// --------------------------------------------------------------------------- +// ═══════════════════════════════════════════════════════════════════════ +// Standard ABI (ADR-0005 four symbols + ADR-0006 invoke) +// ═══════════════════════════════════════════════════════════════════════ + +const shim = @import("cartridge_shim.zig"); + +const CARTRIDGE_NAME_PTR: [*:0]const u8 = "prometheus-mcp"; +const CARTRIDGE_VERSION_PTR: [*:0]const u8 = "0.1.0"; + +export fn boj_cartridge_init() callconv(.c) c_int { + return 0; +} + +export fn boj_cartridge_deinit() callconv(.c) void {} + +export fn boj_cartridge_name() callconv(.c) [*:0]const u8 { + return CARTRIDGE_NAME_PTR; +} + +export fn boj_cartridge_version() callconv(.c) [*:0]const u8 { + return CARTRIDGE_VERSION_PTR; +} + +/// Dispatch the cartridge.json MCP tools. Grade D Alpha stubs. +export fn boj_cartridge_invoke( + tool_name: [*c]const u8, + json_args: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) callconv(.c) i32 { + _ = json_args; + if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; + + const body: []const u8 = if (shim.toolIs(tool_name, "prometheus_query")) + "{\"result\":{\"matches\":[],\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "prometheus_query_range")) + "{\"result\":{\"matches\":[],\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "prometheus_list_targets")) + "{\"result\":{\"items\":[],\"count\":0,\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "prometheus_list_alerts")) + "{\"result\":{\"items\":[],\"count\":0,\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "prometheus_list_labels")) + "{\"result\":{\"items\":[],\"count\":0,\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "prometheus_label_values")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "prometheus_metadata")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "prometheus_series")) + "{\"result\":{\"status\":\"stub\"}}" +else + return shim.RC_UNKNOWN_TOOL; + + return shim.writeResult(out_buf, in_out_len, body); +} + +// Tests +// --------------------------------------------------------------------------- + +test "authenticated session lifecycle" { + prometheus_mcp_reset(); + + const slot = prometheus_mcp_authenticate(0); + try std.testing.expect(slot >= 0); + try std.testing.expectEqual(@as(c_int, 1), prometheus_mcp_session_state(slot)); + + try std.testing.expectEqual(@as(c_int, 0), prometheus_mcp_record_call(slot, 0)); + try std.testing.expectEqual(@as(c_int, 1), prometheus_mcp_call_count(slot)); + try std.testing.expectEqual(@as(c_int, 1), prometheus_mcp_instant_query_count(slot)); + + try std.testing.expectEqual(@as(c_int, 0), prometheus_mcp_close(slot)); +} + +test "anonymous session lifecycle" { + prometheus_mcp_reset(); + + const slot = prometheus_mcp_open_anonymous(0); + try std.testing.expect(slot >= 0); + try std.testing.expectEqual(@as(c_int, 0), prometheus_mcp_session_state(slot)); + + try std.testing.expectEqual(@as(c_int, 0), prometheus_mcp_record_call(slot, 1)); + try std.testing.expectEqual(@as(c_int, 1), prometheus_mcp_range_query_count(slot)); + + try std.testing.expectEqual(@as(c_int, 0), prometheus_mcp_close(slot)); +} + +test "rate limiting flow" { + prometheus_mcp_reset(); + + const slot = prometheus_mcp_authenticate(0); + try std.testing.expect(slot >= 0); + + try std.testing.expectEqual(@as(c_int, 0), prometheus_mcp_throttle(slot)); + try std.testing.expectEqual(@as(c_int, 2), prometheus_mcp_session_state(slot)); + try std.testing.expectEqual(@as(c_int, -2), prometheus_mcp_record_call(slot, 0)); + + try std.testing.expectEqual(@as(c_int, 0), prometheus_mcp_unthrottle(slot)); + try std.testing.expectEqual(@as(c_int, 1), prometheus_mcp_session_state(slot)); +} + +test "category counting" { + prometheus_mcp_reset(); + + const slot = prometheus_mcp_authenticate(0); + try std.testing.expect(slot >= 0); + + // InstantQuery (0) + try std.testing.expectEqual(@as(c_int, 0), prometheus_mcp_record_call(slot, 0)); + // RangeQuery (1) + try std.testing.expectEqual(@as(c_int, 0), prometheus_mcp_record_call(slot, 1)); + // ListTargets (2) + try std.testing.expectEqual(@as(c_int, 0), prometheus_mcp_record_call(slot, 2)); + // ListLabels (4) + try std.testing.expectEqual(@as(c_int, 0), prometheus_mcp_record_call(slot, 4)); + + try std.testing.expectEqual(@as(c_int, 4), prometheus_mcp_call_count(slot)); + try std.testing.expectEqual(@as(c_int, 1), prometheus_mcp_instant_query_count(slot)); + try std.testing.expectEqual(@as(c_int, 1), prometheus_mcp_range_query_count(slot)); + try std.testing.expectEqual(@as(c_int, 2), prometheus_mcp_discovery_count(slot)); +} + +test "transition validator" { + try std.testing.expectEqual(@as(c_int, 1), prometheus_mcp_can_transition(0, 1)); + try std.testing.expectEqual(@as(c_int, 1), prometheus_mcp_can_transition(1, 0)); + try std.testing.expectEqual(@as(c_int, 1), prometheus_mcp_can_transition(1, 2)); + try std.testing.expectEqual(@as(c_int, 1), prometheus_mcp_can_transition(0, 2)); + try std.testing.expectEqual(@as(c_int, 1), prometheus_mcp_can_transition(2, 1)); + try std.testing.expectEqual(@as(c_int, 0), prometheus_mcp_can_transition(2, 3)); +} + +test "slot exhaustion" { + prometheus_mcp_reset(); + + var slots: [MAX_SESSIONS]c_int = undefined; + for (&slots) |*s| { + s.* = prometheus_mcp_authenticate(0); + try std.testing.expect(s.* >= 0); + } + + try std.testing.expectEqual(@as(c_int, -1), prometheus_mcp_authenticate(0)); + + try std.testing.expectEqual(@as(c_int, 0), prometheus_mcp_close(slots[0])); + const new_slot = prometheus_mcp_authenticate(0); + try std.testing.expect(new_slot >= 0); +} + +// ═══════════════════════════════════════════════════════════════════════ +// ADR-0006 invoke dispatch tests +// ═══════════════════════════════════════════════════════════════════════ + +test "boj_cartridge_name returns prometheus-mcp" { + const n = std.mem.span(boj_cartridge_name()); + try std.testing.expectEqualStrings("prometheus-mcp", n); +} + +test "boj_cartridge_init returns 0" { + try std.testing.expectEqual(@as(c_int, 0), boj_cartridge_init()); +} + +test "invoke: each declared tool succeeds" { + var buf: [256]u8 = undefined; + const tools = [_][]const u8{ + "prometheus_query", + "prometheus_query_range", + "prometheus_list_targets", + "prometheus_list_alerts", + "prometheus_list_labels", + "prometheus_label_values", + "prometheus_metadata", + "prometheus_series", + }; + for (tools) |t| { + var len: usize = buf.len; + const rc = boj_cartridge_invoke(t.ptr, "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, 0), rc); + try std.testing.expect(std.mem.indexOf(u8, buf[0..len], "result") != null); + } +} + +test "invoke: unknown tool returns -1" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("nope", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -1), rc); +} + +test "invoke: buffer too small returns -3" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("prometheus_query", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -3), rc); + try std.testing.expect(len > 4); +} diff --git a/cartridges/domains/observability/prometheus-mcp/minter.toml b/cartridges/domains/observability/prometheus-mcp/minter.toml new file mode 100644 index 0000000..c35d8e4 --- /dev/null +++ b/cartridges/domains/observability/prometheus-mcp/minter.toml @@ -0,0 +1,22 @@ +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> + +name = "prometheus-mcp" +description = "Prometheus monitoring cartridge β€” instant/range PromQL queries, target discovery, alerts, label browsing, metric metadata, series listing" +version = "0.1.0" +domain = "Monitoring" +protocols = [ + "MCP", + "REST", +] +tier = "Ayo" +backend = "universal" +generate_panel = true + +[auth] +method = "bearer_token" +credential_source = "vault-mcp" +fields = ["prometheus_token"] + +[api] +base_url = "https://prometheus.example.com/api/v1" diff --git a/cartridges/domains/observability/prometheus-mcp/mod.js b/cartridges/domains/observability/prometheus-mcp/mod.js new file mode 100644 index 0000000..ff69182 --- /dev/null +++ b/cartridges/domains/observability/prometheus-mcp/mod.js @@ -0,0 +1,177 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// prometheus-mcp/mod.js -- Prometheus monitoring cartridge implementation. +// +// Provides MCP tool handlers for the Prometheus HTTP API v1: +// - Instant queries (single point in time PromQL evaluation) +// - Range queries (PromQL over time window with step resolution) +// - Scrape target listing with health status +// - Alert rule listing with state and annotations +// - Label name and value browsing +// - Metric metadata (type, help, unit) +// - Time series listing by label matchers +// +// Auth: Optional Bearer token via PROMETHEUS_TOKEN. +// API docs: https://prometheus.io/docs/prometheus/latest/querying/api/ +// +// Usage: import { handleTool } from "./mod.js"; +// or: deno run --allow-net --allow-env mod.js + +// --------------------------------------------------------------------------- +// Configuration β€” base URL is instance-specific, read from environment. +// --------------------------------------------------------------------------- + +function getBaseUrl() { + const url = typeof Deno !== "undefined" + ? Deno.env.get("PROMETHEUS_BASE_URL") + : process.env.PROMETHEUS_BASE_URL; + return url || "http://localhost:9090/api/v1"; +} + +// --------------------------------------------------------------------------- +// Auth helper β€” retrieves the Prometheus auth token from environment. +// --------------------------------------------------------------------------- + +function getToken() { + const token = typeof Deno !== "undefined" + ? Deno.env.get("PROMETHEUS_TOKEN") + : process.env.PROMETHEUS_TOKEN; + return token || null; +} + +// --------------------------------------------------------------------------- +// HTTP request helper β€” wraps fetch with Prometheus headers and error handling. +// --------------------------------------------------------------------------- + +async function promFetch(path, { method = "GET", queryParams, body } = {}) { + const baseUrl = getBaseUrl(); + const url = new URL(`${baseUrl}${path}`); + + if (queryParams) { + for (const [key, value] of Object.entries(queryParams)) { + if (value !== undefined && value !== null && value !== "") { + url.searchParams.set(key, String(value)); + } + } + } + + const headers = { + "Accept": "application/json", + }; + + const token = getToken(); + if (token) { + headers["Authorization"] = `Bearer ${token}`; + } + + const options = { method, headers }; + if (body) { + headers["Content-Type"] = "application/x-www-form-urlencoded"; + options.body = body; + } + + const response = await fetch(url.toString(), options); + const data = await response.json().catch(() => ({})); + + if (!response.ok) { + const errorMessage = data.error || `HTTP ${response.status}`; + return { status: response.status, error: errorMessage, data }; + } + + return { status: response.status, data }; +} + +// --------------------------------------------------------------------------- +// Tool handler dispatch β€” maps MCP tool names to Prometheus API operations. +// --------------------------------------------------------------------------- + +export async function handleTool(toolName, args) { + switch (toolName) { + + // --- Instant query --- + + case "prometheus_query": { + if (!args.query) return { error: "Missing required field: query" }; + const query = { query: args.query, time: args.time }; + return promFetch("/query", { queryParams: query }); + } + + // --- Range query --- + + case "prometheus_query_range": { + if (!args.query) return { error: "Missing required field: query" }; + if (!args.start) return { error: "Missing required field: start" }; + if (!args.end) return { error: "Missing required field: end" }; + if (!args.step) return { error: "Missing required field: step" }; + const query = { + query: args.query, + start: args.start, + end: args.end, + step: args.step, + }; + return promFetch("/query_range", { queryParams: query }); + } + + // --- Targets --- + + case "prometheus_list_targets": { + const query = { state: args.state }; + return promFetch("/targets", { queryParams: query }); + } + + // --- Alerts --- + + case "prometheus_list_alerts": { + return promFetch("/alerts"); + } + + // --- Labels --- + + case "prometheus_list_labels": { + const query = { start: args.start, end: args.end }; + return promFetch("/labels", { queryParams: query }); + } + + case "prometheus_label_values": { + if (!args.label) return { error: "Missing required field: label" }; + const query = { start: args.start, end: args.end }; + return promFetch(`/label/${encodeURIComponent(args.label)}/values`, { queryParams: query }); + } + + // --- Metadata --- + + case "prometheus_metadata": { + const query = { metric: args.metric, limit: args.limit }; + return promFetch("/metadata", { queryParams: query }); + } + + // --- Series --- + + case "prometheus_series": { + if (!args.match) return { error: "Missing required field: match" }; + const query = { + "match[]": args.match, + start: args.start, + end: args.end, + }; + return promFetch("/series", { queryParams: query }); + } + + default: + return { error: `Unknown prometheus-mcp tool: ${toolName}` }; + } +} + +// --------------------------------------------------------------------------- +// Cartridge metadata export β€” used by the BoJ cartridge loader. +// --------------------------------------------------------------------------- + +export const metadata = { + name: "prometheus-mcp", + version: "0.1.0", + domain: "Monitoring", + tier: "Ayo", + protocols: ["MCP", "REST"], + toolCount: 8, +}; diff --git a/cartridges/domains/observability/prometheus-mcp/panels/manifest.json b/cartridges/domains/observability/prometheus-mcp/panels/manifest.json new file mode 100644 index 0000000..2665939 --- /dev/null +++ b/cartridges/domains/observability/prometheus-mcp/panels/manifest.json @@ -0,0 +1,90 @@ +{ + "$schema": "https://panll.dev/schemas/panel-manifest/v1.json", + "spdx": "MPL-2.0", + "copyright": "Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath)", + "cartridge": "prometheus-mcp", + "domain": "Monitoring", + "version": "0.1.0", + "panels": [ + { + "id": "prometheus-auth-status", + "title": "Auth Status", + "description": "Prometheus session state (Unauthenticated / Authenticated / RateLimited / Error)", + "type": "status-indicator", + "data_source": { + "endpoint": "/prometheus/state", + "method": "GET", + "refresh_interval_ms": 5000 + }, + "widgets": [ + { + "type": "state-badge", + "field": "state", + "states": { + "unauthenticated": { "color": "#95a5a6", "icon": "user-x" }, + "authenticated": { "color": "#2ecc71", "icon": "user-check" }, + "rate_limited": { "color": "#f39c12", "icon": "clock" }, + "error": { "color": "#e74c3c", "icon": "alert-triangle" } + } + } + ] + }, + { + "id": "prometheus-instant-queries", + "title": "Instant Queries", + "description": "Number of instant PromQL queries executed in the current session", + "type": "metric", + "data_source": { + "endpoint": "/prometheus/status", + "method": "GET", + "refresh_interval_ms": 10000 + }, + "widgets": [ + { + "type": "counter", + "field": "instant_queries", + "label": "Instant Queries", + "icon": "zap" + } + ] + }, + { + "id": "prometheus-range-queries", + "title": "Range Queries", + "description": "Number of range PromQL queries executed in the current session", + "type": "metric", + "data_source": { + "endpoint": "/prometheus/status", + "method": "GET", + "refresh_interval_ms": 10000 + }, + "widgets": [ + { + "type": "counter", + "field": "range_queries", + "label": "Range Queries", + "icon": "trending-up" + } + ] + }, + { + "id": "prometheus-discovery-ops", + "title": "Discovery Operations", + "description": "Number of target/label/series discovery operations in the current session", + "type": "metric", + "data_source": { + "endpoint": "/prometheus/status", + "method": "GET", + "refresh_interval_ms": 10000 + }, + "widgets": [ + { + "type": "counter", + "field": "discovery_ops", + "label": "Discovery Ops", + "icon": "compass" + } + ] + } + ] +} diff --git a/cartridges/domains/observability/prometheus-mcp/tests/integration_test.sh b/cartridges/domains/observability/prometheus-mcp/tests/integration_test.sh new file mode 100755 index 0000000..4f4721a --- /dev/null +++ b/cartridges/domains/observability/prometheus-mcp/tests/integration_test.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Integration tests for prometheus-mcp cartridge. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CART_DIR="$(dirname "$SCRIPT_DIR")" +FFI_DIR="$CART_DIR/ffi" + +echo "=== prometheus-mcp integration tests ===" + +# Build FFI +echo "[1/3] Building FFI..." +cd "$FFI_DIR" && zig build 2>&1 + +# Run Zig unit tests +echo "[2/3] Running FFI unit tests..." +cd "$FFI_DIR" && zig build test 2>&1 + +# Validate Idris2 ABI (if idris2 available) +echo "[3/3] Checking ABI..." +if command -v idris2 &>/dev/null; then + cd "$CART_DIR/abi" && idris2 --check PrometheusMcp.SafeRegistry 2>&1 + echo " ABI: OK" +else + echo " ABI: SKIPPED (idris2 not in PATH)" +fi + +echo "" +echo "All tests passed for prometheus-mcp!" diff --git a/cartridges/domains/observability/sentry-mcp/README.adoc b/cartridges/domains/observability/sentry-mcp/README.adoc new file mode 100644 index 0000000..32bd2ea --- /dev/null +++ b/cartridges/domains/observability/sentry-mcp/README.adoc @@ -0,0 +1,74 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += sentry-mcp +Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk> +:spdx: MPL-2.0 +:tier: Ayo +:domain: Monitoring +:protocols: MCP, REST + +== Overview + +Sentry error tracking cartridge for the BoJ server. Provides type-safe access to +the Sentry API for issue listing, event retrieval, project browsing, release +management, DSN lookup, team listing, tag search, error resolution, and +performance transaction queries. + +=== State Machine + +`Unauthenticated -> Authenticated` (required for all operations) + +`Authenticated -> RateLimited -> Authenticated` (normal flow) + +`Authenticated -> Error -> Unauthenticated` (error recovery) + +=== Actions (10) + +[cols="1,1"] +|=== +| Category | Actions + +| Issues +| ListIssues, GetIssue, ListEvents, ResolveIssue + +| Projects +| ListProjects, ListReleases, GetDsn + +| Organization +| ListTeams, SearchTags + +| Performance +| ListTransactions +|=== + +== Architecture + +[cols="1,1,2"] +|=== +| Layer | Language | Purpose + +| ABI +| Idris2 +| Formally verified state machine with dependent-type proofs (SentryMcp.SafeRegistry) + +| FFI +| Zig +| C-compatible implementation with thread-safe session pool, category counting + +| Adapter +| zig +| REST bridge to BoJ unified adapter protocol +|=== + +== Building + +[source,bash] +---- +cd ffi && zig build +cd ffi && zig build test +cd abi && idris2 --check SentryMcp.SafeRegistry +---- + +== Status + +Development -- Tier 6 monitoring cartridge with issue tracking, project management, and performance monitoring. diff --git a/cartridges/domains/observability/sentry-mcp/abi/README.adoc b/cartridges/domains/observability/sentry-mcp/abi/README.adoc new file mode 100644 index 0000000..035d23a --- /dev/null +++ b/cartridges/domains/observability/sentry-mcp/abi/README.adoc @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += sentry-mcp / abi β€” Idris2 ABI layer +:orientation: shallow + +== Purpose + +Cartridge-local protocol types for `sentry-mcp`. Defines the data, tool, and +transition vocabulary this cartridge speaks. Inherits safety lemmas and the +matrix-cell identifier scheme from the trunk ABI at `src/abi/`. + +== Files + +1 Idris2 module(s), ~173 lines total. Lead module: +`SafeRegistry.idr`. + +No `.ipkg` β€” consumed directly by wider BoJ callers; type-checks inside the +trunk `boj.ipkg`. + +== Invariants + +* `%default total` at every module top. +* Zero `believe_me`, `assert_total`, `postulate`, `Admitted`, or + `sorry` (the trunk holds the four declared `believe_me` sites; leaf + cartridges carry none). + +== Test/proof surface + +Thin module(s), no inline tests. Proof surface = type-check success under the +trunk `idris2 --check`. + +== Read-first + +. `SafeRegistry.idr` β€” cartridge-local data records and enums. +. Trunk `src/abi/Boj/Catalogue.idr` for how this cell participates in the + 2D matrix. diff --git a/cartridges/domains/observability/sentry-mcp/abi/SentryMcp/SafeRegistry.idr b/cartridges/domains/observability/sentry-mcp/abi/SentryMcp/SafeRegistry.idr new file mode 100644 index 0000000..0df22a8 --- /dev/null +++ b/cartridges/domains/observability/sentry-mcp/abi/SentryMcp/SafeRegistry.idr @@ -0,0 +1,173 @@ +-- SPDX-License-Identifier: MPL-2.0 +-- Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +-- +-- SentryMcp.SafeRegistry β€” Type-safe ABI for sentry-mcp cartridge. +-- +-- Dependent-type state machine governing Sentry API access. +-- Encodes Bearer token auth, issue listing, event retrieval, +-- project browsing, release management, DSN lookup, teams, +-- tag search, resolution, and performance transactions +-- as compile-time invariants. +-- API: https://docs.sentry.io/api/ +-- No unsafe escape hatches. + +module SentryMcp.SafeRegistry + +%default total + +-- --------------------------------------------------------------------------- +-- Authentication state machine +-- --------------------------------------------------------------------------- + +||| Session state for Sentry MCP operations. +||| Unauthenticated: no API token; no operations available. +||| Authenticated: Sentry auth token active, full access. +||| RateLimited: API rate limit hit; must wait. +||| Error: unrecoverable error (invalid token, network failure). +public export +data SessionState + = Unauthenticated + | Authenticated + | RateLimited + | Error + +||| Proof that a state transition is valid. +||| Sentry requires authentication for all operations. +public export +data ValidTransition : SessionState -> SessionState -> Type where + Authenticate : ValidTransition Unauthenticated Authenticated + Deauthenticate : ValidTransition Authenticated Unauthenticated + Throttle : ValidTransition Authenticated RateLimited + Unthrottle : ValidTransition RateLimited Authenticated + AuthError : ValidTransition Authenticated Error + AnonError : ValidTransition Unauthenticated Error + RecoverToAuth : ValidTransition Error Authenticated + RecoverToAnon : ValidTransition Error Unauthenticated + +-- --------------------------------------------------------------------------- +-- C-ABI integer encoding +-- --------------------------------------------------------------------------- + +||| Encode session state as C-compatible integer for FFI boundary. +export +sessionStateToInt : SessionState -> Int +sessionStateToInt Unauthenticated = 0 +sessionStateToInt Authenticated = 1 +sessionStateToInt RateLimited = 2 +sessionStateToInt Error = 3 + +||| Decode integer back to session state. +export +intToSessionState : Int -> Maybe SessionState +intToSessionState 0 = Just Unauthenticated +intToSessionState 1 = Just Authenticated +intToSessionState 2 = Just RateLimited +intToSessionState 3 = Just Error +intToSessionState _ = Nothing + +||| Check if a state transition is valid (C-ABI export). +export +sentry_mcp_can_transition : Int -> Int -> Int +sentry_mcp_can_transition from to = + case (intToSessionState from, intToSessionState to) of + (Just Unauthenticated, Just Authenticated) => 1 + (Just Authenticated, Just Unauthenticated) => 1 + (Just Authenticated, Just RateLimited) => 1 + (Just RateLimited, Just Authenticated) => 1 + (Just Authenticated, Just Error) => 1 + (Just Unauthenticated, Just Error) => 1 + (Just Error, Just Authenticated) => 1 + (Just Error, Just Unauthenticated) => 1 + _ => 0 + +-- --------------------------------------------------------------------------- +-- Sentry actions +-- --------------------------------------------------------------------------- + +||| Actions available through the Sentry MCP cartridge. +public export +data SentryAction + = ListIssues + | GetIssue + | ListEvents + | ResolveIssue + | ListProjects + | ListReleases + | GetDsn + | ListTeams + | SearchTags + | ListTransactions + +||| Whether an action requires Authenticated state. +||| All Sentry API operations require authentication. +export +actionRequiresAuth : SentryAction -> Bool +actionRequiresAuth _ = True + +||| Whether an action is a write/mutating operation. +export +actionIsMutating : SentryAction -> Bool +actionIsMutating ResolveIssue = True +actionIsMutating _ = False + +||| Encode action as C-compatible integer for FFI. +export +actionToInt : SentryAction -> Int +actionToInt ListIssues = 0 +actionToInt GetIssue = 1 +actionToInt ListEvents = 2 +actionToInt ResolveIssue = 3 +actionToInt ListProjects = 4 +actionToInt ListReleases = 5 +actionToInt GetDsn = 6 +actionToInt ListTeams = 7 +actionToInt SearchTags = 8 +actionToInt ListTransactions = 9 + +||| Decode integer to Sentry action. +export +intToAction : Int -> Maybe SentryAction +intToAction 0 = Just ListIssues +intToAction 1 = Just GetIssue +intToAction 2 = Just ListEvents +intToAction 3 = Just ResolveIssue +intToAction 4 = Just ListProjects +intToAction 5 = Just ListReleases +intToAction 6 = Just GetDsn +intToAction 7 = Just ListTeams +intToAction 8 = Just SearchTags +intToAction 9 = Just ListTransactions +intToAction _ = Nothing + +-- --------------------------------------------------------------------------- +-- MCP tool declarations +-- --------------------------------------------------------------------------- + +||| Tools exposed via MCP protocol for this cartridge. +public export +data McpTool + = ToolListIssues + | ToolGetIssue + | ToolListEvents + | ToolResolveIssue + | ToolListProjects + | ToolListReleases + | ToolGetDsn + | ToolListTeams + | ToolSearchTags + | ToolListTransactions + +||| Check if a tool requires an authenticated session. +export +toolRequiresSession : McpTool -> Bool +toolRequiresSession _ = True + +||| Total tool count for this cartridge. +export +toolCount : Nat +toolCount = 10 + +||| Total action count for this cartridge. +export +actionCount : Nat +actionCount = 10 diff --git a/cartridges/domains/observability/sentry-mcp/adapter/README.adoc b/cartridges/domains/observability/sentry-mcp/adapter/README.adoc new file mode 100644 index 0000000..10d912a --- /dev/null +++ b/cartridges/domains/observability/sentry-mcp/adapter/README.adoc @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += sentry-mcp / adapter β€” protocol bridge +:orientation: shallow + +== Purpose + +Exposes this cartridge's FFI over the wire protocols declared in +`cartridge.json`. Dispatches tool invocations to the C-ABI exports at +`../ffi/` and formats responses per protocol. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” depends on the sibling `ffi` target. +| `sentry_adapter.zig` | Protocol dispatch (204 lines). Tool catalogue matches `cartridge.json`. +| `SIDELINED-*.v.adoc` | zig predecessor(s). Not built β€” zig banned 2026-04-10. +|=== + +== Invariants + +* **Stateless** β€” all state lives behind the FFI, never in the adapter. +* **Response passthrough** β€” whatever the FFI returns goes back to the wire + unmodified (no embellishment, no silent recovery). +* **Cartridge.json is source of truth** for the tool catalogue; drift between + adapter and manifest is a CI failure. + +== Test/proof surface + +No inline tests at the adapter layer β€” protocol routing is exercised by the +trunk seam tests at `ffi/zig/src/seams.zig`. + +== Read-first + +. `sentry_adapter.zig` β€” dispatch function and per-protocol routers. +. `../cartridge.json` β€” the tool manifest the dispatcher mirrors. diff --git a/cartridges/domains/observability/sentry-mcp/adapter/build.zig b/cartridges/domains/observability/sentry-mcp/adapter/build.zig new file mode 100644 index 0000000..fcda170 --- /dev/null +++ b/cartridges/domains/observability/sentry-mcp/adapter/build.zig @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// sentry-mcp/adapter/build.zig + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const ffi_mod = b.createModule(.{ + .root_source_file = b.path("../ffi/sentry_mcp_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + const adapter = b.addExecutable(.{ + .name = "sentry_adapter", + .root_source_file = b.path("sentry_adapter.zig"), + .target = target, + .optimize = optimize, + }); + adapter.root_module.addImport("sentry_mcp_ffi", ffi_mod); + b.installArtifact(adapter); + + const run_artifact = b.addRunArtifact(adapter); + const run_step = b.step("run", "Run the sentry-mcp adapter"); + run_step.dependOn(&run_artifact.step); + + const tests = b.addTest(.{ + .root_source_file = b.path("sentry_adapter.zig"), + .target = target, + .optimize = optimize, + }); + tests.root_module.addImport("sentry_mcp_ffi", ffi_mod); + const run_tests = b.addRunArtifact(tests); + const test_step = b.step("test", "Run sentry-mcp adapter tests"); + test_step.dependOn(&run_tests.step); +} diff --git a/cartridges/domains/observability/sentry-mcp/adapter/sentry_adapter.zig b/cartridges/domains/observability/sentry-mcp/adapter/sentry_adapter.zig new file mode 100644 index 0000000..5b7341a --- /dev/null +++ b/cartridges/domains/observability/sentry-mcp/adapter/sentry_adapter.zig @@ -0,0 +1,204 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// sentry-mcp/adapter/sentry_adapter.zig -- Unified three-protocol adapter. +// +// Replaces the banned sentry_adapter.v (zig, removed 2026-04-12). +// +// Bridges the Zig FFI (sentry_mcp_ffi.zig) to three network protocols: +// REST :9094 POST /tools/<tool> +// gRPC-compat :9095 /SentryMcpService/<Method> +// GraphQL :9096 POST /graphql { query: "..." } +// +// Sentry error tracking: issues, events, releases, projects, teams +// Tools: +// sentry_list_issues +// sentry_get_issue +// sentry_list_events +// sentry_resolve_issue +// sentry_list_projects +// sentry_list_releases +// sentry_get_dsn +// sentry_list_teams +// sentry_search_tags +// sentry_list_transactions + +const std = @import("std"); +const ffi = @import("sentry_mcp_ffi"); + +const REST_PORT: u16 = 9094; +const GRPC_PORT: u16 = 9095; +const GQL_PORT: u16 = 9096; + +const MAX_CONN_BUF: usize = 16 * 1024; + +// ============================================================================ +// JSON response builders +// ============================================================================ + +fn okJson(buf: []u8, msg: []const u8) []u8 { + return std.fmt.bufPrint(buf, + \{{"success":true,"message":"{}"}} + , .{msg}) catch buf[0..0]; +} + +fn errJson(buf: []u8, msg: []const u8) []u8 { + return std.fmt.bufPrint(buf, + \{{"success":false,"error":"{}"}} + , .{msg}) catch buf[0..0]; +} + +fn statusJson(buf: []u8) []u8 { + return std.fmt.bufPrint(buf, + \{{"success":true,"state":"ready","service":"sentry-mcp"}} + , .{}) catch buf[0..0]; +} + +// ============================================================================ +// Tool dispatcher +// ============================================================================ + +const Response = struct { status: u16, body: []u8 }; + +fn dispatch(tool: []const u8, body: []const u8, resp: []u8) Response { + _ = body; + if (std.mem.eql(u8, tool, "sentry_list_issues")) return .{ .status = 200, .body = okJson(resp, "sentry_list_issues forwarded to backend") }; + if (std.mem.eql(u8, tool, "sentry_get_issue")) return .{ .status = 200, .body = okJson(resp, "sentry_get_issue forwarded to backend") }; + if (std.mem.eql(u8, tool, "sentry_list_events")) return .{ .status = 200, .body = okJson(resp, "sentry_list_events forwarded to backend") }; + if (std.mem.eql(u8, tool, "sentry_resolve_issue")) return .{ .status = 200, .body = okJson(resp, "sentry_resolve_issue forwarded to backend") }; + if (std.mem.eql(u8, tool, "sentry_list_projects")) return .{ .status = 200, .body = okJson(resp, "sentry_list_projects forwarded to backend") }; + if (std.mem.eql(u8, tool, "sentry_list_releases")) return .{ .status = 200, .body = okJson(resp, "sentry_list_releases forwarded to backend") }; + if (std.mem.eql(u8, tool, "sentry_get_dsn")) return .{ .status = 200, .body = okJson(resp, "sentry_get_dsn forwarded to backend") }; + if (std.mem.eql(u8, tool, "sentry_list_teams")) return .{ .status = 200, .body = okJson(resp, "sentry_list_teams forwarded to backend") }; + if (std.mem.eql(u8, tool, "sentry_search_tags")) return .{ .status = 200, .body = okJson(resp, "sentry_search_tags forwarded to backend") }; + if (std.mem.eql(u8, tool, "sentry_list_transactions")) return .{ .status = 200, .body = okJson(resp, "sentry_list_transactions forwarded to backend") }; + if (std.mem.eql(u8, tool, "status") or std.mem.eql(u8, tool, "health")) + return .{ .status = 200, .body = statusJson(resp) }; + return .{ .status = 404, .body = errJson(resp, "Unknown tool") }; +} + +// ============================================================================ +// REST handler +// ============================================================================ + +fn dispatchRest(path: []const u8, body: []const u8, resp: []u8) Response { + if (std.mem.startsWith(u8, path, "/tools/")) { + return dispatch(path["/tools/".len..], body, resp); + } + if (std.mem.eql(u8, path, "/status") or std.mem.eql(u8, path, "/health")) { + return .{ .status = 200, .body = statusJson(resp) }; + } + return .{ .status = 404, .body = errJson(resp, "Not found") }; +} + +// ============================================================================ +// gRPC-compat handler +// ============================================================================ + +fn dispatchGrpc(path: []const u8, body: []const u8, resp: []u8) Response { + const prefix = "/SentryMcpService/"; + if (!std.mem.startsWith(u8, path, prefix)) + return .{ .status = 404, .body = errJson(resp, "Not a recognized gRPC path") }; + const method = path[prefix.len..]; + const tool = blk: { + if (std.mem.eql(u8, method, "SentryListIssues")) break :blk "sentry_list_issues"; + if (std.mem.eql(u8, method, "SentryGetIssue")) break :blk "sentry_get_issue"; + if (std.mem.eql(u8, method, "SentryListEvents")) break :blk "sentry_list_events"; + if (std.mem.eql(u8, method, "SentryResolveIssue")) break :blk "sentry_resolve_issue"; + if (std.mem.eql(u8, method, "SentryListProjects")) break :blk "sentry_list_projects"; + if (std.mem.eql(u8, method, "SentryListReleases")) break :blk "sentry_list_releases"; + if (std.mem.eql(u8, method, "SentryGetDsn")) break :blk "sentry_get_dsn"; + if (std.mem.eql(u8, method, "SentryListTeams")) break :blk "sentry_list_teams"; + if (std.mem.eql(u8, method, "SentrySearchTags")) break :blk "sentry_search_tags"; + if (std.mem.eql(u8, method, "SentryListTransactions")) break :blk "sentry_list_transactions"; + return .{ .status = 404, .body = errJson(resp, "Unknown gRPC method") }; + }; + return dispatch(tool, body, resp); +} + +// ============================================================================ +// GraphQL handler +// ============================================================================ + +fn dispatchGraphql(body: []const u8, resp: []u8) Response { + if (std.mem.indexOf(u8, body, "__schema") != null) + return .{ .status = 200, .body = okJson(resp, "schema introspection not yet supported") }; + if (std.mem.indexOf(u8, body, "list_issues") != null) return dispatch("sentry_list_issues", body, resp); + if (std.mem.indexOf(u8, body, "get_issue") != null) return dispatch("sentry_get_issue", body, resp); + if (std.mem.indexOf(u8, body, "list_events") != null) return dispatch("sentry_list_events", body, resp); + if (std.mem.indexOf(u8, body, "resolve_issue") != null) return dispatch("sentry_resolve_issue", body, resp); + if (std.mem.indexOf(u8, body, "list_projects") != null) return dispatch("sentry_list_projects", body, resp); + if (std.mem.indexOf(u8, body, "list_releases") != null) return dispatch("sentry_list_releases", body, resp); + if (std.mem.indexOf(u8, body, "get_dsn") != null) return dispatch("sentry_get_dsn", body, resp); + if (std.mem.indexOf(u8, body, "list_teams") != null) return dispatch("sentry_list_teams", body, resp); + if (std.mem.indexOf(u8, body, "search_tags") != null) return dispatch("sentry_search_tags", body, resp); + if (std.mem.indexOf(u8, body, "list_transactions") != null) return dispatch("sentry_list_transactions", body, resp); + return .{ .status = 200, .body = errJson(resp, "Unrecognised GraphQL operation") }; +} + +// ============================================================================ +// HTTP/1.1 connection handler +// ============================================================================ + +const Protocol = enum { rest, grpc, graphql }; + +fn handleConnection(conn: std.net.Server.Connection, proto: Protocol) void { + defer conn.stream.close(); + var in_buf: [MAX_CONN_BUF]u8 = undefined; + const n = conn.stream.read(&in_buf) catch return; + const req = in_buf[0..n]; + + var path: []const u8 = "/"; + var body: []const u8 = ""; + if (n > 4) { + const line_end = std.mem.indexOf(u8, req, "\r\n") orelse req.len; + const first_line = req[0..line_end]; + const sp1 = std.mem.indexOfScalar(u8, first_line, ' ') orelse 0; + const rest_of = first_line[sp1 + 1 ..]; + const sp2 = std.mem.indexOfScalar(u8, rest_of, ' ') orelse rest_of.len; + path = rest_of[0..sp2]; + const body_sep = std.mem.indexOf(u8, req, "\r\n\r\n") orelse n; + body = req[@min(body_sep + 4, n)..]; + } + + var resp_buf: [MAX_CONN_BUF]u8 = undefined; + const result = switch (proto) { + .rest => dispatchRest(path, body, &resp_buf), + .grpc => dispatchGrpc(path, body, &resp_buf), + .graphql => dispatchGraphql(body, &resp_buf), + }; + + const content_type = switch (proto) { + .rest => "application/json", + .grpc => "application/grpc+json", + .graphql => "application/json", + }; + + var hdr_buf: [256]u8 = undefined; + const hdr = std.fmt.bufPrint(&hdr_buf, + "HTTP/1.1 {d} OK\r\nContent-Type: {s}\r\nContent-Length: {d}\r\nConnection: close\r\n\r\n", + .{ result.status, content_type, result.body.len }, + ) catch return; + _ = conn.stream.writeAll(hdr) catch return; + _ = conn.stream.writeAll(result.body) catch return; +} + +fn listenLoop(port: u16, proto: Protocol) void { + const addr = std.net.Address.initIp4(.{ 127, 0, 0, 1 }, port) catch return; + var server = addr.listen(.{ .reuse_address = true }) catch return; + defer server.deinit(); + while (true) { + const conn = server.accept() catch continue; + handleConnection(conn, proto); + } +} + +pub fn main() !void { + ffi.sentry_init(); + const rest_thread = try std.Thread.spawn(.{}, listenLoop, .{ REST_PORT, Protocol.rest }); + const grpc_thread = try std.Thread.spawn(.{}, listenLoop, .{ GRPC_PORT, Protocol.grpc }); + const gql_thread = try std.Thread.spawn(.{}, listenLoop, .{ GQL_PORT, Protocol.graphql }); + rest_thread.join(); + grpc_thread.join(); + gql_thread.join(); +} diff --git a/cartridges/domains/observability/sentry-mcp/benchmarks/quick-bench.sh b/cartridges/domains/observability/sentry-mcp/benchmarks/quick-bench.sh new file mode 100755 index 0000000..c3ecfae --- /dev/null +++ b/cartridges/domains/observability/sentry-mcp/benchmarks/quick-bench.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Benchmarks for sentry-mcp cartridge. +set -euo pipefail + +echo "=== sentry-mcp benchmarks ===" + +cd "$(dirname "${BASH_SOURCE[0]}")/../ffi" + +zig build -Doptimize=ReleaseFast 2>&1 + +echo "Session open/close cycle (1000 iterations):" +time for i in $(seq 1 1000); do + true +done + +echo "" +echo "Benchmark placeholder -- implement real benchmarks in Zig test blocks" +echo "or via the zig adapter HTTP benchmark tool." diff --git a/cartridges/domains/observability/sentry-mcp/cartridge.json b/cartridges/domains/observability/sentry-mcp/cartridge.json new file mode 100644 index 0000000..a2b0ff9 --- /dev/null +++ b/cartridges/domains/observability/sentry-mcp/cartridge.json @@ -0,0 +1,265 @@ +{ + "$schema": "https://boj.dev/schemas/cartridge/v1.json", + "spdx": "MPL-2.0", + "copyright": "Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath)", + "name": "sentry-mcp", + "version": "0.1.0", + "description": "Sentry error tracking cartridge -- issue listing, event retrieval, project browsing, release management, DSN lookup, team listing, tag search, error resolution, breadcrumb inspection, and performance transaction queries via the Sentry API", + "domain": "Monitoring", + "tier": "Ayo", + "protocols": [ + "MCP", + "REST" + ], + "auth": { + "method": "bearer_token", + "env_var": "SENTRY_AUTH_TOKEN", + "credential_source": "vault-mcp" + }, + "api": { + "base_url": "https://sentry.io/api/0", + "content_type": "application/json" + }, + "tools": [ + { + "name": "sentry_list_issues", + "description": "List Sentry issues for a project with optional query and sort filters", + "inputSchema": { + "type": "object", + "properties": { + "organization": { + "type": "string", + "description": "Organization slug" + }, + "project": { + "type": "string", + "description": "Project slug" + }, + "query": { + "type": "string", + "description": "Search query (e.g. 'is:unresolved level:error')" + }, + "sort": { + "type": "string", + "description": "Sort: date, new, priority, freq, user" + }, + "limit": { + "type": "number", + "description": "Max results (default 25)" + } + }, + "required": [ + "organization", + "project" + ] + } + }, + { + "name": "sentry_get_issue", + "description": "Get detailed information about a specific Sentry issue by ID", + "inputSchema": { + "type": "object", + "properties": { + "issue_id": { + "type": "string", + "description": "Sentry issue ID" + } + }, + "required": [ + "issue_id" + ] + } + }, + { + "name": "sentry_list_events", + "description": "List events for a specific Sentry issue with full stack traces", + "inputSchema": { + "type": "object", + "properties": { + "issue_id": { + "type": "string", + "description": "Sentry issue ID" + }, + "full": { + "type": "boolean", + "description": "Include full event details (default false)" + }, + "limit": { + "type": "number", + "description": "Max results" + } + }, + "required": [ + "issue_id" + ] + } + }, + { + "name": "sentry_resolve_issue", + "description": "Resolve or unresolve a Sentry issue", + "inputSchema": { + "type": "object", + "properties": { + "issue_id": { + "type": "string", + "description": "Sentry issue ID" + }, + "status": { + "type": "string", + "description": "Status: resolved, unresolved, ignored" + } + }, + "required": [ + "issue_id", + "status" + ] + } + }, + { + "name": "sentry_list_projects", + "description": "List all projects in a Sentry organization", + "inputSchema": { + "type": "object", + "properties": { + "organization": { + "type": "string", + "description": "Organization slug" + } + }, + "required": [ + "organization" + ] + } + }, + { + "name": "sentry_list_releases", + "description": "List releases for a Sentry project with commit and deploy info", + "inputSchema": { + "type": "object", + "properties": { + "organization": { + "type": "string", + "description": "Organization slug" + }, + "project": { + "type": "string", + "description": "Project slug" + }, + "limit": { + "type": "number", + "description": "Max results" + } + }, + "required": [ + "organization" + ] + } + }, + { + "name": "sentry_get_dsn", + "description": "Get the DSN (Data Source Name) keys for a Sentry project", + "inputSchema": { + "type": "object", + "properties": { + "organization": { + "type": "string", + "description": "Organization slug" + }, + "project": { + "type": "string", + "description": "Project slug" + } + }, + "required": [ + "organization", + "project" + ] + } + }, + { + "name": "sentry_list_teams", + "description": "List teams in a Sentry organization", + "inputSchema": { + "type": "object", + "properties": { + "organization": { + "type": "string", + "description": "Organization slug" + } + }, + "required": [ + "organization" + ] + } + }, + { + "name": "sentry_search_tags", + "description": "Search tag keys and values for a Sentry project", + "inputSchema": { + "type": "object", + "properties": { + "organization": { + "type": "string", + "description": "Organization slug" + }, + "project": { + "type": "string", + "description": "Project slug" + }, + "key": { + "type": "string", + "description": "Tag key to inspect (e.g. 'browser', 'os', 'environment')" + } + }, + "required": [ + "organization", + "project" + ] + } + }, + { + "name": "sentry_list_transactions", + "description": "List performance transactions for a Sentry project", + "inputSchema": { + "type": "object", + "properties": { + "organization": { + "type": "string", + "description": "Organization slug" + }, + "project": { + "type": "string", + "description": "Project slug" + }, + "query": { + "type": "string", + "description": "Search query for transactions" + }, + "sort": { + "type": "string", + "description": "Sort: p50, p75, p95, frequency, failure_rate" + }, + "limit": { + "type": "number", + "description": "Max results" + } + }, + "required": [ + "organization", + "project" + ] + } + } + ], + "ffi": { + "so_path": "ffi/zig-out/lib/libsentry_mcp.so", + "abi_version": "ADR-0006", + "symbols": [ + "boj_cartridge_init", + "boj_cartridge_deinit", + "boj_cartridge_name", + "boj_cartridge_version", + "boj_cartridge_invoke" + ] + } +} diff --git a/cartridges/domains/observability/sentry-mcp/ffi/README.adoc b/cartridges/domains/observability/sentry-mcp/ffi/README.adoc new file mode 100644 index 0000000..8ebc6f8 --- /dev/null +++ b/cartridges/domains/observability/sentry-mcp/ffi/README.adoc @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += sentry-mcp / ffi β€” Zig FFI layer +:orientation: shallow + +== Purpose + +C-ABI exports bridging this cartridge's Idris2 ABI to the runtime. Consumed by +the adapter for protocol dispatch and by the trunk loader for mount. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” produces `zig-out/lib/libsentry.so`. +| `sentry_mcp_ffi.zig` | C-ABI exports (14 exports, 5 inline tests, 322 lines). +|=== + +== Invariants + +* **Null-pointer rejection** on `[*c]const u8` arguments before any + dereference. +* **Negative return values** carry documented error meanings; zero is + success (matching the trunk error-code convention). +* **No allocator leaks** β€” every allocation path pairs with `defer free`. + +== Test/proof surface + +5 inline `test "..."` block(s) in `sentry_mcp_ffi.zig`. Run with +`cd ffi && zig build test`. + +== Read-first + +. `sentry_mcp_ffi.zig` β€” C-ABI signatures and guards. +. Trunk `ffi/zig/src/catalogue.zig` for how this `.so` is loaded. diff --git a/cartridges/domains/observability/sentry-mcp/ffi/build.zig b/cartridges/domains/observability/sentry-mcp/ffi/build.zig new file mode 100644 index 0000000..05ecce8 --- /dev/null +++ b/cartridges/domains/observability/sentry-mcp/ffi/build.zig @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + // Shared ADR-0006 invoke-shim module (relative path up to boj-server trunk). + + const shim_mod = b.addModule("cartridge_shim", .{ + + .root_source_file = b.path("../../../ffi/zig/src/cartridge_shim.zig"), + + .target = target, + + .optimize = optimize, + + }); + + const ffi_mod = b.addModule("sentry_mcp", .{ + .root_source_file = b.path("sentry_mcp_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + ffi_mod.addImport("cartridge_shim", shim_mod); + + const lib = b.addLibrary(.{ + .name = "sentry_mcp", + .root_module = ffi_mod, + .linkage = .dynamic, + }); + lib.linkLibC(); + b.installArtifact(lib); + + const tests = b.addTest(.{ .root_module = ffi_mod }); + tests.linkLibC(); + const run_tests = b.addRunArtifact(tests); + const test_step = b.step("test", "Run FFI tests"); + test_step.dependOn(&run_tests.step); +} + diff --git a/cartridges/domains/observability/sentry-mcp/ffi/cartridge_shim.zig b/cartridges/domains/observability/sentry-mcp/ffi/cartridge_shim.zig new file mode 100644 index 0000000..0e399db --- /dev/null +++ b/cartridges/domains/observability/sentry-mcp/ffi/cartridge_shim.zig @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// cartridge_shim.zig β€” Shared helpers for the ADR-0006 five-symbol +// cartridge ABI (`boj_cartridge_init / deinit / name / version / invoke`). +// +// The shim centralises the seven-code return convention, NUL-argument +// guards, tool-name comparison, and the buffer-too-small path so each +// cartridge's `boj_cartridge_invoke` can stay short β€” typically a tool +// table plus `shim.writeResult(...)`. +// +// Cartridges import this file by relative path (no build-graph change +// needed). Example: +// +// const shim = @import("../../../ffi/zig/src/cartridge_shim.zig"); +// +// export fn boj_cartridge_invoke( +// tool_name: [*c]const u8, +// json_args: [*c]const u8, +// out_buf: [*c]u8, +// in_out_len: [*c]usize, +// ) callconv(.c) i32 { +// _ = json_args; +// if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; +// const body = if (shim.toolIs(tool_name, "foo")) "{\"result\":{}}" +// else return shim.RC_UNKNOWN_TOOL; +// return shim.writeResult(out_buf, in_out_len, body); +// } + +const std = @import("std"); + +// ── Return codes (ADR-0006 Β§Return codes) ──────────────────────────── +// +// Frozen by ADR-0006. New failure modes compose these via the error +// JSON body β€” the integer surface does not grow without a follow-up ADR. + +pub const RC_SUCCESS: i32 = 0; +pub const RC_UNKNOWN_TOOL: i32 = -1; +pub const RC_BAD_ARGS: i32 = -2; +pub const RC_BUFFER_TOO_SMALL: i32 = -3; +pub const RC_RUNTIME_ERROR: i32 = -4; +pub const RC_PANIC: i32 = -5; +pub const RC_AUTH_DENIED: i32 = -6; + +// ── Invoke-path helpers ────────────────────────────────────────────── + +/// True if any of the three mandatory `boj_cartridge_invoke` output-path +/// pointers is null. Use at the top of every invoke to short-circuit to +/// `RC_BAD_ARGS`. +pub fn invokeArgsNull( + tool_name: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) bool { + return tool_name == null or out_buf == null or in_out_len == null; +} + +/// Compare a C-NUL-terminated tool-name pointer against a Zig string +/// literal. Caller must have already verified `tool_name` is non-null +/// (usually via `invokeArgsNull`). +pub fn toolIs(tool_name: [*c]const u8, expected: []const u8) bool { + const s = std.mem.span(@as([*:0]const u8, @ptrCast(tool_name))); + return std.mem.eql(u8, s, expected); +} + +/// Copy `body` into `out_buf[0..*in_out_len]` (as a capacity) and update +/// `*in_out_len` to the number of bytes written. Returns `RC_SUCCESS`. +/// +/// If `body.len` exceeds the current capacity stored in `*in_out_len`, +/// sets `*in_out_len` to the required size and returns +/// `RC_BUFFER_TOO_SMALL` β€” the caller is then expected to re-allocate +/// and retry, per ADR-0006 Β§Memory ownership. +/// +/// Caller must have already verified that `out_buf` and `in_out_len` +/// are non-null. +pub fn writeResult( + out_buf: [*c]u8, + in_out_len: [*c]usize, + body: []const u8, +) i32 { + const cap = in_out_len.*; + if (body.len > cap) { + in_out_len.* = body.len; + return RC_BUFFER_TOO_SMALL; + } + @memcpy(out_buf[0..body.len], body); + in_out_len.* = body.len; + return RC_SUCCESS; +} + +// ── Tests ──────────────────────────────────────────────────────────── + +test "writeResult: body fits, writes and sets length" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); + try std.testing.expectEqualStrings("hello", buf[0..len]); +} + +test "writeResult: too small returns -3 and sets required length" { + var buf: [2]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_BUFFER_TOO_SMALL, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: exact-fit succeeds" { + var buf: [5]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: empty body" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, ""); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 0), len); +} + +test "toolIs: matches and rejects" { + const name: [*:0]const u8 = "foo"; + try std.testing.expect(toolIs(@ptrCast(name), "foo")); + try std.testing.expect(!toolIs(@ptrCast(name), "bar")); + try std.testing.expect(!toolIs(@ptrCast(name), "foobar")); + try std.testing.expect(!toolIs(@ptrCast(name), "fo")); +} + +test "invokeArgsNull: detects each null slot" { + var buf: [4]u8 = undefined; + var len: usize = 4; + const name: [*:0]const u8 = "x"; + try std.testing.expect(!invokeArgsNull(@ptrCast(name), &buf, &len)); + try std.testing.expect(invokeArgsNull(null, &buf, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), null, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), &buf, null)); +} diff --git a/cartridges/domains/observability/sentry-mcp/ffi/sentry_mcp_ffi.zig b/cartridges/domains/observability/sentry-mcp/ffi/sentry_mcp_ffi.zig new file mode 100644 index 0000000..e901797 --- /dev/null +++ b/cartridges/domains/observability/sentry-mcp/ffi/sentry_mcp_ffi.zig @@ -0,0 +1,431 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// sentry_mcp_ffi.zig β€” C-ABI FFI implementation for sentry-mcp cartridge. +// +// Implements the state machine defined in SentryMcp.SafeRegistry (Idris2 ABI). +// State machine: Unauthenticated | Authenticated | RateLimited | Error +// Auth: Bearer token required for all Sentry API operations. +// Actions: ListIssues, GetIssue, ListEvents, ResolveIssue, ListProjects, +// ListReleases, GetDsn, ListTeams, SearchTags, ListTransactions +// Thread-safe via std.Thread.Mutex. Fixed-size session pool, no heap allocations. + +const std = @import("std"); + +// --------------------------------------------------------------------------- +// State machine (matches Idris2 ABI SessionState exactly) +// --------------------------------------------------------------------------- + +pub const SessionState = enum(c_int) { + unauthenticated = 0, + authenticated = 1, + rate_limited = 2, + err = 3, +}; + +pub const SentryAction = enum(c_int) { + list_issues = 0, + get_issue = 1, + list_events = 2, + resolve_issue = 3, + list_projects = 4, + list_releases = 5, + get_dsn = 6, + list_teams = 7, + search_tags = 8, + list_transactions = 9, +}; + +fn isValidTransition(from: SessionState, to: SessionState) bool { + return switch (from) { + .unauthenticated => to == .authenticated or to == .err, + .authenticated => to == .unauthenticated or to == .rate_limited or to == .err, + .rate_limited => to == .authenticated, + .err => to == .authenticated or to == .unauthenticated, + }; +} + +// --------------------------------------------------------------------------- +// Session slots (thread-safe, fixed-size pool) +// --------------------------------------------------------------------------- + +const MAX_SESSIONS: usize = 16; + +const SessionSlot = struct { + active: bool = false, + state: SessionState = .unauthenticated, + api_call_count: u64 = 0, + last_action: c_int = -1, + issue_ops: u32 = 0, + project_ops: u32 = 0, + perf_queries: u32 = 0, +}; + +var sessions: [MAX_SESSIONS]SessionSlot = .{SessionSlot{}} ** MAX_SESSIONS; +var mutex: std.Thread.Mutex = .{}; + +// --------------------------------------------------------------------------- +// C-ABI exports β€” state machine +// --------------------------------------------------------------------------- + +pub export fn sentry_mcp_can_transition(from: c_int, to: c_int) c_int { + const f = std.meta.intToEnum(SessionState, from) catch return 0; + const t = std.meta.intToEnum(SessionState, to) catch return 0; + return if (isValidTransition(f, t)) 1 else 0; +} + +pub export fn sentry_mcp_authenticate(dummy: c_int) c_int { + _ = dummy; + mutex.lock(); + defer mutex.unlock(); + + for (&sessions, 0..) |*slot, idx| { + if (!slot.active) { + slot.active = true; + slot.state = .authenticated; + slot.api_call_count = 0; + slot.last_action = -1; + slot.issue_ops = 0; + slot.project_ops = 0; + slot.perf_queries = 0; + return @intCast(idx); + } + } + return -1; +} + +pub export fn sentry_mcp_close(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + + sessions[idx] = SessionSlot{}; + return 0; +} + +pub export fn sentry_mcp_session_state(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + return @intFromEnum(slot.state); +} + +pub export fn sentry_mcp_throttle(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + if (!isValidTransition(slot.state, .rate_limited)) return -2; + + sessions[idx].state = .rate_limited; + return 0; +} + +pub export fn sentry_mcp_unthrottle(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + if (!isValidTransition(slot.state, .authenticated)) return -2; + + sessions[idx].state = .authenticated; + return 0; +} + +pub export fn sentry_mcp_signal_error(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + if (!isValidTransition(slot.state, .err)) return -2; + + sessions[idx].state = .err; + return 0; +} + +// --------------------------------------------------------------------------- +// C-ABI exports β€” action recording and metrics +// --------------------------------------------------------------------------- + +pub export fn sentry_mcp_record_call(slot_idx: c_int, action: c_int) c_int { + const act = std.meta.intToEnum(SentryAction, action) catch return -3; + + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + if (slot.state == .rate_limited) return -2; + if (slot.state == .err) return -2; + if (slot.state == .unauthenticated) return -2; + + sessions[idx].api_call_count += 1; + sessions[idx].last_action = action; + + switch (act) { + .list_issues, .get_issue, .list_events, .resolve_issue => sessions[idx].issue_ops += 1, + .list_projects, .list_releases, .get_dsn, .list_teams, .search_tags => sessions[idx].project_ops += 1, + .list_transactions => sessions[idx].perf_queries += 1, + } + + return 0; +} + +pub export fn sentry_mcp_call_count(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + return @intCast(slot.api_call_count); +} + +pub export fn sentry_mcp_issue_op_count(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + return @intCast(slot.issue_ops); +} + +pub export fn sentry_mcp_project_op_count(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + return @intCast(slot.project_ops); +} + +pub export fn sentry_mcp_perf_query_count(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + return @intCast(slot.perf_queries); +} + +pub export fn sentry_mcp_action_count() c_int { + return 10; +} + +pub export fn sentry_mcp_reset() void { + mutex.lock(); + defer mutex.unlock(); + sessions = .{SessionSlot{}} ** MAX_SESSIONS; +} + +// --------------------------------------------------------------------------- +// ═══════════════════════════════════════════════════════════════════════ +// Standard ABI (ADR-0005 four symbols + ADR-0006 invoke) +// ═══════════════════════════════════════════════════════════════════════ + +const shim = @import("cartridge_shim.zig"); + +const CARTRIDGE_NAME_PTR: [*:0]const u8 = "sentry-mcp"; +const CARTRIDGE_VERSION_PTR: [*:0]const u8 = "0.1.0"; + +export fn boj_cartridge_init() callconv(.c) c_int { + return 0; +} + +export fn boj_cartridge_deinit() callconv(.c) void {} + +export fn boj_cartridge_name() callconv(.c) [*:0]const u8 { + return CARTRIDGE_NAME_PTR; +} + +export fn boj_cartridge_version() callconv(.c) [*:0]const u8 { + return CARTRIDGE_VERSION_PTR; +} + +/// Dispatch the cartridge.json MCP tools. Grade D Alpha stubs. +export fn boj_cartridge_invoke( + tool_name: [*c]const u8, + json_args: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) callconv(.c) i32 { + _ = json_args; + if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; + + const body: []const u8 = if (shim.toolIs(tool_name, "sentry_list_issues")) + "{\"result\":{\"items\":[],\"count\":0,\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "sentry_get_issue")) + "{\"result\":{\"metadata\":{},\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "sentry_list_events")) + "{\"result\":{\"items\":[],\"count\":0,\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "sentry_resolve_issue")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "sentry_list_projects")) + "{\"result\":{\"items\":[],\"count\":0,\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "sentry_list_releases")) + "{\"result\":{\"items\":[],\"count\":0,\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "sentry_get_dsn")) + "{\"result\":{\"metadata\":{},\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "sentry_list_teams")) + "{\"result\":{\"items\":[],\"count\":0,\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "sentry_search_tags")) + "{\"result\":{\"matches\":[],\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "sentry_list_transactions")) + "{\"result\":{\"items\":[],\"count\":0,\"status\":\"stub\"}}" +else + return shim.RC_UNKNOWN_TOOL; + + return shim.writeResult(out_buf, in_out_len, body); +} + +// Tests +// --------------------------------------------------------------------------- + +test "authenticated session lifecycle" { + sentry_mcp_reset(); + + const slot = sentry_mcp_authenticate(0); + try std.testing.expect(slot >= 0); + try std.testing.expectEqual(@as(c_int, 1), sentry_mcp_session_state(slot)); + + try std.testing.expectEqual(@as(c_int, 0), sentry_mcp_record_call(slot, 0)); + try std.testing.expectEqual(@as(c_int, 1), sentry_mcp_call_count(slot)); + try std.testing.expectEqual(@as(c_int, 1), sentry_mcp_issue_op_count(slot)); + + try std.testing.expectEqual(@as(c_int, 0), sentry_mcp_close(slot)); +} + +test "rate limiting flow" { + sentry_mcp_reset(); + + const slot = sentry_mcp_authenticate(0); + try std.testing.expect(slot >= 0); + + try std.testing.expectEqual(@as(c_int, 0), sentry_mcp_throttle(slot)); + try std.testing.expectEqual(@as(c_int, 2), sentry_mcp_session_state(slot)); + try std.testing.expectEqual(@as(c_int, -2), sentry_mcp_record_call(slot, 0)); + + try std.testing.expectEqual(@as(c_int, 0), sentry_mcp_unthrottle(slot)); + try std.testing.expectEqual(@as(c_int, 1), sentry_mcp_session_state(slot)); +} + +test "category counting" { + sentry_mcp_reset(); + + const slot = sentry_mcp_authenticate(0); + try std.testing.expect(slot >= 0); + + // ListIssues (0) + try std.testing.expectEqual(@as(c_int, 0), sentry_mcp_record_call(slot, 0)); + // GetIssue (1) + try std.testing.expectEqual(@as(c_int, 0), sentry_mcp_record_call(slot, 1)); + // ListProjects (4) + try std.testing.expectEqual(@as(c_int, 0), sentry_mcp_record_call(slot, 4)); + // ListTransactions (9) + try std.testing.expectEqual(@as(c_int, 0), sentry_mcp_record_call(slot, 9)); + + try std.testing.expectEqual(@as(c_int, 4), sentry_mcp_call_count(slot)); + try std.testing.expectEqual(@as(c_int, 2), sentry_mcp_issue_op_count(slot)); + try std.testing.expectEqual(@as(c_int, 1), sentry_mcp_project_op_count(slot)); + try std.testing.expectEqual(@as(c_int, 1), sentry_mcp_perf_query_count(slot)); +} + +test "transition validator" { + try std.testing.expectEqual(@as(c_int, 1), sentry_mcp_can_transition(0, 1)); + try std.testing.expectEqual(@as(c_int, 1), sentry_mcp_can_transition(1, 0)); + try std.testing.expectEqual(@as(c_int, 1), sentry_mcp_can_transition(1, 2)); + try std.testing.expectEqual(@as(c_int, 1), sentry_mcp_can_transition(2, 1)); + try std.testing.expectEqual(@as(c_int, 0), sentry_mcp_can_transition(0, 2)); +} + +test "slot exhaustion" { + sentry_mcp_reset(); + + var slots: [MAX_SESSIONS]c_int = undefined; + for (&slots) |*s| { + s.* = sentry_mcp_authenticate(0); + try std.testing.expect(s.* >= 0); + } + + try std.testing.expectEqual(@as(c_int, -1), sentry_mcp_authenticate(0)); + + try std.testing.expectEqual(@as(c_int, 0), sentry_mcp_close(slots[0])); + const new_slot = sentry_mcp_authenticate(0); + try std.testing.expect(new_slot >= 0); +} + +// ═══════════════════════════════════════════════════════════════════════ +// ADR-0006 invoke dispatch tests +// ═══════════════════════════════════════════════════════════════════════ + +test "boj_cartridge_name returns sentry-mcp" { + const n = std.mem.span(boj_cartridge_name()); + try std.testing.expectEqualStrings("sentry-mcp", n); +} + +test "boj_cartridge_init returns 0" { + try std.testing.expectEqual(@as(c_int, 0), boj_cartridge_init()); +} + +test "invoke: each declared tool succeeds" { + var buf: [256]u8 = undefined; + const tools = [_][]const u8{ + "sentry_list_issues", + "sentry_get_issue", + "sentry_list_events", + "sentry_resolve_issue", + "sentry_list_projects", + "sentry_list_releases", + "sentry_get_dsn", + "sentry_list_teams", + "sentry_search_tags", + "sentry_list_transactions", + }; + for (tools) |t| { + var len: usize = buf.len; + const rc = boj_cartridge_invoke(t.ptr, "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, 0), rc); + try std.testing.expect(std.mem.indexOf(u8, buf[0..len], "result") != null); + } +} + +test "invoke: unknown tool returns -1" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("nope", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -1), rc); +} + +test "invoke: buffer too small returns -3" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("sentry_list_issues", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -3), rc); + try std.testing.expect(len > 4); +} diff --git a/cartridges/domains/observability/sentry-mcp/minter.toml b/cartridges/domains/observability/sentry-mcp/minter.toml new file mode 100644 index 0000000..c46d7f4 --- /dev/null +++ b/cartridges/domains/observability/sentry-mcp/minter.toml @@ -0,0 +1,22 @@ +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> + +name = "sentry-mcp" +description = "Sentry error tracking cartridge β€” issue listing, event retrieval, project browsing, releases, DSN lookup, teams, tags, resolution, performance transactions" +version = "0.1.0" +domain = "Monitoring" +protocols = [ + "MCP", + "REST", +] +tier = "Ayo" +backend = "universal" +generate_panel = true + +[auth] +method = "bearer_token" +credential_source = "vault-mcp" +fields = ["sentry_auth_token"] + +[api] +base_url = "https://sentry.io/api/0" diff --git a/cartridges/domains/observability/sentry-mcp/mod.js b/cartridges/domains/observability/sentry-mcp/mod.js new file mode 100644 index 0000000..4e75972 --- /dev/null +++ b/cartridges/domains/observability/sentry-mcp/mod.js @@ -0,0 +1,207 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// sentry-mcp/mod.js -- Sentry error tracking cartridge implementation. +// +// Provides MCP tool handlers for the Sentry API: +// - Issue listing with query/sort filters +// - Issue detail retrieval +// - Event listing with full stack traces +// - Issue resolution/unresolving +// - Project and release listing +// - DSN key lookup +// - Team listing +// - Tag search and browsing +// - Performance transaction queries +// +// Auth: Bearer token via SENTRY_AUTH_TOKEN (required for all operations). +// API docs: https://docs.sentry.io/api/ +// +// Usage: import { handleTool } from "./mod.js"; +// or: deno run --allow-net --allow-env mod.js + +// --------------------------------------------------------------------------- +// Configuration β€” base URL defaults to sentry.io SaaS. +// --------------------------------------------------------------------------- + +function getBaseUrl() { + const url = typeof Deno !== "undefined" + ? Deno.env.get("SENTRY_BASE_URL") + : process.env.SENTRY_BASE_URL; + return url || "https://sentry.io/api/0"; +} + +// --------------------------------------------------------------------------- +// Auth helper β€” retrieves the Sentry auth token from environment. +// --------------------------------------------------------------------------- + +function getToken() { + const token = typeof Deno !== "undefined" + ? Deno.env.get("SENTRY_AUTH_TOKEN") + : process.env.SENTRY_AUTH_TOKEN; + return token || null; +} + +// --------------------------------------------------------------------------- +// HTTP request helper β€” wraps fetch with Sentry auth headers and error handling. +// --------------------------------------------------------------------------- + +async function sentryFetch(path, { method = "GET", queryParams, body } = {}) { + const baseUrl = getBaseUrl(); + const url = new URL(`${baseUrl}${path}`); + + if (queryParams) { + for (const [key, value] of Object.entries(queryParams)) { + if (value !== undefined && value !== null && value !== "") { + url.searchParams.set(key, String(value)); + } + } + } + + const headers = { + "Accept": "application/json", + "Content-Type": "application/json", + }; + + const token = getToken(); + if (token) { + headers["Authorization"] = `Bearer ${token}`; + } + + const options = { method, headers }; + if (body) { + options.body = JSON.stringify(body); + } + + const response = await fetch(url.toString(), options); + + if (response.status === 429) { + const retryAfter = response.headers.get("retry-after"); + return { + status: 429, + error: `Rate limited by Sentry. Retry after ${retryAfter || "unknown"} seconds.`, + retryAfter, + }; + } + + const data = await response.json().catch(() => ({})); + + if (!response.ok) { + const errorMessage = data.detail || `HTTP ${response.status}`; + return { status: response.status, error: errorMessage, data }; + } + + return { status: response.status, data }; +} + +// --------------------------------------------------------------------------- +// Tool handler dispatch β€” maps MCP tool names to Sentry API operations. +// --------------------------------------------------------------------------- + +export async function handleTool(toolName, args) { + switch (toolName) { + + // --- Issues --- + + case "sentry_list_issues": { + if (!args.organization) return { error: "Missing required field: organization" }; + if (!args.project) return { error: "Missing required field: project" }; + const query = { + query: args.query, + sort: args.sort, + limit: args.limit, + }; + return sentryFetch(`/projects/${encodeURIComponent(args.organization)}/${encodeURIComponent(args.project)}/issues/`, { queryParams: query }); + } + + case "sentry_get_issue": { + if (!args.issue_id) return { error: "Missing required field: issue_id" }; + return sentryFetch(`/issues/${encodeURIComponent(args.issue_id)}/`); + } + + case "sentry_list_events": { + if (!args.issue_id) return { error: "Missing required field: issue_id" }; + const query = { full: args.full, limit: args.limit }; + return sentryFetch(`/issues/${encodeURIComponent(args.issue_id)}/events/`, { queryParams: query }); + } + + case "sentry_resolve_issue": { + if (!args.issue_id) return { error: "Missing required field: issue_id" }; + if (!args.status) return { error: "Missing required field: status" }; + const body = { status: args.status }; + return sentryFetch(`/issues/${encodeURIComponent(args.issue_id)}/`, { method: "PUT", body }); + } + + // --- Projects --- + + case "sentry_list_projects": { + if (!args.organization) return { error: "Missing required field: organization" }; + return sentryFetch(`/organizations/${encodeURIComponent(args.organization)}/projects/`); + } + + // --- Releases --- + + case "sentry_list_releases": { + if (!args.organization) return { error: "Missing required field: organization" }; + const query = { project: args.project, per_page: args.limit }; + return sentryFetch(`/organizations/${encodeURIComponent(args.organization)}/releases/`, { queryParams: query }); + } + + // --- DSN --- + + case "sentry_get_dsn": { + if (!args.organization) return { error: "Missing required field: organization" }; + if (!args.project) return { error: "Missing required field: project" }; + return sentryFetch(`/projects/${encodeURIComponent(args.organization)}/${encodeURIComponent(args.project)}/keys/`); + } + + // --- Teams --- + + case "sentry_list_teams": { + if (!args.organization) return { error: "Missing required field: organization" }; + return sentryFetch(`/organizations/${encodeURIComponent(args.organization)}/teams/`); + } + + // --- Tags --- + + case "sentry_search_tags": { + if (!args.organization) return { error: "Missing required field: organization" }; + if (!args.project) return { error: "Missing required field: project" }; + const path = args.key + ? `/projects/${encodeURIComponent(args.organization)}/${encodeURIComponent(args.project)}/tags/${encodeURIComponent(args.key)}/values/` + : `/projects/${encodeURIComponent(args.organization)}/${encodeURIComponent(args.project)}/tags/`; + return sentryFetch(path); + } + + // --- Performance --- + + case "sentry_list_transactions": { + if (!args.organization) return { error: "Missing required field: organization" }; + if (!args.project) return { error: "Missing required field: project" }; + const query = { + query: args.query, + sort: args.sort, + per_page: args.limit, + field: ["transaction", "p50()", "p95()", "failure_rate()", "count()"], + project: args.project, + }; + return sentryFetch(`/organizations/${encodeURIComponent(args.organization)}/events/`, { queryParams: query }); + } + + default: + return { error: `Unknown sentry-mcp tool: ${toolName}` }; + } +} + +// --------------------------------------------------------------------------- +// Cartridge metadata export β€” used by the BoJ cartridge loader. +// --------------------------------------------------------------------------- + +export const metadata = { + name: "sentry-mcp", + version: "0.1.0", + domain: "Monitoring", + tier: "Ayo", + protocols: ["MCP", "REST"], + toolCount: 10, +}; diff --git a/cartridges/domains/observability/sentry-mcp/panels/manifest.json b/cartridges/domains/observability/sentry-mcp/panels/manifest.json new file mode 100644 index 0000000..6217c1e --- /dev/null +++ b/cartridges/domains/observability/sentry-mcp/panels/manifest.json @@ -0,0 +1,90 @@ +{ + "$schema": "https://panll.dev/schemas/panel-manifest/v1.json", + "spdx": "MPL-2.0", + "copyright": "Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath)", + "cartridge": "sentry-mcp", + "domain": "Monitoring", + "version": "0.1.0", + "panels": [ + { + "id": "sentry-auth-status", + "title": "Auth Status", + "description": "Sentry session state (Unauthenticated / Authenticated / RateLimited / Error)", + "type": "status-indicator", + "data_source": { + "endpoint": "/sentry/state", + "method": "GET", + "refresh_interval_ms": 5000 + }, + "widgets": [ + { + "type": "state-badge", + "field": "state", + "states": { + "unauthenticated": { "color": "#95a5a6", "icon": "user-x" }, + "authenticated": { "color": "#2ecc71", "icon": "user-check" }, + "rate_limited": { "color": "#f39c12", "icon": "clock" }, + "error": { "color": "#e74c3c", "icon": "alert-triangle" } + } + } + ] + }, + { + "id": "sentry-issue-ops", + "title": "Issue Operations", + "description": "Number of issue listing, retrieval, and resolution operations in the current session", + "type": "metric", + "data_source": { + "endpoint": "/sentry/status", + "method": "GET", + "refresh_interval_ms": 10000 + }, + "widgets": [ + { + "type": "counter", + "field": "issue_ops", + "label": "Issue Ops", + "icon": "alert-circle" + } + ] + }, + { + "id": "sentry-project-ops", + "title": "Project Operations", + "description": "Number of project, release, DSN, team, and tag operations in the current session", + "type": "metric", + "data_source": { + "endpoint": "/sentry/status", + "method": "GET", + "refresh_interval_ms": 10000 + }, + "widgets": [ + { + "type": "counter", + "field": "project_ops", + "label": "Project Ops", + "icon": "folder" + } + ] + }, + { + "id": "sentry-perf-queries", + "title": "Performance Queries", + "description": "Number of performance transaction queries in the current session", + "type": "metric", + "data_source": { + "endpoint": "/sentry/status", + "method": "GET", + "refresh_interval_ms": 10000 + }, + "widgets": [ + { + "type": "counter", + "field": "perf_queries", + "label": "Perf Queries", + "icon": "activity" + } + ] + } + ] +} diff --git a/cartridges/domains/observability/sentry-mcp/tests/integration_test.sh b/cartridges/domains/observability/sentry-mcp/tests/integration_test.sh new file mode 100755 index 0000000..8f4de34 --- /dev/null +++ b/cartridges/domains/observability/sentry-mcp/tests/integration_test.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Integration tests for sentry-mcp cartridge. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CART_DIR="$(dirname "$SCRIPT_DIR")" +FFI_DIR="$CART_DIR/ffi" + +echo "=== sentry-mcp integration tests ===" + +echo "[1/3] Building FFI..." +cd "$FFI_DIR" && zig build 2>&1 + +echo "[2/3] Running FFI unit tests..." +cd "$FFI_DIR" && zig build test 2>&1 + +echo "[3/3] Checking ABI..." +if command -v idris2 &>/dev/null; then + cd "$CART_DIR/abi" && idris2 --check SentryMcp.SafeRegistry 2>&1 + echo " ABI: OK" +else + echo " ABI: SKIPPED (idris2 not in PATH)" +fi + +echo "" +echo "All tests passed for sentry-mcp!" diff --git a/cartridges/domains/open-data/opendatamcp/README.adoc b/cartridges/domains/open-data/opendatamcp/README.adoc new file mode 100644 index 0000000..0b87dea --- /dev/null +++ b/cartridges/domains/open-data/opendatamcp/README.adoc @@ -0,0 +1,67 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += opendatamcp +Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk> +:spdx: MPL-2.0 +:tier: Ayo +:domain: Open Data +:protocols: MCP, REST + +== Overview + +Open Data Model Context Protocol. Access public datasets from your LLM application. Publish and distribute your Open Data via MCP servers. + +== Tools (5) + +[cols="2,4"] +|=== +| Tool | Description + +| `opendatamcp_query_dataset` | Query a public dataset (e.g., Switzerland SBB train disruptions). +| `opendatamcp_list_datasets` | List available public datasets. +| `opendatamcp_get_dataset_info` | Get metadata about a specific dataset. +| `opendatamcp_publish_dataset` | Publish a new Open Data dataset (for contributors). +| `opendatamcp_search_datasets` | Search for datasets by keywords. +|=== + +== Architecture + +[cols="1,1,3"] +|=== +| Layer | Language | Purpose + +| ABI | Idris2 | Formally verified state machine with dependent-type proofs +| FFI | Zig | C-ABI implementation: Dataset querying, metadata management, search indexing +| Adapter| Zig | Adapter bridge to the BoJ unified adapter protocol +|=== + +== Building + +[source,bash] +---- +# ABI (Idris2) +cd abi && idris2 --check *.idr + +# FFI (Zig) +cd ffi && zig build +cd ffi && zig build test + +# Adapter (Zig) +cd adapter && zig build +---- + +== Status + +Alpha (CRG D). Cartridge metadata declared in `cartridge.json`; 5 tool(s) exposed. +Not yet dogfooded externally. See `docs/READINESS.md` at repo root for the +project-wide grade table and `docs/practice/DOGFOOD-LOG.adoc` for usage evidence. + +== Ports + +Allowed:: 8000 (OpenDataMCP default port) +Denied:: 22 (SSH), 23 (Telnet), 25 (SMTP), 135 (RPC), 139 (NetBIOS), 445 (SMB) + +== References + +- [OpenDataMCP GitHub](https://github.com/OpenDataMCP/OpenDataMCP) +- [MCP Specification](https://spec.modelcontextprotocol.io/) diff --git a/cartridges/domains/open-data/opendatamcp/abi/README.adoc b/cartridges/domains/open-data/opendatamcp/abi/README.adoc new file mode 100644 index 0000000..40d4770 --- /dev/null +++ b/cartridges/domains/open-data/opendatamcp/abi/README.adoc @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) += opendatamcp / abi β€” Idris2 ABI layer +:orientation: deep + +== Purpose + +Encodes the opendatamcp connection state machine and the MCP tool catalogue. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| opendatamcp.ipkg | Idris2 package descriptor. +| opendatamcp/Safeopendatamcp.idr | State machine and tool definitions. +|=== + +== Invariants + +* at module top. +* Zero . + +== Test/proof surface + +Type-check only β€” + Error loading file "opendatamcp.ipkg": File Not Found. + +== Read-first + +. Safeopendatamcp.idr β€” state machine and tool definitions. + diff --git a/cartridges/domains/open-data/opendatamcp/adapter/README.adoc b/cartridges/domains/open-data/opendatamcp/adapter/README.adoc new file mode 100644 index 0000000..0d06949 --- /dev/null +++ b/cartridges/domains/open-data/opendatamcp/adapter/README.adoc @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) += opendatamcp / adapter β€” REST/gRPC/GraphQL bridge +:orientation: deep + +== Purpose + +Exposes the FFI layer over three protocols. Stateless β€” all state lives behind the FFI mutex. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| build.zig | Zig build graph. +| opendatamcp_adapter.zig | Three-protocol dispatcher. +|=== + +== Invariants + +* Stateless β€” no global mutable state. +* One tool call per HTTP request. +* JSON content type for all responses. + +== Test/proof surface + +No inline tests β€” FFI tests cover correctness. Integration tested via cartridge-matrix tests. + +== Read-first + +. opendatamcp_adapter.zig β€” tool-name β†’ FFI-call mapping. diff --git a/cartridges/domains/open-data/opendatamcp/cartridge.json b/cartridges/domains/open-data/opendatamcp/cartridge.json new file mode 100644 index 0000000..34f8912 --- /dev/null +++ b/cartridges/domains/open-data/opendatamcp/cartridge.json @@ -0,0 +1,163 @@ +{ + "$schema": "https://boj.dev/schemas/cartridge/v1.json", + "spdx": "MPL-2.0", + "copyright": "Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath)", + "name": "opendatamcp", + "version": "0.1.0", + "description": "Open Data Model Context Protocol. Access public datasets from your LLM application. Publish and distribute your Open Data via MCP servers.", + "domain": "Open Data", + "tier": "Ayo", + "protocols": [ + "MCP", + "REST" + ], + "auth": { + "method": "none", + "env_var": null, + "credential_source": null + }, + "api": { + "base_url": "local://opendatamcp", + "content_type": "application/json" + }, + "ports": { + "allowed": [ + 8000 + ], + "denied": [ + 22, + 23, + 25, + 135, + 139, + 445 + ] + }, + "tools": [ + { + "name": "opendatamcp_query_dataset", + "description": "Query a public dataset (e.g., Switzerland SBB train disruptions).", + "inputSchema": { + "type": "object", + "properties": { + "dataset_id": { + "type": "string", + "description": "ID of the dataset (e.g., ch_sbb)" + }, + "query": { + "type": "string", + "description": "Query string (e.g., 'train disruptions')" + }, + "limit": { + "type": "integer", + "description": "Maximum number of results (default: 10)" + } + }, + "required": [ + "dataset_id", + "query" + ] + } + }, + { + "name": "opendatamcp_list_datasets", + "description": "List available public datasets.", + "inputSchema": { + "type": "object", + "properties": { + "category": { + "type": "string", + "description": "Filter by category (optional)" + }, + "country": { + "type": "string", + "description": "Filter by country code (optional)" + } + } + } + }, + { + "name": "opendatamcp_get_dataset_info", + "description": "Get metadata about a specific dataset.", + "inputSchema": { + "type": "object", + "properties": { + "dataset_id": { + "type": "string", + "description": "ID of the dataset" + } + }, + "required": [ + "dataset_id" + ] + } + }, + { + "name": "opendatamcp_publish_dataset", + "description": "Publish a new Open Data dataset (for contributors).", + "inputSchema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Name of the dataset" + }, + "description": { + "type": "string", + "description": "Description of the dataset" + }, + "source_url": { + "type": "string", + "description": "URL to the data source" + }, + "license": { + "type": "string", + "description": "License (e.g., MIT, CC-BY-SA)" + }, + "category": { + "type": "string", + "description": "Category (e.g., transportation, weather)" + } + }, + "required": [ + "name", + "description", + "source_url", + "license", + "category" + ] + } + }, + { + "name": "opendatamcp_search_datasets", + "description": "Search for datasets by keywords.", + "inputSchema": { + "type": "object", + "properties": { + "keywords": { + "type": "string", + "description": "Search keywords (e.g., 'train transportation')" + }, + "limit": { + "type": "integer", + "description": "Maximum number of results (default: 10)" + } + }, + "required": [ + "keywords" + ] + } + } + ], + "ffi": { + "so_path": "ffi/zig-out/lib/libopendatamcp.so", + "abi_version": "ADR-0006", + "symbols": [ + "boj_cartridge_init", + "boj_cartridge_deinit", + "boj_cartridge_name", + "boj_cartridge_version", + "boj_cartridge_invoke" + ] + } +} diff --git a/cartridges/domains/open-data/opendatamcp/ffi/README.adoc b/cartridges/domains/open-data/opendatamcp/ffi/README.adoc new file mode 100644 index 0000000..a6c292c --- /dev/null +++ b/cartridges/domains/open-data/opendatamcp/ffi/README.adoc @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) += opendatamcp / ffi β€” Zig FFI layer +:orientation: deep + +== Purpose + +Zig implementation of the connection state machine from . + +== Files + +[cols="1,4"] +|=== +| File | Role + +| build.zig | Zig build graph. +| opendatamcp_ffi.zig | FFI implementation. +|=== + +== Invariants + +* Mutex discipline for all shared state. +* Bounds checks before dereference. +* State-machine mirror of ABI. + +== Test/proof surface + +Inline blocks in opendatamcp_ffi.zig. Run with . + +== Read-first + +. opendatamcp_ffi.zig β€” FFI exports and guards. + diff --git a/cartridges/domains/open-data/opendatamcp/ffi/build.zig b/cartridges/domains/open-data/opendatamcp/ffi/build.zig new file mode 100644 index 0000000..003beab --- /dev/null +++ b/cartridges/domains/open-data/opendatamcp/ffi/build.zig @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const ffi_mod = b.addModule("opendatamcp", .{ + .root_source_file = b.path("opendatamcp_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + const lib = b.addLibrary(.{ + .name = "opendatamcp", + .root_module = ffi_mod, + .linkage = .dynamic, + }); + lib.linkLibC(); + b.installArtifact(lib); + + const tests = b.addTest(.{ .root_module = ffi_mod }); + tests.linkLibC(); + const run_tests = b.addRunArtifact(tests); + const test_step = b.step("test", "Run FFI tests"); + test_step.dependOn(&run_tests.step); +} diff --git a/cartridges/domains/open-data/opendatamcp/ffi/cartridge_shim.zig b/cartridges/domains/open-data/opendatamcp/ffi/cartridge_shim.zig new file mode 100644 index 0000000..0e399db --- /dev/null +++ b/cartridges/domains/open-data/opendatamcp/ffi/cartridge_shim.zig @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// cartridge_shim.zig β€” Shared helpers for the ADR-0006 five-symbol +// cartridge ABI (`boj_cartridge_init / deinit / name / version / invoke`). +// +// The shim centralises the seven-code return convention, NUL-argument +// guards, tool-name comparison, and the buffer-too-small path so each +// cartridge's `boj_cartridge_invoke` can stay short β€” typically a tool +// table plus `shim.writeResult(...)`. +// +// Cartridges import this file by relative path (no build-graph change +// needed). Example: +// +// const shim = @import("../../../ffi/zig/src/cartridge_shim.zig"); +// +// export fn boj_cartridge_invoke( +// tool_name: [*c]const u8, +// json_args: [*c]const u8, +// out_buf: [*c]u8, +// in_out_len: [*c]usize, +// ) callconv(.c) i32 { +// _ = json_args; +// if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; +// const body = if (shim.toolIs(tool_name, "foo")) "{\"result\":{}}" +// else return shim.RC_UNKNOWN_TOOL; +// return shim.writeResult(out_buf, in_out_len, body); +// } + +const std = @import("std"); + +// ── Return codes (ADR-0006 Β§Return codes) ──────────────────────────── +// +// Frozen by ADR-0006. New failure modes compose these via the error +// JSON body β€” the integer surface does not grow without a follow-up ADR. + +pub const RC_SUCCESS: i32 = 0; +pub const RC_UNKNOWN_TOOL: i32 = -1; +pub const RC_BAD_ARGS: i32 = -2; +pub const RC_BUFFER_TOO_SMALL: i32 = -3; +pub const RC_RUNTIME_ERROR: i32 = -4; +pub const RC_PANIC: i32 = -5; +pub const RC_AUTH_DENIED: i32 = -6; + +// ── Invoke-path helpers ────────────────────────────────────────────── + +/// True if any of the three mandatory `boj_cartridge_invoke` output-path +/// pointers is null. Use at the top of every invoke to short-circuit to +/// `RC_BAD_ARGS`. +pub fn invokeArgsNull( + tool_name: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) bool { + return tool_name == null or out_buf == null or in_out_len == null; +} + +/// Compare a C-NUL-terminated tool-name pointer against a Zig string +/// literal. Caller must have already verified `tool_name` is non-null +/// (usually via `invokeArgsNull`). +pub fn toolIs(tool_name: [*c]const u8, expected: []const u8) bool { + const s = std.mem.span(@as([*:0]const u8, @ptrCast(tool_name))); + return std.mem.eql(u8, s, expected); +} + +/// Copy `body` into `out_buf[0..*in_out_len]` (as a capacity) and update +/// `*in_out_len` to the number of bytes written. Returns `RC_SUCCESS`. +/// +/// If `body.len` exceeds the current capacity stored in `*in_out_len`, +/// sets `*in_out_len` to the required size and returns +/// `RC_BUFFER_TOO_SMALL` β€” the caller is then expected to re-allocate +/// and retry, per ADR-0006 Β§Memory ownership. +/// +/// Caller must have already verified that `out_buf` and `in_out_len` +/// are non-null. +pub fn writeResult( + out_buf: [*c]u8, + in_out_len: [*c]usize, + body: []const u8, +) i32 { + const cap = in_out_len.*; + if (body.len > cap) { + in_out_len.* = body.len; + return RC_BUFFER_TOO_SMALL; + } + @memcpy(out_buf[0..body.len], body); + in_out_len.* = body.len; + return RC_SUCCESS; +} + +// ── Tests ──────────────────────────────────────────────────────────── + +test "writeResult: body fits, writes and sets length" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); + try std.testing.expectEqualStrings("hello", buf[0..len]); +} + +test "writeResult: too small returns -3 and sets required length" { + var buf: [2]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_BUFFER_TOO_SMALL, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: exact-fit succeeds" { + var buf: [5]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: empty body" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, ""); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 0), len); +} + +test "toolIs: matches and rejects" { + const name: [*:0]const u8 = "foo"; + try std.testing.expect(toolIs(@ptrCast(name), "foo")); + try std.testing.expect(!toolIs(@ptrCast(name), "bar")); + try std.testing.expect(!toolIs(@ptrCast(name), "foobar")); + try std.testing.expect(!toolIs(@ptrCast(name), "fo")); +} + +test "invokeArgsNull: detects each null slot" { + var buf: [4]u8 = undefined; + var len: usize = 4; + const name: [*:0]const u8 = "x"; + try std.testing.expect(!invokeArgsNull(@ptrCast(name), &buf, &len)); + try std.testing.expect(invokeArgsNull(null, &buf, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), null, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), &buf, null)); +} diff --git a/cartridges/domains/open-data/opendatamcp/ffi/opendatamcp_ffi.zig b/cartridges/domains/open-data/opendatamcp/ffi/opendatamcp_ffi.zig new file mode 100644 index 0000000..db5d48f --- /dev/null +++ b/cartridges/domains/open-data/opendatamcp/ffi/opendatamcp_ffi.zig @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// opendatamcp FFI β€” ADR-0006 five-symbol cartridge ABI implementation. + +const std = @import("std"); +const shim = @import("cartridge_shim.zig"); + +const CARTRIDGE_NAME_PTR: [*:0]const u8 = "opendatamcp"; +const CARTRIDGE_VERSION_PTR: [*:0]const u8 = "0.1.0"; + +export fn boj_cartridge_init() callconv(.c) c_int { + return 0; +} + +export fn boj_cartridge_deinit() callconv(.c) void {} + +export fn boj_cartridge_name() callconv(.c) [*:0]const u8 { + return CARTRIDGE_NAME_PTR; +} + +export fn boj_cartridge_version() callconv(.c) [*:0]const u8 { + return CARTRIDGE_VERSION_PTR; +} + +export fn boj_cartridge_invoke( + tool_name: [*c]const u8, + json_args: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) callconv(.c) i32 { + _ = json_args; + if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; + const body: []const u8 = if (shim.toolIs(tool_name, "opendatamcp_query_dataset")) + "{\"result\":{}}" + else if (shim.toolIs(tool_name, "opendatamcp_list_datasets")) + "{\"result\":{}}" + else if (shim.toolIs(tool_name, "opendatamcp_get_dataset_info")) + "{\"result\":{}}" + else if (shim.toolIs(tool_name, "opendatamcp_publish_dataset")) + "{\"result\":{}}" + else if (shim.toolIs(tool_name, "opendatamcp_search_datasets")) + "{\"result\":{}}" + else + return shim.RC_UNKNOWN_TOOL; + + return shim.writeResult(out_buf, in_out_len, body); +} + +test "boj_cartridge_name returns opendatamcp" { + try std.testing.expectEqualStrings("opendatamcp", std.mem.span(boj_cartridge_name())); +} + +test "boj_cartridge_init returns 0" { + try std.testing.expectEqual(@as(c_int, 0), boj_cartridge_init()); +} + +test "invoke unknown tool returns RC_UNKNOWN_TOOL" { + var buf: [256]u8 = undefined; + var len: usize = buf.len; + try std.testing.expectEqual(@as(i32, shim.RC_UNKNOWN_TOOL), boj_cartridge_invoke("unknown_xyz", "{}", &buf, &len)); +} + +test "invoke opendatamcp_query_dataset returns 0" { + var buf: [256]u8 = undefined; + var len: usize = buf.len; + try std.testing.expectEqual(@as(i32, 0), boj_cartridge_invoke("opendatamcp_query_dataset", "{}", &buf, &len)); +} diff --git a/cartridges/domains/open-data/opendatamcp/mod.js b/cartridges/domains/open-data/opendatamcp/mod.js new file mode 100644 index 0000000..acf6731 --- /dev/null +++ b/cartridges/domains/open-data/opendatamcp/mod.js @@ -0,0 +1,74 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// opendatamcp/mod.js β€” Open Data MCP cartridge. +// +// Delegates to backend at http://127.0.0.1:8000 (override with OPENDATA_URL). +// No auth required. Access and publish public open datasets. + +const BASE_URL = Deno.env.get("OPENDATA_URL") ?? "http://127.0.0.1:8000"; +const TIMEOUT_MS = 30_000; // dataset queries can be slow + +async function post(path, payload) { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), TIMEOUT_MS); + try { + const r = await fetch(`${BASE_URL}${path}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + signal: ctrl.signal, + }); + const data = await r.json().catch(() => ({ success: false, error: "non-JSON response" })); + return { status: r.status, data }; + } catch (e) { + if (e.name === "AbortError") + return { status: 504, data: { success: false, error: "opendatamcp backend timed out" } }; + return { status: 503, data: { success: false, error: `opendatamcp backend unavailable: ${e.message}` } }; + } finally { clearTimeout(t); } +} + +export async function handleTool(toolName, args) { + switch (toolName) { + case "opendatamcp_query_dataset": { + const { dataset_id, query, limit } = args ?? {}; + if (!dataset_id || !query) + return { status: 400, data: { error: "dataset_id and query are required" } }; + const payload = { dataset_id, query }; + if (limit !== undefined) payload.limit = limit; + return post("/api/v1/datasets/query", payload); + } + + case "opendatamcp_list_datasets": { + const { category, country } = args ?? {}; + const payload = {}; + if (category !== undefined) payload.category = category; + if (country !== undefined) payload.country = country; + return post("/api/v1/datasets/list", payload); + } + + case "opendatamcp_get_dataset_info": { + const { dataset_id } = args ?? {}; + if (!dataset_id) return { status: 400, data: { error: "dataset_id is required" } }; + return post("/api/v1/datasets/info", { dataset_id }); + } + + case "opendatamcp_publish_dataset": { + const { name, description, source_url, license, category } = args ?? {}; + if (!name || !description || !source_url || !license || !category) + return { status: 400, data: { error: "name, description, source_url, license, and category are required" } }; + return post("/api/v1/datasets/publish", { name, description, source_url, license, category }); + } + + case "opendatamcp_search_datasets": { + const { keywords, limit } = args ?? {}; + if (!keywords) return { status: 400, data: { error: "keywords is required" } }; + const payload = { keywords }; + if (limit !== undefined) payload.limit = limit; + return post("/api/v1/datasets/search", payload); + } + + default: + return { status: 404, data: { error: `Unknown tool: ${toolName}` } }; + } +} diff --git a/cartridges/domains/productivity/airtable-mcp/README.adoc b/cartridges/domains/productivity/airtable-mcp/README.adoc new file mode 100644 index 0000000..11b96ed --- /dev/null +++ b/cartridges/domains/productivity/airtable-mcp/README.adoc @@ -0,0 +1,87 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += airtable-mcp +Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk> +:spdx: MPL-2.0 +:tier: Ayo +:domain: Productivity +:protocols: MCP, REST + +== Overview + +Airtable cartridge for the BoJ server. Provides type-safe access to the +Airtable REST API for base listing, table schema retrieval, record search +with formula filtering, record creation and update, field listing, view +browsing, webhook management, and record comment access. + +Connects to the Airtable API at `api.airtable.com`. Personal access token +auth is always required. + +=== State Machine + +`Disconnected -> Connected` (authenticated via personal access token) + +`Connected -> RateLimited -> Connected` (30 req/sec limit) + +`Connected -> Error -> Disconnected` (error recovery) + +=== Actions (10) + +[cols="1,1"] +|=== +| Category | Actions + +| Record Reads +| ListRecords, GetRecord, GetComments + +| Record Writes +| CreateRecord, UpdateRecord + +| Schema +| GetBaseSchema, ListFields, ListViews + +| Meta +| ListBases, ListWebhooks +|=== + +== Architecture + +[cols="1,1,2"] +|=== +| Layer | Language | Purpose + +| ABI +| Idris2 +| Formally verified state machine with dependent-type proofs (AirtableMcp.SafeRegistry) + +| FFI +| Zig +| C-compatible implementation with thread-safe session pool, action recording, category counting + +| Adapter +| zig +| REST bridge to BoJ unified adapter protocol +|=== + +== Building + +[source,bash] +---- +# Build FFI shared library +cd ffi && zig build + +# Run FFI tests +cd ffi && zig build test + +# Type-check ABI +cd abi && idris2 --check AirtableMcp.SafeRegistry +---- + +== Prerequisites + +1. Create an Airtable personal access token at https://airtable.com/create/tokens +2. Set `AIRTABLE_API_KEY` to the token value. + +== Status + +Development -- customised with Airtable-specific formula filtering, schema introspection, view browsing, webhook management, and record comments. diff --git a/cartridges/domains/productivity/airtable-mcp/abi/AirtableMcp/SafeRegistry.idr b/cartridges/domains/productivity/airtable-mcp/abi/AirtableMcp/SafeRegistry.idr new file mode 100644 index 0000000..50837e6 --- /dev/null +++ b/cartridges/domains/productivity/airtable-mcp/abi/AirtableMcp/SafeRegistry.idr @@ -0,0 +1,166 @@ +-- SPDX-License-Identifier: MPL-2.0 +-- Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +-- +-- AirtableMcp.SafeRegistry β€” Type-safe ABI for airtable-mcp cartridge. +-- +-- Dependent-type state machine governing Airtable REST API access. +-- Encodes mandatory Bearer token auth, base listing, schema retrieval, +-- record search, record creation, record update, field listing, view +-- browsing, webhook management, and comment access as compile-time +-- invariants. +-- REST API: https://api.airtable.com/v0 +-- No unsafe escape hatches. + +module AirtableMcp.SafeRegistry + +%default total + +-- --------------------------------------------------------------------------- +-- Authentication state machine +-- --------------------------------------------------------------------------- + +||| Session state for Airtable MCP operations. +||| Disconnected: no API key configured. +||| Connected: authenticated and ready for API calls. +||| RateLimited: rate limit hit (30 req/sec); must wait. +||| Error: unrecoverable error (invalid key, permission denied). +public export +data SessionState + = Disconnected + | Connected + | RateLimited + | Error + +||| Proof that a state transition is valid. +||| Airtable requires personal access token β€” no anonymous access. +public export +data ValidTransition : SessionState -> SessionState -> Type where + Connect : ValidTransition Disconnected Connected + Disconnect : ValidTransition Connected Disconnected + Throttle : ValidTransition Connected RateLimited + Unthrottle : ValidTransition RateLimited Connected + ConnectError : ValidTransition Connected Error + DisconnError : ValidTransition Disconnected Error + RecoverConnect : ValidTransition Error Connected + RecoverDisconn : ValidTransition Error Disconnected + +-- --------------------------------------------------------------------------- +-- C-ABI integer encoding +-- --------------------------------------------------------------------------- + +export +sessionStateToInt : SessionState -> Int +sessionStateToInt Disconnected = 0 +sessionStateToInt Connected = 1 +sessionStateToInt RateLimited = 2 +sessionStateToInt Error = 3 + +export +intToSessionState : Int -> Maybe SessionState +intToSessionState 0 = Just Disconnected +intToSessionState 1 = Just Connected +intToSessionState 2 = Just RateLimited +intToSessionState 3 = Just Error +intToSessionState _ = Nothing + +export +airtable_mcp_can_transition : Int -> Int -> Int +airtable_mcp_can_transition from to = + case (intToSessionState from, intToSessionState to) of + (Just Disconnected, Just Connected) => 1 + (Just Connected, Just Disconnected) => 1 + (Just Connected, Just RateLimited) => 1 + (Just RateLimited, Just Connected) => 1 + (Just Connected, Just Error) => 1 + (Just Disconnected, Just Error) => 1 + (Just Error, Just Connected) => 1 + (Just Error, Just Disconnected) => 1 + _ => 0 + +-- --------------------------------------------------------------------------- +-- Airtable actions +-- --------------------------------------------------------------------------- + +||| Actions available through the Airtable MCP cartridge. +public export +data AirtableAction + = ListBases + | GetBaseSchema + | ListRecords + | GetRecord + | CreateRecord + | UpdateRecord + | ListFields + | ListViews + | ListWebhooks + | GetComments + +||| Whether an action requires Connected state. +export +actionRequiresAuth : AirtableAction -> Bool +actionRequiresAuth _ = True + +||| Whether an action is a write/mutating operation. +export +actionIsMutating : AirtableAction -> Bool +actionIsMutating CreateRecord = True +actionIsMutating UpdateRecord = True +actionIsMutating _ = False + +||| Encode action as C-compatible integer for FFI. +export +actionToInt : AirtableAction -> Int +actionToInt ListBases = 0 +actionToInt GetBaseSchema = 1 +actionToInt ListRecords = 2 +actionToInt GetRecord = 3 +actionToInt CreateRecord = 4 +actionToInt UpdateRecord = 5 +actionToInt ListFields = 6 +actionToInt ListViews = 7 +actionToInt ListWebhooks = 8 +actionToInt GetComments = 9 + +||| Decode integer to Airtable action. +export +intToAction : Int -> Maybe AirtableAction +intToAction 0 = Just ListBases +intToAction 1 = Just GetBaseSchema +intToAction 2 = Just ListRecords +intToAction 3 = Just GetRecord +intToAction 4 = Just CreateRecord +intToAction 5 = Just UpdateRecord +intToAction 6 = Just ListFields +intToAction 7 = Just ListViews +intToAction 8 = Just ListWebhooks +intToAction 9 = Just GetComments +intToAction _ = Nothing + +-- --------------------------------------------------------------------------- +-- MCP tool declarations +-- --------------------------------------------------------------------------- + +public export +data McpTool + = ToolListBases + | ToolGetBaseSchema + | ToolListRecords + | ToolGetRecord + | ToolCreateRecord + | ToolUpdateRecord + | ToolListFields + | ToolListViews + | ToolListWebhooks + | ToolGetComments + +export +toolRequiresSession : McpTool -> Bool +toolRequiresSession _ = True + +export +toolCount : Nat +toolCount = 10 + +export +actionCount : Nat +actionCount = 10 diff --git a/cartridges/domains/productivity/airtable-mcp/abi/README.adoc b/cartridges/domains/productivity/airtable-mcp/abi/README.adoc new file mode 100644 index 0000000..f08f30f --- /dev/null +++ b/cartridges/domains/productivity/airtable-mcp/abi/README.adoc @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += airtable-mcp / abi β€” Idris2 ABI layer +:orientation: shallow + +== Purpose + +Cartridge-local protocol types for `airtable-mcp`. Defines the data, tool, and +transition vocabulary this cartridge speaks. Inherits safety lemmas and the +matrix-cell identifier scheme from the trunk ABI at `src/abi/`. + +== Files + +1 Idris2 module(s), ~166 lines total. Lead module: +`SafeRegistry.idr`. + +No `.ipkg` β€” consumed directly by wider BoJ callers; type-checks inside the +trunk `boj.ipkg`. + +== Invariants + +* `%default total` at every module top. +* Zero `believe_me`, `assert_total`, `postulate`, `Admitted`, or + `sorry` (the trunk holds the four declared `believe_me` sites; leaf + cartridges carry none). + +== Test/proof surface + +Thin module(s), no inline tests. Proof surface = type-check success under the +trunk `idris2 --check`. + +== Read-first + +. `SafeRegistry.idr` β€” cartridge-local data records and enums. +. Trunk `src/abi/Boj/Catalogue.idr` for how this cell participates in the + 2D matrix. diff --git a/cartridges/domains/productivity/airtable-mcp/adapter/README.adoc b/cartridges/domains/productivity/airtable-mcp/adapter/README.adoc new file mode 100644 index 0000000..c4fb737 --- /dev/null +++ b/cartridges/domains/productivity/airtable-mcp/adapter/README.adoc @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += airtable-mcp / adapter β€” protocol bridge +:orientation: shallow + +== Purpose + +Exposes this cartridge's FFI over the wire protocols declared in +`cartridge.json`. Dispatches tool invocations to the C-ABI exports at +`../ffi/` and formats responses per protocol. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” depends on the sibling `ffi` target. +| `airtable_adapter.zig` | Protocol dispatch (204 lines). Tool catalogue matches `cartridge.json`. +| `SIDELINED-*.v.adoc` | zig predecessor(s). Not built β€” zig banned 2026-04-10. +|=== + +== Invariants + +* **Stateless** β€” all state lives behind the FFI, never in the adapter. +* **Response passthrough** β€” whatever the FFI returns goes back to the wire + unmodified (no embellishment, no silent recovery). +* **Cartridge.json is source of truth** for the tool catalogue; drift between + adapter and manifest is a CI failure. + +== Test/proof surface + +No inline tests at the adapter layer β€” protocol routing is exercised by the +trunk seam tests at `ffi/zig/src/seams.zig`. + +== Read-first + +. `airtable_adapter.zig` β€” dispatch function and per-protocol routers. +. `../cartridge.json` β€” the tool manifest the dispatcher mirrors. diff --git a/cartridges/domains/productivity/airtable-mcp/adapter/airtable_adapter.zig b/cartridges/domains/productivity/airtable-mcp/adapter/airtable_adapter.zig new file mode 100644 index 0000000..84d3c3c --- /dev/null +++ b/cartridges/domains/productivity/airtable-mcp/adapter/airtable_adapter.zig @@ -0,0 +1,204 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// airtable-mcp/adapter/airtable_adapter.zig -- Unified three-protocol adapter. +// +// Replaces the banned airtable_adapter.v (zig, removed 2026-04-12). +// +// Bridges the Zig FFI (airtable_mcp_ffi.zig) to three network protocols: +// REST :9034 POST /tools/<tool> +// gRPC-compat :9035 /AirtableMcpService/<Method> +// GraphQL :9036 POST /graphql { query: "..." } +// +// Airtable REST API: bases, tables, records, fields, views, webhooks, comments +// Tools: +// airtable_list_bases +// airtable_get_base_schema +// airtable_list_records +// airtable_get_record +// airtable_create_record +// airtable_update_record +// airtable_list_fields +// airtable_list_views +// airtable_list_webhooks +// airtable_get_comments + +const std = @import("std"); +const ffi = @import("airtable_mcp_ffi"); + +const REST_PORT: u16 = 9034; +const GRPC_PORT: u16 = 9035; +const GQL_PORT: u16 = 9036; + +const MAX_CONN_BUF: usize = 16 * 1024; + +// ============================================================================ +// JSON response builders +// ============================================================================ + +fn okJson(buf: []u8, msg: []const u8) []u8 { + return std.fmt.bufPrint(buf, + \{{"success":true,"message":"{}"}} + , .{msg}) catch buf[0..0]; +} + +fn errJson(buf: []u8, msg: []const u8) []u8 { + return std.fmt.bufPrint(buf, + \{{"success":false,"error":"{}"}} + , .{msg}) catch buf[0..0]; +} + +fn statusJson(buf: []u8) []u8 { + return std.fmt.bufPrint(buf, + \{{"success":true,"state":"ready","service":"airtable-mcp"}} + , .{}) catch buf[0..0]; +} + +// ============================================================================ +// Tool dispatcher +// ============================================================================ + +const Response = struct { status: u16, body: []u8 }; + +fn dispatch(tool: []const u8, body: []const u8, resp: []u8) Response { + _ = body; + if (std.mem.eql(u8, tool, "airtable_list_bases")) return .{ .status = 200, .body = okJson(resp, "airtable_list_bases forwarded to backend") }; + if (std.mem.eql(u8, tool, "airtable_get_base_schema")) return .{ .status = 200, .body = okJson(resp, "airtable_get_base_schema forwarded to backend") }; + if (std.mem.eql(u8, tool, "airtable_list_records")) return .{ .status = 200, .body = okJson(resp, "airtable_list_records forwarded to backend") }; + if (std.mem.eql(u8, tool, "airtable_get_record")) return .{ .status = 200, .body = okJson(resp, "airtable_get_record forwarded to backend") }; + if (std.mem.eql(u8, tool, "airtable_create_record")) return .{ .status = 200, .body = okJson(resp, "airtable_create_record forwarded to backend") }; + if (std.mem.eql(u8, tool, "airtable_update_record")) return .{ .status = 200, .body = okJson(resp, "airtable_update_record forwarded to backend") }; + if (std.mem.eql(u8, tool, "airtable_list_fields")) return .{ .status = 200, .body = okJson(resp, "airtable_list_fields forwarded to backend") }; + if (std.mem.eql(u8, tool, "airtable_list_views")) return .{ .status = 200, .body = okJson(resp, "airtable_list_views forwarded to backend") }; + if (std.mem.eql(u8, tool, "airtable_list_webhooks")) return .{ .status = 200, .body = okJson(resp, "airtable_list_webhooks forwarded to backend") }; + if (std.mem.eql(u8, tool, "airtable_get_comments")) return .{ .status = 200, .body = okJson(resp, "airtable_get_comments forwarded to backend") }; + if (std.mem.eql(u8, tool, "status") or std.mem.eql(u8, tool, "health")) + return .{ .status = 200, .body = statusJson(resp) }; + return .{ .status = 404, .body = errJson(resp, "Unknown tool") }; +} + +// ============================================================================ +// REST handler +// ============================================================================ + +fn dispatchRest(path: []const u8, body: []const u8, resp: []u8) Response { + if (std.mem.startsWith(u8, path, "/tools/")) { + return dispatch(path["/tools/".len..], body, resp); + } + if (std.mem.eql(u8, path, "/status") or std.mem.eql(u8, path, "/health")) { + return .{ .status = 200, .body = statusJson(resp) }; + } + return .{ .status = 404, .body = errJson(resp, "Not found") }; +} + +// ============================================================================ +// gRPC-compat handler +// ============================================================================ + +fn dispatchGrpc(path: []const u8, body: []const u8, resp: []u8) Response { + const prefix = "/AirtableMcpService/"; + if (!std.mem.startsWith(u8, path, prefix)) + return .{ .status = 404, .body = errJson(resp, "Not a recognized gRPC path") }; + const method = path[prefix.len..]; + const tool = blk: { + if (std.mem.eql(u8, method, "AirtableListBases")) break :blk "airtable_list_bases"; + if (std.mem.eql(u8, method, "AirtableGetBaseSchema")) break :blk "airtable_get_base_schema"; + if (std.mem.eql(u8, method, "AirtableListRecords")) break :blk "airtable_list_records"; + if (std.mem.eql(u8, method, "AirtableGetRecord")) break :blk "airtable_get_record"; + if (std.mem.eql(u8, method, "AirtableCreateRecord")) break :blk "airtable_create_record"; + if (std.mem.eql(u8, method, "AirtableUpdateRecord")) break :blk "airtable_update_record"; + if (std.mem.eql(u8, method, "AirtableListFields")) break :blk "airtable_list_fields"; + if (std.mem.eql(u8, method, "AirtableListViews")) break :blk "airtable_list_views"; + if (std.mem.eql(u8, method, "AirtableListWebhooks")) break :blk "airtable_list_webhooks"; + if (std.mem.eql(u8, method, "AirtableGetComments")) break :blk "airtable_get_comments"; + return .{ .status = 404, .body = errJson(resp, "Unknown gRPC method") }; + }; + return dispatch(tool, body, resp); +} + +// ============================================================================ +// GraphQL handler +// ============================================================================ + +fn dispatchGraphql(body: []const u8, resp: []u8) Response { + if (std.mem.indexOf(u8, body, "__schema") != null) + return .{ .status = 200, .body = okJson(resp, "schema introspection not yet supported") }; + if (std.mem.indexOf(u8, body, "list_bases") != null) return dispatch("airtable_list_bases", body, resp); + if (std.mem.indexOf(u8, body, "get_base_schema") != null) return dispatch("airtable_get_base_schema", body, resp); + if (std.mem.indexOf(u8, body, "list_records") != null) return dispatch("airtable_list_records", body, resp); + if (std.mem.indexOf(u8, body, "get_record") != null) return dispatch("airtable_get_record", body, resp); + if (std.mem.indexOf(u8, body, "create_record") != null) return dispatch("airtable_create_record", body, resp); + if (std.mem.indexOf(u8, body, "update_record") != null) return dispatch("airtable_update_record", body, resp); + if (std.mem.indexOf(u8, body, "list_fields") != null) return dispatch("airtable_list_fields", body, resp); + if (std.mem.indexOf(u8, body, "list_views") != null) return dispatch("airtable_list_views", body, resp); + if (std.mem.indexOf(u8, body, "list_webhooks") != null) return dispatch("airtable_list_webhooks", body, resp); + if (std.mem.indexOf(u8, body, "get_comments") != null) return dispatch("airtable_get_comments", body, resp); + return .{ .status = 200, .body = errJson(resp, "Unrecognised GraphQL operation") }; +} + +// ============================================================================ +// HTTP/1.1 connection handler +// ============================================================================ + +const Protocol = enum { rest, grpc, graphql }; + +fn handleConnection(conn: std.net.Server.Connection, proto: Protocol) void { + defer conn.stream.close(); + var in_buf: [MAX_CONN_BUF]u8 = undefined; + const n = conn.stream.read(&in_buf) catch return; + const req = in_buf[0..n]; + + var path: []const u8 = "/"; + var body: []const u8 = ""; + if (n > 4) { + const line_end = std.mem.indexOf(u8, req, "\r\n") orelse req.len; + const first_line = req[0..line_end]; + const sp1 = std.mem.indexOfScalar(u8, first_line, ' ') orelse 0; + const rest_of = first_line[sp1 + 1 ..]; + const sp2 = std.mem.indexOfScalar(u8, rest_of, ' ') orelse rest_of.len; + path = rest_of[0..sp2]; + const body_sep = std.mem.indexOf(u8, req, "\r\n\r\n") orelse n; + body = req[@min(body_sep + 4, n)..]; + } + + var resp_buf: [MAX_CONN_BUF]u8 = undefined; + const result = switch (proto) { + .rest => dispatchRest(path, body, &resp_buf), + .grpc => dispatchGrpc(path, body, &resp_buf), + .graphql => dispatchGraphql(body, &resp_buf), + }; + + const content_type = switch (proto) { + .rest => "application/json", + .grpc => "application/grpc+json", + .graphql => "application/json", + }; + + var hdr_buf: [256]u8 = undefined; + const hdr = std.fmt.bufPrint(&hdr_buf, + "HTTP/1.1 {d} OK\r\nContent-Type: {s}\r\nContent-Length: {d}\r\nConnection: close\r\n\r\n", + .{ result.status, content_type, result.body.len }, + ) catch return; + _ = conn.stream.writeAll(hdr) catch return; + _ = conn.stream.writeAll(result.body) catch return; +} + +fn listenLoop(port: u16, proto: Protocol) void { + const addr = std.net.Address.initIp4(.{ 127, 0, 0, 1 }, port) catch return; + var server = addr.listen(.{ .reuse_address = true }) catch return; + defer server.deinit(); + while (true) { + const conn = server.accept() catch continue; + handleConnection(conn, proto); + } +} + +pub fn main() !void { + ffi.airtable_init(); + const rest_thread = try std.Thread.spawn(.{}, listenLoop, .{ REST_PORT, Protocol.rest }); + const grpc_thread = try std.Thread.spawn(.{}, listenLoop, .{ GRPC_PORT, Protocol.grpc }); + const gql_thread = try std.Thread.spawn(.{}, listenLoop, .{ GQL_PORT, Protocol.graphql }); + rest_thread.join(); + grpc_thread.join(); + gql_thread.join(); +} diff --git a/cartridges/domains/productivity/airtable-mcp/adapter/build.zig b/cartridges/domains/productivity/airtable-mcp/adapter/build.zig new file mode 100644 index 0000000..27f045a --- /dev/null +++ b/cartridges/domains/productivity/airtable-mcp/adapter/build.zig @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// airtable-mcp/adapter/build.zig + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const ffi_mod = b.createModule(.{ + .root_source_file = b.path("../ffi/airtable_mcp_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + const adapter = b.addExecutable(.{ + .name = "airtable_adapter", + .root_source_file = b.path("airtable_adapter.zig"), + .target = target, + .optimize = optimize, + }); + adapter.root_module.addImport("airtable_mcp_ffi", ffi_mod); + b.installArtifact(adapter); + + const run_artifact = b.addRunArtifact(adapter); + const run_step = b.step("run", "Run the airtable-mcp adapter"); + run_step.dependOn(&run_artifact.step); + + const tests = b.addTest(.{ + .root_source_file = b.path("airtable_adapter.zig"), + .target = target, + .optimize = optimize, + }); + tests.root_module.addImport("airtable_mcp_ffi", ffi_mod); + const run_tests = b.addRunArtifact(tests); + const test_step = b.step("test", "Run airtable-mcp adapter tests"); + test_step.dependOn(&run_tests.step); +} diff --git a/cartridges/domains/productivity/airtable-mcp/benchmarks/quick-bench.sh b/cartridges/domains/productivity/airtable-mcp/benchmarks/quick-bench.sh new file mode 100755 index 0000000..96cc370 --- /dev/null +++ b/cartridges/domains/productivity/airtable-mcp/benchmarks/quick-bench.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Benchmarks for airtable-mcp cartridge. +set -euo pipefail + +echo "=== airtable-mcp benchmarks ===" + +cd "$(dirname "${BASH_SOURCE[0]}")/../ffi" + +# Build with ReleaseFast for benchmarking +zig build -Doptimize=ReleaseFast 2>&1 + +echo "Session connect/disconnect cycle (1000 iterations):" +time for i in $(seq 1 1000); do + true +done + +echo "" +echo "Benchmark placeholder -- implement real benchmarks in Zig test blocks" +echo "or via the zig adapter HTTP benchmark tool." diff --git a/cartridges/domains/productivity/airtable-mcp/cartridge.json b/cartridges/domains/productivity/airtable-mcp/cartridge.json new file mode 100644 index 0000000..d433142 --- /dev/null +++ b/cartridges/domains/productivity/airtable-mcp/cartridge.json @@ -0,0 +1,277 @@ +{ + "$schema": "https://boj.dev/schemas/cartridge/v1.json", + "spdx": "MPL-2.0", + "copyright": "Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath)", + "name": "airtable-mcp", + "version": "0.2.0", + "description": "Airtable cartridge -- base listing, table schema retrieval, record search, record creation, record update, field listing, view browsing, webhook management, comment access, and revision history via the Airtable REST API", + "domain": "Productivity", + "tier": "Ayo", + "protocols": [ + "MCP", + "REST" + ], + "auth": { + "method": "bearer_token", + "env_var": "AIRTABLE_API_KEY", + "credential_source": "vault-mcp" + }, + "api": { + "base_url": "https://api.airtable.com/v0", + "content_type": "application/json" + }, + "tools": [ + { + "name": "airtable_list_bases", + "description": "List all accessible Airtable bases with their names and permissions", + "inputSchema": { + "type": "object", + "properties": { + "offset": { + "type": "string", + "description": "Pagination offset token" + } + } + } + }, + { + "name": "airtable_get_base_schema", + "description": "Get the full schema of an Airtable base (tables, fields, views)", + "inputSchema": { + "type": "object", + "properties": { + "base_id": { + "type": "string", + "description": "Airtable base ID (e.g. 'appXXXXXXXXXXXXXX')" + } + }, + "required": [ + "base_id" + ] + } + }, + { + "name": "airtable_list_records", + "description": "List records from a table with optional filtering, sorting, and field selection", + "inputSchema": { + "type": "object", + "properties": { + "base_id": { + "type": "string", + "description": "Base ID" + }, + "table_name": { + "type": "string", + "description": "Table name or ID" + }, + "filter_formula": { + "type": "string", + "description": "Airtable formula for filtering (e.g. \"{Status} = 'Active'\")" + }, + "sort_field": { + "type": "string", + "description": "Field name to sort by" + }, + "sort_direction": { + "type": "string", + "description": "Sort direction: asc or desc" + }, + "max_records": { + "type": "number", + "description": "Maximum records to return (default 100)" + }, + "view": { + "type": "string", + "description": "View name or ID to use" + }, + "fields": { + "type": "string", + "description": "Comma-separated field names to include" + } + }, + "required": [ + "base_id", + "table_name" + ] + } + }, + { + "name": "airtable_get_record", + "description": "Get a single record by ID with all field values", + "inputSchema": { + "type": "object", + "properties": { + "base_id": { + "type": "string", + "description": "Base ID" + }, + "table_name": { + "type": "string", + "description": "Table name or ID" + }, + "record_id": { + "type": "string", + "description": "Record ID (e.g. 'recXXXXXXXXXXXXXX')" + } + }, + "required": [ + "base_id", + "table_name", + "record_id" + ] + } + }, + { + "name": "airtable_create_record", + "description": "Create a new record in a table with specified field values", + "inputSchema": { + "type": "object", + "properties": { + "base_id": { + "type": "string", + "description": "Base ID" + }, + "table_name": { + "type": "string", + "description": "Table name or ID" + }, + "fields": { + "type": "string", + "description": "JSON object of field name-value pairs" + } + }, + "required": [ + "base_id", + "table_name", + "fields" + ] + } + }, + { + "name": "airtable_update_record", + "description": "Update an existing record's field values (PATCH β€” only specified fields are updated)", + "inputSchema": { + "type": "object", + "properties": { + "base_id": { + "type": "string", + "description": "Base ID" + }, + "table_name": { + "type": "string", + "description": "Table name or ID" + }, + "record_id": { + "type": "string", + "description": "Record ID" + }, + "fields": { + "type": "string", + "description": "JSON object of field name-value pairs to update" + } + }, + "required": [ + "base_id", + "table_name", + "record_id", + "fields" + ] + } + }, + { + "name": "airtable_list_fields", + "description": "List all fields in a table with their types and configuration", + "inputSchema": { + "type": "object", + "properties": { + "base_id": { + "type": "string", + "description": "Base ID" + }, + "table_name": { + "type": "string", + "description": "Table name or ID" + } + }, + "required": [ + "base_id", + "table_name" + ] + } + }, + { + "name": "airtable_list_views", + "description": "List all views in a table (grid, kanban, gallery, form, calendar, timeline)", + "inputSchema": { + "type": "object", + "properties": { + "base_id": { + "type": "string", + "description": "Base ID" + }, + "table_name": { + "type": "string", + "description": "Table name or ID" + } + }, + "required": [ + "base_id", + "table_name" + ] + } + }, + { + "name": "airtable_list_webhooks", + "description": "List all webhooks configured for a base", + "inputSchema": { + "type": "object", + "properties": { + "base_id": { + "type": "string", + "description": "Base ID" + } + }, + "required": [ + "base_id" + ] + } + }, + { + "name": "airtable_get_comments", + "description": "Get comments on a specific record", + "inputSchema": { + "type": "object", + "properties": { + "base_id": { + "type": "string", + "description": "Base ID" + }, + "table_name": { + "type": "string", + "description": "Table name or ID" + }, + "record_id": { + "type": "string", + "description": "Record ID" + } + }, + "required": [ + "base_id", + "table_name", + "record_id" + ] + } + } + ], + "ffi": { + "so_path": "ffi/zig-out/lib/libairtable_mcp.so", + "abi_version": "ADR-0006", + "symbols": [ + "boj_cartridge_init", + "boj_cartridge_deinit", + "boj_cartridge_name", + "boj_cartridge_version", + "boj_cartridge_invoke" + ] + } +} diff --git a/cartridges/domains/productivity/airtable-mcp/ffi/README.adoc b/cartridges/domains/productivity/airtable-mcp/ffi/README.adoc new file mode 100644 index 0000000..fdd708c --- /dev/null +++ b/cartridges/domains/productivity/airtable-mcp/ffi/README.adoc @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += airtable-mcp / ffi β€” Zig FFI layer +:orientation: shallow + +== Purpose + +C-ABI exports bridging this cartridge's Idris2 ABI to the runtime. Consumed by +the adapter for protocol dispatch and by the trunk loader for mount. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” produces `zig-out/lib/libairtable.so`. +| `airtable_mcp_ffi.zig` | C-ABI exports (15 exports, 5 inline tests, 333 lines). +|=== + +== Invariants + +* **Null-pointer rejection** on `[*c]const u8` arguments before any + dereference. +* **Negative return values** carry documented error meanings; zero is + success (matching the trunk error-code convention). +* **No allocator leaks** β€” every allocation path pairs with `defer free`. + +== Test/proof surface + +5 inline `test "..."` block(s) in `airtable_mcp_ffi.zig`. Run with +`cd ffi && zig build test`. + +== Read-first + +. `airtable_mcp_ffi.zig` β€” C-ABI signatures and guards. +. Trunk `ffi/zig/src/catalogue.zig` for how this `.so` is loaded. diff --git a/cartridges/domains/productivity/airtable-mcp/ffi/airtable_mcp_ffi.zig b/cartridges/domains/productivity/airtable-mcp/ffi/airtable_mcp_ffi.zig new file mode 100644 index 0000000..54aa587 --- /dev/null +++ b/cartridges/domains/productivity/airtable-mcp/ffi/airtable_mcp_ffi.zig @@ -0,0 +1,442 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// airtable_mcp_ffi.zig β€” C-ABI FFI implementation for airtable-mcp cartridge. +// +// Implements the state machine defined in AirtableMcp.SafeRegistry (Idris2 ABI). +// State machine: Disconnected | Connected | RateLimited | Error +// Auth: Required Bearer token (personal access token). +// REST API: https://api.airtable.com/v0 +// Actions: ListBases, GetBaseSchema, ListRecords, GetRecord, +// CreateRecord, UpdateRecord, ListFields, ListViews, +// ListWebhooks, GetComments +// Thread-safe via std.Thread.Mutex. Fixed-size session pool, no heap allocations. + +const std = @import("std"); + +// --------------------------------------------------------------------------- +// State machine (matches Idris2 ABI SessionState exactly) +// --------------------------------------------------------------------------- + +pub const SessionState = enum(c_int) { + disconnected = 0, + connected = 1, + rate_limited = 2, + err = 3, +}; + +pub const AirtableAction = enum(c_int) { + list_bases = 0, + get_base_schema = 1, + list_records = 2, + get_record = 3, + create_record = 4, + update_record = 5, + list_fields = 6, + list_views = 7, + list_webhooks = 8, + get_comments = 9, +}; + +fn isValidTransition(from: SessionState, to: SessionState) bool { + return switch (from) { + .disconnected => to == .connected or to == .err, + .connected => to == .disconnected or to == .rate_limited or to == .err, + .rate_limited => to == .connected, + .err => to == .connected or to == .disconnected, + }; +} + +// --------------------------------------------------------------------------- +// Session slots (thread-safe, fixed-size pool) +// --------------------------------------------------------------------------- + +const MAX_SESSIONS: usize = 16; + +const SessionSlot = struct { + active: bool = false, + state: SessionState = .disconnected, + api_call_count: u64 = 0, + last_action: c_int = -1, + record_reads: u32 = 0, + record_writes: u32 = 0, + schema_queries: u32 = 0, + meta_queries: u32 = 0, +}; + +var sessions: [MAX_SESSIONS]SessionSlot = .{SessionSlot{}} ** MAX_SESSIONS; +var mutex: std.Thread.Mutex = .{}; + +// --------------------------------------------------------------------------- +// C-ABI exports β€” state machine +// --------------------------------------------------------------------------- + +pub export fn airtable_mcp_can_transition(from: c_int, to: c_int) c_int { + const f = std.meta.intToEnum(SessionState, from) catch return 0; + const t = std.meta.intToEnum(SessionState, to) catch return 0; + return if (isValidTransition(f, t)) 1 else 0; +} + +pub export fn airtable_mcp_connect(dummy: c_int) c_int { + _ = dummy; + mutex.lock(); + defer mutex.unlock(); + + for (&sessions, 0..) |*slot, idx| { + if (!slot.active) { + slot.active = true; + slot.state = .connected; + slot.api_call_count = 0; + slot.last_action = -1; + slot.record_reads = 0; + slot.record_writes = 0; + slot.schema_queries = 0; + slot.meta_queries = 0; + return @intCast(idx); + } + } + return -1; +} + +pub export fn airtable_mcp_disconnect(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + + sessions[idx] = SessionSlot{}; + return 0; +} + +pub export fn airtable_mcp_session_state(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + return @intFromEnum(slot.state); +} + +pub export fn airtable_mcp_throttle(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + if (!isValidTransition(slot.state, .rate_limited)) return -2; + + sessions[idx].state = .rate_limited; + return 0; +} + +pub export fn airtable_mcp_unthrottle(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + if (!isValidTransition(slot.state, .connected)) return -2; + + sessions[idx].state = .connected; + return 0; +} + +pub export fn airtable_mcp_signal_error(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + if (!isValidTransition(slot.state, .err)) return -2; + + sessions[idx].state = .err; + return 0; +} + +// --------------------------------------------------------------------------- +// C-ABI exports β€” action recording and metrics +// --------------------------------------------------------------------------- + +pub export fn airtable_mcp_record_call(slot_idx: c_int, action: c_int) c_int { + const act = std.meta.intToEnum(AirtableAction, action) catch return -3; + + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + if (slot.state != .connected) return -2; + + sessions[idx].api_call_count += 1; + sessions[idx].last_action = action; + + switch (act) { + .list_records, .get_record, .get_comments => sessions[idx].record_reads += 1, + .create_record, .update_record => sessions[idx].record_writes += 1, + .get_base_schema, .list_fields, .list_views => sessions[idx].schema_queries += 1, + .list_bases, .list_webhooks => sessions[idx].meta_queries += 1, + } + + return 0; +} + +pub export fn airtable_mcp_call_count(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + return @intCast(slot.api_call_count); +} + +pub export fn airtable_mcp_record_read_count(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + return @intCast(slot.record_reads); +} + +pub export fn airtable_mcp_record_write_count(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + return @intCast(slot.record_writes); +} + +pub export fn airtable_mcp_schema_query_count(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + return @intCast(slot.schema_queries); +} + +pub export fn airtable_mcp_meta_query_count(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + return @intCast(slot.meta_queries); +} + +pub export fn airtable_mcp_action_count() c_int { + return 10; +} + +pub export fn airtable_mcp_reset() void { + mutex.lock(); + defer mutex.unlock(); + sessions = .{SessionSlot{}} ** MAX_SESSIONS; +} + +// --------------------------------------------------------------------------- +// ═══════════════════════════════════════════════════════════════════════ +// Standard ABI (ADR-0005 four symbols + ADR-0006 invoke) +// ═══════════════════════════════════════════════════════════════════════ + +const shim = @import("cartridge_shim.zig"); + +const CARTRIDGE_NAME_PTR: [*:0]const u8 = "airtable-mcp"; +const CARTRIDGE_VERSION_PTR: [*:0]const u8 = "0.1.0"; + +export fn boj_cartridge_init() callconv(.c) c_int { + return 0; +} + +export fn boj_cartridge_deinit() callconv(.c) void {} + +export fn boj_cartridge_name() callconv(.c) [*:0]const u8 { + return CARTRIDGE_NAME_PTR; +} + +export fn boj_cartridge_version() callconv(.c) [*:0]const u8 { + return CARTRIDGE_VERSION_PTR; +} + +/// Dispatch the cartridge.json MCP tools. Grade D Alpha stubs. +export fn boj_cartridge_invoke( + tool_name: [*c]const u8, + json_args: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) callconv(.c) i32 { + _ = json_args; + if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; + + const body: []const u8 = if (shim.toolIs(tool_name, "airtable_list_bases")) + "{\"result\":{\"items\":[],\"count\":0,\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "airtable_get_base_schema")) + "{\"result\":{\"metadata\":{},\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "airtable_list_records")) + "{\"result\":{\"items\":[],\"count\":0,\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "airtable_get_record")) + "{\"result\":{\"metadata\":{},\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "airtable_create_record")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "airtable_update_record")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "airtable_list_fields")) + "{\"result\":{\"items\":[],\"count\":0,\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "airtable_list_views")) + "{\"result\":{\"items\":[],\"count\":0,\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "airtable_list_webhooks")) + "{\"result\":{\"items\":[],\"count\":0,\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "airtable_get_comments")) + "{\"result\":{\"metadata\":{},\"status\":\"stub\"}}" +else + return shim.RC_UNKNOWN_TOOL; + + return shim.writeResult(out_buf, in_out_len, body); +} + +// Tests +// --------------------------------------------------------------------------- + +test "connected session lifecycle" { + airtable_mcp_reset(); + + const slot = airtable_mcp_connect(0); + try std.testing.expect(slot >= 0); + try std.testing.expectEqual(@as(c_int, 1), airtable_mcp_session_state(slot)); + + try std.testing.expectEqual(@as(c_int, 0), airtable_mcp_record_call(slot, 2)); + try std.testing.expectEqual(@as(c_int, 1), airtable_mcp_call_count(slot)); + try std.testing.expectEqual(@as(c_int, 1), airtable_mcp_record_read_count(slot)); + + try std.testing.expectEqual(@as(c_int, 0), airtable_mcp_disconnect(slot)); +} + +test "requires connected state for actions" { + airtable_mcp_reset(); + + const slot = airtable_mcp_connect(0); + try std.testing.expect(slot >= 0); + + try std.testing.expectEqual(@as(c_int, 0), airtable_mcp_throttle(slot)); + try std.testing.expectEqual(@as(c_int, -2), airtable_mcp_record_call(slot, 0)); + + try std.testing.expectEqual(@as(c_int, 0), airtable_mcp_unthrottle(slot)); + try std.testing.expectEqual(@as(c_int, 0), airtable_mcp_record_call(slot, 0)); +} + +test "category counting" { + airtable_mcp_reset(); + + const slot = airtable_mcp_connect(0); + try std.testing.expect(slot >= 0); + + try std.testing.expectEqual(@as(c_int, 0), airtable_mcp_record_call(slot, 2)); // ListRecords + try std.testing.expectEqual(@as(c_int, 0), airtable_mcp_record_call(slot, 4)); // CreateRecord + try std.testing.expectEqual(@as(c_int, 0), airtable_mcp_record_call(slot, 1)); // GetBaseSchema + try std.testing.expectEqual(@as(c_int, 0), airtable_mcp_record_call(slot, 0)); // ListBases + + try std.testing.expectEqual(@as(c_int, 4), airtable_mcp_call_count(slot)); + try std.testing.expectEqual(@as(c_int, 1), airtable_mcp_record_read_count(slot)); + try std.testing.expectEqual(@as(c_int, 1), airtable_mcp_record_write_count(slot)); + try std.testing.expectEqual(@as(c_int, 1), airtable_mcp_schema_query_count(slot)); + try std.testing.expectEqual(@as(c_int, 1), airtable_mcp_meta_query_count(slot)); +} + +test "transition validator" { + try std.testing.expectEqual(@as(c_int, 1), airtable_mcp_can_transition(0, 1)); + try std.testing.expectEqual(@as(c_int, 1), airtable_mcp_can_transition(1, 0)); + try std.testing.expectEqual(@as(c_int, 1), airtable_mcp_can_transition(1, 2)); + try std.testing.expectEqual(@as(c_int, 1), airtable_mcp_can_transition(2, 1)); + try std.testing.expectEqual(@as(c_int, 0), airtable_mcp_can_transition(2, 3)); + try std.testing.expectEqual(@as(c_int, 0), airtable_mcp_can_transition(0, 2)); +} + +test "slot exhaustion" { + airtable_mcp_reset(); + + var slots: [MAX_SESSIONS]c_int = undefined; + for (&slots) |*s| { + s.* = airtable_mcp_connect(0); + try std.testing.expect(s.* >= 0); + } + + try std.testing.expectEqual(@as(c_int, -1), airtable_mcp_connect(0)); + + try std.testing.expectEqual(@as(c_int, 0), airtable_mcp_disconnect(slots[0])); + const new_slot = airtable_mcp_connect(0); + try std.testing.expect(new_slot >= 0); +} + +// ═══════════════════════════════════════════════════════════════════════ +// ADR-0006 invoke dispatch tests +// ═══════════════════════════════════════════════════════════════════════ + +test "boj_cartridge_name returns airtable-mcp" { + const n = std.mem.span(boj_cartridge_name()); + try std.testing.expectEqualStrings("airtable-mcp", n); +} + +test "boj_cartridge_init returns 0" { + try std.testing.expectEqual(@as(c_int, 0), boj_cartridge_init()); +} + +test "invoke: each declared tool succeeds" { + var buf: [256]u8 = undefined; + const tools = [_][]const u8{ + "airtable_list_bases", + "airtable_get_base_schema", + "airtable_list_records", + "airtable_get_record", + "airtable_create_record", + "airtable_update_record", + "airtable_list_fields", + "airtable_list_views", + "airtable_list_webhooks", + "airtable_get_comments", + }; + for (tools) |t| { + var len: usize = buf.len; + const rc = boj_cartridge_invoke(t.ptr, "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, 0), rc); + try std.testing.expect(std.mem.indexOf(u8, buf[0..len], "result") != null); + } +} + +test "invoke: unknown tool returns -1" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("nope", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -1), rc); +} + +test "invoke: buffer too small returns -3" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("airtable_list_bases", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -3), rc); + try std.testing.expect(len > 4); +} diff --git a/cartridges/domains/productivity/airtable-mcp/ffi/build.zig b/cartridges/domains/productivity/airtable-mcp/ffi/build.zig new file mode 100644 index 0000000..3d21609 --- /dev/null +++ b/cartridges/domains/productivity/airtable-mcp/ffi/build.zig @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + // Shared ADR-0006 invoke-shim module (relative path up to boj-server trunk). + + const shim_mod = b.addModule("cartridge_shim", .{ + + .root_source_file = b.path("../../../ffi/zig/src/cartridge_shim.zig"), + + .target = target, + + .optimize = optimize, + + }); + + const ffi_mod = b.addModule("airtable_mcp", .{ + .root_source_file = b.path("airtable_mcp_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + ffi_mod.addImport("cartridge_shim", shim_mod); + + const lib = b.addLibrary(.{ + .name = "airtable_mcp", + .root_module = ffi_mod, + .linkage = .dynamic, + }); + lib.linkLibC(); + b.installArtifact(lib); + + const tests = b.addTest(.{ .root_module = ffi_mod }); + tests.linkLibC(); + const run_tests = b.addRunArtifact(tests); + const test_step = b.step("test", "Run FFI tests"); + test_step.dependOn(&run_tests.step); +} + diff --git a/cartridges/domains/productivity/airtable-mcp/ffi/cartridge_shim.zig b/cartridges/domains/productivity/airtable-mcp/ffi/cartridge_shim.zig new file mode 100644 index 0000000..0e399db --- /dev/null +++ b/cartridges/domains/productivity/airtable-mcp/ffi/cartridge_shim.zig @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// cartridge_shim.zig β€” Shared helpers for the ADR-0006 five-symbol +// cartridge ABI (`boj_cartridge_init / deinit / name / version / invoke`). +// +// The shim centralises the seven-code return convention, NUL-argument +// guards, tool-name comparison, and the buffer-too-small path so each +// cartridge's `boj_cartridge_invoke` can stay short β€” typically a tool +// table plus `shim.writeResult(...)`. +// +// Cartridges import this file by relative path (no build-graph change +// needed). Example: +// +// const shim = @import("../../../ffi/zig/src/cartridge_shim.zig"); +// +// export fn boj_cartridge_invoke( +// tool_name: [*c]const u8, +// json_args: [*c]const u8, +// out_buf: [*c]u8, +// in_out_len: [*c]usize, +// ) callconv(.c) i32 { +// _ = json_args; +// if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; +// const body = if (shim.toolIs(tool_name, "foo")) "{\"result\":{}}" +// else return shim.RC_UNKNOWN_TOOL; +// return shim.writeResult(out_buf, in_out_len, body); +// } + +const std = @import("std"); + +// ── Return codes (ADR-0006 Β§Return codes) ──────────────────────────── +// +// Frozen by ADR-0006. New failure modes compose these via the error +// JSON body β€” the integer surface does not grow without a follow-up ADR. + +pub const RC_SUCCESS: i32 = 0; +pub const RC_UNKNOWN_TOOL: i32 = -1; +pub const RC_BAD_ARGS: i32 = -2; +pub const RC_BUFFER_TOO_SMALL: i32 = -3; +pub const RC_RUNTIME_ERROR: i32 = -4; +pub const RC_PANIC: i32 = -5; +pub const RC_AUTH_DENIED: i32 = -6; + +// ── Invoke-path helpers ────────────────────────────────────────────── + +/// True if any of the three mandatory `boj_cartridge_invoke` output-path +/// pointers is null. Use at the top of every invoke to short-circuit to +/// `RC_BAD_ARGS`. +pub fn invokeArgsNull( + tool_name: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) bool { + return tool_name == null or out_buf == null or in_out_len == null; +} + +/// Compare a C-NUL-terminated tool-name pointer against a Zig string +/// literal. Caller must have already verified `tool_name` is non-null +/// (usually via `invokeArgsNull`). +pub fn toolIs(tool_name: [*c]const u8, expected: []const u8) bool { + const s = std.mem.span(@as([*:0]const u8, @ptrCast(tool_name))); + return std.mem.eql(u8, s, expected); +} + +/// Copy `body` into `out_buf[0..*in_out_len]` (as a capacity) and update +/// `*in_out_len` to the number of bytes written. Returns `RC_SUCCESS`. +/// +/// If `body.len` exceeds the current capacity stored in `*in_out_len`, +/// sets `*in_out_len` to the required size and returns +/// `RC_BUFFER_TOO_SMALL` β€” the caller is then expected to re-allocate +/// and retry, per ADR-0006 Β§Memory ownership. +/// +/// Caller must have already verified that `out_buf` and `in_out_len` +/// are non-null. +pub fn writeResult( + out_buf: [*c]u8, + in_out_len: [*c]usize, + body: []const u8, +) i32 { + const cap = in_out_len.*; + if (body.len > cap) { + in_out_len.* = body.len; + return RC_BUFFER_TOO_SMALL; + } + @memcpy(out_buf[0..body.len], body); + in_out_len.* = body.len; + return RC_SUCCESS; +} + +// ── Tests ──────────────────────────────────────────────────────────── + +test "writeResult: body fits, writes and sets length" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); + try std.testing.expectEqualStrings("hello", buf[0..len]); +} + +test "writeResult: too small returns -3 and sets required length" { + var buf: [2]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_BUFFER_TOO_SMALL, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: exact-fit succeeds" { + var buf: [5]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: empty body" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, ""); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 0), len); +} + +test "toolIs: matches and rejects" { + const name: [*:0]const u8 = "foo"; + try std.testing.expect(toolIs(@ptrCast(name), "foo")); + try std.testing.expect(!toolIs(@ptrCast(name), "bar")); + try std.testing.expect(!toolIs(@ptrCast(name), "foobar")); + try std.testing.expect(!toolIs(@ptrCast(name), "fo")); +} + +test "invokeArgsNull: detects each null slot" { + var buf: [4]u8 = undefined; + var len: usize = 4; + const name: [*:0]const u8 = "x"; + try std.testing.expect(!invokeArgsNull(@ptrCast(name), &buf, &len)); + try std.testing.expect(invokeArgsNull(null, &buf, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), null, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), &buf, null)); +} diff --git a/cartridges/domains/productivity/airtable-mcp/minter.toml b/cartridges/domains/productivity/airtable-mcp/minter.toml new file mode 100644 index 0000000..bc065fc --- /dev/null +++ b/cartridges/domains/productivity/airtable-mcp/minter.toml @@ -0,0 +1,23 @@ +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> + +name = "airtable-mcp" +description = "Airtable cartridge β€” base listing, schema retrieval, record CRUD, field listing, view browsing, webhooks, comments" +version = "0.2.0" +domain = "Productivity" +protocols = [ + "MCP", + "REST", +] +tier = "Ayo" +backend = "universal" +generate_panel = true + +[auth] +method = "bearer_token" +credential_source = "vault-mcp" +fields = ["airtable_api_key"] + +[api] +base_url = "https://api.airtable.com/v0" +local_only = false diff --git a/cartridges/domains/productivity/airtable-mcp/mod.js b/cartridges/domains/productivity/airtable-mcp/mod.js new file mode 100644 index 0000000..a06970b --- /dev/null +++ b/cartridges/domains/productivity/airtable-mcp/mod.js @@ -0,0 +1,248 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// airtable-mcp/mod.js -- Airtable cartridge implementation. +// +// Provides MCP tool handlers for the Airtable REST API: +// - Base listing +// - Table schema retrieval +// - Record listing with filtering and sorting +// - Single record retrieval +// - Record creation +// - Record update (PATCH) +// - Field listing +// - View browsing +// - Webhook management +// - Record comment access +// +// Auth: Bearer token via AIRTABLE_API_KEY (personal access token, required). +// API docs: https://airtable.com/developers/web/api/introduction +// +// Usage: import { handleTool } from "./mod.js"; +// or: deno run --allow-net --allow-env mod.js + +const API_BASE = "https://api.airtable.com/v0"; +const META_API_BASE = "https://api.airtable.com/v0/meta"; + +// --------------------------------------------------------------------------- +// Auth helper β€” retrieves the Airtable personal access token from environment. +// In production, vault-mcp provides zero-knowledge credential proxying. +// --------------------------------------------------------------------------- + +function getToken() { + const token = typeof Deno !== "undefined" + ? Deno.env.get("AIRTABLE_API_KEY") + : process.env.AIRTABLE_API_KEY; + return token || null; +} + +// --------------------------------------------------------------------------- +// HTTP request helper β€” wraps fetch with Airtable API headers, +// bearer auth, and error normalization. +// --------------------------------------------------------------------------- + +async function airtableFetch(path, queryParams, method, body, useMeta) { + const base = useMeta ? META_API_BASE : API_BASE; + const url = new URL(`${base}${path}`); + + if (queryParams) { + for (const [key, value] of Object.entries(queryParams)) { + if (value !== undefined && value !== null && value !== "") { + if (Array.isArray(value)) { + for (const v of value) url.searchParams.append(key, v); + } else { + url.searchParams.set(key, String(value)); + } + } + } + } + + const headers = { + "Accept": "application/json", + "User-Agent": "boj-server/airtable-mcp/0.2.0", + }; + + const token = getToken(); + if (!token) { + return { status: 401, error: "AIRTABLE_API_KEY not set." }; + } + headers["Authorization"] = `Bearer ${token}`; + + const fetchOpts = { method: method || "GET", headers }; + + if (body) { + headers["Content-Type"] = "application/json"; + fetchOpts.body = JSON.stringify(body); + } + + const response = await fetch(url.toString(), fetchOpts); + + if (response.status === 429) { + const retryAfter = response.headers.get("retry-after"); + return { + status: 429, + error: `Rate limited. Retry after ${retryAfter || "30"} seconds.`, + retryAfter, + }; + } + + const data = await response.json().catch(() => ({})); + + if (!response.ok) { + const errorMessage = data.error?.message || data.message || `HTTP ${response.status}`; + return { status: response.status, error: errorMessage, data }; + } + + return { status: response.status, data }; +} + +// --------------------------------------------------------------------------- +// Tool handler dispatch β€” maps MCP tool names to Airtable API operations. +// --------------------------------------------------------------------------- + +export async function handleTool(toolName, args) { + switch (toolName) { + + // --- Base listing --- + + case "airtable_list_bases": { + const params = {}; + if (args.offset) params.offset = args.offset; + return airtableFetch("/bases", params, "GET", null, true); + } + + // --- Schema --- + + case "airtable_get_base_schema": { + if (!args.base_id) return { error: "Missing required field: base_id" }; + return airtableFetch(`/bases/${encodeURIComponent(args.base_id)}/tables`, null, "GET", null, true); + } + + // --- Record listing --- + + case "airtable_list_records": { + if (!args.base_id) return { error: "Missing required field: base_id" }; + if (!args.table_name) return { error: "Missing required field: table_name" }; + const params = {}; + if (args.filter_formula) params.filterByFormula = args.filter_formula; + if (args.max_records) params.maxRecords = args.max_records; + if (args.view) params.view = args.view; + if (args.sort_field) { + params["sort[0][field]"] = args.sort_field; + params["sort[0][direction]"] = args.sort_direction || "asc"; + } + if (args.fields) { + const fieldList = args.fields.split(",").map((f) => f.trim()); + for (let i = 0; i < fieldList.length; i++) { + params[`fields[${i}]`] = fieldList[i]; + } + } + return airtableFetch(`/${encodeURIComponent(args.base_id)}/${encodeURIComponent(args.table_name)}`, params); + } + + // --- Single record --- + + case "airtable_get_record": { + if (!args.base_id) return { error: "Missing required field: base_id" }; + if (!args.table_name) return { error: "Missing required field: table_name" }; + if (!args.record_id) return { error: "Missing required field: record_id" }; + return airtableFetch( + `/${encodeURIComponent(args.base_id)}/${encodeURIComponent(args.table_name)}/${encodeURIComponent(args.record_id)}`, + ); + } + + // --- Create record --- + + case "airtable_create_record": { + if (!args.base_id) return { error: "Missing required field: base_id" }; + if (!args.table_name) return { error: "Missing required field: table_name" }; + if (!args.fields) return { error: "Missing required field: fields" }; + const fields = typeof args.fields === "string" ? JSON.parse(args.fields) : args.fields; + return airtableFetch( + `/${encodeURIComponent(args.base_id)}/${encodeURIComponent(args.table_name)}`, + null, + "POST", + { fields }, + ); + } + + // --- Update record --- + + case "airtable_update_record": { + if (!args.base_id) return { error: "Missing required field: base_id" }; + if (!args.table_name) return { error: "Missing required field: table_name" }; + if (!args.record_id) return { error: "Missing required field: record_id" }; + if (!args.fields) return { error: "Missing required field: fields" }; + const fields = typeof args.fields === "string" ? JSON.parse(args.fields) : args.fields; + return airtableFetch( + `/${encodeURIComponent(args.base_id)}/${encodeURIComponent(args.table_name)}/${encodeURIComponent(args.record_id)}`, + null, + "PATCH", + { fields }, + ); + } + + // --- Fields --- + + case "airtable_list_fields": { + if (!args.base_id) return { error: "Missing required field: base_id" }; + if (!args.table_name) return { error: "Missing required field: table_name" }; + const result = await airtableFetch(`/bases/${encodeURIComponent(args.base_id)}/tables`, null, "GET", null, true); + if (result.error) return result; + const table = (result.data.tables || []).find( + (t) => t.name === args.table_name || t.id === args.table_name, + ); + if (!table) return { error: `Table '${args.table_name}' not found in base` }; + return { status: result.status, data: { fields: table.fields || [] } }; + } + + // --- Views --- + + case "airtable_list_views": { + if (!args.base_id) return { error: "Missing required field: base_id" }; + if (!args.table_name) return { error: "Missing required field: table_name" }; + const result = await airtableFetch(`/bases/${encodeURIComponent(args.base_id)}/tables`, null, "GET", null, true); + if (result.error) return result; + const table = (result.data.tables || []).find( + (t) => t.name === args.table_name || t.id === args.table_name, + ); + if (!table) return { error: `Table '${args.table_name}' not found in base` }; + return { status: result.status, data: { views: table.views || [] } }; + } + + // --- Webhooks --- + + case "airtable_list_webhooks": { + if (!args.base_id) return { error: "Missing required field: base_id" }; + return airtableFetch(`/bases/${encodeURIComponent(args.base_id)}/webhooks`, null, "GET", null, true); + } + + // --- Comments --- + + case "airtable_get_comments": { + if (!args.base_id) return { error: "Missing required field: base_id" }; + if (!args.table_name) return { error: "Missing required field: table_name" }; + if (!args.record_id) return { error: "Missing required field: record_id" }; + return airtableFetch( + `/${encodeURIComponent(args.base_id)}/${encodeURIComponent(args.table_name)}/${encodeURIComponent(args.record_id)}/comments`, + ); + } + + default: + return { error: `Unknown airtable-mcp tool: ${toolName}` }; + } +} + +// --------------------------------------------------------------------------- +// Cartridge metadata export β€” used by the BoJ cartridge loader to register +// this cartridge's tools without reading cartridge.json separately. +// --------------------------------------------------------------------------- + +export const metadata = { + name: "airtable-mcp", + version: "0.2.0", + domain: "Productivity", + tier: "Ayo", + protocols: ["MCP", "REST"], + toolCount: 10, +}; diff --git a/cartridges/domains/productivity/airtable-mcp/panels/manifest.json b/cartridges/domains/productivity/airtable-mcp/panels/manifest.json new file mode 100644 index 0000000..c842c9c --- /dev/null +++ b/cartridges/domains/productivity/airtable-mcp/panels/manifest.json @@ -0,0 +1,90 @@ +{ + "$schema": "https://panll.dev/schemas/panel-manifest/v1.json", + "spdx": "MPL-2.0", + "copyright": "Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath)", + "cartridge": "airtable-mcp", + "domain": "Productivity", + "version": "0.2.0", + "panels": [ + { + "id": "airtable-connection-status", + "title": "Connection Status", + "description": "Airtable session state (Disconnected / Connected / RateLimited / Error)", + "type": "status-indicator", + "data_source": { + "endpoint": "/airtable/state", + "method": "GET", + "refresh_interval_ms": 5000 + }, + "widgets": [ + { + "type": "state-badge", + "field": "state", + "states": { + "disconnected": { "color": "#95a5a6", "icon": "wifi-off" }, + "connected": { "color": "#2ecc71", "icon": "wifi" }, + "rate_limited": { "color": "#f39c12", "icon": "clock" }, + "error": { "color": "#e74c3c", "icon": "alert-triangle" } + } + } + ] + }, + { + "id": "airtable-record-reads", + "title": "Record Reads", + "description": "Number of record read operations in the current session", + "type": "metric", + "data_source": { + "endpoint": "/airtable/status", + "method": "GET", + "refresh_interval_ms": 10000 + }, + "widgets": [ + { + "type": "counter", + "field": "record_reads", + "label": "Record Reads", + "icon": "database" + } + ] + }, + { + "id": "airtable-record-writes", + "title": "Record Writes", + "description": "Number of record create/update operations in the current session", + "type": "metric", + "data_source": { + "endpoint": "/airtable/status", + "method": "GET", + "refresh_interval_ms": 10000 + }, + "widgets": [ + { + "type": "counter", + "field": "record_writes", + "label": "Record Writes", + "icon": "edit" + } + ] + }, + { + "id": "airtable-schema-queries", + "title": "Schema Queries", + "description": "Number of schema/field/view queries in the current session", + "type": "metric", + "data_source": { + "endpoint": "/airtable/status", + "method": "GET", + "refresh_interval_ms": 10000 + }, + "widgets": [ + { + "type": "counter", + "field": "schema_queries", + "label": "Schema Queries", + "icon": "columns" + } + ] + } + ] +} diff --git a/cartridges/domains/productivity/airtable-mcp/tests/integration_test.sh b/cartridges/domains/productivity/airtable-mcp/tests/integration_test.sh new file mode 100755 index 0000000..7225d8f --- /dev/null +++ b/cartridges/domains/productivity/airtable-mcp/tests/integration_test.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Integration tests for airtable-mcp cartridge. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CART_DIR="$(dirname "$SCRIPT_DIR")" +FFI_DIR="$CART_DIR/ffi" + +echo "=== airtable-mcp integration tests ===" + +# Build FFI +echo "[1/3] Building FFI..." +cd "$FFI_DIR" && zig build 2>&1 + +# Run Zig unit tests +echo "[2/3] Running FFI unit tests..." +cd "$FFI_DIR" && zig build test 2>&1 + +# Validate Idris2 ABI (if idris2 available) +echo "[3/3] Checking ABI..." +if command -v idris2 &>/dev/null; then + cd "$CART_DIR/abi" && idris2 --check AirtableMcp.SafeRegistry 2>&1 + echo " ABI: OK" +else + echo " ABI: SKIPPED (idris2 not in PATH)" +fi + +echo "" +echo "All tests passed for airtable-mcp!" diff --git a/cartridges/domains/productivity/google-docs-mcp/README.adoc b/cartridges/domains/productivity/google-docs-mcp/README.adoc new file mode 100644 index 0000000..160fd74 --- /dev/null +++ b/cartridges/domains/productivity/google-docs-mcp/README.adoc @@ -0,0 +1,89 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += google-docs-mcp +Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk> +:spdx: MPL-2.0 +:tier: Ayo +:domain: Productivity +:protocols: MCP, REST + +== Overview + +Google Docs cartridge for the BoJ server. Provides type-safe access to the +Google Docs API v1 for document retrieval, plain-text content extraction, +heading extraction, text search within documents, comment listing, +suggestion browsing, revision history, named range access, document +creation, and text insertion. + +Connects to the Google Docs API at `docs.googleapis.com`. OAuth2 bearer +token auth is always required. + +=== State Machine + +`Disconnected -> Connected` (authenticated via OAuth2 token) + +`Connected -> RateLimited -> Connected` (normal flow) + +`Connected -> Error -> Disconnected` (error recovery) + +=== Actions (10) + +[cols="1,1"] +|=== +| Category | Actions + +| Document Reads +| GetDocument, GetContent, GetHeadings, SearchContent, GetNamedRanges + +| Document Writes +| CreateDocument, InsertText + +| Comments +| ListComments, ListSuggestions + +| Revisions +| GetRevisions +|=== + +== Architecture + +[cols="1,1,2"] +|=== +| Layer | Language | Purpose + +| ABI +| Idris2 +| Formally verified state machine with dependent-type proofs (GoogleDocsMcp.SafeRegistry) + +| FFI +| Zig +| C-compatible implementation with thread-safe session pool, action recording, category counting + +| Adapter +| zig +| REST bridge to BoJ unified adapter protocol +|=== + +== Building + +[source,bash] +---- +# Build FFI shared library +cd ffi && zig build + +# Run FFI tests +cd ffi && zig build test + +# Type-check ABI +cd abi && idris2 --check GoogleDocsMcp.SafeRegistry +---- + +== Prerequisites + +1. Create a Google Cloud project with the Docs API enabled. +2. Generate an OAuth2 access token with `documents.readonly` (or `documents`) scope. +3. Set `GOOGLE_DOCS_ACCESS_TOKEN` to the token value. + +== Status + +Development -- customised with Google Docs-specific heading extraction, in-document search, comments/suggestions, and batchUpdate text insertion. diff --git a/cartridges/domains/productivity/google-docs-mcp/abi/GoogleDocsMcp/SafeRegistry.idr b/cartridges/domains/productivity/google-docs-mcp/abi/GoogleDocsMcp/SafeRegistry.idr new file mode 100644 index 0000000..41d7cd3 --- /dev/null +++ b/cartridges/domains/productivity/google-docs-mcp/abi/GoogleDocsMcp/SafeRegistry.idr @@ -0,0 +1,179 @@ +-- SPDX-License-Identifier: MPL-2.0 +-- Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +-- +-- GoogleDocsMcp.SafeRegistry β€” Type-safe ABI for google-docs-mcp cartridge. +-- +-- Dependent-type state machine governing Google Docs API v1 access. +-- Encodes mandatory OAuth2 auth, document retrieval, content reading, +-- text search, heading extraction, comment listing, suggestion browsing, +-- revision history, named range access, document creation, and text +-- insertion as compile-time invariants. +-- REST API: https://docs.googleapis.com/v1 +-- No unsafe escape hatches. + +module GoogleDocsMcp.SafeRegistry + +%default total + +-- --------------------------------------------------------------------------- +-- Authentication state machine +-- --------------------------------------------------------------------------- + +||| Session state for Google Docs MCP operations. +||| Disconnected: no OAuth2 token configured. +||| Connected: authenticated and ready for API calls. +||| RateLimited: rate limit hit; must wait before retrying. +||| Error: unrecoverable error (expired token, permission denied). +public export +data SessionState + = Disconnected + | Connected + | RateLimited + | Error + +||| Proof that a state transition is valid. +||| Google Docs requires OAuth2 auth β€” no anonymous access. +public export +data ValidTransition : SessionState -> SessionState -> Type where + Connect : ValidTransition Disconnected Connected + Disconnect : ValidTransition Connected Disconnected + Throttle : ValidTransition Connected RateLimited + Unthrottle : ValidTransition RateLimited Connected + ConnectError : ValidTransition Connected Error + DisconnError : ValidTransition Disconnected Error + RecoverConnect : ValidTransition Error Connected + RecoverDisconn : ValidTransition Error Disconnected + +-- --------------------------------------------------------------------------- +-- C-ABI integer encoding +-- --------------------------------------------------------------------------- + +||| Encode session state as C-compatible integer for FFI boundary. +export +sessionStateToInt : SessionState -> Int +sessionStateToInt Disconnected = 0 +sessionStateToInt Connected = 1 +sessionStateToInt RateLimited = 2 +sessionStateToInt Error = 3 + +||| Decode integer back to session state. Returns Nothing for out-of-range. +export +intToSessionState : Int -> Maybe SessionState +intToSessionState 0 = Just Disconnected +intToSessionState 1 = Just Connected +intToSessionState 2 = Just RateLimited +intToSessionState 3 = Just Error +intToSessionState _ = Nothing + +||| Check if a state transition is valid (C-ABI export). +export +google_docs_mcp_can_transition : Int -> Int -> Int +google_docs_mcp_can_transition from to = + case (intToSessionState from, intToSessionState to) of + (Just Disconnected, Just Connected) => 1 + (Just Connected, Just Disconnected) => 1 + (Just Connected, Just RateLimited) => 1 + (Just RateLimited, Just Connected) => 1 + (Just Connected, Just Error) => 1 + (Just Disconnected, Just Error) => 1 + (Just Error, Just Connected) => 1 + (Just Error, Just Disconnected) => 1 + _ => 0 + +-- --------------------------------------------------------------------------- +-- Google Docs actions +-- --------------------------------------------------------------------------- + +||| Actions available through the Google Docs MCP cartridge. +||| Grouped: GetDocument, GetContent, GetHeadings, SearchContent, +||| ListComments, ListSuggestions, GetRevisions, GetNamedRanges, +||| CreateDocument, InsertText. +public export +data GoogleDocsAction + = GetDocument + | GetContent + | GetHeadings + | SearchContent + | ListComments + | ListSuggestions + | GetRevisions + | GetNamedRanges + | CreateDocument + | InsertText + +||| Whether an action requires Connected state. +||| All Google Docs operations require an active authenticated session. +export +actionRequiresAuth : GoogleDocsAction -> Bool +actionRequiresAuth _ = True + +||| Whether an action is a write/mutating operation. +||| CreateDocument and InsertText are mutating; all others are read-only. +export +actionIsMutating : GoogleDocsAction -> Bool +actionIsMutating CreateDocument = True +actionIsMutating InsertText = True +actionIsMutating _ = False + +||| Encode action as C-compatible integer for FFI. +export +actionToInt : GoogleDocsAction -> Int +actionToInt GetDocument = 0 +actionToInt GetContent = 1 +actionToInt GetHeadings = 2 +actionToInt SearchContent = 3 +actionToInt ListComments = 4 +actionToInt ListSuggestions = 5 +actionToInt GetRevisions = 6 +actionToInt GetNamedRanges = 7 +actionToInt CreateDocument = 8 +actionToInt InsertText = 9 + +||| Decode integer to Google Docs action. +export +intToAction : Int -> Maybe GoogleDocsAction +intToAction 0 = Just GetDocument +intToAction 1 = Just GetContent +intToAction 2 = Just GetHeadings +intToAction 3 = Just SearchContent +intToAction 4 = Just ListComments +intToAction 5 = Just ListSuggestions +intToAction 6 = Just GetRevisions +intToAction 7 = Just GetNamedRanges +intToAction 8 = Just CreateDocument +intToAction 9 = Just InsertText +intToAction _ = Nothing + +-- --------------------------------------------------------------------------- +-- MCP tool declarations +-- --------------------------------------------------------------------------- + +||| Tools exposed via MCP protocol for this cartridge. +public export +data McpTool + = ToolGetDocument + | ToolGetContent + | ToolGetHeadings + | ToolSearchContent + | ToolListComments + | ToolListSuggestions + | ToolGetRevisions + | ToolGetNamedRanges + | ToolCreateDocument + | ToolInsertText + +||| Check if a tool requires a connected session. +||| All Google Docs tools require an active authenticated connection. +export +toolRequiresSession : McpTool -> Bool +toolRequiresSession _ = True + +||| Total tool count for this cartridge. +export +toolCount : Nat +toolCount = 10 + +||| Total action count for this cartridge. +export +actionCount : Nat +actionCount = 10 diff --git a/cartridges/domains/productivity/google-docs-mcp/abi/README.adoc b/cartridges/domains/productivity/google-docs-mcp/abi/README.adoc new file mode 100644 index 0000000..13b7cf1 --- /dev/null +++ b/cartridges/domains/productivity/google-docs-mcp/abi/README.adoc @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += google-docs-mcp / abi β€” Idris2 ABI layer +:orientation: shallow + +== Purpose + +Cartridge-local protocol types for `google-docs-mcp`. Defines the data, tool, and +transition vocabulary this cartridge speaks. Inherits safety lemmas and the +matrix-cell identifier scheme from the trunk ABI at `src/abi/`. + +== Files + +1 Idris2 module(s), ~179 lines total. Lead module: +`SafeRegistry.idr`. + +No `.ipkg` β€” consumed directly by wider BoJ callers; type-checks inside the +trunk `boj.ipkg`. + +== Invariants + +* `%default total` at every module top. +* Zero `believe_me`, `assert_total`, `postulate`, `Admitted`, or + `sorry` (the trunk holds the four declared `believe_me` sites; leaf + cartridges carry none). + +== Test/proof surface + +Thin module(s), no inline tests. Proof surface = type-check success under the +trunk `idris2 --check`. + +== Read-first + +. `SafeRegistry.idr` β€” cartridge-local data records and enums. +. Trunk `src/abi/Boj/Catalogue.idr` for how this cell participates in the + 2D matrix. diff --git a/cartridges/domains/productivity/google-docs-mcp/adapter/README.adoc b/cartridges/domains/productivity/google-docs-mcp/adapter/README.adoc new file mode 100644 index 0000000..f305acb --- /dev/null +++ b/cartridges/domains/productivity/google-docs-mcp/adapter/README.adoc @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += google-docs-mcp / adapter β€” protocol bridge +:orientation: shallow + +== Purpose + +Exposes this cartridge's FFI over the wire protocols declared in +`cartridge.json`. Dispatches tool invocations to the C-ABI exports at +`../ffi/` and formats responses per protocol. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” depends on the sibling `ffi` target. +| `google_docs_adapter.zig` | Protocol dispatch (204 lines). Tool catalogue matches `cartridge.json`. +| `SIDELINED-*.v.adoc` | zig predecessor(s). Not built β€” zig banned 2026-04-10. +|=== + +== Invariants + +* **Stateless** β€” all state lives behind the FFI, never in the adapter. +* **Response passthrough** β€” whatever the FFI returns goes back to the wire + unmodified (no embellishment, no silent recovery). +* **Cartridge.json is source of truth** for the tool catalogue; drift between + adapter and manifest is a CI failure. + +== Test/proof surface + +No inline tests at the adapter layer β€” protocol routing is exercised by the +trunk seam tests at `ffi/zig/src/seams.zig`. + +== Read-first + +. `google_docs_adapter.zig` β€” dispatch function and per-protocol routers. +. `../cartridge.json` β€” the tool manifest the dispatcher mirrors. diff --git a/cartridges/domains/productivity/google-docs-mcp/adapter/build.zig b/cartridges/domains/productivity/google-docs-mcp/adapter/build.zig new file mode 100644 index 0000000..f50113a --- /dev/null +++ b/cartridges/domains/productivity/google-docs-mcp/adapter/build.zig @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// google-docs-mcp/adapter/build.zig + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const ffi_mod = b.createModule(.{ + .root_source_file = b.path("../ffi/google_docs_mcp_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + const adapter = b.addExecutable(.{ + .name = "google_docs_adapter", + .root_source_file = b.path("google_docs_adapter.zig"), + .target = target, + .optimize = optimize, + }); + adapter.root_module.addImport("google_docs_mcp_ffi", ffi_mod); + b.installArtifact(adapter); + + const run_artifact = b.addRunArtifact(adapter); + const run_step = b.step("run", "Run the google-docs-mcp adapter"); + run_step.dependOn(&run_artifact.step); + + const tests = b.addTest(.{ + .root_source_file = b.path("google_docs_adapter.zig"), + .target = target, + .optimize = optimize, + }); + tests.root_module.addImport("google_docs_mcp_ffi", ffi_mod); + const run_tests = b.addRunArtifact(tests); + const test_step = b.step("test", "Run google-docs-mcp adapter tests"); + test_step.dependOn(&run_tests.step); +} diff --git a/cartridges/domains/productivity/google-docs-mcp/adapter/google_docs_adapter.zig b/cartridges/domains/productivity/google-docs-mcp/adapter/google_docs_adapter.zig new file mode 100644 index 0000000..8d506f8 --- /dev/null +++ b/cartridges/domains/productivity/google-docs-mcp/adapter/google_docs_adapter.zig @@ -0,0 +1,204 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// google-docs-mcp/adapter/google_docs_adapter.zig -- Unified three-protocol adapter. +// +// Replaces the banned google_docs_adapter.v (zig, removed 2026-04-12). +// +// Bridges the Zig FFI (google_docs_mcp_ffi.zig) to three network protocols: +// REST :9052 POST /tools/<tool> +// gRPC-compat :9053 /GoogleDocsMcpService/<Method> +// GraphQL :9054 POST /graphql { query: "..." } +// +// Google Docs API: read, search, comments, suggestions, revisions +// Tools: +// gdocs_get_document +// gdocs_get_content +// gdocs_get_headings +// gdocs_search_content +// gdocs_list_comments +// gdocs_list_suggestions +// gdocs_get_revisions +// gdocs_get_named_ranges +// gdocs_create_document +// gdocs_insert_text + +const std = @import("std"); +const ffi = @import("google_docs_mcp_ffi"); + +const REST_PORT: u16 = 9052; +const GRPC_PORT: u16 = 9053; +const GQL_PORT: u16 = 9054; + +const MAX_CONN_BUF: usize = 16 * 1024; + +// ============================================================================ +// JSON response builders +// ============================================================================ + +fn okJson(buf: []u8, msg: []const u8) []u8 { + return std.fmt.bufPrint(buf, + \{{"success":true,"message":"{}"}} + , .{msg}) catch buf[0..0]; +} + +fn errJson(buf: []u8, msg: []const u8) []u8 { + return std.fmt.bufPrint(buf, + \{{"success":false,"error":"{}"}} + , .{msg}) catch buf[0..0]; +} + +fn statusJson(buf: []u8) []u8 { + return std.fmt.bufPrint(buf, + \{{"success":true,"state":"ready","service":"google-docs-mcp"}} + , .{}) catch buf[0..0]; +} + +// ============================================================================ +// Tool dispatcher +// ============================================================================ + +const Response = struct { status: u16, body: []u8 }; + +fn dispatch(tool: []const u8, body: []const u8, resp: []u8) Response { + _ = body; + if (std.mem.eql(u8, tool, "gdocs_get_document")) return .{ .status = 200, .body = okJson(resp, "gdocs_get_document forwarded to backend") }; + if (std.mem.eql(u8, tool, "gdocs_get_content")) return .{ .status = 200, .body = okJson(resp, "gdocs_get_content forwarded to backend") }; + if (std.mem.eql(u8, tool, "gdocs_get_headings")) return .{ .status = 200, .body = okJson(resp, "gdocs_get_headings forwarded to backend") }; + if (std.mem.eql(u8, tool, "gdocs_search_content")) return .{ .status = 200, .body = okJson(resp, "gdocs_search_content forwarded to backend") }; + if (std.mem.eql(u8, tool, "gdocs_list_comments")) return .{ .status = 200, .body = okJson(resp, "gdocs_list_comments forwarded to backend") }; + if (std.mem.eql(u8, tool, "gdocs_list_suggestions")) return .{ .status = 200, .body = okJson(resp, "gdocs_list_suggestions forwarded to backend") }; + if (std.mem.eql(u8, tool, "gdocs_get_revisions")) return .{ .status = 200, .body = okJson(resp, "gdocs_get_revisions forwarded to backend") }; + if (std.mem.eql(u8, tool, "gdocs_get_named_ranges")) return .{ .status = 200, .body = okJson(resp, "gdocs_get_named_ranges forwarded to backend") }; + if (std.mem.eql(u8, tool, "gdocs_create_document")) return .{ .status = 200, .body = okJson(resp, "gdocs_create_document forwarded to backend") }; + if (std.mem.eql(u8, tool, "gdocs_insert_text")) return .{ .status = 200, .body = okJson(resp, "gdocs_insert_text forwarded to backend") }; + if (std.mem.eql(u8, tool, "status") or std.mem.eql(u8, tool, "health")) + return .{ .status = 200, .body = statusJson(resp) }; + return .{ .status = 404, .body = errJson(resp, "Unknown tool") }; +} + +// ============================================================================ +// REST handler +// ============================================================================ + +fn dispatchRest(path: []const u8, body: []const u8, resp: []u8) Response { + if (std.mem.startsWith(u8, path, "/tools/")) { + return dispatch(path["/tools/".len..], body, resp); + } + if (std.mem.eql(u8, path, "/status") or std.mem.eql(u8, path, "/health")) { + return .{ .status = 200, .body = statusJson(resp) }; + } + return .{ .status = 404, .body = errJson(resp, "Not found") }; +} + +// ============================================================================ +// gRPC-compat handler +// ============================================================================ + +fn dispatchGrpc(path: []const u8, body: []const u8, resp: []u8) Response { + const prefix = "/GoogleDocsMcpService/"; + if (!std.mem.startsWith(u8, path, prefix)) + return .{ .status = 404, .body = errJson(resp, "Not a recognized gRPC path") }; + const method = path[prefix.len..]; + const tool = blk: { + if (std.mem.eql(u8, method, "GdocsGetDocument")) break :blk "gdocs_get_document"; + if (std.mem.eql(u8, method, "GdocsGetContent")) break :blk "gdocs_get_content"; + if (std.mem.eql(u8, method, "GdocsGetHeadings")) break :blk "gdocs_get_headings"; + if (std.mem.eql(u8, method, "GdocsSearchContent")) break :blk "gdocs_search_content"; + if (std.mem.eql(u8, method, "GdocsListComments")) break :blk "gdocs_list_comments"; + if (std.mem.eql(u8, method, "GdocsListSuggestions")) break :blk "gdocs_list_suggestions"; + if (std.mem.eql(u8, method, "GdocsGetRevisions")) break :blk "gdocs_get_revisions"; + if (std.mem.eql(u8, method, "GdocsGetNamedRanges")) break :blk "gdocs_get_named_ranges"; + if (std.mem.eql(u8, method, "GdocsCreateDocument")) break :blk "gdocs_create_document"; + if (std.mem.eql(u8, method, "GdocsInsertText")) break :blk "gdocs_insert_text"; + return .{ .status = 404, .body = errJson(resp, "Unknown gRPC method") }; + }; + return dispatch(tool, body, resp); +} + +// ============================================================================ +// GraphQL handler +// ============================================================================ + +fn dispatchGraphql(body: []const u8, resp: []u8) Response { + if (std.mem.indexOf(u8, body, "__schema") != null) + return .{ .status = 200, .body = okJson(resp, "schema introspection not yet supported") }; + if (std.mem.indexOf(u8, body, "get_document") != null) return dispatch("gdocs_get_document", body, resp); + if (std.mem.indexOf(u8, body, "get_content") != null) return dispatch("gdocs_get_content", body, resp); + if (std.mem.indexOf(u8, body, "get_headings") != null) return dispatch("gdocs_get_headings", body, resp); + if (std.mem.indexOf(u8, body, "search_content") != null) return dispatch("gdocs_search_content", body, resp); + if (std.mem.indexOf(u8, body, "list_comments") != null) return dispatch("gdocs_list_comments", body, resp); + if (std.mem.indexOf(u8, body, "list_suggestions") != null) return dispatch("gdocs_list_suggestions", body, resp); + if (std.mem.indexOf(u8, body, "get_revisions") != null) return dispatch("gdocs_get_revisions", body, resp); + if (std.mem.indexOf(u8, body, "get_named_ranges") != null) return dispatch("gdocs_get_named_ranges", body, resp); + if (std.mem.indexOf(u8, body, "create_document") != null) return dispatch("gdocs_create_document", body, resp); + if (std.mem.indexOf(u8, body, "insert_text") != null) return dispatch("gdocs_insert_text", body, resp); + return .{ .status = 200, .body = errJson(resp, "Unrecognised GraphQL operation") }; +} + +// ============================================================================ +// HTTP/1.1 connection handler +// ============================================================================ + +const Protocol = enum { rest, grpc, graphql }; + +fn handleConnection(conn: std.net.Server.Connection, proto: Protocol) void { + defer conn.stream.close(); + var in_buf: [MAX_CONN_BUF]u8 = undefined; + const n = conn.stream.read(&in_buf) catch return; + const req = in_buf[0..n]; + + var path: []const u8 = "/"; + var body: []const u8 = ""; + if (n > 4) { + const line_end = std.mem.indexOf(u8, req, "\r\n") orelse req.len; + const first_line = req[0..line_end]; + const sp1 = std.mem.indexOfScalar(u8, first_line, ' ') orelse 0; + const rest_of = first_line[sp1 + 1 ..]; + const sp2 = std.mem.indexOfScalar(u8, rest_of, ' ') orelse rest_of.len; + path = rest_of[0..sp2]; + const body_sep = std.mem.indexOf(u8, req, "\r\n\r\n") orelse n; + body = req[@min(body_sep + 4, n)..]; + } + + var resp_buf: [MAX_CONN_BUF]u8 = undefined; + const result = switch (proto) { + .rest => dispatchRest(path, body, &resp_buf), + .grpc => dispatchGrpc(path, body, &resp_buf), + .graphql => dispatchGraphql(body, &resp_buf), + }; + + const content_type = switch (proto) { + .rest => "application/json", + .grpc => "application/grpc+json", + .graphql => "application/json", + }; + + var hdr_buf: [256]u8 = undefined; + const hdr = std.fmt.bufPrint(&hdr_buf, + "HTTP/1.1 {d} OK\r\nContent-Type: {s}\r\nContent-Length: {d}\r\nConnection: close\r\n\r\n", + .{ result.status, content_type, result.body.len }, + ) catch return; + _ = conn.stream.writeAll(hdr) catch return; + _ = conn.stream.writeAll(result.body) catch return; +} + +fn listenLoop(port: u16, proto: Protocol) void { + const addr = std.net.Address.initIp4(.{ 127, 0, 0, 1 }, port) catch return; + var server = addr.listen(.{ .reuse_address = true }) catch return; + defer server.deinit(); + while (true) { + const conn = server.accept() catch continue; + handleConnection(conn, proto); + } +} + +pub fn main() !void { + ffi.google_docs_init(); + const rest_thread = try std.Thread.spawn(.{}, listenLoop, .{ REST_PORT, Protocol.rest }); + const grpc_thread = try std.Thread.spawn(.{}, listenLoop, .{ GRPC_PORT, Protocol.grpc }); + const gql_thread = try std.Thread.spawn(.{}, listenLoop, .{ GQL_PORT, Protocol.graphql }); + rest_thread.join(); + grpc_thread.join(); + gql_thread.join(); +} diff --git a/cartridges/domains/productivity/google-docs-mcp/benchmarks/quick-bench.sh b/cartridges/domains/productivity/google-docs-mcp/benchmarks/quick-bench.sh new file mode 100755 index 0000000..01bae92 --- /dev/null +++ b/cartridges/domains/productivity/google-docs-mcp/benchmarks/quick-bench.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Benchmarks for google-docs-mcp cartridge. +set -euo pipefail + +echo "=== google-docs-mcp benchmarks ===" + +cd "$(dirname "${BASH_SOURCE[0]}")/../ffi" + +# Build with ReleaseFast for benchmarking +zig build -Doptimize=ReleaseFast 2>&1 + +echo "Session connect/disconnect cycle (1000 iterations):" +time for i in $(seq 1 1000); do + true +done + +echo "" +echo "Benchmark placeholder -- implement real benchmarks in Zig test blocks" +echo "or via the zig adapter HTTP benchmark tool." diff --git a/cartridges/domains/productivity/google-docs-mcp/cartridge.json b/cartridges/domains/productivity/google-docs-mcp/cartridge.json new file mode 100644 index 0000000..a39e2c4 --- /dev/null +++ b/cartridges/domains/productivity/google-docs-mcp/cartridge.json @@ -0,0 +1,223 @@ +{ + "$schema": "https://boj.dev/schemas/cartridge/v1.json", + "spdx": "MPL-2.0", + "copyright": "Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath)", + "name": "google-docs-mcp", + "version": "0.2.0", + "description": "Google Docs cartridge -- document retrieval, content reading, text search, heading extraction, comment listing, suggestion browsing, revision history, named range access, document creation, and text insertion via the Google Docs API v1", + "domain": "Productivity", + "tier": "Ayo", + "protocols": [ + "MCP", + "REST" + ], + "auth": { + "method": "oauth2", + "env_var": "GOOGLE_DOCS_ACCESS_TOKEN", + "credential_source": "vault-mcp", + "scopes": [ + "https://www.googleapis.com/auth/documents.readonly", + "https://www.googleapis.com/auth/documents" + ] + }, + "api": { + "base_url": "https://docs.googleapis.com/v1", + "content_type": "application/json" + }, + "tools": [ + { + "name": "gdocs_get_document", + "description": "Get full document metadata and structure by document ID", + "inputSchema": { + "type": "object", + "properties": { + "document_id": { + "type": "string", + "description": "Google Docs document ID (from URL)" + } + }, + "required": [ + "document_id" + ] + } + }, + { + "name": "gdocs_get_content", + "description": "Get the plain-text content of a Google Doc", + "inputSchema": { + "type": "object", + "properties": { + "document_id": { + "type": "string", + "description": "Document ID" + } + }, + "required": [ + "document_id" + ] + } + }, + { + "name": "gdocs_get_headings", + "description": "Extract all headings from a Google Doc as a table of contents", + "inputSchema": { + "type": "object", + "properties": { + "document_id": { + "type": "string", + "description": "Document ID" + } + }, + "required": [ + "document_id" + ] + } + }, + { + "name": "gdocs_search_content", + "description": "Search for text within a Google Doc and return matching paragraphs with context", + "inputSchema": { + "type": "object", + "properties": { + "document_id": { + "type": "string", + "description": "Document ID" + }, + "query": { + "type": "string", + "description": "Text to search for" + } + }, + "required": [ + "document_id", + "query" + ] + } + }, + { + "name": "gdocs_list_comments", + "description": "List all comments on a Google Doc (via Drive API comments endpoint)", + "inputSchema": { + "type": "object", + "properties": { + "document_id": { + "type": "string", + "description": "Document ID" + }, + "include_resolved": { + "type": "boolean", + "description": "Include resolved comments (default false)" + } + }, + "required": [ + "document_id" + ] + } + }, + { + "name": "gdocs_list_suggestions", + "description": "List all pending suggestions (suggested edits) in a Google Doc", + "inputSchema": { + "type": "object", + "properties": { + "document_id": { + "type": "string", + "description": "Document ID" + } + }, + "required": [ + "document_id" + ] + } + }, + { + "name": "gdocs_get_revisions", + "description": "Get revision history for a Google Doc (via Drive API revisions endpoint)", + "inputSchema": { + "type": "object", + "properties": { + "document_id": { + "type": "string", + "description": "Document ID" + }, + "limit": { + "type": "number", + "description": "Max revisions to return (default 10)" + } + }, + "required": [ + "document_id" + ] + } + }, + { + "name": "gdocs_get_named_ranges", + "description": "Get all named ranges defined in a Google Doc", + "inputSchema": { + "type": "object", + "properties": { + "document_id": { + "type": "string", + "description": "Document ID" + } + }, + "required": [ + "document_id" + ] + } + }, + { + "name": "gdocs_create_document", + "description": "Create a new blank Google Doc with a title", + "inputSchema": { + "type": "object", + "properties": { + "title": { + "type": "string", + "description": "Document title" + } + }, + "required": [ + "title" + ] + } + }, + { + "name": "gdocs_insert_text", + "description": "Insert text at a specified index in a Google Doc", + "inputSchema": { + "type": "object", + "properties": { + "document_id": { + "type": "string", + "description": "Document ID" + }, + "text": { + "type": "string", + "description": "Text to insert" + }, + "index": { + "type": "number", + "description": "Character index to insert at (1 = start of body)" + } + }, + "required": [ + "document_id", + "text", + "index" + ] + } + } + ], + "ffi": { + "so_path": "ffi/zig-out/lib/libgoogle_docs_mcp.so", + "abi_version": "ADR-0006", + "symbols": [ + "boj_cartridge_init", + "boj_cartridge_deinit", + "boj_cartridge_name", + "boj_cartridge_version", + "boj_cartridge_invoke" + ] + } +} diff --git a/cartridges/domains/productivity/google-docs-mcp/ffi/README.adoc b/cartridges/domains/productivity/google-docs-mcp/ffi/README.adoc new file mode 100644 index 0000000..7d8b334 --- /dev/null +++ b/cartridges/domains/productivity/google-docs-mcp/ffi/README.adoc @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += google-docs-mcp / ffi β€” Zig FFI layer +:orientation: shallow + +== Purpose + +C-ABI exports bridging this cartridge's Idris2 ABI to the runtime. Consumed by +the adapter for protocol dispatch and by the trunk loader for mount. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” produces `zig-out/lib/libgoogle-docs.so`. +| `google_docs_mcp_ffi.zig` | C-ABI exports (15 exports, 5 inline tests, 354 lines). +|=== + +== Invariants + +* **Null-pointer rejection** on `[*c]const u8` arguments before any + dereference. +* **Negative return values** carry documented error meanings; zero is + success (matching the trunk error-code convention). +* **No allocator leaks** β€” every allocation path pairs with `defer free`. + +== Test/proof surface + +5 inline `test "..."` block(s) in `google_docs_mcp_ffi.zig`. Run with +`cd ffi && zig build test`. + +== Read-first + +. `google_docs_mcp_ffi.zig` β€” C-ABI signatures and guards. +. Trunk `ffi/zig/src/catalogue.zig` for how this `.so` is loaded. diff --git a/cartridges/domains/productivity/google-docs-mcp/ffi/build.zig b/cartridges/domains/productivity/google-docs-mcp/ffi/build.zig new file mode 100644 index 0000000..7f077d7 --- /dev/null +++ b/cartridges/domains/productivity/google-docs-mcp/ffi/build.zig @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + // Shared ADR-0006 invoke-shim module (relative path up to boj-server trunk). + + const shim_mod = b.addModule("cartridge_shim", .{ + + .root_source_file = b.path("../../../ffi/zig/src/cartridge_shim.zig"), + + .target = target, + + .optimize = optimize, + + }); + + const ffi_mod = b.addModule("google_docs_mcp", .{ + .root_source_file = b.path("google_docs_mcp_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + ffi_mod.addImport("cartridge_shim", shim_mod); + + const lib = b.addLibrary(.{ + .name = "google_docs_mcp", + .root_module = ffi_mod, + .linkage = .dynamic, + }); + lib.linkLibC(); + b.installArtifact(lib); + + const tests = b.addTest(.{ .root_module = ffi_mod }); + tests.linkLibC(); + const run_tests = b.addRunArtifact(tests); + const test_step = b.step("test", "Run FFI tests"); + test_step.dependOn(&run_tests.step); +} + diff --git a/cartridges/domains/productivity/google-docs-mcp/ffi/cartridge_shim.zig b/cartridges/domains/productivity/google-docs-mcp/ffi/cartridge_shim.zig new file mode 100644 index 0000000..0e399db --- /dev/null +++ b/cartridges/domains/productivity/google-docs-mcp/ffi/cartridge_shim.zig @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// cartridge_shim.zig β€” Shared helpers for the ADR-0006 five-symbol +// cartridge ABI (`boj_cartridge_init / deinit / name / version / invoke`). +// +// The shim centralises the seven-code return convention, NUL-argument +// guards, tool-name comparison, and the buffer-too-small path so each +// cartridge's `boj_cartridge_invoke` can stay short β€” typically a tool +// table plus `shim.writeResult(...)`. +// +// Cartridges import this file by relative path (no build-graph change +// needed). Example: +// +// const shim = @import("../../../ffi/zig/src/cartridge_shim.zig"); +// +// export fn boj_cartridge_invoke( +// tool_name: [*c]const u8, +// json_args: [*c]const u8, +// out_buf: [*c]u8, +// in_out_len: [*c]usize, +// ) callconv(.c) i32 { +// _ = json_args; +// if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; +// const body = if (shim.toolIs(tool_name, "foo")) "{\"result\":{}}" +// else return shim.RC_UNKNOWN_TOOL; +// return shim.writeResult(out_buf, in_out_len, body); +// } + +const std = @import("std"); + +// ── Return codes (ADR-0006 Β§Return codes) ──────────────────────────── +// +// Frozen by ADR-0006. New failure modes compose these via the error +// JSON body β€” the integer surface does not grow without a follow-up ADR. + +pub const RC_SUCCESS: i32 = 0; +pub const RC_UNKNOWN_TOOL: i32 = -1; +pub const RC_BAD_ARGS: i32 = -2; +pub const RC_BUFFER_TOO_SMALL: i32 = -3; +pub const RC_RUNTIME_ERROR: i32 = -4; +pub const RC_PANIC: i32 = -5; +pub const RC_AUTH_DENIED: i32 = -6; + +// ── Invoke-path helpers ────────────────────────────────────────────── + +/// True if any of the three mandatory `boj_cartridge_invoke` output-path +/// pointers is null. Use at the top of every invoke to short-circuit to +/// `RC_BAD_ARGS`. +pub fn invokeArgsNull( + tool_name: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) bool { + return tool_name == null or out_buf == null or in_out_len == null; +} + +/// Compare a C-NUL-terminated tool-name pointer against a Zig string +/// literal. Caller must have already verified `tool_name` is non-null +/// (usually via `invokeArgsNull`). +pub fn toolIs(tool_name: [*c]const u8, expected: []const u8) bool { + const s = std.mem.span(@as([*:0]const u8, @ptrCast(tool_name))); + return std.mem.eql(u8, s, expected); +} + +/// Copy `body` into `out_buf[0..*in_out_len]` (as a capacity) and update +/// `*in_out_len` to the number of bytes written. Returns `RC_SUCCESS`. +/// +/// If `body.len` exceeds the current capacity stored in `*in_out_len`, +/// sets `*in_out_len` to the required size and returns +/// `RC_BUFFER_TOO_SMALL` β€” the caller is then expected to re-allocate +/// and retry, per ADR-0006 Β§Memory ownership. +/// +/// Caller must have already verified that `out_buf` and `in_out_len` +/// are non-null. +pub fn writeResult( + out_buf: [*c]u8, + in_out_len: [*c]usize, + body: []const u8, +) i32 { + const cap = in_out_len.*; + if (body.len > cap) { + in_out_len.* = body.len; + return RC_BUFFER_TOO_SMALL; + } + @memcpy(out_buf[0..body.len], body); + in_out_len.* = body.len; + return RC_SUCCESS; +} + +// ── Tests ──────────────────────────────────────────────────────────── + +test "writeResult: body fits, writes and sets length" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); + try std.testing.expectEqualStrings("hello", buf[0..len]); +} + +test "writeResult: too small returns -3 and sets required length" { + var buf: [2]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_BUFFER_TOO_SMALL, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: exact-fit succeeds" { + var buf: [5]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: empty body" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, ""); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 0), len); +} + +test "toolIs: matches and rejects" { + const name: [*:0]const u8 = "foo"; + try std.testing.expect(toolIs(@ptrCast(name), "foo")); + try std.testing.expect(!toolIs(@ptrCast(name), "bar")); + try std.testing.expect(!toolIs(@ptrCast(name), "foobar")); + try std.testing.expect(!toolIs(@ptrCast(name), "fo")); +} + +test "invokeArgsNull: detects each null slot" { + var buf: [4]u8 = undefined; + var len: usize = 4; + const name: [*:0]const u8 = "x"; + try std.testing.expect(!invokeArgsNull(@ptrCast(name), &buf, &len)); + try std.testing.expect(invokeArgsNull(null, &buf, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), null, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), &buf, null)); +} diff --git a/cartridges/domains/productivity/google-docs-mcp/ffi/google_docs_mcp_ffi.zig b/cartridges/domains/productivity/google-docs-mcp/ffi/google_docs_mcp_ffi.zig new file mode 100644 index 0000000..2398194 --- /dev/null +++ b/cartridges/domains/productivity/google-docs-mcp/ffi/google_docs_mcp_ffi.zig @@ -0,0 +1,463 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// google_docs_mcp_ffi.zig β€” C-ABI FFI implementation for google-docs-mcp cartridge. +// +// Implements the state machine defined in GoogleDocsMcp.SafeRegistry (Idris2 ABI). +// State machine: Disconnected | Connected | RateLimited | Error +// Auth: Required OAuth2 Bearer token β€” Google Docs API is always gated. +// REST API: https://docs.googleapis.com/v1 +// Actions: GetDocument, GetContent, GetHeadings, SearchContent, +// ListComments, ListSuggestions, GetRevisions, GetNamedRanges, +// CreateDocument, InsertText +// Thread-safe via std.Thread.Mutex. Fixed-size session pool, no heap allocations. + +const std = @import("std"); + +// --------------------------------------------------------------------------- +// State machine (matches Idris2 ABI SessionState exactly) +// --------------------------------------------------------------------------- + +/// Session connection/lifecycle state. +/// 0 = Disconnected, 1 = Connected, 2 = RateLimited, 3 = Error. +pub const SessionState = enum(c_int) { + disconnected = 0, + connected = 1, + rate_limited = 2, + err = 3, +}; + +/// Google Docs action identifiers matching Idris2 GoogleDocsAction encoding. +pub const GoogleDocsAction = enum(c_int) { + get_document = 0, + get_content = 1, + get_headings = 2, + search_content = 3, + list_comments = 4, + list_suggestions = 5, + get_revisions = 6, + get_named_ranges = 7, + create_document = 8, + insert_text = 9, +}; + +/// Check valid state transitions per the Idris2 ValidTransition proof. +fn isValidTransition(from: SessionState, to: SessionState) bool { + return switch (from) { + .disconnected => to == .connected or to == .err, + .connected => to == .disconnected or to == .rate_limited or to == .err, + .rate_limited => to == .connected, + .err => to == .connected or to == .disconnected, + }; +} + +// --------------------------------------------------------------------------- +// Session slots (thread-safe, fixed-size pool) +// --------------------------------------------------------------------------- + +const MAX_SESSIONS: usize = 16; + +const SessionSlot = struct { + active: bool = false, + state: SessionState = .disconnected, + api_call_count: u64 = 0, + last_action: c_int = -1, + doc_reads: u32 = 0, + doc_writes: u32 = 0, + comment_queries: u32 = 0, + revision_queries: u32 = 0, +}; + +var sessions: [MAX_SESSIONS]SessionSlot = .{SessionSlot{}} ** MAX_SESSIONS; +var mutex: std.Thread.Mutex = .{}; + +// --------------------------------------------------------------------------- +// C-ABI exports β€” state machine +// --------------------------------------------------------------------------- + +/// Check if a state transition is valid. Returns 1 (valid) or 0 (invalid). +pub export fn google_docs_mcp_can_transition(from: c_int, to: c_int) c_int { + const f = std.meta.intToEnum(SessionState, from) catch return 0; + const t = std.meta.intToEnum(SessionState, to) catch return 0; + return if (isValidTransition(f, t)) 1 else 0; +} + +/// Connect (authenticated session). Returns slot index (>= 0) or error (< 0). +pub export fn google_docs_mcp_connect(dummy: c_int) c_int { + _ = dummy; + mutex.lock(); + defer mutex.unlock(); + + for (&sessions, 0..) |*slot, idx| { + if (!slot.active) { + slot.active = true; + slot.state = .connected; + slot.api_call_count = 0; + slot.last_action = -1; + slot.doc_reads = 0; + slot.doc_writes = 0; + slot.comment_queries = 0; + slot.revision_queries = 0; + return @intCast(idx); + } + } + return -1; +} + +/// Disconnect. Returns 0 on success. +pub export fn google_docs_mcp_disconnect(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + + sessions[idx] = SessionSlot{}; + return 0; +} + +/// Get current state of a session. Returns state int or -1 if invalid. +pub export fn google_docs_mcp_session_state(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + return @intFromEnum(slot.state); +} + +/// Signal rate limiting on a session. Returns 0 on success. +pub export fn google_docs_mcp_throttle(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + if (!isValidTransition(slot.state, .rate_limited)) return -2; + + sessions[idx].state = .rate_limited; + return 0; +} + +/// Clear rate limiting. Returns 0 on success. +pub export fn google_docs_mcp_unthrottle(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + if (!isValidTransition(slot.state, .connected)) return -2; + + sessions[idx].state = .connected; + return 0; +} + +/// Signal an error on a session. Returns 0 on success. +pub export fn google_docs_mcp_signal_error(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + if (!isValidTransition(slot.state, .err)) return -2; + + sessions[idx].state = .err; + return 0; +} + +// --------------------------------------------------------------------------- +// C-ABI exports β€” action recording and metrics +// --------------------------------------------------------------------------- + +/// Record an API call on a session. Returns 0 on success. +pub export fn google_docs_mcp_record_call(slot_idx: c_int, action: c_int) c_int { + const act = std.meta.intToEnum(GoogleDocsAction, action) catch return -3; + + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + if (slot.state != .connected) return -2; + + sessions[idx].api_call_count += 1; + sessions[idx].last_action = action; + + switch (act) { + .get_document, .get_content, .get_headings, .search_content, .get_named_ranges => sessions[idx].doc_reads += 1, + .create_document, .insert_text => sessions[idx].doc_writes += 1, + .list_comments, .list_suggestions => sessions[idx].comment_queries += 1, + .get_revisions => sessions[idx].revision_queries += 1, + } + + return 0; +} + +/// Get API call count for a session. +pub export fn google_docs_mcp_call_count(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + return @intCast(slot.api_call_count); +} + +/// Get document read count. +pub export fn google_docs_mcp_doc_read_count(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + return @intCast(slot.doc_reads); +} + +/// Get document write count. +pub export fn google_docs_mcp_doc_write_count(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + return @intCast(slot.doc_writes); +} + +/// Get comment query count. +pub export fn google_docs_mcp_comment_query_count(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + return @intCast(slot.comment_queries); +} + +/// Get revision query count. +pub export fn google_docs_mcp_revision_query_count(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + return @intCast(slot.revision_queries); +} + +/// Get total action count. Always returns 10. +pub export fn google_docs_mcp_action_count() c_int { + return 10; +} + +/// Reset all sessions (test/debug use only). +pub export fn google_docs_mcp_reset() void { + mutex.lock(); + defer mutex.unlock(); + sessions = .{SessionSlot{}} ** MAX_SESSIONS; +} + +// --------------------------------------------------------------------------- +// ═══════════════════════════════════════════════════════════════════════ +// Standard ABI (ADR-0005 four symbols + ADR-0006 invoke) +// ═══════════════════════════════════════════════════════════════════════ + +const shim = @import("cartridge_shim.zig"); + +const CARTRIDGE_NAME_PTR: [*:0]const u8 = "google-docs-mcp"; +const CARTRIDGE_VERSION_PTR: [*:0]const u8 = "0.1.0"; + +export fn boj_cartridge_init() callconv(.c) c_int { + return 0; +} + +export fn boj_cartridge_deinit() callconv(.c) void {} + +export fn boj_cartridge_name() callconv(.c) [*:0]const u8 { + return CARTRIDGE_NAME_PTR; +} + +export fn boj_cartridge_version() callconv(.c) [*:0]const u8 { + return CARTRIDGE_VERSION_PTR; +} + +/// Dispatch the cartridge.json MCP tools. Grade D Alpha stubs. +export fn boj_cartridge_invoke( + tool_name: [*c]const u8, + json_args: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) callconv(.c) i32 { + _ = json_args; + if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; + + const body: []const u8 = if (shim.toolIs(tool_name, "gdocs_get_document")) + "{\"result\":{\"metadata\":{},\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "gdocs_get_content")) + "{\"result\":{\"metadata\":{},\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "gdocs_get_headings")) + "{\"result\":{\"metadata\":{},\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "gdocs_search_content")) + "{\"result\":{\"matches\":[],\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "gdocs_list_comments")) + "{\"result\":{\"items\":[],\"count\":0,\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "gdocs_list_suggestions")) + "{\"result\":{\"items\":[],\"count\":0,\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "gdocs_get_revisions")) + "{\"result\":{\"metadata\":{},\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "gdocs_get_named_ranges")) + "{\"result\":{\"metadata\":{},\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "gdocs_create_document")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "gdocs_insert_text")) + "{\"result\":{\"status\":\"stub\"}}" +else + return shim.RC_UNKNOWN_TOOL; + + return shim.writeResult(out_buf, in_out_len, body); +} + +// Tests +// --------------------------------------------------------------------------- + +test "connected session lifecycle" { + google_docs_mcp_reset(); + + const slot = google_docs_mcp_connect(0); + try std.testing.expect(slot >= 0); + try std.testing.expectEqual(@as(c_int, 1), google_docs_mcp_session_state(slot)); + + try std.testing.expectEqual(@as(c_int, 0), google_docs_mcp_record_call(slot, 0)); + try std.testing.expectEqual(@as(c_int, 1), google_docs_mcp_call_count(slot)); + try std.testing.expectEqual(@as(c_int, 1), google_docs_mcp_doc_read_count(slot)); + + try std.testing.expectEqual(@as(c_int, 0), google_docs_mcp_disconnect(slot)); +} + +test "requires connected state for actions" { + google_docs_mcp_reset(); + + const slot = google_docs_mcp_connect(0); + try std.testing.expect(slot >= 0); + + try std.testing.expectEqual(@as(c_int, 0), google_docs_mcp_throttle(slot)); + try std.testing.expectEqual(@as(c_int, -2), google_docs_mcp_record_call(slot, 0)); + + try std.testing.expectEqual(@as(c_int, 0), google_docs_mcp_unthrottle(slot)); + try std.testing.expectEqual(@as(c_int, 0), google_docs_mcp_record_call(slot, 0)); +} + +test "category counting" { + google_docs_mcp_reset(); + + const slot = google_docs_mcp_connect(0); + try std.testing.expect(slot >= 0); + + try std.testing.expectEqual(@as(c_int, 0), google_docs_mcp_record_call(slot, 0)); // GetDocument + try std.testing.expectEqual(@as(c_int, 0), google_docs_mcp_record_call(slot, 8)); // CreateDocument + try std.testing.expectEqual(@as(c_int, 0), google_docs_mcp_record_call(slot, 4)); // ListComments + try std.testing.expectEqual(@as(c_int, 0), google_docs_mcp_record_call(slot, 6)); // GetRevisions + + try std.testing.expectEqual(@as(c_int, 4), google_docs_mcp_call_count(slot)); + try std.testing.expectEqual(@as(c_int, 1), google_docs_mcp_doc_read_count(slot)); + try std.testing.expectEqual(@as(c_int, 1), google_docs_mcp_doc_write_count(slot)); + try std.testing.expectEqual(@as(c_int, 1), google_docs_mcp_comment_query_count(slot)); + try std.testing.expectEqual(@as(c_int, 1), google_docs_mcp_revision_query_count(slot)); +} + +test "transition validator" { + try std.testing.expectEqual(@as(c_int, 1), google_docs_mcp_can_transition(0, 1)); + try std.testing.expectEqual(@as(c_int, 1), google_docs_mcp_can_transition(1, 0)); + try std.testing.expectEqual(@as(c_int, 1), google_docs_mcp_can_transition(1, 2)); + try std.testing.expectEqual(@as(c_int, 1), google_docs_mcp_can_transition(2, 1)); + try std.testing.expectEqual(@as(c_int, 1), google_docs_mcp_can_transition(1, 3)); + try std.testing.expectEqual(@as(c_int, 1), google_docs_mcp_can_transition(3, 0)); + try std.testing.expectEqual(@as(c_int, 0), google_docs_mcp_can_transition(2, 3)); + try std.testing.expectEqual(@as(c_int, 0), google_docs_mcp_can_transition(0, 2)); +} + +test "slot exhaustion" { + google_docs_mcp_reset(); + + var slots: [MAX_SESSIONS]c_int = undefined; + for (&slots) |*s| { + s.* = google_docs_mcp_connect(0); + try std.testing.expect(s.* >= 0); + } + + try std.testing.expectEqual(@as(c_int, -1), google_docs_mcp_connect(0)); + + try std.testing.expectEqual(@as(c_int, 0), google_docs_mcp_disconnect(slots[0])); + const new_slot = google_docs_mcp_connect(0); + try std.testing.expect(new_slot >= 0); +} + +// ═══════════════════════════════════════════════════════════════════════ +// ADR-0006 invoke dispatch tests +// ═══════════════════════════════════════════════════════════════════════ + +test "boj_cartridge_name returns google-docs-mcp" { + const n = std.mem.span(boj_cartridge_name()); + try std.testing.expectEqualStrings("google-docs-mcp", n); +} + +test "boj_cartridge_init returns 0" { + try std.testing.expectEqual(@as(c_int, 0), boj_cartridge_init()); +} + +test "invoke: each declared tool succeeds" { + var buf: [256]u8 = undefined; + const tools = [_][]const u8{ + "gdocs_get_document", + "gdocs_get_content", + "gdocs_get_headings", + "gdocs_search_content", + "gdocs_list_comments", + "gdocs_list_suggestions", + "gdocs_get_revisions", + "gdocs_get_named_ranges", + "gdocs_create_document", + "gdocs_insert_text", + }; + for (tools) |t| { + var len: usize = buf.len; + const rc = boj_cartridge_invoke(t.ptr, "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, 0), rc); + try std.testing.expect(std.mem.indexOf(u8, buf[0..len], "result") != null); + } +} + +test "invoke: unknown tool returns -1" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("nope", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -1), rc); +} + +test "invoke: buffer too small returns -3" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("gdocs_get_document", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -3), rc); + try std.testing.expect(len > 4); +} diff --git a/cartridges/domains/productivity/google-docs-mcp/minter.toml b/cartridges/domains/productivity/google-docs-mcp/minter.toml new file mode 100644 index 0000000..6425497 --- /dev/null +++ b/cartridges/domains/productivity/google-docs-mcp/minter.toml @@ -0,0 +1,23 @@ +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> + +name = "google-docs-mcp" +description = "Google Docs cartridge β€” document retrieval, content reading, heading extraction, text search, comments, suggestions, revisions, named ranges, document creation, text insertion" +version = "0.2.0" +domain = "Productivity" +protocols = [ + "MCP", + "REST", +] +tier = "Ayo" +backend = "universal" +generate_panel = true + +[auth] +method = "oauth2" +credential_source = "vault-mcp" +fields = ["google_docs_access_token"] + +[api] +base_url = "https://docs.googleapis.com/v1" +local_only = false diff --git a/cartridges/domains/productivity/google-docs-mcp/mod.js b/cartridges/domains/productivity/google-docs-mcp/mod.js new file mode 100644 index 0000000..644dd82 --- /dev/null +++ b/cartridges/domains/productivity/google-docs-mcp/mod.js @@ -0,0 +1,264 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// google-docs-mcp/mod.js -- Google Docs cartridge implementation. +// +// Provides MCP tool handlers for the Google Docs API v1: +// - Document retrieval (full structure) +// - Plain-text content extraction +// - Heading extraction (table of contents) +// - Text search within documents +// - Comment listing (via Drive API) +// - Suggestion browsing +// - Revision history (via Drive API) +// - Named range access +// - Document creation +// - Text insertion +// +// Auth: OAuth2 Bearer token via GOOGLE_DOCS_ACCESS_TOKEN (required). +// API docs: https://developers.google.com/docs/api/reference/rest/v1 +// +// Usage: import { handleTool } from "./mod.js"; +// or: deno run --allow-net --allow-env mod.js + +const DOCS_API_BASE = "https://docs.googleapis.com/v1"; +const DRIVE_API_BASE = "https://www.googleapis.com/drive/v3"; + +// --------------------------------------------------------------------------- +// Auth helper β€” retrieves the Google OAuth2 access token from environment. +// In production, vault-mcp provides zero-knowledge credential proxying. +// --------------------------------------------------------------------------- + +function getToken() { + const token = typeof Deno !== "undefined" + ? Deno.env.get("GOOGLE_DOCS_ACCESS_TOKEN") + : process.env.GOOGLE_DOCS_ACCESS_TOKEN; + return token || null; +} + +// --------------------------------------------------------------------------- +// HTTP request helper β€” wraps fetch with Google API headers, +// OAuth2 bearer auth, and error normalization. +// --------------------------------------------------------------------------- + +async function googleFetch(base, path, queryParams, method, body) { + const url = new URL(`${base}${path}`); + + if (queryParams) { + for (const [key, value] of Object.entries(queryParams)) { + if (value !== undefined && value !== null && value !== "") { + url.searchParams.set(key, String(value)); + } + } + } + + const headers = { + "Accept": "application/json", + "User-Agent": "boj-server/google-docs-mcp/0.2.0", + }; + + const token = getToken(); + if (!token) { + return { status: 401, error: "GOOGLE_DOCS_ACCESS_TOKEN not set." }; + } + headers["Authorization"] = `Bearer ${token}`; + + const fetchOpts = { method: method || "GET", headers }; + + if (body) { + headers["Content-Type"] = "application/json"; + fetchOpts.body = JSON.stringify(body); + } + + const response = await fetch(url.toString(), fetchOpts); + + if (response.status === 429) { + const retryAfter = response.headers.get("retry-after"); + return { + status: 429, + error: `Rate limited. Retry after ${retryAfter || "unknown"} seconds.`, + retryAfter, + }; + } + + const data = await response.json().catch(() => ({})); + + if (!response.ok) { + const errorMessage = data.error?.message || data.message || `HTTP ${response.status}`; + return { status: response.status, error: errorMessage, data }; + } + + return { status: response.status, data }; +} + +// --------------------------------------------------------------------------- +// Content extraction helpers +// --------------------------------------------------------------------------- + +function extractPlainText(doc) { + const body = doc.body; + if (!body || !body.content) return ""; + let text = ""; + for (const element of body.content) { + if (element.paragraph) { + for (const el of element.paragraph.elements || []) { + if (el.textRun) text += el.textRun.content; + } + } + } + return text; +} + +function extractHeadings(doc) { + const body = doc.body; + if (!body || !body.content) return []; + const headings = []; + for (const element of body.content) { + if (element.paragraph) { + const style = element.paragraph.paragraphStyle; + if (style && style.namedStyleType && style.namedStyleType.startsWith("HEADING_")) { + const level = parseInt(style.namedStyleType.replace("HEADING_", ""), 10); + let text = ""; + for (const el of element.paragraph.elements || []) { + if (el.textRun) text += el.textRun.content; + } + headings.push({ level, text: text.trim(), startIndex: element.startIndex }); + } + } + } + return headings; +} + +// --------------------------------------------------------------------------- +// Tool handler dispatch β€” maps MCP tool names to Google Docs API operations. +// --------------------------------------------------------------------------- + +export async function handleTool(toolName, args) { + switch (toolName) { + + // --- Document retrieval --- + + case "gdocs_get_document": { + if (!args.document_id) return { error: "Missing required field: document_id" }; + return googleFetch(DOCS_API_BASE, `/documents/${encodeURIComponent(args.document_id)}`); + } + + // --- Plain-text content --- + + case "gdocs_get_content": { + if (!args.document_id) return { error: "Missing required field: document_id" }; + const result = await googleFetch(DOCS_API_BASE, `/documents/${encodeURIComponent(args.document_id)}`); + if (result.error) return result; + return { status: result.status, data: { title: result.data.title, content: extractPlainText(result.data) } }; + } + + // --- Headings --- + + case "gdocs_get_headings": { + if (!args.document_id) return { error: "Missing required field: document_id" }; + const result = await googleFetch(DOCS_API_BASE, `/documents/${encodeURIComponent(args.document_id)}`); + if (result.error) return result; + return { status: result.status, data: { title: result.data.title, headings: extractHeadings(result.data) } }; + } + + // --- Search --- + + case "gdocs_search_content": { + if (!args.document_id) return { error: "Missing required field: document_id" }; + if (!args.query) return { error: "Missing required field: query" }; + const result = await googleFetch(DOCS_API_BASE, `/documents/${encodeURIComponent(args.document_id)}`); + if (result.error) return result; + const text = extractPlainText(result.data); + const matches = []; + const queryLower = args.query.toLowerCase(); + const lines = text.split("\n"); + for (let i = 0; i < lines.length; i++) { + if (lines[i].toLowerCase().includes(queryLower)) { + matches.push({ line: i + 1, text: lines[i].trim() }); + } + } + return { status: result.status, data: { query: args.query, matches } }; + } + + // --- Comments (Drive API) --- + + case "gdocs_list_comments": { + if (!args.document_id) return { error: "Missing required field: document_id" }; + const params = { fields: "comments(id,content,author,resolved,createdTime)" }; + if (!args.include_resolved) params.includeDeleted = "false"; + return googleFetch(DRIVE_API_BASE, `/files/${encodeURIComponent(args.document_id)}/comments`, params); + } + + // --- Suggestions --- + + case "gdocs_list_suggestions": { + if (!args.document_id) return { error: "Missing required field: document_id" }; + const result = await googleFetch(DOCS_API_BASE, `/documents/${encodeURIComponent(args.document_id)}`, { + suggestionsViewMode: "SUGGESTIONS_INLINE", + }); + if (result.error) return result; + const suggestions = result.data.suggestedDocumentStyleChanges || {}; + return { status: result.status, data: { suggestedChanges: suggestions } }; + } + + // --- Revisions (Drive API) --- + + case "gdocs_get_revisions": { + if (!args.document_id) return { error: "Missing required field: document_id" }; + const params = { + fields: "revisions(id,modifiedTime,lastModifyingUser)", + pageSize: args.limit || 10, + }; + return googleFetch(DRIVE_API_BASE, `/files/${encodeURIComponent(args.document_id)}/revisions`, params); + } + + // --- Named ranges --- + + case "gdocs_get_named_ranges": { + if (!args.document_id) return { error: "Missing required field: document_id" }; + const result = await googleFetch(DOCS_API_BASE, `/documents/${encodeURIComponent(args.document_id)}`); + if (result.error) return result; + return { status: result.status, data: { namedRanges: result.data.namedRanges || {} } }; + } + + // --- Create document --- + + case "gdocs_create_document": { + if (!args.title) return { error: "Missing required field: title" }; + return googleFetch(DOCS_API_BASE, "/documents", null, "POST", { title: args.title }); + } + + // --- Insert text --- + + case "gdocs_insert_text": { + if (!args.document_id) return { error: "Missing required field: document_id" }; + if (!args.text) return { error: "Missing required field: text" }; + if (args.index === undefined) return { error: "Missing required field: index" }; + return googleFetch(DOCS_API_BASE, `/documents/${encodeURIComponent(args.document_id)}:batchUpdate`, null, "POST", { + requests: [{ + insertText: { + location: { index: args.index }, + text: args.text, + }, + }], + }); + } + + default: + return { error: `Unknown google-docs-mcp tool: ${toolName}` }; + } +} + +// --------------------------------------------------------------------------- +// Cartridge metadata export β€” used by the BoJ cartridge loader to register +// this cartridge's tools without reading cartridge.json separately. +// --------------------------------------------------------------------------- + +export const metadata = { + name: "google-docs-mcp", + version: "0.2.0", + domain: "Productivity", + tier: "Ayo", + protocols: ["MCP", "REST"], + toolCount: 10, +}; diff --git a/cartridges/domains/productivity/google-docs-mcp/panels/manifest.json b/cartridges/domains/productivity/google-docs-mcp/panels/manifest.json new file mode 100644 index 0000000..7dfbad7 --- /dev/null +++ b/cartridges/domains/productivity/google-docs-mcp/panels/manifest.json @@ -0,0 +1,90 @@ +{ + "$schema": "https://panll.dev/schemas/panel-manifest/v1.json", + "spdx": "MPL-2.0", + "copyright": "Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath)", + "cartridge": "google-docs-mcp", + "domain": "Productivity", + "version": "0.2.0", + "panels": [ + { + "id": "gdocs-connection-status", + "title": "Connection Status", + "description": "Google Docs session state (Disconnected / Connected / RateLimited / Error)", + "type": "status-indicator", + "data_source": { + "endpoint": "/google-docs/state", + "method": "GET", + "refresh_interval_ms": 5000 + }, + "widgets": [ + { + "type": "state-badge", + "field": "state", + "states": { + "disconnected": { "color": "#95a5a6", "icon": "wifi-off" }, + "connected": { "color": "#2ecc71", "icon": "wifi" }, + "rate_limited": { "color": "#f39c12", "icon": "clock" }, + "error": { "color": "#e74c3c", "icon": "alert-triangle" } + } + } + ] + }, + { + "id": "gdocs-doc-reads", + "title": "Document Reads", + "description": "Number of document read operations in the current session", + "type": "metric", + "data_source": { + "endpoint": "/google-docs/status", + "method": "GET", + "refresh_interval_ms": 10000 + }, + "widgets": [ + { + "type": "counter", + "field": "doc_reads", + "label": "Doc Reads", + "icon": "file-text" + } + ] + }, + { + "id": "gdocs-doc-writes", + "title": "Document Writes", + "description": "Number of document create/edit operations in the current session", + "type": "metric", + "data_source": { + "endpoint": "/google-docs/status", + "method": "GET", + "refresh_interval_ms": 10000 + }, + "widgets": [ + { + "type": "counter", + "field": "doc_writes", + "label": "Doc Writes", + "icon": "edit" + } + ] + }, + { + "id": "gdocs-comment-queries", + "title": "Comment Queries", + "description": "Number of comment/suggestion queries in the current session", + "type": "metric", + "data_source": { + "endpoint": "/google-docs/status", + "method": "GET", + "refresh_interval_ms": 10000 + }, + "widgets": [ + { + "type": "counter", + "field": "comment_queries", + "label": "Comments", + "icon": "message-square" + } + ] + } + ] +} diff --git a/cartridges/domains/productivity/google-docs-mcp/tests/integration_test.sh b/cartridges/domains/productivity/google-docs-mcp/tests/integration_test.sh new file mode 100755 index 0000000..5a7bf33 --- /dev/null +++ b/cartridges/domains/productivity/google-docs-mcp/tests/integration_test.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Integration tests for google-docs-mcp cartridge. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CART_DIR="$(dirname "$SCRIPT_DIR")" +FFI_DIR="$CART_DIR/ffi" + +echo "=== google-docs-mcp integration tests ===" + +# Build FFI +echo "[1/3] Building FFI..." +cd "$FFI_DIR" && zig build 2>&1 + +# Run Zig unit tests +echo "[2/3] Running FFI unit tests..." +cd "$FFI_DIR" && zig build test 2>&1 + +# Validate Idris2 ABI (if idris2 available) +echo "[3/3] Checking ABI..." +if command -v idris2 &>/dev/null; then + cd "$CART_DIR/abi" && idris2 --check GoogleDocsMcp.SafeRegistry 2>&1 + echo " ABI: OK" +else + echo " ABI: SKIPPED (idris2 not in PATH)" +fi + +echo "" +echo "All tests passed for google-docs-mcp!" diff --git a/cartridges/domains/productivity/google-sheets-mcp/README.adoc b/cartridges/domains/productivity/google-sheets-mcp/README.adoc new file mode 100644 index 0000000..de2d29c --- /dev/null +++ b/cartridges/domains/productivity/google-sheets-mcp/README.adoc @@ -0,0 +1,89 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += google-sheets-mcp +Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk> +:spdx: MPL-2.0 +:tier: Ayo +:domain: Productivity +:protocols: MCP, REST + +== Overview + +Google Sheets cartridge for the BoJ server. Provides type-safe access to +the Google Sheets API v4 for spreadsheet metadata retrieval, cell range +reading, sheet listing, named range access, cell value writing, row +appending, sheet creation, batch reading, conditional format listing, and +pivot table access. + +Connects to the Google Sheets API at `sheets.googleapis.com`. OAuth2 +bearer token auth is always required. + +=== State Machine + +`Disconnected -> Connected` (authenticated via OAuth2 token) + +`Connected -> RateLimited -> Connected` (normal flow) + +`Connected -> Error -> Disconnected` (error recovery) + +=== Actions (10) + +[cols="1,1"] +|=== +| Category | Actions + +| Cell Reads +| ReadRange, BatchRead, GetNamedRanges + +| Cell Writes +| WriteRange, AppendRows + +| Sheet Management +| GetSpreadsheet, ListSheets, CreateSheet + +| Formatting +| GetConditionalFormats, GetPivotTables +|=== + +== Architecture + +[cols="1,1,2"] +|=== +| Layer | Language | Purpose + +| ABI +| Idris2 +| Formally verified state machine with dependent-type proofs (GoogleSheetsMcp.SafeRegistry) + +| FFI +| Zig +| C-compatible implementation with thread-safe session pool, action recording, category counting + +| Adapter +| zig +| REST bridge to BoJ unified adapter protocol +|=== + +== Building + +[source,bash] +---- +# Build FFI shared library +cd ffi && zig build + +# Run FFI tests +cd ffi && zig build test + +# Type-check ABI +cd abi && idris2 --check GoogleSheetsMcp.SafeRegistry +---- + +== Prerequisites + +1. Create a Google Cloud project with the Sheets API enabled. +2. Generate an OAuth2 access token with `spreadsheets.readonly` (or `spreadsheets`) scope. +3. Set `GOOGLE_SHEETS_ACCESS_TOKEN` to the token value. + +== Status + +Development -- customised with Google Sheets-specific A1 notation range reading, batch operations, cell writing, row appending, conditional formats, and pivot table access. diff --git a/cartridges/domains/productivity/google-sheets-mcp/abi/GoogleSheetsMcp/SafeRegistry.idr b/cartridges/domains/productivity/google-sheets-mcp/abi/GoogleSheetsMcp/SafeRegistry.idr new file mode 100644 index 0000000..d09befa --- /dev/null +++ b/cartridges/domains/productivity/google-sheets-mcp/abi/GoogleSheetsMcp/SafeRegistry.idr @@ -0,0 +1,174 @@ +-- SPDX-License-Identifier: MPL-2.0 +-- Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +-- +-- GoogleSheetsMcp.SafeRegistry β€” Type-safe ABI for google-sheets-mcp cartridge. +-- +-- Dependent-type state machine governing Google Sheets API v4 access. +-- Encodes mandatory OAuth2 auth, spreadsheet retrieval, cell range reading, +-- sheet listing, named range access, cell writing, row appending, sheet +-- creation, batch reading, conditional format listing, and pivot table +-- access as compile-time invariants. +-- REST API: https://sheets.googleapis.com/v4 +-- No unsafe escape hatches. + +module GoogleSheetsMcp.SafeRegistry + +%default total + +-- --------------------------------------------------------------------------- +-- Authentication state machine +-- --------------------------------------------------------------------------- + +||| Session state for Google Sheets MCP operations. +||| Disconnected: no OAuth2 token configured. +||| Connected: authenticated and ready for API calls. +||| RateLimited: rate limit hit; must wait before retrying. +||| Error: unrecoverable error (expired token, permission denied). +public export +data SessionState + = Disconnected + | Connected + | RateLimited + | Error + +||| Proof that a state transition is valid. +||| Google Sheets requires OAuth2 auth β€” no anonymous access. +public export +data ValidTransition : SessionState -> SessionState -> Type where + Connect : ValidTransition Disconnected Connected + Disconnect : ValidTransition Connected Disconnected + Throttle : ValidTransition Connected RateLimited + Unthrottle : ValidTransition RateLimited Connected + ConnectError : ValidTransition Connected Error + DisconnError : ValidTransition Disconnected Error + RecoverConnect : ValidTransition Error Connected + RecoverDisconn : ValidTransition Error Disconnected + +-- --------------------------------------------------------------------------- +-- C-ABI integer encoding +-- --------------------------------------------------------------------------- + +||| Encode session state as C-compatible integer for FFI boundary. +export +sessionStateToInt : SessionState -> Int +sessionStateToInt Disconnected = 0 +sessionStateToInt Connected = 1 +sessionStateToInt RateLimited = 2 +sessionStateToInt Error = 3 + +||| Decode integer back to session state. Returns Nothing for out-of-range. +export +intToSessionState : Int -> Maybe SessionState +intToSessionState 0 = Just Disconnected +intToSessionState 1 = Just Connected +intToSessionState 2 = Just RateLimited +intToSessionState 3 = Just Error +intToSessionState _ = Nothing + +||| Check if a state transition is valid (C-ABI export). +export +google_sheets_mcp_can_transition : Int -> Int -> Int +google_sheets_mcp_can_transition from to = + case (intToSessionState from, intToSessionState to) of + (Just Disconnected, Just Connected) => 1 + (Just Connected, Just Disconnected) => 1 + (Just Connected, Just RateLimited) => 1 + (Just RateLimited, Just Connected) => 1 + (Just Connected, Just Error) => 1 + (Just Disconnected, Just Error) => 1 + (Just Error, Just Connected) => 1 + (Just Error, Just Disconnected) => 1 + _ => 0 + +-- --------------------------------------------------------------------------- +-- Google Sheets actions +-- --------------------------------------------------------------------------- + +||| Actions available through the Google Sheets MCP cartridge. +public export +data GoogleSheetsAction + = GetSpreadsheet + | ReadRange + | ListSheets + | GetNamedRanges + | WriteRange + | AppendRows + | CreateSheet + | BatchRead + | GetConditionalFormats + | GetPivotTables + +||| Whether an action requires Connected state. +export +actionRequiresAuth : GoogleSheetsAction -> Bool +actionRequiresAuth _ = True + +||| Whether an action is a write/mutating operation. +export +actionIsMutating : GoogleSheetsAction -> Bool +actionIsMutating WriteRange = True +actionIsMutating AppendRows = True +actionIsMutating CreateSheet = True +actionIsMutating _ = False + +||| Encode action as C-compatible integer for FFI. +export +actionToInt : GoogleSheetsAction -> Int +actionToInt GetSpreadsheet = 0 +actionToInt ReadRange = 1 +actionToInt ListSheets = 2 +actionToInt GetNamedRanges = 3 +actionToInt WriteRange = 4 +actionToInt AppendRows = 5 +actionToInt CreateSheet = 6 +actionToInt BatchRead = 7 +actionToInt GetConditionalFormats = 8 +actionToInt GetPivotTables = 9 + +||| Decode integer to Google Sheets action. +export +intToAction : Int -> Maybe GoogleSheetsAction +intToAction 0 = Just GetSpreadsheet +intToAction 1 = Just ReadRange +intToAction 2 = Just ListSheets +intToAction 3 = Just GetNamedRanges +intToAction 4 = Just WriteRange +intToAction 5 = Just AppendRows +intToAction 6 = Just CreateSheet +intToAction 7 = Just BatchRead +intToAction 8 = Just GetConditionalFormats +intToAction 9 = Just GetPivotTables +intToAction _ = Nothing + +-- --------------------------------------------------------------------------- +-- MCP tool declarations +-- --------------------------------------------------------------------------- + +||| Tools exposed via MCP protocol for this cartridge. +public export +data McpTool + = ToolGetSpreadsheet + | ToolReadRange + | ToolListSheets + | ToolGetNamedRanges + | ToolWriteRange + | ToolAppendRows + | ToolCreateSheet + | ToolBatchRead + | ToolGetConditionalFormats + | ToolGetPivotTables + +||| Check if a tool requires a connected session. +export +toolRequiresSession : McpTool -> Bool +toolRequiresSession _ = True + +||| Total tool count for this cartridge. +export +toolCount : Nat +toolCount = 10 + +||| Total action count for this cartridge. +export +actionCount : Nat +actionCount = 10 diff --git a/cartridges/domains/productivity/google-sheets-mcp/abi/README.adoc b/cartridges/domains/productivity/google-sheets-mcp/abi/README.adoc new file mode 100644 index 0000000..960fede --- /dev/null +++ b/cartridges/domains/productivity/google-sheets-mcp/abi/README.adoc @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += google-sheets-mcp / abi β€” Idris2 ABI layer +:orientation: shallow + +== Purpose + +Cartridge-local protocol types for `google-sheets-mcp`. Defines the data, tool, and +transition vocabulary this cartridge speaks. Inherits safety lemmas and the +matrix-cell identifier scheme from the trunk ABI at `src/abi/`. + +== Files + +1 Idris2 module(s), ~174 lines total. Lead module: +`SafeRegistry.idr`. + +No `.ipkg` β€” consumed directly by wider BoJ callers; type-checks inside the +trunk `boj.ipkg`. + +== Invariants + +* `%default total` at every module top. +* Zero `believe_me`, `assert_total`, `postulate`, `Admitted`, or + `sorry` (the trunk holds the four declared `believe_me` sites; leaf + cartridges carry none). + +== Test/proof surface + +Thin module(s), no inline tests. Proof surface = type-check success under the +trunk `idris2 --check`. + +== Read-first + +. `SafeRegistry.idr` β€” cartridge-local data records and enums. +. Trunk `src/abi/Boj/Catalogue.idr` for how this cell participates in the + 2D matrix. diff --git a/cartridges/domains/productivity/google-sheets-mcp/adapter/README.adoc b/cartridges/domains/productivity/google-sheets-mcp/adapter/README.adoc new file mode 100644 index 0000000..57fc2a1 --- /dev/null +++ b/cartridges/domains/productivity/google-sheets-mcp/adapter/README.adoc @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += google-sheets-mcp / adapter β€” protocol bridge +:orientation: shallow + +== Purpose + +Exposes this cartridge's FFI over the wire protocols declared in +`cartridge.json`. Dispatches tool invocations to the C-ABI exports at +`../ffi/` and formats responses per protocol. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” depends on the sibling `ffi` target. +| `google_sheets_adapter.zig` | Protocol dispatch (204 lines). Tool catalogue matches `cartridge.json`. +| `SIDELINED-*.v.adoc` | zig predecessor(s). Not built β€” zig banned 2026-04-10. +|=== + +== Invariants + +* **Stateless** β€” all state lives behind the FFI, never in the adapter. +* **Response passthrough** β€” whatever the FFI returns goes back to the wire + unmodified (no embellishment, no silent recovery). +* **Cartridge.json is source of truth** for the tool catalogue; drift between + adapter and manifest is a CI failure. + +== Test/proof surface + +No inline tests at the adapter layer β€” protocol routing is exercised by the +trunk seam tests at `ffi/zig/src/seams.zig`. + +== Read-first + +. `google_sheets_adapter.zig` β€” dispatch function and per-protocol routers. +. `../cartridge.json` β€” the tool manifest the dispatcher mirrors. diff --git a/cartridges/domains/productivity/google-sheets-mcp/adapter/build.zig b/cartridges/domains/productivity/google-sheets-mcp/adapter/build.zig new file mode 100644 index 0000000..5015ba4 --- /dev/null +++ b/cartridges/domains/productivity/google-sheets-mcp/adapter/build.zig @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// google-sheets-mcp/adapter/build.zig + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const ffi_mod = b.createModule(.{ + .root_source_file = b.path("../ffi/google_sheets_mcp_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + const adapter = b.addExecutable(.{ + .name = "google_sheets_adapter", + .root_source_file = b.path("google_sheets_adapter.zig"), + .target = target, + .optimize = optimize, + }); + adapter.root_module.addImport("google_sheets_mcp_ffi", ffi_mod); + b.installArtifact(adapter); + + const run_artifact = b.addRunArtifact(adapter); + const run_step = b.step("run", "Run the google-sheets-mcp adapter"); + run_step.dependOn(&run_artifact.step); + + const tests = b.addTest(.{ + .root_source_file = b.path("google_sheets_adapter.zig"), + .target = target, + .optimize = optimize, + }); + tests.root_module.addImport("google_sheets_mcp_ffi", ffi_mod); + const run_tests = b.addRunArtifact(tests); + const test_step = b.step("test", "Run google-sheets-mcp adapter tests"); + test_step.dependOn(&run_tests.step); +} diff --git a/cartridges/domains/productivity/google-sheets-mcp/adapter/google_sheets_adapter.zig b/cartridges/domains/productivity/google-sheets-mcp/adapter/google_sheets_adapter.zig new file mode 100644 index 0000000..da7b08f --- /dev/null +++ b/cartridges/domains/productivity/google-sheets-mcp/adapter/google_sheets_adapter.zig @@ -0,0 +1,204 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// google-sheets-mcp/adapter/google_sheets_adapter.zig -- Unified three-protocol adapter. +// +// Replaces the banned google_sheets_adapter.v (zig, removed 2026-04-12). +// +// Bridges the Zig FFI (google_sheets_mcp_ffi.zig) to three network protocols: +// REST :9055 POST /tools/<tool> +// gRPC-compat :9056 /GoogleSheetsMcpService/<Method> +// GraphQL :9057 POST /graphql { query: "..." } +// +// Google Sheets API: read/write ranges, sheets, named ranges, pivot tables +// Tools: +// gsheets_get_spreadsheet +// gsheets_read_range +// gsheets_list_sheets +// gsheets_get_named_ranges +// gsheets_write_range +// gsheets_append_rows +// gsheets_create_sheet +// gsheets_batch_read +// gsheets_get_conditional_formats +// gsheets_get_pivot_tables + +const std = @import("std"); +const ffi = @import("google_sheets_mcp_ffi"); + +const REST_PORT: u16 = 9055; +const GRPC_PORT: u16 = 9056; +const GQL_PORT: u16 = 9057; + +const MAX_CONN_BUF: usize = 16 * 1024; + +// ============================================================================ +// JSON response builders +// ============================================================================ + +fn okJson(buf: []u8, msg: []const u8) []u8 { + return std.fmt.bufPrint(buf, + \{{"success":true,"message":"{}"}} + , .{msg}) catch buf[0..0]; +} + +fn errJson(buf: []u8, msg: []const u8) []u8 { + return std.fmt.bufPrint(buf, + \{{"success":false,"error":"{}"}} + , .{msg}) catch buf[0..0]; +} + +fn statusJson(buf: []u8) []u8 { + return std.fmt.bufPrint(buf, + \{{"success":true,"state":"ready","service":"google-sheets-mcp"}} + , .{}) catch buf[0..0]; +} + +// ============================================================================ +// Tool dispatcher +// ============================================================================ + +const Response = struct { status: u16, body: []u8 }; + +fn dispatch(tool: []const u8, body: []const u8, resp: []u8) Response { + _ = body; + if (std.mem.eql(u8, tool, "gsheets_get_spreadsheet")) return .{ .status = 200, .body = okJson(resp, "gsheets_get_spreadsheet forwarded to backend") }; + if (std.mem.eql(u8, tool, "gsheets_read_range")) return .{ .status = 200, .body = okJson(resp, "gsheets_read_range forwarded to backend") }; + if (std.mem.eql(u8, tool, "gsheets_list_sheets")) return .{ .status = 200, .body = okJson(resp, "gsheets_list_sheets forwarded to backend") }; + if (std.mem.eql(u8, tool, "gsheets_get_named_ranges")) return .{ .status = 200, .body = okJson(resp, "gsheets_get_named_ranges forwarded to backend") }; + if (std.mem.eql(u8, tool, "gsheets_write_range")) return .{ .status = 200, .body = okJson(resp, "gsheets_write_range forwarded to backend") }; + if (std.mem.eql(u8, tool, "gsheets_append_rows")) return .{ .status = 200, .body = okJson(resp, "gsheets_append_rows forwarded to backend") }; + if (std.mem.eql(u8, tool, "gsheets_create_sheet")) return .{ .status = 200, .body = okJson(resp, "gsheets_create_sheet forwarded to backend") }; + if (std.mem.eql(u8, tool, "gsheets_batch_read")) return .{ .status = 200, .body = okJson(resp, "gsheets_batch_read forwarded to backend") }; + if (std.mem.eql(u8, tool, "gsheets_get_conditional_formats")) return .{ .status = 200, .body = okJson(resp, "gsheets_get_conditional_formats forwarded to backend") }; + if (std.mem.eql(u8, tool, "gsheets_get_pivot_tables")) return .{ .status = 200, .body = okJson(resp, "gsheets_get_pivot_tables forwarded to backend") }; + if (std.mem.eql(u8, tool, "status") or std.mem.eql(u8, tool, "health")) + return .{ .status = 200, .body = statusJson(resp) }; + return .{ .status = 404, .body = errJson(resp, "Unknown tool") }; +} + +// ============================================================================ +// REST handler +// ============================================================================ + +fn dispatchRest(path: []const u8, body: []const u8, resp: []u8) Response { + if (std.mem.startsWith(u8, path, "/tools/")) { + return dispatch(path["/tools/".len..], body, resp); + } + if (std.mem.eql(u8, path, "/status") or std.mem.eql(u8, path, "/health")) { + return .{ .status = 200, .body = statusJson(resp) }; + } + return .{ .status = 404, .body = errJson(resp, "Not found") }; +} + +// ============================================================================ +// gRPC-compat handler +// ============================================================================ + +fn dispatchGrpc(path: []const u8, body: []const u8, resp: []u8) Response { + const prefix = "/GoogleSheetsMcpService/"; + if (!std.mem.startsWith(u8, path, prefix)) + return .{ .status = 404, .body = errJson(resp, "Not a recognized gRPC path") }; + const method = path[prefix.len..]; + const tool = blk: { + if (std.mem.eql(u8, method, "GsheetsGetSpreadsheet")) break :blk "gsheets_get_spreadsheet"; + if (std.mem.eql(u8, method, "GsheetsReadRange")) break :blk "gsheets_read_range"; + if (std.mem.eql(u8, method, "GsheetsListSheets")) break :blk "gsheets_list_sheets"; + if (std.mem.eql(u8, method, "GsheetsGetNamedRanges")) break :blk "gsheets_get_named_ranges"; + if (std.mem.eql(u8, method, "GsheetsWriteRange")) break :blk "gsheets_write_range"; + if (std.mem.eql(u8, method, "GsheetsAppendRows")) break :blk "gsheets_append_rows"; + if (std.mem.eql(u8, method, "GsheetsCreateSheet")) break :blk "gsheets_create_sheet"; + if (std.mem.eql(u8, method, "GsheetsBatchRead")) break :blk "gsheets_batch_read"; + if (std.mem.eql(u8, method, "GsheetsGetConditionalFormats")) break :blk "gsheets_get_conditional_formats"; + if (std.mem.eql(u8, method, "GsheetsGetPivotTables")) break :blk "gsheets_get_pivot_tables"; + return .{ .status = 404, .body = errJson(resp, "Unknown gRPC method") }; + }; + return dispatch(tool, body, resp); +} + +// ============================================================================ +// GraphQL handler +// ============================================================================ + +fn dispatchGraphql(body: []const u8, resp: []u8) Response { + if (std.mem.indexOf(u8, body, "__schema") != null) + return .{ .status = 200, .body = okJson(resp, "schema introspection not yet supported") }; + if (std.mem.indexOf(u8, body, "get_spreadsheet") != null) return dispatch("gsheets_get_spreadsheet", body, resp); + if (std.mem.indexOf(u8, body, "read_range") != null) return dispatch("gsheets_read_range", body, resp); + if (std.mem.indexOf(u8, body, "list_sheets") != null) return dispatch("gsheets_list_sheets", body, resp); + if (std.mem.indexOf(u8, body, "get_named_ranges") != null) return dispatch("gsheets_get_named_ranges", body, resp); + if (std.mem.indexOf(u8, body, "write_range") != null) return dispatch("gsheets_write_range", body, resp); + if (std.mem.indexOf(u8, body, "append_rows") != null) return dispatch("gsheets_append_rows", body, resp); + if (std.mem.indexOf(u8, body, "create_sheet") != null) return dispatch("gsheets_create_sheet", body, resp); + if (std.mem.indexOf(u8, body, "batch_read") != null) return dispatch("gsheets_batch_read", body, resp); + if (std.mem.indexOf(u8, body, "get_conditional_formats") != null) return dispatch("gsheets_get_conditional_formats", body, resp); + if (std.mem.indexOf(u8, body, "get_pivot_tables") != null) return dispatch("gsheets_get_pivot_tables", body, resp); + return .{ .status = 200, .body = errJson(resp, "Unrecognised GraphQL operation") }; +} + +// ============================================================================ +// HTTP/1.1 connection handler +// ============================================================================ + +const Protocol = enum { rest, grpc, graphql }; + +fn handleConnection(conn: std.net.Server.Connection, proto: Protocol) void { + defer conn.stream.close(); + var in_buf: [MAX_CONN_BUF]u8 = undefined; + const n = conn.stream.read(&in_buf) catch return; + const req = in_buf[0..n]; + + var path: []const u8 = "/"; + var body: []const u8 = ""; + if (n > 4) { + const line_end = std.mem.indexOf(u8, req, "\r\n") orelse req.len; + const first_line = req[0..line_end]; + const sp1 = std.mem.indexOfScalar(u8, first_line, ' ') orelse 0; + const rest_of = first_line[sp1 + 1 ..]; + const sp2 = std.mem.indexOfScalar(u8, rest_of, ' ') orelse rest_of.len; + path = rest_of[0..sp2]; + const body_sep = std.mem.indexOf(u8, req, "\r\n\r\n") orelse n; + body = req[@min(body_sep + 4, n)..]; + } + + var resp_buf: [MAX_CONN_BUF]u8 = undefined; + const result = switch (proto) { + .rest => dispatchRest(path, body, &resp_buf), + .grpc => dispatchGrpc(path, body, &resp_buf), + .graphql => dispatchGraphql(body, &resp_buf), + }; + + const content_type = switch (proto) { + .rest => "application/json", + .grpc => "application/grpc+json", + .graphql => "application/json", + }; + + var hdr_buf: [256]u8 = undefined; + const hdr = std.fmt.bufPrint(&hdr_buf, + "HTTP/1.1 {d} OK\r\nContent-Type: {s}\r\nContent-Length: {d}\r\nConnection: close\r\n\r\n", + .{ result.status, content_type, result.body.len }, + ) catch return; + _ = conn.stream.writeAll(hdr) catch return; + _ = conn.stream.writeAll(result.body) catch return; +} + +fn listenLoop(port: u16, proto: Protocol) void { + const addr = std.net.Address.initIp4(.{ 127, 0, 0, 1 }, port) catch return; + var server = addr.listen(.{ .reuse_address = true }) catch return; + defer server.deinit(); + while (true) { + const conn = server.accept() catch continue; + handleConnection(conn, proto); + } +} + +pub fn main() !void { + ffi.google_sheets_init(); + const rest_thread = try std.Thread.spawn(.{}, listenLoop, .{ REST_PORT, Protocol.rest }); + const grpc_thread = try std.Thread.spawn(.{}, listenLoop, .{ GRPC_PORT, Protocol.grpc }); + const gql_thread = try std.Thread.spawn(.{}, listenLoop, .{ GQL_PORT, Protocol.graphql }); + rest_thread.join(); + grpc_thread.join(); + gql_thread.join(); +} diff --git a/cartridges/domains/productivity/google-sheets-mcp/benchmarks/quick-bench.sh b/cartridges/domains/productivity/google-sheets-mcp/benchmarks/quick-bench.sh new file mode 100755 index 0000000..a63a52c --- /dev/null +++ b/cartridges/domains/productivity/google-sheets-mcp/benchmarks/quick-bench.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Benchmarks for google-sheets-mcp cartridge. +set -euo pipefail + +echo "=== google-sheets-mcp benchmarks ===" + +cd "$(dirname "${BASH_SOURCE[0]}")/../ffi" + +# Build with ReleaseFast for benchmarking +zig build -Doptimize=ReleaseFast 2>&1 + +echo "Session connect/disconnect cycle (1000 iterations):" +time for i in $(seq 1 1000); do + true +done + +echo "" +echo "Benchmark placeholder -- implement real benchmarks in Zig test blocks" +echo "or via the zig adapter HTTP benchmark tool." diff --git a/cartridges/domains/productivity/google-sheets-mcp/cartridge.json b/cartridges/domains/productivity/google-sheets-mcp/cartridge.json new file mode 100644 index 0000000..a6374bb --- /dev/null +++ b/cartridges/domains/productivity/google-sheets-mcp/cartridge.json @@ -0,0 +1,264 @@ +{ + "$schema": "https://boj.dev/schemas/cartridge/v1.json", + "spdx": "MPL-2.0", + "copyright": "Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath)", + "name": "google-sheets-mcp", + "version": "0.2.0", + "description": "Google Sheets cartridge -- spreadsheet metadata retrieval, cell range reading, sheet listing, named range access, cell value writing, row appending, sheet creation, formula evaluation, conditional format listing, and pivot table access via the Google Sheets API v4", + "domain": "Productivity", + "tier": "Ayo", + "protocols": [ + "MCP", + "REST" + ], + "auth": { + "method": "oauth2", + "env_var": "GOOGLE_SHEETS_ACCESS_TOKEN", + "credential_source": "vault-mcp", + "scopes": [ + "https://www.googleapis.com/auth/spreadsheets.readonly", + "https://www.googleapis.com/auth/spreadsheets" + ] + }, + "api": { + "base_url": "https://sheets.googleapis.com/v4", + "content_type": "application/json" + }, + "tools": [ + { + "name": "gsheets_get_spreadsheet", + "description": "Get spreadsheet metadata including title, sheets, and properties", + "inputSchema": { + "type": "object", + "properties": { + "spreadsheet_id": { + "type": "string", + "description": "Google Sheets spreadsheet ID (from URL)" + } + }, + "required": [ + "spreadsheet_id" + ] + } + }, + { + "name": "gsheets_read_range", + "description": "Read cell values from a specified range (e.g. 'Sheet1!A1:D10')", + "inputSchema": { + "type": "object", + "properties": { + "spreadsheet_id": { + "type": "string", + "description": "Spreadsheet ID" + }, + "range": { + "type": "string", + "description": "A1 notation range (e.g. 'Sheet1!A1:D10', 'A:A')" + }, + "value_render": { + "type": "string", + "description": "How values are rendered: FORMATTED_VALUE, UNFORMATTED_VALUE, FORMULA (default FORMATTED_VALUE)" + } + }, + "required": [ + "spreadsheet_id", + "range" + ] + } + }, + { + "name": "gsheets_list_sheets", + "description": "List all sheets (tabs) in a spreadsheet with their properties", + "inputSchema": { + "type": "object", + "properties": { + "spreadsheet_id": { + "type": "string", + "description": "Spreadsheet ID" + } + }, + "required": [ + "spreadsheet_id" + ] + } + }, + { + "name": "gsheets_get_named_ranges", + "description": "Get all named ranges defined in a spreadsheet", + "inputSchema": { + "type": "object", + "properties": { + "spreadsheet_id": { + "type": "string", + "description": "Spreadsheet ID" + } + }, + "required": [ + "spreadsheet_id" + ] + } + }, + { + "name": "gsheets_write_range", + "description": "Write values to a specified range in a spreadsheet", + "inputSchema": { + "type": "object", + "properties": { + "spreadsheet_id": { + "type": "string", + "description": "Spreadsheet ID" + }, + "range": { + "type": "string", + "description": "A1 notation range to write to" + }, + "values": { + "type": "string", + "description": "JSON array of arrays representing rows (e.g. '[[\"A\",\"B\"],[\"C\",\"D\"]]')" + }, + "value_input": { + "type": "string", + "description": "How input is interpreted: RAW, USER_ENTERED (default USER_ENTERED)" + } + }, + "required": [ + "spreadsheet_id", + "range", + "values" + ] + } + }, + { + "name": "gsheets_append_rows", + "description": "Append rows to the end of a sheet or table in a spreadsheet", + "inputSchema": { + "type": "object", + "properties": { + "spreadsheet_id": { + "type": "string", + "description": "Spreadsheet ID" + }, + "range": { + "type": "string", + "description": "A1 notation range indicating the table to append to (e.g. 'Sheet1!A:E')" + }, + "values": { + "type": "string", + "description": "JSON array of arrays representing rows to append" + }, + "value_input": { + "type": "string", + "description": "How input is interpreted: RAW, USER_ENTERED (default USER_ENTERED)" + } + }, + "required": [ + "spreadsheet_id", + "range", + "values" + ] + } + }, + { + "name": "gsheets_create_sheet", + "description": "Add a new sheet (tab) to an existing spreadsheet", + "inputSchema": { + "type": "object", + "properties": { + "spreadsheet_id": { + "type": "string", + "description": "Spreadsheet ID" + }, + "title": { + "type": "string", + "description": "Title for the new sheet" + }, + "row_count": { + "type": "number", + "description": "Number of rows (default 1000)" + }, + "column_count": { + "type": "number", + "description": "Number of columns (default 26)" + } + }, + "required": [ + "spreadsheet_id", + "title" + ] + } + }, + { + "name": "gsheets_batch_read", + "description": "Read multiple ranges from a spreadsheet in a single request", + "inputSchema": { + "type": "object", + "properties": { + "spreadsheet_id": { + "type": "string", + "description": "Spreadsheet ID" + }, + "ranges": { + "type": "string", + "description": "Comma-separated A1 notation ranges" + }, + "value_render": { + "type": "string", + "description": "How values are rendered (default FORMATTED_VALUE)" + } + }, + "required": [ + "spreadsheet_id", + "ranges" + ] + } + }, + { + "name": "gsheets_get_conditional_formats", + "description": "Get all conditional formatting rules for a specific sheet", + "inputSchema": { + "type": "object", + "properties": { + "spreadsheet_id": { + "type": "string", + "description": "Spreadsheet ID" + }, + "sheet_id": { + "type": "number", + "description": "Sheet ID (numeric, from sheet properties)" + } + }, + "required": [ + "spreadsheet_id", + "sheet_id" + ] + } + }, + { + "name": "gsheets_get_pivot_tables", + "description": "Get all pivot tables in a spreadsheet", + "inputSchema": { + "type": "object", + "properties": { + "spreadsheet_id": { + "type": "string", + "description": "Spreadsheet ID" + } + }, + "required": [ + "spreadsheet_id" + ] + } + } + ], + "ffi": { + "so_path": "ffi/zig-out/lib/libgoogle_sheets_mcp.so", + "abi_version": "ADR-0006", + "symbols": [ + "boj_cartridge_init", + "boj_cartridge_deinit", + "boj_cartridge_name", + "boj_cartridge_version", + "boj_cartridge_invoke" + ] + } +} diff --git a/cartridges/domains/productivity/google-sheets-mcp/ffi/README.adoc b/cartridges/domains/productivity/google-sheets-mcp/ffi/README.adoc new file mode 100644 index 0000000..5840144 --- /dev/null +++ b/cartridges/domains/productivity/google-sheets-mcp/ffi/README.adoc @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += google-sheets-mcp / ffi β€” Zig FFI layer +:orientation: shallow + +== Purpose + +C-ABI exports bridging this cartridge's Idris2 ABI to the runtime. Consumed by +the adapter for protocol dispatch and by the trunk loader for mount. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” produces `zig-out/lib/libgoogle-sheets.so`. +| `google_sheets_mcp_ffi.zig` | C-ABI exports (15 exports, 5 inline tests, 333 lines). +|=== + +== Invariants + +* **Null-pointer rejection** on `[*c]const u8` arguments before any + dereference. +* **Negative return values** carry documented error meanings; zero is + success (matching the trunk error-code convention). +* **No allocator leaks** β€” every allocation path pairs with `defer free`. + +== Test/proof surface + +5 inline `test "..."` block(s) in `google_sheets_mcp_ffi.zig`. Run with +`cd ffi && zig build test`. + +== Read-first + +. `google_sheets_mcp_ffi.zig` β€” C-ABI signatures and guards. +. Trunk `ffi/zig/src/catalogue.zig` for how this `.so` is loaded. diff --git a/cartridges/domains/productivity/google-sheets-mcp/ffi/build.zig b/cartridges/domains/productivity/google-sheets-mcp/ffi/build.zig new file mode 100644 index 0000000..643be59 --- /dev/null +++ b/cartridges/domains/productivity/google-sheets-mcp/ffi/build.zig @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + // Shared ADR-0006 invoke-shim module (relative path up to boj-server trunk). + + const shim_mod = b.addModule("cartridge_shim", .{ + + .root_source_file = b.path("../../../ffi/zig/src/cartridge_shim.zig"), + + .target = target, + + .optimize = optimize, + + }); + + const ffi_mod = b.addModule("google_sheets_mcp", .{ + .root_source_file = b.path("google_sheets_mcp_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + ffi_mod.addImport("cartridge_shim", shim_mod); + + const lib = b.addLibrary(.{ + .name = "google_sheets_mcp", + .root_module = ffi_mod, + .linkage = .dynamic, + }); + lib.linkLibC(); + b.installArtifact(lib); + + const tests = b.addTest(.{ .root_module = ffi_mod }); + tests.linkLibC(); + const run_tests = b.addRunArtifact(tests); + const test_step = b.step("test", "Run FFI tests"); + test_step.dependOn(&run_tests.step); +} + diff --git a/cartridges/domains/productivity/google-sheets-mcp/ffi/cartridge_shim.zig b/cartridges/domains/productivity/google-sheets-mcp/ffi/cartridge_shim.zig new file mode 100644 index 0000000..0e399db --- /dev/null +++ b/cartridges/domains/productivity/google-sheets-mcp/ffi/cartridge_shim.zig @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// cartridge_shim.zig β€” Shared helpers for the ADR-0006 five-symbol +// cartridge ABI (`boj_cartridge_init / deinit / name / version / invoke`). +// +// The shim centralises the seven-code return convention, NUL-argument +// guards, tool-name comparison, and the buffer-too-small path so each +// cartridge's `boj_cartridge_invoke` can stay short β€” typically a tool +// table plus `shim.writeResult(...)`. +// +// Cartridges import this file by relative path (no build-graph change +// needed). Example: +// +// const shim = @import("../../../ffi/zig/src/cartridge_shim.zig"); +// +// export fn boj_cartridge_invoke( +// tool_name: [*c]const u8, +// json_args: [*c]const u8, +// out_buf: [*c]u8, +// in_out_len: [*c]usize, +// ) callconv(.c) i32 { +// _ = json_args; +// if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; +// const body = if (shim.toolIs(tool_name, "foo")) "{\"result\":{}}" +// else return shim.RC_UNKNOWN_TOOL; +// return shim.writeResult(out_buf, in_out_len, body); +// } + +const std = @import("std"); + +// ── Return codes (ADR-0006 Β§Return codes) ──────────────────────────── +// +// Frozen by ADR-0006. New failure modes compose these via the error +// JSON body β€” the integer surface does not grow without a follow-up ADR. + +pub const RC_SUCCESS: i32 = 0; +pub const RC_UNKNOWN_TOOL: i32 = -1; +pub const RC_BAD_ARGS: i32 = -2; +pub const RC_BUFFER_TOO_SMALL: i32 = -3; +pub const RC_RUNTIME_ERROR: i32 = -4; +pub const RC_PANIC: i32 = -5; +pub const RC_AUTH_DENIED: i32 = -6; + +// ── Invoke-path helpers ────────────────────────────────────────────── + +/// True if any of the three mandatory `boj_cartridge_invoke` output-path +/// pointers is null. Use at the top of every invoke to short-circuit to +/// `RC_BAD_ARGS`. +pub fn invokeArgsNull( + tool_name: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) bool { + return tool_name == null or out_buf == null or in_out_len == null; +} + +/// Compare a C-NUL-terminated tool-name pointer against a Zig string +/// literal. Caller must have already verified `tool_name` is non-null +/// (usually via `invokeArgsNull`). +pub fn toolIs(tool_name: [*c]const u8, expected: []const u8) bool { + const s = std.mem.span(@as([*:0]const u8, @ptrCast(tool_name))); + return std.mem.eql(u8, s, expected); +} + +/// Copy `body` into `out_buf[0..*in_out_len]` (as a capacity) and update +/// `*in_out_len` to the number of bytes written. Returns `RC_SUCCESS`. +/// +/// If `body.len` exceeds the current capacity stored in `*in_out_len`, +/// sets `*in_out_len` to the required size and returns +/// `RC_BUFFER_TOO_SMALL` β€” the caller is then expected to re-allocate +/// and retry, per ADR-0006 Β§Memory ownership. +/// +/// Caller must have already verified that `out_buf` and `in_out_len` +/// are non-null. +pub fn writeResult( + out_buf: [*c]u8, + in_out_len: [*c]usize, + body: []const u8, +) i32 { + const cap = in_out_len.*; + if (body.len > cap) { + in_out_len.* = body.len; + return RC_BUFFER_TOO_SMALL; + } + @memcpy(out_buf[0..body.len], body); + in_out_len.* = body.len; + return RC_SUCCESS; +} + +// ── Tests ──────────────────────────────────────────────────────────── + +test "writeResult: body fits, writes and sets length" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); + try std.testing.expectEqualStrings("hello", buf[0..len]); +} + +test "writeResult: too small returns -3 and sets required length" { + var buf: [2]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_BUFFER_TOO_SMALL, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: exact-fit succeeds" { + var buf: [5]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: empty body" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, ""); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 0), len); +} + +test "toolIs: matches and rejects" { + const name: [*:0]const u8 = "foo"; + try std.testing.expect(toolIs(@ptrCast(name), "foo")); + try std.testing.expect(!toolIs(@ptrCast(name), "bar")); + try std.testing.expect(!toolIs(@ptrCast(name), "foobar")); + try std.testing.expect(!toolIs(@ptrCast(name), "fo")); +} + +test "invokeArgsNull: detects each null slot" { + var buf: [4]u8 = undefined; + var len: usize = 4; + const name: [*:0]const u8 = "x"; + try std.testing.expect(!invokeArgsNull(@ptrCast(name), &buf, &len)); + try std.testing.expect(invokeArgsNull(null, &buf, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), null, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), &buf, null)); +} diff --git a/cartridges/domains/productivity/google-sheets-mcp/ffi/google_sheets_mcp_ffi.zig b/cartridges/domains/productivity/google-sheets-mcp/ffi/google_sheets_mcp_ffi.zig new file mode 100644 index 0000000..7c3436d --- /dev/null +++ b/cartridges/domains/productivity/google-sheets-mcp/ffi/google_sheets_mcp_ffi.zig @@ -0,0 +1,442 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// google_sheets_mcp_ffi.zig β€” C-ABI FFI implementation for google-sheets-mcp cartridge. +// +// Implements the state machine defined in GoogleSheetsMcp.SafeRegistry (Idris2 ABI). +// State machine: Disconnected | Connected | RateLimited | Error +// Auth: Required OAuth2 Bearer token β€” Google Sheets API is always gated. +// REST API: https://sheets.googleapis.com/v4 +// Actions: GetSpreadsheet, ReadRange, ListSheets, GetNamedRanges, +// WriteRange, AppendRows, CreateSheet, BatchRead, +// GetConditionalFormats, GetPivotTables +// Thread-safe via std.Thread.Mutex. Fixed-size session pool, no heap allocations. + +const std = @import("std"); + +// --------------------------------------------------------------------------- +// State machine (matches Idris2 ABI SessionState exactly) +// --------------------------------------------------------------------------- + +pub const SessionState = enum(c_int) { + disconnected = 0, + connected = 1, + rate_limited = 2, + err = 3, +}; + +pub const GoogleSheetsAction = enum(c_int) { + get_spreadsheet = 0, + read_range = 1, + list_sheets = 2, + get_named_ranges = 3, + write_range = 4, + append_rows = 5, + create_sheet = 6, + batch_read = 7, + get_conditional_formats = 8, + get_pivot_tables = 9, +}; + +fn isValidTransition(from: SessionState, to: SessionState) bool { + return switch (from) { + .disconnected => to == .connected or to == .err, + .connected => to == .disconnected or to == .rate_limited or to == .err, + .rate_limited => to == .connected, + .err => to == .connected or to == .disconnected, + }; +} + +// --------------------------------------------------------------------------- +// Session slots (thread-safe, fixed-size pool) +// --------------------------------------------------------------------------- + +const MAX_SESSIONS: usize = 16; + +const SessionSlot = struct { + active: bool = false, + state: SessionState = .disconnected, + api_call_count: u64 = 0, + last_action: c_int = -1, + cell_reads: u32 = 0, + cell_writes: u32 = 0, + sheet_queries: u32 = 0, + format_queries: u32 = 0, +}; + +var sessions: [MAX_SESSIONS]SessionSlot = .{SessionSlot{}} ** MAX_SESSIONS; +var mutex: std.Thread.Mutex = .{}; + +// --------------------------------------------------------------------------- +// C-ABI exports β€” state machine +// --------------------------------------------------------------------------- + +pub export fn google_sheets_mcp_can_transition(from: c_int, to: c_int) c_int { + const f = std.meta.intToEnum(SessionState, from) catch return 0; + const t = std.meta.intToEnum(SessionState, to) catch return 0; + return if (isValidTransition(f, t)) 1 else 0; +} + +pub export fn google_sheets_mcp_connect(dummy: c_int) c_int { + _ = dummy; + mutex.lock(); + defer mutex.unlock(); + + for (&sessions, 0..) |*slot, idx| { + if (!slot.active) { + slot.active = true; + slot.state = .connected; + slot.api_call_count = 0; + slot.last_action = -1; + slot.cell_reads = 0; + slot.cell_writes = 0; + slot.sheet_queries = 0; + slot.format_queries = 0; + return @intCast(idx); + } + } + return -1; +} + +pub export fn google_sheets_mcp_disconnect(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + + sessions[idx] = SessionSlot{}; + return 0; +} + +pub export fn google_sheets_mcp_session_state(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + return @intFromEnum(slot.state); +} + +pub export fn google_sheets_mcp_throttle(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + if (!isValidTransition(slot.state, .rate_limited)) return -2; + + sessions[idx].state = .rate_limited; + return 0; +} + +pub export fn google_sheets_mcp_unthrottle(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + if (!isValidTransition(slot.state, .connected)) return -2; + + sessions[idx].state = .connected; + return 0; +} + +pub export fn google_sheets_mcp_signal_error(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + if (!isValidTransition(slot.state, .err)) return -2; + + sessions[idx].state = .err; + return 0; +} + +// --------------------------------------------------------------------------- +// C-ABI exports β€” action recording and metrics +// --------------------------------------------------------------------------- + +pub export fn google_sheets_mcp_record_call(slot_idx: c_int, action: c_int) c_int { + const act = std.meta.intToEnum(GoogleSheetsAction, action) catch return -3; + + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + if (slot.state != .connected) return -2; + + sessions[idx].api_call_count += 1; + sessions[idx].last_action = action; + + switch (act) { + .read_range, .batch_read, .get_named_ranges => sessions[idx].cell_reads += 1, + .write_range, .append_rows => sessions[idx].cell_writes += 1, + .get_spreadsheet, .list_sheets, .create_sheet => sessions[idx].sheet_queries += 1, + .get_conditional_formats, .get_pivot_tables => sessions[idx].format_queries += 1, + } + + return 0; +} + +pub export fn google_sheets_mcp_call_count(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + return @intCast(slot.api_call_count); +} + +pub export fn google_sheets_mcp_cell_read_count(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + return @intCast(slot.cell_reads); +} + +pub export fn google_sheets_mcp_cell_write_count(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + return @intCast(slot.cell_writes); +} + +pub export fn google_sheets_mcp_sheet_query_count(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + return @intCast(slot.sheet_queries); +} + +pub export fn google_sheets_mcp_format_query_count(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + return @intCast(slot.format_queries); +} + +pub export fn google_sheets_mcp_action_count() c_int { + return 10; +} + +pub export fn google_sheets_mcp_reset() void { + mutex.lock(); + defer mutex.unlock(); + sessions = .{SessionSlot{}} ** MAX_SESSIONS; +} + +// --------------------------------------------------------------------------- +// ═══════════════════════════════════════════════════════════════════════ +// Standard ABI (ADR-0005 four symbols + ADR-0006 invoke) +// ═══════════════════════════════════════════════════════════════════════ + +const shim = @import("cartridge_shim.zig"); + +const CARTRIDGE_NAME_PTR: [*:0]const u8 = "google-sheets-mcp"; +const CARTRIDGE_VERSION_PTR: [*:0]const u8 = "0.1.0"; + +export fn boj_cartridge_init() callconv(.c) c_int { + return 0; +} + +export fn boj_cartridge_deinit() callconv(.c) void {} + +export fn boj_cartridge_name() callconv(.c) [*:0]const u8 { + return CARTRIDGE_NAME_PTR; +} + +export fn boj_cartridge_version() callconv(.c) [*:0]const u8 { + return CARTRIDGE_VERSION_PTR; +} + +/// Dispatch the cartridge.json MCP tools. Grade D Alpha stubs. +export fn boj_cartridge_invoke( + tool_name: [*c]const u8, + json_args: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) callconv(.c) i32 { + _ = json_args; + if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; + + const body: []const u8 = if (shim.toolIs(tool_name, "gsheets_get_spreadsheet")) + "{\"result\":{\"metadata\":{},\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "gsheets_read_range")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "gsheets_list_sheets")) + "{\"result\":{\"items\":[],\"count\":0,\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "gsheets_get_named_ranges")) + "{\"result\":{\"metadata\":{},\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "gsheets_write_range")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "gsheets_append_rows")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "gsheets_create_sheet")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "gsheets_batch_read")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "gsheets_get_conditional_formats")) + "{\"result\":{\"metadata\":{},\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "gsheets_get_pivot_tables")) + "{\"result\":{\"metadata\":{},\"status\":\"stub\"}}" +else + return shim.RC_UNKNOWN_TOOL; + + return shim.writeResult(out_buf, in_out_len, body); +} + +// Tests +// --------------------------------------------------------------------------- + +test "connected session lifecycle" { + google_sheets_mcp_reset(); + + const slot = google_sheets_mcp_connect(0); + try std.testing.expect(slot >= 0); + try std.testing.expectEqual(@as(c_int, 1), google_sheets_mcp_session_state(slot)); + + try std.testing.expectEqual(@as(c_int, 0), google_sheets_mcp_record_call(slot, 1)); + try std.testing.expectEqual(@as(c_int, 1), google_sheets_mcp_call_count(slot)); + try std.testing.expectEqual(@as(c_int, 1), google_sheets_mcp_cell_read_count(slot)); + + try std.testing.expectEqual(@as(c_int, 0), google_sheets_mcp_disconnect(slot)); +} + +test "requires connected state for actions" { + google_sheets_mcp_reset(); + + const slot = google_sheets_mcp_connect(0); + try std.testing.expect(slot >= 0); + + try std.testing.expectEqual(@as(c_int, 0), google_sheets_mcp_throttle(slot)); + try std.testing.expectEqual(@as(c_int, -2), google_sheets_mcp_record_call(slot, 0)); + + try std.testing.expectEqual(@as(c_int, 0), google_sheets_mcp_unthrottle(slot)); + try std.testing.expectEqual(@as(c_int, 0), google_sheets_mcp_record_call(slot, 0)); +} + +test "category counting" { + google_sheets_mcp_reset(); + + const slot = google_sheets_mcp_connect(0); + try std.testing.expect(slot >= 0); + + try std.testing.expectEqual(@as(c_int, 0), google_sheets_mcp_record_call(slot, 1)); // ReadRange + try std.testing.expectEqual(@as(c_int, 0), google_sheets_mcp_record_call(slot, 4)); // WriteRange + try std.testing.expectEqual(@as(c_int, 0), google_sheets_mcp_record_call(slot, 0)); // GetSpreadsheet + try std.testing.expectEqual(@as(c_int, 0), google_sheets_mcp_record_call(slot, 8)); // GetConditionalFormats + + try std.testing.expectEqual(@as(c_int, 4), google_sheets_mcp_call_count(slot)); + try std.testing.expectEqual(@as(c_int, 1), google_sheets_mcp_cell_read_count(slot)); + try std.testing.expectEqual(@as(c_int, 1), google_sheets_mcp_cell_write_count(slot)); + try std.testing.expectEqual(@as(c_int, 1), google_sheets_mcp_sheet_query_count(slot)); + try std.testing.expectEqual(@as(c_int, 1), google_sheets_mcp_format_query_count(slot)); +} + +test "transition validator" { + try std.testing.expectEqual(@as(c_int, 1), google_sheets_mcp_can_transition(0, 1)); + try std.testing.expectEqual(@as(c_int, 1), google_sheets_mcp_can_transition(1, 0)); + try std.testing.expectEqual(@as(c_int, 1), google_sheets_mcp_can_transition(1, 2)); + try std.testing.expectEqual(@as(c_int, 1), google_sheets_mcp_can_transition(2, 1)); + try std.testing.expectEqual(@as(c_int, 0), google_sheets_mcp_can_transition(2, 3)); + try std.testing.expectEqual(@as(c_int, 0), google_sheets_mcp_can_transition(0, 2)); +} + +test "slot exhaustion" { + google_sheets_mcp_reset(); + + var slots: [MAX_SESSIONS]c_int = undefined; + for (&slots) |*s| { + s.* = google_sheets_mcp_connect(0); + try std.testing.expect(s.* >= 0); + } + + try std.testing.expectEqual(@as(c_int, -1), google_sheets_mcp_connect(0)); + + try std.testing.expectEqual(@as(c_int, 0), google_sheets_mcp_disconnect(slots[0])); + const new_slot = google_sheets_mcp_connect(0); + try std.testing.expect(new_slot >= 0); +} + +// ═══════════════════════════════════════════════════════════════════════ +// ADR-0006 invoke dispatch tests +// ═══════════════════════════════════════════════════════════════════════ + +test "boj_cartridge_name returns google-sheets-mcp" { + const n = std.mem.span(boj_cartridge_name()); + try std.testing.expectEqualStrings("google-sheets-mcp", n); +} + +test "boj_cartridge_init returns 0" { + try std.testing.expectEqual(@as(c_int, 0), boj_cartridge_init()); +} + +test "invoke: each declared tool succeeds" { + var buf: [256]u8 = undefined; + const tools = [_][]const u8{ + "gsheets_get_spreadsheet", + "gsheets_read_range", + "gsheets_list_sheets", + "gsheets_get_named_ranges", + "gsheets_write_range", + "gsheets_append_rows", + "gsheets_create_sheet", + "gsheets_batch_read", + "gsheets_get_conditional_formats", + "gsheets_get_pivot_tables", + }; + for (tools) |t| { + var len: usize = buf.len; + const rc = boj_cartridge_invoke(t.ptr, "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, 0), rc); + try std.testing.expect(std.mem.indexOf(u8, buf[0..len], "result") != null); + } +} + +test "invoke: unknown tool returns -1" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("nope", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -1), rc); +} + +test "invoke: buffer too small returns -3" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("gsheets_get_spreadsheet", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -3), rc); + try std.testing.expect(len > 4); +} diff --git a/cartridges/domains/productivity/google-sheets-mcp/minter.toml b/cartridges/domains/productivity/google-sheets-mcp/minter.toml new file mode 100644 index 0000000..715449e --- /dev/null +++ b/cartridges/domains/productivity/google-sheets-mcp/minter.toml @@ -0,0 +1,23 @@ +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> + +name = "google-sheets-mcp" +description = "Google Sheets cartridge β€” spreadsheet metadata, cell range reading, sheet listing, named ranges, cell writing, row appending, sheet creation, batch reads, conditional formats, pivot tables" +version = "0.2.0" +domain = "Productivity" +protocols = [ + "MCP", + "REST", +] +tier = "Ayo" +backend = "universal" +generate_panel = true + +[auth] +method = "oauth2" +credential_source = "vault-mcp" +fields = ["google_sheets_access_token"] + +[api] +base_url = "https://sheets.googleapis.com/v4" +local_only = false diff --git a/cartridges/domains/productivity/google-sheets-mcp/mod.js b/cartridges/domains/productivity/google-sheets-mcp/mod.js new file mode 100644 index 0000000..6fd22da --- /dev/null +++ b/cartridges/domains/productivity/google-sheets-mcp/mod.js @@ -0,0 +1,271 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// google-sheets-mcp/mod.js -- Google Sheets cartridge implementation. +// +// Provides MCP tool handlers for the Google Sheets API v4: +// - Spreadsheet metadata retrieval +// - Cell range reading (single and batch) +// - Sheet (tab) listing +// - Named range access +// - Cell value writing +// - Row appending +// - Sheet creation +// - Conditional format listing +// - Pivot table access +// +// Auth: OAuth2 Bearer token via GOOGLE_SHEETS_ACCESS_TOKEN (required). +// API docs: https://developers.google.com/sheets/api/reference/rest/v4 +// +// Usage: import { handleTool } from "./mod.js"; +// or: deno run --allow-net --allow-env mod.js + +const API_BASE = "https://sheets.googleapis.com/v4"; + +// --------------------------------------------------------------------------- +// Auth helper β€” retrieves the Google OAuth2 access token from environment. +// In production, vault-mcp provides zero-knowledge credential proxying. +// --------------------------------------------------------------------------- + +function getToken() { + const token = typeof Deno !== "undefined" + ? Deno.env.get("GOOGLE_SHEETS_ACCESS_TOKEN") + : process.env.GOOGLE_SHEETS_ACCESS_TOKEN; + return token || null; +} + +// --------------------------------------------------------------------------- +// HTTP request helper β€” wraps fetch with Google Sheets API headers, +// OAuth2 bearer auth, and error normalization. +// --------------------------------------------------------------------------- + +async function sheetsFetch(path, queryParams, method, body) { + const url = new URL(`${API_BASE}${path}`); + + if (queryParams) { + for (const [key, value] of Object.entries(queryParams)) { + if (value !== undefined && value !== null && value !== "") { + if (Array.isArray(value)) { + for (const v of value) url.searchParams.append(key, v); + } else { + url.searchParams.set(key, String(value)); + } + } + } + } + + const headers = { + "Accept": "application/json", + "User-Agent": "boj-server/google-sheets-mcp/0.2.0", + }; + + const token = getToken(); + if (!token) { + return { status: 401, error: "GOOGLE_SHEETS_ACCESS_TOKEN not set." }; + } + headers["Authorization"] = `Bearer ${token}`; + + const fetchOpts = { method: method || "GET", headers }; + + if (body) { + headers["Content-Type"] = "application/json"; + fetchOpts.body = JSON.stringify(body); + } + + const response = await fetch(url.toString(), fetchOpts); + + if (response.status === 429) { + const retryAfter = response.headers.get("retry-after"); + return { + status: 429, + error: `Rate limited. Retry after ${retryAfter || "unknown"} seconds.`, + retryAfter, + }; + } + + const data = await response.json().catch(() => ({})); + + if (!response.ok) { + const errorMessage = data.error?.message || data.message || `HTTP ${response.status}`; + return { status: response.status, error: errorMessage, data }; + } + + return { status: response.status, data }; +} + +// --------------------------------------------------------------------------- +// Tool handler dispatch β€” maps MCP tool names to Google Sheets API operations. +// --------------------------------------------------------------------------- + +export async function handleTool(toolName, args) { + switch (toolName) { + + // --- Spreadsheet metadata --- + + case "gsheets_get_spreadsheet": { + if (!args.spreadsheet_id) return { error: "Missing required field: spreadsheet_id" }; + return sheetsFetch(`/spreadsheets/${encodeURIComponent(args.spreadsheet_id)}`, { + fields: "spreadsheetId,properties,sheets.properties,namedRanges", + }); + } + + // --- Read range --- + + case "gsheets_read_range": { + if (!args.spreadsheet_id) return { error: "Missing required field: spreadsheet_id" }; + if (!args.range) return { error: "Missing required field: range" }; + return sheetsFetch( + `/spreadsheets/${encodeURIComponent(args.spreadsheet_id)}/values/${encodeURIComponent(args.range)}`, + { valueRenderOption: args.value_render || "FORMATTED_VALUE" }, + ); + } + + // --- List sheets --- + + case "gsheets_list_sheets": { + if (!args.spreadsheet_id) return { error: "Missing required field: spreadsheet_id" }; + const result = await sheetsFetch(`/spreadsheets/${encodeURIComponent(args.spreadsheet_id)}`, { + fields: "sheets.properties", + }); + if (result.error) return result; + return { status: result.status, data: { sheets: (result.data.sheets || []).map((s) => s.properties) } }; + } + + // --- Named ranges --- + + case "gsheets_get_named_ranges": { + if (!args.spreadsheet_id) return { error: "Missing required field: spreadsheet_id" }; + const result = await sheetsFetch(`/spreadsheets/${encodeURIComponent(args.spreadsheet_id)}`, { + fields: "namedRanges", + }); + if (result.error) return result; + return { status: result.status, data: { namedRanges: result.data.namedRanges || [] } }; + } + + // --- Write range --- + + case "gsheets_write_range": { + if (!args.spreadsheet_id) return { error: "Missing required field: spreadsheet_id" }; + if (!args.range) return { error: "Missing required field: range" }; + if (!args.values) return { error: "Missing required field: values" }; + const values = typeof args.values === "string" ? JSON.parse(args.values) : args.values; + return sheetsFetch( + `/spreadsheets/${encodeURIComponent(args.spreadsheet_id)}/values/${encodeURIComponent(args.range)}`, + { valueInputOption: args.value_input || "USER_ENTERED" }, + "PUT", + { range: args.range, majorDimension: "ROWS", values }, + ); + } + + // --- Append rows --- + + case "gsheets_append_rows": { + if (!args.spreadsheet_id) return { error: "Missing required field: spreadsheet_id" }; + if (!args.range) return { error: "Missing required field: range" }; + if (!args.values) return { error: "Missing required field: values" }; + const values = typeof args.values === "string" ? JSON.parse(args.values) : args.values; + return sheetsFetch( + `/spreadsheets/${encodeURIComponent(args.spreadsheet_id)}/values/${encodeURIComponent(args.range)}:append`, + { + valueInputOption: args.value_input || "USER_ENTERED", + insertDataOption: "INSERT_ROWS", + }, + "POST", + { range: args.range, majorDimension: "ROWS", values }, + ); + } + + // --- Create sheet --- + + case "gsheets_create_sheet": { + if (!args.spreadsheet_id) return { error: "Missing required field: spreadsheet_id" }; + if (!args.title) return { error: "Missing required field: title" }; + return sheetsFetch( + `/spreadsheets/${encodeURIComponent(args.spreadsheet_id)}:batchUpdate`, + null, + "POST", + { + requests: [{ + addSheet: { + properties: { + title: args.title, + gridProperties: { + rowCount: args.row_count || 1000, + columnCount: args.column_count || 26, + }, + }, + }, + }], + }, + ); + } + + // --- Batch read --- + + case "gsheets_batch_read": { + if (!args.spreadsheet_id) return { error: "Missing required field: spreadsheet_id" }; + if (!args.ranges) return { error: "Missing required field: ranges" }; + const ranges = args.ranges.split(",").map((r) => r.trim()); + return sheetsFetch( + `/spreadsheets/${encodeURIComponent(args.spreadsheet_id)}/values:batchGet`, + { + ranges, + valueRenderOption: args.value_render || "FORMATTED_VALUE", + }, + ); + } + + // --- Conditional formats --- + + case "gsheets_get_conditional_formats": { + if (!args.spreadsheet_id) return { error: "Missing required field: spreadsheet_id" }; + if (args.sheet_id === undefined) return { error: "Missing required field: sheet_id" }; + const result = await sheetsFetch(`/spreadsheets/${encodeURIComponent(args.spreadsheet_id)}`, { + fields: "sheets(properties,conditionalFormats)", + }); + if (result.error) return result; + const sheet = (result.data.sheets || []).find((s) => s.properties.sheetId === args.sheet_id); + return { status: result.status, data: { conditionalFormats: sheet?.conditionalFormats || [] } }; + } + + // --- Pivot tables --- + + case "gsheets_get_pivot_tables": { + if (!args.spreadsheet_id) return { error: "Missing required field: spreadsheet_id" }; + const result = await sheetsFetch(`/spreadsheets/${encodeURIComponent(args.spreadsheet_id)}`, { + fields: "sheets(properties,data.rowData.values.pivotTable)", + }); + if (result.error) return result; + const pivots = []; + for (const sheet of result.data.sheets || []) { + if (sheet.data) { + for (const rowData of sheet.data) { + for (const row of rowData.rowData || []) { + for (const cell of row.values || []) { + if (cell.pivotTable) pivots.push({ sheet: sheet.properties.title, pivotTable: cell.pivotTable }); + } + } + } + } + } + return { status: result.status, data: { pivotTables: pivots } }; + } + + default: + return { error: `Unknown google-sheets-mcp tool: ${toolName}` }; + } +} + +// --------------------------------------------------------------------------- +// Cartridge metadata export β€” used by the BoJ cartridge loader to register +// this cartridge's tools without reading cartridge.json separately. +// --------------------------------------------------------------------------- + +export const metadata = { + name: "google-sheets-mcp", + version: "0.2.0", + domain: "Productivity", + tier: "Ayo", + protocols: ["MCP", "REST"], + toolCount: 10, +}; diff --git a/cartridges/domains/productivity/google-sheets-mcp/panels/manifest.json b/cartridges/domains/productivity/google-sheets-mcp/panels/manifest.json new file mode 100644 index 0000000..da6b34c --- /dev/null +++ b/cartridges/domains/productivity/google-sheets-mcp/panels/manifest.json @@ -0,0 +1,90 @@ +{ + "$schema": "https://panll.dev/schemas/panel-manifest/v1.json", + "spdx": "MPL-2.0", + "copyright": "Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath)", + "cartridge": "google-sheets-mcp", + "domain": "Productivity", + "version": "0.2.0", + "panels": [ + { + "id": "gsheets-connection-status", + "title": "Connection Status", + "description": "Google Sheets session state (Disconnected / Connected / RateLimited / Error)", + "type": "status-indicator", + "data_source": { + "endpoint": "/google-sheets/state", + "method": "GET", + "refresh_interval_ms": 5000 + }, + "widgets": [ + { + "type": "state-badge", + "field": "state", + "states": { + "disconnected": { "color": "#95a5a6", "icon": "wifi-off" }, + "connected": { "color": "#2ecc71", "icon": "wifi" }, + "rate_limited": { "color": "#f39c12", "icon": "clock" }, + "error": { "color": "#e74c3c", "icon": "alert-triangle" } + } + } + ] + }, + { + "id": "gsheets-cell-reads", + "title": "Cell Reads", + "description": "Number of cell/range read operations in the current session", + "type": "metric", + "data_source": { + "endpoint": "/google-sheets/status", + "method": "GET", + "refresh_interval_ms": 10000 + }, + "widgets": [ + { + "type": "counter", + "field": "cell_reads", + "label": "Cell Reads", + "icon": "grid" + } + ] + }, + { + "id": "gsheets-cell-writes", + "title": "Cell Writes", + "description": "Number of cell/range write operations in the current session", + "type": "metric", + "data_source": { + "endpoint": "/google-sheets/status", + "method": "GET", + "refresh_interval_ms": 10000 + }, + "widgets": [ + { + "type": "counter", + "field": "cell_writes", + "label": "Cell Writes", + "icon": "edit" + } + ] + }, + { + "id": "gsheets-sheet-queries", + "title": "Sheet Queries", + "description": "Number of sheet/spreadsheet metadata queries in the current session", + "type": "metric", + "data_source": { + "endpoint": "/google-sheets/status", + "method": "GET", + "refresh_interval_ms": 10000 + }, + "widgets": [ + { + "type": "counter", + "field": "sheet_queries", + "label": "Sheet Queries", + "icon": "layers" + } + ] + } + ] +} diff --git a/cartridges/domains/productivity/google-sheets-mcp/tests/integration_test.sh b/cartridges/domains/productivity/google-sheets-mcp/tests/integration_test.sh new file mode 100755 index 0000000..4ec7366 --- /dev/null +++ b/cartridges/domains/productivity/google-sheets-mcp/tests/integration_test.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Integration tests for google-sheets-mcp cartridge. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CART_DIR="$(dirname "$SCRIPT_DIR")" +FFI_DIR="$CART_DIR/ffi" + +echo "=== google-sheets-mcp integration tests ===" + +# Build FFI +echo "[1/3] Building FFI..." +cd "$FFI_DIR" && zig build 2>&1 + +# Run Zig unit tests +echo "[2/3] Running FFI unit tests..." +cd "$FFI_DIR" && zig build test 2>&1 + +# Validate Idris2 ABI (if idris2 available) +echo "[3/3] Checking ABI..." +if command -v idris2 &>/dev/null; then + cd "$CART_DIR/abi" && idris2 --check GoogleSheetsMcp.SafeRegistry 2>&1 + echo " ABI: OK" +else + echo " ABI: SKIPPED (idris2 not in PATH)" +fi + +echo "" +echo "All tests passed for google-sheets-mcp!" diff --git a/cartridges/domains/productivity/notion-mcp/README.adoc b/cartridges/domains/productivity/notion-mcp/README.adoc new file mode 100644 index 0000000..d268310 --- /dev/null +++ b/cartridges/domains/productivity/notion-mcp/README.adoc @@ -0,0 +1,126 @@ += notion-mcp +Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk> +:spdx: MPL-2.0 +:tier: Ayo +:domain: Comms +:protocols: MCP, REST + +== Overview + +Notion REST API cartridge for the BoJ server. +Wraps the full Notion platform β€” pages, databases, blocks, users, +and comments β€” behind a type-safe, rate-limit-aware interface using +the REST API at https://api.notion.com/v1/ with Notion-Version header +`2022-06-28`. + +== Architecture + +[cols="1,1,2"] +|=== +| Layer | Language | Purpose + +| ABI +| Idris2 +| Formally verified connection state machine (`SafeComms.idr`) with + dependent-type proofs for all valid transitions + +| FFI +| Zig +| C-compatible implementation with thread-safe session pool, Notion + HTTP client stubs, Notion-Version header, and rate-limit tracking + +| Adapter +| zig +| REST bridge exposing all 16 Notion actions to the BoJ unified adapter +|=== + +== Integration Token Setup + +This cartridge authenticates using Notion integration tokens (Bearer). + +1. Go to https://www.notion.so/my-integrations +2. Create a new **internal integration** with the capabilities you need: + - **Read content** β€” search, read pages/databases/blocks + - **Update content** β€” create/update pages, append blocks + - **Insert content** β€” create databases, comments +3. Copy the **Internal Integration Secret** (`secret_...` or `ntn_...`) +4. Share the relevant Notion pages/databases with your integration +5. Store the token in **vault-mcp** so the cartridge can retrieve it at authentication time + +== Notion Actions (16 total) + +[cols="1,2,1"] +|=== +| Action | REST Endpoint | Method + +| SearchPages | `POST /v1/search` | POST +| GetPage | `GET /v1/pages/{page_id}` | GET +| CreatePage | `POST /v1/pages` | POST +| UpdatePage | `PATCH /v1/pages/{page_id}` | PATCH +| DeletePage | `PATCH /v1/pages/{page_id}` (archived=true) | PATCH +| GetDatabase | `GET /v1/databases/{database_id}` | GET +| QueryDatabase | `POST /v1/databases/{database_id}/query` | POST +| CreateDatabase | `POST /v1/databases` | POST +| ListBlocks | `GET /v1/blocks/{block_id}/children` | GET +| GetBlock | `GET /v1/blocks/{block_id}` | GET +| AppendBlocks | `PATCH /v1/blocks/{block_id}/children` | PATCH +| DeleteBlock | `DELETE /v1/blocks/{block_id}` | DELETE +| ListUsers | `GET /v1/users` | GET +| GetUser | `GET /v1/users/{user_id}` | GET +| CreateComment | `POST /v1/comments` | POST +| ListComments | `GET /v1/comments?block_id={block_id}` | GET +|=== + +== Connection State Machine + +.... +Unauthenticated ──→ Authenticated ──→ Unauthenticated + β”‚ ↑ + ↓ β”‚ + RateLimited + β”‚ + ↓ + Error ──→ Unauthenticated +.... + +Valid transitions: + +- `Unauthenticated` -> `Authenticated` (authenticate with integration token) +- `Authenticated` -> `RateLimited` (rate budget exhausted: ~3 req/s) +- `RateLimited` -> `Authenticated` (budget window reset) +- `Authenticated` -> `Error` (network/API fault) +- `RateLimited` -> `Error` (unrecoverable error during rate limit) +- `Authenticated` -> `Unauthenticated` (graceful close) +- `Error` -> `Unauthenticated` (reset for re-auth) + +== Rate Limits + +Notion enforces approximately 3 requests per second per integration. +The cartridge tracks usage and transitions to `RateLimited` state +when the budget is exhausted. The budget resets every second. + +== Building + +[source,bash] +---- +# Build FFI shared library +cd ffi && zig build + +# Run FFI tests +cd ffi && zig build test + +# Type-check ABI +cd abi && idris2 --check notion_mcp.ipkg +---- + +== PanLL Panels + +The `panels/manifest.json` provides three data sources for the PanLL dashboard: + +- **Auth Status** β€” live state badge (5 s refresh) +- **Workspace** β€” connected workspace name (30 s refresh) +- **Page Operations** β€” session page operation counter (10 s refresh) + +== Status + +Development β€” not yet ready for mounting. diff --git a/cartridges/domains/productivity/notion-mcp/abi/NotionMcp/SafeComms.idr b/cartridges/domains/productivity/notion-mcp/abi/NotionMcp/SafeComms.idr new file mode 100644 index 0000000..2db23a8 --- /dev/null +++ b/cartridges/domains/productivity/notion-mcp/abi/NotionMcp/SafeComms.idr @@ -0,0 +1,223 @@ +-- SPDX-License-Identifier: MPL-2.0 +-- Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +-- +-- NotionMcp.SafeComms β€” Type-safe ABI for the Notion REST API cartridge. +-- +-- Dependent-type state machine governing Notion connection lifecycle. +-- All transitions proven valid at compile time. Zero unsafe escape hatches. + +module NotionMcp.SafeComms + +%default total + +-- --------------------------------------------------------------------------- +-- Connection state machine +-- --------------------------------------------------------------------------- + +||| Connection lifecycle states for Notion API interactions. +||| Models the full lifecycle: authentication via integration token, +||| connected operation against https://api.notion.com/v1/, rate limiting +||| (Notion enforces 3 requests/second), and error recovery. +public export +data ConnState + = Unauthenticated + | Authenticated + | RateLimited + | Error + +-- --------------------------------------------------------------------------- +-- Valid state transitions (proven at the type level) +-- --------------------------------------------------------------------------- + +||| Proof witness that a state transition is permitted. +||| Only the transitions enumerated here can ever occur at the FFI boundary. +public export +data ValidTransition : ConnState -> ConnState -> Type where + ||| Authenticate with a Notion integration token (Bearer). + Authenticate : ValidTransition Unauthenticated Authenticated + ||| Hit a Notion rate limit (3 req/s budget exhausted). + HitRateLimit : ValidTransition Authenticated RateLimited + ||| Rate limit window expired β€” resume operations. + RateRecovered : ValidTransition RateLimited Authenticated + ||| Operational error while authenticated (network, API fault). + AuthError : ValidTransition Authenticated Error + ||| Rate-limited session encounters an unrecoverable error. + RateLimitError : ValidTransition RateLimited Error + ||| Error recovery β€” return to unauthenticated for re-auth. + ErrorReset : ValidTransition Error Unauthenticated + ||| Graceful disconnect from an authenticated session. + GracefulClose : ValidTransition Authenticated Unauthenticated + +-- --------------------------------------------------------------------------- +-- C-ABI integer encoding for ConnState +-- --------------------------------------------------------------------------- + +||| Encode connection state as a C-compatible integer. +||| Mapping: Unauthenticated=0, Authenticated=1, RateLimited=2, Error=3. +export +connStateToInt : ConnState -> Int +connStateToInt Unauthenticated = 0 +connStateToInt Authenticated = 1 +connStateToInt RateLimited = 2 +connStateToInt Error = 3 + +||| Decode a C integer back to a connection state. +||| Returns Nothing for out-of-range values. +export +intToConnState : Int -> Maybe ConnState +intToConnState 0 = Just Unauthenticated +intToConnState 1 = Just Authenticated +intToConnState 2 = Just RateLimited +intToConnState 3 = Just Error +intToConnState _ = Nothing + +||| C-ABI export: check whether a state transition is valid. +||| Returns 1 for valid, 0 for invalid. Used by the Zig FFI layer. +export +notion_mcp_can_transition : Int -> Int -> Int +notion_mcp_can_transition from to = + case (intToConnState from, intToConnState to) of + (Just Unauthenticated, Just Authenticated) => 1 + (Just Authenticated, Just RateLimited) => 1 + (Just RateLimited, Just Authenticated) => 1 + (Just Authenticated, Just Error) => 1 + (Just RateLimited, Just Error) => 1 + (Just Error, Just Unauthenticated) => 1 + (Just Authenticated, Just Unauthenticated) => 1 + _ => 0 + +-- --------------------------------------------------------------------------- +-- Notion action vocabulary +-- --------------------------------------------------------------------------- + +||| All actions supported by the notion-mcp cartridge. +||| Each maps to a Notion REST API endpoint under +||| https://api.notion.com/v1/. +public export +data NotionAction + = SearchPages -- POST /search + | GetPage -- GET /pages/{page_id} + | CreatePage -- POST /pages + | UpdatePage -- PATCH /pages/{page_id} + | DeletePage -- PATCH /pages/{page_id} (archived=true) + | GetDatabase -- GET /databases/{database_id} + | QueryDatabase -- POST /databases/{database_id}/query + | CreateDatabase -- POST /databases + | ListBlocks -- GET /blocks/{block_id}/children + | GetBlock -- GET /blocks/{block_id} + | AppendBlocks -- PATCH /blocks/{block_id}/children + | DeleteBlock -- DELETE /blocks/{block_id} + | ListUsers -- GET /users + | GetUser -- GET /users/{user_id} + | CreateComment -- POST /comments + | ListComments -- GET /comments?block_id={block_id} + +||| Encode a NotionAction as a C integer for FFI. +export +notionActionToInt : NotionAction -> Int +notionActionToInt SearchPages = 0 +notionActionToInt GetPage = 1 +notionActionToInt CreatePage = 2 +notionActionToInt UpdatePage = 3 +notionActionToInt DeletePage = 4 +notionActionToInt GetDatabase = 5 +notionActionToInt QueryDatabase = 6 +notionActionToInt CreateDatabase = 7 +notionActionToInt ListBlocks = 8 +notionActionToInt GetBlock = 9 +notionActionToInt AppendBlocks = 10 +notionActionToInt DeleteBlock = 11 +notionActionToInt ListUsers = 12 +notionActionToInt GetUser = 13 +notionActionToInt CreateComment = 14 +notionActionToInt ListComments = 15 + +||| Decode a C integer back to a NotionAction. +export +intToNotionAction : Int -> Maybe NotionAction +intToNotionAction 0 = Just SearchPages +intToNotionAction 1 = Just GetPage +intToNotionAction 2 = Just CreatePage +intToNotionAction 3 = Just UpdatePage +intToNotionAction 4 = Just DeletePage +intToNotionAction 5 = Just GetDatabase +intToNotionAction 6 = Just QueryDatabase +intToNotionAction 7 = Just CreateDatabase +intToNotionAction 8 = Just ListBlocks +intToNotionAction 9 = Just GetBlock +intToNotionAction 10 = Just AppendBlocks +intToNotionAction 11 = Just DeleteBlock +intToNotionAction 12 = Just ListUsers +intToNotionAction 13 = Just GetUser +intToNotionAction 14 = Just CreateComment +intToNotionAction 15 = Just ListComments +intToNotionAction _ = Nothing + +||| Total action count exposed via C-ABI. +export +notion_mcp_action_count : Int +notion_mcp_action_count = 16 + +-- --------------------------------------------------------------------------- +-- HTTP method classification +-- --------------------------------------------------------------------------- + +||| HTTP methods used by Notion REST API endpoints. +public export +data HttpMethod = GET | POST | PATCH | DELETE + +||| Map each action to its HTTP method. +export +actionHttpMethod : NotionAction -> HttpMethod +actionHttpMethod SearchPages = POST +actionHttpMethod GetPage = GET +actionHttpMethod CreatePage = POST +actionHttpMethod UpdatePage = PATCH +actionHttpMethod DeletePage = PATCH -- uses archived=true +actionHttpMethod GetDatabase = GET +actionHttpMethod QueryDatabase = POST +actionHttpMethod CreateDatabase = POST +actionHttpMethod ListBlocks = GET +actionHttpMethod GetBlock = GET +actionHttpMethod AppendBlocks = PATCH +actionHttpMethod DeleteBlock = DELETE +actionHttpMethod ListUsers = GET +actionHttpMethod GetUser = GET +actionHttpMethod CreateComment = POST +actionHttpMethod ListComments = GET + +||| C-ABI export: check if an action requires an authenticated state. +||| All Notion actions require authentication. +export +notion_mcp_action_requires_auth : Int -> Int +notion_mcp_action_requires_auth actionId = + case intToNotionAction actionId of + Just _ => 1 -- all actions require Authenticated state + Nothing => 0 -- unknown action + +-- --------------------------------------------------------------------------- +-- MCP tool declarations +-- --------------------------------------------------------------------------- + +||| Tools exposed via the MCP protocol by this cartridge. +public export +data McpTool + = ToolConnect + | ToolDisconnect + | ToolStatus + | ToolInvoke + | ToolList + +||| Check if a tool requires an authenticated session. +export +toolRequiresSession : McpTool -> Bool +toolRequiresSession ToolConnect = False +toolRequiresSession ToolDisconnect = True +toolRequiresSession ToolStatus = False +toolRequiresSession ToolInvoke = True +toolRequiresSession ToolList = False + +||| Tool count for this cartridge. +export +toolCount : Nat +toolCount = 5 diff --git a/cartridges/domains/productivity/notion-mcp/abi/README.adoc b/cartridges/domains/productivity/notion-mcp/abi/README.adoc new file mode 100644 index 0000000..3923e7c --- /dev/null +++ b/cartridges/domains/productivity/notion-mcp/abi/README.adoc @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += notion-mcp / abi β€” Idris2 ABI layer +:orientation: shallow + +== Purpose + +Cartridge-local protocol types for `notion-mcp`. Defines the data, tool, and +transition vocabulary this cartridge speaks. Inherits safety lemmas and the +matrix-cell identifier scheme from the trunk ABI at `src/abi/`. + +== Files + +1 Idris2 module(s), ~223 lines total. Lead module: +`SafeComms.idr`. + +No `.ipkg` β€” consumed directly by wider BoJ callers; type-checks inside the +trunk `boj.ipkg`. + +== Invariants + +* `%default total` at every module top. +* Zero `believe_me`, `assert_total`, `postulate`, `Admitted`, or + `sorry` (the trunk holds the four declared `believe_me` sites; leaf + cartridges carry none). + +== Test/proof surface + +Thin module(s), no inline tests. Proof surface = type-check success under the +trunk `idris2 --check`. + +== Read-first + +. `SafeComms.idr` β€” cartridge-local data records and enums. +. Trunk `src/abi/Boj/Catalogue.idr` for how this cell participates in the + 2D matrix. diff --git a/cartridges/domains/productivity/notion-mcp/abi/notion_mcp.ipkg b/cartridges/domains/productivity/notion-mcp/abi/notion_mcp.ipkg new file mode 100644 index 0000000..b8a7685 --- /dev/null +++ b/cartridges/domains/productivity/notion-mcp/abi/notion_mcp.ipkg @@ -0,0 +1,11 @@ +-- SPDX-License-Identifier: MPL-2.0 +-- Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +package notion_mcp + +version = "0.1.0" +authors = "Jonathan D.A. Jewell" +brief = "Type-safe ABI for the Notion REST API cartridge" + +depends = base + +modules = NotionMcp.SafeComms diff --git a/cartridges/domains/productivity/notion-mcp/adapter/README.adoc b/cartridges/domains/productivity/notion-mcp/adapter/README.adoc new file mode 100644 index 0000000..906a7f6 --- /dev/null +++ b/cartridges/domains/productivity/notion-mcp/adapter/README.adoc @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += notion-mcp / adapter β€” protocol bridge +:orientation: shallow + +== Purpose + +Exposes this cartridge's FFI over the wire protocols declared in +`cartridge.json`. Dispatches tool invocations to the C-ABI exports at +`../ffi/` and formats responses per protocol. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” depends on the sibling `ffi` target. +| `notion_mcp_adapter.zig` | Protocol dispatch (144 lines). Tool catalogue matches `cartridge.json`. +| `SIDELINED-*.v.adoc` | zig predecessor(s). Not built β€” zig banned 2026-04-10. +|=== + +== Invariants + +* **Stateless** β€” all state lives behind the FFI, never in the adapter. +* **Response passthrough** β€” whatever the FFI returns goes back to the wire + unmodified (no embellishment, no silent recovery). +* **Cartridge.json is source of truth** for the tool catalogue; drift between + adapter and manifest is a CI failure. + +== Test/proof surface + +No inline tests at the adapter layer β€” protocol routing is exercised by the +trunk seam tests at `ffi/zig/src/seams.zig`. + +== Read-first + +. `notion_mcp_adapter.zig` β€” dispatch function and per-protocol routers. +. `../cartridge.json` β€” the tool manifest the dispatcher mirrors. diff --git a/cartridges/domains/productivity/notion-mcp/adapter/build.zig b/cartridges/domains/productivity/notion-mcp/adapter/build.zig new file mode 100644 index 0000000..b7a251e --- /dev/null +++ b/cartridges/domains/productivity/notion-mcp/adapter/build.zig @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// notion-mcp/adapter/build.zig + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const ffi_mod = b.createModule(.{ + .root_source_file = b.path("../ffi/notion_mcp_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + const adapter = b.addExecutable(.{ + .name = "notion_mcp_adapter", + .root_source_file = b.path("notion_mcp_adapter.zig"), + .target = target, + .optimize = optimize, + }); + adapter.root_module.addImport("notion_mcp_ffi", ffi_mod); + b.installArtifact(adapter); +} diff --git a/cartridges/domains/productivity/notion-mcp/adapter/notion_mcp_adapter.zig b/cartridges/domains/productivity/notion-mcp/adapter/notion_mcp_adapter.zig new file mode 100644 index 0000000..62d60bc --- /dev/null +++ b/cartridges/domains/productivity/notion-mcp/adapter/notion_mcp_adapter.zig @@ -0,0 +1,144 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// notion-mcp/adapter/notion_mcp_adapter.zig +// +// Three-protocol BoJ adapter: REST (port 9271), gRPC-compat (port 9272), +// GraphQL (port 9273). +// Replaces the banned zig adapter (notion_mcp_adapter.v). + +const std = @import("std"); +const ffi = @import("notion_mcp_ffi"); + +const REST_PORT: u16 = 9271; +const GRPC_PORT: u16 = 9272; +const GQL_PORT: u16 = 9273; + +const Response = struct { status: u16, body: []const u8 }; + +fn okJson(buf: []u8, msg: []const u8) []u8 { + return std.fmt.bufPrint(buf, "{{\"success\":true,\"message\":\"{s}\"}}", .{msg}) catch buf[0..0]; +} + +fn errJson(buf: []u8, msg: []const u8) []u8 { + return std.fmt.bufPrint(buf, "{{\"success\":false,\"error\":\"{s}\"}}", .{msg}) catch buf[0..0]; +} + +fn dispatch(tool: []const u8, _body: []const u8, resp: []u8) Response { + _ = _body; + if (std.mem.eql(u8, tool, "notion_authenticate")) { + return .{ .status = 200, .body = okJson(resp, "notion_authenticate forwarded") }; + } + if (std.mem.eql(u8, tool, "notion_search")) { + return .{ .status = 200, .body = okJson(resp, "notion_search forwarded") }; + } + if (std.mem.eql(u8, tool, "notion_get_page")) { + return .{ .status = 200, .body = okJson(resp, "notion_get_page forwarded") }; + } + if (std.mem.eql(u8, tool, "notion_create_page")) { + return .{ .status = 200, .body = okJson(resp, "notion_create_page forwarded") }; + } + if (std.mem.eql(u8, tool, "notion_update_page")) { + return .{ .status = 200, .body = okJson(resp, "notion_update_page forwarded") }; + } + if (std.mem.eql(u8, tool, "notion_query_database")) { + return .{ .status = 200, .body = okJson(resp, "notion_query_database forwarded") }; + } + if (std.mem.eql(u8, tool, "notion_append_blocks")) { + return .{ .status = 200, .body = okJson(resp, "notion_append_blocks forwarded") }; + } + return .{ .status = 404, .body = errJson(resp, "unknown tool") }; +} + +fn dispatchRest(path: []const u8, body: []const u8, resp: []u8) Response { + // Expect /tools/<tool_name> + const prefix = "/tools/"; + if (std.mem.startsWith(u8, path, prefix)) { + const tool = path[prefix.len..]; + return dispatch(tool, body, resp); + } + return .{ .status = 404, .body = errJson(resp, "not found") }; +} + +fn dispatchGrpc(path: []const u8, body: []const u8, resp: []u8) Response { + // Expect /<Service>/<Method> β€” derive tool from Method + var it = std.mem.splitScalar(u8, path, '/'); + _ = it.next(); // leading empty + _ = it.next(); // service + const method = it.next() orelse return .{ .status = 404, .body = errJson(resp, "bad gRPC path") }; + return dispatch(method, body, resp); +} + +fn dispatchGraphql(body: []const u8, resp: []u8) Response { + if (std.mem.indexOf(u8, body, "notion_authenticate") != null) + return dispatch("notion_authenticate", body, resp); + if (std.mem.indexOf(u8, body, "notion_search") != null) + return dispatch("notion_search", body, resp); + if (std.mem.indexOf(u8, body, "notion_get_page") != null) + return dispatch("notion_get_page", body, resp); + if (std.mem.indexOf(u8, body, "notion_create_page") != null) + return dispatch("notion_create_page", body, resp); + if (std.mem.indexOf(u8, body, "notion_update_page") != null) + return dispatch("notion_update_page", body, resp); + if (std.mem.indexOf(u8, body, "notion_query_database") != null) + return dispatch("notion_query_database", body, resp); + if (std.mem.indexOf(u8, body, "notion_append_blocks") != null) + return dispatch("notion_append_blocks", body, resp); + return .{ .status = 400, .body = errJson(resp, "unrecognised GraphQL operation") }; +} + +fn handleConnection(stream: std.net.Stream, port: u16) void { + defer stream.close(); + var buf: [4096]u8 = undefined; + var resp_buf: [4096]u8 = undefined; + const n = stream.read(&buf) catch return; + const req = buf[0..n]; + const result = switch (port) { + REST_PORT => blk: { + // Parse HTTP/1.1: first line = METHOD PATH HTTP/x.y + var lines = std.mem.splitScalar(u8, req, '\n'); + const first = lines.next() orelse break :blk Response{ .status = 400, .body = "" }; + var parts = std.mem.splitScalar(u8, std.mem.trim(u8, first, "\r"), ' '); + _ = parts.next(); // method + const path = parts.next() orelse break :blk Response{ .status = 400, .body = "" }; + break :blk dispatchRest(path, req, &resp_buf); + }, + GRPC_PORT => blk: { + var lines = std.mem.splitScalar(u8, req, '\n'); + const first = lines.next() orelse break :blk Response{ .status = 400, .body = "" }; + var parts = std.mem.splitScalar(u8, std.mem.trim(u8, first, "\r"), ' '); + _ = parts.next(); + const path = parts.next() orelse break :blk Response{ .status = 400, .body = "" }; + break :blk dispatchGrpc(path, req, &resp_buf); + }, + GQL_PORT => dispatchGraphql(req, &resp_buf), + else => Response{ .status = 500, .body = "" }, + }; + var http_resp: [512]u8 = undefined; + const http = std.fmt.bufPrint(&http_resp, + "HTTP/1.1 {d} OK\r\nContent-Length: {d}\r\nContent-Type: application/json\r\n\r\n", + .{ result.status, result.body.len }) catch return; + _ = stream.write(http) catch {}; + _ = stream.write(result.body) catch {}; +} + +fn listenLoop(port: u16) !void { + const addr = std.net.Address.initIp4(.{ 127, 0, 0, 1 }, port); + var server = try addr.listen(.{ .reuse_address = true }); + defer server.deinit(); + while (true) { + const conn = try server.accept(); + const t = try std.Thread.spawn(.{}, handleConnection, .{ conn.stream, port }); + t.detach(); + } +} + +pub fn main() !void { + ffi.notion_mcp_init(); + const t1 = try std.Thread.spawn(.{}, listenLoop, .{REST_PORT}); + const t2 = try std.Thread.spawn(.{}, listenLoop, .{GRPC_PORT}); + const t3 = try std.Thread.spawn(.{}, listenLoop, .{GQL_PORT}); + t1.join(); + t2.join(); + t3.join(); +} diff --git a/cartridges/domains/productivity/notion-mcp/benchmarks/quick-bench.sh b/cartridges/domains/productivity/notion-mcp/benchmarks/quick-bench.sh new file mode 100755 index 0000000..cf32e07 --- /dev/null +++ b/cartridges/domains/productivity/notion-mcp/benchmarks/quick-bench.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Benchmarks for notion-mcp cartridge. +set -euo pipefail + +echo "=== notion-mcp benchmarks ===" + +cd "$(dirname "${BASH_SOURCE[0]}")/../ffi" + +# Build with ReleaseFast for benchmarking +zig build -Doptimize=ReleaseFast 2>&1 + +echo "Session open/close cycle (1000 iterations):" +time for i in $(seq 1 1000); do + # This would call the FFI benchmark binary + true +done + +echo "" +echo "Benchmark placeholder β€” implement real benchmarks in Zig test blocks" +echo "or via the zig adapter HTTP benchmark tool." diff --git a/cartridges/domains/productivity/notion-mcp/cartridge.json b/cartridges/domains/productivity/notion-mcp/cartridge.json new file mode 100644 index 0000000..e725946 --- /dev/null +++ b/cartridges/domains/productivity/notion-mcp/cartridge.json @@ -0,0 +1,211 @@ +{ + "$schema": "https://boj.dev/schemas/cartridge/v1.json", + "spdx": "MPL-2.0", + "copyright": "Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk>", + "name": "notion-mcp", + "version": "0.1.0", + "description": "Notion workspace pages, databases, and blocks", + "domain": "productivity", + "tier": "Ayo", + "protocols": [ + "MCP", + "REST" + ], + "auth": { + "method": "none", + "env_var": null, + "credential_source": null + }, + "api": { + "base_url": "local://notion-mcp", + "content_type": "application/json" + }, + "tools": [ + { + "name": "notion_authenticate", + "description": "Authenticate with Notion integration token", + "inputSchema": { + "type": "object", + "properties": { + "token": { + "type": "string", + "description": "Notion integration token" + } + }, + "required": [ + "token" + ] + } + }, + { + "name": "notion_search", + "description": "Search pages and databases", + "inputSchema": { + "type": "object", + "properties": { + "session_id": { + "type": "integer", + "description": "Session index" + }, + "query": { + "type": "string", + "description": "Search query" + } + }, + "required": [ + "session_id", + "query" + ] + } + }, + { + "name": "notion_get_page", + "description": "Get a page by ID", + "inputSchema": { + "type": "object", + "properties": { + "session_id": { + "type": "integer", + "description": "Session index" + }, + "page_id": { + "type": "string", + "description": "Notion page ID" + } + }, + "required": [ + "session_id", + "page_id" + ] + } + }, + { + "name": "notion_create_page", + "description": "Create a new page", + "inputSchema": { + "type": "object", + "properties": { + "session_id": { + "type": "integer", + "description": "Session index" + }, + "parent_id": { + "type": "string", + "description": "Parent page or database ID" + }, + "title": { + "type": "string", + "description": "Page title" + }, + "content": { + "type": "string", + "description": "Page content (markdown)" + } + }, + "required": [ + "session_id", + "parent_id", + "title" + ] + } + }, + { + "name": "notion_update_page", + "description": "Update page properties", + "inputSchema": { + "type": "object", + "properties": { + "session_id": { + "type": "integer", + "description": "Session index" + }, + "page_id": { + "type": "string", + "description": "Page ID" + }, + "properties": { + "type": "object", + "description": "Properties to update" + } + }, + "required": [ + "session_id", + "page_id", + "properties" + ] + } + }, + { + "name": "notion_query_database", + "description": "Query a Notion database", + "inputSchema": { + "type": "object", + "properties": { + "session_id": { + "type": "integer", + "description": "Session index" + }, + "database_id": { + "type": "string", + "description": "Database ID" + }, + "filter": { + "type": "object", + "description": "Filter object" + }, + "sorts": { + "type": "array", + "description": "Sort specifications", + "items": { + "type": "object" + } + } + }, + "required": [ + "session_id", + "database_id" + ] + } + }, + { + "name": "notion_append_blocks", + "description": "Append blocks to a page", + "inputSchema": { + "type": "object", + "properties": { + "session_id": { + "type": "integer", + "description": "Session index" + }, + "block_id": { + "type": "string", + "description": "Parent block or page ID" + }, + "children": { + "type": "array", + "description": "Block children to append", + "items": { + "type": "object" + } + } + }, + "required": [ + "session_id", + "block_id", + "children" + ] + } + } + ], + "ffi": { + "so_path": "ffi/zig-out/lib/libnotion_mcp.so", + "abi_version": "ADR-0006", + "symbols": [ + "boj_cartridge_init", + "boj_cartridge_deinit", + "boj_cartridge_name", + "boj_cartridge_version", + "boj_cartridge_invoke" + ] + } +} diff --git a/cartridges/domains/productivity/notion-mcp/ffi/README.adoc b/cartridges/domains/productivity/notion-mcp/ffi/README.adoc new file mode 100644 index 0000000..83820ea --- /dev/null +++ b/cartridges/domains/productivity/notion-mcp/ffi/README.adoc @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += notion-mcp / ffi β€” Zig FFI layer **(stub)** +:orientation: shallow + +== Purpose + +C-ABI exports bridging this cartridge's Idris2 ABI to the runtime. Consumed by +the adapter for protocol dispatch and by the trunk loader for mount. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” produces `zig-out/lib/libnotion.so`. +| `notion_mcp_ffi.zig` | C-ABI exports (11 exports, 6 inline tests, 540 lines). +|=== + +== Invariants + +* **Null-pointer rejection** on `[*c]const u8` arguments before any + dereference. +* **Negative return values** carry documented error meanings; zero is + success (matching the trunk error-code convention). +* **No allocator leaks** β€” every allocation path pairs with `defer free`. + +== Test/proof surface + +6 inline `test "..."` block(s) in `notion_mcp_ffi.zig`. Run with +`cd ffi && zig build test`. + +== Read-first + +. `notion_mcp_ffi.zig` β€” C-ABI signatures and guards. +. Trunk `ffi/zig/src/catalogue.zig` for how this `.so` is loaded. + +== Promotion gate + +Wire these exports to real backing state (not `return 0` / `return 1` +placeholders) before promoting past D. diff --git a/cartridges/domains/productivity/notion-mcp/ffi/build.zig b/cartridges/domains/productivity/notion-mcp/ffi/build.zig new file mode 100644 index 0000000..d041ed8 --- /dev/null +++ b/cartridges/domains/productivity/notion-mcp/ffi/build.zig @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + // Module + // Shared ADR-0006 invoke-shim module (relative path up to boj-server trunk). + + const shim_mod = b.addModule("cartridge_shim", .{ + + .root_source_file = b.path("../../../ffi/zig/src/cartridge_shim.zig"), + + .target = target, + + .optimize = optimize, + + }); + + const ffi_mod = b.addModule("notion_mcp", .{ + .root_source_file = b.path("notion_mcp_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + ffi_mod.addImport("cartridge_shim", shim_mod); + + // Shared library + const lib = b.addLibrary(.{ + .name = "notion_mcp", + .root_module = ffi_mod, + .linkage = .dynamic, + }); + lib.linkLibC(); + b.installArtifact(lib); + + // Tests + const tests = b.addTest(.{ + .root_module = ffi_mod, + }); + tests.linkLibC(); + + const run_tests = b.addRunArtifact(tests); + const test_step = b.step("test", "Run FFI tests"); + test_step.dependOn(&run_tests.step); +} diff --git a/cartridges/domains/productivity/notion-mcp/ffi/cartridge_shim.zig b/cartridges/domains/productivity/notion-mcp/ffi/cartridge_shim.zig new file mode 100644 index 0000000..0e399db --- /dev/null +++ b/cartridges/domains/productivity/notion-mcp/ffi/cartridge_shim.zig @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// cartridge_shim.zig β€” Shared helpers for the ADR-0006 five-symbol +// cartridge ABI (`boj_cartridge_init / deinit / name / version / invoke`). +// +// The shim centralises the seven-code return convention, NUL-argument +// guards, tool-name comparison, and the buffer-too-small path so each +// cartridge's `boj_cartridge_invoke` can stay short β€” typically a tool +// table plus `shim.writeResult(...)`. +// +// Cartridges import this file by relative path (no build-graph change +// needed). Example: +// +// const shim = @import("../../../ffi/zig/src/cartridge_shim.zig"); +// +// export fn boj_cartridge_invoke( +// tool_name: [*c]const u8, +// json_args: [*c]const u8, +// out_buf: [*c]u8, +// in_out_len: [*c]usize, +// ) callconv(.c) i32 { +// _ = json_args; +// if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; +// const body = if (shim.toolIs(tool_name, "foo")) "{\"result\":{}}" +// else return shim.RC_UNKNOWN_TOOL; +// return shim.writeResult(out_buf, in_out_len, body); +// } + +const std = @import("std"); + +// ── Return codes (ADR-0006 Β§Return codes) ──────────────────────────── +// +// Frozen by ADR-0006. New failure modes compose these via the error +// JSON body β€” the integer surface does not grow without a follow-up ADR. + +pub const RC_SUCCESS: i32 = 0; +pub const RC_UNKNOWN_TOOL: i32 = -1; +pub const RC_BAD_ARGS: i32 = -2; +pub const RC_BUFFER_TOO_SMALL: i32 = -3; +pub const RC_RUNTIME_ERROR: i32 = -4; +pub const RC_PANIC: i32 = -5; +pub const RC_AUTH_DENIED: i32 = -6; + +// ── Invoke-path helpers ────────────────────────────────────────────── + +/// True if any of the three mandatory `boj_cartridge_invoke` output-path +/// pointers is null. Use at the top of every invoke to short-circuit to +/// `RC_BAD_ARGS`. +pub fn invokeArgsNull( + tool_name: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) bool { + return tool_name == null or out_buf == null or in_out_len == null; +} + +/// Compare a C-NUL-terminated tool-name pointer against a Zig string +/// literal. Caller must have already verified `tool_name` is non-null +/// (usually via `invokeArgsNull`). +pub fn toolIs(tool_name: [*c]const u8, expected: []const u8) bool { + const s = std.mem.span(@as([*:0]const u8, @ptrCast(tool_name))); + return std.mem.eql(u8, s, expected); +} + +/// Copy `body` into `out_buf[0..*in_out_len]` (as a capacity) and update +/// `*in_out_len` to the number of bytes written. Returns `RC_SUCCESS`. +/// +/// If `body.len` exceeds the current capacity stored in `*in_out_len`, +/// sets `*in_out_len` to the required size and returns +/// `RC_BUFFER_TOO_SMALL` β€” the caller is then expected to re-allocate +/// and retry, per ADR-0006 Β§Memory ownership. +/// +/// Caller must have already verified that `out_buf` and `in_out_len` +/// are non-null. +pub fn writeResult( + out_buf: [*c]u8, + in_out_len: [*c]usize, + body: []const u8, +) i32 { + const cap = in_out_len.*; + if (body.len > cap) { + in_out_len.* = body.len; + return RC_BUFFER_TOO_SMALL; + } + @memcpy(out_buf[0..body.len], body); + in_out_len.* = body.len; + return RC_SUCCESS; +} + +// ── Tests ──────────────────────────────────────────────────────────── + +test "writeResult: body fits, writes and sets length" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); + try std.testing.expectEqualStrings("hello", buf[0..len]); +} + +test "writeResult: too small returns -3 and sets required length" { + var buf: [2]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_BUFFER_TOO_SMALL, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: exact-fit succeeds" { + var buf: [5]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: empty body" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, ""); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 0), len); +} + +test "toolIs: matches and rejects" { + const name: [*:0]const u8 = "foo"; + try std.testing.expect(toolIs(@ptrCast(name), "foo")); + try std.testing.expect(!toolIs(@ptrCast(name), "bar")); + try std.testing.expect(!toolIs(@ptrCast(name), "foobar")); + try std.testing.expect(!toolIs(@ptrCast(name), "fo")); +} + +test "invokeArgsNull: detects each null slot" { + var buf: [4]u8 = undefined; + var len: usize = 4; + const name: [*:0]const u8 = "x"; + try std.testing.expect(!invokeArgsNull(@ptrCast(name), &buf, &len)); + try std.testing.expect(invokeArgsNull(null, &buf, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), null, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), &buf, null)); +} diff --git a/cartridges/domains/productivity/notion-mcp/ffi/notion_mcp_ffi.zig b/cartridges/domains/productivity/notion-mcp/ffi/notion_mcp_ffi.zig new file mode 100644 index 0000000..b6fe2c9 --- /dev/null +++ b/cartridges/domains/productivity/notion-mcp/ffi/notion_mcp_ffi.zig @@ -0,0 +1,640 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// notion_mcp_ffi.zig β€” C-ABI FFI for the Notion REST API cartridge. +// +// Implements the state machine defined in NotionMcp.SafeComms (Idris2 ABI). +// Provides HTTP client stubs for the Notion API (https://api.notion.com/v1/), +// Notion-Version header management (2022-06-28), rate-limit tracking +// (Notion enforces ~3 requests/second), and Bearer-token authentication +// via integration tokens obtained from vault-mcp. +// +// Thread-safe via std.Thread.Mutex. No heap allocations for result buffers. + +const std = @import("std"); + +// --------------------------------------------------------------------------- +// Connection state machine (matches Idris2 ConnState exactly) +// --------------------------------------------------------------------------- + +/// Connection lifecycle states mirroring NotionMcp.SafeComms.ConnState. +pub const ConnState = enum(c_int) { + unauthenticated = 0, + authenticated = 1, + rate_limited = 2, + err = 3, +}; + +/// Check whether a transition between two states is valid. +/// Encodes the same transition table as the Idris2 ValidTransition GADT. +fn isValidTransition(from: ConnState, to: ConnState) bool { + return switch (from) { + .unauthenticated => to == .authenticated, + .authenticated => to == .rate_limited or to == .err or to == .unauthenticated, + .rate_limited => to == .authenticated or to == .err, + .err => to == .unauthenticated, + }; +} + +// --------------------------------------------------------------------------- +// Notion action vocabulary (matches Idris2 NotionAction exactly) +// --------------------------------------------------------------------------- + +/// All 16 Notion actions supported by this cartridge. +pub const NotionAction = enum(c_int) { + search_pages = 0, + get_page = 1, + create_page = 2, + update_page = 3, + delete_page = 4, + get_database = 5, + query_database = 6, + create_database = 7, + list_blocks = 8, + get_block = 9, + append_blocks = 10, + delete_block = 11, + list_users = 12, + get_user = 13, + create_comment = 14, + list_comments = 15, +}; + +/// Map each action to its Notion REST API endpoint path. +fn actionToEndpoint(action: NotionAction) []const u8 { + return switch (action) { + .search_pages => "/v1/search", + .get_page => "/v1/pages/{page_id}", + .create_page => "/v1/pages", + .update_page => "/v1/pages/{page_id}", + .delete_page => "/v1/pages/{page_id}", + .get_database => "/v1/databases/{database_id}", + .query_database => "/v1/databases/{database_id}/query", + .create_database => "/v1/databases", + .list_blocks => "/v1/blocks/{block_id}/children", + .get_block => "/v1/blocks/{block_id}", + .append_blocks => "/v1/blocks/{block_id}/children", + .delete_block => "/v1/blocks/{block_id}", + .list_users => "/v1/users", + .get_user => "/v1/users/{user_id}", + .create_comment => "/v1/comments", + .list_comments => "/v1/comments", + }; +} + +/// Map each action to its HTTP method string. +fn actionToMethod(action: NotionAction) []const u8 { + return switch (action) { + .search_pages => "POST", + .get_page => "GET", + .create_page => "POST", + .update_page => "PATCH", + .delete_page => "PATCH", + .get_database => "GET", + .query_database => "POST", + .create_database => "POST", + .list_blocks => "GET", + .get_block => "GET", + .append_blocks => "PATCH", + .delete_block => "DELETE", + .list_users => "GET", + .get_user => "GET", + .create_comment => "POST", + .list_comments => "GET", + }; +} + +// --------------------------------------------------------------------------- +// Notion-Version header constant +// --------------------------------------------------------------------------- + +/// The Notion API version header value. All requests must include +/// `Notion-Version: 2022-06-28` per Notion API requirements. +pub const NOTION_API_VERSION: []const u8 = "2022-06-28"; + +// --------------------------------------------------------------------------- +// Rate-limit tracking +// --------------------------------------------------------------------------- + +/// Tracks rate-limit usage within a sliding window. +/// Notion enforces approximately 3 requests per second. +const RateTracker = struct { + /// Number of requests made in the current window. + count: u32 = 0, + /// Epoch timestamp (seconds) when the current window started. + window_start: i64 = 0, + + /// Record one request. Returns true if within budget, false if exhausted. + fn record(self: *RateTracker, now: i64, budget: u32) bool { + // Reset window every second (Notion rate limits are per-second). + if (now - self.window_start >= 1) { + self.window_start = now; + self.count = 0; + } + if (self.count >= budget) return false; + self.count += 1; + return true; + } +}; + +/// Notion rate budget: ~3 requests per second. +const RATE_BUDGET: u32 = 3; + +// --------------------------------------------------------------------------- +// Session slot pool (thread-safe, fixed-size) +// --------------------------------------------------------------------------- + +const MAX_SESSIONS: usize = 16; +const BUF_SIZE: usize = 8192; +const TOKEN_MAX: usize = 256; + +const SessionSlot = struct { + active: bool = false, + state: ConnState = .unauthenticated, + /// Bearer token (Notion integration token) obtained from vault-mcp. + token_buf: [TOKEN_MAX]u8 = undefined, + token_len: usize = 0, + /// Workspace name populated after authentication. + workspace_buf: [256]u8 = undefined, + workspace_len: usize = 0, + /// Rate-limit tracker for request budgeting. + rate_tracker: RateTracker = .{}, + /// Count of pages accessed in this session (for panel metrics). + page_count: u32 = 0, + /// Count of actions performed in this session (for panel metrics). + actions_performed: u32 = 0, + /// General-purpose output buffer for API responses. + out_buf: [BUF_SIZE]u8 = undefined, + out_len: usize = 0, +}; + +var sessions: [MAX_SESSIONS]SessionSlot = [_]SessionSlot{.{}} ** MAX_SESSIONS; +var mutex: std.Thread.Mutex = .{}; + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +/// Copy a null-terminated C string into a fixed buffer. Returns bytes written. +fn copyFromCStr(dest: []u8, src: [*c]const u8) usize { + if (src == null) return 0; + var i: usize = 0; + while (i < dest.len and src[i] != 0) : (i += 1) { + dest[i] = src[i]; + } + return i; +} + +/// Write a slice into an output buffer pointer + length pointer. +fn writeOutput(buf: [*c]u8, buf_cap: usize, out_len: *c_int, data: []const u8) void { + if (buf == null) return; + const to_copy = @min(data.len, buf_cap); + for (0..to_copy) |i| { + buf[i] = data[i]; + } + out_len.* = @intCast(to_copy); +} + +/// Get a slot by index, returning null if invalid or inactive. +fn getSlot(slot_idx: c_int) ?*SessionSlot { + const idx: usize = std.math.cast(usize, slot_idx) orelse return null; + if (idx >= MAX_SESSIONS) return null; + const slot = &sessions[idx]; + if (!slot.active) return null; + return slot; +} + +/// Attempt a state transition on a slot. Returns 0 on success, -2 if invalid. +fn tryTransition(slot: *SessionSlot, target: ConnState) c_int { + if (!isValidTransition(slot.state, target)) return -2; + slot.state = target; + return 0; +} + +/// Stub: format a Notion API response into JSON. +/// In production this would perform the actual HTTP request to +/// https://api.notion.com with the Notion-Version: 2022-06-28 header. +fn formatStubResponse(buf: []u8, endpoint: []const u8, method: []const u8) usize { + const prefix = "{\"object\":\"stub\",\"endpoint\":\""; + const mid = "\",\"method\":\""; + const suffix = "\",\"notion_version\":\"2022-06-28\",\"results\":[]}"; + var pos: usize = 0; + + for (prefix) |ch| { + if (pos >= buf.len) break; + buf[pos] = ch; + pos += 1; + } + for (endpoint) |ch| { + if (pos >= buf.len) break; + buf[pos] = ch; + pos += 1; + } + for (mid) |ch| { + if (pos >= buf.len) break; + buf[pos] = ch; + pos += 1; + } + for (method) |ch| { + if (pos >= buf.len) break; + buf[pos] = ch; + pos += 1; + } + for (suffix) |ch| { + if (pos >= buf.len) break; + buf[pos] = ch; + pos += 1; + } + return pos; +} + +// --------------------------------------------------------------------------- +// C-ABI exports β€” state machine +// --------------------------------------------------------------------------- + +/// Check if a state transition is valid. Returns 1 (valid) or 0 (invalid). +pub export fn notion_mcp_can_transition(from: c_int, to: c_int) c_int { + const f = std.meta.intToEnum(ConnState, from) catch return 0; + const t = std.meta.intToEnum(ConnState, to) catch return 0; + return if (isValidTransition(f, t)) 1 else 0; +} + +// --------------------------------------------------------------------------- +// C-ABI exports β€” authentication +// --------------------------------------------------------------------------- + +/// Authenticate with a Notion integration token (Bearer). +/// Transitions: Unauthenticated -> Authenticated. +/// Returns slot index (>= 0) on success, negative on error. +/// Error codes: -1 = no free slots, -3 = empty token, -4 = invalid token prefix. +pub export fn notion_mcp_authenticate(token: [*c]const u8) c_int { + if (token == null) return -3; + // Validate token length: Notion integration tokens start with "secret_" or "ntn_". + var len: usize = 0; + while (len < TOKEN_MAX and token[len] != 0) : (len += 1) {} + if (len < 8) return -4; + + mutex.lock(); + defer mutex.unlock(); + + // Find a free slot. + for (&sessions, 0..) |*slot, idx| { + if (!slot.active) { + slot.active = true; + slot.state = .authenticated; + slot.token_len = copyFromCStr(&slot.token_buf, token); + slot.page_count = 0; + slot.actions_performed = 0; + slot.rate_tracker = .{}; + + // Stub: in production, GET /v1/users/me to verify token + get workspace. + const ws = "stub-workspace"; + @memcpy(slot.workspace_buf[0..ws.len], ws); + slot.workspace_len = ws.len; + + return @intCast(idx); + } + } + return -1; // No free slots. +} + +// --------------------------------------------------------------------------- +// C-ABI exports β€” generic API call +// --------------------------------------------------------------------------- + +/// Invoke a Notion REST API operation by action ID. +/// action_id: integer matching NotionAction enum. +/// params: JSON-encoded parameters (C string, may be null). +/// Returns 0 on success, negative on error. +/// Error codes: -1 = invalid slot, -2 = bad state, -5 = rate limited, -6 = unknown action. +pub export fn notion_mcp_api_call( + slot_idx: c_int, + action_id: c_int, + params: [*c]const u8, + out_buf: [*c]u8, + out_cap: c_int, + out_len: *c_int, +) c_int { + _ = params; + + const action = std.meta.intToEnum(NotionAction, action_id) catch return -6; + + mutex.lock(); + defer mutex.unlock(); + + const slot = getSlot(slot_idx) orelse return -1; + if (slot.state != .authenticated) return -2; + + // Rate-limit check. + const now = std.time.timestamp(); + if (!slot.rate_tracker.record(now, RATE_BUDGET)) { + slot.state = .rate_limited; + return -5; + } + + const endpoint = actionToEndpoint(action); + const method = actionToMethod(action); + const len = formatStubResponse(&slot.out_buf, endpoint, method); + slot.out_len = len; + slot.actions_performed += 1; + + // Track page operations for panel metrics. + if (action == .get_page or action == .search_pages or action == .create_page) { + slot.page_count += 1; + } + + const cap: usize = std.math.cast(usize, out_cap) orelse 0; + writeOutput(out_buf, cap, out_len, slot.out_buf[0..len]); + return 0; +} + +// --------------------------------------------------------------------------- +// C-ABI exports β€” session management +// --------------------------------------------------------------------------- + +/// Disconnect a session gracefully. Returns 0 on success. +/// Error codes: -1 = invalid slot, -2 = bad state transition. +pub export fn notion_mcp_disconnect(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const slot = getSlot(slot_idx) orelse return -1; + const rc = tryTransition(slot, .unauthenticated); + if (rc == 0) { + slot.active = false; + slot.token_len = 0; + slot.workspace_len = 0; + slot.page_count = 0; + slot.actions_performed = 0; + } + return rc; +} + +/// Get the current connection state. Returns state int or -1 if invalid slot. +pub export fn notion_mcp_session_state(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const slot = getSlot(slot_idx) orelse return -1; + return @intFromEnum(slot.state); +} + +/// Get the workspace name for an authenticated session. +/// Writes workspace name into out_buf. Returns 0 on success, -1 if invalid. +pub export fn notion_mcp_workspace(slot_idx: c_int, out_buf: [*c]u8, out_cap: c_int, out_len: *c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const slot = getSlot(slot_idx) orelse return -1; + const cap: usize = std.math.cast(usize, out_cap) orelse 0; + writeOutput(out_buf, cap, out_len, slot.workspace_buf[0..slot.workspace_len]); + return 0; +} + +/// Get the rate-limit request count for a session. +/// Returns count (>= 0) or -1 if invalid. +pub export fn notion_mcp_rate_count(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const slot = getSlot(slot_idx) orelse return -1; + return @intCast(slot.rate_tracker.count); +} + +/// Get the page-operation counter for a session. +pub export fn notion_mcp_page_count(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const slot = getSlot(slot_idx) orelse return -1; + return @intCast(slot.page_count); +} + +/// Get the actions-performed counter for a session. +pub export fn notion_mcp_actions_performed(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const slot = getSlot(slot_idx) orelse return -1; + return @intCast(slot.actions_performed); +} + +/// Recover from rate-limited state (RateLimited -> Authenticated). +/// Returns 0 on success, -2 if bad transition. +pub export fn notion_mcp_rate_recover(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const slot = getSlot(slot_idx) orelse return -1; + return tryTransition(slot, .authenticated); +} + +/// Reset all sessions (test/debug use only). +pub export fn notion_mcp_reset() void { + mutex.lock(); + defer mutex.unlock(); + sessions = [_]SessionSlot{.{}} ** MAX_SESSIONS; +} + +// --------------------------------------------------------------------------- +// ═══════════════════════════════════════════════════════════════════════ +// Standard ABI (ADR-0005 four symbols + ADR-0006 invoke) +// ═══════════════════════════════════════════════════════════════════════ + +const shim = @import("cartridge_shim.zig"); + +const CARTRIDGE_NAME_PTR: [*:0]const u8 = "notion-mcp"; +const CARTRIDGE_VERSION_PTR: [*:0]const u8 = "0.1.0"; + +export fn boj_cartridge_init() callconv(.c) c_int { + return 0; +} + +export fn boj_cartridge_deinit() callconv(.c) void {} + +export fn boj_cartridge_name() callconv(.c) [*:0]const u8 { + return CARTRIDGE_NAME_PTR; +} + +export fn boj_cartridge_version() callconv(.c) [*:0]const u8 { + return CARTRIDGE_VERSION_PTR; +} + +/// Dispatch the cartridge.json MCP tools. Grade D Alpha stubs. +export fn boj_cartridge_invoke( + tool_name: [*c]const u8, + json_args: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) callconv(.c) i32 { + _ = json_args; + if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; + + const body: []const u8 = if (shim.toolIs(tool_name, "notion_authenticate")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "notion_search")) + "{\"result\":{\"matches\":[],\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "notion_get_page")) + "{\"result\":{\"metadata\":{},\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "notion_create_page")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "notion_update_page")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "notion_query_database")) + "{\"result\":{\"matches\":[],\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "notion_append_blocks")) + "{\"result\":{\"status\":\"stub\"}}" +else + return shim.RC_UNKNOWN_TOOL; + + return shim.writeResult(out_buf, in_out_len, body); +} + +// Tests +// --------------------------------------------------------------------------- + +test "state machine transitions" { + // Valid transitions. + try std.testing.expectEqual(@as(c_int, 1), notion_mcp_can_transition(0, 1)); // unauth -> auth + try std.testing.expectEqual(@as(c_int, 1), notion_mcp_can_transition(1, 2)); // auth -> rate_limited + try std.testing.expectEqual(@as(c_int, 1), notion_mcp_can_transition(2, 1)); // rate_limited -> auth + try std.testing.expectEqual(@as(c_int, 1), notion_mcp_can_transition(1, 3)); // auth -> error + try std.testing.expectEqual(@as(c_int, 1), notion_mcp_can_transition(2, 3)); // rate_limited -> error + try std.testing.expectEqual(@as(c_int, 1), notion_mcp_can_transition(3, 0)); // error -> unauth + try std.testing.expectEqual(@as(c_int, 1), notion_mcp_can_transition(1, 0)); // auth -> unauth (graceful) + + // Invalid transitions. + try std.testing.expectEqual(@as(c_int, 0), notion_mcp_can_transition(0, 2)); // unauth -> rate_limited + try std.testing.expectEqual(@as(c_int, 0), notion_mcp_can_transition(0, 3)); // unauth -> error + try std.testing.expectEqual(@as(c_int, 0), notion_mcp_can_transition(3, 1)); // error -> auth + try std.testing.expectEqual(@as(c_int, 0), notion_mcp_can_transition(2, 0)); // rate_limited -> unauth + + // Out of range. + try std.testing.expectEqual(@as(c_int, 0), notion_mcp_can_transition(99, 0)); + try std.testing.expectEqual(@as(c_int, 0), notion_mcp_can_transition(0, 99)); +} + +test "authenticate and disconnect lifecycle" { + notion_mcp_reset(); + + // Authenticate with a valid integration token. + const slot = notion_mcp_authenticate("secret_test_integration_token_1234"); + try std.testing.expect(slot >= 0); + + // Should be authenticated. + try std.testing.expectEqual(@as(c_int, 1), notion_mcp_session_state(slot)); + + // Graceful disconnect. + try std.testing.expectEqual(@as(c_int, 0), notion_mcp_disconnect(slot)); +} + +test "reject short token" { + notion_mcp_reset(); + + // Token too short. + try std.testing.expectEqual(@as(c_int, -4), notion_mcp_authenticate("short")); + + // Null token. + try std.testing.expectEqual(@as(c_int, -3), notion_mcp_authenticate(null)); +} + +test "api call updates counters" { + notion_mcp_reset(); + + const slot = notion_mcp_authenticate("secret_test_api_call_token_abcdef"); + try std.testing.expect(slot >= 0); + + var buf: [1024]u8 = undefined; + var out_len: c_int = 0; + + // Call search_pages (action_id = 0). + const rc = notion_mcp_api_call(slot, 0, null, &buf, 1024, &out_len); + try std.testing.expectEqual(@as(c_int, 0), rc); + try std.testing.expect(out_len > 0); + try std.testing.expectEqual(@as(c_int, 1), notion_mcp_actions_performed(slot)); + try std.testing.expectEqual(@as(c_int, 1), notion_mcp_page_count(slot)); + + // Unknown action. + try std.testing.expectEqual(@as(c_int, -6), notion_mcp_api_call(slot, 99, null, &buf, 1024, &out_len)); + + _ = notion_mcp_disconnect(slot); +} + +test "workspace retrieval" { + notion_mcp_reset(); + + const slot = notion_mcp_authenticate("secret_test_workspace_token_12345"); + try std.testing.expect(slot >= 0); + + var ws_buf: [256]u8 = undefined; + var ws_len: c_int = 0; + try std.testing.expectEqual(@as(c_int, 0), notion_mcp_workspace(slot, &ws_buf, 256, &ws_len)); + try std.testing.expect(ws_len > 0); + + _ = notion_mcp_disconnect(slot); +} + +test "slot exhaustion" { + notion_mcp_reset(); + + var slots: [MAX_SESSIONS]c_int = undefined; + for (&slots) |*s| { + s.* = notion_mcp_authenticate("secret_fill_slot_token_abcdefghij"); + try std.testing.expect(s.* >= 0); + } + + // Next should fail. + try std.testing.expectEqual(@as(c_int, -1), notion_mcp_authenticate("secret_overflow_token_0000000000")); + + // Free one and retry. + try std.testing.expectEqual(@as(c_int, 0), notion_mcp_disconnect(slots[0])); + const new_slot = notion_mcp_authenticate("secret_reuse_slot_token_12345678"); + try std.testing.expect(new_slot >= 0); +} + +// ═══════════════════════════════════════════════════════════════════════ +// ADR-0006 invoke dispatch tests +// ═══════════════════════════════════════════════════════════════════════ + +test "boj_cartridge_name returns notion-mcp" { + const n = std.mem.span(boj_cartridge_name()); + try std.testing.expectEqualStrings("notion-mcp", n); +} + +test "boj_cartridge_init returns 0" { + try std.testing.expectEqual(@as(c_int, 0), boj_cartridge_init()); +} + +test "invoke: each declared tool succeeds" { + var buf: [256]u8 = undefined; + const tools = [_][]const u8{ + "notion_authenticate", + "notion_search", + "notion_get_page", + "notion_create_page", + "notion_update_page", + "notion_query_database", + "notion_append_blocks", + }; + for (tools) |t| { + var len: usize = buf.len; + const rc = boj_cartridge_invoke(t.ptr, "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, 0), rc); + try std.testing.expect(std.mem.indexOf(u8, buf[0..len], "result") != null); + } +} + +test "invoke: unknown tool returns -1" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("nope", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -1), rc); +} + +test "invoke: buffer too small returns -3" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("notion_authenticate", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -3), rc); + try std.testing.expect(len > 4); +} diff --git a/cartridges/domains/productivity/notion-mcp/minter.toml b/cartridges/domains/productivity/notion-mcp/minter.toml new file mode 100644 index 0000000..89ef108 --- /dev/null +++ b/cartridges/domains/productivity/notion-mcp/minter.toml @@ -0,0 +1,13 @@ +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +name = "notion-mcp" +description = "Notion REST API cartridge β€” pages, databases, blocks, users, and comments via https://api.notion.com/v1/" +version = "0.1.0" +domain = "Comms" +protocols = [ + "MCP", + "REST", +] +tier = "Ayo" +backend = "universal" +generate_panel = true diff --git a/cartridges/domains/productivity/notion-mcp/mod.js b/cartridges/domains/productivity/notion-mcp/mod.js new file mode 100644 index 0000000..c6ef1f4 --- /dev/null +++ b/cartridges/domains/productivity/notion-mcp/mod.js @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// notion-mcp/mod.js β€” Notion workspace pages, databases, and blocks +// +// Delegates to backend at http://127.0.0.1:7735 (override with NOTION_BACKEND_URL). + +const BASE_URL = Deno.env.get("NOTION_BACKEND_URL") ?? "http://127.0.0.1:7735"; +const TIMEOUT_MS = 15_000; + +async function post(path, payload) { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), TIMEOUT_MS); + try { + const r = await fetch(`${BASE_URL}${path}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + signal: ctrl.signal, + }); + const data = await r.json().catch(() => ({ success: false, error: "non-JSON response" })); + return { status: r.status, data }; + } catch (e) { + if (e.name === "AbortError") return { status: 504, data: { success: false, error: "notion-mcp backend timed out" } }; + return { status: 503, data: { success: false, error: `notion-mcp backend unavailable: ${e.message}` } }; + } finally { clearTimeout(t); } +} + +async function get(path) { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), TIMEOUT_MS); + try { + const r = await fetch(`${BASE_URL}${path}`, { method: "GET", signal: ctrl.signal }); + const data = await r.json().catch(() => ({ success: false, error: "non-JSON response" })); + return { status: r.status, data }; + } catch (e) { + if (e.name === "AbortError") return { status: 504, data: { success: false, error: "notion-mcp backend timed out" } }; + return { status: 503, data: { success: false, error: `notion-mcp backend unavailable: ${e.message}` } }; + } finally { clearTimeout(t); } +} + +export async function handleTool(toolName, args) { + switch (toolName) { + case "notion_authenticate": + return post("/api/v1/notion_authenticate", args ?? {}); + case "notion_search": + return post("/api/v1/notion_search", args ?? {}); + case "notion_get_page": + return post("/api/v1/notion_get_page", args ?? {}); + case "notion_create_page": + return post("/api/v1/notion_create_page", args ?? {}); + case "notion_update_page": + return post("/api/v1/notion_update_page", args ?? {}); + case "notion_query_database": + return post("/api/v1/notion_query_database", args ?? {}); + case "notion_append_blocks": + return post("/api/v1/notion_append_blocks", args ?? {}); + default: + return { status: 404, data: { error: `Unknown tool: ${toolName}` } }; + } +} diff --git a/cartridges/domains/productivity/notion-mcp/panels/manifest.json b/cartridges/domains/productivity/notion-mcp/panels/manifest.json new file mode 100644 index 0000000..dadbcbe --- /dev/null +++ b/cartridges/domains/productivity/notion-mcp/panels/manifest.json @@ -0,0 +1,70 @@ +{ + "$schema": "https://panll.dev/schemas/panel-manifest/v1.json", + "spdx": "MPL-2.0", + "cartridge": "notion-mcp", + "domain": "Comms", + "version": "0.1.0", + "panels": [ + { + "id": "notion-auth-status", + "title": "Auth Status", + "description": "Current Notion connection state (Unauthenticated / Authenticated / RateLimited / Error)", + "type": "status-indicator", + "data_source": { + "endpoint": "/notion/state", + "method": "GET", + "refresh_interval_ms": 5000 + }, + "widgets": [ + { + "type": "state-badge", + "field": "state", + "states": { + "unauthenticated": { "color": "#95a5a6", "icon": "plug-disconnected" }, + "authenticated": { "color": "#2ecc71", "icon": "plug-connected" }, + "rate_limited": { "color": "#e67e22", "icon": "clock" }, + "error": { "color": "#e74c3c", "icon": "alert-triangle" } + } + } + ] + }, + { + "id": "notion-workspace", + "title": "Workspace", + "description": "Name of the Notion workspace the integration is connected to", + "type": "metric", + "data_source": { + "endpoint": "/notion/metrics", + "method": "GET", + "refresh_interval_ms": 30000 + }, + "widgets": [ + { + "type": "text", + "field": "workspace", + "label": "Workspace", + "icon": "building" + } + ] + }, + { + "id": "notion-page-count", + "title": "Page Operations", + "description": "Number of page search/get/create operations in the current session", + "type": "metric", + "data_source": { + "endpoint": "/notion/metrics", + "method": "GET", + "refresh_interval_ms": 10000 + }, + "widgets": [ + { + "type": "counter", + "field": "page_count", + "label": "Page Operations", + "icon": "file-text" + } + ] + } + ] +} diff --git a/cartridges/domains/productivity/notion-mcp/tests/integration_test.sh b/cartridges/domains/productivity/notion-mcp/tests/integration_test.sh new file mode 100755 index 0000000..b7e73bb --- /dev/null +++ b/cartridges/domains/productivity/notion-mcp/tests/integration_test.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Integration tests for notion-mcp cartridge. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CART_DIR="$(dirname "$SCRIPT_DIR")" +FFI_DIR="$CART_DIR/ffi" + +echo "=== notion-mcp integration tests ===" + +# Build FFI +echo "[1/3] Building FFI..." +cd "$FFI_DIR" && zig build 2>&1 + +# Run Zig unit tests +echo "[2/3] Running FFI unit tests..." +cd "$FFI_DIR" && zig build test 2>&1 + +# Validate Idris2 ABI (if idris2 available) +echo "[3/3] Checking ABI..." +if command -v idris2 &>/dev/null; then + cd "$CART_DIR/abi" && idris2 --check notion_mcp.ipkg 2>&1 + echo " ABI: OK" +else + echo " ABI: SKIPPED (idris2 not in PATH)" +fi + +echo "" +echo "All tests passed for notion-mcp!" diff --git a/cartridges/domains/productivity/todoist-mcp/README.adoc b/cartridges/domains/productivity/todoist-mcp/README.adoc new file mode 100644 index 0000000..264a47f --- /dev/null +++ b/cartridges/domains/productivity/todoist-mcp/README.adoc @@ -0,0 +1,88 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += todoist-mcp +Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk> +:spdx: MPL-2.0 +:tier: Ayo +:domain: Productivity +:protocols: MCP, REST + +== Overview + +Todoist task manager cartridge for the BoJ server. Provides type-safe +access to the Todoist REST API v2 for task listing with filter expressions, +task creation with due dates and priorities, task completion, project +browsing, label management, comment retrieval, section listing, and +completed task history. + +Connects to the Todoist API at `api.todoist.com`. Bearer token auth is +always required. + +=== State Machine + +`Disconnected -> Connected` (authenticated via API token) + +`Connected -> RateLimited -> Connected` (normal flow) + +`Connected -> Error -> Disconnected` (error recovery) + +=== Actions (10) + +[cols="1,1"] +|=== +| Category | Actions + +| Task Reads +| GetTasks, GetTask, GetCompletedTasks + +| Task Writes +| CreateTask, CompleteTask + +| Projects +| ListProjects, GetProject, ListSections + +| Metadata +| ListLabels, GetComments +|=== + +== Architecture + +[cols="1,1,2"] +|=== +| Layer | Language | Purpose + +| ABI +| Idris2 +| Formally verified state machine with dependent-type proofs (TodoistMcp.SafeRegistry) + +| FFI +| Zig +| C-compatible implementation with thread-safe session pool, action recording, category counting + +| Adapter +| zig +| REST bridge to BoJ unified adapter protocol +|=== + +== Building + +[source,bash] +---- +# Build FFI shared library +cd ffi && zig build + +# Run FFI tests +cd ffi && zig build test + +# Type-check ABI +cd abi && idris2 --check TodoistMcp.SafeRegistry +---- + +== Prerequisites + +1. Create a Todoist API token at https://app.todoist.com/app/settings/integrations/developer +2. Set `TODOIST_API_TOKEN` to the token value. + +== Status + +Development -- customised with Todoist-specific task filtering, natural language due dates, and Sync API completed task history. diff --git a/cartridges/domains/productivity/todoist-mcp/abi/README.adoc b/cartridges/domains/productivity/todoist-mcp/abi/README.adoc new file mode 100644 index 0000000..1e76297 --- /dev/null +++ b/cartridges/domains/productivity/todoist-mcp/abi/README.adoc @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += todoist-mcp / abi β€” Idris2 ABI layer +:orientation: shallow + +== Purpose + +Cartridge-local protocol types for `todoist-mcp`. Defines the data, tool, and +transition vocabulary this cartridge speaks. Inherits safety lemmas and the +matrix-cell identifier scheme from the trunk ABI at `src/abi/`. + +== Files + +1 Idris2 module(s), ~178 lines total. Lead module: +`SafeRegistry.idr`. + +No `.ipkg` β€” consumed directly by wider BoJ callers; type-checks inside the +trunk `boj.ipkg`. + +== Invariants + +* `%default total` at every module top. +* Zero `believe_me`, `assert_total`, `postulate`, `Admitted`, or + `sorry` (the trunk holds the four declared `believe_me` sites; leaf + cartridges carry none). + +== Test/proof surface + +Thin module(s), no inline tests. Proof surface = type-check success under the +trunk `idris2 --check`. + +== Read-first + +. `SafeRegistry.idr` β€” cartridge-local data records and enums. +. Trunk `src/abi/Boj/Catalogue.idr` for how this cell participates in the + 2D matrix. diff --git a/cartridges/domains/productivity/todoist-mcp/abi/TodoistMcp/SafeRegistry.idr b/cartridges/domains/productivity/todoist-mcp/abi/TodoistMcp/SafeRegistry.idr new file mode 100644 index 0000000..84ddd75 --- /dev/null +++ b/cartridges/domains/productivity/todoist-mcp/abi/TodoistMcp/SafeRegistry.idr @@ -0,0 +1,178 @@ +-- SPDX-License-Identifier: MPL-2.0 +-- Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +-- +-- TodoistMcp.SafeRegistry β€” Type-safe ABI for todoist-mcp cartridge. +-- +-- Dependent-type state machine governing Todoist REST API v2 access. +-- Encodes mandatory Bearer token auth, task listing, task creation, +-- task completion, project browsing, label management, comment retrieval, +-- section browsing, and completed task history as compile-time invariants. +-- REST API: https://api.todoist.com/rest/v2 +-- No unsafe escape hatches. + +module TodoistMcp.SafeRegistry + +%default total + +-- --------------------------------------------------------------------------- +-- Authentication state machine +-- --------------------------------------------------------------------------- + +||| Session state for Todoist MCP operations. +||| Disconnected: no API token configured. +||| Connected: authenticated and ready for API calls. +||| RateLimited: rate limit hit; must wait before retrying. +||| Error: unrecoverable error (invalid token, network failure). +public export +data SessionState + = Disconnected + | Connected + | RateLimited + | Error + +||| Proof that a state transition is valid. +||| Todoist requires API token auth β€” no anonymous access. +public export +data ValidTransition : SessionState -> SessionState -> Type where + Connect : ValidTransition Disconnected Connected + Disconnect : ValidTransition Connected Disconnected + Throttle : ValidTransition Connected RateLimited + Unthrottle : ValidTransition RateLimited Connected + ConnectError : ValidTransition Connected Error + DisconnError : ValidTransition Disconnected Error + RecoverConnect : ValidTransition Error Connected + RecoverDisconn : ValidTransition Error Disconnected + +-- --------------------------------------------------------------------------- +-- C-ABI integer encoding +-- --------------------------------------------------------------------------- + +||| Encode session state as C-compatible integer for FFI boundary. +export +sessionStateToInt : SessionState -> Int +sessionStateToInt Disconnected = 0 +sessionStateToInt Connected = 1 +sessionStateToInt RateLimited = 2 +sessionStateToInt Error = 3 + +||| Decode integer back to session state. Returns Nothing for out-of-range. +export +intToSessionState : Int -> Maybe SessionState +intToSessionState 0 = Just Disconnected +intToSessionState 1 = Just Connected +intToSessionState 2 = Just RateLimited +intToSessionState 3 = Just Error +intToSessionState _ = Nothing + +||| Check if a state transition is valid (C-ABI export). +export +todoist_mcp_can_transition : Int -> Int -> Int +todoist_mcp_can_transition from to = + case (intToSessionState from, intToSessionState to) of + (Just Disconnected, Just Connected) => 1 + (Just Connected, Just Disconnected) => 1 + (Just Connected, Just RateLimited) => 1 + (Just RateLimited, Just Connected) => 1 + (Just Connected, Just Error) => 1 + (Just Disconnected, Just Error) => 1 + (Just Error, Just Connected) => 1 + (Just Error, Just Disconnected) => 1 + _ => 0 + +-- --------------------------------------------------------------------------- +-- Todoist actions +-- --------------------------------------------------------------------------- + +||| Actions available through the Todoist MCP cartridge. +||| Grouped: GetTasks, GetTask, CreateTask, CompleteTask, +||| ListProjects, GetProject, ListLabels, GetComments, +||| ListSections, GetCompletedTasks. +public export +data TodoistAction + = GetTasks + | GetTask + | CreateTask + | CompleteTask + | ListProjects + | GetProject + | ListLabels + | GetComments + | ListSections + | GetCompletedTasks + +||| Whether an action requires Connected state. +||| All Todoist operations require an active authenticated session. +export +actionRequiresAuth : TodoistAction -> Bool +actionRequiresAuth _ = True + +||| Whether an action is a write/mutating operation. +||| CreateTask and CompleteTask are mutating; all others are read-only. +export +actionIsMutating : TodoistAction -> Bool +actionIsMutating CreateTask = True +actionIsMutating CompleteTask = True +actionIsMutating _ = False + +||| Encode action as C-compatible integer for FFI. +export +actionToInt : TodoistAction -> Int +actionToInt GetTasks = 0 +actionToInt GetTask = 1 +actionToInt CreateTask = 2 +actionToInt CompleteTask = 3 +actionToInt ListProjects = 4 +actionToInt GetProject = 5 +actionToInt ListLabels = 6 +actionToInt GetComments = 7 +actionToInt ListSections = 8 +actionToInt GetCompletedTasks = 9 + +||| Decode integer to Todoist action. +export +intToAction : Int -> Maybe TodoistAction +intToAction 0 = Just GetTasks +intToAction 1 = Just GetTask +intToAction 2 = Just CreateTask +intToAction 3 = Just CompleteTask +intToAction 4 = Just ListProjects +intToAction 5 = Just GetProject +intToAction 6 = Just ListLabels +intToAction 7 = Just GetComments +intToAction 8 = Just ListSections +intToAction 9 = Just GetCompletedTasks +intToAction _ = Nothing + +-- --------------------------------------------------------------------------- +-- MCP tool declarations +-- --------------------------------------------------------------------------- + +||| Tools exposed via MCP protocol for this cartridge. +public export +data McpTool + = ToolGetTasks + | ToolGetTask + | ToolCreateTask + | ToolCompleteTask + | ToolListProjects + | ToolGetProject + | ToolListLabels + | ToolGetComments + | ToolListSections + | ToolGetCompletedTasks + +||| Check if a tool requires a connected session. +||| All Todoist tools require an active authenticated connection. +export +toolRequiresSession : McpTool -> Bool +toolRequiresSession _ = True + +||| Total tool count for this cartridge. +export +toolCount : Nat +toolCount = 10 + +||| Total action count for this cartridge. +export +actionCount : Nat +actionCount = 10 diff --git a/cartridges/domains/productivity/todoist-mcp/adapter/README.adoc b/cartridges/domains/productivity/todoist-mcp/adapter/README.adoc new file mode 100644 index 0000000..2315e36 --- /dev/null +++ b/cartridges/domains/productivity/todoist-mcp/adapter/README.adoc @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += todoist-mcp / adapter β€” protocol bridge +:orientation: shallow + +== Purpose + +Exposes this cartridge's FFI over the wire protocols declared in +`cartridge.json`. Dispatches tool invocations to the C-ABI exports at +`../ffi/` and formats responses per protocol. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” depends on the sibling `ffi` target. +| `todoist_adapter.zig` | Protocol dispatch (204 lines). Tool catalogue matches `cartridge.json`. +| `SIDELINED-*.v.adoc` | zig predecessor(s). Not built β€” zig banned 2026-04-10. +|=== + +== Invariants + +* **Stateless** β€” all state lives behind the FFI, never in the adapter. +* **Response passthrough** β€” whatever the FFI returns goes back to the wire + unmodified (no embellishment, no silent recovery). +* **Cartridge.json is source of truth** for the tool catalogue; drift between + adapter and manifest is a CI failure. + +== Test/proof surface + +No inline tests at the adapter layer β€” protocol routing is exercised by the +trunk seam tests at `ffi/zig/src/seams.zig`. + +== Read-first + +. `todoist_adapter.zig` β€” dispatch function and per-protocol routers. +. `../cartridge.json` β€” the tool manifest the dispatcher mirrors. diff --git a/cartridges/domains/productivity/todoist-mcp/adapter/build.zig b/cartridges/domains/productivity/todoist-mcp/adapter/build.zig new file mode 100644 index 0000000..9f7969f --- /dev/null +++ b/cartridges/domains/productivity/todoist-mcp/adapter/build.zig @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// todoist-mcp/adapter/build.zig + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const ffi_mod = b.createModule(.{ + .root_source_file = b.path("../ffi/todoist_mcp_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + const adapter = b.addExecutable(.{ + .name = "todoist_adapter", + .root_source_file = b.path("todoist_adapter.zig"), + .target = target, + .optimize = optimize, + }); + adapter.root_module.addImport("todoist_mcp_ffi", ffi_mod); + b.installArtifact(adapter); + + const run_artifact = b.addRunArtifact(adapter); + const run_step = b.step("run", "Run the todoist-mcp adapter"); + run_step.dependOn(&run_artifact.step); + + const tests = b.addTest(.{ + .root_source_file = b.path("todoist_adapter.zig"), + .target = target, + .optimize = optimize, + }); + tests.root_module.addImport("todoist_mcp_ffi", ffi_mod); + const run_tests = b.addRunArtifact(tests); + const test_step = b.step("test", "Run todoist-mcp adapter tests"); + test_step.dependOn(&run_tests.step); +} diff --git a/cartridges/domains/productivity/todoist-mcp/adapter/todoist_adapter.zig b/cartridges/domains/productivity/todoist-mcp/adapter/todoist_adapter.zig new file mode 100644 index 0000000..0dc45eb --- /dev/null +++ b/cartridges/domains/productivity/todoist-mcp/adapter/todoist_adapter.zig @@ -0,0 +1,204 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// todoist-mcp/adapter/todoist_adapter.zig -- Unified three-protocol adapter. +// +// Replaces the banned todoist_adapter.v (zig, removed 2026-04-12). +// +// Bridges the Zig FFI (todoist_mcp_ffi.zig) to three network protocols: +// REST :9097 POST /tools/<tool> +// gRPC-compat :9098 /TodoistMcpService/<Method> +// GraphQL :9099 POST /graphql { query: "..." } +// +// Todoist task management: tasks, projects, labels, comments, sections +// Tools: +// todoist_get_tasks +// todoist_get_task +// todoist_create_task +// todoist_complete_task +// todoist_list_projects +// todoist_get_project +// todoist_list_labels +// todoist_get_comments +// todoist_list_sections +// todoist_get_completed_tasks + +const std = @import("std"); +const ffi = @import("todoist_mcp_ffi"); + +const REST_PORT: u16 = 9097; +const GRPC_PORT: u16 = 9098; +const GQL_PORT: u16 = 9099; + +const MAX_CONN_BUF: usize = 16 * 1024; + +// ============================================================================ +// JSON response builders +// ============================================================================ + +fn okJson(buf: []u8, msg: []const u8) []u8 { + return std.fmt.bufPrint(buf, + \{{"success":true,"message":"{}"}} + , .{msg}) catch buf[0..0]; +} + +fn errJson(buf: []u8, msg: []const u8) []u8 { + return std.fmt.bufPrint(buf, + \{{"success":false,"error":"{}"}} + , .{msg}) catch buf[0..0]; +} + +fn statusJson(buf: []u8) []u8 { + return std.fmt.bufPrint(buf, + \{{"success":true,"state":"ready","service":"todoist-mcp"}} + , .{}) catch buf[0..0]; +} + +// ============================================================================ +// Tool dispatcher +// ============================================================================ + +const Response = struct { status: u16, body: []u8 }; + +fn dispatch(tool: []const u8, body: []const u8, resp: []u8) Response { + _ = body; + if (std.mem.eql(u8, tool, "todoist_get_tasks")) return .{ .status = 200, .body = okJson(resp, "todoist_get_tasks forwarded to backend") }; + if (std.mem.eql(u8, tool, "todoist_get_task")) return .{ .status = 200, .body = okJson(resp, "todoist_get_task forwarded to backend") }; + if (std.mem.eql(u8, tool, "todoist_create_task")) return .{ .status = 200, .body = okJson(resp, "todoist_create_task forwarded to backend") }; + if (std.mem.eql(u8, tool, "todoist_complete_task")) return .{ .status = 200, .body = okJson(resp, "todoist_complete_task forwarded to backend") }; + if (std.mem.eql(u8, tool, "todoist_list_projects")) return .{ .status = 200, .body = okJson(resp, "todoist_list_projects forwarded to backend") }; + if (std.mem.eql(u8, tool, "todoist_get_project")) return .{ .status = 200, .body = okJson(resp, "todoist_get_project forwarded to backend") }; + if (std.mem.eql(u8, tool, "todoist_list_labels")) return .{ .status = 200, .body = okJson(resp, "todoist_list_labels forwarded to backend") }; + if (std.mem.eql(u8, tool, "todoist_get_comments")) return .{ .status = 200, .body = okJson(resp, "todoist_get_comments forwarded to backend") }; + if (std.mem.eql(u8, tool, "todoist_list_sections")) return .{ .status = 200, .body = okJson(resp, "todoist_list_sections forwarded to backend") }; + if (std.mem.eql(u8, tool, "todoist_get_completed_tasks")) return .{ .status = 200, .body = okJson(resp, "todoist_get_completed_tasks forwarded to backend") }; + if (std.mem.eql(u8, tool, "status") or std.mem.eql(u8, tool, "health")) + return .{ .status = 200, .body = statusJson(resp) }; + return .{ .status = 404, .body = errJson(resp, "Unknown tool") }; +} + +// ============================================================================ +// REST handler +// ============================================================================ + +fn dispatchRest(path: []const u8, body: []const u8, resp: []u8) Response { + if (std.mem.startsWith(u8, path, "/tools/")) { + return dispatch(path["/tools/".len..], body, resp); + } + if (std.mem.eql(u8, path, "/status") or std.mem.eql(u8, path, "/health")) { + return .{ .status = 200, .body = statusJson(resp) }; + } + return .{ .status = 404, .body = errJson(resp, "Not found") }; +} + +// ============================================================================ +// gRPC-compat handler +// ============================================================================ + +fn dispatchGrpc(path: []const u8, body: []const u8, resp: []u8) Response { + const prefix = "/TodoistMcpService/"; + if (!std.mem.startsWith(u8, path, prefix)) + return .{ .status = 404, .body = errJson(resp, "Not a recognized gRPC path") }; + const method = path[prefix.len..]; + const tool = blk: { + if (std.mem.eql(u8, method, "TodoistGetTasks")) break :blk "todoist_get_tasks"; + if (std.mem.eql(u8, method, "TodoistGetTask")) break :blk "todoist_get_task"; + if (std.mem.eql(u8, method, "TodoistCreateTask")) break :blk "todoist_create_task"; + if (std.mem.eql(u8, method, "TodoistCompleteTask")) break :blk "todoist_complete_task"; + if (std.mem.eql(u8, method, "TodoistListProjects")) break :blk "todoist_list_projects"; + if (std.mem.eql(u8, method, "TodoistGetProject")) break :blk "todoist_get_project"; + if (std.mem.eql(u8, method, "TodoistListLabels")) break :blk "todoist_list_labels"; + if (std.mem.eql(u8, method, "TodoistGetComments")) break :blk "todoist_get_comments"; + if (std.mem.eql(u8, method, "TodoistListSections")) break :blk "todoist_list_sections"; + if (std.mem.eql(u8, method, "TodoistGetCompletedTasks")) break :blk "todoist_get_completed_tasks"; + return .{ .status = 404, .body = errJson(resp, "Unknown gRPC method") }; + }; + return dispatch(tool, body, resp); +} + +// ============================================================================ +// GraphQL handler +// ============================================================================ + +fn dispatchGraphql(body: []const u8, resp: []u8) Response { + if (std.mem.indexOf(u8, body, "__schema") != null) + return .{ .status = 200, .body = okJson(resp, "schema introspection not yet supported") }; + if (std.mem.indexOf(u8, body, "get_tasks") != null) return dispatch("todoist_get_tasks", body, resp); + if (std.mem.indexOf(u8, body, "get_task") != null) return dispatch("todoist_get_task", body, resp); + if (std.mem.indexOf(u8, body, "create_task") != null) return dispatch("todoist_create_task", body, resp); + if (std.mem.indexOf(u8, body, "complete_task") != null) return dispatch("todoist_complete_task", body, resp); + if (std.mem.indexOf(u8, body, "list_projects") != null) return dispatch("todoist_list_projects", body, resp); + if (std.mem.indexOf(u8, body, "get_project") != null) return dispatch("todoist_get_project", body, resp); + if (std.mem.indexOf(u8, body, "list_labels") != null) return dispatch("todoist_list_labels", body, resp); + if (std.mem.indexOf(u8, body, "get_comments") != null) return dispatch("todoist_get_comments", body, resp); + if (std.mem.indexOf(u8, body, "list_sections") != null) return dispatch("todoist_list_sections", body, resp); + if (std.mem.indexOf(u8, body, "get_completed_tasks") != null) return dispatch("todoist_get_completed_tasks", body, resp); + return .{ .status = 200, .body = errJson(resp, "Unrecognised GraphQL operation") }; +} + +// ============================================================================ +// HTTP/1.1 connection handler +// ============================================================================ + +const Protocol = enum { rest, grpc, graphql }; + +fn handleConnection(conn: std.net.Server.Connection, proto: Protocol) void { + defer conn.stream.close(); + var in_buf: [MAX_CONN_BUF]u8 = undefined; + const n = conn.stream.read(&in_buf) catch return; + const req = in_buf[0..n]; + + var path: []const u8 = "/"; + var body: []const u8 = ""; + if (n > 4) { + const line_end = std.mem.indexOf(u8, req, "\r\n") orelse req.len; + const first_line = req[0..line_end]; + const sp1 = std.mem.indexOfScalar(u8, first_line, ' ') orelse 0; + const rest_of = first_line[sp1 + 1 ..]; + const sp2 = std.mem.indexOfScalar(u8, rest_of, ' ') orelse rest_of.len; + path = rest_of[0..sp2]; + const body_sep = std.mem.indexOf(u8, req, "\r\n\r\n") orelse n; + body = req[@min(body_sep + 4, n)..]; + } + + var resp_buf: [MAX_CONN_BUF]u8 = undefined; + const result = switch (proto) { + .rest => dispatchRest(path, body, &resp_buf), + .grpc => dispatchGrpc(path, body, &resp_buf), + .graphql => dispatchGraphql(body, &resp_buf), + }; + + const content_type = switch (proto) { + .rest => "application/json", + .grpc => "application/grpc+json", + .graphql => "application/json", + }; + + var hdr_buf: [256]u8 = undefined; + const hdr = std.fmt.bufPrint(&hdr_buf, + "HTTP/1.1 {d} OK\r\nContent-Type: {s}\r\nContent-Length: {d}\r\nConnection: close\r\n\r\n", + .{ result.status, content_type, result.body.len }, + ) catch return; + _ = conn.stream.writeAll(hdr) catch return; + _ = conn.stream.writeAll(result.body) catch return; +} + +fn listenLoop(port: u16, proto: Protocol) void { + const addr = std.net.Address.initIp4(.{ 127, 0, 0, 1 }, port) catch return; + var server = addr.listen(.{ .reuse_address = true }) catch return; + defer server.deinit(); + while (true) { + const conn = server.accept() catch continue; + handleConnection(conn, proto); + } +} + +pub fn main() !void { + ffi.todoist_init(); + const rest_thread = try std.Thread.spawn(.{}, listenLoop, .{ REST_PORT, Protocol.rest }); + const grpc_thread = try std.Thread.spawn(.{}, listenLoop, .{ GRPC_PORT, Protocol.grpc }); + const gql_thread = try std.Thread.spawn(.{}, listenLoop, .{ GQL_PORT, Protocol.graphql }); + rest_thread.join(); + grpc_thread.join(); + gql_thread.join(); +} diff --git a/cartridges/domains/productivity/todoist-mcp/benchmarks/quick-bench.sh b/cartridges/domains/productivity/todoist-mcp/benchmarks/quick-bench.sh new file mode 100755 index 0000000..c26d800 --- /dev/null +++ b/cartridges/domains/productivity/todoist-mcp/benchmarks/quick-bench.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Benchmarks for todoist-mcp cartridge. +set -euo pipefail + +echo "=== todoist-mcp benchmarks ===" + +cd "$(dirname "${BASH_SOURCE[0]}")/../ffi" + +# Build with ReleaseFast for benchmarking +zig build -Doptimize=ReleaseFast 2>&1 + +echo "Session connect/disconnect cycle (1000 iterations):" +time for i in $(seq 1 1000); do + true +done + +echo "" +echo "Benchmark placeholder -- implement real benchmarks in Zig test blocks" +echo "or via the zig adapter HTTP benchmark tool." diff --git a/cartridges/domains/productivity/todoist-mcp/cartridge.json b/cartridges/domains/productivity/todoist-mcp/cartridge.json new file mode 100644 index 0000000..77710e8 --- /dev/null +++ b/cartridges/domains/productivity/todoist-mcp/cartridge.json @@ -0,0 +1,211 @@ +{ + "$schema": "https://boj.dev/schemas/cartridge/v1.json", + "spdx": "MPL-2.0", + "copyright": "Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath)", + "name": "todoist-mcp", + "version": "0.2.0", + "description": "Todoist task manager cartridge -- task search, project listing, task creation, task completion, label management, comment retrieval, section browsing, filter queries, activity log access, and productivity stats via the Todoist REST API v2", + "domain": "Productivity", + "tier": "Ayo", + "protocols": [ + "MCP", + "REST" + ], + "auth": { + "method": "bearer_token", + "env_var": "TODOIST_API_TOKEN", + "credential_source": "vault-mcp" + }, + "api": { + "base_url": "https://api.todoist.com/rest/v2", + "content_type": "application/json" + }, + "tools": [ + { + "name": "todoist_get_tasks", + "description": "Get active tasks with optional filtering by project, label, or filter expression", + "inputSchema": { + "type": "object", + "properties": { + "project_id": { + "type": "string", + "description": "Filter by project ID" + }, + "label": { + "type": "string", + "description": "Filter by label name" + }, + "filter": { + "type": "string", + "description": "Todoist filter expression (e.g. 'today', 'overdue', 'priority 1')" + } + } + } + }, + { + "name": "todoist_get_task", + "description": "Get a single task by ID with full details (description, due date, priority, labels)", + "inputSchema": { + "type": "object", + "properties": { + "task_id": { + "type": "string", + "description": "Task ID" + } + }, + "required": [ + "task_id" + ] + } + }, + { + "name": "todoist_create_task", + "description": "Create a new task with optional project, due date, priority, labels, and description", + "inputSchema": { + "type": "object", + "properties": { + "content": { + "type": "string", + "description": "Task title/content" + }, + "description": { + "type": "string", + "description": "Task description (markdown)" + }, + "project_id": { + "type": "string", + "description": "Project to add task to" + }, + "due_string": { + "type": "string", + "description": "Natural language due date (e.g. 'tomorrow', 'next Monday')" + }, + "due_date": { + "type": "string", + "description": "Due date in YYYY-MM-DD format" + }, + "priority": { + "type": "number", + "description": "Priority 1-4 (4 = urgent)" + }, + "labels": { + "type": "string", + "description": "Comma-separated label names" + } + }, + "required": [ + "content" + ] + } + }, + { + "name": "todoist_complete_task", + "description": "Mark a task as completed", + "inputSchema": { + "type": "object", + "properties": { + "task_id": { + "type": "string", + "description": "Task ID to complete" + } + }, + "required": [ + "task_id" + ] + } + }, + { + "name": "todoist_list_projects", + "description": "List all projects in the Todoist workspace", + "inputSchema": { + "type": "object", + "properties": {} + } + }, + { + "name": "todoist_get_project", + "description": "Get details for a specific project by ID", + "inputSchema": { + "type": "object", + "properties": { + "project_id": { + "type": "string", + "description": "Project ID" + } + }, + "required": [ + "project_id" + ] + } + }, + { + "name": "todoist_list_labels", + "description": "List all personal labels", + "inputSchema": { + "type": "object", + "properties": {} + } + }, + { + "name": "todoist_get_comments", + "description": "Get comments for a task or project", + "inputSchema": { + "type": "object", + "properties": { + "task_id": { + "type": "string", + "description": "Task ID (mutually exclusive with project_id)" + }, + "project_id": { + "type": "string", + "description": "Project ID (mutually exclusive with task_id)" + } + } + } + }, + { + "name": "todoist_list_sections", + "description": "List sections within a project", + "inputSchema": { + "type": "object", + "properties": { + "project_id": { + "type": "string", + "description": "Project ID" + } + }, + "required": [ + "project_id" + ] + } + }, + { + "name": "todoist_get_completed_tasks", + "description": "Get recently completed tasks (via Sync API)", + "inputSchema": { + "type": "object", + "properties": { + "project_id": { + "type": "string", + "description": "Filter by project ID" + }, + "limit": { + "type": "number", + "description": "Max results (default 30)" + } + } + } + } + ], + "ffi": { + "so_path": "ffi/zig-out/lib/libtodoist_mcp.so", + "abi_version": "ADR-0006", + "symbols": [ + "boj_cartridge_init", + "boj_cartridge_deinit", + "boj_cartridge_name", + "boj_cartridge_version", + "boj_cartridge_invoke" + ] + } +} diff --git a/cartridges/domains/productivity/todoist-mcp/ffi/README.adoc b/cartridges/domains/productivity/todoist-mcp/ffi/README.adoc new file mode 100644 index 0000000..2356b8d --- /dev/null +++ b/cartridges/domains/productivity/todoist-mcp/ffi/README.adoc @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += todoist-mcp / ffi β€” Zig FFI layer +:orientation: shallow + +== Purpose + +C-ABI exports bridging this cartridge's Idris2 ABI to the runtime. Consumed by +the adapter for protocol dispatch and by the trunk loader for mount. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” produces `zig-out/lib/libtodoist.so`. +| `todoist_mcp_ffi.zig` | C-ABI exports (15 exports, 5 inline tests, 353 lines). +|=== + +== Invariants + +* **Null-pointer rejection** on `[*c]const u8` arguments before any + dereference. +* **Negative return values** carry documented error meanings; zero is + success (matching the trunk error-code convention). +* **No allocator leaks** β€” every allocation path pairs with `defer free`. + +== Test/proof surface + +5 inline `test "..."` block(s) in `todoist_mcp_ffi.zig`. Run with +`cd ffi && zig build test`. + +== Read-first + +. `todoist_mcp_ffi.zig` β€” C-ABI signatures and guards. +. Trunk `ffi/zig/src/catalogue.zig` for how this `.so` is loaded. diff --git a/cartridges/domains/productivity/todoist-mcp/ffi/build.zig b/cartridges/domains/productivity/todoist-mcp/ffi/build.zig new file mode 100644 index 0000000..46ca1f3 --- /dev/null +++ b/cartridges/domains/productivity/todoist-mcp/ffi/build.zig @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + // Shared ADR-0006 invoke-shim module (relative path up to boj-server trunk). + + const shim_mod = b.addModule("cartridge_shim", .{ + + .root_source_file = b.path("../../../ffi/zig/src/cartridge_shim.zig"), + + .target = target, + + .optimize = optimize, + + }); + + const ffi_mod = b.addModule("todoist_mcp", .{ + .root_source_file = b.path("todoist_mcp_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + ffi_mod.addImport("cartridge_shim", shim_mod); + + const lib = b.addLibrary(.{ + .name = "todoist_mcp", + .root_module = ffi_mod, + .linkage = .dynamic, + }); + lib.linkLibC(); + b.installArtifact(lib); + + const tests = b.addTest(.{ .root_module = ffi_mod }); + tests.linkLibC(); + const run_tests = b.addRunArtifact(tests); + const test_step = b.step("test", "Run FFI tests"); + test_step.dependOn(&run_tests.step); +} + diff --git a/cartridges/domains/productivity/todoist-mcp/ffi/cartridge_shim.zig b/cartridges/domains/productivity/todoist-mcp/ffi/cartridge_shim.zig new file mode 100644 index 0000000..0e399db --- /dev/null +++ b/cartridges/domains/productivity/todoist-mcp/ffi/cartridge_shim.zig @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// cartridge_shim.zig β€” Shared helpers for the ADR-0006 five-symbol +// cartridge ABI (`boj_cartridge_init / deinit / name / version / invoke`). +// +// The shim centralises the seven-code return convention, NUL-argument +// guards, tool-name comparison, and the buffer-too-small path so each +// cartridge's `boj_cartridge_invoke` can stay short β€” typically a tool +// table plus `shim.writeResult(...)`. +// +// Cartridges import this file by relative path (no build-graph change +// needed). Example: +// +// const shim = @import("../../../ffi/zig/src/cartridge_shim.zig"); +// +// export fn boj_cartridge_invoke( +// tool_name: [*c]const u8, +// json_args: [*c]const u8, +// out_buf: [*c]u8, +// in_out_len: [*c]usize, +// ) callconv(.c) i32 { +// _ = json_args; +// if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; +// const body = if (shim.toolIs(tool_name, "foo")) "{\"result\":{}}" +// else return shim.RC_UNKNOWN_TOOL; +// return shim.writeResult(out_buf, in_out_len, body); +// } + +const std = @import("std"); + +// ── Return codes (ADR-0006 Β§Return codes) ──────────────────────────── +// +// Frozen by ADR-0006. New failure modes compose these via the error +// JSON body β€” the integer surface does not grow without a follow-up ADR. + +pub const RC_SUCCESS: i32 = 0; +pub const RC_UNKNOWN_TOOL: i32 = -1; +pub const RC_BAD_ARGS: i32 = -2; +pub const RC_BUFFER_TOO_SMALL: i32 = -3; +pub const RC_RUNTIME_ERROR: i32 = -4; +pub const RC_PANIC: i32 = -5; +pub const RC_AUTH_DENIED: i32 = -6; + +// ── Invoke-path helpers ────────────────────────────────────────────── + +/// True if any of the three mandatory `boj_cartridge_invoke` output-path +/// pointers is null. Use at the top of every invoke to short-circuit to +/// `RC_BAD_ARGS`. +pub fn invokeArgsNull( + tool_name: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) bool { + return tool_name == null or out_buf == null or in_out_len == null; +} + +/// Compare a C-NUL-terminated tool-name pointer against a Zig string +/// literal. Caller must have already verified `tool_name` is non-null +/// (usually via `invokeArgsNull`). +pub fn toolIs(tool_name: [*c]const u8, expected: []const u8) bool { + const s = std.mem.span(@as([*:0]const u8, @ptrCast(tool_name))); + return std.mem.eql(u8, s, expected); +} + +/// Copy `body` into `out_buf[0..*in_out_len]` (as a capacity) and update +/// `*in_out_len` to the number of bytes written. Returns `RC_SUCCESS`. +/// +/// If `body.len` exceeds the current capacity stored in `*in_out_len`, +/// sets `*in_out_len` to the required size and returns +/// `RC_BUFFER_TOO_SMALL` β€” the caller is then expected to re-allocate +/// and retry, per ADR-0006 Β§Memory ownership. +/// +/// Caller must have already verified that `out_buf` and `in_out_len` +/// are non-null. +pub fn writeResult( + out_buf: [*c]u8, + in_out_len: [*c]usize, + body: []const u8, +) i32 { + const cap = in_out_len.*; + if (body.len > cap) { + in_out_len.* = body.len; + return RC_BUFFER_TOO_SMALL; + } + @memcpy(out_buf[0..body.len], body); + in_out_len.* = body.len; + return RC_SUCCESS; +} + +// ── Tests ──────────────────────────────────────────────────────────── + +test "writeResult: body fits, writes and sets length" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); + try std.testing.expectEqualStrings("hello", buf[0..len]); +} + +test "writeResult: too small returns -3 and sets required length" { + var buf: [2]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_BUFFER_TOO_SMALL, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: exact-fit succeeds" { + var buf: [5]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: empty body" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, ""); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 0), len); +} + +test "toolIs: matches and rejects" { + const name: [*:0]const u8 = "foo"; + try std.testing.expect(toolIs(@ptrCast(name), "foo")); + try std.testing.expect(!toolIs(@ptrCast(name), "bar")); + try std.testing.expect(!toolIs(@ptrCast(name), "foobar")); + try std.testing.expect(!toolIs(@ptrCast(name), "fo")); +} + +test "invokeArgsNull: detects each null slot" { + var buf: [4]u8 = undefined; + var len: usize = 4; + const name: [*:0]const u8 = "x"; + try std.testing.expect(!invokeArgsNull(@ptrCast(name), &buf, &len)); + try std.testing.expect(invokeArgsNull(null, &buf, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), null, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), &buf, null)); +} diff --git a/cartridges/domains/productivity/todoist-mcp/ffi/todoist_mcp_ffi.zig b/cartridges/domains/productivity/todoist-mcp/ffi/todoist_mcp_ffi.zig new file mode 100644 index 0000000..23b60bc --- /dev/null +++ b/cartridges/domains/productivity/todoist-mcp/ffi/todoist_mcp_ffi.zig @@ -0,0 +1,462 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// todoist_mcp_ffi.zig β€” C-ABI FFI implementation for todoist-mcp cartridge. +// +// Implements the state machine defined in TodoistMcp.SafeRegistry (Idris2 ABI). +// State machine: Disconnected | Connected | RateLimited | Error +// Auth: Required Bearer token β€” Todoist API is always gated. +// REST API: https://api.todoist.com/rest/v2 +// Actions: GetTasks, GetTask, CreateTask, CompleteTask, ListProjects, +// GetProject, ListLabels, GetComments, ListSections, GetCompletedTasks +// Thread-safe via std.Thread.Mutex. Fixed-size session pool, no heap allocations. + +const std = @import("std"); + +// --------------------------------------------------------------------------- +// State machine (matches Idris2 ABI SessionState exactly) +// --------------------------------------------------------------------------- + +/// Session connection/lifecycle state. +/// 0 = Disconnected, 1 = Connected, 2 = RateLimited, 3 = Error. +pub const SessionState = enum(c_int) { + disconnected = 0, + connected = 1, + rate_limited = 2, + err = 3, +}; + +/// Todoist action identifiers matching Idris2 TodoistAction encoding. +pub const TodoistAction = enum(c_int) { + get_tasks = 0, + get_task = 1, + create_task = 2, + complete_task = 3, + list_projects = 4, + get_project = 5, + list_labels = 6, + get_comments = 7, + list_sections = 8, + get_completed_tasks = 9, +}; + +/// Check valid state transitions per the Idris2 ValidTransition proof. +fn isValidTransition(from: SessionState, to: SessionState) bool { + return switch (from) { + .disconnected => to == .connected or to == .err, + .connected => to == .disconnected or to == .rate_limited or to == .err, + .rate_limited => to == .connected, + .err => to == .connected or to == .disconnected, + }; +} + +// --------------------------------------------------------------------------- +// Session slots (thread-safe, fixed-size pool) +// --------------------------------------------------------------------------- + +const MAX_SESSIONS: usize = 16; + +const SessionSlot = struct { + active: bool = false, + state: SessionState = .disconnected, + api_call_count: u64 = 0, + last_action: c_int = -1, + task_reads: u32 = 0, + task_writes: u32 = 0, + project_queries: u32 = 0, + meta_queries: u32 = 0, +}; + +var sessions: [MAX_SESSIONS]SessionSlot = .{SessionSlot{}} ** MAX_SESSIONS; +var mutex: std.Thread.Mutex = .{}; + +// --------------------------------------------------------------------------- +// C-ABI exports β€” state machine +// --------------------------------------------------------------------------- + +/// Check if a state transition is valid. Returns 1 (valid) or 0 (invalid). +pub export fn todoist_mcp_can_transition(from: c_int, to: c_int) c_int { + const f = std.meta.intToEnum(SessionState, from) catch return 0; + const t = std.meta.intToEnum(SessionState, to) catch return 0; + return if (isValidTransition(f, t)) 1 else 0; +} + +/// Connect to Todoist (authenticated session). Returns slot index (>= 0) or error (< 0). +pub export fn todoist_mcp_connect(dummy: c_int) c_int { + _ = dummy; + mutex.lock(); + defer mutex.unlock(); + + for (&sessions, 0..) |*slot, idx| { + if (!slot.active) { + slot.active = true; + slot.state = .connected; + slot.api_call_count = 0; + slot.last_action = -1; + slot.task_reads = 0; + slot.task_writes = 0; + slot.project_queries = 0; + slot.meta_queries = 0; + return @intCast(idx); + } + } + return -1; +} + +/// Disconnect from Todoist. Returns 0 on success. +pub export fn todoist_mcp_disconnect(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + + sessions[idx] = SessionSlot{}; + return 0; +} + +/// Get current state of a session. Returns state int or -1 if invalid. +pub export fn todoist_mcp_session_state(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + return @intFromEnum(slot.state); +} + +/// Signal rate limiting on a session. Returns 0 on success. +pub export fn todoist_mcp_throttle(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + if (!isValidTransition(slot.state, .rate_limited)) return -2; + + sessions[idx].state = .rate_limited; + return 0; +} + +/// Clear rate limiting. Returns 0 on success. +pub export fn todoist_mcp_unthrottle(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + if (!isValidTransition(slot.state, .connected)) return -2; + + sessions[idx].state = .connected; + return 0; +} + +/// Signal an error on a session. Returns 0 on success. +pub export fn todoist_mcp_signal_error(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + if (!isValidTransition(slot.state, .err)) return -2; + + sessions[idx].state = .err; + return 0; +} + +// --------------------------------------------------------------------------- +// C-ABI exports β€” action recording and metrics +// --------------------------------------------------------------------------- + +/// Record an API call on a session. Returns 0 on success. +pub export fn todoist_mcp_record_call(slot_idx: c_int, action: c_int) c_int { + const act = std.meta.intToEnum(TodoistAction, action) catch return -3; + + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + if (slot.state != .connected) return -2; + + sessions[idx].api_call_count += 1; + sessions[idx].last_action = action; + + switch (act) { + .get_tasks, .get_task, .get_completed_tasks => sessions[idx].task_reads += 1, + .create_task, .complete_task => sessions[idx].task_writes += 1, + .list_projects, .get_project, .list_sections => sessions[idx].project_queries += 1, + .list_labels, .get_comments => sessions[idx].meta_queries += 1, + } + + return 0; +} + +/// Get API call count for a session. +pub export fn todoist_mcp_call_count(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + return @intCast(slot.api_call_count); +} + +/// Get task read count. +pub export fn todoist_mcp_task_read_count(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + return @intCast(slot.task_reads); +} + +/// Get task write count. +pub export fn todoist_mcp_task_write_count(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + return @intCast(slot.task_writes); +} + +/// Get project query count. +pub export fn todoist_mcp_project_query_count(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + return @intCast(slot.project_queries); +} + +/// Get meta query count. +pub export fn todoist_mcp_meta_query_count(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + return @intCast(slot.meta_queries); +} + +/// Get total action count. Always returns 10. +pub export fn todoist_mcp_action_count() c_int { + return 10; +} + +/// Reset all sessions (test/debug use only). +pub export fn todoist_mcp_reset() void { + mutex.lock(); + defer mutex.unlock(); + sessions = .{SessionSlot{}} ** MAX_SESSIONS; +} + +// --------------------------------------------------------------------------- +// ═══════════════════════════════════════════════════════════════════════ +// Standard ABI (ADR-0005 four symbols + ADR-0006 invoke) +// ═══════════════════════════════════════════════════════════════════════ + +const shim = @import("cartridge_shim.zig"); + +const CARTRIDGE_NAME_PTR: [*:0]const u8 = "todoist-mcp"; +const CARTRIDGE_VERSION_PTR: [*:0]const u8 = "0.1.0"; + +export fn boj_cartridge_init() callconv(.c) c_int { + return 0; +} + +export fn boj_cartridge_deinit() callconv(.c) void {} + +export fn boj_cartridge_name() callconv(.c) [*:0]const u8 { + return CARTRIDGE_NAME_PTR; +} + +export fn boj_cartridge_version() callconv(.c) [*:0]const u8 { + return CARTRIDGE_VERSION_PTR; +} + +/// Dispatch the cartridge.json MCP tools. Grade D Alpha stubs. +export fn boj_cartridge_invoke( + tool_name: [*c]const u8, + json_args: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) callconv(.c) i32 { + _ = json_args; + if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; + + const body: []const u8 = if (shim.toolIs(tool_name, "todoist_get_tasks")) + "{\"result\":{\"metadata\":{},\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "todoist_get_task")) + "{\"result\":{\"metadata\":{},\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "todoist_create_task")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "todoist_complete_task")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "todoist_list_projects")) + "{\"result\":{\"items\":[],\"count\":0,\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "todoist_get_project")) + "{\"result\":{\"metadata\":{},\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "todoist_list_labels")) + "{\"result\":{\"items\":[],\"count\":0,\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "todoist_get_comments")) + "{\"result\":{\"metadata\":{},\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "todoist_list_sections")) + "{\"result\":{\"items\":[],\"count\":0,\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "todoist_get_completed_tasks")) + "{\"result\":{\"metadata\":{},\"status\":\"stub\"}}" +else + return shim.RC_UNKNOWN_TOOL; + + return shim.writeResult(out_buf, in_out_len, body); +} + +// Tests +// --------------------------------------------------------------------------- + +test "connected session lifecycle" { + todoist_mcp_reset(); + + const slot = todoist_mcp_connect(0); + try std.testing.expect(slot >= 0); + try std.testing.expectEqual(@as(c_int, 1), todoist_mcp_session_state(slot)); + + try std.testing.expectEqual(@as(c_int, 0), todoist_mcp_record_call(slot, 0)); + try std.testing.expectEqual(@as(c_int, 1), todoist_mcp_call_count(slot)); + try std.testing.expectEqual(@as(c_int, 1), todoist_mcp_task_read_count(slot)); + + try std.testing.expectEqual(@as(c_int, 0), todoist_mcp_disconnect(slot)); +} + +test "requires connected state for actions" { + todoist_mcp_reset(); + + const slot = todoist_mcp_connect(0); + try std.testing.expect(slot >= 0); + + try std.testing.expectEqual(@as(c_int, 0), todoist_mcp_throttle(slot)); + try std.testing.expectEqual(@as(c_int, -2), todoist_mcp_record_call(slot, 0)); + + try std.testing.expectEqual(@as(c_int, 0), todoist_mcp_unthrottle(slot)); + try std.testing.expectEqual(@as(c_int, 0), todoist_mcp_record_call(slot, 0)); +} + +test "category counting" { + todoist_mcp_reset(); + + const slot = todoist_mcp_connect(0); + try std.testing.expect(slot >= 0); + + try std.testing.expectEqual(@as(c_int, 0), todoist_mcp_record_call(slot, 0)); // GetTasks + try std.testing.expectEqual(@as(c_int, 0), todoist_mcp_record_call(slot, 2)); // CreateTask + try std.testing.expectEqual(@as(c_int, 0), todoist_mcp_record_call(slot, 4)); // ListProjects + try std.testing.expectEqual(@as(c_int, 0), todoist_mcp_record_call(slot, 6)); // ListLabels + + try std.testing.expectEqual(@as(c_int, 4), todoist_mcp_call_count(slot)); + try std.testing.expectEqual(@as(c_int, 1), todoist_mcp_task_read_count(slot)); + try std.testing.expectEqual(@as(c_int, 1), todoist_mcp_task_write_count(slot)); + try std.testing.expectEqual(@as(c_int, 1), todoist_mcp_project_query_count(slot)); + try std.testing.expectEqual(@as(c_int, 1), todoist_mcp_meta_query_count(slot)); +} + +test "transition validator" { + try std.testing.expectEqual(@as(c_int, 1), todoist_mcp_can_transition(0, 1)); + try std.testing.expectEqual(@as(c_int, 1), todoist_mcp_can_transition(1, 0)); + try std.testing.expectEqual(@as(c_int, 1), todoist_mcp_can_transition(1, 2)); + try std.testing.expectEqual(@as(c_int, 1), todoist_mcp_can_transition(2, 1)); + try std.testing.expectEqual(@as(c_int, 1), todoist_mcp_can_transition(1, 3)); + try std.testing.expectEqual(@as(c_int, 1), todoist_mcp_can_transition(3, 0)); + try std.testing.expectEqual(@as(c_int, 0), todoist_mcp_can_transition(2, 3)); + try std.testing.expectEqual(@as(c_int, 0), todoist_mcp_can_transition(0, 2)); +} + +test "slot exhaustion" { + todoist_mcp_reset(); + + var slots: [MAX_SESSIONS]c_int = undefined; + for (&slots) |*s| { + s.* = todoist_mcp_connect(0); + try std.testing.expect(s.* >= 0); + } + + try std.testing.expectEqual(@as(c_int, -1), todoist_mcp_connect(0)); + + try std.testing.expectEqual(@as(c_int, 0), todoist_mcp_disconnect(slots[0])); + const new_slot = todoist_mcp_connect(0); + try std.testing.expect(new_slot >= 0); +} + +// ═══════════════════════════════════════════════════════════════════════ +// ADR-0006 invoke dispatch tests +// ═══════════════════════════════════════════════════════════════════════ + +test "boj_cartridge_name returns todoist-mcp" { + const n = std.mem.span(boj_cartridge_name()); + try std.testing.expectEqualStrings("todoist-mcp", n); +} + +test "boj_cartridge_init returns 0" { + try std.testing.expectEqual(@as(c_int, 0), boj_cartridge_init()); +} + +test "invoke: each declared tool succeeds" { + var buf: [256]u8 = undefined; + const tools = [_][]const u8{ + "todoist_get_tasks", + "todoist_get_task", + "todoist_create_task", + "todoist_complete_task", + "todoist_list_projects", + "todoist_get_project", + "todoist_list_labels", + "todoist_get_comments", + "todoist_list_sections", + "todoist_get_completed_tasks", + }; + for (tools) |t| { + var len: usize = buf.len; + const rc = boj_cartridge_invoke(t.ptr, "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, 0), rc); + try std.testing.expect(std.mem.indexOf(u8, buf[0..len], "result") != null); + } +} + +test "invoke: unknown tool returns -1" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("nope", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -1), rc); +} + +test "invoke: buffer too small returns -3" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("todoist_get_tasks", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -3), rc); + try std.testing.expect(len > 4); +} diff --git a/cartridges/domains/productivity/todoist-mcp/minter.toml b/cartridges/domains/productivity/todoist-mcp/minter.toml new file mode 100644 index 0000000..3e3e2b3 --- /dev/null +++ b/cartridges/domains/productivity/todoist-mcp/minter.toml @@ -0,0 +1,23 @@ +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> + +name = "todoist-mcp" +description = "Todoist task manager cartridge β€” task listing, creation, completion, project browsing, label management, comments, sections, completed task history" +version = "0.2.0" +domain = "Productivity" +protocols = [ + "MCP", + "REST", +] +tier = "Ayo" +backend = "universal" +generate_panel = true + +[auth] +method = "bearer_token" +credential_source = "vault-mcp" +fields = ["todoist_api_token"] + +[api] +base_url = "https://api.todoist.com/rest/v2" +local_only = false diff --git a/cartridges/domains/productivity/todoist-mcp/mod.js b/cartridges/domains/productivity/todoist-mcp/mod.js new file mode 100644 index 0000000..6444766 --- /dev/null +++ b/cartridges/domains/productivity/todoist-mcp/mod.js @@ -0,0 +1,202 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// todoist-mcp/mod.js -- Todoist task manager cartridge implementation. +// +// Provides MCP tool handlers for the Todoist REST API v2: +// - Task listing with filter expressions +// - Single task retrieval by ID +// - Task creation with due dates, priorities, labels +// - Task completion +// - Project listing and details +// - Label listing +// - Comment retrieval +// - Section browsing +// - Completed task history (Sync API) +// +// Auth: Bearer token via TODOIST_API_TOKEN (required). +// API docs: https://developer.todoist.com/rest/v2/ +// +// Usage: import { handleTool } from "./mod.js"; +// or: deno run --allow-net --allow-env mod.js + +const API_BASE = "https://api.todoist.com/rest/v2"; +const SYNC_API_BASE = "https://api.todoist.com/sync/v9"; + +// --------------------------------------------------------------------------- +// Auth helper β€” retrieves the Todoist API token from environment. +// In production, vault-mcp provides zero-knowledge credential proxying. +// --------------------------------------------------------------------------- + +function getToken() { + const token = typeof Deno !== "undefined" + ? Deno.env.get("TODOIST_API_TOKEN") + : process.env.TODOIST_API_TOKEN; + return token || null; +} + +// --------------------------------------------------------------------------- +// HTTP request helper β€” wraps fetch with Todoist API headers, +// bearer auth, and error normalization. +// --------------------------------------------------------------------------- + +async function todoistFetch(path, queryParams, method, body, useSyncApi) { + const base = useSyncApi ? SYNC_API_BASE : API_BASE; + const url = new URL(`${base}${path}`); + + if (queryParams) { + for (const [key, value] of Object.entries(queryParams)) { + if (value !== undefined && value !== null && value !== "") { + url.searchParams.set(key, String(value)); + } + } + } + + const headers = { + "Accept": "application/json", + "User-Agent": "boj-server/todoist-mcp/0.2.0", + }; + + const token = getToken(); + if (!token) { + return { status: 401, error: "TODOIST_API_TOKEN not set." }; + } + headers["Authorization"] = `Bearer ${token}`; + + const fetchOpts = { method: method || "GET", headers }; + + if (body) { + headers["Content-Type"] = "application/json"; + fetchOpts.body = JSON.stringify(body); + } + + const response = await fetch(url.toString(), fetchOpts); + + if (response.status === 429) { + const retryAfter = response.headers.get("retry-after"); + return { + status: 429, + error: `Rate limited. Retry after ${retryAfter || "unknown"} seconds.`, + retryAfter, + }; + } + + if (response.status === 204) { + return { status: 204, data: { success: true } }; + } + + const data = await response.json().catch(() => ({})); + + if (!response.ok) { + const errorMessage = data.message || data.error || `HTTP ${response.status}`; + return { status: response.status, error: errorMessage, data }; + } + + return { status: response.status, data }; +} + +// --------------------------------------------------------------------------- +// Tool handler dispatch β€” maps MCP tool names to Todoist API operations. +// --------------------------------------------------------------------------- + +export async function handleTool(toolName, args) { + switch (toolName) { + + // --- Task listing --- + + case "todoist_get_tasks": { + const params = {}; + if (args.project_id) params.project_id = args.project_id; + if (args.label) params.label = args.label; + if (args.filter) params.filter = args.filter; + return todoistFetch("/tasks", params); + } + + // --- Single task --- + + case "todoist_get_task": { + if (!args.task_id) return { error: "Missing required field: task_id" }; + return todoistFetch(`/tasks/${encodeURIComponent(args.task_id)}`); + } + + // --- Task creation --- + + case "todoist_create_task": { + if (!args.content) return { error: "Missing required field: content" }; + const body = { content: args.content }; + if (args.description) body.description = args.description; + if (args.project_id) body.project_id = args.project_id; + if (args.due_string) body.due_string = args.due_string; + if (args.due_date) body.due_date = args.due_date; + if (args.priority) body.priority = args.priority; + if (args.labels) body.labels = args.labels.split(",").map((l) => l.trim()); + return todoistFetch("/tasks", null, "POST", body); + } + + // --- Task completion --- + + case "todoist_complete_task": { + if (!args.task_id) return { error: "Missing required field: task_id" }; + return todoistFetch(`/tasks/${encodeURIComponent(args.task_id)}/close`, null, "POST"); + } + + // --- Projects --- + + case "todoist_list_projects": { + return todoistFetch("/projects"); + } + + case "todoist_get_project": { + if (!args.project_id) return { error: "Missing required field: project_id" }; + return todoistFetch(`/projects/${encodeURIComponent(args.project_id)}`); + } + + // --- Labels --- + + case "todoist_list_labels": { + return todoistFetch("/labels"); + } + + // --- Comments --- + + case "todoist_get_comments": { + const params = {}; + if (args.task_id) params.task_id = args.task_id; + if (args.project_id) params.project_id = args.project_id; + return todoistFetch("/comments", params); + } + + // --- Sections --- + + case "todoist_list_sections": { + if (!args.project_id) return { error: "Missing required field: project_id" }; + return todoistFetch("/sections", { project_id: args.project_id }); + } + + // --- Completed tasks (Sync API) --- + + case "todoist_get_completed_tasks": { + const params = {}; + if (args.project_id) params.project_id = args.project_id; + if (args.limit) params.limit = args.limit; + return todoistFetch("/completed/get_all", params, "GET", null, true); + } + + default: + return { error: `Unknown todoist-mcp tool: ${toolName}` }; + } +} + +// --------------------------------------------------------------------------- +// Cartridge metadata export β€” used by the BoJ cartridge loader to register +// this cartridge's tools without reading cartridge.json separately. +// --------------------------------------------------------------------------- + +export const metadata = { + name: "todoist-mcp", + version: "0.2.0", + domain: "Productivity", + tier: "Ayo", + protocols: ["MCP", "REST"], + toolCount: 10, +}; diff --git a/cartridges/domains/productivity/todoist-mcp/panels/manifest.json b/cartridges/domains/productivity/todoist-mcp/panels/manifest.json new file mode 100644 index 0000000..c00ad68 --- /dev/null +++ b/cartridges/domains/productivity/todoist-mcp/panels/manifest.json @@ -0,0 +1,90 @@ +{ + "$schema": "https://panll.dev/schemas/panel-manifest/v1.json", + "spdx": "MPL-2.0", + "copyright": "Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath)", + "cartridge": "todoist-mcp", + "domain": "Productivity", + "version": "0.2.0", + "panels": [ + { + "id": "todoist-connection-status", + "title": "Connection Status", + "description": "Todoist session state (Disconnected / Connected / RateLimited / Error)", + "type": "status-indicator", + "data_source": { + "endpoint": "/todoist/state", + "method": "GET", + "refresh_interval_ms": 5000 + }, + "widgets": [ + { + "type": "state-badge", + "field": "state", + "states": { + "disconnected": { "color": "#95a5a6", "icon": "wifi-off" }, + "connected": { "color": "#2ecc71", "icon": "wifi" }, + "rate_limited": { "color": "#f39c12", "icon": "clock" }, + "error": { "color": "#e74c3c", "icon": "alert-triangle" } + } + } + ] + }, + { + "id": "todoist-task-reads", + "title": "Task Reads", + "description": "Number of task read operations in the current session", + "type": "metric", + "data_source": { + "endpoint": "/todoist/status", + "method": "GET", + "refresh_interval_ms": 10000 + }, + "widgets": [ + { + "type": "counter", + "field": "task_reads", + "label": "Task Reads", + "icon": "list" + } + ] + }, + { + "id": "todoist-task-writes", + "title": "Task Writes", + "description": "Number of task create/complete operations in the current session", + "type": "metric", + "data_source": { + "endpoint": "/todoist/status", + "method": "GET", + "refresh_interval_ms": 10000 + }, + "widgets": [ + { + "type": "counter", + "field": "task_writes", + "label": "Task Writes", + "icon": "check-square" + } + ] + }, + { + "id": "todoist-project-queries", + "title": "Project Queries", + "description": "Number of project/section queries in the current session", + "type": "metric", + "data_source": { + "endpoint": "/todoist/status", + "method": "GET", + "refresh_interval_ms": 10000 + }, + "widgets": [ + { + "type": "counter", + "field": "project_queries", + "label": "Projects", + "icon": "folder" + } + ] + } + ] +} diff --git a/cartridges/domains/productivity/todoist-mcp/tests/integration_test.sh b/cartridges/domains/productivity/todoist-mcp/tests/integration_test.sh new file mode 100755 index 0000000..ebde755 --- /dev/null +++ b/cartridges/domains/productivity/todoist-mcp/tests/integration_test.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Integration tests for todoist-mcp cartridge. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CART_DIR="$(dirname "$SCRIPT_DIR")" +FFI_DIR="$CART_DIR/ffi" + +echo "=== todoist-mcp integration tests ===" + +# Build FFI +echo "[1/3] Building FFI..." +cd "$FFI_DIR" && zig build 2>&1 + +# Run Zig unit tests +echo "[2/3] Running FFI unit tests..." +cd "$FFI_DIR" && zig build test 2>&1 + +# Validate Idris2 ABI (if idris2 available) +echo "[3/3] Checking ABI..." +if command -v idris2 &>/dev/null; then + cd "$CART_DIR/abi" && idris2 --check TodoistMcp.SafeRegistry 2>&1 + echo " ABI: OK" +else + echo " ABI: SKIPPED (idris2 not in PATH)" +fi + +echo "" +echo "All tests passed for todoist-mcp!" diff --git a/cartridges/domains/project-management/jira-mcp/README.adoc b/cartridges/domains/project-management/jira-mcp/README.adoc new file mode 100644 index 0000000..e0c6b4a --- /dev/null +++ b/cartridges/domains/project-management/jira-mcp/README.adoc @@ -0,0 +1,127 @@ += jira-mcp +Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk> +:spdx: MPL-2.0 +:tier: Ayo +:domain: Comms +:protocols: MCP, REST + +== Overview + +Jira Cloud REST API cartridge for the BoJ server. +Wraps the full Jira platform β€” issues, projects, boards, sprints, +transitions, comments, fields, and user management β€” behind a +type-safe, rate-limit-aware interface using the REST API v3 at +`https://{instance}.atlassian.net/rest/api/3/` and the Agile API at +`https://{instance}.atlassian.net/rest/agile/1.0/`. + +== Architecture + +[cols="1,1,2"] +|=== +| Layer | Language | Purpose + +| ABI +| Idris2 +| Formally verified connection state machine (`SafeComms.idr`) with + dependent-type proofs for all valid transitions + +| FFI +| Zig +| C-compatible implementation with thread-safe session pool, REST + client stubs, configurable instance URL, and Basic auth header + +| Adapter +| zig +| REST bridge exposing all 16 Jira actions to the BoJ unified adapter +|=== + +== Authentication Setup + +This cartridge authenticates using **Basic auth** with an email address +and an **Atlassian API Token** (NOT an app password). + +1. Go to https://id.atlassian.com/manage-profile/security/api-tokens +2. Click **Create API token** and copy the generated token +3. Store both the email and the API token in **vault-mcp** so the + cartridge can retrieve them at authentication time +4. The instance subdomain (e.g. `mycompany` for `mycompany.atlassian.net`) + is also required + +== Jira Actions (16 total) + +[cols="1,2,1"] +|=== +| Action | REST Endpoint | Method + +| SearchIssues | `GET /rest/api/3/search` | GET (JQL) +| GetIssue | `GET /rest/api/3/issue/{issueIdOrKey}` | GET +| CreateIssue | `POST /rest/api/3/issue` | POST +| UpdateIssue | `PUT /rest/api/3/issue/{issueIdOrKey}` | PUT +| DeleteIssue | `DELETE /rest/api/3/issue/{issueIdOrKey}` | DELETE +| AddComment | `POST /rest/api/3/issue/{issueIdOrKey}/comment` | POST +| ListProjects | `GET /rest/api/3/project` | GET +| GetProject | `GET /rest/api/3/project/{projectIdOrKey}` | GET +| ListBoards | `GET /rest/agile/1.0/board` | GET +| GetBoard | `GET /rest/agile/1.0/board/{boardId}` | GET +| ListSprints | `GET /rest/agile/1.0/board/{boardId}/sprint` | GET +| GetSprint | `GET /rest/agile/1.0/sprint/{sprintId}` | GET +| TransitionIssue | `POST /rest/api/3/issue/{issueIdOrKey}/transitions` | POST +| AssignIssue | `PUT /rest/api/3/issue/{issueIdOrKey}/assignee` | PUT +| ListFields | `GET /rest/api/3/field` | GET +| GetUser | `GET /rest/api/3/user?accountId={accountId}` | GET +|=== + +== Connection State Machine + +.... +Unauthenticated ──→ Authenticated ──→ Unauthenticated + β”‚ ↑ + ↓ β”‚ + RateLimited + β”‚ + ↓ + Error ──→ Unauthenticated +.... + +Valid transitions: + +- `Unauthenticated` -> `Authenticated` (authenticate with Basic auth) +- `Authenticated` -> `RateLimited` (rate budget exhausted) +- `RateLimited` -> `Authenticated` (budget window reset) +- `Authenticated` -> `Error` (network/API fault) +- `RateLimited` -> `Error` (unrecoverable error during rate limit) +- `Authenticated` -> `Unauthenticated` (graceful close) +- `Error` -> `Unauthenticated` (reset for re-auth) + +== Rate Limits + +Jira Cloud rate limits vary by instance and license tier. The cartridge +uses a conservative budget of ~100 requests per minute. When the budget +is exhausted, the session transitions to `RateLimited` state. The budget +resets every 60 seconds. + +== Building + +[source,bash] +---- +# Build FFI shared library +cd ffi && zig build + +# Run FFI tests +cd ffi && zig build test + +# Type-check ABI +cd abi && idris2 --check jira_mcp.ipkg +---- + +== PanLL Panels + +The `panels/manifest.json` provides three data sources for the PanLL dashboard: + +- **Auth Status** β€” live state badge (5 s refresh) +- **Instance URL** β€” connected Jira instance (30 s refresh) +- **Sprint Progress** β€” sprint completion + actions performed (10 s refresh) + +== Status + +Development β€” not yet ready for mounting. diff --git a/cartridges/domains/project-management/jira-mcp/abi/JiraMcp/SafeComms.idr b/cartridges/domains/project-management/jira-mcp/abi/JiraMcp/SafeComms.idr new file mode 100644 index 0000000..4f40a03 --- /dev/null +++ b/cartridges/domains/project-management/jira-mcp/abi/JiraMcp/SafeComms.idr @@ -0,0 +1,224 @@ +-- SPDX-License-Identifier: MPL-2.0 +-- Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +-- +-- JiraMcp.SafeComms β€” Type-safe ABI for the Jira Cloud REST API cartridge. +-- +-- Dependent-type state machine governing Jira connection lifecycle. +-- All transitions proven valid at compile time. Zero unsafe escape hatches. + +module JiraMcp.SafeComms + +%default total + +-- --------------------------------------------------------------------------- +-- Connection state machine +-- --------------------------------------------------------------------------- + +||| Connection lifecycle states for Jira API interactions. +||| Models the full lifecycle: Basic auth (email + Atlassian API token), +||| connected operation against https://{instance}.atlassian.net/rest/api/3/, +||| rate limiting, and error recovery. +public export +data ConnState + = Unauthenticated + | Authenticated + | RateLimited + | Error + +-- --------------------------------------------------------------------------- +-- Valid state transitions (proven at the type level) +-- --------------------------------------------------------------------------- + +||| Proof witness that a state transition is permitted. +||| Only the transitions enumerated here can ever occur at the FFI boundary. +public export +data ValidTransition : ConnState -> ConnState -> Type where + ||| Authenticate with Basic auth (email + Atlassian API token). + Authenticate : ValidTransition Unauthenticated Authenticated + ||| Hit a Jira rate limit (request budget exhausted). + HitRateLimit : ValidTransition Authenticated RateLimited + ||| Rate limit window expired β€” resume operations. + RateRecovered : ValidTransition RateLimited Authenticated + ||| Operational error while authenticated (network, API fault). + AuthError : ValidTransition Authenticated Error + ||| Rate-limited session encounters an unrecoverable error. + RateLimitError : ValidTransition RateLimited Error + ||| Error recovery β€” return to unauthenticated for re-auth. + ErrorReset : ValidTransition Error Unauthenticated + ||| Graceful disconnect from an authenticated session. + GracefulClose : ValidTransition Authenticated Unauthenticated + +-- --------------------------------------------------------------------------- +-- C-ABI integer encoding for ConnState +-- --------------------------------------------------------------------------- + +||| Encode connection state as a C-compatible integer. +||| Mapping: Unauthenticated=0, Authenticated=1, RateLimited=2, Error=3. +export +connStateToInt : ConnState -> Int +connStateToInt Unauthenticated = 0 +connStateToInt Authenticated = 1 +connStateToInt RateLimited = 2 +connStateToInt Error = 3 + +||| Decode a C integer back to a connection state. +||| Returns Nothing for out-of-range values. +export +intToConnState : Int -> Maybe ConnState +intToConnState 0 = Just Unauthenticated +intToConnState 1 = Just Authenticated +intToConnState 2 = Just RateLimited +intToConnState 3 = Just Error +intToConnState _ = Nothing + +||| C-ABI export: check whether a state transition is valid. +||| Returns 1 for valid, 0 for invalid. Used by the Zig FFI layer. +export +jira_mcp_can_transition : Int -> Int -> Int +jira_mcp_can_transition from to = + case (intToConnState from, intToConnState to) of + (Just Unauthenticated, Just Authenticated) => 1 + (Just Authenticated, Just RateLimited) => 1 + (Just RateLimited, Just Authenticated) => 1 + (Just Authenticated, Just Error) => 1 + (Just RateLimited, Just Error) => 1 + (Just Error, Just Unauthenticated) => 1 + (Just Authenticated, Just Unauthenticated) => 1 + _ => 0 + +-- --------------------------------------------------------------------------- +-- Jira action vocabulary +-- --------------------------------------------------------------------------- + +||| All actions supported by the jira-mcp cartridge. +||| Each maps to a Jira Cloud REST API v3 endpoint under +||| https://{instance}.atlassian.net/rest/api/3/. +||| Auth uses Basic auth with email + Atlassian API token (NOT app password). +public export +data JiraAction + = SearchIssues -- GET /search (JQL) + | GetIssue -- GET /issue/{issueIdOrKey} + | CreateIssue -- POST /issue + | UpdateIssue -- PUT /issue/{issueIdOrKey} + | DeleteIssue -- DELETE /issue/{issueIdOrKey} + | AddComment -- POST /issue/{issueIdOrKey}/comment + | ListProjects -- GET /project + | GetProject -- GET /project/{projectIdOrKey} + | ListBoards -- GET /board (Agile API) + | GetBoard -- GET /board/{boardId} (Agile API) + | ListSprints -- GET /board/{boardId}/sprint (Agile API) + | GetSprint -- GET /sprint/{sprintId} (Agile API) + | TransitionIssue -- POST /issue/{issueIdOrKey}/transitions + | AssignIssue -- PUT /issue/{issueIdOrKey}/assignee + | ListFields -- GET /field + | GetUser -- GET /user?accountId={accountId} + +||| Encode a JiraAction as a C integer for FFI. +export +jiraActionToInt : JiraAction -> Int +jiraActionToInt SearchIssues = 0 +jiraActionToInt GetIssue = 1 +jiraActionToInt CreateIssue = 2 +jiraActionToInt UpdateIssue = 3 +jiraActionToInt DeleteIssue = 4 +jiraActionToInt AddComment = 5 +jiraActionToInt ListProjects = 6 +jiraActionToInt GetProject = 7 +jiraActionToInt ListBoards = 8 +jiraActionToInt GetBoard = 9 +jiraActionToInt ListSprints = 10 +jiraActionToInt GetSprint = 11 +jiraActionToInt TransitionIssue = 12 +jiraActionToInt AssignIssue = 13 +jiraActionToInt ListFields = 14 +jiraActionToInt GetUser = 15 + +||| Decode a C integer back to a JiraAction. +export +intToJiraAction : Int -> Maybe JiraAction +intToJiraAction 0 = Just SearchIssues +intToJiraAction 1 = Just GetIssue +intToJiraAction 2 = Just CreateIssue +intToJiraAction 3 = Just UpdateIssue +intToJiraAction 4 = Just DeleteIssue +intToJiraAction 5 = Just AddComment +intToJiraAction 6 = Just ListProjects +intToJiraAction 7 = Just GetProject +intToJiraAction 8 = Just ListBoards +intToJiraAction 9 = Just GetBoard +intToJiraAction 10 = Just ListSprints +intToJiraAction 11 = Just GetSprint +intToJiraAction 12 = Just TransitionIssue +intToJiraAction 13 = Just AssignIssue +intToJiraAction 14 = Just ListFields +intToJiraAction 15 = Just GetUser +intToJiraAction _ = Nothing + +||| Total action count exposed via C-ABI. +export +jira_mcp_action_count : Int +jira_mcp_action_count = 16 + +-- --------------------------------------------------------------------------- +-- API classification +-- --------------------------------------------------------------------------- + +||| Jira API families: REST v3 for most operations, Agile for boards/sprints. +public export +data ApiFamily = RestV3 | Agile + +||| Classify each action by its API family. +export +actionApiFamily : JiraAction -> ApiFamily +actionApiFamily SearchIssues = RestV3 +actionApiFamily GetIssue = RestV3 +actionApiFamily CreateIssue = RestV3 +actionApiFamily UpdateIssue = RestV3 +actionApiFamily DeleteIssue = RestV3 +actionApiFamily AddComment = RestV3 +actionApiFamily ListProjects = RestV3 +actionApiFamily GetProject = RestV3 +actionApiFamily ListBoards = Agile +actionApiFamily GetBoard = Agile +actionApiFamily ListSprints = Agile +actionApiFamily GetSprint = Agile +actionApiFamily TransitionIssue = RestV3 +actionApiFamily AssignIssue = RestV3 +actionApiFamily ListFields = RestV3 +actionApiFamily GetUser = RestV3 + +||| C-ABI export: check if an action requires an authenticated state. +||| All Jira actions require authentication. +export +jira_mcp_action_requires_auth : Int -> Int +jira_mcp_action_requires_auth actionId = + case intToJiraAction actionId of + Just _ => 1 -- all actions require Authenticated state + Nothing => 0 -- unknown action + +-- --------------------------------------------------------------------------- +-- MCP tool declarations +-- --------------------------------------------------------------------------- + +||| Tools exposed via the MCP protocol by this cartridge. +public export +data McpTool + = ToolConnect + | ToolDisconnect + | ToolStatus + | ToolInvoke + | ToolList + +||| Check if a tool requires an authenticated session. +export +toolRequiresSession : McpTool -> Bool +toolRequiresSession ToolConnect = False +toolRequiresSession ToolDisconnect = True +toolRequiresSession ToolStatus = False +toolRequiresSession ToolInvoke = True +toolRequiresSession ToolList = False + +||| Tool count for this cartridge. +export +toolCount : Nat +toolCount = 5 diff --git a/cartridges/domains/project-management/jira-mcp/abi/README.adoc b/cartridges/domains/project-management/jira-mcp/abi/README.adoc new file mode 100644 index 0000000..4a9259e --- /dev/null +++ b/cartridges/domains/project-management/jira-mcp/abi/README.adoc @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += jira-mcp / abi β€” Idris2 ABI layer +:orientation: shallow + +== Purpose + +Cartridge-local protocol types for `jira-mcp`. Defines the data, tool, and +transition vocabulary this cartridge speaks. Inherits safety lemmas and the +matrix-cell identifier scheme from the trunk ABI at `src/abi/`. + +== Files + +1 Idris2 module(s), ~224 lines total. Lead module: +`SafeComms.idr`. + +No `.ipkg` β€” consumed directly by wider BoJ callers; type-checks inside the +trunk `boj.ipkg`. + +== Invariants + +* `%default total` at every module top. +* Zero `believe_me`, `assert_total`, `postulate`, `Admitted`, or + `sorry` (the trunk holds the four declared `believe_me` sites; leaf + cartridges carry none). + +== Test/proof surface + +Thin module(s), no inline tests. Proof surface = type-check success under the +trunk `idris2 --check`. + +== Read-first + +. `SafeComms.idr` β€” cartridge-local data records and enums. +. Trunk `src/abi/Boj/Catalogue.idr` for how this cell participates in the + 2D matrix. diff --git a/cartridges/domains/project-management/jira-mcp/abi/jira_mcp.ipkg b/cartridges/domains/project-management/jira-mcp/abi/jira_mcp.ipkg new file mode 100644 index 0000000..5bea826 --- /dev/null +++ b/cartridges/domains/project-management/jira-mcp/abi/jira_mcp.ipkg @@ -0,0 +1,11 @@ +-- SPDX-License-Identifier: MPL-2.0 +-- Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +package jira_mcp + +version = "0.1.0" +authors = "Jonathan D.A. Jewell" +brief = "Type-safe ABI for the Jira Cloud REST API cartridge" + +depends = base + +modules = JiraMcp.SafeComms diff --git a/cartridges/domains/project-management/jira-mcp/adapter/README.adoc b/cartridges/domains/project-management/jira-mcp/adapter/README.adoc new file mode 100644 index 0000000..4d96b93 --- /dev/null +++ b/cartridges/domains/project-management/jira-mcp/adapter/README.adoc @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += jira-mcp / adapter β€” protocol bridge +:orientation: shallow + +== Purpose + +Exposes this cartridge's FFI over the wire protocols declared in +`cartridge.json`. Dispatches tool invocations to the C-ABI exports at +`../ffi/` and formats responses per protocol. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” depends on the sibling `ffi` target. +| `jira_mcp_adapter.zig` | Protocol dispatch (149 lines). Tool catalogue matches `cartridge.json`. +| `SIDELINED-*.v.adoc` | zig predecessor(s). Not built β€” zig banned 2026-04-10. +|=== + +== Invariants + +* **Stateless** β€” all state lives behind the FFI, never in the adapter. +* **Response passthrough** β€” whatever the FFI returns goes back to the wire + unmodified (no embellishment, no silent recovery). +* **Cartridge.json is source of truth** for the tool catalogue; drift between + adapter and manifest is a CI failure. + +== Test/proof surface + +No inline tests at the adapter layer β€” protocol routing is exercised by the +trunk seam tests at `ffi/zig/src/seams.zig`. + +== Read-first + +. `jira_mcp_adapter.zig` β€” dispatch function and per-protocol routers. +. `../cartridge.json` β€” the tool manifest the dispatcher mirrors. diff --git a/cartridges/domains/project-management/jira-mcp/adapter/build.zig b/cartridges/domains/project-management/jira-mcp/adapter/build.zig new file mode 100644 index 0000000..58edbb2 --- /dev/null +++ b/cartridges/domains/project-management/jira-mcp/adapter/build.zig @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// jira-mcp/adapter/build.zig + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const ffi_mod = b.createModule(.{ + .root_source_file = b.path("../ffi/jira_mcp_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + const adapter = b.addExecutable(.{ + .name = "jira_mcp_adapter", + .root_source_file = b.path("jira_mcp_adapter.zig"), + .target = target, + .optimize = optimize, + }); + adapter.root_module.addImport("jira_mcp_ffi", ffi_mod); + b.installArtifact(adapter); +} diff --git a/cartridges/domains/project-management/jira-mcp/adapter/jira_mcp_adapter.zig b/cartridges/domains/project-management/jira-mcp/adapter/jira_mcp_adapter.zig new file mode 100644 index 0000000..a465ad6 --- /dev/null +++ b/cartridges/domains/project-management/jira-mcp/adapter/jira_mcp_adapter.zig @@ -0,0 +1,149 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// jira-mcp/adapter/jira_mcp_adapter.zig +// +// Three-protocol BoJ adapter: REST (port 9256), gRPC-compat (port 9257), +// GraphQL (port 9258). +// Replaces the banned zig adapter (jira_mcp_adapter.v). + +const std = @import("std"); +const ffi = @import("jira_mcp_ffi"); + +const REST_PORT: u16 = 9256; +const GRPC_PORT: u16 = 9257; +const GQL_PORT: u16 = 9258; + +const Response = struct { status: u16, body: []const u8 }; + +fn okJson(buf: []u8, msg: []const u8) []u8 { + return std.fmt.bufPrint(buf, "{{\"success\":true,\"message\":\"{s}\"}}", .{msg}) catch buf[0..0]; +} + +fn errJson(buf: []u8, msg: []const u8) []u8 { + return std.fmt.bufPrint(buf, "{{\"success\":false,\"error\":\"{s}\"}}", .{msg}) catch buf[0..0]; +} + +fn dispatch(tool: []const u8, _body: []const u8, resp: []u8) Response { + _ = _body; + if (std.mem.eql(u8, tool, "jira_authenticate")) { + return .{ .status = 200, .body = okJson(resp, "jira_authenticate forwarded") }; + } + if (std.mem.eql(u8, tool, "jira_search_issues")) { + return .{ .status = 200, .body = okJson(resp, "jira_search_issues forwarded") }; + } + if (std.mem.eql(u8, tool, "jira_get_issue")) { + return .{ .status = 200, .body = okJson(resp, "jira_get_issue forwarded") }; + } + if (std.mem.eql(u8, tool, "jira_create_issue")) { + return .{ .status = 200, .body = okJson(resp, "jira_create_issue forwarded") }; + } + if (std.mem.eql(u8, tool, "jira_update_issue")) { + return .{ .status = 200, .body = okJson(resp, "jira_update_issue forwarded") }; + } + if (std.mem.eql(u8, tool, "jira_add_comment")) { + return .{ .status = 200, .body = okJson(resp, "jira_add_comment forwarded") }; + } + if (std.mem.eql(u8, tool, "jira_list_projects")) { + return .{ .status = 200, .body = okJson(resp, "jira_list_projects forwarded") }; + } + if (std.mem.eql(u8, tool, "jira_transition_issue")) { + return .{ .status = 200, .body = okJson(resp, "jira_transition_issue forwarded") }; + } + return .{ .status = 404, .body = errJson(resp, "unknown tool") }; +} + +fn dispatchRest(path: []const u8, body: []const u8, resp: []u8) Response { + // Expect /tools/<tool_name> + const prefix = "/tools/"; + if (std.mem.startsWith(u8, path, prefix)) { + const tool = path[prefix.len..]; + return dispatch(tool, body, resp); + } + return .{ .status = 404, .body = errJson(resp, "not found") }; +} + +fn dispatchGrpc(path: []const u8, body: []const u8, resp: []u8) Response { + // Expect /<Service>/<Method> β€” derive tool from Method + var it = std.mem.splitScalar(u8, path, '/'); + _ = it.next(); // leading empty + _ = it.next(); // service + const method = it.next() orelse return .{ .status = 404, .body = errJson(resp, "bad gRPC path") }; + return dispatch(method, body, resp); +} + +fn dispatchGraphql(body: []const u8, resp: []u8) Response { + if (std.mem.indexOf(u8, body, "jira_authenticate") != null) + return dispatch("jira_authenticate", body, resp); + if (std.mem.indexOf(u8, body, "jira_search_issues") != null) + return dispatch("jira_search_issues", body, resp); + if (std.mem.indexOf(u8, body, "jira_get_issue") != null) + return dispatch("jira_get_issue", body, resp); + if (std.mem.indexOf(u8, body, "jira_create_issue") != null) + return dispatch("jira_create_issue", body, resp); + if (std.mem.indexOf(u8, body, "jira_update_issue") != null) + return dispatch("jira_update_issue", body, resp); + if (std.mem.indexOf(u8, body, "jira_add_comment") != null) + return dispatch("jira_add_comment", body, resp); + if (std.mem.indexOf(u8, body, "jira_list_projects") != null) + return dispatch("jira_list_projects", body, resp); + if (std.mem.indexOf(u8, body, "jira_transition_issue") != null) + return dispatch("jira_transition_issue", body, resp); + return .{ .status = 400, .body = errJson(resp, "unrecognised GraphQL operation") }; +} + +fn handleConnection(stream: std.net.Stream, port: u16) void { + defer stream.close(); + var buf: [4096]u8 = undefined; + var resp_buf: [4096]u8 = undefined; + const n = stream.read(&buf) catch return; + const req = buf[0..n]; + const result = switch (port) { + REST_PORT => blk: { + // Parse HTTP/1.1: first line = METHOD PATH HTTP/x.y + var lines = std.mem.splitScalar(u8, req, '\n'); + const first = lines.next() orelse break :blk Response{ .status = 400, .body = "" }; + var parts = std.mem.splitScalar(u8, std.mem.trim(u8, first, "\r"), ' '); + _ = parts.next(); // method + const path = parts.next() orelse break :blk Response{ .status = 400, .body = "" }; + break :blk dispatchRest(path, req, &resp_buf); + }, + GRPC_PORT => blk: { + var lines = std.mem.splitScalar(u8, req, '\n'); + const first = lines.next() orelse break :blk Response{ .status = 400, .body = "" }; + var parts = std.mem.splitScalar(u8, std.mem.trim(u8, first, "\r"), ' '); + _ = parts.next(); + const path = parts.next() orelse break :blk Response{ .status = 400, .body = "" }; + break :blk dispatchGrpc(path, req, &resp_buf); + }, + GQL_PORT => dispatchGraphql(req, &resp_buf), + else => Response{ .status = 500, .body = "" }, + }; + var http_resp: [512]u8 = undefined; + const http = std.fmt.bufPrint(&http_resp, + "HTTP/1.1 {d} OK\r\nContent-Length: {d}\r\nContent-Type: application/json\r\n\r\n", + .{ result.status, result.body.len }) catch return; + _ = stream.write(http) catch {}; + _ = stream.write(result.body) catch {}; +} + +fn listenLoop(port: u16) !void { + const addr = std.net.Address.initIp4(.{ 127, 0, 0, 1 }, port); + var server = try addr.listen(.{ .reuse_address = true }); + defer server.deinit(); + while (true) { + const conn = try server.accept(); + const t = try std.Thread.spawn(.{}, handleConnection, .{ conn.stream, port }); + t.detach(); + } +} + +pub fn main() !void { + ffi.jira_mcp_init(); + const t1 = try std.Thread.spawn(.{}, listenLoop, .{REST_PORT}); + const t2 = try std.Thread.spawn(.{}, listenLoop, .{GRPC_PORT}); + const t3 = try std.Thread.spawn(.{}, listenLoop, .{GQL_PORT}); + t1.join(); + t2.join(); + t3.join(); +} diff --git a/cartridges/domains/project-management/jira-mcp/benchmarks/quick-bench.sh b/cartridges/domains/project-management/jira-mcp/benchmarks/quick-bench.sh new file mode 100755 index 0000000..e920142 --- /dev/null +++ b/cartridges/domains/project-management/jira-mcp/benchmarks/quick-bench.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Benchmarks for jira-mcp cartridge. +set -euo pipefail + +echo "=== jira-mcp benchmarks ===" + +cd "$(dirname "${BASH_SOURCE[0]}")/../ffi" + +# Build with ReleaseFast for benchmarking +zig build -Doptimize=ReleaseFast 2>&1 + +echo "Session open/close cycle (1000 iterations):" +time for i in $(seq 1 1000); do + # This would call the FFI benchmark binary + true +done + +echo "" +echo "Benchmark placeholder β€” implement real benchmarks in Zig test blocks" +echo "or via the zig adapter HTTP benchmark tool." diff --git a/cartridges/domains/project-management/jira-mcp/cartridge.json b/cartridges/domains/project-management/jira-mcp/cartridge.json new file mode 100644 index 0000000..522645d --- /dev/null +++ b/cartridges/domains/project-management/jira-mcp/cartridge.json @@ -0,0 +1,235 @@ +{ + "$schema": "https://boj.dev/schemas/cartridge/v1.json", + "spdx": "MPL-2.0", + "copyright": "Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk>", + "name": "jira-mcp", + "version": "0.1.0", + "description": "Jira project management and issue tracking", + "domain": "project-management", + "tier": "Ayo", + "protocols": [ + "MCP", + "REST" + ], + "auth": { + "method": "none", + "env_var": null, + "credential_source": null + }, + "api": { + "base_url": "local://jira-mcp", + "content_type": "application/json" + }, + "tools": [ + { + "name": "jira_authenticate", + "description": "Authenticate with Jira Cloud or Server", + "inputSchema": { + "type": "object", + "properties": { + "base_url": { + "type": "string", + "description": "Jira base URL" + }, + "token": { + "type": "string", + "description": "API token" + }, + "email": { + "type": "string", + "description": "Account email (Cloud only)" + } + }, + "required": [ + "base_url", + "token" + ] + } + }, + { + "name": "jira_search_issues", + "description": "Search issues with JQL", + "inputSchema": { + "type": "object", + "properties": { + "session_id": { + "type": "integer", + "description": "Session index" + }, + "jql": { + "type": "string", + "description": "JQL query string" + }, + "limit": { + "type": "integer", + "description": "Max results" + } + }, + "required": [ + "session_id", + "jql" + ] + } + }, + { + "name": "jira_get_issue", + "description": "Get a specific issue", + "inputSchema": { + "type": "object", + "properties": { + "session_id": { + "type": "integer", + "description": "Session index" + }, + "issue_key": { + "type": "string", + "description": "Issue key (e.g. PROJ-123)" + } + }, + "required": [ + "session_id", + "issue_key" + ] + } + }, + { + "name": "jira_create_issue", + "description": "Create a new issue", + "inputSchema": { + "type": "object", + "properties": { + "session_id": { + "type": "integer", + "description": "Session index" + }, + "project_key": { + "type": "string", + "description": "Project key" + }, + "summary": { + "type": "string", + "description": "Issue summary" + }, + "issue_type": { + "type": "string", + "description": "Issue type: Bug|Story|Task|Epic" + }, + "description": { + "type": "string", + "description": "Issue description" + } + }, + "required": [ + "session_id", + "project_key", + "summary" + ] + } + }, + { + "name": "jira_update_issue", + "description": "Update an issue", + "inputSchema": { + "type": "object", + "properties": { + "session_id": { + "type": "integer", + "description": "Session index" + }, + "issue_key": { + "type": "string", + "description": "Issue key" + }, + "fields": { + "type": "object", + "description": "Fields to update" + } + }, + "required": [ + "session_id", + "issue_key", + "fields" + ] + } + }, + { + "name": "jira_add_comment", + "description": "Add a comment to an issue", + "inputSchema": { + "type": "object", + "properties": { + "session_id": { + "type": "integer", + "description": "Session index" + }, + "issue_key": { + "type": "string", + "description": "Issue key" + }, + "body": { + "type": "string", + "description": "Comment body" + } + }, + "required": [ + "session_id", + "issue_key", + "body" + ] + } + }, + { + "name": "jira_list_projects", + "description": "List accessible projects", + "inputSchema": { + "type": "object", + "properties": { + "session_id": { + "type": "integer", + "description": "Session index" + } + }, + "required": [ + "session_id" + ] + } + }, + { + "name": "jira_transition_issue", + "description": "Transition issue to a new status", + "inputSchema": { + "type": "object", + "properties": { + "session_id": { + "type": "integer", + "description": "Session index" + }, + "issue_key": { + "type": "string", + "description": "Issue key" + }, + "transition_id": { + "type": "string", + "description": "Transition ID or name" + } + }, + "required": [ + "session_id", + "issue_key", + "transition_id" + ] + } + } + ], + "ffi": { + "so_path": "ffi/zig-out/lib/libjira_mcp.so", + "abi_version": "ADR-0006", + "symbols": [ + "boj_cartridge_init", + "boj_cartridge_deinit", + "boj_cartridge_name", + "boj_cartridge_version", + "boj_cartridge_invoke" + ] + } +} diff --git a/cartridges/domains/project-management/jira-mcp/ffi/README.adoc b/cartridges/domains/project-management/jira-mcp/ffi/README.adoc new file mode 100644 index 0000000..bd028e7 --- /dev/null +++ b/cartridges/domains/project-management/jira-mcp/ffi/README.adoc @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += jira-mcp / ffi β€” Zig FFI layer +:orientation: shallow + +== Purpose + +C-ABI exports bridging this cartridge's Idris2 ABI to the runtime. Consumed by +the adapter for protocol dispatch and by the trunk loader for mount. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” produces `zig-out/lib/libjira.so`. +| `jira_mcp_ffi.zig` | C-ABI exports (11 exports, 6 inline tests, 593 lines). +|=== + +== Invariants + +* **Null-pointer rejection** on `[*c]const u8` arguments before any + dereference. +* **Negative return values** carry documented error meanings; zero is + success (matching the trunk error-code convention). +* **No allocator leaks** β€” every allocation path pairs with `defer free`. + +== Test/proof surface + +6 inline `test "..."` block(s) in `jira_mcp_ffi.zig`. Run with +`cd ffi && zig build test`. + +== Read-first + +. `jira_mcp_ffi.zig` β€” C-ABI signatures and guards. +. Trunk `ffi/zig/src/catalogue.zig` for how this `.so` is loaded. diff --git a/cartridges/domains/project-management/jira-mcp/ffi/build.zig b/cartridges/domains/project-management/jira-mcp/ffi/build.zig new file mode 100644 index 0000000..82aa8f8 --- /dev/null +++ b/cartridges/domains/project-management/jira-mcp/ffi/build.zig @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + // Module + // Shared ADR-0006 invoke-shim module (relative path up to boj-server trunk). + + const shim_mod = b.addModule("cartridge_shim", .{ + + .root_source_file = b.path("../../../ffi/zig/src/cartridge_shim.zig"), + + .target = target, + + .optimize = optimize, + + }); + + const ffi_mod = b.addModule("jira_mcp", .{ + .root_source_file = b.path("jira_mcp_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + ffi_mod.addImport("cartridge_shim", shim_mod); + + // Shared library + const lib = b.addLibrary(.{ + .name = "jira_mcp", + .root_module = ffi_mod, + .linkage = .dynamic, + }); + lib.linkLibC(); + b.installArtifact(lib); + + // Tests + const tests = b.addTest(.{ + .root_module = ffi_mod, + }); + tests.linkLibC(); + + const run_tests = b.addRunArtifact(tests); + const test_step = b.step("test", "Run FFI tests"); + test_step.dependOn(&run_tests.step); +} diff --git a/cartridges/domains/project-management/jira-mcp/ffi/cartridge_shim.zig b/cartridges/domains/project-management/jira-mcp/ffi/cartridge_shim.zig new file mode 100644 index 0000000..0e399db --- /dev/null +++ b/cartridges/domains/project-management/jira-mcp/ffi/cartridge_shim.zig @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// cartridge_shim.zig β€” Shared helpers for the ADR-0006 five-symbol +// cartridge ABI (`boj_cartridge_init / deinit / name / version / invoke`). +// +// The shim centralises the seven-code return convention, NUL-argument +// guards, tool-name comparison, and the buffer-too-small path so each +// cartridge's `boj_cartridge_invoke` can stay short β€” typically a tool +// table plus `shim.writeResult(...)`. +// +// Cartridges import this file by relative path (no build-graph change +// needed). Example: +// +// const shim = @import("../../../ffi/zig/src/cartridge_shim.zig"); +// +// export fn boj_cartridge_invoke( +// tool_name: [*c]const u8, +// json_args: [*c]const u8, +// out_buf: [*c]u8, +// in_out_len: [*c]usize, +// ) callconv(.c) i32 { +// _ = json_args; +// if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; +// const body = if (shim.toolIs(tool_name, "foo")) "{\"result\":{}}" +// else return shim.RC_UNKNOWN_TOOL; +// return shim.writeResult(out_buf, in_out_len, body); +// } + +const std = @import("std"); + +// ── Return codes (ADR-0006 Β§Return codes) ──────────────────────────── +// +// Frozen by ADR-0006. New failure modes compose these via the error +// JSON body β€” the integer surface does not grow without a follow-up ADR. + +pub const RC_SUCCESS: i32 = 0; +pub const RC_UNKNOWN_TOOL: i32 = -1; +pub const RC_BAD_ARGS: i32 = -2; +pub const RC_BUFFER_TOO_SMALL: i32 = -3; +pub const RC_RUNTIME_ERROR: i32 = -4; +pub const RC_PANIC: i32 = -5; +pub const RC_AUTH_DENIED: i32 = -6; + +// ── Invoke-path helpers ────────────────────────────────────────────── + +/// True if any of the three mandatory `boj_cartridge_invoke` output-path +/// pointers is null. Use at the top of every invoke to short-circuit to +/// `RC_BAD_ARGS`. +pub fn invokeArgsNull( + tool_name: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) bool { + return tool_name == null or out_buf == null or in_out_len == null; +} + +/// Compare a C-NUL-terminated tool-name pointer against a Zig string +/// literal. Caller must have already verified `tool_name` is non-null +/// (usually via `invokeArgsNull`). +pub fn toolIs(tool_name: [*c]const u8, expected: []const u8) bool { + const s = std.mem.span(@as([*:0]const u8, @ptrCast(tool_name))); + return std.mem.eql(u8, s, expected); +} + +/// Copy `body` into `out_buf[0..*in_out_len]` (as a capacity) and update +/// `*in_out_len` to the number of bytes written. Returns `RC_SUCCESS`. +/// +/// If `body.len` exceeds the current capacity stored in `*in_out_len`, +/// sets `*in_out_len` to the required size and returns +/// `RC_BUFFER_TOO_SMALL` β€” the caller is then expected to re-allocate +/// and retry, per ADR-0006 Β§Memory ownership. +/// +/// Caller must have already verified that `out_buf` and `in_out_len` +/// are non-null. +pub fn writeResult( + out_buf: [*c]u8, + in_out_len: [*c]usize, + body: []const u8, +) i32 { + const cap = in_out_len.*; + if (body.len > cap) { + in_out_len.* = body.len; + return RC_BUFFER_TOO_SMALL; + } + @memcpy(out_buf[0..body.len], body); + in_out_len.* = body.len; + return RC_SUCCESS; +} + +// ── Tests ──────────────────────────────────────────────────────────── + +test "writeResult: body fits, writes and sets length" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); + try std.testing.expectEqualStrings("hello", buf[0..len]); +} + +test "writeResult: too small returns -3 and sets required length" { + var buf: [2]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_BUFFER_TOO_SMALL, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: exact-fit succeeds" { + var buf: [5]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: empty body" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, ""); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 0), len); +} + +test "toolIs: matches and rejects" { + const name: [*:0]const u8 = "foo"; + try std.testing.expect(toolIs(@ptrCast(name), "foo")); + try std.testing.expect(!toolIs(@ptrCast(name), "bar")); + try std.testing.expect(!toolIs(@ptrCast(name), "foobar")); + try std.testing.expect(!toolIs(@ptrCast(name), "fo")); +} + +test "invokeArgsNull: detects each null slot" { + var buf: [4]u8 = undefined; + var len: usize = 4; + const name: [*:0]const u8 = "x"; + try std.testing.expect(!invokeArgsNull(@ptrCast(name), &buf, &len)); + try std.testing.expect(invokeArgsNull(null, &buf, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), null, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), &buf, null)); +} diff --git a/cartridges/domains/project-management/jira-mcp/ffi/jira_mcp_ffi.zig b/cartridges/domains/project-management/jira-mcp/ffi/jira_mcp_ffi.zig new file mode 100644 index 0000000..d832c58 --- /dev/null +++ b/cartridges/domains/project-management/jira-mcp/ffi/jira_mcp_ffi.zig @@ -0,0 +1,696 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// jira_mcp_ffi.zig β€” C-ABI FFI for the Jira Cloud REST API cartridge. +// +// Implements the state machine defined in JiraMcp.SafeComms (Idris2 ABI). +// Provides real HTTP dispatch to the Jira REST API v3 +// (https://{instance}.atlassian.net/rest/api/3/) and the Agile API +// via std.http.Client, rate-limit tracking, and Basic auth +// (email + Atlassian API token) obtained from vault-mcp. +// +// Thread-safe via std.Thread.Mutex. No heap allocations for result buffers. + +const std = @import("std"); + +// --------------------------------------------------------------------------- +// Connection state machine (matches Idris2 ConnState exactly) +// --------------------------------------------------------------------------- + +/// Connection lifecycle states mirroring JiraMcp.SafeComms.ConnState. +pub const ConnState = enum(c_int) { + unauthenticated = 0, + authenticated = 1, + rate_limited = 2, + err = 3, +}; + +/// Check whether a transition between two states is valid. +/// Encodes the same transition table as the Idris2 ValidTransition GADT. +fn isValidTransition(from: ConnState, to: ConnState) bool { + return switch (from) { + .unauthenticated => to == .authenticated, + .authenticated => to == .rate_limited or to == .err or to == .unauthenticated, + .rate_limited => to == .authenticated or to == .err, + .err => to == .unauthenticated, + }; +} + +// --------------------------------------------------------------------------- +// Jira action vocabulary (matches Idris2 JiraAction exactly) +// --------------------------------------------------------------------------- + +/// All 16 Jira actions supported by this cartridge. +pub const JiraAction = enum(c_int) { + search_issues = 0, + get_issue = 1, + create_issue = 2, + update_issue = 3, + delete_issue = 4, + add_comment = 5, + list_projects = 6, + get_project = 7, + list_boards = 8, + get_board = 9, + list_sprints = 10, + get_sprint = 11, + transition_issue = 12, + assign_issue = 13, + list_fields = 14, + get_user = 15, +}; + +/// Map each action to its Jira REST API endpoint path template. +fn actionToEndpoint(action: JiraAction) []const u8 { + return switch (action) { + .search_issues => "/rest/api/3/search", + .get_issue => "/rest/api/3/issue/{issueIdOrKey}", + .create_issue => "/rest/api/3/issue", + .update_issue => "/rest/api/3/issue/{issueIdOrKey}", + .delete_issue => "/rest/api/3/issue/{issueIdOrKey}", + .add_comment => "/rest/api/3/issue/{issueIdOrKey}/comment", + .list_projects => "/rest/api/3/project", + .get_project => "/rest/api/3/project/{projectIdOrKey}", + .list_boards => "/rest/agile/1.0/board", + .get_board => "/rest/agile/1.0/board/{boardId}", + .list_sprints => "/rest/agile/1.0/board/{boardId}/sprint", + .get_sprint => "/rest/agile/1.0/sprint/{sprintId}", + .transition_issue => "/rest/api/3/issue/{issueIdOrKey}/transitions", + .assign_issue => "/rest/api/3/issue/{issueIdOrKey}/assignee", + .list_fields => "/rest/api/3/field", + .get_user => "/rest/api/3/user", + }; +} + +/// Map each action to its HTTP method string. +fn actionToMethod(action: JiraAction) []const u8 { + return switch (action) { + .search_issues => "GET", + .get_issue => "GET", + .create_issue => "POST", + .update_issue => "PUT", + .delete_issue => "DELETE", + .add_comment => "POST", + .list_projects => "GET", + .get_project => "GET", + .list_boards => "GET", + .get_board => "GET", + .list_sprints => "GET", + .get_sprint => "GET", + .transition_issue => "POST", + .assign_issue => "PUT", + .list_fields => "GET", + .get_user => "GET", + }; +} + +// --------------------------------------------------------------------------- +// Rate-limit tracking +// --------------------------------------------------------------------------- + +/// Tracks rate-limit usage within a sliding window. +/// Jira Cloud typically allows hundreds of requests per minute, +/// but can vary by instance and license tier. +const RateTracker = struct { + /// Number of requests made in the current window. + count: u32 = 0, + /// Epoch timestamp (seconds) when the current window started. + window_start: i64 = 0, + + /// Record one request. Returns true if within budget, false if exhausted. + fn record(self: *RateTracker, now: i64, budget: u32) bool { + // Reset window every 60 seconds. + if (now - self.window_start >= 60) { + self.window_start = now; + self.count = 0; + } + if (self.count >= budget) return false; + self.count += 1; + return true; + } +}; + +/// Jira rate budget: conservative estimate (~100 requests per minute). +const RATE_BUDGET: u32 = 100; + +// --------------------------------------------------------------------------- +// Session slot pool (thread-safe, fixed-size) +// --------------------------------------------------------------------------- + +const MAX_SESSIONS: usize = 16; +const BUF_SIZE: usize = 8192; +const CRED_MAX: usize = 256; +const URL_MAX: usize = 256; + +const SessionSlot = struct { + active: bool = false, + state: ConnState = .unauthenticated, + /// Base64-encoded Basic auth credentials (email:api_token). + cred_buf: [CRED_MAX]u8 = undefined, + cred_len: usize = 0, + /// Instance URL (e.g. "mycompany.atlassian.net"). + instance_buf: [URL_MAX]u8 = undefined, + instance_len: usize = 0, + /// Rate-limit tracker for request budgeting. + rate_tracker: RateTracker = .{}, + /// Sprint progress percentage (0-100, for panel metrics). + sprint_progress: u8 = 0, + /// Count of actions performed in this session (for panel metrics). + actions_performed: u32 = 0, + /// General-purpose output buffer for API responses. + out_buf: [BUF_SIZE]u8 = undefined, + out_len: usize = 0, +}; + +var sessions: [MAX_SESSIONS]SessionSlot = [_]SessionSlot{.{}} ** MAX_SESSIONS; +var mutex: std.Thread.Mutex = .{}; + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +/// Copy a null-terminated C string into a fixed buffer. Returns bytes written. +fn copyFromCStr(dest: []u8, src: [*c]const u8) usize { + if (src == null) return 0; + var i: usize = 0; + while (i < dest.len and src[i] != 0) : (i += 1) { + dest[i] = src[i]; + } + return i; +} + +/// Write a slice into an output buffer pointer + length pointer. +fn writeOutput(buf: [*c]u8, buf_cap: usize, out_len: *c_int, data: []const u8) void { + if (buf == null) return; + const to_copy = @min(data.len, buf_cap); + for (0..to_copy) |i| { + buf[i] = data[i]; + } + out_len.* = @intCast(to_copy); +} + +/// Get a slot by index, returning null if invalid or inactive. +fn getSlot(slot_idx: c_int) ?*SessionSlot { + const idx: usize = std.math.cast(usize, slot_idx) orelse return null; + if (idx >= MAX_SESSIONS) return null; + const slot = &sessions[idx]; + if (!slot.active) return null; + return slot; +} + +/// Attempt a state transition on a slot. Returns 0 on success, -2 if invalid. +fn tryTransition(slot: *SessionSlot, target: ConnState) c_int { + if (!isValidTransition(slot.state, target)) return -2; + slot.state = target; + return 0; +} + +/// Perform a real HTTP request to the Jira Cloud REST API. +/// slot: session slot (must be authenticated, caller verifies). +/// endpoint: Jira REST API path (e.g. "/rest/api/3/issue"). +/// http_method: HTTP method string ("GET", "POST", "PUT", "DELETE"). +/// params_json: JSON body for POST/PUT (may be null). +/// out_buf: fixed output buffer for writing the API response. +/// Returns bytes written to out_buf, or 0 on error. +fn doJiraApiCall(slot: *SessionSlot, endpoint: []const u8, http_method: []const u8, params_json: ?[]const u8, out_buf: []u8) usize { + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); + defer arena.deinit(); + const allocator = arena.allocator(); + + // Build URL: https://<instance>.atlassian.net<endpoint> + const instance = slot.instance_buf[0..slot.instance_len]; + const url_str = std.fmt.allocPrint(allocator, "https://{s}.atlassian.net{s}", .{ instance, endpoint }) catch return 0; + const uri = std.Uri.parse(url_str) catch return 0; + + // Build Basic auth header from stored credentials (email:token) + // Base64 encode the credentials + const cred_slice = slot.cred_buf[0..slot.cred_len]; + const encoded_len = std.base64.standard.Encoder.calcSize(cred_slice.len); + const encoded = allocator.alloc(u8, encoded_len) catch return 0; + _ = std.base64.standard.Encoder.encode(encoded, cred_slice); + const auth_header = std.fmt.allocPrint(allocator, "Basic {s}", .{encoded}) catch return 0; + + var client = std.http.Client{ .allocator = allocator }; + defer client.deinit(); + + var headers_buf: [3]std.http.Header = .{ + .{ .name = "Authorization", .value = auth_header }, + .{ .name = "Content-Type", .value = "application/json" }, + .{ .name = "User-Agent", .value = "boj-server/1.0 (jira-mcp cartridge)" }, + }; + + // Parse HTTP method + const method: std.http.Method = if (std.ascii.eqlIgnoreCase(http_method, "POST")) + .POST + else if (std.ascii.eqlIgnoreCase(http_method, "PUT")) + .PUT + else if (std.ascii.eqlIgnoreCase(http_method, "DELETE")) + .DELETE + else + .GET; + + // Fetch the request (Zig 0.15 API β€” replaces open/send/wait) + const body = params_json orelse ""; + const payload: ?[]const u8 = if (body.len > 0 and (method == .POST or method == .PUT)) body else null; + var aw: std.Io.Writer.Allocating = .init(allocator); + defer aw.deinit(); + + const fetch_result = client.fetch(.{ + .method = method, + .location = .{ .uri = uri }, + .extra_headers = &headers_buf, + .payload = payload, + .response_writer = &aw.writer, + }) catch return 0; + + // Check for rate limiting (HTTP 429) + const status_code = @intFromEnum(fetch_result.status); + if (status_code == 429) { + slot.state = .rate_limited; + return 0; + } + + // Copy response body into the caller's output buffer + const response_bytes = aw.writer.buffered(); + const to_copy = @min(response_bytes.len, out_buf.len); + @memcpy(out_buf[0..to_copy], response_bytes[0..to_copy]); + return to_copy; +} + +// --------------------------------------------------------------------------- +// C-ABI exports β€” state machine +// --------------------------------------------------------------------------- + +/// Check if a state transition is valid. Returns 1 (valid) or 0 (invalid). +pub export fn jira_mcp_can_transition(from: c_int, to: c_int) c_int { + const f = std.meta.intToEnum(ConnState, from) catch return 0; + const t = std.meta.intToEnum(ConnState, to) catch return 0; + return if (isValidTransition(f, t)) 1 else 0; +} + +// --------------------------------------------------------------------------- +// C-ABI exports β€” authentication +// --------------------------------------------------------------------------- + +/// Authenticate with Jira Cloud using Basic auth (email + Atlassian API token). +/// instance: C string instance subdomain (e.g. "mycompany" for mycompany.atlassian.net). +/// email: C string email address associated with the Atlassian account. +/// api_token: C string Atlassian API token (NOT app password). +/// Returns slot index (>= 0) on success, negative on error. +/// Error codes: -1 = no free slots, -3 = null argument, -4 = argument too short. +pub export fn jira_mcp_authenticate( + instance: [*c]const u8, + email: [*c]const u8, + api_token: [*c]const u8, +) c_int { + if (instance == null or email == null or api_token == null) return -3; + + // Validate minimum lengths. + var inst_len: usize = 0; + while (inst_len < URL_MAX and instance[inst_len] != 0) : (inst_len += 1) {} + if (inst_len < 2) return -4; + + var email_len: usize = 0; + while (email_len < CRED_MAX and email[email_len] != 0) : (email_len += 1) {} + if (email_len < 5) return -4; + + var token_len: usize = 0; + while (token_len < CRED_MAX and api_token[token_len] != 0) : (token_len += 1) {} + if (token_len < 8) return -4; + + mutex.lock(); + defer mutex.unlock(); + + // Find a free slot. + for (&sessions, 0..) |*slot, idx| { + if (!slot.active) { + slot.active = true; + slot.state = .authenticated; + slot.instance_len = copyFromCStr(&slot.instance_buf, instance); + // Store email:token as credential (in production, base64-encode for Basic auth). + slot.cred_len = copyFromCStr(&slot.cred_buf, email); + if (slot.cred_len < CRED_MAX) { + slot.cred_buf[slot.cred_len] = ':'; + slot.cred_len += 1; + } + var j: usize = 0; + while (j < token_len and slot.cred_len + j < CRED_MAX) : (j += 1) { + slot.cred_buf[slot.cred_len + j] = api_token[j]; + } + slot.cred_len += j; + slot.sprint_progress = 0; + slot.actions_performed = 0; + slot.rate_tracker = .{}; + return @intCast(idx); + } + } + return -1; // No free slots. +} + +// --------------------------------------------------------------------------- +// C-ABI exports β€” generic API call +// --------------------------------------------------------------------------- + +/// Invoke a Jira REST API operation by action ID. +/// action_id: integer matching JiraAction enum. +/// params: JSON-encoded parameters (C string, may be null). +/// Returns 0 on success, negative on error. +/// Error codes: -1 = invalid slot, -2 = bad state, -5 = rate limited, -6 = unknown action. +pub export fn jira_mcp_api_call( + slot_idx: c_int, + action_id: c_int, + params: [*c]const u8, + out_buf: [*c]u8, + out_cap: c_int, + out_len: *c_int, +) c_int { + const action = std.meta.intToEnum(JiraAction, action_id) catch return -6; + + mutex.lock(); + defer mutex.unlock(); + + const slot = getSlot(slot_idx) orelse return -1; + if (slot.state != .authenticated) return -2; + + // Rate-limit check. + const now = std.time.timestamp(); + if (!slot.rate_tracker.record(now, RATE_BUDGET)) { + slot.state = .rate_limited; + return -5; + } + + // Extract params as a slice if present + const params_slice: ?[]const u8 = if (params != null) blk: { + var i: usize = 0; + while (i < 8192 and params[i] != 0) : (i += 1) {} + if (i == 0) break :blk null; + break :blk params[0..i]; + } else null; + + const endpoint = actionToEndpoint(action); + const method = actionToMethod(action); + const len = doJiraApiCall(slot, endpoint, method, params_slice, &slot.out_buf); + slot.out_len = len; + slot.actions_performed += 1; + + const cap: usize = std.math.cast(usize, out_cap) orelse 0; + writeOutput(out_buf, cap, out_len, slot.out_buf[0..len]); + return 0; +} + +// --------------------------------------------------------------------------- +// C-ABI exports β€” session management +// --------------------------------------------------------------------------- + +/// Disconnect a session gracefully. Returns 0 on success. +/// Error codes: -1 = invalid slot, -2 = bad state transition. +pub export fn jira_mcp_disconnect(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const slot = getSlot(slot_idx) orelse return -1; + const rc = tryTransition(slot, .unauthenticated); + if (rc == 0) { + slot.active = false; + slot.cred_len = 0; + slot.instance_len = 0; + slot.sprint_progress = 0; + slot.actions_performed = 0; + } + return rc; +} + +/// Get the current connection state. Returns state int or -1 if invalid slot. +pub export fn jira_mcp_session_state(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const slot = getSlot(slot_idx) orelse return -1; + return @intFromEnum(slot.state); +} + +/// Get the instance URL for an authenticated session. +/// Writes instance name into out_buf. Returns 0 on success, -1 if invalid. +pub export fn jira_mcp_instance(slot_idx: c_int, out_buf: [*c]u8, out_cap: c_int, out_len: *c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const slot = getSlot(slot_idx) orelse return -1; + const cap: usize = std.math.cast(usize, out_cap) orelse 0; + writeOutput(out_buf, cap, out_len, slot.instance_buf[0..slot.instance_len]); + return 0; +} + +/// Get the rate-limit request count for a session. +/// Returns count (>= 0) or -1 if invalid. +pub export fn jira_mcp_rate_count(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const slot = getSlot(slot_idx) orelse return -1; + return @intCast(slot.rate_tracker.count); +} + +/// Get the actions-performed counter for a session. +pub export fn jira_mcp_actions_performed(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const slot = getSlot(slot_idx) orelse return -1; + return @intCast(slot.actions_performed); +} + +/// Get the sprint progress percentage (0-100). +pub export fn jira_mcp_sprint_progress(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const slot = getSlot(slot_idx) orelse return -1; + return @intCast(slot.sprint_progress); +} + +/// Recover from rate-limited state (RateLimited -> Authenticated). +/// Returns 0 on success, -2 if bad transition. +pub export fn jira_mcp_rate_recover(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const slot = getSlot(slot_idx) orelse return -1; + return tryTransition(slot, .authenticated); +} + +/// Reset all sessions (test/debug use only). +pub export fn jira_mcp_reset() void { + mutex.lock(); + defer mutex.unlock(); + sessions = [_]SessionSlot{.{}} ** MAX_SESSIONS; +} + +// --------------------------------------------------------------------------- +// ═══════════════════════════════════════════════════════════════════════ +// Standard ABI (ADR-0005 four symbols + ADR-0006 invoke) +// ═══════════════════════════════════════════════════════════════════════ + +const shim = @import("cartridge_shim.zig"); + +const CARTRIDGE_NAME_PTR: [*:0]const u8 = "jira-mcp"; +const CARTRIDGE_VERSION_PTR: [*:0]const u8 = "0.1.0"; + +export fn boj_cartridge_init() callconv(.c) c_int { + return 0; +} + +export fn boj_cartridge_deinit() callconv(.c) void {} + +export fn boj_cartridge_name() callconv(.c) [*:0]const u8 { + return CARTRIDGE_NAME_PTR; +} + +export fn boj_cartridge_version() callconv(.c) [*:0]const u8 { + return CARTRIDGE_VERSION_PTR; +} + +/// Dispatch the cartridge.json MCP tools. Grade D Alpha stubs. +export fn boj_cartridge_invoke( + tool_name: [*c]const u8, + json_args: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) callconv(.c) i32 { + _ = json_args; + if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; + + const body: []const u8 = if (shim.toolIs(tool_name, "jira_authenticate")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "jira_search_issues")) + "{\"result\":{\"matches\":[],\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "jira_get_issue")) + "{\"result\":{\"metadata\":{},\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "jira_create_issue")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "jira_update_issue")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "jira_add_comment")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "jira_list_projects")) + "{\"result\":{\"items\":[],\"count\":0,\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "jira_transition_issue")) + "{\"result\":{\"status\":\"stub\"}}" +else + return shim.RC_UNKNOWN_TOOL; + + return shim.writeResult(out_buf, in_out_len, body); +} + +// Tests +// --------------------------------------------------------------------------- + +test "state machine transitions" { + // Valid transitions. + try std.testing.expectEqual(@as(c_int, 1), jira_mcp_can_transition(0, 1)); // unauth -> auth + try std.testing.expectEqual(@as(c_int, 1), jira_mcp_can_transition(1, 2)); // auth -> rate_limited + try std.testing.expectEqual(@as(c_int, 1), jira_mcp_can_transition(2, 1)); // rate_limited -> auth + try std.testing.expectEqual(@as(c_int, 1), jira_mcp_can_transition(1, 3)); // auth -> error + try std.testing.expectEqual(@as(c_int, 1), jira_mcp_can_transition(2, 3)); // rate_limited -> error + try std.testing.expectEqual(@as(c_int, 1), jira_mcp_can_transition(3, 0)); // error -> unauth + try std.testing.expectEqual(@as(c_int, 1), jira_mcp_can_transition(1, 0)); // auth -> unauth (graceful) + + // Invalid transitions. + try std.testing.expectEqual(@as(c_int, 0), jira_mcp_can_transition(0, 2)); // unauth -> rate_limited + try std.testing.expectEqual(@as(c_int, 0), jira_mcp_can_transition(0, 3)); // unauth -> error + try std.testing.expectEqual(@as(c_int, 0), jira_mcp_can_transition(3, 1)); // error -> auth + try std.testing.expectEqual(@as(c_int, 0), jira_mcp_can_transition(2, 0)); // rate_limited -> unauth + + // Out of range. + try std.testing.expectEqual(@as(c_int, 0), jira_mcp_can_transition(99, 0)); + try std.testing.expectEqual(@as(c_int, 0), jira_mcp_can_transition(0, 99)); +} + +test "authenticate and disconnect lifecycle" { + jira_mcp_reset(); + + // Authenticate with valid credentials. + const slot = jira_mcp_authenticate("mycompany", "user@example.com", "atlassian_api_token_1234567890"); + try std.testing.expect(slot >= 0); + + // Should be authenticated. + try std.testing.expectEqual(@as(c_int, 1), jira_mcp_session_state(slot)); + + // Graceful disconnect. + try std.testing.expectEqual(@as(c_int, 0), jira_mcp_disconnect(slot)); +} + +test "reject invalid credentials" { + jira_mcp_reset(); + + // Null arguments. + try std.testing.expectEqual(@as(c_int, -3), jira_mcp_authenticate(null, "a@b.com", "token123")); + try std.testing.expectEqual(@as(c_int, -3), jira_mcp_authenticate("inst", null, "token123")); + try std.testing.expectEqual(@as(c_int, -3), jira_mcp_authenticate("inst", "a@b.com", null)); + + // Too-short arguments. + try std.testing.expectEqual(@as(c_int, -4), jira_mcp_authenticate("x", "user@example.com", "token_long_enough")); + try std.testing.expectEqual(@as(c_int, -4), jira_mcp_authenticate("inst", "ab", "token_long_enough")); + try std.testing.expectEqual(@as(c_int, -4), jira_mcp_authenticate("inst", "user@example.com", "short")); +} + +test "api call updates counters" { + jira_mcp_reset(); + + const slot = jira_mcp_authenticate("testinst", "user@example.com", "atlassian_api_token_abcdefgh"); + try std.testing.expect(slot >= 0); + + var buf: [1024]u8 = undefined; + var out_len: c_int = 0; + + // Call search_issues (action_id = 0). + const rc = jira_mcp_api_call(slot, 0, null, &buf, 1024, &out_len); + try std.testing.expectEqual(@as(c_int, 0), rc); + try std.testing.expect(out_len > 0); + try std.testing.expectEqual(@as(c_int, 1), jira_mcp_actions_performed(slot)); + + // Unknown action. + try std.testing.expectEqual(@as(c_int, -6), jira_mcp_api_call(slot, 99, null, &buf, 1024, &out_len)); + + _ = jira_mcp_disconnect(slot); +} + +test "instance retrieval" { + jira_mcp_reset(); + + const slot = jira_mcp_authenticate("mycompany", "user@example.com", "atlassian_api_token_retrieve"); + try std.testing.expect(slot >= 0); + + var inst_buf: [256]u8 = undefined; + var inst_len: c_int = 0; + try std.testing.expectEqual(@as(c_int, 0), jira_mcp_instance(slot, &inst_buf, 256, &inst_len)); + try std.testing.expect(inst_len > 0); + + _ = jira_mcp_disconnect(slot); +} + +test "slot exhaustion" { + jira_mcp_reset(); + + var slots: [MAX_SESSIONS]c_int = undefined; + for (&slots) |*s| { + s.* = jira_mcp_authenticate("fillinst", "fill@example.com", "fill_token_abcdefghijklmnop"); + try std.testing.expect(s.* >= 0); + } + + // Next should fail. + try std.testing.expectEqual(@as(c_int, -1), jira_mcp_authenticate("overflow", "over@example.com", "overflow_token_0000000000000")); + + // Free one and retry. + try std.testing.expectEqual(@as(c_int, 0), jira_mcp_disconnect(slots[0])); + const new_slot = jira_mcp_authenticate("reuseinst", "reuse@example.com", "reuse_token_abcdefghijklmnop"); + try std.testing.expect(new_slot >= 0); +} + +// ═══════════════════════════════════════════════════════════════════════ +// ADR-0006 invoke dispatch tests +// ═══════════════════════════════════════════════════════════════════════ + +test "boj_cartridge_name returns jira-mcp" { + const n = std.mem.span(boj_cartridge_name()); + try std.testing.expectEqualStrings("jira-mcp", n); +} + +test "boj_cartridge_init returns 0" { + try std.testing.expectEqual(@as(c_int, 0), boj_cartridge_init()); +} + +test "invoke: each declared tool succeeds" { + var buf: [256]u8 = undefined; + const tools = [_][]const u8{ + "jira_authenticate", + "jira_search_issues", + "jira_get_issue", + "jira_create_issue", + "jira_update_issue", + "jira_add_comment", + "jira_list_projects", + "jira_transition_issue", + }; + for (tools) |t| { + var len: usize = buf.len; + const rc = boj_cartridge_invoke(t.ptr, "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, 0), rc); + try std.testing.expect(std.mem.indexOf(u8, buf[0..len], "result") != null); + } +} + +test "invoke: unknown tool returns -1" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("nope", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -1), rc); +} + +test "invoke: buffer too small returns -3" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("jira_authenticate", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -3), rc); + try std.testing.expect(len > 4); +} diff --git a/cartridges/domains/project-management/jira-mcp/minter.toml b/cartridges/domains/project-management/jira-mcp/minter.toml new file mode 100644 index 0000000..d7b2dbf --- /dev/null +++ b/cartridges/domains/project-management/jira-mcp/minter.toml @@ -0,0 +1,13 @@ +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +name = "jira-mcp" +description = "Jira Cloud REST API cartridge β€” issues, projects, boards, sprints, transitions, and user management" +version = "0.1.0" +domain = "Comms" +protocols = [ + "MCP", + "REST", +] +tier = "Ayo" +backend = "universal" +generate_panel = true diff --git a/cartridges/domains/project-management/jira-mcp/mod.js b/cartridges/domains/project-management/jira-mcp/mod.js new file mode 100644 index 0000000..e4f167a --- /dev/null +++ b/cartridges/domains/project-management/jira-mcp/mod.js @@ -0,0 +1,63 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// jira-mcp/mod.js β€” Jira project management and issue tracking +// +// Delegates to backend at http://127.0.0.1:7730 (override with JIRA_BACKEND_URL). + +const BASE_URL = Deno.env.get("JIRA_BACKEND_URL") ?? "http://127.0.0.1:7730"; +const TIMEOUT_MS = 15_000; + +async function post(path, payload) { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), TIMEOUT_MS); + try { + const r = await fetch(`${BASE_URL}${path}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + signal: ctrl.signal, + }); + const data = await r.json().catch(() => ({ success: false, error: "non-JSON response" })); + return { status: r.status, data }; + } catch (e) { + if (e.name === "AbortError") return { status: 504, data: { success: false, error: "jira-mcp backend timed out" } }; + return { status: 503, data: { success: false, error: `jira-mcp backend unavailable: ${e.message}` } }; + } finally { clearTimeout(t); } +} + +async function get(path) { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), TIMEOUT_MS); + try { + const r = await fetch(`${BASE_URL}${path}`, { method: "GET", signal: ctrl.signal }); + const data = await r.json().catch(() => ({ success: false, error: "non-JSON response" })); + return { status: r.status, data }; + } catch (e) { + if (e.name === "AbortError") return { status: 504, data: { success: false, error: "jira-mcp backend timed out" } }; + return { status: 503, data: { success: false, error: `jira-mcp backend unavailable: ${e.message}` } }; + } finally { clearTimeout(t); } +} + +export async function handleTool(toolName, args) { + switch (toolName) { + case "jira_authenticate": + return post("/api/v1/jira_authenticate", args ?? {}); + case "jira_search_issues": + return post("/api/v1/jira_search_issues", args ?? {}); + case "jira_get_issue": + return post("/api/v1/jira_get_issue", args ?? {}); + case "jira_create_issue": + return post("/api/v1/jira_create_issue", args ?? {}); + case "jira_update_issue": + return post("/api/v1/jira_update_issue", args ?? {}); + case "jira_add_comment": + return post("/api/v1/jira_add_comment", args ?? {}); + case "jira_list_projects": + return post("/api/v1/jira_list_projects", args ?? {}); + case "jira_transition_issue": + return post("/api/v1/jira_transition_issue", args ?? {}); + default: + return { status: 404, data: { error: `Unknown tool: ${toolName}` } }; + } +} diff --git a/cartridges/domains/project-management/jira-mcp/panels/manifest.json b/cartridges/domains/project-management/jira-mcp/panels/manifest.json new file mode 100644 index 0000000..718a9cd --- /dev/null +++ b/cartridges/domains/project-management/jira-mcp/panels/manifest.json @@ -0,0 +1,76 @@ +{ + "$schema": "https://panll.dev/schemas/panel-manifest/v1.json", + "spdx": "MPL-2.0", + "cartridge": "jira-mcp", + "domain": "Comms", + "version": "0.1.0", + "panels": [ + { + "id": "jira-auth-status", + "title": "Auth Status", + "description": "Current Jira connection state (Unauthenticated / Authenticated / RateLimited / Error)", + "type": "status-indicator", + "data_source": { + "endpoint": "/jira/state", + "method": "GET", + "refresh_interval_ms": 5000 + }, + "widgets": [ + { + "type": "state-badge", + "field": "state", + "states": { + "unauthenticated": { "color": "#95a5a6", "icon": "plug-disconnected" }, + "authenticated": { "color": "#2ecc71", "icon": "plug-connected" }, + "rate_limited": { "color": "#e67e22", "icon": "clock" }, + "error": { "color": "#e74c3c", "icon": "alert-triangle" } + } + } + ] + }, + { + "id": "jira-instance", + "title": "Instance URL", + "description": "The Jira Cloud instance this session is connected to", + "type": "metric", + "data_source": { + "endpoint": "/jira/metrics", + "method": "GET", + "refresh_interval_ms": 30000 + }, + "widgets": [ + { + "type": "text", + "field": "instance", + "label": "Instance", + "icon": "server" + } + ] + }, + { + "id": "jira-sprint-progress", + "title": "Sprint Progress", + "description": "Current active sprint completion percentage and actions performed", + "type": "multi-metric", + "data_source": { + "endpoint": "/jira/metrics", + "method": "GET", + "refresh_interval_ms": 10000 + }, + "widgets": [ + { + "type": "progress-bar", + "items": [ + { "field": "sprint_progress", "max_value": 100, "label": "Sprint Progress", "color": "#3498db" } + ] + }, + { + "type": "counter", + "field": "actions_performed", + "label": "Actions Performed", + "icon": "activity" + } + ] + } + ] +} diff --git a/cartridges/domains/project-management/jira-mcp/tests/integration_test.sh b/cartridges/domains/project-management/jira-mcp/tests/integration_test.sh new file mode 100755 index 0000000..4ba0613 --- /dev/null +++ b/cartridges/domains/project-management/jira-mcp/tests/integration_test.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Integration tests for jira-mcp cartridge. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CART_DIR="$(dirname "$SCRIPT_DIR")" +FFI_DIR="$CART_DIR/ffi" + +echo "=== jira-mcp integration tests ===" + +# Build FFI +echo "[1/3] Building FFI..." +cd "$FFI_DIR" && zig build 2>&1 + +# Run Zig unit tests +echo "[2/3] Running FFI unit tests..." +cd "$FFI_DIR" && zig build test 2>&1 + +# Validate Idris2 ABI (if idris2 available) +echo "[3/3] Checking ABI..." +if command -v idris2 &>/dev/null; then + cd "$CART_DIR/abi" && idris2 --check jira_mcp.ipkg 2>&1 + echo " ABI: OK" +else + echo " ABI: SKIPPED (idris2 not in PATH)" +fi + +echo "" +echo "All tests passed for jira-mcp!" diff --git a/cartridges/domains/project-management/linear-mcp/README.adoc b/cartridges/domains/project-management/linear-mcp/README.adoc new file mode 100644 index 0000000..50386a7 --- /dev/null +++ b/cartridges/domains/project-management/linear-mcp/README.adoc @@ -0,0 +1,116 @@ += linear-mcp +Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk> +:spdx: MPL-2.0 +:tier: Ayo +:domain: Comms +:protocols: MCP, GraphQL + +== Overview + +Linear project management GraphQL API cartridge for the BoJ server. +Wraps the full Linear platform β€” issues, projects, teams, cycles, +labels, comments, workflow states, and search β€” behind a type-safe, +rate-limit-aware interface using the GraphQL endpoint at +https://api.linear.app/graphql. + +== Architecture + +[cols="1,1,2"] +|=== +| Layer | Language | Purpose + +| ABI +| Idris2 +| Formally verified connection state machine (`SafeComms.idr`) with + dependent-type proofs for all valid transitions + +| FFI +| Zig +| C-compatible implementation with thread-safe session pool, GraphQL + request stubs, and rate-limit tracking + +| Adapter +| zig +| REST bridge exposing all 16 Linear actions to the BoJ unified adapter +|=== + +== API Key Setup + +This cartridge authenticates using Linear API keys (Bearer tokens). + +1. Go to **Linear Settings** -> **API** -> **Personal API keys** + (or create a workspace-level OAuth application) +2. Create a new API key with the scopes you need +3. Store the key in **vault-mcp** so the cartridge can retrieve it at authentication time + +== Linear Actions (16 total) + +[cols="1,2,1"] +|=== +| Action | GraphQL Operation | Kind + +| ListIssues | `issues` | Query +| GetIssue | `issue(id)` | Query +| CreateIssue | `issueCreate` | Mutation +| UpdateIssue | `issueUpdate` | Mutation +| DeleteIssue | `issueDelete` | Mutation +| ListProjects | `projects` | Query +| GetProject | `project(id)` | Query +| ListTeams | `teams` | Query +| ListCycles | `cycles` | Query +| CreateComment | `commentCreate` | Mutation +| SearchIssues | `issueSearch` | Query +| ListLabels | `issueLabels` | Query +| AssignIssue | `issueUpdate` | Mutation +| SetPriority | `issueUpdate` | Mutation +| MoveToProject | `issueUpdate` | Mutation +| ListWorkflowStates | `workflowStates` | Query +|=== + +== Connection State Machine + +.... +Unauthenticated ──→ Authenticated ──→ Unauthenticated + β”‚ ↑ + ↓ β”‚ + RateLimited + β”‚ + ↓ + Error ──→ Unauthenticated +.... + +Valid transitions: + +- `Unauthenticated` -> `Authenticated` (authenticate with API key) +- `Authenticated` -> `RateLimited` (rate budget exhausted) +- `RateLimited` -> `Authenticated` (budget window reset) +- `Authenticated` -> `Error` (network/API fault) +- `RateLimited` -> `Error` (unrecoverable error during rate limit) +- `Authenticated` -> `Unauthenticated` (graceful close) +- `Error` -> `Unauthenticated` (reset for re-auth) + +== Building + +[source,bash] +---- +# Build FFI shared library +cd ffi && zig build + +# Run FFI tests +cd ffi && zig build test + +# Type-check ABI +cd abi && idris2 --check linear_mcp.ipkg +---- + +== PanLL Panels + +The `panels/manifest.json` provides three data sources for the PanLL dashboard: + +- **Auth Status** β€” live state badge (5 s refresh) +- **Issue Queries** β€” session issue query counter (10 s refresh) +- **Recent Activity** β€” actions performed + rate limit usage (10 s refresh) + +== Status + +Development β€” not yet ready for mounting. diff --git a/cartridges/domains/project-management/linear-mcp/abi/LinearMcp/SafeComms.idr b/cartridges/domains/project-management/linear-mcp/abi/LinearMcp/SafeComms.idr new file mode 100644 index 0000000..321da32 --- /dev/null +++ b/cartridges/domains/project-management/linear-mcp/abi/LinearMcp/SafeComms.idr @@ -0,0 +1,224 @@ +-- SPDX-License-Identifier: MPL-2.0 +-- Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +-- +-- LinearMcp.SafeComms β€” Type-safe ABI for the Linear GraphQL API cartridge. +-- +-- Dependent-type state machine governing Linear connection lifecycle. +-- All transitions proven valid at compile time. Zero unsafe escape hatches. + +module LinearMcp.SafeComms + +%default total + +-- --------------------------------------------------------------------------- +-- Connection state machine +-- --------------------------------------------------------------------------- + +||| Connection lifecycle states for Linear API interactions. +||| Models the full lifecycle: authentication via Bearer token, +||| connected operation against the GraphQL endpoint, rate limiting +||| (Linear enforces request-based limits), and error recovery. +public export +data ConnState + = Unauthenticated + | Authenticated + | RateLimited + | Error + +-- --------------------------------------------------------------------------- +-- Valid state transitions (proven at the type level) +-- --------------------------------------------------------------------------- + +||| Proof witness that a state transition is permitted. +||| Only the transitions enumerated here can ever occur at the FFI boundary. +public export +data ValidTransition : ConnState -> ConnState -> Type where + ||| Authenticate with a Linear API key (Bearer token). + Authenticate : ValidTransition Unauthenticated Authenticated + ||| Hit a Linear rate limit (complexity or request budget exhausted). + HitRateLimit : ValidTransition Authenticated RateLimited + ||| Rate limit window expired β€” resume operations. + RateRecovered : ValidTransition RateLimited Authenticated + ||| Operational error while authenticated (network, API fault). + AuthError : ValidTransition Authenticated Error + ||| Rate-limited session encounters an unrecoverable error. + RateLimitError : ValidTransition RateLimited Error + ||| Error recovery β€” return to unauthenticated for re-auth. + ErrorReset : ValidTransition Error Unauthenticated + ||| Graceful disconnect from an authenticated session. + GracefulClose : ValidTransition Authenticated Unauthenticated + +-- --------------------------------------------------------------------------- +-- C-ABI integer encoding for ConnState +-- --------------------------------------------------------------------------- + +||| Encode connection state as a C-compatible integer. +||| Mapping: Unauthenticated=0, Authenticated=1, RateLimited=2, Error=3. +export +connStateToInt : ConnState -> Int +connStateToInt Unauthenticated = 0 +connStateToInt Authenticated = 1 +connStateToInt RateLimited = 2 +connStateToInt Error = 3 + +||| Decode a C integer back to a connection state. +||| Returns Nothing for out-of-range values. +export +intToConnState : Int -> Maybe ConnState +intToConnState 0 = Just Unauthenticated +intToConnState 1 = Just Authenticated +intToConnState 2 = Just RateLimited +intToConnState 3 = Just Error +intToConnState _ = Nothing + +||| C-ABI export: check whether a state transition is valid. +||| Returns 1 for valid, 0 for invalid. Used by the Zig FFI layer. +export +linear_mcp_can_transition : Int -> Int -> Int +linear_mcp_can_transition from to = + case (intToConnState from, intToConnState to) of + (Just Unauthenticated, Just Authenticated) => 1 + (Just Authenticated, Just RateLimited) => 1 + (Just RateLimited, Just Authenticated) => 1 + (Just Authenticated, Just Error) => 1 + (Just RateLimited, Just Error) => 1 + (Just Error, Just Unauthenticated) => 1 + (Just Authenticated, Just Unauthenticated) => 1 + _ => 0 + +-- --------------------------------------------------------------------------- +-- Linear action vocabulary +-- --------------------------------------------------------------------------- + +||| All actions supported by the linear-mcp cartridge. +||| Each maps to a Linear GraphQL query or mutation via +||| the endpoint https://api.linear.app/graphql. +public export +data LinearAction + = ListIssues -- Query: issues + | GetIssue -- Query: issue(id) + | CreateIssue -- Mutation: issueCreate + | UpdateIssue -- Mutation: issueUpdate + | DeleteIssue -- Mutation: issueDelete + | ListProjects -- Query: projects + | GetProject -- Query: project(id) + | ListTeams -- Query: teams + | ListCycles -- Query: cycles + | CreateComment -- Mutation: commentCreate + | SearchIssues -- Query: issueSearch + | ListLabels -- Query: issueLabels + | AssignIssue -- Mutation: issueUpdate (assigneeId) + | SetPriority -- Mutation: issueUpdate (priority) + | MoveToProject -- Mutation: issueUpdate (projectId) + | ListWorkflowStates -- Query: workflowStates + +||| Encode a LinearAction as a C integer for FFI. +export +linearActionToInt : LinearAction -> Int +linearActionToInt ListIssues = 0 +linearActionToInt GetIssue = 1 +linearActionToInt CreateIssue = 2 +linearActionToInt UpdateIssue = 3 +linearActionToInt DeleteIssue = 4 +linearActionToInt ListProjects = 5 +linearActionToInt GetProject = 6 +linearActionToInt ListTeams = 7 +linearActionToInt ListCycles = 8 +linearActionToInt CreateComment = 9 +linearActionToInt SearchIssues = 10 +linearActionToInt ListLabels = 11 +linearActionToInt AssignIssue = 12 +linearActionToInt SetPriority = 13 +linearActionToInt MoveToProject = 14 +linearActionToInt ListWorkflowStates = 15 + +||| Decode a C integer back to a LinearAction. +export +intToLinearAction : Int -> Maybe LinearAction +intToLinearAction 0 = Just ListIssues +intToLinearAction 1 = Just GetIssue +intToLinearAction 2 = Just CreateIssue +intToLinearAction 3 = Just UpdateIssue +intToLinearAction 4 = Just DeleteIssue +intToLinearAction 5 = Just ListProjects +intToLinearAction 6 = Just GetProject +intToLinearAction 7 = Just ListTeams +intToLinearAction 8 = Just ListCycles +intToLinearAction 9 = Just CreateComment +intToLinearAction 10 = Just SearchIssues +intToLinearAction 11 = Just ListLabels +intToLinearAction 12 = Just AssignIssue +intToLinearAction 13 = Just SetPriority +intToLinearAction 14 = Just MoveToProject +intToLinearAction 15 = Just ListWorkflowStates +intToLinearAction _ = Nothing + +||| Total action count exposed via C-ABI. +export +linear_mcp_action_count : Int +linear_mcp_action_count = 16 + +-- --------------------------------------------------------------------------- +-- Action classification +-- --------------------------------------------------------------------------- + +||| Whether an action is a mutation (write) or query (read). +||| Used to determine if additional confirmation is needed. +public export +data ActionKind = Query | Mutation + +||| Classify each action as a query or mutation. +export +actionKind : LinearAction -> ActionKind +actionKind ListIssues = Query +actionKind GetIssue = Query +actionKind CreateIssue = Mutation +actionKind UpdateIssue = Mutation +actionKind DeleteIssue = Mutation +actionKind ListProjects = Query +actionKind GetProject = Query +actionKind ListTeams = Query +actionKind ListCycles = Query +actionKind CreateComment = Mutation +actionKind SearchIssues = Query +actionKind ListLabels = Query +actionKind AssignIssue = Mutation +actionKind SetPriority = Mutation +actionKind MoveToProject = Mutation +actionKind ListWorkflowStates = Query + +||| C-ABI export: check if an action requires an authenticated state. +||| All Linear actions require authentication. +export +linear_mcp_action_requires_auth : Int -> Int +linear_mcp_action_requires_auth actionId = + case intToLinearAction actionId of + Just _ => 1 -- all actions require Authenticated state + Nothing => 0 -- unknown action + +-- --------------------------------------------------------------------------- +-- MCP tool declarations +-- --------------------------------------------------------------------------- + +||| Tools exposed via the MCP protocol by this cartridge. +public export +data McpTool + = ToolConnect + | ToolDisconnect + | ToolStatus + | ToolInvoke + | ToolList + +||| Check if a tool requires an authenticated session. +export +toolRequiresSession : McpTool -> Bool +toolRequiresSession ToolConnect = False +toolRequiresSession ToolDisconnect = True +toolRequiresSession ToolStatus = False +toolRequiresSession ToolInvoke = True +toolRequiresSession ToolList = False + +||| Tool count for this cartridge. +export +toolCount : Nat +toolCount = 5 diff --git a/cartridges/domains/project-management/linear-mcp/abi/README.adoc b/cartridges/domains/project-management/linear-mcp/abi/README.adoc new file mode 100644 index 0000000..04bb0cb --- /dev/null +++ b/cartridges/domains/project-management/linear-mcp/abi/README.adoc @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += linear-mcp / abi β€” Idris2 ABI layer +:orientation: shallow + +== Purpose + +Cartridge-local protocol types for `linear-mcp`. Defines the data, tool, and +transition vocabulary this cartridge speaks. Inherits safety lemmas and the +matrix-cell identifier scheme from the trunk ABI at `src/abi/`. + +== Files + +1 Idris2 module(s), ~224 lines total. Lead module: +`SafeComms.idr`. + +No `.ipkg` β€” consumed directly by wider BoJ callers; type-checks inside the +trunk `boj.ipkg`. + +== Invariants + +* `%default total` at every module top. +* Zero `believe_me`, `assert_total`, `postulate`, `Admitted`, or + `sorry` (the trunk holds the four declared `believe_me` sites; leaf + cartridges carry none). + +== Test/proof surface + +Thin module(s), no inline tests. Proof surface = type-check success under the +trunk `idris2 --check`. + +== Read-first + +. `SafeComms.idr` β€” cartridge-local data records and enums. +. Trunk `src/abi/Boj/Catalogue.idr` for how this cell participates in the + 2D matrix. diff --git a/cartridges/domains/project-management/linear-mcp/abi/linear_mcp.ipkg b/cartridges/domains/project-management/linear-mcp/abi/linear_mcp.ipkg new file mode 100644 index 0000000..27a4531 --- /dev/null +++ b/cartridges/domains/project-management/linear-mcp/abi/linear_mcp.ipkg @@ -0,0 +1,11 @@ +-- SPDX-License-Identifier: MPL-2.0 +-- Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +package linear_mcp + +version = "0.1.0" +authors = "Jonathan D.A. Jewell" +brief = "Type-safe ABI for the Linear GraphQL API cartridge" + +depends = base + +modules = LinearMcp.SafeComms diff --git a/cartridges/domains/project-management/linear-mcp/adapter/README.adoc b/cartridges/domains/project-management/linear-mcp/adapter/README.adoc new file mode 100644 index 0000000..a91825e --- /dev/null +++ b/cartridges/domains/project-management/linear-mcp/adapter/README.adoc @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += linear-mcp / adapter β€” protocol bridge +:orientation: shallow + +== Purpose + +Exposes this cartridge's FFI over the wire protocols declared in +`cartridge.json`. Dispatches tool invocations to the C-ABI exports at +`../ffi/` and formats responses per protocol. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” depends on the sibling `ffi` target. +| `linear_mcp_adapter.zig` | Protocol dispatch (144 lines). Tool catalogue matches `cartridge.json`. +| `SIDELINED-*.v.adoc` | zig predecessor(s). Not built β€” zig banned 2026-04-10. +|=== + +== Invariants + +* **Stateless** β€” all state lives behind the FFI, never in the adapter. +* **Response passthrough** β€” whatever the FFI returns goes back to the wire + unmodified (no embellishment, no silent recovery). +* **Cartridge.json is source of truth** for the tool catalogue; drift between + adapter and manifest is a CI failure. + +== Test/proof surface + +No inline tests at the adapter layer β€” protocol routing is exercised by the +trunk seam tests at `ffi/zig/src/seams.zig`. + +== Read-first + +. `linear_mcp_adapter.zig` β€” dispatch function and per-protocol routers. +. `../cartridge.json` β€” the tool manifest the dispatcher mirrors. diff --git a/cartridges/domains/project-management/linear-mcp/adapter/build.zig b/cartridges/domains/project-management/linear-mcp/adapter/build.zig new file mode 100644 index 0000000..51f6890 --- /dev/null +++ b/cartridges/domains/project-management/linear-mcp/adapter/build.zig @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// linear-mcp/adapter/build.zig + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const ffi_mod = b.createModule(.{ + .root_source_file = b.path("../ffi/linear_mcp_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + const adapter = b.addExecutable(.{ + .name = "linear_mcp_adapter", + .root_source_file = b.path("linear_mcp_adapter.zig"), + .target = target, + .optimize = optimize, + }); + adapter.root_module.addImport("linear_mcp_ffi", ffi_mod); + b.installArtifact(adapter); +} diff --git a/cartridges/domains/project-management/linear-mcp/adapter/linear_mcp_adapter.zig b/cartridges/domains/project-management/linear-mcp/adapter/linear_mcp_adapter.zig new file mode 100644 index 0000000..3a94814 --- /dev/null +++ b/cartridges/domains/project-management/linear-mcp/adapter/linear_mcp_adapter.zig @@ -0,0 +1,144 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// linear-mcp/adapter/linear_mcp_adapter.zig +// +// Three-protocol BoJ adapter: REST (port 9262), gRPC-compat (port 9263), +// GraphQL (port 9264). +// Replaces the banned zig adapter (linear_mcp_adapter.v). + +const std = @import("std"); +const ffi = @import("linear_mcp_ffi"); + +const REST_PORT: u16 = 9262; +const GRPC_PORT: u16 = 9263; +const GQL_PORT: u16 = 9264; + +const Response = struct { status: u16, body: []const u8 }; + +fn okJson(buf: []u8, msg: []const u8) []u8 { + return std.fmt.bufPrint(buf, "{{\"success\":true,\"message\":\"{s}\"}}", .{msg}) catch buf[0..0]; +} + +fn errJson(buf: []u8, msg: []const u8) []u8 { + return std.fmt.bufPrint(buf, "{{\"success\":false,\"error\":\"{s}\"}}", .{msg}) catch buf[0..0]; +} + +fn dispatch(tool: []const u8, _body: []const u8, resp: []u8) Response { + _ = _body; + if (std.mem.eql(u8, tool, "linear_authenticate")) { + return .{ .status = 200, .body = okJson(resp, "linear_authenticate forwarded") }; + } + if (std.mem.eql(u8, tool, "linear_list_issues")) { + return .{ .status = 200, .body = okJson(resp, "linear_list_issues forwarded") }; + } + if (std.mem.eql(u8, tool, "linear_get_issue")) { + return .{ .status = 200, .body = okJson(resp, "linear_get_issue forwarded") }; + } + if (std.mem.eql(u8, tool, "linear_create_issue")) { + return .{ .status = 200, .body = okJson(resp, "linear_create_issue forwarded") }; + } + if (std.mem.eql(u8, tool, "linear_update_issue")) { + return .{ .status = 200, .body = okJson(resp, "linear_update_issue forwarded") }; + } + if (std.mem.eql(u8, tool, "linear_list_teams")) { + return .{ .status = 200, .body = okJson(resp, "linear_list_teams forwarded") }; + } + if (std.mem.eql(u8, tool, "linear_search_issues")) { + return .{ .status = 200, .body = okJson(resp, "linear_search_issues forwarded") }; + } + return .{ .status = 404, .body = errJson(resp, "unknown tool") }; +} + +fn dispatchRest(path: []const u8, body: []const u8, resp: []u8) Response { + // Expect /tools/<tool_name> + const prefix = "/tools/"; + if (std.mem.startsWith(u8, path, prefix)) { + const tool = path[prefix.len..]; + return dispatch(tool, body, resp); + } + return .{ .status = 404, .body = errJson(resp, "not found") }; +} + +fn dispatchGrpc(path: []const u8, body: []const u8, resp: []u8) Response { + // Expect /<Service>/<Method> β€” derive tool from Method + var it = std.mem.splitScalar(u8, path, '/'); + _ = it.next(); // leading empty + _ = it.next(); // service + const method = it.next() orelse return .{ .status = 404, .body = errJson(resp, "bad gRPC path") }; + return dispatch(method, body, resp); +} + +fn dispatchGraphql(body: []const u8, resp: []u8) Response { + if (std.mem.indexOf(u8, body, "linear_authenticate") != null) + return dispatch("linear_authenticate", body, resp); + if (std.mem.indexOf(u8, body, "linear_list_issues") != null) + return dispatch("linear_list_issues", body, resp); + if (std.mem.indexOf(u8, body, "linear_get_issue") != null) + return dispatch("linear_get_issue", body, resp); + if (std.mem.indexOf(u8, body, "linear_create_issue") != null) + return dispatch("linear_create_issue", body, resp); + if (std.mem.indexOf(u8, body, "linear_update_issue") != null) + return dispatch("linear_update_issue", body, resp); + if (std.mem.indexOf(u8, body, "linear_list_teams") != null) + return dispatch("linear_list_teams", body, resp); + if (std.mem.indexOf(u8, body, "linear_search_issues") != null) + return dispatch("linear_search_issues", body, resp); + return .{ .status = 400, .body = errJson(resp, "unrecognised GraphQL operation") }; +} + +fn handleConnection(stream: std.net.Stream, port: u16) void { + defer stream.close(); + var buf: [4096]u8 = undefined; + var resp_buf: [4096]u8 = undefined; + const n = stream.read(&buf) catch return; + const req = buf[0..n]; + const result = switch (port) { + REST_PORT => blk: { + // Parse HTTP/1.1: first line = METHOD PATH HTTP/x.y + var lines = std.mem.splitScalar(u8, req, '\n'); + const first = lines.next() orelse break :blk Response{ .status = 400, .body = "" }; + var parts = std.mem.splitScalar(u8, std.mem.trim(u8, first, "\r"), ' '); + _ = parts.next(); // method + const path = parts.next() orelse break :blk Response{ .status = 400, .body = "" }; + break :blk dispatchRest(path, req, &resp_buf); + }, + GRPC_PORT => blk: { + var lines = std.mem.splitScalar(u8, req, '\n'); + const first = lines.next() orelse break :blk Response{ .status = 400, .body = "" }; + var parts = std.mem.splitScalar(u8, std.mem.trim(u8, first, "\r"), ' '); + _ = parts.next(); + const path = parts.next() orelse break :blk Response{ .status = 400, .body = "" }; + break :blk dispatchGrpc(path, req, &resp_buf); + }, + GQL_PORT => dispatchGraphql(req, &resp_buf), + else => Response{ .status = 500, .body = "" }, + }; + var http_resp: [512]u8 = undefined; + const http = std.fmt.bufPrint(&http_resp, + "HTTP/1.1 {d} OK\r\nContent-Length: {d}\r\nContent-Type: application/json\r\n\r\n", + .{ result.status, result.body.len }) catch return; + _ = stream.write(http) catch {}; + _ = stream.write(result.body) catch {}; +} + +fn listenLoop(port: u16) !void { + const addr = std.net.Address.initIp4(.{ 127, 0, 0, 1 }, port); + var server = try addr.listen(.{ .reuse_address = true }); + defer server.deinit(); + while (true) { + const conn = try server.accept(); + const t = try std.Thread.spawn(.{}, handleConnection, .{ conn.stream, port }); + t.detach(); + } +} + +pub fn main() !void { + ffi.linear_mcp_init(); + const t1 = try std.Thread.spawn(.{}, listenLoop, .{REST_PORT}); + const t2 = try std.Thread.spawn(.{}, listenLoop, .{GRPC_PORT}); + const t3 = try std.Thread.spawn(.{}, listenLoop, .{GQL_PORT}); + t1.join(); + t2.join(); + t3.join(); +} diff --git a/cartridges/domains/project-management/linear-mcp/benchmarks/quick-bench.sh b/cartridges/domains/project-management/linear-mcp/benchmarks/quick-bench.sh new file mode 100755 index 0000000..be459f6 --- /dev/null +++ b/cartridges/domains/project-management/linear-mcp/benchmarks/quick-bench.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Benchmarks for linear-mcp cartridge. +set -euo pipefail + +echo "=== linear-mcp benchmarks ===" + +cd "$(dirname "${BASH_SOURCE[0]}")/../ffi" + +# Build with ReleaseFast for benchmarking +zig build -Doptimize=ReleaseFast 2>&1 + +echo "Session open/close cycle (1000 iterations):" +time for i in $(seq 1 1000); do + # This would call the FFI benchmark binary + true +done + +echo "" +echo "Benchmark placeholder β€” implement real benchmarks in Zig test blocks" +echo "or via the zig adapter HTTP benchmark tool." diff --git a/cartridges/domains/project-management/linear-mcp/cartridge.json b/cartridges/domains/project-management/linear-mcp/cartridge.json new file mode 100644 index 0000000..df6ecc2 --- /dev/null +++ b/cartridges/domains/project-management/linear-mcp/cartridge.json @@ -0,0 +1,194 @@ +{ + "$schema": "https://boj.dev/schemas/cartridge/v1.json", + "spdx": "MPL-2.0", + "copyright": "Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk>", + "name": "linear-mcp", + "version": "0.1.0", + "description": "Linear issue tracking and project management", + "domain": "project-management", + "tier": "Ayo", + "protocols": [ + "MCP", + "REST" + ], + "auth": { + "method": "none", + "env_var": null, + "credential_source": null + }, + "api": { + "base_url": "local://linear-mcp", + "content_type": "application/json" + }, + "tools": [ + { + "name": "linear_authenticate", + "description": "Authenticate with Linear API key", + "inputSchema": { + "type": "object", + "properties": { + "api_key": { + "type": "string", + "description": "Linear API key" + } + }, + "required": [ + "api_key" + ] + } + }, + { + "name": "linear_list_issues", + "description": "List issues (optionally filtered)", + "inputSchema": { + "type": "object", + "properties": { + "session_id": { + "type": "integer", + "description": "Session index" + }, + "team_id": { + "type": "string", + "description": "Team ID filter" + }, + "state": { + "type": "string", + "description": "State filter" + } + }, + "required": [ + "session_id" + ] + } + }, + { + "name": "linear_get_issue", + "description": "Get a specific issue", + "inputSchema": { + "type": "object", + "properties": { + "session_id": { + "type": "integer", + "description": "Session index" + }, + "issue_id": { + "type": "string", + "description": "Issue ID" + } + }, + "required": [ + "session_id", + "issue_id" + ] + } + }, + { + "name": "linear_create_issue", + "description": "Create a new issue", + "inputSchema": { + "type": "object", + "properties": { + "session_id": { + "type": "integer", + "description": "Session index" + }, + "team_id": { + "type": "string", + "description": "Team ID" + }, + "title": { + "type": "string", + "description": "Issue title" + }, + "description": { + "type": "string", + "description": "Issue description" + }, + "priority": { + "type": "integer", + "description": "Priority 0-4" + } + }, + "required": [ + "session_id", + "team_id", + "title" + ] + } + }, + { + "name": "linear_update_issue", + "description": "Update an issue", + "inputSchema": { + "type": "object", + "properties": { + "session_id": { + "type": "integer", + "description": "Session index" + }, + "issue_id": { + "type": "string", + "description": "Issue ID" + }, + "fields": { + "type": "object", + "description": "Fields to update" + } + }, + "required": [ + "session_id", + "issue_id", + "fields" + ] + } + }, + { + "name": "linear_list_teams", + "description": "List teams in the workspace", + "inputSchema": { + "type": "object", + "properties": { + "session_id": { + "type": "integer", + "description": "Session index" + } + }, + "required": [ + "session_id" + ] + } + }, + { + "name": "linear_search_issues", + "description": "Search issues by query", + "inputSchema": { + "type": "object", + "properties": { + "session_id": { + "type": "integer", + "description": "Session index" + }, + "query": { + "type": "string", + "description": "Search query" + } + }, + "required": [ + "session_id", + "query" + ] + } + } + ], + "ffi": { + "so_path": "ffi/zig-out/lib/liblinear_mcp.so", + "abi_version": "ADR-0006", + "symbols": [ + "boj_cartridge_init", + "boj_cartridge_deinit", + "boj_cartridge_name", + "boj_cartridge_version", + "boj_cartridge_invoke" + ] + } +} diff --git a/cartridges/domains/project-management/linear-mcp/ffi/README.adoc b/cartridges/domains/project-management/linear-mcp/ffi/README.adoc new file mode 100644 index 0000000..13fa41b --- /dev/null +++ b/cartridges/domains/project-management/linear-mcp/ffi/README.adoc @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += linear-mcp / ffi β€” Zig FFI layer **(stub)** +:orientation: shallow + +== Purpose + +C-ABI exports bridging this cartridge's Idris2 ABI to the runtime. Consumed by +the adapter for protocol dispatch and by the trunk loader for mount. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” produces `zig-out/lib/liblinear.so`. +| `linear_mcp_ffi.zig` | C-ABI exports (10 exports, 5 inline tests, 461 lines). +|=== + +== Invariants + +* **Null-pointer rejection** on `[*c]const u8` arguments before any + dereference. +* **Negative return values** carry documented error meanings; zero is + success (matching the trunk error-code convention). +* **No allocator leaks** β€” every allocation path pairs with `defer free`. + +== Test/proof surface + +5 inline `test "..."` block(s) in `linear_mcp_ffi.zig`. Run with +`cd ffi && zig build test`. + +== Read-first + +. `linear_mcp_ffi.zig` β€” C-ABI signatures and guards. +. Trunk `ffi/zig/src/catalogue.zig` for how this `.so` is loaded. + +== Promotion gate + +Wire these exports to real backing state (not `return 0` / `return 1` +placeholders) before promoting past D. diff --git a/cartridges/domains/project-management/linear-mcp/ffi/build.zig b/cartridges/domains/project-management/linear-mcp/ffi/build.zig new file mode 100644 index 0000000..d9bbc88 --- /dev/null +++ b/cartridges/domains/project-management/linear-mcp/ffi/build.zig @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + // Module + // Shared ADR-0006 invoke-shim module (relative path up to boj-server trunk). + + const shim_mod = b.addModule("cartridge_shim", .{ + + .root_source_file = b.path("../../../ffi/zig/src/cartridge_shim.zig"), + + .target = target, + + .optimize = optimize, + + }); + + const ffi_mod = b.addModule("linear_mcp", .{ + .root_source_file = b.path("linear_mcp_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + ffi_mod.addImport("cartridge_shim", shim_mod); + + // Shared library + const lib = b.addLibrary(.{ + .name = "linear_mcp", + .root_module = ffi_mod, + .linkage = .dynamic, + }); + lib.linkLibC(); + b.installArtifact(lib); + + // Tests + const tests = b.addTest(.{ + .root_module = ffi_mod, + }); + tests.linkLibC(); + + const run_tests = b.addRunArtifact(tests); + const test_step = b.step("test", "Run FFI tests"); + test_step.dependOn(&run_tests.step); +} diff --git a/cartridges/domains/project-management/linear-mcp/ffi/cartridge_shim.zig b/cartridges/domains/project-management/linear-mcp/ffi/cartridge_shim.zig new file mode 100644 index 0000000..0e399db --- /dev/null +++ b/cartridges/domains/project-management/linear-mcp/ffi/cartridge_shim.zig @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// cartridge_shim.zig β€” Shared helpers for the ADR-0006 five-symbol +// cartridge ABI (`boj_cartridge_init / deinit / name / version / invoke`). +// +// The shim centralises the seven-code return convention, NUL-argument +// guards, tool-name comparison, and the buffer-too-small path so each +// cartridge's `boj_cartridge_invoke` can stay short β€” typically a tool +// table plus `shim.writeResult(...)`. +// +// Cartridges import this file by relative path (no build-graph change +// needed). Example: +// +// const shim = @import("../../../ffi/zig/src/cartridge_shim.zig"); +// +// export fn boj_cartridge_invoke( +// tool_name: [*c]const u8, +// json_args: [*c]const u8, +// out_buf: [*c]u8, +// in_out_len: [*c]usize, +// ) callconv(.c) i32 { +// _ = json_args; +// if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; +// const body = if (shim.toolIs(tool_name, "foo")) "{\"result\":{}}" +// else return shim.RC_UNKNOWN_TOOL; +// return shim.writeResult(out_buf, in_out_len, body); +// } + +const std = @import("std"); + +// ── Return codes (ADR-0006 Β§Return codes) ──────────────────────────── +// +// Frozen by ADR-0006. New failure modes compose these via the error +// JSON body β€” the integer surface does not grow without a follow-up ADR. + +pub const RC_SUCCESS: i32 = 0; +pub const RC_UNKNOWN_TOOL: i32 = -1; +pub const RC_BAD_ARGS: i32 = -2; +pub const RC_BUFFER_TOO_SMALL: i32 = -3; +pub const RC_RUNTIME_ERROR: i32 = -4; +pub const RC_PANIC: i32 = -5; +pub const RC_AUTH_DENIED: i32 = -6; + +// ── Invoke-path helpers ────────────────────────────────────────────── + +/// True if any of the three mandatory `boj_cartridge_invoke` output-path +/// pointers is null. Use at the top of every invoke to short-circuit to +/// `RC_BAD_ARGS`. +pub fn invokeArgsNull( + tool_name: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) bool { + return tool_name == null or out_buf == null or in_out_len == null; +} + +/// Compare a C-NUL-terminated tool-name pointer against a Zig string +/// literal. Caller must have already verified `tool_name` is non-null +/// (usually via `invokeArgsNull`). +pub fn toolIs(tool_name: [*c]const u8, expected: []const u8) bool { + const s = std.mem.span(@as([*:0]const u8, @ptrCast(tool_name))); + return std.mem.eql(u8, s, expected); +} + +/// Copy `body` into `out_buf[0..*in_out_len]` (as a capacity) and update +/// `*in_out_len` to the number of bytes written. Returns `RC_SUCCESS`. +/// +/// If `body.len` exceeds the current capacity stored in `*in_out_len`, +/// sets `*in_out_len` to the required size and returns +/// `RC_BUFFER_TOO_SMALL` β€” the caller is then expected to re-allocate +/// and retry, per ADR-0006 Β§Memory ownership. +/// +/// Caller must have already verified that `out_buf` and `in_out_len` +/// are non-null. +pub fn writeResult( + out_buf: [*c]u8, + in_out_len: [*c]usize, + body: []const u8, +) i32 { + const cap = in_out_len.*; + if (body.len > cap) { + in_out_len.* = body.len; + return RC_BUFFER_TOO_SMALL; + } + @memcpy(out_buf[0..body.len], body); + in_out_len.* = body.len; + return RC_SUCCESS; +} + +// ── Tests ──────────────────────────────────────────────────────────── + +test "writeResult: body fits, writes and sets length" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); + try std.testing.expectEqualStrings("hello", buf[0..len]); +} + +test "writeResult: too small returns -3 and sets required length" { + var buf: [2]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_BUFFER_TOO_SMALL, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: exact-fit succeeds" { + var buf: [5]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: empty body" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, ""); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 0), len); +} + +test "toolIs: matches and rejects" { + const name: [*:0]const u8 = "foo"; + try std.testing.expect(toolIs(@ptrCast(name), "foo")); + try std.testing.expect(!toolIs(@ptrCast(name), "bar")); + try std.testing.expect(!toolIs(@ptrCast(name), "foobar")); + try std.testing.expect(!toolIs(@ptrCast(name), "fo")); +} + +test "invokeArgsNull: detects each null slot" { + var buf: [4]u8 = undefined; + var len: usize = 4; + const name: [*:0]const u8 = "x"; + try std.testing.expect(!invokeArgsNull(@ptrCast(name), &buf, &len)); + try std.testing.expect(invokeArgsNull(null, &buf, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), null, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), &buf, null)); +} diff --git a/cartridges/domains/project-management/linear-mcp/ffi/linear_mcp_ffi.zig b/cartridges/domains/project-management/linear-mcp/ffi/linear_mcp_ffi.zig new file mode 100644 index 0000000..d05c3e1 --- /dev/null +++ b/cartridges/domains/project-management/linear-mcp/ffi/linear_mcp_ffi.zig @@ -0,0 +1,561 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// linear_mcp_ffi.zig β€” C-ABI FFI for the Linear GraphQL API cartridge. +// +// Implements the state machine defined in LinearMcp.SafeComms (Idris2 ABI). +// Provides GraphQL request stubs for the Linear API (https://api.linear.app/graphql), +// rate-limit tracking, and Bearer-token authentication via API keys obtained +// from vault-mcp. +// +// Thread-safe via std.Thread.Mutex. No heap allocations for result buffers. + +const std = @import("std"); + +// --------------------------------------------------------------------------- +// Connection state machine (matches Idris2 ConnState exactly) +// --------------------------------------------------------------------------- + +/// Connection lifecycle states mirroring LinearMcp.SafeComms.ConnState. +pub const ConnState = enum(c_int) { + unauthenticated = 0, + authenticated = 1, + rate_limited = 2, + err = 3, +}; + +/// Check whether a transition between two states is valid. +/// Encodes the same transition table as the Idris2 ValidTransition GADT. +fn isValidTransition(from: ConnState, to: ConnState) bool { + return switch (from) { + .unauthenticated => to == .authenticated, + .authenticated => to == .rate_limited or to == .err or to == .unauthenticated, + .rate_limited => to == .authenticated or to == .err, + .err => to == .unauthenticated, + }; +} + +// --------------------------------------------------------------------------- +// Linear action vocabulary (matches Idris2 LinearAction exactly) +// --------------------------------------------------------------------------- + +/// All 16 Linear actions supported by this cartridge. +pub const LinearAction = enum(c_int) { + list_issues = 0, + get_issue = 1, + create_issue = 2, + update_issue = 3, + delete_issue = 4, + list_projects = 5, + get_project = 6, + list_teams = 7, + list_cycles = 8, + create_comment = 9, + search_issues = 10, + list_labels = 11, + assign_issue = 12, + set_priority = 13, + move_to_project = 14, + list_workflow_states = 15, +}; + +/// Map each action to its Linear GraphQL operation name. +fn actionToOperation(action: LinearAction) []const u8 { + return switch (action) { + .list_issues => "issues", + .get_issue => "issue", + .create_issue => "issueCreate", + .update_issue => "issueUpdate", + .delete_issue => "issueDelete", + .list_projects => "projects", + .get_project => "project", + .list_teams => "teams", + .list_cycles => "cycles", + .create_comment => "commentCreate", + .search_issues => "issueSearch", + .list_labels => "issueLabels", + .assign_issue => "issueUpdate", + .set_priority => "issueUpdate", + .move_to_project => "issueUpdate", + .list_workflow_states => "workflowStates", + }; +} + +// --------------------------------------------------------------------------- +// Rate-limit tracking +// --------------------------------------------------------------------------- + +/// Tracks rate-limit usage within a sliding window. +/// Linear enforces request complexity limits; we approximate with a +/// simple requests-per-minute counter. +const RateTracker = struct { + /// Number of requests made in the current window. + count: u32 = 0, + /// Epoch timestamp (seconds) when the current window started. + window_start: i64 = 0, + + /// Record one request. Returns true if within budget, false if exhausted. + fn record(self: *RateTracker, now: i64, budget: u32) bool { + // Reset window every 60 seconds. + if (now - self.window_start >= 60) { + self.window_start = now; + self.count = 0; + } + if (self.count >= budget) return false; + self.count += 1; + return true; + } +}; + +/// Linear rate budget: ~50 requests per minute (complexity-based, approximated). +const RATE_BUDGET: u32 = 50; + +// --------------------------------------------------------------------------- +// Session slot pool (thread-safe, fixed-size) +// --------------------------------------------------------------------------- + +const MAX_SESSIONS: usize = 16; +const BUF_SIZE: usize = 8192; +const TOKEN_MAX: usize = 256; + +const SessionSlot = struct { + active: bool = false, + state: ConnState = .unauthenticated, + /// Bearer token (Linear API key) obtained from vault-mcp. + token_buf: [TOKEN_MAX]u8 = undefined, + token_len: usize = 0, + /// Rate-limit tracker for request budgeting. + rate_tracker: RateTracker = .{}, + /// Count of issues retrieved in this session (for panel metrics). + issue_count: u32 = 0, + /// Count of actions performed in this session (for panel metrics). + actions_performed: u32 = 0, + /// General-purpose output buffer for API responses. + out_buf: [BUF_SIZE]u8 = undefined, + out_len: usize = 0, +}; + +var sessions: [MAX_SESSIONS]SessionSlot = [_]SessionSlot{.{}} ** MAX_SESSIONS; +var mutex: std.Thread.Mutex = .{}; + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +/// Copy a null-terminated C string into a fixed buffer. Returns bytes written. +fn copyFromCStr(dest: []u8, src: [*c]const u8) usize { + if (src == null) return 0; + var i: usize = 0; + while (i < dest.len and src[i] != 0) : (i += 1) { + dest[i] = src[i]; + } + return i; +} + +/// Write a slice into an output buffer pointer + length pointer. +fn writeOutput(buf: [*c]u8, buf_cap: usize, out_len: *c_int, data: []const u8) void { + if (buf == null) return; + const to_copy = @min(data.len, buf_cap); + for (0..to_copy) |i| { + buf[i] = data[i]; + } + out_len.* = @intCast(to_copy); +} + +/// Get a slot by index, returning null if invalid or inactive. +fn getSlot(slot_idx: c_int) ?*SessionSlot { + const idx: usize = std.math.cast(usize, slot_idx) orelse return null; + if (idx >= MAX_SESSIONS) return null; + const slot = &sessions[idx]; + if (!slot.active) return null; + return slot; +} + +/// Attempt a state transition on a slot. Returns 0 on success, -2 if invalid. +fn tryTransition(slot: *SessionSlot, target: ConnState) c_int { + if (!isValidTransition(slot.state, target)) return -2; + slot.state = target; + return 0; +} + +/// Stub: format a Linear GraphQL response into JSON. +/// In production this would POST to https://api.linear.app/graphql. +fn formatStubResponse(buf: []u8, operation: []const u8) usize { + const prefix = "{\"data\":{\""; + const mid = "\":{},\"stub\":true},\"extensions\":{\"requestId\":\"stub\"}}"; + var pos: usize = 0; + + for (prefix) |ch| { + if (pos >= buf.len) break; + buf[pos] = ch; + pos += 1; + } + for (operation) |ch| { + if (pos >= buf.len) break; + buf[pos] = ch; + pos += 1; + } + for (mid) |ch| { + if (pos >= buf.len) break; + buf[pos] = ch; + pos += 1; + } + return pos; +} + +// --------------------------------------------------------------------------- +// C-ABI exports β€” state machine +// --------------------------------------------------------------------------- + +/// Check if a state transition is valid. Returns 1 (valid) or 0 (invalid). +pub export fn linear_mcp_can_transition(from: c_int, to: c_int) c_int { + const f = std.meta.intToEnum(ConnState, from) catch return 0; + const t = std.meta.intToEnum(ConnState, to) catch return 0; + return if (isValidTransition(f, t)) 1 else 0; +} + +// --------------------------------------------------------------------------- +// C-ABI exports β€” authentication +// --------------------------------------------------------------------------- + +/// Authenticate with a Linear API key (Bearer token). +/// Transitions: Unauthenticated -> Authenticated (or remains Unauthenticated on error). +/// Returns slot index (>= 0) on success, negative on error. +/// Error codes: -1 = no free slots, -3 = empty token, -4 = token too short. +pub export fn linear_mcp_authenticate(token: [*c]const u8) c_int { + if (token == null) return -3; + // Validate minimum token length (Linear API keys are typically 40+ chars). + var len: usize = 0; + while (len < TOKEN_MAX and token[len] != 0) : (len += 1) {} + if (len < 8) return -4; + + mutex.lock(); + defer mutex.unlock(); + + // Find a free slot. + for (&sessions, 0..) |*slot, idx| { + if (!slot.active) { + slot.active = true; + slot.state = .authenticated; + slot.token_len = copyFromCStr(&slot.token_buf, token); + slot.issue_count = 0; + slot.actions_performed = 0; + slot.rate_tracker = .{}; + return @intCast(idx); + } + } + return -1; // No free slots. +} + +// --------------------------------------------------------------------------- +// C-ABI exports β€” generic GraphQL call +// --------------------------------------------------------------------------- + +/// Invoke a Linear GraphQL operation by action ID. +/// action_id: integer matching LinearAction enum. +/// params: JSON-encoded GraphQL variables (C string, may be null). +/// Returns 0 on success, negative on error. +/// Error codes: -1 = invalid slot, -2 = bad state, -5 = rate limited, -6 = unknown action. +pub export fn linear_mcp_graphql_call( + slot_idx: c_int, + action_id: c_int, + params: [*c]const u8, + out_buf: [*c]u8, + out_cap: c_int, + out_len: *c_int, +) c_int { + _ = params; + + const action = std.meta.intToEnum(LinearAction, action_id) catch return -6; + + mutex.lock(); + defer mutex.unlock(); + + const slot = getSlot(slot_idx) orelse return -1; + if (slot.state != .authenticated) return -2; + + // Rate-limit check. + const now = std.time.timestamp(); + if (!slot.rate_tracker.record(now, RATE_BUDGET)) { + slot.state = .rate_limited; + return -5; + } + + const operation = actionToOperation(action); + const len = formatStubResponse(&slot.out_buf, operation); + slot.out_len = len; + slot.actions_performed += 1; + + // Track issue queries for panel metrics. + if (action == .list_issues or action == .search_issues) { + slot.issue_count += 1; + } + + const cap: usize = std.math.cast(usize, out_cap) orelse 0; + writeOutput(out_buf, cap, out_len, slot.out_buf[0..len]); + return 0; +} + +// --------------------------------------------------------------------------- +// C-ABI exports β€” session management +// --------------------------------------------------------------------------- + +/// Disconnect a session gracefully. Returns 0 on success. +/// Error codes: -1 = invalid slot, -2 = bad state transition. +pub export fn linear_mcp_disconnect(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const slot = getSlot(slot_idx) orelse return -1; + const rc = tryTransition(slot, .unauthenticated); + if (rc == 0) { + slot.active = false; + slot.token_len = 0; + slot.issue_count = 0; + slot.actions_performed = 0; + } + return rc; +} + +/// Get the current connection state. Returns state int or -1 if invalid slot. +pub export fn linear_mcp_session_state(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const slot = getSlot(slot_idx) orelse return -1; + return @intFromEnum(slot.state); +} + +/// Get the rate-limit request count for a session. +/// Returns count (>= 0) or -1 if invalid. +pub export fn linear_mcp_rate_count(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const slot = getSlot(slot_idx) orelse return -1; + return @intCast(slot.rate_tracker.count); +} + +/// Get the actions-performed counter for a session. +pub export fn linear_mcp_actions_performed(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const slot = getSlot(slot_idx) orelse return -1; + return @intCast(slot.actions_performed); +} + +/// Get the issue-query counter for a session. +pub export fn linear_mcp_issue_count(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const slot = getSlot(slot_idx) orelse return -1; + return @intCast(slot.issue_count); +} + +/// Recover from rate-limited state (RateLimited -> Authenticated). +/// Returns 0 on success, -2 if bad transition. +pub export fn linear_mcp_rate_recover(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const slot = getSlot(slot_idx) orelse return -1; + return tryTransition(slot, .authenticated); +} + +/// Reset all sessions (test/debug use only). +pub export fn linear_mcp_reset() void { + mutex.lock(); + defer mutex.unlock(); + sessions = [_]SessionSlot{.{}} ** MAX_SESSIONS; +} + +// --------------------------------------------------------------------------- +// ═══════════════════════════════════════════════════════════════════════ +// Standard ABI (ADR-0005 four symbols + ADR-0006 invoke) +// ═══════════════════════════════════════════════════════════════════════ + +const shim = @import("cartridge_shim.zig"); + +const CARTRIDGE_NAME_PTR: [*:0]const u8 = "linear-mcp"; +const CARTRIDGE_VERSION_PTR: [*:0]const u8 = "0.1.0"; + +export fn boj_cartridge_init() callconv(.c) c_int { + return 0; +} + +export fn boj_cartridge_deinit() callconv(.c) void {} + +export fn boj_cartridge_name() callconv(.c) [*:0]const u8 { + return CARTRIDGE_NAME_PTR; +} + +export fn boj_cartridge_version() callconv(.c) [*:0]const u8 { + return CARTRIDGE_VERSION_PTR; +} + +/// Dispatch the cartridge.json MCP tools. Grade D Alpha stubs. +export fn boj_cartridge_invoke( + tool_name: [*c]const u8, + json_args: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) callconv(.c) i32 { + _ = json_args; + if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; + + const body: []const u8 = if (shim.toolIs(tool_name, "linear_authenticate")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "linear_list_issues")) + "{\"result\":{\"items\":[],\"count\":0,\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "linear_get_issue")) + "{\"result\":{\"metadata\":{},\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "linear_create_issue")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "linear_update_issue")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "linear_list_teams")) + "{\"result\":{\"items\":[],\"count\":0,\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "linear_search_issues")) + "{\"result\":{\"matches\":[],\"status\":\"stub\"}}" +else + return shim.RC_UNKNOWN_TOOL; + + return shim.writeResult(out_buf, in_out_len, body); +} + +// Tests +// --------------------------------------------------------------------------- + +test "state machine transitions" { + // Valid transitions. + try std.testing.expectEqual(@as(c_int, 1), linear_mcp_can_transition(0, 1)); // unauth -> auth + try std.testing.expectEqual(@as(c_int, 1), linear_mcp_can_transition(1, 2)); // auth -> rate_limited + try std.testing.expectEqual(@as(c_int, 1), linear_mcp_can_transition(2, 1)); // rate_limited -> auth + try std.testing.expectEqual(@as(c_int, 1), linear_mcp_can_transition(1, 3)); // auth -> error + try std.testing.expectEqual(@as(c_int, 1), linear_mcp_can_transition(2, 3)); // rate_limited -> error + try std.testing.expectEqual(@as(c_int, 1), linear_mcp_can_transition(3, 0)); // error -> unauth + try std.testing.expectEqual(@as(c_int, 1), linear_mcp_can_transition(1, 0)); // auth -> unauth (graceful) + + // Invalid transitions. + try std.testing.expectEqual(@as(c_int, 0), linear_mcp_can_transition(0, 2)); // unauth -> rate_limited + try std.testing.expectEqual(@as(c_int, 0), linear_mcp_can_transition(0, 3)); // unauth -> error + try std.testing.expectEqual(@as(c_int, 0), linear_mcp_can_transition(3, 1)); // error -> auth + try std.testing.expectEqual(@as(c_int, 0), linear_mcp_can_transition(2, 0)); // rate_limited -> unauth + + // Out of range. + try std.testing.expectEqual(@as(c_int, 0), linear_mcp_can_transition(99, 0)); + try std.testing.expectEqual(@as(c_int, 0), linear_mcp_can_transition(0, 99)); +} + +test "authenticate and disconnect lifecycle" { + linear_mcp_reset(); + + // Authenticate with a valid API key. + const slot = linear_mcp_authenticate("lin_api_test_key_1234567890abcdef"); + try std.testing.expect(slot >= 0); + + // Should be authenticated. + try std.testing.expectEqual(@as(c_int, 1), linear_mcp_session_state(slot)); + + // Graceful disconnect. + try std.testing.expectEqual(@as(c_int, 0), linear_mcp_disconnect(slot)); +} + +test "reject short token" { + linear_mcp_reset(); + + // Token too short. + try std.testing.expectEqual(@as(c_int, -4), linear_mcp_authenticate("short")); + + // Null token. + try std.testing.expectEqual(@as(c_int, -3), linear_mcp_authenticate(null)); +} + +test "graphql call updates counters" { + linear_mcp_reset(); + + const slot = linear_mcp_authenticate("lin_api_test_graphql_call_key_0123"); + try std.testing.expect(slot >= 0); + + var buf: [1024]u8 = undefined; + var out_len: c_int = 0; + + // Call list_issues (action_id = 0). + const rc = linear_mcp_graphql_call(slot, 0, null, &buf, 1024, &out_len); + try std.testing.expectEqual(@as(c_int, 0), rc); + try std.testing.expect(out_len > 0); + try std.testing.expectEqual(@as(c_int, 1), linear_mcp_actions_performed(slot)); + try std.testing.expectEqual(@as(c_int, 1), linear_mcp_issue_count(slot)); + + // Unknown action. + try std.testing.expectEqual(@as(c_int, -6), linear_mcp_graphql_call(slot, 99, null, &buf, 1024, &out_len)); + + _ = linear_mcp_disconnect(slot); +} + +test "slot exhaustion" { + linear_mcp_reset(); + + var slots: [MAX_SESSIONS]c_int = undefined; + for (&slots) |*s| { + s.* = linear_mcp_authenticate("lin_api_fill_slot_token_abcdefgh"); + try std.testing.expect(s.* >= 0); + } + + // Next should fail. + try std.testing.expectEqual(@as(c_int, -1), linear_mcp_authenticate("lin_api_overflow_token_000000000")); + + // Free one and retry. + try std.testing.expectEqual(@as(c_int, 0), linear_mcp_disconnect(slots[0])); + const new_slot = linear_mcp_authenticate("lin_api_reuse_slot_token_1234567"); + try std.testing.expect(new_slot >= 0); +} + +// ═══════════════════════════════════════════════════════════════════════ +// ADR-0006 invoke dispatch tests +// ═══════════════════════════════════════════════════════════════════════ + +test "boj_cartridge_name returns linear-mcp" { + const n = std.mem.span(boj_cartridge_name()); + try std.testing.expectEqualStrings("linear-mcp", n); +} + +test "boj_cartridge_init returns 0" { + try std.testing.expectEqual(@as(c_int, 0), boj_cartridge_init()); +} + +test "invoke: each declared tool succeeds" { + var buf: [256]u8 = undefined; + const tools = [_][]const u8{ + "linear_authenticate", + "linear_list_issues", + "linear_get_issue", + "linear_create_issue", + "linear_update_issue", + "linear_list_teams", + "linear_search_issues", + }; + for (tools) |t| { + var len: usize = buf.len; + const rc = boj_cartridge_invoke(t.ptr, "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, 0), rc); + try std.testing.expect(std.mem.indexOf(u8, buf[0..len], "result") != null); + } +} + +test "invoke: unknown tool returns -1" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("nope", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -1), rc); +} + +test "invoke: buffer too small returns -3" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("linear_authenticate", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -3), rc); + try std.testing.expect(len > 4); +} diff --git a/cartridges/domains/project-management/linear-mcp/minter.toml b/cartridges/domains/project-management/linear-mcp/minter.toml new file mode 100644 index 0000000..a5c0428 --- /dev/null +++ b/cartridges/domains/project-management/linear-mcp/minter.toml @@ -0,0 +1,13 @@ +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +name = "linear-mcp" +description = "Linear project management GraphQL API cartridge β€” issues, projects, teams, cycles, labels, and workflow states" +version = "0.1.0" +domain = "Comms" +protocols = [ + "MCP", + "GraphQL", +] +tier = "Ayo" +backend = "universal" +generate_panel = true diff --git a/cartridges/domains/project-management/linear-mcp/mod.js b/cartridges/domains/project-management/linear-mcp/mod.js new file mode 100644 index 0000000..942b115 --- /dev/null +++ b/cartridges/domains/project-management/linear-mcp/mod.js @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// linear-mcp/mod.js β€” Linear issue tracking and project management +// +// Delegates to backend at http://127.0.0.1:7732 (override with LINEAR_BACKEND_URL). + +const BASE_URL = Deno.env.get("LINEAR_BACKEND_URL") ?? "http://127.0.0.1:7732"; +const TIMEOUT_MS = 15_000; + +async function post(path, payload) { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), TIMEOUT_MS); + try { + const r = await fetch(`${BASE_URL}${path}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + signal: ctrl.signal, + }); + const data = await r.json().catch(() => ({ success: false, error: "non-JSON response" })); + return { status: r.status, data }; + } catch (e) { + if (e.name === "AbortError") return { status: 504, data: { success: false, error: "linear-mcp backend timed out" } }; + return { status: 503, data: { success: false, error: `linear-mcp backend unavailable: ${e.message}` } }; + } finally { clearTimeout(t); } +} + +async function get(path) { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), TIMEOUT_MS); + try { + const r = await fetch(`${BASE_URL}${path}`, { method: "GET", signal: ctrl.signal }); + const data = await r.json().catch(() => ({ success: false, error: "non-JSON response" })); + return { status: r.status, data }; + } catch (e) { + if (e.name === "AbortError") return { status: 504, data: { success: false, error: "linear-mcp backend timed out" } }; + return { status: 503, data: { success: false, error: `linear-mcp backend unavailable: ${e.message}` } }; + } finally { clearTimeout(t); } +} + +export async function handleTool(toolName, args) { + switch (toolName) { + case "linear_authenticate": + return post("/api/v1/linear_authenticate", args ?? {}); + case "linear_list_issues": + return post("/api/v1/linear_list_issues", args ?? {}); + case "linear_get_issue": + return post("/api/v1/linear_get_issue", args ?? {}); + case "linear_create_issue": + return post("/api/v1/linear_create_issue", args ?? {}); + case "linear_update_issue": + return post("/api/v1/linear_update_issue", args ?? {}); + case "linear_list_teams": + return post("/api/v1/linear_list_teams", args ?? {}); + case "linear_search_issues": + return post("/api/v1/linear_search_issues", args ?? {}); + default: + return { status: 404, data: { error: `Unknown tool: ${toolName}` } }; + } +} diff --git a/cartridges/domains/project-management/linear-mcp/panels/manifest.json b/cartridges/domains/project-management/linear-mcp/panels/manifest.json new file mode 100644 index 0000000..dab32a9 --- /dev/null +++ b/cartridges/domains/project-management/linear-mcp/panels/manifest.json @@ -0,0 +1,76 @@ +{ + "$schema": "https://panll.dev/schemas/panel-manifest/v1.json", + "spdx": "MPL-2.0", + "cartridge": "linear-mcp", + "domain": "Comms", + "version": "0.1.0", + "panels": [ + { + "id": "linear-auth-status", + "title": "Auth Status", + "description": "Current Linear connection state (Unauthenticated / Authenticated / RateLimited / Error)", + "type": "status-indicator", + "data_source": { + "endpoint": "/linear/state", + "method": "GET", + "refresh_interval_ms": 5000 + }, + "widgets": [ + { + "type": "state-badge", + "field": "state", + "states": { + "unauthenticated": { "color": "#95a5a6", "icon": "plug-disconnected" }, + "authenticated": { "color": "#2ecc71", "icon": "plug-connected" }, + "rate_limited": { "color": "#e67e22", "icon": "clock" }, + "error": { "color": "#e74c3c", "icon": "alert-triangle" } + } + } + ] + }, + { + "id": "linear-issue-count", + "title": "Issue Queries", + "description": "Number of issue list/search queries performed in the current session", + "type": "metric", + "data_source": { + "endpoint": "/linear/metrics", + "method": "GET", + "refresh_interval_ms": 10000 + }, + "widgets": [ + { + "type": "counter", + "field": "issue_count", + "label": "Issue Queries", + "icon": "list" + } + ] + }, + { + "id": "linear-recent-activity", + "title": "Recent Activity", + "description": "Total actions performed and rate-limit usage in the current session", + "type": "multi-metric", + "data_source": { + "endpoint": "/linear/metrics", + "method": "GET", + "refresh_interval_ms": 10000 + }, + "widgets": [ + { + "type": "counter", + "field": "actions_performed", + "label": "Actions Performed", + "icon": "activity" + }, + { + "type": "progress-bar", + "items": [ + { "field": "rate_count", "max_field": "rate_budget", "label": "Rate Limit (50/min)", "color": "#3498db" } + ] + } + ] + } + ] +} diff --git a/cartridges/domains/project-management/linear-mcp/tests/integration_test.sh b/cartridges/domains/project-management/linear-mcp/tests/integration_test.sh new file mode 100755 index 0000000..113808d --- /dev/null +++ b/cartridges/domains/project-management/linear-mcp/tests/integration_test.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Integration tests for linear-mcp cartridge. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CART_DIR="$(dirname "$SCRIPT_DIR")" +FFI_DIR="$CART_DIR/ffi" + +echo "=== linear-mcp integration tests ===" + +# Build FFI +echo "[1/3] Building FFI..." +cd "$FFI_DIR" && zig build 2>&1 + +# Run Zig unit tests +echo "[2/3] Running FFI unit tests..." +cd "$FFI_DIR" && zig build test 2>&1 + +# Validate Idris2 ABI (if idris2 available) +echo "[3/3] Checking ABI..." +if command -v idris2 &>/dev/null; then + cd "$CART_DIR/abi" && idris2 --check linear_mcp.ipkg 2>&1 + echo " ABI: OK" +else + echo " ABI: SKIPPED (idris2 not in PATH)" +fi + +echo "" +echo "All tests passed for linear-mcp!" diff --git a/cartridges/domains/registry/crates-mcp/README.adoc b/cartridges/domains/registry/crates-mcp/README.adoc new file mode 100644 index 0000000..544300e --- /dev/null +++ b/cartridges/domains/registry/crates-mcp/README.adoc @@ -0,0 +1,88 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += crates-mcp +Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk> +:spdx: MPL-2.0 +:tier: Ayo +:domain: Registry +:protocols: MCP, REST + +== Overview + +crates.io registry cartridge for the BoJ server. Provides type-safe access to +the crates.io API for Rust crate search, metadata retrieval, version listing, +download statistics, dependency analysis, reverse dependency lookup, owner +management, category/keyword browsing, and feature flag inspection. + +=== State Machine + +`Unauthenticated -> Authenticated` (optional, all reads work without auth) + +`Authenticated -> RateLimited -> Authenticated` (normal flow, 429 handling) + +`Authenticated -> Error -> Unauthenticated` (error recovery) + +=== Actions (13) + +[cols="1,1"] +|=== +| Category | Actions + +| Search +| SearchCrates + +| Crate Metadata +| GetCrate, GetVersion, ListVersions, GetFeatures + +| Downloads +| GetDownloads + +| Dependencies +| GetDependencies, GetReverseDependencies + +| Ownership +| GetOwners + +| Taxonomy +| ListCategories, GetCategory, ListKeywords + +| Users +| GetUser +|=== + +== Architecture + +[cols="1,1,2"] +|=== +| Layer | Language | Purpose + +| ABI +| Idris2 +| Formally verified state machine with dependent-type proofs (CratesMcp.SafeRegistry) + +| FFI +| Zig +| C-compatible implementation with thread-safe session pool, action recording, category counting + +| Adapter +| zig +| REST bridge to BoJ unified adapter protocol +|=== + +== Building + +[source,bash] +---- +# Build FFI shared library +cd ffi && zig build + +# Run FFI tests +cd ffi && zig build test + +# Type-check ABI +cd abi && idris2 --check crates_mcp.ipkg +---- + +== Status + +Development -- customised with crates.io-specific search, downloads, reverse deps, categories, and feature APIs. diff --git a/cartridges/domains/registry/crates-mcp/abi/CratesMcp/SafeRegistry.idr b/cartridges/domains/registry/crates-mcp/abi/CratesMcp/SafeRegistry.idr new file mode 100644 index 0000000..2370fda --- /dev/null +++ b/cartridges/domains/registry/crates-mcp/abi/CratesMcp/SafeRegistry.idr @@ -0,0 +1,191 @@ +-- SPDX-License-Identifier: MPL-2.0 +-- Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +-- +-- CratesMcp.SafeRegistry β€” Type-safe ABI for crates-mcp cartridge. +-- +-- Dependent-type state machine governing crates.io API access. +-- Encodes optional Bearer token auth, crate search, metadata retrieval, +-- version listing, download stats, dependency analysis, reverse deps, +-- owner listing, and category/keyword browsing as compile-time invariants. +-- REST API: https://crates.io/api/v1 +-- No unsafe escape hatches. + +module CratesMcp.SafeRegistry + +%default total + +-- --------------------------------------------------------------------------- +-- Authentication state machine +-- --------------------------------------------------------------------------- + +||| Session state for crates.io MCP operations. +||| Unauthenticated: no API token; read-only operations available. +||| Authenticated: crates.io API token active, full access. +||| RateLimited: crates.io rate limit hit (429); must wait. +||| Error: unrecoverable error (invalid token, network failure). +public export +data SessionState + = Unauthenticated + | Authenticated + | RateLimited + | Error + +||| Proof that a state transition is valid. +||| crates.io allows both authenticated and unauthenticated sessions. +public export +data ValidTransition : SessionState -> SessionState -> Type where + Authenticate : ValidTransition Unauthenticated Authenticated + Deauthenticate : ValidTransition Authenticated Unauthenticated + Throttle : ValidTransition Authenticated RateLimited + ThrottleAnon : ValidTransition Unauthenticated RateLimited + Unthrottle : ValidTransition RateLimited Authenticated + UnthrottleAnon : ValidTransition RateLimited Unauthenticated + AuthError : ValidTransition Authenticated Error + AnonError : ValidTransition Unauthenticated Error + RecoverToAuth : ValidTransition Error Authenticated + RecoverToAnon : ValidTransition Error Unauthenticated + +-- --------------------------------------------------------------------------- +-- C-ABI integer encoding +-- --------------------------------------------------------------------------- + +||| Encode session state as C-compatible integer for FFI boundary. +export +sessionStateToInt : SessionState -> Int +sessionStateToInt Unauthenticated = 0 +sessionStateToInt Authenticated = 1 +sessionStateToInt RateLimited = 2 +sessionStateToInt Error = 3 + +||| Decode integer back to session state. Returns Nothing for out-of-range. +export +intToSessionState : Int -> Maybe SessionState +intToSessionState 0 = Just Unauthenticated +intToSessionState 1 = Just Authenticated +intToSessionState 2 = Just RateLimited +intToSessionState 3 = Just Error +intToSessionState _ = Nothing + +||| Check if a state transition is valid (C-ABI export). +export +crates_mcp_can_transition : Int -> Int -> Int +crates_mcp_can_transition from to = + case (intToSessionState from, intToSessionState to) of + (Just Unauthenticated, Just Authenticated) => 1 + (Just Authenticated, Just Unauthenticated) => 1 + (Just Authenticated, Just RateLimited) => 1 + (Just Unauthenticated, Just RateLimited) => 1 + (Just RateLimited, Just Authenticated) => 1 + (Just RateLimited, Just Unauthenticated) => 1 + (Just Authenticated, Just Error) => 1 + (Just Unauthenticated, Just Error) => 1 + (Just Error, Just Authenticated) => 1 + (Just Error, Just Unauthenticated) => 1 + _ => 0 + +-- --------------------------------------------------------------------------- +-- crates.io actions +-- --------------------------------------------------------------------------- + +||| Actions available through the crates.io MCP cartridge. +||| Grouped: Search, Metadata, Versions, Downloads, Dependencies, +||| Reverse Dependencies, Owners, Categories, Keywords, Users, Features. +public export +data CratesAction + = SearchCrates + | GetCrate + | GetVersion + | ListVersions + | GetDownloads + | GetDependencies + | GetReverseDependencies + | GetOwners + | ListCategories + | GetCategory + | ListKeywords + | GetUser + | GetFeatures + +||| Whether an action requires Authenticated state. +||| crates.io allows unauthenticated read access for all query operations. +export +actionRequiresAuth : CratesAction -> Bool +actionRequiresAuth _ = False + +||| Whether an action is a write/mutating operation. +||| All crates-mcp actions are read-only queries. +export +actionIsMutating : CratesAction -> Bool +actionIsMutating _ = False + +||| Encode action as C-compatible integer for FFI. +export +actionToInt : CratesAction -> Int +actionToInt SearchCrates = 0 +actionToInt GetCrate = 1 +actionToInt GetVersion = 2 +actionToInt ListVersions = 3 +actionToInt GetDownloads = 4 +actionToInt GetDependencies = 5 +actionToInt GetReverseDependencies = 6 +actionToInt GetOwners = 7 +actionToInt ListCategories = 8 +actionToInt GetCategory = 9 +actionToInt ListKeywords = 10 +actionToInt GetUser = 11 +actionToInt GetFeatures = 12 + +||| Decode integer to crates action. +export +intToAction : Int -> Maybe CratesAction +intToAction 0 = Just SearchCrates +intToAction 1 = Just GetCrate +intToAction 2 = Just GetVersion +intToAction 3 = Just ListVersions +intToAction 4 = Just GetDownloads +intToAction 5 = Just GetDependencies +intToAction 6 = Just GetReverseDependencies +intToAction 7 = Just GetOwners +intToAction 8 = Just ListCategories +intToAction 9 = Just GetCategory +intToAction 10 = Just ListKeywords +intToAction 11 = Just GetUser +intToAction 12 = Just GetFeatures +intToAction _ = Nothing + +-- --------------------------------------------------------------------------- +-- MCP tool declarations +-- --------------------------------------------------------------------------- + +||| Tools exposed via MCP protocol for this cartridge. +public export +data McpTool + = ToolSearchCrates + | ToolGetCrate + | ToolGetVersion + | ToolListVersions + | ToolGetDownloads + | ToolGetDependencies + | ToolGetReverseDependencies + | ToolGetOwners + | ToolListCategories + | ToolGetCategory + | ToolListKeywords + | ToolGetUser + | ToolGetFeatures + +||| Check if a tool requires an authenticated session. +||| All crates.io read operations work without auth. +export +toolRequiresSession : McpTool -> Bool +toolRequiresSession _ = False + +||| Total tool count for this cartridge. +export +toolCount : Nat +toolCount = 13 + +||| Total action count for this cartridge. +export +actionCount : Nat +actionCount = 13 diff --git a/cartridges/domains/registry/crates-mcp/abi/README.adoc b/cartridges/domains/registry/crates-mcp/abi/README.adoc new file mode 100644 index 0000000..8796b71 --- /dev/null +++ b/cartridges/domains/registry/crates-mcp/abi/README.adoc @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += crates-mcp / abi β€” Idris2 ABI layer +:orientation: shallow + +== Purpose + +Cartridge-local protocol types for `crates-mcp`. Defines the data, tool, and +transition vocabulary this cartridge speaks. Inherits safety lemmas and the +matrix-cell identifier scheme from the trunk ABI at `src/abi/`. + +== Files + +1 Idris2 module(s), ~191 lines total. Lead module: +`SafeRegistry.idr`. + +No `.ipkg` β€” consumed directly by wider BoJ callers; type-checks inside the +trunk `boj.ipkg`. + +== Invariants + +* `%default total` at every module top. +* Zero `believe_me`, `assert_total`, `postulate`, `Admitted`, or + `sorry` (the trunk holds the four declared `believe_me` sites; leaf + cartridges carry none). + +== Test/proof surface + +Thin module(s), no inline tests. Proof surface = type-check success under the +trunk `idris2 --check`. + +== Read-first + +. `SafeRegistry.idr` β€” cartridge-local data records and enums. +. Trunk `src/abi/Boj/Catalogue.idr` for how this cell participates in the + 2D matrix. diff --git a/cartridges/domains/registry/crates-mcp/abi/crates_mcp.ipkg b/cartridges/domains/registry/crates-mcp/abi/crates_mcp.ipkg new file mode 100644 index 0000000..7930db1 --- /dev/null +++ b/cartridges/domains/registry/crates-mcp/abi/crates_mcp.ipkg @@ -0,0 +1,13 @@ +-- SPDX-License-Identifier: MPL-2.0 +-- Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +package crates_mcp + +version = "0.2.0" +authors = "Jonathan D.A. Jewell" +brief = "Type-safe ABI for crates.io MCP cartridge β€” crate search, metadata, downloads, dependencies, reverse deps, owners, categories" + +sourcedir = "." + +depends = base + +modules = CratesMcp.SafeRegistry diff --git a/cartridges/domains/registry/crates-mcp/adapter/README.adoc b/cartridges/domains/registry/crates-mcp/adapter/README.adoc new file mode 100644 index 0000000..5653a01 --- /dev/null +++ b/cartridges/domains/registry/crates-mcp/adapter/README.adoc @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += crates-mcp / adapter β€” protocol bridge +:orientation: shallow + +== Purpose + +Exposes this cartridge's FFI over the wire protocols declared in +`cartridge.json`. Dispatches tool invocations to the C-ABI exports at +`../ffi/` and formats responses per protocol. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” depends on the sibling `ffi` target. +| `crates_adapter.zig` | Protocol dispatch (216 lines). Tool catalogue matches `cartridge.json`. +| `SIDELINED-*.v.adoc` | zig predecessor(s). Not built β€” zig banned 2026-04-10. +|=== + +== Invariants + +* **Stateless** β€” all state lives behind the FFI, never in the adapter. +* **Response passthrough** β€” whatever the FFI returns goes back to the wire + unmodified (no embellishment, no silent recovery). +* **Cartridge.json is source of truth** for the tool catalogue; drift between + adapter and manifest is a CI failure. + +== Test/proof surface + +No inline tests at the adapter layer β€” protocol routing is exercised by the +trunk seam tests at `ffi/zig/src/seams.zig`. + +== Read-first + +. `crates_adapter.zig` β€” dispatch function and per-protocol routers. +. `../cartridge.json` β€” the tool manifest the dispatcher mirrors. diff --git a/cartridges/domains/registry/crates-mcp/adapter/build.zig b/cartridges/domains/registry/crates-mcp/adapter/build.zig new file mode 100644 index 0000000..7d29851 --- /dev/null +++ b/cartridges/domains/registry/crates-mcp/adapter/build.zig @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// crates-mcp/adapter/build.zig + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const ffi_mod = b.createModule(.{ + .root_source_file = b.path("../ffi/crates_mcp_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + const adapter = b.addExecutable(.{ + .name = "crates_adapter", + .root_source_file = b.path("crates_adapter.zig"), + .target = target, + .optimize = optimize, + }); + adapter.root_module.addImport("crates_mcp_ffi", ffi_mod); + b.installArtifact(adapter); + + const run_artifact = b.addRunArtifact(adapter); + const run_step = b.step("run", "Run the crates-mcp adapter"); + run_step.dependOn(&run_artifact.step); + + const tests = b.addTest(.{ + .root_source_file = b.path("crates_adapter.zig"), + .target = target, + .optimize = optimize, + }); + tests.root_module.addImport("crates_mcp_ffi", ffi_mod); + const run_tests = b.addRunArtifact(tests); + const test_step = b.step("test", "Run crates-mcp adapter tests"); + test_step.dependOn(&run_tests.step); +} diff --git a/cartridges/domains/registry/crates-mcp/adapter/crates_adapter.zig b/cartridges/domains/registry/crates-mcp/adapter/crates_adapter.zig new file mode 100644 index 0000000..4885832 --- /dev/null +++ b/cartridges/domains/registry/crates-mcp/adapter/crates_adapter.zig @@ -0,0 +1,216 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// crates-mcp/adapter/crates_adapter.zig -- Unified three-protocol adapter. +// +// Replaces the banned crates_adapter.v (zig, removed 2026-04-12). +// +// Bridges the Zig FFI (crates_mcp_ffi.zig) to three network protocols: +// REST :9043 POST /tools/<tool> +// gRPC-compat :9044 /CratesMcpService/<Method> +// GraphQL :9045 POST /graphql { query: "..." } +// +// crates.io registry: search, metadata, versions, downloads, dependencies +// Tools: +// crates_search +// crates_get_crate +// crates_get_version +// crates_list_versions +// crates_get_downloads +// crates_get_dependencies +// crates_get_reverse_dependencies +// crates_get_owners +// crates_list_categories +// crates_get_category +// crates_list_keywords +// crates_get_user +// crates_get_features + +const std = @import("std"); +const ffi = @import("crates_mcp_ffi"); + +const REST_PORT: u16 = 9043; +const GRPC_PORT: u16 = 9044; +const GQL_PORT: u16 = 9045; + +const MAX_CONN_BUF: usize = 16 * 1024; + +// ============================================================================ +// JSON response builders +// ============================================================================ + +fn okJson(buf: []u8, msg: []const u8) []u8 { + return std.fmt.bufPrint(buf, + \{{"success":true,"message":"{}"}} + , .{msg}) catch buf[0..0]; +} + +fn errJson(buf: []u8, msg: []const u8) []u8 { + return std.fmt.bufPrint(buf, + \{{"success":false,"error":"{}"}} + , .{msg}) catch buf[0..0]; +} + +fn statusJson(buf: []u8) []u8 { + return std.fmt.bufPrint(buf, + \{{"success":true,"state":"ready","service":"crates-mcp"}} + , .{}) catch buf[0..0]; +} + +// ============================================================================ +// Tool dispatcher +// ============================================================================ + +const Response = struct { status: u16, body: []u8 }; + +fn dispatch(tool: []const u8, body: []const u8, resp: []u8) Response { + _ = body; + if (std.mem.eql(u8, tool, "crates_search")) return .{ .status = 200, .body = okJson(resp, "crates_search forwarded to backend") }; + if (std.mem.eql(u8, tool, "crates_get_crate")) return .{ .status = 200, .body = okJson(resp, "crates_get_crate forwarded to backend") }; + if (std.mem.eql(u8, tool, "crates_get_version")) return .{ .status = 200, .body = okJson(resp, "crates_get_version forwarded to backend") }; + if (std.mem.eql(u8, tool, "crates_list_versions")) return .{ .status = 200, .body = okJson(resp, "crates_list_versions forwarded to backend") }; + if (std.mem.eql(u8, tool, "crates_get_downloads")) return .{ .status = 200, .body = okJson(resp, "crates_get_downloads forwarded to backend") }; + if (std.mem.eql(u8, tool, "crates_get_dependencies")) return .{ .status = 200, .body = okJson(resp, "crates_get_dependencies forwarded to backend") }; + if (std.mem.eql(u8, tool, "crates_get_reverse_dependencies")) return .{ .status = 200, .body = okJson(resp, "crates_get_reverse_dependencies forwarded to backend") }; + if (std.mem.eql(u8, tool, "crates_get_owners")) return .{ .status = 200, .body = okJson(resp, "crates_get_owners forwarded to backend") }; + if (std.mem.eql(u8, tool, "crates_list_categories")) return .{ .status = 200, .body = okJson(resp, "crates_list_categories forwarded to backend") }; + if (std.mem.eql(u8, tool, "crates_get_category")) return .{ .status = 200, .body = okJson(resp, "crates_get_category forwarded to backend") }; + if (std.mem.eql(u8, tool, "crates_list_keywords")) return .{ .status = 200, .body = okJson(resp, "crates_list_keywords forwarded to backend") }; + if (std.mem.eql(u8, tool, "crates_get_user")) return .{ .status = 200, .body = okJson(resp, "crates_get_user forwarded to backend") }; + if (std.mem.eql(u8, tool, "crates_get_features")) return .{ .status = 200, .body = okJson(resp, "crates_get_features forwarded to backend") }; + if (std.mem.eql(u8, tool, "status") or std.mem.eql(u8, tool, "health")) + return .{ .status = 200, .body = statusJson(resp) }; + return .{ .status = 404, .body = errJson(resp, "Unknown tool") }; +} + +// ============================================================================ +// REST handler +// ============================================================================ + +fn dispatchRest(path: []const u8, body: []const u8, resp: []u8) Response { + if (std.mem.startsWith(u8, path, "/tools/")) { + return dispatch(path["/tools/".len..], body, resp); + } + if (std.mem.eql(u8, path, "/status") or std.mem.eql(u8, path, "/health")) { + return .{ .status = 200, .body = statusJson(resp) }; + } + return .{ .status = 404, .body = errJson(resp, "Not found") }; +} + +// ============================================================================ +// gRPC-compat handler +// ============================================================================ + +fn dispatchGrpc(path: []const u8, body: []const u8, resp: []u8) Response { + const prefix = "/CratesMcpService/"; + if (!std.mem.startsWith(u8, path, prefix)) + return .{ .status = 404, .body = errJson(resp, "Not a recognized gRPC path") }; + const method = path[prefix.len..]; + const tool = blk: { + if (std.mem.eql(u8, method, "CratesSearch")) break :blk "crates_search"; + if (std.mem.eql(u8, method, "CratesGetCrate")) break :blk "crates_get_crate"; + if (std.mem.eql(u8, method, "CratesGetVersion")) break :blk "crates_get_version"; + if (std.mem.eql(u8, method, "CratesListVersions")) break :blk "crates_list_versions"; + if (std.mem.eql(u8, method, "CratesGetDownloads")) break :blk "crates_get_downloads"; + if (std.mem.eql(u8, method, "CratesGetDependencies")) break :blk "crates_get_dependencies"; + if (std.mem.eql(u8, method, "CratesGetReverseDependencies")) break :blk "crates_get_reverse_dependencies"; + if (std.mem.eql(u8, method, "CratesGetOwners")) break :blk "crates_get_owners"; + if (std.mem.eql(u8, method, "CratesListCategories")) break :blk "crates_list_categories"; + if (std.mem.eql(u8, method, "CratesGetCategory")) break :blk "crates_get_category"; + if (std.mem.eql(u8, method, "CratesListKeywords")) break :blk "crates_list_keywords"; + if (std.mem.eql(u8, method, "CratesGetUser")) break :blk "crates_get_user"; + if (std.mem.eql(u8, method, "CratesGetFeatures")) break :blk "crates_get_features"; + return .{ .status = 404, .body = errJson(resp, "Unknown gRPC method") }; + }; + return dispatch(tool, body, resp); +} + +// ============================================================================ +// GraphQL handler +// ============================================================================ + +fn dispatchGraphql(body: []const u8, resp: []u8) Response { + if (std.mem.indexOf(u8, body, "__schema") != null) + return .{ .status = 200, .body = okJson(resp, "schema introspection not yet supported") }; + if (std.mem.indexOf(u8, body, "search") != null) return dispatch("crates_search", body, resp); + if (std.mem.indexOf(u8, body, "get_crate") != null) return dispatch("crates_get_crate", body, resp); + if (std.mem.indexOf(u8, body, "get_version") != null) return dispatch("crates_get_version", body, resp); + if (std.mem.indexOf(u8, body, "list_versions") != null) return dispatch("crates_list_versions", body, resp); + if (std.mem.indexOf(u8, body, "get_downloads") != null) return dispatch("crates_get_downloads", body, resp); + if (std.mem.indexOf(u8, body, "get_dependencies") != null) return dispatch("crates_get_dependencies", body, resp); + if (std.mem.indexOf(u8, body, "get_reverse_dependencies") != null) return dispatch("crates_get_reverse_dependencies", body, resp); + if (std.mem.indexOf(u8, body, "get_owners") != null) return dispatch("crates_get_owners", body, resp); + if (std.mem.indexOf(u8, body, "list_categories") != null) return dispatch("crates_list_categories", body, resp); + if (std.mem.indexOf(u8, body, "get_category") != null) return dispatch("crates_get_category", body, resp); + if (std.mem.indexOf(u8, body, "list_keywords") != null) return dispatch("crates_list_keywords", body, resp); + if (std.mem.indexOf(u8, body, "get_user") != null) return dispatch("crates_get_user", body, resp); + if (std.mem.indexOf(u8, body, "get_features") != null) return dispatch("crates_get_features", body, resp); + return .{ .status = 200, .body = errJson(resp, "Unrecognised GraphQL operation") }; +} + +// ============================================================================ +// HTTP/1.1 connection handler +// ============================================================================ + +const Protocol = enum { rest, grpc, graphql }; + +fn handleConnection(conn: std.net.Server.Connection, proto: Protocol) void { + defer conn.stream.close(); + var in_buf: [MAX_CONN_BUF]u8 = undefined; + const n = conn.stream.read(&in_buf) catch return; + const req = in_buf[0..n]; + + var path: []const u8 = "/"; + var body: []const u8 = ""; + if (n > 4) { + const line_end = std.mem.indexOf(u8, req, "\r\n") orelse req.len; + const first_line = req[0..line_end]; + const sp1 = std.mem.indexOfScalar(u8, first_line, ' ') orelse 0; + const rest_of = first_line[sp1 + 1 ..]; + const sp2 = std.mem.indexOfScalar(u8, rest_of, ' ') orelse rest_of.len; + path = rest_of[0..sp2]; + const body_sep = std.mem.indexOf(u8, req, "\r\n\r\n") orelse n; + body = req[@min(body_sep + 4, n)..]; + } + + var resp_buf: [MAX_CONN_BUF]u8 = undefined; + const result = switch (proto) { + .rest => dispatchRest(path, body, &resp_buf), + .grpc => dispatchGrpc(path, body, &resp_buf), + .graphql => dispatchGraphql(body, &resp_buf), + }; + + const content_type = switch (proto) { + .rest => "application/json", + .grpc => "application/grpc+json", + .graphql => "application/json", + }; + + var hdr_buf: [256]u8 = undefined; + const hdr = std.fmt.bufPrint(&hdr_buf, + "HTTP/1.1 {d} OK\r\nContent-Type: {s}\r\nContent-Length: {d}\r\nConnection: close\r\n\r\n", + .{ result.status, content_type, result.body.len }, + ) catch return; + _ = conn.stream.writeAll(hdr) catch return; + _ = conn.stream.writeAll(result.body) catch return; +} + +fn listenLoop(port: u16, proto: Protocol) void { + const addr = std.net.Address.initIp4(.{ 127, 0, 0, 1 }, port) catch return; + var server = addr.listen(.{ .reuse_address = true }) catch return; + defer server.deinit(); + while (true) { + const conn = server.accept() catch continue; + handleConnection(conn, proto); + } +} + +pub fn main() !void { + ffi.crates_init(); + const rest_thread = try std.Thread.spawn(.{}, listenLoop, .{ REST_PORT, Protocol.rest }); + const grpc_thread = try std.Thread.spawn(.{}, listenLoop, .{ GRPC_PORT, Protocol.grpc }); + const gql_thread = try std.Thread.spawn(.{}, listenLoop, .{ GQL_PORT, Protocol.graphql }); + rest_thread.join(); + grpc_thread.join(); + gql_thread.join(); +} diff --git a/cartridges/domains/registry/crates-mcp/benchmarks/quick-bench.sh b/cartridges/domains/registry/crates-mcp/benchmarks/quick-bench.sh new file mode 100755 index 0000000..4e912fd --- /dev/null +++ b/cartridges/domains/registry/crates-mcp/benchmarks/quick-bench.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Benchmarks for crates-mcp cartridge. +set -euo pipefail + +echo "=== crates-mcp benchmarks ===" + +cd "$(dirname "${BASH_SOURCE[0]}")/../ffi" + +# Build with ReleaseFast for benchmarking +zig build -Doptimize=ReleaseFast 2>&1 + +echo "Session open/close cycle (1000 iterations):" +time for i in $(seq 1 1000); do + # This would call the FFI benchmark binary + true +done + +echo "" +echo "Benchmark placeholder -- implement real benchmarks in Zig test blocks" +echo "or via the zig adapter HTTP benchmark tool." diff --git a/cartridges/domains/registry/crates-mcp/cartridge.json b/cartridges/domains/registry/crates-mcp/cartridge.json new file mode 100644 index 0000000..909a864 --- /dev/null +++ b/cartridges/domains/registry/crates-mcp/cartridge.json @@ -0,0 +1,286 @@ +{ + "$schema": "https://boj.dev/schemas/cartridge/v1.json", + "spdx": "MPL-2.0", + "copyright": "Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath)", + "name": "crates-mcp", + "version": "0.2.0", + "description": "crates.io registry cartridge -- Rust crate search, metadata retrieval, version listing, download stats, dependency analysis, owner management, category browsing, and reverse dependency lookup via the crates.io API", + "domain": "Registry", + "tier": "Ayo", + "protocols": [ + "MCP", + "REST" + ], + "auth": { + "method": "bearer_token", + "env_var": "CRATES_IO_TOKEN", + "credential_source": "vault-mcp" + }, + "api": { + "base_url": "https://crates.io/api/v1", + "content_type": "application/json", + "user_agent_required": true + }, + "tools": [ + { + "name": "crates_search", + "description": "Search crates.io for Rust crates by text query with optional category/keyword filters", + "inputSchema": { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "Search query (crate name, keywords, description)" + }, + "page": { + "type": "number", + "description": "Page number (default 1)" + }, + "per_page": { + "type": "number", + "description": "Results per page (default 10, max 100)" + }, + "sort": { + "type": "string", + "description": "Sort order: relevance, downloads, recent-downloads, recent-updates, new, alpha" + }, + "category": { + "type": "string", + "description": "Filter by category slug (e.g. 'web-programming', 'parsing')" + } + }, + "required": [ + "query" + ] + } + }, + { + "name": "crates_get_crate", + "description": "Get full metadata for a Rust crate (description, repository, homepage, categories, keywords, versions summary)", + "inputSchema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Crate name (e.g. 'serde', 'tokio', 'clap')" + } + }, + "required": [ + "name" + ] + } + }, + { + "name": "crates_get_version", + "description": "Get metadata for a specific version of a Rust crate (features, license, MSRV, checksum)", + "inputSchema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Crate name" + }, + "version": { + "type": "string", + "description": "Semver version (e.g. '1.0.210')" + } + }, + "required": [ + "name", + "version" + ] + } + }, + { + "name": "crates_list_versions", + "description": "List all published versions of a Rust crate with timestamps, downloads, and yanked status", + "inputSchema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Crate name" + } + }, + "required": [ + "name" + ] + } + }, + { + "name": "crates_get_downloads", + "description": "Get download statistics for a Rust crate (total and per-version counts)", + "inputSchema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Crate name" + } + }, + "required": [ + "name" + ] + } + }, + { + "name": "crates_get_dependencies", + "description": "Get the dependency list for a specific version of a Rust crate", + "inputSchema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Crate name" + }, + "version": { + "type": "string", + "description": "Version to inspect (e.g. '1.0.210')" + } + }, + "required": [ + "name", + "version" + ] + } + }, + { + "name": "crates_get_reverse_dependencies", + "description": "List crates that depend on a given crate (reverse dependency lookup)", + "inputSchema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Crate name" + }, + "page": { + "type": "number", + "description": "Page number (default 1)" + }, + "per_page": { + "type": "number", + "description": "Results per page (default 10, max 100)" + } + }, + "required": [ + "name" + ] + } + }, + { + "name": "crates_get_owners", + "description": "List all owners (users and teams) of a Rust crate on crates.io", + "inputSchema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Crate name" + } + }, + "required": [ + "name" + ] + } + }, + { + "name": "crates_list_categories", + "description": "List all available crate categories on crates.io", + "inputSchema": { + "type": "object", + "properties": { + "sort": { + "type": "string", + "description": "Sort order: alpha, crates (by crate count)" + } + } + } + }, + { + "name": "crates_get_category", + "description": "Get details and crate count for a specific category on crates.io", + "inputSchema": { + "type": "object", + "properties": { + "slug": { + "type": "string", + "description": "Category slug (e.g. 'web-programming', 'parsing', 'cryptography')" + } + }, + "required": [ + "slug" + ] + } + }, + { + "name": "crates_list_keywords", + "description": "List popular keywords on crates.io with crate counts", + "inputSchema": { + "type": "object", + "properties": { + "sort": { + "type": "string", + "description": "Sort order: alpha, crates (by crate count)" + }, + "page": { + "type": "number", + "description": "Page number" + }, + "per_page": { + "type": "number", + "description": "Results per page" + } + } + } + }, + { + "name": "crates_get_user", + "description": "Get profile information for a crates.io user by login name", + "inputSchema": { + "type": "object", + "properties": { + "login": { + "type": "string", + "description": "crates.io login name (GitHub username)" + } + }, + "required": [ + "login" + ] + } + }, + { + "name": "crates_get_features", + "description": "Get the feature flags for a specific version of a Rust crate", + "inputSchema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Crate name" + }, + "version": { + "type": "string", + "description": "Version to inspect" + } + }, + "required": [ + "name", + "version" + ] + } + } + ], + "ffi": { + "so_path": "ffi/zig-out/lib/libcrates_mcp.so", + "abi_version": "ADR-0006", + "symbols": [ + "boj_cartridge_init", + "boj_cartridge_deinit", + "boj_cartridge_name", + "boj_cartridge_version", + "boj_cartridge_invoke" + ] + } +} diff --git a/cartridges/domains/registry/crates-mcp/ffi/README.adoc b/cartridges/domains/registry/crates-mcp/ffi/README.adoc new file mode 100644 index 0000000..e484508 --- /dev/null +++ b/cartridges/domains/registry/crates-mcp/ffi/README.adoc @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += crates-mcp / ffi β€” Zig FFI layer +:orientation: shallow + +== Purpose + +C-ABI exports bridging this cartridge's Idris2 ABI to the runtime. Consumed by +the adapter for protocol dispatch and by the trunk loader for mount. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” produces `zig-out/lib/libcrates.so`. +| `crates_mcp_ffi.zig` | C-ABI exports (15 exports, 7 inline tests, 427 lines). +|=== + +== Invariants + +* **Null-pointer rejection** on `[*c]const u8` arguments before any + dereference. +* **Negative return values** carry documented error meanings; zero is + success (matching the trunk error-code convention). +* **No allocator leaks** β€” every allocation path pairs with `defer free`. + +== Test/proof surface + +7 inline `test "..."` block(s) in `crates_mcp_ffi.zig`. Run with +`cd ffi && zig build test`. + +== Read-first + +. `crates_mcp_ffi.zig` β€” C-ABI signatures and guards. +. Trunk `ffi/zig/src/catalogue.zig` for how this `.so` is loaded. diff --git a/cartridges/domains/registry/crates-mcp/ffi/build.zig b/cartridges/domains/registry/crates-mcp/ffi/build.zig new file mode 100644 index 0000000..d0aa965 --- /dev/null +++ b/cartridges/domains/registry/crates-mcp/ffi/build.zig @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// build.zig β€” Build configuration for crates-mcp FFI shared library and tests. + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + // Module + // Shared ADR-0006 invoke-shim module (relative path up to boj-server trunk). + + const shim_mod = b.addModule("cartridge_shim", .{ + + .root_source_file = b.path("../../../ffi/zig/src/cartridge_shim.zig"), + + .target = target, + + .optimize = optimize, + + }); + + const ffi_mod = b.addModule("crates_mcp", .{ + .root_source_file = b.path("crates_mcp_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + ffi_mod.addImport("cartridge_shim", shim_mod); + + // Shared library + const lib = b.addLibrary(.{ + .name = "crates_mcp", + .root_module = ffi_mod, + .linkage = .dynamic, + }); + lib.linkLibC(); + b.installArtifact(lib); + + // Tests + const tests = b.addTest(.{ + .root_module = ffi_mod, + }); + tests.linkLibC(); + + const run_tests = b.addRunArtifact(tests); + const test_step = b.step("test", "Run FFI tests"); + test_step.dependOn(&run_tests.step); +} diff --git a/cartridges/domains/registry/crates-mcp/ffi/cartridge_shim.zig b/cartridges/domains/registry/crates-mcp/ffi/cartridge_shim.zig new file mode 100644 index 0000000..0e399db --- /dev/null +++ b/cartridges/domains/registry/crates-mcp/ffi/cartridge_shim.zig @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// cartridge_shim.zig β€” Shared helpers for the ADR-0006 five-symbol +// cartridge ABI (`boj_cartridge_init / deinit / name / version / invoke`). +// +// The shim centralises the seven-code return convention, NUL-argument +// guards, tool-name comparison, and the buffer-too-small path so each +// cartridge's `boj_cartridge_invoke` can stay short β€” typically a tool +// table plus `shim.writeResult(...)`. +// +// Cartridges import this file by relative path (no build-graph change +// needed). Example: +// +// const shim = @import("../../../ffi/zig/src/cartridge_shim.zig"); +// +// export fn boj_cartridge_invoke( +// tool_name: [*c]const u8, +// json_args: [*c]const u8, +// out_buf: [*c]u8, +// in_out_len: [*c]usize, +// ) callconv(.c) i32 { +// _ = json_args; +// if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; +// const body = if (shim.toolIs(tool_name, "foo")) "{\"result\":{}}" +// else return shim.RC_UNKNOWN_TOOL; +// return shim.writeResult(out_buf, in_out_len, body); +// } + +const std = @import("std"); + +// ── Return codes (ADR-0006 Β§Return codes) ──────────────────────────── +// +// Frozen by ADR-0006. New failure modes compose these via the error +// JSON body β€” the integer surface does not grow without a follow-up ADR. + +pub const RC_SUCCESS: i32 = 0; +pub const RC_UNKNOWN_TOOL: i32 = -1; +pub const RC_BAD_ARGS: i32 = -2; +pub const RC_BUFFER_TOO_SMALL: i32 = -3; +pub const RC_RUNTIME_ERROR: i32 = -4; +pub const RC_PANIC: i32 = -5; +pub const RC_AUTH_DENIED: i32 = -6; + +// ── Invoke-path helpers ────────────────────────────────────────────── + +/// True if any of the three mandatory `boj_cartridge_invoke` output-path +/// pointers is null. Use at the top of every invoke to short-circuit to +/// `RC_BAD_ARGS`. +pub fn invokeArgsNull( + tool_name: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) bool { + return tool_name == null or out_buf == null or in_out_len == null; +} + +/// Compare a C-NUL-terminated tool-name pointer against a Zig string +/// literal. Caller must have already verified `tool_name` is non-null +/// (usually via `invokeArgsNull`). +pub fn toolIs(tool_name: [*c]const u8, expected: []const u8) bool { + const s = std.mem.span(@as([*:0]const u8, @ptrCast(tool_name))); + return std.mem.eql(u8, s, expected); +} + +/// Copy `body` into `out_buf[0..*in_out_len]` (as a capacity) and update +/// `*in_out_len` to the number of bytes written. Returns `RC_SUCCESS`. +/// +/// If `body.len` exceeds the current capacity stored in `*in_out_len`, +/// sets `*in_out_len` to the required size and returns +/// `RC_BUFFER_TOO_SMALL` β€” the caller is then expected to re-allocate +/// and retry, per ADR-0006 Β§Memory ownership. +/// +/// Caller must have already verified that `out_buf` and `in_out_len` +/// are non-null. +pub fn writeResult( + out_buf: [*c]u8, + in_out_len: [*c]usize, + body: []const u8, +) i32 { + const cap = in_out_len.*; + if (body.len > cap) { + in_out_len.* = body.len; + return RC_BUFFER_TOO_SMALL; + } + @memcpy(out_buf[0..body.len], body); + in_out_len.* = body.len; + return RC_SUCCESS; +} + +// ── Tests ──────────────────────────────────────────────────────────── + +test "writeResult: body fits, writes and sets length" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); + try std.testing.expectEqualStrings("hello", buf[0..len]); +} + +test "writeResult: too small returns -3 and sets required length" { + var buf: [2]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_BUFFER_TOO_SMALL, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: exact-fit succeeds" { + var buf: [5]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: empty body" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, ""); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 0), len); +} + +test "toolIs: matches and rejects" { + const name: [*:0]const u8 = "foo"; + try std.testing.expect(toolIs(@ptrCast(name), "foo")); + try std.testing.expect(!toolIs(@ptrCast(name), "bar")); + try std.testing.expect(!toolIs(@ptrCast(name), "foobar")); + try std.testing.expect(!toolIs(@ptrCast(name), "fo")); +} + +test "invokeArgsNull: detects each null slot" { + var buf: [4]u8 = undefined; + var len: usize = 4; + const name: [*:0]const u8 = "x"; + try std.testing.expect(!invokeArgsNull(@ptrCast(name), &buf, &len)); + try std.testing.expect(invokeArgsNull(null, &buf, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), null, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), &buf, null)); +} diff --git a/cartridges/domains/registry/crates-mcp/ffi/crates_mcp_ffi.zig b/cartridges/domains/registry/crates-mcp/ffi/crates_mcp_ffi.zig new file mode 100644 index 0000000..fd23a46 --- /dev/null +++ b/cartridges/domains/registry/crates-mcp/ffi/crates_mcp_ffi.zig @@ -0,0 +1,545 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// crates_mcp_ffi.zig β€” C-ABI FFI implementation for crates-mcp cartridge. +// +// Implements the state machine defined in CratesMcp.SafeRegistry (Idris2 ABI). +// State machine: Unauthenticated | Authenticated | RateLimited | Error +// Auth: Optional Bearer token β€” most crates.io reads are public. +// REST API: https://crates.io/api/v1 +// Actions: SearchCrates, GetCrate, GetVersion, ListVersions, GetDownloads, +// GetDependencies, GetReverseDependencies, GetOwners, ListCategories, +// GetCategory, ListKeywords, GetUser, GetFeatures +// Thread-safe via std.Thread.Mutex. Fixed-size session pool, no heap allocations. + +const std = @import("std"); + +// --------------------------------------------------------------------------- +// State machine (matches Idris2 ABI SessionState exactly) +// --------------------------------------------------------------------------- + +/// Session authentication/lifecycle state. +/// 0 = Unauthenticated, 1 = Authenticated, 2 = RateLimited, 3 = Error. +pub const SessionState = enum(c_int) { + unauthenticated = 0, + authenticated = 1, + rate_limited = 2, + err = 3, +}; + +/// crates.io action identifiers matching Idris2 CratesAction encoding. +pub const CratesAction = enum(c_int) { + search_crates = 0, + get_crate = 1, + get_version = 2, + list_versions = 3, + get_downloads = 4, + get_dependencies = 5, + get_reverse_dependencies = 6, + get_owners = 7, + list_categories = 8, + get_category = 9, + list_keywords = 10, + get_user = 11, + get_features = 12, +}; + +/// Check valid state transitions per the Idris2 ValidTransition proof. +/// crates.io allows both authenticated and unauthenticated sessions, +/// so transitions mirror the npm registry pattern. +fn isValidTransition(from: SessionState, to: SessionState) bool { + return switch (from) { + .unauthenticated => to == .authenticated or to == .rate_limited or to == .err, + .authenticated => to == .unauthenticated or to == .rate_limited or to == .err, + .rate_limited => to == .authenticated or to == .unauthenticated, + .err => to == .authenticated or to == .unauthenticated, + }; +} + +// --------------------------------------------------------------------------- +// Session slots (thread-safe, fixed-size pool) +// --------------------------------------------------------------------------- + +const MAX_SESSIONS: usize = 16; + +const SessionSlot = struct { + active: bool = false, + state: SessionState = .unauthenticated, + api_call_count: u64 = 0, + last_action: c_int = -1, + search_count: u32 = 0, + crate_lookups: u32 = 0, + dep_queries: u32 = 0, +}; + +var sessions: [MAX_SESSIONS]SessionSlot = .{SessionSlot{}} ** MAX_SESSIONS; +var mutex: std.Thread.Mutex = .{}; + +// --------------------------------------------------------------------------- +// C-ABI exports β€” state machine +// --------------------------------------------------------------------------- + +/// Check if a state transition is valid. Returns 1 (valid) or 0 (invalid). +pub export fn crates_mcp_can_transition(from: c_int, to: c_int) c_int { + const f = std.meta.intToEnum(SessionState, from) catch return 0; + const t = std.meta.intToEnum(SessionState, to) catch return 0; + return if (isValidTransition(f, t)) 1 else 0; +} + +/// Open an authenticated session. Returns slot index (>= 0) or error (< 0). +/// Error codes: -1 = no free slots. +pub export fn crates_mcp_authenticate(dummy: c_int) c_int { + _ = dummy; + mutex.lock(); + defer mutex.unlock(); + + for (&sessions, 0..) |*slot, idx| { + if (!slot.active) { + slot.active = true; + slot.state = .authenticated; + slot.api_call_count = 0; + slot.last_action = -1; + slot.search_count = 0; + slot.crate_lookups = 0; + slot.dep_queries = 0; + return @intCast(idx); + } + } + return -1; +} + +/// Open an unauthenticated session (read-only public access). +/// Returns slot index (>= 0) or error (< 0). +pub export fn crates_mcp_open_anonymous(dummy: c_int) c_int { + _ = dummy; + mutex.lock(); + defer mutex.unlock(); + + for (&sessions, 0..) |*slot, idx| { + if (!slot.active) { + slot.active = true; + slot.state = .unauthenticated; + slot.api_call_count = 0; + slot.last_action = -1; + slot.search_count = 0; + slot.crate_lookups = 0; + slot.dep_queries = 0; + return @intCast(idx); + } + } + return -1; +} + +/// Close a session. Returns 0 on success. +/// Error codes: -1 = invalid slot. +pub export fn crates_mcp_close(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + + sessions[idx] = SessionSlot{}; + return 0; +} + +/// Get current state of a session. Returns state int or -1 if invalid. +pub export fn crates_mcp_session_state(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + return @intFromEnum(slot.state); +} + +/// Signal rate limiting on a session (429 from crates.io). Returns 0 on success. +pub export fn crates_mcp_throttle(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + if (!isValidTransition(slot.state, .rate_limited)) return -2; + + sessions[idx].state = .rate_limited; + return 0; +} + +/// Clear rate limiting. Returns 0 on success. +pub export fn crates_mcp_unthrottle(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + if (!isValidTransition(slot.state, .authenticated) and !isValidTransition(slot.state, .unauthenticated)) return -2; + + sessions[idx].state = .authenticated; + return 0; +} + +/// Signal an error on a session. Returns 0 on success. +pub export fn crates_mcp_signal_error(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + if (!isValidTransition(slot.state, .err)) return -2; + + sessions[idx].state = .err; + return 0; +} + +// --------------------------------------------------------------------------- +// C-ABI exports β€” action recording and metrics +// --------------------------------------------------------------------------- + +/// Record an API call on a session. Returns 0 on success. +/// Error codes: -1 = invalid slot, -2 = rate limited/error state, -3 = invalid action. +pub export fn crates_mcp_record_call(slot_idx: c_int, action: c_int) c_int { + const act = std.meta.intToEnum(CratesAction, action) catch return -3; + + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + if (slot.state == .rate_limited) return -2; + if (slot.state == .err) return -2; + + sessions[idx].api_call_count += 1; + sessions[idx].last_action = action; + + // Track category-specific counts + switch (act) { + .search_crates => sessions[idx].search_count += 1, + .get_crate, .get_version, .list_versions, .get_features => sessions[idx].crate_lookups += 1, + .get_dependencies, .get_reverse_dependencies => sessions[idx].dep_queries += 1, + else => {}, + } + + return 0; +} + +/// Get API call count for a session. Returns count or -1 if invalid. +pub export fn crates_mcp_call_count(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + return @intCast(slot.api_call_count); +} + +/// Get search query count. Returns count or -1 if invalid. +pub export fn crates_mcp_search_count(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + return @intCast(slot.search_count); +} + +/// Get crate metadata lookup count. Returns count or -1 if invalid. +pub export fn crates_mcp_crate_lookup_count(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + return @intCast(slot.crate_lookups); +} + +/// Get dependency query count. Returns count or -1 if invalid. +pub export fn crates_mcp_dep_query_count(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + return @intCast(slot.dep_queries); +} + +/// Get total action count. Always returns 13. +pub export fn crates_mcp_action_count() c_int { + return 13; +} + +/// Reset all sessions (test/debug use only). +pub export fn crates_mcp_reset() void { + mutex.lock(); + defer mutex.unlock(); + sessions = .{SessionSlot{}} ** MAX_SESSIONS; +} + +// --------------------------------------------------------------------------- +// ═══════════════════════════════════════════════════════════════════════ +// Standard ABI (ADR-0005 four symbols + ADR-0006 invoke) +// ═══════════════════════════════════════════════════════════════════════ + +const shim = @import("cartridge_shim.zig"); + +const CARTRIDGE_NAME_PTR: [*:0]const u8 = "crates-mcp"; +const CARTRIDGE_VERSION_PTR: [*:0]const u8 = "0.1.0"; + +export fn boj_cartridge_init() callconv(.c) c_int { + return 0; +} + +export fn boj_cartridge_deinit() callconv(.c) void {} + +export fn boj_cartridge_name() callconv(.c) [*:0]const u8 { + return CARTRIDGE_NAME_PTR; +} + +export fn boj_cartridge_version() callconv(.c) [*:0]const u8 { + return CARTRIDGE_VERSION_PTR; +} + +/// Dispatch the cartridge.json MCP tools. Grade D Alpha stubs. +export fn boj_cartridge_invoke( + tool_name: [*c]const u8, + json_args: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) callconv(.c) i32 { + _ = json_args; + if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; + + const body: []const u8 = if (shim.toolIs(tool_name, "crates_search")) + "{\"result\":{\"matches\":[],\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "crates_get_crate")) + "{\"result\":{\"metadata\":{},\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "crates_get_version")) + "{\"result\":{\"metadata\":{},\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "crates_list_versions")) + "{\"result\":{\"items\":[],\"count\":0,\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "crates_get_downloads")) + "{\"result\":{\"metadata\":{},\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "crates_get_dependencies")) + "{\"result\":{\"metadata\":{},\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "crates_get_reverse_dependencies")) + "{\"result\":{\"metadata\":{},\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "crates_get_owners")) + "{\"result\":{\"metadata\":{},\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "crates_list_categories")) + "{\"result\":{\"items\":[],\"count\":0,\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "crates_get_category")) + "{\"result\":{\"metadata\":{},\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "crates_list_keywords")) + "{\"result\":{\"items\":[],\"count\":0,\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "crates_get_user")) + "{\"result\":{\"metadata\":{},\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "crates_get_features")) + "{\"result\":{\"metadata\":{},\"status\":\"stub\"}}" +else + return shim.RC_UNKNOWN_TOOL; + + return shim.writeResult(out_buf, in_out_len, body); +} + +// Tests +// --------------------------------------------------------------------------- + +test "authenticated session lifecycle" { + crates_mcp_reset(); + + const slot = crates_mcp_authenticate(0); + try std.testing.expect(slot >= 0); + + // Should be authenticated (1) + try std.testing.expectEqual(@as(c_int, 1), crates_mcp_session_state(slot)); + + // Record a search call (action 0) + try std.testing.expectEqual(@as(c_int, 0), crates_mcp_record_call(slot, 0)); + try std.testing.expectEqual(@as(c_int, 1), crates_mcp_call_count(slot)); + try std.testing.expectEqual(@as(c_int, 1), crates_mcp_search_count(slot)); + + // Close + try std.testing.expectEqual(@as(c_int, 0), crates_mcp_close(slot)); +} + +test "anonymous session lifecycle" { + crates_mcp_reset(); + + const slot = crates_mcp_open_anonymous(0); + try std.testing.expect(slot >= 0); + + // Should be unauthenticated (0) + try std.testing.expectEqual(@as(c_int, 0), crates_mcp_session_state(slot)); + + // Record a crate lookup (action 1) + try std.testing.expectEqual(@as(c_int, 0), crates_mcp_record_call(slot, 1)); + try std.testing.expectEqual(@as(c_int, 1), crates_mcp_crate_lookup_count(slot)); + + // Close + try std.testing.expectEqual(@as(c_int, 0), crates_mcp_close(slot)); +} + +test "rate limiting flow (429 handling)" { + crates_mcp_reset(); + + const slot = crates_mcp_authenticate(0); + try std.testing.expect(slot >= 0); + + // Throttle (simulating 429 from crates.io) + try std.testing.expectEqual(@as(c_int, 0), crates_mcp_throttle(slot)); + try std.testing.expectEqual(@as(c_int, 2), crates_mcp_session_state(slot)); + + // Cannot invoke while rate limited + try std.testing.expectEqual(@as(c_int, -2), crates_mcp_record_call(slot, 0)); + + // Unthrottle + try std.testing.expectEqual(@as(c_int, 0), crates_mcp_unthrottle(slot)); + try std.testing.expectEqual(@as(c_int, 1), crates_mcp_session_state(slot)); +} + +test "error and recovery" { + crates_mcp_reset(); + + const slot = crates_mcp_authenticate(0); + try std.testing.expect(slot >= 0); + + // Signal error + try std.testing.expectEqual(@as(c_int, 0), crates_mcp_signal_error(slot)); + try std.testing.expectEqual(@as(c_int, 3), crates_mcp_session_state(slot)); + + // Cannot invoke in error state + try std.testing.expectEqual(@as(c_int, -2), crates_mcp_record_call(slot, 0)); + + // Close (recover) + try std.testing.expectEqual(@as(c_int, 0), crates_mcp_close(slot)); +} + +test "category counting" { + crates_mcp_reset(); + + const slot = crates_mcp_authenticate(0); + try std.testing.expect(slot >= 0); + + // Search (0) + try std.testing.expectEqual(@as(c_int, 0), crates_mcp_record_call(slot, 0)); + // GetCrate (1) + try std.testing.expectEqual(@as(c_int, 0), crates_mcp_record_call(slot, 1)); + // GetVersion (2) + try std.testing.expectEqual(@as(c_int, 0), crates_mcp_record_call(slot, 2)); + // GetDependencies (5) + try std.testing.expectEqual(@as(c_int, 0), crates_mcp_record_call(slot, 5)); + // GetReverseDependencies (6) + try std.testing.expectEqual(@as(c_int, 0), crates_mcp_record_call(slot, 6)); + + try std.testing.expectEqual(@as(c_int, 5), crates_mcp_call_count(slot)); + try std.testing.expectEqual(@as(c_int, 1), crates_mcp_search_count(slot)); + try std.testing.expectEqual(@as(c_int, 2), crates_mcp_crate_lookup_count(slot)); + try std.testing.expectEqual(@as(c_int, 2), crates_mcp_dep_query_count(slot)); +} + +test "transition validator" { + // Unauthenticated -> Authenticated + try std.testing.expectEqual(@as(c_int, 1), crates_mcp_can_transition(0, 1)); + // Authenticated -> Unauthenticated + try std.testing.expectEqual(@as(c_int, 1), crates_mcp_can_transition(1, 0)); + // Authenticated -> RateLimited + try std.testing.expectEqual(@as(c_int, 1), crates_mcp_can_transition(1, 2)); + // Unauthenticated -> RateLimited + try std.testing.expectEqual(@as(c_int, 1), crates_mcp_can_transition(0, 2)); + // RateLimited -> Authenticated + try std.testing.expectEqual(@as(c_int, 1), crates_mcp_can_transition(2, 1)); + // Error -> Unauthenticated + try std.testing.expectEqual(@as(c_int, 1), crates_mcp_can_transition(3, 0)); + + // Invalid: RateLimited -> Error + try std.testing.expectEqual(@as(c_int, 0), crates_mcp_can_transition(2, 3)); +} + +test "slot exhaustion" { + crates_mcp_reset(); + + var slots: [MAX_SESSIONS]c_int = undefined; + for (&slots) |*s| { + s.* = crates_mcp_authenticate(0); + try std.testing.expect(s.* >= 0); + } + + try std.testing.expectEqual(@as(c_int, -1), crates_mcp_authenticate(0)); + + try std.testing.expectEqual(@as(c_int, 0), crates_mcp_close(slots[0])); + const new_slot = crates_mcp_authenticate(0); + try std.testing.expect(new_slot >= 0); +} + +// ═══════════════════════════════════════════════════════════════════════ +// ADR-0006 invoke dispatch tests +// ═══════════════════════════════════════════════════════════════════════ + +test "boj_cartridge_name returns crates-mcp" { + const n = std.mem.span(boj_cartridge_name()); + try std.testing.expectEqualStrings("crates-mcp", n); +} + +test "boj_cartridge_init returns 0" { + try std.testing.expectEqual(@as(c_int, 0), boj_cartridge_init()); +} + +test "invoke: each declared tool succeeds" { + var buf: [256]u8 = undefined; + const tools = [_][]const u8{ + "crates_search", + "crates_get_crate", + "crates_get_version", + "crates_list_versions", + "crates_get_downloads", + "crates_get_dependencies", + "crates_get_reverse_dependencies", + "crates_get_owners", + "crates_list_categories", + "crates_get_category", + "crates_list_keywords", + "crates_get_user", + "crates_get_features", + }; + for (tools) |t| { + var len: usize = buf.len; + const rc = boj_cartridge_invoke(t.ptr, "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, 0), rc); + try std.testing.expect(std.mem.indexOf(u8, buf[0..len], "result") != null); + } +} + +test "invoke: unknown tool returns -1" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("nope", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -1), rc); +} + +test "invoke: buffer too small returns -3" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("crates_search", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -3), rc); + try std.testing.expect(len > 4); +} diff --git a/cartridges/domains/registry/crates-mcp/minter.toml b/cartridges/domains/registry/crates-mcp/minter.toml new file mode 100644 index 0000000..754bac1 --- /dev/null +++ b/cartridges/domains/registry/crates-mcp/minter.toml @@ -0,0 +1,23 @@ +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> + +name = "crates-mcp" +description = "crates.io registry cartridge β€” Rust crate search, metadata, version listing, download stats, dependencies, reverse deps, owners, categories, keywords" +version = "0.2.0" +domain = "Registry" +protocols = [ + "MCP", + "REST", +] +tier = "Ayo" +backend = "universal" +generate_panel = true + +[auth] +method = "bearer_token" +credential_source = "vault-mcp" +fields = ["crates_io_token"] + +[api] +base_url = "https://crates.io/api/v1" +user_agent_required = true diff --git a/cartridges/domains/registry/crates-mcp/mod.js b/cartridges/domains/registry/crates-mcp/mod.js new file mode 100644 index 0000000..76314c5 --- /dev/null +++ b/cartridges/domains/registry/crates-mcp/mod.js @@ -0,0 +1,245 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// crates-mcp/mod.js -- crates.io registry cartridge implementation. +// +// Provides MCP tool handlers for the crates.io REST API v1: +// - Crate search (text query, category/keyword filters, sorting) +// - Crate metadata (full info, specific versions, features) +// - Version listing with timestamps and yanked status +// - Download statistics (total and per-version) +// - Dependency tree inspection for specific versions +// - Reverse dependency lookup (who depends on this crate?) +// - Owner listing (users and teams) +// - Category and keyword browsing +// - User profile lookup +// +// Auth: Optional Bearer token via CRATES_IO_TOKEN. Read-only operations +// are fully public. Token required only for owner management. +// API docs: https://crates.io/policies +// User-Agent: Required by crates.io policy. +// +// Usage: import { handleTool } from "./mod.js"; +// or: deno run --allow-net --allow-env mod.js + +const API_BASE = "https://crates.io/api/v1"; + +// --------------------------------------------------------------------------- +// Auth helper β€” retrieves the crates.io auth token from environment. +// In production, vault-mcp provides zero-knowledge credential proxying; +// for development, CRATES_IO_TOKEN is read directly. Optional for reads. +// --------------------------------------------------------------------------- + +function getToken() { + const token = typeof Deno !== "undefined" + ? Deno.env.get("CRATES_IO_TOKEN") + : process.env.CRATES_IO_TOKEN; + return token || null; +} + +// --------------------------------------------------------------------------- +// HTTP request helper β€” wraps fetch with crates.io headers, User-Agent +// (required by policy), error handling, and pagination forwarding. +// --------------------------------------------------------------------------- + +async function cratesFetch(path, queryParams) { + const url = new URL(`${API_BASE}${path}`); + + // Append query parameters (pagination, filters, sorting) + if (queryParams) { + for (const [key, value] of Object.entries(queryParams)) { + if (value !== undefined && value !== null && value !== "") { + url.searchParams.set(key, String(value)); + } + } + } + + const headers = { + "Accept": "application/json", + // crates.io requires a descriptive User-Agent per their crawling policy + "User-Agent": "boj-server/crates-mcp/0.2.0 (https://github.com/hyperpolymath/boj-server)", + }; + + const token = getToken(); + if (token) { + headers["Authorization"] = token; + } + + const response = await fetch(url.toString(), { method: "GET", headers }); + + // Handle rate limiting (crates.io returns 429) + if (response.status === 429) { + const retryAfter = response.headers.get("retry-after"); + return { + status: 429, + error: `Rate limited by crates.io. Retry after ${retryAfter || "unknown"} seconds.`, + retryAfter, + }; + } + + const data = await response.json().catch(() => ({})); + + if (!response.ok) { + const errorMessage = data.errors + ? data.errors.map((e) => e.detail).join("; ") + : `HTTP ${response.status}`; + return { status: response.status, error: errorMessage, data }; + } + + return { status: response.status, data }; +} + +// --------------------------------------------------------------------------- +// Tool handler dispatch β€” maps MCP tool names to crates.io API operations. +// Each handler validates required arguments, builds the API request, +// and returns structured results. +// --------------------------------------------------------------------------- + +export async function handleTool(toolName, args) { + switch (toolName) { + + // --- Search --- + + case "crates_search": { + if (!args.query) return { error: "Missing required field: query" }; + const query = { + q: args.query, + page: args.page, + per_page: args.per_page, + sort: args.sort, + category: args.category, + }; + return cratesFetch("/crates", query); + } + + // --- Crate metadata --- + + case "crates_get_crate": { + if (!args.name) return { error: "Missing required field: name" }; + return cratesFetch(`/crates/${encodeURIComponent(args.name)}`); + } + + case "crates_get_version": { + if (!args.name) return { error: "Missing required field: name" }; + if (!args.version) return { error: "Missing required field: version" }; + return cratesFetch(`/crates/${encodeURIComponent(args.name)}/${args.version}`); + } + + case "crates_list_versions": { + if (!args.name) return { error: "Missing required field: name" }; + const result = await cratesFetch(`/crates/${encodeURIComponent(args.name)}/versions`); + if (result.error) return result; + return { + status: result.status, + data: { + name: args.name, + versions: (result.data.versions || []).map((v) => ({ + num: v.num, + created_at: v.created_at, + downloads: v.downloads, + yanked: v.yanked, + license: v.license, + rust_version: v.rust_version, + })), + }, + }; + } + + // --- Downloads --- + + case "crates_get_downloads": { + if (!args.name) return { error: "Missing required field: name" }; + return cratesFetch(`/crates/${encodeURIComponent(args.name)}/downloads`); + } + + // --- Dependencies --- + + case "crates_get_dependencies": { + if (!args.name) return { error: "Missing required field: name" }; + if (!args.version) return { error: "Missing required field: version" }; + return cratesFetch(`/crates/${encodeURIComponent(args.name)}/${args.version}/dependencies`); + } + + // --- Reverse dependencies --- + + case "crates_get_reverse_dependencies": { + if (!args.name) return { error: "Missing required field: name" }; + const query = { + page: args.page, + per_page: args.per_page, + }; + return cratesFetch(`/crates/${encodeURIComponent(args.name)}/reverse_dependencies`, query); + } + + // --- Owners --- + + case "crates_get_owners": { + if (!args.name) return { error: "Missing required field: name" }; + return cratesFetch(`/crates/${encodeURIComponent(args.name)}/owners`); + } + + // --- Categories --- + + case "crates_list_categories": { + const query = { sort: args.sort }; + return cratesFetch("/categories", query); + } + + case "crates_get_category": { + if (!args.slug) return { error: "Missing required field: slug" }; + return cratesFetch(`/categories/${encodeURIComponent(args.slug)}`); + } + + // --- Keywords --- + + case "crates_list_keywords": { + const query = { + sort: args.sort, + page: args.page, + per_page: args.per_page, + }; + return cratesFetch("/keywords", query); + } + + // --- Users --- + + case "crates_get_user": { + if (!args.login) return { error: "Missing required field: login" }; + return cratesFetch(`/users/${encodeURIComponent(args.login)}`); + } + + // --- Features --- + + case "crates_get_features": { + if (!args.name) return { error: "Missing required field: name" }; + if (!args.version) return { error: "Missing required field: version" }; + const result = await cratesFetch(`/crates/${encodeURIComponent(args.name)}/${args.version}`); + if (result.error) return result; + return { + status: result.status, + data: { + name: args.name, + version: args.version, + features: result.data.version?.features || {}, + }, + }; + } + + default: + return { error: `Unknown crates-mcp tool: ${toolName}` }; + } +} + +// --------------------------------------------------------------------------- +// Cartridge metadata export β€” used by the BoJ cartridge loader to register +// this cartridge's tools without reading cartridge.json separately. +// --------------------------------------------------------------------------- + +export const metadata = { + name: "crates-mcp", + version: "0.2.0", + domain: "Registry", + tier: "Ayo", + protocols: ["MCP", "REST"], + toolCount: 13, +}; diff --git a/cartridges/domains/registry/crates-mcp/panels/manifest.json b/cartridges/domains/registry/crates-mcp/panels/manifest.json new file mode 100644 index 0000000..b4cb18c --- /dev/null +++ b/cartridges/domains/registry/crates-mcp/panels/manifest.json @@ -0,0 +1,90 @@ +{ + "$schema": "https://panll.dev/schemas/panel-manifest/v1.json", + "spdx": "MPL-2.0", + "copyright": "Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath)", + "cartridge": "crates-mcp", + "domain": "Registry", + "version": "0.2.0", + "panels": [ + { + "id": "crates-auth-status", + "title": "Auth Status", + "description": "crates.io session state (Unauthenticated / Authenticated / RateLimited / Error)", + "type": "status-indicator", + "data_source": { + "endpoint": "/crates/state", + "method": "GET", + "refresh_interval_ms": 5000 + }, + "widgets": [ + { + "type": "state-badge", + "field": "state", + "states": { + "unauthenticated": { "color": "#95a5a6", "icon": "user-x" }, + "authenticated": { "color": "#2ecc71", "icon": "user-check" }, + "rate_limited": { "color": "#f39c12", "icon": "clock" }, + "error": { "color": "#e74c3c", "icon": "alert-triangle" } + } + } + ] + }, + { + "id": "crates-search-count", + "title": "Search Queries", + "description": "Number of crate search queries executed in the current session", + "type": "metric", + "data_source": { + "endpoint": "/crates/status", + "method": "GET", + "refresh_interval_ms": 10000 + }, + "widgets": [ + { + "type": "counter", + "field": "search_count", + "label": "Searches", + "icon": "search" + } + ] + }, + { + "id": "crates-lookup-count", + "title": "Crate Lookups", + "description": "Number of crate metadata queries executed in the current session", + "type": "metric", + "data_source": { + "endpoint": "/crates/status", + "method": "GET", + "refresh_interval_ms": 10000 + }, + "widgets": [ + { + "type": "counter", + "field": "crate_lookups", + "label": "Crate Lookups", + "icon": "package" + } + ] + }, + { + "id": "crates-dep-queries", + "title": "Dependency Queries", + "description": "Number of dependency and reverse dependency queries in the current session", + "type": "metric", + "data_source": { + "endpoint": "/crates/status", + "method": "GET", + "refresh_interval_ms": 10000 + }, + "widgets": [ + { + "type": "counter", + "field": "dep_queries", + "label": "Dep Queries", + "icon": "git-branch" + } + ] + } + ] +} diff --git a/cartridges/domains/registry/crates-mcp/tests/integration_test.sh b/cartridges/domains/registry/crates-mcp/tests/integration_test.sh new file mode 100755 index 0000000..787e431 --- /dev/null +++ b/cartridges/domains/registry/crates-mcp/tests/integration_test.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Integration tests for crates-mcp cartridge. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CART_DIR="$(dirname "$SCRIPT_DIR")" +FFI_DIR="$CART_DIR/ffi" + +echo "=== crates-mcp integration tests ===" + +# Build FFI +echo "[1/3] Building FFI..." +cd "$FFI_DIR" && zig build 2>&1 + +# Run Zig unit tests +echo "[2/3] Running FFI unit tests..." +cd "$FFI_DIR" && zig build test 2>&1 + +# Validate Idris2 ABI (if idris2 available) +echo "[3/3] Checking ABI..." +if command -v idris2 &>/dev/null; then + cd "$CART_DIR/abi" && idris2 --check CratesMcp.SafeRegistry 2>&1 + echo " ABI: OK" +else + echo " ABI: SKIPPED (idris2 not in PATH)" +fi + +echo "" +echo "All tests passed for crates-mcp!" diff --git a/cartridges/domains/registry/hackage-mcp/README.adoc b/cartridges/domains/registry/hackage-mcp/README.adoc new file mode 100644 index 0000000..e94ba29 --- /dev/null +++ b/cartridges/domains/registry/hackage-mcp/README.adoc @@ -0,0 +1,95 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += hackage-mcp +Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk> +:spdx: MPL-2.0 +:tier: Ayo +:domain: Registry +:protocols: MCP, REST + +== Overview + +Hackage registry cartridge for the BoJ server. Provides type-safe access to +the Hackage API for Haskell package search, metadata retrieval, version listing, +download statistics, dependency analysis, reverse dependency lookup, maintainer +listing, deprecation status checks, raw .cabal file retrieval, and user profile +lookup. + +=== State Machine + +`Unauthenticated -> Authenticated` (optional, all reads work without auth) + +`Authenticated -> RateLimited -> Authenticated` (normal flow, 429 handling) + +`Authenticated -> Error -> Unauthenticated` (error recovery) + +=== Actions (12) + +[cols="1,1"] +|=== +| Category | Actions + +| Search +| SearchPackages + +| Package Metadata +| GetPackage, GetVersion, ListVersions + +| Downloads +| GetDownloads + +| Dependencies +| GetDependencies, GetReverseDependencies + +| Maintainers +| GetMaintainers + +| Status +| GetDeprecated + +| Raw Files +| GetCabalFile + +| Listing +| ListAllPackages + +| Users +| GetUser +|=== + +== Architecture + +[cols="1,1,2"] +|=== +| Layer | Language | Purpose + +| ABI +| Idris2 +| Formally verified state machine with dependent-type proofs (HackageMcp.SafeRegistry) + +| FFI +| Zig +| C-compatible implementation with thread-safe session pool, action recording, category counting + +| Adapter +| zig +| REST bridge to BoJ unified adapter protocol +|=== + +== Building + +[source,bash] +---- +# Build FFI shared library +cd ffi && zig build + +# Run FFI tests +cd ffi && zig build test + +# Type-check ABI +cd abi && idris2 --check HackageMcp.SafeRegistry +---- + +== Status + +Development -- customised with Hackage-specific search, reverse deps, deprecation, cabal file, and user APIs. diff --git a/cartridges/domains/registry/hackage-mcp/abi/HackageMcp/SafeRegistry.idr b/cartridges/domains/registry/hackage-mcp/abi/HackageMcp/SafeRegistry.idr new file mode 100644 index 0000000..2ac9038 --- /dev/null +++ b/cartridges/domains/registry/hackage-mcp/abi/HackageMcp/SafeRegistry.idr @@ -0,0 +1,188 @@ +-- SPDX-License-Identifier: MPL-2.0 +-- Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +-- +-- HackageMcp.SafeRegistry β€” Type-safe ABI for hackage-mcp cartridge. +-- +-- Dependent-type state machine governing Hackage API access. +-- Encodes optional Basic auth, package search, metadata retrieval, +-- version listing, download stats, dependency analysis, reverse deps, +-- maintainer listing, deprecation checks, cabal file retrieval, +-- and user profile lookup as compile-time invariants. +-- REST API: https://hackage.haskell.org +-- No unsafe escape hatches. + +module HackageMcp.SafeRegistry + +%default total + +-- --------------------------------------------------------------------------- +-- Authentication state machine +-- --------------------------------------------------------------------------- + +||| Session state for Hackage MCP operations. +||| Unauthenticated: no credentials; read-only operations available. +||| Authenticated: Hackage credentials active, full access. +||| RateLimited: Hackage rate limit hit (429); must wait. +||| Error: unrecoverable error (invalid credentials, network failure). +public export +data SessionState + = Unauthenticated + | Authenticated + | RateLimited + | Error + +||| Proof that a state transition is valid. +||| Hackage allows both authenticated and unauthenticated sessions. +public export +data ValidTransition : SessionState -> SessionState -> Type where + Authenticate : ValidTransition Unauthenticated Authenticated + Deauthenticate : ValidTransition Authenticated Unauthenticated + Throttle : ValidTransition Authenticated RateLimited + ThrottleAnon : ValidTransition Unauthenticated RateLimited + Unthrottle : ValidTransition RateLimited Authenticated + UnthrottleAnon : ValidTransition RateLimited Unauthenticated + AuthError : ValidTransition Authenticated Error + AnonError : ValidTransition Unauthenticated Error + RecoverToAuth : ValidTransition Error Authenticated + RecoverToAnon : ValidTransition Error Unauthenticated + +-- --------------------------------------------------------------------------- +-- C-ABI integer encoding +-- --------------------------------------------------------------------------- + +||| Encode session state as C-compatible integer for FFI boundary. +export +sessionStateToInt : SessionState -> Int +sessionStateToInt Unauthenticated = 0 +sessionStateToInt Authenticated = 1 +sessionStateToInt RateLimited = 2 +sessionStateToInt Error = 3 + +||| Decode integer back to session state. Returns Nothing for out-of-range. +export +intToSessionState : Int -> Maybe SessionState +intToSessionState 0 = Just Unauthenticated +intToSessionState 1 = Just Authenticated +intToSessionState 2 = Just RateLimited +intToSessionState 3 = Just Error +intToSessionState _ = Nothing + +||| Check if a state transition is valid (C-ABI export). +export +hackage_mcp_can_transition : Int -> Int -> Int +hackage_mcp_can_transition from to = + case (intToSessionState from, intToSessionState to) of + (Just Unauthenticated, Just Authenticated) => 1 + (Just Authenticated, Just Unauthenticated) => 1 + (Just Authenticated, Just RateLimited) => 1 + (Just Unauthenticated, Just RateLimited) => 1 + (Just RateLimited, Just Authenticated) => 1 + (Just RateLimited, Just Unauthenticated) => 1 + (Just Authenticated, Just Error) => 1 + (Just Unauthenticated, Just Error) => 1 + (Just Error, Just Authenticated) => 1 + (Just Error, Just Unauthenticated) => 1 + _ => 0 + +-- --------------------------------------------------------------------------- +-- Hackage actions +-- --------------------------------------------------------------------------- + +||| Actions available through the Hackage MCP cartridge. +||| Grouped: Search, Metadata, Versions, Downloads, Dependencies, +||| ReverseDeps, Maintainers, Deprecation, CabalFile, ListAll, Users. +public export +data HackageAction + = SearchPackages + | GetPackage + | GetVersion + | ListVersions + | GetDownloads + | GetDependencies + | GetReverseDependencies + | GetMaintainers + | GetDeprecated + | GetCabalFile + | ListAllPackages + | GetUser + +||| Whether an action requires Authenticated state. +||| All Hackage read operations work without auth. +export +actionRequiresAuth : HackageAction -> Bool +actionRequiresAuth _ = False + +||| Whether an action is a write/mutating operation. +||| All hackage-mcp actions are read-only queries. +export +actionIsMutating : HackageAction -> Bool +actionIsMutating _ = False + +||| Encode action as C-compatible integer for FFI. +export +actionToInt : HackageAction -> Int +actionToInt SearchPackages = 0 +actionToInt GetPackage = 1 +actionToInt GetVersion = 2 +actionToInt ListVersions = 3 +actionToInt GetDownloads = 4 +actionToInt GetDependencies = 5 +actionToInt GetReverseDependencies = 6 +actionToInt GetMaintainers = 7 +actionToInt GetDeprecated = 8 +actionToInt GetCabalFile = 9 +actionToInt ListAllPackages = 10 +actionToInt GetUser = 11 + +||| Decode integer to Hackage action. +export +intToAction : Int -> Maybe HackageAction +intToAction 0 = Just SearchPackages +intToAction 1 = Just GetPackage +intToAction 2 = Just GetVersion +intToAction 3 = Just ListVersions +intToAction 4 = Just GetDownloads +intToAction 5 = Just GetDependencies +intToAction 6 = Just GetReverseDependencies +intToAction 7 = Just GetMaintainers +intToAction 8 = Just GetDeprecated +intToAction 9 = Just GetCabalFile +intToAction 10 = Just ListAllPackages +intToAction 11 = Just GetUser +intToAction _ = Nothing + +-- --------------------------------------------------------------------------- +-- MCP tool declarations +-- --------------------------------------------------------------------------- + +||| Tools exposed via MCP protocol for this cartridge. +public export +data McpTool + = ToolSearchPackages + | ToolGetPackage + | ToolGetVersion + | ToolListVersions + | ToolGetDownloads + | ToolGetDependencies + | ToolGetReverseDependencies + | ToolGetMaintainers + | ToolGetDeprecated + | ToolGetCabalFile + | ToolListAllPackages + | ToolGetUser + +||| Check if a tool requires an authenticated session. +||| All Hackage read operations work without auth. +export +toolRequiresSession : McpTool -> Bool +toolRequiresSession _ = False + +||| Total tool count for this cartridge. +export +toolCount : Nat +toolCount = 12 + +||| Total action count for this cartridge. +export +actionCount : Nat +actionCount = 12 diff --git a/cartridges/domains/registry/hackage-mcp/abi/README.adoc b/cartridges/domains/registry/hackage-mcp/abi/README.adoc new file mode 100644 index 0000000..92a9139 --- /dev/null +++ b/cartridges/domains/registry/hackage-mcp/abi/README.adoc @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += hackage-mcp / abi β€” Idris2 ABI layer +:orientation: shallow + +== Purpose + +Cartridge-local protocol types for `hackage-mcp`. Defines the data, tool, and +transition vocabulary this cartridge speaks. Inherits safety lemmas and the +matrix-cell identifier scheme from the trunk ABI at `src/abi/`. + +== Files + +1 Idris2 module(s), ~188 lines total. Lead module: +`SafeRegistry.idr`. + +No `.ipkg` β€” consumed directly by wider BoJ callers; type-checks inside the +trunk `boj.ipkg`. + +== Invariants + +* `%default total` at every module top. +* Zero `believe_me`, `assert_total`, `postulate`, `Admitted`, or + `sorry` (the trunk holds the four declared `believe_me` sites; leaf + cartridges carry none). + +== Test/proof surface + +Thin module(s), no inline tests. Proof surface = type-check success under the +trunk `idris2 --check`. + +== Read-first + +. `SafeRegistry.idr` β€” cartridge-local data records and enums. +. Trunk `src/abi/Boj/Catalogue.idr` for how this cell participates in the + 2D matrix. diff --git a/cartridges/domains/registry/hackage-mcp/adapter/README.adoc b/cartridges/domains/registry/hackage-mcp/adapter/README.adoc new file mode 100644 index 0000000..339ae91 --- /dev/null +++ b/cartridges/domains/registry/hackage-mcp/adapter/README.adoc @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += hackage-mcp / adapter β€” protocol bridge +:orientation: shallow + +== Purpose + +Exposes this cartridge's FFI over the wire protocols declared in +`cartridge.json`. Dispatches tool invocations to the C-ABI exports at +`../ffi/` and formats responses per protocol. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” depends on the sibling `ffi` target. +| `hackage_adapter.zig` | Protocol dispatch (212 lines). Tool catalogue matches `cartridge.json`. +| `SIDELINED-*.v.adoc` | zig predecessor(s). Not built β€” zig banned 2026-04-10. +|=== + +== Invariants + +* **Stateless** β€” all state lives behind the FFI, never in the adapter. +* **Response passthrough** β€” whatever the FFI returns goes back to the wire + unmodified (no embellishment, no silent recovery). +* **Cartridge.json is source of truth** for the tool catalogue; drift between + adapter and manifest is a CI failure. + +== Test/proof surface + +No inline tests at the adapter layer β€” protocol routing is exercised by the +trunk seam tests at `ffi/zig/src/seams.zig`. + +== Read-first + +. `hackage_adapter.zig` β€” dispatch function and per-protocol routers. +. `../cartridge.json` β€” the tool manifest the dispatcher mirrors. diff --git a/cartridges/domains/registry/hackage-mcp/adapter/build.zig b/cartridges/domains/registry/hackage-mcp/adapter/build.zig new file mode 100644 index 0000000..b06f217 --- /dev/null +++ b/cartridges/domains/registry/hackage-mcp/adapter/build.zig @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// hackage-mcp/adapter/build.zig + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const ffi_mod = b.createModule(.{ + .root_source_file = b.path("../ffi/hackage_mcp_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + const adapter = b.addExecutable(.{ + .name = "hackage_adapter", + .root_source_file = b.path("hackage_adapter.zig"), + .target = target, + .optimize = optimize, + }); + adapter.root_module.addImport("hackage_mcp_ffi", ffi_mod); + b.installArtifact(adapter); + + const run_artifact = b.addRunArtifact(adapter); + const run_step = b.step("run", "Run the hackage-mcp adapter"); + run_step.dependOn(&run_artifact.step); + + const tests = b.addTest(.{ + .root_source_file = b.path("hackage_adapter.zig"), + .target = target, + .optimize = optimize, + }); + tests.root_module.addImport("hackage_mcp_ffi", ffi_mod); + const run_tests = b.addRunArtifact(tests); + const test_step = b.step("test", "Run hackage-mcp adapter tests"); + test_step.dependOn(&run_tests.step); +} diff --git a/cartridges/domains/registry/hackage-mcp/adapter/hackage_adapter.zig b/cartridges/domains/registry/hackage-mcp/adapter/hackage_adapter.zig new file mode 100644 index 0000000..0df0738 --- /dev/null +++ b/cartridges/domains/registry/hackage-mcp/adapter/hackage_adapter.zig @@ -0,0 +1,212 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// hackage-mcp/adapter/hackage_adapter.zig -- Unified three-protocol adapter. +// +// Replaces the banned hackage_adapter.v (zig, removed 2026-04-12). +// +// Bridges the Zig FFI (hackage_mcp_ffi.zig) to three network protocols: +// REST :9061 POST /tools/<tool> +// gRPC-compat :9062 /HackageMcpService/<Method> +// GraphQL :9063 POST /graphql { query: "..." } +// +// Hackage Haskell registry: packages, versions, downloads, cabal files +// Tools: +// hackage_search_packages +// hackage_get_package +// hackage_get_version +// hackage_list_versions +// hackage_get_downloads +// hackage_get_dependencies +// hackage_get_reverse_dependencies +// hackage_get_maintainers +// hackage_get_deprecated +// hackage_get_cabal_file +// hackage_list_all_packages +// hackage_get_user + +const std = @import("std"); +const ffi = @import("hackage_mcp_ffi"); + +const REST_PORT: u16 = 9061; +const GRPC_PORT: u16 = 9062; +const GQL_PORT: u16 = 9063; + +const MAX_CONN_BUF: usize = 16 * 1024; + +// ============================================================================ +// JSON response builders +// ============================================================================ + +fn okJson(buf: []u8, msg: []const u8) []u8 { + return std.fmt.bufPrint(buf, + \{{"success":true,"message":"{}"}} + , .{msg}) catch buf[0..0]; +} + +fn errJson(buf: []u8, msg: []const u8) []u8 { + return std.fmt.bufPrint(buf, + \{{"success":false,"error":"{}"}} + , .{msg}) catch buf[0..0]; +} + +fn statusJson(buf: []u8) []u8 { + return std.fmt.bufPrint(buf, + \{{"success":true,"state":"ready","service":"hackage-mcp"}} + , .{}) catch buf[0..0]; +} + +// ============================================================================ +// Tool dispatcher +// ============================================================================ + +const Response = struct { status: u16, body: []u8 }; + +fn dispatch(tool: []const u8, body: []const u8, resp: []u8) Response { + _ = body; + if (std.mem.eql(u8, tool, "hackage_search_packages")) return .{ .status = 200, .body = okJson(resp, "hackage_search_packages forwarded to backend") }; + if (std.mem.eql(u8, tool, "hackage_get_package")) return .{ .status = 200, .body = okJson(resp, "hackage_get_package forwarded to backend") }; + if (std.mem.eql(u8, tool, "hackage_get_version")) return .{ .status = 200, .body = okJson(resp, "hackage_get_version forwarded to backend") }; + if (std.mem.eql(u8, tool, "hackage_list_versions")) return .{ .status = 200, .body = okJson(resp, "hackage_list_versions forwarded to backend") }; + if (std.mem.eql(u8, tool, "hackage_get_downloads")) return .{ .status = 200, .body = okJson(resp, "hackage_get_downloads forwarded to backend") }; + if (std.mem.eql(u8, tool, "hackage_get_dependencies")) return .{ .status = 200, .body = okJson(resp, "hackage_get_dependencies forwarded to backend") }; + if (std.mem.eql(u8, tool, "hackage_get_reverse_dependencies")) return .{ .status = 200, .body = okJson(resp, "hackage_get_reverse_dependencies forwarded to backend") }; + if (std.mem.eql(u8, tool, "hackage_get_maintainers")) return .{ .status = 200, .body = okJson(resp, "hackage_get_maintainers forwarded to backend") }; + if (std.mem.eql(u8, tool, "hackage_get_deprecated")) return .{ .status = 200, .body = okJson(resp, "hackage_get_deprecated forwarded to backend") }; + if (std.mem.eql(u8, tool, "hackage_get_cabal_file")) return .{ .status = 200, .body = okJson(resp, "hackage_get_cabal_file forwarded to backend") }; + if (std.mem.eql(u8, tool, "hackage_list_all_packages")) return .{ .status = 200, .body = okJson(resp, "hackage_list_all_packages forwarded to backend") }; + if (std.mem.eql(u8, tool, "hackage_get_user")) return .{ .status = 200, .body = okJson(resp, "hackage_get_user forwarded to backend") }; + if (std.mem.eql(u8, tool, "status") or std.mem.eql(u8, tool, "health")) + return .{ .status = 200, .body = statusJson(resp) }; + return .{ .status = 404, .body = errJson(resp, "Unknown tool") }; +} + +// ============================================================================ +// REST handler +// ============================================================================ + +fn dispatchRest(path: []const u8, body: []const u8, resp: []u8) Response { + if (std.mem.startsWith(u8, path, "/tools/")) { + return dispatch(path["/tools/".len..], body, resp); + } + if (std.mem.eql(u8, path, "/status") or std.mem.eql(u8, path, "/health")) { + return .{ .status = 200, .body = statusJson(resp) }; + } + return .{ .status = 404, .body = errJson(resp, "Not found") }; +} + +// ============================================================================ +// gRPC-compat handler +// ============================================================================ + +fn dispatchGrpc(path: []const u8, body: []const u8, resp: []u8) Response { + const prefix = "/HackageMcpService/"; + if (!std.mem.startsWith(u8, path, prefix)) + return .{ .status = 404, .body = errJson(resp, "Not a recognized gRPC path") }; + const method = path[prefix.len..]; + const tool = blk: { + if (std.mem.eql(u8, method, "HackageSearchPackages")) break :blk "hackage_search_packages"; + if (std.mem.eql(u8, method, "HackageGetPackage")) break :blk "hackage_get_package"; + if (std.mem.eql(u8, method, "HackageGetVersion")) break :blk "hackage_get_version"; + if (std.mem.eql(u8, method, "HackageListVersions")) break :blk "hackage_list_versions"; + if (std.mem.eql(u8, method, "HackageGetDownloads")) break :blk "hackage_get_downloads"; + if (std.mem.eql(u8, method, "HackageGetDependencies")) break :blk "hackage_get_dependencies"; + if (std.mem.eql(u8, method, "HackageGetReverseDependencies")) break :blk "hackage_get_reverse_dependencies"; + if (std.mem.eql(u8, method, "HackageGetMaintainers")) break :blk "hackage_get_maintainers"; + if (std.mem.eql(u8, method, "HackageGetDeprecated")) break :blk "hackage_get_deprecated"; + if (std.mem.eql(u8, method, "HackageGetCabalFile")) break :blk "hackage_get_cabal_file"; + if (std.mem.eql(u8, method, "HackageListAllPackages")) break :blk "hackage_list_all_packages"; + if (std.mem.eql(u8, method, "HackageGetUser")) break :blk "hackage_get_user"; + return .{ .status = 404, .body = errJson(resp, "Unknown gRPC method") }; + }; + return dispatch(tool, body, resp); +} + +// ============================================================================ +// GraphQL handler +// ============================================================================ + +fn dispatchGraphql(body: []const u8, resp: []u8) Response { + if (std.mem.indexOf(u8, body, "__schema") != null) + return .{ .status = 200, .body = okJson(resp, "schema introspection not yet supported") }; + if (std.mem.indexOf(u8, body, "search_packages") != null) return dispatch("hackage_search_packages", body, resp); + if (std.mem.indexOf(u8, body, "get_package") != null) return dispatch("hackage_get_package", body, resp); + if (std.mem.indexOf(u8, body, "get_version") != null) return dispatch("hackage_get_version", body, resp); + if (std.mem.indexOf(u8, body, "list_versions") != null) return dispatch("hackage_list_versions", body, resp); + if (std.mem.indexOf(u8, body, "get_downloads") != null) return dispatch("hackage_get_downloads", body, resp); + if (std.mem.indexOf(u8, body, "get_dependencies") != null) return dispatch("hackage_get_dependencies", body, resp); + if (std.mem.indexOf(u8, body, "get_reverse_dependencies") != null) return dispatch("hackage_get_reverse_dependencies", body, resp); + if (std.mem.indexOf(u8, body, "get_maintainers") != null) return dispatch("hackage_get_maintainers", body, resp); + if (std.mem.indexOf(u8, body, "get_deprecated") != null) return dispatch("hackage_get_deprecated", body, resp); + if (std.mem.indexOf(u8, body, "get_cabal_file") != null) return dispatch("hackage_get_cabal_file", body, resp); + if (std.mem.indexOf(u8, body, "list_all_packages") != null) return dispatch("hackage_list_all_packages", body, resp); + if (std.mem.indexOf(u8, body, "get_user") != null) return dispatch("hackage_get_user", body, resp); + return .{ .status = 200, .body = errJson(resp, "Unrecognised GraphQL operation") }; +} + +// ============================================================================ +// HTTP/1.1 connection handler +// ============================================================================ + +const Protocol = enum { rest, grpc, graphql }; + +fn handleConnection(conn: std.net.Server.Connection, proto: Protocol) void { + defer conn.stream.close(); + var in_buf: [MAX_CONN_BUF]u8 = undefined; + const n = conn.stream.read(&in_buf) catch return; + const req = in_buf[0..n]; + + var path: []const u8 = "/"; + var body: []const u8 = ""; + if (n > 4) { + const line_end = std.mem.indexOf(u8, req, "\r\n") orelse req.len; + const first_line = req[0..line_end]; + const sp1 = std.mem.indexOfScalar(u8, first_line, ' ') orelse 0; + const rest_of = first_line[sp1 + 1 ..]; + const sp2 = std.mem.indexOfScalar(u8, rest_of, ' ') orelse rest_of.len; + path = rest_of[0..sp2]; + const body_sep = std.mem.indexOf(u8, req, "\r\n\r\n") orelse n; + body = req[@min(body_sep + 4, n)..]; + } + + var resp_buf: [MAX_CONN_BUF]u8 = undefined; + const result = switch (proto) { + .rest => dispatchRest(path, body, &resp_buf), + .grpc => dispatchGrpc(path, body, &resp_buf), + .graphql => dispatchGraphql(body, &resp_buf), + }; + + const content_type = switch (proto) { + .rest => "application/json", + .grpc => "application/grpc+json", + .graphql => "application/json", + }; + + var hdr_buf: [256]u8 = undefined; + const hdr = std.fmt.bufPrint(&hdr_buf, + "HTTP/1.1 {d} OK\r\nContent-Type: {s}\r\nContent-Length: {d}\r\nConnection: close\r\n\r\n", + .{ result.status, content_type, result.body.len }, + ) catch return; + _ = conn.stream.writeAll(hdr) catch return; + _ = conn.stream.writeAll(result.body) catch return; +} + +fn listenLoop(port: u16, proto: Protocol) void { + const addr = std.net.Address.initIp4(.{ 127, 0, 0, 1 }, port) catch return; + var server = addr.listen(.{ .reuse_address = true }) catch return; + defer server.deinit(); + while (true) { + const conn = server.accept() catch continue; + handleConnection(conn, proto); + } +} + +pub fn main() !void { + ffi.hackage_init(); + const rest_thread = try std.Thread.spawn(.{}, listenLoop, .{ REST_PORT, Protocol.rest }); + const grpc_thread = try std.Thread.spawn(.{}, listenLoop, .{ GRPC_PORT, Protocol.grpc }); + const gql_thread = try std.Thread.spawn(.{}, listenLoop, .{ GQL_PORT, Protocol.graphql }); + rest_thread.join(); + grpc_thread.join(); + gql_thread.join(); +} diff --git a/cartridges/domains/registry/hackage-mcp/benchmarks/quick-bench.sh b/cartridges/domains/registry/hackage-mcp/benchmarks/quick-bench.sh new file mode 100755 index 0000000..fce386f --- /dev/null +++ b/cartridges/domains/registry/hackage-mcp/benchmarks/quick-bench.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Benchmarks for hackage-mcp cartridge. +set -euo pipefail + +echo "=== hackage-mcp benchmarks ===" + +cd "$(dirname "${BASH_SOURCE[0]}")/../ffi" + +# Build with ReleaseFast for benchmarking +zig build -Doptimize=ReleaseFast 2>&1 + +echo "Session open/close cycle (1000 iterations):" +time for i in $(seq 1 1000); do + true +done + +echo "" +echo "Benchmark placeholder -- implement real benchmarks in Zig test blocks" +echo "or via the zig adapter HTTP benchmark tool." diff --git a/cartridges/domains/registry/hackage-mcp/cartridge.json b/cartridges/domains/registry/hackage-mcp/cartridge.json new file mode 100644 index 0000000..d053eba --- /dev/null +++ b/cartridges/domains/registry/hackage-mcp/cartridge.json @@ -0,0 +1,244 @@ +{ + "$schema": "https://boj.dev/schemas/cartridge/v1.json", + "spdx": "MPL-2.0", + "copyright": "Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath)", + "name": "hackage-mcp", + "version": "0.2.0", + "description": "Hackage registry cartridge -- Haskell package search, metadata retrieval, version listing, download stats, dependency analysis, reverse dependency lookup, maintainer listing, deprecated status, and cabal file retrieval via the Hackage API", + "domain": "Registry", + "tier": "Ayo", + "protocols": [ + "MCP", + "REST" + ], + "auth": { + "method": "basic", + "env_var": "HACKAGE_CREDENTIALS", + "credential_source": "vault-mcp" + }, + "api": { + "base_url": "https://hackage.haskell.org", + "content_type": "application/json" + }, + "tools": [ + { + "name": "hackage_search_packages", + "description": "Search Hackage for Haskell packages by text query", + "inputSchema": { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "Search query (package name, synopsis)" + }, + "page": { + "type": "number", + "description": "Page number (default 0)" + } + }, + "required": [ + "query" + ] + } + }, + { + "name": "hackage_get_package", + "description": "Get full metadata for a Haskell package (synopsis, description, license, category, homepage, latest version)", + "inputSchema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Package name (e.g. 'aeson', 'lens', 'servant')" + } + }, + "required": [ + "name" + ] + } + }, + { + "name": "hackage_get_version", + "description": "Get metadata for a specific version of a Haskell package (cabal file, dependencies, exposed modules)", + "inputSchema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Package name" + }, + "version": { + "type": "string", + "description": "Version string (e.g. '2.2.3.0')" + } + }, + "required": [ + "name", + "version" + ] + } + }, + { + "name": "hackage_list_versions", + "description": "List all published versions of a Haskell package with upload timestamps", + "inputSchema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Package name" + } + }, + "required": [ + "name" + ] + } + }, + { + "name": "hackage_get_downloads", + "description": "Get download statistics for a Haskell package (total download count)", + "inputSchema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Package name" + } + }, + "required": [ + "name" + ] + } + }, + { + "name": "hackage_get_dependencies", + "description": "Get the dependency list (build-depends) for a specific version of a Haskell package", + "inputSchema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Package name" + }, + "version": { + "type": "string", + "description": "Version to inspect" + } + }, + "required": [ + "name", + "version" + ] + } + }, + { + "name": "hackage_get_reverse_dependencies", + "description": "List packages that depend on a given Haskell package (reverse dependency lookup)", + "inputSchema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Package name" + } + }, + "required": [ + "name" + ] + } + }, + { + "name": "hackage_get_maintainers", + "description": "Get maintainer list for a Haskell package on Hackage", + "inputSchema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Package name" + } + }, + "required": [ + "name" + ] + } + }, + { + "name": "hackage_get_deprecated", + "description": "Check if a Haskell package is deprecated on Hackage, with suggested replacement", + "inputSchema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Package name" + } + }, + "required": [ + "name" + ] + } + }, + { + "name": "hackage_get_cabal_file", + "description": "Get the raw .cabal file contents for a specific version of a Haskell package", + "inputSchema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Package name" + }, + "version": { + "type": "string", + "description": "Version to retrieve" + } + }, + "required": [ + "name", + "version" + ] + } + }, + { + "name": "hackage_list_all_packages", + "description": "List all packages on Hackage with basic metadata", + "inputSchema": { + "type": "object", + "properties": { + "page": { + "type": "number", + "description": "Page number (default 0)" + } + } + } + }, + { + "name": "hackage_get_user", + "description": "Get profile information for a Hackage user", + "inputSchema": { + "type": "object", + "properties": { + "username": { + "type": "string", + "description": "Hackage username" + } + }, + "required": [ + "username" + ] + } + } + ], + "ffi": { + "so_path": "ffi/zig-out/lib/libhackage_mcp.so", + "abi_version": "ADR-0006", + "symbols": [ + "boj_cartridge_init", + "boj_cartridge_deinit", + "boj_cartridge_name", + "boj_cartridge_version", + "boj_cartridge_invoke" + ] + } +} diff --git a/cartridges/domains/registry/hackage-mcp/ffi/README.adoc b/cartridges/domains/registry/hackage-mcp/ffi/README.adoc new file mode 100644 index 0000000..72ed42b --- /dev/null +++ b/cartridges/domains/registry/hackage-mcp/ffi/README.adoc @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += hackage-mcp / ffi β€” Zig FFI layer +:orientation: shallow + +== Purpose + +C-ABI exports bridging this cartridge's Idris2 ABI to the runtime. Consumed by +the adapter for protocol dispatch and by the trunk loader for mount. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” produces `zig-out/lib/libhackage.so`. +| `hackage_mcp_ffi.zig` | C-ABI exports (16 exports, 7 inline tests, 405 lines). +|=== + +== Invariants + +* **Null-pointer rejection** on `[*c]const u8` arguments before any + dereference. +* **Negative return values** carry documented error meanings; zero is + success (matching the trunk error-code convention). +* **No allocator leaks** β€” every allocation path pairs with `defer free`. + +== Test/proof surface + +7 inline `test "..."` block(s) in `hackage_mcp_ffi.zig`. Run with +`cd ffi && zig build test`. + +== Read-first + +. `hackage_mcp_ffi.zig` β€” C-ABI signatures and guards. +. Trunk `ffi/zig/src/catalogue.zig` for how this `.so` is loaded. diff --git a/cartridges/domains/registry/hackage-mcp/ffi/build.zig b/cartridges/domains/registry/hackage-mcp/ffi/build.zig new file mode 100644 index 0000000..df579b5 --- /dev/null +++ b/cartridges/domains/registry/hackage-mcp/ffi/build.zig @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + // Shared ADR-0006 invoke-shim module (relative path up to boj-server trunk). + + const shim_mod = b.addModule("cartridge_shim", .{ + + .root_source_file = b.path("../../../ffi/zig/src/cartridge_shim.zig"), + + .target = target, + + .optimize = optimize, + + }); + + const ffi_mod = b.addModule("hackage_mcp", .{ + .root_source_file = b.path("hackage_mcp_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + ffi_mod.addImport("cartridge_shim", shim_mod); + + const lib = b.addLibrary(.{ + .name = "hackage_mcp", + .root_module = ffi_mod, + .linkage = .dynamic, + }); + lib.linkLibC(); + b.installArtifact(lib); + + const tests = b.addTest(.{ .root_module = ffi_mod }); + tests.linkLibC(); + const run_tests = b.addRunArtifact(tests); + const test_step = b.step("test", "Run FFI tests"); + test_step.dependOn(&run_tests.step); +} + diff --git a/cartridges/domains/registry/hackage-mcp/ffi/cartridge_shim.zig b/cartridges/domains/registry/hackage-mcp/ffi/cartridge_shim.zig new file mode 100644 index 0000000..0e399db --- /dev/null +++ b/cartridges/domains/registry/hackage-mcp/ffi/cartridge_shim.zig @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// cartridge_shim.zig β€” Shared helpers for the ADR-0006 five-symbol +// cartridge ABI (`boj_cartridge_init / deinit / name / version / invoke`). +// +// The shim centralises the seven-code return convention, NUL-argument +// guards, tool-name comparison, and the buffer-too-small path so each +// cartridge's `boj_cartridge_invoke` can stay short β€” typically a tool +// table plus `shim.writeResult(...)`. +// +// Cartridges import this file by relative path (no build-graph change +// needed). Example: +// +// const shim = @import("../../../ffi/zig/src/cartridge_shim.zig"); +// +// export fn boj_cartridge_invoke( +// tool_name: [*c]const u8, +// json_args: [*c]const u8, +// out_buf: [*c]u8, +// in_out_len: [*c]usize, +// ) callconv(.c) i32 { +// _ = json_args; +// if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; +// const body = if (shim.toolIs(tool_name, "foo")) "{\"result\":{}}" +// else return shim.RC_UNKNOWN_TOOL; +// return shim.writeResult(out_buf, in_out_len, body); +// } + +const std = @import("std"); + +// ── Return codes (ADR-0006 Β§Return codes) ──────────────────────────── +// +// Frozen by ADR-0006. New failure modes compose these via the error +// JSON body β€” the integer surface does not grow without a follow-up ADR. + +pub const RC_SUCCESS: i32 = 0; +pub const RC_UNKNOWN_TOOL: i32 = -1; +pub const RC_BAD_ARGS: i32 = -2; +pub const RC_BUFFER_TOO_SMALL: i32 = -3; +pub const RC_RUNTIME_ERROR: i32 = -4; +pub const RC_PANIC: i32 = -5; +pub const RC_AUTH_DENIED: i32 = -6; + +// ── Invoke-path helpers ────────────────────────────────────────────── + +/// True if any of the three mandatory `boj_cartridge_invoke` output-path +/// pointers is null. Use at the top of every invoke to short-circuit to +/// `RC_BAD_ARGS`. +pub fn invokeArgsNull( + tool_name: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) bool { + return tool_name == null or out_buf == null or in_out_len == null; +} + +/// Compare a C-NUL-terminated tool-name pointer against a Zig string +/// literal. Caller must have already verified `tool_name` is non-null +/// (usually via `invokeArgsNull`). +pub fn toolIs(tool_name: [*c]const u8, expected: []const u8) bool { + const s = std.mem.span(@as([*:0]const u8, @ptrCast(tool_name))); + return std.mem.eql(u8, s, expected); +} + +/// Copy `body` into `out_buf[0..*in_out_len]` (as a capacity) and update +/// `*in_out_len` to the number of bytes written. Returns `RC_SUCCESS`. +/// +/// If `body.len` exceeds the current capacity stored in `*in_out_len`, +/// sets `*in_out_len` to the required size and returns +/// `RC_BUFFER_TOO_SMALL` β€” the caller is then expected to re-allocate +/// and retry, per ADR-0006 Β§Memory ownership. +/// +/// Caller must have already verified that `out_buf` and `in_out_len` +/// are non-null. +pub fn writeResult( + out_buf: [*c]u8, + in_out_len: [*c]usize, + body: []const u8, +) i32 { + const cap = in_out_len.*; + if (body.len > cap) { + in_out_len.* = body.len; + return RC_BUFFER_TOO_SMALL; + } + @memcpy(out_buf[0..body.len], body); + in_out_len.* = body.len; + return RC_SUCCESS; +} + +// ── Tests ──────────────────────────────────────────────────────────── + +test "writeResult: body fits, writes and sets length" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); + try std.testing.expectEqualStrings("hello", buf[0..len]); +} + +test "writeResult: too small returns -3 and sets required length" { + var buf: [2]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_BUFFER_TOO_SMALL, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: exact-fit succeeds" { + var buf: [5]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: empty body" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, ""); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 0), len); +} + +test "toolIs: matches and rejects" { + const name: [*:0]const u8 = "foo"; + try std.testing.expect(toolIs(@ptrCast(name), "foo")); + try std.testing.expect(!toolIs(@ptrCast(name), "bar")); + try std.testing.expect(!toolIs(@ptrCast(name), "foobar")); + try std.testing.expect(!toolIs(@ptrCast(name), "fo")); +} + +test "invokeArgsNull: detects each null slot" { + var buf: [4]u8 = undefined; + var len: usize = 4; + const name: [*:0]const u8 = "x"; + try std.testing.expect(!invokeArgsNull(@ptrCast(name), &buf, &len)); + try std.testing.expect(invokeArgsNull(null, &buf, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), null, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), &buf, null)); +} diff --git a/cartridges/domains/registry/hackage-mcp/ffi/hackage_mcp_ffi.zig b/cartridges/domains/registry/hackage-mcp/ffi/hackage_mcp_ffi.zig new file mode 100644 index 0000000..c4daeae --- /dev/null +++ b/cartridges/domains/registry/hackage-mcp/ffi/hackage_mcp_ffi.zig @@ -0,0 +1,520 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// hackage_mcp_ffi.zig β€” C-ABI FFI implementation for hackage-mcp cartridge. +// +// Implements the state machine defined in HackageMcp.SafeRegistry (Idris2 ABI). +// State machine: Unauthenticated | Authenticated | RateLimited | Error +// Auth: Optional Basic auth β€” most Hackage reads are public. +// REST API: https://hackage.haskell.org +// Actions: SearchPackages, GetPackage, GetVersion, ListVersions, GetDownloads, +// GetDependencies, GetReverseDependencies, GetMaintainers, GetDeprecated, +// GetCabalFile, ListAllPackages, GetUser +// Thread-safe via std.Thread.Mutex. Fixed-size session pool, no heap allocations. + +const std = @import("std"); + +// --------------------------------------------------------------------------- +// State machine (matches Idris2 ABI SessionState exactly) +// --------------------------------------------------------------------------- + +/// Session authentication/lifecycle state. +/// 0 = Unauthenticated, 1 = Authenticated, 2 = RateLimited, 3 = Error. +pub const SessionState = enum(c_int) { + unauthenticated = 0, + authenticated = 1, + rate_limited = 2, + err = 3, +}; + +/// Hackage action identifiers matching Idris2 HackageAction encoding. +pub const HackageAction = enum(c_int) { + search_packages = 0, + get_package = 1, + get_version = 2, + list_versions = 3, + get_downloads = 4, + get_dependencies = 5, + get_reverse_dependencies = 6, + get_maintainers = 7, + get_deprecated = 8, + get_cabal_file = 9, + list_all_packages = 10, + get_user = 11, +}; + +/// Check valid state transitions per the Idris2 ValidTransition proof. +fn isValidTransition(from: SessionState, to: SessionState) bool { + return switch (from) { + .unauthenticated => to == .authenticated or to == .rate_limited or to == .err, + .authenticated => to == .unauthenticated or to == .rate_limited or to == .err, + .rate_limited => to == .authenticated or to == .unauthenticated, + .err => to == .authenticated or to == .unauthenticated, + }; +} + +// --------------------------------------------------------------------------- +// Session slots (thread-safe, fixed-size pool) +// --------------------------------------------------------------------------- + +const MAX_SESSIONS: usize = 16; + +const SessionSlot = struct { + active: bool = false, + state: SessionState = .unauthenticated, + api_call_count: u64 = 0, + last_action: c_int = -1, + search_count: u32 = 0, + package_lookups: u32 = 0, + dep_queries: u32 = 0, + revdep_queries: u32 = 0, +}; + +var sessions: [MAX_SESSIONS]SessionSlot = .{SessionSlot{}} ** MAX_SESSIONS; +var mutex: std.Thread.Mutex = .{}; + +// --------------------------------------------------------------------------- +// C-ABI exports β€” state machine +// --------------------------------------------------------------------------- + +/// Check if a state transition is valid. Returns 1 (valid) or 0 (invalid). +pub export fn hackage_mcp_can_transition(from: c_int, to: c_int) c_int { + const f = std.meta.intToEnum(SessionState, from) catch return 0; + const t = std.meta.intToEnum(SessionState, to) catch return 0; + return if (isValidTransition(f, t)) 1 else 0; +} + +/// Open an authenticated session. Returns slot index (>= 0) or error (< 0). +pub export fn hackage_mcp_authenticate(dummy: c_int) c_int { + _ = dummy; + mutex.lock(); + defer mutex.unlock(); + + for (&sessions, 0..) |*slot, idx| { + if (!slot.active) { + slot.active = true; + slot.state = .authenticated; + slot.api_call_count = 0; + slot.last_action = -1; + slot.search_count = 0; + slot.package_lookups = 0; + slot.dep_queries = 0; + slot.revdep_queries = 0; + return @intCast(idx); + } + } + return -1; +} + +/// Open an unauthenticated session (read-only public access). +pub export fn hackage_mcp_open_anonymous(dummy: c_int) c_int { + _ = dummy; + mutex.lock(); + defer mutex.unlock(); + + for (&sessions, 0..) |*slot, idx| { + if (!slot.active) { + slot.active = true; + slot.state = .unauthenticated; + slot.api_call_count = 0; + slot.last_action = -1; + slot.search_count = 0; + slot.package_lookups = 0; + slot.dep_queries = 0; + slot.revdep_queries = 0; + return @intCast(idx); + } + } + return -1; +} + +/// Close a session. Returns 0 on success. +pub export fn hackage_mcp_close(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + + sessions[idx] = SessionSlot{}; + return 0; +} + +/// Get current state of a session. Returns state int or -1 if invalid. +pub export fn hackage_mcp_session_state(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + return @intFromEnum(slot.state); +} + +/// Signal rate limiting on a session. Returns 0 on success. +pub export fn hackage_mcp_throttle(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + if (!isValidTransition(slot.state, .rate_limited)) return -2; + + sessions[idx].state = .rate_limited; + return 0; +} + +/// Clear rate limiting. Returns 0 on success. +pub export fn hackage_mcp_unthrottle(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + if (!isValidTransition(slot.state, .authenticated) and !isValidTransition(slot.state, .unauthenticated)) return -2; + + sessions[idx].state = .authenticated; + return 0; +} + +/// Signal an error on a session. Returns 0 on success. +pub export fn hackage_mcp_signal_error(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + if (!isValidTransition(slot.state, .err)) return -2; + + sessions[idx].state = .err; + return 0; +} + +// --------------------------------------------------------------------------- +// C-ABI exports β€” action recording and metrics +// --------------------------------------------------------------------------- + +/// Record an API call on a session. Returns 0 on success. +pub export fn hackage_mcp_record_call(slot_idx: c_int, action: c_int) c_int { + const act = std.meta.intToEnum(HackageAction, action) catch return -3; + + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + if (slot.state == .rate_limited) return -2; + if (slot.state == .err) return -2; + + sessions[idx].api_call_count += 1; + sessions[idx].last_action = action; + + switch (act) { + .search_packages => sessions[idx].search_count += 1, + .get_package, .get_version, .list_versions, .get_cabal_file, .get_deprecated => sessions[idx].package_lookups += 1, + .get_dependencies => sessions[idx].dep_queries += 1, + .get_reverse_dependencies => sessions[idx].revdep_queries += 1, + else => {}, + } + + return 0; +} + +/// Get API call count for a session. +pub export fn hackage_mcp_call_count(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + return @intCast(slot.api_call_count); +} + +/// Get search query count. +pub export fn hackage_mcp_search_count(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + return @intCast(slot.search_count); +} + +/// Get package lookup count. +pub export fn hackage_mcp_package_lookup_count(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + return @intCast(slot.package_lookups); +} + +/// Get dependency query count. +pub export fn hackage_mcp_dep_query_count(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + return @intCast(slot.dep_queries); +} + +/// Get reverse dependency query count. +pub export fn hackage_mcp_revdep_query_count(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + return @intCast(slot.revdep_queries); +} + +/// Get total action count. Always returns 12. +pub export fn hackage_mcp_action_count() c_int { + return 12; +} + +/// Reset all sessions (test/debug use only). +pub export fn hackage_mcp_reset() void { + mutex.lock(); + defer mutex.unlock(); + sessions = .{SessionSlot{}} ** MAX_SESSIONS; +} + +// --------------------------------------------------------------------------- +// ═══════════════════════════════════════════════════════════════════════ +// Standard ABI (ADR-0005 four symbols + ADR-0006 invoke) +// ═══════════════════════════════════════════════════════════════════════ + +const shim = @import("cartridge_shim.zig"); + +const CARTRIDGE_NAME_PTR: [*:0]const u8 = "hackage-mcp"; +const CARTRIDGE_VERSION_PTR: [*:0]const u8 = "0.1.0"; + +export fn boj_cartridge_init() callconv(.c) c_int { + return 0; +} + +export fn boj_cartridge_deinit() callconv(.c) void {} + +export fn boj_cartridge_name() callconv(.c) [*:0]const u8 { + return CARTRIDGE_NAME_PTR; +} + +export fn boj_cartridge_version() callconv(.c) [*:0]const u8 { + return CARTRIDGE_VERSION_PTR; +} + +/// Dispatch the cartridge.json MCP tools. Grade D Alpha stubs. +export fn boj_cartridge_invoke( + tool_name: [*c]const u8, + json_args: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) callconv(.c) i32 { + _ = json_args; + if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; + + const body: []const u8 = if (shim.toolIs(tool_name, "hackage_search_packages")) + "{\"result\":{\"matches\":[],\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "hackage_get_package")) + "{\"result\":{\"metadata\":{},\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "hackage_get_version")) + "{\"result\":{\"metadata\":{},\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "hackage_list_versions")) + "{\"result\":{\"items\":[],\"count\":0,\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "hackage_get_downloads")) + "{\"result\":{\"metadata\":{},\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "hackage_get_dependencies")) + "{\"result\":{\"metadata\":{},\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "hackage_get_reverse_dependencies")) + "{\"result\":{\"metadata\":{},\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "hackage_get_maintainers")) + "{\"result\":{\"metadata\":{},\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "hackage_get_deprecated")) + "{\"result\":{\"metadata\":{},\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "hackage_get_cabal_file")) + "{\"result\":{\"metadata\":{},\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "hackage_list_all_packages")) + "{\"result\":{\"items\":[],\"count\":0,\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "hackage_get_user")) + "{\"result\":{\"metadata\":{},\"status\":\"stub\"}}" +else + return shim.RC_UNKNOWN_TOOL; + + return shim.writeResult(out_buf, in_out_len, body); +} + +// Tests +// --------------------------------------------------------------------------- + +test "authenticated session lifecycle" { + hackage_mcp_reset(); + + const slot = hackage_mcp_authenticate(0); + try std.testing.expect(slot >= 0); + try std.testing.expectEqual(@as(c_int, 1), hackage_mcp_session_state(slot)); + + try std.testing.expectEqual(@as(c_int, 0), hackage_mcp_record_call(slot, 0)); + try std.testing.expectEqual(@as(c_int, 1), hackage_mcp_call_count(slot)); + try std.testing.expectEqual(@as(c_int, 1), hackage_mcp_search_count(slot)); + + try std.testing.expectEqual(@as(c_int, 0), hackage_mcp_close(slot)); +} + +test "anonymous session lifecycle" { + hackage_mcp_reset(); + + const slot = hackage_mcp_open_anonymous(0); + try std.testing.expect(slot >= 0); + try std.testing.expectEqual(@as(c_int, 0), hackage_mcp_session_state(slot)); + + try std.testing.expectEqual(@as(c_int, 0), hackage_mcp_record_call(slot, 1)); + try std.testing.expectEqual(@as(c_int, 1), hackage_mcp_package_lookup_count(slot)); + + try std.testing.expectEqual(@as(c_int, 0), hackage_mcp_close(slot)); +} + +test "rate limiting flow" { + hackage_mcp_reset(); + + const slot = hackage_mcp_authenticate(0); + try std.testing.expect(slot >= 0); + + try std.testing.expectEqual(@as(c_int, 0), hackage_mcp_throttle(slot)); + try std.testing.expectEqual(@as(c_int, 2), hackage_mcp_session_state(slot)); + try std.testing.expectEqual(@as(c_int, -2), hackage_mcp_record_call(slot, 0)); + + try std.testing.expectEqual(@as(c_int, 0), hackage_mcp_unthrottle(slot)); + try std.testing.expectEqual(@as(c_int, 1), hackage_mcp_session_state(slot)); +} + +test "error and recovery" { + hackage_mcp_reset(); + + const slot = hackage_mcp_authenticate(0); + try std.testing.expect(slot >= 0); + + try std.testing.expectEqual(@as(c_int, 0), hackage_mcp_signal_error(slot)); + try std.testing.expectEqual(@as(c_int, 3), hackage_mcp_session_state(slot)); + try std.testing.expectEqual(@as(c_int, -2), hackage_mcp_record_call(slot, 0)); + + try std.testing.expectEqual(@as(c_int, 0), hackage_mcp_close(slot)); +} + +test "category counting" { + hackage_mcp_reset(); + + const slot = hackage_mcp_authenticate(0); + try std.testing.expect(slot >= 0); + + try std.testing.expectEqual(@as(c_int, 0), hackage_mcp_record_call(slot, 0)); // Search + try std.testing.expectEqual(@as(c_int, 0), hackage_mcp_record_call(slot, 1)); // GetPackage + try std.testing.expectEqual(@as(c_int, 0), hackage_mcp_record_call(slot, 5)); // GetDependencies + try std.testing.expectEqual(@as(c_int, 0), hackage_mcp_record_call(slot, 6)); // GetReverseDeps + + try std.testing.expectEqual(@as(c_int, 4), hackage_mcp_call_count(slot)); + try std.testing.expectEqual(@as(c_int, 1), hackage_mcp_search_count(slot)); + try std.testing.expectEqual(@as(c_int, 1), hackage_mcp_package_lookup_count(slot)); + try std.testing.expectEqual(@as(c_int, 1), hackage_mcp_dep_query_count(slot)); + try std.testing.expectEqual(@as(c_int, 1), hackage_mcp_revdep_query_count(slot)); +} + +test "transition validator" { + try std.testing.expectEqual(@as(c_int, 1), hackage_mcp_can_transition(0, 1)); + try std.testing.expectEqual(@as(c_int, 1), hackage_mcp_can_transition(1, 0)); + try std.testing.expectEqual(@as(c_int, 1), hackage_mcp_can_transition(1, 2)); + try std.testing.expectEqual(@as(c_int, 1), hackage_mcp_can_transition(2, 1)); + try std.testing.expectEqual(@as(c_int, 1), hackage_mcp_can_transition(3, 0)); + try std.testing.expectEqual(@as(c_int, 0), hackage_mcp_can_transition(2, 3)); +} + +test "slot exhaustion" { + hackage_mcp_reset(); + + var slots: [MAX_SESSIONS]c_int = undefined; + for (&slots) |*s| { + s.* = hackage_mcp_authenticate(0); + try std.testing.expect(s.* >= 0); + } + + try std.testing.expectEqual(@as(c_int, -1), hackage_mcp_authenticate(0)); + + try std.testing.expectEqual(@as(c_int, 0), hackage_mcp_close(slots[0])); + const new_slot = hackage_mcp_authenticate(0); + try std.testing.expect(new_slot >= 0); +} + +// ═══════════════════════════════════════════════════════════════════════ +// ADR-0006 invoke dispatch tests +// ═══════════════════════════════════════════════════════════════════════ + +test "boj_cartridge_name returns hackage-mcp" { + const n = std.mem.span(boj_cartridge_name()); + try std.testing.expectEqualStrings("hackage-mcp", n); +} + +test "boj_cartridge_init returns 0" { + try std.testing.expectEqual(@as(c_int, 0), boj_cartridge_init()); +} + +test "invoke: each declared tool succeeds" { + var buf: [256]u8 = undefined; + const tools = [_][]const u8{ + "hackage_search_packages", + "hackage_get_package", + "hackage_get_version", + "hackage_list_versions", + "hackage_get_downloads", + "hackage_get_dependencies", + "hackage_get_reverse_dependencies", + "hackage_get_maintainers", + "hackage_get_deprecated", + "hackage_get_cabal_file", + "hackage_list_all_packages", + "hackage_get_user", + }; + for (tools) |t| { + var len: usize = buf.len; + const rc = boj_cartridge_invoke(t.ptr, "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, 0), rc); + try std.testing.expect(std.mem.indexOf(u8, buf[0..len], "result") != null); + } +} + +test "invoke: unknown tool returns -1" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("nope", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -1), rc); +} + +test "invoke: buffer too small returns -3" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("hackage_search_packages", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -3), rc); + try std.testing.expect(len > 4); +} diff --git a/cartridges/domains/registry/hackage-mcp/minter.toml b/cartridges/domains/registry/hackage-mcp/minter.toml new file mode 100644 index 0000000..13e630f --- /dev/null +++ b/cartridges/domains/registry/hackage-mcp/minter.toml @@ -0,0 +1,22 @@ +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> + +name = "hackage-mcp" +description = "Hackage registry cartridge β€” Haskell package search, metadata, version listing, download stats, dependencies, reverse deps, maintainers, deprecation, cabal files, user profiles" +version = "0.2.0" +domain = "Registry" +protocols = [ + "MCP", + "REST", +] +tier = "Ayo" +backend = "universal" +generate_panel = true + +[auth] +method = "basic" +credential_source = "vault-mcp" +fields = ["hackage_credentials"] + +[api] +base_url = "https://hackage.haskell.org" diff --git a/cartridges/domains/registry/hackage-mcp/mod.js b/cartridges/domains/registry/hackage-mcp/mod.js new file mode 100644 index 0000000..a58fdc3 --- /dev/null +++ b/cartridges/domains/registry/hackage-mcp/mod.js @@ -0,0 +1,218 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// hackage-mcp/mod.js -- Hackage registry cartridge implementation. +// +// Provides MCP tool handlers for the Hackage REST API: +// - Package search (text query) +// - Package metadata (full info, specific versions) +// - Version listing with upload timestamps +// - Download statistics +// - Dependency inspection (build-depends) +// - Reverse dependency lookup +// - Maintainer listing +// - Deprecation status and replacement suggestions +// - Raw .cabal file retrieval +// - Full package listing +// - User profile lookup +// +// Auth: Optional Basic auth via HACKAGE_CREDENTIALS. Read-only operations +// are fully public. Auth required only for uploads/edits. +// API docs: https://hackage.haskell.org/api +// +// Usage: import { handleTool } from "./mod.js"; +// or: deno run --allow-net --allow-env mod.js + +const API_BASE = "https://hackage.haskell.org"; + +// --------------------------------------------------------------------------- +// Auth helper β€” retrieves Hackage credentials from environment. +// Format: "username:password" base64-encoded for Basic auth. +// In production, vault-mcp provides zero-knowledge credential proxying. +// --------------------------------------------------------------------------- + +function getCredentials() { + const creds = typeof Deno !== "undefined" + ? Deno.env.get("HACKAGE_CREDENTIALS") + : process.env.HACKAGE_CREDENTIALS; + return creds || null; +} + +// --------------------------------------------------------------------------- +// HTTP request helper β€” wraps fetch with Hackage headers, error handling, +// and response normalization. Supports both JSON and plain-text responses. +// --------------------------------------------------------------------------- + +async function hackageFetch(path, queryParams, acceptText) { + const url = new URL(`${API_BASE}${path}`); + + if (queryParams) { + for (const [key, value] of Object.entries(queryParams)) { + if (value !== undefined && value !== null && value !== "") { + url.searchParams.set(key, String(value)); + } + } + } + + const headers = { + "Accept": acceptText ? "text/plain" : "application/json", + "User-Agent": "boj-server/hackage-mcp/0.2.0 (https://github.com/hyperpolymath/boj-server)", + }; + + const creds = getCredentials(); + if (creds) { + headers["Authorization"] = `Basic ${btoa(creds)}`; + } + + const response = await fetch(url.toString(), { method: "GET", headers }); + + // Handle rate limiting + if (response.status === 429) { + const retryAfter = response.headers.get("retry-after"); + return { + status: 429, + error: `Rate limited by Hackage. Retry after ${retryAfter || "unknown"} seconds.`, + retryAfter, + }; + } + + if (acceptText) { + const text = await response.text().catch(() => ""); + if (!response.ok) { + return { status: response.status, error: `HTTP ${response.status}`, data: text }; + } + return { status: response.status, data: text }; + } + + const data = await response.json().catch(() => ({})); + + if (!response.ok) { + const errorMessage = data.message || `HTTP ${response.status}`; + return { status: response.status, error: errorMessage, data }; + } + + return { status: response.status, data }; +} + +// --------------------------------------------------------------------------- +// Tool handler dispatch β€” maps MCP tool names to Hackage API operations. +// --------------------------------------------------------------------------- + +export async function handleTool(toolName, args) { + switch (toolName) { + + // --- Search --- + + case "hackage_search_packages": { + if (!args.query) return { error: "Missing required field: query" }; + return hackageFetch("/packages/search", { + terms: args.query, + page: args.page, + }); + } + + // --- Package metadata --- + + case "hackage_get_package": { + if (!args.name) return { error: "Missing required field: name" }; + return hackageFetch(`/package/${encodeURIComponent(args.name)}.json`); + } + + case "hackage_get_version": { + if (!args.name) return { error: "Missing required field: name" }; + if (!args.version) return { error: "Missing required field: version" }; + return hackageFetch(`/package/${encodeURIComponent(args.name)}-${args.version}.json`); + } + + case "hackage_list_versions": { + if (!args.name) return { error: "Missing required field: name" }; + const result = await hackageFetch(`/package/${encodeURIComponent(args.name)}.json`); + if (result.error) return result; + return { + status: result.status, + data: { + name: args.name, + versions: Object.keys(result.data || {}).filter((k) => /^\d/.test(k)), + }, + }; + } + + // --- Downloads --- + + case "hackage_get_downloads": { + if (!args.name) return { error: "Missing required field: name" }; + return hackageFetch(`/package/${encodeURIComponent(args.name)}/downloads.json`); + } + + // --- Dependencies --- + + case "hackage_get_dependencies": { + if (!args.name) return { error: "Missing required field: name" }; + if (!args.version) return { error: "Missing required field: version" }; + return hackageFetch(`/package/${encodeURIComponent(args.name)}-${args.version}/dependencies.json`); + } + + // --- Reverse dependencies --- + + case "hackage_get_reverse_dependencies": { + if (!args.name) return { error: "Missing required field: name" }; + return hackageFetch(`/package/${encodeURIComponent(args.name)}/reverse.json`); + } + + // --- Maintainers --- + + case "hackage_get_maintainers": { + if (!args.name) return { error: "Missing required field: name" }; + return hackageFetch(`/package/${encodeURIComponent(args.name)}/maintainers.json`); + } + + // --- Deprecation --- + + case "hackage_get_deprecated": { + if (!args.name) return { error: "Missing required field: name" }; + return hackageFetch(`/package/${encodeURIComponent(args.name)}/deprecated.json`); + } + + // --- Cabal file --- + + case "hackage_get_cabal_file": { + if (!args.name) return { error: "Missing required field: name" }; + if (!args.version) return { error: "Missing required field: version" }; + return hackageFetch( + `/package/${encodeURIComponent(args.name)}-${args.version}/${args.name}.cabal`, + null, + true, // Accept text/plain + ); + } + + // --- List all --- + + case "hackage_list_all_packages": { + return hackageFetch("/packages/", { page: args.page }); + } + + // --- Users --- + + case "hackage_get_user": { + if (!args.username) return { error: "Missing required field: username" }; + return hackageFetch(`/user/${encodeURIComponent(args.username)}.json`); + } + + default: + return { error: `Unknown hackage-mcp tool: ${toolName}` }; + } +} + +// --------------------------------------------------------------------------- +// Cartridge metadata export β€” used by the BoJ cartridge loader to register +// this cartridge's tools without reading cartridge.json separately. +// --------------------------------------------------------------------------- + +export const metadata = { + name: "hackage-mcp", + version: "0.2.0", + domain: "Registry", + tier: "Ayo", + protocols: ["MCP", "REST"], + toolCount: 12, +}; diff --git a/cartridges/domains/registry/hackage-mcp/panels/manifest.json b/cartridges/domains/registry/hackage-mcp/panels/manifest.json new file mode 100644 index 0000000..c102cf2 --- /dev/null +++ b/cartridges/domains/registry/hackage-mcp/panels/manifest.json @@ -0,0 +1,90 @@ +{ + "$schema": "https://panll.dev/schemas/panel-manifest/v1.json", + "spdx": "MPL-2.0", + "copyright": "Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath)", + "cartridge": "hackage-mcp", + "domain": "Registry", + "version": "0.2.0", + "panels": [ + { + "id": "hackage-auth-status", + "title": "Auth Status", + "description": "Hackage session state (Unauthenticated / Authenticated / RateLimited / Error)", + "type": "status-indicator", + "data_source": { + "endpoint": "/hackage/state", + "method": "GET", + "refresh_interval_ms": 5000 + }, + "widgets": [ + { + "type": "state-badge", + "field": "state", + "states": { + "unauthenticated": { "color": "#95a5a6", "icon": "user-x" }, + "authenticated": { "color": "#2ecc71", "icon": "user-check" }, + "rate_limited": { "color": "#f39c12", "icon": "clock" }, + "error": { "color": "#e74c3c", "icon": "alert-triangle" } + } + } + ] + }, + { + "id": "hackage-search-count", + "title": "Search Queries", + "description": "Number of package search queries executed in the current session", + "type": "metric", + "data_source": { + "endpoint": "/hackage/status", + "method": "GET", + "refresh_interval_ms": 10000 + }, + "widgets": [ + { + "type": "counter", + "field": "search_count", + "label": "Searches", + "icon": "search" + } + ] + }, + { + "id": "hackage-package-lookups", + "title": "Package Lookups", + "description": "Number of package metadata queries executed in the current session", + "type": "metric", + "data_source": { + "endpoint": "/hackage/status", + "method": "GET", + "refresh_interval_ms": 10000 + }, + "widgets": [ + { + "type": "counter", + "field": "package_lookups", + "label": "Package Lookups", + "icon": "package" + } + ] + }, + { + "id": "hackage-revdep-queries", + "title": "Reverse Dep Queries", + "description": "Number of reverse dependency queries in the current session", + "type": "metric", + "data_source": { + "endpoint": "/hackage/status", + "method": "GET", + "refresh_interval_ms": 10000 + }, + "widgets": [ + { + "type": "counter", + "field": "revdep_queries", + "label": "Revdep Queries", + "icon": "git-branch" + } + ] + } + ] +} diff --git a/cartridges/domains/registry/hackage-mcp/tests/integration_test.sh b/cartridges/domains/registry/hackage-mcp/tests/integration_test.sh new file mode 100755 index 0000000..9032188 --- /dev/null +++ b/cartridges/domains/registry/hackage-mcp/tests/integration_test.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Integration tests for hackage-mcp cartridge. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CART_DIR="$(dirname "$SCRIPT_DIR")" +FFI_DIR="$CART_DIR/ffi" + +echo "=== hackage-mcp integration tests ===" + +# Build FFI +echo "[1/3] Building FFI..." +cd "$FFI_DIR" && zig build 2>&1 + +# Run Zig unit tests +echo "[2/3] Running FFI unit tests..." +cd "$FFI_DIR" && zig build test 2>&1 + +# Validate Idris2 ABI (if idris2 available) +echo "[3/3] Checking ABI..." +if command -v idris2 &>/dev/null; then + cd "$CART_DIR/abi" && idris2 --check HackageMcp.SafeRegistry 2>&1 + echo " ABI: OK" +else + echo " ABI: SKIPPED (idris2 not in PATH)" +fi + +echo "" +echo "All tests passed for hackage-mcp!" diff --git a/cartridges/domains/registry/hex-mcp/README.adoc b/cartridges/domains/registry/hex-mcp/README.adoc new file mode 100644 index 0000000..e179f21 --- /dev/null +++ b/cartridges/domains/registry/hex-mcp/README.adoc @@ -0,0 +1,88 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += hex-mcp +Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk> +:spdx: MPL-2.0 +:tier: Ayo +:domain: Registry +:protocols: MCP, REST + +== Overview + +Hex.pm registry cartridge for the BoJ server. Provides type-safe access to +the Hex.pm API for Elixir/Erlang package search, metadata retrieval, release +listing, download statistics, dependency analysis, owner management, retirement +status checks, and user profile/package browsing. + +=== State Machine + +`Unauthenticated -> Authenticated` (optional, all reads work without auth) + +`Authenticated -> RateLimited -> Authenticated` (normal flow, 429 handling) + +`Authenticated -> Error -> Unauthenticated` (error recovery) + +=== Actions (10) + +[cols="1,1"] +|=== +| Category | Actions + +| Search +| SearchPackages + +| Package Metadata +| GetPackage, GetRelease, ListReleases + +| Downloads +| GetDownloads + +| Dependencies +| GetDependencies + +| Ownership +| GetOwners + +| Retirement +| GetRetirement + +| Users +| GetUser, ListUserPackages +|=== + +== Architecture + +[cols="1,1,2"] +|=== +| Layer | Language | Purpose + +| ABI +| Idris2 +| Formally verified state machine with dependent-type proofs (HexMcp.SafeRegistry) + +| FFI +| Zig +| C-compatible implementation with thread-safe session pool, action recording, category counting + +| Adapter +| zig +| REST bridge to BoJ unified adapter protocol +|=== + +== Building + +[source,bash] +---- +# Build FFI shared library +cd ffi && zig build + +# Run FFI tests +cd ffi && zig build test + +# Type-check ABI +cd abi && idris2 --check HexMcp.SafeRegistry +---- + +== Status + +Development -- customised with Hex.pm-specific search, releases, retirement status, and owner APIs. diff --git a/cartridges/domains/registry/hex-mcp/abi/HexMcp/SafeRegistry.idr b/cartridges/domains/registry/hex-mcp/abi/HexMcp/SafeRegistry.idr new file mode 100644 index 0000000..a9ee423 --- /dev/null +++ b/cartridges/domains/registry/hex-mcp/abi/HexMcp/SafeRegistry.idr @@ -0,0 +1,179 @@ +-- SPDX-License-Identifier: MPL-2.0 +-- Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +-- +-- HexMcp.SafeRegistry β€” Type-safe ABI for hex-mcp cartridge. +-- +-- Dependent-type state machine governing Hex.pm API access. +-- Encodes optional API key auth, package search, metadata retrieval, +-- release listing, download stats, dependency analysis, owner listing, +-- retirement checks, and user profile lookup as compile-time invariants. +-- REST API: https://hex.pm/api +-- No unsafe escape hatches. + +module HexMcp.SafeRegistry + +%default total + +-- --------------------------------------------------------------------------- +-- Authentication state machine +-- --------------------------------------------------------------------------- + +||| Session state for Hex.pm MCP operations. +||| Unauthenticated: no API key; read-only operations available. +||| Authenticated: Hex API key active, full access. +||| RateLimited: Hex rate limit hit (429); must wait. +||| Error: unrecoverable error (invalid key, network failure). +public export +data SessionState + = Unauthenticated + | Authenticated + | RateLimited + | Error + +||| Proof that a state transition is valid. +||| Hex.pm allows both authenticated and unauthenticated sessions. +public export +data ValidTransition : SessionState -> SessionState -> Type where + Authenticate : ValidTransition Unauthenticated Authenticated + Deauthenticate : ValidTransition Authenticated Unauthenticated + Throttle : ValidTransition Authenticated RateLimited + ThrottleAnon : ValidTransition Unauthenticated RateLimited + Unthrottle : ValidTransition RateLimited Authenticated + UnthrottleAnon : ValidTransition RateLimited Unauthenticated + AuthError : ValidTransition Authenticated Error + AnonError : ValidTransition Unauthenticated Error + RecoverToAuth : ValidTransition Error Authenticated + RecoverToAnon : ValidTransition Error Unauthenticated + +-- --------------------------------------------------------------------------- +-- C-ABI integer encoding +-- --------------------------------------------------------------------------- + +||| Encode session state as C-compatible integer for FFI boundary. +export +sessionStateToInt : SessionState -> Int +sessionStateToInt Unauthenticated = 0 +sessionStateToInt Authenticated = 1 +sessionStateToInt RateLimited = 2 +sessionStateToInt Error = 3 + +||| Decode integer back to session state. Returns Nothing for out-of-range. +export +intToSessionState : Int -> Maybe SessionState +intToSessionState 0 = Just Unauthenticated +intToSessionState 1 = Just Authenticated +intToSessionState 2 = Just RateLimited +intToSessionState 3 = Just Error +intToSessionState _ = Nothing + +||| Check if a state transition is valid (C-ABI export). +export +hex_mcp_can_transition : Int -> Int -> Int +hex_mcp_can_transition from to = + case (intToSessionState from, intToSessionState to) of + (Just Unauthenticated, Just Authenticated) => 1 + (Just Authenticated, Just Unauthenticated) => 1 + (Just Authenticated, Just RateLimited) => 1 + (Just Unauthenticated, Just RateLimited) => 1 + (Just RateLimited, Just Authenticated) => 1 + (Just RateLimited, Just Unauthenticated) => 1 + (Just Authenticated, Just Error) => 1 + (Just Unauthenticated, Just Error) => 1 + (Just Error, Just Authenticated) => 1 + (Just Error, Just Unauthenticated) => 1 + _ => 0 + +-- --------------------------------------------------------------------------- +-- Hex.pm actions +-- --------------------------------------------------------------------------- + +||| Actions available through the Hex MCP cartridge. +||| Grouped: Search, Metadata, Releases, Downloads, Dependencies, +||| Owners, Retirement, Users. +public export +data HexAction + = SearchPackages + | GetPackage + | GetRelease + | ListReleases + | GetDownloads + | GetDependencies + | GetOwners + | GetRetirement + | GetUser + | ListUserPackages + +||| Whether an action requires Authenticated state. +||| All Hex.pm read operations work without auth. +export +actionRequiresAuth : HexAction -> Bool +actionRequiresAuth _ = False + +||| Whether an action is a write/mutating operation. +||| All hex-mcp actions are read-only queries. +export +actionIsMutating : HexAction -> Bool +actionIsMutating _ = False + +||| Encode action as C-compatible integer for FFI. +export +actionToInt : HexAction -> Int +actionToInt SearchPackages = 0 +actionToInt GetPackage = 1 +actionToInt GetRelease = 2 +actionToInt ListReleases = 3 +actionToInt GetDownloads = 4 +actionToInt GetDependencies = 5 +actionToInt GetOwners = 6 +actionToInt GetRetirement = 7 +actionToInt GetUser = 8 +actionToInt ListUserPackages = 9 + +||| Decode integer to Hex action. +export +intToAction : Int -> Maybe HexAction +intToAction 0 = Just SearchPackages +intToAction 1 = Just GetPackage +intToAction 2 = Just GetRelease +intToAction 3 = Just ListReleases +intToAction 4 = Just GetDownloads +intToAction 5 = Just GetDependencies +intToAction 6 = Just GetOwners +intToAction 7 = Just GetRetirement +intToAction 8 = Just GetUser +intToAction 9 = Just ListUserPackages +intToAction _ = Nothing + +-- --------------------------------------------------------------------------- +-- MCP tool declarations +-- --------------------------------------------------------------------------- + +||| Tools exposed via MCP protocol for this cartridge. +public export +data McpTool + = ToolSearchPackages + | ToolGetPackage + | ToolGetRelease + | ToolListReleases + | ToolGetDownloads + | ToolGetDependencies + | ToolGetOwners + | ToolGetRetirement + | ToolGetUser + | ToolListUserPackages + +||| Check if a tool requires an authenticated session. +||| All Hex.pm read operations work without auth. +export +toolRequiresSession : McpTool -> Bool +toolRequiresSession _ = False + +||| Total tool count for this cartridge. +export +toolCount : Nat +toolCount = 10 + +||| Total action count for this cartridge. +export +actionCount : Nat +actionCount = 10 diff --git a/cartridges/domains/registry/hex-mcp/abi/README.adoc b/cartridges/domains/registry/hex-mcp/abi/README.adoc new file mode 100644 index 0000000..735de4a --- /dev/null +++ b/cartridges/domains/registry/hex-mcp/abi/README.adoc @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += hex-mcp / abi β€” Idris2 ABI layer +:orientation: shallow + +== Purpose + +Cartridge-local protocol types for `hex-mcp`. Defines the data, tool, and +transition vocabulary this cartridge speaks. Inherits safety lemmas and the +matrix-cell identifier scheme from the trunk ABI at `src/abi/`. + +== Files + +1 Idris2 module(s), ~179 lines total. Lead module: +`SafeRegistry.idr`. + +No `.ipkg` β€” consumed directly by wider BoJ callers; type-checks inside the +trunk `boj.ipkg`. + +== Invariants + +* `%default total` at every module top. +* Zero `believe_me`, `assert_total`, `postulate`, `Admitted`, or + `sorry` (the trunk holds the four declared `believe_me` sites; leaf + cartridges carry none). + +== Test/proof surface + +Thin module(s), no inline tests. Proof surface = type-check success under the +trunk `idris2 --check`. + +== Read-first + +. `SafeRegistry.idr` β€” cartridge-local data records and enums. +. Trunk `src/abi/Boj/Catalogue.idr` for how this cell participates in the + 2D matrix. diff --git a/cartridges/domains/registry/hex-mcp/adapter/README.adoc b/cartridges/domains/registry/hex-mcp/adapter/README.adoc new file mode 100644 index 0000000..003a013 --- /dev/null +++ b/cartridges/domains/registry/hex-mcp/adapter/README.adoc @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += hex-mcp / adapter β€” protocol bridge +:orientation: shallow + +== Purpose + +Exposes this cartridge's FFI over the wire protocols declared in +`cartridge.json`. Dispatches tool invocations to the C-ABI exports at +`../ffi/` and formats responses per protocol. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” depends on the sibling `ffi` target. +| `hex_adapter.zig` | Protocol dispatch (204 lines). Tool catalogue matches `cartridge.json`. +| `SIDELINED-*.v.adoc` | zig predecessor(s). Not built β€” zig banned 2026-04-10. +|=== + +== Invariants + +* **Stateless** β€” all state lives behind the FFI, never in the adapter. +* **Response passthrough** β€” whatever the FFI returns goes back to the wire + unmodified (no embellishment, no silent recovery). +* **Cartridge.json is source of truth** for the tool catalogue; drift between + adapter and manifest is a CI failure. + +== Test/proof surface + +No inline tests at the adapter layer β€” protocol routing is exercised by the +trunk seam tests at `ffi/zig/src/seams.zig`. + +== Read-first + +. `hex_adapter.zig` β€” dispatch function and per-protocol routers. +. `../cartridge.json` β€” the tool manifest the dispatcher mirrors. diff --git a/cartridges/domains/registry/hex-mcp/adapter/build.zig b/cartridges/domains/registry/hex-mcp/adapter/build.zig new file mode 100644 index 0000000..d1a3840 --- /dev/null +++ b/cartridges/domains/registry/hex-mcp/adapter/build.zig @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// hex-mcp/adapter/build.zig + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const ffi_mod = b.createModule(.{ + .root_source_file = b.path("../ffi/hex_mcp_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + const adapter = b.addExecutable(.{ + .name = "hex_adapter", + .root_source_file = b.path("hex_adapter.zig"), + .target = target, + .optimize = optimize, + }); + adapter.root_module.addImport("hex_mcp_ffi", ffi_mod); + b.installArtifact(adapter); + + const run_artifact = b.addRunArtifact(adapter); + const run_step = b.step("run", "Run the hex-mcp adapter"); + run_step.dependOn(&run_artifact.step); + + const tests = b.addTest(.{ + .root_source_file = b.path("hex_adapter.zig"), + .target = target, + .optimize = optimize, + }); + tests.root_module.addImport("hex_mcp_ffi", ffi_mod); + const run_tests = b.addRunArtifact(tests); + const test_step = b.step("test", "Run hex-mcp adapter tests"); + test_step.dependOn(&run_tests.step); +} diff --git a/cartridges/domains/registry/hex-mcp/adapter/hex_adapter.zig b/cartridges/domains/registry/hex-mcp/adapter/hex_adapter.zig new file mode 100644 index 0000000..d7a2843 --- /dev/null +++ b/cartridges/domains/registry/hex-mcp/adapter/hex_adapter.zig @@ -0,0 +1,204 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// hex-mcp/adapter/hex_adapter.zig -- Unified three-protocol adapter. +// +// Replaces the banned hex_adapter.v (zig, removed 2026-04-12). +// +// Bridges the Zig FFI (hex_mcp_ffi.zig) to three network protocols: +// REST :9067 POST /tools/<tool> +// gRPC-compat :9068 /HexMcpService/<Method> +// GraphQL :9069 POST /graphql { query: "..." } +// +// Hex.pm Elixir/Erlang registry: packages, releases, downloads +// Tools: +// hex_search_packages +// hex_get_package +// hex_get_release +// hex_list_releases +// hex_get_downloads +// hex_get_dependencies +// hex_get_owners +// hex_get_retirement +// hex_get_user +// hex_list_user_packages + +const std = @import("std"); +const ffi = @import("hex_mcp_ffi"); + +const REST_PORT: u16 = 9067; +const GRPC_PORT: u16 = 9068; +const GQL_PORT: u16 = 9069; + +const MAX_CONN_BUF: usize = 16 * 1024; + +// ============================================================================ +// JSON response builders +// ============================================================================ + +fn okJson(buf: []u8, msg: []const u8) []u8 { + return std.fmt.bufPrint(buf, + \{{"success":true,"message":"{}"}} + , .{msg}) catch buf[0..0]; +} + +fn errJson(buf: []u8, msg: []const u8) []u8 { + return std.fmt.bufPrint(buf, + \{{"success":false,"error":"{}"}} + , .{msg}) catch buf[0..0]; +} + +fn statusJson(buf: []u8) []u8 { + return std.fmt.bufPrint(buf, + \{{"success":true,"state":"ready","service":"hex-mcp"}} + , .{}) catch buf[0..0]; +} + +// ============================================================================ +// Tool dispatcher +// ============================================================================ + +const Response = struct { status: u16, body: []u8 }; + +fn dispatch(tool: []const u8, body: []const u8, resp: []u8) Response { + _ = body; + if (std.mem.eql(u8, tool, "hex_search_packages")) return .{ .status = 200, .body = okJson(resp, "hex_search_packages forwarded to backend") }; + if (std.mem.eql(u8, tool, "hex_get_package")) return .{ .status = 200, .body = okJson(resp, "hex_get_package forwarded to backend") }; + if (std.mem.eql(u8, tool, "hex_get_release")) return .{ .status = 200, .body = okJson(resp, "hex_get_release forwarded to backend") }; + if (std.mem.eql(u8, tool, "hex_list_releases")) return .{ .status = 200, .body = okJson(resp, "hex_list_releases forwarded to backend") }; + if (std.mem.eql(u8, tool, "hex_get_downloads")) return .{ .status = 200, .body = okJson(resp, "hex_get_downloads forwarded to backend") }; + if (std.mem.eql(u8, tool, "hex_get_dependencies")) return .{ .status = 200, .body = okJson(resp, "hex_get_dependencies forwarded to backend") }; + if (std.mem.eql(u8, tool, "hex_get_owners")) return .{ .status = 200, .body = okJson(resp, "hex_get_owners forwarded to backend") }; + if (std.mem.eql(u8, tool, "hex_get_retirement")) return .{ .status = 200, .body = okJson(resp, "hex_get_retirement forwarded to backend") }; + if (std.mem.eql(u8, tool, "hex_get_user")) return .{ .status = 200, .body = okJson(resp, "hex_get_user forwarded to backend") }; + if (std.mem.eql(u8, tool, "hex_list_user_packages")) return .{ .status = 200, .body = okJson(resp, "hex_list_user_packages forwarded to backend") }; + if (std.mem.eql(u8, tool, "status") or std.mem.eql(u8, tool, "health")) + return .{ .status = 200, .body = statusJson(resp) }; + return .{ .status = 404, .body = errJson(resp, "Unknown tool") }; +} + +// ============================================================================ +// REST handler +// ============================================================================ + +fn dispatchRest(path: []const u8, body: []const u8, resp: []u8) Response { + if (std.mem.startsWith(u8, path, "/tools/")) { + return dispatch(path["/tools/".len..], body, resp); + } + if (std.mem.eql(u8, path, "/status") or std.mem.eql(u8, path, "/health")) { + return .{ .status = 200, .body = statusJson(resp) }; + } + return .{ .status = 404, .body = errJson(resp, "Not found") }; +} + +// ============================================================================ +// gRPC-compat handler +// ============================================================================ + +fn dispatchGrpc(path: []const u8, body: []const u8, resp: []u8) Response { + const prefix = "/HexMcpService/"; + if (!std.mem.startsWith(u8, path, prefix)) + return .{ .status = 404, .body = errJson(resp, "Not a recognized gRPC path") }; + const method = path[prefix.len..]; + const tool = blk: { + if (std.mem.eql(u8, method, "HexSearchPackages")) break :blk "hex_search_packages"; + if (std.mem.eql(u8, method, "HexGetPackage")) break :blk "hex_get_package"; + if (std.mem.eql(u8, method, "HexGetRelease")) break :blk "hex_get_release"; + if (std.mem.eql(u8, method, "HexListReleases")) break :blk "hex_list_releases"; + if (std.mem.eql(u8, method, "HexGetDownloads")) break :blk "hex_get_downloads"; + if (std.mem.eql(u8, method, "HexGetDependencies")) break :blk "hex_get_dependencies"; + if (std.mem.eql(u8, method, "HexGetOwners")) break :blk "hex_get_owners"; + if (std.mem.eql(u8, method, "HexGetRetirement")) break :blk "hex_get_retirement"; + if (std.mem.eql(u8, method, "HexGetUser")) break :blk "hex_get_user"; + if (std.mem.eql(u8, method, "HexListUserPackages")) break :blk "hex_list_user_packages"; + return .{ .status = 404, .body = errJson(resp, "Unknown gRPC method") }; + }; + return dispatch(tool, body, resp); +} + +// ============================================================================ +// GraphQL handler +// ============================================================================ + +fn dispatchGraphql(body: []const u8, resp: []u8) Response { + if (std.mem.indexOf(u8, body, "__schema") != null) + return .{ .status = 200, .body = okJson(resp, "schema introspection not yet supported") }; + if (std.mem.indexOf(u8, body, "search_packages") != null) return dispatch("hex_search_packages", body, resp); + if (std.mem.indexOf(u8, body, "get_package") != null) return dispatch("hex_get_package", body, resp); + if (std.mem.indexOf(u8, body, "get_release") != null) return dispatch("hex_get_release", body, resp); + if (std.mem.indexOf(u8, body, "list_releases") != null) return dispatch("hex_list_releases", body, resp); + if (std.mem.indexOf(u8, body, "get_downloads") != null) return dispatch("hex_get_downloads", body, resp); + if (std.mem.indexOf(u8, body, "get_dependencies") != null) return dispatch("hex_get_dependencies", body, resp); + if (std.mem.indexOf(u8, body, "get_owners") != null) return dispatch("hex_get_owners", body, resp); + if (std.mem.indexOf(u8, body, "get_retirement") != null) return dispatch("hex_get_retirement", body, resp); + if (std.mem.indexOf(u8, body, "get_user") != null) return dispatch("hex_get_user", body, resp); + if (std.mem.indexOf(u8, body, "list_user_packages") != null) return dispatch("hex_list_user_packages", body, resp); + return .{ .status = 200, .body = errJson(resp, "Unrecognised GraphQL operation") }; +} + +// ============================================================================ +// HTTP/1.1 connection handler +// ============================================================================ + +const Protocol = enum { rest, grpc, graphql }; + +fn handleConnection(conn: std.net.Server.Connection, proto: Protocol) void { + defer conn.stream.close(); + var in_buf: [MAX_CONN_BUF]u8 = undefined; + const n = conn.stream.read(&in_buf) catch return; + const req = in_buf[0..n]; + + var path: []const u8 = "/"; + var body: []const u8 = ""; + if (n > 4) { + const line_end = std.mem.indexOf(u8, req, "\r\n") orelse req.len; + const first_line = req[0..line_end]; + const sp1 = std.mem.indexOfScalar(u8, first_line, ' ') orelse 0; + const rest_of = first_line[sp1 + 1 ..]; + const sp2 = std.mem.indexOfScalar(u8, rest_of, ' ') orelse rest_of.len; + path = rest_of[0..sp2]; + const body_sep = std.mem.indexOf(u8, req, "\r\n\r\n") orelse n; + body = req[@min(body_sep + 4, n)..]; + } + + var resp_buf: [MAX_CONN_BUF]u8 = undefined; + const result = switch (proto) { + .rest => dispatchRest(path, body, &resp_buf), + .grpc => dispatchGrpc(path, body, &resp_buf), + .graphql => dispatchGraphql(body, &resp_buf), + }; + + const content_type = switch (proto) { + .rest => "application/json", + .grpc => "application/grpc+json", + .graphql => "application/json", + }; + + var hdr_buf: [256]u8 = undefined; + const hdr = std.fmt.bufPrint(&hdr_buf, + "HTTP/1.1 {d} OK\r\nContent-Type: {s}\r\nContent-Length: {d}\r\nConnection: close\r\n\r\n", + .{ result.status, content_type, result.body.len }, + ) catch return; + _ = conn.stream.writeAll(hdr) catch return; + _ = conn.stream.writeAll(result.body) catch return; +} + +fn listenLoop(port: u16, proto: Protocol) void { + const addr = std.net.Address.initIp4(.{ 127, 0, 0, 1 }, port) catch return; + var server = addr.listen(.{ .reuse_address = true }) catch return; + defer server.deinit(); + while (true) { + const conn = server.accept() catch continue; + handleConnection(conn, proto); + } +} + +pub fn main() !void { + ffi.hex_init(); + const rest_thread = try std.Thread.spawn(.{}, listenLoop, .{ REST_PORT, Protocol.rest }); + const grpc_thread = try std.Thread.spawn(.{}, listenLoop, .{ GRPC_PORT, Protocol.grpc }); + const gql_thread = try std.Thread.spawn(.{}, listenLoop, .{ GQL_PORT, Protocol.graphql }); + rest_thread.join(); + grpc_thread.join(); + gql_thread.join(); +} diff --git a/cartridges/domains/registry/hex-mcp/benchmarks/quick-bench.sh b/cartridges/domains/registry/hex-mcp/benchmarks/quick-bench.sh new file mode 100755 index 0000000..a402367 --- /dev/null +++ b/cartridges/domains/registry/hex-mcp/benchmarks/quick-bench.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Benchmarks for hex-mcp cartridge. +set -euo pipefail + +echo "=== hex-mcp benchmarks ===" + +cd "$(dirname "${BASH_SOURCE[0]}")/../ffi" + +# Build with ReleaseFast for benchmarking +zig build -Doptimize=ReleaseFast 2>&1 + +echo "Session open/close cycle (1000 iterations):" +time for i in $(seq 1 1000); do + true +done + +echo "" +echo "Benchmark placeholder -- implement real benchmarks in Zig test blocks" +echo "or via the zig adapter HTTP benchmark tool." diff --git a/cartridges/domains/registry/hex-mcp/cartridge.json b/cartridges/domains/registry/hex-mcp/cartridge.json new file mode 100644 index 0000000..c165bfc --- /dev/null +++ b/cartridges/domains/registry/hex-mcp/cartridge.json @@ -0,0 +1,219 @@ +{ + "$schema": "https://boj.dev/schemas/cartridge/v1.json", + "spdx": "MPL-2.0", + "copyright": "Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath)", + "name": "hex-mcp", + "version": "0.2.0", + "description": "Hex.pm registry cartridge -- Elixir/Erlang package search, metadata retrieval, version listing, download stats, dependency analysis, owner listing, retirement status, and API key management via the Hex API", + "domain": "Registry", + "tier": "Ayo", + "protocols": [ + "MCP", + "REST" + ], + "auth": { + "method": "bearer_token", + "env_var": "HEX_API_KEY", + "credential_source": "vault-mcp" + }, + "api": { + "base_url": "https://hex.pm/api", + "content_type": "application/json" + }, + "tools": [ + { + "name": "hex_search_packages", + "description": "Search Hex.pm for Elixir/Erlang packages by text query with optional sort order", + "inputSchema": { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "Search query (package name, description)" + }, + "page": { + "type": "number", + "description": "Page number (default 1)" + }, + "sort": { + "type": "string", + "description": "Sort order: name, inserted_at, updated_at, total_downloads, recent_downloads" + } + }, + "required": [ + "query" + ] + } + }, + { + "name": "hex_get_package", + "description": "Get full metadata for a Hex package (description, repository, links, licenses, latest version)", + "inputSchema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Package name (e.g. 'phoenix', 'ecto', 'plug')" + } + }, + "required": [ + "name" + ] + } + }, + { + "name": "hex_get_release", + "description": "Get metadata for a specific release of a Hex package (requirements, checksum, retirement)", + "inputSchema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Package name" + }, + "version": { + "type": "string", + "description": "Version string (e.g. '1.7.14')" + } + }, + "required": [ + "name", + "version" + ] + } + }, + { + "name": "hex_list_releases", + "description": "List all published releases of a Hex package with timestamps, downloads, and retirement status", + "inputSchema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Package name" + } + }, + "required": [ + "name" + ] + } + }, + { + "name": "hex_get_downloads", + "description": "Get download statistics for a Hex package (total, recent, per-release)", + "inputSchema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Package name" + } + }, + "required": [ + "name" + ] + } + }, + { + "name": "hex_get_dependencies", + "description": "Get the dependency list (requirements) for a specific release of a Hex package", + "inputSchema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Package name" + }, + "version": { + "type": "string", + "description": "Release version to inspect" + } + }, + "required": [ + "name", + "version" + ] + } + }, + { + "name": "hex_get_owners", + "description": "List all owners of a Hex package with usernames and email addresses", + "inputSchema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Package name" + } + }, + "required": [ + "name" + ] + } + }, + { + "name": "hex_get_retirement", + "description": "Get retirement status and reason for a specific release of a Hex package", + "inputSchema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Package name" + }, + "version": { + "type": "string", + "description": "Release version to check" + } + }, + "required": [ + "name", + "version" + ] + } + }, + { + "name": "hex_get_user", + "description": "Get profile information for a Hex.pm user", + "inputSchema": { + "type": "object", + "properties": { + "username": { + "type": "string", + "description": "Hex.pm username" + } + }, + "required": [ + "username" + ] + } + }, + { + "name": "hex_list_user_packages", + "description": "List all packages owned by a Hex.pm user", + "inputSchema": { + "type": "object", + "properties": { + "username": { + "type": "string", + "description": "Hex.pm username" + } + }, + "required": [ + "username" + ] + } + } + ], + "ffi": { + "so_path": "ffi/zig-out/lib/libhex_mcp.so", + "abi_version": "ADR-0006", + "symbols": [ + "boj_cartridge_init", + "boj_cartridge_deinit", + "boj_cartridge_name", + "boj_cartridge_version", + "boj_cartridge_invoke" + ] + } +} diff --git a/cartridges/domains/registry/hex-mcp/ffi/README.adoc b/cartridges/domains/registry/hex-mcp/ffi/README.adoc new file mode 100644 index 0000000..6e3455b --- /dev/null +++ b/cartridges/domains/registry/hex-mcp/ffi/README.adoc @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += hex-mcp / ffi β€” Zig FFI layer +:orientation: shallow + +== Purpose + +C-ABI exports bridging this cartridge's Idris2 ABI to the runtime. Consumed by +the adapter for protocol dispatch and by the trunk loader for mount. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” produces `zig-out/lib/libhex.so`. +| `hex_mcp_ffi.zig` | C-ABI exports (16 exports, 7 inline tests, 403 lines). +|=== + +== Invariants + +* **Null-pointer rejection** on `[*c]const u8` arguments before any + dereference. +* **Negative return values** carry documented error meanings; zero is + success (matching the trunk error-code convention). +* **No allocator leaks** β€” every allocation path pairs with `defer free`. + +== Test/proof surface + +7 inline `test "..."` block(s) in `hex_mcp_ffi.zig`. Run with +`cd ffi && zig build test`. + +== Read-first + +. `hex_mcp_ffi.zig` β€” C-ABI signatures and guards. +. Trunk `ffi/zig/src/catalogue.zig` for how this `.so` is loaded. diff --git a/cartridges/domains/registry/hex-mcp/ffi/build.zig b/cartridges/domains/registry/hex-mcp/ffi/build.zig new file mode 100644 index 0000000..e044888 --- /dev/null +++ b/cartridges/domains/registry/hex-mcp/ffi/build.zig @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + // Shared ADR-0006 invoke-shim module (relative path up to boj-server trunk). + + const shim_mod = b.addModule("cartridge_shim", .{ + + .root_source_file = b.path("../../../ffi/zig/src/cartridge_shim.zig"), + + .target = target, + + .optimize = optimize, + + }); + + const ffi_mod = b.addModule("hex_mcp", .{ + .root_source_file = b.path("hex_mcp_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + ffi_mod.addImport("cartridge_shim", shim_mod); + + const lib = b.addLibrary(.{ + .name = "hex_mcp", + .root_module = ffi_mod, + .linkage = .dynamic, + }); + lib.linkLibC(); + b.installArtifact(lib); + + const tests = b.addTest(.{ .root_module = ffi_mod }); + tests.linkLibC(); + const run_tests = b.addRunArtifact(tests); + const test_step = b.step("test", "Run FFI tests"); + test_step.dependOn(&run_tests.step); +} + diff --git a/cartridges/domains/registry/hex-mcp/ffi/cartridge_shim.zig b/cartridges/domains/registry/hex-mcp/ffi/cartridge_shim.zig new file mode 100644 index 0000000..0e399db --- /dev/null +++ b/cartridges/domains/registry/hex-mcp/ffi/cartridge_shim.zig @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// cartridge_shim.zig β€” Shared helpers for the ADR-0006 five-symbol +// cartridge ABI (`boj_cartridge_init / deinit / name / version / invoke`). +// +// The shim centralises the seven-code return convention, NUL-argument +// guards, tool-name comparison, and the buffer-too-small path so each +// cartridge's `boj_cartridge_invoke` can stay short β€” typically a tool +// table plus `shim.writeResult(...)`. +// +// Cartridges import this file by relative path (no build-graph change +// needed). Example: +// +// const shim = @import("../../../ffi/zig/src/cartridge_shim.zig"); +// +// export fn boj_cartridge_invoke( +// tool_name: [*c]const u8, +// json_args: [*c]const u8, +// out_buf: [*c]u8, +// in_out_len: [*c]usize, +// ) callconv(.c) i32 { +// _ = json_args; +// if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; +// const body = if (shim.toolIs(tool_name, "foo")) "{\"result\":{}}" +// else return shim.RC_UNKNOWN_TOOL; +// return shim.writeResult(out_buf, in_out_len, body); +// } + +const std = @import("std"); + +// ── Return codes (ADR-0006 Β§Return codes) ──────────────────────────── +// +// Frozen by ADR-0006. New failure modes compose these via the error +// JSON body β€” the integer surface does not grow without a follow-up ADR. + +pub const RC_SUCCESS: i32 = 0; +pub const RC_UNKNOWN_TOOL: i32 = -1; +pub const RC_BAD_ARGS: i32 = -2; +pub const RC_BUFFER_TOO_SMALL: i32 = -3; +pub const RC_RUNTIME_ERROR: i32 = -4; +pub const RC_PANIC: i32 = -5; +pub const RC_AUTH_DENIED: i32 = -6; + +// ── Invoke-path helpers ────────────────────────────────────────────── + +/// True if any of the three mandatory `boj_cartridge_invoke` output-path +/// pointers is null. Use at the top of every invoke to short-circuit to +/// `RC_BAD_ARGS`. +pub fn invokeArgsNull( + tool_name: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) bool { + return tool_name == null or out_buf == null or in_out_len == null; +} + +/// Compare a C-NUL-terminated tool-name pointer against a Zig string +/// literal. Caller must have already verified `tool_name` is non-null +/// (usually via `invokeArgsNull`). +pub fn toolIs(tool_name: [*c]const u8, expected: []const u8) bool { + const s = std.mem.span(@as([*:0]const u8, @ptrCast(tool_name))); + return std.mem.eql(u8, s, expected); +} + +/// Copy `body` into `out_buf[0..*in_out_len]` (as a capacity) and update +/// `*in_out_len` to the number of bytes written. Returns `RC_SUCCESS`. +/// +/// If `body.len` exceeds the current capacity stored in `*in_out_len`, +/// sets `*in_out_len` to the required size and returns +/// `RC_BUFFER_TOO_SMALL` β€” the caller is then expected to re-allocate +/// and retry, per ADR-0006 Β§Memory ownership. +/// +/// Caller must have already verified that `out_buf` and `in_out_len` +/// are non-null. +pub fn writeResult( + out_buf: [*c]u8, + in_out_len: [*c]usize, + body: []const u8, +) i32 { + const cap = in_out_len.*; + if (body.len > cap) { + in_out_len.* = body.len; + return RC_BUFFER_TOO_SMALL; + } + @memcpy(out_buf[0..body.len], body); + in_out_len.* = body.len; + return RC_SUCCESS; +} + +// ── Tests ──────────────────────────────────────────────────────────── + +test "writeResult: body fits, writes and sets length" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); + try std.testing.expectEqualStrings("hello", buf[0..len]); +} + +test "writeResult: too small returns -3 and sets required length" { + var buf: [2]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_BUFFER_TOO_SMALL, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: exact-fit succeeds" { + var buf: [5]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: empty body" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, ""); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 0), len); +} + +test "toolIs: matches and rejects" { + const name: [*:0]const u8 = "foo"; + try std.testing.expect(toolIs(@ptrCast(name), "foo")); + try std.testing.expect(!toolIs(@ptrCast(name), "bar")); + try std.testing.expect(!toolIs(@ptrCast(name), "foobar")); + try std.testing.expect(!toolIs(@ptrCast(name), "fo")); +} + +test "invokeArgsNull: detects each null slot" { + var buf: [4]u8 = undefined; + var len: usize = 4; + const name: [*:0]const u8 = "x"; + try std.testing.expect(!invokeArgsNull(@ptrCast(name), &buf, &len)); + try std.testing.expect(invokeArgsNull(null, &buf, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), null, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), &buf, null)); +} diff --git a/cartridges/domains/registry/hex-mcp/ffi/hex_mcp_ffi.zig b/cartridges/domains/registry/hex-mcp/ffi/hex_mcp_ffi.zig new file mode 100644 index 0000000..126edca --- /dev/null +++ b/cartridges/domains/registry/hex-mcp/ffi/hex_mcp_ffi.zig @@ -0,0 +1,512 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// hex_mcp_ffi.zig β€” C-ABI FFI implementation for hex-mcp cartridge. +// +// Implements the state machine defined in HexMcp.SafeRegistry (Idris2 ABI). +// State machine: Unauthenticated | Authenticated | RateLimited | Error +// Auth: Optional API key β€” most Hex.pm reads are public. +// REST API: https://hex.pm/api +// Actions: SearchPackages, GetPackage, GetRelease, ListReleases, GetDownloads, +// GetDependencies, GetOwners, GetRetirement, GetUser, ListUserPackages +// Thread-safe via std.Thread.Mutex. Fixed-size session pool, no heap allocations. + +const std = @import("std"); + +// --------------------------------------------------------------------------- +// State machine (matches Idris2 ABI SessionState exactly) +// --------------------------------------------------------------------------- + +/// Session authentication/lifecycle state. +/// 0 = Unauthenticated, 1 = Authenticated, 2 = RateLimited, 3 = Error. +pub const SessionState = enum(c_int) { + unauthenticated = 0, + authenticated = 1, + rate_limited = 2, + err = 3, +}; + +/// Hex.pm action identifiers matching Idris2 HexAction encoding. +pub const HexAction = enum(c_int) { + search_packages = 0, + get_package = 1, + get_release = 2, + list_releases = 3, + get_downloads = 4, + get_dependencies = 5, + get_owners = 6, + get_retirement = 7, + get_user = 8, + list_user_packages = 9, +}; + +/// Check valid state transitions per the Idris2 ValidTransition proof. +fn isValidTransition(from: SessionState, to: SessionState) bool { + return switch (from) { + .unauthenticated => to == .authenticated or to == .rate_limited or to == .err, + .authenticated => to == .unauthenticated or to == .rate_limited or to == .err, + .rate_limited => to == .authenticated or to == .unauthenticated, + .err => to == .authenticated or to == .unauthenticated, + }; +} + +// --------------------------------------------------------------------------- +// Session slots (thread-safe, fixed-size pool) +// --------------------------------------------------------------------------- + +const MAX_SESSIONS: usize = 16; + +const SessionSlot = struct { + active: bool = false, + state: SessionState = .unauthenticated, + api_call_count: u64 = 0, + last_action: c_int = -1, + search_count: u32 = 0, + package_lookups: u32 = 0, + dep_queries: u32 = 0, + owner_queries: u32 = 0, +}; + +var sessions: [MAX_SESSIONS]SessionSlot = .{SessionSlot{}} ** MAX_SESSIONS; +var mutex: std.Thread.Mutex = .{}; + +// --------------------------------------------------------------------------- +// C-ABI exports β€” state machine +// --------------------------------------------------------------------------- + +/// Check if a state transition is valid. Returns 1 (valid) or 0 (invalid). +pub export fn hex_mcp_can_transition(from: c_int, to: c_int) c_int { + const f = std.meta.intToEnum(SessionState, from) catch return 0; + const t = std.meta.intToEnum(SessionState, to) catch return 0; + return if (isValidTransition(f, t)) 1 else 0; +} + +/// Open an authenticated session. Returns slot index (>= 0) or error (< 0). +pub export fn hex_mcp_authenticate(dummy: c_int) c_int { + _ = dummy; + mutex.lock(); + defer mutex.unlock(); + + for (&sessions, 0..) |*slot, idx| { + if (!slot.active) { + slot.active = true; + slot.state = .authenticated; + slot.api_call_count = 0; + slot.last_action = -1; + slot.search_count = 0; + slot.package_lookups = 0; + slot.dep_queries = 0; + slot.owner_queries = 0; + return @intCast(idx); + } + } + return -1; +} + +/// Open an unauthenticated session (read-only public access). +pub export fn hex_mcp_open_anonymous(dummy: c_int) c_int { + _ = dummy; + mutex.lock(); + defer mutex.unlock(); + + for (&sessions, 0..) |*slot, idx| { + if (!slot.active) { + slot.active = true; + slot.state = .unauthenticated; + slot.api_call_count = 0; + slot.last_action = -1; + slot.search_count = 0; + slot.package_lookups = 0; + slot.dep_queries = 0; + slot.owner_queries = 0; + return @intCast(idx); + } + } + return -1; +} + +/// Close a session. Returns 0 on success. +pub export fn hex_mcp_close(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + + sessions[idx] = SessionSlot{}; + return 0; +} + +/// Get current state of a session. Returns state int or -1 if invalid. +pub export fn hex_mcp_session_state(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + return @intFromEnum(slot.state); +} + +/// Signal rate limiting on a session (429 from Hex.pm). Returns 0 on success. +pub export fn hex_mcp_throttle(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + if (!isValidTransition(slot.state, .rate_limited)) return -2; + + sessions[idx].state = .rate_limited; + return 0; +} + +/// Clear rate limiting. Returns 0 on success. +pub export fn hex_mcp_unthrottle(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + if (!isValidTransition(slot.state, .authenticated) and !isValidTransition(slot.state, .unauthenticated)) return -2; + + sessions[idx].state = .authenticated; + return 0; +} + +/// Signal an error on a session. Returns 0 on success. +pub export fn hex_mcp_signal_error(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + if (!isValidTransition(slot.state, .err)) return -2; + + sessions[idx].state = .err; + return 0; +} + +// --------------------------------------------------------------------------- +// C-ABI exports β€” action recording and metrics +// --------------------------------------------------------------------------- + +/// Record an API call on a session. Returns 0 on success. +pub export fn hex_mcp_record_call(slot_idx: c_int, action: c_int) c_int { + const act = std.meta.intToEnum(HexAction, action) catch return -3; + + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + if (slot.state == .rate_limited) return -2; + if (slot.state == .err) return -2; + + sessions[idx].api_call_count += 1; + sessions[idx].last_action = action; + + // Track category-specific counts + switch (act) { + .search_packages => sessions[idx].search_count += 1, + .get_package, .get_release, .list_releases, .get_retirement => sessions[idx].package_lookups += 1, + .get_dependencies => sessions[idx].dep_queries += 1, + .get_owners => sessions[idx].owner_queries += 1, + else => {}, + } + + return 0; +} + +/// Get API call count for a session. +pub export fn hex_mcp_call_count(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + return @intCast(slot.api_call_count); +} + +/// Get search query count. +pub export fn hex_mcp_search_count(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + return @intCast(slot.search_count); +} + +/// Get package lookup count. +pub export fn hex_mcp_package_lookup_count(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + return @intCast(slot.package_lookups); +} + +/// Get dependency query count. +pub export fn hex_mcp_dep_query_count(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + return @intCast(slot.dep_queries); +} + +/// Get owner query count. +pub export fn hex_mcp_owner_query_count(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + return @intCast(slot.owner_queries); +} + +/// Get total action count. Always returns 10. +pub export fn hex_mcp_action_count() c_int { + return 10; +} + +/// Reset all sessions (test/debug use only). +pub export fn hex_mcp_reset() void { + mutex.lock(); + defer mutex.unlock(); + sessions = .{SessionSlot{}} ** MAX_SESSIONS; +} + +// --------------------------------------------------------------------------- +// ═══════════════════════════════════════════════════════════════════════ +// Standard ABI (ADR-0005 four symbols + ADR-0006 invoke) +// ═══════════════════════════════════════════════════════════════════════ + +const shim = @import("cartridge_shim.zig"); + +const CARTRIDGE_NAME_PTR: [*:0]const u8 = "hex-mcp"; +const CARTRIDGE_VERSION_PTR: [*:0]const u8 = "0.1.0"; + +export fn boj_cartridge_init() callconv(.c) c_int { + return 0; +} + +export fn boj_cartridge_deinit() callconv(.c) void {} + +export fn boj_cartridge_name() callconv(.c) [*:0]const u8 { + return CARTRIDGE_NAME_PTR; +} + +export fn boj_cartridge_version() callconv(.c) [*:0]const u8 { + return CARTRIDGE_VERSION_PTR; +} + +/// Dispatch the cartridge.json MCP tools. Grade D Alpha stubs. +export fn boj_cartridge_invoke( + tool_name: [*c]const u8, + json_args: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) callconv(.c) i32 { + _ = json_args; + if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; + + const body: []const u8 = if (shim.toolIs(tool_name, "hex_search_packages")) + "{\"result\":{\"matches\":[],\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "hex_get_package")) + "{\"result\":{\"metadata\":{},\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "hex_get_release")) + "{\"result\":{\"metadata\":{},\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "hex_list_releases")) + "{\"result\":{\"items\":[],\"count\":0,\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "hex_get_downloads")) + "{\"result\":{\"metadata\":{},\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "hex_get_dependencies")) + "{\"result\":{\"metadata\":{},\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "hex_get_owners")) + "{\"result\":{\"metadata\":{},\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "hex_get_retirement")) + "{\"result\":{\"metadata\":{},\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "hex_get_user")) + "{\"result\":{\"metadata\":{},\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "hex_list_user_packages")) + "{\"result\":{\"items\":[],\"count\":0,\"status\":\"stub\"}}" +else + return shim.RC_UNKNOWN_TOOL; + + return shim.writeResult(out_buf, in_out_len, body); +} + +// Tests +// --------------------------------------------------------------------------- + +test "authenticated session lifecycle" { + hex_mcp_reset(); + + const slot = hex_mcp_authenticate(0); + try std.testing.expect(slot >= 0); + try std.testing.expectEqual(@as(c_int, 1), hex_mcp_session_state(slot)); + + try std.testing.expectEqual(@as(c_int, 0), hex_mcp_record_call(slot, 0)); + try std.testing.expectEqual(@as(c_int, 1), hex_mcp_call_count(slot)); + try std.testing.expectEqual(@as(c_int, 1), hex_mcp_search_count(slot)); + + try std.testing.expectEqual(@as(c_int, 0), hex_mcp_close(slot)); +} + +test "anonymous session lifecycle" { + hex_mcp_reset(); + + const slot = hex_mcp_open_anonymous(0); + try std.testing.expect(slot >= 0); + try std.testing.expectEqual(@as(c_int, 0), hex_mcp_session_state(slot)); + + try std.testing.expectEqual(@as(c_int, 0), hex_mcp_record_call(slot, 1)); + try std.testing.expectEqual(@as(c_int, 1), hex_mcp_package_lookup_count(slot)); + + try std.testing.expectEqual(@as(c_int, 0), hex_mcp_close(slot)); +} + +test "rate limiting flow" { + hex_mcp_reset(); + + const slot = hex_mcp_authenticate(0); + try std.testing.expect(slot >= 0); + + try std.testing.expectEqual(@as(c_int, 0), hex_mcp_throttle(slot)); + try std.testing.expectEqual(@as(c_int, 2), hex_mcp_session_state(slot)); + try std.testing.expectEqual(@as(c_int, -2), hex_mcp_record_call(slot, 0)); + + try std.testing.expectEqual(@as(c_int, 0), hex_mcp_unthrottle(slot)); + try std.testing.expectEqual(@as(c_int, 1), hex_mcp_session_state(slot)); +} + +test "error and recovery" { + hex_mcp_reset(); + + const slot = hex_mcp_authenticate(0); + try std.testing.expect(slot >= 0); + + try std.testing.expectEqual(@as(c_int, 0), hex_mcp_signal_error(slot)); + try std.testing.expectEqual(@as(c_int, 3), hex_mcp_session_state(slot)); + try std.testing.expectEqual(@as(c_int, -2), hex_mcp_record_call(slot, 0)); + + try std.testing.expectEqual(@as(c_int, 0), hex_mcp_close(slot)); +} + +test "category counting" { + hex_mcp_reset(); + + const slot = hex_mcp_authenticate(0); + try std.testing.expect(slot >= 0); + + try std.testing.expectEqual(@as(c_int, 0), hex_mcp_record_call(slot, 0)); // Search + try std.testing.expectEqual(@as(c_int, 0), hex_mcp_record_call(slot, 1)); // GetPackage + try std.testing.expectEqual(@as(c_int, 0), hex_mcp_record_call(slot, 5)); // GetDependencies + try std.testing.expectEqual(@as(c_int, 0), hex_mcp_record_call(slot, 6)); // GetOwners + + try std.testing.expectEqual(@as(c_int, 4), hex_mcp_call_count(slot)); + try std.testing.expectEqual(@as(c_int, 1), hex_mcp_search_count(slot)); + try std.testing.expectEqual(@as(c_int, 1), hex_mcp_package_lookup_count(slot)); + try std.testing.expectEqual(@as(c_int, 1), hex_mcp_dep_query_count(slot)); + try std.testing.expectEqual(@as(c_int, 1), hex_mcp_owner_query_count(slot)); +} + +test "transition validator" { + try std.testing.expectEqual(@as(c_int, 1), hex_mcp_can_transition(0, 1)); + try std.testing.expectEqual(@as(c_int, 1), hex_mcp_can_transition(1, 0)); + try std.testing.expectEqual(@as(c_int, 1), hex_mcp_can_transition(1, 2)); + try std.testing.expectEqual(@as(c_int, 1), hex_mcp_can_transition(2, 1)); + try std.testing.expectEqual(@as(c_int, 1), hex_mcp_can_transition(3, 0)); + try std.testing.expectEqual(@as(c_int, 0), hex_mcp_can_transition(2, 3)); +} + +test "slot exhaustion" { + hex_mcp_reset(); + + var slots: [MAX_SESSIONS]c_int = undefined; + for (&slots) |*s| { + s.* = hex_mcp_authenticate(0); + try std.testing.expect(s.* >= 0); + } + + try std.testing.expectEqual(@as(c_int, -1), hex_mcp_authenticate(0)); + + try std.testing.expectEqual(@as(c_int, 0), hex_mcp_close(slots[0])); + const new_slot = hex_mcp_authenticate(0); + try std.testing.expect(new_slot >= 0); +} + +// ═══════════════════════════════════════════════════════════════════════ +// ADR-0006 invoke dispatch tests +// ═══════════════════════════════════════════════════════════════════════ + +test "boj_cartridge_name returns hex-mcp" { + const n = std.mem.span(boj_cartridge_name()); + try std.testing.expectEqualStrings("hex-mcp", n); +} + +test "boj_cartridge_init returns 0" { + try std.testing.expectEqual(@as(c_int, 0), boj_cartridge_init()); +} + +test "invoke: each declared tool succeeds" { + var buf: [256]u8 = undefined; + const tools = [_][]const u8{ + "hex_search_packages", + "hex_get_package", + "hex_get_release", + "hex_list_releases", + "hex_get_downloads", + "hex_get_dependencies", + "hex_get_owners", + "hex_get_retirement", + "hex_get_user", + "hex_list_user_packages", + }; + for (tools) |t| { + var len: usize = buf.len; + const rc = boj_cartridge_invoke(t.ptr, "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, 0), rc); + try std.testing.expect(std.mem.indexOf(u8, buf[0..len], "result") != null); + } +} + +test "invoke: unknown tool returns -1" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("nope", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -1), rc); +} + +test "invoke: buffer too small returns -3" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("hex_search_packages", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -3), rc); + try std.testing.expect(len > 4); +} diff --git a/cartridges/domains/registry/hex-mcp/minter.toml b/cartridges/domains/registry/hex-mcp/minter.toml new file mode 100644 index 0000000..224a902 --- /dev/null +++ b/cartridges/domains/registry/hex-mcp/minter.toml @@ -0,0 +1,22 @@ +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> + +name = "hex-mcp" +description = "Hex.pm registry cartridge β€” Elixir/Erlang package search, metadata, release listing, download stats, dependencies, owners, retirement status, user profiles" +version = "0.2.0" +domain = "Registry" +protocols = [ + "MCP", + "REST", +] +tier = "Ayo" +backend = "universal" +generate_panel = true + +[auth] +method = "bearer_token" +credential_source = "vault-mcp" +fields = ["hex_api_key"] + +[api] +base_url = "https://hex.pm/api" diff --git a/cartridges/domains/registry/hex-mcp/mod.js b/cartridges/domains/registry/hex-mcp/mod.js new file mode 100644 index 0000000..e36a63f --- /dev/null +++ b/cartridges/domains/registry/hex-mcp/mod.js @@ -0,0 +1,225 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// hex-mcp/mod.js -- Hex.pm registry cartridge implementation. +// +// Provides MCP tool handlers for the Hex.pm REST API: +// - Package search (text query, sort order) +// - Package metadata (full info, specific releases) +// - Release listing with timestamps, downloads, retirement status +// - Download statistics (total and per-release) +// - Dependency inspection (requirements) +// - Owner listing +// - Retirement status checks +// - User profile and package listing +// +// Auth: Optional API key via HEX_API_KEY. Read-only operations +// are fully public. Key required only for publish/manage. +// API docs: https://github.com/hexpm/hex/blob/main/doc/API.md +// +// Usage: import { handleTool } from "./mod.js"; +// or: deno run --allow-net --allow-env mod.js + +const API_BASE = "https://hex.pm/api"; + +// --------------------------------------------------------------------------- +// Auth helper β€” retrieves the Hex API key from environment. +// In production, vault-mcp provides zero-knowledge credential proxying; +// for development, HEX_API_KEY is read directly. Optional for reads. +// --------------------------------------------------------------------------- + +function getToken() { + const token = typeof Deno !== "undefined" + ? Deno.env.get("HEX_API_KEY") + : process.env.HEX_API_KEY; + return token || null; +} + +// --------------------------------------------------------------------------- +// HTTP request helper β€” wraps fetch with Hex API headers, error handling, +// and response normalization. +// --------------------------------------------------------------------------- + +async function hexFetch(path, queryParams) { + const url = new URL(`${API_BASE}${path}`); + + // Append query parameters (pagination, filters, sorting) + if (queryParams) { + for (const [key, value] of Object.entries(queryParams)) { + if (value !== undefined && value !== null && value !== "") { + url.searchParams.set(key, String(value)); + } + } + } + + const headers = { + "Accept": "application/json", + "User-Agent": "boj-server/hex-mcp/0.2.0 (https://github.com/hyperpolymath/boj-server)", + }; + + const token = getToken(); + if (token) { + headers["Authorization"] = token; + } + + const response = await fetch(url.toString(), { method: "GET", headers }); + + // Handle rate limiting + if (response.status === 429) { + const retryAfter = response.headers.get("retry-after"); + return { + status: 429, + error: `Rate limited by Hex.pm. Retry after ${retryAfter || "unknown"} seconds.`, + retryAfter, + }; + } + + const data = await response.json().catch(() => ({})); + + if (!response.ok) { + const errorMessage = data.message || `HTTP ${response.status}`; + return { status: response.status, error: errorMessage, data }; + } + + return { status: response.status, data }; +} + +// --------------------------------------------------------------------------- +// Tool handler dispatch β€” maps MCP tool names to Hex API operations. +// Each handler validates required arguments, builds the API request, +// and returns structured results. +// --------------------------------------------------------------------------- + +export async function handleTool(toolName, args) { + switch (toolName) { + + // --- Search --- + + case "hex_search_packages": { + if (!args.query) return { error: "Missing required field: query" }; + const query = { + search: args.query, + page: args.page, + sort: args.sort, + }; + return hexFetch("/packages", query); + } + + // --- Package metadata --- + + case "hex_get_package": { + if (!args.name) return { error: "Missing required field: name" }; + return hexFetch(`/packages/${encodeURIComponent(args.name)}`); + } + + case "hex_get_release": { + if (!args.name) return { error: "Missing required field: name" }; + if (!args.version) return { error: "Missing required field: version" }; + return hexFetch(`/packages/${encodeURIComponent(args.name)}/releases/${args.version}`); + } + + case "hex_list_releases": { + if (!args.name) return { error: "Missing required field: name" }; + const result = await hexFetch(`/packages/${encodeURIComponent(args.name)}`); + if (result.error) return result; + return { + status: result.status, + data: { + name: args.name, + releases: (result.data.releases || []).map((r) => ({ + version: r.version, + inserted_at: r.inserted_at, + updated_at: r.updated_at, + retirement: r.retirement || null, + })), + }, + }; + } + + // --- Downloads --- + + case "hex_get_downloads": { + if (!args.name) return { error: "Missing required field: name" }; + const result = await hexFetch(`/packages/${encodeURIComponent(args.name)}`); + if (result.error) return result; + return { + status: result.status, + data: { + name: args.name, + downloads: result.data.downloads || {}, + }, + }; + } + + // --- Dependencies --- + + case "hex_get_dependencies": { + if (!args.name) return { error: "Missing required field: name" }; + if (!args.version) return { error: "Missing required field: version" }; + const result = await hexFetch(`/packages/${encodeURIComponent(args.name)}/releases/${args.version}`); + if (result.error) return result; + return { + status: result.status, + data: { + name: args.name, + version: args.version, + requirements: result.data.requirements || {}, + }, + }; + } + + // --- Owners --- + + case "hex_get_owners": { + if (!args.name) return { error: "Missing required field: name" }; + return hexFetch(`/packages/${encodeURIComponent(args.name)}/owners`); + } + + // --- Retirement --- + + case "hex_get_retirement": { + if (!args.name) return { error: "Missing required field: name" }; + if (!args.version) return { error: "Missing required field: version" }; + const result = await hexFetch(`/packages/${encodeURIComponent(args.name)}/releases/${args.version}`); + if (result.error) return result; + return { + status: result.status, + data: { + name: args.name, + version: args.version, + retirement: result.data.retirement || null, + retired: !!result.data.retirement, + }, + }; + } + + // --- Users --- + + case "hex_get_user": { + if (!args.username) return { error: "Missing required field: username" }; + return hexFetch(`/users/${encodeURIComponent(args.username)}`); + } + + case "hex_list_user_packages": { + if (!args.username) return { error: "Missing required field: username" }; + return hexFetch(`/users/${encodeURIComponent(args.username)}/packages`); + } + + default: + return { error: `Unknown hex-mcp tool: ${toolName}` }; + } +} + +// --------------------------------------------------------------------------- +// Cartridge metadata export β€” used by the BoJ cartridge loader to register +// this cartridge's tools without reading cartridge.json separately. +// --------------------------------------------------------------------------- + +export const metadata = { + name: "hex-mcp", + version: "0.2.0", + domain: "Registry", + tier: "Ayo", + protocols: ["MCP", "REST"], + toolCount: 10, +}; diff --git a/cartridges/domains/registry/hex-mcp/panels/manifest.json b/cartridges/domains/registry/hex-mcp/panels/manifest.json new file mode 100644 index 0000000..04bb215 --- /dev/null +++ b/cartridges/domains/registry/hex-mcp/panels/manifest.json @@ -0,0 +1,90 @@ +{ + "$schema": "https://panll.dev/schemas/panel-manifest/v1.json", + "spdx": "MPL-2.0", + "copyright": "Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath)", + "cartridge": "hex-mcp", + "domain": "Registry", + "version": "0.2.0", + "panels": [ + { + "id": "hex-auth-status", + "title": "Auth Status", + "description": "Hex.pm session state (Unauthenticated / Authenticated / RateLimited / Error)", + "type": "status-indicator", + "data_source": { + "endpoint": "/hex/state", + "method": "GET", + "refresh_interval_ms": 5000 + }, + "widgets": [ + { + "type": "state-badge", + "field": "state", + "states": { + "unauthenticated": { "color": "#95a5a6", "icon": "user-x" }, + "authenticated": { "color": "#2ecc71", "icon": "user-check" }, + "rate_limited": { "color": "#f39c12", "icon": "clock" }, + "error": { "color": "#e74c3c", "icon": "alert-triangle" } + } + } + ] + }, + { + "id": "hex-search-count", + "title": "Search Queries", + "description": "Number of package search queries executed in the current session", + "type": "metric", + "data_source": { + "endpoint": "/hex/status", + "method": "GET", + "refresh_interval_ms": 10000 + }, + "widgets": [ + { + "type": "counter", + "field": "search_count", + "label": "Searches", + "icon": "search" + } + ] + }, + { + "id": "hex-package-lookups", + "title": "Package Lookups", + "description": "Number of package metadata queries executed in the current session", + "type": "metric", + "data_source": { + "endpoint": "/hex/status", + "method": "GET", + "refresh_interval_ms": 10000 + }, + "widgets": [ + { + "type": "counter", + "field": "package_lookups", + "label": "Package Lookups", + "icon": "package" + } + ] + }, + { + "id": "hex-owner-queries", + "title": "Owner Queries", + "description": "Number of owner listing queries in the current session", + "type": "metric", + "data_source": { + "endpoint": "/hex/status", + "method": "GET", + "refresh_interval_ms": 10000 + }, + "widgets": [ + { + "type": "counter", + "field": "owner_queries", + "label": "Owner Queries", + "icon": "users" + } + ] + } + ] +} diff --git a/cartridges/domains/registry/hex-mcp/tests/integration_test.sh b/cartridges/domains/registry/hex-mcp/tests/integration_test.sh new file mode 100755 index 0000000..6da9ea8 --- /dev/null +++ b/cartridges/domains/registry/hex-mcp/tests/integration_test.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Integration tests for hex-mcp cartridge. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CART_DIR="$(dirname "$SCRIPT_DIR")" +FFI_DIR="$CART_DIR/ffi" + +echo "=== hex-mcp integration tests ===" + +# Build FFI +echo "[1/3] Building FFI..." +cd "$FFI_DIR" && zig build 2>&1 + +# Run Zig unit tests +echo "[2/3] Running FFI unit tests..." +cd "$FFI_DIR" && zig build test 2>&1 + +# Validate Idris2 ABI (if idris2 available) +echo "[3/3] Checking ABI..." +if command -v idris2 &>/dev/null; then + cd "$CART_DIR/abi" && idris2 --check HexMcp.SafeRegistry 2>&1 + echo " ABI: OK" +else + echo " ABI: SKIPPED (idris2 not in PATH)" +fi + +echo "" +echo "All tests passed for hex-mcp!" diff --git a/cartridges/domains/registry/npm-registry-mcp/README.adoc b/cartridges/domains/registry/npm-registry-mcp/README.adoc new file mode 100644 index 0000000..86f139d --- /dev/null +++ b/cartridges/domains/registry/npm-registry-mcp/README.adoc @@ -0,0 +1,85 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += npm-registry-mcp +Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk> +:spdx: MPL-2.0 +:tier: Ayo +:domain: Registry +:protocols: MCP, REST + +== Overview + +npm registry cartridge for the BoJ server. Provides type-safe access to +the npm registry API for package search, metadata retrieval, version listing, +download statistics, dependency analysis, maintainer lookup, audit advisories, +and SLSA build provenance attestation. + +=== State Machine + +`Unauthenticated -> Authenticated` (optional, most reads work without auth) + +`Authenticated -> RateLimited -> Authenticated` (normal flow) + +`Authenticated -> Error -> Unauthenticated` (error recovery) + +=== Actions (12) + +[cols="1,1"] +|=== +| Category | Actions + +| Search +| SearchPackages + +| Package Metadata +| GetPackage, GetPackageVersion, ListVersions, GetPackument + +| Downloads +| GetDownloads, GetDownloadsRange + +| Dependencies +| GetDependencies + +| Maintainers +| GetMaintainers, GetDistTags + +| Security +| GetAuditAdvisories, GetProvenance +|=== + +== Architecture + +[cols="1,1,2"] +|=== +| Layer | Language | Purpose + +| ABI +| Idris2 +| Formally verified state machine with dependent-type proofs (NpmRegistryMcp.SafeRegistry) + +| FFI +| Zig +| C-compatible implementation with thread-safe session pool, action recording, category counting + +| Adapter +| zig +| REST bridge to BoJ unified adapter protocol +|=== + +== Building + +[source,bash] +---- +# Build FFI shared library +cd ffi && zig build + +# Run FFI tests +cd ffi && zig build test + +# Type-check ABI +cd abi && idris2 --check npm_registry_mcp.ipkg +---- + +== Status + +Development -- customised with npm-specific search, downloads, audit, and provenance APIs. diff --git a/cartridges/domains/registry/npm-registry-mcp/abi/NpmRegistryMcp/SafeRegistry.idr b/cartridges/domains/registry/npm-registry-mcp/abi/NpmRegistryMcp/SafeRegistry.idr new file mode 100644 index 0000000..71aa1dc --- /dev/null +++ b/cartridges/domains/registry/npm-registry-mcp/abi/NpmRegistryMcp/SafeRegistry.idr @@ -0,0 +1,188 @@ +-- SPDX-License-Identifier: MPL-2.0 +-- Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +-- +-- NpmRegistryMcp.SafeRegistry β€” Type-safe ABI for npm-registry-mcp cartridge. +-- +-- Dependent-type state machine governing npm registry API access. +-- Encodes Bearer token auth (optional for reads), package search, +-- metadata retrieval, download stats, audit advisories, and +-- provenance attestation queries as compile-time invariants. +-- REST API: https://registry.npmjs.org +-- No unsafe escape hatches. + +module NpmRegistryMcp.SafeRegistry + +%default total + +-- --------------------------------------------------------------------------- +-- Authentication state machine +-- --------------------------------------------------------------------------- + +||| Session state for npm registry MCP operations. +||| Unauthenticated: no token loaded; read-only operations available. +||| Authenticated: npm Bearer token active, full access. +||| RateLimited: registry rate limit hit; must wait. +||| Error: unrecoverable error (invalid token, network failure). +public export +data SessionState + = Unauthenticated + | Authenticated + | RateLimited + | Error + +||| Proof that a state transition is valid. +||| Only these transitions are permitted in the session lifecycle. +public export +data ValidTransition : SessionState -> SessionState -> Type where + Authenticate : ValidTransition Unauthenticated Authenticated + Deauthenticate : ValidTransition Authenticated Unauthenticated + Throttle : ValidTransition Authenticated RateLimited + ThrottleAnon : ValidTransition Unauthenticated RateLimited + Unthrottle : ValidTransition RateLimited Authenticated + UnthrottleAnon : ValidTransition RateLimited Unauthenticated + AuthError : ValidTransition Authenticated Error + AnonError : ValidTransition Unauthenticated Error + RecoverToAuth : ValidTransition Error Authenticated + RecoverToAnon : ValidTransition Error Unauthenticated + +-- --------------------------------------------------------------------------- +-- C-ABI integer encoding +-- --------------------------------------------------------------------------- + +||| Encode session state as C-compatible integer for the Zig FFI boundary. +export +sessionStateToInt : SessionState -> Int +sessionStateToInt Unauthenticated = 0 +sessionStateToInt Authenticated = 1 +sessionStateToInt RateLimited = 2 +sessionStateToInt Error = 3 + +||| Decode integer back to session state. Returns Nothing for out-of-range. +export +intToSessionState : Int -> Maybe SessionState +intToSessionState 0 = Just Unauthenticated +intToSessionState 1 = Just Authenticated +intToSessionState 2 = Just RateLimited +intToSessionState 3 = Just Error +intToSessionState _ = Nothing + +||| Check if a state transition is valid (C-ABI export). +||| Returns 1 for valid, 0 for invalid. +export +npm_registry_mcp_can_transition : Int -> Int -> Int +npm_registry_mcp_can_transition from to = + case (intToSessionState from, intToSessionState to) of + (Just Unauthenticated, Just Authenticated) => 1 + (Just Authenticated, Just Unauthenticated) => 1 + (Just Authenticated, Just RateLimited) => 1 + (Just Unauthenticated, Just RateLimited) => 1 + (Just RateLimited, Just Authenticated) => 1 + (Just RateLimited, Just Unauthenticated) => 1 + (Just Authenticated, Just Error) => 1 + (Just Unauthenticated, Just Error) => 1 + (Just Error, Just Authenticated) => 1 + (Just Error, Just Unauthenticated) => 1 + _ => 0 + +-- --------------------------------------------------------------------------- +-- npm registry actions +-- --------------------------------------------------------------------------- + +||| Actions available through the npm registry MCP cartridge. +||| Grouped: Search, Package metadata, Downloads, Dependencies, +||| Maintainers, Dist-tags, Security, Provenance. +public export +data NpmAction + = SearchPackages + | GetPackage + | GetPackageVersion + | ListVersions + | GetDownloads + | GetDownloadsRange + | GetDependencies + | GetMaintainers + | GetDistTags + | GetAuditAdvisories + | GetProvenance + | GetPackument + +||| Whether an action requires Authenticated state. +||| npm registry allows unauthenticated read access for most operations. +export +actionRequiresAuth : NpmAction -> Bool +actionRequiresAuth _ = False + +||| Whether an action is a write/mutating operation. +||| All npm-registry-mcp actions are read-only queries. +export +actionIsMutating : NpmAction -> Bool +actionIsMutating _ = False + +||| Encode action as C-compatible integer for FFI. +export +actionToInt : NpmAction -> Int +actionToInt SearchPackages = 0 +actionToInt GetPackage = 1 +actionToInt GetPackageVersion = 2 +actionToInt ListVersions = 3 +actionToInt GetDownloads = 4 +actionToInt GetDownloadsRange = 5 +actionToInt GetDependencies = 6 +actionToInt GetMaintainers = 7 +actionToInt GetDistTags = 8 +actionToInt GetAuditAdvisories = 9 +actionToInt GetProvenance = 10 +actionToInt GetPackument = 11 + +||| Decode integer to npm action. +export +intToAction : Int -> Maybe NpmAction +intToAction 0 = Just SearchPackages +intToAction 1 = Just GetPackage +intToAction 2 = Just GetPackageVersion +intToAction 3 = Just ListVersions +intToAction 4 = Just GetDownloads +intToAction 5 = Just GetDownloadsRange +intToAction 6 = Just GetDependencies +intToAction 7 = Just GetMaintainers +intToAction 8 = Just GetDistTags +intToAction 9 = Just GetAuditAdvisories +intToAction 10 = Just GetProvenance +intToAction 11 = Just GetPackument +intToAction _ = Nothing + +-- --------------------------------------------------------------------------- +-- MCP tool declarations +-- --------------------------------------------------------------------------- + +||| Tools exposed via MCP protocol for this cartridge. +public export +data McpTool + = ToolSearchPackages + | ToolGetPackage + | ToolGetPackageVersion + | ToolListVersions + | ToolGetDownloads + | ToolGetDownloadsRange + | ToolGetDependencies + | ToolGetMaintainers + | ToolGetDistTags + | ToolGetAuditAdvisories + | ToolGetProvenance + | ToolGetPackument + +||| Check if a tool requires an authenticated session. +||| All npm registry read operations work without auth. +export +toolRequiresSession : McpTool -> Bool +toolRequiresSession _ = False + +||| Total tool count for this cartridge. +export +toolCount : Nat +toolCount = 12 + +||| Total action count for this cartridge. +export +actionCount : Nat +actionCount = 12 diff --git a/cartridges/domains/registry/npm-registry-mcp/abi/README.adoc b/cartridges/domains/registry/npm-registry-mcp/abi/README.adoc new file mode 100644 index 0000000..62c1b7c --- /dev/null +++ b/cartridges/domains/registry/npm-registry-mcp/abi/README.adoc @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += npm-registry-mcp / abi β€” Idris2 ABI layer +:orientation: shallow + +== Purpose + +Cartridge-local protocol types for `npm-registry-mcp`. Defines the data, tool, and +transition vocabulary this cartridge speaks. Inherits safety lemmas and the +matrix-cell identifier scheme from the trunk ABI at `src/abi/`. + +== Files + +1 Idris2 module(s), ~188 lines total. Lead module: +`SafeRegistry.idr`. + +No `.ipkg` β€” consumed directly by wider BoJ callers; type-checks inside the +trunk `boj.ipkg`. + +== Invariants + +* `%default total` at every module top. +* Zero `believe_me`, `assert_total`, `postulate`, `Admitted`, or + `sorry` (the trunk holds the four declared `believe_me` sites; leaf + cartridges carry none). + +== Test/proof surface + +Thin module(s), no inline tests. Proof surface = type-check success under the +trunk `idris2 --check`. + +== Read-first + +. `SafeRegistry.idr` β€” cartridge-local data records and enums. +. Trunk `src/abi/Boj/Catalogue.idr` for how this cell participates in the + 2D matrix. diff --git a/cartridges/domains/registry/npm-registry-mcp/abi/npm_registry_mcp.ipkg b/cartridges/domains/registry/npm-registry-mcp/abi/npm_registry_mcp.ipkg new file mode 100644 index 0000000..7576a7f --- /dev/null +++ b/cartridges/domains/registry/npm-registry-mcp/abi/npm_registry_mcp.ipkg @@ -0,0 +1,13 @@ +-- SPDX-License-Identifier: MPL-2.0 +-- Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +package npm_registry_mcp + +version = "0.2.0" +authors = "Jonathan D.A. Jewell" +brief = "Type-safe ABI for npm registry MCP cartridge β€” package search, metadata, downloads, audit, provenance" + +sourcedir = "." + +depends = base + +modules = NpmRegistryMcp.SafeRegistry diff --git a/cartridges/domains/registry/npm-registry-mcp/adapter/README.adoc b/cartridges/domains/registry/npm-registry-mcp/adapter/README.adoc new file mode 100644 index 0000000..e2263b9 --- /dev/null +++ b/cartridges/domains/registry/npm-registry-mcp/adapter/README.adoc @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += npm-registry-mcp / adapter β€” protocol bridge +:orientation: shallow + +== Purpose + +Exposes this cartridge's FFI over the wire protocols declared in +`cartridge.json`. Dispatches tool invocations to the C-ABI exports at +`../ffi/` and formats responses per protocol. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” depends on the sibling `ffi` target. +| `npm_registry_adapter.zig` | Protocol dispatch (212 lines). Tool catalogue matches `cartridge.json`. +| `SIDELINED-*.v.adoc` | zig predecessor(s). Not built β€” zig banned 2026-04-10. +|=== + +== Invariants + +* **Stateless** β€” all state lives behind the FFI, never in the adapter. +* **Response passthrough** β€” whatever the FFI returns goes back to the wire + unmodified (no embellishment, no silent recovery). +* **Cartridge.json is source of truth** for the tool catalogue; drift between + adapter and manifest is a CI failure. + +== Test/proof surface + +No inline tests at the adapter layer β€” protocol routing is exercised by the +trunk seam tests at `ffi/zig/src/seams.zig`. + +== Read-first + +. `npm_registry_adapter.zig` β€” dispatch function and per-protocol routers. +. `../cartridge.json` β€” the tool manifest the dispatcher mirrors. diff --git a/cartridges/domains/registry/npm-registry-mcp/adapter/build.zig b/cartridges/domains/registry/npm-registry-mcp/adapter/build.zig new file mode 100644 index 0000000..22da548 --- /dev/null +++ b/cartridges/domains/registry/npm-registry-mcp/adapter/build.zig @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// npm-registry-mcp/adapter/build.zig + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const ffi_mod = b.createModule(.{ + .root_source_file = b.path("../ffi/npm_registry_mcp_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + const adapter = b.addExecutable(.{ + .name = "npm_registry_adapter", + .root_source_file = b.path("npm_registry_adapter.zig"), + .target = target, + .optimize = optimize, + }); + adapter.root_module.addImport("npm_registry_mcp_ffi", ffi_mod); + b.installArtifact(adapter); + + const run_artifact = b.addRunArtifact(adapter); + const run_step = b.step("run", "Run the npm-registry-mcp adapter"); + run_step.dependOn(&run_artifact.step); + + const tests = b.addTest(.{ + .root_source_file = b.path("npm_registry_adapter.zig"), + .target = target, + .optimize = optimize, + }); + tests.root_module.addImport("npm_registry_mcp_ffi", ffi_mod); + const run_tests = b.addRunArtifact(tests); + const test_step = b.step("test", "Run npm-registry-mcp adapter tests"); + test_step.dependOn(&run_tests.step); +} diff --git a/cartridges/domains/registry/npm-registry-mcp/adapter/npm_registry_adapter.zig b/cartridges/domains/registry/npm-registry-mcp/adapter/npm_registry_adapter.zig new file mode 100644 index 0000000..14a30f0 --- /dev/null +++ b/cartridges/domains/registry/npm-registry-mcp/adapter/npm_registry_adapter.zig @@ -0,0 +1,212 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// npm-registry-mcp/adapter/npm_registry_adapter.zig -- Unified three-protocol adapter. +// +// Replaces the banned npm_registry_adapter.v (zig, removed 2026-04-12). +// +// Bridges the Zig FFI (npm_registry_mcp_ffi.zig) to three network protocols: +// REST :9073 POST /tools/<tool> +// gRPC-compat :9074 /NpmRegistryMcpService/<Method> +// GraphQL :9075 POST /graphql { query: "..." } +// +// npm registry: search, metadata, versions, downloads, audit advisories +// Tools: +// npm_search_packages +// npm_get_package +// npm_get_package_version +// npm_list_versions +// npm_get_downloads +// npm_get_downloads_range +// npm_get_dependencies +// npm_get_maintainers +// npm_get_dist_tags +// npm_get_audit_advisories +// npm_get_provenance +// npm_get_packument + +const std = @import("std"); +const ffi = @import("npm_registry_mcp_ffi"); + +const REST_PORT: u16 = 9073; +const GRPC_PORT: u16 = 9074; +const GQL_PORT: u16 = 9075; + +const MAX_CONN_BUF: usize = 16 * 1024; + +// ============================================================================ +// JSON response builders +// ============================================================================ + +fn okJson(buf: []u8, msg: []const u8) []u8 { + return std.fmt.bufPrint(buf, + \{{"success":true,"message":"{}"}} + , .{msg}) catch buf[0..0]; +} + +fn errJson(buf: []u8, msg: []const u8) []u8 { + return std.fmt.bufPrint(buf, + \{{"success":false,"error":"{}"}} + , .{msg}) catch buf[0..0]; +} + +fn statusJson(buf: []u8) []u8 { + return std.fmt.bufPrint(buf, + \{{"success":true,"state":"ready","service":"npm-registry-mcp"}} + , .{}) catch buf[0..0]; +} + +// ============================================================================ +// Tool dispatcher +// ============================================================================ + +const Response = struct { status: u16, body: []u8 }; + +fn dispatch(tool: []const u8, body: []const u8, resp: []u8) Response { + _ = body; + if (std.mem.eql(u8, tool, "npm_search_packages")) return .{ .status = 200, .body = okJson(resp, "npm_search_packages forwarded to backend") }; + if (std.mem.eql(u8, tool, "npm_get_package")) return .{ .status = 200, .body = okJson(resp, "npm_get_package forwarded to backend") }; + if (std.mem.eql(u8, tool, "npm_get_package_version")) return .{ .status = 200, .body = okJson(resp, "npm_get_package_version forwarded to backend") }; + if (std.mem.eql(u8, tool, "npm_list_versions")) return .{ .status = 200, .body = okJson(resp, "npm_list_versions forwarded to backend") }; + if (std.mem.eql(u8, tool, "npm_get_downloads")) return .{ .status = 200, .body = okJson(resp, "npm_get_downloads forwarded to backend") }; + if (std.mem.eql(u8, tool, "npm_get_downloads_range")) return .{ .status = 200, .body = okJson(resp, "npm_get_downloads_range forwarded to backend") }; + if (std.mem.eql(u8, tool, "npm_get_dependencies")) return .{ .status = 200, .body = okJson(resp, "npm_get_dependencies forwarded to backend") }; + if (std.mem.eql(u8, tool, "npm_get_maintainers")) return .{ .status = 200, .body = okJson(resp, "npm_get_maintainers forwarded to backend") }; + if (std.mem.eql(u8, tool, "npm_get_dist_tags")) return .{ .status = 200, .body = okJson(resp, "npm_get_dist_tags forwarded to backend") }; + if (std.mem.eql(u8, tool, "npm_get_audit_advisories")) return .{ .status = 200, .body = okJson(resp, "npm_get_audit_advisories forwarded to backend") }; + if (std.mem.eql(u8, tool, "npm_get_provenance")) return .{ .status = 200, .body = okJson(resp, "npm_get_provenance forwarded to backend") }; + if (std.mem.eql(u8, tool, "npm_get_packument")) return .{ .status = 200, .body = okJson(resp, "npm_get_packument forwarded to backend") }; + if (std.mem.eql(u8, tool, "status") or std.mem.eql(u8, tool, "health")) + return .{ .status = 200, .body = statusJson(resp) }; + return .{ .status = 404, .body = errJson(resp, "Unknown tool") }; +} + +// ============================================================================ +// REST handler +// ============================================================================ + +fn dispatchRest(path: []const u8, body: []const u8, resp: []u8) Response { + if (std.mem.startsWith(u8, path, "/tools/")) { + return dispatch(path["/tools/".len..], body, resp); + } + if (std.mem.eql(u8, path, "/status") or std.mem.eql(u8, path, "/health")) { + return .{ .status = 200, .body = statusJson(resp) }; + } + return .{ .status = 404, .body = errJson(resp, "Not found") }; +} + +// ============================================================================ +// gRPC-compat handler +// ============================================================================ + +fn dispatchGrpc(path: []const u8, body: []const u8, resp: []u8) Response { + const prefix = "/NpmRegistryMcpService/"; + if (!std.mem.startsWith(u8, path, prefix)) + return .{ .status = 404, .body = errJson(resp, "Not a recognized gRPC path") }; + const method = path[prefix.len..]; + const tool = blk: { + if (std.mem.eql(u8, method, "NpmSearchPackages")) break :blk "npm_search_packages"; + if (std.mem.eql(u8, method, "NpmGetPackage")) break :blk "npm_get_package"; + if (std.mem.eql(u8, method, "NpmGetPackageVersion")) break :blk "npm_get_package_version"; + if (std.mem.eql(u8, method, "NpmListVersions")) break :blk "npm_list_versions"; + if (std.mem.eql(u8, method, "NpmGetDownloads")) break :blk "npm_get_downloads"; + if (std.mem.eql(u8, method, "NpmGetDownloadsRange")) break :blk "npm_get_downloads_range"; + if (std.mem.eql(u8, method, "NpmGetDependencies")) break :blk "npm_get_dependencies"; + if (std.mem.eql(u8, method, "NpmGetMaintainers")) break :blk "npm_get_maintainers"; + if (std.mem.eql(u8, method, "NpmGetDistTags")) break :blk "npm_get_dist_tags"; + if (std.mem.eql(u8, method, "NpmGetAuditAdvisories")) break :blk "npm_get_audit_advisories"; + if (std.mem.eql(u8, method, "NpmGetProvenance")) break :blk "npm_get_provenance"; + if (std.mem.eql(u8, method, "NpmGetPackument")) break :blk "npm_get_packument"; + return .{ .status = 404, .body = errJson(resp, "Unknown gRPC method") }; + }; + return dispatch(tool, body, resp); +} + +// ============================================================================ +// GraphQL handler +// ============================================================================ + +fn dispatchGraphql(body: []const u8, resp: []u8) Response { + if (std.mem.indexOf(u8, body, "__schema") != null) + return .{ .status = 200, .body = okJson(resp, "schema introspection not yet supported") }; + if (std.mem.indexOf(u8, body, "search_packages") != null) return dispatch("npm_search_packages", body, resp); + if (std.mem.indexOf(u8, body, "get_package") != null) return dispatch("npm_get_package", body, resp); + if (std.mem.indexOf(u8, body, "get_package_version") != null) return dispatch("npm_get_package_version", body, resp); + if (std.mem.indexOf(u8, body, "list_versions") != null) return dispatch("npm_list_versions", body, resp); + if (std.mem.indexOf(u8, body, "get_downloads") != null) return dispatch("npm_get_downloads", body, resp); + if (std.mem.indexOf(u8, body, "get_downloads_range") != null) return dispatch("npm_get_downloads_range", body, resp); + if (std.mem.indexOf(u8, body, "get_dependencies") != null) return dispatch("npm_get_dependencies", body, resp); + if (std.mem.indexOf(u8, body, "get_maintainers") != null) return dispatch("npm_get_maintainers", body, resp); + if (std.mem.indexOf(u8, body, "get_dist_tags") != null) return dispatch("npm_get_dist_tags", body, resp); + if (std.mem.indexOf(u8, body, "get_audit_advisories") != null) return dispatch("npm_get_audit_advisories", body, resp); + if (std.mem.indexOf(u8, body, "get_provenance") != null) return dispatch("npm_get_provenance", body, resp); + if (std.mem.indexOf(u8, body, "get_packument") != null) return dispatch("npm_get_packument", body, resp); + return .{ .status = 200, .body = errJson(resp, "Unrecognised GraphQL operation") }; +} + +// ============================================================================ +// HTTP/1.1 connection handler +// ============================================================================ + +const Protocol = enum { rest, grpc, graphql }; + +fn handleConnection(conn: std.net.Server.Connection, proto: Protocol) void { + defer conn.stream.close(); + var in_buf: [MAX_CONN_BUF]u8 = undefined; + const n = conn.stream.read(&in_buf) catch return; + const req = in_buf[0..n]; + + var path: []const u8 = "/"; + var body: []const u8 = ""; + if (n > 4) { + const line_end = std.mem.indexOf(u8, req, "\r\n") orelse req.len; + const first_line = req[0..line_end]; + const sp1 = std.mem.indexOfScalar(u8, first_line, ' ') orelse 0; + const rest_of = first_line[sp1 + 1 ..]; + const sp2 = std.mem.indexOfScalar(u8, rest_of, ' ') orelse rest_of.len; + path = rest_of[0..sp2]; + const body_sep = std.mem.indexOf(u8, req, "\r\n\r\n") orelse n; + body = req[@min(body_sep + 4, n)..]; + } + + var resp_buf: [MAX_CONN_BUF]u8 = undefined; + const result = switch (proto) { + .rest => dispatchRest(path, body, &resp_buf), + .grpc => dispatchGrpc(path, body, &resp_buf), + .graphql => dispatchGraphql(body, &resp_buf), + }; + + const content_type = switch (proto) { + .rest => "application/json", + .grpc => "application/grpc+json", + .graphql => "application/json", + }; + + var hdr_buf: [256]u8 = undefined; + const hdr = std.fmt.bufPrint(&hdr_buf, + "HTTP/1.1 {d} OK\r\nContent-Type: {s}\r\nContent-Length: {d}\r\nConnection: close\r\n\r\n", + .{ result.status, content_type, result.body.len }, + ) catch return; + _ = conn.stream.writeAll(hdr) catch return; + _ = conn.stream.writeAll(result.body) catch return; +} + +fn listenLoop(port: u16, proto: Protocol) void { + const addr = std.net.Address.initIp4(.{ 127, 0, 0, 1 }, port) catch return; + var server = addr.listen(.{ .reuse_address = true }) catch return; + defer server.deinit(); + while (true) { + const conn = server.accept() catch continue; + handleConnection(conn, proto); + } +} + +pub fn main() !void { + ffi.npm_registry_init(); + const rest_thread = try std.Thread.spawn(.{}, listenLoop, .{ REST_PORT, Protocol.rest }); + const grpc_thread = try std.Thread.spawn(.{}, listenLoop, .{ GRPC_PORT, Protocol.grpc }); + const gql_thread = try std.Thread.spawn(.{}, listenLoop, .{ GQL_PORT, Protocol.graphql }); + rest_thread.join(); + grpc_thread.join(); + gql_thread.join(); +} diff --git a/cartridges/domains/registry/npm-registry-mcp/benchmarks/quick-bench.sh b/cartridges/domains/registry/npm-registry-mcp/benchmarks/quick-bench.sh new file mode 100755 index 0000000..4f979f3 --- /dev/null +++ b/cartridges/domains/registry/npm-registry-mcp/benchmarks/quick-bench.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Benchmarks for npm-registry-mcp cartridge. +set -euo pipefail + +echo "=== npm-registry-mcp benchmarks ===" + +cd "$(dirname "${BASH_SOURCE[0]}")/../ffi" + +# Build with ReleaseFast for benchmarking +zig build -Doptimize=ReleaseFast 2>&1 + +echo "Session open/close cycle (1000 iterations):" +time for i in $(seq 1 1000); do + # This would call the FFI benchmark binary + true +done + +echo "" +echo "Benchmark placeholder -- implement real benchmarks in Zig test blocks" +echo "or via the zig adapter HTTP benchmark tool." diff --git a/cartridges/domains/registry/npm-registry-mcp/cartridge.json b/cartridges/domains/registry/npm-registry-mcp/cartridge.json new file mode 100644 index 0000000..4f17f05 --- /dev/null +++ b/cartridges/domains/registry/npm-registry-mcp/cartridge.json @@ -0,0 +1,283 @@ +{ + "$schema": "https://boj.dev/schemas/cartridge/v1.json", + "spdx": "MPL-2.0", + "copyright": "Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath)", + "name": "npm-registry-mcp", + "version": "0.2.0", + "description": "npm registry cartridge -- package search, metadata retrieval, version listing, download stats, dependency analysis, maintainer lookup, and audit advisory queries via the npm registry API", + "domain": "Registry", + "tier": "Ayo", + "protocols": [ + "MCP", + "REST" + ], + "auth": { + "method": "bearer_token", + "env_var": "NPM_TOKEN", + "credential_source": "vault-mcp" + }, + "api": { + "base_url": "https://registry.npmjs.org", + "search_url": "https://registry.npmjs.org/-/v1/search", + "downloads_url": "https://api.npmjs.org/downloads", + "content_type": "application/json" + }, + "tools": [ + { + "name": "npm_search_packages", + "description": "Search the npm registry for packages by text query with optional filters", + "inputSchema": { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "Search query (package name, keywords, description)" + }, + "size": { + "type": "number", + "description": "Number of results (default 20, max 250)" + }, + "from": { + "type": "number", + "description": "Offset for pagination (default 0)" + }, + "quality": { + "type": "number", + "description": "Quality weight 0.0-1.0 (default 0.65)" + }, + "popularity": { + "type": "number", + "description": "Popularity weight 0.0-1.0 (default 0.98)" + }, + "maintenance": { + "type": "number", + "description": "Maintenance weight 0.0-1.0 (default 0.5)" + } + }, + "required": [ + "query" + ] + } + }, + { + "name": "npm_get_package", + "description": "Get full metadata for an npm package (all versions, dist-tags, maintainers, repository)", + "inputSchema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Package name (e.g. 'lodash', '@scope/pkg')" + } + }, + "required": [ + "name" + ] + } + }, + { + "name": "npm_get_package_version", + "description": "Get metadata for a specific version of an npm package", + "inputSchema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Package name" + }, + "version": { + "type": "string", + "description": "Semver version (e.g. '4.17.21') or dist-tag (e.g. 'latest')" + } + }, + "required": [ + "name", + "version" + ] + } + }, + { + "name": "npm_list_versions", + "description": "List all published versions of an npm package with timestamps", + "inputSchema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Package name" + } + }, + "required": [ + "name" + ] + } + }, + { + "name": "npm_get_downloads", + "description": "Get download statistics for an npm package over a time period", + "inputSchema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Package name" + }, + "period": { + "type": "string", + "description": "Time period: last-day, last-week, last-month, last-year, or date range YYYY-MM-DD:YYYY-MM-DD" + } + }, + "required": [ + "name", + "period" + ] + } + }, + { + "name": "npm_get_downloads_range", + "description": "Get per-day download counts for an npm package over a date range", + "inputSchema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Package name" + }, + "start": { + "type": "string", + "description": "Start date (YYYY-MM-DD)" + }, + "end": { + "type": "string", + "description": "End date (YYYY-MM-DD)" + } + }, + "required": [ + "name", + "start", + "end" + ] + } + }, + { + "name": "npm_get_dependencies", + "description": "Get the dependency tree for a specific version of a package", + "inputSchema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Package name" + }, + "version": { + "type": "string", + "description": "Version to inspect (default: latest)" + } + }, + "required": [ + "name" + ] + } + }, + { + "name": "npm_get_maintainers", + "description": "List all maintainers of an npm package with email addresses", + "inputSchema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Package name" + } + }, + "required": [ + "name" + ] + } + }, + { + "name": "npm_get_dist_tags", + "description": "Get distribution tags (latest, next, beta, etc.) for an npm package", + "inputSchema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Package name" + } + }, + "required": [ + "name" + ] + } + }, + { + "name": "npm_get_audit_advisories", + "description": "Query npm security advisories for a package and version range", + "inputSchema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Package name" + }, + "version_range": { + "type": "string", + "description": "Semver range to check (e.g. '>=4.0.0 <4.17.21')" + } + }, + "required": [ + "name" + ] + } + }, + { + "name": "npm_get_provenance", + "description": "Get SLSA build provenance attestation for a package version (if published with --provenance)", + "inputSchema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Package name" + }, + "version": { + "type": "string", + "description": "Version to check provenance for" + } + }, + "required": [ + "name", + "version" + ] + } + }, + { + "name": "npm_get_packument", + "description": "Get the abbreviated packument (lightweight metadata without full version details)", + "inputSchema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Package name" + } + }, + "required": [ + "name" + ] + } + } + ], + "ffi": { + "so_path": "ffi/zig-out/lib/libnpm_registry_mcp.so", + "abi_version": "ADR-0006", + "symbols": [ + "boj_cartridge_init", + "boj_cartridge_deinit", + "boj_cartridge_name", + "boj_cartridge_version", + "boj_cartridge_invoke" + ] + } +} diff --git a/cartridges/domains/registry/npm-registry-mcp/ffi/README.adoc b/cartridges/domains/registry/npm-registry-mcp/ffi/README.adoc new file mode 100644 index 0000000..5ed3808 --- /dev/null +++ b/cartridges/domains/registry/npm-registry-mcp/ffi/README.adoc @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += npm-registry-mcp / ffi β€” Zig FFI layer +:orientation: shallow + +== Purpose + +C-ABI exports bridging this cartridge's Idris2 ABI to the runtime. Consumed by +the adapter for protocol dispatch and by the trunk loader for mount. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” produces `zig-out/lib/libnpm-registry.so`. +| `npm_registry_mcp_ffi.zig` | C-ABI exports (15 exports, 7 inline tests, 427 lines). +|=== + +== Invariants + +* **Null-pointer rejection** on `[*c]const u8` arguments before any + dereference. +* **Negative return values** carry documented error meanings; zero is + success (matching the trunk error-code convention). +* **No allocator leaks** β€” every allocation path pairs with `defer free`. + +== Test/proof surface + +7 inline `test "..."` block(s) in `npm_registry_mcp_ffi.zig`. Run with +`cd ffi && zig build test`. + +== Read-first + +. `npm_registry_mcp_ffi.zig` β€” C-ABI signatures and guards. +. Trunk `ffi/zig/src/catalogue.zig` for how this `.so` is loaded. diff --git a/cartridges/domains/registry/npm-registry-mcp/ffi/build.zig b/cartridges/domains/registry/npm-registry-mcp/ffi/build.zig new file mode 100644 index 0000000..c75b3a8 --- /dev/null +++ b/cartridges/domains/registry/npm-registry-mcp/ffi/build.zig @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// build.zig β€” Build configuration for npm-registry-mcp FFI shared library and tests. + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + // Module + // Shared ADR-0006 invoke-shim module (relative path up to boj-server trunk). + + const shim_mod = b.addModule("cartridge_shim", .{ + + .root_source_file = b.path("../../../ffi/zig/src/cartridge_shim.zig"), + + .target = target, + + .optimize = optimize, + + }); + + const ffi_mod = b.addModule("npm_registry_mcp", .{ + .root_source_file = b.path("npm_registry_mcp_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + ffi_mod.addImport("cartridge_shim", shim_mod); + + // Shared library + const lib = b.addLibrary(.{ + .name = "npm_registry_mcp", + .root_module = ffi_mod, + .linkage = .dynamic, + }); + lib.linkLibC(); + b.installArtifact(lib); + + // Tests + const tests = b.addTest(.{ + .root_module = ffi_mod, + }); + tests.linkLibC(); + + const run_tests = b.addRunArtifact(tests); + const test_step = b.step("test", "Run FFI tests"); + test_step.dependOn(&run_tests.step); +} diff --git a/cartridges/domains/registry/npm-registry-mcp/ffi/cartridge_shim.zig b/cartridges/domains/registry/npm-registry-mcp/ffi/cartridge_shim.zig new file mode 100644 index 0000000..0e399db --- /dev/null +++ b/cartridges/domains/registry/npm-registry-mcp/ffi/cartridge_shim.zig @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// cartridge_shim.zig β€” Shared helpers for the ADR-0006 five-symbol +// cartridge ABI (`boj_cartridge_init / deinit / name / version / invoke`). +// +// The shim centralises the seven-code return convention, NUL-argument +// guards, tool-name comparison, and the buffer-too-small path so each +// cartridge's `boj_cartridge_invoke` can stay short β€” typically a tool +// table plus `shim.writeResult(...)`. +// +// Cartridges import this file by relative path (no build-graph change +// needed). Example: +// +// const shim = @import("../../../ffi/zig/src/cartridge_shim.zig"); +// +// export fn boj_cartridge_invoke( +// tool_name: [*c]const u8, +// json_args: [*c]const u8, +// out_buf: [*c]u8, +// in_out_len: [*c]usize, +// ) callconv(.c) i32 { +// _ = json_args; +// if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; +// const body = if (shim.toolIs(tool_name, "foo")) "{\"result\":{}}" +// else return shim.RC_UNKNOWN_TOOL; +// return shim.writeResult(out_buf, in_out_len, body); +// } + +const std = @import("std"); + +// ── Return codes (ADR-0006 Β§Return codes) ──────────────────────────── +// +// Frozen by ADR-0006. New failure modes compose these via the error +// JSON body β€” the integer surface does not grow without a follow-up ADR. + +pub const RC_SUCCESS: i32 = 0; +pub const RC_UNKNOWN_TOOL: i32 = -1; +pub const RC_BAD_ARGS: i32 = -2; +pub const RC_BUFFER_TOO_SMALL: i32 = -3; +pub const RC_RUNTIME_ERROR: i32 = -4; +pub const RC_PANIC: i32 = -5; +pub const RC_AUTH_DENIED: i32 = -6; + +// ── Invoke-path helpers ────────────────────────────────────────────── + +/// True if any of the three mandatory `boj_cartridge_invoke` output-path +/// pointers is null. Use at the top of every invoke to short-circuit to +/// `RC_BAD_ARGS`. +pub fn invokeArgsNull( + tool_name: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) bool { + return tool_name == null or out_buf == null or in_out_len == null; +} + +/// Compare a C-NUL-terminated tool-name pointer against a Zig string +/// literal. Caller must have already verified `tool_name` is non-null +/// (usually via `invokeArgsNull`). +pub fn toolIs(tool_name: [*c]const u8, expected: []const u8) bool { + const s = std.mem.span(@as([*:0]const u8, @ptrCast(tool_name))); + return std.mem.eql(u8, s, expected); +} + +/// Copy `body` into `out_buf[0..*in_out_len]` (as a capacity) and update +/// `*in_out_len` to the number of bytes written. Returns `RC_SUCCESS`. +/// +/// If `body.len` exceeds the current capacity stored in `*in_out_len`, +/// sets `*in_out_len` to the required size and returns +/// `RC_BUFFER_TOO_SMALL` β€” the caller is then expected to re-allocate +/// and retry, per ADR-0006 Β§Memory ownership. +/// +/// Caller must have already verified that `out_buf` and `in_out_len` +/// are non-null. +pub fn writeResult( + out_buf: [*c]u8, + in_out_len: [*c]usize, + body: []const u8, +) i32 { + const cap = in_out_len.*; + if (body.len > cap) { + in_out_len.* = body.len; + return RC_BUFFER_TOO_SMALL; + } + @memcpy(out_buf[0..body.len], body); + in_out_len.* = body.len; + return RC_SUCCESS; +} + +// ── Tests ──────────────────────────────────────────────────────────── + +test "writeResult: body fits, writes and sets length" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); + try std.testing.expectEqualStrings("hello", buf[0..len]); +} + +test "writeResult: too small returns -3 and sets required length" { + var buf: [2]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_BUFFER_TOO_SMALL, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: exact-fit succeeds" { + var buf: [5]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: empty body" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, ""); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 0), len); +} + +test "toolIs: matches and rejects" { + const name: [*:0]const u8 = "foo"; + try std.testing.expect(toolIs(@ptrCast(name), "foo")); + try std.testing.expect(!toolIs(@ptrCast(name), "bar")); + try std.testing.expect(!toolIs(@ptrCast(name), "foobar")); + try std.testing.expect(!toolIs(@ptrCast(name), "fo")); +} + +test "invokeArgsNull: detects each null slot" { + var buf: [4]u8 = undefined; + var len: usize = 4; + const name: [*:0]const u8 = "x"; + try std.testing.expect(!invokeArgsNull(@ptrCast(name), &buf, &len)); + try std.testing.expect(invokeArgsNull(null, &buf, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), null, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), &buf, null)); +} diff --git a/cartridges/domains/registry/npm-registry-mcp/ffi/npm_registry_mcp_ffi.zig b/cartridges/domains/registry/npm-registry-mcp/ffi/npm_registry_mcp_ffi.zig new file mode 100644 index 0000000..131bbae --- /dev/null +++ b/cartridges/domains/registry/npm-registry-mcp/ffi/npm_registry_mcp_ffi.zig @@ -0,0 +1,542 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// npm_registry_mcp_ffi.zig β€” C-ABI FFI implementation for npm-registry-mcp cartridge. +// +// Implements the state machine defined in NpmRegistryMcp.SafeRegistry (Idris2 ABI). +// State machine: Unauthenticated | Authenticated | RateLimited | Error +// Auth: Optional Bearer token β€” most npm registry reads are public. +// REST API: https://registry.npmjs.org +// Actions: SearchPackages, GetPackage, GetPackageVersion, ListVersions, +// GetDownloads, GetDownloadsRange, GetDependencies, GetMaintainers, +// GetDistTags, GetAuditAdvisories, GetProvenance, GetPackument +// Thread-safe via std.Thread.Mutex. Fixed-size session pool, no heap allocations. + +const std = @import("std"); + +// --------------------------------------------------------------------------- +// State machine (matches Idris2 ABI SessionState exactly) +// --------------------------------------------------------------------------- + +/// Session authentication/lifecycle state. +/// 0 = Unauthenticated, 1 = Authenticated, 2 = RateLimited, 3 = Error. +pub const SessionState = enum(c_int) { + unauthenticated = 0, + authenticated = 1, + rate_limited = 2, + err = 3, +}; + +/// npm registry action identifiers matching Idris2 NpmAction encoding. +pub const NpmAction = enum(c_int) { + search_packages = 0, + get_package = 1, + get_package_version = 2, + list_versions = 3, + get_downloads = 4, + get_downloads_range = 5, + get_dependencies = 6, + get_maintainers = 7, + get_dist_tags = 8, + get_audit_advisories = 9, + get_provenance = 10, + get_packument = 11, +}; + +/// Check valid state transitions per the Idris2 ValidTransition proof. +/// npm registry allows both authenticated and unauthenticated sessions, +/// so transitions are more flexible than services requiring auth. +fn isValidTransition(from: SessionState, to: SessionState) bool { + return switch (from) { + .unauthenticated => to == .authenticated or to == .rate_limited or to == .err, + .authenticated => to == .unauthenticated or to == .rate_limited or to == .err, + .rate_limited => to == .authenticated or to == .unauthenticated, + .err => to == .authenticated or to == .unauthenticated, + }; +} + +// --------------------------------------------------------------------------- +// Session slots (thread-safe, fixed-size pool) +// --------------------------------------------------------------------------- + +const MAX_SESSIONS: usize = 16; + +const SessionSlot = struct { + active: bool = false, + state: SessionState = .unauthenticated, + api_call_count: u64 = 0, + last_action: c_int = -1, + search_count: u32 = 0, + package_count: u32 = 0, + download_queries: u32 = 0, +}; + +var sessions: [MAX_SESSIONS]SessionSlot = .{SessionSlot{}} ** MAX_SESSIONS; +var mutex: std.Thread.Mutex = .{}; + +// --------------------------------------------------------------------------- +// C-ABI exports β€” state machine +// --------------------------------------------------------------------------- + +/// Check if a state transition is valid. Returns 1 (valid) or 0 (invalid). +pub export fn npm_registry_mcp_can_transition(from: c_int, to: c_int) c_int { + const f = std.meta.intToEnum(SessionState, from) catch return 0; + const t = std.meta.intToEnum(SessionState, to) catch return 0; + return if (isValidTransition(f, t)) 1 else 0; +} + +/// Open an authenticated session. Returns slot index (>= 0) or error (< 0). +/// Error codes: -1 = no free slots. +pub export fn npm_registry_mcp_authenticate(dummy: c_int) c_int { + _ = dummy; + mutex.lock(); + defer mutex.unlock(); + + for (&sessions, 0..) |*slot, idx| { + if (!slot.active) { + slot.active = true; + slot.state = .authenticated; + slot.api_call_count = 0; + slot.last_action = -1; + slot.search_count = 0; + slot.package_count = 0; + slot.download_queries = 0; + return @intCast(idx); + } + } + return -1; +} + +/// Open an unauthenticated session (read-only public access). +/// Returns slot index (>= 0) or error (< 0). +pub export fn npm_registry_mcp_open_anonymous(dummy: c_int) c_int { + _ = dummy; + mutex.lock(); + defer mutex.unlock(); + + for (&sessions, 0..) |*slot, idx| { + if (!slot.active) { + slot.active = true; + slot.state = .unauthenticated; + slot.api_call_count = 0; + slot.last_action = -1; + slot.search_count = 0; + slot.package_count = 0; + slot.download_queries = 0; + return @intCast(idx); + } + } + return -1; +} + +/// Close a session. Returns 0 on success. +/// Error codes: -1 = invalid slot. +pub export fn npm_registry_mcp_close(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + + sessions[idx] = SessionSlot{}; + return 0; +} + +/// Get current state of a session. Returns state int or -1 if invalid. +pub export fn npm_registry_mcp_session_state(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + return @intFromEnum(slot.state); +} + +/// Signal rate limiting on a session. Returns 0 on success. +pub export fn npm_registry_mcp_throttle(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + if (!isValidTransition(slot.state, .rate_limited)) return -2; + + sessions[idx].state = .rate_limited; + return 0; +} + +/// Clear rate limiting. Returns 0 on success. +pub export fn npm_registry_mcp_unthrottle(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + if (!isValidTransition(slot.state, .authenticated) and !isValidTransition(slot.state, .unauthenticated)) return -2; + + // Restore to previous meaningful state (authenticated if was authed) + sessions[idx].state = .authenticated; + return 0; +} + +/// Signal an error on a session. Returns 0 on success. +pub export fn npm_registry_mcp_signal_error(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + if (!isValidTransition(slot.state, .err)) return -2; + + sessions[idx].state = .err; + return 0; +} + +// --------------------------------------------------------------------------- +// C-ABI exports β€” action recording and metrics +// --------------------------------------------------------------------------- + +/// Record an API call on a session. Returns 0 on success. +/// Error codes: -1 = invalid slot, -2 = rate limited, -3 = invalid action. +pub export fn npm_registry_mcp_record_call(slot_idx: c_int, action: c_int) c_int { + const act = std.meta.intToEnum(NpmAction, action) catch return -3; + + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + if (slot.state == .rate_limited) return -2; + if (slot.state == .err) return -2; + + sessions[idx].api_call_count += 1; + sessions[idx].last_action = action; + + // Track category-specific counts + switch (act) { + .search_packages => sessions[idx].search_count += 1, + .get_package, .get_package_version, .get_packument => sessions[idx].package_count += 1, + .get_downloads, .get_downloads_range => sessions[idx].download_queries += 1, + else => {}, + } + + return 0; +} + +/// Get API call count for a session. Returns count or -1 if invalid. +pub export fn npm_registry_mcp_call_count(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + return @intCast(slot.api_call_count); +} + +/// Get search query count. Returns count or -1 if invalid. +pub export fn npm_registry_mcp_search_count(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + return @intCast(slot.search_count); +} + +/// Get package metadata query count. Returns count or -1 if invalid. +pub export fn npm_registry_mcp_package_count(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + return @intCast(slot.package_count); +} + +/// Get download statistics query count. Returns count or -1 if invalid. +pub export fn npm_registry_mcp_download_query_count(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + return @intCast(slot.download_queries); +} + +/// Get total action count. Always returns 12. +pub export fn npm_registry_mcp_action_count() c_int { + return 12; +} + +/// Reset all sessions (test/debug use only). +pub export fn npm_registry_mcp_reset() void { + mutex.lock(); + defer mutex.unlock(); + sessions = .{SessionSlot{}} ** MAX_SESSIONS; +} + +// --------------------------------------------------------------------------- +// ═══════════════════════════════════════════════════════════════════════ +// Standard ABI (ADR-0005 four symbols + ADR-0006 invoke) +// ═══════════════════════════════════════════════════════════════════════ + +const shim = @import("cartridge_shim.zig"); + +const CARTRIDGE_NAME_PTR: [*:0]const u8 = "npm-registry-mcp"; +const CARTRIDGE_VERSION_PTR: [*:0]const u8 = "0.1.0"; + +export fn boj_cartridge_init() callconv(.c) c_int { + return 0; +} + +export fn boj_cartridge_deinit() callconv(.c) void {} + +export fn boj_cartridge_name() callconv(.c) [*:0]const u8 { + return CARTRIDGE_NAME_PTR; +} + +export fn boj_cartridge_version() callconv(.c) [*:0]const u8 { + return CARTRIDGE_VERSION_PTR; +} + +/// Dispatch the cartridge.json MCP tools. Grade D Alpha stubs. +export fn boj_cartridge_invoke( + tool_name: [*c]const u8, + json_args: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) callconv(.c) i32 { + _ = json_args; + if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; + + const body: []const u8 = if (shim.toolIs(tool_name, "npm_search_packages")) + "{\"result\":{\"matches\":[],\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "npm_get_package")) + "{\"result\":{\"metadata\":{},\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "npm_get_package_version")) + "{\"result\":{\"metadata\":{},\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "npm_list_versions")) + "{\"result\":{\"items\":[],\"count\":0,\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "npm_get_downloads")) + "{\"result\":{\"metadata\":{},\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "npm_get_downloads_range")) + "{\"result\":{\"metadata\":{},\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "npm_get_dependencies")) + "{\"result\":{\"metadata\":{},\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "npm_get_maintainers")) + "{\"result\":{\"metadata\":{},\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "npm_get_dist_tags")) + "{\"result\":{\"metadata\":{},\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "npm_get_audit_advisories")) + "{\"result\":{\"metadata\":{},\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "npm_get_provenance")) + "{\"result\":{\"metadata\":{},\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "npm_get_packument")) + "{\"result\":{\"metadata\":{},\"status\":\"stub\"}}" +else + return shim.RC_UNKNOWN_TOOL; + + return shim.writeResult(out_buf, in_out_len, body); +} + +// Tests +// --------------------------------------------------------------------------- + +test "authenticated session lifecycle" { + npm_registry_mcp_reset(); + + const slot = npm_registry_mcp_authenticate(0); + try std.testing.expect(slot >= 0); + + // Should be authenticated (1) + try std.testing.expectEqual(@as(c_int, 1), npm_registry_mcp_session_state(slot)); + + // Record a search call (action 0) + try std.testing.expectEqual(@as(c_int, 0), npm_registry_mcp_record_call(slot, 0)); + try std.testing.expectEqual(@as(c_int, 1), npm_registry_mcp_call_count(slot)); + try std.testing.expectEqual(@as(c_int, 1), npm_registry_mcp_search_count(slot)); + + // Close + try std.testing.expectEqual(@as(c_int, 0), npm_registry_mcp_close(slot)); +} + +test "anonymous session lifecycle" { + npm_registry_mcp_reset(); + + const slot = npm_registry_mcp_open_anonymous(0); + try std.testing.expect(slot >= 0); + + // Should be unauthenticated (0) + try std.testing.expectEqual(@as(c_int, 0), npm_registry_mcp_session_state(slot)); + + // Record a package lookup (action 1) + try std.testing.expectEqual(@as(c_int, 0), npm_registry_mcp_record_call(slot, 1)); + try std.testing.expectEqual(@as(c_int, 1), npm_registry_mcp_package_count(slot)); + + // Close + try std.testing.expectEqual(@as(c_int, 0), npm_registry_mcp_close(slot)); +} + +test "rate limiting flow" { + npm_registry_mcp_reset(); + + const slot = npm_registry_mcp_authenticate(0); + try std.testing.expect(slot >= 0); + + // Throttle + try std.testing.expectEqual(@as(c_int, 0), npm_registry_mcp_throttle(slot)); + try std.testing.expectEqual(@as(c_int, 2), npm_registry_mcp_session_state(slot)); + + // Cannot invoke while rate limited + try std.testing.expectEqual(@as(c_int, -2), npm_registry_mcp_record_call(slot, 0)); + + // Unthrottle + try std.testing.expectEqual(@as(c_int, 0), npm_registry_mcp_unthrottle(slot)); + try std.testing.expectEqual(@as(c_int, 1), npm_registry_mcp_session_state(slot)); +} + +test "error and recovery" { + npm_registry_mcp_reset(); + + const slot = npm_registry_mcp_authenticate(0); + try std.testing.expect(slot >= 0); + + // Signal error + try std.testing.expectEqual(@as(c_int, 0), npm_registry_mcp_signal_error(slot)); + try std.testing.expectEqual(@as(c_int, 3), npm_registry_mcp_session_state(slot)); + + // Cannot invoke in error state + try std.testing.expectEqual(@as(c_int, -2), npm_registry_mcp_record_call(slot, 0)); + + // Close (recover) + try std.testing.expectEqual(@as(c_int, 0), npm_registry_mcp_close(slot)); +} + +test "category counting" { + npm_registry_mcp_reset(); + + const slot = npm_registry_mcp_authenticate(0); + try std.testing.expect(slot >= 0); + + // Search (0) + try std.testing.expectEqual(@as(c_int, 0), npm_registry_mcp_record_call(slot, 0)); + // GetPackage (1) + try std.testing.expectEqual(@as(c_int, 0), npm_registry_mcp_record_call(slot, 1)); + // GetDownloads (4) + try std.testing.expectEqual(@as(c_int, 0), npm_registry_mcp_record_call(slot, 4)); + // GetDownloadsRange (5) + try std.testing.expectEqual(@as(c_int, 0), npm_registry_mcp_record_call(slot, 5)); + + try std.testing.expectEqual(@as(c_int, 4), npm_registry_mcp_call_count(slot)); + try std.testing.expectEqual(@as(c_int, 1), npm_registry_mcp_search_count(slot)); + try std.testing.expectEqual(@as(c_int, 1), npm_registry_mcp_package_count(slot)); + try std.testing.expectEqual(@as(c_int, 2), npm_registry_mcp_download_query_count(slot)); +} + +test "transition validator" { + // Unauthenticated -> Authenticated + try std.testing.expectEqual(@as(c_int, 1), npm_registry_mcp_can_transition(0, 1)); + // Authenticated -> Unauthenticated + try std.testing.expectEqual(@as(c_int, 1), npm_registry_mcp_can_transition(1, 0)); + // Authenticated -> RateLimited + try std.testing.expectEqual(@as(c_int, 1), npm_registry_mcp_can_transition(1, 2)); + // Unauthenticated -> RateLimited + try std.testing.expectEqual(@as(c_int, 1), npm_registry_mcp_can_transition(0, 2)); + // RateLimited -> Authenticated + try std.testing.expectEqual(@as(c_int, 1), npm_registry_mcp_can_transition(2, 1)); + // Error -> Unauthenticated + try std.testing.expectEqual(@as(c_int, 1), npm_registry_mcp_can_transition(3, 0)); + + // Invalid: RateLimited -> Error + try std.testing.expectEqual(@as(c_int, 0), npm_registry_mcp_can_transition(2, 3)); + // Invalid: RateLimited -> RateLimited + try std.testing.expectEqual(@as(c_int, 0), npm_registry_mcp_can_transition(2, 2)); +} + +test "slot exhaustion" { + npm_registry_mcp_reset(); + + var slots: [MAX_SESSIONS]c_int = undefined; + for (&slots) |*s| { + s.* = npm_registry_mcp_authenticate(0); + try std.testing.expect(s.* >= 0); + } + + try std.testing.expectEqual(@as(c_int, -1), npm_registry_mcp_authenticate(0)); + + try std.testing.expectEqual(@as(c_int, 0), npm_registry_mcp_close(slots[0])); + const new_slot = npm_registry_mcp_authenticate(0); + try std.testing.expect(new_slot >= 0); +} + +// ═══════════════════════════════════════════════════════════════════════ +// ADR-0006 invoke dispatch tests +// ═══════════════════════════════════════════════════════════════════════ + +test "boj_cartridge_name returns npm-registry-mcp" { + const n = std.mem.span(boj_cartridge_name()); + try std.testing.expectEqualStrings("npm-registry-mcp", n); +} + +test "boj_cartridge_init returns 0" { + try std.testing.expectEqual(@as(c_int, 0), boj_cartridge_init()); +} + +test "invoke: each declared tool succeeds" { + var buf: [256]u8 = undefined; + const tools = [_][]const u8{ + "npm_search_packages", + "npm_get_package", + "npm_get_package_version", + "npm_list_versions", + "npm_get_downloads", + "npm_get_downloads_range", + "npm_get_dependencies", + "npm_get_maintainers", + "npm_get_dist_tags", + "npm_get_audit_advisories", + "npm_get_provenance", + "npm_get_packument", + }; + for (tools) |t| { + var len: usize = buf.len; + const rc = boj_cartridge_invoke(t.ptr, "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, 0), rc); + try std.testing.expect(std.mem.indexOf(u8, buf[0..len], "result") != null); + } +} + +test "invoke: unknown tool returns -1" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("nope", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -1), rc); +} + +test "invoke: buffer too small returns -3" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("npm_search_packages", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -3), rc); + try std.testing.expect(len > 4); +} diff --git a/cartridges/domains/registry/npm-registry-mcp/minter.toml b/cartridges/domains/registry/npm-registry-mcp/minter.toml new file mode 100644 index 0000000..8cf3d5b --- /dev/null +++ b/cartridges/domains/registry/npm-registry-mcp/minter.toml @@ -0,0 +1,24 @@ +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> + +name = "npm-registry-mcp" +description = "npm registry cartridge β€” package search, metadata, version listing, download stats, dependency analysis, audit advisories, provenance attestation" +version = "0.2.0" +domain = "Registry" +protocols = [ + "MCP", + "REST", +] +tier = "Ayo" +backend = "universal" +generate_panel = true + +[auth] +method = "bearer_token" +credential_source = "vault-mcp" +fields = ["npm_token"] + +[api] +base_url = "https://registry.npmjs.org" +search_url = "https://registry.npmjs.org/-/v1/search" +downloads_url = "https://api.npmjs.org/downloads" diff --git a/cartridges/domains/registry/npm-registry-mcp/mod.js b/cartridges/domains/registry/npm-registry-mcp/mod.js new file mode 100644 index 0000000..0240d41 --- /dev/null +++ b/cartridges/domains/registry/npm-registry-mcp/mod.js @@ -0,0 +1,313 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// npm-registry-mcp/mod.js -- npm registry cartridge implementation. +// +// Provides MCP tool handlers for the npm registry REST API: +// - Package search (text query, weighted scoring) +// - Package metadata (full packument, specific versions, dist-tags) +// - Download statistics (point-in-time and per-day ranges) +// - Dependency tree inspection +// - Maintainer listing +// - Security audit advisories +// - SLSA build provenance attestation +// +// Auth: Bearer token via NPM_TOKEN env var or vault-mcp proxy. +// Read-only operations work without auth; publish/unpublish require auth. +// API docs: https://github.com/npm/registry/blob/master/docs/REGISTRY-API.md +// +// Usage: import { handleTool } from "./mod.js"; +// or: deno run --allow-net --allow-env mod.js + +const REGISTRY_BASE = "https://registry.npmjs.org"; +const SEARCH_BASE = "https://registry.npmjs.org/-/v1/search"; +const DOWNLOADS_BASE = "https://api.npmjs.org/downloads"; + +// --------------------------------------------------------------------------- +// Auth helper β€” retrieves the npm auth token from environment. +// In production, vault-mcp provides zero-knowledge credential proxying; +// for development, NPM_TOKEN is read directly. Optional for read-only ops. +// --------------------------------------------------------------------------- + +function getToken() { + const token = typeof Deno !== "undefined" + ? Deno.env.get("NPM_TOKEN") + : process.env.NPM_TOKEN; + return token || null; +} + +// --------------------------------------------------------------------------- +// HTTP request helpers β€” wraps fetch with npm registry auth headers, error +// handling, and structured response formatting. +// --------------------------------------------------------------------------- + +/** + * Fetch from the main npm registry (registry.npmjs.org). + * Supports abbreviated metadata via Accept header. + */ +async function registryFetch(path, queryParams, abbreviated = false) { + const url = new URL(`${REGISTRY_BASE}${path}`); + + if (queryParams) { + for (const [key, value] of Object.entries(queryParams)) { + if (value !== undefined && value !== null && value !== "") { + url.searchParams.set(key, String(value)); + } + } + } + + const headers = { + "Accept": abbreviated + ? "application/vnd.npm.install-v1+json" + : "application/json", + "User-Agent": "boj-server/npm-registry-mcp/0.2.0", + }; + + const token = getToken(); + if (token) { + headers["Authorization"] = `Bearer ${token}`; + } + + const response = await fetch(url.toString(), { method: "GET", headers }); + const data = await response.json().catch(() => ({})); + + if (!response.ok) { + const errorMessage = data.error || `HTTP ${response.status}`; + return { status: response.status, error: errorMessage, data }; + } + + return { status: response.status, data }; +} + +/** + * Fetch from the npm downloads API (api.npmjs.org/downloads). + * No auth required; fully public endpoint. + */ +async function downloadsFetch(path) { + const url = `${DOWNLOADS_BASE}${path}`; + const response = await fetch(url, { + method: "GET", + headers: { + "Accept": "application/json", + "User-Agent": "boj-server/npm-registry-mcp/0.2.0", + }, + }); + + const data = await response.json().catch(() => ({})); + + if (!response.ok) { + const errorMessage = data.error || `HTTP ${response.status}`; + return { status: response.status, error: errorMessage, data }; + } + + return { status: response.status, data }; +} + +// --------------------------------------------------------------------------- +// Encode scoped package names for URL paths. +// '@scope/name' -> '%40scope%2Fname' +// --------------------------------------------------------------------------- + +function encodePkgName(name) { + return name.replaceAll("/", "%2f"); +} + +// --------------------------------------------------------------------------- +// Tool handler dispatch β€” maps MCP tool names to npm registry API operations. +// Each handler validates required arguments, builds the API request, +// and returns structured results. +// --------------------------------------------------------------------------- + +export async function handleTool(toolName, args) { + switch (toolName) { + + // --- Search --- + + case "npm_search_packages": { + if (!args.query) return { error: "Missing required field: query" }; + const query = { + text: args.query, + size: args.size, + from: args.from, + quality: args.quality, + popularity: args.popularity, + maintenance: args.maintenance, + }; + const url = new URL(SEARCH_BASE); + for (const [key, value] of Object.entries(query)) { + if (value !== undefined && value !== null) { + url.searchParams.set(key, String(value)); + } + } + const response = await fetch(url.toString(), { + method: "GET", + headers: { + "Accept": "application/json", + "User-Agent": "boj-server/npm-registry-mcp/0.2.0", + }, + }); + const data = await response.json().catch(() => ({})); + if (!response.ok) { + return { status: response.status, error: data.error || `HTTP ${response.status}`, data }; + } + return { status: response.status, data }; + } + + // --- Package metadata --- + + case "npm_get_package": { + if (!args.name) return { error: "Missing required field: name" }; + return registryFetch(`/${encodePkgName(args.name)}`); + } + + case "npm_get_package_version": { + if (!args.name) return { error: "Missing required field: name" }; + if (!args.version) return { error: "Missing required field: version" }; + return registryFetch(`/${encodePkgName(args.name)}/${args.version}`); + } + + case "npm_list_versions": { + if (!args.name) return { error: "Missing required field: name" }; + const result = await registryFetch(`/${encodePkgName(args.name)}`); + if (result.error) return result; + // Extract version list with timestamps from the time field + const versions = Object.keys(result.data.versions || {}); + const time = result.data.time || {}; + return { + status: result.status, + data: { + name: args.name, + version_count: versions.length, + versions: versions.map((v) => ({ version: v, published: time[v] || null })), + dist_tags: result.data["dist-tags"] || {}, + }, + }; + } + + case "npm_get_packument": { + if (!args.name) return { error: "Missing required field: name" }; + return registryFetch(`/${encodePkgName(args.name)}`, null, true); + } + + // --- Downloads --- + + case "npm_get_downloads": { + if (!args.name) return { error: "Missing required field: name" }; + if (!args.period) return { error: "Missing required field: period" }; + return downloadsFetch(`/point/${args.period}/${encodePkgName(args.name)}`); + } + + case "npm_get_downloads_range": { + if (!args.name) return { error: "Missing required field: name" }; + if (!args.start) return { error: "Missing required field: start" }; + if (!args.end) return { error: "Missing required field: end" }; + return downloadsFetch(`/range/${args.start}:${args.end}/${encodePkgName(args.name)}`); + } + + // --- Dependencies --- + + case "npm_get_dependencies": { + if (!args.name) return { error: "Missing required field: name" }; + const version = args.version || "latest"; + const result = await registryFetch(`/${encodePkgName(args.name)}/${version}`); + if (result.error) return result; + return { + status: result.status, + data: { + name: args.name, + version: result.data.version, + dependencies: result.data.dependencies || {}, + devDependencies: result.data.devDependencies || {}, + peerDependencies: result.data.peerDependencies || {}, + optionalDependencies: result.data.optionalDependencies || {}, + }, + }; + } + + // --- Maintainers --- + + case "npm_get_maintainers": { + if (!args.name) return { error: "Missing required field: name" }; + const result = await registryFetch(`/${encodePkgName(args.name)}/latest`); + if (result.error) return result; + return { + status: result.status, + data: { + name: args.name, + maintainers: result.data.maintainers || [], + }, + }; + } + + // --- Dist-tags --- + + case "npm_get_dist_tags": { + if (!args.name) return { error: "Missing required field: name" }; + const result = await registryFetch(`/${encodePkgName(args.name)}`); + if (result.error) return result; + return { + status: result.status, + data: { + name: args.name, + "dist-tags": result.data["dist-tags"] || {}, + }, + }; + } + + // --- Security --- + + case "npm_get_audit_advisories": { + if (!args.name) return { error: "Missing required field: name" }; + // Use the npm audit advisory API (public) + const url = `https://registry.npmjs.org/-/npm/v1/security/advisories?package=${encodeURIComponent(args.name)}`; + const response = await fetch(url, { + method: "GET", + headers: { + "Accept": "application/json", + "User-Agent": "boj-server/npm-registry-mcp/0.2.0", + }, + }); + const data = await response.json().catch(() => ({})); + if (!response.ok) { + return { status: response.status, error: data.error || `HTTP ${response.status}`, data }; + } + return { status: response.status, data }; + } + + // --- Provenance --- + + case "npm_get_provenance": { + if (!args.name) return { error: "Missing required field: name" }; + if (!args.version) return { error: "Missing required field: version" }; + // Get the version-specific metadata which includes attestations + const result = await registryFetch(`/${encodePkgName(args.name)}/${args.version}`); + if (result.error) return result; + return { + status: result.status, + data: { + name: args.name, + version: args.version, + dist: result.data.dist || {}, + _attestations: result.data._attestations || null, + }, + }; + } + + default: + return { error: `Unknown npm-registry-mcp tool: ${toolName}` }; + } +} + +// --------------------------------------------------------------------------- +// Cartridge metadata export β€” used by the BoJ cartridge loader to register +// this cartridge's tools without reading cartridge.json separately. +// --------------------------------------------------------------------------- + +export const metadata = { + name: "npm-registry-mcp", + version: "0.2.0", + domain: "Registry", + tier: "Ayo", + protocols: ["MCP", "REST"], + toolCount: 12, +}; diff --git a/cartridges/domains/registry/npm-registry-mcp/panels/manifest.json b/cartridges/domains/registry/npm-registry-mcp/panels/manifest.json new file mode 100644 index 0000000..741419d --- /dev/null +++ b/cartridges/domains/registry/npm-registry-mcp/panels/manifest.json @@ -0,0 +1,90 @@ +{ + "$schema": "https://panll.dev/schemas/panel-manifest/v1.json", + "spdx": "MPL-2.0", + "copyright": "Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath)", + "cartridge": "npm-registry-mcp", + "domain": "Registry", + "version": "0.2.0", + "panels": [ + { + "id": "npm-auth-status", + "title": "Auth Status", + "description": "npm registry session state (Unauthenticated / Authenticated / RateLimited / Error)", + "type": "status-indicator", + "data_source": { + "endpoint": "/npm-registry/state", + "method": "GET", + "refresh_interval_ms": 5000 + }, + "widgets": [ + { + "type": "state-badge", + "field": "state", + "states": { + "unauthenticated": { "color": "#95a5a6", "icon": "user-x" }, + "authenticated": { "color": "#2ecc71", "icon": "user-check" }, + "rate_limited": { "color": "#f39c12", "icon": "clock" }, + "error": { "color": "#e74c3c", "icon": "alert-triangle" } + } + } + ] + }, + { + "id": "npm-search-count", + "title": "Search Queries", + "description": "Number of package search queries executed in the current session", + "type": "metric", + "data_source": { + "endpoint": "/npm-registry/status", + "method": "GET", + "refresh_interval_ms": 10000 + }, + "widgets": [ + { + "type": "counter", + "field": "search_count", + "label": "Searches", + "icon": "search" + } + ] + }, + { + "id": "npm-package-count", + "title": "Package Lookups", + "description": "Number of package metadata queries executed in the current session", + "type": "metric", + "data_source": { + "endpoint": "/npm-registry/status", + "method": "GET", + "refresh_interval_ms": 10000 + }, + "widgets": [ + { + "type": "counter", + "field": "package_count", + "label": "Packages", + "icon": "package" + } + ] + }, + { + "id": "npm-download-queries", + "title": "Download Stat Queries", + "description": "Number of download statistics queries executed in the current session", + "type": "metric", + "data_source": { + "endpoint": "/npm-registry/status", + "method": "GET", + "refresh_interval_ms": 10000 + }, + "widgets": [ + { + "type": "counter", + "field": "download_queries", + "label": "Download Queries", + "icon": "bar-chart" + } + ] + } + ] +} diff --git a/cartridges/domains/registry/npm-registry-mcp/tests/integration_test.sh b/cartridges/domains/registry/npm-registry-mcp/tests/integration_test.sh new file mode 100755 index 0000000..7a5bc1e --- /dev/null +++ b/cartridges/domains/registry/npm-registry-mcp/tests/integration_test.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Integration tests for npm-registry-mcp cartridge. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CART_DIR="$(dirname "$SCRIPT_DIR")" +FFI_DIR="$CART_DIR/ffi" + +echo "=== npm-registry-mcp integration tests ===" + +# Build FFI +echo "[1/3] Building FFI..." +cd "$FFI_DIR" && zig build 2>&1 + +# Run Zig unit tests +echo "[2/3] Running FFI unit tests..." +cd "$FFI_DIR" && zig build test 2>&1 + +# Validate Idris2 ABI (if idris2 available) +echo "[3/3] Checking ABI..." +if command -v idris2 &>/dev/null; then + cd "$CART_DIR/abi" && idris2 --check NpmRegistryMcp.SafeRegistry 2>&1 + echo " ABI: OK" +else + echo " ABI: SKIPPED (idris2 not in PATH)" +fi + +echo "" +echo "All tests passed for npm-registry-mcp!" diff --git a/cartridges/domains/registry/opam-mcp/README.adoc b/cartridges/domains/registry/opam-mcp/README.adoc new file mode 100644 index 0000000..daa84ee --- /dev/null +++ b/cartridges/domains/registry/opam-mcp/README.adoc @@ -0,0 +1,88 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += opam-mcp +Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk> +:spdx: MPL-2.0 +:tier: Ayo +:domain: Registry +:protocols: MCP, REST + +== Overview + +opam registry cartridge for the BoJ server. Provides type-safe access to +the opam.ocaml.org API for OCaml package search, metadata retrieval, version +listing, dependency analysis, reverse dependency lookup, maintainer/author +listing, tag browsing, full package listing, and raw opam file retrieval. + +No authentication required β€” opam is a fully public registry. + +=== State Machine + +`Active -> RateLimited -> Active` (normal flow, 429 handling) + +`Active -> Error -> Active` (error recovery) + +=== Actions (10) + +[cols="1,1"] +|=== +| Category | Actions + +| Search +| SearchPackages + +| Package Metadata +| GetPackage, GetVersion, ListVersions + +| Dependencies +| GetDependencies, GetReverseDependencies + +| Maintainers +| GetMaintainers + +| Tags +| GetTags + +| Listing +| ListAllPackages + +| Raw Files +| GetOpamFile +|=== + +== Architecture + +[cols="1,1,2"] +|=== +| Layer | Language | Purpose + +| ABI +| Idris2 +| Formally verified state machine with dependent-type proofs (OpamMcp.SafeRegistry) + +| FFI +| Zig +| C-compatible implementation with thread-safe session pool, action recording, category counting + +| Adapter +| zig +| REST bridge to BoJ unified adapter protocol +|=== + +== Building + +[source,bash] +---- +# Build FFI shared library +cd ffi && zig build + +# Run FFI tests +cd ffi && zig build test + +# Type-check ABI +cd abi && idris2 --check OpamMcp.SafeRegistry +---- + +== Status + +Development -- customised with opam-specific search, reverse deps, tags, and raw opam file APIs. diff --git a/cartridges/domains/registry/opam-mcp/abi/OpamMcp/SafeRegistry.idr b/cartridges/domains/registry/opam-mcp/abi/OpamMcp/SafeRegistry.idr new file mode 100644 index 0000000..9bfbe23 --- /dev/null +++ b/cartridges/domains/registry/opam-mcp/abi/OpamMcp/SafeRegistry.idr @@ -0,0 +1,165 @@ +-- SPDX-License-Identifier: MPL-2.0 +-- Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +-- +-- OpamMcp.SafeRegistry β€” Type-safe ABI for opam-mcp cartridge. +-- +-- Dependent-type state machine governing opam.ocaml.org API access. +-- Encodes package search, metadata retrieval, version listing, +-- dependency analysis, reverse dependency lookup, maintainer listing, +-- tag retrieval, full package listing, and raw opam file access +-- as compile-time invariants. +-- REST API: https://opam.ocaml.org/api +-- No unsafe escape hatches. No auth required (fully public registry). + +module OpamMcp.SafeRegistry + +%default total + +-- --------------------------------------------------------------------------- +-- Session state machine +-- --------------------------------------------------------------------------- + +||| Session state for opam MCP operations. +||| Active: session is operational, ready for queries. +||| RateLimited: opam.ocaml.org rate limit hit (429); must wait. +||| Error: unrecoverable error (network failure). +||| Note: opam has no auth, so Active replaces Unauthenticated/Authenticated. +public export +data SessionState + = Active + | RateLimited + | Error + +||| Proof that a state transition is valid. +||| opam is always public β€” no auth transitions needed. +public export +data ValidTransition : SessionState -> SessionState -> Type where + Throttle : ValidTransition Active RateLimited + Unthrottle : ValidTransition RateLimited Active + SignalError : ValidTransition Active Error + RecoverToActive : ValidTransition Error Active + +-- --------------------------------------------------------------------------- +-- C-ABI integer encoding +-- --------------------------------------------------------------------------- + +||| Encode session state as C-compatible integer for FFI boundary. +export +sessionStateToInt : SessionState -> Int +sessionStateToInt Active = 0 +sessionStateToInt RateLimited = 1 +sessionStateToInt Error = 2 + +||| Decode integer back to session state. Returns Nothing for out-of-range. +export +intToSessionState : Int -> Maybe SessionState +intToSessionState 0 = Just Active +intToSessionState 1 = Just RateLimited +intToSessionState 2 = Just Error +intToSessionState _ = Nothing + +||| Check if a state transition is valid (C-ABI export). +export +opam_mcp_can_transition : Int -> Int -> Int +opam_mcp_can_transition from to = + case (intToSessionState from, intToSessionState to) of + (Just Active, Just RateLimited) => 1 + (Just RateLimited, Just Active) => 1 + (Just Active, Just Error) => 1 + (Just Error, Just Active) => 1 + _ => 0 + +-- --------------------------------------------------------------------------- +-- opam actions +-- --------------------------------------------------------------------------- + +||| Actions available through the opam MCP cartridge. +||| Grouped: Search, Metadata, Versions, Dependencies, ReverseDeps, +||| Maintainers, Tags, ListAll, OpamFile. +public export +data OpamAction + = SearchPackages + | GetPackage + | GetVersion + | ListVersions + | GetDependencies + | GetReverseDependencies + | GetMaintainers + | GetTags + | ListAllPackages + | GetOpamFile + +||| Whether an action requires authentication. +||| opam is fully public β€” no auth required for any action. +export +actionRequiresAuth : OpamAction -> Bool +actionRequiresAuth _ = False + +||| Whether an action is a write/mutating operation. +||| All opam-mcp actions are read-only queries. +export +actionIsMutating : OpamAction -> Bool +actionIsMutating _ = False + +||| Encode action as C-compatible integer for FFI. +export +actionToInt : OpamAction -> Int +actionToInt SearchPackages = 0 +actionToInt GetPackage = 1 +actionToInt GetVersion = 2 +actionToInt ListVersions = 3 +actionToInt GetDependencies = 4 +actionToInt GetReverseDependencies = 5 +actionToInt GetMaintainers = 6 +actionToInt GetTags = 7 +actionToInt ListAllPackages = 8 +actionToInt GetOpamFile = 9 + +||| Decode integer to opam action. +export +intToAction : Int -> Maybe OpamAction +intToAction 0 = Just SearchPackages +intToAction 1 = Just GetPackage +intToAction 2 = Just GetVersion +intToAction 3 = Just ListVersions +intToAction 4 = Just GetDependencies +intToAction 5 = Just GetReverseDependencies +intToAction 6 = Just GetMaintainers +intToAction 7 = Just GetTags +intToAction 8 = Just ListAllPackages +intToAction 9 = Just GetOpamFile +intToAction _ = Nothing + +-- --------------------------------------------------------------------------- +-- MCP tool declarations +-- --------------------------------------------------------------------------- + +||| Tools exposed via MCP protocol for this cartridge. +public export +data McpTool + = ToolSearchPackages + | ToolGetPackage + | ToolGetVersion + | ToolListVersions + | ToolGetDependencies + | ToolGetReverseDependencies + | ToolGetMaintainers + | ToolGetTags + | ToolListAllPackages + | ToolGetOpamFile + +||| Check if a tool requires an active session. +||| opam is always public. +export +toolRequiresSession : McpTool -> Bool +toolRequiresSession _ = False + +||| Total tool count for this cartridge. +export +toolCount : Nat +toolCount = 10 + +||| Total action count for this cartridge. +export +actionCount : Nat +actionCount = 10 diff --git a/cartridges/domains/registry/opam-mcp/abi/README.adoc b/cartridges/domains/registry/opam-mcp/abi/README.adoc new file mode 100644 index 0000000..da30de6 --- /dev/null +++ b/cartridges/domains/registry/opam-mcp/abi/README.adoc @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += opam-mcp / abi β€” Idris2 ABI layer +:orientation: shallow + +== Purpose + +Cartridge-local protocol types for `opam-mcp`. Defines the data, tool, and +transition vocabulary this cartridge speaks. Inherits safety lemmas and the +matrix-cell identifier scheme from the trunk ABI at `src/abi/`. + +== Files + +1 Idris2 module(s), ~165 lines total. Lead module: +`SafeRegistry.idr`. + +No `.ipkg` β€” consumed directly by wider BoJ callers; type-checks inside the +trunk `boj.ipkg`. + +== Invariants + +* `%default total` at every module top. +* Zero `believe_me`, `assert_total`, `postulate`, `Admitted`, or + `sorry` (the trunk holds the four declared `believe_me` sites; leaf + cartridges carry none). + +== Test/proof surface + +Thin module(s), no inline tests. Proof surface = type-check success under the +trunk `idris2 --check`. + +== Read-first + +. `SafeRegistry.idr` β€” cartridge-local data records and enums. +. Trunk `src/abi/Boj/Catalogue.idr` for how this cell participates in the + 2D matrix. diff --git a/cartridges/domains/registry/opam-mcp/adapter/README.adoc b/cartridges/domains/registry/opam-mcp/adapter/README.adoc new file mode 100644 index 0000000..b4e89d0 --- /dev/null +++ b/cartridges/domains/registry/opam-mcp/adapter/README.adoc @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += opam-mcp / adapter β€” protocol bridge +:orientation: shallow + +== Purpose + +Exposes this cartridge's FFI over the wire protocols declared in +`cartridge.json`. Dispatches tool invocations to the C-ABI exports at +`../ffi/` and formats responses per protocol. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” depends on the sibling `ffi` target. +| `opam_adapter.zig` | Protocol dispatch (204 lines). Tool catalogue matches `cartridge.json`. +| `SIDELINED-*.v.adoc` | zig predecessor(s). Not built β€” zig banned 2026-04-10. +|=== + +== Invariants + +* **Stateless** β€” all state lives behind the FFI, never in the adapter. +* **Response passthrough** β€” whatever the FFI returns goes back to the wire + unmodified (no embellishment, no silent recovery). +* **Cartridge.json is source of truth** for the tool catalogue; drift between + adapter and manifest is a CI failure. + +== Test/proof surface + +No inline tests at the adapter layer β€” protocol routing is exercised by the +trunk seam tests at `ffi/zig/src/seams.zig`. + +== Read-first + +. `opam_adapter.zig` β€” dispatch function and per-protocol routers. +. `../cartridge.json` β€” the tool manifest the dispatcher mirrors. diff --git a/cartridges/domains/registry/opam-mcp/adapter/build.zig b/cartridges/domains/registry/opam-mcp/adapter/build.zig new file mode 100644 index 0000000..7628e1f --- /dev/null +++ b/cartridges/domains/registry/opam-mcp/adapter/build.zig @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// opam-mcp/adapter/build.zig + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const ffi_mod = b.createModule(.{ + .root_source_file = b.path("../ffi/opam_mcp_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + const adapter = b.addExecutable(.{ + .name = "opam_adapter", + .root_source_file = b.path("opam_adapter.zig"), + .target = target, + .optimize = optimize, + }); + adapter.root_module.addImport("opam_mcp_ffi", ffi_mod); + b.installArtifact(adapter); + + const run_artifact = b.addRunArtifact(adapter); + const run_step = b.step("run", "Run the opam-mcp adapter"); + run_step.dependOn(&run_artifact.step); + + const tests = b.addTest(.{ + .root_source_file = b.path("opam_adapter.zig"), + .target = target, + .optimize = optimize, + }); + tests.root_module.addImport("opam_mcp_ffi", ffi_mod); + const run_tests = b.addRunArtifact(tests); + const test_step = b.step("test", "Run opam-mcp adapter tests"); + test_step.dependOn(&run_tests.step); +} diff --git a/cartridges/domains/registry/opam-mcp/adapter/opam_adapter.zig b/cartridges/domains/registry/opam-mcp/adapter/opam_adapter.zig new file mode 100644 index 0000000..12a71a7 --- /dev/null +++ b/cartridges/domains/registry/opam-mcp/adapter/opam_adapter.zig @@ -0,0 +1,204 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// opam-mcp/adapter/opam_adapter.zig -- Unified three-protocol adapter. +// +// Replaces the banned opam_adapter.v (zig, removed 2026-04-12). +// +// Bridges the Zig FFI (opam_mcp_ffi.zig) to three network protocols: +// REST :9079 POST /tools/<tool> +// gRPC-compat :9080 /OpamMcpService/<Method> +// GraphQL :9081 POST /graphql { query: "..." } +// +// OPAM OCaml package registry: packages, versions, dependencies +// Tools: +// opam_search_packages +// opam_get_package +// opam_get_version +// opam_list_versions +// opam_get_dependencies +// opam_get_reverse_dependencies +// opam_get_maintainers +// opam_get_tags +// opam_list_all_packages +// opam_get_opam_file + +const std = @import("std"); +const ffi = @import("opam_mcp_ffi"); + +const REST_PORT: u16 = 9079; +const GRPC_PORT: u16 = 9080; +const GQL_PORT: u16 = 9081; + +const MAX_CONN_BUF: usize = 16 * 1024; + +// ============================================================================ +// JSON response builders +// ============================================================================ + +fn okJson(buf: []u8, msg: []const u8) []u8 { + return std.fmt.bufPrint(buf, + \{{"success":true,"message":"{}"}} + , .{msg}) catch buf[0..0]; +} + +fn errJson(buf: []u8, msg: []const u8) []u8 { + return std.fmt.bufPrint(buf, + \{{"success":false,"error":"{}"}} + , .{msg}) catch buf[0..0]; +} + +fn statusJson(buf: []u8) []u8 { + return std.fmt.bufPrint(buf, + \{{"success":true,"state":"ready","service":"opam-mcp"}} + , .{}) catch buf[0..0]; +} + +// ============================================================================ +// Tool dispatcher +// ============================================================================ + +const Response = struct { status: u16, body: []u8 }; + +fn dispatch(tool: []const u8, body: []const u8, resp: []u8) Response { + _ = body; + if (std.mem.eql(u8, tool, "opam_search_packages")) return .{ .status = 200, .body = okJson(resp, "opam_search_packages forwarded to backend") }; + if (std.mem.eql(u8, tool, "opam_get_package")) return .{ .status = 200, .body = okJson(resp, "opam_get_package forwarded to backend") }; + if (std.mem.eql(u8, tool, "opam_get_version")) return .{ .status = 200, .body = okJson(resp, "opam_get_version forwarded to backend") }; + if (std.mem.eql(u8, tool, "opam_list_versions")) return .{ .status = 200, .body = okJson(resp, "opam_list_versions forwarded to backend") }; + if (std.mem.eql(u8, tool, "opam_get_dependencies")) return .{ .status = 200, .body = okJson(resp, "opam_get_dependencies forwarded to backend") }; + if (std.mem.eql(u8, tool, "opam_get_reverse_dependencies")) return .{ .status = 200, .body = okJson(resp, "opam_get_reverse_dependencies forwarded to backend") }; + if (std.mem.eql(u8, tool, "opam_get_maintainers")) return .{ .status = 200, .body = okJson(resp, "opam_get_maintainers forwarded to backend") }; + if (std.mem.eql(u8, tool, "opam_get_tags")) return .{ .status = 200, .body = okJson(resp, "opam_get_tags forwarded to backend") }; + if (std.mem.eql(u8, tool, "opam_list_all_packages")) return .{ .status = 200, .body = okJson(resp, "opam_list_all_packages forwarded to backend") }; + if (std.mem.eql(u8, tool, "opam_get_opam_file")) return .{ .status = 200, .body = okJson(resp, "opam_get_opam_file forwarded to backend") }; + if (std.mem.eql(u8, tool, "status") or std.mem.eql(u8, tool, "health")) + return .{ .status = 200, .body = statusJson(resp) }; + return .{ .status = 404, .body = errJson(resp, "Unknown tool") }; +} + +// ============================================================================ +// REST handler +// ============================================================================ + +fn dispatchRest(path: []const u8, body: []const u8, resp: []u8) Response { + if (std.mem.startsWith(u8, path, "/tools/")) { + return dispatch(path["/tools/".len..], body, resp); + } + if (std.mem.eql(u8, path, "/status") or std.mem.eql(u8, path, "/health")) { + return .{ .status = 200, .body = statusJson(resp) }; + } + return .{ .status = 404, .body = errJson(resp, "Not found") }; +} + +// ============================================================================ +// gRPC-compat handler +// ============================================================================ + +fn dispatchGrpc(path: []const u8, body: []const u8, resp: []u8) Response { + const prefix = "/OpamMcpService/"; + if (!std.mem.startsWith(u8, path, prefix)) + return .{ .status = 404, .body = errJson(resp, "Not a recognized gRPC path") }; + const method = path[prefix.len..]; + const tool = blk: { + if (std.mem.eql(u8, method, "OpamSearchPackages")) break :blk "opam_search_packages"; + if (std.mem.eql(u8, method, "OpamGetPackage")) break :blk "opam_get_package"; + if (std.mem.eql(u8, method, "OpamGetVersion")) break :blk "opam_get_version"; + if (std.mem.eql(u8, method, "OpamListVersions")) break :blk "opam_list_versions"; + if (std.mem.eql(u8, method, "OpamGetDependencies")) break :blk "opam_get_dependencies"; + if (std.mem.eql(u8, method, "OpamGetReverseDependencies")) break :blk "opam_get_reverse_dependencies"; + if (std.mem.eql(u8, method, "OpamGetMaintainers")) break :blk "opam_get_maintainers"; + if (std.mem.eql(u8, method, "OpamGetTags")) break :blk "opam_get_tags"; + if (std.mem.eql(u8, method, "OpamListAllPackages")) break :blk "opam_list_all_packages"; + if (std.mem.eql(u8, method, "OpamGetOpamFile")) break :blk "opam_get_opam_file"; + return .{ .status = 404, .body = errJson(resp, "Unknown gRPC method") }; + }; + return dispatch(tool, body, resp); +} + +// ============================================================================ +// GraphQL handler +// ============================================================================ + +fn dispatchGraphql(body: []const u8, resp: []u8) Response { + if (std.mem.indexOf(u8, body, "__schema") != null) + return .{ .status = 200, .body = okJson(resp, "schema introspection not yet supported") }; + if (std.mem.indexOf(u8, body, "search_packages") != null) return dispatch("opam_search_packages", body, resp); + if (std.mem.indexOf(u8, body, "get_package") != null) return dispatch("opam_get_package", body, resp); + if (std.mem.indexOf(u8, body, "get_version") != null) return dispatch("opam_get_version", body, resp); + if (std.mem.indexOf(u8, body, "list_versions") != null) return dispatch("opam_list_versions", body, resp); + if (std.mem.indexOf(u8, body, "get_dependencies") != null) return dispatch("opam_get_dependencies", body, resp); + if (std.mem.indexOf(u8, body, "get_reverse_dependencies") != null) return dispatch("opam_get_reverse_dependencies", body, resp); + if (std.mem.indexOf(u8, body, "get_maintainers") != null) return dispatch("opam_get_maintainers", body, resp); + if (std.mem.indexOf(u8, body, "get_tags") != null) return dispatch("opam_get_tags", body, resp); + if (std.mem.indexOf(u8, body, "list_all_packages") != null) return dispatch("opam_list_all_packages", body, resp); + if (std.mem.indexOf(u8, body, "get_opam_file") != null) return dispatch("opam_get_opam_file", body, resp); + return .{ .status = 200, .body = errJson(resp, "Unrecognised GraphQL operation") }; +} + +// ============================================================================ +// HTTP/1.1 connection handler +// ============================================================================ + +const Protocol = enum { rest, grpc, graphql }; + +fn handleConnection(conn: std.net.Server.Connection, proto: Protocol) void { + defer conn.stream.close(); + var in_buf: [MAX_CONN_BUF]u8 = undefined; + const n = conn.stream.read(&in_buf) catch return; + const req = in_buf[0..n]; + + var path: []const u8 = "/"; + var body: []const u8 = ""; + if (n > 4) { + const line_end = std.mem.indexOf(u8, req, "\r\n") orelse req.len; + const first_line = req[0..line_end]; + const sp1 = std.mem.indexOfScalar(u8, first_line, ' ') orelse 0; + const rest_of = first_line[sp1 + 1 ..]; + const sp2 = std.mem.indexOfScalar(u8, rest_of, ' ') orelse rest_of.len; + path = rest_of[0..sp2]; + const body_sep = std.mem.indexOf(u8, req, "\r\n\r\n") orelse n; + body = req[@min(body_sep + 4, n)..]; + } + + var resp_buf: [MAX_CONN_BUF]u8 = undefined; + const result = switch (proto) { + .rest => dispatchRest(path, body, &resp_buf), + .grpc => dispatchGrpc(path, body, &resp_buf), + .graphql => dispatchGraphql(body, &resp_buf), + }; + + const content_type = switch (proto) { + .rest => "application/json", + .grpc => "application/grpc+json", + .graphql => "application/json", + }; + + var hdr_buf: [256]u8 = undefined; + const hdr = std.fmt.bufPrint(&hdr_buf, + "HTTP/1.1 {d} OK\r\nContent-Type: {s}\r\nContent-Length: {d}\r\nConnection: close\r\n\r\n", + .{ result.status, content_type, result.body.len }, + ) catch return; + _ = conn.stream.writeAll(hdr) catch return; + _ = conn.stream.writeAll(result.body) catch return; +} + +fn listenLoop(port: u16, proto: Protocol) void { + const addr = std.net.Address.initIp4(.{ 127, 0, 0, 1 }, port) catch return; + var server = addr.listen(.{ .reuse_address = true }) catch return; + defer server.deinit(); + while (true) { + const conn = server.accept() catch continue; + handleConnection(conn, proto); + } +} + +pub fn main() !void { + ffi.opam_init(); + const rest_thread = try std.Thread.spawn(.{}, listenLoop, .{ REST_PORT, Protocol.rest }); + const grpc_thread = try std.Thread.spawn(.{}, listenLoop, .{ GRPC_PORT, Protocol.grpc }); + const gql_thread = try std.Thread.spawn(.{}, listenLoop, .{ GQL_PORT, Protocol.graphql }); + rest_thread.join(); + grpc_thread.join(); + gql_thread.join(); +} diff --git a/cartridges/domains/registry/opam-mcp/benchmarks/quick-bench.sh b/cartridges/domains/registry/opam-mcp/benchmarks/quick-bench.sh new file mode 100755 index 0000000..d150edc --- /dev/null +++ b/cartridges/domains/registry/opam-mcp/benchmarks/quick-bench.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Benchmarks for opam-mcp cartridge. +set -euo pipefail + +echo "=== opam-mcp benchmarks ===" + +cd "$(dirname "${BASH_SOURCE[0]}")/../ffi" + +# Build with ReleaseFast for benchmarking +zig build -Doptimize=ReleaseFast 2>&1 + +echo "Session open/close cycle (1000 iterations):" +time for i in $(seq 1 1000); do + true +done + +echo "" +echo "Benchmark placeholder -- implement real benchmarks in Zig test blocks" +echo "or via the zig adapter HTTP benchmark tool." diff --git a/cartridges/domains/registry/opam-mcp/cartridge.json b/cartridges/domains/registry/opam-mcp/cartridge.json new file mode 100644 index 0000000..8e779d4 --- /dev/null +++ b/cartridges/domains/registry/opam-mcp/cartridge.json @@ -0,0 +1,220 @@ +{ + "$schema": "https://boj.dev/schemas/cartridge/v1.json", + "spdx": "MPL-2.0", + "copyright": "Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath)", + "name": "opam-mcp", + "version": "0.2.0", + "description": "opam registry cartridge -- OCaml package search, metadata retrieval, version listing, dependency analysis, reverse dependency lookup, maintainer listing, and repository browsing via the opam.ocaml.org API", + "domain": "Registry", + "tier": "Ayo", + "protocols": [ + "MCP", + "REST" + ], + "auth": { + "method": "none", + "env_var": null, + "credential_source": null + }, + "api": { + "base_url": "https://opam.ocaml.org", + "content_type": "application/json" + }, + "tools": [ + { + "name": "opam_search_packages", + "description": "Search opam for OCaml packages by text query", + "inputSchema": { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "Search query (package name, synopsis, tags)" + }, + "page": { + "type": "number", + "description": "Page number (default 1)" + } + }, + "required": [ + "query" + ] + } + }, + { + "name": "opam_get_package", + "description": "Get full metadata for an OCaml package (synopsis, description, license, authors, homepage, latest version)", + "inputSchema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Package name (e.g. 'dune', 'lwt', 'ppx_deriving')" + } + }, + "required": [ + "name" + ] + } + }, + { + "name": "opam_get_version", + "description": "Get metadata for a specific version of an OCaml package (opam file contents, dependencies, conflicts)", + "inputSchema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Package name" + }, + "version": { + "type": "string", + "description": "Version string (e.g. '3.16.0')" + } + }, + "required": [ + "name", + "version" + ] + } + }, + { + "name": "opam_list_versions", + "description": "List all published versions of an OCaml package", + "inputSchema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Package name" + } + }, + "required": [ + "name" + ] + } + }, + { + "name": "opam_get_dependencies", + "description": "Get the dependency list (depends, depopts, conflicts) for a specific version of an OCaml package", + "inputSchema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Package name" + }, + "version": { + "type": "string", + "description": "Version to inspect" + } + }, + "required": [ + "name", + "version" + ] + } + }, + { + "name": "opam_get_reverse_dependencies", + "description": "List packages that depend on a given OCaml package (reverse dependency lookup)", + "inputSchema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Package name" + }, + "page": { + "type": "number", + "description": "Page number (default 1)" + } + }, + "required": [ + "name" + ] + } + }, + { + "name": "opam_get_maintainers", + "description": "Get maintainer and author information for an OCaml package", + "inputSchema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Package name" + } + }, + "required": [ + "name" + ] + } + }, + { + "name": "opam_get_tags", + "description": "Get tags associated with an OCaml package", + "inputSchema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Package name" + } + }, + "required": [ + "name" + ] + } + }, + { + "name": "opam_list_all_packages", + "description": "List all packages in the opam repository with basic metadata", + "inputSchema": { + "type": "object", + "properties": { + "page": { + "type": "number", + "description": "Page number (default 1)" + }, + "per_page": { + "type": "number", + "description": "Results per page (default 50)" + } + } + } + }, + { + "name": "opam_get_opam_file", + "description": "Get the raw opam file contents for a specific version of a package", + "inputSchema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Package name" + }, + "version": { + "type": "string", + "description": "Version to inspect" + } + }, + "required": [ + "name", + "version" + ] + } + } + ], + "ffi": { + "so_path": "ffi/zig-out/lib/libopam_mcp.so", + "abi_version": "ADR-0006", + "symbols": [ + "boj_cartridge_init", + "boj_cartridge_deinit", + "boj_cartridge_name", + "boj_cartridge_version", + "boj_cartridge_invoke" + ] + } +} diff --git a/cartridges/domains/registry/opam-mcp/ffi/README.adoc b/cartridges/domains/registry/opam-mcp/ffi/README.adoc new file mode 100644 index 0000000..91521af --- /dev/null +++ b/cartridges/domains/registry/opam-mcp/ffi/README.adoc @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += opam-mcp / ffi β€” Zig FFI layer +:orientation: shallow + +== Purpose + +C-ABI exports bridging this cartridge's Idris2 ABI to the runtime. Consumed by +the adapter for protocol dispatch and by the trunk loader for mount. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” produces `zig-out/lib/libopam.so`. +| `opam_mcp_ffi.zig` | C-ABI exports (15 exports, 6 inline tests, 364 lines). +|=== + +== Invariants + +* **Null-pointer rejection** on `[*c]const u8` arguments before any + dereference. +* **Negative return values** carry documented error meanings; zero is + success (matching the trunk error-code convention). +* **No allocator leaks** β€” every allocation path pairs with `defer free`. + +== Test/proof surface + +6 inline `test "..."` block(s) in `opam_mcp_ffi.zig`. Run with +`cd ffi && zig build test`. + +== Read-first + +. `opam_mcp_ffi.zig` β€” C-ABI signatures and guards. +. Trunk `ffi/zig/src/catalogue.zig` for how this `.so` is loaded. diff --git a/cartridges/domains/registry/opam-mcp/ffi/build.zig b/cartridges/domains/registry/opam-mcp/ffi/build.zig new file mode 100644 index 0000000..50ff2cf --- /dev/null +++ b/cartridges/domains/registry/opam-mcp/ffi/build.zig @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + // Shared ADR-0006 invoke-shim module (relative path up to boj-server trunk). + + const shim_mod = b.addModule("cartridge_shim", .{ + + .root_source_file = b.path("../../../ffi/zig/src/cartridge_shim.zig"), + + .target = target, + + .optimize = optimize, + + }); + + const ffi_mod = b.addModule("opam_mcp", .{ + .root_source_file = b.path("opam_mcp_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + ffi_mod.addImport("cartridge_shim", shim_mod); + + const lib = b.addLibrary(.{ + .name = "opam_mcp", + .root_module = ffi_mod, + .linkage = .dynamic, + }); + lib.linkLibC(); + b.installArtifact(lib); + + const tests = b.addTest(.{ .root_module = ffi_mod }); + tests.linkLibC(); + const run_tests = b.addRunArtifact(tests); + const test_step = b.step("test", "Run FFI tests"); + test_step.dependOn(&run_tests.step); +} + diff --git a/cartridges/domains/registry/opam-mcp/ffi/cartridge_shim.zig b/cartridges/domains/registry/opam-mcp/ffi/cartridge_shim.zig new file mode 100644 index 0000000..0e399db --- /dev/null +++ b/cartridges/domains/registry/opam-mcp/ffi/cartridge_shim.zig @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// cartridge_shim.zig β€” Shared helpers for the ADR-0006 five-symbol +// cartridge ABI (`boj_cartridge_init / deinit / name / version / invoke`). +// +// The shim centralises the seven-code return convention, NUL-argument +// guards, tool-name comparison, and the buffer-too-small path so each +// cartridge's `boj_cartridge_invoke` can stay short β€” typically a tool +// table plus `shim.writeResult(...)`. +// +// Cartridges import this file by relative path (no build-graph change +// needed). Example: +// +// const shim = @import("../../../ffi/zig/src/cartridge_shim.zig"); +// +// export fn boj_cartridge_invoke( +// tool_name: [*c]const u8, +// json_args: [*c]const u8, +// out_buf: [*c]u8, +// in_out_len: [*c]usize, +// ) callconv(.c) i32 { +// _ = json_args; +// if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; +// const body = if (shim.toolIs(tool_name, "foo")) "{\"result\":{}}" +// else return shim.RC_UNKNOWN_TOOL; +// return shim.writeResult(out_buf, in_out_len, body); +// } + +const std = @import("std"); + +// ── Return codes (ADR-0006 Β§Return codes) ──────────────────────────── +// +// Frozen by ADR-0006. New failure modes compose these via the error +// JSON body β€” the integer surface does not grow without a follow-up ADR. + +pub const RC_SUCCESS: i32 = 0; +pub const RC_UNKNOWN_TOOL: i32 = -1; +pub const RC_BAD_ARGS: i32 = -2; +pub const RC_BUFFER_TOO_SMALL: i32 = -3; +pub const RC_RUNTIME_ERROR: i32 = -4; +pub const RC_PANIC: i32 = -5; +pub const RC_AUTH_DENIED: i32 = -6; + +// ── Invoke-path helpers ────────────────────────────────────────────── + +/// True if any of the three mandatory `boj_cartridge_invoke` output-path +/// pointers is null. Use at the top of every invoke to short-circuit to +/// `RC_BAD_ARGS`. +pub fn invokeArgsNull( + tool_name: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) bool { + return tool_name == null or out_buf == null or in_out_len == null; +} + +/// Compare a C-NUL-terminated tool-name pointer against a Zig string +/// literal. Caller must have already verified `tool_name` is non-null +/// (usually via `invokeArgsNull`). +pub fn toolIs(tool_name: [*c]const u8, expected: []const u8) bool { + const s = std.mem.span(@as([*:0]const u8, @ptrCast(tool_name))); + return std.mem.eql(u8, s, expected); +} + +/// Copy `body` into `out_buf[0..*in_out_len]` (as a capacity) and update +/// `*in_out_len` to the number of bytes written. Returns `RC_SUCCESS`. +/// +/// If `body.len` exceeds the current capacity stored in `*in_out_len`, +/// sets `*in_out_len` to the required size and returns +/// `RC_BUFFER_TOO_SMALL` β€” the caller is then expected to re-allocate +/// and retry, per ADR-0006 Β§Memory ownership. +/// +/// Caller must have already verified that `out_buf` and `in_out_len` +/// are non-null. +pub fn writeResult( + out_buf: [*c]u8, + in_out_len: [*c]usize, + body: []const u8, +) i32 { + const cap = in_out_len.*; + if (body.len > cap) { + in_out_len.* = body.len; + return RC_BUFFER_TOO_SMALL; + } + @memcpy(out_buf[0..body.len], body); + in_out_len.* = body.len; + return RC_SUCCESS; +} + +// ── Tests ──────────────────────────────────────────────────────────── + +test "writeResult: body fits, writes and sets length" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); + try std.testing.expectEqualStrings("hello", buf[0..len]); +} + +test "writeResult: too small returns -3 and sets required length" { + var buf: [2]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_BUFFER_TOO_SMALL, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: exact-fit succeeds" { + var buf: [5]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: empty body" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, ""); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 0), len); +} + +test "toolIs: matches and rejects" { + const name: [*:0]const u8 = "foo"; + try std.testing.expect(toolIs(@ptrCast(name), "foo")); + try std.testing.expect(!toolIs(@ptrCast(name), "bar")); + try std.testing.expect(!toolIs(@ptrCast(name), "foobar")); + try std.testing.expect(!toolIs(@ptrCast(name), "fo")); +} + +test "invokeArgsNull: detects each null slot" { + var buf: [4]u8 = undefined; + var len: usize = 4; + const name: [*:0]const u8 = "x"; + try std.testing.expect(!invokeArgsNull(@ptrCast(name), &buf, &len)); + try std.testing.expect(invokeArgsNull(null, &buf, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), null, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), &buf, null)); +} diff --git a/cartridges/domains/registry/opam-mcp/ffi/opam_mcp_ffi.zig b/cartridges/domains/registry/opam-mcp/ffi/opam_mcp_ffi.zig new file mode 100644 index 0000000..79947c5 --- /dev/null +++ b/cartridges/domains/registry/opam-mcp/ffi/opam_mcp_ffi.zig @@ -0,0 +1,473 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// opam_mcp_ffi.zig β€” C-ABI FFI implementation for opam-mcp cartridge. +// +// Implements the state machine defined in OpamMcp.SafeRegistry (Idris2 ABI). +// State machine: Active | RateLimited | Error (no auth β€” fully public registry) +// REST API: https://opam.ocaml.org/api +// Actions: SearchPackages, GetPackage, GetVersion, ListVersions, +// GetDependencies, GetReverseDependencies, GetMaintainers, +// GetTags, ListAllPackages, GetOpamFile +// Thread-safe via std.Thread.Mutex. Fixed-size session pool, no heap allocations. + +const std = @import("std"); + +// --------------------------------------------------------------------------- +// State machine (matches Idris2 ABI SessionState exactly) +// --------------------------------------------------------------------------- + +/// Session lifecycle state. opam is fully public β€” no auth states needed. +/// 0 = Active, 1 = RateLimited, 2 = Error. +pub const SessionState = enum(c_int) { + active = 0, + rate_limited = 1, + err = 2, +}; + +/// opam action identifiers matching Idris2 OpamAction encoding. +pub const OpamAction = enum(c_int) { + search_packages = 0, + get_package = 1, + get_version = 2, + list_versions = 3, + get_dependencies = 4, + get_reverse_dependencies = 5, + get_maintainers = 6, + get_tags = 7, + list_all_packages = 8, + get_opam_file = 9, +}; + +/// Check valid state transitions per the Idris2 ValidTransition proof. +fn isValidTransition(from: SessionState, to: SessionState) bool { + return switch (from) { + .active => to == .rate_limited or to == .err, + .rate_limited => to == .active, + .err => to == .active, + }; +} + +// --------------------------------------------------------------------------- +// Session slots (thread-safe, fixed-size pool) +// --------------------------------------------------------------------------- + +const MAX_SESSIONS: usize = 16; + +const SessionSlot = struct { + active: bool = false, + state: SessionState = .active, + api_call_count: u64 = 0, + last_action: c_int = -1, + search_count: u32 = 0, + package_lookups: u32 = 0, + dep_queries: u32 = 0, + revdep_queries: u32 = 0, +}; + +var sessions: [MAX_SESSIONS]SessionSlot = .{SessionSlot{}} ** MAX_SESSIONS; +var mutex: std.Thread.Mutex = .{}; + +// --------------------------------------------------------------------------- +// C-ABI exports β€” state machine +// --------------------------------------------------------------------------- + +/// Check if a state transition is valid. Returns 1 (valid) or 0 (invalid). +pub export fn opam_mcp_can_transition(from: c_int, to: c_int) c_int { + const f = std.meta.intToEnum(SessionState, from) catch return 0; + const t = std.meta.intToEnum(SessionState, to) catch return 0; + return if (isValidTransition(f, t)) 1 else 0; +} + +/// Open a session (no auth needed). Returns slot index (>= 0) or error (< 0). +pub export fn opam_mcp_open(dummy: c_int) c_int { + _ = dummy; + mutex.lock(); + defer mutex.unlock(); + + for (&sessions, 0..) |*slot, idx| { + if (!slot.active) { + slot.active = true; + slot.state = .active; + slot.api_call_count = 0; + slot.last_action = -1; + slot.search_count = 0; + slot.package_lookups = 0; + slot.dep_queries = 0; + slot.revdep_queries = 0; + return @intCast(idx); + } + } + return -1; +} + +/// Close a session. Returns 0 on success. +pub export fn opam_mcp_close(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + + sessions[idx] = SessionSlot{}; + return 0; +} + +/// Get current state of a session. Returns state int or -1 if invalid. +pub export fn opam_mcp_session_state(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + return @intFromEnum(slot.state); +} + +/// Signal rate limiting on a session. Returns 0 on success. +pub export fn opam_mcp_throttle(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + if (!isValidTransition(slot.state, .rate_limited)) return -2; + + sessions[idx].state = .rate_limited; + return 0; +} + +/// Clear rate limiting. Returns 0 on success. +pub export fn opam_mcp_unthrottle(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + if (!isValidTransition(slot.state, .active)) return -2; + + sessions[idx].state = .active; + return 0; +} + +/// Signal an error on a session. Returns 0 on success. +pub export fn opam_mcp_signal_error(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + if (!isValidTransition(slot.state, .err)) return -2; + + sessions[idx].state = .err; + return 0; +} + +// --------------------------------------------------------------------------- +// C-ABI exports β€” action recording and metrics +// --------------------------------------------------------------------------- + +/// Record an API call on a session. Returns 0 on success. +pub export fn opam_mcp_record_call(slot_idx: c_int, action: c_int) c_int { + const act = std.meta.intToEnum(OpamAction, action) catch return -3; + + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + if (slot.state == .rate_limited) return -2; + if (slot.state == .err) return -2; + + sessions[idx].api_call_count += 1; + sessions[idx].last_action = action; + + switch (act) { + .search_packages => sessions[idx].search_count += 1, + .get_package, .get_version, .list_versions, .get_opam_file => sessions[idx].package_lookups += 1, + .get_dependencies => sessions[idx].dep_queries += 1, + .get_reverse_dependencies => sessions[idx].revdep_queries += 1, + else => {}, + } + + return 0; +} + +/// Get API call count for a session. +pub export fn opam_mcp_call_count(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + return @intCast(slot.api_call_count); +} + +/// Get search query count. +pub export fn opam_mcp_search_count(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + return @intCast(slot.search_count); +} + +/// Get package lookup count. +pub export fn opam_mcp_package_lookup_count(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + return @intCast(slot.package_lookups); +} + +/// Get dependency query count. +pub export fn opam_mcp_dep_query_count(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + return @intCast(slot.dep_queries); +} + +/// Get reverse dependency query count. +pub export fn opam_mcp_revdep_query_count(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + return @intCast(slot.revdep_queries); +} + +/// Get total action count. Always returns 10. +pub export fn opam_mcp_action_count() c_int { + return 10; +} + +/// Reset all sessions (test/debug use only). +pub export fn opam_mcp_reset() void { + mutex.lock(); + defer mutex.unlock(); + sessions = .{SessionSlot{}} ** MAX_SESSIONS; +} + +// --------------------------------------------------------------------------- +// ═══════════════════════════════════════════════════════════════════════ +// Standard ABI (ADR-0005 four symbols + ADR-0006 invoke) +// ═══════════════════════════════════════════════════════════════════════ + +const shim = @import("cartridge_shim.zig"); + +const CARTRIDGE_NAME_PTR: [*:0]const u8 = "opam-mcp"; +const CARTRIDGE_VERSION_PTR: [*:0]const u8 = "0.1.0"; + +export fn boj_cartridge_init() callconv(.c) c_int { + return 0; +} + +export fn boj_cartridge_deinit() callconv(.c) void {} + +export fn boj_cartridge_name() callconv(.c) [*:0]const u8 { + return CARTRIDGE_NAME_PTR; +} + +export fn boj_cartridge_version() callconv(.c) [*:0]const u8 { + return CARTRIDGE_VERSION_PTR; +} + +/// Dispatch the cartridge.json MCP tools. Grade D Alpha stubs. +export fn boj_cartridge_invoke( + tool_name: [*c]const u8, + json_args: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) callconv(.c) i32 { + _ = json_args; + if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; + + const body: []const u8 = if (shim.toolIs(tool_name, "opam_search_packages")) + "{\"result\":{\"matches\":[],\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "opam_get_package")) + "{\"result\":{\"metadata\":{},\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "opam_get_version")) + "{\"result\":{\"metadata\":{},\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "opam_list_versions")) + "{\"result\":{\"items\":[],\"count\":0,\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "opam_get_dependencies")) + "{\"result\":{\"metadata\":{},\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "opam_get_reverse_dependencies")) + "{\"result\":{\"metadata\":{},\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "opam_get_maintainers")) + "{\"result\":{\"metadata\":{},\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "opam_get_tags")) + "{\"result\":{\"metadata\":{},\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "opam_list_all_packages")) + "{\"result\":{\"items\":[],\"count\":0,\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "opam_get_opam_file")) + "{\"result\":{\"metadata\":{},\"status\":\"stub\"}}" +else + return shim.RC_UNKNOWN_TOOL; + + return shim.writeResult(out_buf, in_out_len, body); +} + +// Tests +// --------------------------------------------------------------------------- + +test "session lifecycle" { + opam_mcp_reset(); + + const slot = opam_mcp_open(0); + try std.testing.expect(slot >= 0); + try std.testing.expectEqual(@as(c_int, 0), opam_mcp_session_state(slot)); + + try std.testing.expectEqual(@as(c_int, 0), opam_mcp_record_call(slot, 0)); + try std.testing.expectEqual(@as(c_int, 1), opam_mcp_call_count(slot)); + try std.testing.expectEqual(@as(c_int, 1), opam_mcp_search_count(slot)); + + try std.testing.expectEqual(@as(c_int, 0), opam_mcp_close(slot)); +} + +test "rate limiting flow" { + opam_mcp_reset(); + + const slot = opam_mcp_open(0); + try std.testing.expect(slot >= 0); + + try std.testing.expectEqual(@as(c_int, 0), opam_mcp_throttle(slot)); + try std.testing.expectEqual(@as(c_int, 1), opam_mcp_session_state(slot)); + try std.testing.expectEqual(@as(c_int, -2), opam_mcp_record_call(slot, 0)); + + try std.testing.expectEqual(@as(c_int, 0), opam_mcp_unthrottle(slot)); + try std.testing.expectEqual(@as(c_int, 0), opam_mcp_session_state(slot)); +} + +test "error and recovery" { + opam_mcp_reset(); + + const slot = opam_mcp_open(0); + try std.testing.expect(slot >= 0); + + try std.testing.expectEqual(@as(c_int, 0), opam_mcp_signal_error(slot)); + try std.testing.expectEqual(@as(c_int, 2), opam_mcp_session_state(slot)); + try std.testing.expectEqual(@as(c_int, -2), opam_mcp_record_call(slot, 0)); + + try std.testing.expectEqual(@as(c_int, 0), opam_mcp_close(slot)); +} + +test "category counting" { + opam_mcp_reset(); + + const slot = opam_mcp_open(0); + try std.testing.expect(slot >= 0); + + try std.testing.expectEqual(@as(c_int, 0), opam_mcp_record_call(slot, 0)); // Search + try std.testing.expectEqual(@as(c_int, 0), opam_mcp_record_call(slot, 1)); // GetPackage + try std.testing.expectEqual(@as(c_int, 0), opam_mcp_record_call(slot, 4)); // GetDependencies + try std.testing.expectEqual(@as(c_int, 0), opam_mcp_record_call(slot, 5)); // GetReverseDeps + + try std.testing.expectEqual(@as(c_int, 4), opam_mcp_call_count(slot)); + try std.testing.expectEqual(@as(c_int, 1), opam_mcp_search_count(slot)); + try std.testing.expectEqual(@as(c_int, 1), opam_mcp_package_lookup_count(slot)); + try std.testing.expectEqual(@as(c_int, 1), opam_mcp_dep_query_count(slot)); + try std.testing.expectEqual(@as(c_int, 1), opam_mcp_revdep_query_count(slot)); +} + +test "transition validator" { + try std.testing.expectEqual(@as(c_int, 1), opam_mcp_can_transition(0, 1)); // Active -> RateLimited + try std.testing.expectEqual(@as(c_int, 1), opam_mcp_can_transition(1, 0)); // RateLimited -> Active + try std.testing.expectEqual(@as(c_int, 1), opam_mcp_can_transition(0, 2)); // Active -> Error + try std.testing.expectEqual(@as(c_int, 1), opam_mcp_can_transition(2, 0)); // Error -> Active + try std.testing.expectEqual(@as(c_int, 0), opam_mcp_can_transition(1, 2)); // RateLimited -> Error (invalid) +} + +test "slot exhaustion" { + opam_mcp_reset(); + + var slots: [MAX_SESSIONS]c_int = undefined; + for (&slots) |*s| { + s.* = opam_mcp_open(0); + try std.testing.expect(s.* >= 0); + } + + try std.testing.expectEqual(@as(c_int, -1), opam_mcp_open(0)); + + try std.testing.expectEqual(@as(c_int, 0), opam_mcp_close(slots[0])); + const new_slot = opam_mcp_open(0); + try std.testing.expect(new_slot >= 0); +} + +// ═══════════════════════════════════════════════════════════════════════ +// ADR-0006 invoke dispatch tests +// ═══════════════════════════════════════════════════════════════════════ + +test "boj_cartridge_name returns opam-mcp" { + const n = std.mem.span(boj_cartridge_name()); + try std.testing.expectEqualStrings("opam-mcp", n); +} + +test "boj_cartridge_init returns 0" { + try std.testing.expectEqual(@as(c_int, 0), boj_cartridge_init()); +} + +test "invoke: each declared tool succeeds" { + var buf: [256]u8 = undefined; + const tools = [_][]const u8{ + "opam_search_packages", + "opam_get_package", + "opam_get_version", + "opam_list_versions", + "opam_get_dependencies", + "opam_get_reverse_dependencies", + "opam_get_maintainers", + "opam_get_tags", + "opam_list_all_packages", + "opam_get_opam_file", + }; + for (tools) |t| { + var len: usize = buf.len; + const rc = boj_cartridge_invoke(t.ptr, "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, 0), rc); + try std.testing.expect(std.mem.indexOf(u8, buf[0..len], "result") != null); + } +} + +test "invoke: unknown tool returns -1" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("nope", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -1), rc); +} + +test "invoke: buffer too small returns -3" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("opam_search_packages", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -3), rc); + try std.testing.expect(len > 4); +} diff --git a/cartridges/domains/registry/opam-mcp/minter.toml b/cartridges/domains/registry/opam-mcp/minter.toml new file mode 100644 index 0000000..d8d21ed --- /dev/null +++ b/cartridges/domains/registry/opam-mcp/minter.toml @@ -0,0 +1,20 @@ +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> + +name = "opam-mcp" +description = "opam registry cartridge β€” OCaml package search, metadata, version listing, dependencies, reverse deps, maintainers, tags, opam file retrieval" +version = "0.2.0" +domain = "Registry" +protocols = [ + "MCP", + "REST", +] +tier = "Ayo" +backend = "universal" +generate_panel = true + +[auth] +method = "none" + +[api] +base_url = "https://opam.ocaml.org" diff --git a/cartridges/domains/registry/opam-mcp/mod.js b/cartridges/domains/registry/opam-mcp/mod.js new file mode 100644 index 0000000..ad7bee6 --- /dev/null +++ b/cartridges/domains/registry/opam-mcp/mod.js @@ -0,0 +1,215 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// opam-mcp/mod.js -- opam registry cartridge implementation. +// +// Provides MCP tool handlers for the opam.ocaml.org API: +// - Package search (text query, tags) +// - Package metadata (full info, specific versions) +// - Version listing +// - Dependency inspection (depends, depopts, conflicts) +// - Reverse dependency lookup +// - Maintainer/author information +// - Tag listing +// - Full package listing +// - Raw opam file retrieval +// +// Auth: None required β€” the opam repository is fully public. +// API docs: https://opam.ocaml.org/doc/ +// +// Usage: import { handleTool } from "./mod.js"; +// or: deno run --allow-net mod.js + +const API_BASE = "https://opam.ocaml.org"; + +// --------------------------------------------------------------------------- +// HTTP request helper β€” wraps fetch with opam.ocaml.org headers, +// error handling, and response normalization. +// --------------------------------------------------------------------------- + +async function opamFetch(path, queryParams, acceptHtml) { + const url = new URL(`${API_BASE}${path}`); + + // Append query parameters if provided + if (queryParams) { + for (const [key, value] of Object.entries(queryParams)) { + if (value !== undefined && value !== null && value !== "") { + url.searchParams.set(key, String(value)); + } + } + } + + const headers = { + "Accept": acceptHtml ? "text/html" : "application/json", + "User-Agent": "boj-server/opam-mcp/0.2.0 (https://github.com/hyperpolymath/boj-server)", + }; + + const response = await fetch(url.toString(), { method: "GET", headers }); + + // Handle rate limiting + if (response.status === 429) { + const retryAfter = response.headers.get("retry-after"); + return { + status: 429, + error: `Rate limited by opam.ocaml.org. Retry after ${retryAfter || "unknown"} seconds.`, + retryAfter, + }; + } + + if (acceptHtml) { + const text = await response.text().catch(() => ""); + if (!response.ok) { + return { status: response.status, error: `HTTP ${response.status}`, data: text }; + } + return { status: response.status, data: text }; + } + + const data = await response.json().catch(() => ({})); + + if (!response.ok) { + const errorMessage = data.message || `HTTP ${response.status}`; + return { status: response.status, error: errorMessage, data }; + } + + return { status: response.status, data }; +} + +// --------------------------------------------------------------------------- +// Tool handler dispatch β€” maps MCP tool names to opam API operations. +// Each handler validates required arguments, builds the API request, +// and returns structured results. +// --------------------------------------------------------------------------- + +export async function handleTool(toolName, args) { + switch (toolName) { + + // --- Search --- + + case "opam_search_packages": { + if (!args.query) return { error: "Missing required field: query" }; + return opamFetch("/api/packages/search", { + q: args.query, + page: args.page, + }); + } + + // --- Package metadata --- + + case "opam_get_package": { + if (!args.name) return { error: "Missing required field: name" }; + return opamFetch(`/api/packages/${encodeURIComponent(args.name)}`); + } + + case "opam_get_version": { + if (!args.name) return { error: "Missing required field: name" }; + if (!args.version) return { error: "Missing required field: version" }; + return opamFetch(`/api/packages/${encodeURIComponent(args.name)}/${args.version}`); + } + + case "opam_list_versions": { + if (!args.name) return { error: "Missing required field: name" }; + const result = await opamFetch(`/api/packages/${encodeURIComponent(args.name)}`); + if (result.error) return result; + return { + status: result.status, + data: { + name: args.name, + versions: result.data.versions || [], + }, + }; + } + + // --- Dependencies --- + + case "opam_get_dependencies": { + if (!args.name) return { error: "Missing required field: name" }; + if (!args.version) return { error: "Missing required field: version" }; + const result = await opamFetch(`/api/packages/${encodeURIComponent(args.name)}/${args.version}`); + if (result.error) return result; + return { + status: result.status, + data: { + name: args.name, + version: args.version, + depends: result.data.depends || [], + depopts: result.data.depopts || [], + conflicts: result.data.conflicts || [], + }, + }; + } + + // --- Reverse dependencies --- + + case "opam_get_reverse_dependencies": { + if (!args.name) return { error: "Missing required field: name" }; + return opamFetch(`/api/packages/${encodeURIComponent(args.name)}/revdeps`, { + page: args.page, + }); + } + + // --- Maintainers --- + + case "opam_get_maintainers": { + if (!args.name) return { error: "Missing required field: name" }; + const result = await opamFetch(`/api/packages/${encodeURIComponent(args.name)}`); + if (result.error) return result; + return { + status: result.status, + data: { + name: args.name, + maintainers: result.data.maintainers || [], + authors: result.data.authors || [], + }, + }; + } + + // --- Tags --- + + case "opam_get_tags": { + if (!args.name) return { error: "Missing required field: name" }; + const result = await opamFetch(`/api/packages/${encodeURIComponent(args.name)}`); + if (result.error) return result; + return { + status: result.status, + data: { + name: args.name, + tags: result.data.tags || [], + }, + }; + } + + // --- List all --- + + case "opam_list_all_packages": { + return opamFetch("/api/packages", { + page: args.page, + per_page: args.per_page, + }); + } + + // --- Raw opam file --- + + case "opam_get_opam_file": { + if (!args.name) return { error: "Missing required field: name" }; + if (!args.version) return { error: "Missing required field: version" }; + return opamFetch(`/api/packages/${encodeURIComponent(args.name)}/${args.version}/opam`); + } + + default: + return { error: `Unknown opam-mcp tool: ${toolName}` }; + } +} + +// --------------------------------------------------------------------------- +// Cartridge metadata export β€” used by the BoJ cartridge loader to register +// this cartridge's tools without reading cartridge.json separately. +// --------------------------------------------------------------------------- + +export const metadata = { + name: "opam-mcp", + version: "0.2.0", + domain: "Registry", + tier: "Ayo", + protocols: ["MCP", "REST"], + toolCount: 10, +}; diff --git a/cartridges/domains/registry/opam-mcp/panels/manifest.json b/cartridges/domains/registry/opam-mcp/panels/manifest.json new file mode 100644 index 0000000..c392f87 --- /dev/null +++ b/cartridges/domains/registry/opam-mcp/panels/manifest.json @@ -0,0 +1,89 @@ +{ + "$schema": "https://panll.dev/schemas/panel-manifest/v1.json", + "spdx": "MPL-2.0", + "copyright": "Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath)", + "cartridge": "opam-mcp", + "domain": "Registry", + "version": "0.2.0", + "panels": [ + { + "id": "opam-session-status", + "title": "Session Status", + "description": "opam session state (Active / RateLimited / Error)", + "type": "status-indicator", + "data_source": { + "endpoint": "/opam/state", + "method": "GET", + "refresh_interval_ms": 5000 + }, + "widgets": [ + { + "type": "state-badge", + "field": "state", + "states": { + "active": { "color": "#2ecc71", "icon": "check-circle" }, + "rate_limited": { "color": "#f39c12", "icon": "clock" }, + "error": { "color": "#e74c3c", "icon": "alert-triangle" } + } + } + ] + }, + { + "id": "opam-search-count", + "title": "Search Queries", + "description": "Number of package search queries executed in the current session", + "type": "metric", + "data_source": { + "endpoint": "/opam/status", + "method": "GET", + "refresh_interval_ms": 10000 + }, + "widgets": [ + { + "type": "counter", + "field": "search_count", + "label": "Searches", + "icon": "search" + } + ] + }, + { + "id": "opam-package-lookups", + "title": "Package Lookups", + "description": "Number of package metadata queries executed in the current session", + "type": "metric", + "data_source": { + "endpoint": "/opam/status", + "method": "GET", + "refresh_interval_ms": 10000 + }, + "widgets": [ + { + "type": "counter", + "field": "package_lookups", + "label": "Package Lookups", + "icon": "package" + } + ] + }, + { + "id": "opam-revdep-queries", + "title": "Reverse Dep Queries", + "description": "Number of reverse dependency queries in the current session", + "type": "metric", + "data_source": { + "endpoint": "/opam/status", + "method": "GET", + "refresh_interval_ms": 10000 + }, + "widgets": [ + { + "type": "counter", + "field": "revdep_queries", + "label": "Revdep Queries", + "icon": "git-branch" + } + ] + } + ] +} diff --git a/cartridges/domains/registry/opam-mcp/tests/integration_test.sh b/cartridges/domains/registry/opam-mcp/tests/integration_test.sh new file mode 100755 index 0000000..0bbdc0b --- /dev/null +++ b/cartridges/domains/registry/opam-mcp/tests/integration_test.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Integration tests for opam-mcp cartridge. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CART_DIR="$(dirname "$SCRIPT_DIR")" +FFI_DIR="$CART_DIR/ffi" + +echo "=== opam-mcp integration tests ===" + +# Build FFI +echo "[1/3] Building FFI..." +cd "$FFI_DIR" && zig build 2>&1 + +# Run Zig unit tests +echo "[2/3] Running FFI unit tests..." +cd "$FFI_DIR" && zig build test 2>&1 + +# Validate Idris2 ABI (if idris2 available) +echo "[3/3] Checking ABI..." +if command -v idris2 &>/dev/null; then + cd "$CART_DIR/abi" && idris2 --check OpamMcp.SafeRegistry 2>&1 + echo " ABI: OK" +else + echo " ABI: SKIPPED (idris2 not in PATH)" +fi + +echo "" +echo "All tests passed for opam-mcp!" diff --git a/cartridges/domains/registry/opsm-mcp/README.adoc b/cartridges/domains/registry/opsm-mcp/README.adoc new file mode 100644 index 0000000..1d531a5 --- /dev/null +++ b/cartridges/domains/registry/opsm-mcp/README.adoc @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += opsm-mcp +Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk> +:spdx: MPL-2.0 +:tier: Ayo +:domain: Package Management +:protocols: MCP, REST + +== Overview + +Odds-and-Sods Package Manager (OPSM) gateway. Routes package search, install, dependency resolution (PubGrub), and registry management across 103 registry adapters (npm, cargo, hex, pypi, affinescript, rattlescript, eclexia, guix, nix, and more). State machine enforces valid registry slot lifecycle via Zig FFI. + +== Tools (7) + +[cols="2,4"] +|=== +| Tool | Description + +| `opsm_search` | Search for packages across all configured registries. Returns matching packages with name, version, description, and registry source. +| `opsm_install` | Install a package from any registry. Resolves the package, downloads it, verifies checksums, and installs into the workspace. +| `opsm_resolve` | Resolve the full dependency tree for a manifest using the PubGrub algorithm. Returns the resolved set of packages and versions, or a conflict explanation if resolution fails. +| `opsm_info` | Fetch metadata for a package: description, versions, authors, license, dependencies, and attestations. +| `opsm_list` | List installed packages in a workspace. Returns name, version, registry, and direct/transitive classification for each installed package. +| `opsm_registries` | List all 103 registry adapters with their names, slot indices, current state (disconnected/connected/querying/idle), and supported operations. +| `opsm_status` | Return the OPSM service health status, version, registry count, resolver algorithm, and security configuration. +|=== + +== Architecture + +Three-layer stack per the BoJ cartridge standard: + +[cols="1,1,3"] +|=== +| Layer | Language | Purpose + +| ABI | Idris2 | Formally verified state machine with dependent-type proofs +| FFI | Zig | C-ABI implementation: session pool, action metrics, thread safety +| Adapter| Zig | Adapter bridge to the BoJ unified adapter protocol (zig predecessors sidelined) +|=== + +== Building + +[source,bash] +---- +# ABI (Idris2) +cd abi && idris2 --check *.idr + +# FFI (Zig) +cd ffi && zig build +cd ffi && zig build test + +# Adapter (Zig) +cd adapter && zig build +---- + +== Status + +Alpha (CRG D). Cartridge metadata declared in `cartridge.json`; 7 tool(s) exposed. +Not yet dogfooded externally. See `docs/READINESS.md` at repo root for the +project-wide grade table and `docs/practice/DOGFOOD-LOG.adoc` for usage evidence. diff --git a/cartridges/domains/registry/opsm-mcp/abi/OpsmMcp/SafeRegistry.idr b/cartridges/domains/registry/opsm-mcp/abi/OpsmMcp/SafeRegistry.idr new file mode 100644 index 0000000..90f4b61 --- /dev/null +++ b/cartridges/domains/registry/opsm-mcp/abi/OpsmMcp/SafeRegistry.idr @@ -0,0 +1,94 @@ +-- SPDX-License-Identifier: MPL-2.0 +-- OPSM MCP Cartridge β€” Registry state machine with safety proofs. +-- +-- Ensures registry operations follow a valid lifecycle: +-- Disconnected -> Connected -> Querying -> Idle +-- +-- Prevents: +-- - Querying a disconnected registry +-- - Double-connect (resource leak) +-- - Use-after-disconnect + +module OpsmMcp.SafeRegistry + +import Data.Fin + +||| Registry connection states. +public export +data RegState = Disconnected | Connected | Querying | Idle + +||| Valid state transitions for registry operations. +public export +data RegTransition : RegState -> RegState -> Type where + Connect : RegTransition Disconnected Connected + StartQuery : RegTransition Connected Querying + EndQuery : RegTransition Querying Idle + Reset : RegTransition Idle Connected + Disconnect : RegTransition Connected Disconnected + IdleDisc : RegTransition Idle Disconnected + +||| A registry handle indexed by its current state. +||| The phantom type parameter prevents misuse at compile time. +public export +data RegistryHandle : RegState -> Type where + MkHandle : (name : String) -> (slot : Nat) -> RegistryHandle s + +||| Extract the registry name from a handle (state-independent). +public export +registryName : RegistryHandle s -> String +registryName (MkHandle name _) = name + +||| Extract the slot index from a handle. +public export +registrySlot : RegistryHandle s -> Nat +registrySlot (MkHandle _ slot) = slot + +||| Connect to a registry. Transitions Disconnected -> Connected. +public export +connect : RegistryHandle Disconnected -> RegistryHandle Connected +connect (MkHandle name slot) = MkHandle name slot + +||| Begin a query. Transitions Connected -> Querying. +public export +startQuery : RegistryHandle Connected -> RegistryHandle Querying +startQuery (MkHandle name slot) = MkHandle name slot + +||| End a query. Transitions Querying -> Idle. +public export +endQuery : RegistryHandle Querying -> RegistryHandle Idle +endQuery (MkHandle name slot) = MkHandle name slot + +||| Reset to connected state. Transitions Idle -> Connected. +public export +reset : RegistryHandle Idle -> RegistryHandle Connected +reset (MkHandle name slot) = MkHandle name slot + +||| Disconnect from a registry. Transitions Connected -> Disconnected. +public export +disconnect : RegistryHandle Connected -> RegistryHandle Disconnected +disconnect (MkHandle name slot) = MkHandle name slot + +||| Disconnect from idle. Transitions Idle -> Disconnected. +public export +disconnectIdle : RegistryHandle Idle -> RegistryHandle Disconnected +disconnectIdle (MkHandle name slot) = MkHandle name slot + +||| Proof: a full lifecycle is valid (connect, query, disconnect). +public export +lifecycleValid : RegistryHandle Disconnected -> RegistryHandle Disconnected +lifecycleValid h = + let h1 = connect h + h2 = startQuery h1 + h3 = endQuery h2 + h4 = disconnectIdle h3 + in h4 + +||| Number of supported registry adapters. +public export +numRegistries : Nat +numRegistries = 101 + +||| Registry adapter index (bounded by numRegistries). +public export +RegistryIdx : Type +RegistryIdx = Fin numRegistries diff --git a/cartridges/domains/registry/opsm-mcp/abi/README.adoc b/cartridges/domains/registry/opsm-mcp/abi/README.adoc new file mode 100644 index 0000000..feccd76 --- /dev/null +++ b/cartridges/domains/registry/opsm-mcp/abi/README.adoc @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += opsm-mcp / abi β€” Idris2 ABI layer +:orientation: shallow + +== Purpose + +Cartridge-local protocol types for `opsm-mcp`. Defines the data, tool, and +transition vocabulary this cartridge speaks. Inherits safety lemmas and the +matrix-cell identifier scheme from the trunk ABI at `src/abi/`. + +== Files + +1 Idris2 module(s), ~94 lines total. Lead module: +`SafeRegistry.idr`. + +No `.ipkg` β€” consumed directly by wider BoJ callers; type-checks inside the +trunk `boj.ipkg`. + +== Invariants + +* `%default total` at every module top. +* Zero `believe_me`, `assert_total`, `postulate`, `Admitted`, or + `sorry` (the trunk holds the four declared `believe_me` sites; leaf + cartridges carry none). + +== Test/proof surface + +Thin module(s), no inline tests. Proof surface = type-check success under the +trunk `idris2 --check`. + +== Read-first + +. `SafeRegistry.idr` β€” cartridge-local data records and enums. +. Trunk `src/abi/Boj/Catalogue.idr` for how this cell participates in the + 2D matrix. diff --git a/cartridges/domains/registry/opsm-mcp/abi/opsm-mcp.ipkg b/cartridges/domains/registry/opsm-mcp/abi/opsm-mcp.ipkg new file mode 100644 index 0000000..98a46da --- /dev/null +++ b/cartridges/domains/registry/opsm-mcp/abi/opsm-mcp.ipkg @@ -0,0 +1,11 @@ +-- SPDX-License-Identifier: MPL-2.0 +package opsmmcp + +authors = "Jonathan D.A. Jewell" +version = 0.1.0 +license = "MPL-2.0" +brief = "OPSM MCP cartridge β€” federated package search with registry state machine" + +sourcedir = "." +modules = OpsmMcp.SafeRegistry +depends = base, contrib diff --git a/cartridges/domains/registry/opsm-mcp/adapter/README.adoc b/cartridges/domains/registry/opsm-mcp/adapter/README.adoc new file mode 100644 index 0000000..cba9d4f --- /dev/null +++ b/cartridges/domains/registry/opsm-mcp/adapter/README.adoc @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += opsm-mcp / adapter β€” protocol bridge +:orientation: shallow + +== Purpose + +Exposes this cartridge's FFI over the wire protocols declared in +`cartridge.json`. Dispatches tool invocations to the C-ABI exports at +`../ffi/` and formats responses per protocol. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” depends on the sibling `ffi` target. +| `opsm_adapter.zig` | Protocol dispatch (365 lines). Tool catalogue matches `cartridge.json`. +| `SIDELINED-*.v.adoc` | zig predecessor(s). Not built β€” zig banned 2026-04-10. +|=== + +== Invariants + +* **Stateless** β€” all state lives behind the FFI, never in the adapter. +* **Response passthrough** β€” whatever the FFI returns goes back to the wire + unmodified (no embellishment, no silent recovery). +* **Cartridge.json is source of truth** for the tool catalogue; drift between + adapter and manifest is a CI failure. + +== Test/proof surface + +No inline tests at the adapter layer β€” protocol routing is exercised by the +trunk seam tests at `ffi/zig/src/seams.zig`. + +== Read-first + +. `opsm_adapter.zig` β€” dispatch function and per-protocol routers. +. `../cartridge.json` β€” the tool manifest the dispatcher mirrors. diff --git a/cartridges/domains/registry/opsm-mcp/adapter/build.zig b/cartridges/domains/registry/opsm-mcp/adapter/build.zig new file mode 100644 index 0000000..3d3f5ef --- /dev/null +++ b/cartridges/domains/registry/opsm-mcp/adapter/build.zig @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// opsm-mcp/adapter/build.zig β€” build configuration for the unified adapter. + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + // Import the FFI module directly (same build system β€” no shared-library linking) + const ffi_mod = b.createModule(.{ + .root_source_file = b.path("../ffi/opsm_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + const adapter = b.addExecutable(.{ + .name = "opsm_adapter", + .root_source_file = b.path("opsm_adapter.zig"), + .target = target, + .optimize = optimize, + }); + adapter.root_module.addImport("opsm_ffi", ffi_mod); + b.installArtifact(adapter); + + const run_artifact = b.addRunArtifact(adapter); + const run_step = b.step("run", "Run the opsm-mcp adapter"); + run_step.dependOn(&run_artifact.step); + + // Unit tests + const tests = b.addTest(.{ + .root_source_file = b.path("opsm_adapter.zig"), + .target = target, + .optimize = optimize, + }); + tests.root_module.addImport("opsm_ffi", ffi_mod); + const run_tests = b.addRunArtifact(tests); + const test_step = b.step("test", "Run opsm-mcp adapter tests"); + test_step.dependOn(&run_tests.step); +} diff --git a/cartridges/domains/registry/opsm-mcp/adapter/opsm_adapter.zig b/cartridges/domains/registry/opsm-mcp/adapter/opsm_adapter.zig new file mode 100644 index 0000000..c793636 --- /dev/null +++ b/cartridges/domains/registry/opsm-mcp/adapter/opsm_adapter.zig @@ -0,0 +1,365 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// opsm-mcp/adapter/opsm_adapter.zig -- Unified three-protocol adapter. +// +// Replaces the banned opsm_adapter.v (zig, removed 2026-04-12). +// +// Bridges the Zig FFI (opsm_ffi.zig) to three network protocols: +// - REST :9028 POST /registries/{slot}/connect|query|disconnect|status +// - gRPC-compat :9029 /opsm_mcp.OpsmService/<Method> +// - GraphQL :9030 POST /graphql { query: "..." } +// +// MCP Tools exposed (103 registry adapters, 101 slots): +// opsm_search β€” cross-registry package search +// opsm_install β€” install a package from any registry +// opsm_resolve β€” resolve dependency tree (PubGrub) +// opsm_info β€” package metadata and versions +// opsm_list β€” list installed packages in a workspace +// opsm_registriesβ€” list all registry adapters and their slot states +// opsm_status β€” service health check + +const std = @import("std"); +const ffi = @import("opsm_ffi"); + +const REST_PORT: u16 = 9028; +const GRPC_PORT: u16 = 9029; +const GQL_PORT: u16 = 9030; + +const MAX_CONN_BUF: usize = 16 * 1024; // 16 KiB per connection + +// ============================================================================ +// State helpers (thin wrappers over FFI) +// ============================================================================ + +const STATE_NAMES = [_][]const u8{ "disconnected", "connected", "querying", "idle" }; + +fn stateName(s: i32) []const u8 { + const idx = @as(usize, @intCast(if (s >= 0 and s <= 3) s else 0)); + return STATE_NAMES[idx]; +} + +// ============================================================================ +// JSON response builders +// ============================================================================ + +fn slotStateJson(slot: u32, buf: []u8) []u8 { + const state = ffi.opsm_state(slot); + const s_name = stateName(state); + const n = std.fmt.bufPrint(buf, + \\{{"slot":{d},"state":"{s}"}} + , .{ slot, s_name }) catch buf[0..0]; + return n; +} + +fn registriesJson(buf: []u8) []u8 { + // Build condensed array: [{"slot":0,"state":"disconnected"}, ...] + // Only emit non-disconnected slots for brevity; always emit all 101. + var pos: usize = 0; + const hdr = "["; + @memcpy(buf[0..hdr.len], hdr); + pos += hdr.len; + + var first = true; + var slot: u32 = 0; + while (slot < 101) : (slot += 1) { + const state = ffi.opsm_state(slot); + if (!first) { + buf[pos] = ','; + pos += 1; + } + first = false; + const entry = std.fmt.bufPrint(buf[pos..], + \\{{"slot":{d},"state":"{s}"}} + , .{ slot, stateName(state) }) catch break; + pos += entry.len; + } + buf[pos] = ']'; + pos += 1; + return buf[0..pos]; +} + +fn statusJson(buf: []u8) []u8 { + const n = std.fmt.bufPrint(buf, + \\{{"success":true,"state":"ready","version":"2.0.0","registry_count":103,"resolver":"PubGrub","security":"post-quantum (Dilithium5 + Kyber-1024)"}} + , .{}) catch buf[0..0]; + return n; +} + +fn okJson(buf: []u8, msg: []const u8) []u8 { + const n = std.fmt.bufPrint(buf, + \\{{"success":true,"message":"{s}"}} + , .{msg}) catch buf[0..0]; + return n; +} + +fn errJson(buf: []u8, msg: []const u8) []u8 { + const n = std.fmt.bufPrint(buf, + \\{{"success":false,"error":"{s}"}} + , .{msg}) catch buf[0..0]; + return n; +} + +// ============================================================================ +// Request body field extraction +// ============================================================================ + +fn parseUintField(body: []const u8, field: []const u8) ?u32 { + const key_start = std.mem.indexOf(u8, body, field) orelse return null; + const after = body[key_start + field.len ..]; + // Skip ": " or ":" + const val_start = std.mem.indexOfAny(u8, after, "0123456789") orelse return null; + const val_slice = after[val_start..]; + const val_end = blk: { + var i: usize = 0; + while (i < val_slice.len and val_slice[i] >= '0' and val_slice[i] <= '9') i += 1; + break :blk i; + }; + return std.fmt.parseInt(u32, val_slice[0..val_end], 10) catch null; +} + +fn parseStringField(body: []const u8, field: []const u8, out: []u8) []u8 { + const key = std.fmt.bufPrint(out, "\"{s}\":", .{field}) catch return out[0..0]; + const start = std.mem.indexOf(u8, body, key) orelse return out[0..0]; + const after = body[start + key.len ..]; + const q1 = std.mem.indexOfScalar(u8, after, '"') orelse return out[0..0]; + const content = after[q1 + 1 ..]; + const q2 = std.mem.indexOfScalar(u8, content, '"') orelse return out[0..0]; + const len = @min(q2, out.len); + @memcpy(out[0..len], content[0..len]); + return out[0..len]; +} + +// ============================================================================ +// Tool dispatcher β€” handles all seven opsm_* tools +// ============================================================================ + +const Response = struct { status: u16, body: []u8 }; + +fn dispatch(tool: []const u8, body: []const u8, resp: []u8) Response { + if (std.mem.eql(u8, tool, "opsm_search") or std.mem.eql(u8, tool, "search")) { + return .{ .status = 200, .body = okJson(resp, + "Search dispatched to OPSM Elixir backend") }; + } + if (std.mem.eql(u8, tool, "opsm_install") or std.mem.eql(u8, tool, "install")) { + var tmp: [128]u8 = undefined; + const pkg = parseStringField(body, "package_name", &tmp); + const msg_buf = resp[0 .. @min(resp.len, 256)]; + const msg = std.fmt.bufPrint(msg_buf, + \\{{"success":true,"package":"{s}","message":"Install request forwarded to OPSM backend"}} + , .{pkg}) catch return .{ .status = 200, .body = okJson(resp, "Install forwarded") }; + return .{ .status = 200, .body = msg }; + } + if (std.mem.eql(u8, tool, "opsm_resolve") or std.mem.eql(u8, tool, "resolve")) { + return .{ .status = 200, .body = okJson(resp, + "Dependency resolution dispatched to PubGrub solver") }; + } + if (std.mem.eql(u8, tool, "opsm_info") or std.mem.eql(u8, tool, "info")) { + return .{ .status = 200, .body = okJson(resp, "Info request forwarded to OPSM backend") }; + } + if (std.mem.eql(u8, tool, "opsm_list") or std.mem.eql(u8, tool, "list")) { + return .{ .status = 200, .body = okJson(resp, "Package list requested from OPSM backend") }; + } + if (std.mem.eql(u8, tool, "opsm_registries") or std.mem.eql(u8, tool, "registries")) { + var reg_buf: [32 * 1024]u8 = undefined; + const reg_json = registriesJson(®_buf); + const full = std.fmt.bufPrint(resp, + \\{{"success":true,"registry_count":103,"registries":{s}}} + , .{reg_json}) catch return .{ .status = 200, .body = okJson(resp, "Registries listed") }; + return .{ .status = 200, .body = full }; + } + if (std.mem.eql(u8, tool, "opsm_status") or std.mem.eql(u8, tool, "status")) { + return .{ .status = 200, .body = statusJson(resp) }; + } + return .{ .status = 404, .body = errJson(resp, "Unknown tool") }; +} + +// ============================================================================ +// REST handler β€” POST /tools/<tool> or POST /registries/<slot>/<op> +// ============================================================================ + +fn dispatchRest(path: []const u8, body: []const u8, resp: []u8) Response { + // /registries/<slot>/connect|query|disconnect|status + if (std.mem.startsWith(u8, path, "/registries/")) { + const after_reg = path["/registries/".len..]; + const slash = std.mem.indexOfScalar(u8, after_reg, '/') orelse + return .{ .status = 400, .body = errJson(resp, "Missing operation") }; + const slot_str = after_reg[0..slash]; + const op = after_reg[slash + 1 ..]; + const slot = std.fmt.parseInt(u32, slot_str, 10) catch + return .{ .status = 400, .body = errJson(resp, "Invalid slot") }; + var state_buf: [128]u8 = undefined; + if (std.mem.eql(u8, op, "connect")) { + const rc = ffi.opsm_connect(slot); + if (rc != 0) return .{ .status = 409, .body = errJson(resp, "Invalid transition") }; + return .{ .status = 200, .body = slotStateJson(slot, &state_buf) }; + } else if (std.mem.eql(u8, op, "query")) { + const rc = ffi.opsm_start_query(slot); + if (rc != 0) return .{ .status = 409, .body = errJson(resp, "Invalid transition") }; + return .{ .status = 200, .body = slotStateJson(slot, &state_buf) }; + } else if (std.mem.eql(u8, op, "end_query")) { + const rc = ffi.opsm_end_query(slot); + if (rc != 0) return .{ .status = 409, .body = errJson(resp, "Invalid transition") }; + return .{ .status = 200, .body = slotStateJson(slot, &state_buf) }; + } else if (std.mem.eql(u8, op, "disconnect")) { + const rc = ffi.opsm_disconnect(slot); + if (rc != 0) return .{ .status = 409, .body = errJson(resp, "Invalid transition") }; + return .{ .status = 200, .body = slotStateJson(slot, &state_buf) }; + } else if (std.mem.eql(u8, op, "status")) { + return .{ .status = 200, .body = slotStateJson(slot, &state_buf) }; + } + return .{ .status = 404, .body = errJson(resp, "Unknown operation") }; + } + // /tools/<tool> + if (std.mem.startsWith(u8, path, "/tools/")) { + const tool = path["/tools/".len..]; + return dispatch(tool, body, resp); + } + if (std.mem.eql(u8, path, "/status") or std.mem.eql(u8, path, "/health")) { + return .{ .status = 200, .body = statusJson(resp) }; + } + return .{ .status = 404, .body = errJson(resp, "Not found") }; +} + +// ============================================================================ +// gRPC-compat handler β€” /opsm_mcp.OpsmService/<Method> +// ============================================================================ + +fn dispatchGrpc(path: []const u8, body: []const u8, resp: []u8) Response { + const prefix = "/opsm_mcp.OpsmService/"; + if (!std.mem.startsWith(u8, path, prefix)) { + return .{ .status = 404, .body = errJson(resp, "Not a recognized gRPC path") }; + } + const method = path[prefix.len..]; + const tool = blk: { + if (std.mem.eql(u8, method, "Search")) break :blk "opsm_search"; + if (std.mem.eql(u8, method, "Install")) break :blk "opsm_install"; + if (std.mem.eql(u8, method, "Resolve")) break :blk "opsm_resolve"; + if (std.mem.eql(u8, method, "Info")) break :blk "opsm_info"; + if (std.mem.eql(u8, method, "List")) break :blk "opsm_list"; + if (std.mem.eql(u8, method, "Registries")) break :blk "opsm_registries"; + if (std.mem.eql(u8, method, "Status")) break :blk "opsm_status"; + return .{ .status = 404, .body = errJson(resp, "Unknown gRPC method") }; + }; + return dispatch(tool, body, resp); +} + +// ============================================================================ +// GraphQL handler β€” POST /graphql +// ============================================================================ + +const GRAPHQL_SCHEMA = + \\type Query { + \\ search(query: String!, registry: String): SearchResult + \\ info(package: String!, registry: String, version: String): PackageInfo + \\ registries: RegistriesResult + \\ status: StatusResult + \\} + \\type Mutation { + \\ install(package: String!, registry: String, version: String): MutationResult + \\ resolve(manifest: String!): MutationResult + \\} + \\type SearchResult { success: Boolean, message: String } + \\type PackageInfo { success: Boolean, message: String } + \\type MutationResult { success: Boolean, message: String } + \\type RegistriesResult { success: Boolean, registry_count: Int } + \\type StatusResult { success: Boolean, state: String, version: String, registry_count: Int } +; + +fn dispatchGraphql(body: []const u8, resp: []u8) Response { + if (std.mem.indexOf(u8, body, "__schema") != null or + std.mem.indexOf(u8, body, "__type") != null) { + const schema_resp = std.fmt.bufPrint(resp, + \\{{"data":{{"__schema":{{"description":"{s}"}}}}}} + , .{GRAPHQL_SCHEMA}) catch return .{ .status = 200, .body = okJson(resp, "schema") }; + return .{ .status = 200, .body = schema_resp }; + } + if (std.mem.indexOf(u8, body, "search") != null) return dispatch("opsm_search", body, resp); + if (std.mem.indexOf(u8, body, "install") != null) return dispatch("opsm_install", body, resp); + if (std.mem.indexOf(u8, body, "resolve") != null) return dispatch("opsm_resolve", body, resp); + if (std.mem.indexOf(u8, body, "info") != null) return dispatch("opsm_info", body, resp); + if (std.mem.indexOf(u8, body, "registries") != null) return dispatch("opsm_registries", body, resp); + if (std.mem.indexOf(u8, body, "status") != null) return dispatch("opsm_status", body, resp); + return .{ .status = 200, .body = errJson(resp, "Unrecognised GraphQL operation") }; +} + +// ============================================================================ +// HTTP/1.1 connection handler (shared by all three listener threads) +// ============================================================================ + +const Protocol = enum { rest, grpc, graphql }; + +fn handleConnection(conn: std.net.Server.Connection, proto: Protocol) void { + defer conn.stream.close(); + + var in_buf: [MAX_CONN_BUF]u8 = undefined; + const n = conn.stream.read(&in_buf) catch return; + const req = in_buf[0..n]; + + // Parse: METHOD SP path SP HTTP/... \r\n[headers]\r\n\r\n[body] + var path: []const u8 = "/"; + var body: []const u8 = ""; + if (n > 4) { + const line_end = std.mem.indexOf(u8, req, "\r\n") orelse req.len; + const first_line = req[0..line_end]; + const parts_sep = std.mem.indexOfScalar(u8, first_line, ' ') orelse 0; + const rest_of_line = first_line[parts_sep + 1 ..]; + const path_end = std.mem.indexOfScalar(u8, rest_of_line, ' ') orelse rest_of_line.len; + path = rest_of_line[0..path_end]; + const body_sep = std.mem.indexOf(u8, req, "\r\n\r\n") orelse n; + body = req[@min(body_sep + 4, n)..]; + } + + var resp_buf: [MAX_CONN_BUF]u8 = undefined; + const result = switch (proto) { + .rest => dispatchRest(path, body, &resp_buf), + .grpc => dispatchGrpc(path, body, &resp_buf), + .graphql => dispatchGraphql(body, &resp_buf), + }; + + const content_type = switch (proto) { + .rest => "application/json", + .grpc => "application/grpc+json", + .graphql => "application/json", + }; + + var hdr_buf: [256]u8 = undefined; + const hdr = std.fmt.bufPrint(&hdr_buf, + "HTTP/1.1 {d} OK\r\nContent-Type: {s}\r\nContent-Length: {d}\r\nConnection: close\r\n\r\n", + .{ result.status, content_type, result.body.len }, + ) catch return; + + _ = conn.stream.writeAll(hdr) catch return; + _ = conn.stream.writeAll(result.body) catch return; +} + +// ============================================================================ +// Per-protocol listener loops (each runs in its own thread) +// ============================================================================ + +fn listenLoop(port: u16, proto: Protocol) void { + const addr = std.net.Address.initIp4(.{ 127, 0, 0, 1 }, port) catch return; + var server = addr.listen(.{ .reuse_address = true }) catch return; + defer server.deinit(); + + while (true) { + const conn = server.accept() catch continue; + handleConnection(conn, proto); + } +} + +// ============================================================================ +// Entry point +// ============================================================================ + +pub fn main() !void { + ffi.opsm_reset_all(); + + const rest_thread = try std.Thread.spawn(.{}, listenLoop, .{ REST_PORT, Protocol.rest }); + const grpc_thread = try std.Thread.spawn(.{}, listenLoop, .{ GRPC_PORT, Protocol.grpc }); + const gql_thread = try std.Thread.spawn(.{}, listenLoop, .{ GQL_PORT, Protocol.graphql }); + + rest_thread.join(); + grpc_thread.join(); + gql_thread.join(); +} diff --git a/cartridges/domains/registry/opsm-mcp/cartridge.json b/cartridges/domains/registry/opsm-mcp/cartridge.json new file mode 100644 index 0000000..692caf1 --- /dev/null +++ b/cartridges/domains/registry/opsm-mcp/cartridge.json @@ -0,0 +1,179 @@ +{ + "$schema": "https://boj.dev/schemas/cartridge/v1.json", + "spdx": "MPL-2.0", + "copyright": "Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath)", + "name": "opsm-mcp", + "version": "0.1.0", + "description": "Odds-and-Sods Package Manager (OPSM) gateway. Routes package search, install, dependency resolution (PubGrub), and registry management across 103 registry adapters (npm, cargo, hex, pypi, affinescript, rattlescript, eclexia, guix, nix, and more). State machine enforces valid registry slot lifecycle via Zig FFI.", + "domain": "Package Management", + "tier": "Ayo", + "protocols": [ + "MCP", + "REST" + ], + "auth": { + "method": "none", + "env_var": null, + "credential_source": null + }, + "api": { + "base_url": "local://opsm-mcp", + "content_type": "application/json" + }, + "tools": [ + { + "name": "opsm_search", + "description": "Search for packages across all configured registries. Returns matching packages with name, version, description, and registry source.", + "inputSchema": { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "Package name or keyword to search for" + }, + "registry": { + "type": "string", + "description": "Limit search to a specific registry (e.g. 'affinescript', 'cargo', 'npm'). Omit to search all." + }, + "limit": { + "type": "number", + "description": "Maximum results to return (default: 20)" + } + }, + "required": [ + "query" + ] + } + }, + { + "name": "opsm_install", + "description": "Install a package from any registry. Resolves the package, downloads it, verifies checksums, and installs into the workspace.", + "inputSchema": { + "type": "object", + "properties": { + "package_name": { + "type": "string", + "description": "Package name to install (e.g. 'affine-json', 'serde', 'phoenix')" + }, + "registry": { + "type": "string", + "description": "Target registry (e.g. 'affinescript', 'cargo', 'hex'). Auto-detected if omitted." + }, + "version": { + "type": "string", + "description": "Package version or version constraint (default: latest)" + }, + "workspace_root": { + "type": "string", + "description": "Workspace directory to install into" + } + }, + "required": [ + "package_name" + ] + } + }, + { + "name": "opsm_resolve", + "description": "Resolve the full dependency tree for a manifest using the PubGrub algorithm. Returns the resolved set of packages and versions, or a conflict explanation if resolution fails.", + "inputSchema": { + "type": "object", + "properties": { + "manifest": { + "type": "string", + "description": "Manifest file content (affine.toml, Cargo.toml, mix.exs, etc.)" + }, + "manifest_format": { + "type": "string", + "description": "Manifest format hint (e.g. 'affine', 'cargo', 'hex'). Auto-detected if omitted." + } + }, + "required": [ + "manifest" + ] + } + }, + { + "name": "opsm_info", + "description": "Fetch metadata for a package: description, versions, authors, license, dependencies, and attestations.", + "inputSchema": { + "type": "object", + "properties": { + "package_name": { + "type": "string", + "description": "Package name" + }, + "registry": { + "type": "string", + "description": "Registry to query (auto-detected if omitted)" + }, + "version": { + "type": "string", + "description": "Specific version to look up (default: latest)" + } + }, + "required": [ + "package_name" + ] + } + }, + { + "name": "opsm_list", + "description": "List installed packages in a workspace. Returns name, version, registry, and direct/transitive classification for each installed package.", + "inputSchema": { + "type": "object", + "properties": { + "workspace_root": { + "type": "string", + "description": "Workspace root directory (uses current directory if omitted)" + }, + "include_transitive": { + "type": "boolean", + "description": "Include transitive dependencies (default: false)" + } + }, + "required": [] + } + }, + { + "name": "opsm_registries", + "description": "List all 103 registry adapters with their names, slot indices, current state (disconnected/connected/querying/idle), and supported operations.", + "inputSchema": { + "type": "object", + "properties": { + "filter_state": { + "type": "string", + "enum": [ + "disconnected", + "connected", + "querying", + "idle" + ], + "description": "Filter to only registries in this state (optional)" + } + }, + "required": [] + } + }, + { + "name": "opsm_status", + "description": "Return the OPSM service health status, version, registry count, resolver algorithm, and security configuration.", + "inputSchema": { + "type": "object", + "properties": {}, + "required": [] + } + } + ], + "ffi": { + "so_path": "ffi/zig-out/lib/libopsm_mcp.so", + "abi_version": "ADR-0006", + "symbols": [ + "boj_cartridge_init", + "boj_cartridge_deinit", + "boj_cartridge_name", + "boj_cartridge_version", + "boj_cartridge_invoke" + ] + } +} diff --git a/cartridges/domains/registry/opsm-mcp/ffi/README.adoc b/cartridges/domains/registry/opsm-mcp/ffi/README.adoc new file mode 100644 index 0000000..54e412a --- /dev/null +++ b/cartridges/domains/registry/opsm-mcp/ffi/README.adoc @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += opsm-mcp / ffi β€” Zig FFI layer +:orientation: shallow + +== Purpose + +C-ABI exports bridging this cartridge's Idris2 ABI to the runtime. Consumed by +the adapter for protocol dispatch and by the trunk loader for mount. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” produces `zig-out/lib/libopsm.so`. +| `opsm_ffi.zig` | C-ABI exports (9 exports, 4 inline tests, 174 lines). +|=== + +== Invariants + +* **Null-pointer rejection** on `[*c]const u8` arguments before any + dereference. +* **Negative return values** carry documented error meanings; zero is + success (matching the trunk error-code convention). +* **No allocator leaks** β€” every allocation path pairs with `defer free`. + +== Test/proof surface + +4 inline `test "..."` block(s) in `opsm_ffi.zig`. Run with +`cd ffi && zig build test`. + +== Read-first + +. `opsm_ffi.zig` β€” C-ABI signatures and guards. +. Trunk `ffi/zig/src/catalogue.zig` for how this `.so` is loaded. diff --git a/cartridges/domains/registry/opsm-mcp/ffi/build.zig b/cartridges/domains/registry/opsm-mcp/ffi/build.zig new file mode 100644 index 0000000..b7ee362 --- /dev/null +++ b/cartridges/domains/registry/opsm-mcp/ffi/build.zig @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: MPL-2.0 +// OPSM-MCP Cartridge β€” Zig FFI build configuration (Zig 0.15+). +// +// Bridges the Idris2 ABI (SafeRegistry state machine) to a C-compatible +// shared library that the zig adapter can call. + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + // Shared ADR-0006 invoke-shim module (relative path up to boj-server trunk). + + const shim_mod = b.addModule("cartridge_shim", .{ + + .root_source_file = b.path("../../../ffi/zig/src/cartridge_shim.zig"), + + .target = target, + + .optimize = optimize, + + }); + + const opsm_mod = b.addModule("opsm_ffi", .{ + .root_source_file = b.path("opsm_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + opsm_mod.addImport("cartridge_shim", shim_mod); + + // Tests + const opsm_tests = b.addTest(.{ + .root_module = opsm_mod, + }); + const run_tests = b.addRunArtifact(opsm_tests); + const test_step = b.step("test", "Run opsm-mcp FFI tests"); + test_step.dependOn(&run_tests.step); + + // Shared library (libopsm_mcp.so) + const lib_mod = b.createModule(.{ + .root_source_file = b.path("opsm_ffi.zig"), + .target = target, + .optimize = optimize, + }); + lib_mod.addImport("cartridge_shim", shim_mod); + + const lib = b.addLibrary(.{ + .name = "opsm_mcp", + .root_module = lib_mod, + .linkage = .dynamic, + }); + b.installArtifact(lib); + + const lib_step = b.step("lib", "Build shared library"); + lib_step.dependOn(&lib.step); +} diff --git a/cartridges/domains/registry/opsm-mcp/ffi/cartridge_shim.zig b/cartridges/domains/registry/opsm-mcp/ffi/cartridge_shim.zig new file mode 100644 index 0000000..0e399db --- /dev/null +++ b/cartridges/domains/registry/opsm-mcp/ffi/cartridge_shim.zig @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// cartridge_shim.zig β€” Shared helpers for the ADR-0006 five-symbol +// cartridge ABI (`boj_cartridge_init / deinit / name / version / invoke`). +// +// The shim centralises the seven-code return convention, NUL-argument +// guards, tool-name comparison, and the buffer-too-small path so each +// cartridge's `boj_cartridge_invoke` can stay short β€” typically a tool +// table plus `shim.writeResult(...)`. +// +// Cartridges import this file by relative path (no build-graph change +// needed). Example: +// +// const shim = @import("../../../ffi/zig/src/cartridge_shim.zig"); +// +// export fn boj_cartridge_invoke( +// tool_name: [*c]const u8, +// json_args: [*c]const u8, +// out_buf: [*c]u8, +// in_out_len: [*c]usize, +// ) callconv(.c) i32 { +// _ = json_args; +// if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; +// const body = if (shim.toolIs(tool_name, "foo")) "{\"result\":{}}" +// else return shim.RC_UNKNOWN_TOOL; +// return shim.writeResult(out_buf, in_out_len, body); +// } + +const std = @import("std"); + +// ── Return codes (ADR-0006 Β§Return codes) ──────────────────────────── +// +// Frozen by ADR-0006. New failure modes compose these via the error +// JSON body β€” the integer surface does not grow without a follow-up ADR. + +pub const RC_SUCCESS: i32 = 0; +pub const RC_UNKNOWN_TOOL: i32 = -1; +pub const RC_BAD_ARGS: i32 = -2; +pub const RC_BUFFER_TOO_SMALL: i32 = -3; +pub const RC_RUNTIME_ERROR: i32 = -4; +pub const RC_PANIC: i32 = -5; +pub const RC_AUTH_DENIED: i32 = -6; + +// ── Invoke-path helpers ────────────────────────────────────────────── + +/// True if any of the three mandatory `boj_cartridge_invoke` output-path +/// pointers is null. Use at the top of every invoke to short-circuit to +/// `RC_BAD_ARGS`. +pub fn invokeArgsNull( + tool_name: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) bool { + return tool_name == null or out_buf == null or in_out_len == null; +} + +/// Compare a C-NUL-terminated tool-name pointer against a Zig string +/// literal. Caller must have already verified `tool_name` is non-null +/// (usually via `invokeArgsNull`). +pub fn toolIs(tool_name: [*c]const u8, expected: []const u8) bool { + const s = std.mem.span(@as([*:0]const u8, @ptrCast(tool_name))); + return std.mem.eql(u8, s, expected); +} + +/// Copy `body` into `out_buf[0..*in_out_len]` (as a capacity) and update +/// `*in_out_len` to the number of bytes written. Returns `RC_SUCCESS`. +/// +/// If `body.len` exceeds the current capacity stored in `*in_out_len`, +/// sets `*in_out_len` to the required size and returns +/// `RC_BUFFER_TOO_SMALL` β€” the caller is then expected to re-allocate +/// and retry, per ADR-0006 Β§Memory ownership. +/// +/// Caller must have already verified that `out_buf` and `in_out_len` +/// are non-null. +pub fn writeResult( + out_buf: [*c]u8, + in_out_len: [*c]usize, + body: []const u8, +) i32 { + const cap = in_out_len.*; + if (body.len > cap) { + in_out_len.* = body.len; + return RC_BUFFER_TOO_SMALL; + } + @memcpy(out_buf[0..body.len], body); + in_out_len.* = body.len; + return RC_SUCCESS; +} + +// ── Tests ──────────────────────────────────────────────────────────── + +test "writeResult: body fits, writes and sets length" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); + try std.testing.expectEqualStrings("hello", buf[0..len]); +} + +test "writeResult: too small returns -3 and sets required length" { + var buf: [2]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_BUFFER_TOO_SMALL, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: exact-fit succeeds" { + var buf: [5]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: empty body" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, ""); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 0), len); +} + +test "toolIs: matches and rejects" { + const name: [*:0]const u8 = "foo"; + try std.testing.expect(toolIs(@ptrCast(name), "foo")); + try std.testing.expect(!toolIs(@ptrCast(name), "bar")); + try std.testing.expect(!toolIs(@ptrCast(name), "foobar")); + try std.testing.expect(!toolIs(@ptrCast(name), "fo")); +} + +test "invokeArgsNull: detects each null slot" { + var buf: [4]u8 = undefined; + var len: usize = 4; + const name: [*:0]const u8 = "x"; + try std.testing.expect(!invokeArgsNull(@ptrCast(name), &buf, &len)); + try std.testing.expect(invokeArgsNull(null, &buf, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), null, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), &buf, null)); +} diff --git a/cartridges/domains/registry/opsm-mcp/ffi/opsm_ffi.zig b/cartridges/domains/registry/opsm-mcp/ffi/opsm_ffi.zig new file mode 100644 index 0000000..9cb3fa9 --- /dev/null +++ b/cartridges/domains/registry/opsm-mcp/ffi/opsm_ffi.zig @@ -0,0 +1,274 @@ +// SPDX-License-Identifier: MPL-2.0 +// OPSM-MCP Cartridge β€” Zig FFI implementation. +// +// Provides C-compatible functions for the OPSM registry state machine. +// The zig adapter calls these to manage registry connections, execute +// searches, and resolve dependencies. +// +// State machine: Disconnected -> Connected -> Querying -> Idle +// +// Each registry slot holds its current state. Operations that violate +// the state machine return error codes rather than proceeding unsafely. + +const std = @import("std"); + +/// Maximum number of concurrent registry connections. +const MAX_REGISTRIES: usize = 101; + +/// Registry connection states (mirrors Idris2 RegState). +const RegState = enum(u8) { + disconnected = 0, + connected = 1, + querying = 2, + idle = 3, +}; + +/// Error codes for invalid state transitions. +const OpsmError = enum(i32) { + ok = 0, + invalid_slot = -1, + invalid_transition = -2, + already_connected = -3, + not_connected = -4, + already_querying = -5, +}; + +/// Per-slot registry state. +const RegistrySlot = struct { + state: RegState = .disconnected, + name: [128]u8 = [_]u8{0} ** 128, + name_len: usize = 0, +}; + +/// Global registry state table. +var slots: [MAX_REGISTRIES]RegistrySlot = [_]RegistrySlot{.{}} ** MAX_REGISTRIES; + +// ======================================================================== +// C ABI exports +// ======================================================================== + +/// Connect to a registry. Slot must be in Disconnected state. +export fn opsm_connect(slot_idx: u32) i32 { + if (slot_idx >= MAX_REGISTRIES) return @intFromEnum(OpsmError.invalid_slot); + const slot = &slots[slot_idx]; + if (slot.state != .disconnected) return @intFromEnum(OpsmError.already_connected); + slot.state = .connected; + return @intFromEnum(OpsmError.ok); +} + +/// Begin a query on a connected registry. +export fn opsm_start_query(slot_idx: u32) i32 { + if (slot_idx >= MAX_REGISTRIES) return @intFromEnum(OpsmError.invalid_slot); + const slot = &slots[slot_idx]; + if (slot.state != .connected) return @intFromEnum(OpsmError.not_connected); + slot.state = .querying; + return @intFromEnum(OpsmError.ok); +} + +/// End a query, transitioning to idle. +export fn opsm_end_query(slot_idx: u32) i32 { + if (slot_idx >= MAX_REGISTRIES) return @intFromEnum(OpsmError.invalid_slot); + const slot = &slots[slot_idx]; + if (slot.state != .querying) return @intFromEnum(OpsmError.invalid_transition); + slot.state = .idle; + return @intFromEnum(OpsmError.ok); +} + +/// Reset an idle registry back to connected. +export fn opsm_reset(slot_idx: u32) i32 { + if (slot_idx >= MAX_REGISTRIES) return @intFromEnum(OpsmError.invalid_slot); + const slot = &slots[slot_idx]; + if (slot.state != .idle) return @intFromEnum(OpsmError.invalid_transition); + slot.state = .connected; + return @intFromEnum(OpsmError.ok); +} + +/// Disconnect a registry (from connected or idle state). +export fn opsm_disconnect(slot_idx: u32) i32 { + if (slot_idx >= MAX_REGISTRIES) return @intFromEnum(OpsmError.invalid_slot); + const slot = &slots[slot_idx]; + if (slot.state != .connected and slot.state != .idle) { + return @intFromEnum(OpsmError.not_connected); + } + slot.state = .disconnected; + return @intFromEnum(OpsmError.ok); +} + +/// Get the current state of a registry slot. +export fn opsm_state(slot_idx: u32) i32 { + if (slot_idx >= MAX_REGISTRIES) return @intFromEnum(OpsmError.invalid_slot); + return @intCast(@intFromEnum(slots[slot_idx].state)); +} + +/// Check if a state transition is valid. +export fn opsm_can_transition(from: u8, to: u8) i32 { + const from_state = std.meta.intToEnum(RegState, from) catch return 0; + const to_state = std.meta.intToEnum(RegState, to) catch return 0; + + const valid = switch (from_state) { + .disconnected => to_state == .connected, + .connected => to_state == .querying or to_state == .disconnected, + .querying => to_state == .idle, + .idle => to_state == .connected or to_state == .disconnected, + }; + + return if (valid) 1 else 0; +} + +/// Reset all registry slots to disconnected. +export fn opsm_reset_all() void { + for (&slots) |*slot| { + slot.state = .disconnected; + slot.name_len = 0; + } +} + +/// Set the name of a registry slot. +export fn opsm_set_name(slot_idx: u32, name_ptr: [*]const u8, name_len: u32) i32 { + if (slot_idx >= MAX_REGISTRIES) return @intFromEnum(OpsmError.invalid_slot); + const len = @min(name_len, 128); + const slot = &slots[slot_idx]; + @memcpy(slot.name[0..len], name_ptr[0..len]); + slot.name_len = len; + return @intFromEnum(OpsmError.ok); +} + +// ======================================================================== +// ═══════════════════════════════════════════════════════════════════════ +// Standard ABI (ADR-0005 four symbols + ADR-0006 invoke) +// ═══════════════════════════════════════════════════════════════════════ + +const shim = @import("cartridge_shim.zig"); + +const CARTRIDGE_NAME_PTR: [*:0]const u8 = "opsm-mcp"; +const CARTRIDGE_VERSION_PTR: [*:0]const u8 = "0.1.0"; + +export fn boj_cartridge_init() callconv(.c) c_int { + return 0; +} + +export fn boj_cartridge_deinit() callconv(.c) void {} + +export fn boj_cartridge_name() callconv(.c) [*:0]const u8 { + return CARTRIDGE_NAME_PTR; +} + +export fn boj_cartridge_version() callconv(.c) [*:0]const u8 { + return CARTRIDGE_VERSION_PTR; +} + +/// Dispatch the cartridge.json MCP tools. Grade D Alpha stubs. +export fn boj_cartridge_invoke( + tool_name: [*c]const u8, + json_args: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) callconv(.c) i32 { + _ = json_args; + if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; + + const body: []const u8 = if (shim.toolIs(tool_name, "opsm_search")) + "{\"result\":{\"matches\":[],\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "opsm_install")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "opsm_resolve")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "opsm_info")) + "{\"result\":{\"metadata\":{},\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "opsm_list")) + "{\"result\":{\"items\":[],\"count\":0,\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "opsm_registries")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "opsm_status")) + "{\"result\":{\"metadata\":{},\"status\":\"stub\"}}" +else + return shim.RC_UNKNOWN_TOOL; + + return shim.writeResult(out_buf, in_out_len, body); +} + +// Tests +// ======================================================================== + +test "connect and disconnect lifecycle" { + opsm_reset_all(); + try std.testing.expectEqual(@as(i32, 0), opsm_connect(0)); + try std.testing.expectEqual(@as(i32, 1), opsm_state(0)); + try std.testing.expectEqual(@as(i32, 0), opsm_start_query(0)); + try std.testing.expectEqual(@as(i32, 2), opsm_state(0)); + try std.testing.expectEqual(@as(i32, 0), opsm_end_query(0)); + try std.testing.expectEqual(@as(i32, 3), opsm_state(0)); + try std.testing.expectEqual(@as(i32, 0), opsm_disconnect(0)); + try std.testing.expectEqual(@as(i32, 0), opsm_state(0)); +} + +test "invalid transition rejected" { + opsm_reset_all(); + // Cannot query a disconnected registry + try std.testing.expectEqual(@as(i32, -4), opsm_start_query(0)); + // Cannot disconnect a disconnected registry + try std.testing.expectEqual(@as(i32, -4), opsm_disconnect(0)); +} + +test "slot bounds checking" { + try std.testing.expectEqual(@as(i32, -1), opsm_connect(200)); + try std.testing.expectEqual(@as(i32, -1), opsm_state(200)); +} + +test "transition validity checker" { + // disconnected -> connected: valid + try std.testing.expectEqual(@as(i32, 1), opsm_can_transition(0, 1)); + // disconnected -> querying: invalid + try std.testing.expectEqual(@as(i32, 0), opsm_can_transition(0, 2)); + // connected -> querying: valid + try std.testing.expectEqual(@as(i32, 1), opsm_can_transition(1, 2)); + // querying -> disconnected: invalid + try std.testing.expectEqual(@as(i32, 0), opsm_can_transition(2, 0)); +} + +// ═══════════════════════════════════════════════════════════════════════ +// ADR-0006 invoke dispatch tests +// ═══════════════════════════════════════════════════════════════════════ + +test "boj_cartridge_name returns opsm-mcp" { + const n = std.mem.span(boj_cartridge_name()); + try std.testing.expectEqualStrings("opsm-mcp", n); +} + +test "boj_cartridge_init returns 0" { + try std.testing.expectEqual(@as(c_int, 0), boj_cartridge_init()); +} + +test "invoke: each declared tool succeeds" { + var buf: [256]u8 = undefined; + const tools = [_][]const u8{ + "opsm_search", + "opsm_install", + "opsm_resolve", + "opsm_info", + "opsm_list", + "opsm_registries", + "opsm_status", + }; + for (tools) |t| { + var len: usize = buf.len; + const rc = boj_cartridge_invoke(t.ptr, "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, 0), rc); + try std.testing.expect(std.mem.indexOf(u8, buf[0..len], "result") != null); + } +} + +test "invoke: unknown tool returns -1" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("nope", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -1), rc); +} + +test "invoke: buffer too small returns -3" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("opsm_search", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -3), rc); + try std.testing.expect(len > 4); +} diff --git a/cartridges/domains/registry/opsm-mcp/mod.js b/cartridges/domains/registry/opsm-mcp/mod.js new file mode 100644 index 0000000..8713d19 --- /dev/null +++ b/cartridges/domains/registry/opsm-mcp/mod.js @@ -0,0 +1,169 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// opsm-mcp/mod.js -- Odds-and-Sods Package Manager gateway cartridge. +// +// Delegates all package operations to the OPSM Elixir backend (opsm_ex) +// via HTTP. The Zig FFI state machine enforces valid registry slot lifecycles +// and is consulted before issuing backend requests. +// +// Supported registries include all 103 OPSM adapters; the AffineScript and +// RattleScript registries are first-class entries. +// +// Backend: OPSM Elixir (opsm_ex) on http://127.0.0.1:7700 (configurable) +// Auth: None required β€” local service. +// +// Usage: import { handleTool } from "./mod.js"; + +// --------------------------------------------------------------------------- +// Configuration +// --------------------------------------------------------------------------- + +const OPSM_BASE_URL = Deno.env.get("OPSM_BACKEND_URL") ?? "http://127.0.0.1:7700"; +const OPSM_TIMEOUT_MS = 15_000; + +// --------------------------------------------------------------------------- +// HTTP helper β€” POST to the OPSM Elixir backend +// --------------------------------------------------------------------------- + +async function opsmPost(path, payload) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), OPSM_TIMEOUT_MS); + try { + const resp = await fetch(`${OPSM_BASE_URL}${path}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + signal: controller.signal, + }); + const data = await resp.json().catch(() => ({ success: false, error: "non-JSON response" })); + return { status: resp.status, data }; + } catch (e) { + if (e.name === "AbortError") { + return { status: 504, data: { success: false, error: "OPSM backend timed out" } }; + } + return { status: 503, data: { success: false, error: `OPSM backend unavailable: ${e.message}` } }; + } finally { + clearTimeout(timer); + } +} + +async function opsmGet(path) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), OPSM_TIMEOUT_MS); + try { + const resp = await fetch(`${OPSM_BASE_URL}${path}`, { + method: "GET", + signal: controller.signal, + }); + const data = await resp.json().catch(() => ({ success: false, error: "non-JSON response" })); + return { status: resp.status, data }; + } catch (e) { + if (e.name === "AbortError") { + return { status: 504, data: { success: false, error: "OPSM backend timed out" } }; + } + return { status: 503, data: { success: false, error: `OPSM backend unavailable: ${e.message}` } }; + } finally { + clearTimeout(timer); + } +} + +// --------------------------------------------------------------------------- +// Registry name β†’ OPSM forth atom normaliser +// --------------------------------------------------------------------------- + +const REGISTRY_ALIASES = { + // AffineScript and RattleScript (first-class) + affinescript: "affinescript", affine: "affinescript", afs: "affinescript", + rattlescript: "rattlescript", rattle: "rattlescript", rts: "rattlescript", + // Hyperpolymath nextgen languages + eclexia: "eclexia", ecl: "eclexia", + ephapax: "ephapax", mylang: "my_lang", wokelang: "wokelang", + julia_the_viper: "julia_the_viper", viper: "julia_the_viper", + error_lang: "error_lang", oblibeny: "oblibeny", idris2: "idris2", idris: "idris2", + // Major ecosystems + cargo: "cargo", rust: "cargo", crates: "cargo", + npm: "npm", node: "npm", + hex: "hex", elixir: "hex", + pypi: "pypi", python: "pypi", pip: "pypi", + gem: "gem", ruby: "gem", + go: "go", golang: "go", + hackage: "hackage", haskell: "hackage", + nuget: "nuget", dotnet: "nuget", + maven: "maven", java: "maven", +}; + +function normaliseRegistry(r) { + if (!r) return null; + return REGISTRY_ALIASES[r.toLowerCase()] ?? r.toLowerCase(); +} + +// --------------------------------------------------------------------------- +// Tool handlers +// --------------------------------------------------------------------------- + +export async function handleTool(toolName, args) { + switch (toolName) { + + // -- opsm_search --------------------------------------------------------- + case "opsm_search": { + const { query, registry, limit = 20 } = args; + if (!query) return { status: 400, data: { error: "query is required" } }; + const payload = { query, limit }; + if (registry) payload.registry = normaliseRegistry(registry); + return opsmPost("/api/v1/search", payload); + } + + // -- opsm_install -------------------------------------------------------- + case "opsm_install": { + const { package_name, registry, version = "latest", workspace_root } = args; + if (!package_name) return { status: 400, data: { error: "package_name is required" } }; + const payload = { package: package_name, version }; + if (registry) payload.registry = normaliseRegistry(registry); + if (workspace_root) payload.workspace_root = workspace_root; + return opsmPost("/api/v1/install", payload); + } + + // -- opsm_resolve -------------------------------------------------------- + case "opsm_resolve": { + const { manifest, manifest_format } = args; + if (!manifest) return { status: 400, data: { error: "manifest is required" } }; + const payload = { manifest }; + if (manifest_format) payload.format = manifest_format; + return opsmPost("/api/v1/resolve", payload); + } + + // -- opsm_info ----------------------------------------------------------- + case "opsm_info": { + const { package_name, registry, version = "latest" } = args; + if (!package_name) return { status: 400, data: { error: "package_name is required" } }; + const forth = registry ? normaliseRegistry(registry) : "auto"; + return opsmGet(`/api/v1/packages/${encodeURIComponent(package_name)}?forth=${forth}&version=${encodeURIComponent(version)}`); + } + + // -- opsm_list ----------------------------------------------------------- + case "opsm_list": { + const { workspace_root, include_transitive = false } = args ?? {}; + const payload = { include_transitive }; + if (workspace_root) payload.workspace_root = workspace_root; + return opsmPost("/api/v1/list", payload); + } + + // -- opsm_registries ----------------------------------------------------- + case "opsm_registries": { + const { filter_state } = args ?? {}; + const url = filter_state + ? `/api/v1/registries?state=${encodeURIComponent(filter_state)}` + : "/api/v1/registries"; + return opsmGet(url); + } + + // -- opsm_status --------------------------------------------------------- + case "opsm_status": { + return opsmGet("/api/v1/status"); + } + + default: + return { status: 404, data: { error: `Unknown tool: ${toolName}` } }; + } +} diff --git a/cartridges/domains/registry/opsm-mcp/panels/manifest.json b/cartridges/domains/registry/opsm-mcp/panels/manifest.json new file mode 100644 index 0000000..f7b8e6e --- /dev/null +++ b/cartridges/domains/registry/opsm-mcp/panels/manifest.json @@ -0,0 +1,132 @@ +{ + "$schema": "https://panll.dev/schemas/panel-manifest/v1.json", + "spdx": "MPL-2.0", + "cartridge": "opsm-mcp", + "domain": "Package Management", + "version": "0.1.0", + "clade": "developer-tools/package-management", + "description": "Universal federated package manager β€” 101 registry adapters across all ecosystems", + "panels": [ + { + "id": "opsm-status", + "title": "OPSM Service Status", + "description": "Package manager health and registry connectivity", + "type": "status-indicator", + "data_source": { + "endpoint": "/cartridge/opsm-mcp/invoke", + "method": "POST", + "body": { "tool": "status" }, + "refresh_interval_ms": 10000 + }, + "widgets": [ + { + "type": "state-badge", + "field": "state", + "states": { + "ready": { "color": "#2ecc71", "icon": "package" }, + "degraded": { "color": "#f39c12", "icon": "alert-circle" }, + "error": { "color": "#e74c3c", "icon": "alert-triangle" } + } + }, + { "type": "text", "field": "version", "label": "Version" }, + { "type": "counter", "field": "registry_count", "label": "Registries" } + ] + }, + { + "id": "opsm-search", + "title": "Cross-Registry Package Search", + "description": "Search packages across all 101 registered ecosystems simultaneously", + "type": "search-input", + "data_source": { + "endpoint": "/cartridge/opsm-mcp/invoke", + "method": "POST", + "body": { "tool": "search", "query": "{{input}}" } + }, + "widgets": [ + { + "type": "table", + "columns": [ + { "field": "name", "label": "Package" }, + { "field": "registry", "label": "Registry" }, + { "field": "version", "label": "Latest" }, + { "field": "description", "label": "Description" } + ] + } + ] + }, + { + "id": "opsm-installed", + "title": "Installed Packages", + "description": "Currently installed packages and their dependency tree", + "type": "data-table", + "data_source": { + "endpoint": "/cartridge/opsm-mcp/invoke", + "method": "POST", + "body": { "tool": "list" }, + "refresh_interval_ms": 30000 + }, + "widgets": [ + { + "type": "table", + "columns": [ + { "field": "name", "label": "Package" }, + { "field": "version", "label": "Version" }, + { "field": "registry", "label": "Source" }, + { "field": "pinned", "label": "Pinned" } + ] + }, + { "type": "counter", "field": "total", "label": "Total Packages" } + ] + }, + { + "id": "opsm-registries", + "title": "Registry Adapters", + "description": "Status of all 101 registry connections", + "type": "data-table", + "data_source": { + "endpoint": "/cartridge/opsm-mcp/invoke", + "method": "POST", + "body": { "tool": "registries" }, + "refresh_interval_ms": 60000 + }, + "widgets": [ + { + "type": "table", + "columns": [ + { "field": "name", "label": "Registry" }, + { "field": "ecosystem", "label": "Ecosystem" }, + { "field": "status", "label": "Status" }, + { "field": "cached", "label": "Cached" } + ] + } + ] + }, + { + "id": "opsm-resolve", + "title": "Dependency Resolver", + "description": "PubGrub dependency resolution with sustainability scoring", + "type": "action-panel", + "data_source": { + "endpoint": "/cartridge/opsm-mcp/invoke", + "method": "POST", + "body": { "tool": "resolve", "manifest": "{{input}}" } + }, + "widgets": [ + { + "type": "code-editor", + "language": "toml", + "label": "Manifest (opsm.toml)" + }, + { + "type": "table", + "field": "resolved", + "columns": [ + { "field": "name", "label": "Package" }, + { "field": "version", "label": "Resolved Version" }, + { "field": "sustainability", "label": "Oikos Score" } + ] + } + ] + } + ] +} diff --git a/cartridges/domains/registry/pypi-mcp/README.adoc b/cartridges/domains/registry/pypi-mcp/README.adoc new file mode 100644 index 0000000..615b4d6 --- /dev/null +++ b/cartridges/domains/registry/pypi-mcp/README.adoc @@ -0,0 +1,95 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += pypi-mcp +Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk> +:spdx: MPL-2.0 +:tier: Ayo +:domain: Registry +:protocols: MCP, REST + +== Overview + +PyPI registry cartridge for the BoJ server. Provides type-safe access to +the PyPI JSON API for Python package search, metadata retrieval, version listing, +download statistics, dependency analysis, release file inspection, maintainer +lookup, classifier browsing, vulnerability advisory checks, and project URL +extraction. + +=== State Machine + +`Unauthenticated -> Authenticated` (optional, all reads work without auth) + +`Authenticated -> RateLimited -> Authenticated` (normal flow, 429 handling) + +`Authenticated -> Error -> Unauthenticated` (error recovery) + +=== Actions (11) + +[cols="1,1"] +|=== +| Category | Actions + +| Search +| SearchPackages + +| Package Metadata +| GetPackage, GetVersion, ListVersions + +| Downloads +| GetDownloads + +| Dependencies +| GetDependencies + +| Release Files +| GetReleaseFiles + +| Maintainers +| GetMaintainers + +| Classifiers +| GetClassifiers + +| Security +| GetVulnerabilities + +| Project URLs +| GetProjectUrls +|=== + +== Architecture + +[cols="1,1,2"] +|=== +| Layer | Language | Purpose + +| ABI +| Idris2 +| Formally verified state machine with dependent-type proofs (PypiMcp.SafeRegistry) + +| FFI +| Zig +| C-compatible implementation with thread-safe session pool, action recording, category counting + +| Adapter +| zig +| REST bridge to BoJ unified adapter protocol +|=== + +== Building + +[source,bash] +---- +# Build FFI shared library +cd ffi && zig build + +# Run FFI tests +cd ffi && zig build test + +# Type-check ABI +cd abi && idris2 --check PypiMcp.SafeRegistry +---- + +== Status + +Development -- customised with PyPI-specific search, downloads, classifiers, vulnerabilities, and release file APIs. diff --git a/cartridges/domains/registry/pypi-mcp/abi/PypiMcp/SafeRegistry.idr b/cartridges/domains/registry/pypi-mcp/abi/PypiMcp/SafeRegistry.idr new file mode 100644 index 0000000..bf2843d --- /dev/null +++ b/cartridges/domains/registry/pypi-mcp/abi/PypiMcp/SafeRegistry.idr @@ -0,0 +1,184 @@ +-- SPDX-License-Identifier: MPL-2.0 +-- Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +-- +-- PypiMcp.SafeRegistry β€” Type-safe ABI for pypi-mcp cartridge. +-- +-- Dependent-type state machine governing PyPI API access. +-- Encodes optional Bearer token auth, package search, metadata retrieval, +-- version listing, download stats, dependency analysis, release files, +-- maintainer lookup, classifier browsing, vulnerability checks, +-- and project URL extraction as compile-time invariants. +-- REST API: https://pypi.org/pypi/<package>/json +-- No unsafe escape hatches. + +module PypiMcp.SafeRegistry + +%default total + +-- --------------------------------------------------------------------------- +-- Authentication state machine +-- --------------------------------------------------------------------------- + +||| Session state for PyPI MCP operations. +||| Unauthenticated: no API token; read-only operations available. +||| Authenticated: PyPI API token active, full access. +||| RateLimited: PyPI rate limit hit (429); must wait. +||| Error: unrecoverable error (invalid token, network failure). +public export +data SessionState + = Unauthenticated + | Authenticated + | RateLimited + | Error + +||| Proof that a state transition is valid. +||| PyPI allows both authenticated and unauthenticated sessions. +public export +data ValidTransition : SessionState -> SessionState -> Type where + Authenticate : ValidTransition Unauthenticated Authenticated + Deauthenticate : ValidTransition Authenticated Unauthenticated + Throttle : ValidTransition Authenticated RateLimited + ThrottleAnon : ValidTransition Unauthenticated RateLimited + Unthrottle : ValidTransition RateLimited Authenticated + UnthrottleAnon : ValidTransition RateLimited Unauthenticated + AuthError : ValidTransition Authenticated Error + AnonError : ValidTransition Unauthenticated Error + RecoverToAuth : ValidTransition Error Authenticated + RecoverToAnon : ValidTransition Error Unauthenticated + +-- --------------------------------------------------------------------------- +-- C-ABI integer encoding +-- --------------------------------------------------------------------------- + +||| Encode session state as C-compatible integer for FFI boundary. +export +sessionStateToInt : SessionState -> Int +sessionStateToInt Unauthenticated = 0 +sessionStateToInt Authenticated = 1 +sessionStateToInt RateLimited = 2 +sessionStateToInt Error = 3 + +||| Decode integer back to session state. Returns Nothing for out-of-range. +export +intToSessionState : Int -> Maybe SessionState +intToSessionState 0 = Just Unauthenticated +intToSessionState 1 = Just Authenticated +intToSessionState 2 = Just RateLimited +intToSessionState 3 = Just Error +intToSessionState _ = Nothing + +||| Check if a state transition is valid (C-ABI export). +export +pypi_mcp_can_transition : Int -> Int -> Int +pypi_mcp_can_transition from to = + case (intToSessionState from, intToSessionState to) of + (Just Unauthenticated, Just Authenticated) => 1 + (Just Authenticated, Just Unauthenticated) => 1 + (Just Authenticated, Just RateLimited) => 1 + (Just Unauthenticated, Just RateLimited) => 1 + (Just RateLimited, Just Authenticated) => 1 + (Just RateLimited, Just Unauthenticated) => 1 + (Just Authenticated, Just Error) => 1 + (Just Unauthenticated, Just Error) => 1 + (Just Error, Just Authenticated) => 1 + (Just Error, Just Unauthenticated) => 1 + _ => 0 + +-- --------------------------------------------------------------------------- +-- PyPI actions +-- --------------------------------------------------------------------------- + +||| Actions available through the PyPI MCP cartridge. +||| Grouped: Search, Metadata, Versions, Downloads, Dependencies, +||| ReleaseFiles, Maintainers, Classifiers, Vulnerabilities, ProjectURLs. +public export +data PypiAction + = SearchPackages + | GetPackage + | GetVersion + | ListVersions + | GetDownloads + | GetDependencies + | GetReleaseFiles + | GetMaintainers + | GetClassifiers + | GetVulnerabilities + | GetProjectUrls + +||| Whether an action requires Authenticated state. +||| All PyPI read operations work without auth. +export +actionRequiresAuth : PypiAction -> Bool +actionRequiresAuth _ = False + +||| Whether an action is a write/mutating operation. +||| All pypi-mcp actions are read-only queries. +export +actionIsMutating : PypiAction -> Bool +actionIsMutating _ = False + +||| Encode action as C-compatible integer for FFI. +export +actionToInt : PypiAction -> Int +actionToInt SearchPackages = 0 +actionToInt GetPackage = 1 +actionToInt GetVersion = 2 +actionToInt ListVersions = 3 +actionToInt GetDownloads = 4 +actionToInt GetDependencies = 5 +actionToInt GetReleaseFiles = 6 +actionToInt GetMaintainers = 7 +actionToInt GetClassifiers = 8 +actionToInt GetVulnerabilities = 9 +actionToInt GetProjectUrls = 10 + +||| Decode integer to PyPI action. +export +intToAction : Int -> Maybe PypiAction +intToAction 0 = Just SearchPackages +intToAction 1 = Just GetPackage +intToAction 2 = Just GetVersion +intToAction 3 = Just ListVersions +intToAction 4 = Just GetDownloads +intToAction 5 = Just GetDependencies +intToAction 6 = Just GetReleaseFiles +intToAction 7 = Just GetMaintainers +intToAction 8 = Just GetClassifiers +intToAction 9 = Just GetVulnerabilities +intToAction 10 = Just GetProjectUrls +intToAction _ = Nothing + +-- --------------------------------------------------------------------------- +-- MCP tool declarations +-- --------------------------------------------------------------------------- + +||| Tools exposed via MCP protocol for this cartridge. +public export +data McpTool + = ToolSearchPackages + | ToolGetPackage + | ToolGetVersion + | ToolListVersions + | ToolGetDownloads + | ToolGetDependencies + | ToolGetReleaseFiles + | ToolGetMaintainers + | ToolGetClassifiers + | ToolGetVulnerabilities + | ToolGetProjectUrls + +||| Check if a tool requires an authenticated session. +||| All PyPI read operations work without auth. +export +toolRequiresSession : McpTool -> Bool +toolRequiresSession _ = False + +||| Total tool count for this cartridge. +export +toolCount : Nat +toolCount = 11 + +||| Total action count for this cartridge. +export +actionCount : Nat +actionCount = 11 diff --git a/cartridges/domains/registry/pypi-mcp/abi/README.adoc b/cartridges/domains/registry/pypi-mcp/abi/README.adoc new file mode 100644 index 0000000..3aead27 --- /dev/null +++ b/cartridges/domains/registry/pypi-mcp/abi/README.adoc @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += pypi-mcp / abi β€” Idris2 ABI layer +:orientation: shallow + +== Purpose + +Cartridge-local protocol types for `pypi-mcp`. Defines the data, tool, and +transition vocabulary this cartridge speaks. Inherits safety lemmas and the +matrix-cell identifier scheme from the trunk ABI at `src/abi/`. + +== Files + +1 Idris2 module(s), ~184 lines total. Lead module: +`SafeRegistry.idr`. + +No `.ipkg` β€” consumed directly by wider BoJ callers; type-checks inside the +trunk `boj.ipkg`. + +== Invariants + +* `%default total` at every module top. +* Zero `believe_me`, `assert_total`, `postulate`, `Admitted`, or + `sorry` (the trunk holds the four declared `believe_me` sites; leaf + cartridges carry none). + +== Test/proof surface + +Thin module(s), no inline tests. Proof surface = type-check success under the +trunk `idris2 --check`. + +== Read-first + +. `SafeRegistry.idr` β€” cartridge-local data records and enums. +. Trunk `src/abi/Boj/Catalogue.idr` for how this cell participates in the + 2D matrix. diff --git a/cartridges/domains/registry/pypi-mcp/adapter/README.adoc b/cartridges/domains/registry/pypi-mcp/adapter/README.adoc new file mode 100644 index 0000000..1bee776 --- /dev/null +++ b/cartridges/domains/registry/pypi-mcp/adapter/README.adoc @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += pypi-mcp / adapter β€” protocol bridge +:orientation: shallow + +== Purpose + +Exposes this cartridge's FFI over the wire protocols declared in +`cartridge.json`. Dispatches tool invocations to the C-ABI exports at +`../ffi/` and formats responses per protocol. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” depends on the sibling `ffi` target. +| `pypi_adapter.zig` | Protocol dispatch (208 lines). Tool catalogue matches `cartridge.json`. +| `SIDELINED-*.v.adoc` | zig predecessor(s). Not built β€” zig banned 2026-04-10. +|=== + +== Invariants + +* **Stateless** β€” all state lives behind the FFI, never in the adapter. +* **Response passthrough** β€” whatever the FFI returns goes back to the wire + unmodified (no embellishment, no silent recovery). +* **Cartridge.json is source of truth** for the tool catalogue; drift between + adapter and manifest is a CI failure. + +== Test/proof surface + +No inline tests at the adapter layer β€” protocol routing is exercised by the +trunk seam tests at `ffi/zig/src/seams.zig`. + +== Read-first + +. `pypi_adapter.zig` β€” dispatch function and per-protocol routers. +. `../cartridge.json` β€” the tool manifest the dispatcher mirrors. diff --git a/cartridges/domains/registry/pypi-mcp/adapter/build.zig b/cartridges/domains/registry/pypi-mcp/adapter/build.zig new file mode 100644 index 0000000..3beb81f --- /dev/null +++ b/cartridges/domains/registry/pypi-mcp/adapter/build.zig @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// pypi-mcp/adapter/build.zig + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const ffi_mod = b.createModule(.{ + .root_source_file = b.path("../ffi/pypi_mcp_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + const adapter = b.addExecutable(.{ + .name = "pypi_adapter", + .root_source_file = b.path("pypi_adapter.zig"), + .target = target, + .optimize = optimize, + }); + adapter.root_module.addImport("pypi_mcp_ffi", ffi_mod); + b.installArtifact(adapter); + + const run_artifact = b.addRunArtifact(adapter); + const run_step = b.step("run", "Run the pypi-mcp adapter"); + run_step.dependOn(&run_artifact.step); + + const tests = b.addTest(.{ + .root_source_file = b.path("pypi_adapter.zig"), + .target = target, + .optimize = optimize, + }); + tests.root_module.addImport("pypi_mcp_ffi", ffi_mod); + const run_tests = b.addRunArtifact(tests); + const test_step = b.step("test", "Run pypi-mcp adapter tests"); + test_step.dependOn(&run_tests.step); +} diff --git a/cartridges/domains/registry/pypi-mcp/adapter/pypi_adapter.zig b/cartridges/domains/registry/pypi-mcp/adapter/pypi_adapter.zig new file mode 100644 index 0000000..12d8661 --- /dev/null +++ b/cartridges/domains/registry/pypi-mcp/adapter/pypi_adapter.zig @@ -0,0 +1,208 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// pypi-mcp/adapter/pypi_adapter.zig -- Unified three-protocol adapter. +// +// Replaces the banned pypi_adapter.v (zig, removed 2026-04-12). +// +// Bridges the Zig FFI (pypi_mcp_ffi.zig) to three network protocols: +// REST :9085 POST /tools/<tool> +// gRPC-compat :9086 /PypiMcpService/<Method> +// GraphQL :9087 POST /graphql { query: "..." } +// +// PyPI Python registry: packages, versions, downloads, vulnerabilities +// Tools: +// pypi_search_packages +// pypi_get_package +// pypi_get_version +// pypi_list_versions +// pypi_get_downloads +// pypi_get_dependencies +// pypi_get_release_files +// pypi_get_maintainers +// pypi_get_classifiers +// pypi_get_vulnerabilities +// pypi_get_project_urls + +const std = @import("std"); +const ffi = @import("pypi_mcp_ffi"); + +const REST_PORT: u16 = 9085; +const GRPC_PORT: u16 = 9086; +const GQL_PORT: u16 = 9087; + +const MAX_CONN_BUF: usize = 16 * 1024; + +// ============================================================================ +// JSON response builders +// ============================================================================ + +fn okJson(buf: []u8, msg: []const u8) []u8 { + return std.fmt.bufPrint(buf, + \{{"success":true,"message":"{}"}} + , .{msg}) catch buf[0..0]; +} + +fn errJson(buf: []u8, msg: []const u8) []u8 { + return std.fmt.bufPrint(buf, + \{{"success":false,"error":"{}"}} + , .{msg}) catch buf[0..0]; +} + +fn statusJson(buf: []u8) []u8 { + return std.fmt.bufPrint(buf, + \{{"success":true,"state":"ready","service":"pypi-mcp"}} + , .{}) catch buf[0..0]; +} + +// ============================================================================ +// Tool dispatcher +// ============================================================================ + +const Response = struct { status: u16, body: []u8 }; + +fn dispatch(tool: []const u8, body: []const u8, resp: []u8) Response { + _ = body; + if (std.mem.eql(u8, tool, "pypi_search_packages")) return .{ .status = 200, .body = okJson(resp, "pypi_search_packages forwarded to backend") }; + if (std.mem.eql(u8, tool, "pypi_get_package")) return .{ .status = 200, .body = okJson(resp, "pypi_get_package forwarded to backend") }; + if (std.mem.eql(u8, tool, "pypi_get_version")) return .{ .status = 200, .body = okJson(resp, "pypi_get_version forwarded to backend") }; + if (std.mem.eql(u8, tool, "pypi_list_versions")) return .{ .status = 200, .body = okJson(resp, "pypi_list_versions forwarded to backend") }; + if (std.mem.eql(u8, tool, "pypi_get_downloads")) return .{ .status = 200, .body = okJson(resp, "pypi_get_downloads forwarded to backend") }; + if (std.mem.eql(u8, tool, "pypi_get_dependencies")) return .{ .status = 200, .body = okJson(resp, "pypi_get_dependencies forwarded to backend") }; + if (std.mem.eql(u8, tool, "pypi_get_release_files")) return .{ .status = 200, .body = okJson(resp, "pypi_get_release_files forwarded to backend") }; + if (std.mem.eql(u8, tool, "pypi_get_maintainers")) return .{ .status = 200, .body = okJson(resp, "pypi_get_maintainers forwarded to backend") }; + if (std.mem.eql(u8, tool, "pypi_get_classifiers")) return .{ .status = 200, .body = okJson(resp, "pypi_get_classifiers forwarded to backend") }; + if (std.mem.eql(u8, tool, "pypi_get_vulnerabilities")) return .{ .status = 200, .body = okJson(resp, "pypi_get_vulnerabilities forwarded to backend") }; + if (std.mem.eql(u8, tool, "pypi_get_project_urls")) return .{ .status = 200, .body = okJson(resp, "pypi_get_project_urls forwarded to backend") }; + if (std.mem.eql(u8, tool, "status") or std.mem.eql(u8, tool, "health")) + return .{ .status = 200, .body = statusJson(resp) }; + return .{ .status = 404, .body = errJson(resp, "Unknown tool") }; +} + +// ============================================================================ +// REST handler +// ============================================================================ + +fn dispatchRest(path: []const u8, body: []const u8, resp: []u8) Response { + if (std.mem.startsWith(u8, path, "/tools/")) { + return dispatch(path["/tools/".len..], body, resp); + } + if (std.mem.eql(u8, path, "/status") or std.mem.eql(u8, path, "/health")) { + return .{ .status = 200, .body = statusJson(resp) }; + } + return .{ .status = 404, .body = errJson(resp, "Not found") }; +} + +// ============================================================================ +// gRPC-compat handler +// ============================================================================ + +fn dispatchGrpc(path: []const u8, body: []const u8, resp: []u8) Response { + const prefix = "/PypiMcpService/"; + if (!std.mem.startsWith(u8, path, prefix)) + return .{ .status = 404, .body = errJson(resp, "Not a recognized gRPC path") }; + const method = path[prefix.len..]; + const tool = blk: { + if (std.mem.eql(u8, method, "PypiSearchPackages")) break :blk "pypi_search_packages"; + if (std.mem.eql(u8, method, "PypiGetPackage")) break :blk "pypi_get_package"; + if (std.mem.eql(u8, method, "PypiGetVersion")) break :blk "pypi_get_version"; + if (std.mem.eql(u8, method, "PypiListVersions")) break :blk "pypi_list_versions"; + if (std.mem.eql(u8, method, "PypiGetDownloads")) break :blk "pypi_get_downloads"; + if (std.mem.eql(u8, method, "PypiGetDependencies")) break :blk "pypi_get_dependencies"; + if (std.mem.eql(u8, method, "PypiGetReleaseFiles")) break :blk "pypi_get_release_files"; + if (std.mem.eql(u8, method, "PypiGetMaintainers")) break :blk "pypi_get_maintainers"; + if (std.mem.eql(u8, method, "PypiGetClassifiers")) break :blk "pypi_get_classifiers"; + if (std.mem.eql(u8, method, "PypiGetVulnerabilities")) break :blk "pypi_get_vulnerabilities"; + if (std.mem.eql(u8, method, "PypiGetProjectUrls")) break :blk "pypi_get_project_urls"; + return .{ .status = 404, .body = errJson(resp, "Unknown gRPC method") }; + }; + return dispatch(tool, body, resp); +} + +// ============================================================================ +// GraphQL handler +// ============================================================================ + +fn dispatchGraphql(body: []const u8, resp: []u8) Response { + if (std.mem.indexOf(u8, body, "__schema") != null) + return .{ .status = 200, .body = okJson(resp, "schema introspection not yet supported") }; + if (std.mem.indexOf(u8, body, "search_packages") != null) return dispatch("pypi_search_packages", body, resp); + if (std.mem.indexOf(u8, body, "get_package") != null) return dispatch("pypi_get_package", body, resp); + if (std.mem.indexOf(u8, body, "get_version") != null) return dispatch("pypi_get_version", body, resp); + if (std.mem.indexOf(u8, body, "list_versions") != null) return dispatch("pypi_list_versions", body, resp); + if (std.mem.indexOf(u8, body, "get_downloads") != null) return dispatch("pypi_get_downloads", body, resp); + if (std.mem.indexOf(u8, body, "get_dependencies") != null) return dispatch("pypi_get_dependencies", body, resp); + if (std.mem.indexOf(u8, body, "get_release_files") != null) return dispatch("pypi_get_release_files", body, resp); + if (std.mem.indexOf(u8, body, "get_maintainers") != null) return dispatch("pypi_get_maintainers", body, resp); + if (std.mem.indexOf(u8, body, "get_classifiers") != null) return dispatch("pypi_get_classifiers", body, resp); + if (std.mem.indexOf(u8, body, "get_vulnerabilities") != null) return dispatch("pypi_get_vulnerabilities", body, resp); + if (std.mem.indexOf(u8, body, "get_project_urls") != null) return dispatch("pypi_get_project_urls", body, resp); + return .{ .status = 200, .body = errJson(resp, "Unrecognised GraphQL operation") }; +} + +// ============================================================================ +// HTTP/1.1 connection handler +// ============================================================================ + +const Protocol = enum { rest, grpc, graphql }; + +fn handleConnection(conn: std.net.Server.Connection, proto: Protocol) void { + defer conn.stream.close(); + var in_buf: [MAX_CONN_BUF]u8 = undefined; + const n = conn.stream.read(&in_buf) catch return; + const req = in_buf[0..n]; + + var path: []const u8 = "/"; + var body: []const u8 = ""; + if (n > 4) { + const line_end = std.mem.indexOf(u8, req, "\r\n") orelse req.len; + const first_line = req[0..line_end]; + const sp1 = std.mem.indexOfScalar(u8, first_line, ' ') orelse 0; + const rest_of = first_line[sp1 + 1 ..]; + const sp2 = std.mem.indexOfScalar(u8, rest_of, ' ') orelse rest_of.len; + path = rest_of[0..sp2]; + const body_sep = std.mem.indexOf(u8, req, "\r\n\r\n") orelse n; + body = req[@min(body_sep + 4, n)..]; + } + + var resp_buf: [MAX_CONN_BUF]u8 = undefined; + const result = switch (proto) { + .rest => dispatchRest(path, body, &resp_buf), + .grpc => dispatchGrpc(path, body, &resp_buf), + .graphql => dispatchGraphql(body, &resp_buf), + }; + + const content_type = switch (proto) { + .rest => "application/json", + .grpc => "application/grpc+json", + .graphql => "application/json", + }; + + var hdr_buf: [256]u8 = undefined; + const hdr = std.fmt.bufPrint(&hdr_buf, + "HTTP/1.1 {d} OK\r\nContent-Type: {s}\r\nContent-Length: {d}\r\nConnection: close\r\n\r\n", + .{ result.status, content_type, result.body.len }, + ) catch return; + _ = conn.stream.writeAll(hdr) catch return; + _ = conn.stream.writeAll(result.body) catch return; +} + +fn listenLoop(port: u16, proto: Protocol) void { + const addr = std.net.Address.initIp4(.{ 127, 0, 0, 1 }, port) catch return; + var server = addr.listen(.{ .reuse_address = true }) catch return; + defer server.deinit(); + while (true) { + const conn = server.accept() catch continue; + handleConnection(conn, proto); + } +} + +pub fn main() !void { + ffi.pypi_init(); + const rest_thread = try std.Thread.spawn(.{}, listenLoop, .{ REST_PORT, Protocol.rest }); + const grpc_thread = try std.Thread.spawn(.{}, listenLoop, .{ GRPC_PORT, Protocol.grpc }); + const gql_thread = try std.Thread.spawn(.{}, listenLoop, .{ GQL_PORT, Protocol.graphql }); + rest_thread.join(); + grpc_thread.join(); + gql_thread.join(); +} diff --git a/cartridges/domains/registry/pypi-mcp/benchmarks/quick-bench.sh b/cartridges/domains/registry/pypi-mcp/benchmarks/quick-bench.sh new file mode 100755 index 0000000..f9ff15f --- /dev/null +++ b/cartridges/domains/registry/pypi-mcp/benchmarks/quick-bench.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Benchmarks for pypi-mcp cartridge. +set -euo pipefail + +echo "=== pypi-mcp benchmarks ===" + +cd "$(dirname "${BASH_SOURCE[0]}")/../ffi" + +# Build with ReleaseFast for benchmarking +zig build -Doptimize=ReleaseFast 2>&1 + +echo "Session open/close cycle (1000 iterations):" +time for i in $(seq 1 1000); do + # This would call the FFI benchmark binary + true +done + +echo "" +echo "Benchmark placeholder -- implement real benchmarks in Zig test blocks" +echo "or via the zig adapter HTTP benchmark tool." diff --git a/cartridges/domains/registry/pypi-mcp/cartridge.json b/cartridges/domains/registry/pypi-mcp/cartridge.json new file mode 100644 index 0000000..883bae8 --- /dev/null +++ b/cartridges/domains/registry/pypi-mcp/cartridge.json @@ -0,0 +1,246 @@ +{ + "$schema": "https://boj.dev/schemas/cartridge/v1.json", + "spdx": "MPL-2.0", + "copyright": "Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath)", + "name": "pypi-mcp", + "version": "0.2.0", + "description": "PyPI registry cartridge -- Python package search, metadata retrieval, version listing, download stats, dependency analysis, maintainer lookup, classifier browsing, and vulnerability advisory queries via the PyPI JSON API and Warehouse API", + "domain": "Registry", + "tier": "Ayo", + "protocols": [ + "MCP", + "REST" + ], + "auth": { + "method": "bearer_token", + "env_var": "PYPI_TOKEN", + "credential_source": "vault-mcp" + }, + "api": { + "base_url": "https://pypi.org/pypi", + "search_url": "https://pypi.org/search", + "simple_url": "https://pypi.org/simple", + "stats_url": "https://pypistats.org/api", + "content_type": "application/json" + }, + "tools": [ + { + "name": "pypi_search_packages", + "description": "Search PyPI for Python packages by text query with optional classifier filters", + "inputSchema": { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "Search query (package name, keywords, description)" + }, + "page": { + "type": "number", + "description": "Page number (default 1)" + }, + "classifier": { + "type": "string", + "description": "Trove classifier filter (e.g. 'Programming Language :: Python :: 3')" + } + }, + "required": [ + "query" + ] + } + }, + { + "name": "pypi_get_package", + "description": "Get full metadata for a Python package (description, author, license, classifiers, project URLs, latest version)", + "inputSchema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Package name (e.g. 'requests', 'django', 'numpy')" + } + }, + "required": [ + "name" + ] + } + }, + { + "name": "pypi_get_version", + "description": "Get metadata for a specific version of a Python package (requires_python, requires_dist, upload time, digests)", + "inputSchema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Package name" + }, + "version": { + "type": "string", + "description": "Version string (e.g. '2.31.0')" + } + }, + "required": [ + "name", + "version" + ] + } + }, + { + "name": "pypi_list_versions", + "description": "List all published versions of a Python package with upload timestamps and yanked status", + "inputSchema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Package name" + } + }, + "required": [ + "name" + ] + } + }, + { + "name": "pypi_get_downloads", + "description": "Get download statistics for a Python package (recent, overall, per-version via pypistats.org)", + "inputSchema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Package name" + }, + "period": { + "type": "string", + "description": "Period: 'last-day', 'last-week', 'last-month' (default 'last-month')" + } + }, + "required": [ + "name" + ] + } + }, + { + "name": "pypi_get_dependencies", + "description": "Get the dependency list (requires_dist) for a specific version of a Python package", + "inputSchema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Package name" + }, + "version": { + "type": "string", + "description": "Version to inspect (e.g. '2.31.0')" + } + }, + "required": [ + "name", + "version" + ] + } + }, + { + "name": "pypi_get_release_files", + "description": "List distribution files (sdist, wheel) for a specific version with digests and sizes", + "inputSchema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Package name" + }, + "version": { + "type": "string", + "description": "Version to inspect" + } + }, + "required": [ + "name", + "version" + ] + } + }, + { + "name": "pypi_get_maintainers", + "description": "Get maintainer/author information for a Python package on PyPI", + "inputSchema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Package name" + } + }, + "required": [ + "name" + ] + } + }, + { + "name": "pypi_get_classifiers", + "description": "Get Trove classifiers for a Python package (topic, license, framework, etc.)", + "inputSchema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Package name" + } + }, + "required": [ + "name" + ] + } + }, + { + "name": "pypi_get_vulnerabilities", + "description": "Check known vulnerabilities for a Python package version via the PyPI advisory database", + "inputSchema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Package name" + }, + "version": { + "type": "string", + "description": "Version to check (omit for latest)" + } + }, + "required": [ + "name" + ] + } + }, + { + "name": "pypi_get_project_urls", + "description": "Get project URLs (homepage, repository, documentation, issue tracker) for a Python package", + "inputSchema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Package name" + } + }, + "required": [ + "name" + ] + } + } + ], + "ffi": { + "so_path": "ffi/zig-out/lib/libpypi_mcp.so", + "abi_version": "ADR-0006", + "symbols": [ + "boj_cartridge_init", + "boj_cartridge_deinit", + "boj_cartridge_name", + "boj_cartridge_version", + "boj_cartridge_invoke" + ] + } +} diff --git a/cartridges/domains/registry/pypi-mcp/ffi/README.adoc b/cartridges/domains/registry/pypi-mcp/ffi/README.adoc new file mode 100644 index 0000000..ccb3bdf --- /dev/null +++ b/cartridges/domains/registry/pypi-mcp/ffi/README.adoc @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += pypi-mcp / ffi β€” Zig FFI layer +:orientation: shallow + +== Purpose + +C-ABI exports bridging this cartridge's Idris2 ABI to the runtime. Consumed by +the adapter for protocol dispatch and by the trunk loader for mount. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” produces `zig-out/lib/libpypi.so`. +| `pypi_mcp_ffi.zig` | C-ABI exports (16 exports, 7 inline tests, 440 lines). +|=== + +== Invariants + +* **Null-pointer rejection** on `[*c]const u8` arguments before any + dereference. +* **Negative return values** carry documented error meanings; zero is + success (matching the trunk error-code convention). +* **No allocator leaks** β€” every allocation path pairs with `defer free`. + +== Test/proof surface + +7 inline `test "..."` block(s) in `pypi_mcp_ffi.zig`. Run with +`cd ffi && zig build test`. + +== Read-first + +. `pypi_mcp_ffi.zig` β€” C-ABI signatures and guards. +. Trunk `ffi/zig/src/catalogue.zig` for how this `.so` is loaded. diff --git a/cartridges/domains/registry/pypi-mcp/ffi/build.zig b/cartridges/domains/registry/pypi-mcp/ffi/build.zig new file mode 100644 index 0000000..2c0b1bd --- /dev/null +++ b/cartridges/domains/registry/pypi-mcp/ffi/build.zig @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + // Shared ADR-0006 invoke-shim module (relative path up to boj-server trunk). + + const shim_mod = b.addModule("cartridge_shim", .{ + + .root_source_file = b.path("../../../ffi/zig/src/cartridge_shim.zig"), + + .target = target, + + .optimize = optimize, + + }); + + const ffi_mod = b.addModule("pypi_mcp", .{ + .root_source_file = b.path("pypi_mcp_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + ffi_mod.addImport("cartridge_shim", shim_mod); + + const lib = b.addLibrary(.{ + .name = "pypi_mcp", + .root_module = ffi_mod, + .linkage = .dynamic, + }); + lib.linkLibC(); + b.installArtifact(lib); + + const tests = b.addTest(.{ .root_module = ffi_mod }); + tests.linkLibC(); + const run_tests = b.addRunArtifact(tests); + const test_step = b.step("test", "Run FFI tests"); + test_step.dependOn(&run_tests.step); +} + diff --git a/cartridges/domains/registry/pypi-mcp/ffi/cartridge_shim.zig b/cartridges/domains/registry/pypi-mcp/ffi/cartridge_shim.zig new file mode 100644 index 0000000..0e399db --- /dev/null +++ b/cartridges/domains/registry/pypi-mcp/ffi/cartridge_shim.zig @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// cartridge_shim.zig β€” Shared helpers for the ADR-0006 five-symbol +// cartridge ABI (`boj_cartridge_init / deinit / name / version / invoke`). +// +// The shim centralises the seven-code return convention, NUL-argument +// guards, tool-name comparison, and the buffer-too-small path so each +// cartridge's `boj_cartridge_invoke` can stay short β€” typically a tool +// table plus `shim.writeResult(...)`. +// +// Cartridges import this file by relative path (no build-graph change +// needed). Example: +// +// const shim = @import("../../../ffi/zig/src/cartridge_shim.zig"); +// +// export fn boj_cartridge_invoke( +// tool_name: [*c]const u8, +// json_args: [*c]const u8, +// out_buf: [*c]u8, +// in_out_len: [*c]usize, +// ) callconv(.c) i32 { +// _ = json_args; +// if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; +// const body = if (shim.toolIs(tool_name, "foo")) "{\"result\":{}}" +// else return shim.RC_UNKNOWN_TOOL; +// return shim.writeResult(out_buf, in_out_len, body); +// } + +const std = @import("std"); + +// ── Return codes (ADR-0006 Β§Return codes) ──────────────────────────── +// +// Frozen by ADR-0006. New failure modes compose these via the error +// JSON body β€” the integer surface does not grow without a follow-up ADR. + +pub const RC_SUCCESS: i32 = 0; +pub const RC_UNKNOWN_TOOL: i32 = -1; +pub const RC_BAD_ARGS: i32 = -2; +pub const RC_BUFFER_TOO_SMALL: i32 = -3; +pub const RC_RUNTIME_ERROR: i32 = -4; +pub const RC_PANIC: i32 = -5; +pub const RC_AUTH_DENIED: i32 = -6; + +// ── Invoke-path helpers ────────────────────────────────────────────── + +/// True if any of the three mandatory `boj_cartridge_invoke` output-path +/// pointers is null. Use at the top of every invoke to short-circuit to +/// `RC_BAD_ARGS`. +pub fn invokeArgsNull( + tool_name: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) bool { + return tool_name == null or out_buf == null or in_out_len == null; +} + +/// Compare a C-NUL-terminated tool-name pointer against a Zig string +/// literal. Caller must have already verified `tool_name` is non-null +/// (usually via `invokeArgsNull`). +pub fn toolIs(tool_name: [*c]const u8, expected: []const u8) bool { + const s = std.mem.span(@as([*:0]const u8, @ptrCast(tool_name))); + return std.mem.eql(u8, s, expected); +} + +/// Copy `body` into `out_buf[0..*in_out_len]` (as a capacity) and update +/// `*in_out_len` to the number of bytes written. Returns `RC_SUCCESS`. +/// +/// If `body.len` exceeds the current capacity stored in `*in_out_len`, +/// sets `*in_out_len` to the required size and returns +/// `RC_BUFFER_TOO_SMALL` β€” the caller is then expected to re-allocate +/// and retry, per ADR-0006 Β§Memory ownership. +/// +/// Caller must have already verified that `out_buf` and `in_out_len` +/// are non-null. +pub fn writeResult( + out_buf: [*c]u8, + in_out_len: [*c]usize, + body: []const u8, +) i32 { + const cap = in_out_len.*; + if (body.len > cap) { + in_out_len.* = body.len; + return RC_BUFFER_TOO_SMALL; + } + @memcpy(out_buf[0..body.len], body); + in_out_len.* = body.len; + return RC_SUCCESS; +} + +// ── Tests ──────────────────────────────────────────────────────────── + +test "writeResult: body fits, writes and sets length" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); + try std.testing.expectEqualStrings("hello", buf[0..len]); +} + +test "writeResult: too small returns -3 and sets required length" { + var buf: [2]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_BUFFER_TOO_SMALL, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: exact-fit succeeds" { + var buf: [5]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: empty body" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, ""); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 0), len); +} + +test "toolIs: matches and rejects" { + const name: [*:0]const u8 = "foo"; + try std.testing.expect(toolIs(@ptrCast(name), "foo")); + try std.testing.expect(!toolIs(@ptrCast(name), "bar")); + try std.testing.expect(!toolIs(@ptrCast(name), "foobar")); + try std.testing.expect(!toolIs(@ptrCast(name), "fo")); +} + +test "invokeArgsNull: detects each null slot" { + var buf: [4]u8 = undefined; + var len: usize = 4; + const name: [*:0]const u8 = "x"; + try std.testing.expect(!invokeArgsNull(@ptrCast(name), &buf, &len)); + try std.testing.expect(invokeArgsNull(null, &buf, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), null, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), &buf, null)); +} diff --git a/cartridges/domains/registry/pypi-mcp/ffi/pypi_mcp_ffi.zig b/cartridges/domains/registry/pypi-mcp/ffi/pypi_mcp_ffi.zig new file mode 100644 index 0000000..6e42458 --- /dev/null +++ b/cartridges/domains/registry/pypi-mcp/ffi/pypi_mcp_ffi.zig @@ -0,0 +1,552 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// pypi_mcp_ffi.zig β€” C-ABI FFI implementation for pypi-mcp cartridge. +// +// Implements the state machine defined in PypiMcp.SafeRegistry (Idris2 ABI). +// State machine: Unauthenticated | Authenticated | RateLimited | Error +// Auth: Optional Bearer token β€” most PyPI reads are public. +// REST API: https://pypi.org/pypi/<package>/json +// Actions: SearchPackages, GetPackage, GetVersion, ListVersions, GetDownloads, +// GetDependencies, GetReleaseFiles, GetMaintainers, GetClassifiers, +// GetVulnerabilities, GetProjectUrls +// Thread-safe via std.Thread.Mutex. Fixed-size session pool, no heap allocations. + +const std = @import("std"); + +// --------------------------------------------------------------------------- +// State machine (matches Idris2 ABI SessionState exactly) +// --------------------------------------------------------------------------- + +/// Session authentication/lifecycle state. +/// 0 = Unauthenticated, 1 = Authenticated, 2 = RateLimited, 3 = Error. +pub const SessionState = enum(c_int) { + unauthenticated = 0, + authenticated = 1, + rate_limited = 2, + err = 3, +}; + +/// PyPI action identifiers matching Idris2 PypiAction encoding. +pub const PypiAction = enum(c_int) { + search_packages = 0, + get_package = 1, + get_version = 2, + list_versions = 3, + get_downloads = 4, + get_dependencies = 5, + get_release_files = 6, + get_maintainers = 7, + get_classifiers = 8, + get_vulnerabilities = 9, + get_project_urls = 10, +}; + +/// Check valid state transitions per the Idris2 ValidTransition proof. +/// PyPI allows both authenticated and unauthenticated sessions, +/// mirroring the crates.io registry pattern. +fn isValidTransition(from: SessionState, to: SessionState) bool { + return switch (from) { + .unauthenticated => to == .authenticated or to == .rate_limited or to == .err, + .authenticated => to == .unauthenticated or to == .rate_limited or to == .err, + .rate_limited => to == .authenticated or to == .unauthenticated, + .err => to == .authenticated or to == .unauthenticated, + }; +} + +// --------------------------------------------------------------------------- +// Session slots (thread-safe, fixed-size pool) +// --------------------------------------------------------------------------- + +const MAX_SESSIONS: usize = 16; + +const SessionSlot = struct { + active: bool = false, + state: SessionState = .unauthenticated, + api_call_count: u64 = 0, + last_action: c_int = -1, + search_count: u32 = 0, + package_lookups: u32 = 0, + dep_queries: u32 = 0, + vuln_checks: u32 = 0, +}; + +var sessions: [MAX_SESSIONS]SessionSlot = .{SessionSlot{}} ** MAX_SESSIONS; +var mutex: std.Thread.Mutex = .{}; + +// --------------------------------------------------------------------------- +// C-ABI exports β€” state machine +// --------------------------------------------------------------------------- + +/// Check if a state transition is valid. Returns 1 (valid) or 0 (invalid). +pub export fn pypi_mcp_can_transition(from: c_int, to: c_int) c_int { + const f = std.meta.intToEnum(SessionState, from) catch return 0; + const t = std.meta.intToEnum(SessionState, to) catch return 0; + return if (isValidTransition(f, t)) 1 else 0; +} + +/// Open an authenticated session. Returns slot index (>= 0) or error (< 0). +/// Error codes: -1 = no free slots. +pub export fn pypi_mcp_authenticate(dummy: c_int) c_int { + _ = dummy; + mutex.lock(); + defer mutex.unlock(); + + for (&sessions, 0..) |*slot, idx| { + if (!slot.active) { + slot.active = true; + slot.state = .authenticated; + slot.api_call_count = 0; + slot.last_action = -1; + slot.search_count = 0; + slot.package_lookups = 0; + slot.dep_queries = 0; + slot.vuln_checks = 0; + return @intCast(idx); + } + } + return -1; +} + +/// Open an unauthenticated session (read-only public access). +/// Returns slot index (>= 0) or error (< 0). +pub export fn pypi_mcp_open_anonymous(dummy: c_int) c_int { + _ = dummy; + mutex.lock(); + defer mutex.unlock(); + + for (&sessions, 0..) |*slot, idx| { + if (!slot.active) { + slot.active = true; + slot.state = .unauthenticated; + slot.api_call_count = 0; + slot.last_action = -1; + slot.search_count = 0; + slot.package_lookups = 0; + slot.dep_queries = 0; + slot.vuln_checks = 0; + return @intCast(idx); + } + } + return -1; +} + +/// Close a session. Returns 0 on success. +/// Error codes: -1 = invalid slot. +pub export fn pypi_mcp_close(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + + sessions[idx] = SessionSlot{}; + return 0; +} + +/// Get current state of a session. Returns state int or -1 if invalid. +pub export fn pypi_mcp_session_state(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + return @intFromEnum(slot.state); +} + +/// Signal rate limiting on a session (429 from PyPI). Returns 0 on success. +pub export fn pypi_mcp_throttle(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + if (!isValidTransition(slot.state, .rate_limited)) return -2; + + sessions[idx].state = .rate_limited; + return 0; +} + +/// Clear rate limiting. Returns 0 on success. +pub export fn pypi_mcp_unthrottle(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + if (!isValidTransition(slot.state, .authenticated) and !isValidTransition(slot.state, .unauthenticated)) return -2; + + sessions[idx].state = .authenticated; + return 0; +} + +/// Signal an error on a session. Returns 0 on success. +pub export fn pypi_mcp_signal_error(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + if (!isValidTransition(slot.state, .err)) return -2; + + sessions[idx].state = .err; + return 0; +} + +// --------------------------------------------------------------------------- +// C-ABI exports β€” action recording and metrics +// --------------------------------------------------------------------------- + +/// Record an API call on a session. Returns 0 on success. +/// Error codes: -1 = invalid slot, -2 = rate limited/error state, -3 = invalid action. +pub export fn pypi_mcp_record_call(slot_idx: c_int, action: c_int) c_int { + const act = std.meta.intToEnum(PypiAction, action) catch return -3; + + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + if (slot.state == .rate_limited) return -2; + if (slot.state == .err) return -2; + + sessions[idx].api_call_count += 1; + sessions[idx].last_action = action; + + // Track category-specific counts + switch (act) { + .search_packages => sessions[idx].search_count += 1, + .get_package, .get_version, .list_versions, .get_release_files => sessions[idx].package_lookups += 1, + .get_dependencies => sessions[idx].dep_queries += 1, + .get_vulnerabilities => sessions[idx].vuln_checks += 1, + else => {}, + } + + return 0; +} + +/// Get API call count for a session. Returns count or -1 if invalid. +pub export fn pypi_mcp_call_count(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + return @intCast(slot.api_call_count); +} + +/// Get search query count. Returns count or -1 if invalid. +pub export fn pypi_mcp_search_count(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + return @intCast(slot.search_count); +} + +/// Get package metadata lookup count. Returns count or -1 if invalid. +pub export fn pypi_mcp_package_lookup_count(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + return @intCast(slot.package_lookups); +} + +/// Get dependency query count. Returns count or -1 if invalid. +pub export fn pypi_mcp_dep_query_count(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + return @intCast(slot.dep_queries); +} + +/// Get vulnerability check count. Returns count or -1 if invalid. +pub export fn pypi_mcp_vuln_check_count(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + return @intCast(slot.vuln_checks); +} + +/// Get total action count. Always returns 11. +pub export fn pypi_mcp_action_count() c_int { + return 11; +} + +/// Reset all sessions (test/debug use only). +pub export fn pypi_mcp_reset() void { + mutex.lock(); + defer mutex.unlock(); + sessions = .{SessionSlot{}} ** MAX_SESSIONS; +} + +// --------------------------------------------------------------------------- +// ═══════════════════════════════════════════════════════════════════════ +// Standard ABI (ADR-0005 four symbols + ADR-0006 invoke) +// ═══════════════════════════════════════════════════════════════════════ + +const shim = @import("cartridge_shim.zig"); + +const CARTRIDGE_NAME_PTR: [*:0]const u8 = "pypi-mcp"; +const CARTRIDGE_VERSION_PTR: [*:0]const u8 = "0.1.0"; + +export fn boj_cartridge_init() callconv(.c) c_int { + return 0; +} + +export fn boj_cartridge_deinit() callconv(.c) void {} + +export fn boj_cartridge_name() callconv(.c) [*:0]const u8 { + return CARTRIDGE_NAME_PTR; +} + +export fn boj_cartridge_version() callconv(.c) [*:0]const u8 { + return CARTRIDGE_VERSION_PTR; +} + +/// Dispatch the cartridge.json MCP tools. Grade D Alpha stubs. +export fn boj_cartridge_invoke( + tool_name: [*c]const u8, + json_args: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) callconv(.c) i32 { + _ = json_args; + if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; + + const body: []const u8 = if (shim.toolIs(tool_name, "pypi_search_packages")) + "{\"result\":{\"matches\":[],\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "pypi_get_package")) + "{\"result\":{\"metadata\":{},\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "pypi_get_version")) + "{\"result\":{\"metadata\":{},\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "pypi_list_versions")) + "{\"result\":{\"items\":[],\"count\":0,\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "pypi_get_downloads")) + "{\"result\":{\"metadata\":{},\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "pypi_get_dependencies")) + "{\"result\":{\"metadata\":{},\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "pypi_get_release_files")) + "{\"result\":{\"metadata\":{},\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "pypi_get_maintainers")) + "{\"result\":{\"metadata\":{},\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "pypi_get_classifiers")) + "{\"result\":{\"metadata\":{},\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "pypi_get_vulnerabilities")) + "{\"result\":{\"metadata\":{},\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "pypi_get_project_urls")) + "{\"result\":{\"metadata\":{},\"status\":\"stub\"}}" +else + return shim.RC_UNKNOWN_TOOL; + + return shim.writeResult(out_buf, in_out_len, body); +} + +// Tests +// --------------------------------------------------------------------------- + +test "authenticated session lifecycle" { + pypi_mcp_reset(); + + const slot = pypi_mcp_authenticate(0); + try std.testing.expect(slot >= 0); + + // Should be authenticated (1) + try std.testing.expectEqual(@as(c_int, 1), pypi_mcp_session_state(slot)); + + // Record a search call (action 0) + try std.testing.expectEqual(@as(c_int, 0), pypi_mcp_record_call(slot, 0)); + try std.testing.expectEqual(@as(c_int, 1), pypi_mcp_call_count(slot)); + try std.testing.expectEqual(@as(c_int, 1), pypi_mcp_search_count(slot)); + + // Close + try std.testing.expectEqual(@as(c_int, 0), pypi_mcp_close(slot)); +} + +test "anonymous session lifecycle" { + pypi_mcp_reset(); + + const slot = pypi_mcp_open_anonymous(0); + try std.testing.expect(slot >= 0); + + // Should be unauthenticated (0) + try std.testing.expectEqual(@as(c_int, 0), pypi_mcp_session_state(slot)); + + // Record a package lookup (action 1) + try std.testing.expectEqual(@as(c_int, 0), pypi_mcp_record_call(slot, 1)); + try std.testing.expectEqual(@as(c_int, 1), pypi_mcp_package_lookup_count(slot)); + + // Close + try std.testing.expectEqual(@as(c_int, 0), pypi_mcp_close(slot)); +} + +test "rate limiting flow (429 handling)" { + pypi_mcp_reset(); + + const slot = pypi_mcp_authenticate(0); + try std.testing.expect(slot >= 0); + + // Throttle (simulating 429 from PyPI) + try std.testing.expectEqual(@as(c_int, 0), pypi_mcp_throttle(slot)); + try std.testing.expectEqual(@as(c_int, 2), pypi_mcp_session_state(slot)); + + // Cannot invoke while rate limited + try std.testing.expectEqual(@as(c_int, -2), pypi_mcp_record_call(slot, 0)); + + // Unthrottle + try std.testing.expectEqual(@as(c_int, 0), pypi_mcp_unthrottle(slot)); + try std.testing.expectEqual(@as(c_int, 1), pypi_mcp_session_state(slot)); +} + +test "error and recovery" { + pypi_mcp_reset(); + + const slot = pypi_mcp_authenticate(0); + try std.testing.expect(slot >= 0); + + // Signal error + try std.testing.expectEqual(@as(c_int, 0), pypi_mcp_signal_error(slot)); + try std.testing.expectEqual(@as(c_int, 3), pypi_mcp_session_state(slot)); + + // Cannot invoke in error state + try std.testing.expectEqual(@as(c_int, -2), pypi_mcp_record_call(slot, 0)); + + // Close (recover) + try std.testing.expectEqual(@as(c_int, 0), pypi_mcp_close(slot)); +} + +test "category counting" { + pypi_mcp_reset(); + + const slot = pypi_mcp_authenticate(0); + try std.testing.expect(slot >= 0); + + // Search (0) + try std.testing.expectEqual(@as(c_int, 0), pypi_mcp_record_call(slot, 0)); + // GetPackage (1) + try std.testing.expectEqual(@as(c_int, 0), pypi_mcp_record_call(slot, 1)); + // GetVersion (2) + try std.testing.expectEqual(@as(c_int, 0), pypi_mcp_record_call(slot, 2)); + // GetDependencies (5) + try std.testing.expectEqual(@as(c_int, 0), pypi_mcp_record_call(slot, 5)); + // GetVulnerabilities (9) + try std.testing.expectEqual(@as(c_int, 0), pypi_mcp_record_call(slot, 9)); + + try std.testing.expectEqual(@as(c_int, 5), pypi_mcp_call_count(slot)); + try std.testing.expectEqual(@as(c_int, 1), pypi_mcp_search_count(slot)); + try std.testing.expectEqual(@as(c_int, 2), pypi_mcp_package_lookup_count(slot)); + try std.testing.expectEqual(@as(c_int, 1), pypi_mcp_dep_query_count(slot)); + try std.testing.expectEqual(@as(c_int, 1), pypi_mcp_vuln_check_count(slot)); +} + +test "transition validator" { + // Unauthenticated -> Authenticated + try std.testing.expectEqual(@as(c_int, 1), pypi_mcp_can_transition(0, 1)); + // Authenticated -> Unauthenticated + try std.testing.expectEqual(@as(c_int, 1), pypi_mcp_can_transition(1, 0)); + // Authenticated -> RateLimited + try std.testing.expectEqual(@as(c_int, 1), pypi_mcp_can_transition(1, 2)); + // RateLimited -> Authenticated + try std.testing.expectEqual(@as(c_int, 1), pypi_mcp_can_transition(2, 1)); + // Error -> Unauthenticated + try std.testing.expectEqual(@as(c_int, 1), pypi_mcp_can_transition(3, 0)); + + // Invalid: RateLimited -> Error + try std.testing.expectEqual(@as(c_int, 0), pypi_mcp_can_transition(2, 3)); +} + +test "slot exhaustion" { + pypi_mcp_reset(); + + var slots: [MAX_SESSIONS]c_int = undefined; + for (&slots) |*s| { + s.* = pypi_mcp_authenticate(0); + try std.testing.expect(s.* >= 0); + } + + try std.testing.expectEqual(@as(c_int, -1), pypi_mcp_authenticate(0)); + + try std.testing.expectEqual(@as(c_int, 0), pypi_mcp_close(slots[0])); + const new_slot = pypi_mcp_authenticate(0); + try std.testing.expect(new_slot >= 0); +} + +// ═══════════════════════════════════════════════════════════════════════ +// ADR-0006 invoke dispatch tests +// ═══════════════════════════════════════════════════════════════════════ + +test "boj_cartridge_name returns pypi-mcp" { + const n = std.mem.span(boj_cartridge_name()); + try std.testing.expectEqualStrings("pypi-mcp", n); +} + +test "boj_cartridge_init returns 0" { + try std.testing.expectEqual(@as(c_int, 0), boj_cartridge_init()); +} + +test "invoke: each declared tool succeeds" { + var buf: [256]u8 = undefined; + const tools = [_][]const u8{ + "pypi_search_packages", + "pypi_get_package", + "pypi_get_version", + "pypi_list_versions", + "pypi_get_downloads", + "pypi_get_dependencies", + "pypi_get_release_files", + "pypi_get_maintainers", + "pypi_get_classifiers", + "pypi_get_vulnerabilities", + "pypi_get_project_urls", + }; + for (tools) |t| { + var len: usize = buf.len; + const rc = boj_cartridge_invoke(t.ptr, "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, 0), rc); + try std.testing.expect(std.mem.indexOf(u8, buf[0..len], "result") != null); + } +} + +test "invoke: unknown tool returns -1" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("nope", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -1), rc); +} + +test "invoke: buffer too small returns -3" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("pypi_search_packages", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -3), rc); + try std.testing.expect(len > 4); +} diff --git a/cartridges/domains/registry/pypi-mcp/minter.toml b/cartridges/domains/registry/pypi-mcp/minter.toml new file mode 100644 index 0000000..3ff4c93 --- /dev/null +++ b/cartridges/domains/registry/pypi-mcp/minter.toml @@ -0,0 +1,23 @@ +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> + +name = "pypi-mcp" +description = "PyPI registry cartridge β€” Python package search, metadata, version listing, download stats, dependencies, release files, maintainers, classifiers, vulnerabilities, project URLs" +version = "0.2.0" +domain = "Registry" +protocols = [ + "MCP", + "REST", +] +tier = "Ayo" +backend = "universal" +generate_panel = true + +[auth] +method = "bearer_token" +credential_source = "vault-mcp" +fields = ["pypi_token"] + +[api] +base_url = "https://pypi.org/pypi" +stats_url = "https://pypistats.org/api" diff --git a/cartridges/domains/registry/pypi-mcp/mod.js b/cartridges/domains/registry/pypi-mcp/mod.js new file mode 100644 index 0000000..51cbe76 --- /dev/null +++ b/cartridges/domains/registry/pypi-mcp/mod.js @@ -0,0 +1,281 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// pypi-mcp/mod.js -- PyPI registry cartridge implementation. +// +// Provides MCP tool handlers for the PyPI JSON API and Warehouse API: +// - Package search (text query, classifier filters) +// - Package metadata (full info, specific versions) +// - Version listing with timestamps and yanked status +// - Download statistics via pypistats.org +// - Dependency inspection (requires_dist) +// - Release file listing (sdist, wheels, digests) +// - Maintainer/author information +// - Trove classifier listing +// - Vulnerability advisory queries +// - Project URL extraction +// +// Auth: Optional Bearer token via PYPI_TOKEN. Read-only operations +// are fully public. Token required only for upload/manage. +// API docs: https://warehouse.pypa.io/api-reference/json.html +// +// Usage: import { handleTool } from "./mod.js"; +// or: deno run --allow-net --allow-env mod.js + +const API_BASE = "https://pypi.org/pypi"; +const STATS_BASE = "https://pypistats.org/api"; + +// --------------------------------------------------------------------------- +// Auth helper β€” retrieves the PyPI auth token from environment. +// In production, vault-mcp provides zero-knowledge credential proxying; +// for development, PYPI_TOKEN is read directly. Optional for reads. +// --------------------------------------------------------------------------- + +function getToken() { + const token = typeof Deno !== "undefined" + ? Deno.env.get("PYPI_TOKEN") + : process.env.PYPI_TOKEN; + return token || null; +} + +// --------------------------------------------------------------------------- +// HTTP request helper β€” wraps fetch with PyPI headers, error handling, +// and response normalization. +// --------------------------------------------------------------------------- + +async function pypiFetch(url, queryParams) { + const target = new URL(url); + + // Append query parameters if provided + if (queryParams) { + for (const [key, value] of Object.entries(queryParams)) { + if (value !== undefined && value !== null && value !== "") { + target.searchParams.set(key, String(value)); + } + } + } + + const headers = { + "Accept": "application/json", + "User-Agent": "boj-server/pypi-mcp/0.2.0 (https://github.com/hyperpolymath/boj-server)", + }; + + const token = getToken(); + if (token) { + headers["Authorization"] = `Bearer ${token}`; + } + + const response = await fetch(target.toString(), { method: "GET", headers }); + + // Handle rate limiting + if (response.status === 429) { + const retryAfter = response.headers.get("retry-after"); + return { + status: 429, + error: `Rate limited by PyPI. Retry after ${retryAfter || "unknown"} seconds.`, + retryAfter, + }; + } + + const data = await response.json().catch(() => ({})); + + if (!response.ok) { + const errorMessage = data.message || `HTTP ${response.status}`; + return { status: response.status, error: errorMessage, data }; + } + + return { status: response.status, data }; +} + +// --------------------------------------------------------------------------- +// Tool handler dispatch β€” maps MCP tool names to PyPI API operations. +// Each handler validates required arguments, builds the API request, +// and returns structured results. +// --------------------------------------------------------------------------- + +export async function handleTool(toolName, args) { + switch (toolName) { + + // --- Search --- + + case "pypi_search_packages": { + if (!args.query) return { error: "Missing required field: query" }; + // PyPI JSON API doesn't have a search endpoint; use XMLRPC or scrape. + // We use the warehouse search API with query params. + return pypiFetch("https://pypi.org/search/", { + q: args.query, + page: args.page, + c: args.classifier, + }); + } + + // --- Package metadata --- + + case "pypi_get_package": { + if (!args.name) return { error: "Missing required field: name" }; + return pypiFetch(`${API_BASE}/${encodeURIComponent(args.name)}/json`); + } + + case "pypi_get_version": { + if (!args.name) return { error: "Missing required field: name" }; + if (!args.version) return { error: "Missing required field: version" }; + return pypiFetch(`${API_BASE}/${encodeURIComponent(args.name)}/${args.version}/json`); + } + + case "pypi_list_versions": { + if (!args.name) return { error: "Missing required field: name" }; + const result = await pypiFetch(`${API_BASE}/${encodeURIComponent(args.name)}/json`); + if (result.error) return result; + const releases = result.data.releases || {}; + return { + status: result.status, + data: { + name: args.name, + versions: Object.keys(releases).map((ver) => ({ + version: ver, + files: (releases[ver] || []).length, + yanked: (releases[ver] || []).some((f) => f.yanked), + upload_time: (releases[ver] || [])[0]?.upload_time || null, + })), + }, + }; + } + + // --- Downloads --- + + case "pypi_get_downloads": { + if (!args.name) return { error: "Missing required field: name" }; + const period = args.period || "last-month"; + return pypiFetch(`${STATS_BASE}/packages/${encodeURIComponent(args.name)}/recent`); + } + + // --- Dependencies --- + + case "pypi_get_dependencies": { + if (!args.name) return { error: "Missing required field: name" }; + if (!args.version) return { error: "Missing required field: version" }; + const result = await pypiFetch(`${API_BASE}/${encodeURIComponent(args.name)}/${args.version}/json`); + if (result.error) return result; + return { + status: result.status, + data: { + name: args.name, + version: args.version, + requires_dist: result.data.info?.requires_dist || [], + requires_python: result.data.info?.requires_python || null, + }, + }; + } + + // --- Release files --- + + case "pypi_get_release_files": { + if (!args.name) return { error: "Missing required field: name" }; + if (!args.version) return { error: "Missing required field: version" }; + const result = await pypiFetch(`${API_BASE}/${encodeURIComponent(args.name)}/${args.version}/json`); + if (result.error) return result; + return { + status: result.status, + data: { + name: args.name, + version: args.version, + files: (result.data.urls || []).map((f) => ({ + filename: f.filename, + packagetype: f.packagetype, + size: f.size, + digests: f.digests, + requires_python: f.requires_python, + upload_time: f.upload_time, + })), + }, + }; + } + + // --- Maintainers --- + + case "pypi_get_maintainers": { + if (!args.name) return { error: "Missing required field: name" }; + const result = await pypiFetch(`${API_BASE}/${encodeURIComponent(args.name)}/json`); + if (result.error) return result; + const info = result.data.info || {}; + return { + status: result.status, + data: { + name: args.name, + author: info.author, + author_email: info.author_email, + maintainer: info.maintainer, + maintainer_email: info.maintainer_email, + }, + }; + } + + // --- Classifiers --- + + case "pypi_get_classifiers": { + if (!args.name) return { error: "Missing required field: name" }; + const result = await pypiFetch(`${API_BASE}/${encodeURIComponent(args.name)}/json`); + if (result.error) return result; + return { + status: result.status, + data: { + name: args.name, + classifiers: result.data.info?.classifiers || [], + }, + }; + } + + // --- Vulnerabilities --- + + case "pypi_get_vulnerabilities": { + if (!args.name) return { error: "Missing required field: name" }; + const version = args.version || null; + const url = version + ? `${API_BASE}/${encodeURIComponent(args.name)}/${version}/json` + : `${API_BASE}/${encodeURIComponent(args.name)}/json`; + const result = await pypiFetch(url); + if (result.error) return result; + return { + status: result.status, + data: { + name: args.name, + version: version || result.data.info?.version, + vulnerabilities: result.data.vulnerabilities || [], + }, + }; + } + + // --- Project URLs --- + + case "pypi_get_project_urls": { + if (!args.name) return { error: "Missing required field: name" }; + const result = await pypiFetch(`${API_BASE}/${encodeURIComponent(args.name)}/json`); + if (result.error) return result; + return { + status: result.status, + data: { + name: args.name, + project_urls: result.data.info?.project_urls || {}, + home_page: result.data.info?.home_page || null, + }, + }; + } + + default: + return { error: `Unknown pypi-mcp tool: ${toolName}` }; + } +} + +// --------------------------------------------------------------------------- +// Cartridge metadata export β€” used by the BoJ cartridge loader to register +// this cartridge's tools without reading cartridge.json separately. +// --------------------------------------------------------------------------- + +export const metadata = { + name: "pypi-mcp", + version: "0.2.0", + domain: "Registry", + tier: "Ayo", + protocols: ["MCP", "REST"], + toolCount: 11, +}; diff --git a/cartridges/domains/registry/pypi-mcp/panels/manifest.json b/cartridges/domains/registry/pypi-mcp/panels/manifest.json new file mode 100644 index 0000000..3ee310d --- /dev/null +++ b/cartridges/domains/registry/pypi-mcp/panels/manifest.json @@ -0,0 +1,90 @@ +{ + "$schema": "https://panll.dev/schemas/panel-manifest/v1.json", + "spdx": "MPL-2.0", + "copyright": "Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath)", + "cartridge": "pypi-mcp", + "domain": "Registry", + "version": "0.2.0", + "panels": [ + { + "id": "pypi-auth-status", + "title": "Auth Status", + "description": "PyPI session state (Unauthenticated / Authenticated / RateLimited / Error)", + "type": "status-indicator", + "data_source": { + "endpoint": "/pypi/state", + "method": "GET", + "refresh_interval_ms": 5000 + }, + "widgets": [ + { + "type": "state-badge", + "field": "state", + "states": { + "unauthenticated": { "color": "#95a5a6", "icon": "user-x" }, + "authenticated": { "color": "#2ecc71", "icon": "user-check" }, + "rate_limited": { "color": "#f39c12", "icon": "clock" }, + "error": { "color": "#e74c3c", "icon": "alert-triangle" } + } + } + ] + }, + { + "id": "pypi-search-count", + "title": "Search Queries", + "description": "Number of package search queries executed in the current session", + "type": "metric", + "data_source": { + "endpoint": "/pypi/status", + "method": "GET", + "refresh_interval_ms": 10000 + }, + "widgets": [ + { + "type": "counter", + "field": "search_count", + "label": "Searches", + "icon": "search" + } + ] + }, + { + "id": "pypi-package-lookups", + "title": "Package Lookups", + "description": "Number of package metadata queries executed in the current session", + "type": "metric", + "data_source": { + "endpoint": "/pypi/status", + "method": "GET", + "refresh_interval_ms": 10000 + }, + "widgets": [ + { + "type": "counter", + "field": "package_lookups", + "label": "Package Lookups", + "icon": "package" + } + ] + }, + { + "id": "pypi-vuln-checks", + "title": "Vulnerability Checks", + "description": "Number of vulnerability advisory checks in the current session", + "type": "metric", + "data_source": { + "endpoint": "/pypi/status", + "method": "GET", + "refresh_interval_ms": 10000 + }, + "widgets": [ + { + "type": "counter", + "field": "vuln_checks", + "label": "Vuln Checks", + "icon": "shield" + } + ] + } + ] +} diff --git a/cartridges/domains/registry/pypi-mcp/tests/integration_test.sh b/cartridges/domains/registry/pypi-mcp/tests/integration_test.sh new file mode 100755 index 0000000..eafef4f --- /dev/null +++ b/cartridges/domains/registry/pypi-mcp/tests/integration_test.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Integration tests for pypi-mcp cartridge. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CART_DIR="$(dirname "$SCRIPT_DIR")" +FFI_DIR="$CART_DIR/ffi" + +echo "=== pypi-mcp integration tests ===" + +# Build FFI +echo "[1/3] Building FFI..." +cd "$FFI_DIR" && zig build 2>&1 + +# Run Zig unit tests +echo "[2/3] Running FFI unit tests..." +cd "$FFI_DIR" && zig build test 2>&1 + +# Validate Idris2 ABI (if idris2 available) +echo "[3/3] Checking ABI..." +if command -v idris2 &>/dev/null; then + cd "$CART_DIR/abi" && idris2 --check PypiMcp.SafeRegistry 2>&1 + echo " ABI: OK" +else + echo " ABI: SKIPPED (idris2 not in PATH)" +fi + +echo "" +echo "All tests passed for pypi-mcp!" diff --git a/cartridges/domains/repository-management/reposystem-mcp/README.adoc b/cartridges/domains/repository-management/reposystem-mcp/README.adoc new file mode 100644 index 0000000..47f08e5 --- /dev/null +++ b/cartridges/domains/repository-management/reposystem-mcp/README.adoc @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += reposystem-mcp +Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk> +:spdx: MPL-2.0 +:tier: Ayo +:domain: Repository Management +:protocols: MCP, REST + +== Overview + +Reposystem repository management. Lists managed repos, checks health, syncs mirrors, and runs RSR compliance audits. + +== Tools (4) + +[cols="2,4"] +|=== +| Tool | Description + +| `reposystem_list_repos` | List all managed repositories with counts and summary status. +| `reposystem_check_health` | Check health of a named repository: green / yellow / red / unknown. +| `reposystem_sync_mirrors` | Trigger a mirror sync for a repository across all downstream forges. +| `reposystem_run_audit` | Run RSR compliance audit on a repository. Returns checks passed count. +|=== + +== Architecture + +Three-layer stack per the BoJ cartridge standard: + +[cols="1,1,3"] +|=== +| Layer | Language | Purpose + +| ABI | Idris2 | Formally verified state machine with dependent-type proofs +| FFI | Zig | C-ABI implementation: session pool, action metrics, thread safety +| Adapter| Zig | Adapter bridge to the BoJ unified adapter protocol (zig predecessors sidelined) +|=== + +== Building + +[source,bash] +---- +# ABI (Idris2) +cd abi && idris2 --check *.idr + +# FFI (Zig) +cd ffi && zig build +cd ffi && zig build test + +# Adapter (Zig) +cd adapter && zig build +---- + +== Status + +Alpha (CRG D). Cartridge metadata declared in `cartridge.json`; 4 tool(s) exposed. +Not yet dogfooded externally. See `docs/READINESS.md` at repo root for the +project-wide grade table and `docs/practice/DOGFOOD-LOG.adoc` for usage evidence. diff --git a/cartridges/domains/repository-management/reposystem-mcp/abi/README.adoc b/cartridges/domains/repository-management/reposystem-mcp/abi/README.adoc new file mode 100644 index 0000000..9de7443 --- /dev/null +++ b/cartridges/domains/repository-management/reposystem-mcp/abi/README.adoc @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += reposystem-mcp / abi β€” Idris2 ABI layer +:orientation: deep + +== Purpose + +Protocol types for repository management operations: list, health check, +mirror sync, and RSR compliance audit. Carries an **audit conservation proof** +β€” a full-pass audit has zero failures β€” and a health/sync state vocabulary +for the whole estate. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `Reposystem/Protocol.idr` | Enums `ReposystemOp`, `RepoHealth` (`Green`, `Yellow`, `Red`, `Unknown`), `SyncState` (`Synced`, `Behind Nat`, `Diverged`, `Unreachable`). Records `Repo` (name, health, sync), `AuditResult` (`passed`, `failed`, `total`) with equality proof `sumPrf : passed + failed = total`. Helper `fullPassAudit`. +|=== + +No `.ipkg`. The module is imported directly by consuming packages. + +== Invariants + +* **Audit conservation**: `AuditResult.sumPrf : passed + failed = total` + (line 41). An audit that mis-counts cannot be expressed in the type. +* **Full-pass witness**: `fullPassAudit n = MkAuditResult n 0 n` (line 44) + is the canonical all-pass result. +* **Sync semantics**: `Behind 0` is semantically equal to `Synced` (line + 50–51). Runtime code should not treat them as different states. + +== Test/proof surface + +Thin module. No inline tests. + +== Read-first + +. Lines 12–32 β€” the enums and basic records. +. Lines 34–46 β€” `AuditResult` with the conservation proof. diff --git a/cartridges/domains/repository-management/reposystem-mcp/abi/Reposystem/Protocol.idr b/cartridges/domains/repository-management/reposystem-mcp/abi/Reposystem/Protocol.idr new file mode 100644 index 0000000..1965ff9 --- /dev/null +++ b/cartridges/domains/repository-management/reposystem-mcp/abi/Reposystem/Protocol.idr @@ -0,0 +1,51 @@ +-- SPDX-License-Identifier: MPL-2.0 +-- Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +-- +-- Reposystem ABI β€” repository management protocol definitions. + +module Reposystem.Protocol + +import Data.Nat + +||| Reposystem operation codes. +public export +data ReposystemOp + = ListRepos + | CheckHealth + | SyncMirrors + | RunAudit + +||| Repository health status. +public export +data RepoHealth = Green | Yellow | Red | Unknown + +||| Mirror sync state. +public export +data SyncState = Synced | Behind Nat | Diverged | Unreachable + +||| Repository entry. +public export +record Repo where + constructor MkRepo + name : String + health : RepoHealth + sync : SyncState + +||| Audit result with pass/fail counts. +public export +record AuditResult where + constructor MkAuditResult + passed : Nat + failed : Nat + total : Nat + sumPrf : passed + failed = total + +||| Proof: a fully passing audit has zero failures. +export +fullPassAudit : (n : Nat) -> AuditResult +fullPassAudit n = MkAuditResult n 0 n (plusZeroRightNeutral n) + +||| Proof: Behind 0 is equivalent to Synced semantically. +export +behindZeroIsSynced : SyncState +behindZeroIsSynced = Behind 0 diff --git a/cartridges/domains/repository-management/reposystem-mcp/adapter/README.adoc b/cartridges/domains/repository-management/reposystem-mcp/adapter/README.adoc new file mode 100644 index 0000000..54a92f3 --- /dev/null +++ b/cartridges/domains/repository-management/reposystem-mcp/adapter/README.adoc @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += reposystem-mcp / adapter β€” REST/gRPC/GraphQL bridge +:orientation: deep + +== Purpose + +Exposes the (stub) reposystem FFI over three protocols: REST on **9130**, +gRPC-compat on **9131**, GraphQL on **9132**. Stateless. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph. +| `reposystem_adapter.zig` | Protocol dispatch. Tools: `reposystem_list_repos`, `reposystem_check_health`, `reposystem_sync_mirrors`, `reposystem_run_audit`. +| `SIDELINED-reposystem_adapter.v.adoc` | zig predecessor. Not built. +|=== + +== Invariants + +* **Stateless.** +* Responses reflect whatever the (stub) FFI returns β€” the adapter does not + embellish. + +== Test/proof surface + +No inline tests. + +== Read-first + +. Lines 35–43 β€” `dispatch`. +. Lines 46–73 β€” protocol routers. diff --git a/cartridges/domains/repository-management/reposystem-mcp/adapter/build.zig b/cartridges/domains/repository-management/reposystem-mcp/adapter/build.zig new file mode 100644 index 0000000..ebf3930 --- /dev/null +++ b/cartridges/domains/repository-management/reposystem-mcp/adapter/build.zig @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// reposystem-mcp/adapter/build.zig + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + const ffi_mod = b.createModule(.{ + .root_source_file = b.path("../ffi/reposystem_ffi.zig"), + .target = target, .optimize = optimize, + }); + const adapter = b.addExecutable(.{ + .name = "reposystem_adapter", + .root_source_file = b.path("reposystem_adapter.zig"), + .target = target, .optimize = optimize, + }); + adapter.root_module.addImport("reposystem_ffi", ffi_mod); + b.installArtifact(adapter); + const run_step = b.step("run", "Run the reposystem-mcp adapter"); + run_step.dependOn(&b.addRunArtifact(adapter).step); + const tests = b.addTest(.{ + .root_source_file = b.path("reposystem_adapter.zig"), + .target = target, .optimize = optimize, + }); + tests.root_module.addImport("reposystem_ffi", ffi_mod); + const test_step = b.step("test", "Run reposystem-mcp adapter tests"); + test_step.dependOn(&b.addRunArtifact(tests).step); +} diff --git a/cartridges/domains/repository-management/reposystem-mcp/adapter/reposystem_adapter.zig b/cartridges/domains/repository-management/reposystem-mcp/adapter/reposystem_adapter.zig new file mode 100644 index 0000000..806086d --- /dev/null +++ b/cartridges/domains/repository-management/reposystem-mcp/adapter/reposystem_adapter.zig @@ -0,0 +1,121 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// reposystem-mcp/adapter/reposystem_adapter.zig -- Unified three-protocol adapter. +// +// Replaces the banned reposystem_adapter.v (zig, removed 2026-04-12). +// +// REST :9130 gRPC-compat :9131 GraphQL :9132 +// Reposystem repository management. Lists managed repos, checks health, syncs mirrors, and runs RSR co +// Tools: reposystem_list_repos, reposystem_check_health, reposystem_sync_mirrors, reposystem_run_audit + +const std = @import("std"); +const ffi = @import("reposystem_ffi"); + +const REST_PORT: u16 = 9130; +const GRPC_PORT: u16 = 9131; +const GQL_PORT: u16 = 9132; +const MAX_CONN_BUF: usize = 16 * 1024; + +fn okJson(buf: []u8, msg: []const u8) []u8 { + const n = std.fmt.bufPrint(buf, "{{\"success\":true,\"message\":\"{s}\"}}", .{msg}) catch return buf[0..0]; + return n; +} +fn errJson(buf: []u8, msg: []const u8) []u8 { + const n = std.fmt.bufPrint(buf, "{{\"success\":false,\"error\":\"{s}\"}}", .{msg}) catch return buf[0..0]; + return n; +} +fn statusJson(buf: []u8) []u8 { + const n = std.fmt.bufPrint(buf, "{{\"success\":true,\"state\":\"ready\",\"service\":\"reposystem-mcp\"}}", .{}) catch return buf[0..0]; + return n; +} + +const Response = struct { status: u16, body: []u8 }; + +fn dispatch(tool: []const u8, body: []const u8, resp: []u8) Response { + _ = body; + if (std.mem.eql(u8, tool, "reposystem_list_repos")) return .{ .status = 200, .body = okJson(resp, "reposystem_list_repos forwarded") }; + if (std.mem.eql(u8, tool, "reposystem_check_health")) return .{ .status = 200, .body = okJson(resp, "reposystem_check_health forwarded") }; + if (std.mem.eql(u8, tool, "reposystem_sync_mirrors")) return .{ .status = 200, .body = okJson(resp, "reposystem_sync_mirrors forwarded") }; + if (std.mem.eql(u8, tool, "reposystem_run_audit")) return .{ .status = 200, .body = okJson(resp, "reposystem_run_audit forwarded") }; + if (std.mem.eql(u8, tool, "status") or std.mem.eql(u8, tool, "health")) + return .{ .status = 200, .body = statusJson(resp) }; + return .{ .status = 404, .body = errJson(resp, "Unknown tool") }; +} + +fn dispatchRest(path: []const u8, body: []const u8, resp: []u8) Response { + if (std.mem.startsWith(u8, path, "/tools/")) return dispatch(path["/tools/".len..], body, resp); + if (std.mem.eql(u8, path, "/status") or std.mem.eql(u8, path, "/health")) return .{ .status = 200, .body = statusJson(resp) }; + return .{ .status = 404, .body = errJson(resp, "Not found") }; +} + +fn dispatchGrpc(path: []const u8, body: []const u8, resp: []u8) Response { + const prefix = "/Reposystemservice/"; + if (!std.mem.startsWith(u8, path, prefix)) + return .{ .status = 404, .body = errJson(resp, "Not a recognized gRPC path") }; + const method = path[prefix.len..]; + const tool = blk: { + if (std.mem.eql(u8, method, "reposystem_list_repos")) break :blk "reposystem_list_repos"; + if (std.mem.eql(u8, method, "reposystem_check_health")) break :blk "reposystem_check_health"; + if (std.mem.eql(u8, method, "reposystem_sync_mirrors")) break :blk "reposystem_sync_mirrors"; + if (std.mem.eql(u8, method, "reposystem_run_audit")) break :blk "reposystem_run_audit"; + return .{ .status = 404, .body = errJson(resp, "Unknown gRPC method") }; + }; + return dispatch(tool, body, resp); +} + +fn dispatchGraphql(body: []const u8, resp: []u8) Response { + if (std.mem.indexOf(u8, body, "__schema") != null) return .{ .status = 200, .body = okJson(resp, "schema not supported") }; + if (std.mem.indexOf(u8, body, "list_repos") != null) return dispatch("reposystem_list_repos", body, resp); + if (std.mem.indexOf(u8, body, "check_health") != null) return dispatch("reposystem_check_health", body, resp); + if (std.mem.indexOf(u8, body, "sync_mirrors") != null) return dispatch("reposystem_sync_mirrors", body, resp); + if (std.mem.indexOf(u8, body, "run_audit") != null) return dispatch("reposystem_run_audit", body, resp); + return .{ .status = 200, .body = errJson(resp, "Unrecognised GraphQL operation") }; +} + +const Protocol = enum { rest, grpc, graphql }; + +fn handleConnection(conn: std.net.Server.Connection, proto: Protocol) void { + defer conn.stream.close(); + var in_buf: [MAX_CONN_BUF]u8 = undefined; + const n = conn.stream.read(&in_buf) catch return; + const req = in_buf[0..n]; + var path: []const u8 = "/"; + var body: []const u8 = ""; + if (n > 4) { + const line_end = std.mem.indexOf(u8, req, "\r\n") orelse req.len; + const first_line = req[0..line_end]; + const sp1 = std.mem.indexOfScalar(u8, first_line, ' ') orelse 0; + const rest_of = first_line[sp1 + 1 ..]; + const sp2 = std.mem.indexOfScalar(u8, rest_of, ' ') orelse rest_of.len; + path = rest_of[0..sp2]; + const body_sep = std.mem.indexOf(u8, req, "\r\n\r\n") orelse n; + body = req[@min(body_sep + 4, n)..]; + } + var resp_buf: [MAX_CONN_BUF]u8 = undefined; + const result = switch (proto) { + .rest => dispatchRest(path, body, &resp_buf), + .grpc => dispatchGrpc(path, body, &resp_buf), + .graphql => dispatchGraphql(body, &resp_buf), + }; + const ct: []const u8 = if (proto == .grpc) "application/grpc+json" else "application/json"; + var hdr_buf: [256]u8 = undefined; + const hdr = std.fmt.bufPrint(&hdr_buf, "HTTP/1.1 {d} OK\r\nContent-Type: {s}\r\nContent-Length: {d}\r\nConnection: close\r\n\r\n", .{ result.status, ct, result.body.len }) catch return; + _ = conn.stream.writeAll(hdr) catch return; + _ = conn.stream.writeAll(result.body) catch return; +} + +fn listenLoop(port: u16, proto: Protocol) void { + const addr = std.net.Address.initIp4(.{ 127, 0, 0, 1 }, port) catch return; + var server = addr.listen(.{ .reuse_address = true }) catch return; + defer server.deinit(); + while (true) { const conn = server.accept() catch continue; handleConnection(conn, proto); } +} + +pub fn main() !void { + ffi.reposystem_init(); + const t1 = try std.Thread.spawn(.{}, listenLoop, .{ REST_PORT, Protocol.rest }); + const t2 = try std.Thread.spawn(.{}, listenLoop, .{ GRPC_PORT, Protocol.grpc }); + const t3 = try std.Thread.spawn(.{}, listenLoop, .{ GQL_PORT, Protocol.graphql }); + t1.join(); t2.join(); t3.join(); +} diff --git a/cartridges/domains/repository-management/reposystem-mcp/cartridge.json b/cartridges/domains/repository-management/reposystem-mcp/cartridge.json new file mode 100644 index 0000000..8ab668e --- /dev/null +++ b/cartridges/domains/repository-management/reposystem-mcp/cartridge.json @@ -0,0 +1,93 @@ +{ + "$schema": "https://boj.dev/schemas/cartridge/v1.json", + "spdx": "MPL-2.0", + "copyright": "Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath)", + "name": "reposystem-mcp", + "version": "0.1.0", + "description": "Reposystem repository management. Lists managed repos, checks health, syncs mirrors, and runs RSR compliance audits.", + "domain": "Repository Management", + "tier": "Ayo", + "protocols": [ + "MCP", + "REST" + ], + "auth": { + "method": "none", + "env_var": null, + "credential_source": null + }, + "api": { + "base_url": "local://reposystem-mcp", + "content_type": "application/json" + }, + "tools": [ + { + "name": "reposystem_list_repos", + "description": "List all managed repositories with counts and summary status.", + "inputSchema": { + "type": "object", + "properties": {}, + "required": [] + } + }, + { + "name": "reposystem_check_health", + "description": "Check health of a named repository: green / yellow / red / unknown.", + "inputSchema": { + "type": "object", + "properties": { + "repo_name": { + "type": "string", + "description": "Repository name (e.g. 'boj-server')" + } + }, + "required": [ + "repo_name" + ] + } + }, + { + "name": "reposystem_sync_mirrors", + "description": "Trigger a mirror sync for a repository across all downstream forges.", + "inputSchema": { + "type": "object", + "properties": { + "repo_name": { + "type": "string", + "description": "Repository name to sync" + } + }, + "required": [ + "repo_name" + ] + } + }, + { + "name": "reposystem_run_audit", + "description": "Run RSR compliance audit on a repository. Returns checks passed count.", + "inputSchema": { + "type": "object", + "properties": { + "repo_name": { + "type": "string", + "description": "Repository name to audit" + } + }, + "required": [ + "repo_name" + ] + } + } + ], + "ffi": { + "so_path": "ffi/zig-out/lib/libreposystem_mcp.so", + "abi_version": "ADR-0006", + "symbols": [ + "boj_cartridge_init", + "boj_cartridge_deinit", + "boj_cartridge_name", + "boj_cartridge_version", + "boj_cartridge_invoke" + ] + } +} diff --git a/cartridges/domains/repository-management/reposystem-mcp/ffi/README.adoc b/cartridges/domains/repository-management/reposystem-mcp/ffi/README.adoc new file mode 100644 index 0000000..57ded92 --- /dev/null +++ b/cartridges/domains/repository-management/reposystem-mcp/ffi/README.adoc @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += reposystem-mcp / ffi β€” Zig FFI layer (stubs) +:orientation: deep + +== Purpose + +**Stub** implementation. `list_repos_count` returns a hardcoded count, +`check_health` returns `0` (green), `sync_mirrors` is a no-op, and +`run_audit` returns a hardcoded pass count (17). The real reposystem driver +lives elsewhere; this layer exists so the cartridge can be mounted and its +ABI consumed by the BoJ loader today. + +Honestly recorded as stubs; no C-promotion is possible until a real backend +is wired in. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph. +| `reposystem_ffi.zig` | C-ABI exports `reposystem_list_repos_count`, `reposystem_check_health` (`u8` with 0=green, 1=yellow, 2=red, 3=unknown), `reposystem_sync_mirrors`, `reposystem_run_audit` (u32 pass count). Null-guarded throughout. +| `zig-out/` | Build artefacts (gitignored). +|=== + +== Invariants + +* **Null rejection** on every repo-name string (lines 14–16, 20–21, 26–27). +* **Health encoding** β€” `u8` values 0–3 map 1:1 to the ABI's `RepoHealth` + enum. Unknown maps to `3`. +* **Stub returns** are the *only* source of data; do not build on the + current return values elsewhere in the codebase. + +== Test/proof surface + +3 inline `test "..."` blocks (lines 33–43): + +* health returns `Unknown` for a null repo name +* sync rejects null repo name +* audit returns zero for null input + +Run with `cd ffi && zig build test`. + +== Read-first + +. Lines 8–29 β€” function signatures. +. Lines 33–43 β€” null-guard tests. + +== Promotion gate + +Replace stubs with calls into the real reposystem inventory/health/audit +pipeline before attempting Dβ†’C promotion for this component. diff --git a/cartridges/domains/repository-management/reposystem-mcp/ffi/build.zig b/cartridges/domains/repository-management/reposystem-mcp/ffi/build.zig new file mode 100644 index 0000000..a8db86f --- /dev/null +++ b/cartridges/domains/repository-management/reposystem-mcp/ffi/build.zig @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + // Shared ADR-0006 invoke-shim module (relative path up to boj-server trunk). + + const shim_mod = b.addModule("cartridge_shim", .{ + + .root_source_file = b.path("../../../ffi/zig/src/cartridge_shim.zig"), + + .target = target, + + .optimize = optimize, + + }); + + const ffi_mod = b.addModule("reposystem_mcp", .{ + .root_source_file = b.path("reposystem_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + ffi_mod.addImport("cartridge_shim", shim_mod); + + const lib = b.addLibrary(.{ + .name = "reposystem_mcp", + .root_module = ffi_mod, + .linkage = .dynamic, + }); + lib.linkLibC(); + b.installArtifact(lib); + + const tests = b.addTest(.{ .root_module = ffi_mod }); + tests.linkLibC(); + const run_tests = b.addRunArtifact(tests); + const test_step = b.step("test", "Run FFI tests"); + test_step.dependOn(&run_tests.step); +} + diff --git a/cartridges/domains/repository-management/reposystem-mcp/ffi/cartridge_shim.zig b/cartridges/domains/repository-management/reposystem-mcp/ffi/cartridge_shim.zig new file mode 100644 index 0000000..0e399db --- /dev/null +++ b/cartridges/domains/repository-management/reposystem-mcp/ffi/cartridge_shim.zig @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// cartridge_shim.zig β€” Shared helpers for the ADR-0006 five-symbol +// cartridge ABI (`boj_cartridge_init / deinit / name / version / invoke`). +// +// The shim centralises the seven-code return convention, NUL-argument +// guards, tool-name comparison, and the buffer-too-small path so each +// cartridge's `boj_cartridge_invoke` can stay short β€” typically a tool +// table plus `shim.writeResult(...)`. +// +// Cartridges import this file by relative path (no build-graph change +// needed). Example: +// +// const shim = @import("../../../ffi/zig/src/cartridge_shim.zig"); +// +// export fn boj_cartridge_invoke( +// tool_name: [*c]const u8, +// json_args: [*c]const u8, +// out_buf: [*c]u8, +// in_out_len: [*c]usize, +// ) callconv(.c) i32 { +// _ = json_args; +// if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; +// const body = if (shim.toolIs(tool_name, "foo")) "{\"result\":{}}" +// else return shim.RC_UNKNOWN_TOOL; +// return shim.writeResult(out_buf, in_out_len, body); +// } + +const std = @import("std"); + +// ── Return codes (ADR-0006 Β§Return codes) ──────────────────────────── +// +// Frozen by ADR-0006. New failure modes compose these via the error +// JSON body β€” the integer surface does not grow without a follow-up ADR. + +pub const RC_SUCCESS: i32 = 0; +pub const RC_UNKNOWN_TOOL: i32 = -1; +pub const RC_BAD_ARGS: i32 = -2; +pub const RC_BUFFER_TOO_SMALL: i32 = -3; +pub const RC_RUNTIME_ERROR: i32 = -4; +pub const RC_PANIC: i32 = -5; +pub const RC_AUTH_DENIED: i32 = -6; + +// ── Invoke-path helpers ────────────────────────────────────────────── + +/// True if any of the three mandatory `boj_cartridge_invoke` output-path +/// pointers is null. Use at the top of every invoke to short-circuit to +/// `RC_BAD_ARGS`. +pub fn invokeArgsNull( + tool_name: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) bool { + return tool_name == null or out_buf == null or in_out_len == null; +} + +/// Compare a C-NUL-terminated tool-name pointer against a Zig string +/// literal. Caller must have already verified `tool_name` is non-null +/// (usually via `invokeArgsNull`). +pub fn toolIs(tool_name: [*c]const u8, expected: []const u8) bool { + const s = std.mem.span(@as([*:0]const u8, @ptrCast(tool_name))); + return std.mem.eql(u8, s, expected); +} + +/// Copy `body` into `out_buf[0..*in_out_len]` (as a capacity) and update +/// `*in_out_len` to the number of bytes written. Returns `RC_SUCCESS`. +/// +/// If `body.len` exceeds the current capacity stored in `*in_out_len`, +/// sets `*in_out_len` to the required size and returns +/// `RC_BUFFER_TOO_SMALL` β€” the caller is then expected to re-allocate +/// and retry, per ADR-0006 Β§Memory ownership. +/// +/// Caller must have already verified that `out_buf` and `in_out_len` +/// are non-null. +pub fn writeResult( + out_buf: [*c]u8, + in_out_len: [*c]usize, + body: []const u8, +) i32 { + const cap = in_out_len.*; + if (body.len > cap) { + in_out_len.* = body.len; + return RC_BUFFER_TOO_SMALL; + } + @memcpy(out_buf[0..body.len], body); + in_out_len.* = body.len; + return RC_SUCCESS; +} + +// ── Tests ──────────────────────────────────────────────────────────── + +test "writeResult: body fits, writes and sets length" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); + try std.testing.expectEqualStrings("hello", buf[0..len]); +} + +test "writeResult: too small returns -3 and sets required length" { + var buf: [2]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_BUFFER_TOO_SMALL, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: exact-fit succeeds" { + var buf: [5]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: empty body" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, ""); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 0), len); +} + +test "toolIs: matches and rejects" { + const name: [*:0]const u8 = "foo"; + try std.testing.expect(toolIs(@ptrCast(name), "foo")); + try std.testing.expect(!toolIs(@ptrCast(name), "bar")); + try std.testing.expect(!toolIs(@ptrCast(name), "foobar")); + try std.testing.expect(!toolIs(@ptrCast(name), "fo")); +} + +test "invokeArgsNull: detects each null slot" { + var buf: [4]u8 = undefined; + var len: usize = 4; + const name: [*:0]const u8 = "x"; + try std.testing.expect(!invokeArgsNull(@ptrCast(name), &buf, &len)); + try std.testing.expect(invokeArgsNull(null, &buf, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), null, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), &buf, null)); +} diff --git a/cartridges/domains/repository-management/reposystem-mcp/ffi/reposystem_ffi.zig b/cartridges/domains/repository-management/reposystem-mcp/ffi/reposystem_ffi.zig new file mode 100644 index 0000000..6d0c0b9 --- /dev/null +++ b/cartridges/domains/repository-management/reposystem-mcp/ffi/reposystem_ffi.zig @@ -0,0 +1,134 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +// +// Reposystem FFI β€” C-compatible exports for repository management. + +const std = @import("std"); + +/// List repository count. +export fn reposystem_list_repos_count() u32 { + return 0; // Stub +} + +/// Check health of a repo. Returns 0=green, 1=yellow, 2=red, 3=unknown. +export fn reposystem_check_health(repo_name: [*c]const u8) u8 { + if (repo_name == null) return 3; + return 0; // Stub β€” green +} + +/// Sync mirrors for a repo. Returns 0 on success, -1 on error. +export fn reposystem_sync_mirrors(repo_name: [*c]const u8) i32 { + if (repo_name == null) return -1; + return 0; // Stub +} + +/// Run audit. Returns number of checks passed. +export fn reposystem_run_audit(repo_name: [*c]const u8) u32 { + if (repo_name == null) return 0; + return 17; // Stub β€” all RSR checks pass +} + +// ═══════════════════════════════════════════════════════════════════════ +// Standard ABI (ADR-0005 four symbols + ADR-0006 invoke) +// ═══════════════════════════════════════════════════════════════════════ + +const shim = @import("cartridge_shim.zig"); + +const CARTRIDGE_NAME_PTR: [*:0]const u8 = "reposystem-mcp"; +const CARTRIDGE_VERSION_PTR: [*:0]const u8 = "0.1.0"; + +export fn boj_cartridge_init() callconv(.c) c_int { + return 0; +} + +export fn boj_cartridge_deinit() callconv(.c) void {} + +export fn boj_cartridge_name() callconv(.c) [*:0]const u8 { + return CARTRIDGE_NAME_PTR; +} + +export fn boj_cartridge_version() callconv(.c) [*:0]const u8 { + return CARTRIDGE_VERSION_PTR; +} + +/// Dispatch the cartridge.json MCP tools. Grade D Alpha stubs. +export fn boj_cartridge_invoke( + tool_name: [*c]const u8, + json_args: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) callconv(.c) i32 { + _ = json_args; + if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; + + const body: []const u8 = if (shim.toolIs(tool_name, "reposystem_list_repos")) + "{\"result\":{\"items\":[],\"count\":0,\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "reposystem_check_health")) + "{\"result\":{\"health\":\"healthy\",\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "reposystem_sync_mirrors")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "reposystem_run_audit")) + "{\"result\":{\"status\":\"stub\"}}" +else + return shim.RC_UNKNOWN_TOOL; + + return shim.writeResult(out_buf, in_out_len, body); +} + +// ── Tests ── + +test "health returns unknown for null" { + try std.testing.expectEqual(@as(u8, 3), reposystem_check_health(null)); +} + +test "sync rejects null repo" { + try std.testing.expectEqual(@as(i32, -1), reposystem_sync_mirrors(null)); +} + +test "audit returns zero for null" { + try std.testing.expectEqual(@as(u32, 0), reposystem_run_audit(null)); +} + +// ═══════════════════════════════════════════════════════════════════════ +// ADR-0006 invoke dispatch tests +// ═══════════════════════════════════════════════════════════════════════ + +test "boj_cartridge_name returns reposystem-mcp" { + const n = std.mem.span(boj_cartridge_name()); + try std.testing.expectEqualStrings("reposystem-mcp", n); +} + +test "boj_cartridge_init returns 0" { + try std.testing.expectEqual(@as(c_int, 0), boj_cartridge_init()); +} + +test "invoke: each declared tool succeeds" { + var buf: [256]u8 = undefined; + const tools = [_][]const u8{ + "reposystem_list_repos", + "reposystem_check_health", + "reposystem_sync_mirrors", + "reposystem_run_audit", + }; + for (tools) |t| { + var len: usize = buf.len; + const rc = boj_cartridge_invoke(t.ptr, "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, 0), rc); + try std.testing.expect(std.mem.indexOf(u8, buf[0..len], "result") != null); + } +} + +test "invoke: unknown tool returns -1" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("nope", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -1), rc); +} + +test "invoke: buffer too small returns -3" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("reposystem_list_repos", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -3), rc); + try std.testing.expect(len > 4); +} diff --git a/cartridges/domains/repository-management/reposystem-mcp/mod.js b/cartridges/domains/repository-management/reposystem-mcp/mod.js new file mode 100644 index 0000000..afd1423 --- /dev/null +++ b/cartridges/domains/repository-management/reposystem-mcp/mod.js @@ -0,0 +1,55 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// reposystem-mcp/mod.js -- reposystem gateway + +const BASE_URL = Deno.env.get("REPOSYSTEM_BACKEND_URL") ?? "http://127.0.0.1:7710"; + +async function post(path, payload) { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), 15000); + try { + const r = await fetch(`${BASE_URL}${path}`, { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), signal: ctrl.signal, + }); + const data = await r.json().catch(() => ({ success: false, error: "non-JSON response" })); + return { status: r.status, data }; + } catch (e) { + if (e.name === "AbortError") return { status: 504, data: { success: false, error: "reposystem-mcp backend timed out" } }; + return { status: 503, data: { success: false, error: `reposystem-mcp backend unavailable: ${e.message}` } }; + } finally { clearTimeout(t); } +} + +export async function handleTool(toolName, args) { + switch (toolName) { + case "reposystem_list_repos": { + const { _args } = args ?? {}; + const payload = { }; + return post("/api/v1/list-repos", payload); + } + + case "reposystem_check_health": { + const { repo_name } = args ?? {}; + if (!repo_name) return { status: 400, data: { error: "repo_name is required" } }; + const payload = { repo_name }; + return post("/api/v1/check-health", payload); + } + + case "reposystem_sync_mirrors": { + const { repo_name } = args ?? {}; + if (!repo_name) return { status: 400, data: { error: "repo_name is required" } }; + const payload = { repo_name }; + return post("/api/v1/sync-mirrors", payload); + } + + case "reposystem_run_audit": { + const { repo_name } = args ?? {}; + if (!repo_name) return { status: 400, data: { error: "repo_name is required" } }; + const payload = { repo_name }; + return post("/api/v1/run-audit", payload); + } + default: + return { status: 404, data: { error: `Unknown tool: ${toolName}` } }; + } +} diff --git a/cartridges/domains/repository-management/reposystem-mcp/panels/manifest.json b/cartridges/domains/repository-management/reposystem-mcp/panels/manifest.json new file mode 100644 index 0000000..4bea1d9 --- /dev/null +++ b/cartridges/domains/repository-management/reposystem-mcp/panels/manifest.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://panll.dev/schemas/panel-manifest/v1.json", + "spdx": "MPL-2.0", + "cartridge": "reposystem-mcp", + "domain": "repositories", + "version": "0.1.0", + "panels": [ + { + "id": "reposystem-health-table", + "title": "Repo Health", + "description": "Repository health overview with mirror sync status and audit pass rates.", + "type": "data-table", + "entrypoint": "panels/reposystem-health-table.js", + "size": { "cols": 4, "rows": 3 }, + "refresh_interval_ms": 15000, + "data_sources": [ + { "op": "ListRepos", "interval_ms": 15000 }, + { "op": "CheckHealth", "interval_ms": 15000 } + ] + } + ] +} diff --git a/cartridges/domains/research/academic-workflow-mcp/.editorconfig b/cartridges/domains/research/academic-workflow-mcp/.editorconfig new file mode 100644 index 0000000..ffee079 --- /dev/null +++ b/cartridges/domains/research/academic-workflow-mcp/.editorconfig @@ -0,0 +1,8 @@ +root = true +[*] +charset = utf-8 +end_of_line = lf +indent_size = 2 +indent_style = space +insert_final_newline = true +trim_trailing_whitespace = true diff --git a/cartridges/domains/research/academic-workflow-mcp/.gitignore b/cartridges/domains/research/academic-workflow-mcp/.gitignore new file mode 100644 index 0000000..e246cc5 --- /dev/null +++ b/cartridges/domains/research/academic-workflow-mcp/.gitignore @@ -0,0 +1,13 @@ +zig-cache/ +*.o +*.a +*.so +*.dylib +*.wasm +deno.lock +.deno/ +.vscode/ +.idea/ +*.swp +.DS_Store +node_modules/ diff --git a/cartridges/domains/research/academic-workflow-mcp/LICENSE b/cartridges/domains/research/academic-workflow-mcp/LICENSE new file mode 100644 index 0000000..ea5bc1c --- /dev/null +++ b/cartridges/domains/research/academic-workflow-mcp/LICENSE @@ -0,0 +1,8 @@ +Mozilla Public License Version 2.0 + +This academic-workflow-mcp cartridge is licensed under the Mozilla Public +License, Version 2.0 as the legal fallback. The preferred license is +MPL-2.0. + +See SPDX-License-Identifier headers in source files. +For MPL-2.0 text: https://opensource.org/licenses/MPL-2.0 diff --git a/cartridges/domains/research/academic-workflow-mcp/README.adoc b/cartridges/domains/research/academic-workflow-mcp/README.adoc new file mode 100644 index 0000000..0ca87b1 --- /dev/null +++ b/cartridges/domains/research/academic-workflow-mcp/README.adoc @@ -0,0 +1,75 @@ += Academic Workflow Cartridge +:toc: preamble +:author: Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk> +:date: 2026-04-25 +:spdx: MPL-2.0 + +// SPDX-License-Identifier: MPL-2.0 + +Zotero integration for academic research workflows β€” search papers, manage citations, add review notes. + +== Features + +- **Zotero Search** β€” Query papers and collections +- **Metadata Extraction** β€” Fetch full paper metadata (title, authors, DOI, year, abstract) +- **Citation Export** β€” Generate citations in BibTeX, CSL-JSON, RIS, EndNote formats +- **Review Annotations** β€” Add notes to papers (typo, unclear, question, suggestion) +- **Batch Export** β€” Export entire collections as BibTeX + +== Architecture + +[cols="1,3"] +|=== +| Component | Purpose + +| `abi/AcademicWorkflow.idr` +| Idris2 interface with proof-indexed citation formats. + +| `ffi/academic_ffi.zig` +| Zig bindings for Zotero API calls and citation generation. + +| `adapter/mod.ts` +| Deno MCP server bridging Zotero API and citation tools. + Runs on `127.0.0.1:5174` (loopback only). + +| `cartridge.json` +| Tool manifest with 6 MCP tools for academic workflow. +|=== + +== MCP Tools + +=== `search_zotero` +Search Zotero library (supports filtering by collection). + +=== `get_paper_metadata` +Fetch complete metadata for a paper by Zotero item ID. + +=== `generate_citation` +Export paper citation in requested format (BibTeX/CSL/RIS/EndNote). + +=== `extract_bibkeys` +Parse text and extract citation keys (\cite{...}, @key patterns). + +=== `export_collection` +Export entire Zotero collection as BibTeX file. + +=== `add_review_note` +Add review annotation (page, text, category) to a paper. + +== Integration + +Connects to Zotero via: +- **Zotero Web API** (https://www.zotero.org/support/dev/web_api) for library access +- **Citation Style Language** (CSL) for format generation +- **BibTeX export** for LaTeX workflows + +Loopback proof pinning: `IsLoopback 5174` at compile-time. + +== CRG Phase 3b: New-cartridge backlog + +High external-facing ROI for academic teams (citations, paper management). +Matches gossamer-mcp template (~270 LOC ABI+FFI+adapter). + +== License + +MPL-2.0 (MPL-2.0 legal fallback). diff --git a/cartridges/domains/research/academic-workflow-mcp/abi/AcademicWorkflow.idr b/cartridges/domains/research/academic-workflow-mcp/abi/AcademicWorkflow.idr new file mode 100644 index 0000000..03474e5 --- /dev/null +++ b/cartridges/domains/research/academic-workflow-mcp/abi/AcademicWorkflow.idr @@ -0,0 +1,71 @@ +-- SPDX-License-Identifier: MPL-2.0 +-- Academic Workflow Cartridge ABI β€” Citations, Zotero, paper review + +module AcademicWorkflow + +||| Paper metadata +public record Paper where + constructor MkPaper + title : String + authors : List String + doi : String + year : Nat + abstract : String + +||| Citation format +public data CitationFormat = + | BibTeX + | CSL + | RIS + | EndNote + +||| Proof that a citation format is supported +public data Supported : CitationFormat -> Type where + SupportedBibTeX : Supported BibTeX + SupportedCSL : Supported CSL + SupportedRIS : Supported RIS + SupportedEndNote : Supported EndNote + +||| Review annotation +public record ReviewNote where + constructor MkReviewNote + page : Nat + text : String + category : String -- "typo", "unclear", "question", "suggestion" + +||| Zotero collection reference +public record ZoteroCollection where + constructor MkZoteroCollection + id : String + name : String + itemCount : Nat + +||| Academic workflow operations +public interface AcademicWorkflow.Workflow (m : Type -> Type) where + ||| Search Zotero collections + searchZotero : (query : String) -> m (List ZoteroCollection) + + ||| Fetch paper metadata from Zotero + getPaperMetadata : (itemId : String) -> m Paper + + ||| Generate citation in requested format + generateCitation : {fmt : CitationFormat} -> + (paper : Paper) -> + Supported fmt -> m String + + ||| Extract BibTeX keys from text + extractBibKeys : (text : String) -> m (List String) + + ||| Add review annotations to paper + addReviewNote : (paperId : String) -> (note : ReviewNote) -> m () + + ||| Export collection as BibTeX + exportCollection : (collectionId : String) -> m String + +||| Loopback proof: academic-mcp runs on 127.0.0.1:5174 +public data IsLoopback : (port : Nat) -> Type where + LoopbackProof : IsLoopback 5174 + +export +loopbackInvariant : IsLoopback 5174 +loopbackInvariant = LoopbackProof diff --git a/cartridges/domains/research/academic-workflow-mcp/adapter/mod.ts b/cartridges/domains/research/academic-workflow-mcp/adapter/mod.ts new file mode 100644 index 0000000..fca3fc3 --- /dev/null +++ b/cartridges/domains/research/academic-workflow-mcp/adapter/mod.ts @@ -0,0 +1,264 @@ +// SPDX-License-Identifier: MPL-2.0 +// Academic Workflow Cartridge β€” Zotero & citation management MCP server + +import { Server } from "https://esm.sh/@modelcontextprotocol/sdk/server/index.js"; +import { + CallToolRequestSchema, + ListToolsRequestSchema, + Tool, +} from "https://esm.sh/@modelcontextprotocol/sdk/types.js"; + +// Zotero integration (would use actual Zotero API) +const ZOTERO = { + apiKey: Deno.env.get("ZOTERO_API_KEY") || "", + baseUrl: "https://api.zotero.org", +}; + +// MCP tool definitions +const TOOLS: Tool[] = [ + { + name: "search_zotero", + description: "Search Zotero library for papers and collections", + inputSchema: { + type: "object" as const, + properties: { + query: { + type: "string", + description: "Search query (title, author, keywords)", + }, + collection_id: { + type: "string", + description: "Filter to specific collection (optional)", + }, + }, + required: ["query"], + }, + }, + { + name: "get_paper_metadata", + description: + "Fetch complete metadata for a paper from Zotero (title, authors, DOI, year, abstract)", + inputSchema: { + type: "object" as const, + properties: { + item_id: { + type: "string", + description: "Zotero item ID", + }, + }, + required: ["item_id"], + }, + }, + { + name: "generate_citation", + description: "Generate formatted citation (BibTeX, CSL, RIS, EndNote)", + inputSchema: { + type: "object" as const, + properties: { + item_id: { + type: "string", + description: "Zotero item ID", + }, + format: { + type: "string", + enum: ["BibTeX", "CSL", "RIS", "EndNote"], + description: "Citation format", + }, + }, + required: ["item_id", "format"], + }, + }, + { + name: "extract_bibkeys", + description: "Extract BibTeX citation keys from text", + inputSchema: { + type: "object" as const, + properties: { + text: { + type: "string", + description: "Text to extract keys from", + }, + }, + required: ["text"], + }, + }, + { + name: "export_collection", + description: "Export entire Zotero collection as BibTeX", + inputSchema: { + type: "object" as const, + properties: { + collection_id: { + type: "string", + description: "Zotero collection ID", + }, + }, + required: ["collection_id"], + }, + }, + { + name: "add_review_note", + description: + "Add review annotation to a paper (page, note, category: typo/unclear/question/suggestion)", + inputSchema: { + type: "object" as const, + properties: { + paper_id: { + type: "string", + description: "Paper identifier", + }, + page: { + type: "number", + description: "Page number", + }, + text: { + type: "string", + description: "Review note text", + }, + category: { + type: "string", + enum: ["typo", "unclear", "question", "suggestion"], + description: "Note category", + }, + }, + required: ["paper_id", "page", "text", "category"], + }, + }, +]; + +// Tool handlers +async function handleSearchZotero( + args: Record<string, unknown> +): Promise<string> { + const query = String(args.query); + const collectionId = args.collection_id + ? String(args.collection_id) + : undefined; + return JSON.stringify({ + query, + collectionId, + results: [], + }); +} + +async function handleGetPaperMetadata( + args: Record<string, unknown> +): Promise<string> { + const itemId = String(args.item_id); + return JSON.stringify({ + itemId, + title: "", + authors: [], + doi: "", + year: 0, + abstract: "", + }); +} + +async function handleGenerateCitation( + args: Record<string, unknown> +): Promise<string> { + const itemId = String(args.item_id); + const format = String(args.format); + return JSON.stringify({ + itemId, + format, + citation: "", + }); +} + +async function handleExtractBibKeys( + args: Record<string, unknown> +): Promise<string> { + const text = String(args.text); + const keys: string[] = []; + // Simple regex to find \cite{key} or @key patterns + const regex = /(?:\\cite\{|@)([a-zA-Z0-9_-]+)\}?/g; + let match; + while ((match = regex.exec(text)) !== null) { + if (!keys.includes(match[1])) { + keys.push(match[1]); + } + } + return JSON.stringify({ keys }); +} + +async function handleExportCollection( + args: Record<string, unknown> +): Promise<string> { + const collectionId = String(args.collection_id); + return JSON.stringify({ + collectionId, + bibtex: "", + }); +} + +async function handleAddReviewNote( + args: Record<string, unknown> +): Promise<string> { + const paperId = String(args.paper_id); + const page = Number(args.page); + const text = String(args.text); + const category = String(args.category); + return JSON.stringify({ + paperId, + page, + text, + category, + saved: true, + }); +} + +// Initialize MCP server +const server = new Server({ + name: "academic-workflow-mcp", + version: "1.0.0", +}); + +// Register tool handlers +server.setRequestHandler(ListToolsRequestSchema, async () => { + return { tools: TOOLS }; +}); + +server.setRequestHandler(CallToolRequestSchema, async (request) => { + const { name, arguments: args } = request; + + let result: string; + if (name === "search_zotero") { + result = await handleSearchZotero(args as Record<string, unknown>); + } else if (name === "get_paper_metadata") { + result = await handleGetPaperMetadata(args as Record<string, unknown>); + } else if (name === "generate_citation") { + result = await handleGenerateCitation(args as Record<string, unknown>); + } else if (name === "extract_bibkeys") { + result = await handleExtractBibKeys(args as Record<string, unknown>); + } else if (name === "export_collection") { + result = await handleExportCollection(args as Record<string, unknown>); + } else if (name === "add_review_note") { + result = await handleAddReviewNote(args as Record<string, unknown>); + } else { + return { + content: [ + { + type: "text" as const, + text: `Unknown tool: ${name}`, + }, + ], + isError: true, + }; + } + + return { + content: [ + { + type: "text" as const, + text: result, + }, + ], + }; +}); + +// Start server on loopback +const port = 5174; +await server.connect(new WebSocket(`ws://127.0.0.1:${port}`)); +console.log("Academic Workflow MCP server running on ws://127.0.0.1:5174"); diff --git a/cartridges/domains/research/academic-workflow-mcp/cartridge.json b/cartridges/domains/research/academic-workflow-mcp/cartridge.json new file mode 100644 index 0000000..85b7672 --- /dev/null +++ b/cartridges/domains/research/academic-workflow-mcp/cartridge.json @@ -0,0 +1,75 @@ +{ + "$schema": "https://boj.dev/schemas/cartridge/v1.json", + "spdx": "MPL-2.0", + "copyright": "Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk>", + "name": "academic-workflow-mcp", + "version": "1.0.0", + "description": "Academic workflow \u2014 Zotero integration, citations, paper review", + "domain": "Research", + "tier": "Ayo", + "auth": { + "method": "none" + }, + "author": "Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk>", + "license": "MPL-2.0", + "tools": [ + { + "id": "search_zotero", + "name": "Search Zotero", + "description": "Search papers and collections in Zotero library" + }, + { + "id": "get_paper_metadata", + "name": "Get Paper Metadata", + "description": "Fetch title, authors, DOI, year, abstract from Zotero" + }, + { + "id": "generate_citation", + "name": "Generate Citation", + "description": "Export citation in BibTeX/CSL/RIS/EndNote format" + }, + { + "id": "extract_bibkeys", + "name": "Extract BibTeX Keys", + "description": "Find citation keys in text" + }, + { + "id": "export_collection", + "name": "Export Collection", + "description": "Export entire collection as BibTeX" + }, + { + "id": "add_review_note", + "name": "Add Review Note", + "description": "Annotate papers with review comments" + } + ], + "abi": { + "interface": "abi/AcademicWorkflow.idr", + "loopback_proof": "IsLoopback 5174" + }, + "ffi": { + "so_path": "ffi/zig-out/lib/libacademic_workflow_mcp.so", + "abi_version": "ADR-0006", + "symbols": [ + "boj_cartridge_init", + "boj_cartridge_deinit", + "boj_cartridge_name", + "boj_cartridge_version", + "boj_cartridge_invoke" + ] + }, + "adapter": { + "language": "typescript", + "runtime": "deno", + "entry": "adapter/mod.ts", + "permissions": [ + "net", + "env" + ] + }, + "loopback": { + "host": "127.0.0.1", + "port": 5174 + } +} diff --git a/cartridges/domains/research/academic-workflow-mcp/ffi/academic_ffi.zig b/cartridges/domains/research/academic-workflow-mcp/ffi/academic_ffi.zig new file mode 100644 index 0000000..4f724b7 --- /dev/null +++ b/cartridges/domains/research/academic-workflow-mcp/ffi/academic_ffi.zig @@ -0,0 +1,70 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// academic-workflow-mcp FFI β€” ADR-0006 five-symbol cartridge ABI implementation. + +const std = @import("std"); +const shim = @import("cartridge_shim.zig"); + +const CARTRIDGE_NAME_PTR: [*:0]const u8 = "academic-workflow-mcp"; +const CARTRIDGE_VERSION_PTR: [*:0]const u8 = "0.1.0"; + +export fn boj_cartridge_init() callconv(.c) c_int { + return 0; +} + +export fn boj_cartridge_deinit() callconv(.c) void {} + +export fn boj_cartridge_name() callconv(.c) [*:0]const u8 { + return CARTRIDGE_NAME_PTR; +} + +export fn boj_cartridge_version() callconv(.c) [*:0]const u8 { + return CARTRIDGE_VERSION_PTR; +} + +export fn boj_cartridge_invoke( + tool_name: [*c]const u8, + json_args: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) callconv(.c) i32 { + _ = json_args; + if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; + const body: []const u8 = if (shim.toolIs(tool_name, "search_zotero")) + "{\"result\":{}}" + else if (shim.toolIs(tool_name, "get_paper_metadata")) + "{\"result\":{}}" + else if (shim.toolIs(tool_name, "generate_citation")) + "{\"result\":{}}" + else if (shim.toolIs(tool_name, "extract_bibkeys")) + "{\"result\":{}}" + else if (shim.toolIs(tool_name, "export_collection")) + "{\"result\":{}}" + else if (shim.toolIs(tool_name, "add_review_note")) + "{\"result\":{}}" + else + return shim.RC_UNKNOWN_TOOL; + + return shim.writeResult(out_buf, in_out_len, body); +} + +test "boj_cartridge_name returns academic-workflow-mcp" { + try std.testing.expectEqualStrings("academic-workflow-mcp", std.mem.span(boj_cartridge_name())); +} + +test "boj_cartridge_init returns 0" { + try std.testing.expectEqual(@as(c_int, 0), boj_cartridge_init()); +} + +test "invoke unknown tool returns RC_UNKNOWN_TOOL" { + var buf: [256]u8 = undefined; + var len: usize = buf.len; + try std.testing.expectEqual(@as(i32, shim.RC_UNKNOWN_TOOL), boj_cartridge_invoke("unknown_xyz", "{}", &buf, &len)); +} + +test "invoke search_zotero returns 0" { + var buf: [256]u8 = undefined; + var len: usize = buf.len; + try std.testing.expectEqual(@as(i32, 0), boj_cartridge_invoke("search_zotero", "{}", &buf, &len)); +} diff --git a/cartridges/domains/research/academic-workflow-mcp/ffi/build.zig b/cartridges/domains/research/academic-workflow-mcp/ffi/build.zig new file mode 100644 index 0000000..550b92c --- /dev/null +++ b/cartridges/domains/research/academic-workflow-mcp/ffi/build.zig @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const ffi_mod = b.addModule("academic_workflow_mcp", .{ + .root_source_file = b.path("academic_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + const lib = b.addLibrary(.{ + .name = "academic_workflow_mcp", + .root_module = ffi_mod, + .linkage = .dynamic, + }); + lib.linkLibC(); + b.installArtifact(lib); + + const tests = b.addTest(.{ .root_module = ffi_mod }); + tests.linkLibC(); + const run_tests = b.addRunArtifact(tests); + const test_step = b.step("test", "Run FFI tests"); + test_step.dependOn(&run_tests.step); +} diff --git a/cartridges/domains/research/academic-workflow-mcp/ffi/cartridge_shim.zig b/cartridges/domains/research/academic-workflow-mcp/ffi/cartridge_shim.zig new file mode 100644 index 0000000..0e399db --- /dev/null +++ b/cartridges/domains/research/academic-workflow-mcp/ffi/cartridge_shim.zig @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// cartridge_shim.zig β€” Shared helpers for the ADR-0006 five-symbol +// cartridge ABI (`boj_cartridge_init / deinit / name / version / invoke`). +// +// The shim centralises the seven-code return convention, NUL-argument +// guards, tool-name comparison, and the buffer-too-small path so each +// cartridge's `boj_cartridge_invoke` can stay short β€” typically a tool +// table plus `shim.writeResult(...)`. +// +// Cartridges import this file by relative path (no build-graph change +// needed). Example: +// +// const shim = @import("../../../ffi/zig/src/cartridge_shim.zig"); +// +// export fn boj_cartridge_invoke( +// tool_name: [*c]const u8, +// json_args: [*c]const u8, +// out_buf: [*c]u8, +// in_out_len: [*c]usize, +// ) callconv(.c) i32 { +// _ = json_args; +// if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; +// const body = if (shim.toolIs(tool_name, "foo")) "{\"result\":{}}" +// else return shim.RC_UNKNOWN_TOOL; +// return shim.writeResult(out_buf, in_out_len, body); +// } + +const std = @import("std"); + +// ── Return codes (ADR-0006 Β§Return codes) ──────────────────────────── +// +// Frozen by ADR-0006. New failure modes compose these via the error +// JSON body β€” the integer surface does not grow without a follow-up ADR. + +pub const RC_SUCCESS: i32 = 0; +pub const RC_UNKNOWN_TOOL: i32 = -1; +pub const RC_BAD_ARGS: i32 = -2; +pub const RC_BUFFER_TOO_SMALL: i32 = -3; +pub const RC_RUNTIME_ERROR: i32 = -4; +pub const RC_PANIC: i32 = -5; +pub const RC_AUTH_DENIED: i32 = -6; + +// ── Invoke-path helpers ────────────────────────────────────────────── + +/// True if any of the three mandatory `boj_cartridge_invoke` output-path +/// pointers is null. Use at the top of every invoke to short-circuit to +/// `RC_BAD_ARGS`. +pub fn invokeArgsNull( + tool_name: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) bool { + return tool_name == null or out_buf == null or in_out_len == null; +} + +/// Compare a C-NUL-terminated tool-name pointer against a Zig string +/// literal. Caller must have already verified `tool_name` is non-null +/// (usually via `invokeArgsNull`). +pub fn toolIs(tool_name: [*c]const u8, expected: []const u8) bool { + const s = std.mem.span(@as([*:0]const u8, @ptrCast(tool_name))); + return std.mem.eql(u8, s, expected); +} + +/// Copy `body` into `out_buf[0..*in_out_len]` (as a capacity) and update +/// `*in_out_len` to the number of bytes written. Returns `RC_SUCCESS`. +/// +/// If `body.len` exceeds the current capacity stored in `*in_out_len`, +/// sets `*in_out_len` to the required size and returns +/// `RC_BUFFER_TOO_SMALL` β€” the caller is then expected to re-allocate +/// and retry, per ADR-0006 Β§Memory ownership. +/// +/// Caller must have already verified that `out_buf` and `in_out_len` +/// are non-null. +pub fn writeResult( + out_buf: [*c]u8, + in_out_len: [*c]usize, + body: []const u8, +) i32 { + const cap = in_out_len.*; + if (body.len > cap) { + in_out_len.* = body.len; + return RC_BUFFER_TOO_SMALL; + } + @memcpy(out_buf[0..body.len], body); + in_out_len.* = body.len; + return RC_SUCCESS; +} + +// ── Tests ──────────────────────────────────────────────────────────── + +test "writeResult: body fits, writes and sets length" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); + try std.testing.expectEqualStrings("hello", buf[0..len]); +} + +test "writeResult: too small returns -3 and sets required length" { + var buf: [2]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_BUFFER_TOO_SMALL, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: exact-fit succeeds" { + var buf: [5]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: empty body" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, ""); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 0), len); +} + +test "toolIs: matches and rejects" { + const name: [*:0]const u8 = "foo"; + try std.testing.expect(toolIs(@ptrCast(name), "foo")); + try std.testing.expect(!toolIs(@ptrCast(name), "bar")); + try std.testing.expect(!toolIs(@ptrCast(name), "foobar")); + try std.testing.expect(!toolIs(@ptrCast(name), "fo")); +} + +test "invokeArgsNull: detects each null slot" { + var buf: [4]u8 = undefined; + var len: usize = 4; + const name: [*:0]const u8 = "x"; + try std.testing.expect(!invokeArgsNull(@ptrCast(name), &buf, &len)); + try std.testing.expect(invokeArgsNull(null, &buf, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), null, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), &buf, null)); +} diff --git a/cartridges/domains/research/academic-workflow-mcp/mod.js b/cartridges/domains/research/academic-workflow-mcp/mod.js new file mode 100644 index 0000000..927d3f3 --- /dev/null +++ b/cartridges/domains/research/academic-workflow-mcp/mod.js @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: MPL-2.0 +export const cartridge = { + name: "academic-workflow-mcp", + version: "1.0.0", + description: "Academic workflow cartridge", + tools: [ + { id: "search_zotero", name: "Search Zotero" }, + { id: "get_paper_metadata", name: "Get Paper Metadata" }, + { id: "generate_citation", name: "Generate Citation" }, + { id: "extract_bibkeys", name: "Extract BibTeX Keys" }, + { id: "export_collection", name: "Export Collection" }, + { id: "add_review_note", name: "Add Review Note" }, + ], +}; + +export async function health() { + return { status: "healthy", cartridge: "academic-workflow-mcp" }; +} + +export async function init() { + console.log("[academic-workflow-mcp] Initializing"); + return { initialized: true }; +} + +export async function cleanup() { + console.log("[academic-workflow-mcp] Shutting down"); + return { cleaned: true }; +} diff --git a/cartridges/domains/research/bofig-mcp/.editorconfig b/cartridges/domains/research/bofig-mcp/.editorconfig new file mode 100644 index 0000000..36a608d --- /dev/null +++ b/cartridges/domains/research/bofig-mcp/.editorconfig @@ -0,0 +1,13 @@ +# SPDX-License-Identifier: MPL-2.0 +root = true + +[*] +charset = utf-8 +indent_style = space +indent_size = 2 +end_of_line = lf +trim_trailing_whitespace = true +insert_final_newline = true + +[*.md] +trim_trailing_whitespace = false diff --git a/cartridges/domains/research/bofig-mcp/.gitignore b/cartridges/domains/research/bofig-mcp/.gitignore new file mode 100644 index 0000000..6f90203 --- /dev/null +++ b/cartridges/domains/research/bofig-mcp/.gitignore @@ -0,0 +1,11 @@ +// SPDX-License-Identifier: MPL-2.0 +*.swp +*.swo +*~ +.DS_Store +node_modules/ +dist/ +build/ +target/ +.deno +deno.lock diff --git a/cartridges/domains/research/bofig-mcp/LICENSE b/cartridges/domains/research/bofig-mcp/LICENSE new file mode 100644 index 0000000..5f231c5 --- /dev/null +++ b/cartridges/domains/research/bofig-mcp/LICENSE @@ -0,0 +1,15 @@ +SPDX-License-Identifier: MPL-2.0 + +Bofig Cartridge +Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) + +This software is licensed under the MPL-2.0 license. + +MPL-2.0 is a license supporting dual licensing with MPL-2.0 as automatic fallback. +For the full license text, see: https://hyperpolymath.dev/standards/PMPL-1.0 + +Legal Notice: +Until PMPL achieves formal recognition as a standalone license, this software is +automatically operative under the Mozilla Public License 2.0 (MPL-2.0). + +This is a legal fallback arrangement confirmed by legal counsel. diff --git a/cartridges/domains/research/bofig-mcp/README.adoc b/cartridges/domains/research/bofig-mcp/README.adoc new file mode 100644 index 0000000..8296b41 --- /dev/null +++ b/cartridges/domains/research/bofig-mcp/README.adoc @@ -0,0 +1,80 @@ += Bofig Cartridge +:toc: preamble +:author: Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk> +:date: 2026-04-25 +:spdx: MPL-2.0 + +// SPDX-License-Identifier: MPL-2.0 + +Evidence graph query interface for investigative journalism workflows β€” search evidence, find connections, analyze relationships in complex networks. + +== Features + +- **Evidence Queries** β€” Retrieve evidence by ID with metadata (source, confidence, date) +- **Keyword Search** β€” Search evidence across documents, interviews, datasets, media +- **Connection Discovery** β€” Find relationships and connections between entities +- **Path Analysis** β€” Shortest path finding between entities in the graph +- **Custom Queries** β€” Execute graph queries (Cypher-like syntax) +- **Graph Analytics** β€” Get statistics on nodes and edges + +== Architecture + +[cols="1,3"] +|=== +| Component | Purpose + +| `abi/Bofig.idr` +| Idris2 interface with evidence types (Document, Interview, Dataset, Analysis, Media, Archive) + and connection/relationship definitions. + +| `ffi/bofig_ffi.zig` +| Zig bindings for evidence graph database queries. + +| `adapter/mod.ts` +| Deno MCP server exposing evidence-graph tools. + Runs on `127.0.0.1:5178` (loopback only). + +| `cartridge.json` +| Tool manifest with 6 MCP tools for evidence analysis. +|=== + +== MCP Tools + +=== `query_evidence` +Retrieve evidence record by ID with full metadata (source type, confidence level, date collected, description). + +=== `search_evidence` +Search evidence by keyword across all fields (titles, descriptions, source types, content). + +=== `get_connections` +Retrieve all relationships and connections for an entity in the evidence graph. + +=== `find_path` +Compute shortest path between two entities, useful for analyzing degrees of separation. + +=== `execute_query` +Execute custom graph query using graph query syntax (similar to Cypher). + +=== `get_graph_stats` +Retrieve overall statistics: total nodes, total edges, last updated timestamp. + +== Integration + +Connects to bofig (evidence graph) via: +- **Evidence database** for record storage and retrieval +- **Relationship index** for connection queries +- **Graph algorithms** for path finding and analysis +- **Search engine** for full-text evidence discovery + +Loopback proof pinning: `IsLoopback 5178` at compile-time. + +== Use Cases + +- **Investigative Research** β€” Discover connections and relationships in complex networks +- **Fact Checking** β€” Trace evidence chains and corroboration paths +- **Narrative Analysis** β€” Identify key entities and relationship patterns +- **Story Development** β€” Build and validate investigative narratives + +== License + +MPL-2.0 (MPL-2.0 legal fallback). diff --git a/cartridges/domains/research/bofig-mcp/abi/Bofig.idr b/cartridges/domains/research/bofig-mcp/abi/Bofig.idr new file mode 100644 index 0000000..b071369 --- /dev/null +++ b/cartridges/domains/research/bofig-mcp/abi/Bofig.idr @@ -0,0 +1,84 @@ +-- SPDX-License-Identifier: MPL-2.0 +-- Bofig Cartridge ABI β€” Evidence graph query interface + +module ABI.Bofig + +%language ElabReflection + +-- Evidence source type +public export +data EvidenceSource : Type where + Document : EvidenceSource + Interview : EvidenceSource + DataSet : EvidenceSource + Analysis : EvidenceSource + Media : EvidenceSource + Archive : EvidenceSource + +-- Confidence level +public export +data ConfidenceLevel : Type where + High : ConfidenceLevel + Medium : ConfidenceLevel + Low : ConfidenceLevel + Unverified : ConfidenceLevel + +-- Evidence record +public export +record Evidence where + constructor MkEvidence + evidenceId : String + title : String + source : EvidenceSource + confidence : ConfidenceLevel + description : String + dateCollected : String + +-- Connection/relationship in graph +public export +record Connection where + constructor MkConnection + fromId : String + toId : String + relationshipType : String + strength : Nat -- 0-100 + description : String + +-- Graph query result +public export +record GraphQueryResult where + constructor MkGraphQueryResult + queryId : String + nodeCount : Nat + edgeCount : Nat + evidence : List Evidence + connections : List Connection + +-- Bofig cartridge interface +public export +interface Bofig.Graph where + -- Query evidence by ID + queryEvidence : String -> IO (Maybe Evidence) + + -- Search evidence by keyword + searchEvidence : String -> IO (List Evidence) + + -- Get connections for an entity + getConnections : String -> IO (List Connection) + + -- Find shortest path between two nodes + findPath : String -> String -> IO (List String) + + -- Execute graph query + executeQuery : String -> IO GraphQueryResult + + -- Get graph statistics + getGraphStats : IO (Nat, Nat) -- (nodes, edges) + + -- Loopback proof: cartridge runs on localhost only + IsLoopback : (port : Nat) -> Type + IsLoopback 5178 = () + +public export +Loopback.proof : IsLoopback 5178 +Loopback.proof = () diff --git a/cartridges/domains/research/bofig-mcp/adapter/mod.ts b/cartridges/domains/research/bofig-mcp/adapter/mod.ts new file mode 100644 index 0000000..066cebd --- /dev/null +++ b/cartridges/domains/research/bofig-mcp/adapter/mod.ts @@ -0,0 +1,220 @@ +// SPDX-License-Identifier: MPL-2.0 +// Bofig Cartridge β€” Evidence graph query MCP server + +import { Server } from "https://esm.sh/@modelcontextprotocol/sdk/server/index.js"; +import { + CallToolRequestSchema, + ListToolsRequestSchema, + Tool, +} from "https://esm.sh/@modelcontextprotocol/sdk/types.js"; + +// MCP tool definitions for evidence graph queries +const TOOLS: Tool[] = [ + { + name: "query_evidence", + description: "Query evidence by ID from the graph database", + inputSchema: { + type: "object" as const, + properties: { + evidence_id: { + type: "string", + description: "Evidence identifier", + }, + }, + required: ["evidence_id"], + }, + }, + { + name: "search_evidence", + description: "Search evidence by keyword (title, description, source)", + inputSchema: { + type: "object" as const, + properties: { + keyword: { + type: "string", + description: "Search keyword or phrase", + }, + }, + required: ["keyword"], + }, + }, + { + name: "get_connections", + description: "Get all connections/relationships for an entity in the graph", + inputSchema: { + type: "object" as const, + properties: { + entity_id: { + type: "string", + description: "Entity or evidence ID", + }, + }, + required: ["entity_id"], + }, + }, + { + name: "find_path", + description: "Find shortest path between two entities in the evidence graph", + inputSchema: { + type: "object" as const, + properties: { + from_id: { + type: "string", + description: "Starting entity ID", + }, + to_id: { + type: "string", + description: "Target entity ID", + }, + }, + required: ["from_id", "to_id"], + }, + }, + { + name: "execute_query", + description: "Execute a custom graph query", + inputSchema: { + type: "object" as const, + properties: { + query: { + type: "string", + description: "Graph query string (Cypher-like syntax)", + }, + }, + required: ["query"], + }, + }, + { + name: "get_graph_stats", + description: "Get overall statistics about the evidence graph (node and edge counts)", + inputSchema: { + type: "object" as const, + properties: {}, + }, + }, +]; + +// Tool handlers +async function handleQueryEvidence( + args: Record<string, unknown> +): Promise<string> { + const evidenceId = String(args.evidence_id); + return JSON.stringify({ + evidence_id: evidenceId, + found: false, + evidence: null, + }); +} + +async function handleSearchEvidence( + args: Record<string, unknown> +): Promise<string> { + const keyword = String(args.keyword); + return JSON.stringify({ + keyword, + results: [], + count: 0, + }); +} + +async function handleGetConnections( + args: Record<string, unknown> +): Promise<string> { + const entityId = String(args.entity_id); + return JSON.stringify({ + entity_id: entityId, + connections: [], + count: 0, + }); +} + +async function handleFindPath( + args: Record<string, unknown> +): Promise<string> { + const fromId = String(args.from_id); + const toId = String(args.to_id); + return JSON.stringify({ + from_id: fromId, + to_id: toId, + path: [], + path_length: 0, + found: false, + }); +} + +async function handleExecuteQuery( + args: Record<string, unknown> +): Promise<string> { + const query = String(args.query); + return JSON.stringify({ + query, + success: true, + node_count: 0, + edge_count: 0, + results: [], + }); +} + +async function handleGetGraphStats( + _args: Record<string, unknown> +): Promise<string> { + return JSON.stringify({ + node_count: 0, + edge_count: 0, + last_updated: new Date().toISOString(), + }); +} + +// Initialize MCP server +const server = new Server({ + name: "bofig-mcp", + version: "1.0.0", +}); + +// Register tool handlers +server.setRequestHandler(ListToolsRequestSchema, async () => { + return { tools: TOOLS }; +}); + +server.setRequestHandler(CallToolRequestSchema, async (request) => { + const { name, arguments: args } = request; + + let result: string; + if (name === "query_evidence") { + result = await handleQueryEvidence(args as Record<string, unknown>); + } else if (name === "search_evidence") { + result = await handleSearchEvidence(args as Record<string, unknown>); + } else if (name === "get_connections") { + result = await handleGetConnections(args as Record<string, unknown>); + } else if (name === "find_path") { + result = await handleFindPath(args as Record<string, unknown>); + } else if (name === "execute_query") { + result = await handleExecuteQuery(args as Record<string, unknown>); + } else if (name === "get_graph_stats") { + result = await handleGetGraphStats(args as Record<string, unknown>); + } else { + return { + content: [ + { + type: "text" as const, + text: `Unknown tool: ${name}`, + }, + ], + isError: true, + }; + } + + return { + content: [ + { + type: "text" as const, + text: result, + }, + ], + }; +}); + +// Start server on loopback +const port = 5178; +await server.connect(new WebSocket(`ws://127.0.0.1:${port}`)); +console.log("Bofig MCP server running on ws://127.0.0.1:5178"); diff --git a/cartridges/domains/research/bofig-mcp/cartridge.json b/cartridges/domains/research/bofig-mcp/cartridge.json new file mode 100644 index 0000000..466e83b --- /dev/null +++ b/cartridges/domains/research/bofig-mcp/cartridge.json @@ -0,0 +1,75 @@ +{ + "$schema": "https://boj.dev/schemas/cartridge/v1.json", + "spdx": "MPL-2.0", + "copyright": "Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath)", + "name": "bofig-mcp", + "version": "1.0.0", + "description": "Bofig Cartridge \u2014 Evidence graph query tools for investigative workflows", + "domain": "Research", + "tier": "Ayo", + "auth": { + "method": "none" + }, + "author": "Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk>", + "license": "MPL-2.0", + "tools": [ + { + "id": "query_evidence", + "name": "Query Evidence", + "description": "Query evidence by ID from the graph database" + }, + { + "id": "search_evidence", + "name": "Search Evidence", + "description": "Search evidence by keyword (title, description, source)" + }, + { + "id": "get_connections", + "name": "Get Connections", + "description": "Get all connections/relationships for an entity in the graph" + }, + { + "id": "find_path", + "name": "Find Path", + "description": "Find shortest path between two entities in the evidence graph" + }, + { + "id": "execute_query", + "name": "Execute Query", + "description": "Execute a custom graph query" + }, + { + "id": "get_graph_stats", + "name": "Get Graph Stats", + "description": "Get overall statistics about the evidence graph" + } + ], + "abi": { + "interface": "abi/Bofig.idr", + "loopback_proof": "IsLoopback 5178" + }, + "ffi": { + "so_path": "ffi/zig-out/lib/libbofig_mcp.so", + "abi_version": "ADR-0006", + "symbols": [ + "boj_cartridge_init", + "boj_cartridge_deinit", + "boj_cartridge_name", + "boj_cartridge_version", + "boj_cartridge_invoke" + ] + }, + "adapter": { + "language": "typescript", + "runtime": "deno", + "entry": "adapter/mod.ts", + "permissions": [ + "net", + "env" + ] + }, + "loopback": { + "host": "127.0.0.1", + "port": 5178 + } +} diff --git a/cartridges/domains/research/bofig-mcp/ffi/bofig_ffi.zig b/cartridges/domains/research/bofig-mcp/ffi/bofig_ffi.zig new file mode 100644 index 0000000..80a2662 --- /dev/null +++ b/cartridges/domains/research/bofig-mcp/ffi/bofig_ffi.zig @@ -0,0 +1,70 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// bofig-mcp FFI β€” ADR-0006 five-symbol cartridge ABI implementation. + +const std = @import("std"); +const shim = @import("cartridge_shim.zig"); + +const CARTRIDGE_NAME_PTR: [*:0]const u8 = "bofig-mcp"; +const CARTRIDGE_VERSION_PTR: [*:0]const u8 = "0.1.0"; + +export fn boj_cartridge_init() callconv(.c) c_int { + return 0; +} + +export fn boj_cartridge_deinit() callconv(.c) void {} + +export fn boj_cartridge_name() callconv(.c) [*:0]const u8 { + return CARTRIDGE_NAME_PTR; +} + +export fn boj_cartridge_version() callconv(.c) [*:0]const u8 { + return CARTRIDGE_VERSION_PTR; +} + +export fn boj_cartridge_invoke( + tool_name: [*c]const u8, + json_args: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) callconv(.c) i32 { + _ = json_args; + if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; + const body: []const u8 = if (shim.toolIs(tool_name, "query_evidence")) + "{\"result\":{}}" + else if (shim.toolIs(tool_name, "search_evidence")) + "{\"result\":{}}" + else if (shim.toolIs(tool_name, "get_connections")) + "{\"result\":{}}" + else if (shim.toolIs(tool_name, "find_path")) + "{\"result\":{}}" + else if (shim.toolIs(tool_name, "execute_query")) + "{\"result\":{}}" + else if (shim.toolIs(tool_name, "get_graph_stats")) + "{\"result\":{}}" + else + return shim.RC_UNKNOWN_TOOL; + + return shim.writeResult(out_buf, in_out_len, body); +} + +test "boj_cartridge_name returns bofig-mcp" { + try std.testing.expectEqualStrings("bofig-mcp", std.mem.span(boj_cartridge_name())); +} + +test "boj_cartridge_init returns 0" { + try std.testing.expectEqual(@as(c_int, 0), boj_cartridge_init()); +} + +test "invoke unknown tool returns RC_UNKNOWN_TOOL" { + var buf: [256]u8 = undefined; + var len: usize = buf.len; + try std.testing.expectEqual(@as(i32, shim.RC_UNKNOWN_TOOL), boj_cartridge_invoke("unknown_xyz", "{}", &buf, &len)); +} + +test "invoke query_evidence returns 0" { + var buf: [256]u8 = undefined; + var len: usize = buf.len; + try std.testing.expectEqual(@as(i32, 0), boj_cartridge_invoke("query_evidence", "{}", &buf, &len)); +} diff --git a/cartridges/domains/research/bofig-mcp/ffi/build.zig b/cartridges/domains/research/bofig-mcp/ffi/build.zig new file mode 100644 index 0000000..5621a23 --- /dev/null +++ b/cartridges/domains/research/bofig-mcp/ffi/build.zig @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const ffi_mod = b.addModule("bofig_mcp", .{ + .root_source_file = b.path("bofig_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + const lib = b.addLibrary(.{ + .name = "bofig_mcp", + .root_module = ffi_mod, + .linkage = .dynamic, + }); + lib.linkLibC(); + b.installArtifact(lib); + + const tests = b.addTest(.{ .root_module = ffi_mod }); + tests.linkLibC(); + const run_tests = b.addRunArtifact(tests); + const test_step = b.step("test", "Run FFI tests"); + test_step.dependOn(&run_tests.step); +} diff --git a/cartridges/domains/research/bofig-mcp/ffi/cartridge_shim.zig b/cartridges/domains/research/bofig-mcp/ffi/cartridge_shim.zig new file mode 100644 index 0000000..0e399db --- /dev/null +++ b/cartridges/domains/research/bofig-mcp/ffi/cartridge_shim.zig @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// cartridge_shim.zig β€” Shared helpers for the ADR-0006 five-symbol +// cartridge ABI (`boj_cartridge_init / deinit / name / version / invoke`). +// +// The shim centralises the seven-code return convention, NUL-argument +// guards, tool-name comparison, and the buffer-too-small path so each +// cartridge's `boj_cartridge_invoke` can stay short β€” typically a tool +// table plus `shim.writeResult(...)`. +// +// Cartridges import this file by relative path (no build-graph change +// needed). Example: +// +// const shim = @import("../../../ffi/zig/src/cartridge_shim.zig"); +// +// export fn boj_cartridge_invoke( +// tool_name: [*c]const u8, +// json_args: [*c]const u8, +// out_buf: [*c]u8, +// in_out_len: [*c]usize, +// ) callconv(.c) i32 { +// _ = json_args; +// if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; +// const body = if (shim.toolIs(tool_name, "foo")) "{\"result\":{}}" +// else return shim.RC_UNKNOWN_TOOL; +// return shim.writeResult(out_buf, in_out_len, body); +// } + +const std = @import("std"); + +// ── Return codes (ADR-0006 Β§Return codes) ──────────────────────────── +// +// Frozen by ADR-0006. New failure modes compose these via the error +// JSON body β€” the integer surface does not grow without a follow-up ADR. + +pub const RC_SUCCESS: i32 = 0; +pub const RC_UNKNOWN_TOOL: i32 = -1; +pub const RC_BAD_ARGS: i32 = -2; +pub const RC_BUFFER_TOO_SMALL: i32 = -3; +pub const RC_RUNTIME_ERROR: i32 = -4; +pub const RC_PANIC: i32 = -5; +pub const RC_AUTH_DENIED: i32 = -6; + +// ── Invoke-path helpers ────────────────────────────────────────────── + +/// True if any of the three mandatory `boj_cartridge_invoke` output-path +/// pointers is null. Use at the top of every invoke to short-circuit to +/// `RC_BAD_ARGS`. +pub fn invokeArgsNull( + tool_name: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) bool { + return tool_name == null or out_buf == null or in_out_len == null; +} + +/// Compare a C-NUL-terminated tool-name pointer against a Zig string +/// literal. Caller must have already verified `tool_name` is non-null +/// (usually via `invokeArgsNull`). +pub fn toolIs(tool_name: [*c]const u8, expected: []const u8) bool { + const s = std.mem.span(@as([*:0]const u8, @ptrCast(tool_name))); + return std.mem.eql(u8, s, expected); +} + +/// Copy `body` into `out_buf[0..*in_out_len]` (as a capacity) and update +/// `*in_out_len` to the number of bytes written. Returns `RC_SUCCESS`. +/// +/// If `body.len` exceeds the current capacity stored in `*in_out_len`, +/// sets `*in_out_len` to the required size and returns +/// `RC_BUFFER_TOO_SMALL` β€” the caller is then expected to re-allocate +/// and retry, per ADR-0006 Β§Memory ownership. +/// +/// Caller must have already verified that `out_buf` and `in_out_len` +/// are non-null. +pub fn writeResult( + out_buf: [*c]u8, + in_out_len: [*c]usize, + body: []const u8, +) i32 { + const cap = in_out_len.*; + if (body.len > cap) { + in_out_len.* = body.len; + return RC_BUFFER_TOO_SMALL; + } + @memcpy(out_buf[0..body.len], body); + in_out_len.* = body.len; + return RC_SUCCESS; +} + +// ── Tests ──────────────────────────────────────────────────────────── + +test "writeResult: body fits, writes and sets length" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); + try std.testing.expectEqualStrings("hello", buf[0..len]); +} + +test "writeResult: too small returns -3 and sets required length" { + var buf: [2]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_BUFFER_TOO_SMALL, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: exact-fit succeeds" { + var buf: [5]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: empty body" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, ""); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 0), len); +} + +test "toolIs: matches and rejects" { + const name: [*:0]const u8 = "foo"; + try std.testing.expect(toolIs(@ptrCast(name), "foo")); + try std.testing.expect(!toolIs(@ptrCast(name), "bar")); + try std.testing.expect(!toolIs(@ptrCast(name), "foobar")); + try std.testing.expect(!toolIs(@ptrCast(name), "fo")); +} + +test "invokeArgsNull: detects each null slot" { + var buf: [4]u8 = undefined; + var len: usize = 4; + const name: [*:0]const u8 = "x"; + try std.testing.expect(!invokeArgsNull(@ptrCast(name), &buf, &len)); + try std.testing.expect(invokeArgsNull(null, &buf, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), null, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), &buf, null)); +} diff --git a/cartridges/domains/research/bofig-mcp/mod.js b/cartridges/domains/research/bofig-mcp/mod.js new file mode 100644 index 0000000..c937487 --- /dev/null +++ b/cartridges/domains/research/bofig-mcp/mod.js @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: MPL-2.0 +export const cartridge = { + name: "bofig-mcp", + version: "1.0.0", + description: "Bofig cartridge β€” evidence graph queries", + tools: [ + { id: "query_evidence", name: "Query Evidence" }, + { id: "search_evidence", name: "Search Evidence" }, + { id: "get_connections", name: "Get Connections" }, + { id: "find_path", name: "Find Path" }, + { id: "execute_query", name: "Execute Query" }, + { id: "get_graph_stats", name: "Get Graph Stats" }, + ], +}; + +export async function health() { + return { status: "healthy", cartridge: "bofig-mcp" }; +} + +export async function init() { + console.log("[bofig-mcp] Initializing"); + return { initialized: true }; +} + +export async function cleanup() { + console.log("[bofig-mcp] Shutting down"); + return { cleaned: true }; +} diff --git a/cartridges/domains/research/research-mcp/README.adoc b/cartridges/domains/research/research-mcp/README.adoc new file mode 100644 index 0000000..e65d48e --- /dev/null +++ b/cartridges/domains/research/research-mcp/README.adoc @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += research-mcp +Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk> +:spdx: MPL-2.0 +:tier: Ayo +:domain: research +:protocols: MCP, REST + +== Overview + +Academic paper search (Semantic Scholar, OpenAlex) + +== Tools (4) + +[cols="2,4"] +|=== +| Tool | Description + +| `research_authenticate` | Authenticate with a research provider +| `research_search` | Search for academic papers +| `research_get_paper` | Get paper details by ID +| `research_list_providers` | List available research providers +|=== + +== Architecture + +Three-layer stack per the BoJ cartridge standard: + +[cols="1,1,3"] +|=== +| Layer | Language | Purpose + +| ABI | Idris2 | Formally verified state machine with dependent-type proofs +| FFI | Zig | C-ABI implementation: session pool, action metrics, thread safety +| Adapter| Zig | Adapter bridge to the BoJ unified adapter protocol (zig predecessors sidelined) +|=== + +== Building + +[source,bash] +---- +# ABI (Idris2) +cd abi && idris2 --check *.idr + +# FFI (Zig) +cd ffi && zig build +cd ffi && zig build test + +# Adapter (Zig) +cd adapter && zig build +---- + +== Status + +Alpha (CRG D). Cartridge metadata declared in `cartridge.json`; 4 tool(s) exposed. +Not yet dogfooded externally. See `docs/READINESS.md` at repo root for the +project-wide grade table and `docs/practice/DOGFOOD-LOG.adoc` for usage evidence. diff --git a/cartridges/domains/research/research-mcp/abi/README.adoc b/cartridges/domains/research/research-mcp/abi/README.adoc new file mode 100644 index 0000000..7362955 --- /dev/null +++ b/cartridges/domains/research/research-mcp/abi/README.adoc @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += research-mcp / abi β€” Idris2 ABI layer +:orientation: shallow + +== Purpose + +Cartridge-local protocol types for `research-mcp`. Defines the data, tool, and +transition vocabulary this cartridge speaks. Inherits safety lemmas and the +matrix-cell identifier scheme from the trunk ABI at `src/abi/`. + +== Files + +1 Idris2 module(s), ~216 lines total. Lead module: +`SafeResearch.idr`. + +No `.ipkg` β€” consumed directly by wider BoJ callers; type-checks inside the +trunk `boj.ipkg`. + +== Invariants + +* `%default total` at every module top. +* Zero `believe_me`, `assert_total`, `postulate`, `Admitted`, or + `sorry` (the trunk holds the four declared `believe_me` sites; leaf + cartridges carry none). + +== Test/proof surface + +Thin module(s), no inline tests. Proof surface = type-check success under the +trunk `idris2 --check`. + +== Read-first + +. `SafeResearch.idr` β€” cartridge-local data records and enums. +. Trunk `src/abi/Boj/Catalogue.idr` for how this cell participates in the + 2D matrix. diff --git a/cartridges/domains/research/research-mcp/abi/ResearchMcp/SafeResearch.idr b/cartridges/domains/research/research-mcp/abi/ResearchMcp/SafeResearch.idr new file mode 100644 index 0000000..1bbeca8 --- /dev/null +++ b/cartridges/domains/research/research-mcp/abi/ResearchMcp/SafeResearch.idr @@ -0,0 +1,216 @@ +-- SPDX-License-Identifier: MPL-2.0 +-- Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +||| ResearchMcp.SafeResearch: Formally verified academic research provider operations. +||| +||| Cartridge: research-mcp +||| Matrix cell: Research domain x {MCP, LSP} protocols +||| +||| This module defines type-safe research provider operations with a +||| session state machine that prevents: +||| - Operations on unauthenticated providers +||| - Credential leaks by tracking auth lifecycle +||| - Operations without proper session teardown +||| +||| State machine: Unauthenticated -> Authenticated -> Operating -> Authenticated -> Unauthenticated +module ResearchMcp.SafeResearch + +import Data.List + +%default total + +-- ═══════════════════════════════════════════════════════════════════════════ +-- Session State Machine +-- ═══════════════════════════════════════════════════════════════════════════ + +||| Provider session lifecycle states. +||| A session progresses: Unauthenticated -> Authenticated -> Operating -> Authenticated -> Unauthenticated +public export +data SessionState = Unauthenticated | Authenticated | Operating | AuthError + +||| Equality for session states. +public export +Eq SessionState where + Unauthenticated == Unauthenticated = True + Authenticated == Authenticated = True + Operating == Operating = True + AuthError == AuthError = True + _ == _ = False + +||| Valid state transitions (enforced at the type level). +public export +data ValidTransition : SessionState -> SessionState -> Type where + Authenticate : ValidTransition Unauthenticated Authenticated + BeginOperation : ValidTransition Authenticated Operating + EndOperation : ValidTransition Operating Authenticated + Logout : ValidTransition Authenticated Unauthenticated + OpError : ValidTransition Operating AuthError + Recover : ValidTransition AuthError Unauthenticated + +||| Runtime transition validator. +public export +canTransition : SessionState -> SessionState -> Bool +canTransition Unauthenticated Authenticated = True +canTransition Authenticated Operating = True +canTransition Operating Authenticated = True +canTransition Authenticated Unauthenticated = True +canTransition Operating AuthError = True +canTransition AuthError Unauthenticated = True +canTransition _ _ = False + +-- ═══════════════════════════════════════════════════════════════════════════ +-- Research Provider Types +-- ═══════════════════════════════════════════════════════════════════════════ + +||| Supported academic research providers. +public export +data ResearchProvider + = ScholarGateway -- Scholar Gateway aggregator + | SemanticScholar -- Semantic Scholar (Allen AI) + | OpenAlex -- OpenAlex open catalogue + | Custom String -- User-defined provider + +||| C-ABI encoding. +public export +providerToInt : ResearchProvider -> Int +providerToInt ScholarGateway = 1 +providerToInt SemanticScholar = 2 +providerToInt OpenAlex = 3 +providerToInt (Custom _) = 99 + +-- ═══════════════════════════════════════════════════════════════════════════ +-- Provider Capabilities +-- ═══════════════════════════════════════════════════════════════════════════ + +||| Capabilities a research provider may support. +public export +data ProviderCapability + = SearchPapers -- Search for papers + | PaperDetails -- Get paper metadata + | Citations -- Get citations for a paper + | References -- Get references from a paper + | AuthorSearch -- Search for authors + | AuthorPapers -- Get papers by an author + +-- ═══════════════════════════════════════════════════════════════════════════ +-- Research Resource Types +-- ═══════════════════════════════════════════════════════════════════════════ + +||| Resource types available on research providers. +public export +data ResearchResource + = ResPaper -- Academic paper + | ResAuthor -- Author profile + | ResCitation -- Citation link + | ResVenue -- Conference / journal venue + +||| C-ABI encoding for research resource types. +public export +resResourceToInt : ResearchResource -> Int +resResourceToInt ResPaper = 1 +resResourceToInt ResAuthor = 2 +resResourceToInt ResCitation = 3 +resResourceToInt ResVenue = 4 + +||| Map Scholar Gateway to its supported capabilities. +public export +scholarGatewayCapabilities : List ProviderCapability +scholarGatewayCapabilities = [SearchPapers, PaperDetails, Citations, References, AuthorSearch, AuthorPapers] + +||| Map Semantic Scholar to its supported capabilities. +public export +semanticScholarCapabilities : List ProviderCapability +semanticScholarCapabilities = [SearchPapers, PaperDetails, Citations, References, AuthorSearch, AuthorPapers] + +||| Map OpenAlex to its supported capabilities. +public export +openAlexCapabilities : List ProviderCapability +openAlexCapabilities = [SearchPapers, PaperDetails, Citations, References, AuthorSearch, AuthorPapers] + +-- ═══════════════════════════════════════════════════════════════════════════ +-- Session Record +-- ═══════════════════════════════════════════════════════════════════════════ + +||| A research provider session with tracked state. +public export +record Session where + constructor MkSession + sessionId : String + provider : ResearchProvider + state : SessionState + endpoint : String + +||| Proof that a session is authenticated (ready for operations). +public export +data IsAuthenticated : Session -> Type where + ActiveSession : (s : Session) -> + (state s = Authenticated) -> + IsAuthenticated s + +-- ═══════════════════════════════════════════════════════════════════════════ +-- MCP Tool Definitions +-- ═══════════════════════════════════════════════════════════════════════════ + +||| MCP tools exposed by this cartridge. +||| These map to MCP tool definitions that AI agents can call. +public export +data McpTool + = ToolAuthenticate -- Authenticate with a research provider + | ToolSearchPapers -- Search for papers + | ToolPaperDetails -- Get paper metadata + | ToolCitations -- Get citations for a paper + | ToolReferences -- Get references from a paper + | ToolAuthorSearch -- Search for authors + | ToolAuthorPapers -- Get papers by an author + | ToolLogout -- End provider session + +||| MCP tool name (for JSON-RPC method name). +public export +toolName : McpTool -> String +toolName ToolAuthenticate = "research/authenticate" +toolName ToolSearchPapers = "research/papers/search" +toolName ToolPaperDetails = "research/papers/details" +toolName ToolCitations = "research/papers/citations" +toolName ToolReferences = "research/papers/references" +toolName ToolAuthorSearch = "research/authors/search" +toolName ToolAuthorPapers = "research/authors/papers" +toolName ToolLogout = "research/logout" + +||| Which tools require an authenticated session. +public export +requiresAuth : McpTool -> Bool +requiresAuth ToolAuthenticate = False +requiresAuth _ = True + +-- ═══════════════════════════════════════════════════════════════════════════ +-- C-ABI Exports +-- ═══════════════════════════════════════════════════════════════════════════ + +||| Session state to integer. +public export +sessionStateToInt : SessionState -> Int +sessionStateToInt Unauthenticated = 0 +sessionStateToInt Authenticated = 1 +sessionStateToInt Operating = 2 +sessionStateToInt AuthError = 3 + +||| FFI: Validate a state transition. +export +research_can_transition : Int -> Int -> Int +research_can_transition from to = + let fromState = case from of + 0 => Unauthenticated + 1 => Authenticated + 2 => Operating + _ => AuthError + toState = case to of + 0 => Unauthenticated + 1 => Authenticated + 2 => Operating + _ => AuthError + in if canTransition fromState toState then 1 else 0 + +||| FFI: Check if a tool requires authentication. +export +research_tool_requires_auth : Int -> Int +research_tool_requires_auth 1 = 0 -- ToolAuthenticate +research_tool_requires_auth _ = 1 -- All others require auth diff --git a/cartridges/domains/research/research-mcp/abi/research-mcp.ipkg b/cartridges/domains/research/research-mcp/abi/research-mcp.ipkg new file mode 100644 index 0000000..7ebe354 --- /dev/null +++ b/cartridges/domains/research/research-mcp/abi/research-mcp.ipkg @@ -0,0 +1,11 @@ +-- SPDX-License-Identifier: MPL-2.0 +package researchmcp + +authors = "Jonathan D.A. Jewell" +version = 0.1.0 +license = "MPL-2.0" +brief = "Research MCP cartridge β€” academic paper search with session safety" + +sourcedir = "." +modules = ResearchMcp.SafeResearch +depends = base, contrib diff --git a/cartridges/domains/research/research-mcp/adapter/README.adoc b/cartridges/domains/research/research-mcp/adapter/README.adoc new file mode 100644 index 0000000..ecedd62 --- /dev/null +++ b/cartridges/domains/research/research-mcp/adapter/README.adoc @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += research-mcp / adapter β€” protocol bridge +:orientation: shallow + +== Purpose + +Exposes this cartridge's FFI over the wire protocols declared in +`cartridge.json`. Dispatches tool invocations to the C-ABI exports at +`../ffi/` and formats responses per protocol. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” depends on the sibling `ffi` target. +| `research_adapter.zig` | Protocol dispatch (129 lines). Tool catalogue matches `cartridge.json`. +| `SIDELINED-*.v.adoc` | zig predecessor(s). Not built β€” zig banned 2026-04-10. +|=== + +== Invariants + +* **Stateless** β€” all state lives behind the FFI, never in the adapter. +* **Response passthrough** β€” whatever the FFI returns goes back to the wire + unmodified (no embellishment, no silent recovery). +* **Cartridge.json is source of truth** for the tool catalogue; drift between + adapter and manifest is a CI failure. + +== Test/proof surface + +No inline tests at the adapter layer β€” protocol routing is exercised by the +trunk seam tests at `ffi/zig/src/seams.zig`. + +== Read-first + +. `research_adapter.zig` β€” dispatch function and per-protocol routers. +. `../cartridge.json` β€” the tool manifest the dispatcher mirrors. diff --git a/cartridges/domains/research/research-mcp/adapter/build.zig b/cartridges/domains/research/research-mcp/adapter/build.zig new file mode 100644 index 0000000..6292bbd --- /dev/null +++ b/cartridges/domains/research/research-mcp/adapter/build.zig @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// research-mcp/adapter/build.zig + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const ffi_mod = b.createModule(.{ + .root_source_file = b.path("../ffi/research_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + const adapter = b.addExecutable(.{ + .name = "research_adapter", + .root_source_file = b.path("research_adapter.zig"), + .target = target, + .optimize = optimize, + }); + adapter.root_module.addImport("research_ffi", ffi_mod); + b.installArtifact(adapter); +} diff --git a/cartridges/domains/research/research-mcp/adapter/research_adapter.zig b/cartridges/domains/research/research-mcp/adapter/research_adapter.zig new file mode 100644 index 0000000..9ef38d6 --- /dev/null +++ b/cartridges/domains/research/research-mcp/adapter/research_adapter.zig @@ -0,0 +1,129 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// research-mcp/adapter/research_adapter.zig +// +// Three-protocol BoJ adapter: REST (port 9283), gRPC-compat (port 9284), +// GraphQL (port 9285). +// Replaces the banned zig adapter (research_adapter.v). + +const std = @import("std"); +const ffi = @import("research_ffi"); + +const REST_PORT: u16 = 9283; +const GRPC_PORT: u16 = 9284; +const GQL_PORT: u16 = 9285; + +const Response = struct { status: u16, body: []const u8 }; + +fn okJson(buf: []u8, msg: []const u8) []u8 { + return std.fmt.bufPrint(buf, "{{\"success\":true,\"message\":\"{s}\"}}", .{msg}) catch buf[0..0]; +} + +fn errJson(buf: []u8, msg: []const u8) []u8 { + return std.fmt.bufPrint(buf, "{{\"success\":false,\"error\":\"{s}\"}}", .{msg}) catch buf[0..0]; +} + +fn dispatch(tool: []const u8, _body: []const u8, resp: []u8) Response { + _ = _body; + if (std.mem.eql(u8, tool, "research_authenticate")) { + return .{ .status = 200, .body = okJson(resp, "research_authenticate forwarded") }; + } + if (std.mem.eql(u8, tool, "research_search")) { + return .{ .status = 200, .body = okJson(resp, "research_search forwarded") }; + } + if (std.mem.eql(u8, tool, "research_get_paper")) { + return .{ .status = 200, .body = okJson(resp, "research_get_paper forwarded") }; + } + if (std.mem.eql(u8, tool, "research_list_providers")) { + return .{ .status = 200, .body = okJson(resp, "research_list_providers forwarded") }; + } + return .{ .status = 404, .body = errJson(resp, "unknown tool") }; +} + +fn dispatchRest(path: []const u8, body: []const u8, resp: []u8) Response { + // Expect /tools/<tool_name> + const prefix = "/tools/"; + if (std.mem.startsWith(u8, path, prefix)) { + const tool = path[prefix.len..]; + return dispatch(tool, body, resp); + } + return .{ .status = 404, .body = errJson(resp, "not found") }; +} + +fn dispatchGrpc(path: []const u8, body: []const u8, resp: []u8) Response { + // Expect /<Service>/<Method> β€” derive tool from Method + var it = std.mem.splitScalar(u8, path, '/'); + _ = it.next(); // leading empty + _ = it.next(); // service + const method = it.next() orelse return .{ .status = 404, .body = errJson(resp, "bad gRPC path") }; + return dispatch(method, body, resp); +} + +fn dispatchGraphql(body: []const u8, resp: []u8) Response { + if (std.mem.indexOf(u8, body, "research_authenticate") != null) + return dispatch("research_authenticate", body, resp); + if (std.mem.indexOf(u8, body, "research_search") != null) + return dispatch("research_search", body, resp); + if (std.mem.indexOf(u8, body, "research_get_paper") != null) + return dispatch("research_get_paper", body, resp); + if (std.mem.indexOf(u8, body, "research_list_providers") != null) + return dispatch("research_list_providers", body, resp); + return .{ .status = 400, .body = errJson(resp, "unrecognised GraphQL operation") }; +} + +fn handleConnection(stream: std.net.Stream, port: u16) void { + defer stream.close(); + var buf: [4096]u8 = undefined; + var resp_buf: [4096]u8 = undefined; + const n = stream.read(&buf) catch return; + const req = buf[0..n]; + const result = switch (port) { + REST_PORT => blk: { + // Parse HTTP/1.1: first line = METHOD PATH HTTP/x.y + var lines = std.mem.splitScalar(u8, req, '\n'); + const first = lines.next() orelse break :blk Response{ .status = 400, .body = "" }; + var parts = std.mem.splitScalar(u8, std.mem.trim(u8, first, "\r"), ' '); + _ = parts.next(); // method + const path = parts.next() orelse break :blk Response{ .status = 400, .body = "" }; + break :blk dispatchRest(path, req, &resp_buf); + }, + GRPC_PORT => blk: { + var lines = std.mem.splitScalar(u8, req, '\n'); + const first = lines.next() orelse break :blk Response{ .status = 400, .body = "" }; + var parts = std.mem.splitScalar(u8, std.mem.trim(u8, first, "\r"), ' '); + _ = parts.next(); + const path = parts.next() orelse break :blk Response{ .status = 400, .body = "" }; + break :blk dispatchGrpc(path, req, &resp_buf); + }, + GQL_PORT => dispatchGraphql(req, &resp_buf), + else => Response{ .status = 500, .body = "" }, + }; + var http_resp: [512]u8 = undefined; + const http = std.fmt.bufPrint(&http_resp, + "HTTP/1.1 {d} OK\r\nContent-Length: {d}\r\nContent-Type: application/json\r\n\r\n", + .{ result.status, result.body.len }) catch return; + _ = stream.write(http) catch {}; + _ = stream.write(result.body) catch {}; +} + +fn listenLoop(port: u16) !void { + const addr = std.net.Address.initIp4(.{ 127, 0, 0, 1 }, port); + var server = try addr.listen(.{ .reuse_address = true }); + defer server.deinit(); + while (true) { + const conn = try server.accept(); + const t = try std.Thread.spawn(.{}, handleConnection, .{ conn.stream, port }); + t.detach(); + } +} + +pub fn main() !void { + ffi.research_init(); + const t1 = try std.Thread.spawn(.{}, listenLoop, .{REST_PORT}); + const t2 = try std.Thread.spawn(.{}, listenLoop, .{GRPC_PORT}); + const t3 = try std.Thread.spawn(.{}, listenLoop, .{GQL_PORT}); + t1.join(); + t2.join(); + t3.join(); +} diff --git a/cartridges/domains/research/research-mcp/cartridge.json b/cartridges/domains/research/research-mcp/cartridge.json new file mode 100644 index 0000000..638c89a --- /dev/null +++ b/cartridges/domains/research/research-mcp/cartridge.json @@ -0,0 +1,114 @@ +{ + "$schema": "https://boj.dev/schemas/cartridge/v1.json", + "spdx": "MPL-2.0", + "copyright": "Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk>", + "name": "research-mcp", + "version": "0.1.0", + "description": "Academic paper search (Semantic Scholar, OpenAlex)", + "domain": "research", + "tier": "Ayo", + "protocols": [ + "MCP", + "REST" + ], + "auth": { + "method": "none", + "env_var": null, + "credential_source": null + }, + "api": { + "base_url": "local://research-mcp", + "content_type": "application/json" + }, + "tools": [ + { + "name": "research_authenticate", + "description": "Authenticate with a research provider", + "inputSchema": { + "type": "object", + "properties": { + "provider": { + "type": "string", + "description": "Provider: semantic_scholar|open_alex|scholar_gateway" + }, + "api_key": { + "type": "string", + "description": "API key (if required)" + } + }, + "required": [ + "provider" + ] + } + }, + { + "name": "research_search", + "description": "Search for academic papers", + "inputSchema": { + "type": "object", + "properties": { + "session_id": { + "type": "integer", + "description": "Session index" + }, + "query": { + "type": "string", + "description": "Search query" + }, + "limit": { + "type": "integer", + "description": "Max results" + }, + "year_from": { + "type": "integer", + "description": "Earliest publication year" + } + }, + "required": [ + "session_id", + "query" + ] + } + }, + { + "name": "research_get_paper", + "description": "Get paper details by ID", + "inputSchema": { + "type": "object", + "properties": { + "session_id": { + "type": "integer", + "description": "Session index" + }, + "paper_id": { + "type": "string", + "description": "Paper ID (DOI, arXiv ID, S2 ID)" + } + }, + "required": [ + "session_id", + "paper_id" + ] + } + }, + { + "name": "research_list_providers", + "description": "List available research providers", + "inputSchema": { + "type": "object", + "properties": {} + } + } + ], + "ffi": { + "so_path": "ffi/zig-out/lib/libresearch_mcp.so", + "abi_version": "ADR-0006", + "symbols": [ + "boj_cartridge_init", + "boj_cartridge_deinit", + "boj_cartridge_name", + "boj_cartridge_version", + "boj_cartridge_invoke" + ] + } +} diff --git a/cartridges/domains/research/research-mcp/ffi/README.adoc b/cartridges/domains/research/research-mcp/ffi/README.adoc new file mode 100644 index 0000000..9e89943 --- /dev/null +++ b/cartridges/domains/research/research-mcp/ffi/README.adoc @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += research-mcp / ffi β€” Zig FFI layer +:orientation: shallow + +== Purpose + +C-ABI exports bridging this cartridge's Idris2 ABI to the runtime. Consumed by +the adapter for protocol dispatch and by the trunk loader for mount. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” produces `zig-out/lib/libresearch.so`. +| `research_ffi.zig` | C-ABI exports (19 exports, 12 inline tests, 463 lines). +|=== + +== Invariants + +* **Null-pointer rejection** on `[*c]const u8` arguments before any + dereference. +* **Negative return values** carry documented error meanings; zero is + success (matching the trunk error-code convention). +* **No allocator leaks** β€” every allocation path pairs with `defer free`. + +== Test/proof surface + +12 inline `test "..."` block(s) in `research_ffi.zig`. Run with +`cd ffi && zig build test`. + +== Read-first + +. `research_ffi.zig` β€” C-ABI signatures and guards. +. Trunk `ffi/zig/src/catalogue.zig` for how this `.so` is loaded. diff --git a/cartridges/domains/research/research-mcp/ffi/build.zig b/cartridges/domains/research/research-mcp/ffi/build.zig new file mode 100644 index 0000000..8ddb763 --- /dev/null +++ b/cartridges/domains/research/research-mcp/ffi/build.zig @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// Research-MCP Cartridge β€” Zig FFI build configuration (Zig 0.15+). + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + // Shared ADR-0006 invoke-shim module (relative path up to boj-server trunk). + + const shim_mod = b.addModule("cartridge_shim", .{ + + .root_source_file = b.path("../../../ffi/zig/src/cartridge_shim.zig"), + + .target = target, + + .optimize = optimize, + + }); + + const research_mod = b.addModule("research_ffi", .{ + .root_source_file = b.path("research_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + research_mod.addImport("cartridge_shim", shim_mod); + + // ── Tests ──────────────────────────────────────────────────────── + const research_tests = b.addTest(.{ + .root_module = research_mod, + }); + + const run_tests = b.addRunArtifact(research_tests); + + const test_step = b.step("test", "Run research-mcp FFI tests"); + test_step.dependOn(&run_tests.step); + + // ── Shared library ────────────────────────────────────────────── + const lib_mod = b.createModule(.{ + .root_source_file = b.path("research_ffi.zig"), + .target = target, + .optimize = optimize, + }); + lib_mod.addImport("cartridge_shim", shim_mod); + + const lib = b.addLibrary(.{ + .name = "research_mcp", + .root_module = lib_mod, + .linkage = .dynamic, + }); + b.installArtifact(lib); + + const lib_step = b.step("lib", "Build shared library"); + lib_step.dependOn(&lib.step); +} diff --git a/cartridges/domains/research/research-mcp/ffi/cartridge_shim.zig b/cartridges/domains/research/research-mcp/ffi/cartridge_shim.zig new file mode 100644 index 0000000..0e399db --- /dev/null +++ b/cartridges/domains/research/research-mcp/ffi/cartridge_shim.zig @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// cartridge_shim.zig β€” Shared helpers for the ADR-0006 five-symbol +// cartridge ABI (`boj_cartridge_init / deinit / name / version / invoke`). +// +// The shim centralises the seven-code return convention, NUL-argument +// guards, tool-name comparison, and the buffer-too-small path so each +// cartridge's `boj_cartridge_invoke` can stay short β€” typically a tool +// table plus `shim.writeResult(...)`. +// +// Cartridges import this file by relative path (no build-graph change +// needed). Example: +// +// const shim = @import("../../../ffi/zig/src/cartridge_shim.zig"); +// +// export fn boj_cartridge_invoke( +// tool_name: [*c]const u8, +// json_args: [*c]const u8, +// out_buf: [*c]u8, +// in_out_len: [*c]usize, +// ) callconv(.c) i32 { +// _ = json_args; +// if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; +// const body = if (shim.toolIs(tool_name, "foo")) "{\"result\":{}}" +// else return shim.RC_UNKNOWN_TOOL; +// return shim.writeResult(out_buf, in_out_len, body); +// } + +const std = @import("std"); + +// ── Return codes (ADR-0006 Β§Return codes) ──────────────────────────── +// +// Frozen by ADR-0006. New failure modes compose these via the error +// JSON body β€” the integer surface does not grow without a follow-up ADR. + +pub const RC_SUCCESS: i32 = 0; +pub const RC_UNKNOWN_TOOL: i32 = -1; +pub const RC_BAD_ARGS: i32 = -2; +pub const RC_BUFFER_TOO_SMALL: i32 = -3; +pub const RC_RUNTIME_ERROR: i32 = -4; +pub const RC_PANIC: i32 = -5; +pub const RC_AUTH_DENIED: i32 = -6; + +// ── Invoke-path helpers ────────────────────────────────────────────── + +/// True if any of the three mandatory `boj_cartridge_invoke` output-path +/// pointers is null. Use at the top of every invoke to short-circuit to +/// `RC_BAD_ARGS`. +pub fn invokeArgsNull( + tool_name: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) bool { + return tool_name == null or out_buf == null or in_out_len == null; +} + +/// Compare a C-NUL-terminated tool-name pointer against a Zig string +/// literal. Caller must have already verified `tool_name` is non-null +/// (usually via `invokeArgsNull`). +pub fn toolIs(tool_name: [*c]const u8, expected: []const u8) bool { + const s = std.mem.span(@as([*:0]const u8, @ptrCast(tool_name))); + return std.mem.eql(u8, s, expected); +} + +/// Copy `body` into `out_buf[0..*in_out_len]` (as a capacity) and update +/// `*in_out_len` to the number of bytes written. Returns `RC_SUCCESS`. +/// +/// If `body.len` exceeds the current capacity stored in `*in_out_len`, +/// sets `*in_out_len` to the required size and returns +/// `RC_BUFFER_TOO_SMALL` β€” the caller is then expected to re-allocate +/// and retry, per ADR-0006 Β§Memory ownership. +/// +/// Caller must have already verified that `out_buf` and `in_out_len` +/// are non-null. +pub fn writeResult( + out_buf: [*c]u8, + in_out_len: [*c]usize, + body: []const u8, +) i32 { + const cap = in_out_len.*; + if (body.len > cap) { + in_out_len.* = body.len; + return RC_BUFFER_TOO_SMALL; + } + @memcpy(out_buf[0..body.len], body); + in_out_len.* = body.len; + return RC_SUCCESS; +} + +// ── Tests ──────────────────────────────────────────────────────────── + +test "writeResult: body fits, writes and sets length" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); + try std.testing.expectEqualStrings("hello", buf[0..len]); +} + +test "writeResult: too small returns -3 and sets required length" { + var buf: [2]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_BUFFER_TOO_SMALL, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: exact-fit succeeds" { + var buf: [5]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: empty body" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, ""); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 0), len); +} + +test "toolIs: matches and rejects" { + const name: [*:0]const u8 = "foo"; + try std.testing.expect(toolIs(@ptrCast(name), "foo")); + try std.testing.expect(!toolIs(@ptrCast(name), "bar")); + try std.testing.expect(!toolIs(@ptrCast(name), "foobar")); + try std.testing.expect(!toolIs(@ptrCast(name), "fo")); +} + +test "invokeArgsNull: detects each null slot" { + var buf: [4]u8 = undefined; + var len: usize = 4; + const name: [*:0]const u8 = "x"; + try std.testing.expect(!invokeArgsNull(@ptrCast(name), &buf, &len)); + try std.testing.expect(invokeArgsNull(null, &buf, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), null, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), &buf, null)); +} diff --git a/cartridges/domains/research/research-mcp/ffi/research_ffi.zig b/cartridges/domains/research/research-mcp/ffi/research_ffi.zig new file mode 100644 index 0000000..bdfc5b2 --- /dev/null +++ b/cartridges/domains/research/research-mcp/ffi/research_ffi.zig @@ -0,0 +1,539 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// Research-MCP Cartridge β€” Zig FFI bridge for academic research provider operations. +// +// Implements the provider session state machine from SafeResearch.idr. +// Ensures no operation can execute on an unauthenticated provider, +// and tracks credential lifecycle to prevent leaks. + +const std = @import("std"); + +// ═══════════════════════════════════════════════════════════════════════ +// Types (must match ResearchMcp.SafeResearch encodings) +// ═══════════════════════════════════════════════════════════════════════ + +pub const SessionState = enum(c_int) { + unauthenticated = 0, + authenticated = 1, + operating = 2, + auth_error = 3, +}; + +pub const ResearchProvider = enum(c_int) { + scholar_gateway = 1, + semantic_scholar = 2, + open_alex = 3, + custom = 99, +}; + +/// Research resource types β€” mirrors `ResearchMcp.SafeResearch.ResearchResource` +/// + `resResourceToInt` encoding. Declared here so `iseriser abi-verify` can +/// structurally check the encoding against the Idris2 source. +pub const ResearchResource = enum(c_int) { + res_paper = 1, + res_author = 2, + res_citation = 3, + res_venue = 4, +}; + +// ═══════════════════════════════════════════════════════════════════════ +// Session State Machine +// ═══════════════════════════════════════════════════════════════════════ + +const MAX_SESSIONS: usize = 8; + +const RESULT_BUF_SIZE: usize = 4096; + +const API_KEY_SIZE: usize = 256; + +const SessionSlot = struct { + active: bool, + state: SessionState, + provider: ResearchProvider, + api_key: [API_KEY_SIZE]u8 = [_]u8{0} ** API_KEY_SIZE, + api_key_len: usize = 0, + result_buf: [RESULT_BUF_SIZE]u8 = [_]u8{0} ** RESULT_BUF_SIZE, + result_len: usize = 0, +}; + +var sessions: [MAX_SESSIONS]SessionSlot = [_]SessionSlot{.{ + .active = false, + .state = .unauthenticated, + .provider = .scholar_gateway, + .api_key = [_]u8{0} ** API_KEY_SIZE, + .api_key_len = 0, + .result_buf = [_]u8{0} ** RESULT_BUF_SIZE, + .result_len = 0, +}} ** MAX_SESSIONS; + +var mutex: std.Thread.Mutex = .{}; + +/// Validate a state transition (matches Idris2 canTransition). +fn isValidTransition(from: SessionState, to: SessionState) bool { + return switch (from) { + .unauthenticated => to == .authenticated, + .authenticated => to == .operating or to == .unauthenticated, + .operating => to == .authenticated or to == .auth_error, + .auth_error => to == .unauthenticated, + }; +} + +/// Authenticate with a provider. Returns slot index or -1 on failure. +pub export fn research_authenticate(provider: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + for (&sessions, 0..) |*slot, i| { + if (!slot.active) { + slot.active = true; + slot.state = .authenticated; + slot.provider = @enumFromInt(provider); + return @intCast(i); + } + } + return -1; // No slots available +} + +/// Logout from a provider session by slot index. +pub export fn research_logout(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + if (slot_idx < 0 or slot_idx >= MAX_SESSIONS) return -1; + const idx: usize = @intCast(slot_idx); + if (!sessions[idx].active) return -1; + if (!isValidTransition(sessions[idx].state, .unauthenticated)) return -2; + + // Wipe API key on logout + @memset(&sessions[idx].api_key, 0); + sessions[idx].api_key_len = 0; + sessions[idx].active = false; + sessions[idx].state = .unauthenticated; + return 0; +} + +/// Begin an operation (transition Authenticated -> Operating). +pub export fn research_begin_operation(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + if (slot_idx < 0 or slot_idx >= MAX_SESSIONS) return -1; + const idx: usize = @intCast(slot_idx); + if (!sessions[idx].active) return -1; + if (!isValidTransition(sessions[idx].state, .operating)) return -2; + + sessions[idx].state = .operating; + return 0; +} + +/// End an operation (transition Operating -> Authenticated). +pub export fn research_end_operation(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + if (slot_idx < 0 or slot_idx >= MAX_SESSIONS) return -1; + const idx: usize = @intCast(slot_idx); + if (!sessions[idx].active) return -1; + if (!isValidTransition(sessions[idx].state, .authenticated)) return -2; + + sessions[idx].state = .authenticated; + return 0; +} + +/// Get the state of a session. +pub export fn research_state(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + if (slot_idx < 0 or slot_idx >= MAX_SESSIONS) return -1; + const idx: usize = @intCast(slot_idx); + if (!sessions[idx].active) return @intFromEnum(SessionState.unauthenticated); + return @intFromEnum(sessions[idx].state); +} + +/// Validate a state transition (C-ABI export). +pub export fn research_can_transition(from: c_int, to: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + const f: SessionState = @enumFromInt(from); + const t: SessionState = @enumFromInt(to); + return if (isValidTransition(f, t)) 1 else 0; +} + +/// Reset all sessions (for testing). +pub export fn research_reset() void { + mutex.lock(); + defer mutex.unlock(); + for (&sessions) |*slot| { + @memset(&slot.api_key, 0); + slot.api_key_len = 0; + slot.active = false; + slot.state = .unauthenticated; + slot.result_len = 0; + } +} + +// ═══════════════════════════════════════════════════════════════════════ +// Standard Cartridge Interface (loader expects these 4 C-ABI symbols) +// ═══════════════════════════════════════════════════════════════════════ + +/// Initialise the research-mcp cartridge. Resets all session slots. +pub export fn boj_cartridge_init() c_int { + research_reset(); + return 0; +} + +/// Deinitialise the research-mcp cartridge. Resets all session slots. +pub export fn boj_cartridge_deinit() void { + research_reset(); +} + +/// Return the cartridge name as a null-terminated C string. +pub export fn boj_cartridge_name() [*:0]const u8 { + mutex.lock(); + defer mutex.unlock(); + return "research-mcp"; +} + +/// Return the cartridge version as a null-terminated C string. +pub export fn boj_cartridge_version() [*:0]const u8 { + mutex.lock(); + defer mutex.unlock(); + return "0.1.0"; +} + +// ═══════════════════════════════════════════════════════════════════════ +// ADR-0006 dispatch (boj_cartridge_invoke, 5th standard symbol) +// ═══════════════════════════════════════════════════════════════════════ + +const shim = @import("cartridge_shim.zig"); + +/// Dispatch the cartridge.json MCP tools. Grade D Alpha β€” each arm +/// returns a stub JSON body shaped to the tool's intended response. +export fn boj_cartridge_invoke( + tool_name: [*c]const u8, + json_args: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) callconv(.c) i32 { + _ = json_args; + if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; + + const body: []const u8 = if (shim.toolIs(tool_name, "research_authenticate")) + "{\"result\":{\"matches\":[],\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "research_search")) + "{\"result\":{\"matches\":[],\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "research_get_paper")) + "{\"result\":{\"metadata\":{},\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "research_list_providers")) + "{\"result\":{\"items\":[],\"count\":0,\"status\":\"stub\"}}" +else + return shim.RC_UNKNOWN_TOOL; + + return shim.writeResult(out_buf, in_out_len, body); +} + +// ═══════════════════════════════════════════════════════════════════════ +// Research Provider Operations (all providers share the same API shape) +// Grade D Alpha β€” stub implementations +// ═══════════════════════════════════════════════════════════════════════ + +/// Validate that a slot is active and authenticated (any research provider). +fn validateResearchSlot(slot_idx: c_int) ?usize { + if (slot_idx < 0 or slot_idx >= MAX_SESSIONS) return null; + const idx: usize = @intCast(slot_idx); + if (!sessions[idx].active) return null; + if (sessions[idx].state != .authenticated) return null; + return idx; +} + +/// Get the provider name string for JSON output. +fn providerName(provider: ResearchProvider) []const u8 { + return switch (provider) { + .scholar_gateway => "scholar_gateway", + .semantic_scholar => "semantic_scholar", + .open_alex => "open_alex", + .custom => "custom", + }; +} + +/// Write a JSON stub response into a session's result buffer. +fn writeResearchResult(slot: *SessionSlot, endpoint: []const u8, method: []const u8) void { + const prefix = "{\"provider\":\""; + const mid0 = "\",\"endpoint\":\""; + const mid1 = "\",\"method\":\""; + const mid2 = "\",\"status\":\"stub\",\"note\":\"Grade D Alpha\"}"; + + const pname = providerName(slot.provider); + + var pos: usize = 0; + const parts = [_][]const u8{ prefix, pname, mid0, endpoint, mid1, method, mid2 }; + for (parts) |part| { + if (pos + part.len > RESULT_BUF_SIZE) break; + @memcpy(slot.result_buf[pos .. pos + part.len], part); + pos += part.len; + } + slot.result_len = pos; +} + +/// Set API key credentials on a research session slot. +pub export fn research_set_credentials(slot_idx: c_int, key_ptr: [*]const u8, key_len: usize) c_int { + mutex.lock(); + defer mutex.unlock(); + const idx = validateResearchSlot(slot_idx) orelse return -1; + if (key_len > API_KEY_SIZE) return -3; + @memcpy(sessions[idx].api_key[0..key_len], key_ptr[0..key_len]); + sessions[idx].api_key_len = key_len; + return 0; +} + +/// Search for papers. query_ptr/query_len contain the search query. +pub export fn research_search_papers(slot_idx: c_int, query_ptr: [*]const u8, query_len: usize) c_int { + mutex.lock(); + defer mutex.unlock(); + const idx = validateResearchSlot(slot_idx) orelse return -1; + _ = query_ptr[0..query_len]; + writeResearchResult(&sessions[idx], "papers?query={q}", "GET"); + return 0; +} + +/// Get paper details by ID. +pub export fn research_paper_details(slot_idx: c_int, id_ptr: [*]const u8, id_len: usize) c_int { + mutex.lock(); + defer mutex.unlock(); + const idx = validateResearchSlot(slot_idx) orelse return -1; + _ = id_ptr[0..id_len]; + writeResearchResult(&sessions[idx], "papers/{id}", "GET"); + return 0; +} + +/// Get citations for a paper by ID. +pub export fn research_paper_citations(slot_idx: c_int, id_ptr: [*]const u8, id_len: usize) c_int { + mutex.lock(); + defer mutex.unlock(); + const idx = validateResearchSlot(slot_idx) orelse return -1; + _ = id_ptr[0..id_len]; + writeResearchResult(&sessions[idx], "papers/{id}/citations", "GET"); + return 0; +} + +/// Get references from a paper by ID. +pub export fn research_paper_references(slot_idx: c_int, id_ptr: [*]const u8, id_len: usize) c_int { + mutex.lock(); + defer mutex.unlock(); + const idx = validateResearchSlot(slot_idx) orelse return -1; + _ = id_ptr[0..id_len]; + writeResearchResult(&sessions[idx], "papers/{id}/references", "GET"); + return 0; +} + +/// Search for authors by name. +pub export fn research_author_search(slot_idx: c_int, name_ptr: [*]const u8, name_len: usize) c_int { + mutex.lock(); + defer mutex.unlock(); + const idx = validateResearchSlot(slot_idx) orelse return -1; + _ = name_ptr[0..name_len]; + writeResearchResult(&sessions[idx], "authors?query={name}", "GET"); + return 0; +} + +/// Get papers by an author ID. +pub export fn research_author_papers(slot_idx: c_int, id_ptr: [*]const u8, id_len: usize) c_int { + mutex.lock(); + defer mutex.unlock(); + const idx = validateResearchSlot(slot_idx) orelse return -1; + _ = id_ptr[0..id_len]; + writeResearchResult(&sessions[idx], "authors/{id}/papers", "GET"); + return 0; +} + +/// Read the result buffer for a research session slot. Returns length or -1 on error. +pub export fn research_read_result(slot_idx: c_int, out_ptr: [*]u8, out_cap: usize) c_int { + mutex.lock(); + defer mutex.unlock(); + if (slot_idx < 0 or slot_idx >= MAX_SESSIONS) return -1; + const idx: usize = @intCast(slot_idx); + if (!sessions[idx].active) return -1; + const len = @min(sessions[idx].result_len, out_cap); + @memcpy(out_ptr[0..len], sessions[idx].result_buf[0..len]); + return @intCast(len); +} + +// ═══════════════════════════════════════════════════════════════════════ +// Tests +// ═══════════════════════════════════════════════════════════════════════ + +test "authenticate and logout" { + research_reset(); + const slot = research_authenticate(@intFromEnum(ResearchProvider.scholar_gateway)); + try std.testing.expect(slot >= 0); + try std.testing.expectEqual(@as(c_int, @intFromEnum(SessionState.authenticated)), research_state(slot)); + try std.testing.expectEqual(@as(c_int, 0), research_logout(slot)); +} + +test "cannot operate on unauthenticated" { + research_reset(); + const slot = research_authenticate(@intFromEnum(ResearchProvider.semantic_scholar)); + _ = research_logout(slot); + try std.testing.expectEqual(@as(c_int, -1), research_begin_operation(slot)); +} + +test "operation lifecycle" { + research_reset(); + const slot = research_authenticate(@intFromEnum(ResearchProvider.open_alex)); + try std.testing.expectEqual(@as(c_int, 0), research_begin_operation(slot)); + try std.testing.expectEqual(@as(c_int, @intFromEnum(SessionState.operating)), research_state(slot)); + try std.testing.expectEqual(@as(c_int, 0), research_end_operation(slot)); + try std.testing.expectEqual(@as(c_int, @intFromEnum(SessionState.authenticated)), research_state(slot)); + try std.testing.expectEqual(@as(c_int, 0), research_logout(slot)); +} + +test "cannot double-logout" { + research_reset(); + const slot = research_authenticate(@intFromEnum(ResearchProvider.scholar_gateway)); + _ = research_logout(slot); + try std.testing.expectEqual(@as(c_int, -1), research_logout(slot)); +} + +test "cannot logout while operating" { + research_reset(); + const slot = research_authenticate(@intFromEnum(ResearchProvider.semantic_scholar)); + _ = research_begin_operation(slot); + try std.testing.expectEqual(@as(c_int, -2), research_logout(slot)); +} + +test "state transition validation" { + try std.testing.expectEqual(@as(c_int, 1), research_can_transition(0, 1)); + try std.testing.expectEqual(@as(c_int, 1), research_can_transition(1, 2)); + try std.testing.expectEqual(@as(c_int, 1), research_can_transition(2, 1)); + try std.testing.expectEqual(@as(c_int, 1), research_can_transition(1, 0)); + try std.testing.expectEqual(@as(c_int, 1), research_can_transition(2, 3)); + try std.testing.expectEqual(@as(c_int, 1), research_can_transition(3, 0)); + try std.testing.expectEqual(@as(c_int, 0), research_can_transition(0, 2)); + try std.testing.expectEqual(@as(c_int, 0), research_can_transition(2, 0)); +} + +test "max sessions enforced" { + research_reset(); + var slots: [MAX_SESSIONS]c_int = undefined; + for (&slots) |*s| { + s.* = research_authenticate(@intFromEnum(ResearchProvider.scholar_gateway)); + try std.testing.expect(s.* >= 0); + } + try std.testing.expectEqual(@as(c_int, -1), research_authenticate(@intFromEnum(ResearchProvider.scholar_gateway))); + _ = research_logout(slots[0]); + try std.testing.expect(research_authenticate(@intFromEnum(ResearchProvider.scholar_gateway)) >= 0); +} + +// ═══════════════════════════════════════════════════════════════════════ +// Scholar Gateway Provider Tests +// ═══════════════════════════════════════════════════════════════════════ + +test "scholar gateway auth and credential storage" { + research_reset(); + const slot = research_authenticate(@intFromEnum(ResearchProvider.scholar_gateway)); + try std.testing.expect(slot >= 0); + const key = "sg_test_api_key_abc123"; + try std.testing.expectEqual(@as(c_int, 0), research_set_credentials(slot, key.ptr, key.len)); + try std.testing.expectEqual(@as(c_int, 0), research_logout(slot)); +} + +test "scholar gateway search papers and details" { + research_reset(); + const slot = research_authenticate(@intFromEnum(ResearchProvider.scholar_gateway)); + try std.testing.expect(slot >= 0); + // Search papers + const query = "dependent types"; + try std.testing.expectEqual(@as(c_int, 0), research_search_papers(slot, query.ptr, query.len)); + var buf: [RESULT_BUF_SIZE]u8 = undefined; + const len = research_read_result(slot, &buf, buf.len); + try std.testing.expect(len > 0); + const result = buf[0..@intCast(len)]; + try std.testing.expect(std.mem.indexOf(u8, result, "\"provider\":\"scholar_gateway\"") != null); + try std.testing.expect(std.mem.indexOf(u8, result, "\"endpoint\":\"papers?query={q}\"") != null); + // Paper details + const paper_id = "10.1145/3371098"; + try std.testing.expectEqual(@as(c_int, 0), research_paper_details(slot, paper_id.ptr, paper_id.len)); + _ = research_logout(slot); +} + +test "semantic scholar citations and references" { + research_reset(); + const slot = research_authenticate(@intFromEnum(ResearchProvider.semantic_scholar)); + try std.testing.expect(slot >= 0); + const paper_id = "649def34f8be52c8b66281af98ae884c09aef38b"; + // Citations + try std.testing.expectEqual(@as(c_int, 0), research_paper_citations(slot, paper_id.ptr, paper_id.len)); + var buf: [RESULT_BUF_SIZE]u8 = undefined; + var len = research_read_result(slot, &buf, buf.len); + try std.testing.expect(std.mem.indexOf(u8, buf[0..@intCast(len)], "\"provider\":\"semantic_scholar\"") != null); + try std.testing.expect(std.mem.indexOf(u8, buf[0..@intCast(len)], "\"endpoint\":\"papers/{id}/citations\"") != null); + // References + try std.testing.expectEqual(@as(c_int, 0), research_paper_references(slot, paper_id.ptr, paper_id.len)); + len = research_read_result(slot, &buf, buf.len); + try std.testing.expect(std.mem.indexOf(u8, buf[0..@intCast(len)], "\"endpoint\":\"papers/{id}/references\"") != null); + _ = research_logout(slot); +} + +test "open alex author search and papers" { + research_reset(); + const slot = research_authenticate(@intFromEnum(ResearchProvider.open_alex)); + try std.testing.expect(slot >= 0); + // Author search + const name = "Edwin Brady"; + try std.testing.expectEqual(@as(c_int, 0), research_author_search(slot, name.ptr, name.len)); + var buf: [RESULT_BUF_SIZE]u8 = undefined; + var len = research_read_result(slot, &buf, buf.len); + try std.testing.expect(std.mem.indexOf(u8, buf[0..@intCast(len)], "\"provider\":\"open_alex\"") != null); + try std.testing.expect(std.mem.indexOf(u8, buf[0..@intCast(len)], "\"endpoint\":\"authors?query={name}\"") != null); + // Author papers + const author_id = "A12345678"; + try std.testing.expectEqual(@as(c_int, 0), research_author_papers(slot, author_id.ptr, author_id.len)); + len = research_read_result(slot, &buf, buf.len); + try std.testing.expect(std.mem.indexOf(u8, buf[0..@intCast(len)], "\"endpoint\":\"authors/{id}/papers\"") != null); + _ = research_logout(slot); +} + +test "research all providers credential storage" { + research_reset(); + // Test credentials work with all three providers + const providers = [_]ResearchProvider{ .scholar_gateway, .semantic_scholar, .open_alex }; + for (providers) |prov| { + const slot = research_authenticate(@intFromEnum(prov)); + try std.testing.expect(slot >= 0); + const key = "test_key_123"; + try std.testing.expectEqual(@as(c_int, 0), research_set_credentials(slot, key.ptr, key.len)); + try std.testing.expectEqual(@as(c_int, 0), research_logout(slot)); + } +} + +// ═══════════════════════════════════════════════════════════════════════ +// ADR-0006 invoke dispatch tests +// ═══════════════════════════════════════════════════════════════════════ + +test "invoke: each declared tool succeeds" { + var buf: [256]u8 = undefined; + const tools = [_][]const u8{ + "research_authenticate", + "research_search", + "research_get_paper", + "research_list_providers", + }; + for (tools) |t| { + var len: usize = buf.len; + const rc = boj_cartridge_invoke(t.ptr, "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, 0), rc); + try std.testing.expect(std.mem.indexOf(u8, buf[0..len], "result") != null); + } +} + +test "invoke: unknown tool returns -1" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("nope", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -1), rc); +} + +test "invoke: buffer too small returns -3" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("research_authenticate", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -3), rc); + try std.testing.expect(len > 4); +} diff --git a/cartridges/domains/research/research-mcp/mod.js b/cartridges/domains/research/research-mcp/mod.js new file mode 100644 index 0000000..bb7c44e --- /dev/null +++ b/cartridges/domains/research/research-mcp/mod.js @@ -0,0 +1,55 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// research-mcp/mod.js β€” Academic paper search (Semantic Scholar, OpenAlex) +// +// Delegates to backend at http://127.0.0.1:7739 (override with RESEARCH_BACKEND_URL). + +const BASE_URL = Deno.env.get("RESEARCH_BACKEND_URL") ?? "http://127.0.0.1:7739"; +const TIMEOUT_MS = 15_000; + +async function post(path, payload) { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), TIMEOUT_MS); + try { + const r = await fetch(`${BASE_URL}${path}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + signal: ctrl.signal, + }); + const data = await r.json().catch(() => ({ success: false, error: "non-JSON response" })); + return { status: r.status, data }; + } catch (e) { + if (e.name === "AbortError") return { status: 504, data: { success: false, error: "research-mcp backend timed out" } }; + return { status: 503, data: { success: false, error: `research-mcp backend unavailable: ${e.message}` } }; + } finally { clearTimeout(t); } +} + +async function get(path) { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), TIMEOUT_MS); + try { + const r = await fetch(`${BASE_URL}${path}`, { method: "GET", signal: ctrl.signal }); + const data = await r.json().catch(() => ({ success: false, error: "non-JSON response" })); + return { status: r.status, data }; + } catch (e) { + if (e.name === "AbortError") return { status: 504, data: { success: false, error: "research-mcp backend timed out" } }; + return { status: 503, data: { success: false, error: `research-mcp backend unavailable: ${e.message}` } }; + } finally { clearTimeout(t); } +} + +export async function handleTool(toolName, args) { + switch (toolName) { + case "research_authenticate": + return post("/api/v1/research_authenticate", args ?? {}); + case "research_search": + return post("/api/v1/research_search", args ?? {}); + case "research_get_paper": + return post("/api/v1/research_get_paper", args ?? {}); + case "research_list_providers": + return post("/api/v1/research_list_providers", args ?? {}); + default: + return { status: 404, data: { error: `Unknown tool: ${toolName}` } }; + } +} diff --git a/cartridges/domains/research/research-mcp/panels/manifest.json b/cartridges/domains/research/research-mcp/panels/manifest.json new file mode 100644 index 0000000..3116e16 --- /dev/null +++ b/cartridges/domains/research/research-mcp/panels/manifest.json @@ -0,0 +1,67 @@ +{ + "$schema": "https://panll.dev/schemas/panel-manifest/v1.json", + "spdx": "MPL-2.0", + "cartridge": "research-mcp", + "domain": "Research", + "version": "0.1.0", + "panels": [ + { + "id": "research-status", + "title": "Research Engine Status", + "description": "Web search, academic search, and knowledge retrieval readiness", + "type": "status-indicator", + "data_source": { + "endpoint": "/cartridge/research-mcp/invoke", + "method": "POST", + "body": { "tool": "status" }, + "refresh_interval_ms": 5000 + }, + "widgets": [ + { + "type": "state-badge", + "field": "state", + "states": { + "ready": { "color": "#2ecc71", "icon": "search" }, + "degraded": { "color": "#f39c12", "icon": "alert-circle" }, + "error": { "color": "#e74c3c", "icon": "alert-triangle" } + } + }, + { "type": "text", "field": "version", "label": "Version" } + ] + }, + { + "id": "research-sources", + "title": "Data Sources", + "description": "Connected research sources β€” Brave, Semantic Scholar, arXiv, etc.", + "type": "metric", + "data_source": { + "endpoint": "/cartridge/research-mcp/invoke", + "method": "POST", + "body": { "tool": "source_status" }, + "refresh_interval_ms": 30000 + }, + "widgets": [ + { "type": "counter", "field": "active_sources", "label": "Active Sources", "icon": "database" }, + { "type": "counter", "field": "cached_documents", "label": "Cached Docs", "icon": "file-text" }, + { "type": "text", "field": "index_freshness", "label": "Index Freshness" } + ] + }, + { + "id": "research-query-stats", + "title": "Query Statistics", + "description": "Search volume, cache hit rate, and average result relevance", + "type": "metric", + "data_source": { + "endpoint": "/cartridge/research-mcp/invoke", + "method": "POST", + "body": { "tool": "query_stats" }, + "refresh_interval_ms": 15000 + }, + "widgets": [ + { "type": "counter", "field": "queries_today", "label": "Queries Today", "icon": "search" }, + { "type": "gauge", "field": "cache_hit_rate", "label": "Cache Hit %", "min": 0, "max": 100 }, + { "type": "counter", "field": "avg_results_count", "label": "Avg Results", "icon": "list" } + ] + } + ] +} diff --git a/cartridges/domains/research/search-mcp/README.adoc b/cartridges/domains/research/search-mcp/README.adoc new file mode 100644 index 0000000..6ff82b6 --- /dev/null +++ b/cartridges/domains/research/search-mcp/README.adoc @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: MPL-2.0 += search-mcp β€” Multi-provider web search + +Cartridge that exposes web search across four providers behind a single MCP tool surface: + +|=== +| Provider | Strengths | Auth env var + +| Tavily +| LLM-optimised summaries; answer-mode; extract API +| `TAVILY_API_KEY` + +| Brave +| Privacy-first index; broad freshness; no tracking +| `BRAVE_SEARCH_API_KEY` + +| Exa +| Neural search over high-quality content (Twitter, Reddit, papers, blogs); extract API +| `EXA_API_KEY` + +| Perplexity +| Q&A-style answers with citations; conversational search +| `PERPLEXITY_API_KEY` +|=== + +== Tools + +* `search_authenticate` β€” store provider API key (per-provider; called once) +* `search_web` β€” keyword/phrase search; returns ranked results +* `search_answer` β€” single answer with citations (perplexity, tavily) +* `search_extract` β€” structured content extraction from URLs (tavily, exa) + +== Bridge tool + +The MCP bridge exposes a single high-level `boj_search` tool that dispatches to `search-mcp` via `boj_cartridge_invoke`. See `boj_search` in `mcp-bridge/lib/tools.js`. + +== Status + +Cartridge surface declared (this manifest); backend implementation in the Elixir/Zig dispatch layer is tracked separately. Until backend lands, `search_*` operations route through the unified gateway and return `{error, hint}` for unimplemented providers. diff --git a/cartridges/domains/research/search-mcp/cartridge.json b/cartridges/domains/research/search-mcp/cartridge.json new file mode 100644 index 0000000..f57b325 --- /dev/null +++ b/cartridges/domains/research/search-mcp/cartridge.json @@ -0,0 +1,132 @@ +{ + "$schema": "https://boj.dev/schemas/cartridge/v1.json", + "spdx": "MPL-2.0", + "copyright": "Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk>", + "name": "search-mcp", + "version": "0.1.0", + "status": "stub", + "description": "Web search across multiple providers (Tavily, Brave, Exa, Perplexity) behind one cartridge.", + "domain": "research", + "tier": "Teranga", + "protocols": [ + "MCP", + "REST" + ], + "auth": { + "method": "api_key", + "env_var": "SEARCH_API_KEY", + "credential_source": "per-provider env var (TAVILY_API_KEY / BRAVE_SEARCH_API_KEY / EXA_API_KEY / PERPLEXITY_API_KEY); see operation 'authenticate'." + }, + "api": { + "base_url": "local://search-mcp", + "content_type": "application/json" + }, + "tools": [ + { + "name": "search_authenticate", + "description": "Store an API key for a search provider. Required before first use of that provider.", + "inputSchema": { + "type": "object", + "properties": { + "provider": { + "type": "string", + "enum": ["tavily", "brave", "exa", "perplexity"], + "description": "Provider to authenticate." + }, + "api_key": { + "type": "string", + "description": "API key from the provider's dashboard." + } + }, + "required": ["provider", "api_key"] + } + }, + { + "name": "search_web", + "description": "Run a web search query through the chosen provider. Read-only.", + "inputSchema": { + "type": "object", + "properties": { + "provider": { + "type": "string", + "enum": ["tavily", "brave", "exa", "perplexity"], + "description": "Which provider to query. Each has different strengths: tavily = LLM-optimised summaries; brave = privacy-first index; exa = neural search over high-quality content; perplexity = Q&A-style answers with citations." + }, + "query": { + "type": "string", + "description": "Search query string.", + "minLength": 1 + }, + "max_results": { + "type": "integer", + "minimum": 1, + "maximum": 50, + "description": "Max results to return (default 10)." + }, + "include_domains": { + "type": "array", + "items": { "type": "string" }, + "description": "Optional allow-list of domains." + }, + "exclude_domains": { + "type": "array", + "items": { "type": "string" }, + "description": "Optional deny-list of domains." + }, + "freshness": { + "type": "string", + "enum": ["day", "week", "month", "year", "any"], + "description": "Recency filter (provider-best-effort; not all providers honour all values)." + } + }, + "required": ["provider", "query"] + } + }, + { + "name": "search_answer", + "description": "Get a single answer with citations for a question. Best for Q&A workflows. Read-only.", + "inputSchema": { + "type": "object", + "properties": { + "provider": { + "type": "string", + "enum": ["perplexity", "tavily"], + "description": "Only providers that support answer-mode (perplexity, tavily)." + }, + "question": { + "type": "string", + "description": "Natural-language question.", + "minLength": 1 + }, + "model": { + "type": "string", + "description": "Provider-specific model identifier. Optional; defaults to provider's standard model." + } + }, + "required": ["provider", "question"] + } + }, + { + "name": "search_extract", + "description": "Extract structured content from one or more URLs (provider-side scraping + cleanup). Read-only.", + "inputSchema": { + "type": "object", + "properties": { + "provider": { + "type": "string", + "enum": ["tavily", "exa"], + "description": "Only providers that support extraction (tavily, exa)." + }, + "urls": { + "type": "array", + "items": { "type": "string", "format": "uri" }, + "minItems": 1, + "maxItems": 20, + "description": "URLs to extract content from." + } + }, + "required": ["provider", "urls"] + } + } + ] +} diff --git a/cartridges/domains/research/zotero-mcp/README.adoc b/cartridges/domains/research/zotero-mcp/README.adoc new file mode 100644 index 0000000..219fdfa --- /dev/null +++ b/cartridges/domains/research/zotero-mcp/README.adoc @@ -0,0 +1,97 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += zotero-mcp +Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk> +:spdx: MPL-2.0 +:tier: Ayo +:domain: Research +:protocols: MCP, REST + +== Overview + +Zotero reference manager cartridge for the BoJ server. Provides type-safe +access to the Zotero Web API v3 for library search, item metadata retrieval, +collection browsing, tag management, attachment access, citation export +(BibTeX, RIS, CSL JSON), note extraction, saved search listing, group +library access, and bibliography generation. + +Connects to the Zotero Web API at `api.zotero.org`. API key auth is always +required. + +=== State Machine + +`Disconnected -> Connected` (authenticated via API key) + +`Connected -> RateLimited -> Connected` (normal flow) + +`Connected -> Error -> Disconnected` (error recovery) + +=== Actions (12) + +[cols="1,1"] +|=== +| Category | Actions + +| Search +| SearchItems + +| Item Content +| GetItem, GetAttachments, ExportCitation, GetNotes + +| Collections +| ListCollections, GetCollectionItems + +| Tags +| ListTags, GetItemsByTag + +| Saved Searches +| ListSavedSearches + +| Groups +| GetGroupLibraries + +| Bibliography +| GenerateBibliography +|=== + +== Architecture + +[cols="1,1,2"] +|=== +| Layer | Language | Purpose + +| ABI +| Idris2 +| Formally verified state machine with dependent-type proofs (ZoteroMcp.SafeRegistry) + +| FFI +| Zig +| C-compatible implementation with thread-safe session pool, action recording, category counting + +| Adapter +| zig +| REST bridge to BoJ unified adapter protocol +|=== + +== Building + +[source,bash] +---- +# Build FFI shared library +cd ffi && zig build + +# Run FFI tests +cd ffi && zig build test + +# Type-check ABI +cd abi && idris2 --check ZoteroMcp.SafeRegistry +---- + +== Prerequisites + +1. Create a https://www.zotero.org/settings/keys[Zotero API key] with read access. +2. Set `ZOTERO_API_KEY` to the API key value. + +== Status + +Development -- customised with Zotero-specific library search, collection browsing, citation export, and bibliography generation APIs. diff --git a/cartridges/domains/research/zotero-mcp/abi/README.adoc b/cartridges/domains/research/zotero-mcp/abi/README.adoc new file mode 100644 index 0000000..e533deb --- /dev/null +++ b/cartridges/domains/research/zotero-mcp/abi/README.adoc @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += zotero-mcp / abi β€” Idris2 ABI layer +:orientation: shallow + +== Purpose + +Cartridge-local protocol types for `zotero-mcp`. Defines the data, tool, and +transition vocabulary this cartridge speaks. Inherits safety lemmas and the +matrix-cell identifier scheme from the trunk ABI at `src/abi/`. + +== Files + +1 Idris2 module(s), ~185 lines total. Lead module: +`SafeRegistry.idr`. + +No `.ipkg` β€” consumed directly by wider BoJ callers; type-checks inside the +trunk `boj.ipkg`. + +== Invariants + +* `%default total` at every module top. +* Zero `believe_me`, `assert_total`, `postulate`, `Admitted`, or + `sorry` (the trunk holds the four declared `believe_me` sites; leaf + cartridges carry none). + +== Test/proof surface + +Thin module(s), no inline tests. Proof surface = type-check success under the +trunk `idris2 --check`. + +== Read-first + +. `SafeRegistry.idr` β€” cartridge-local data records and enums. +. Trunk `src/abi/Boj/Catalogue.idr` for how this cell participates in the + 2D matrix. diff --git a/cartridges/domains/research/zotero-mcp/abi/ZoteroMcp/SafeRegistry.idr b/cartridges/domains/research/zotero-mcp/abi/ZoteroMcp/SafeRegistry.idr new file mode 100644 index 0000000..7f06653 --- /dev/null +++ b/cartridges/domains/research/zotero-mcp/abi/ZoteroMcp/SafeRegistry.idr @@ -0,0 +1,185 @@ +-- SPDX-License-Identifier: MPL-2.0 +-- Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +-- +-- ZoteroMcp.SafeRegistry β€” Type-safe ABI for zotero-mcp cartridge. +-- +-- Dependent-type state machine governing Zotero Web API v3 access. +-- Encodes mandatory API key auth, library search, item retrieval, +-- collection browsing, tag management, attachment access, citation export, +-- note extraction, saved search execution, group library access, and +-- bibliography generation as compile-time invariants. +-- REST API: https://api.zotero.org +-- No unsafe escape hatches. + +module ZoteroMcp.SafeRegistry + +%default total + +-- --------------------------------------------------------------------------- +-- Authentication state machine +-- --------------------------------------------------------------------------- + +||| Session state for Zotero MCP operations. +||| Disconnected: no API key configured or validated. +||| Connected: authenticated and ready for API calls. +||| RateLimited: rate limit hit; must wait before retrying. +||| Error: unrecoverable error (invalid key, network failure). +public export +data SessionState + = Disconnected + | Connected + | RateLimited + | Error + +||| Proof that a state transition is valid. +||| Zotero requires API key auth β€” no anonymous access to user libraries. +public export +data ValidTransition : SessionState -> SessionState -> Type where + Connect : ValidTransition Disconnected Connected + Disconnect : ValidTransition Connected Disconnected + Throttle : ValidTransition Connected RateLimited + Unthrottle : ValidTransition RateLimited Connected + ConnectError : ValidTransition Connected Error + DisconnError : ValidTransition Disconnected Error + RecoverConnect : ValidTransition Error Connected + RecoverDisconn : ValidTransition Error Disconnected + +-- --------------------------------------------------------------------------- +-- C-ABI integer encoding +-- --------------------------------------------------------------------------- + +||| Encode session state as C-compatible integer for FFI boundary. +export +sessionStateToInt : SessionState -> Int +sessionStateToInt Disconnected = 0 +sessionStateToInt Connected = 1 +sessionStateToInt RateLimited = 2 +sessionStateToInt Error = 3 + +||| Decode integer back to session state. Returns Nothing for out-of-range. +export +intToSessionState : Int -> Maybe SessionState +intToSessionState 0 = Just Disconnected +intToSessionState 1 = Just Connected +intToSessionState 2 = Just RateLimited +intToSessionState 3 = Just Error +intToSessionState _ = Nothing + +||| Check if a state transition is valid (C-ABI export). +export +zotero_mcp_can_transition : Int -> Int -> Int +zotero_mcp_can_transition from to = + case (intToSessionState from, intToSessionState to) of + (Just Disconnected, Just Connected) => 1 + (Just Connected, Just Disconnected) => 1 + (Just Connected, Just RateLimited) => 1 + (Just RateLimited, Just Connected) => 1 + (Just Connected, Just Error) => 1 + (Just Disconnected, Just Error) => 1 + (Just Error, Just Connected) => 1 + (Just Error, Just Disconnected) => 1 + _ => 0 + +-- --------------------------------------------------------------------------- +-- Zotero actions +-- --------------------------------------------------------------------------- + +||| Actions available through the Zotero MCP cartridge. +||| Grouped: Search, ItemRetrieval, Collections, CollectionItems, +||| Tags, ItemsByTag, Attachments, CitationExport, Notes, +||| SavedSearches, GroupLibraries, Bibliography. +public export +data ZoteroAction + = SearchItems + | GetItem + | ListCollections + | GetCollectionItems + | ListTags + | GetItemsByTag + | GetAttachments + | ExportCitation + | GetNotes + | ListSavedSearches + | GetGroupLibraries + | GenerateBibliography + +||| Whether an action requires Connected state. +||| All Zotero operations require an active authenticated session. +export +actionRequiresAuth : ZoteroAction -> Bool +actionRequiresAuth _ = True + +||| Whether an action is a write/mutating operation. +||| All zotero-mcp actions are read-only queries. +export +actionIsMutating : ZoteroAction -> Bool +actionIsMutating _ = False + +||| Encode action as C-compatible integer for FFI. +export +actionToInt : ZoteroAction -> Int +actionToInt SearchItems = 0 +actionToInt GetItem = 1 +actionToInt ListCollections = 2 +actionToInt GetCollectionItems = 3 +actionToInt ListTags = 4 +actionToInt GetItemsByTag = 5 +actionToInt GetAttachments = 6 +actionToInt ExportCitation = 7 +actionToInt GetNotes = 8 +actionToInt ListSavedSearches = 9 +actionToInt GetGroupLibraries = 10 +actionToInt GenerateBibliography = 11 + +||| Decode integer to Zotero action. +export +intToAction : Int -> Maybe ZoteroAction +intToAction 0 = Just SearchItems +intToAction 1 = Just GetItem +intToAction 2 = Just ListCollections +intToAction 3 = Just GetCollectionItems +intToAction 4 = Just ListTags +intToAction 5 = Just GetItemsByTag +intToAction 6 = Just GetAttachments +intToAction 7 = Just ExportCitation +intToAction 8 = Just GetNotes +intToAction 9 = Just ListSavedSearches +intToAction 10 = Just GetGroupLibraries +intToAction 11 = Just GenerateBibliography +intToAction _ = Nothing + +-- --------------------------------------------------------------------------- +-- MCP tool declarations +-- --------------------------------------------------------------------------- + +||| Tools exposed via MCP protocol for this cartridge. +public export +data McpTool + = ToolSearchItems + | ToolGetItem + | ToolListCollections + | ToolGetCollectionItems + | ToolListTags + | ToolGetItemsByTag + | ToolGetAttachments + | ToolExportCitation + | ToolGetNotes + | ToolListSavedSearches + | ToolGetGroupLibraries + | ToolGenerateBibliography + +||| Check if a tool requires a connected session. +||| All Zotero tools require an active authenticated connection. +export +toolRequiresSession : McpTool -> Bool +toolRequiresSession _ = True + +||| Total tool count for this cartridge. +export +toolCount : Nat +toolCount = 12 + +||| Total action count for this cartridge. +export +actionCount : Nat +actionCount = 12 diff --git a/cartridges/domains/research/zotero-mcp/adapter/README.adoc b/cartridges/domains/research/zotero-mcp/adapter/README.adoc new file mode 100644 index 0000000..5a20c6a --- /dev/null +++ b/cartridges/domains/research/zotero-mcp/adapter/README.adoc @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += zotero-mcp / adapter β€” protocol bridge +:orientation: shallow + +== Purpose + +Exposes this cartridge's FFI over the wire protocols declared in +`cartridge.json`. Dispatches tool invocations to the C-ABI exports at +`../ffi/` and formats responses per protocol. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” depends on the sibling `ffi` target. +| `zotero_adapter.zig` | Protocol dispatch (212 lines). Tool catalogue matches `cartridge.json`. +| `SIDELINED-*.v.adoc` | zig predecessor(s). Not built β€” zig banned 2026-04-10. +|=== + +== Invariants + +* **Stateless** β€” all state lives behind the FFI, never in the adapter. +* **Response passthrough** β€” whatever the FFI returns goes back to the wire + unmodified (no embellishment, no silent recovery). +* **Cartridge.json is source of truth** for the tool catalogue; drift between + adapter and manifest is a CI failure. + +== Test/proof surface + +No inline tests at the adapter layer β€” protocol routing is exercised by the +trunk seam tests at `ffi/zig/src/seams.zig`. + +== Read-first + +. `zotero_adapter.zig` β€” dispatch function and per-protocol routers. +. `../cartridge.json` β€” the tool manifest the dispatcher mirrors. diff --git a/cartridges/domains/research/zotero-mcp/adapter/build.zig b/cartridges/domains/research/zotero-mcp/adapter/build.zig new file mode 100644 index 0000000..219d55f --- /dev/null +++ b/cartridges/domains/research/zotero-mcp/adapter/build.zig @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// zotero-mcp/adapter/build.zig + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const ffi_mod = b.createModule(.{ + .root_source_file = b.path("../ffi/zotero_mcp_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + const adapter = b.addExecutable(.{ + .name = "zotero_adapter", + .root_source_file = b.path("zotero_adapter.zig"), + .target = target, + .optimize = optimize, + }); + adapter.root_module.addImport("zotero_mcp_ffi", ffi_mod); + b.installArtifact(adapter); + + const run_artifact = b.addRunArtifact(adapter); + const run_step = b.step("run", "Run the zotero-mcp adapter"); + run_step.dependOn(&run_artifact.step); + + const tests = b.addTest(.{ + .root_source_file = b.path("zotero_adapter.zig"), + .target = target, + .optimize = optimize, + }); + tests.root_module.addImport("zotero_mcp_ffi", ffi_mod); + const run_tests = b.addRunArtifact(tests); + const test_step = b.step("test", "Run zotero-mcp adapter tests"); + test_step.dependOn(&run_tests.step); +} diff --git a/cartridges/domains/research/zotero-mcp/adapter/zotero_adapter.zig b/cartridges/domains/research/zotero-mcp/adapter/zotero_adapter.zig new file mode 100644 index 0000000..cebe78f --- /dev/null +++ b/cartridges/domains/research/zotero-mcp/adapter/zotero_adapter.zig @@ -0,0 +1,212 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// zotero-mcp/adapter/zotero_adapter.zig -- Unified three-protocol adapter. +// +// Replaces the banned zotero_adapter.v (zig, removed 2026-04-12). +// +// Bridges the Zig FFI (zotero_mcp_ffi.zig) to three network protocols: +// REST :9100 POST /tools/<tool> +// gRPC-compat :9101 /ZoteroMcpService/<Method> +// GraphQL :9102 POST /graphql { query: "..." } +// +// Zotero reference manager: items, collections, tags, citations, bibliographies +// Tools: +// zotero_search_items +// zotero_get_item +// zotero_list_collections +// zotero_get_collection_items +// zotero_list_tags +// zotero_get_items_by_tag +// zotero_get_attachments +// zotero_export_citation +// zotero_get_notes +// zotero_list_saved_searches +// zotero_get_group_libraries +// zotero_generate_bibliography + +const std = @import("std"); +const ffi = @import("zotero_mcp_ffi"); + +const REST_PORT: u16 = 9100; +const GRPC_PORT: u16 = 9101; +const GQL_PORT: u16 = 9102; + +const MAX_CONN_BUF: usize = 16 * 1024; + +// ============================================================================ +// JSON response builders +// ============================================================================ + +fn okJson(buf: []u8, msg: []const u8) []u8 { + return std.fmt.bufPrint(buf, + \{{"success":true,"message":"{}"}} + , .{msg}) catch buf[0..0]; +} + +fn errJson(buf: []u8, msg: []const u8) []u8 { + return std.fmt.bufPrint(buf, + \{{"success":false,"error":"{}"}} + , .{msg}) catch buf[0..0]; +} + +fn statusJson(buf: []u8) []u8 { + return std.fmt.bufPrint(buf, + \{{"success":true,"state":"ready","service":"zotero-mcp"}} + , .{}) catch buf[0..0]; +} + +// ============================================================================ +// Tool dispatcher +// ============================================================================ + +const Response = struct { status: u16, body: []u8 }; + +fn dispatch(tool: []const u8, body: []const u8, resp: []u8) Response { + _ = body; + if (std.mem.eql(u8, tool, "zotero_search_items")) return .{ .status = 200, .body = okJson(resp, "zotero_search_items forwarded to backend") }; + if (std.mem.eql(u8, tool, "zotero_get_item")) return .{ .status = 200, .body = okJson(resp, "zotero_get_item forwarded to backend") }; + if (std.mem.eql(u8, tool, "zotero_list_collections")) return .{ .status = 200, .body = okJson(resp, "zotero_list_collections forwarded to backend") }; + if (std.mem.eql(u8, tool, "zotero_get_collection_items")) return .{ .status = 200, .body = okJson(resp, "zotero_get_collection_items forwarded to backend") }; + if (std.mem.eql(u8, tool, "zotero_list_tags")) return .{ .status = 200, .body = okJson(resp, "zotero_list_tags forwarded to backend") }; + if (std.mem.eql(u8, tool, "zotero_get_items_by_tag")) return .{ .status = 200, .body = okJson(resp, "zotero_get_items_by_tag forwarded to backend") }; + if (std.mem.eql(u8, tool, "zotero_get_attachments")) return .{ .status = 200, .body = okJson(resp, "zotero_get_attachments forwarded to backend") }; + if (std.mem.eql(u8, tool, "zotero_export_citation")) return .{ .status = 200, .body = okJson(resp, "zotero_export_citation forwarded to backend") }; + if (std.mem.eql(u8, tool, "zotero_get_notes")) return .{ .status = 200, .body = okJson(resp, "zotero_get_notes forwarded to backend") }; + if (std.mem.eql(u8, tool, "zotero_list_saved_searches")) return .{ .status = 200, .body = okJson(resp, "zotero_list_saved_searches forwarded to backend") }; + if (std.mem.eql(u8, tool, "zotero_get_group_libraries")) return .{ .status = 200, .body = okJson(resp, "zotero_get_group_libraries forwarded to backend") }; + if (std.mem.eql(u8, tool, "zotero_generate_bibliography")) return .{ .status = 200, .body = okJson(resp, "zotero_generate_bibliography forwarded to backend") }; + if (std.mem.eql(u8, tool, "status") or std.mem.eql(u8, tool, "health")) + return .{ .status = 200, .body = statusJson(resp) }; + return .{ .status = 404, .body = errJson(resp, "Unknown tool") }; +} + +// ============================================================================ +// REST handler +// ============================================================================ + +fn dispatchRest(path: []const u8, body: []const u8, resp: []u8) Response { + if (std.mem.startsWith(u8, path, "/tools/")) { + return dispatch(path["/tools/".len..], body, resp); + } + if (std.mem.eql(u8, path, "/status") or std.mem.eql(u8, path, "/health")) { + return .{ .status = 200, .body = statusJson(resp) }; + } + return .{ .status = 404, .body = errJson(resp, "Not found") }; +} + +// ============================================================================ +// gRPC-compat handler +// ============================================================================ + +fn dispatchGrpc(path: []const u8, body: []const u8, resp: []u8) Response { + const prefix = "/ZoteroMcpService/"; + if (!std.mem.startsWith(u8, path, prefix)) + return .{ .status = 404, .body = errJson(resp, "Not a recognized gRPC path") }; + const method = path[prefix.len..]; + const tool = blk: { + if (std.mem.eql(u8, method, "ZoteroSearchItems")) break :blk "zotero_search_items"; + if (std.mem.eql(u8, method, "ZoteroGetItem")) break :blk "zotero_get_item"; + if (std.mem.eql(u8, method, "ZoteroListCollections")) break :blk "zotero_list_collections"; + if (std.mem.eql(u8, method, "ZoteroGetCollectionItems")) break :blk "zotero_get_collection_items"; + if (std.mem.eql(u8, method, "ZoteroListTags")) break :blk "zotero_list_tags"; + if (std.mem.eql(u8, method, "ZoteroGetItemsByTag")) break :blk "zotero_get_items_by_tag"; + if (std.mem.eql(u8, method, "ZoteroGetAttachments")) break :blk "zotero_get_attachments"; + if (std.mem.eql(u8, method, "ZoteroExportCitation")) break :blk "zotero_export_citation"; + if (std.mem.eql(u8, method, "ZoteroGetNotes")) break :blk "zotero_get_notes"; + if (std.mem.eql(u8, method, "ZoteroListSavedSearches")) break :blk "zotero_list_saved_searches"; + if (std.mem.eql(u8, method, "ZoteroGetGroupLibraries")) break :blk "zotero_get_group_libraries"; + if (std.mem.eql(u8, method, "ZoteroGenerateBibliography")) break :blk "zotero_generate_bibliography"; + return .{ .status = 404, .body = errJson(resp, "Unknown gRPC method") }; + }; + return dispatch(tool, body, resp); +} + +// ============================================================================ +// GraphQL handler +// ============================================================================ + +fn dispatchGraphql(body: []const u8, resp: []u8) Response { + if (std.mem.indexOf(u8, body, "__schema") != null) + return .{ .status = 200, .body = okJson(resp, "schema introspection not yet supported") }; + if (std.mem.indexOf(u8, body, "search_items") != null) return dispatch("zotero_search_items", body, resp); + if (std.mem.indexOf(u8, body, "get_item") != null) return dispatch("zotero_get_item", body, resp); + if (std.mem.indexOf(u8, body, "list_collections") != null) return dispatch("zotero_list_collections", body, resp); + if (std.mem.indexOf(u8, body, "get_collection_items") != null) return dispatch("zotero_get_collection_items", body, resp); + if (std.mem.indexOf(u8, body, "list_tags") != null) return dispatch("zotero_list_tags", body, resp); + if (std.mem.indexOf(u8, body, "get_items_by_tag") != null) return dispatch("zotero_get_items_by_tag", body, resp); + if (std.mem.indexOf(u8, body, "get_attachments") != null) return dispatch("zotero_get_attachments", body, resp); + if (std.mem.indexOf(u8, body, "export_citation") != null) return dispatch("zotero_export_citation", body, resp); + if (std.mem.indexOf(u8, body, "get_notes") != null) return dispatch("zotero_get_notes", body, resp); + if (std.mem.indexOf(u8, body, "list_saved_searches") != null) return dispatch("zotero_list_saved_searches", body, resp); + if (std.mem.indexOf(u8, body, "get_group_libraries") != null) return dispatch("zotero_get_group_libraries", body, resp); + if (std.mem.indexOf(u8, body, "generate_bibliography") != null) return dispatch("zotero_generate_bibliography", body, resp); + return .{ .status = 200, .body = errJson(resp, "Unrecognised GraphQL operation") }; +} + +// ============================================================================ +// HTTP/1.1 connection handler +// ============================================================================ + +const Protocol = enum { rest, grpc, graphql }; + +fn handleConnection(conn: std.net.Server.Connection, proto: Protocol) void { + defer conn.stream.close(); + var in_buf: [MAX_CONN_BUF]u8 = undefined; + const n = conn.stream.read(&in_buf) catch return; + const req = in_buf[0..n]; + + var path: []const u8 = "/"; + var body: []const u8 = ""; + if (n > 4) { + const line_end = std.mem.indexOf(u8, req, "\r\n") orelse req.len; + const first_line = req[0..line_end]; + const sp1 = std.mem.indexOfScalar(u8, first_line, ' ') orelse 0; + const rest_of = first_line[sp1 + 1 ..]; + const sp2 = std.mem.indexOfScalar(u8, rest_of, ' ') orelse rest_of.len; + path = rest_of[0..sp2]; + const body_sep = std.mem.indexOf(u8, req, "\r\n\r\n") orelse n; + body = req[@min(body_sep + 4, n)..]; + } + + var resp_buf: [MAX_CONN_BUF]u8 = undefined; + const result = switch (proto) { + .rest => dispatchRest(path, body, &resp_buf), + .grpc => dispatchGrpc(path, body, &resp_buf), + .graphql => dispatchGraphql(body, &resp_buf), + }; + + const content_type = switch (proto) { + .rest => "application/json", + .grpc => "application/grpc+json", + .graphql => "application/json", + }; + + var hdr_buf: [256]u8 = undefined; + const hdr = std.fmt.bufPrint(&hdr_buf, + "HTTP/1.1 {d} OK\r\nContent-Type: {s}\r\nContent-Length: {d}\r\nConnection: close\r\n\r\n", + .{ result.status, content_type, result.body.len }, + ) catch return; + _ = conn.stream.writeAll(hdr) catch return; + _ = conn.stream.writeAll(result.body) catch return; +} + +fn listenLoop(port: u16, proto: Protocol) void { + const addr = std.net.Address.initIp4(.{ 127, 0, 0, 1 }, port) catch return; + var server = addr.listen(.{ .reuse_address = true }) catch return; + defer server.deinit(); + while (true) { + const conn = server.accept() catch continue; + handleConnection(conn, proto); + } +} + +pub fn main() !void { + ffi.zotero_init(); + const rest_thread = try std.Thread.spawn(.{}, listenLoop, .{ REST_PORT, Protocol.rest }); + const grpc_thread = try std.Thread.spawn(.{}, listenLoop, .{ GRPC_PORT, Protocol.grpc }); + const gql_thread = try std.Thread.spawn(.{}, listenLoop, .{ GQL_PORT, Protocol.graphql }); + rest_thread.join(); + grpc_thread.join(); + gql_thread.join(); +} diff --git a/cartridges/domains/research/zotero-mcp/benchmarks/quick-bench.sh b/cartridges/domains/research/zotero-mcp/benchmarks/quick-bench.sh new file mode 100755 index 0000000..d774212 --- /dev/null +++ b/cartridges/domains/research/zotero-mcp/benchmarks/quick-bench.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Benchmarks for zotero-mcp cartridge. +set -euo pipefail + +echo "=== zotero-mcp benchmarks ===" + +cd "$(dirname "${BASH_SOURCE[0]}")/../ffi" + +# Build with ReleaseFast for benchmarking +zig build -Doptimize=ReleaseFast 2>&1 + +echo "Session connect/disconnect cycle (1000 iterations):" +time for i in $(seq 1 1000); do + true +done + +echo "" +echo "Benchmark placeholder -- implement real benchmarks in Zig test blocks" +echo "or via the zig adapter HTTP benchmark tool." diff --git a/cartridges/domains/research/zotero-mcp/cartridge.json b/cartridges/domains/research/zotero-mcp/cartridge.json new file mode 100644 index 0000000..f1baf12 --- /dev/null +++ b/cartridges/domains/research/zotero-mcp/cartridge.json @@ -0,0 +1,239 @@ +{ + "$schema": "https://boj.dev/schemas/cartridge/v1.json", + "spdx": "MPL-2.0", + "copyright": "Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath)", + "name": "zotero-mcp", + "version": "0.2.0", + "description": "Zotero reference manager cartridge -- library search, item retrieval, collection browsing, tag management, attachment access, citation export, note extraction, saved search execution, group library access, and bibliography generation via the Zotero Web API v3", + "domain": "Research", + "tier": "Ayo", + "protocols": [ + "MCP", + "REST" + ], + "auth": { + "method": "bearer_token", + "env_var": "ZOTERO_API_KEY", + "credential_source": "vault-mcp" + }, + "api": { + "base_url": "https://api.zotero.org", + "content_type": "application/json", + "user_agent_required": true + }, + "tools": [ + { + "name": "zotero_search_items", + "description": "Search Zotero library for items by text query across titles, authors, tags, and full-text content", + "inputSchema": { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "Search query (title, author, tag, full-text)" + }, + "item_type": { + "type": "string", + "description": "Filter by item type (e.g. 'journalArticle', 'book', 'conferencePaper')" + }, + "limit": { + "type": "number", + "description": "Max results (default 25, max 100)" + }, + "sort": { + "type": "string", + "description": "Sort field: dateAdded, dateModified, title, creator, itemType" + } + }, + "required": [ + "query" + ] + } + }, + { + "name": "zotero_get_item", + "description": "Get full metadata for a Zotero item by key (title, authors, abstract, DOI, tags, notes)", + "inputSchema": { + "type": "object", + "properties": { + "item_key": { + "type": "string", + "description": "Zotero item key (8-character alphanumeric)" + } + }, + "required": [ + "item_key" + ] + } + }, + { + "name": "zotero_list_collections", + "description": "List all collections in the Zotero library with item counts", + "inputSchema": { + "type": "object", + "properties": { + "parent_key": { + "type": "string", + "description": "Parent collection key (omit for top-level)" + } + } + } + }, + { + "name": "zotero_get_collection_items", + "description": "Get all items in a specific Zotero collection", + "inputSchema": { + "type": "object", + "properties": { + "collection_key": { + "type": "string", + "description": "Collection key" + }, + "limit": { + "type": "number", + "description": "Max results (default 25)" + }, + "sort": { + "type": "string", + "description": "Sort field" + } + }, + "required": [ + "collection_key" + ] + } + }, + { + "name": "zotero_list_tags", + "description": "List all tags in the Zotero library with usage counts", + "inputSchema": { + "type": "object", + "properties": { + "limit": { + "type": "number", + "description": "Max results (default 50)" + } + } + } + }, + { + "name": "zotero_get_items_by_tag", + "description": "Get all items with a specific tag applied", + "inputSchema": { + "type": "object", + "properties": { + "tag": { + "type": "string", + "description": "Tag name" + }, + "limit": { + "type": "number", + "description": "Max results" + } + }, + "required": [ + "tag" + ] + } + }, + { + "name": "zotero_get_attachments", + "description": "Get attachment metadata for a Zotero item (PDF, snapshot, linked files)", + "inputSchema": { + "type": "object", + "properties": { + "item_key": { + "type": "string", + "description": "Parent item key" + } + }, + "required": [ + "item_key" + ] + } + }, + { + "name": "zotero_export_citation", + "description": "Export citation for an item in a given format (BibTeX, RIS, CSL JSON, etc.)", + "inputSchema": { + "type": "object", + "properties": { + "item_key": { + "type": "string", + "description": "Item key" + }, + "format": { + "type": "string", + "description": "Export format: bibtex, ris, csljson, refer, coins (default bibtex)" + } + }, + "required": [ + "item_key" + ] + } + }, + { + "name": "zotero_get_notes", + "description": "Get child notes for a Zotero item", + "inputSchema": { + "type": "object", + "properties": { + "item_key": { + "type": "string", + "description": "Parent item key" + } + }, + "required": [ + "item_key" + ] + } + }, + { + "name": "zotero_list_saved_searches", + "description": "List saved searches in the Zotero library", + "inputSchema": { + "type": "object", + "properties": {} + } + }, + { + "name": "zotero_get_group_libraries", + "description": "List group libraries the user has access to", + "inputSchema": { + "type": "object", + "properties": {} + } + }, + { + "name": "zotero_generate_bibliography", + "description": "Generate a formatted bibliography for one or more items using a CSL style", + "inputSchema": { + "type": "object", + "properties": { + "item_keys": { + "type": "string", + "description": "Comma-separated item keys" + }, + "style": { + "type": "string", + "description": "CSL style ID (e.g. 'apa', 'chicago-note-bibliography', 'ieee')" + } + }, + "required": [ + "item_keys" + ] + } + } + ], + "ffi": { + "so_path": "ffi/zig-out/lib/libzotero_mcp.so", + "abi_version": "ADR-0006", + "symbols": [ + "boj_cartridge_init", + "boj_cartridge_deinit", + "boj_cartridge_name", + "boj_cartridge_version", + "boj_cartridge_invoke" + ] + } +} diff --git a/cartridges/domains/research/zotero-mcp/ffi/README.adoc b/cartridges/domains/research/zotero-mcp/ffi/README.adoc new file mode 100644 index 0000000..8fd389a --- /dev/null +++ b/cartridges/domains/research/zotero-mcp/ffi/README.adoc @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += zotero-mcp / ffi β€” Zig FFI layer +:orientation: shallow + +== Purpose + +C-ABI exports bridging this cartridge's Idris2 ABI to the runtime. Consumed by +the adapter for protocol dispatch and by the trunk loader for mount. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” produces `zig-out/lib/libzotero.so`. +| `zotero_mcp_ffi.zig` | C-ABI exports (15 exports, 6 inline tests, 371 lines). +|=== + +== Invariants + +* **Null-pointer rejection** on `[*c]const u8` arguments before any + dereference. +* **Negative return values** carry documented error meanings; zero is + success (matching the trunk error-code convention). +* **No allocator leaks** β€” every allocation path pairs with `defer free`. + +== Test/proof surface + +6 inline `test "..."` block(s) in `zotero_mcp_ffi.zig`. Run with +`cd ffi && zig build test`. + +== Read-first + +. `zotero_mcp_ffi.zig` β€” C-ABI signatures and guards. +. Trunk `ffi/zig/src/catalogue.zig` for how this `.so` is loaded. diff --git a/cartridges/domains/research/zotero-mcp/ffi/build.zig b/cartridges/domains/research/zotero-mcp/ffi/build.zig new file mode 100644 index 0000000..1dd7c52 --- /dev/null +++ b/cartridges/domains/research/zotero-mcp/ffi/build.zig @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + // Shared ADR-0006 invoke-shim module (relative path up to boj-server trunk). + + const shim_mod = b.addModule("cartridge_shim", .{ + + .root_source_file = b.path("../../../ffi/zig/src/cartridge_shim.zig"), + + .target = target, + + .optimize = optimize, + + }); + + const ffi_mod = b.addModule("zotero_mcp", .{ + .root_source_file = b.path("zotero_mcp_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + ffi_mod.addImport("cartridge_shim", shim_mod); + + const lib = b.addLibrary(.{ + .name = "zotero_mcp", + .root_module = ffi_mod, + .linkage = .dynamic, + }); + lib.linkLibC(); + b.installArtifact(lib); + + const tests = b.addTest(.{ .root_module = ffi_mod }); + tests.linkLibC(); + const run_tests = b.addRunArtifact(tests); + const test_step = b.step("test", "Run FFI tests"); + test_step.dependOn(&run_tests.step); +} + diff --git a/cartridges/domains/research/zotero-mcp/ffi/cartridge_shim.zig b/cartridges/domains/research/zotero-mcp/ffi/cartridge_shim.zig new file mode 100644 index 0000000..0e399db --- /dev/null +++ b/cartridges/domains/research/zotero-mcp/ffi/cartridge_shim.zig @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// cartridge_shim.zig β€” Shared helpers for the ADR-0006 five-symbol +// cartridge ABI (`boj_cartridge_init / deinit / name / version / invoke`). +// +// The shim centralises the seven-code return convention, NUL-argument +// guards, tool-name comparison, and the buffer-too-small path so each +// cartridge's `boj_cartridge_invoke` can stay short β€” typically a tool +// table plus `shim.writeResult(...)`. +// +// Cartridges import this file by relative path (no build-graph change +// needed). Example: +// +// const shim = @import("../../../ffi/zig/src/cartridge_shim.zig"); +// +// export fn boj_cartridge_invoke( +// tool_name: [*c]const u8, +// json_args: [*c]const u8, +// out_buf: [*c]u8, +// in_out_len: [*c]usize, +// ) callconv(.c) i32 { +// _ = json_args; +// if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; +// const body = if (shim.toolIs(tool_name, "foo")) "{\"result\":{}}" +// else return shim.RC_UNKNOWN_TOOL; +// return shim.writeResult(out_buf, in_out_len, body); +// } + +const std = @import("std"); + +// ── Return codes (ADR-0006 Β§Return codes) ──────────────────────────── +// +// Frozen by ADR-0006. New failure modes compose these via the error +// JSON body β€” the integer surface does not grow without a follow-up ADR. + +pub const RC_SUCCESS: i32 = 0; +pub const RC_UNKNOWN_TOOL: i32 = -1; +pub const RC_BAD_ARGS: i32 = -2; +pub const RC_BUFFER_TOO_SMALL: i32 = -3; +pub const RC_RUNTIME_ERROR: i32 = -4; +pub const RC_PANIC: i32 = -5; +pub const RC_AUTH_DENIED: i32 = -6; + +// ── Invoke-path helpers ────────────────────────────────────────────── + +/// True if any of the three mandatory `boj_cartridge_invoke` output-path +/// pointers is null. Use at the top of every invoke to short-circuit to +/// `RC_BAD_ARGS`. +pub fn invokeArgsNull( + tool_name: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) bool { + return tool_name == null or out_buf == null or in_out_len == null; +} + +/// Compare a C-NUL-terminated tool-name pointer against a Zig string +/// literal. Caller must have already verified `tool_name` is non-null +/// (usually via `invokeArgsNull`). +pub fn toolIs(tool_name: [*c]const u8, expected: []const u8) bool { + const s = std.mem.span(@as([*:0]const u8, @ptrCast(tool_name))); + return std.mem.eql(u8, s, expected); +} + +/// Copy `body` into `out_buf[0..*in_out_len]` (as a capacity) and update +/// `*in_out_len` to the number of bytes written. Returns `RC_SUCCESS`. +/// +/// If `body.len` exceeds the current capacity stored in `*in_out_len`, +/// sets `*in_out_len` to the required size and returns +/// `RC_BUFFER_TOO_SMALL` β€” the caller is then expected to re-allocate +/// and retry, per ADR-0006 Β§Memory ownership. +/// +/// Caller must have already verified that `out_buf` and `in_out_len` +/// are non-null. +pub fn writeResult( + out_buf: [*c]u8, + in_out_len: [*c]usize, + body: []const u8, +) i32 { + const cap = in_out_len.*; + if (body.len > cap) { + in_out_len.* = body.len; + return RC_BUFFER_TOO_SMALL; + } + @memcpy(out_buf[0..body.len], body); + in_out_len.* = body.len; + return RC_SUCCESS; +} + +// ── Tests ──────────────────────────────────────────────────────────── + +test "writeResult: body fits, writes and sets length" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); + try std.testing.expectEqualStrings("hello", buf[0..len]); +} + +test "writeResult: too small returns -3 and sets required length" { + var buf: [2]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_BUFFER_TOO_SMALL, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: exact-fit succeeds" { + var buf: [5]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: empty body" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, ""); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 0), len); +} + +test "toolIs: matches and rejects" { + const name: [*:0]const u8 = "foo"; + try std.testing.expect(toolIs(@ptrCast(name), "foo")); + try std.testing.expect(!toolIs(@ptrCast(name), "bar")); + try std.testing.expect(!toolIs(@ptrCast(name), "foobar")); + try std.testing.expect(!toolIs(@ptrCast(name), "fo")); +} + +test "invokeArgsNull: detects each null slot" { + var buf: [4]u8 = undefined; + var len: usize = 4; + const name: [*:0]const u8 = "x"; + try std.testing.expect(!invokeArgsNull(@ptrCast(name), &buf, &len)); + try std.testing.expect(invokeArgsNull(null, &buf, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), null, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), &buf, null)); +} diff --git a/cartridges/domains/research/zotero-mcp/ffi/zotero_mcp_ffi.zig b/cartridges/domains/research/zotero-mcp/ffi/zotero_mcp_ffi.zig new file mode 100644 index 0000000..0f01575 --- /dev/null +++ b/cartridges/domains/research/zotero-mcp/ffi/zotero_mcp_ffi.zig @@ -0,0 +1,486 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// zotero_mcp_ffi.zig β€” C-ABI FFI implementation for zotero-mcp cartridge. +// +// Implements the state machine defined in ZoteroMcp.SafeRegistry (Idris2 ABI). +// State machine: Disconnected | Connected | RateLimited | Error +// Auth: Required API key β€” Zotero user libraries are always gated. +// REST API: https://api.zotero.org +// Actions: SearchItems, GetItem, ListCollections, GetCollectionItems, +// ListTags, GetItemsByTag, GetAttachments, ExportCitation, +// GetNotes, ListSavedSearches, GetGroupLibraries, GenerateBibliography +// Thread-safe via std.Thread.Mutex. Fixed-size session pool, no heap allocations. + +const std = @import("std"); + +// --------------------------------------------------------------------------- +// State machine (matches Idris2 ABI SessionState exactly) +// --------------------------------------------------------------------------- + +/// Session connection/lifecycle state. +/// 0 = Disconnected, 1 = Connected, 2 = RateLimited, 3 = Error. +pub const SessionState = enum(c_int) { + disconnected = 0, + connected = 1, + rate_limited = 2, + err = 3, +}; + +/// Zotero action identifiers matching Idris2 ZoteroAction encoding. +pub const ZoteroAction = enum(c_int) { + search_items = 0, + get_item = 1, + list_collections = 2, + get_collection_items = 3, + list_tags = 4, + get_items_by_tag = 5, + get_attachments = 6, + export_citation = 7, + get_notes = 8, + list_saved_searches = 9, + get_group_libraries = 10, + generate_bibliography = 11, +}; + +/// Check valid state transitions per the Idris2 ValidTransition proof. +/// Zotero always requires auth β€” no anonymous sessions. +fn isValidTransition(from: SessionState, to: SessionState) bool { + return switch (from) { + .disconnected => to == .connected or to == .err, + .connected => to == .disconnected or to == .rate_limited or to == .err, + .rate_limited => to == .connected, + .err => to == .connected or to == .disconnected, + }; +} + +// --------------------------------------------------------------------------- +// Session slots (thread-safe, fixed-size pool) +// --------------------------------------------------------------------------- + +const MAX_SESSIONS: usize = 16; + +const SessionSlot = struct { + active: bool = false, + state: SessionState = .disconnected, + api_call_count: u64 = 0, + last_action: c_int = -1, + search_count: u32 = 0, + item_reads: u32 = 0, + collection_queries: u32 = 0, + tag_queries: u32 = 0, +}; + +var sessions: [MAX_SESSIONS]SessionSlot = .{SessionSlot{}} ** MAX_SESSIONS; +var mutex: std.Thread.Mutex = .{}; + +// --------------------------------------------------------------------------- +// C-ABI exports β€” state machine +// --------------------------------------------------------------------------- + +/// Check if a state transition is valid. Returns 1 (valid) or 0 (invalid). +pub export fn zotero_mcp_can_transition(from: c_int, to: c_int) c_int { + const f = std.meta.intToEnum(SessionState, from) catch return 0; + const t = std.meta.intToEnum(SessionState, to) catch return 0; + return if (isValidTransition(f, t)) 1 else 0; +} + +/// Connect to Zotero (authenticated session). Returns slot index (>= 0) or error (< 0). +pub export fn zotero_mcp_connect(dummy: c_int) c_int { + _ = dummy; + mutex.lock(); + defer mutex.unlock(); + + for (&sessions, 0..) |*slot, idx| { + if (!slot.active) { + slot.active = true; + slot.state = .connected; + slot.api_call_count = 0; + slot.last_action = -1; + slot.search_count = 0; + slot.item_reads = 0; + slot.collection_queries = 0; + slot.tag_queries = 0; + return @intCast(idx); + } + } + return -1; +} + +/// Disconnect from Zotero. Returns 0 on success. +pub export fn zotero_mcp_disconnect(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + + sessions[idx] = SessionSlot{}; + return 0; +} + +/// Get current state of a session. Returns state int or -1 if invalid. +pub export fn zotero_mcp_session_state(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + return @intFromEnum(slot.state); +} + +/// Signal rate limiting on a session. Returns 0 on success. +pub export fn zotero_mcp_throttle(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + if (!isValidTransition(slot.state, .rate_limited)) return -2; + + sessions[idx].state = .rate_limited; + return 0; +} + +/// Clear rate limiting. Returns 0 on success. +pub export fn zotero_mcp_unthrottle(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + if (!isValidTransition(slot.state, .connected)) return -2; + + sessions[idx].state = .connected; + return 0; +} + +/// Signal an error on a session. Returns 0 on success. +pub export fn zotero_mcp_signal_error(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + if (!isValidTransition(slot.state, .err)) return -2; + + sessions[idx].state = .err; + return 0; +} + +// --------------------------------------------------------------------------- +// C-ABI exports β€” action recording and metrics +// --------------------------------------------------------------------------- + +/// Record an API call on a session. Returns 0 on success. +pub export fn zotero_mcp_record_call(slot_idx: c_int, action: c_int) c_int { + const act = std.meta.intToEnum(ZoteroAction, action) catch return -3; + + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + if (slot.state != .connected) return -2; + + sessions[idx].api_call_count += 1; + sessions[idx].last_action = action; + + switch (act) { + .search_items => sessions[idx].search_count += 1, + .get_item, .get_attachments, .get_notes, .export_citation => sessions[idx].item_reads += 1, + .list_collections, .get_collection_items, .get_group_libraries => sessions[idx].collection_queries += 1, + .list_tags, .get_items_by_tag => sessions[idx].tag_queries += 1, + else => {}, + } + + return 0; +} + +/// Get API call count for a session. +pub export fn zotero_mcp_call_count(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + return @intCast(slot.api_call_count); +} + +/// Get search query count. +pub export fn zotero_mcp_search_count(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + return @intCast(slot.search_count); +} + +/// Get item read count. +pub export fn zotero_mcp_item_read_count(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + return @intCast(slot.item_reads); +} + +/// Get collection query count. +pub export fn zotero_mcp_collection_query_count(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + return @intCast(slot.collection_queries); +} + +/// Get tag query count. +pub export fn zotero_mcp_tag_query_count(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const idx: usize = std.math.cast(usize, slot_idx) orelse return -1; + if (idx >= MAX_SESSIONS) return -1; + const slot = &sessions[idx]; + if (!slot.active) return -1; + return @intCast(slot.tag_queries); +} + +/// Get total action count. Always returns 12. +pub export fn zotero_mcp_action_count() c_int { + return 12; +} + +/// Reset all sessions (test/debug use only). +pub export fn zotero_mcp_reset() void { + mutex.lock(); + defer mutex.unlock(); + sessions = .{SessionSlot{}} ** MAX_SESSIONS; +} + +// --------------------------------------------------------------------------- +// ═══════════════════════════════════════════════════════════════════════ +// Standard ABI (ADR-0005 four symbols + ADR-0006 invoke) +// ═══════════════════════════════════════════════════════════════════════ + +const shim = @import("cartridge_shim.zig"); + +const CARTRIDGE_NAME_PTR: [*:0]const u8 = "zotero-mcp"; +const CARTRIDGE_VERSION_PTR: [*:0]const u8 = "0.1.0"; + +export fn boj_cartridge_init() callconv(.c) c_int { + return 0; +} + +export fn boj_cartridge_deinit() callconv(.c) void {} + +export fn boj_cartridge_name() callconv(.c) [*:0]const u8 { + return CARTRIDGE_NAME_PTR; +} + +export fn boj_cartridge_version() callconv(.c) [*:0]const u8 { + return CARTRIDGE_VERSION_PTR; +} + +/// Dispatch the cartridge.json MCP tools. Grade D Alpha stubs. +export fn boj_cartridge_invoke( + tool_name: [*c]const u8, + json_args: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) callconv(.c) i32 { + _ = json_args; + if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; + + const body: []const u8 = if (shim.toolIs(tool_name, "zotero_search_items")) + "{\"result\":{\"matches\":[],\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "zotero_get_item")) + "{\"result\":{\"metadata\":{},\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "zotero_list_collections")) + "{\"result\":{\"items\":[],\"count\":0,\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "zotero_get_collection_items")) + "{\"result\":{\"metadata\":{},\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "zotero_list_tags")) + "{\"result\":{\"items\":[],\"count\":0,\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "zotero_get_items_by_tag")) + "{\"result\":{\"metadata\":{},\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "zotero_get_attachments")) + "{\"result\":{\"metadata\":{},\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "zotero_export_citation")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "zotero_get_notes")) + "{\"result\":{\"metadata\":{},\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "zotero_list_saved_searches")) + "{\"result\":{\"items\":[],\"count\":0,\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "zotero_get_group_libraries")) + "{\"result\":{\"metadata\":{},\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "zotero_generate_bibliography")) + "{\"result\":{\"status\":\"stub\"}}" +else + return shim.RC_UNKNOWN_TOOL; + + return shim.writeResult(out_buf, in_out_len, body); +} + +// Tests +// --------------------------------------------------------------------------- + +test "connected session lifecycle" { + zotero_mcp_reset(); + + const slot = zotero_mcp_connect(0); + try std.testing.expect(slot >= 0); + try std.testing.expectEqual(@as(c_int, 1), zotero_mcp_session_state(slot)); + + try std.testing.expectEqual(@as(c_int, 0), zotero_mcp_record_call(slot, 0)); + try std.testing.expectEqual(@as(c_int, 1), zotero_mcp_call_count(slot)); + try std.testing.expectEqual(@as(c_int, 1), zotero_mcp_search_count(slot)); + + try std.testing.expectEqual(@as(c_int, 0), zotero_mcp_disconnect(slot)); +} + +test "requires connected state for actions" { + zotero_mcp_reset(); + + const slot = zotero_mcp_connect(0); + try std.testing.expect(slot >= 0); + + try std.testing.expectEqual(@as(c_int, 0), zotero_mcp_throttle(slot)); + try std.testing.expectEqual(@as(c_int, -2), zotero_mcp_record_call(slot, 0)); + + try std.testing.expectEqual(@as(c_int, 0), zotero_mcp_unthrottle(slot)); + try std.testing.expectEqual(@as(c_int, 0), zotero_mcp_record_call(slot, 0)); +} + +test "error and recovery" { + zotero_mcp_reset(); + + const slot = zotero_mcp_connect(0); + try std.testing.expect(slot >= 0); + + try std.testing.expectEqual(@as(c_int, 0), zotero_mcp_signal_error(slot)); + try std.testing.expectEqual(@as(c_int, 3), zotero_mcp_session_state(slot)); + try std.testing.expectEqual(@as(c_int, -2), zotero_mcp_record_call(slot, 0)); + + try std.testing.expectEqual(@as(c_int, 0), zotero_mcp_disconnect(slot)); +} + +test "category counting" { + zotero_mcp_reset(); + + const slot = zotero_mcp_connect(0); + try std.testing.expect(slot >= 0); + + try std.testing.expectEqual(@as(c_int, 0), zotero_mcp_record_call(slot, 0)); // SearchItems + try std.testing.expectEqual(@as(c_int, 0), zotero_mcp_record_call(slot, 1)); // GetItem + try std.testing.expectEqual(@as(c_int, 0), zotero_mcp_record_call(slot, 2)); // ListCollections + try std.testing.expectEqual(@as(c_int, 0), zotero_mcp_record_call(slot, 4)); // ListTags + + try std.testing.expectEqual(@as(c_int, 4), zotero_mcp_call_count(slot)); + try std.testing.expectEqual(@as(c_int, 1), zotero_mcp_search_count(slot)); + try std.testing.expectEqual(@as(c_int, 1), zotero_mcp_item_read_count(slot)); + try std.testing.expectEqual(@as(c_int, 1), zotero_mcp_collection_query_count(slot)); + try std.testing.expectEqual(@as(c_int, 1), zotero_mcp_tag_query_count(slot)); +} + +test "transition validator" { + try std.testing.expectEqual(@as(c_int, 1), zotero_mcp_can_transition(0, 1)); + try std.testing.expectEqual(@as(c_int, 1), zotero_mcp_can_transition(1, 0)); + try std.testing.expectEqual(@as(c_int, 1), zotero_mcp_can_transition(1, 2)); + try std.testing.expectEqual(@as(c_int, 1), zotero_mcp_can_transition(2, 1)); + try std.testing.expectEqual(@as(c_int, 1), zotero_mcp_can_transition(1, 3)); + try std.testing.expectEqual(@as(c_int, 1), zotero_mcp_can_transition(3, 0)); + try std.testing.expectEqual(@as(c_int, 0), zotero_mcp_can_transition(2, 3)); + try std.testing.expectEqual(@as(c_int, 0), zotero_mcp_can_transition(0, 2)); +} + +test "slot exhaustion" { + zotero_mcp_reset(); + + var slots: [MAX_SESSIONS]c_int = undefined; + for (&slots) |*s| { + s.* = zotero_mcp_connect(0); + try std.testing.expect(s.* >= 0); + } + + try std.testing.expectEqual(@as(c_int, -1), zotero_mcp_connect(0)); + + try std.testing.expectEqual(@as(c_int, 0), zotero_mcp_disconnect(slots[0])); + const new_slot = zotero_mcp_connect(0); + try std.testing.expect(new_slot >= 0); +} + +// ═══════════════════════════════════════════════════════════════════════ +// ADR-0006 invoke dispatch tests +// ═══════════════════════════════════════════════════════════════════════ + +test "boj_cartridge_name returns zotero-mcp" { + const n = std.mem.span(boj_cartridge_name()); + try std.testing.expectEqualStrings("zotero-mcp", n); +} + +test "boj_cartridge_init returns 0" { + try std.testing.expectEqual(@as(c_int, 0), boj_cartridge_init()); +} + +test "invoke: each declared tool succeeds" { + var buf: [256]u8 = undefined; + const tools = [_][]const u8{ + "zotero_search_items", + "zotero_get_item", + "zotero_list_collections", + "zotero_get_collection_items", + "zotero_list_tags", + "zotero_get_items_by_tag", + "zotero_get_attachments", + "zotero_export_citation", + "zotero_get_notes", + "zotero_list_saved_searches", + "zotero_get_group_libraries", + "zotero_generate_bibliography", + }; + for (tools) |t| { + var len: usize = buf.len; + const rc = boj_cartridge_invoke(t.ptr, "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, 0), rc); + try std.testing.expect(std.mem.indexOf(u8, buf[0..len], "result") != null); + } +} + +test "invoke: unknown tool returns -1" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("nope", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -1), rc); +} + +test "invoke: buffer too small returns -3" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("zotero_search_items", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -3), rc); + try std.testing.expect(len > 4); +} diff --git a/cartridges/domains/research/zotero-mcp/minter.toml b/cartridges/domains/research/zotero-mcp/minter.toml new file mode 100644 index 0000000..a4793af --- /dev/null +++ b/cartridges/domains/research/zotero-mcp/minter.toml @@ -0,0 +1,23 @@ +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> + +name = "zotero-mcp" +description = "Zotero reference manager cartridge β€” library search, item retrieval, collection browsing, tag management, attachment access, citation export, note extraction, saved searches, group libraries, bibliography generation" +version = "0.2.0" +domain = "Research" +protocols = [ + "MCP", + "REST", +] +tier = "Ayo" +backend = "universal" +generate_panel = true + +[auth] +method = "bearer_token" +credential_source = "vault-mcp" +fields = ["zotero_api_key"] + +[api] +base_url = "https://api.zotero.org" +local_only = false diff --git a/cartridges/domains/research/zotero-mcp/mod.js b/cartridges/domains/research/zotero-mcp/mod.js new file mode 100644 index 0000000..2666148 --- /dev/null +++ b/cartridges/domains/research/zotero-mcp/mod.js @@ -0,0 +1,260 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// zotero-mcp/mod.js -- Zotero reference manager cartridge implementation. +// +// Provides MCP tool handlers for the Zotero Web API v3: +// - Library search (full-text across titles, authors, tags) +// - Item metadata retrieval by key +// - Collection listing and browsing +// - Collection item retrieval +// - Tag listing with counts +// - Tag-based item filtering +// - Attachment metadata access +// - Citation export (BibTeX, RIS, CSL JSON) +// - Child note extraction +// - Saved search listing +// - Group library access +// - Bibliography generation +// +// Auth: Bearer token via ZOTERO_API_KEY (required). +// API docs: https://www.zotero.org/support/dev/web_api/v3/start +// Note: User ID is derived from the API key via /keys/current endpoint. +// +// Usage: import { handleTool } from "./mod.js"; +// or: deno run --allow-net --allow-env mod.js + +const API_BASE = "https://api.zotero.org"; + +// --------------------------------------------------------------------------- +// Auth helper β€” retrieves the Zotero API key from environment. +// In production, vault-mcp provides zero-knowledge credential proxying. +// --------------------------------------------------------------------------- + +function getToken() { + const token = typeof Deno !== "undefined" + ? Deno.env.get("ZOTERO_API_KEY") + : process.env.ZOTERO_API_KEY; + return token || null; +} + +// --------------------------------------------------------------------------- +// User ID cache β€” Zotero API requires /users/{userID}/ prefix. +// Resolved once via /keys/current then cached for session. +// --------------------------------------------------------------------------- + +let cachedUserId = null; + +async function getUserId(token) { + if (cachedUserId) return cachedUserId; + const response = await fetch(`${API_BASE}/keys/current`, { + headers: { + "Zotero-API-Key": token, + "Zotero-API-Version": "3", + }, + }); + if (!response.ok) return null; + const data = await response.json(); + cachedUserId = data.userID; + return cachedUserId; +} + +// --------------------------------------------------------------------------- +// HTTP request helper β€” wraps fetch with Zotero API headers, +// API key auth, version header, and error normalization. +// --------------------------------------------------------------------------- + +async function zoteroFetch(path, queryParams, acceptFormat) { + const token = getToken(); + if (!token) { + return { status: 401, error: "ZOTERO_API_KEY not set." }; + } + + const userId = await getUserId(token); + if (!userId) { + return { status: 401, error: "Could not resolve Zotero user ID from API key." }; + } + + const fullPath = path.startsWith("/users/") || path.startsWith("/groups/") + ? path + : `/users/${userId}${path}`; + + const url = new URL(`${API_BASE}${fullPath}`); + + if (queryParams) { + for (const [key, value] of Object.entries(queryParams)) { + if (value !== undefined && value !== null && value !== "") { + url.searchParams.set(key, String(value)); + } + } + } + + const headers = { + "Zotero-API-Key": token, + "Zotero-API-Version": "3", + "User-Agent": "boj-server/zotero-mcp/0.2.0", + }; + + if (acceptFormat) { + url.searchParams.set("format", acceptFormat); + } + + const response = await fetch(url.toString(), { method: "GET", headers }); + + if (response.status === 429) { + const retryAfter = response.headers.get("retry-after"); + return { + status: 429, + error: `Rate limited. Retry after ${retryAfter || "unknown"} seconds.`, + retryAfter, + }; + } + + if (acceptFormat && acceptFormat !== "json") { + const text = await response.text().catch(() => ""); + if (!response.ok) { + return { status: response.status, error: `HTTP ${response.status}`, data: text }; + } + return { status: response.status, data: text }; + } + + const data = await response.json().catch(() => ({})); + + if (!response.ok) { + const errorMessage = data.message || data.error || `HTTP ${response.status}`; + return { status: response.status, error: errorMessage, data }; + } + + return { status: response.status, data }; +} + +// --------------------------------------------------------------------------- +// Tool handler dispatch β€” maps MCP tool names to Zotero API operations. +// --------------------------------------------------------------------------- + +export async function handleTool(toolName, args) { + switch (toolName) { + + // --- Search --- + + case "zotero_search_items": { + if (!args.query) return { error: "Missing required field: query" }; + return zoteroFetch("/items", { + q: args.query, + itemType: args.item_type, + limit: args.limit, + sort: args.sort, + }); + } + + // --- Item metadata --- + + case "zotero_get_item": { + if (!args.item_key) return { error: "Missing required field: item_key" }; + return zoteroFetch(`/items/${encodeURIComponent(args.item_key)}`); + } + + // --- Collections --- + + case "zotero_list_collections": { + const path = args.parent_key + ? `/collections/${encodeURIComponent(args.parent_key)}/collections` + : "/collections"; + return zoteroFetch(path); + } + + case "zotero_get_collection_items": { + if (!args.collection_key) return { error: "Missing required field: collection_key" }; + return zoteroFetch(`/collections/${encodeURIComponent(args.collection_key)}/items`, { + limit: args.limit, + sort: args.sort, + }); + } + + // --- Tags --- + + case "zotero_list_tags": { + return zoteroFetch("/tags", { limit: args.limit }); + } + + case "zotero_get_items_by_tag": { + if (!args.tag) return { error: "Missing required field: tag" }; + return zoteroFetch("/items", { + tag: args.tag, + limit: args.limit, + }); + } + + // --- Attachments --- + + case "zotero_get_attachments": { + if (!args.item_key) return { error: "Missing required field: item_key" }; + return zoteroFetch(`/items/${encodeURIComponent(args.item_key)}/children`, { + itemType: "attachment", + }); + } + + // --- Citation export --- + + case "zotero_export_citation": { + if (!args.item_key) return { error: "Missing required field: item_key" }; + const format = args.format || "bibtex"; + return zoteroFetch(`/items/${encodeURIComponent(args.item_key)}`, null, format); + } + + // --- Notes --- + + case "zotero_get_notes": { + if (!args.item_key) return { error: "Missing required field: item_key" }; + return zoteroFetch(`/items/${encodeURIComponent(args.item_key)}/children`, { + itemType: "note", + }); + } + + // --- Saved searches --- + + case "zotero_list_saved_searches": { + return zoteroFetch("/searches"); + } + + // --- Group libraries --- + + case "zotero_get_group_libraries": { + const token = getToken(); + if (!token) return { status: 401, error: "ZOTERO_API_KEY not set." }; + const userId = await getUserId(token); + if (!userId) return { status: 401, error: "Could not resolve user ID." }; + return zoteroFetch(`/users/${userId}/groups`); + } + + // --- Bibliography --- + + case "zotero_generate_bibliography": { + if (!args.item_keys) return { error: "Missing required field: item_keys" }; + const style = args.style || "apa"; + const keys = args.item_keys.split(",").map((k) => k.trim()); + return zoteroFetch("/items", { + itemKey: keys.join(","), + format: "bib", + style, + }); + } + + default: + return { error: `Unknown zotero-mcp tool: ${toolName}` }; + } +} + +// --------------------------------------------------------------------------- +// Cartridge metadata export β€” used by the BoJ cartridge loader to register +// this cartridge's tools without reading cartridge.json separately. +// --------------------------------------------------------------------------- + +export const metadata = { + name: "zotero-mcp", + version: "0.2.0", + domain: "Research", + tier: "Ayo", + protocols: ["MCP", "REST"], + toolCount: 12, +}; diff --git a/cartridges/domains/research/zotero-mcp/panels/manifest.json b/cartridges/domains/research/zotero-mcp/panels/manifest.json new file mode 100644 index 0000000..6f35980 --- /dev/null +++ b/cartridges/domains/research/zotero-mcp/panels/manifest.json @@ -0,0 +1,90 @@ +{ + "$schema": "https://panll.dev/schemas/panel-manifest/v1.json", + "spdx": "MPL-2.0", + "copyright": "Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath)", + "cartridge": "zotero-mcp", + "domain": "Research", + "version": "0.2.0", + "panels": [ + { + "id": "zotero-connection-status", + "title": "Connection Status", + "description": "Zotero session state (Disconnected / Connected / RateLimited / Error)", + "type": "status-indicator", + "data_source": { + "endpoint": "/zotero/state", + "method": "GET", + "refresh_interval_ms": 5000 + }, + "widgets": [ + { + "type": "state-badge", + "field": "state", + "states": { + "disconnected": { "color": "#95a5a6", "icon": "wifi-off" }, + "connected": { "color": "#2ecc71", "icon": "wifi" }, + "rate_limited": { "color": "#f39c12", "icon": "clock" }, + "error": { "color": "#e74c3c", "icon": "alert-triangle" } + } + } + ] + }, + { + "id": "zotero-search-count", + "title": "Library Searches", + "description": "Number of library search queries executed in the current session", + "type": "metric", + "data_source": { + "endpoint": "/zotero/status", + "method": "GET", + "refresh_interval_ms": 10000 + }, + "widgets": [ + { + "type": "counter", + "field": "search_count", + "label": "Searches", + "icon": "search" + } + ] + }, + { + "id": "zotero-item-reads", + "title": "Item Reads", + "description": "Number of item metadata reads in the current session", + "type": "metric", + "data_source": { + "endpoint": "/zotero/status", + "method": "GET", + "refresh_interval_ms": 10000 + }, + "widgets": [ + { + "type": "counter", + "field": "item_reads", + "label": "Item Reads", + "icon": "book-open" + } + ] + }, + { + "id": "zotero-collection-queries", + "title": "Collection Queries", + "description": "Number of collection browsing queries in the current session", + "type": "metric", + "data_source": { + "endpoint": "/zotero/status", + "method": "GET", + "refresh_interval_ms": 10000 + }, + "widgets": [ + { + "type": "counter", + "field": "collection_queries", + "label": "Collections", + "icon": "folder" + } + ] + } + ] +} diff --git a/cartridges/domains/research/zotero-mcp/tests/integration_test.sh b/cartridges/domains/research/zotero-mcp/tests/integration_test.sh new file mode 100755 index 0000000..8eff827 --- /dev/null +++ b/cartridges/domains/research/zotero-mcp/tests/integration_test.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Integration tests for zotero-mcp cartridge. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CART_DIR="$(dirname "$SCRIPT_DIR")" +FFI_DIR="$CART_DIR/ffi" + +echo "=== zotero-mcp integration tests ===" + +# Build FFI +echo "[1/3] Building FFI..." +cd "$FFI_DIR" && zig build 2>&1 + +# Run Zig unit tests +echo "[2/3] Running FFI unit tests..." +cd "$FFI_DIR" && zig build test 2>&1 + +# Validate Idris2 ABI (if idris2 available) +echo "[3/3] Checking ABI..." +if command -v idris2 &>/dev/null; then + cd "$CART_DIR/abi" && idris2 --check ZoteroMcp.SafeRegistry 2>&1 + echo " ABI: OK" +else + echo " ABI: SKIPPED (idris2 not in PATH)" +fi + +echo "" +echo "All tests passed for zotero-mcp!" diff --git a/cartridges/domains/security/dns-shield-mcp/README.adoc b/cartridges/domains/security/dns-shield-mcp/README.adoc new file mode 100644 index 0000000..d7c66cc --- /dev/null +++ b/cartridges/domains/security/dns-shield-mcp/README.adoc @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += dns-shield-mcp +Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk> +:spdx: MPL-2.0 +:tier: Ayo +:domain: security +:protocols: MCP, REST + +== Overview + +DNS security shield β€” DoQ, DoH, DNSSEC, CAA + +== Tools (5) + +[cols="2,4"] +|=== +| Tool | Description + +| `dns_resolve_doq` | Resolve domain via DNS-over-QUIC (encrypted) +| `dns_resolve_doh` | Resolve domain via DNS-over-HTTPS (encrypted) +| `dns_check_caa` | Check CAA records for CA authorization +| `dns_validate_dnssec` | Validate DNSSEC chain for a domain +| `dns_flush_cache` | Flush DNS resolver cache +|=== + +== Architecture + +Three-layer stack per the BoJ cartridge standard: + +[cols="1,1,3"] +|=== +| Layer | Language | Purpose + +| ABI | Idris2 | Formally verified state machine with dependent-type proofs +| FFI | Zig | C-ABI implementation: session pool, action metrics, thread safety +| Adapter| Zig | Adapter bridge to the BoJ unified adapter protocol (zig predecessors sidelined) +|=== + +== Building + +[source,bash] +---- +# ABI (Idris2) +cd abi && idris2 --check *.idr + +# FFI (Zig) +cd ffi && zig build +cd ffi && zig build test + +# Adapter (Zig) +cd adapter && zig build +---- + +== Status + +Alpha (CRG D). Cartridge metadata declared in `cartridge.json`; 5 tool(s) exposed. +Not yet dogfooded externally. See `docs/READINESS.md` at repo root for the +project-wide grade table and `docs/practice/DOGFOOD-LOG.adoc` for usage evidence. diff --git a/cartridges/domains/security/dns-shield-mcp/abi/DnsShieldMcp/SafeDns.idr b/cartridges/domains/security/dns-shield-mcp/abi/DnsShieldMcp/SafeDns.idr new file mode 100644 index 0000000..0c55289 --- /dev/null +++ b/cartridges/domains/security/dns-shield-mcp/abi/DnsShieldMcp/SafeDns.idr @@ -0,0 +1,103 @@ +-- SPDX-License-Identifier: MPL-2.0 +-- Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +||| DnsShieldMcp.SafeDns: Formally verified DNS security operations. +||| +||| Cartridge: dns-shield-mcp (v0.5 Shield) +||| Supports: DNS-over-QUIC (DoQ, RFC 9250), DNS-over-HTTPS (DoH, RFC 8484), +||| Oblivious DNS (oDNS), DNSSEC validation, CAA record enforcement. +||| +||| Safety guarantees: +||| - DNS queries MUST use encrypted transport (DoQ or DoH) +||| - Plaintext DNS (port 53 UDP/TCP) is rejected at the type level +||| - DNSSEC signatures are validated before trust +||| - CAA records are checked before certificate issuance +module DnsShieldMcp.SafeDns + +import Data.List + +%default total + +-- ═══════════════════════════════════════════════════════════════════════════ +-- DNS Transport Safety +-- ═══════════════════════════════════════════════════════════════════════════ + +||| Encrypted DNS transport protocols. +||| Plaintext DNS is NOT representable β€” by construction. +public export +data DnsTransport = DoQ | DoH | ODoH + +||| DNS record types we validate. +public export +data RecordType = A | AAAA | CNAME | MX | TXT | CAA | DNSKEY | RRSIG | DS | NSEC | NSEC3 + +||| DNSSEC validation state β€” a record is either validated or untrusted. +public export +data DnssecState = Validated | Untrusted | Insecure | Bogus + +||| A DNS query that is guaranteed to use encrypted transport. +||| There is no constructor for plaintext DNS. +public export +record SafeQuery where + constructor MkSafeQuery + domain : String + recordType : RecordType + transport : DnsTransport + +||| A DNSSEC-validated response. +public export +record ValidatedResponse where + constructor MkValidated + query : SafeQuery + answer : String + dnssecState : DnssecState + ttl : Nat + +-- ═══════════════════════════════════════════════════════════════════════════ +-- CAA Record Enforcement +-- ═══════════════════════════════════════════════════════════════════════════ + +||| CAA (Certificate Authority Authorization) record. +public export +record CaaRecord where + constructor MkCaa + flags : Nat + tag : String -- "issue", "issuewild", "iodef" + value : String -- CA domain or reporting URL + +||| Proof that a CA is authorized by CAA records. +||| If no CAA records exist, any CA is authorized (RFC 8659). +public export +data CaaAuthorized : String -> List CaaRecord -> Type where + NoCaaRecords : CaaAuthorized ca [] + CaaMatch : (ca : String) -> (rec : CaaRecord) -> + rec.tag = "issue" -> rec.value = ca -> + CaaAuthorized ca (rec :: rest) + CaaWild : (ca : String) -> (rec : CaaRecord) -> + rec.tag = "issuewild" -> rec.value = ca -> + CaaAuthorized ca (rec :: rest) + +-- ═══════════════════════════════════════════════════════════════════════════ +-- Transport Safety Proof +-- ═══════════════════════════════════════════════════════════════════════════ + +||| Every SafeQuery uses encrypted transport β€” by construction. +||| This is a trivially true proof because SafeQuery's transport field +||| can only be DoQ, DoH, or ODoH. Plaintext is not representable. +public export +queryIsEncrypted : (q : SafeQuery) -> Either (q.transport = DoQ) (Either (q.transport = DoH) (q.transport = ODoH)) +queryIsEncrypted (MkSafeQuery _ _ DoQ) = Left Refl +queryIsEncrypted (MkSafeQuery _ _ DoH) = Right (Left Refl) +queryIsEncrypted (MkSafeQuery _ _ ODoH) = Right (Right Refl) + +-- ═══════════════════════════════════════════════════════════════════════════ +-- FFI Interface Declarations +-- ═══════════════════════════════════════════════════════════════════════════ + +||| FFI operations exported to Zig. Each returns a result code (0 = ok). +public export +interface DnsShieldFFI where + resolveDoQ : String -> RecordType -> IO (Either String ValidatedResponse) + resolveDoH : String -> RecordType -> IO (Either String ValidatedResponse) + validateDnssec : ValidatedResponse -> IO DnssecState + checkCaa : String -> String -> IO (Either String Bool) + flushCache : IO () diff --git a/cartridges/domains/security/dns-shield-mcp/abi/README.adoc b/cartridges/domains/security/dns-shield-mcp/abi/README.adoc new file mode 100644 index 0000000..6da7844 --- /dev/null +++ b/cartridges/domains/security/dns-shield-mcp/abi/README.adoc @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += dns-shield-mcp / abi β€” Idris2 ABI layer +:orientation: shallow + +== Purpose + +Cartridge-local protocol types for `dns-shield-mcp`. Defines the data, tool, and +transition vocabulary this cartridge speaks. Inherits safety lemmas and the +matrix-cell identifier scheme from the trunk ABI at `src/abi/`. + +== Files + +1 Idris2 module(s), ~103 lines total. Lead module: +`SafeDns.idr`. + +No `.ipkg` β€” consumed directly by wider BoJ callers; type-checks inside the +trunk `boj.ipkg`. + +== Invariants + +* `%default total` at every module top. +* Zero `believe_me`, `assert_total`, `postulate`, `Admitted`, or + `sorry` (the trunk holds the four declared `believe_me` sites; leaf + cartridges carry none). + +== Test/proof surface + +Thin module(s), no inline tests. Proof surface = type-check success under the +trunk `idris2 --check`. + +== Read-first + +. `SafeDns.idr` β€” cartridge-local data records and enums. +. Trunk `src/abi/Boj/Catalogue.idr` for how this cell participates in the + 2D matrix. diff --git a/cartridges/domains/security/dns-shield-mcp/adapter/README.adoc b/cartridges/domains/security/dns-shield-mcp/adapter/README.adoc new file mode 100644 index 0000000..0445e08 --- /dev/null +++ b/cartridges/domains/security/dns-shield-mcp/adapter/README.adoc @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += dns-shield-mcp / adapter β€” protocol bridge +:orientation: shallow + +== Purpose + +Exposes this cartridge's FFI over the wire protocols declared in +`cartridge.json`. Dispatches tool invocations to the C-ABI exports at +`../ffi/` and formats responses per protocol. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” depends on the sibling `ffi` target. +| `dns_shield_adapter.zig` | Protocol dispatch (134 lines). Tool catalogue matches `cartridge.json`. +| `SIDELINED-*.v.adoc` | zig predecessor(s). Not built β€” zig banned 2026-04-10. +|=== + +== Invariants + +* **Stateless** β€” all state lives behind the FFI, never in the adapter. +* **Response passthrough** β€” whatever the FFI returns goes back to the wire + unmodified (no embellishment, no silent recovery). +* **Cartridge.json is source of truth** for the tool catalogue; drift between + adapter and manifest is a CI failure. + +== Test/proof surface + +No inline tests at the adapter layer β€” protocol routing is exercised by the +trunk seam tests at `ffi/zig/src/seams.zig`. + +== Read-first + +. `dns_shield_adapter.zig` β€” dispatch function and per-protocol routers. +. `../cartridge.json` β€” the tool manifest the dispatcher mirrors. diff --git a/cartridges/domains/security/dns-shield-mcp/adapter/build.zig b/cartridges/domains/security/dns-shield-mcp/adapter/build.zig new file mode 100644 index 0000000..009eb85 --- /dev/null +++ b/cartridges/domains/security/dns-shield-mcp/adapter/build.zig @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// dns-shield-mcp/adapter/build.zig + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const ffi_mod = b.createModule(.{ + .root_source_file = b.path("../ffi/dns_shield_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + const adapter = b.addExecutable(.{ + .name = "dns_shield_adapter", + .root_source_file = b.path("dns_shield_adapter.zig"), + .target = target, + .optimize = optimize, + }); + adapter.root_module.addImport("dns_shield_ffi", ffi_mod); + b.installArtifact(adapter); +} diff --git a/cartridges/domains/security/dns-shield-mcp/adapter/dns_shield_adapter.zig b/cartridges/domains/security/dns-shield-mcp/adapter/dns_shield_adapter.zig new file mode 100644 index 0000000..9c8a997 --- /dev/null +++ b/cartridges/domains/security/dns-shield-mcp/adapter/dns_shield_adapter.zig @@ -0,0 +1,134 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// dns-shield-mcp/adapter/dns_shield_adapter.zig +// +// Three-protocol BoJ adapter: REST (port 9226), gRPC-compat (port 9227), +// GraphQL (port 9228). +// Replaces the banned zig adapter (dns_shield_adapter.v). + +const std = @import("std"); +const ffi = @import("dns_shield_ffi"); + +const REST_PORT: u16 = 9226; +const GRPC_PORT: u16 = 9227; +const GQL_PORT: u16 = 9228; + +const Response = struct { status: u16, body: []const u8 }; + +fn okJson(buf: []u8, msg: []const u8) []u8 { + return std.fmt.bufPrint(buf, "{{\"success\":true,\"message\":\"{s}\"}}", .{msg}) catch buf[0..0]; +} + +fn errJson(buf: []u8, msg: []const u8) []u8 { + return std.fmt.bufPrint(buf, "{{\"success\":false,\"error\":\"{s}\"}}", .{msg}) catch buf[0..0]; +} + +fn dispatch(tool: []const u8, _body: []const u8, resp: []u8) Response { + _ = _body; + if (std.mem.eql(u8, tool, "dns_resolve_doq")) { + return .{ .status = 200, .body = okJson(resp, "dns_resolve_doq forwarded") }; + } + if (std.mem.eql(u8, tool, "dns_resolve_doh")) { + return .{ .status = 200, .body = okJson(resp, "dns_resolve_doh forwarded") }; + } + if (std.mem.eql(u8, tool, "dns_check_caa")) { + return .{ .status = 200, .body = okJson(resp, "dns_check_caa forwarded") }; + } + if (std.mem.eql(u8, tool, "dns_validate_dnssec")) { + return .{ .status = 200, .body = okJson(resp, "dns_validate_dnssec forwarded") }; + } + if (std.mem.eql(u8, tool, "dns_flush_cache")) { + return .{ .status = 200, .body = okJson(resp, "dns_flush_cache forwarded") }; + } + return .{ .status = 404, .body = errJson(resp, "unknown tool") }; +} + +fn dispatchRest(path: []const u8, body: []const u8, resp: []u8) Response { + // Expect /tools/<tool_name> + const prefix = "/tools/"; + if (std.mem.startsWith(u8, path, prefix)) { + const tool = path[prefix.len..]; + return dispatch(tool, body, resp); + } + return .{ .status = 404, .body = errJson(resp, "not found") }; +} + +fn dispatchGrpc(path: []const u8, body: []const u8, resp: []u8) Response { + // Expect /<Service>/<Method> β€” derive tool from Method + var it = std.mem.splitScalar(u8, path, '/'); + _ = it.next(); // leading empty + _ = it.next(); // service + const method = it.next() orelse return .{ .status = 404, .body = errJson(resp, "bad gRPC path") }; + return dispatch(method, body, resp); +} + +fn dispatchGraphql(body: []const u8, resp: []u8) Response { + if (std.mem.indexOf(u8, body, "dns_resolve_doq") != null) + return dispatch("dns_resolve_doq", body, resp); + if (std.mem.indexOf(u8, body, "dns_resolve_doh") != null) + return dispatch("dns_resolve_doh", body, resp); + if (std.mem.indexOf(u8, body, "dns_check_caa") != null) + return dispatch("dns_check_caa", body, resp); + if (std.mem.indexOf(u8, body, "dns_validate_dnssec") != null) + return dispatch("dns_validate_dnssec", body, resp); + if (std.mem.indexOf(u8, body, "dns_flush_cache") != null) + return dispatch("dns_flush_cache", body, resp); + return .{ .status = 400, .body = errJson(resp, "unrecognised GraphQL operation") }; +} + +fn handleConnection(stream: std.net.Stream, port: u16) void { + defer stream.close(); + var buf: [4096]u8 = undefined; + var resp_buf: [4096]u8 = undefined; + const n = stream.read(&buf) catch return; + const req = buf[0..n]; + const result = switch (port) { + REST_PORT => blk: { + // Parse HTTP/1.1: first line = METHOD PATH HTTP/x.y + var lines = std.mem.splitScalar(u8, req, '\n'); + const first = lines.next() orelse break :blk Response{ .status = 400, .body = "" }; + var parts = std.mem.splitScalar(u8, std.mem.trim(u8, first, "\r"), ' '); + _ = parts.next(); // method + const path = parts.next() orelse break :blk Response{ .status = 400, .body = "" }; + break :blk dispatchRest(path, req, &resp_buf); + }, + GRPC_PORT => blk: { + var lines = std.mem.splitScalar(u8, req, '\n'); + const first = lines.next() orelse break :blk Response{ .status = 400, .body = "" }; + var parts = std.mem.splitScalar(u8, std.mem.trim(u8, first, "\r"), ' '); + _ = parts.next(); + const path = parts.next() orelse break :blk Response{ .status = 400, .body = "" }; + break :blk dispatchGrpc(path, req, &resp_buf); + }, + GQL_PORT => dispatchGraphql(req, &resp_buf), + else => Response{ .status = 500, .body = "" }, + }; + var http_resp: [512]u8 = undefined; + const http = std.fmt.bufPrint(&http_resp, + "HTTP/1.1 {d} OK\r\nContent-Length: {d}\r\nContent-Type: application/json\r\n\r\n", + .{ result.status, result.body.len }) catch return; + _ = stream.write(http) catch {}; + _ = stream.write(result.body) catch {}; +} + +fn listenLoop(port: u16) !void { + const addr = std.net.Address.initIp4(.{ 127, 0, 0, 1 }, port); + var server = try addr.listen(.{ .reuse_address = true }); + defer server.deinit(); + while (true) { + const conn = try server.accept(); + const t = try std.Thread.spawn(.{}, handleConnection, .{ conn.stream, port }); + t.detach(); + } +} + +pub fn main() !void { + ffi.dns_shield_init(); + const t1 = try std.Thread.spawn(.{}, listenLoop, .{REST_PORT}); + const t2 = try std.Thread.spawn(.{}, listenLoop, .{GRPC_PORT}); + const t3 = try std.Thread.spawn(.{}, listenLoop, .{GQL_PORT}); + t1.join(); + t2.join(); + t3.join(); +} diff --git a/cartridges/domains/security/dns-shield-mcp/cartridge.json b/cartridges/domains/security/dns-shield-mcp/cartridge.json new file mode 100644 index 0000000..f480692 --- /dev/null +++ b/cartridges/domains/security/dns-shield-mcp/cartridge.json @@ -0,0 +1,121 @@ +{ + "$schema": "https://boj.dev/schemas/cartridge/v1.json", + "spdx": "MPL-2.0", + "copyright": "Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk>", + "name": "dns-shield-mcp", + "version": "0.1.0", + "description": "DNS security shield β€” DoQ, DoH, DNSSEC, CAA", + "domain": "security", + "tier": "Ayo", + "protocols": [ + "MCP", + "REST" + ], + "auth": { + "method": "none", + "env_var": null, + "credential_source": null + }, + "api": { + "base_url": "local://dns-shield-mcp", + "content_type": "application/json" + }, + "tools": [ + { + "name": "dns_resolve_doq", + "description": "Resolve domain via DNS-over-QUIC (encrypted)", + "inputSchema": { + "type": "object", + "properties": { + "domain": { + "type": "string", + "description": "Domain name to resolve" + }, + "record_type": { + "type": "string", + "description": "Record type: A|AAAA|MX|TXT|CNAME" + } + }, + "required": [ + "domain" + ] + } + }, + { + "name": "dns_resolve_doh", + "description": "Resolve domain via DNS-over-HTTPS (encrypted)", + "inputSchema": { + "type": "object", + "properties": { + "domain": { + "type": "string", + "description": "Domain name to resolve" + }, + "record_type": { + "type": "string", + "description": "Record type: A|AAAA|MX|TXT|CNAME" + } + }, + "required": [ + "domain" + ] + } + }, + { + "name": "dns_check_caa", + "description": "Check CAA records for CA authorization", + "inputSchema": { + "type": "object", + "properties": { + "domain": { + "type": "string", + "description": "Domain name" + } + }, + "required": [ + "domain" + ] + } + }, + { + "name": "dns_validate_dnssec", + "description": "Validate DNSSEC chain for a domain", + "inputSchema": { + "type": "object", + "properties": { + "domain": { + "type": "string", + "description": "Domain name" + } + }, + "required": [ + "domain" + ] + } + }, + { + "name": "dns_flush_cache", + "description": "Flush DNS resolver cache", + "inputSchema": { + "type": "object", + "properties": { + "domain": { + "type": "string", + "description": "Domain to flush, or * for all" + } + } + } + } + ], + "ffi": { + "so_path": "ffi/zig-out/lib/libdns_shield_mcp.so", + "abi_version": "ADR-0006", + "symbols": [ + "boj_cartridge_init", + "boj_cartridge_deinit", + "boj_cartridge_name", + "boj_cartridge_version", + "boj_cartridge_invoke" + ] + } +} diff --git a/cartridges/domains/security/dns-shield-mcp/ffi/README.adoc b/cartridges/domains/security/dns-shield-mcp/ffi/README.adoc new file mode 100644 index 0000000..978524e --- /dev/null +++ b/cartridges/domains/security/dns-shield-mcp/ffi/README.adoc @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += dns-shield-mcp / ffi β€” Zig FFI layer +:orientation: shallow + +== Purpose + +C-ABI exports bridging this cartridge's Idris2 ABI to the runtime. Consumed by +the adapter for protocol dispatch and by the trunk loader for mount. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” produces `zig-out/lib/libdns-shield.so`. +| `dns_shield_ffi.zig` | C-ABI exports (6 exports, 4 inline tests, 160 lines). +|=== + +== Invariants + +* **Null-pointer rejection** on `[*c]const u8` arguments before any + dereference. +* **Negative return values** carry documented error meanings; zero is + success (matching the trunk error-code convention). +* **No allocator leaks** β€” every allocation path pairs with `defer free`. + +== Test/proof surface + +4 inline `test "..."` block(s) in `dns_shield_ffi.zig`. Run with +`cd ffi && zig build test`. + +== Read-first + +. `dns_shield_ffi.zig` β€” C-ABI signatures and guards. +. Trunk `ffi/zig/src/catalogue.zig` for how this `.so` is loaded. diff --git a/cartridges/domains/security/dns-shield-mcp/ffi/build.zig b/cartridges/domains/security/dns-shield-mcp/ffi/build.zig new file mode 100644 index 0000000..65ebdad --- /dev/null +++ b/cartridges/domains/security/dns-shield-mcp/ffi/build.zig @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + // Shared ADR-0006 invoke-shim module (relative path up to boj-server trunk). + + const shim_mod = b.addModule("cartridge_shim", .{ + + .root_source_file = b.path("../../../ffi/zig/src/cartridge_shim.zig"), + + .target = target, + + .optimize = optimize, + + }); + + const ffi_mod = b.addModule("dns_shield_mcp", .{ + .root_source_file = b.path("dns_shield_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + ffi_mod.addImport("cartridge_shim", shim_mod); + + const lib = b.addLibrary(.{ + .name = "dns_shield_mcp", + .root_module = ffi_mod, + .linkage = .dynamic, + }); + lib.linkLibC(); + b.installArtifact(lib); + + const tests = b.addTest(.{ .root_module = ffi_mod }); + tests.linkLibC(); + const run_tests = b.addRunArtifact(tests); + const test_step = b.step("test", "Run FFI tests"); + test_step.dependOn(&run_tests.step); +} + diff --git a/cartridges/domains/security/dns-shield-mcp/ffi/cartridge_shim.zig b/cartridges/domains/security/dns-shield-mcp/ffi/cartridge_shim.zig new file mode 100644 index 0000000..0e399db --- /dev/null +++ b/cartridges/domains/security/dns-shield-mcp/ffi/cartridge_shim.zig @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// cartridge_shim.zig β€” Shared helpers for the ADR-0006 five-symbol +// cartridge ABI (`boj_cartridge_init / deinit / name / version / invoke`). +// +// The shim centralises the seven-code return convention, NUL-argument +// guards, tool-name comparison, and the buffer-too-small path so each +// cartridge's `boj_cartridge_invoke` can stay short β€” typically a tool +// table plus `shim.writeResult(...)`. +// +// Cartridges import this file by relative path (no build-graph change +// needed). Example: +// +// const shim = @import("../../../ffi/zig/src/cartridge_shim.zig"); +// +// export fn boj_cartridge_invoke( +// tool_name: [*c]const u8, +// json_args: [*c]const u8, +// out_buf: [*c]u8, +// in_out_len: [*c]usize, +// ) callconv(.c) i32 { +// _ = json_args; +// if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; +// const body = if (shim.toolIs(tool_name, "foo")) "{\"result\":{}}" +// else return shim.RC_UNKNOWN_TOOL; +// return shim.writeResult(out_buf, in_out_len, body); +// } + +const std = @import("std"); + +// ── Return codes (ADR-0006 Β§Return codes) ──────────────────────────── +// +// Frozen by ADR-0006. New failure modes compose these via the error +// JSON body β€” the integer surface does not grow without a follow-up ADR. + +pub const RC_SUCCESS: i32 = 0; +pub const RC_UNKNOWN_TOOL: i32 = -1; +pub const RC_BAD_ARGS: i32 = -2; +pub const RC_BUFFER_TOO_SMALL: i32 = -3; +pub const RC_RUNTIME_ERROR: i32 = -4; +pub const RC_PANIC: i32 = -5; +pub const RC_AUTH_DENIED: i32 = -6; + +// ── Invoke-path helpers ────────────────────────────────────────────── + +/// True if any of the three mandatory `boj_cartridge_invoke` output-path +/// pointers is null. Use at the top of every invoke to short-circuit to +/// `RC_BAD_ARGS`. +pub fn invokeArgsNull( + tool_name: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) bool { + return tool_name == null or out_buf == null or in_out_len == null; +} + +/// Compare a C-NUL-terminated tool-name pointer against a Zig string +/// literal. Caller must have already verified `tool_name` is non-null +/// (usually via `invokeArgsNull`). +pub fn toolIs(tool_name: [*c]const u8, expected: []const u8) bool { + const s = std.mem.span(@as([*:0]const u8, @ptrCast(tool_name))); + return std.mem.eql(u8, s, expected); +} + +/// Copy `body` into `out_buf[0..*in_out_len]` (as a capacity) and update +/// `*in_out_len` to the number of bytes written. Returns `RC_SUCCESS`. +/// +/// If `body.len` exceeds the current capacity stored in `*in_out_len`, +/// sets `*in_out_len` to the required size and returns +/// `RC_BUFFER_TOO_SMALL` β€” the caller is then expected to re-allocate +/// and retry, per ADR-0006 Β§Memory ownership. +/// +/// Caller must have already verified that `out_buf` and `in_out_len` +/// are non-null. +pub fn writeResult( + out_buf: [*c]u8, + in_out_len: [*c]usize, + body: []const u8, +) i32 { + const cap = in_out_len.*; + if (body.len > cap) { + in_out_len.* = body.len; + return RC_BUFFER_TOO_SMALL; + } + @memcpy(out_buf[0..body.len], body); + in_out_len.* = body.len; + return RC_SUCCESS; +} + +// ── Tests ──────────────────────────────────────────────────────────── + +test "writeResult: body fits, writes and sets length" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); + try std.testing.expectEqualStrings("hello", buf[0..len]); +} + +test "writeResult: too small returns -3 and sets required length" { + var buf: [2]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_BUFFER_TOO_SMALL, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: exact-fit succeeds" { + var buf: [5]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: empty body" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, ""); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 0), len); +} + +test "toolIs: matches and rejects" { + const name: [*:0]const u8 = "foo"; + try std.testing.expect(toolIs(@ptrCast(name), "foo")); + try std.testing.expect(!toolIs(@ptrCast(name), "bar")); + try std.testing.expect(!toolIs(@ptrCast(name), "foobar")); + try std.testing.expect(!toolIs(@ptrCast(name), "fo")); +} + +test "invokeArgsNull: detects each null slot" { + var buf: [4]u8 = undefined; + var len: usize = 4; + const name: [*:0]const u8 = "x"; + try std.testing.expect(!invokeArgsNull(@ptrCast(name), &buf, &len)); + try std.testing.expect(invokeArgsNull(null, &buf, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), null, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), &buf, null)); +} diff --git a/cartridges/domains/security/dns-shield-mcp/ffi/dns_shield_ffi.zig b/cartridges/domains/security/dns-shield-mcp/ffi/dns_shield_ffi.zig new file mode 100644 index 0000000..06228a2 --- /dev/null +++ b/cartridges/domains/security/dns-shield-mcp/ffi/dns_shield_ffi.zig @@ -0,0 +1,254 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// dns_shield_ffi.zig β€” C-compatible FFI for DNS Shield cartridge. +// +// Provides DoQ/DoH resolution, DNSSEC validation, and CAA checking +// via the system's DNS resolver with encrypted transport enforcement. + +const std = @import("std"); + +// ═══════════════════════════════════════════════════════════════════════════ +// Types (matching Idris2 ABI) +// ═══════════════════════════════════════════════════════════════════════════ + +pub const DnsTransport = enum(u8) { + doq = 0, + doh = 1, + odoh = 2, +}; + +pub const RecordType = enum(u8) { + a = 0, + aaaa = 1, + cname = 2, + mx = 3, + txt = 4, + caa = 5, + dnskey = 6, + rrsig = 7, + ds = 8, + nsec = 9, + nsec3 = 10, +}; + +pub const DnssecState = enum(u8) { + validated = 0, + untrusted = 1, + insecure = 2, + bogus = 3, +}; + +pub const DnsResult = extern struct { + status: i32, // 0 = ok, -1 = error + answer: [*:0]const u8, + answer_len: u32, + dnssec_state: DnssecState, + ttl: u32, +}; + +// ═══════════════════════════════════════════════════════════════════════════ +// Exported FFI Functions +// ═══════════════════════════════════════════════════════════════════════════ + +/// Resolve a domain using DNS-over-QUIC (RFC 9250). +/// Returns a DnsResult with the answer and DNSSEC validation state. +export fn dns_shield_resolve_doq( + domain: [*:0]const u8, + record_type: RecordType, + result: *DnsResult, +) i32 { + return resolve_encrypted(domain, record_type, .doq, result); +} + +/// Resolve a domain using DNS-over-HTTPS (RFC 8484). +export fn dns_shield_resolve_doh( + domain: [*:0]const u8, + record_type: RecordType, + result: *DnsResult, +) i32 { + return resolve_encrypted(domain, record_type, .doh, result); +} + +/// Validate DNSSEC signatures for a response. +/// Returns the validation state (0=validated, 1=untrusted, 2=insecure, 3=bogus). +export fn dns_shield_validate_dnssec( + domain: [*:0]const u8, + record_type: RecordType, +) DnssecState { + // DNSSEC validation requires checking RRSIG + DNSKEY chain. + // For now, delegate to the system resolver's DNSSEC support + // (most modern resolvers like systemd-resolved, Unbound, or + // Knot Resolver handle this). + _ = domain; + _ = record_type; + return .validated; +} + +/// Check CAA records for a domain to verify CA authorization. +/// Returns 0 if the CA is authorized, -1 if not, -2 if no CAA records. +export fn dns_shield_check_caa( + domain: [*:0]const u8, + ca_domain: [*:0]const u8, +) i32 { + _ = domain; + _ = ca_domain; + // CAA check: resolve CAA records, compare with ca_domain. + // No CAA records = any CA authorized (returns -2). + return -2; // No CAA records (default: authorized) +} + +/// Flush the DNS cache for all encrypted resolvers. +export fn dns_shield_flush_cache() void { + // Clear any cached DoQ/DoH responses. +} + +/// Get the DNS Shield cartridge version. +export fn dns_shield_version() [*:0]const u8 { + return "0.5.0"; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Internal Implementation +// ═══════════════════════════════════════════════════════════════════════════ + +fn resolve_encrypted( + domain: [*:0]const u8, + record_type: RecordType, + transport: DnsTransport, + result: *DnsResult, +) i32 { + // Encrypted DNS resolution via system resolver. + // In production, this calls out to a DoQ/DoH stub resolver + // (e.g., Unbound with forward-tls, or dnscrypt-proxy). + _ = domain; + _ = record_type; + _ = transport; + result.status = 0; + result.answer = "127.0.0.1"; + result.answer_len = 9; + result.dnssec_state = .validated; + result.ttl = 300; + return 0; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// ═══════════════════════════════════════════════════════════════════════ +// Standard ABI (ADR-0005 four symbols + ADR-0006 invoke) +// ═══════════════════════════════════════════════════════════════════════ + +const shim = @import("cartridge_shim.zig"); + +const CARTRIDGE_NAME_PTR: [*:0]const u8 = "dns-shield-mcp"; +const CARTRIDGE_VERSION_PTR: [*:0]const u8 = "0.1.0"; + +export fn boj_cartridge_init() callconv(.c) c_int { + return 0; +} + +export fn boj_cartridge_deinit() callconv(.c) void {} + +export fn boj_cartridge_name() callconv(.c) [*:0]const u8 { + return CARTRIDGE_NAME_PTR; +} + +export fn boj_cartridge_version() callconv(.c) [*:0]const u8 { + return CARTRIDGE_VERSION_PTR; +} + +/// Dispatch the cartridge.json MCP tools. Grade D Alpha stubs. +export fn boj_cartridge_invoke( + tool_name: [*c]const u8, + json_args: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) callconv(.c) i32 { + _ = json_args; + if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; + + const body: []const u8 = if (shim.toolIs(tool_name, "dns_resolve_doq")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "dns_resolve_doh")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "dns_check_caa")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "dns_validate_dnssec")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "dns_flush_cache")) + "{\"result\":{\"status\":\"stub\"}}" +else + return shim.RC_UNKNOWN_TOOL; + + return shim.writeResult(out_buf, in_out_len, body); +} + +// Tests +// ═══════════════════════════════════════════════════════════════════════════ + +test "resolve_doq returns ok" { + var result: DnsResult = undefined; + const status = dns_shield_resolve_doq("example.com", .a, &result); + try std.testing.expectEqual(@as(i32, 0), status); + try std.testing.expectEqual(DnssecState.validated, result.dnssec_state); +} + +test "resolve_doh returns ok" { + var result: DnsResult = undefined; + const status = dns_shield_resolve_doh("example.com", .aaaa, &result); + try std.testing.expectEqual(@as(i32, 0), status); +} + +test "caa check returns no_records" { + const status = dns_shield_check_caa("example.com", "letsencrypt.org"); + try std.testing.expectEqual(@as(i32, -2), status); +} + +test "version returns 0.5.0" { + const ver = dns_shield_version(); + try std.testing.expectEqualStrings("0.5.0", std.mem.span(ver)); +} + +// ═══════════════════════════════════════════════════════════════════════ +// ADR-0006 invoke dispatch tests +// ═══════════════════════════════════════════════════════════════════════ + +test "boj_cartridge_name returns dns-shield-mcp" { + const n = std.mem.span(boj_cartridge_name()); + try std.testing.expectEqualStrings("dns-shield-mcp", n); +} + +test "boj_cartridge_init returns 0" { + try std.testing.expectEqual(@as(c_int, 0), boj_cartridge_init()); +} + +test "invoke: each declared tool succeeds" { + var buf: [256]u8 = undefined; + const tools = [_][]const u8{ + "dns_resolve_doq", + "dns_resolve_doh", + "dns_check_caa", + "dns_validate_dnssec", + "dns_flush_cache", + }; + for (tools) |t| { + var len: usize = buf.len; + const rc = boj_cartridge_invoke(t.ptr, "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, 0), rc); + try std.testing.expect(std.mem.indexOf(u8, buf[0..len], "result") != null); + } +} + +test "invoke: unknown tool returns -1" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("nope", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -1), rc); +} + +test "invoke: buffer too small returns -3" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("dns_resolve_doq", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -3), rc); + try std.testing.expect(len > 4); +} diff --git a/cartridges/domains/security/dns-shield-mcp/mod.js b/cartridges/domains/security/dns-shield-mcp/mod.js new file mode 100644 index 0000000..5711fe7 --- /dev/null +++ b/cartridges/domains/security/dns-shield-mcp/mod.js @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// dns-shield-mcp/mod.js β€” DNS security shield β€” DoQ, DoH, DNSSEC, CAA +// +// Delegates to backend at http://127.0.0.1:7720 (override with DNS_SHIELD_URL). + +const BASE_URL = Deno.env.get("DNS_SHIELD_URL") ?? "http://127.0.0.1:7720"; +const TIMEOUT_MS = 15_000; + +async function post(path, payload) { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), TIMEOUT_MS); + try { + const r = await fetch(`${BASE_URL}${path}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + signal: ctrl.signal, + }); + const data = await r.json().catch(() => ({ success: false, error: "non-JSON response" })); + return { status: r.status, data }; + } catch (e) { + if (e.name === "AbortError") return { status: 504, data: { success: false, error: "dns-shield-mcp backend timed out" } }; + return { status: 503, data: { success: false, error: `dns-shield-mcp backend unavailable: ${e.message}` } }; + } finally { clearTimeout(t); } +} + +async function get(path) { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), TIMEOUT_MS); + try { + const r = await fetch(`${BASE_URL}${path}`, { method: "GET", signal: ctrl.signal }); + const data = await r.json().catch(() => ({ success: false, error: "non-JSON response" })); + return { status: r.status, data }; + } catch (e) { + if (e.name === "AbortError") return { status: 504, data: { success: false, error: "dns-shield-mcp backend timed out" } }; + return { status: 503, data: { success: false, error: `dns-shield-mcp backend unavailable: ${e.message}` } }; + } finally { clearTimeout(t); } +} + +export async function handleTool(toolName, args) { + switch (toolName) { + case "dns_resolve_doq": + return post("/api/v1/dns_resolve_doq", args ?? {}); + case "dns_resolve_doh": + return post("/api/v1/dns_resolve_doh", args ?? {}); + case "dns_check_caa": + return post("/api/v1/dns_check_caa", args ?? {}); + case "dns_validate_dnssec": + return post("/api/v1/dns_validate_dnssec", args ?? {}); + case "dns_flush_cache": + return post("/api/v1/dns_flush_cache", args ?? {}); + default: + return { status: 404, data: { error: `Unknown tool: ${toolName}` } }; + } +} diff --git a/cartridges/domains/security/panic-attack-mcp/README.adoc b/cartridges/domains/security/panic-attack-mcp/README.adoc new file mode 100644 index 0000000..33ac626 --- /dev/null +++ b/cartridges/domains/security/panic-attack-mcp/README.adoc @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += panic-attack-mcp +Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk> +:spdx: MPL-2.0 +:tier: Ayo +:domain: Security +:protocols: MCP, REST + +== Overview + +panic-attacker static analysis. Scans codebases for dangerous patterns, banned constructs, proof drift, and OWASP-class vulnerabilities across 49 languages. + +== Tools (3) + +[cols="2,4"] +|=== +| Tool | Description + +| `panic_attack_scan` | Run panic-attack assail on a path. Returns weak points across all 21 categories. +| `panic_attack_get_findings` | Retrieve cached findings for a previous scan by scan ID. +| `panic_attack_get_severity` | Get severity distribution (info/low/medium/high/critical counts) for a scan. +|=== + +== Architecture + +Three-layer stack per the BoJ cartridge standard: + +[cols="1,1,3"] +|=== +| Layer | Language | Purpose + +| ABI | Idris2 | Formally verified state machine with dependent-type proofs +| FFI | Zig | C-ABI implementation: session pool, action metrics, thread safety +| Adapter| Zig | Adapter bridge to the BoJ unified adapter protocol (zig predecessors sidelined) +|=== + +== Building + +[source,bash] +---- +# ABI (Idris2) +cd abi && idris2 --check *.idr + +# FFI (Zig) +cd ffi && zig build +cd ffi && zig build test + +# Adapter (Zig) +cd adapter && zig build +---- + +== Status + +Alpha (CRG D). Cartridge metadata declared in `cartridge.json`; 3 tool(s) exposed. +Not yet dogfooded externally. See `docs/READINESS.md` at repo root for the +project-wide grade table and `docs/practice/DOGFOOD-LOG.adoc` for usage evidence. diff --git a/cartridges/domains/security/panic-attack-mcp/abi/PanicAttack/Protocol.idr b/cartridges/domains/security/panic-attack-mcp/abi/PanicAttack/Protocol.idr new file mode 100644 index 0000000..89b2fea --- /dev/null +++ b/cartridges/domains/security/panic-attack-mcp/abi/PanicAttack/Protocol.idr @@ -0,0 +1,55 @@ +-- SPDX-License-Identifier: MPL-2.0 +-- Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +-- +-- PanicAttack ABI β€” security scanner protocol definitions. + +module PanicAttack.Protocol + +import Data.Nat + +||| Scanner operation codes. +public export +data PanicAttackOp + = Scan + | GetFindings + | GetSeverity + +||| Severity levels ordered by criticality. +public export +data Severity = Info | Low | Medium | High | Critical + +||| A security finding with guaranteed non-empty description. +public export +record Finding where + constructor MkFinding + severity : Severity + description : String + {auto prf : NonEmpty (unpack description)} + +||| Scan target path. +public export +record ScanTarget where + constructor MkScanTarget + path : String + +||| Scan result with finding count. +public export +record ScanResult where + constructor MkScanResult + findings : List Finding + total : Nat + countPrf : total = length findings + +||| Severity has a total ordering β€” Critical is always >= Info. +public export +severityToNat : Severity -> Nat +severityToNat Info = 0 +severityToNat Low = 1 +severityToNat Medium = 2 +severityToNat High = 3 +severityToNat Critical = 4 + +||| Proof: Critical severity is strictly greater than Info. +export +criticalGtInfo : LTE 1 (severityToNat Critical) +criticalGtInfo = LTESucc LTEZero diff --git a/cartridges/domains/security/panic-attack-mcp/abi/README.adoc b/cartridges/domains/security/panic-attack-mcp/abi/README.adoc new file mode 100644 index 0000000..16def5d --- /dev/null +++ b/cartridges/domains/security/panic-attack-mcp/abi/README.adoc @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += panic-attack-mcp / abi β€” Idris2 ABI layer +:orientation: shallow + +== Purpose + +Cartridge-local protocol types for `panic-attack-mcp`. Defines the data, tool, and +transition vocabulary this cartridge speaks. Inherits safety lemmas and the +matrix-cell identifier scheme from the trunk ABI at `src/abi/`. + +== Files + +1 Idris2 module(s), ~55 lines total. Lead module: +`Protocol.idr`. + +No `.ipkg` β€” consumed directly by wider BoJ callers; type-checks inside the +trunk `boj.ipkg`. + +== Invariants + +* `%default total` at every module top. +* Zero `believe_me`, `assert_total`, `postulate`, `Admitted`, or + `sorry` (the trunk holds the four declared `believe_me` sites; leaf + cartridges carry none). + +== Test/proof surface + +Thin module(s), no inline tests. Proof surface = type-check success under the +trunk `idris2 --check`. + +== Read-first + +. `Protocol.idr` β€” cartridge-local data records and enums. +. Trunk `src/abi/Boj/Catalogue.idr` for how this cell participates in the + 2D matrix. diff --git a/cartridges/domains/security/panic-attack-mcp/adapter/README.adoc b/cartridges/domains/security/panic-attack-mcp/adapter/README.adoc new file mode 100644 index 0000000..9ec3476 --- /dev/null +++ b/cartridges/domains/security/panic-attack-mcp/adapter/README.adoc @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += panic-attack-mcp / adapter β€” protocol bridge +:orientation: shallow + +== Purpose + +Exposes this cartridge's FFI over the wire protocols declared in +`cartridge.json`. Dispatches tool invocations to the C-ABI exports at +`../ffi/` and formats responses per protocol. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” depends on the sibling `ffi` target. +| `panic_attack_adapter.zig` | Protocol dispatch (118 lines). Tool catalogue matches `cartridge.json`. +| `SIDELINED-*.v.adoc` | zig predecessor(s). Not built β€” zig banned 2026-04-10. +|=== + +== Invariants + +* **Stateless** β€” all state lives behind the FFI, never in the adapter. +* **Response passthrough** β€” whatever the FFI returns goes back to the wire + unmodified (no embellishment, no silent recovery). +* **Cartridge.json is source of truth** for the tool catalogue; drift between + adapter and manifest is a CI failure. + +== Test/proof surface + +No inline tests at the adapter layer β€” protocol routing is exercised by the +trunk seam tests at `ffi/zig/src/seams.zig`. + +== Read-first + +. `panic_attack_adapter.zig` β€” dispatch function and per-protocol routers. +. `../cartridge.json` β€” the tool manifest the dispatcher mirrors. diff --git a/cartridges/domains/security/panic-attack-mcp/adapter/build.zig b/cartridges/domains/security/panic-attack-mcp/adapter/build.zig new file mode 100644 index 0000000..cba4121 --- /dev/null +++ b/cartridges/domains/security/panic-attack-mcp/adapter/build.zig @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// panic-attack-mcp/adapter/build.zig + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + const ffi_mod = b.createModule(.{ + .root_source_file = b.path("../ffi/panic_attack_ffi.zig"), + .target = target, .optimize = optimize, + }); + const adapter = b.addExecutable(.{ + .name = "panic_attack_adapter", + .root_source_file = b.path("panic_attack_adapter.zig"), + .target = target, .optimize = optimize, + }); + adapter.root_module.addImport("panic_attack_ffi", ffi_mod); + b.installArtifact(adapter); + const run_step = b.step("run", "Run the panic-attack-mcp adapter"); + run_step.dependOn(&b.addRunArtifact(adapter).step); + const tests = b.addTest(.{ + .root_source_file = b.path("panic_attack_adapter.zig"), + .target = target, .optimize = optimize, + }); + tests.root_module.addImport("panic_attack_ffi", ffi_mod); + const test_step = b.step("test", "Run panic-attack-mcp adapter tests"); + test_step.dependOn(&b.addRunArtifact(tests).step); +} diff --git a/cartridges/domains/security/panic-attack-mcp/adapter/panic_attack_adapter.zig b/cartridges/domains/security/panic-attack-mcp/adapter/panic_attack_adapter.zig new file mode 100644 index 0000000..b20b82b --- /dev/null +++ b/cartridges/domains/security/panic-attack-mcp/adapter/panic_attack_adapter.zig @@ -0,0 +1,118 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// panic-attack-mcp/adapter/panic_attack_adapter.zig -- Unified three-protocol adapter. +// +// Replaces the banned panic_attack_adapter.v (zig, removed 2026-04-12). +// +// REST :9106 gRPC-compat :9107 GraphQL :9108 +// panic-attacker static analysis. Scans codebases for dangerous patterns, banned constructs, proof dri +// Tools: panic_attack_scan, panic_attack_get_findings, panic_attack_get_severity + +const std = @import("std"); +const ffi = @import("panic_attack_ffi"); + +const REST_PORT: u16 = 9106; +const GRPC_PORT: u16 = 9107; +const GQL_PORT: u16 = 9108; +const MAX_CONN_BUF: usize = 16 * 1024; + +fn okJson(buf: []u8, msg: []const u8) []u8 { + const n = std.fmt.bufPrint(buf, "{{\"success\":true,\"message\":\"{s}\"}}", .{msg}) catch return buf[0..0]; + return n; +} +fn errJson(buf: []u8, msg: []const u8) []u8 { + const n = std.fmt.bufPrint(buf, "{{\"success\":false,\"error\":\"{s}\"}}", .{msg}) catch return buf[0..0]; + return n; +} +fn statusJson(buf: []u8) []u8 { + const n = std.fmt.bufPrint(buf, "{{\"success\":true,\"state\":\"ready\",\"service\":\"panic-attack-mcp\"}}", .{}) catch return buf[0..0]; + return n; +} + +const Response = struct { status: u16, body: []u8 }; + +fn dispatch(tool: []const u8, body: []const u8, resp: []u8) Response { + _ = body; + if (std.mem.eql(u8, tool, "panic_attack_scan")) return .{ .status = 200, .body = okJson(resp, "panic_attack_scan forwarded") }; + if (std.mem.eql(u8, tool, "panic_attack_get_findings")) return .{ .status = 200, .body = okJson(resp, "panic_attack_get_findings forwarded") }; + if (std.mem.eql(u8, tool, "panic_attack_get_severity")) return .{ .status = 200, .body = okJson(resp, "panic_attack_get_severity forwarded") }; + if (std.mem.eql(u8, tool, "status") or std.mem.eql(u8, tool, "health")) + return .{ .status = 200, .body = statusJson(resp) }; + return .{ .status = 404, .body = errJson(resp, "Unknown tool") }; +} + +fn dispatchRest(path: []const u8, body: []const u8, resp: []u8) Response { + if (std.mem.startsWith(u8, path, "/tools/")) return dispatch(path["/tools/".len..], body, resp); + if (std.mem.eql(u8, path, "/status") or std.mem.eql(u8, path, "/health")) return .{ .status = 200, .body = statusJson(resp) }; + return .{ .status = 404, .body = errJson(resp, "Not found") }; +} + +fn dispatchGrpc(path: []const u8, body: []const u8, resp: []u8) Response { + const prefix = "/PanicAttackservice/"; + if (!std.mem.startsWith(u8, path, prefix)) + return .{ .status = 404, .body = errJson(resp, "Not a recognized gRPC path") }; + const method = path[prefix.len..]; + const tool = blk: { + if (std.mem.eql(u8, method, "panic_attack_scan")) break :blk "panic_attack_scan"; + if (std.mem.eql(u8, method, "panic_attack_get_findings")) break :blk "panic_attack_get_findings"; + if (std.mem.eql(u8, method, "panic_attack_get_severity")) break :blk "panic_attack_get_severity"; + return .{ .status = 404, .body = errJson(resp, "Unknown gRPC method") }; + }; + return dispatch(tool, body, resp); +} + +fn dispatchGraphql(body: []const u8, resp: []u8) Response { + if (std.mem.indexOf(u8, body, "__schema") != null) return .{ .status = 200, .body = okJson(resp, "schema not supported") }; + if (std.mem.indexOf(u8, body, "attack_scan") != null) return dispatch("panic_attack_scan", body, resp); + if (std.mem.indexOf(u8, body, "attack_get_findings") != null) return dispatch("panic_attack_get_findings", body, resp); + if (std.mem.indexOf(u8, body, "attack_get_severity") != null) return dispatch("panic_attack_get_severity", body, resp); + return .{ .status = 200, .body = errJson(resp, "Unrecognised GraphQL operation") }; +} + +const Protocol = enum { rest, grpc, graphql }; + +fn handleConnection(conn: std.net.Server.Connection, proto: Protocol) void { + defer conn.stream.close(); + var in_buf: [MAX_CONN_BUF]u8 = undefined; + const n = conn.stream.read(&in_buf) catch return; + const req = in_buf[0..n]; + var path: []const u8 = "/"; + var body: []const u8 = ""; + if (n > 4) { + const line_end = std.mem.indexOf(u8, req, "\r\n") orelse req.len; + const first_line = req[0..line_end]; + const sp1 = std.mem.indexOfScalar(u8, first_line, ' ') orelse 0; + const rest_of = first_line[sp1 + 1 ..]; + const sp2 = std.mem.indexOfScalar(u8, rest_of, ' ') orelse rest_of.len; + path = rest_of[0..sp2]; + const body_sep = std.mem.indexOf(u8, req, "\r\n\r\n") orelse n; + body = req[@min(body_sep + 4, n)..]; + } + var resp_buf: [MAX_CONN_BUF]u8 = undefined; + const result = switch (proto) { + .rest => dispatchRest(path, body, &resp_buf), + .grpc => dispatchGrpc(path, body, &resp_buf), + .graphql => dispatchGraphql(body, &resp_buf), + }; + const ct: []const u8 = if (proto == .grpc) "application/grpc+json" else "application/json"; + var hdr_buf: [256]u8 = undefined; + const hdr = std.fmt.bufPrint(&hdr_buf, "HTTP/1.1 {d} OK\r\nContent-Type: {s}\r\nContent-Length: {d}\r\nConnection: close\r\n\r\n", .{ result.status, ct, result.body.len }) catch return; + _ = conn.stream.writeAll(hdr) catch return; + _ = conn.stream.writeAll(result.body) catch return; +} + +fn listenLoop(port: u16, proto: Protocol) void { + const addr = std.net.Address.initIp4(.{ 127, 0, 0, 1 }, port) catch return; + var server = addr.listen(.{ .reuse_address = true }) catch return; + defer server.deinit(); + while (true) { const conn = server.accept() catch continue; handleConnection(conn, proto); } +} + +pub fn main() !void { + ffi.panic_attack_init(); + const t1 = try std.Thread.spawn(.{}, listenLoop, .{ REST_PORT, Protocol.rest }); + const t2 = try std.Thread.spawn(.{}, listenLoop, .{ GRPC_PORT, Protocol.grpc }); + const t3 = try std.Thread.spawn(.{}, listenLoop, .{ GQL_PORT, Protocol.graphql }); + t1.join(); t2.join(); t3.join(); +} diff --git a/cartridges/domains/security/panic-attack-mcp/cartridge.json b/cartridges/domains/security/panic-attack-mcp/cartridge.json new file mode 100644 index 0000000..2d3a06d --- /dev/null +++ b/cartridges/domains/security/panic-attack-mcp/cartridge.json @@ -0,0 +1,92 @@ +{ + "$schema": "https://boj.dev/schemas/cartridge/v1.json", + "spdx": "MPL-2.0", + "copyright": "Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath)", + "name": "panic-attack-mcp", + "version": "0.1.0", + "description": "panic-attacker static analysis. Scans codebases for dangerous patterns, banned constructs, proof drift, and OWASP-class vulnerabilities across 49 languages.", + "domain": "Security", + "tier": "Ayo", + "protocols": [ + "MCP", + "REST" + ], + "auth": { + "method": "none", + "env_var": null, + "credential_source": null + }, + "api": { + "base_url": "local://panic-attack-mcp", + "content_type": "application/json" + }, + "tools": [ + { + "name": "panic_attack_scan", + "description": "Run panic-attack assail on a path. Returns weak points across all 21 categories.", + "inputSchema": { + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "Absolute path to scan (file or directory)" + }, + "verbose": { + "type": "boolean", + "description": "Include suppressed weak points (default: false)" + }, + "format": { + "type": "string", + "description": "Output format: 'json' (default) or 'text'" + } + }, + "required": [ + "path" + ] + } + }, + { + "name": "panic_attack_get_findings", + "description": "Retrieve cached findings for a previous scan by scan ID.", + "inputSchema": { + "type": "object", + "properties": { + "scan_id": { + "type": "string", + "description": "Scan ID from a prior panic_attack_scan call" + } + }, + "required": [ + "scan_id" + ] + } + }, + { + "name": "panic_attack_get_severity", + "description": "Get severity distribution (info/low/medium/high/critical counts) for a scan.", + "inputSchema": { + "type": "object", + "properties": { + "scan_id": { + "type": "string", + "description": "Scan ID" + } + }, + "required": [ + "scan_id" + ] + } + } + ], + "ffi": { + "so_path": "ffi/zig-out/lib/libpanic_attack_mcp.so", + "abi_version": "ADR-0006", + "symbols": [ + "boj_cartridge_init", + "boj_cartridge_deinit", + "boj_cartridge_name", + "boj_cartridge_version", + "boj_cartridge_invoke" + ] + } +} diff --git a/cartridges/domains/security/panic-attack-mcp/ffi/README.adoc b/cartridges/domains/security/panic-attack-mcp/ffi/README.adoc new file mode 100644 index 0000000..873ccd1 --- /dev/null +++ b/cartridges/domains/security/panic-attack-mcp/ffi/README.adoc @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += panic-attack-mcp / ffi β€” Zig FFI layer **(stub)** +:orientation: shallow + +== Purpose + +C-ABI exports bridging this cartridge's Idris2 ABI to the runtime. Consumed by +the adapter for protocol dispatch and by the trunk loader for mount. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” produces `zig-out/lib/libpanic-attack.so`. +| `panic_attack_ffi.zig` | C-ABI exports (3 exports, 3 inline tests, 42 lines). +|=== + +== Invariants + +* **Null-pointer rejection** on `[*c]const u8` arguments before any + dereference. +* **Negative return values** carry documented error meanings; zero is + success (matching the trunk error-code convention). +* **No allocator leaks** β€” every allocation path pairs with `defer free`. + +== Test/proof surface + +3 inline `test "..."` block(s) in `panic_attack_ffi.zig`. Run with +`cd ffi && zig build test`. + +== Read-first + +. `panic_attack_ffi.zig` β€” C-ABI signatures and guards. +. Trunk `ffi/zig/src/catalogue.zig` for how this `.so` is loaded. + +== Promotion gate + +Wire these exports to real backing state (not `return 0` / `return 1` +placeholders) before promoting past D. diff --git a/cartridges/domains/security/panic-attack-mcp/ffi/build.zig b/cartridges/domains/security/panic-attack-mcp/ffi/build.zig new file mode 100644 index 0000000..cf65e3b --- /dev/null +++ b/cartridges/domains/security/panic-attack-mcp/ffi/build.zig @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + // Shared ADR-0006 invoke-shim module (relative path up to boj-server trunk). + + const shim_mod = b.addModule("cartridge_shim", .{ + + .root_source_file = b.path("../../../ffi/zig/src/cartridge_shim.zig"), + + .target = target, + + .optimize = optimize, + + }); + + const ffi_mod = b.addModule("panic_attack_mcp", .{ + .root_source_file = b.path("panic_attack_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + ffi_mod.addImport("cartridge_shim", shim_mod); + + const lib = b.addLibrary(.{ + .name = "panic_attack_mcp", + .root_module = ffi_mod, + .linkage = .dynamic, + }); + lib.linkLibC(); + b.installArtifact(lib); + + const tests = b.addTest(.{ .root_module = ffi_mod }); + tests.linkLibC(); + const run_tests = b.addRunArtifact(tests); + const test_step = b.step("test", "Run FFI tests"); + test_step.dependOn(&run_tests.step); +} + diff --git a/cartridges/domains/security/panic-attack-mcp/ffi/cartridge_shim.zig b/cartridges/domains/security/panic-attack-mcp/ffi/cartridge_shim.zig new file mode 100644 index 0000000..0e399db --- /dev/null +++ b/cartridges/domains/security/panic-attack-mcp/ffi/cartridge_shim.zig @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// cartridge_shim.zig β€” Shared helpers for the ADR-0006 five-symbol +// cartridge ABI (`boj_cartridge_init / deinit / name / version / invoke`). +// +// The shim centralises the seven-code return convention, NUL-argument +// guards, tool-name comparison, and the buffer-too-small path so each +// cartridge's `boj_cartridge_invoke` can stay short β€” typically a tool +// table plus `shim.writeResult(...)`. +// +// Cartridges import this file by relative path (no build-graph change +// needed). Example: +// +// const shim = @import("../../../ffi/zig/src/cartridge_shim.zig"); +// +// export fn boj_cartridge_invoke( +// tool_name: [*c]const u8, +// json_args: [*c]const u8, +// out_buf: [*c]u8, +// in_out_len: [*c]usize, +// ) callconv(.c) i32 { +// _ = json_args; +// if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; +// const body = if (shim.toolIs(tool_name, "foo")) "{\"result\":{}}" +// else return shim.RC_UNKNOWN_TOOL; +// return shim.writeResult(out_buf, in_out_len, body); +// } + +const std = @import("std"); + +// ── Return codes (ADR-0006 Β§Return codes) ──────────────────────────── +// +// Frozen by ADR-0006. New failure modes compose these via the error +// JSON body β€” the integer surface does not grow without a follow-up ADR. + +pub const RC_SUCCESS: i32 = 0; +pub const RC_UNKNOWN_TOOL: i32 = -1; +pub const RC_BAD_ARGS: i32 = -2; +pub const RC_BUFFER_TOO_SMALL: i32 = -3; +pub const RC_RUNTIME_ERROR: i32 = -4; +pub const RC_PANIC: i32 = -5; +pub const RC_AUTH_DENIED: i32 = -6; + +// ── Invoke-path helpers ────────────────────────────────────────────── + +/// True if any of the three mandatory `boj_cartridge_invoke` output-path +/// pointers is null. Use at the top of every invoke to short-circuit to +/// `RC_BAD_ARGS`. +pub fn invokeArgsNull( + tool_name: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) bool { + return tool_name == null or out_buf == null or in_out_len == null; +} + +/// Compare a C-NUL-terminated tool-name pointer against a Zig string +/// literal. Caller must have already verified `tool_name` is non-null +/// (usually via `invokeArgsNull`). +pub fn toolIs(tool_name: [*c]const u8, expected: []const u8) bool { + const s = std.mem.span(@as([*:0]const u8, @ptrCast(tool_name))); + return std.mem.eql(u8, s, expected); +} + +/// Copy `body` into `out_buf[0..*in_out_len]` (as a capacity) and update +/// `*in_out_len` to the number of bytes written. Returns `RC_SUCCESS`. +/// +/// If `body.len` exceeds the current capacity stored in `*in_out_len`, +/// sets `*in_out_len` to the required size and returns +/// `RC_BUFFER_TOO_SMALL` β€” the caller is then expected to re-allocate +/// and retry, per ADR-0006 Β§Memory ownership. +/// +/// Caller must have already verified that `out_buf` and `in_out_len` +/// are non-null. +pub fn writeResult( + out_buf: [*c]u8, + in_out_len: [*c]usize, + body: []const u8, +) i32 { + const cap = in_out_len.*; + if (body.len > cap) { + in_out_len.* = body.len; + return RC_BUFFER_TOO_SMALL; + } + @memcpy(out_buf[0..body.len], body); + in_out_len.* = body.len; + return RC_SUCCESS; +} + +// ── Tests ──────────────────────────────────────────────────────────── + +test "writeResult: body fits, writes and sets length" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); + try std.testing.expectEqualStrings("hello", buf[0..len]); +} + +test "writeResult: too small returns -3 and sets required length" { + var buf: [2]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_BUFFER_TOO_SMALL, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: exact-fit succeeds" { + var buf: [5]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: empty body" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, ""); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 0), len); +} + +test "toolIs: matches and rejects" { + const name: [*:0]const u8 = "foo"; + try std.testing.expect(toolIs(@ptrCast(name), "foo")); + try std.testing.expect(!toolIs(@ptrCast(name), "bar")); + try std.testing.expect(!toolIs(@ptrCast(name), "foobar")); + try std.testing.expect(!toolIs(@ptrCast(name), "fo")); +} + +test "invokeArgsNull: detects each null slot" { + var buf: [4]u8 = undefined; + var len: usize = 4; + const name: [*:0]const u8 = "x"; + try std.testing.expect(!invokeArgsNull(@ptrCast(name), &buf, &len)); + try std.testing.expect(invokeArgsNull(null, &buf, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), null, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), &buf, null)); +} diff --git a/cartridges/domains/security/panic-attack-mcp/ffi/panic_attack_ffi.zig b/cartridges/domains/security/panic-attack-mcp/ffi/panic_attack_ffi.zig new file mode 100644 index 0000000..1881038 --- /dev/null +++ b/cartridges/domains/security/panic-attack-mcp/ffi/panic_attack_ffi.zig @@ -0,0 +1,130 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +// +// PanicAttack FFI β€” C-compatible exports for security scanning. + +const std = @import("std"); + +/// Severity levels matching the ABI definition. +pub const Severity = enum(u8) { info = 0, low = 1, medium = 2, high = 3, critical = 4 }; + +/// Initiate a scan on a target path. Returns scan ID or 0 on error. +export fn panic_attack_scan(target: [*c]const u8) u32 { + if (target == null) return 0; + // Stub: real impl delegates to panic-attacker binary + return 1; +} + +/// Get number of findings for a completed scan. +export fn panic_attack_get_findings_count(scan_id: u32) u32 { + if (scan_id == 0) return 0; + return 0; // Stub +} + +/// Get the highest severity found in a scan. +export fn panic_attack_get_severity(scan_id: u32) u8 { + if (scan_id == 0) return @intFromEnum(Severity.info); + return @intFromEnum(Severity.info); // Stub +} + +// ═══════════════════════════════════════════════════════════════════════ +// Standard ABI (ADR-0005 four symbols + ADR-0006 invoke) +// ═══════════════════════════════════════════════════════════════════════ + +const shim = @import("cartridge_shim.zig"); + +const CARTRIDGE_NAME_PTR: [*:0]const u8 = "panic-attack-mcp"; +const CARTRIDGE_VERSION_PTR: [*:0]const u8 = "0.1.0"; + +export fn boj_cartridge_init() callconv(.c) c_int { + return 0; +} + +export fn boj_cartridge_deinit() callconv(.c) void {} + +export fn boj_cartridge_name() callconv(.c) [*:0]const u8 { + return CARTRIDGE_NAME_PTR; +} + +export fn boj_cartridge_version() callconv(.c) [*:0]const u8 { + return CARTRIDGE_VERSION_PTR; +} + +/// Dispatch the cartridge.json MCP tools. Grade D Alpha stubs. +export fn boj_cartridge_invoke( + tool_name: [*c]const u8, + json_args: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) callconv(.c) i32 { + _ = json_args; + if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; + + const body: []const u8 = if (shim.toolIs(tool_name, "panic_attack_scan")) + "{\"result\":{\"matches\":[],\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "panic_attack_get_findings")) + "{\"result\":{\"metadata\":{},\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "panic_attack_get_severity")) + "{\"result\":{\"metadata\":{},\"status\":\"stub\"}}" +else + return shim.RC_UNKNOWN_TOOL; + + return shim.writeResult(out_buf, in_out_len, body); +} + +// ── Tests ── + +test "scan rejects null target" { + try std.testing.expectEqual(@as(u32, 0), panic_attack_scan(null)); +} + +test "scan accepts valid target" { + try std.testing.expect(panic_attack_scan("/tmp/repo") != 0); +} + +test "findings count zero for invalid scan" { + try std.testing.expectEqual(@as(u32, 0), panic_attack_get_findings_count(0)); +} + +// ═══════════════════════════════════════════════════════════════════════ +// ADR-0006 invoke dispatch tests +// ═══════════════════════════════════════════════════════════════════════ + +test "boj_cartridge_name returns panic-attack-mcp" { + const n = std.mem.span(boj_cartridge_name()); + try std.testing.expectEqualStrings("panic-attack-mcp", n); +} + +test "boj_cartridge_init returns 0" { + try std.testing.expectEqual(@as(c_int, 0), boj_cartridge_init()); +} + +test "invoke: each declared tool succeeds" { + var buf: [256]u8 = undefined; + const tools = [_][]const u8{ + "panic_attack_scan", + "panic_attack_get_findings", + "panic_attack_get_severity", + }; + for (tools) |t| { + var len: usize = buf.len; + const rc = boj_cartridge_invoke(t.ptr, "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, 0), rc); + try std.testing.expect(std.mem.indexOf(u8, buf[0..len], "result") != null); + } +} + +test "invoke: unknown tool returns -1" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("nope", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -1), rc); +} + +test "invoke: buffer too small returns -3" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("panic_attack_scan", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -3), rc); + try std.testing.expect(len > 4); +} diff --git a/cartridges/domains/security/panic-attack-mcp/mod.js b/cartridges/domains/security/panic-attack-mcp/mod.js new file mode 100644 index 0000000..8430e7f --- /dev/null +++ b/cartridges/domains/security/panic-attack-mcp/mod.js @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// panic-attack-mcp/mod.js -- panic-attack-mcp gateway. Delegates to the `panic-attack` CLI binary. + +export async function handleTool(toolName, args) { + switch (toolName) { + case "panic_attack_scan": { + const { path, verbose, format } = args ?? {}; + if (!path) return { status: 400, data: { error: "path is required" } }; + const cmd = new Deno.Command("panic-attack", { args: ["scan", String(path)], stdout: "piped", stderr: "piped" }); + const out = await cmd.output(); + if (!out.success) return { status: 500, data: { success: false, error: new TextDecoder().decode(out.stderr) } }; + const stdout = new TextDecoder().decode(out.stdout); + try { return { status: 200, data: JSON.parse(stdout) }; } catch { return { status: 200, data: { success: true, output: stdout } }; } + } + + case "panic_attack_get_findings": { + const { scan_id } = args ?? {}; + if (!scan_id) return { status: 400, data: { error: "scan_id is required" } }; + const cmd = new Deno.Command("panic-attack", { args: ["get-findings", String(scan_id)], stdout: "piped", stderr: "piped" }); + const out = await cmd.output(); + if (!out.success) return { status: 500, data: { success: false, error: new TextDecoder().decode(out.stderr) } }; + const stdout = new TextDecoder().decode(out.stdout); + try { return { status: 200, data: JSON.parse(stdout) }; } catch { return { status: 200, data: { success: true, output: stdout } }; } + } + + case "panic_attack_get_severity": { + const { scan_id } = args ?? {}; + if (!scan_id) return { status: 400, data: { error: "scan_id is required" } }; + const cmd = new Deno.Command("panic-attack", { args: ["get-severity", String(scan_id)], stdout: "piped", stderr: "piped" }); + const out = await cmd.output(); + if (!out.success) return { status: 500, data: { success: false, error: new TextDecoder().decode(out.stderr) } }; + const stdout = new TextDecoder().decode(out.stdout); + try { return { status: 200, data: JSON.parse(stdout) }; } catch { return { status: 200, data: { success: true, output: stdout } }; } + } + default: + return { status: 404, data: { error: `Unknown tool: ${toolName}` } }; + } +} diff --git a/cartridges/domains/security/panic-attack-mcp/panels/manifest.json b/cartridges/domains/security/panic-attack-mcp/panels/manifest.json new file mode 100644 index 0000000..f865cd3 --- /dev/null +++ b/cartridges/domains/security/panic-attack-mcp/panels/manifest.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://panll.dev/schemas/panel-manifest/v1.json", + "spdx": "MPL-2.0", + "cartridge": "panic-attack-mcp", + "domain": "security", + "version": "0.1.0", + "panels": [ + { + "id": "panic-attack-scan-results", + "title": "Scan Results", + "description": "Security scan findings with severity-level filter and sortable results table.", + "type": "data-table", + "entrypoint": "panels/panic-attack-scan-results.js", + "size": { "cols": 4, "rows": 3 }, + "refresh_interval_ms": 5000, + "data_sources": [ + { "op": "GetFindings", "interval_ms": 5000 }, + { "op": "GetSeverity", "interval_ms": 5000 } + ], + "filters": ["severity"] + } + ] +} diff --git a/cartridges/domains/security/rokur-mcp/README.adoc b/cartridges/domains/security/rokur-mcp/README.adoc new file mode 100644 index 0000000..085afc2 --- /dev/null +++ b/cartridges/domains/security/rokur-mcp/README.adoc @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: MPL-2.0 += rokur-mcp +:author: Jonathan D.A. Jewell +:email: j.d.a.jewell@open.ac.uk + +Container pre-start secrets gate for the Stapeln container ecosystem. + +== What It Does + +Rokur validates that all required secrets are present before allowing a container +to start. It communicates with the Rokur sidecar service (Deno, port 9090) via +REST, and exposes the authorization verdict through BoJ's MCP protocol. + +**Zero-knowledge invariant**: Secret values are never exposed through this +cartridge. Only presence/absence verdicts and counts are returned. + +== Relationship to vault-mcp (RGTV) + +[cols="1,1"] +|=== +| **vault-mcp (RGTV)** | **rokur-mcp** + +| Stores and delivers credentials +| Validates credentials are present + +| Post-quantum encrypted vault +| Lightweight HTTP gate + +| AI agent zero-knowledge proxy +| Container orchestration gate + +| Long-lived service +| Pre-start check (ephemeral) +|=== + +In production: RGTV delivers secrets to the container environment, then Rokur +verifies they all arrived before Svalinn allows the container to start. + +== MCP Tools + +* `boj_rokur_authorize_start` β€” Pre-start authorization check +* `boj_rokur_status` β€” Secrets presence status (counts only) +* `boj_rokur_reload` β€” Hot-reload secrets from environment +* `boj_rokur_health` β€” Sidecar liveness check + +== Architecture + +---- +Idris2 ABI (SafeGate.idr) + ↓ state machine proofs +Zig FFI (rokur_mcp_ffi.zig) + ↓ curl β†’ Rokur :9090 +zig Adapter (rokur_mcp_adapter.v) + ↓ REST bridge +BoJ MCP SSE Transport +---- diff --git a/cartridges/domains/security/rokur-mcp/abi/README.adoc b/cartridges/domains/security/rokur-mcp/abi/README.adoc new file mode 100644 index 0000000..fa5eb7e --- /dev/null +++ b/cartridges/domains/security/rokur-mcp/abi/README.adoc @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += rokur-mcp / abi β€” Idris2 ABI layer +:orientation: shallow + +== Purpose + +Cartridge-local protocol types for `rokur-mcp`. Defines the data, tool, and +transition vocabulary this cartridge speaks. Inherits safety lemmas and the +matrix-cell identifier scheme from the trunk ABI at `src/abi/`. + +== Files + +1 Idris2 module(s), ~157 lines total. Lead module: +`SafeGate.idr`. + +No `.ipkg` β€” consumed directly by wider BoJ callers; type-checks inside the +trunk `boj.ipkg`. + +== Invariants + +* `%default total` at every module top. +* Zero `believe_me`, `assert_total`, `postulate`, `Admitted`, or + `sorry` (the trunk holds the four declared `believe_me` sites; leaf + cartridges carry none). + +== Test/proof surface + +Thin module(s), no inline tests. Proof surface = type-check success under the +trunk `idris2 --check`. + +== Read-first + +. `SafeGate.idr` β€” cartridge-local data records and enums. +. Trunk `src/abi/Boj/Catalogue.idr` for how this cell participates in the + 2D matrix. diff --git a/cartridges/domains/security/rokur-mcp/abi/RokurMcp/SafeGate.idr b/cartridges/domains/security/rokur-mcp/abi/RokurMcp/SafeGate.idr new file mode 100644 index 0000000..bf84bf5 --- /dev/null +++ b/cartridges/domains/security/rokur-mcp/abi/RokurMcp/SafeGate.idr @@ -0,0 +1,157 @@ +-- SPDX-License-Identifier: MPL-2.0 +-- Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +-- +-- RokurMcp.SafeGate β€” Type-safe ABI for the rokur-mcp cartridge. +-- +-- Container pre-start secrets gate. Validates that all required secrets +-- are present before allowing a container to start. Delegates to the +-- Rokur sidecar service (Deno, port 9090) for policy evaluation. +-- Never exposes secret values β€” only presence/absence verdicts. + +module RokurMcp.SafeGate + +%default total + +-- --------------------------------------------------------------------------- +-- Gate state machine +-- --------------------------------------------------------------------------- + +||| Gate lifecycle states for container pre-start authorization. +||| +||| - Idle: no authorization request pending. +||| - Checking: secrets presence check in progress. +||| - Allowed: all required secrets present, container may start. +||| - Denied: one or more required secrets missing, container blocked. +||| - Error: policy engine failure (fail-closed = deny). +public export +data GateState = Idle | Checking | Allowed | Denied | Error + +||| Proof that a gate state transition is valid. +||| +||| Transition graph: +||| Idle -> Checking (begin authorization) +||| Checking -> Allowed (all secrets present) +||| Checking -> Denied (missing secrets) +||| Checking -> Error (policy engine failure) +||| Allowed -> Idle (reset after container started) +||| Denied -> Idle (reset after denial logged) +||| Error -> Idle (reset after error handled) +public export +data ValidGateTransition : GateState -> GateState -> Type where + BeginCheck : ValidGateTransition Idle Checking + Approve : ValidGateTransition Checking Allowed + Reject : ValidGateTransition Checking Denied + Fault : ValidGateTransition Checking Error + ResetAllowed : ValidGateTransition Allowed Idle + ResetDenied : ValidGateTransition Denied Idle + ResetError : ValidGateTransition Error Idle + +-- --------------------------------------------------------------------------- +-- Gate actions +-- --------------------------------------------------------------------------- + +||| Actions that can be performed through the MCP rokur interface. +public export +data GateAction + = AuthorizeStart -- ^ Request pre-start authorization for a container + | CheckStatus -- ^ Query current secrets presence status + | ReloadSecrets -- ^ Hot-reload required secrets from environment + | QueryHealth -- ^ Liveness check on the Rokur sidecar + +||| Whether an action requires the gate to be in a specific state. +||| Health is always available. AuthorizeStart requires Idle. +||| CheckStatus and ReloadSecrets available in Idle or Allowed. +export +actionRequiresIdle : GateAction -> Bool +actionRequiresIdle AuthorizeStart = True +actionRequiresIdle CheckStatus = False +actionRequiresIdle ReloadSecrets = False +actionRequiresIdle QueryHealth = False + +-- --------------------------------------------------------------------------- +-- Policy verdict +-- --------------------------------------------------------------------------- + +||| Authorization verdict from the policy engine. +||| Secret names are intentionally NEVER exposed β€” only counts. +public export +record AuthVerdict where + constructor MkVerdict + allowed : Bool + policy : String -- "allow" or "deny" + code : String -- decision reason code + requiredCount : Nat + missingCount : Nat + policyEngine : String -- "builtin" or "external" + +-- --------------------------------------------------------------------------- +-- C-ABI integer encoding β€” gate state +-- --------------------------------------------------------------------------- + +export +gateStateToInt : GateState -> Int +gateStateToInt Idle = 0 +gateStateToInt Checking = 1 +gateStateToInt Allowed = 2 +gateStateToInt Denied = 3 +gateStateToInt Error = 4 + +export +intToGateState : Int -> Maybe GateState +intToGateState 0 = Just Idle +intToGateState 1 = Just Checking +intToGateState 2 = Just Allowed +intToGateState 3 = Just Denied +intToGateState 4 = Just Error +intToGateState _ = Nothing + +-- --------------------------------------------------------------------------- +-- C-ABI integer encoding β€” gate action +-- --------------------------------------------------------------------------- + +export +gateActionToInt : GateAction -> Int +gateActionToInt AuthorizeStart = 0 +gateActionToInt CheckStatus = 1 +gateActionToInt ReloadSecrets = 2 +gateActionToInt QueryHealth = 3 + +export +intToGateAction : Int -> Maybe GateAction +intToGateAction 0 = Just AuthorizeStart +intToGateAction 1 = Just CheckStatus +intToGateAction 2 = Just ReloadSecrets +intToGateAction 3 = Just QueryHealth +intToGateAction _ = Nothing + +-- --------------------------------------------------------------------------- +-- C-ABI transition validator +-- --------------------------------------------------------------------------- + +export +rokur_mcp_can_transition : Int -> Int -> Int +rokur_mcp_can_transition from to = + case (intToGateState from, intToGateState to) of + (Just Idle, Just Checking) => 1 -- BeginCheck + (Just Checking, Just Allowed) => 1 -- Approve + (Just Checking, Just Denied) => 1 -- Reject + (Just Checking, Just Error) => 1 -- Fault + (Just Allowed, Just Idle) => 1 -- ResetAllowed + (Just Denied, Just Idle) => 1 -- ResetDenied + (Just Error, Just Idle) => 1 -- ResetError + _ => 0 + +-- --------------------------------------------------------------------------- +-- MCP tool declarations +-- --------------------------------------------------------------------------- + +public export +data McpTool + = ToolAuthorizeStart -- ^ rokur/authorize-start + | ToolCheckStatus -- ^ rokur/status + | ToolReloadSecrets -- ^ rokur/reload + | ToolHealth -- ^ rokur/health + +export +toolCount : Nat +toolCount = 4 diff --git a/cartridges/domains/security/rokur-mcp/abi/rokur_mcp.ipkg b/cartridges/domains/security/rokur-mcp/abi/rokur_mcp.ipkg new file mode 100644 index 0000000..5f5f69d --- /dev/null +++ b/cartridges/domains/security/rokur-mcp/abi/rokur_mcp.ipkg @@ -0,0 +1,10 @@ +-- SPDX-License-Identifier: MPL-2.0 +package rokur_mcp + +version = "0.1.0" +authors = "Jonathan D.A. Jewell" +brief = "Container pre-start secrets gate β€” Idris2 ABI for rokur-mcp" + +depends = base + +modules = RokurMcp.SafeGate diff --git a/cartridges/domains/security/rokur-mcp/adapter/README.adoc b/cartridges/domains/security/rokur-mcp/adapter/README.adoc new file mode 100644 index 0000000..cb4d44a --- /dev/null +++ b/cartridges/domains/security/rokur-mcp/adapter/README.adoc @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += rokur-mcp / adapter β€” protocol bridge +:orientation: shallow + +== Purpose + +Exposes this cartridge's FFI over the wire protocols declared in +`cartridge.json`. Dispatches tool invocations to the C-ABI exports at +`../ffi/` and formats responses per protocol. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” depends on the sibling `ffi` target. +| `rokur_mcp_adapter.zig` | Protocol dispatch (129 lines). Tool catalogue matches `cartridge.json`. +| `SIDELINED-*.v.adoc` | zig predecessor(s). Not built β€” zig banned 2026-04-10. +|=== + +== Invariants + +* **Stateless** β€” all state lives behind the FFI, never in the adapter. +* **Response passthrough** β€” whatever the FFI returns goes back to the wire + unmodified (no embellishment, no silent recovery). +* **Cartridge.json is source of truth** for the tool catalogue; drift between + adapter and manifest is a CI failure. + +== Test/proof surface + +No inline tests at the adapter layer β€” protocol routing is exercised by the +trunk seam tests at `ffi/zig/src/seams.zig`. + +== Read-first + +. `rokur_mcp_adapter.zig` β€” dispatch function and per-protocol routers. +. `../cartridge.json` β€” the tool manifest the dispatcher mirrors. diff --git a/cartridges/domains/security/rokur-mcp/adapter/build.zig b/cartridges/domains/security/rokur-mcp/adapter/build.zig new file mode 100644 index 0000000..d8e8e25 --- /dev/null +++ b/cartridges/domains/security/rokur-mcp/adapter/build.zig @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// rokur-mcp/adapter/build.zig + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const ffi_mod = b.createModule(.{ + .root_source_file = b.path("../ffi/rokur_mcp_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + const adapter = b.addExecutable(.{ + .name = "rokur_mcp_adapter", + .root_source_file = b.path("rokur_mcp_adapter.zig"), + .target = target, + .optimize = optimize, + }); + adapter.root_module.addImport("rokur_mcp_ffi", ffi_mod); + b.installArtifact(adapter); +} diff --git a/cartridges/domains/security/rokur-mcp/adapter/rokur_mcp_adapter.zig b/cartridges/domains/security/rokur-mcp/adapter/rokur_mcp_adapter.zig new file mode 100644 index 0000000..2b29359 --- /dev/null +++ b/cartridges/domains/security/rokur-mcp/adapter/rokur_mcp_adapter.zig @@ -0,0 +1,129 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// rokur-mcp/adapter/rokur_mcp_adapter.zig +// +// Three-protocol BoJ adapter: REST (port 9286), gRPC-compat (port 9287), +// GraphQL (port 9288). +// Replaces the banned zig adapter (rokur_mcp_adapter.v). + +const std = @import("std"); +const ffi = @import("rokur_mcp_ffi"); + +const REST_PORT: u16 = 9286; +const GRPC_PORT: u16 = 9287; +const GQL_PORT: u16 = 9288; + +const Response = struct { status: u16, body: []const u8 }; + +fn okJson(buf: []u8, msg: []const u8) []u8 { + return std.fmt.bufPrint(buf, "{{\"success\":true,\"message\":\"{s}\"}}", .{msg}) catch buf[0..0]; +} + +fn errJson(buf: []u8, msg: []const u8) []u8 { + return std.fmt.bufPrint(buf, "{{\"success\":false,\"error\":\"{s}\"}}", .{msg}) catch buf[0..0]; +} + +fn dispatch(tool: []const u8, _body: []const u8, resp: []u8) Response { + _ = _body; + if (std.mem.eql(u8, tool, "rokur_authorize")) { + return .{ .status = 200, .body = okJson(resp, "rokur_authorize forwarded") }; + } + if (std.mem.eql(u8, tool, "rokur_health")) { + return .{ .status = 200, .body = okJson(resp, "rokur_health forwarded") }; + } + if (std.mem.eql(u8, tool, "rokur_secrets_status")) { + return .{ .status = 200, .body = okJson(resp, "rokur_secrets_status forwarded") }; + } + if (std.mem.eql(u8, tool, "rokur_reload")) { + return .{ .status = 200, .body = okJson(resp, "rokur_reload forwarded") }; + } + return .{ .status = 404, .body = errJson(resp, "unknown tool") }; +} + +fn dispatchRest(path: []const u8, body: []const u8, resp: []u8) Response { + // Expect /tools/<tool_name> + const prefix = "/tools/"; + if (std.mem.startsWith(u8, path, prefix)) { + const tool = path[prefix.len..]; + return dispatch(tool, body, resp); + } + return .{ .status = 404, .body = errJson(resp, "not found") }; +} + +fn dispatchGrpc(path: []const u8, body: []const u8, resp: []u8) Response { + // Expect /<Service>/<Method> β€” derive tool from Method + var it = std.mem.splitScalar(u8, path, '/'); + _ = it.next(); // leading empty + _ = it.next(); // service + const method = it.next() orelse return .{ .status = 404, .body = errJson(resp, "bad gRPC path") }; + return dispatch(method, body, resp); +} + +fn dispatchGraphql(body: []const u8, resp: []u8) Response { + if (std.mem.indexOf(u8, body, "rokur_authorize") != null) + return dispatch("rokur_authorize", body, resp); + if (std.mem.indexOf(u8, body, "rokur_health") != null) + return dispatch("rokur_health", body, resp); + if (std.mem.indexOf(u8, body, "rokur_secrets_status") != null) + return dispatch("rokur_secrets_status", body, resp); + if (std.mem.indexOf(u8, body, "rokur_reload") != null) + return dispatch("rokur_reload", body, resp); + return .{ .status = 400, .body = errJson(resp, "unrecognised GraphQL operation") }; +} + +fn handleConnection(stream: std.net.Stream, port: u16) void { + defer stream.close(); + var buf: [4096]u8 = undefined; + var resp_buf: [4096]u8 = undefined; + const n = stream.read(&buf) catch return; + const req = buf[0..n]; + const result = switch (port) { + REST_PORT => blk: { + // Parse HTTP/1.1: first line = METHOD PATH HTTP/x.y + var lines = std.mem.splitScalar(u8, req, '\n'); + const first = lines.next() orelse break :blk Response{ .status = 400, .body = "" }; + var parts = std.mem.splitScalar(u8, std.mem.trim(u8, first, "\r"), ' '); + _ = parts.next(); // method + const path = parts.next() orelse break :blk Response{ .status = 400, .body = "" }; + break :blk dispatchRest(path, req, &resp_buf); + }, + GRPC_PORT => blk: { + var lines = std.mem.splitScalar(u8, req, '\n'); + const first = lines.next() orelse break :blk Response{ .status = 400, .body = "" }; + var parts = std.mem.splitScalar(u8, std.mem.trim(u8, first, "\r"), ' '); + _ = parts.next(); + const path = parts.next() orelse break :blk Response{ .status = 400, .body = "" }; + break :blk dispatchGrpc(path, req, &resp_buf); + }, + GQL_PORT => dispatchGraphql(req, &resp_buf), + else => Response{ .status = 500, .body = "" }, + }; + var http_resp: [512]u8 = undefined; + const http = std.fmt.bufPrint(&http_resp, + "HTTP/1.1 {d} OK\r\nContent-Length: {d}\r\nContent-Type: application/json\r\n\r\n", + .{ result.status, result.body.len }) catch return; + _ = stream.write(http) catch {}; + _ = stream.write(result.body) catch {}; +} + +fn listenLoop(port: u16) !void { + const addr = std.net.Address.initIp4(.{ 127, 0, 0, 1 }, port); + var server = try addr.listen(.{ .reuse_address = true }); + defer server.deinit(); + while (true) { + const conn = try server.accept(); + const t = try std.Thread.spawn(.{}, handleConnection, .{ conn.stream, port }); + t.detach(); + } +} + +pub fn main() !void { + ffi.rokur_mcp_init(); + const t1 = try std.Thread.spawn(.{}, listenLoop, .{REST_PORT}); + const t2 = try std.Thread.spawn(.{}, listenLoop, .{GRPC_PORT}); + const t3 = try std.Thread.spawn(.{}, listenLoop, .{GQL_PORT}); + t1.join(); + t2.join(); + t3.join(); +} diff --git a/cartridges/domains/security/rokur-mcp/cartridge.json b/cartridges/domains/security/rokur-mcp/cartridge.json new file mode 100644 index 0000000..15f31c7 --- /dev/null +++ b/cartridges/domains/security/rokur-mcp/cartridge.json @@ -0,0 +1,85 @@ +{ + "$schema": "https://boj.dev/schemas/cartridge/v1.json", + "spdx": "MPL-2.0", + "copyright": "Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk>", + "name": "rokur-mcp", + "version": "0.1.0", + "description": "Rokur β€” Svalinn secrets GUI authorisation layer", + "domain": "security", + "tier": "Ayo", + "protocols": [ + "MCP", + "REST" + ], + "auth": { + "method": "none", + "env_var": null, + "credential_source": null + }, + "api": { + "base_url": "local://rokur-mcp", + "content_type": "application/json" + }, + "tools": [ + { + "name": "rokur_authorize", + "description": "Authorise a secrets access request", + "inputSchema": { + "type": "object", + "properties": { + "requester": { + "type": "string", + "description": "Requester identity" + }, + "secret_path": { + "type": "string", + "description": "Secret path to authorise" + }, + "operation": { + "type": "string", + "description": "Operation: read|write|list" + } + }, + "required": [ + "requester", + "secret_path" + ] + } + }, + { + "name": "rokur_health", + "description": "Check Rokur/Svalinn health", + "inputSchema": { + "type": "object", + "properties": {} + } + }, + { + "name": "rokur_secrets_status", + "description": "Get secrets backend status", + "inputSchema": { + "type": "object", + "properties": {} + } + }, + { + "name": "rokur_reload", + "description": "Reload secrets configuration", + "inputSchema": { + "type": "object", + "properties": {} + } + } + ], + "ffi": { + "so_path": "ffi/zig-out/lib/librokur_mcp.so", + "abi_version": "ADR-0006", + "symbols": [ + "boj_cartridge_init", + "boj_cartridge_deinit", + "boj_cartridge_name", + "boj_cartridge_version", + "boj_cartridge_invoke" + ] + } +} diff --git a/cartridges/domains/security/rokur-mcp/ffi/README.adoc b/cartridges/domains/security/rokur-mcp/ffi/README.adoc new file mode 100644 index 0000000..1f62114 --- /dev/null +++ b/cartridges/domains/security/rokur-mcp/ffi/README.adoc @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += rokur-mcp / ffi β€” Zig FFI layer +:orientation: shallow + +== Purpose + +C-ABI exports bridging this cartridge's Idris2 ABI to the runtime. Consumed by +the adapter for protocol dispatch and by the trunk loader for mount. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” produces `zig-out/lib/librokur.so`. +| `rokur_mcp_ffi.zig` | C-ABI exports (13 exports, 5 inline tests, 387 lines). +|=== + +== Invariants + +* **Null-pointer rejection** on `[*c]const u8` arguments before any + dereference. +* **Negative return values** carry documented error meanings; zero is + success (matching the trunk error-code convention). +* **No allocator leaks** β€” every allocation path pairs with `defer free`. + +== Test/proof surface + +5 inline `test "..."` block(s) in `rokur_mcp_ffi.zig`. Run with +`cd ffi && zig build test`. + +== Read-first + +. `rokur_mcp_ffi.zig` β€” C-ABI signatures and guards. +. Trunk `ffi/zig/src/catalogue.zig` for how this `.so` is loaded. diff --git a/cartridges/domains/security/rokur-mcp/ffi/build.zig b/cartridges/domains/security/rokur-mcp/ffi/build.zig new file mode 100644 index 0000000..80bb5bf --- /dev/null +++ b/cartridges/domains/security/rokur-mcp/ffi/build.zig @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: MPL-2.0 +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + // Shared ADR-0006 invoke-shim module (relative path up to boj-server trunk). + + const shim_mod = b.addModule("cartridge_shim", .{ + + .root_source_file = b.path("../../../ffi/zig/src/cartridge_shim.zig"), + + .target = target, + + .optimize = optimize, + + }); + + const ffi_mod = b.addModule("rokur_mcp", .{ + .root_source_file = b.path("rokur_mcp_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + ffi_mod.addImport("cartridge_shim", shim_mod); + + const lib = b.addLibrary(.{ + .name = "rokur_mcp", + .root_module = ffi_mod, + .linkage = .dynamic, + }); + lib.linkLibC(); + b.installArtifact(lib); + + const tests = b.addTest(.{ + .root_module = ffi_mod, + }); + tests.linkLibC(); + + const run_tests = b.addRunArtifact(tests); + const test_step = b.step("test", "Run FFI tests"); + test_step.dependOn(&run_tests.step); +} diff --git a/cartridges/domains/security/rokur-mcp/ffi/cartridge_shim.zig b/cartridges/domains/security/rokur-mcp/ffi/cartridge_shim.zig new file mode 100644 index 0000000..0e399db --- /dev/null +++ b/cartridges/domains/security/rokur-mcp/ffi/cartridge_shim.zig @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// cartridge_shim.zig β€” Shared helpers for the ADR-0006 five-symbol +// cartridge ABI (`boj_cartridge_init / deinit / name / version / invoke`). +// +// The shim centralises the seven-code return convention, NUL-argument +// guards, tool-name comparison, and the buffer-too-small path so each +// cartridge's `boj_cartridge_invoke` can stay short β€” typically a tool +// table plus `shim.writeResult(...)`. +// +// Cartridges import this file by relative path (no build-graph change +// needed). Example: +// +// const shim = @import("../../../ffi/zig/src/cartridge_shim.zig"); +// +// export fn boj_cartridge_invoke( +// tool_name: [*c]const u8, +// json_args: [*c]const u8, +// out_buf: [*c]u8, +// in_out_len: [*c]usize, +// ) callconv(.c) i32 { +// _ = json_args; +// if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; +// const body = if (shim.toolIs(tool_name, "foo")) "{\"result\":{}}" +// else return shim.RC_UNKNOWN_TOOL; +// return shim.writeResult(out_buf, in_out_len, body); +// } + +const std = @import("std"); + +// ── Return codes (ADR-0006 Β§Return codes) ──────────────────────────── +// +// Frozen by ADR-0006. New failure modes compose these via the error +// JSON body β€” the integer surface does not grow without a follow-up ADR. + +pub const RC_SUCCESS: i32 = 0; +pub const RC_UNKNOWN_TOOL: i32 = -1; +pub const RC_BAD_ARGS: i32 = -2; +pub const RC_BUFFER_TOO_SMALL: i32 = -3; +pub const RC_RUNTIME_ERROR: i32 = -4; +pub const RC_PANIC: i32 = -5; +pub const RC_AUTH_DENIED: i32 = -6; + +// ── Invoke-path helpers ────────────────────────────────────────────── + +/// True if any of the three mandatory `boj_cartridge_invoke` output-path +/// pointers is null. Use at the top of every invoke to short-circuit to +/// `RC_BAD_ARGS`. +pub fn invokeArgsNull( + tool_name: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) bool { + return tool_name == null or out_buf == null or in_out_len == null; +} + +/// Compare a C-NUL-terminated tool-name pointer against a Zig string +/// literal. Caller must have already verified `tool_name` is non-null +/// (usually via `invokeArgsNull`). +pub fn toolIs(tool_name: [*c]const u8, expected: []const u8) bool { + const s = std.mem.span(@as([*:0]const u8, @ptrCast(tool_name))); + return std.mem.eql(u8, s, expected); +} + +/// Copy `body` into `out_buf[0..*in_out_len]` (as a capacity) and update +/// `*in_out_len` to the number of bytes written. Returns `RC_SUCCESS`. +/// +/// If `body.len` exceeds the current capacity stored in `*in_out_len`, +/// sets `*in_out_len` to the required size and returns +/// `RC_BUFFER_TOO_SMALL` β€” the caller is then expected to re-allocate +/// and retry, per ADR-0006 Β§Memory ownership. +/// +/// Caller must have already verified that `out_buf` and `in_out_len` +/// are non-null. +pub fn writeResult( + out_buf: [*c]u8, + in_out_len: [*c]usize, + body: []const u8, +) i32 { + const cap = in_out_len.*; + if (body.len > cap) { + in_out_len.* = body.len; + return RC_BUFFER_TOO_SMALL; + } + @memcpy(out_buf[0..body.len], body); + in_out_len.* = body.len; + return RC_SUCCESS; +} + +// ── Tests ──────────────────────────────────────────────────────────── + +test "writeResult: body fits, writes and sets length" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); + try std.testing.expectEqualStrings("hello", buf[0..len]); +} + +test "writeResult: too small returns -3 and sets required length" { + var buf: [2]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_BUFFER_TOO_SMALL, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: exact-fit succeeds" { + var buf: [5]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: empty body" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, ""); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 0), len); +} + +test "toolIs: matches and rejects" { + const name: [*:0]const u8 = "foo"; + try std.testing.expect(toolIs(@ptrCast(name), "foo")); + try std.testing.expect(!toolIs(@ptrCast(name), "bar")); + try std.testing.expect(!toolIs(@ptrCast(name), "foobar")); + try std.testing.expect(!toolIs(@ptrCast(name), "fo")); +} + +test "invokeArgsNull: detects each null slot" { + var buf: [4]u8 = undefined; + var len: usize = 4; + const name: [*:0]const u8 = "x"; + try std.testing.expect(!invokeArgsNull(@ptrCast(name), &buf, &len)); + try std.testing.expect(invokeArgsNull(null, &buf, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), null, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), &buf, null)); +} diff --git a/cartridges/domains/security/rokur-mcp/ffi/rokur_mcp_ffi.zig b/cartridges/domains/security/rokur-mcp/ffi/rokur_mcp_ffi.zig new file mode 100644 index 0000000..5f47499 --- /dev/null +++ b/cartridges/domains/security/rokur-mcp/ffi/rokur_mcp_ffi.zig @@ -0,0 +1,478 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// rokur_mcp_ffi.zig β€” C-ABI FFI for the rokur-mcp cartridge. +// +// Container pre-start secrets gate. Communicates with the Rokur sidecar +// service (Deno, default http://127.0.0.1:9090) to validate that required +// secrets are present before allowing containers to start. +// +// Thread-safe via std.Thread.Mutex. No heap allocations for result buffers. + +const std = @import("std"); + +// --------------------------------------------------------------------------- +// Gate state machine (matches Idris2 ABI: RokurMcp.SafeGate) +// --------------------------------------------------------------------------- + +pub const GateState = enum(c_int) { + idle = 0, + checking = 1, + allowed = 2, + denied = 3, + err = 4, +}; + +pub const GateAction = enum(c_int) { + authorize_start = 0, + check_status = 1, + reload_secrets = 2, + query_health = 3, +}; + +fn isValidTransition(from: GateState, to: GateState) bool { + return switch (from) { + .idle => to == .checking, + .checking => to == .allowed or to == .denied or to == .err, + .allowed => to == .idle, + .denied => to == .idle, + .err => to == .idle, + }; +} + +fn isActionPermitted(state: GateState, action: GateAction) bool { + return switch (action) { + .query_health => true, + .check_status => true, + .reload_secrets => state == .idle or state == .allowed or state == .denied, + .authorize_start => state == .idle, + }; +} + +// --------------------------------------------------------------------------- +// Rokur proxy state +// --------------------------------------------------------------------------- + +const RESULT_BUF_SIZE: usize = 4096; +const ROKUR_DEFAULT_URL: []const u8 = "http://127.0.0.1:9090"; + +const GateProxy = struct { + state: GateState = .idle, + result_buf: [RESULT_BUF_SIZE]u8 = undefined, + result_len: usize = 0, + last_error: [512]u8 = undefined, + last_error_len: usize = 0, + last_verdict_allowed: bool = false, + required_count: u32 = 0, + missing_count: u32 = 0, + check_count: u32 = 0, +}; + +var proxy: GateProxy = .{}; +var mutex: std.Thread.Mutex = .{}; + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +fn setError(msg: []const u8) void { + const len = @min(msg.len, proxy.last_error.len); + @memcpy(proxy.last_error[0..len], msg[0..len]); + proxy.last_error_len = len; +} + +/// Call Rokur sidecar REST API via curl. +/// We use curl rather than std.http to avoid TLS complexity in Zig. +fn callRokur(path: []const u8, method: []const u8, token: []const u8) !usize { + // Build URL + var url_buf: [512]u8 = undefined; + const url = std.fmt.bufPrint(&url_buf, "{s}{s}", .{ ROKUR_DEFAULT_URL, path }) catch return error.UrlTooLong; + + // Build auth header + var auth_buf: [256]u8 = undefined; + const auth_header = std.fmt.bufPrint(&auth_buf, "X-Rokur-Token: {s}", .{token}) catch return error.TokenTooLong; + + const argv = [_][]const u8{ + "curl", "-s", "-X", method, "-H", auth_header, url, + }; + + var child = std.process.Child.init(&argv, std.heap.page_allocator); + child.stdout_behavior = .Pipe; + child.stderr_behavior = .Pipe; + try child.spawn(); + + const stdout = child.stdout.?; + const bytes_read = stdout.readAll(&proxy.result_buf) catch |e| { + _ = child.wait() catch {}; + return e; + }; + + const stderr = child.stderr.?; + const err_read = stderr.readAll(&proxy.last_error) catch 0; + proxy.last_error_len = err_read; + + const term = try child.wait(); + if (term.Exited != 0) return error.CurlFailed; + + proxy.result_len = bytes_read; + return bytes_read; +} + +// --------------------------------------------------------------------------- +// C-ABI exports β€” state machine +// --------------------------------------------------------------------------- + +pub export fn rokur_mcp_can_transition(from: c_int, to: c_int) c_int { + const f = std.meta.intToEnum(GateState, from) catch return 0; + const t = std.meta.intToEnum(GateState, to) catch return 0; + return if (isValidTransition(f, t)) 1 else 0; +} + +pub export fn rokur_mcp_state() c_int { + mutex.lock(); + defer mutex.unlock(); + return @intFromEnum(proxy.state); +} + +pub export fn rokur_mcp_transition(to: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + const target = std.meta.intToEnum(GateState, to) catch return -1; + if (!isValidTransition(proxy.state, target)) return -2; + proxy.state = target; + return 0; +} + +pub export fn rokur_mcp_action_permitted(action: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + const a = std.meta.intToEnum(GateAction, action) catch return 0; + return if (isActionPermitted(proxy.state, a)) 1 else 0; +} + +// --------------------------------------------------------------------------- +// C-ABI exports β€” gate operations +// --------------------------------------------------------------------------- + +/// POST /v1/authorize-start β€” request pre-start authorization. +/// Calls Rokur sidecar, transitions to Allowed/Denied/Error. +/// +/// Parameters: +/// token_ptr/token_len: ROKUR_API_TOKEN for authentication +/// image_ptr/image_len: container image name (for policy context) +/// +/// Returns: bytes in result buffer, or negative error code. +/// -1 = not in idle state +/// -2 = Rokur call failed +/// -3 = null pointer +pub export fn rokur_mcp_authorize( + token_ptr: [*c]const u8, + token_len: c_int, + image_ptr: [*c]const u8, + image_len: c_int, +) c_int { + mutex.lock(); + defer mutex.unlock(); + + if (proxy.state != .idle) { + setError("gate not in idle state"); + return -1; + } + + if (token_ptr == null) return -3; + + const t_ulen: usize = std.math.cast(usize, token_len) orelse return -3; + const token = token_ptr[0..t_ulen]; + + // image_ptr/image_len reserved for future policy context (container name). + // Currently Rokur's /v1/authorize-start doesn't require it in the URL, + // but we validate the parameters for forward compatibility. + if (image_ptr == null) return -3; + const i_ulen: usize = std.math.cast(usize, image_len) orelse return -3; + const image = image_ptr[0..i_ulen]; + _ = image; + + // Transition to checking + proxy.state = .checking; + proxy.check_count += 1; + + const bytes = callRokur("/v1/authorize-start", "POST", token) catch { + setError("rokur sidecar unreachable"); + proxy.state = .err; + return -2; + }; + + // Parse the "allowed" field from JSON response. + // Rokur returns: {"allowed":true/false, "policy":"allow"/"deny", ...} + const result = proxy.result_buf[0..bytes]; + if (std.mem.indexOf(u8, result, "\"allowed\":true")) |_| { + proxy.state = .allowed; + proxy.last_verdict_allowed = true; + } else { + proxy.state = .denied; + proxy.last_verdict_allowed = false; + } + + return @intCast(bytes); +} + +/// GET /health β€” Rokur sidecar liveness check. +pub export fn rokur_mcp_health(token_ptr: [*c]const u8, token_len: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + if (token_ptr == null) return -3; + const t_ulen: usize = std.math.cast(usize, token_len) orelse return -3; + const token = token_ptr[0..t_ulen]; + + const bytes = callRokur("/health", "GET", token) catch { + setError("rokur health check failed"); + return -2; + }; + + return @intCast(bytes); +} + +/// GET /v1/secrets/status β€” query secrets presence status. +pub export fn rokur_mcp_secrets_status(token_ptr: [*c]const u8, token_len: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + if (token_ptr == null) return -3; + const t_ulen: usize = std.math.cast(usize, token_len) orelse return -3; + const token = token_ptr[0..t_ulen]; + + const bytes = callRokur("/v1/secrets/status", "GET", token) catch { + setError("rokur secrets status failed"); + return -2; + }; + + return @intCast(bytes); +} + +/// POST /v1/secrets/reload β€” hot-reload required secrets. +pub export fn rokur_mcp_reload(token_ptr: [*c]const u8, token_len: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + if (token_ptr == null) return -3; + const t_ulen: usize = std.math.cast(usize, token_len) orelse return -3; + const token = token_ptr[0..t_ulen]; + + const bytes = callRokur("/v1/secrets/reload", "POST", token) catch { + setError("rokur reload failed"); + return -2; + }; + + return @intCast(bytes); +} + +/// Read the result buffer from the last operation. +pub export fn rokur_mcp_read_result(out_ptr: [*c]u8, max_len: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + if (out_ptr == null) return -1; + const umax: usize = std.math.cast(usize, max_len) orelse return -1; + const copy_len = @min(proxy.result_len, umax); + @memcpy(out_ptr[0..copy_len], proxy.result_buf[0..copy_len]); + return @intCast(copy_len); +} + +/// Read the last error message. +pub export fn rokur_mcp_read_error(out_ptr: [*c]u8, max_len: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + if (out_ptr == null) return -1; + const umax: usize = std.math.cast(usize, max_len) orelse return -1; + const copy_len = @min(proxy.last_error_len, umax); + @memcpy(out_ptr[0..copy_len], proxy.last_error[0..copy_len]); + return @intCast(copy_len); +} + +/// Get the last authorization verdict (1=allowed, 0=denied). +pub export fn rokur_mcp_last_verdict() c_int { + mutex.lock(); + defer mutex.unlock(); + return if (proxy.last_verdict_allowed) 1 else 0; +} + +/// Get total authorization check count. +pub export fn rokur_mcp_check_count() c_int { + mutex.lock(); + defer mutex.unlock(); + return @intCast(proxy.check_count); +} + +/// Reset gate to initial state (test/debug only). +pub export fn rokur_mcp_reset() void { + mutex.lock(); + defer mutex.unlock(); + proxy = .{}; +} + +// --------------------------------------------------------------------------- +// ═══════════════════════════════════════════════════════════════════════ +// Standard ABI (ADR-0005 four symbols + ADR-0006 invoke) +// ═══════════════════════════════════════════════════════════════════════ + +const shim = @import("cartridge_shim.zig"); + +const CARTRIDGE_NAME_PTR: [*:0]const u8 = "rokur-mcp"; +const CARTRIDGE_VERSION_PTR: [*:0]const u8 = "0.1.0"; + +export fn boj_cartridge_init() callconv(.c) c_int { + return 0; +} + +export fn boj_cartridge_deinit() callconv(.c) void {} + +export fn boj_cartridge_name() callconv(.c) [*:0]const u8 { + return CARTRIDGE_NAME_PTR; +} + +export fn boj_cartridge_version() callconv(.c) [*:0]const u8 { + return CARTRIDGE_VERSION_PTR; +} + +/// Dispatch the cartridge.json MCP tools. Grade D Alpha stubs. +export fn boj_cartridge_invoke( + tool_name: [*c]const u8, + json_args: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) callconv(.c) i32 { + _ = json_args; + if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; + + const body: []const u8 = if (shim.toolIs(tool_name, "rokur_authorize")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "rokur_health")) + "{\"result\":{\"health\":\"healthy\",\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "rokur_secrets_status")) + "{\"result\":{\"metadata\":{},\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "rokur_reload")) + "{\"result\":{\"status\":\"stub\"}}" +else + return shim.RC_UNKNOWN_TOOL; + + return shim.writeResult(out_buf, in_out_len, body); +} + +// Tests +// --------------------------------------------------------------------------- + +test "gate state transitions" { + rokur_mcp_reset(); + try std.testing.expectEqual(@as(c_int, 0), rokur_mcp_state()); // idle + + // Idle -> Checking + try std.testing.expectEqual(@as(c_int, 0), rokur_mcp_transition(1)); + try std.testing.expectEqual(@as(c_int, 1), rokur_mcp_state()); + + // Checking -> Allowed + try std.testing.expectEqual(@as(c_int, 0), rokur_mcp_transition(2)); + try std.testing.expectEqual(@as(c_int, 2), rokur_mcp_state()); + + // Allowed -> Idle (reset) + try std.testing.expectEqual(@as(c_int, 0), rokur_mcp_transition(0)); + try std.testing.expectEqual(@as(c_int, 0), rokur_mcp_state()); +} + +test "invalid gate transitions" { + rokur_mcp_reset(); + + // Idle -> Allowed directly (must go through Checking) + try std.testing.expectEqual(@as(c_int, -2), rokur_mcp_transition(2)); + + // Idle -> Denied directly + try std.testing.expectEqual(@as(c_int, -2), rokur_mcp_transition(3)); + + // Go to Checking -> Denied + _ = rokur_mcp_transition(1); // idle -> checking + _ = rokur_mcp_transition(3); // checking -> denied + + // Denied -> Allowed (invalid, must reset to Idle first) + try std.testing.expectEqual(@as(c_int, -2), rokur_mcp_transition(2)); + + // Denied -> Idle (valid reset) + try std.testing.expectEqual(@as(c_int, 0), rokur_mcp_transition(0)); +} + +test "gate transition validator" { + try std.testing.expectEqual(@as(c_int, 1), rokur_mcp_can_transition(0, 1)); // idle -> checking + try std.testing.expectEqual(@as(c_int, 1), rokur_mcp_can_transition(1, 2)); // checking -> allowed + try std.testing.expectEqual(@as(c_int, 1), rokur_mcp_can_transition(1, 3)); // checking -> denied + try std.testing.expectEqual(@as(c_int, 1), rokur_mcp_can_transition(1, 4)); // checking -> error + try std.testing.expectEqual(@as(c_int, 1), rokur_mcp_can_transition(2, 0)); // allowed -> idle + try std.testing.expectEqual(@as(c_int, 1), rokur_mcp_can_transition(3, 0)); // denied -> idle + try std.testing.expectEqual(@as(c_int, 1), rokur_mcp_can_transition(4, 0)); // error -> idle + + try std.testing.expectEqual(@as(c_int, 0), rokur_mcp_can_transition(0, 2)); // idle -> allowed + try std.testing.expectEqual(@as(c_int, 0), rokur_mcp_can_transition(2, 3)); // allowed -> denied +} + +test "action permissions" { + rokur_mcp_reset(); + + // Health always allowed + try std.testing.expectEqual(@as(c_int, 1), rokur_mcp_action_permitted(3)); + // Status always allowed + try std.testing.expectEqual(@as(c_int, 1), rokur_mcp_action_permitted(1)); + // Authorize requires idle + try std.testing.expectEqual(@as(c_int, 1), rokur_mcp_action_permitted(0)); + + // Transition to checking β€” authorize should be blocked + _ = rokur_mcp_transition(1); + try std.testing.expectEqual(@as(c_int, 0), rokur_mcp_action_permitted(0)); +} + +test "authorize requires idle state" { + rokur_mcp_reset(); + _ = rokur_mcp_transition(1); // idle -> checking + const result = rokur_mcp_authorize("token", 5, "nginx:latest", 12); + try std.testing.expectEqual(@as(c_int, -1), result); +} + +// ═══════════════════════════════════════════════════════════════════════ +// ADR-0006 invoke dispatch tests +// ═══════════════════════════════════════════════════════════════════════ + +test "boj_cartridge_name returns rokur-mcp" { + const n = std.mem.span(boj_cartridge_name()); + try std.testing.expectEqualStrings("rokur-mcp", n); +} + +test "boj_cartridge_init returns 0" { + try std.testing.expectEqual(@as(c_int, 0), boj_cartridge_init()); +} + +test "invoke: each declared tool succeeds" { + var buf: [256]u8 = undefined; + const tools = [_][]const u8{ + "rokur_authorize", + "rokur_health", + "rokur_secrets_status", + "rokur_reload", + }; + for (tools) |t| { + var len: usize = buf.len; + const rc = boj_cartridge_invoke(t.ptr, "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, 0), rc); + try std.testing.expect(std.mem.indexOf(u8, buf[0..len], "result") != null); + } +} + +test "invoke: unknown tool returns -1" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("nope", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -1), rc); +} + +test "invoke: buffer too small returns -3" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("rokur_authorize", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -3), rc); + try std.testing.expect(len > 4); +} diff --git a/cartridges/domains/security/rokur-mcp/mcp-tools.json b/cartridges/domains/security/rokur-mcp/mcp-tools.json new file mode 100644 index 0000000..ca3d6f9 --- /dev/null +++ b/cartridges/domains/security/rokur-mcp/mcp-tools.json @@ -0,0 +1,45 @@ +{ + "$schema": "https://spec.modelcontextprotocol.io/2024-11-05/schema/tools", + "spdx": "MPL-2.0", + "cartridge": "rokur-mcp", + "tools": [ + { + "name": "boj_rokur_authorize_start", + "description": "Request pre-start authorization for a container. Rokur validates that all required secrets are present. Returns allow/deny verdict β€” never secret values. Gate must be in idle state.", + "inputSchema": { + "type": "object", + "properties": { + "image": { + "type": "string", + "description": "Container image name (e.g. 'nginx:latest', 'ghcr.io/hyperpolymath/panll:v0.1.0')" + } + }, + "required": ["image"] + } + }, + { + "name": "boj_rokur_status", + "description": "Query the secrets presence status. Returns required/missing counts β€” never secret names or values. Available in any gate state.", + "inputSchema": { + "type": "object", + "properties": {} + } + }, + { + "name": "boj_rokur_reload", + "description": "Hot-reload required secrets from the environment without restarting the Rokur sidecar. Use after deploying new secrets.", + "inputSchema": { + "type": "object", + "properties": {} + } + }, + { + "name": "boj_rokur_health", + "description": "Liveness check on the Rokur sidecar service. Returns health status and non-sensitive configuration.", + "inputSchema": { + "type": "object", + "properties": {} + } + } + ] +} diff --git a/cartridges/domains/security/rokur-mcp/minter.toml b/cartridges/domains/security/rokur-mcp/minter.toml new file mode 100644 index 0000000..8bacede --- /dev/null +++ b/cartridges/domains/security/rokur-mcp/minter.toml @@ -0,0 +1,12 @@ +# SPDX-License-Identifier: MPL-2.0 +name = "rokur-mcp" +description = "Container pre-start secrets gate β€” validates required secrets before launch" +version = "0.1.0" +domain = "Secrets" +protocols = [ + "MCP", + "REST", +] +tier = "Ayo" +backend = "universal" +generate_panel = true diff --git a/cartridges/domains/security/rokur-mcp/mod.js b/cartridges/domains/security/rokur-mcp/mod.js new file mode 100644 index 0000000..1044da7 --- /dev/null +++ b/cartridges/domains/security/rokur-mcp/mod.js @@ -0,0 +1,55 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// rokur-mcp/mod.js β€” Rokur β€” Svalinn secrets GUI authorisation layer +// +// Delegates to backend at http://127.0.0.1:7740 (override with ROKUR_BACKEND_URL). + +const BASE_URL = Deno.env.get("ROKUR_BACKEND_URL") ?? "http://127.0.0.1:7740"; +const TIMEOUT_MS = 15_000; + +async function post(path, payload) { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), TIMEOUT_MS); + try { + const r = await fetch(`${BASE_URL}${path}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + signal: ctrl.signal, + }); + const data = await r.json().catch(() => ({ success: false, error: "non-JSON response" })); + return { status: r.status, data }; + } catch (e) { + if (e.name === "AbortError") return { status: 504, data: { success: false, error: "rokur-mcp backend timed out" } }; + return { status: 503, data: { success: false, error: `rokur-mcp backend unavailable: ${e.message}` } }; + } finally { clearTimeout(t); } +} + +async function get(path) { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), TIMEOUT_MS); + try { + const r = await fetch(`${BASE_URL}${path}`, { method: "GET", signal: ctrl.signal }); + const data = await r.json().catch(() => ({ success: false, error: "non-JSON response" })); + return { status: r.status, data }; + } catch (e) { + if (e.name === "AbortError") return { status: 504, data: { success: false, error: "rokur-mcp backend timed out" } }; + return { status: 503, data: { success: false, error: `rokur-mcp backend unavailable: ${e.message}` } }; + } finally { clearTimeout(t); } +} + +export async function handleTool(toolName, args) { + switch (toolName) { + case "rokur_authorize": + return post("/api/v1/rokur_authorize", args ?? {}); + case "rokur_health": + return post("/api/v1/rokur_health", args ?? {}); + case "rokur_secrets_status": + return post("/api/v1/rokur_secrets_status", args ?? {}); + case "rokur_reload": + return post("/api/v1/rokur_reload", args ?? {}); + default: + return { status: 404, data: { error: `Unknown tool: ${toolName}` } }; + } +} diff --git a/cartridges/domains/security/rokur-mcp/panels/manifest.json b/cartridges/domains/security/rokur-mcp/panels/manifest.json new file mode 100644 index 0000000..8baf871 --- /dev/null +++ b/cartridges/domains/security/rokur-mcp/panels/manifest.json @@ -0,0 +1,81 @@ +{ + "$schema": "https://panll.dev/schemas/panel-manifest/v1.json", + "spdx": "MPL-2.0", + "cartridge": "rokur-mcp", + "domain": "Secrets", + "version": "0.1.0", + "panels": [ + { + "id": "rokur-gate-state", + "title": "Gate State", + "description": "Current container authorization gate state", + "type": "status-indicator", + "data_source": { + "endpoint": "/rokur/state", + "method": "GET", + "refresh_interval_ms": 3000 + }, + "widgets": [ + { + "type": "state-badge", + "field": "state", + "states": { + "idle": { "color": "#95a5a6", "icon": "circle-pause" }, + "checking": { "color": "#3498db", "icon": "loader" }, + "allowed": { "color": "#2ecc71", "icon": "check-circle" }, + "denied": { "color": "#e74c3c", "icon": "x-circle" }, + "error": { "color": "#f39c12", "icon": "alert-triangle" } + } + } + ] + }, + { + "id": "rokur-check-count", + "title": "Authorization Checks", + "description": "Total container authorization checks performed", + "type": "metric", + "data_source": { + "endpoint": "/rokur/status", + "method": "GET", + "refresh_interval_ms": 10000 + }, + "widgets": [ + { + "type": "counter", + "field": "check_count", + "label": "Total Checks", + "icon": "shield-check" + }, + { + "type": "text", + "field": "last_verdict", + "label": "Last Verdict" + } + ] + }, + { + "id": "rokur-secrets-presence", + "title": "Secrets Presence", + "description": "Required vs missing secrets count (names never exposed)", + "type": "metric", + "data_source": { + "endpoint": "/rokur/secrets/status", + "method": "GET", + "refresh_interval_ms": 15000 + }, + "widgets": [ + { + "type": "counter", + "field": "requiredSecretCount", + "label": "Required" + }, + { + "type": "counter", + "field": "missingSecretCount", + "label": "Missing", + "alert_threshold": 1 + } + ] + } + ] +} diff --git a/cartridges/domains/security/rokur-mcp/tests/integration_test.sh b/cartridges/domains/security/rokur-mcp/tests/integration_test.sh new file mode 100644 index 0000000..6b0c572 --- /dev/null +++ b/cartridges/domains/security/rokur-mcp/tests/integration_test.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Integration tests for rokur-mcp cartridge. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CART_DIR="$(dirname "$SCRIPT_DIR")" +FFI_DIR="$CART_DIR/ffi" + +echo "=== rokur-mcp integration tests ===" + +# Build FFI +echo "[1/4] Building FFI..." +cd "$FFI_DIR" && zig build 2>&1 + +# Run Zig unit tests +echo "[2/4] Running FFI unit tests..." +cd "$FFI_DIR" && zig build test 2>&1 + +# Validate Idris2 ABI +echo "[3/4] Checking ABI..." +if command -v idris2 &>/dev/null; then + cd "$CART_DIR/abi" && idris2 --check RokurMcp.SafeGate 2>&1 + echo " ABI: OK" +else + echo " ABI: SKIPPED (idris2 not in PATH)" +fi + +# Validate MCP tool definitions +echo "[4/4] Validating MCP tool definitions..." +if [ -f "$CART_DIR/mcp-tools.json" ]; then + if command -v deno &>/dev/null; then + deno eval "const t = JSON.parse(await Deno.readTextFile('$CART_DIR/mcp-tools.json')); console.log(' Tools:', t.tools.length, 'defined'); for (const tool of t.tools) { console.log(' -', tool.name); }" 2>&1 + else + echo " MCP tools: JSON present (no validator available)" + fi +else + echo " MCP tools: MISSING" + exit 1 +fi + +echo "" +echo "All tests passed for rokur-mcp!" diff --git a/cartridges/domains/security/secrets-mcp/README.adoc b/cartridges/domains/security/secrets-mcp/README.adoc new file mode 100644 index 0000000..a37c8e5 --- /dev/null +++ b/cartridges/domains/security/secrets-mcp/README.adoc @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += secrets-mcp +Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk> +:spdx: MPL-2.0 +:tier: Ayo +:domain: security +:protocols: MCP, REST + +== Overview + +Secrets management (Vault, SOPS, env-vault) + +== Tools (5) + +[cols="2,4"] +|=== +| Tool | Description + +| `secrets_unseal` | Unseal the secrets backend +| `secrets_authenticate` | Authenticate with secrets backend +| `secrets_get` | Retrieve a secret +| `secrets_set` | Write a secret +| `secrets_seal` | Seal the secrets backend +|=== + +== Architecture + +Three-layer stack per the BoJ cartridge standard: + +[cols="1,1,3"] +|=== +| Layer | Language | Purpose + +| ABI | Idris2 | Formally verified state machine with dependent-type proofs +| FFI | Zig | C-ABI implementation: session pool, action metrics, thread safety +| Adapter| Zig | Adapter bridge to the BoJ unified adapter protocol (zig predecessors sidelined) +|=== + +== Building + +[source,bash] +---- +# ABI (Idris2) +cd abi && idris2 --check *.idr + +# FFI (Zig) +cd ffi && zig build +cd ffi && zig build test + +# Adapter (Zig) +cd adapter && zig build +---- + +== Status + +Alpha (CRG D). Cartridge metadata declared in `cartridge.json`; 5 tool(s) exposed. +Not yet dogfooded externally. See `docs/READINESS.md` at repo root for the +project-wide grade table and `docs/practice/DOGFOOD-LOG.adoc` for usage evidence. diff --git a/cartridges/domains/security/secrets-mcp/abi/README.adoc b/cartridges/domains/security/secrets-mcp/abi/README.adoc new file mode 100644 index 0000000..b5ed0ad --- /dev/null +++ b/cartridges/domains/security/secrets-mcp/abi/README.adoc @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += secrets-mcp / abi β€” Idris2 ABI layer +:orientation: shallow + +== Purpose + +Cartridge-local protocol types for `secrets-mcp`. Defines the data, tool, and +transition vocabulary this cartridge speaks. Inherits safety lemmas and the +matrix-cell identifier scheme from the trunk ABI at `src/abi/`. + +== Files + +1 Idris2 module(s), ~200 lines total. Lead module: +`SafeSecrets.idr`. + +No `.ipkg` β€” consumed directly by wider BoJ callers; type-checks inside the +trunk `boj.ipkg`. + +== Invariants + +* `%default total` at every module top. +* Zero `believe_me`, `assert_total`, `postulate`, `Admitted`, or + `sorry` (the trunk holds the four declared `believe_me` sites; leaf + cartridges carry none). + +== Test/proof surface + +Thin module(s), no inline tests. Proof surface = type-check success under the +trunk `idris2 --check`. + +== Read-first + +. `SafeSecrets.idr` β€” cartridge-local data records and enums. +. Trunk `src/abi/Boj/Catalogue.idr` for how this cell participates in the + 2D matrix. diff --git a/cartridges/domains/security/secrets-mcp/abi/SecretsMcp/SafeSecrets.idr b/cartridges/domains/security/secrets-mcp/abi/SecretsMcp/SafeSecrets.idr new file mode 100644 index 0000000..018eb9b --- /dev/null +++ b/cartridges/domains/security/secrets-mcp/abi/SecretsMcp/SafeSecrets.idr @@ -0,0 +1,200 @@ +-- SPDX-License-Identifier: MPL-2.0 +-- Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +||| SecretsMcp.SafeSecrets: Formally verified secret management operations. +||| +||| Cartridge: secrets-mcp +||| Matrix cell: Secrets domain x {MCP, LSP} protocols +||| +||| This module defines type-safe vault operations with a +||| seal/unseal state machine that prevents: +||| - Reading secrets from a sealed vault +||| - Accessing secrets without authentication +||| - Secret exposure in logs (audit trail enforcement) +||| +||| Supports Vault, SOPS, and env-vault backends. +module SecretsMcp.SafeSecrets + +import Data.List + +%default total + +-- ═══════════════════════════════════════════════════════════════════════════ +-- Vault State Machine +-- ═══════════════════════════════════════════════════════════════════════════ + +||| Vault lifecycle states. +||| A vault progresses: Sealed -> Unsealed -> Authenticated -> Accessing -> Authenticated -> Sealed +public export +data VaultState = Sealed | Unsealed | Authenticated | Accessing | SecretError + +||| Equality for vault states. +public export +Eq VaultState where + Sealed == Sealed = True + Unsealed == Unsealed = True + Authenticated == Authenticated = True + Accessing == Accessing = True + SecretError == SecretError = True + _ == _ = False + +||| Valid state transitions (enforced at the type level). +public export +data ValidTransition : VaultState -> VaultState -> Type where + Unseal : ValidTransition Sealed Unsealed + Authenticate : ValidTransition Unsealed Authenticated + BeginAccess : ValidTransition Authenticated Accessing + EndAccess : ValidTransition Accessing Authenticated + Deauth : ValidTransition Authenticated Unsealed + Seal : ValidTransition Unsealed Sealed + AccessError : ValidTransition Accessing SecretError + Recover : ValidTransition SecretError Authenticated + +||| Runtime transition validator. +public export +canTransition : VaultState -> VaultState -> Bool +canTransition Sealed Unsealed = True +canTransition Unsealed Authenticated = True +canTransition Authenticated Accessing = True +canTransition Accessing Authenticated = True +canTransition Authenticated Unsealed = True +canTransition Unsealed Sealed = True +canTransition Accessing SecretError = True +canTransition SecretError Authenticated = True +canTransition _ _ = False + +-- ═══════════════════════════════════════════════════════════════════════════ +-- Secret Backend Types +-- ═══════════════════════════════════════════════════════════════════════════ + +||| Supported secret management backends. +public export +data SecretBackend + = Vault -- HashiCorp Vault + | SOPS -- Mozilla SOPS + | EnvVault -- Environment-based vault + | Custom String -- User-defined backend + +||| C-ABI encoding for backends. +public export +backendToInt : SecretBackend -> Int +backendToInt Vault = 1 +backendToInt SOPS = 2 +backendToInt EnvVault = 3 +backendToInt (Custom _) = 99 + +-- ═══════════════════════════════════════════════════════════════════════════ +-- Secret Access Audit +-- ═══════════════════════════════════════════════════════════════════════════ + +||| Secret access classification for audit trail. +public export +data AccessType + = Read -- Read a secret value + | Write -- Create or update a secret + | Delete -- Delete a secret + | Rotate -- Rotate a secret + | List -- List secret keys (values not exposed) + +||| A secret access record for audit purposes. +public export +record AuditEntry where + constructor MkAuditEntry + accessType : AccessType + keyPath : String + timestamp : Nat -- Unix timestamp + +-- ═══════════════════════════════════════════════════════════════════════════ +-- Vault Record +-- ═══════════════════════════════════════════════════════════════════════════ + +||| A vault session with tracked state. +public export +record VaultSession where + constructor MkVaultSession + vaultId : String + backend : SecretBackend + state : VaultState + accessCount : Nat -- Total accesses for audit + +||| Proof that a vault is authenticated and ready for access. +public export +data IsAuthenticated : VaultSession -> Type where + ActiveVault : (v : VaultSession) -> + (state v = Authenticated) -> + IsAuthenticated v + +-- ═══════════════════════════════════════════════════════════════════════════ +-- MCP Tool Definitions +-- ═══════════════════════════════════════════════════════════════════════════ + +||| MCP tools exposed by this cartridge. +||| These map to MCP tool definitions that AI agents can call. +public export +data McpTool + = ToolUnseal -- Unseal a vault + | ToolAuthenticate -- Authenticate with an unsealed vault + | ToolGetSecret -- Read a secret value + | ToolPutSecret -- Create or update a secret + | ToolDeleteSecret -- Delete a secret + | ToolListSecrets -- List available secret keys + | ToolRotate -- Rotate a secret + | ToolSeal -- Seal the vault + +||| MCP tool name (for JSON-RPC method name). +public export +toolName : McpTool -> String +toolName ToolUnseal = "secrets/unseal" +toolName ToolAuthenticate = "secrets/authenticate" +toolName ToolGetSecret = "secrets/get" +toolName ToolPutSecret = "secrets/put" +toolName ToolDeleteSecret = "secrets/delete" +toolName ToolListSecrets = "secrets/list" +toolName ToolRotate = "secrets/rotate" +toolName ToolSeal = "secrets/seal" + +||| Which tools require authentication (vs just unsealed vault). +public export +requiresAuth : McpTool -> Bool +requiresAuth ToolUnseal = False +requiresAuth ToolAuthenticate = False +requiresAuth ToolSeal = False +requiresAuth _ = True + +-- ═══════════════════════════════════════════════════════════════════════════ +-- C-ABI Exports +-- ═══════════════════════════════════════════════════════════════════════════ + +||| Vault state to integer. +public export +vaultStateToInt : VaultState -> Int +vaultStateToInt Sealed = 0 +vaultStateToInt Unsealed = 1 +vaultStateToInt Authenticated = 2 +vaultStateToInt Accessing = 3 +vaultStateToInt SecretError = 4 + +||| FFI: Validate a state transition. +export +sec_can_transition : Int -> Int -> Int +sec_can_transition from to = + let fromState = case from of + 0 => Sealed + 1 => Unsealed + 2 => Authenticated + 3 => Accessing + _ => SecretError + toState = case to of + 0 => Sealed + 1 => Unsealed + 2 => Authenticated + 3 => Accessing + _ => SecretError + in if canTransition fromState toState then 1 else 0 + +||| FFI: Check if a tool requires authentication. +export +sec_tool_requires_auth : Int -> Int +sec_tool_requires_auth 1 = 0 -- ToolUnseal +sec_tool_requires_auth 2 = 0 -- ToolAuthenticate +sec_tool_requires_auth 8 = 0 -- ToolSeal +sec_tool_requires_auth _ = 1 -- All others require auth diff --git a/cartridges/domains/security/secrets-mcp/abi/secrets-mcp.ipkg b/cartridges/domains/security/secrets-mcp/abi/secrets-mcp.ipkg new file mode 100644 index 0000000..64b433e --- /dev/null +++ b/cartridges/domains/security/secrets-mcp/abi/secrets-mcp.ipkg @@ -0,0 +1,11 @@ +-- SPDX-License-Identifier: MPL-2.0 +package secretsmcp + +authors = "Jonathan D.A. Jewell" +version = 0.1.0 +license = "MPL-2.0" +brief = "Secrets MCP cartridge β€” vault seal/unseal state machine with audit trail enforcement" + +sourcedir = "." +modules = SecretsMcp.SafeSecrets +depends = base, contrib diff --git a/cartridges/domains/security/secrets-mcp/adapter/README.adoc b/cartridges/domains/security/secrets-mcp/adapter/README.adoc new file mode 100644 index 0000000..7a6ae79 --- /dev/null +++ b/cartridges/domains/security/secrets-mcp/adapter/README.adoc @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += secrets-mcp / adapter β€” protocol bridge +:orientation: shallow + +== Purpose + +Exposes this cartridge's FFI over the wire protocols declared in +`cartridge.json`. Dispatches tool invocations to the C-ABI exports at +`../ffi/` and formats responses per protocol. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” depends on the sibling `ffi` target. +| `secrets_adapter.zig` | Protocol dispatch (134 lines). Tool catalogue matches `cartridge.json`. +| `SIDELINED-*.v.adoc` | zig predecessor(s). Not built β€” zig banned 2026-04-10. +|=== + +== Invariants + +* **Stateless** β€” all state lives behind the FFI, never in the adapter. +* **Response passthrough** β€” whatever the FFI returns goes back to the wire + unmodified (no embellishment, no silent recovery). +* **Cartridge.json is source of truth** for the tool catalogue; drift between + adapter and manifest is a CI failure. + +== Test/proof surface + +No inline tests at the adapter layer β€” protocol routing is exercised by the +trunk seam tests at `ffi/zig/src/seams.zig`. + +== Read-first + +. `secrets_adapter.zig` β€” dispatch function and per-protocol routers. +. `../cartridge.json` β€” the tool manifest the dispatcher mirrors. diff --git a/cartridges/domains/security/secrets-mcp/adapter/build.zig b/cartridges/domains/security/secrets-mcp/adapter/build.zig new file mode 100644 index 0000000..3a3b966 --- /dev/null +++ b/cartridges/domains/security/secrets-mcp/adapter/build.zig @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// secrets-mcp/adapter/build.zig + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const ffi_mod = b.createModule(.{ + .root_source_file = b.path("../ffi/secrets_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + const adapter = b.addExecutable(.{ + .name = "secrets_adapter", + .root_source_file = b.path("secrets_adapter.zig"), + .target = target, + .optimize = optimize, + }); + adapter.root_module.addImport("secrets_ffi", ffi_mod); + b.installArtifact(adapter); +} diff --git a/cartridges/domains/security/secrets-mcp/adapter/secrets_adapter.zig b/cartridges/domains/security/secrets-mcp/adapter/secrets_adapter.zig new file mode 100644 index 0000000..13a943b --- /dev/null +++ b/cartridges/domains/security/secrets-mcp/adapter/secrets_adapter.zig @@ -0,0 +1,134 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// secrets-mcp/adapter/secrets_adapter.zig +// +// Three-protocol BoJ adapter: REST (port 9289), gRPC-compat (port 9290), +// GraphQL (port 9291). +// Replaces the banned zig adapter (secrets_adapter.v). + +const std = @import("std"); +const ffi = @import("secrets_ffi"); + +const REST_PORT: u16 = 9289; +const GRPC_PORT: u16 = 9290; +const GQL_PORT: u16 = 9291; + +const Response = struct { status: u16, body: []const u8 }; + +fn okJson(buf: []u8, msg: []const u8) []u8 { + return std.fmt.bufPrint(buf, "{{\"success\":true,\"message\":\"{s}\"}}", .{msg}) catch buf[0..0]; +} + +fn errJson(buf: []u8, msg: []const u8) []u8 { + return std.fmt.bufPrint(buf, "{{\"success\":false,\"error\":\"{s}\"}}", .{msg}) catch buf[0..0]; +} + +fn dispatch(tool: []const u8, _body: []const u8, resp: []u8) Response { + _ = _body; + if (std.mem.eql(u8, tool, "secrets_unseal")) { + return .{ .status = 200, .body = okJson(resp, "secrets_unseal forwarded") }; + } + if (std.mem.eql(u8, tool, "secrets_authenticate")) { + return .{ .status = 200, .body = okJson(resp, "secrets_authenticate forwarded") }; + } + if (std.mem.eql(u8, tool, "secrets_get")) { + return .{ .status = 200, .body = okJson(resp, "secrets_get forwarded") }; + } + if (std.mem.eql(u8, tool, "secrets_set")) { + return .{ .status = 200, .body = okJson(resp, "secrets_set forwarded") }; + } + if (std.mem.eql(u8, tool, "secrets_seal")) { + return .{ .status = 200, .body = okJson(resp, "secrets_seal forwarded") }; + } + return .{ .status = 404, .body = errJson(resp, "unknown tool") }; +} + +fn dispatchRest(path: []const u8, body: []const u8, resp: []u8) Response { + // Expect /tools/<tool_name> + const prefix = "/tools/"; + if (std.mem.startsWith(u8, path, prefix)) { + const tool = path[prefix.len..]; + return dispatch(tool, body, resp); + } + return .{ .status = 404, .body = errJson(resp, "not found") }; +} + +fn dispatchGrpc(path: []const u8, body: []const u8, resp: []u8) Response { + // Expect /<Service>/<Method> β€” derive tool from Method + var it = std.mem.splitScalar(u8, path, '/'); + _ = it.next(); // leading empty + _ = it.next(); // service + const method = it.next() orelse return .{ .status = 404, .body = errJson(resp, "bad gRPC path") }; + return dispatch(method, body, resp); +} + +fn dispatchGraphql(body: []const u8, resp: []u8) Response { + if (std.mem.indexOf(u8, body, "secrets_unseal") != null) + return dispatch("secrets_unseal", body, resp); + if (std.mem.indexOf(u8, body, "secrets_authenticate") != null) + return dispatch("secrets_authenticate", body, resp); + if (std.mem.indexOf(u8, body, "secrets_get") != null) + return dispatch("secrets_get", body, resp); + if (std.mem.indexOf(u8, body, "secrets_set") != null) + return dispatch("secrets_set", body, resp); + if (std.mem.indexOf(u8, body, "secrets_seal") != null) + return dispatch("secrets_seal", body, resp); + return .{ .status = 400, .body = errJson(resp, "unrecognised GraphQL operation") }; +} + +fn handleConnection(stream: std.net.Stream, port: u16) void { + defer stream.close(); + var buf: [4096]u8 = undefined; + var resp_buf: [4096]u8 = undefined; + const n = stream.read(&buf) catch return; + const req = buf[0..n]; + const result = switch (port) { + REST_PORT => blk: { + // Parse HTTP/1.1: first line = METHOD PATH HTTP/x.y + var lines = std.mem.splitScalar(u8, req, '\n'); + const first = lines.next() orelse break :blk Response{ .status = 400, .body = "" }; + var parts = std.mem.splitScalar(u8, std.mem.trim(u8, first, "\r"), ' '); + _ = parts.next(); // method + const path = parts.next() orelse break :blk Response{ .status = 400, .body = "" }; + break :blk dispatchRest(path, req, &resp_buf); + }, + GRPC_PORT => blk: { + var lines = std.mem.splitScalar(u8, req, '\n'); + const first = lines.next() orelse break :blk Response{ .status = 400, .body = "" }; + var parts = std.mem.splitScalar(u8, std.mem.trim(u8, first, "\r"), ' '); + _ = parts.next(); + const path = parts.next() orelse break :blk Response{ .status = 400, .body = "" }; + break :blk dispatchGrpc(path, req, &resp_buf); + }, + GQL_PORT => dispatchGraphql(req, &resp_buf), + else => Response{ .status = 500, .body = "" }, + }; + var http_resp: [512]u8 = undefined; + const http = std.fmt.bufPrint(&http_resp, + "HTTP/1.1 {d} OK\r\nContent-Length: {d}\r\nContent-Type: application/json\r\n\r\n", + .{ result.status, result.body.len }) catch return; + _ = stream.write(http) catch {}; + _ = stream.write(result.body) catch {}; +} + +fn listenLoop(port: u16) !void { + const addr = std.net.Address.initIp4(.{ 127, 0, 0, 1 }, port); + var server = try addr.listen(.{ .reuse_address = true }); + defer server.deinit(); + while (true) { + const conn = try server.accept(); + const t = try std.Thread.spawn(.{}, handleConnection, .{ conn.stream, port }); + t.detach(); + } +} + +pub fn main() !void { + ffi.secrets_init(); + const t1 = try std.Thread.spawn(.{}, listenLoop, .{REST_PORT}); + const t2 = try std.Thread.spawn(.{}, listenLoop, .{GRPC_PORT}); + const t3 = try std.Thread.spawn(.{}, listenLoop, .{GQL_PORT}); + t1.join(); + t2.join(); + t3.join(); +} diff --git a/cartridges/domains/security/secrets-mcp/cartridge.json b/cartridges/domains/security/secrets-mcp/cartridge.json new file mode 100644 index 0000000..f31a784 --- /dev/null +++ b/cartridges/domains/security/secrets-mcp/cartridge.json @@ -0,0 +1,151 @@ +{ + "$schema": "https://boj.dev/schemas/cartridge/v1.json", + "spdx": "MPL-2.0", + "copyright": "Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk>", + "name": "secrets-mcp", + "version": "0.1.0", + "description": "Secrets management (Vault, SOPS, env-vault)", + "domain": "security", + "tier": "Ayo", + "protocols": [ + "MCP", + "REST" + ], + "auth": { + "method": "none", + "env_var": null, + "credential_source": null + }, + "api": { + "base_url": "local://secrets-mcp", + "content_type": "application/json" + }, + "tools": [ + { + "name": "secrets_unseal", + "description": "Unseal the secrets backend", + "inputSchema": { + "type": "object", + "properties": { + "backend": { + "type": "string", + "description": "Backend: vault|sops|env_vault" + }, + "unseal_key": { + "type": "string", + "description": "Unseal key or passphrase" + } + }, + "required": [ + "backend", + "unseal_key" + ] + } + }, + { + "name": "secrets_authenticate", + "description": "Authenticate with secrets backend", + "inputSchema": { + "type": "object", + "properties": { + "session_id": { + "type": "integer", + "description": "Session index" + }, + "token": { + "type": "string", + "description": "Auth token" + } + }, + "required": [ + "session_id", + "token" + ] + } + }, + { + "name": "secrets_get", + "description": "Retrieve a secret", + "inputSchema": { + "type": "object", + "properties": { + "session_id": { + "type": "integer", + "description": "Session index" + }, + "path": { + "type": "string", + "description": "Secret path" + }, + "key": { + "type": "string", + "description": "Secret key within path" + } + }, + "required": [ + "session_id", + "path", + "key" + ] + } + }, + { + "name": "secrets_set", + "description": "Write a secret", + "inputSchema": { + "type": "object", + "properties": { + "session_id": { + "type": "integer", + "description": "Session index" + }, + "path": { + "type": "string", + "description": "Secret path" + }, + "key": { + "type": "string", + "description": "Secret key" + }, + "value": { + "type": "string", + "description": "Secret value" + } + }, + "required": [ + "session_id", + "path", + "key", + "value" + ] + } + }, + { + "name": "secrets_seal", + "description": "Seal the secrets backend", + "inputSchema": { + "type": "object", + "properties": { + "session_id": { + "type": "integer", + "description": "Session index" + } + }, + "required": [ + "session_id" + ] + } + } + ], + "ffi": { + "so_path": "ffi/zig-out/lib/libsecrets_mcp.so", + "abi_version": "ADR-0006", + "symbols": [ + "boj_cartridge_init", + "boj_cartridge_deinit", + "boj_cartridge_name", + "boj_cartridge_version", + "boj_cartridge_invoke" + ] + } +} diff --git a/cartridges/domains/security/secrets-mcp/ffi/README.adoc b/cartridges/domains/security/secrets-mcp/ffi/README.adoc new file mode 100644 index 0000000..49076f5 --- /dev/null +++ b/cartridges/domains/security/secrets-mcp/ffi/README.adoc @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += secrets-mcp / ffi β€” Zig FFI layer +:orientation: shallow + +== Purpose + +C-ABI exports bridging this cartridge's Idris2 ABI to the runtime. Consumed by +the adapter for protocol dispatch and by the trunk loader for mount. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” produces `zig-out/lib/libsecrets.so`. +| `secrets_ffi.zig` | C-ABI exports (13 exports, 6 inline tests, 270 lines). +|=== + +== Invariants + +* **Null-pointer rejection** on `[*c]const u8` arguments before any + dereference. +* **Negative return values** carry documented error meanings; zero is + success (matching the trunk error-code convention). +* **No allocator leaks** β€” every allocation path pairs with `defer free`. + +== Test/proof surface + +6 inline `test "..."` block(s) in `secrets_ffi.zig`. Run with +`cd ffi && zig build test`. + +== Read-first + +. `secrets_ffi.zig` β€” C-ABI signatures and guards. +. Trunk `ffi/zig/src/catalogue.zig` for how this `.so` is loaded. diff --git a/cartridges/domains/security/secrets-mcp/ffi/build.zig b/cartridges/domains/security/secrets-mcp/ffi/build.zig new file mode 100644 index 0000000..0379895 --- /dev/null +++ b/cartridges/domains/security/secrets-mcp/ffi/build.zig @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// Secrets-MCP Cartridge β€” Zig FFI build configuration (Zig 0.15+). + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + // Shared ADR-0006 invoke-shim module (relative path up to boj-server trunk). + + const shim_mod = b.addModule("cartridge_shim", .{ + + .root_source_file = b.path("../../../ffi/zig/src/cartridge_shim.zig"), + + .target = target, + + .optimize = optimize, + + }); + + const sec_mod = b.addModule("secrets_ffi", .{ + .root_source_file = b.path("secrets_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + sec_mod.addImport("cartridge_shim", shim_mod); + + // ── Tests ──────────────────────────────────────────────────────── + const sec_tests = b.addTest(.{ + .root_module = sec_mod, + }); + + const run_tests = b.addRunArtifact(sec_tests); + + const test_step = b.step("test", "Run secrets-mcp FFI tests"); + test_step.dependOn(&run_tests.step); + + // ── Shared library ────────────────────────────────────────────── + const lib_mod = b.createModule(.{ + .root_source_file = b.path("secrets_ffi.zig"), + .target = target, + .optimize = optimize, + }); + lib_mod.addImport("cartridge_shim", shim_mod); + + const lib = b.addLibrary(.{ + .name = "secrets_mcp", + .root_module = lib_mod, + .linkage = .dynamic, + }); + b.installArtifact(lib); + + const lib_step = b.step("lib", "Build shared library"); + lib_step.dependOn(&lib.step); +} diff --git a/cartridges/domains/security/secrets-mcp/ffi/cartridge_shim.zig b/cartridges/domains/security/secrets-mcp/ffi/cartridge_shim.zig new file mode 100644 index 0000000..0e399db --- /dev/null +++ b/cartridges/domains/security/secrets-mcp/ffi/cartridge_shim.zig @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// cartridge_shim.zig β€” Shared helpers for the ADR-0006 five-symbol +// cartridge ABI (`boj_cartridge_init / deinit / name / version / invoke`). +// +// The shim centralises the seven-code return convention, NUL-argument +// guards, tool-name comparison, and the buffer-too-small path so each +// cartridge's `boj_cartridge_invoke` can stay short β€” typically a tool +// table plus `shim.writeResult(...)`. +// +// Cartridges import this file by relative path (no build-graph change +// needed). Example: +// +// const shim = @import("../../../ffi/zig/src/cartridge_shim.zig"); +// +// export fn boj_cartridge_invoke( +// tool_name: [*c]const u8, +// json_args: [*c]const u8, +// out_buf: [*c]u8, +// in_out_len: [*c]usize, +// ) callconv(.c) i32 { +// _ = json_args; +// if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; +// const body = if (shim.toolIs(tool_name, "foo")) "{\"result\":{}}" +// else return shim.RC_UNKNOWN_TOOL; +// return shim.writeResult(out_buf, in_out_len, body); +// } + +const std = @import("std"); + +// ── Return codes (ADR-0006 Β§Return codes) ──────────────────────────── +// +// Frozen by ADR-0006. New failure modes compose these via the error +// JSON body β€” the integer surface does not grow without a follow-up ADR. + +pub const RC_SUCCESS: i32 = 0; +pub const RC_UNKNOWN_TOOL: i32 = -1; +pub const RC_BAD_ARGS: i32 = -2; +pub const RC_BUFFER_TOO_SMALL: i32 = -3; +pub const RC_RUNTIME_ERROR: i32 = -4; +pub const RC_PANIC: i32 = -5; +pub const RC_AUTH_DENIED: i32 = -6; + +// ── Invoke-path helpers ────────────────────────────────────────────── + +/// True if any of the three mandatory `boj_cartridge_invoke` output-path +/// pointers is null. Use at the top of every invoke to short-circuit to +/// `RC_BAD_ARGS`. +pub fn invokeArgsNull( + tool_name: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) bool { + return tool_name == null or out_buf == null or in_out_len == null; +} + +/// Compare a C-NUL-terminated tool-name pointer against a Zig string +/// literal. Caller must have already verified `tool_name` is non-null +/// (usually via `invokeArgsNull`). +pub fn toolIs(tool_name: [*c]const u8, expected: []const u8) bool { + const s = std.mem.span(@as([*:0]const u8, @ptrCast(tool_name))); + return std.mem.eql(u8, s, expected); +} + +/// Copy `body` into `out_buf[0..*in_out_len]` (as a capacity) and update +/// `*in_out_len` to the number of bytes written. Returns `RC_SUCCESS`. +/// +/// If `body.len` exceeds the current capacity stored in `*in_out_len`, +/// sets `*in_out_len` to the required size and returns +/// `RC_BUFFER_TOO_SMALL` β€” the caller is then expected to re-allocate +/// and retry, per ADR-0006 Β§Memory ownership. +/// +/// Caller must have already verified that `out_buf` and `in_out_len` +/// are non-null. +pub fn writeResult( + out_buf: [*c]u8, + in_out_len: [*c]usize, + body: []const u8, +) i32 { + const cap = in_out_len.*; + if (body.len > cap) { + in_out_len.* = body.len; + return RC_BUFFER_TOO_SMALL; + } + @memcpy(out_buf[0..body.len], body); + in_out_len.* = body.len; + return RC_SUCCESS; +} + +// ── Tests ──────────────────────────────────────────────────────────── + +test "writeResult: body fits, writes and sets length" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); + try std.testing.expectEqualStrings("hello", buf[0..len]); +} + +test "writeResult: too small returns -3 and sets required length" { + var buf: [2]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_BUFFER_TOO_SMALL, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: exact-fit succeeds" { + var buf: [5]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: empty body" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, ""); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 0), len); +} + +test "toolIs: matches and rejects" { + const name: [*:0]const u8 = "foo"; + try std.testing.expect(toolIs(@ptrCast(name), "foo")); + try std.testing.expect(!toolIs(@ptrCast(name), "bar")); + try std.testing.expect(!toolIs(@ptrCast(name), "foobar")); + try std.testing.expect(!toolIs(@ptrCast(name), "fo")); +} + +test "invokeArgsNull: detects each null slot" { + var buf: [4]u8 = undefined; + var len: usize = 4; + const name: [*:0]const u8 = "x"; + try std.testing.expect(!invokeArgsNull(@ptrCast(name), &buf, &len)); + try std.testing.expect(invokeArgsNull(null, &buf, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), null, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), &buf, null)); +} diff --git a/cartridges/domains/security/secrets-mcp/ffi/secrets_ffi.zig b/cartridges/domains/security/secrets-mcp/ffi/secrets_ffi.zig new file mode 100644 index 0000000..84e11b7 --- /dev/null +++ b/cartridges/domains/security/secrets-mcp/ffi/secrets_ffi.zig @@ -0,0 +1,339 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// Secrets-MCP Cartridge β€” Zig FFI bridge for secret management operations. +// +// Implements the vault seal/unseal state machine from SafeSecrets.idr. +// Ensures no secret can be read from a sealed vault, authentication +// is required before access, and all accesses are counted for audit. + +const std = @import("std"); + +// ═══════════════════════════════════════════════════════════════════════ +// Types (must match SecretsMcp.SafeSecrets encodings) +// ═══════════════════════════════════════════════════════════════════════ + +pub const VaultState = enum(c_int) { + sealed = 0, + unsealed = 1, + authenticated = 2, + accessing = 3, + secret_error = 4, +}; + +pub const SecretBackend = enum(c_int) { + vault = 1, + sops = 2, + env_vault = 3, + custom = 99, +}; + +// ═══════════════════════════════════════════════════════════════════════ +// Vault State Machine +// ═══════════════════════════════════════════════════════════════════════ + +const MAX_VAULTS: usize = 4; + +const VaultSlot = struct { + active: bool, + state: VaultState, + backend: SecretBackend, + access_count: u64, +}; + +var vaults: [MAX_VAULTS]VaultSlot = [_]VaultSlot{.{ + .active = false, + .state = .sealed, + .backend = .vault, + .access_count = 0, +}} ** MAX_VAULTS; + +var mutex: std.Thread.Mutex = .{}; + +/// Validate a state transition (matches Idris2 canTransition). +fn isValidTransition(from: VaultState, to: VaultState) bool { + return switch (from) { + .sealed => to == .unsealed, + .unsealed => to == .authenticated or to == .sealed, + .authenticated => to == .accessing or to == .unsealed, + .accessing => to == .authenticated or to == .secret_error, + .secret_error => to == .authenticated, + }; +} + +/// Unseal a vault. Returns slot index or -1 on failure. +pub export fn sec_unseal(backend: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + for (&vaults, 0..) |*slot, i| { + if (!slot.active) { + slot.active = true; + slot.state = .unsealed; + slot.backend = @enumFromInt(backend); + slot.access_count = 0; + return @intCast(i); + } + } + return -1; // No slots available +} + +/// Authenticate with an unsealed vault (transition Unsealed -> Authenticated). +pub export fn sec_authenticate(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + if (slot_idx < 0 or slot_idx >= MAX_VAULTS) return -1; + const idx: usize = @intCast(slot_idx); + if (!vaults[idx].active) return -1; + if (!isValidTransition(vaults[idx].state, .authenticated)) return -2; + + vaults[idx].state = .authenticated; + return 0; +} + +/// Begin a secret access (transition Authenticated -> Accessing). +pub export fn sec_begin_access(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + if (slot_idx < 0 or slot_idx >= MAX_VAULTS) return -1; + const idx: usize = @intCast(slot_idx); + if (!vaults[idx].active) return -1; + if (!isValidTransition(vaults[idx].state, .accessing)) return -2; + + vaults[idx].state = .accessing; + return 0; +} + +/// End a secret access (transition Accessing -> Authenticated). +/// Increments the audit access count. +pub export fn sec_end_access(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + if (slot_idx < 0 or slot_idx >= MAX_VAULTS) return -1; + const idx: usize = @intCast(slot_idx); + if (!vaults[idx].active) return -1; + if (!isValidTransition(vaults[idx].state, .authenticated)) return -2; + + vaults[idx].state = .authenticated; + vaults[idx].access_count += 1; + return 0; +} + +/// Seal the vault (transition Unsealed -> Sealed). +pub export fn sec_seal(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + if (slot_idx < 0 or slot_idx >= MAX_VAULTS) return -1; + const idx: usize = @intCast(slot_idx); + if (!vaults[idx].active) return -1; + if (!isValidTransition(vaults[idx].state, .sealed)) return -2; + + vaults[idx].active = false; + vaults[idx].state = .sealed; + vaults[idx].access_count = 0; + return 0; +} + +/// Get the state of a vault. +pub export fn sec_state(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + if (slot_idx < 0 or slot_idx >= MAX_VAULTS) return -1; + const idx: usize = @intCast(slot_idx); + if (!vaults[idx].active) return @intFromEnum(VaultState.sealed); + return @intFromEnum(vaults[idx].state); +} + +/// Get the access count for audit purposes. +pub export fn sec_access_count(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + if (slot_idx < 0 or slot_idx >= MAX_VAULTS) return -1; + const idx: usize = @intCast(slot_idx); + if (!vaults[idx].active) return 0; + return @intCast(vaults[idx].access_count); +} + +/// Validate a state transition (C-ABI export). +pub export fn sec_can_transition(from: c_int, to: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + const f: VaultState = @enumFromInt(from); + const t: VaultState = @enumFromInt(to); + return if (isValidTransition(f, t)) 1 else 0; +} + +/// Reset all vaults (for testing). +pub export fn sec_reset() void { + mutex.lock(); + defer mutex.unlock(); + for (&vaults) |*slot| { + slot.active = false; + slot.state = .sealed; + slot.access_count = 0; + } +} + +// ═══════════════════════════════════════════════════════════════════════ +// Standard Cartridge Interface (loader expects these 4 C-ABI symbols) +// ═══════════════════════════════════════════════════════════════════════ + +/// Initialise the secrets-mcp cartridge. Resets all vault slots. +pub export fn boj_cartridge_init() c_int { + sec_reset(); + return 0; +} + +/// Deinitialise the secrets-mcp cartridge. Resets all vault slots. +pub export fn boj_cartridge_deinit() void { + sec_reset(); +} + +/// Return the cartridge name as a null-terminated C string. +pub export fn boj_cartridge_name() [*:0]const u8 { + mutex.lock(); + defer mutex.unlock(); + return "secrets-mcp"; +} + +/// Return the cartridge version as a null-terminated C string. +pub export fn boj_cartridge_version() [*:0]const u8 { + mutex.lock(); + defer mutex.unlock(); + return "0.1.0"; +} + +// ═══════════════════════════════════════════════════════════════════════ +// ADR-0006 dispatch (boj_cartridge_invoke, 5th standard symbol) +// ═══════════════════════════════════════════════════════════════════════ + +const shim = @import("cartridge_shim.zig"); + +/// Dispatch the cartridge.json MCP tools. Grade D Alpha β€” each arm +/// returns a stub JSON body shaped to the tool's intended response. +export fn boj_cartridge_invoke( + tool_name: [*c]const u8, + json_args: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) callconv(.c) i32 { + _ = json_args; + if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; + + const body: []const u8 = if (shim.toolIs(tool_name, "secrets_unseal")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "secrets_authenticate")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "secrets_get")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "secrets_set")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "secrets_seal")) + "{\"result\":{\"status\":\"stub\"}}" +else + return shim.RC_UNKNOWN_TOOL; + + return shim.writeResult(out_buf, in_out_len, body); +} + +// ═══════════════════════════════════════════════════════════════════════ +// Tests +// ═══════════════════════════════════════════════════════════════════════ + +test "unseal and seal" { + sec_reset(); + const slot = sec_unseal(@intFromEnum(SecretBackend.vault)); + try std.testing.expect(slot >= 0); + try std.testing.expectEqual(@as(c_int, @intFromEnum(VaultState.unsealed)), sec_state(slot)); + try std.testing.expectEqual(@as(c_int, 0), sec_seal(slot)); + try std.testing.expectEqual(@as(c_int, @intFromEnum(VaultState.sealed)), sec_state(slot)); +} + +test "cannot read from sealed vault" { + sec_reset(); + // No vault unsealed β€” begin_access on slot 0 should fail + try std.testing.expectEqual(@as(c_int, -1), sec_begin_access(0)); +} + +test "cannot access without authentication" { + sec_reset(); + const slot = sec_unseal(@intFromEnum(SecretBackend.sops)); + // Unsealed but not authenticated β€” should fail + try std.testing.expectEqual(@as(c_int, -2), sec_begin_access(slot)); + _ = sec_seal(slot); +} + +test "full access lifecycle with audit count" { + sec_reset(); + const slot = sec_unseal(@intFromEnum(SecretBackend.vault)); + try std.testing.expectEqual(@as(c_int, 0), sec_authenticate(slot)); + try std.testing.expectEqual(@as(c_int, 0), sec_access_count(slot)); + + // First access + try std.testing.expectEqual(@as(c_int, 0), sec_begin_access(slot)); + try std.testing.expectEqual(@as(c_int, @intFromEnum(VaultState.accessing)), sec_state(slot)); + try std.testing.expectEqual(@as(c_int, 0), sec_end_access(slot)); + try std.testing.expectEqual(@as(c_int, 1), sec_access_count(slot)); + + // Second access + try std.testing.expectEqual(@as(c_int, 0), sec_begin_access(slot)); + try std.testing.expectEqual(@as(c_int, 0), sec_end_access(slot)); + try std.testing.expectEqual(@as(c_int, 2), sec_access_count(slot)); +} + +test "cannot seal while authenticated" { + sec_reset(); + const slot = sec_unseal(@intFromEnum(SecretBackend.env_vault)); + _ = sec_authenticate(slot); + // Must deauth (go to unsealed) before sealing + try std.testing.expectEqual(@as(c_int, -2), sec_seal(slot)); +} + +test "state transition validation" { + // Valid transitions + try std.testing.expectEqual(@as(c_int, 1), sec_can_transition(0, 1)); // sealed -> unsealed + try std.testing.expectEqual(@as(c_int, 1), sec_can_transition(1, 2)); // unsealed -> authenticated + try std.testing.expectEqual(@as(c_int, 1), sec_can_transition(2, 3)); // authenticated -> accessing + try std.testing.expectEqual(@as(c_int, 1), sec_can_transition(3, 2)); // accessing -> authenticated + try std.testing.expectEqual(@as(c_int, 1), sec_can_transition(2, 1)); // authenticated -> unsealed + try std.testing.expectEqual(@as(c_int, 1), sec_can_transition(1, 0)); // unsealed -> sealed + // Invalid transitions + try std.testing.expectEqual(@as(c_int, 0), sec_can_transition(0, 2)); // sealed -> authenticated + try std.testing.expectEqual(@as(c_int, 0), sec_can_transition(0, 3)); // sealed -> accessing + try std.testing.expectEqual(@as(c_int, 0), sec_can_transition(3, 0)); // accessing -> sealed +} + +// ═══════════════════════════════════════════════════════════════════════ +// ADR-0006 invoke dispatch tests +// ═══════════════════════════════════════════════════════════════════════ + +test "invoke: each declared tool succeeds" { + var buf: [256]u8 = undefined; + const tools = [_][]const u8{ + "secrets_unseal", + "secrets_authenticate", + "secrets_get", + "secrets_set", + "secrets_seal", + }; + for (tools) |t| { + var len: usize = buf.len; + const rc = boj_cartridge_invoke(t.ptr, "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, 0), rc); + try std.testing.expect(std.mem.indexOf(u8, buf[0..len], "result") != null); + } +} + +test "invoke: unknown tool returns -1" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("nope", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -1), rc); +} + +test "invoke: buffer too small returns -3" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("secrets_unseal", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -3), rc); + try std.testing.expect(len > 4); +} diff --git a/cartridges/domains/security/secrets-mcp/mod.js b/cartridges/domains/security/secrets-mcp/mod.js new file mode 100644 index 0000000..397fa55 --- /dev/null +++ b/cartridges/domains/security/secrets-mcp/mod.js @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// secrets-mcp/mod.js β€” Secrets management (Vault, SOPS, env-vault) +// +// Delegates to backend at http://127.0.0.1:7741 (override with SECRETS_BACKEND_URL). + +const BASE_URL = Deno.env.get("SECRETS_BACKEND_URL") ?? "http://127.0.0.1:7741"; +const TIMEOUT_MS = 15_000; + +async function post(path, payload) { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), TIMEOUT_MS); + try { + const r = await fetch(`${BASE_URL}${path}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + signal: ctrl.signal, + }); + const data = await r.json().catch(() => ({ success: false, error: "non-JSON response" })); + return { status: r.status, data }; + } catch (e) { + if (e.name === "AbortError") return { status: 504, data: { success: false, error: "secrets-mcp backend timed out" } }; + return { status: 503, data: { success: false, error: `secrets-mcp backend unavailable: ${e.message}` } }; + } finally { clearTimeout(t); } +} + +async function get(path) { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), TIMEOUT_MS); + try { + const r = await fetch(`${BASE_URL}${path}`, { method: "GET", signal: ctrl.signal }); + const data = await r.json().catch(() => ({ success: false, error: "non-JSON response" })); + return { status: r.status, data }; + } catch (e) { + if (e.name === "AbortError") return { status: 504, data: { success: false, error: "secrets-mcp backend timed out" } }; + return { status: 503, data: { success: false, error: `secrets-mcp backend unavailable: ${e.message}` } }; + } finally { clearTimeout(t); } +} + +export async function handleTool(toolName, args) { + switch (toolName) { + case "secrets_unseal": + return post("/api/v1/secrets_unseal", args ?? {}); + case "secrets_authenticate": + return post("/api/v1/secrets_authenticate", args ?? {}); + case "secrets_get": + return post("/api/v1/secrets_get", args ?? {}); + case "secrets_set": + return post("/api/v1/secrets_set", args ?? {}); + case "secrets_seal": + return post("/api/v1/secrets_seal", args ?? {}); + default: + return { status: 404, data: { error: `Unknown tool: ${toolName}` } }; + } +} diff --git a/cartridges/domains/security/secrets-mcp/panels/manifest.json b/cartridges/domains/security/secrets-mcp/panels/manifest.json new file mode 100644 index 0000000..63cd719 --- /dev/null +++ b/cartridges/domains/security/secrets-mcp/panels/manifest.json @@ -0,0 +1,50 @@ +{ + "$schema": "https://panll.dev/schemas/panel-manifest/v1.json", + "spdx": "MPL-2.0", + "cartridge": "secrets-mcp", + "domain": "Secrets Management", + "version": "0.1.0", + "panels": [ + { + "id": "secrets-status", + "title": "Secrets Gateway Status", + "description": "Vault and secrets management health", + "type": "status-indicator", + "data_source": { + "endpoint": "/cartridge/secrets-mcp/invoke", + "method": "POST", + "body": { "tool": "status" }, + "refresh_interval_ms": 5000 + }, + "widgets": [ + { + "type": "state-badge", + "field": "state", + "states": { + "ready": { "color": "#2ecc71", "icon": "lock" }, + "degraded": { "color": "#f39c12", "icon": "alert-circle" }, + "sealed": { "color": "#e74c3c", "icon": "shield-off" } + } + }, + { "type": "text", "field": "version", "label": "Version" } + ] + }, + { + "id": "secrets-audit", + "title": "Access Audit", + "description": "Secret access patterns and rotation status", + "type": "metric", + "data_source": { + "endpoint": "/cartridge/secrets-mcp/invoke", + "method": "POST", + "body": { "tool": "audit_stats" }, + "refresh_interval_ms": 30000 + }, + "widgets": [ + { "type": "counter", "field": "total_secrets", "label": "Secrets", "icon": "key" }, + { "type": "counter", "field": "accesses_24h", "label": "Access (24h)", "icon": "eye" }, + { "type": "counter", "field": "rotations_due", "label": "Rotations Due", "icon": "refresh-cw" } + ] + } + ] +} diff --git a/cartridges/domains/security/secrets-mcp/panels/session-sentinel.json b/cartridges/domains/security/secrets-mcp/panels/session-sentinel.json new file mode 100644 index 0000000..f55c93e --- /dev/null +++ b/cartridges/domains/security/secrets-mcp/panels/session-sentinel.json @@ -0,0 +1,147 @@ +{ + "$schema": "https://panll.dev/schemas/panel-manifest/v1.json", + "id": "org.hyperpolymath.session-sentinel", + "name": "Session Sentinel", + "version": "0.1.0", + "description": "Multi-AI session health monitor β€” storage trends, zone status, healing controls, diagnostics", + "author": "Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk>", + "license": "MPL-2.0", + + "panel": { + "uri": "sentinel://health", + "title": "Session Sentinel", + "icon": "shield-check", + "category": "system", + "defaultSize": { "width": 480, "height": 640 }, + "minSize": { "width": 320, "height": 400 }, + "resizable": true + }, + + "capabilities": [ + "realtime-data", + "interactive-controls", + "trend-graphs", + "threshold-tuning", + "diagnostic-viewer" + ], + + "dbus": { + "busName": "org.hyperpolymath.SessionSentinel", + "objectPath": "/org/hyperpolymath/SessionSentinel", + "interface": "org.hyperpolymath.SessionSentinel1" + }, + + "views": [ + { + "id": "overview", + "title": "Health Overview", + "default": true, + "sections": [ + { + "type": "status-badge", + "binding": "currentZone", + "colors": { + "Green": "#22c55e", + "Yellow": "#eab308", + "Red": "#ef4444", + "Purple": "#a855f7" + } + }, + { + "type": "metric-row", + "metrics": [ + { "label": "Total Storage", "binding": "totalBytes", "format": "bytes" }, + { "label": "Conversations", "binding": "conversationCount", "format": "number" }, + { "label": "Orphan Files", "binding": "orphanCount", "format": "number" }, + { "label": "AI Providers", "binding": "providerCount", "format": "number" } + ] + }, + { + "type": "provider-breakdown", + "binding": "providerSnapshots", + "columns": ["provider", "size", "files", "staleDays"] + } + ] + }, + { + "id": "trends", + "title": "Storage Trends", + "sections": [ + { + "type": "line-chart", + "binding": "historyReadings", + "xAxis": "timestamp", + "yAxis": "totalBytes", + "colorBy": "zone", + "timeRange": "7d" + }, + { + "type": "prediction", + "binding": "trendPrediction", + "label": "Predicted zone in 24h" + } + ] + }, + { + "id": "controls", + "title": "Controls", + "sections": [ + { + "type": "button-group", + "buttons": [ + { "label": "Force Scan", "action": "forceScan", "style": "primary" }, + { "label": "Run Healing", "action": "triggerHeal", "style": "warning" }, + { "label": "Run Diagnostics", "action": "triggerDiagnostic", "style": "secondary" } + ] + }, + { + "type": "threshold-sliders", + "thresholds": [ + { "zone": "Green", "binding": "greenMax", "min": 50000000, "max": 500000000, "step": 10000000, "format": "bytes" }, + { "zone": "Yellow", "binding": "yellowMax", "min": 100000000, "max": 1000000000, "step": 10000000, "format": "bytes" }, + { "zone": "Red", "binding": "redMax", "min": 200000000, "max": 2000000000, "step": 50000000, "format": "bytes" } + ] + }, + { + "type": "toggle-group", + "toggles": [ + { "label": "Auto-healing", "binding": "enableSelfHealing" }, + { "label": "Diagnostics", "binding": "enableDiagnostics" }, + { "label": "Purple flash", "binding": "enablePurpleFlash" } + ] + } + ] + }, + { + "id": "diagnostics", + "title": "Diagnostics", + "sections": [ + { + "type": "log-viewer", + "binding": "diagnosticLog", + "filters": ["Storage", "Process", "Health", "Healing", "Error"], + "severityColors": { + "Info": "#6b7280", + "Warning": "#eab308", + "Critical": "#ef4444" + }, + "maxEntries": 500, + "autoScroll": true + }, + { + "type": "watchdog-status", + "binding": "watchdogState", + "showUptime": true, + "showLastScan": true, + "showConsecutiveFailures": true + } + ] + } + ], + + "health": { + "checkEndpoint": "GetHealth", + "interval": 30, + "timeout": 10 + } +} diff --git a/cartridges/domains/security/vault-mcp/README.adoc b/cartridges/domains/security/vault-mcp/README.adoc new file mode 100644 index 0000000..76c8eeb --- /dev/null +++ b/cartridges/domains/security/vault-mcp/README.adoc @@ -0,0 +1,126 @@ +// SPDX-License-Identifier: MPL-2.0 += vault-mcp +Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk> +:spdx: MPL-2.0 +:tier: Ayo +:domain: Secrets +:protocols: MCP, REST + +== Overview + +Zero-knowledge credential proxy for the *reasonably-good-token-vault*. + +BoJ cartridges never see credentials directly. The vault resolves credential +hints (e.g. `github.com`, `cloudflare-api`) internally and injects secrets +into command execution environments via the Ada CLI (`svalinn_cli`). + +The primary MCP tool is `vault/execute`: + +[source,json] +---- +{ + "name": "vault/execute", + "description": "Execute command with vault-managed credentials. You never see the credential.", + "inputSchema": { + "properties": { + "command": { "type": "string" }, + "credential_hint": { "type": "string", "description": "Which service needs auth (e.g. 'github.com')" } + } + } +} +---- + +== Vault State Machine + +[source] +---- +Locked ──> MfaPending ──> Unlocked ──> Locked + β”‚ β”‚ β”‚ + β”‚ └──> Locked └──> Sealed + β”‚ + └──> Sealed (terminal) +---- + +States:: + * **Locked** β€” vault sealed, no operations until unlock + MFA + * **MfaPending** β€” unlock requested, awaiting second factor + * **Unlocked** β€” vault open, credential-proxied operations permitted + * **Sealed** β€” permanently sealed, requires full re-init + +== MCP Tools + +[cols="1,2,1"] +|=== +| Tool | Description | Requires Unlock? + +| `vault/execute` +| Execute command with vault-managed credentials +| Yes + +| `vault/list` +| List available credential hints (no secrets exposed) +| Yes + +| `vault/status` +| Query vault lock/seal state +| No + +| `vault/rotate` +| Rotate a credential by hint +| Yes + +| `vault/verify` +| Verify credential integrity without revealing it +| Yes +|=== + +== Supported Identity Types + +The vault supports 12 identity types via the Ada CLI: + +`ssh`, `pgp`, `pat`, `rest-api`, `graphql-api`, `grpc-api`, +`xpc`, `x509`, `did`, `oauth2`, `jwt`, `wireguard` + +== Architecture + +[cols="1,1,2"] +|=== +| Layer | Language | Purpose + +| ABI +| Idris2 +| Formally verified vault state machine with dependent-type proofs (`VaultMcp.SafeSecrets`) + +| FFI +| Zig +| C-compatible implementation with thread-safe vault proxy; calls `svalinn_cli` via subprocess + +| Adapter +| zig +| REST bridge to BoJ unified adapter protocol +|=== + +== Ada CLI Integration + +The Zig FFI calls the `svalinn_cli` Ada binary for all credential operations: + +* Socket path: `/run/svalinn/api.sock` +* CLI verbs: `init`, `unlock`, `lock`, `add`, `list`, `get`, `remove`, `export`, `import`, `tui`, `status`, `rotate`, `verify` + +== Building + +[source,bash] +---- +# Build FFI shared library +cd ffi && zig build + +# Run FFI tests +cd ffi && zig build test + +# Type-check ABI +cd abi && idris2 --check VaultMcp.SafeSecrets +---- + +== Status + +Development β€” not yet ready for mounting. diff --git a/cartridges/domains/security/vault-mcp/abi/README.adoc b/cartridges/domains/security/vault-mcp/abi/README.adoc new file mode 100644 index 0000000..b1eb60e --- /dev/null +++ b/cartridges/domains/security/vault-mcp/abi/README.adoc @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += vault-mcp / abi β€” Idris2 ABI layer +:orientation: shallow + +== Purpose + +Cartridge-local protocol types for `vault-mcp`. Defines the data, tool, and +transition vocabulary this cartridge speaks. Inherits safety lemmas and the +matrix-cell identifier scheme from the trunk ABI at `src/abi/`. + +== Files + +1 Idris2 module(s), ~279 lines total. Lead module: +`SafeSecrets.idr`. + +No `.ipkg` β€” consumed directly by wider BoJ callers; type-checks inside the +trunk `boj.ipkg`. + +== Invariants + +* `%default total` at every module top. +* Zero `believe_me`, `assert_total`, `postulate`, `Admitted`, or + `sorry` (the trunk holds the four declared `believe_me` sites; leaf + cartridges carry none). + +== Test/proof surface + +Thin module(s), no inline tests. Proof surface = type-check success under the +trunk `idris2 --check`. + +== Read-first + +. `SafeSecrets.idr` β€” cartridge-local data records and enums. +. Trunk `src/abi/Boj/Catalogue.idr` for how this cell participates in the + 2D matrix. diff --git a/cartridges/domains/security/vault-mcp/abi/VaultMcp/SafeSecrets.idr b/cartridges/domains/security/vault-mcp/abi/VaultMcp/SafeSecrets.idr new file mode 100644 index 0000000..72dcda8 --- /dev/null +++ b/cartridges/domains/security/vault-mcp/abi/VaultMcp/SafeSecrets.idr @@ -0,0 +1,279 @@ +-- SPDX-License-Identifier: MPL-2.0 +-- Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +-- +-- VaultMcp.SafeSecrets β€” Type-safe ABI for vault-mcp cartridge. +-- +-- Zero-knowledge proxy state machine for the reasonably-good-token-vault. +-- Dependent-type proofs ensure only valid vault state transitions can occur +-- at the FFI boundary. BoJ cartridges never see credentials directly. +-- Zero unsafe escape hatches. Fully total, formally verified. + +module VaultMcp.SafeSecrets + +%default total + +-- --------------------------------------------------------------------------- +-- Vault state machine +-- --------------------------------------------------------------------------- + +||| Vault lifecycle states for the zero-knowledge credential proxy. +||| +||| - Locked: vault is sealed, no operations possible until unlock + MFA. +||| - MfaPending: unlock requested, awaiting second factor confirmation. +||| - Unlocked: vault is open, credential-proxied operations permitted. +||| - Sealed: vault has been explicitly sealed; requires full re-init to reopen. +public export +data VaultState = Locked | MfaPending | Unlocked | Sealed + +||| Proof that a vault state transition is valid. +||| +||| The transition graph: +||| Locked -> MfaPending (begin unlock) +||| MfaPending -> Unlocked (MFA confirmed) +||| MfaPending -> Locked (MFA rejected / timeout) +||| Unlocked -> Locked (lock) +||| Unlocked -> Sealed (permanent seal) +||| Locked -> Sealed (seal without unlocking) +public export +data ValidTransition : VaultState -> VaultState -> Type where + BeginUnlock : ValidTransition Locked MfaPending + MfaConfirm : ValidTransition MfaPending Unlocked + MfaReject : ValidTransition MfaPending Locked + Lock : ValidTransition Unlocked Locked + SealFromOpen : ValidTransition Unlocked Sealed + SealFromLock : ValidTransition Locked Sealed + +-- --------------------------------------------------------------------------- +-- Vault actions +-- --------------------------------------------------------------------------- + +||| Actions that can be performed through the MCP vault/execute interface. +||| Maps directly to the Ada CLI verb set used by svalinn_cli. +public export +data VaultAction + = Execute -- ^ Execute a command with vault-managed credentials + | List -- ^ List available credential hints (no secrets exposed) + | Rotate -- ^ Rotate a credential by hint + | Status -- ^ Query vault lock/seal state + | Verify -- ^ Verify credential integrity without revealing it + +||| Check whether an action requires the vault to be in Unlocked state. +||| Status is always available; all others require an unlocked vault. +export +actionRequiresUnlock : VaultAction -> Bool +actionRequiresUnlock Execute = True +actionRequiresUnlock List = True +actionRequiresUnlock Rotate = True +actionRequiresUnlock Status = False +actionRequiresUnlock Verify = True + +-- --------------------------------------------------------------------------- +-- Credential hint +-- --------------------------------------------------------------------------- + +||| Opaque credential hint β€” identifies which service needs auth without +||| revealing the actual credential. The vault resolves this internally. +||| Examples: "github.com", "cloudflare-api", "ssh-deploy-key". +public export +data CredentialHint = MkHint String + +||| Extract the hint string for FFI serialisation. +export +hintToString : CredentialHint -> String +hintToString (MkHint s) = s + +||| Construct a credential hint from a raw string. +export +stringToHint : String -> CredentialHint +stringToHint = MkHint + +-- --------------------------------------------------------------------------- +-- Identity types (matching Ada CLI) +-- --------------------------------------------------------------------------- + +||| Credential identity types supported by the vault. +||| Mirrors the Ada CLI identity type enumeration exactly. +public export +data IdentityType + = SSH | PGP | PAT | RestApi | GraphqlApi | GrpcApi + | XPC | X509 | DID | OAuth2 | JWT | Wireguard + +-- --------------------------------------------------------------------------- +-- C-ABI integer encoding β€” vault state +-- --------------------------------------------------------------------------- + +||| Encode vault state as C-compatible integer. +export +vaultStateToInt : VaultState -> Int +vaultStateToInt Locked = 0 +vaultStateToInt MfaPending = 1 +vaultStateToInt Unlocked = 2 +vaultStateToInt Sealed = 3 + +||| Decode integer back to vault state. +export +intToVaultState : Int -> Maybe VaultState +intToVaultState 0 = Just Locked +intToVaultState 1 = Just MfaPending +intToVaultState 2 = Just Unlocked +intToVaultState 3 = Just Sealed +intToVaultState _ = Nothing + +-- --------------------------------------------------------------------------- +-- C-ABI integer encoding β€” vault action +-- --------------------------------------------------------------------------- + +||| Encode vault action as C-compatible integer. +export +vaultActionToInt : VaultAction -> Int +vaultActionToInt Execute = 0 +vaultActionToInt List = 1 +vaultActionToInt Rotate = 2 +vaultActionToInt Status = 3 +vaultActionToInt Verify = 4 + +||| Decode integer back to vault action. +export +intToVaultAction : Int -> Maybe VaultAction +intToVaultAction 0 = Just Execute +intToVaultAction 1 = Just List +intToVaultAction 2 = Just Rotate +intToVaultAction 3 = Just Status +intToVaultAction 4 = Just Verify +intToVaultAction _ = Nothing + +-- --------------------------------------------------------------------------- +-- C-ABI integer encoding β€” identity type +-- --------------------------------------------------------------------------- + +||| Encode identity type as C-compatible integer. +export +identityTypeToInt : IdentityType -> Int +identityTypeToInt SSH = 0 +identityTypeToInt PGP = 1 +identityTypeToInt PAT = 2 +identityTypeToInt RestApi = 3 +identityTypeToInt GraphqlApi = 4 +identityTypeToInt GrpcApi = 5 +identityTypeToInt XPC = 6 +identityTypeToInt X509 = 7 +identityTypeToInt DID = 8 +identityTypeToInt OAuth2 = 9 +identityTypeToInt JWT = 10 +identityTypeToInt Wireguard = 11 + +||| Decode integer back to identity type. +export +intToIdentityType : Int -> Maybe IdentityType +intToIdentityType 0 = Just SSH +intToIdentityType 1 = Just PGP +intToIdentityType 2 = Just PAT +intToIdentityType 3 = Just RestApi +intToIdentityType 4 = Just GraphqlApi +intToIdentityType 5 = Just GrpcApi +intToIdentityType 6 = Just XPC +intToIdentityType 7 = Just X509 +intToIdentityType 8 = Just DID +intToIdentityType 9 = Just OAuth2 +intToIdentityType 10 = Just JWT +intToIdentityType 11 = Just Wireguard +intToIdentityType _ = Nothing + +-- --------------------------------------------------------------------------- +-- C-ABI transition validator +-- --------------------------------------------------------------------------- + +||| Check if a vault state transition is valid (C-ABI export). +||| Returns 1 for valid, 0 for invalid. +export +vault_mcp_can_transition : Int -> Int -> Int +vault_mcp_can_transition from to = + case (intToVaultState from, intToVaultState to) of + (Just Locked, Just MfaPending) => 1 -- BeginUnlock + (Just MfaPending, Just Unlocked) => 1 -- MfaConfirm + (Just MfaPending, Just Locked) => 1 -- MfaReject + (Just Unlocked, Just Locked) => 1 -- Lock + (Just Unlocked, Just Sealed) => 1 -- SealFromOpen + (Just Locked, Just Sealed) => 1 -- SealFromLock + _ => 0 + +||| Check if an action is permitted in the given vault state (C-ABI export). +||| Returns 1 for permitted, 0 for denied. +export +vault_mcp_action_permitted : Int -> Int -> Int +vault_mcp_action_permitted stateInt actionInt = + case (intToVaultState stateInt, intToVaultAction actionInt) of + (Just Unlocked, _) => 1 -- All actions allowed when unlocked + (Just _, Just Status) => 1 -- Status always allowed + _ => 0 + +-- --------------------------------------------------------------------------- +-- MCP tool declarations +-- --------------------------------------------------------------------------- + +||| Tools exposed via MCP protocol for this cartridge. +public export +data McpTool + = ToolVaultExecute -- ^ vault/execute β€” run command with proxied credentials + | ToolVaultList -- ^ vault/list β€” list credential hints + | ToolVaultStatus -- ^ vault/status β€” query vault state + | ToolVaultRotate -- ^ vault/rotate β€” rotate a credential + | ToolVaultVerify -- ^ vault/verify β€” verify credential integrity + +||| Check if a tool requires the vault to be unlocked. +export +toolRequiresUnlock : McpTool -> Bool +toolRequiresUnlock ToolVaultExecute = True +toolRequiresUnlock ToolVaultList = True +toolRequiresUnlock ToolVaultStatus = False +toolRequiresUnlock ToolVaultRotate = True +toolRequiresUnlock ToolVaultVerify = True + +||| Tool count for this cartridge. +export +toolCount : Nat +toolCount = 5 + +-- --------------------------------------------------------------------------- +-- Audit log types +-- --------------------------------------------------------------------------- + +||| An audit log entry records a vault operation with its outcome. +||| The audit ring buffer retains the most recent MAX_AUDIT_ENTRIES entries. +||| Credential values are NEVER recorded β€” only hints and action types. +public export +record AuditEntry where + constructor MkAuditEntry + timestamp : Int + action : VaultAction + credentialHint : CredentialHint + resultCode : Int + agentId : String + +-- --------------------------------------------------------------------------- +-- Command allowlist +-- --------------------------------------------------------------------------- + +||| A command prefix pattern in the AI agent allowlist. +||| When enforcement is enabled, vault/execute rejects commands not matching +||| any registered prefix. This prevents AI agents from running arbitrary +||| commands with vault-injected credentials. +public export +data AllowlistEntry = MkAllowlistEntry String + +||| Extract the pattern string for FFI serialisation. +export +allowlistPattern : AllowlistEntry -> String +allowlistPattern (MkAllowlistEntry s) = s + +||| C-ABI export for audit entry count query. +export +vault_mcp_audit_count : Int + +||| C-ABI export for allowlist add. +export +vault_mcp_allowlist_add : String -> Int -> Int + +||| C-ABI export for allowlist enforcement toggle. +export +vault_mcp_allowlist_enforce : Int -> () diff --git a/cartridges/domains/security/vault-mcp/abi/vault_mcp.ipkg b/cartridges/domains/security/vault-mcp/abi/vault_mcp.ipkg new file mode 100644 index 0000000..03e84d2 --- /dev/null +++ b/cartridges/domains/security/vault-mcp/abi/vault_mcp.ipkg @@ -0,0 +1,10 @@ +-- SPDX-License-Identifier: MPL-2.0 +package vault_mcp + +version = "0.1.0" +authors = "Jonathan D.A. Jewell" +brief = "vault-mcp cartridge β€” zero-knowledge credential proxy ABI" + +depends = base + +modules = VaultMcp.SafeSecrets diff --git a/cartridges/domains/security/vault-mcp/adapter/README.adoc b/cartridges/domains/security/vault-mcp/adapter/README.adoc new file mode 100644 index 0000000..e21a248 --- /dev/null +++ b/cartridges/domains/security/vault-mcp/adapter/README.adoc @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += vault-mcp / adapter β€” protocol bridge +:orientation: shallow + +== Purpose + +Exposes this cartridge's FFI over the wire protocols declared in +`cartridge.json`. Dispatches tool invocations to the C-ABI exports at +`../ffi/` and formats responses per protocol. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” depends on the sibling `ffi` target. +| `vault_mcp_adapter.zig` | Protocol dispatch (134 lines). Tool catalogue matches `cartridge.json`. +| `SIDELINED-*.v.adoc` | zig predecessor(s). Not built β€” zig banned 2026-04-10. +|=== + +== Invariants + +* **Stateless** β€” all state lives behind the FFI, never in the adapter. +* **Response passthrough** β€” whatever the FFI returns goes back to the wire + unmodified (no embellishment, no silent recovery). +* **Cartridge.json is source of truth** for the tool catalogue; drift between + adapter and manifest is a CI failure. + +== Test/proof surface + +No inline tests at the adapter layer β€” protocol routing is exercised by the +trunk seam tests at `ffi/zig/src/seams.zig`. + +== Read-first + +. `vault_mcp_adapter.zig` β€” dispatch function and per-protocol routers. +. `../cartridge.json` β€” the tool manifest the dispatcher mirrors. diff --git a/cartridges/domains/security/vault-mcp/adapter/build.zig b/cartridges/domains/security/vault-mcp/adapter/build.zig new file mode 100644 index 0000000..f3baa8d --- /dev/null +++ b/cartridges/domains/security/vault-mcp/adapter/build.zig @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// vault-mcp/adapter/build.zig + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const ffi_mod = b.createModule(.{ + .root_source_file = b.path("../ffi/vault_mcp_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + const adapter = b.addExecutable(.{ + .name = "vault_mcp_adapter", + .root_source_file = b.path("vault_mcp_adapter.zig"), + .target = target, + .optimize = optimize, + }); + adapter.root_module.addImport("vault_mcp_ffi", ffi_mod); + b.installArtifact(adapter); +} diff --git a/cartridges/domains/security/vault-mcp/adapter/vault_mcp_adapter.zig b/cartridges/domains/security/vault-mcp/adapter/vault_mcp_adapter.zig new file mode 100644 index 0000000..b0b4588 --- /dev/null +++ b/cartridges/domains/security/vault-mcp/adapter/vault_mcp_adapter.zig @@ -0,0 +1,134 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// vault-mcp/adapter/vault_mcp_adapter.zig +// +// Three-protocol BoJ adapter: REST (port 9298), gRPC-compat (port 9299), +// GraphQL (port 9300). +// Replaces the banned zig adapter (vault_mcp_adapter.v). + +const std = @import("std"); +const ffi = @import("vault_mcp_ffi"); + +const REST_PORT: u16 = 9298; +const GRPC_PORT: u16 = 9299; +const GQL_PORT: u16 = 9300; + +const Response = struct { status: u16, body: []const u8 }; + +fn okJson(buf: []u8, msg: []const u8) []u8 { + return std.fmt.bufPrint(buf, "{{\"success\":true,\"message\":\"{s}\"}}", .{msg}) catch buf[0..0]; +} + +fn errJson(buf: []u8, msg: []const u8) []u8 { + return std.fmt.bufPrint(buf, "{{\"success\":false,\"error\":\"{s}\"}}", .{msg}) catch buf[0..0]; +} + +fn dispatch(tool: []const u8, _body: []const u8, resp: []u8) Response { + _ = _body; + if (std.mem.eql(u8, tool, "vault_execute")) { + return .{ .status = 200, .body = okJson(resp, "vault_execute forwarded") }; + } + if (std.mem.eql(u8, tool, "vault_list")) { + return .{ .status = 200, .body = okJson(resp, "vault_list forwarded") }; + } + if (std.mem.eql(u8, tool, "vault_status")) { + return .{ .status = 200, .body = okJson(resp, "vault_status forwarded") }; + } + if (std.mem.eql(u8, tool, "vault_verify")) { + return .{ .status = 200, .body = okJson(resp, "vault_verify forwarded") }; + } + if (std.mem.eql(u8, tool, "vault_rotate")) { + return .{ .status = 200, .body = okJson(resp, "vault_rotate forwarded") }; + } + return .{ .status = 404, .body = errJson(resp, "unknown tool") }; +} + +fn dispatchRest(path: []const u8, body: []const u8, resp: []u8) Response { + // Expect /tools/<tool_name> + const prefix = "/tools/"; + if (std.mem.startsWith(u8, path, prefix)) { + const tool = path[prefix.len..]; + return dispatch(tool, body, resp); + } + return .{ .status = 404, .body = errJson(resp, "not found") }; +} + +fn dispatchGrpc(path: []const u8, body: []const u8, resp: []u8) Response { + // Expect /<Service>/<Method> β€” derive tool from Method + var it = std.mem.splitScalar(u8, path, '/'); + _ = it.next(); // leading empty + _ = it.next(); // service + const method = it.next() orelse return .{ .status = 404, .body = errJson(resp, "bad gRPC path") }; + return dispatch(method, body, resp); +} + +fn dispatchGraphql(body: []const u8, resp: []u8) Response { + if (std.mem.indexOf(u8, body, "vault_execute") != null) + return dispatch("vault_execute", body, resp); + if (std.mem.indexOf(u8, body, "vault_list") != null) + return dispatch("vault_list", body, resp); + if (std.mem.indexOf(u8, body, "vault_status") != null) + return dispatch("vault_status", body, resp); + if (std.mem.indexOf(u8, body, "vault_verify") != null) + return dispatch("vault_verify", body, resp); + if (std.mem.indexOf(u8, body, "vault_rotate") != null) + return dispatch("vault_rotate", body, resp); + return .{ .status = 400, .body = errJson(resp, "unrecognised GraphQL operation") }; +} + +fn handleConnection(stream: std.net.Stream, port: u16) void { + defer stream.close(); + var buf: [4096]u8 = undefined; + var resp_buf: [4096]u8 = undefined; + const n = stream.read(&buf) catch return; + const req = buf[0..n]; + const result = switch (port) { + REST_PORT => blk: { + // Parse HTTP/1.1: first line = METHOD PATH HTTP/x.y + var lines = std.mem.splitScalar(u8, req, '\n'); + const first = lines.next() orelse break :blk Response{ .status = 400, .body = "" }; + var parts = std.mem.splitScalar(u8, std.mem.trim(u8, first, "\r"), ' '); + _ = parts.next(); // method + const path = parts.next() orelse break :blk Response{ .status = 400, .body = "" }; + break :blk dispatchRest(path, req, &resp_buf); + }, + GRPC_PORT => blk: { + var lines = std.mem.splitScalar(u8, req, '\n'); + const first = lines.next() orelse break :blk Response{ .status = 400, .body = "" }; + var parts = std.mem.splitScalar(u8, std.mem.trim(u8, first, "\r"), ' '); + _ = parts.next(); + const path = parts.next() orelse break :blk Response{ .status = 400, .body = "" }; + break :blk dispatchGrpc(path, req, &resp_buf); + }, + GQL_PORT => dispatchGraphql(req, &resp_buf), + else => Response{ .status = 500, .body = "" }, + }; + var http_resp: [512]u8 = undefined; + const http = std.fmt.bufPrint(&http_resp, + "HTTP/1.1 {d} OK\r\nContent-Length: {d}\r\nContent-Type: application/json\r\n\r\n", + .{ result.status, result.body.len }) catch return; + _ = stream.write(http) catch {}; + _ = stream.write(result.body) catch {}; +} + +fn listenLoop(port: u16) !void { + const addr = std.net.Address.initIp4(.{ 127, 0, 0, 1 }, port); + var server = try addr.listen(.{ .reuse_address = true }); + defer server.deinit(); + while (true) { + const conn = try server.accept(); + const t = try std.Thread.spawn(.{}, handleConnection, .{ conn.stream, port }); + t.detach(); + } +} + +pub fn main() !void { + ffi.vault_mcp_init(); + const t1 = try std.Thread.spawn(.{}, listenLoop, .{REST_PORT}); + const t2 = try std.Thread.spawn(.{}, listenLoop, .{GRPC_PORT}); + const t3 = try std.Thread.spawn(.{}, listenLoop, .{GQL_PORT}); + t1.join(); + t2.join(); + t3.join(); +} diff --git a/cartridges/domains/security/vault-mcp/benchmarks/quick-bench.sh b/cartridges/domains/security/vault-mcp/benchmarks/quick-bench.sh new file mode 100755 index 0000000..11d9426 --- /dev/null +++ b/cartridges/domains/security/vault-mcp/benchmarks/quick-bench.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Benchmarks for vault-mcp cartridge. +set -euo pipefail + +echo "=== vault-mcp benchmarks ===" + +cd "$(dirname "${BASH_SOURCE[0]}")/../ffi" + +# Build with ReleaseFast for benchmarking +zig build -Doptimize=ReleaseFast 2>&1 + +echo "Session open/close cycle (1000 iterations):" +time for i in $(seq 1 1000); do + # This would call the FFI benchmark binary + true +done + +echo "" +echo "Benchmark placeholder β€” implement real benchmarks in Zig test blocks" +echo "or via the zig adapter HTTP benchmark tool." diff --git a/cartridges/domains/security/vault-mcp/cartridge.json b/cartridges/domains/security/vault-mcp/cartridge.json new file mode 100644 index 0000000..15e8f71 --- /dev/null +++ b/cartridges/domains/security/vault-mcp/cartridge.json @@ -0,0 +1,114 @@ +{ + "$schema": "https://boj.dev/schemas/cartridge/v1.json", + "spdx": "MPL-2.0", + "copyright": "Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk>", + "name": "vault-mcp", + "version": "0.1.0", + "description": "Vault CLI credential broker (execute, list, verify, rotate)", + "domain": "security", + "tier": "Ayo", + "protocols": [ + "MCP", + "REST" + ], + "auth": { + "method": "none", + "env_var": null, + "credential_source": null + }, + "api": { + "base_url": "local://vault-mcp", + "content_type": "application/json" + }, + "tools": [ + { + "name": "vault_execute", + "description": "Execute a vault credential operation", + "inputSchema": { + "type": "object", + "properties": { + "credential_hint": { + "type": "string", + "description": "Credential identifier hint" + }, + "operation": { + "type": "string", + "description": "Operation to execute" + }, + "agent": { + "type": "string", + "description": "Executing agent identity" + } + }, + "required": [ + "credential_hint", + "operation" + ] + } + }, + { + "name": "vault_list", + "description": "List vault credentials or paths", + "inputSchema": { + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "Vault path to list" + } + } + } + }, + { + "name": "vault_status", + "description": "Get vault status", + "inputSchema": { + "type": "object", + "properties": {} + } + }, + { + "name": "vault_verify", + "description": "Verify a vault credential", + "inputSchema": { + "type": "object", + "properties": { + "credential_hint": { + "type": "string", + "description": "Credential identifier hint" + } + }, + "required": [ + "credential_hint" + ] + } + }, + { + "name": "vault_rotate", + "description": "Rotate a vault credential", + "inputSchema": { + "type": "object", + "properties": { + "credential_hint": { + "type": "string", + "description": "Credential identifier hint" + } + }, + "required": [ + "credential_hint" + ] + } + } + ], + "ffi": { + "so_path": "ffi/zig-out/lib/libvault_mcp.so", + "abi_version": "ADR-0006", + "symbols": [ + "boj_cartridge_init", + "boj_cartridge_deinit", + "boj_cartridge_name", + "boj_cartridge_version", + "boj_cartridge_invoke" + ] + } +} diff --git a/cartridges/domains/security/vault-mcp/ffi/README.adoc b/cartridges/domains/security/vault-mcp/ffi/README.adoc new file mode 100644 index 0000000..56132a9 --- /dev/null +++ b/cartridges/domains/security/vault-mcp/ffi/README.adoc @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += vault-mcp / ffi β€” Zig FFI layer +:orientation: shallow + +== Purpose + +C-ABI exports bridging this cartridge's Idris2 ABI to the runtime. Consumed by +the adapter for protocol dispatch and by the trunk loader for mount. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” produces `zig-out/lib/libvault.so`. +| `vault_mcp_ffi.zig` | C-ABI exports (18 exports, 10 inline tests, 680 lines). +|=== + +== Invariants + +* **Null-pointer rejection** on `[*c]const u8` arguments before any + dereference. +* **Negative return values** carry documented error meanings; zero is + success (matching the trunk error-code convention). +* **No allocator leaks** β€” every allocation path pairs with `defer free`. + +== Test/proof surface + +10 inline `test "..."` block(s) in `vault_mcp_ffi.zig`. Run with +`cd ffi && zig build test`. + +== Read-first + +. `vault_mcp_ffi.zig` β€” C-ABI signatures and guards. +. Trunk `ffi/zig/src/catalogue.zig` for how this `.so` is loaded. diff --git a/cartridges/domains/security/vault-mcp/ffi/build.zig b/cartridges/domains/security/vault-mcp/ffi/build.zig new file mode 100644 index 0000000..62df257 --- /dev/null +++ b/cartridges/domains/security/vault-mcp/ffi/build.zig @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + // Module + // Shared ADR-0006 invoke-shim module (relative path up to boj-server trunk). + + const shim_mod = b.addModule("cartridge_shim", .{ + + .root_source_file = b.path("../../../ffi/zig/src/cartridge_shim.zig"), + + .target = target, + + .optimize = optimize, + + }); + + const ffi_mod = b.addModule("vault_mcp", .{ + .root_source_file = b.path("vault_mcp_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + ffi_mod.addImport("cartridge_shim", shim_mod); + + // Shared library + const lib = b.addLibrary(.{ + .name = "vault_mcp", + .root_module = ffi_mod, + .linkage = .dynamic, + }); + lib.linkLibC(); + b.installArtifact(lib); + + // Tests + const tests = b.addTest(.{ + .root_module = ffi_mod, + }); + tests.linkLibC(); + + const run_tests = b.addRunArtifact(tests); + const test_step = b.step("test", "Run FFI tests"); + test_step.dependOn(&run_tests.step); +} diff --git a/cartridges/domains/security/vault-mcp/ffi/cartridge_shim.zig b/cartridges/domains/security/vault-mcp/ffi/cartridge_shim.zig new file mode 100644 index 0000000..0e399db --- /dev/null +++ b/cartridges/domains/security/vault-mcp/ffi/cartridge_shim.zig @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// cartridge_shim.zig β€” Shared helpers for the ADR-0006 five-symbol +// cartridge ABI (`boj_cartridge_init / deinit / name / version / invoke`). +// +// The shim centralises the seven-code return convention, NUL-argument +// guards, tool-name comparison, and the buffer-too-small path so each +// cartridge's `boj_cartridge_invoke` can stay short β€” typically a tool +// table plus `shim.writeResult(...)`. +// +// Cartridges import this file by relative path (no build-graph change +// needed). Example: +// +// const shim = @import("../../../ffi/zig/src/cartridge_shim.zig"); +// +// export fn boj_cartridge_invoke( +// tool_name: [*c]const u8, +// json_args: [*c]const u8, +// out_buf: [*c]u8, +// in_out_len: [*c]usize, +// ) callconv(.c) i32 { +// _ = json_args; +// if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; +// const body = if (shim.toolIs(tool_name, "foo")) "{\"result\":{}}" +// else return shim.RC_UNKNOWN_TOOL; +// return shim.writeResult(out_buf, in_out_len, body); +// } + +const std = @import("std"); + +// ── Return codes (ADR-0006 Β§Return codes) ──────────────────────────── +// +// Frozen by ADR-0006. New failure modes compose these via the error +// JSON body β€” the integer surface does not grow without a follow-up ADR. + +pub const RC_SUCCESS: i32 = 0; +pub const RC_UNKNOWN_TOOL: i32 = -1; +pub const RC_BAD_ARGS: i32 = -2; +pub const RC_BUFFER_TOO_SMALL: i32 = -3; +pub const RC_RUNTIME_ERROR: i32 = -4; +pub const RC_PANIC: i32 = -5; +pub const RC_AUTH_DENIED: i32 = -6; + +// ── Invoke-path helpers ────────────────────────────────────────────── + +/// True if any of the three mandatory `boj_cartridge_invoke` output-path +/// pointers is null. Use at the top of every invoke to short-circuit to +/// `RC_BAD_ARGS`. +pub fn invokeArgsNull( + tool_name: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) bool { + return tool_name == null or out_buf == null or in_out_len == null; +} + +/// Compare a C-NUL-terminated tool-name pointer against a Zig string +/// literal. Caller must have already verified `tool_name` is non-null +/// (usually via `invokeArgsNull`). +pub fn toolIs(tool_name: [*c]const u8, expected: []const u8) bool { + const s = std.mem.span(@as([*:0]const u8, @ptrCast(tool_name))); + return std.mem.eql(u8, s, expected); +} + +/// Copy `body` into `out_buf[0..*in_out_len]` (as a capacity) and update +/// `*in_out_len` to the number of bytes written. Returns `RC_SUCCESS`. +/// +/// If `body.len` exceeds the current capacity stored in `*in_out_len`, +/// sets `*in_out_len` to the required size and returns +/// `RC_BUFFER_TOO_SMALL` β€” the caller is then expected to re-allocate +/// and retry, per ADR-0006 Β§Memory ownership. +/// +/// Caller must have already verified that `out_buf` and `in_out_len` +/// are non-null. +pub fn writeResult( + out_buf: [*c]u8, + in_out_len: [*c]usize, + body: []const u8, +) i32 { + const cap = in_out_len.*; + if (body.len > cap) { + in_out_len.* = body.len; + return RC_BUFFER_TOO_SMALL; + } + @memcpy(out_buf[0..body.len], body); + in_out_len.* = body.len; + return RC_SUCCESS; +} + +// ── Tests ──────────────────────────────────────────────────────────── + +test "writeResult: body fits, writes and sets length" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); + try std.testing.expectEqualStrings("hello", buf[0..len]); +} + +test "writeResult: too small returns -3 and sets required length" { + var buf: [2]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_BUFFER_TOO_SMALL, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: exact-fit succeeds" { + var buf: [5]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: empty body" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, ""); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 0), len); +} + +test "toolIs: matches and rejects" { + const name: [*:0]const u8 = "foo"; + try std.testing.expect(toolIs(@ptrCast(name), "foo")); + try std.testing.expect(!toolIs(@ptrCast(name), "bar")); + try std.testing.expect(!toolIs(@ptrCast(name), "foobar")); + try std.testing.expect(!toolIs(@ptrCast(name), "fo")); +} + +test "invokeArgsNull: detects each null slot" { + var buf: [4]u8 = undefined; + var len: usize = 4; + const name: [*:0]const u8 = "x"; + try std.testing.expect(!invokeArgsNull(@ptrCast(name), &buf, &len)); + try std.testing.expect(invokeArgsNull(null, &buf, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), null, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), &buf, null)); +} diff --git a/cartridges/domains/security/vault-mcp/ffi/vault_mcp_ffi.zig b/cartridges/domains/security/vault-mcp/ffi/vault_mcp_ffi.zig new file mode 100644 index 0000000..b441bbd --- /dev/null +++ b/cartridges/domains/security/vault-mcp/ffi/vault_mcp_ffi.zig @@ -0,0 +1,774 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// vault_mcp_ffi.zig β€” C-ABI FFI for the vault-mcp cartridge. +// +// Zero-knowledge credential proxy: BoJ cartridges never see credentials. +// The vault (reasonably-good-token-vault) is accessed via the Ada CLI +// (svalinn_cli) or Unix domain socket at /run/svalinn/api.sock. +// +// Thread-safe via std.Thread.Mutex. No heap allocations for result buffers. + +const std = @import("std"); + +// --------------------------------------------------------------------------- +// Vault state machine (matches Idris2 ABI: VaultMcp.SafeSecrets) +// --------------------------------------------------------------------------- + +/// Vault lifecycle states for the zero-knowledge credential proxy. +pub const VaultState = enum(c_int) { + /// Vault is sealed; no operations until unlock + MFA. + locked = 0, + /// Unlock requested; awaiting second factor confirmation. + mfa_pending = 1, + /// Vault is open; credential-proxied operations permitted. + unlocked = 2, + /// Vault permanently sealed; requires full re-init. + sealed = 3, +}; + +/// Vault actions matching the MCP tool surface. +pub const VaultAction = enum(c_int) { + execute = 0, + list = 1, + rotate = 2, + status = 3, + verify = 4, +}; + +/// Check if a vault state transition is valid. +/// Transition graph: +/// Locked -> MfaPending (begin unlock) +/// MfaPending -> Unlocked (MFA confirmed) +/// MfaPending -> Locked (MFA rejected/timeout) +/// Unlocked -> Locked (lock) +/// Unlocked -> Sealed (permanent seal) +/// Locked -> Sealed (seal without unlock) +fn isValidTransition(from: VaultState, to: VaultState) bool { + return switch (from) { + .locked => to == .mfa_pending or to == .sealed, + .mfa_pending => to == .unlocked or to == .locked, + .unlocked => to == .locked or to == .sealed, + .sealed => false, // Terminal state β€” no transitions out + }; +} + +/// Check if an action is permitted in the given vault state. +fn isActionPermitted(state: VaultState, action: VaultAction) bool { + return switch (action) { + .status => true, // Always available + else => state == .unlocked, + }; +} + +// --------------------------------------------------------------------------- +// Vault proxy state (thread-safe, single instance) +// --------------------------------------------------------------------------- + +/// Result buffer size for CLI output capture. +const RESULT_BUF_SIZE: usize = 8192; + +/// Socket path for the svalinn daemon. +const SVALINN_SOCKET: []const u8 = "/run/svalinn/api.sock"; + +/// Path to the svalinn Ada CLI binary. +const SVALINN_CLI: []const u8 = "svalinn_cli"; + +/// Maximum audit log entries retained in memory (ring buffer). +const MAX_AUDIT_ENTRIES = 128; + +/// Maximum commands in the AI agent allowlist. +const MAX_ALLOWLIST = 64; + +/// A single audit log entry recording a vault operation. +const AuditEntry = struct { + timestamp: i64 = 0, + action: VaultAction = .status, + hint_buf: [128]u8 = undefined, + hint_len: usize = 0, + result_code: c_int = 0, + agent_buf: [64]u8 = undefined, + agent_len: usize = 0, +}; + +/// A command pattern in the AI agent allowlist. +/// Commands must match one of these prefixes to be executed via vault/execute. +const AllowlistEntry = struct { + pattern_buf: [256]u8 = undefined, + pattern_len: usize = 0, + active: bool = false, +}; + +/// Thread-safe vault proxy state. +const VaultProxy = struct { + state: VaultState = .locked, + result_buf: [RESULT_BUF_SIZE]u8 = undefined, + result_len: usize = 0, + last_error: [512]u8 = undefined, + last_error_len: usize = 0, + credential_count: u32 = 0, + last_access_epoch: i64 = 0, + + // Audit ring buffer + audit: [MAX_AUDIT_ENTRIES]AuditEntry = [_]AuditEntry{.{}} ** MAX_AUDIT_ENTRIES, + audit_head: usize = 0, + audit_count: usize = 0, + + // Command allowlist for AI agents + allowlist: [MAX_ALLOWLIST]AllowlistEntry = [_]AllowlistEntry{.{}} ** MAX_ALLOWLIST, + allowlist_count: usize = 0, + allowlist_enforced: bool = false, +}; + +var proxy: VaultProxy = .{}; +var mutex: std.Thread.Mutex = .{}; + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +/// Run svalinn_cli with the given arguments and capture stdout. +/// Returns the number of bytes written to proxy.result_buf, or error. +fn runSvalinnCli(args: []const []const u8) !usize { + // Build argv as a fixed-size buffer: svalinn_cli + up to 15 arguments. + const MAX_ARGS = 16; + var argv_buf: [MAX_ARGS][]const u8 = undefined; + argv_buf[0] = SVALINN_CLI; + if (args.len >= MAX_ARGS) return error.TooManyArguments; + for (args, 0..) |arg, i| { + argv_buf[1 + i] = arg; + } + const argv_slice = argv_buf[0 .. 1 + args.len]; + + var child = std.process.Child.init(argv_slice, std.heap.page_allocator); + child.stdout_behavior = .Pipe; + child.stderr_behavior = .Pipe; + + try child.spawn(); + + // Read stdout into proxy result buffer + const stdout = child.stdout.?; + const bytes_read = stdout.readAll(&proxy.result_buf) catch |e| { + _ = child.wait() catch {}; + return e; + }; + + // Read stderr into last_error buffer + const stderr = child.stderr.?; + const err_read = stderr.readAll(&proxy.last_error) catch 0; + proxy.last_error_len = err_read; + + const term = try child.wait(); + if (term.Exited != 0) { + return error.CliNonZeroExit; + } + + proxy.result_len = bytes_read; + proxy.last_access_epoch = std.time.timestamp(); + return bytes_read; +} + +/// Store an error message in the proxy error buffer. +fn setError(msg: []const u8) void { + const len = @min(msg.len, proxy.last_error.len); + @memcpy(proxy.last_error[0..len], msg[0..len]); + proxy.last_error_len = len; +} + +// --------------------------------------------------------------------------- +// C-ABI exports β€” state machine +// --------------------------------------------------------------------------- + +/// Check if a vault state transition is valid. Returns 1 (valid) or 0 (invalid). +pub export fn vault_mcp_can_transition(from: c_int, to: c_int) c_int { + const f = std.meta.intToEnum(VaultState, from) catch return 0; + const t = std.meta.intToEnum(VaultState, to) catch return 0; + return if (isValidTransition(f, t)) 1 else 0; +} + +/// Get current vault state. Returns state integer. +pub export fn vault_mcp_state() c_int { + mutex.lock(); + defer mutex.unlock(); + return @intFromEnum(proxy.state); +} + +/// Transition vault to a new state. Returns 0 on success, -1 invalid state, -2 bad transition. +pub export fn vault_mcp_transition(to: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const target = std.meta.intToEnum(VaultState, to) catch return -1; + if (!isValidTransition(proxy.state, target)) return -2; + proxy.state = target; + return 0; +} + +/// Check if an action is permitted in the current state. Returns 1 or 0. +pub export fn vault_mcp_action_permitted(action: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const a = std.meta.intToEnum(VaultAction, action) catch return 0; + return if (isActionPermitted(proxy.state, a)) 1 else 0; +} + +// --------------------------------------------------------------------------- +// C-ABI exports β€” vault operations +// --------------------------------------------------------------------------- + +/// Execute a command with vault-managed credentials. +/// The credential_hint tells the vault which service needs auth. +/// The command is executed by svalinn_cli, which injects the credential +/// into the environment β€” this FFI layer never sees the secret. +/// +/// Parameters: +/// command_ptr/command_len: the shell command to execute +/// hint_ptr/hint_len: credential hint (e.g. "github.com") +/// +/// Returns: number of bytes in result buffer, or negative error code. +/// -1 = vault not unlocked +/// -2 = CLI execution failed +/// -3 = null pointer +pub export fn vault_mcp_execute( + command_ptr: [*c]const u8, + command_len: c_int, + hint_ptr: [*c]const u8, + hint_len: c_int, +) c_int { + mutex.lock(); + defer mutex.unlock(); + + if (proxy.state != .unlocked) { + setError("vault is not unlocked"); + return -1; + } + + if (command_ptr == null or hint_ptr == null) return -3; + + const cmd_ulen: usize = std.math.cast(usize, command_len) orelse return -3; + const hint_ulen: usize = std.math.cast(usize, hint_len) orelse return -3; + const command = command_ptr[0..cmd_ulen]; + const hint = hint_ptr[0..hint_ulen]; + + // Check command allowlist before executing + if (!isCommandAllowed(command)) { + setError("command not in allowlist"); + recordAudit(.execute, hint, -4, "ai-agent"); + return -4; + } + + const args = [_][]const u8{ "get", hint, "--exec", command }; + const bytes = runSvalinnCli(&args) catch { + setError("svalinn_cli execution failed"); + recordAudit(.execute, hint, -2, "ai-agent"); + return -2; + }; + + recordAudit(.execute, hint, @intCast(bytes), "ai-agent"); + return @intCast(bytes); +} + +/// List credential hints available in the vault. +/// Returns number of bytes in result buffer, or negative error code. +pub export fn vault_mcp_list() c_int { + mutex.lock(); + defer mutex.unlock(); + + if (proxy.state != .unlocked) { + setError("vault is not unlocked"); + return -1; + } + + const args = [_][]const u8{"list"}; + const bytes = runSvalinnCli(&args) catch { + setError("svalinn_cli list failed"); + return -2; + }; + + return @intCast(bytes); +} + +/// Query vault status. Available in any state. +/// Returns number of bytes in result buffer, or negative error code. +pub export fn vault_mcp_status() c_int { + mutex.lock(); + defer mutex.unlock(); + + const args = [_][]const u8{"status"}; + const bytes = runSvalinnCli(&args) catch { + setError("svalinn_cli status failed"); + return -2; + }; + + return @intCast(bytes); +} + +/// Verify credential integrity for a given hint. +/// Returns number of bytes in result buffer, or negative error code. +pub export fn vault_mcp_verify(hint_ptr: [*c]const u8, hint_len: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + if (proxy.state != .unlocked) { + setError("vault is not unlocked"); + return -1; + } + + if (hint_ptr == null) return -3; + + const ulen: usize = std.math.cast(usize, hint_len) orelse return -3; + const hint = hint_ptr[0..ulen]; + + const args = [_][]const u8{ "verify", hint }; + const bytes = runSvalinnCli(&args) catch { + setError("svalinn_cli verify failed"); + return -2; + }; + + return @intCast(bytes); +} + +/// Rotate credential for a given hint. +/// Returns number of bytes in result buffer, or negative error code. +pub export fn vault_mcp_rotate(hint_ptr: [*c]const u8, hint_len: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + if (proxy.state != .unlocked) { + setError("vault is not unlocked"); + return -1; + } + + if (hint_ptr == null) return -3; + + const ulen: usize = std.math.cast(usize, hint_len) orelse return -3; + const hint = hint_ptr[0..ulen]; + + const args = [_][]const u8{ "rotate", hint }; + const bytes = runSvalinnCli(&args) catch { + setError("svalinn_cli rotate failed"); + return -2; + }; + + return @intCast(bytes); +} + +/// Read the result buffer from the last operation. +/// Copies up to max_len bytes into out_ptr. Returns bytes copied. +pub export fn vault_mcp_read_result(out_ptr: [*c]u8, max_len: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + if (out_ptr == null) return -1; + const umax: usize = std.math.cast(usize, max_len) orelse return -1; + const copy_len = @min(proxy.result_len, umax); + @memcpy(out_ptr[0..copy_len], proxy.result_buf[0..copy_len]); + return @intCast(copy_len); +} + +/// Read the last error message. Returns bytes copied. +pub export fn vault_mcp_read_error(out_ptr: [*c]u8, max_len: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + if (out_ptr == null) return -1; + const umax: usize = std.math.cast(usize, max_len) orelse return -1; + const copy_len = @min(proxy.last_error_len, umax); + @memcpy(out_ptr[0..copy_len], proxy.last_error[0..copy_len]); + return @intCast(copy_len); +} + +// --------------------------------------------------------------------------- +// Internal helpers β€” audit + allowlist +// --------------------------------------------------------------------------- + +/// Record an audit entry in the ring buffer. +fn recordAudit(action: VaultAction, hint: []const u8, result_code: c_int, agent: []const u8) void { + const idx = proxy.audit_head; + var entry = &proxy.audit[idx]; + + entry.timestamp = std.time.timestamp(); + entry.action = action; + entry.result_code = result_code; + + const h_len = @min(hint.len, entry.hint_buf.len); + @memcpy(entry.hint_buf[0..h_len], hint[0..h_len]); + entry.hint_len = h_len; + + const a_len = @min(agent.len, entry.agent_buf.len); + @memcpy(entry.agent_buf[0..a_len], agent[0..a_len]); + entry.agent_len = a_len; + + proxy.audit_head = (proxy.audit_head + 1) % MAX_AUDIT_ENTRIES; + if (proxy.audit_count < MAX_AUDIT_ENTRIES) proxy.audit_count += 1; +} + +/// Check if a command is allowed by the allowlist. +/// Returns true if allowlist is not enforced, or if command matches a prefix. +fn isCommandAllowed(command: []const u8) bool { + if (!proxy.allowlist_enforced) return true; + if (proxy.allowlist_count == 0) return false; + + for (proxy.allowlist[0..proxy.allowlist_count]) |entry| { + if (!entry.active) continue; + const pat = entry.pattern_buf[0..entry.pattern_len]; + if (command.len >= pat.len and std.mem.eql(u8, command[0..pat.len], pat)) { + return true; + } + } + return false; +} + +// --------------------------------------------------------------------------- +// C-ABI exports β€” audit log +// --------------------------------------------------------------------------- + +/// Get the number of audit entries available. +pub export fn vault_mcp_audit_count() c_int { + mutex.lock(); + defer mutex.unlock(); + return @intCast(proxy.audit_count); +} + +/// Read an audit entry as JSON into the result buffer. +/// Index 0 = most recent. Returns bytes written, or -1 if out of range. +pub export fn vault_mcp_audit_entry(index: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + const uidx: usize = std.math.cast(usize, index) orelse return -1; + if (uidx >= proxy.audit_count) return -1; + + // Walk backwards from head + const real_idx = (proxy.audit_head + MAX_AUDIT_ENTRIES - 1 - uidx) % MAX_AUDIT_ENTRIES; + const entry = &proxy.audit[real_idx]; + + const hint = entry.hint_buf[0..entry.hint_len]; + const agent = entry.agent_buf[0..entry.agent_len]; + const action_str: []const u8 = switch (entry.action) { + .execute => "execute", + .list => "list", + .rotate => "rotate", + .status => "status", + .verify => "verify", + }; + const result_str: []const u8 = if (entry.result_code >= 0) "ok" else "error"; + + // Format as JSON line + const written = std.fmt.bufPrint(&proxy.result_buf, "{{\"timestamp\":{d},\"action\":\"{s}\",\"credential_hint\":\"{s}\",\"result\":\"{s}\",\"agent\":\"{s}\"}}", .{ + entry.timestamp, + action_str, + hint, + result_str, + agent, + }) catch return -2; + + proxy.result_len = written.len; + return @intCast(written.len); +} + +// --------------------------------------------------------------------------- +// C-ABI exports β€” command allowlist +// --------------------------------------------------------------------------- + +/// Add a command prefix to the AI agent allowlist. +/// Returns 0 on success, -1 if full, -3 if null/too long. +pub export fn vault_mcp_allowlist_add(pattern_ptr: [*c]const u8, pattern_len: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + + if (pattern_ptr == null) return -3; + const ulen: usize = std.math.cast(usize, pattern_len) orelse return -3; + if (ulen > 256) return -3; + if (proxy.allowlist_count >= MAX_ALLOWLIST) return -1; + + const pattern = pattern_ptr[0..ulen]; + var entry = &proxy.allowlist[proxy.allowlist_count]; + @memcpy(entry.pattern_buf[0..ulen], pattern); + entry.pattern_len = ulen; + entry.active = true; + proxy.allowlist_count += 1; + return 0; +} + +/// Enable or disable allowlist enforcement. +/// When enabled (1), vault/execute rejects commands not matching any prefix. +pub export fn vault_mcp_allowlist_enforce(enabled: c_int) void { + mutex.lock(); + defer mutex.unlock(); + proxy.allowlist_enforced = enabled != 0; +} + +/// Get allowlist enforcement status. Returns 1 (enforced) or 0 (open). +pub export fn vault_mcp_allowlist_status() c_int { + mutex.lock(); + defer mutex.unlock(); + return if (proxy.allowlist_enforced) 1 else 0; +} + +/// Get the number of allowlist entries. +pub export fn vault_mcp_allowlist_count() c_int { + mutex.lock(); + defer mutex.unlock(); + return @intCast(proxy.allowlist_count); +} + +/// Reset vault proxy to initial state (test/debug only). +pub export fn vault_mcp_reset() void { + mutex.lock(); + defer mutex.unlock(); + proxy = .{}; +} + +// --------------------------------------------------------------------------- +// ═══════════════════════════════════════════════════════════════════════ +// Standard ABI (ADR-0005 four symbols + ADR-0006 invoke) +// ═══════════════════════════════════════════════════════════════════════ + +const shim = @import("cartridge_shim.zig"); + +const CARTRIDGE_NAME_PTR: [*:0]const u8 = "vault-mcp"; +const CARTRIDGE_VERSION_PTR: [*:0]const u8 = "0.1.0"; + +export fn boj_cartridge_init() callconv(.c) c_int { + return 0; +} + +export fn boj_cartridge_deinit() callconv(.c) void {} + +export fn boj_cartridge_name() callconv(.c) [*:0]const u8 { + return CARTRIDGE_NAME_PTR; +} + +export fn boj_cartridge_version() callconv(.c) [*:0]const u8 { + return CARTRIDGE_VERSION_PTR; +} + +/// Dispatch the cartridge.json MCP tools. Grade D Alpha stubs. +export fn boj_cartridge_invoke( + tool_name: [*c]const u8, + json_args: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) callconv(.c) i32 { + _ = json_args; + if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; + + const body: []const u8 = if (shim.toolIs(tool_name, "vault_execute")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "vault_list")) + "{\"result\":{\"items\":[],\"count\":0,\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "vault_status")) + "{\"result\":{\"metadata\":{},\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "vault_verify")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "vault_rotate")) + "{\"result\":{\"status\":\"stub\"}}" +else + return shim.RC_UNKNOWN_TOOL; + + return shim.writeResult(out_buf, in_out_len, body); +} + +// Tests +// --------------------------------------------------------------------------- + +test "vault state transitions" { + vault_mcp_reset(); + + // Initial state is locked + try std.testing.expectEqual(@as(c_int, 0), vault_mcp_state()); // locked = 0 + + // Locked -> MfaPending + try std.testing.expectEqual(@as(c_int, 0), vault_mcp_transition(1)); + try std.testing.expectEqual(@as(c_int, 1), vault_mcp_state()); // mfa_pending = 1 + + // MfaPending -> Unlocked + try std.testing.expectEqual(@as(c_int, 0), vault_mcp_transition(2)); + try std.testing.expectEqual(@as(c_int, 2), vault_mcp_state()); // unlocked = 2 + + // Unlocked -> Locked + try std.testing.expectEqual(@as(c_int, 0), vault_mcp_transition(0)); + try std.testing.expectEqual(@as(c_int, 0), vault_mcp_state()); // locked = 0 +} + +test "invalid transitions rejected" { + vault_mcp_reset(); + + // Locked -> Unlocked directly (must go through MfaPending) + try std.testing.expectEqual(@as(c_int, -2), vault_mcp_transition(2)); + + // Locked -> Locked (self-transition not allowed) + try std.testing.expectEqual(@as(c_int, -2), vault_mcp_transition(0)); + + // Go to sealed (terminal state) + try std.testing.expectEqual(@as(c_int, 0), vault_mcp_transition(3)); // locked -> sealed + // No transitions out of sealed + try std.testing.expectEqual(@as(c_int, -2), vault_mcp_transition(0)); + try std.testing.expectEqual(@as(c_int, -2), vault_mcp_transition(1)); + try std.testing.expectEqual(@as(c_int, -2), vault_mcp_transition(2)); +} + +test "transition validator" { + // Valid transitions + try std.testing.expectEqual(@as(c_int, 1), vault_mcp_can_transition(0, 1)); // locked -> mfa_pending + try std.testing.expectEqual(@as(c_int, 1), vault_mcp_can_transition(1, 2)); // mfa_pending -> unlocked + try std.testing.expectEqual(@as(c_int, 1), vault_mcp_can_transition(1, 0)); // mfa_pending -> locked + try std.testing.expectEqual(@as(c_int, 1), vault_mcp_can_transition(2, 0)); // unlocked -> locked + try std.testing.expectEqual(@as(c_int, 1), vault_mcp_can_transition(2, 3)); // unlocked -> sealed + try std.testing.expectEqual(@as(c_int, 1), vault_mcp_can_transition(0, 3)); // locked -> sealed + + // Invalid transitions + try std.testing.expectEqual(@as(c_int, 0), vault_mcp_can_transition(0, 2)); // locked -> unlocked + try std.testing.expectEqual(@as(c_int, 0), vault_mcp_can_transition(3, 0)); // sealed -> locked + try std.testing.expectEqual(@as(c_int, 0), vault_mcp_can_transition(3, 1)); // sealed -> mfa_pending + + // Out of range + try std.testing.expectEqual(@as(c_int, 0), vault_mcp_can_transition(99, 0)); +} + +test "action permissions" { + vault_mcp_reset(); + + // Locked state: only status allowed + try std.testing.expectEqual(@as(c_int, 0), vault_mcp_action_permitted(0)); // execute denied + try std.testing.expectEqual(@as(c_int, 0), vault_mcp_action_permitted(1)); // list denied + try std.testing.expectEqual(@as(c_int, 1), vault_mcp_action_permitted(3)); // status allowed + + // Unlock the vault + _ = vault_mcp_transition(1); // locked -> mfa_pending + _ = vault_mcp_transition(2); // mfa_pending -> unlocked + + // Unlocked: all actions allowed + try std.testing.expectEqual(@as(c_int, 1), vault_mcp_action_permitted(0)); // execute + try std.testing.expectEqual(@as(c_int, 1), vault_mcp_action_permitted(1)); // list + try std.testing.expectEqual(@as(c_int, 1), vault_mcp_action_permitted(2)); // rotate + try std.testing.expectEqual(@as(c_int, 1), vault_mcp_action_permitted(3)); // status + try std.testing.expectEqual(@as(c_int, 1), vault_mcp_action_permitted(4)); // verify +} + +test "execute requires unlocked vault" { + vault_mcp_reset(); + + // Should fail when locked + const result = vault_mcp_execute("echo hello", 10, "github.com", 10); + try std.testing.expectEqual(@as(c_int, -1), result); +} + +test "list requires unlocked vault" { + vault_mcp_reset(); + try std.testing.expectEqual(@as(c_int, -1), vault_mcp_list()); +} + +test "verify requires unlocked vault" { + vault_mcp_reset(); + try std.testing.expectEqual(@as(c_int, -1), vault_mcp_verify("github.com", 10)); +} + +test "rotate requires unlocked vault" { + vault_mcp_reset(); + try std.testing.expectEqual(@as(c_int, -1), vault_mcp_rotate("github.com", 10)); +} + +test "audit ring buffer" { + vault_mcp_reset(); + + // Initially empty + try std.testing.expectEqual(@as(c_int, 0), vault_mcp_audit_count()); + + // Execute when locked produces an audit entry (from the -1 error path) + _ = vault_mcp_execute("echo hello", 10, "github.com", 10); + // Note: the -1 path (not unlocked) doesn't call recordAudit in current impl, + // so audit count should still be 0 from that path. + // But once unlocked with allowlist enforced and blocked, it does record. + + // Enable allowlist enforcement with no entries (blocks everything) + vault_mcp_allowlist_enforce(1); + try std.testing.expectEqual(@as(c_int, 1), vault_mcp_allowlist_status()); + + // Unlock the vault + _ = vault_mcp_transition(1); // locked -> mfa_pending + _ = vault_mcp_transition(2); // mfa_pending -> unlocked + + // Execute should be blocked by allowlist and audited + const result = vault_mcp_execute("echo hello", 10, "github.com", 10); + try std.testing.expectEqual(@as(c_int, -4), result); + + // Should now have 1 audit entry + try std.testing.expectEqual(@as(c_int, 1), vault_mcp_audit_count()); + + // Read the audit entry + const entry_bytes = vault_mcp_audit_entry(0); + try std.testing.expect(entry_bytes > 0); +} + +test "allowlist enforcement" { + vault_mcp_reset(); + + // Add "git " prefix to allowlist + try std.testing.expectEqual(@as(c_int, 0), vault_mcp_allowlist_add("git ", 4)); + try std.testing.expectEqual(@as(c_int, 1), vault_mcp_allowlist_count()); + + // Enable enforcement + vault_mcp_allowlist_enforce(1); + try std.testing.expectEqual(@as(c_int, 1), vault_mcp_allowlist_status()); + + // Unlock vault + _ = vault_mcp_transition(1); + _ = vault_mcp_transition(2); + + // "rm -rf" should be blocked (doesn't match "git " prefix) + const blocked = vault_mcp_execute("rm -rf /", 8, "github.com", 10); + try std.testing.expectEqual(@as(c_int, -4), blocked); + + // "git push" would try to execute (match found) but fail because + // svalinn_cli isn't available in test β€” returns -2 (CLI failed) + const allowed = vault_mcp_execute("git push", 8, "github.com", 10); + try std.testing.expectEqual(@as(c_int, -2), allowed); +} + +// ═══════════════════════════════════════════════════════════════════════ +// ADR-0006 invoke dispatch tests +// ═══════════════════════════════════════════════════════════════════════ + +test "boj_cartridge_name returns vault-mcp" { + const n = std.mem.span(boj_cartridge_name()); + try std.testing.expectEqualStrings("vault-mcp", n); +} + +test "boj_cartridge_init returns 0" { + try std.testing.expectEqual(@as(c_int, 0), boj_cartridge_init()); +} + +test "invoke: each declared tool succeeds" { + var buf: [256]u8 = undefined; + const tools = [_][]const u8{ + "vault_execute", + "vault_list", + "vault_status", + "vault_verify", + "vault_rotate", + }; + for (tools) |t| { + var len: usize = buf.len; + const rc = boj_cartridge_invoke(t.ptr, "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, 0), rc); + try std.testing.expect(std.mem.indexOf(u8, buf[0..len], "result") != null); + } +} + +test "invoke: unknown tool returns -1" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("nope", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -1), rc); +} + +test "invoke: buffer too small returns -3" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("vault_execute", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -3), rc); + try std.testing.expect(len > 4); +} diff --git a/cartridges/domains/security/vault-mcp/mcp-tools.json b/cartridges/domains/security/vault-mcp/mcp-tools.json new file mode 100644 index 0000000..97382d4 --- /dev/null +++ b/cartridges/domains/security/vault-mcp/mcp-tools.json @@ -0,0 +1,69 @@ +{ + "$schema": "https://spec.modelcontextprotocol.io/2024-11-05/schema/tools", + "spdx": "MPL-2.0", + "cartridge": "vault-mcp", + "tools": [ + { + "name": "boj_vault_execute", + "description": "Execute a command with vault-managed credentials. You NEVER see the credential β€” only the command output. The vault injects the secret into an isolated namespace. Requires the vault to be unlocked.", + "inputSchema": { + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "The command to execute (e.g. 'git push origin main', 'gh api /user'). Must match an allowlist prefix if enforcement is enabled." + }, + "credential_hint": { + "type": "string", + "description": "Which service needs auth (e.g. 'github.com', 'cloudflare-api', 'ssh-deploy-key'). The vault resolves this to the actual credential internally." + } + }, + "required": ["command", "credential_hint"] + } + }, + { + "name": "boj_vault_list", + "description": "List available credential hints in the vault. Returns hint names only β€” never actual secrets. Requires the vault to be unlocked.", + "inputSchema": { + "type": "object", + "properties": {} + } + }, + { + "name": "boj_vault_status", + "description": "Query vault operational status (locked/unlocked/sealed, credential count, last access time). Available in any vault state.", + "inputSchema": { + "type": "object", + "properties": {} + } + }, + { + "name": "boj_vault_verify", + "description": "Verify the integrity of a credential without revealing it. Checks that the GUID fragments are complete and hash chains are valid. Requires the vault to be unlocked.", + "inputSchema": { + "type": "object", + "properties": { + "credential_hint": { + "type": "string", + "description": "Which credential to verify (e.g. 'github.com')" + } + }, + "required": ["credential_hint"] + } + }, + { + "name": "boj_vault_audit", + "description": "Query the audit log of recent vault operations. Returns timestamps, actions, hints, and results β€” never actual credentials.", + "inputSchema": { + "type": "object", + "properties": { + "max_entries": { + "type": "integer", + "description": "Maximum number of entries to return (default: 50)", + "default": 50 + } + } + } + } + ] +} diff --git a/cartridges/domains/security/vault-mcp/minter.toml b/cartridges/domains/security/vault-mcp/minter.toml new file mode 100644 index 0000000..64297ed --- /dev/null +++ b/cartridges/domains/security/vault-mcp/minter.toml @@ -0,0 +1,12 @@ +# SPDX-License-Identifier: MPL-2.0 +name = "vault-mcp" +description = "Zero-knowledge credential proxy for the reasonably-good-token-vault" +version = "0.1.0" +domain = "Secrets" +protocols = [ + "MCP", + "REST", +] +tier = "Ayo" +backend = "universal" +generate_panel = true diff --git a/cartridges/domains/security/vault-mcp/mod.js b/cartridges/domains/security/vault-mcp/mod.js new file mode 100644 index 0000000..d989920 --- /dev/null +++ b/cartridges/domains/security/vault-mcp/mod.js @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// vault-mcp/mod.js β€” Vault CLI credential broker (execute, list, verify, rotate) +// +// Delegates to backend at http://127.0.0.1:7744 (override with VAULT_BACKEND_URL). + +const BASE_URL = Deno.env.get("VAULT_BACKEND_URL") ?? "http://127.0.0.1:7744"; +const TIMEOUT_MS = 15_000; + +async function post(path, payload) { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), TIMEOUT_MS); + try { + const r = await fetch(`${BASE_URL}${path}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + signal: ctrl.signal, + }); + const data = await r.json().catch(() => ({ success: false, error: "non-JSON response" })); + return { status: r.status, data }; + } catch (e) { + if (e.name === "AbortError") return { status: 504, data: { success: false, error: "vault-mcp backend timed out" } }; + return { status: 503, data: { success: false, error: `vault-mcp backend unavailable: ${e.message}` } }; + } finally { clearTimeout(t); } +} + +async function get(path) { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), TIMEOUT_MS); + try { + const r = await fetch(`${BASE_URL}${path}`, { method: "GET", signal: ctrl.signal }); + const data = await r.json().catch(() => ({ success: false, error: "non-JSON response" })); + return { status: r.status, data }; + } catch (e) { + if (e.name === "AbortError") return { status: 504, data: { success: false, error: "vault-mcp backend timed out" } }; + return { status: 503, data: { success: false, error: `vault-mcp backend unavailable: ${e.message}` } }; + } finally { clearTimeout(t); } +} + +export async function handleTool(toolName, args) { + switch (toolName) { + case "vault_execute": + return post("/api/v1/vault_execute", args ?? {}); + case "vault_list": + return post("/api/v1/vault_list", args ?? {}); + case "vault_status": + return post("/api/v1/vault_status", args ?? {}); + case "vault_verify": + return post("/api/v1/vault_verify", args ?? {}); + case "vault_rotate": + return post("/api/v1/vault_rotate", args ?? {}); + default: + return { status: 404, data: { error: `Unknown tool: ${toolName}` } }; + } +} diff --git a/cartridges/domains/security/vault-mcp/panels/manifest.json b/cartridges/domains/security/vault-mcp/panels/manifest.json new file mode 100644 index 0000000..e1235d1 --- /dev/null +++ b/cartridges/domains/security/vault-mcp/panels/manifest.json @@ -0,0 +1,94 @@ +{ + "$schema": "https://panll.dev/schemas/panel-manifest/v1.json", + "spdx": "MPL-2.0", + "cartridge": "vault-mcp", + "domain": "Secrets", + "version": "0.1.0", + "panels": [ + { + "id": "vault-state", + "title": "Vault State", + "description": "Current vault lifecycle state (Locked / MfaPending / Unlocked / Sealed)", + "type": "status-indicator", + "data_source": { + "endpoint": "/vault/state", + "method": "GET", + "refresh_interval_ms": 5000 + }, + "widgets": [ + { + "type": "state-badge", + "field": "state", + "states": { + "locked": { "color": "#e74c3c", "icon": "lock" }, + "mfa_pending": { "color": "#f39c12", "icon": "shield-check" }, + "unlocked": { "color": "#2ecc71", "icon": "lock-open" }, + "sealed": { "color": "#95a5a6", "icon": "archive" } + } + } + ] + }, + { + "id": "credential-count", + "title": "Credential Count", + "description": "Number of credential hints registered in the vault", + "type": "metric", + "data_source": { + "endpoint": "/vault/status", + "method": "GET", + "refresh_interval_ms": 30000 + }, + "widgets": [ + { + "type": "counter", + "field": "credential_count", + "label": "Credentials", + "icon": "key" + } + ] + }, + { + "id": "last-access", + "title": "Last Access", + "description": "Timestamp of the most recent credential-proxied operation", + "type": "metric", + "data_source": { + "endpoint": "/vault/status", + "method": "GET", + "refresh_interval_ms": 10000 + }, + "widgets": [ + { + "type": "timestamp", + "field": "last_access_epoch", + "label": "Last Access", + "format": "relative", + "icon": "clock" + } + ] + }, + { + "id": "audit-log", + "title": "Audit Log", + "description": "Recent vault operations (execute, rotate, verify) with timestamps", + "type": "log-stream", + "data_source": { + "endpoint": "/vault/audit", + "method": "GET", + "refresh_interval_ms": 15000, + "max_entries": 50 + }, + "widgets": [ + { + "type": "log-table", + "columns": [ + { "field": "timestamp", "label": "Time", "format": "datetime" }, + { "field": "action", "label": "Action" }, + { "field": "credential_hint", "label": "Hint" }, + { "field": "result", "label": "Result" } + ] + } + ] + } + ] +} diff --git a/cartridges/domains/security/vault-mcp/tests/integration_test.sh b/cartridges/domains/security/vault-mcp/tests/integration_test.sh new file mode 100755 index 0000000..a07816f --- /dev/null +++ b/cartridges/domains/security/vault-mcp/tests/integration_test.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Integration tests for vault-mcp cartridge. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CART_DIR="$(dirname "$SCRIPT_DIR")" +FFI_DIR="$CART_DIR/ffi" + +echo "=== vault-mcp integration tests ===" + +# Build FFI +echo "[1/4] Building FFI..." +cd "$FFI_DIR" && zig build 2>&1 + +# Run Zig unit tests (state machine, audit, allowlist) +echo "[2/4] Running FFI unit tests..." +cd "$FFI_DIR" && zig build test 2>&1 + +# Validate Idris2 ABI (if idris2 available) +echo "[3/4] Checking ABI..." +if command -v idris2 &>/dev/null; then + cd "$CART_DIR/abi" && idris2 --check VaultMcp.SafeSecrets 2>&1 + echo " ABI: OK" +else + echo " ABI: SKIPPED (idris2 not in PATH)" +fi + +# Validate MCP tool definitions +echo "[4/4] Validating MCP tool definitions..." +if [ -f "$CART_DIR/mcp-tools.json" ]; then + # Check JSON is valid + if command -v deno &>/dev/null; then + deno eval "const t = JSON.parse(await Deno.readTextFile('$CART_DIR/mcp-tools.json')); console.log(' Tools:', t.tools.length, 'defined'); for (const tool of t.tools) { console.log(' -', tool.name); }" 2>&1 + elif command -v python3 &>/dev/null; then + python3 -c "import json; t=json.load(open('$CART_DIR/mcp-tools.json')); print(f' Tools: {len(t[\"tools\"])} defined')" + else + echo " MCP tools: JSON present (no validator available)" + fi +else + echo " MCP tools: MISSING (mcp-tools.json not found)" + exit 1 +fi + +echo "" +echo "All tests passed for vault-mcp!" diff --git a/cartridges/domains/security/vext-mcp/README.adoc b/cartridges/domains/security/vext-mcp/README.adoc new file mode 100644 index 0000000..5b8ddb1 --- /dev/null +++ b/cartridges/domains/security/vext-mcp/README.adoc @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += vext-mcp +Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk> +:spdx: MPL-2.0 +:tier: Ayo +:domain: Security +:protocols: MCP, REST + +== Overview + +Vext message verification and attestation chain. Verifies signed messages, checks attestation chains, and appends verified payloads. + +== Tools (3) + +[cols="2,4"] +|=== +| Tool | Description + +| `vext_verify_message` | Verify a message signature. Returns: verified / unverified / tampered / expired. +| `vext_check_attestation` | Check attestation chain for an issuer. Returns chain depth (0 if not found). +| `vext_append_chain` | Append a verified payload to the attestation chain. +|=== + +== Architecture + +Three-layer stack per the BoJ cartridge standard: + +[cols="1,1,3"] +|=== +| Layer | Language | Purpose + +| ABI | Idris2 | Formally verified state machine with dependent-type proofs +| FFI | Zig | C-ABI implementation: session pool, action metrics, thread safety +| Adapter| Zig | Adapter bridge to the BoJ unified adapter protocol (zig predecessors sidelined) +|=== + +== Building + +[source,bash] +---- +# ABI (Idris2) +cd abi && idris2 --check *.idr + +# FFI (Zig) +cd ffi && zig build +cd ffi && zig build test + +# Adapter (Zig) +cd adapter && zig build +---- + +== Status + +Alpha (CRG D). Cartridge metadata declared in `cartridge.json`; 3 tool(s) exposed. +Not yet dogfooded externally. See `docs/READINESS.md` at repo root for the +project-wide grade table and `docs/practice/DOGFOOD-LOG.adoc` for usage evidence. diff --git a/cartridges/domains/security/vext-mcp/abi/README.adoc b/cartridges/domains/security/vext-mcp/abi/README.adoc new file mode 100644 index 0000000..020b164 --- /dev/null +++ b/cartridges/domains/security/vext-mcp/abi/README.adoc @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += vext-mcp / abi β€” Idris2 ABI layer +:orientation: shallow + +== Purpose + +Cartridge-local protocol types for `vext-mcp`. Defines the data, tool, and +transition vocabulary this cartridge speaks. Inherits safety lemmas and the +matrix-cell identifier scheme from the trunk ABI at `src/abi/`. + +== Files + +1 Idris2 module(s), ~45 lines total. Lead module: +`Protocol.idr`. + +No `.ipkg` β€” consumed directly by wider BoJ callers; type-checks inside the +trunk `boj.ipkg`. + +== Invariants + +* `%default total` at every module top. +* Zero `believe_me`, `assert_total`, `postulate`, `Admitted`, or + `sorry` (the trunk holds the four declared `believe_me` sites; leaf + cartridges carry none). + +== Test/proof surface + +Thin module(s), no inline tests. Proof surface = type-check success under the +trunk `idris2 --check`. + +== Read-first + +. `Protocol.idr` β€” cartridge-local data records and enums. +. Trunk `src/abi/Boj/Catalogue.idr` for how this cell participates in the + 2D matrix. diff --git a/cartridges/domains/security/vext-mcp/abi/Vext/Protocol.idr b/cartridges/domains/security/vext-mcp/abi/Vext/Protocol.idr new file mode 100644 index 0000000..a5a6982 --- /dev/null +++ b/cartridges/domains/security/vext-mcp/abi/Vext/Protocol.idr @@ -0,0 +1,45 @@ +-- SPDX-License-Identifier: MPL-2.0 +-- Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +-- +-- Vext ABI β€” verifiable communications protocol definitions. + +module Vext.Protocol + +import Data.Nat + +||| Vext operation codes. +public export +data VextOp + = VerifyMessage + | CheckAttestation + | AppendChain + +||| Verification status. +public export +data VerifyStatus = Verified | Unverified | Tampered | Expired + +||| Attestation with a chain depth. +public export +record Attestation where + constructor MkAttestation + issuer : String + depth : Nat + +||| Chain entry with hash linkage. +public export +record ChainEntry where + constructor MkChainEntry + prevHash : String + payload : String + entryIdx : Nat + +||| Proof: verified status means the message is trustworthy. +export +verifiedIsTrustworthy : (s : VerifyStatus) -> s = Verified -> Bool +verifiedIsTrustworthy Verified Refl = True + +||| Proof: chain entries are monotonically indexed. +export +chainMonotonic : (a, b : ChainEntry) -> LTE a.entryIdx b.entryIdx -> + LTE a.entryIdx (S b.entryIdx) +chainMonotonic _ _ prf = lteSuccRight prf diff --git a/cartridges/domains/security/vext-mcp/adapter/README.adoc b/cartridges/domains/security/vext-mcp/adapter/README.adoc new file mode 100644 index 0000000..e81697b --- /dev/null +++ b/cartridges/domains/security/vext-mcp/adapter/README.adoc @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += vext-mcp / adapter β€” protocol bridge +:orientation: shallow + +== Purpose + +Exposes this cartridge's FFI over the wire protocols declared in +`cartridge.json`. Dispatches tool invocations to the C-ABI exports at +`../ffi/` and formats responses per protocol. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” depends on the sibling `ffi` target. +| `vext_adapter.zig` | Protocol dispatch (118 lines). Tool catalogue matches `cartridge.json`. +| `SIDELINED-*.v.adoc` | zig predecessor(s). Not built β€” zig banned 2026-04-10. +|=== + +== Invariants + +* **Stateless** β€” all state lives behind the FFI, never in the adapter. +* **Response passthrough** β€” whatever the FFI returns goes back to the wire + unmodified (no embellishment, no silent recovery). +* **Cartridge.json is source of truth** for the tool catalogue; drift between + adapter and manifest is a CI failure. + +== Test/proof surface + +No inline tests at the adapter layer β€” protocol routing is exercised by the +trunk seam tests at `ffi/zig/src/seams.zig`. + +== Read-first + +. `vext_adapter.zig` β€” dispatch function and per-protocol routers. +. `../cartridge.json` β€” the tool manifest the dispatcher mirrors. diff --git a/cartridges/domains/security/vext-mcp/adapter/build.zig b/cartridges/domains/security/vext-mcp/adapter/build.zig new file mode 100644 index 0000000..4b5d055 --- /dev/null +++ b/cartridges/domains/security/vext-mcp/adapter/build.zig @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// vext-mcp/adapter/build.zig + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + const ffi_mod = b.createModule(.{ + .root_source_file = b.path("../ffi/vext_ffi.zig"), + .target = target, .optimize = optimize, + }); + const adapter = b.addExecutable(.{ + .name = "vext_adapter", + .root_source_file = b.path("vext_adapter.zig"), + .target = target, .optimize = optimize, + }); + adapter.root_module.addImport("vext_ffi", ffi_mod); + b.installArtifact(adapter); + const run_step = b.step("run", "Run the vext-mcp adapter"); + run_step.dependOn(&b.addRunArtifact(adapter).step); + const tests = b.addTest(.{ + .root_source_file = b.path("vext_adapter.zig"), + .target = target, .optimize = optimize, + }); + tests.root_module.addImport("vext_ffi", ffi_mod); + const test_step = b.step("test", "Run vext-mcp adapter tests"); + test_step.dependOn(&b.addRunArtifact(tests).step); +} diff --git a/cartridges/domains/security/vext-mcp/adapter/vext_adapter.zig b/cartridges/domains/security/vext-mcp/adapter/vext_adapter.zig new file mode 100644 index 0000000..565d7ed --- /dev/null +++ b/cartridges/domains/security/vext-mcp/adapter/vext_adapter.zig @@ -0,0 +1,118 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// vext-mcp/adapter/vext_adapter.zig -- Unified three-protocol adapter. +// +// Replaces the banned vext_adapter.v (zig, removed 2026-04-12). +// +// REST :9133 gRPC-compat :9134 GraphQL :9135 +// Vext message verification and attestation chain. Verifies signed messages, checks attestation chains +// Tools: vext_verify_message, vext_check_attestation, vext_append_chain + +const std = @import("std"); +const ffi = @import("vext_ffi"); + +const REST_PORT: u16 = 9133; +const GRPC_PORT: u16 = 9134; +const GQL_PORT: u16 = 9135; +const MAX_CONN_BUF: usize = 16 * 1024; + +fn okJson(buf: []u8, msg: []const u8) []u8 { + const n = std.fmt.bufPrint(buf, "{{\"success\":true,\"message\":\"{s}\"}}", .{msg}) catch return buf[0..0]; + return n; +} +fn errJson(buf: []u8, msg: []const u8) []u8 { + const n = std.fmt.bufPrint(buf, "{{\"success\":false,\"error\":\"{s}\"}}", .{msg}) catch return buf[0..0]; + return n; +} +fn statusJson(buf: []u8) []u8 { + const n = std.fmt.bufPrint(buf, "{{\"success\":true,\"state\":\"ready\",\"service\":\"vext-mcp\"}}", .{}) catch return buf[0..0]; + return n; +} + +const Response = struct { status: u16, body: []u8 }; + +fn dispatch(tool: []const u8, body: []const u8, resp: []u8) Response { + _ = body; + if (std.mem.eql(u8, tool, "vext_verify_message")) return .{ .status = 200, .body = okJson(resp, "vext_verify_message forwarded") }; + if (std.mem.eql(u8, tool, "vext_check_attestation")) return .{ .status = 200, .body = okJson(resp, "vext_check_attestation forwarded") }; + if (std.mem.eql(u8, tool, "vext_append_chain")) return .{ .status = 200, .body = okJson(resp, "vext_append_chain forwarded") }; + if (std.mem.eql(u8, tool, "status") or std.mem.eql(u8, tool, "health")) + return .{ .status = 200, .body = statusJson(resp) }; + return .{ .status = 404, .body = errJson(resp, "Unknown tool") }; +} + +fn dispatchRest(path: []const u8, body: []const u8, resp: []u8) Response { + if (std.mem.startsWith(u8, path, "/tools/")) return dispatch(path["/tools/".len..], body, resp); + if (std.mem.eql(u8, path, "/status") or std.mem.eql(u8, path, "/health")) return .{ .status = 200, .body = statusJson(resp) }; + return .{ .status = 404, .body = errJson(resp, "Not found") }; +} + +fn dispatchGrpc(path: []const u8, body: []const u8, resp: []u8) Response { + const prefix = "/Vextservice/"; + if (!std.mem.startsWith(u8, path, prefix)) + return .{ .status = 404, .body = errJson(resp, "Not a recognized gRPC path") }; + const method = path[prefix.len..]; + const tool = blk: { + if (std.mem.eql(u8, method, "vext_verify_message")) break :blk "vext_verify_message"; + if (std.mem.eql(u8, method, "vext_check_attestation")) break :blk "vext_check_attestation"; + if (std.mem.eql(u8, method, "vext_append_chain")) break :blk "vext_append_chain"; + return .{ .status = 404, .body = errJson(resp, "Unknown gRPC method") }; + }; + return dispatch(tool, body, resp); +} + +fn dispatchGraphql(body: []const u8, resp: []u8) Response { + if (std.mem.indexOf(u8, body, "__schema") != null) return .{ .status = 200, .body = okJson(resp, "schema not supported") }; + if (std.mem.indexOf(u8, body, "verify_message") != null) return dispatch("vext_verify_message", body, resp); + if (std.mem.indexOf(u8, body, "check_attestation") != null) return dispatch("vext_check_attestation", body, resp); + if (std.mem.indexOf(u8, body, "append_chain") != null) return dispatch("vext_append_chain", body, resp); + return .{ .status = 200, .body = errJson(resp, "Unrecognised GraphQL operation") }; +} + +const Protocol = enum { rest, grpc, graphql }; + +fn handleConnection(conn: std.net.Server.Connection, proto: Protocol) void { + defer conn.stream.close(); + var in_buf: [MAX_CONN_BUF]u8 = undefined; + const n = conn.stream.read(&in_buf) catch return; + const req = in_buf[0..n]; + var path: []const u8 = "/"; + var body: []const u8 = ""; + if (n > 4) { + const line_end = std.mem.indexOf(u8, req, "\r\n") orelse req.len; + const first_line = req[0..line_end]; + const sp1 = std.mem.indexOfScalar(u8, first_line, ' ') orelse 0; + const rest_of = first_line[sp1 + 1 ..]; + const sp2 = std.mem.indexOfScalar(u8, rest_of, ' ') orelse rest_of.len; + path = rest_of[0..sp2]; + const body_sep = std.mem.indexOf(u8, req, "\r\n\r\n") orelse n; + body = req[@min(body_sep + 4, n)..]; + } + var resp_buf: [MAX_CONN_BUF]u8 = undefined; + const result = switch (proto) { + .rest => dispatchRest(path, body, &resp_buf), + .grpc => dispatchGrpc(path, body, &resp_buf), + .graphql => dispatchGraphql(body, &resp_buf), + }; + const ct: []const u8 = if (proto == .grpc) "application/grpc+json" else "application/json"; + var hdr_buf: [256]u8 = undefined; + const hdr = std.fmt.bufPrint(&hdr_buf, "HTTP/1.1 {d} OK\r\nContent-Type: {s}\r\nContent-Length: {d}\r\nConnection: close\r\n\r\n", .{ result.status, ct, result.body.len }) catch return; + _ = conn.stream.writeAll(hdr) catch return; + _ = conn.stream.writeAll(result.body) catch return; +} + +fn listenLoop(port: u16, proto: Protocol) void { + const addr = std.net.Address.initIp4(.{ 127, 0, 0, 1 }, port) catch return; + var server = addr.listen(.{ .reuse_address = true }) catch return; + defer server.deinit(); + while (true) { const conn = server.accept() catch continue; handleConnection(conn, proto); } +} + +pub fn main() !void { + ffi.vext_init(); + const t1 = try std.Thread.spawn(.{}, listenLoop, .{ REST_PORT, Protocol.rest }); + const t2 = try std.Thread.spawn(.{}, listenLoop, .{ GRPC_PORT, Protocol.grpc }); + const t3 = try std.Thread.spawn(.{}, listenLoop, .{ GQL_PORT, Protocol.graphql }); + t1.join(); t2.join(); t3.join(); +} diff --git a/cartridges/domains/security/vext-mcp/cartridge.json b/cartridges/domains/security/vext-mcp/cartridge.json new file mode 100644 index 0000000..32137f5 --- /dev/null +++ b/cartridges/domains/security/vext-mcp/cartridge.json @@ -0,0 +1,89 @@ +{ + "$schema": "https://boj.dev/schemas/cartridge/v1.json", + "spdx": "MPL-2.0", + "copyright": "Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath)", + "name": "vext-mcp", + "version": "0.1.0", + "description": "Vext message verification and attestation chain. Verifies signed messages, checks attestation chains, and appends verified payloads.", + "domain": "Security", + "tier": "Ayo", + "protocols": [ + "MCP", + "REST" + ], + "auth": { + "method": "none", + "env_var": null, + "credential_source": null + }, + "api": { + "base_url": "local://vext-mcp", + "content_type": "application/json" + }, + "tools": [ + { + "name": "vext_verify_message", + "description": "Verify a message signature. Returns: verified / unverified / tampered / expired.", + "inputSchema": { + "type": "object", + "properties": { + "msg": { + "type": "string", + "description": "Message content to verify" + }, + "sig": { + "type": "string", + "description": "Signature (hex or base64)" + } + }, + "required": [ + "msg", + "sig" + ] + } + }, + { + "name": "vext_check_attestation", + "description": "Check attestation chain for an issuer. Returns chain depth (0 if not found).", + "inputSchema": { + "type": "object", + "properties": { + "issuer": { + "type": "string", + "description": "Issuer identifier" + } + }, + "required": [ + "issuer" + ] + } + }, + { + "name": "vext_append_chain", + "description": "Append a verified payload to the attestation chain.", + "inputSchema": { + "type": "object", + "properties": { + "payload": { + "type": "string", + "description": "Payload to append (JSON string)" + } + }, + "required": [ + "payload" + ] + } + } + ], + "ffi": { + "so_path": "ffi/zig-out/lib/libvext_mcp.so", + "abi_version": "ADR-0006", + "symbols": [ + "boj_cartridge_init", + "boj_cartridge_deinit", + "boj_cartridge_name", + "boj_cartridge_version", + "boj_cartridge_invoke" + ] + } +} diff --git a/cartridges/domains/security/vext-mcp/ffi/README.adoc b/cartridges/domains/security/vext-mcp/ffi/README.adoc new file mode 100644 index 0000000..3d2976e --- /dev/null +++ b/cartridges/domains/security/vext-mcp/ffi/README.adoc @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += vext-mcp / ffi β€” Zig FFI layer **(stub)** +:orientation: shallow + +== Purpose + +C-ABI exports bridging this cartridge's Idris2 ABI to the runtime. Consumed by +the adapter for protocol dispatch and by the trunk loader for mount. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” produces `zig-out/lib/libvext.so`. +| `vext_ffi.zig` | C-ABI exports (3 exports, 3 inline tests, 41 lines). +|=== + +== Invariants + +* **Null-pointer rejection** on `[*c]const u8` arguments before any + dereference. +* **Negative return values** carry documented error meanings; zero is + success (matching the trunk error-code convention). +* **No allocator leaks** β€” every allocation path pairs with `defer free`. + +== Test/proof surface + +3 inline `test "..."` block(s) in `vext_ffi.zig`. Run with +`cd ffi && zig build test`. + +== Read-first + +. `vext_ffi.zig` β€” C-ABI signatures and guards. +. Trunk `ffi/zig/src/catalogue.zig` for how this `.so` is loaded. + +== Promotion gate + +Wire these exports to real backing state (not `return 0` / `return 1` +placeholders) before promoting past D. diff --git a/cartridges/domains/security/vext-mcp/ffi/build.zig b/cartridges/domains/security/vext-mcp/ffi/build.zig new file mode 100644 index 0000000..7984bf4 --- /dev/null +++ b/cartridges/domains/security/vext-mcp/ffi/build.zig @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + // Shared ADR-0006 invoke-shim module (relative path up to boj-server trunk). + + const shim_mod = b.addModule("cartridge_shim", .{ + + .root_source_file = b.path("../../../ffi/zig/src/cartridge_shim.zig"), + + .target = target, + + .optimize = optimize, + + }); + + const ffi_mod = b.addModule("vext_mcp", .{ + .root_source_file = b.path("vext_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + ffi_mod.addImport("cartridge_shim", shim_mod); + + const lib = b.addLibrary(.{ + .name = "vext_mcp", + .root_module = ffi_mod, + .linkage = .dynamic, + }); + lib.linkLibC(); + b.installArtifact(lib); + + const tests = b.addTest(.{ .root_module = ffi_mod }); + tests.linkLibC(); + const run_tests = b.addRunArtifact(tests); + const test_step = b.step("test", "Run FFI tests"); + test_step.dependOn(&run_tests.step); +} + diff --git a/cartridges/domains/security/vext-mcp/ffi/cartridge_shim.zig b/cartridges/domains/security/vext-mcp/ffi/cartridge_shim.zig new file mode 100644 index 0000000..0e399db --- /dev/null +++ b/cartridges/domains/security/vext-mcp/ffi/cartridge_shim.zig @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// cartridge_shim.zig β€” Shared helpers for the ADR-0006 five-symbol +// cartridge ABI (`boj_cartridge_init / deinit / name / version / invoke`). +// +// The shim centralises the seven-code return convention, NUL-argument +// guards, tool-name comparison, and the buffer-too-small path so each +// cartridge's `boj_cartridge_invoke` can stay short β€” typically a tool +// table plus `shim.writeResult(...)`. +// +// Cartridges import this file by relative path (no build-graph change +// needed). Example: +// +// const shim = @import("../../../ffi/zig/src/cartridge_shim.zig"); +// +// export fn boj_cartridge_invoke( +// tool_name: [*c]const u8, +// json_args: [*c]const u8, +// out_buf: [*c]u8, +// in_out_len: [*c]usize, +// ) callconv(.c) i32 { +// _ = json_args; +// if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; +// const body = if (shim.toolIs(tool_name, "foo")) "{\"result\":{}}" +// else return shim.RC_UNKNOWN_TOOL; +// return shim.writeResult(out_buf, in_out_len, body); +// } + +const std = @import("std"); + +// ── Return codes (ADR-0006 Β§Return codes) ──────────────────────────── +// +// Frozen by ADR-0006. New failure modes compose these via the error +// JSON body β€” the integer surface does not grow without a follow-up ADR. + +pub const RC_SUCCESS: i32 = 0; +pub const RC_UNKNOWN_TOOL: i32 = -1; +pub const RC_BAD_ARGS: i32 = -2; +pub const RC_BUFFER_TOO_SMALL: i32 = -3; +pub const RC_RUNTIME_ERROR: i32 = -4; +pub const RC_PANIC: i32 = -5; +pub const RC_AUTH_DENIED: i32 = -6; + +// ── Invoke-path helpers ────────────────────────────────────────────── + +/// True if any of the three mandatory `boj_cartridge_invoke` output-path +/// pointers is null. Use at the top of every invoke to short-circuit to +/// `RC_BAD_ARGS`. +pub fn invokeArgsNull( + tool_name: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) bool { + return tool_name == null or out_buf == null or in_out_len == null; +} + +/// Compare a C-NUL-terminated tool-name pointer against a Zig string +/// literal. Caller must have already verified `tool_name` is non-null +/// (usually via `invokeArgsNull`). +pub fn toolIs(tool_name: [*c]const u8, expected: []const u8) bool { + const s = std.mem.span(@as([*:0]const u8, @ptrCast(tool_name))); + return std.mem.eql(u8, s, expected); +} + +/// Copy `body` into `out_buf[0..*in_out_len]` (as a capacity) and update +/// `*in_out_len` to the number of bytes written. Returns `RC_SUCCESS`. +/// +/// If `body.len` exceeds the current capacity stored in `*in_out_len`, +/// sets `*in_out_len` to the required size and returns +/// `RC_BUFFER_TOO_SMALL` β€” the caller is then expected to re-allocate +/// and retry, per ADR-0006 Β§Memory ownership. +/// +/// Caller must have already verified that `out_buf` and `in_out_len` +/// are non-null. +pub fn writeResult( + out_buf: [*c]u8, + in_out_len: [*c]usize, + body: []const u8, +) i32 { + const cap = in_out_len.*; + if (body.len > cap) { + in_out_len.* = body.len; + return RC_BUFFER_TOO_SMALL; + } + @memcpy(out_buf[0..body.len], body); + in_out_len.* = body.len; + return RC_SUCCESS; +} + +// ── Tests ──────────────────────────────────────────────────────────── + +test "writeResult: body fits, writes and sets length" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); + try std.testing.expectEqualStrings("hello", buf[0..len]); +} + +test "writeResult: too small returns -3 and sets required length" { + var buf: [2]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_BUFFER_TOO_SMALL, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: exact-fit succeeds" { + var buf: [5]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: empty body" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, ""); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 0), len); +} + +test "toolIs: matches and rejects" { + const name: [*:0]const u8 = "foo"; + try std.testing.expect(toolIs(@ptrCast(name), "foo")); + try std.testing.expect(!toolIs(@ptrCast(name), "bar")); + try std.testing.expect(!toolIs(@ptrCast(name), "foobar")); + try std.testing.expect(!toolIs(@ptrCast(name), "fo")); +} + +test "invokeArgsNull: detects each null slot" { + var buf: [4]u8 = undefined; + var len: usize = 4; + const name: [*:0]const u8 = "x"; + try std.testing.expect(!invokeArgsNull(@ptrCast(name), &buf, &len)); + try std.testing.expect(invokeArgsNull(null, &buf, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), null, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), &buf, null)); +} diff --git a/cartridges/domains/security/vext-mcp/ffi/vext_ffi.zig b/cartridges/domains/security/vext-mcp/ffi/vext_ffi.zig new file mode 100644 index 0000000..b86291b --- /dev/null +++ b/cartridges/domains/security/vext-mcp/ffi/vext_ffi.zig @@ -0,0 +1,129 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +// +// Vext FFI β€” C-compatible exports for verifiable communications. + +const std = @import("std"); + +/// Verify status codes: 0=verified, 1=unverified, 2=tampered, 3=expired. +pub const VerifyStatus = enum(u8) { verified = 0, unverified = 1, tampered = 2, expired = 3 }; + +/// Verify a message. Returns status code. +export fn vext_verify_message(msg: [*c]const u8, sig: [*c]const u8) u8 { + if (msg == null or sig == null) return @intFromEnum(VerifyStatus.unverified); + return @intFromEnum(VerifyStatus.verified); // Stub +} + +/// Check an attestation by issuer. Returns depth or 0 if not found. +export fn vext_check_attestation(issuer: [*c]const u8) u32 { + if (issuer == null) return 0; + return 1; // Stub +} + +/// Append an entry to the verification chain. Returns 0 on success. +export fn vext_append_chain(payload: [*c]const u8) i32 { + if (payload == null) return -1; + return 0; // Stub +} + +// ═══════════════════════════════════════════════════════════════════════ +// Standard ABI (ADR-0005 four symbols + ADR-0006 invoke) +// ═══════════════════════════════════════════════════════════════════════ + +const shim = @import("cartridge_shim.zig"); + +const CARTRIDGE_NAME_PTR: [*:0]const u8 = "vext-mcp"; +const CARTRIDGE_VERSION_PTR: [*:0]const u8 = "0.1.0"; + +export fn boj_cartridge_init() callconv(.c) c_int { + return 0; +} + +export fn boj_cartridge_deinit() callconv(.c) void {} + +export fn boj_cartridge_name() callconv(.c) [*:0]const u8 { + return CARTRIDGE_NAME_PTR; +} + +export fn boj_cartridge_version() callconv(.c) [*:0]const u8 { + return CARTRIDGE_VERSION_PTR; +} + +/// Dispatch the cartridge.json MCP tools. Grade D Alpha stubs. +export fn boj_cartridge_invoke( + tool_name: [*c]const u8, + json_args: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) callconv(.c) i32 { + _ = json_args; + if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; + + const body: []const u8 = if (shim.toolIs(tool_name, "vext_verify_message")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "vext_check_attestation")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "vext_append_chain")) + "{\"result\":{\"status\":\"stub\"}}" +else + return shim.RC_UNKNOWN_TOOL; + + return shim.writeResult(out_buf, in_out_len, body); +} + +// ── Tests ── + +test "verify rejects null message" { + try std.testing.expectEqual(@as(u8, 1), vext_verify_message(null, "sig")); +} + +test "verify accepts valid inputs" { + try std.testing.expectEqual(@as(u8, 0), vext_verify_message("hello", "sig")); +} + +test "append rejects null payload" { + try std.testing.expectEqual(@as(i32, -1), vext_append_chain(null)); +} + +// ═══════════════════════════════════════════════════════════════════════ +// ADR-0006 invoke dispatch tests +// ═══════════════════════════════════════════════════════════════════════ + +test "boj_cartridge_name returns vext-mcp" { + const n = std.mem.span(boj_cartridge_name()); + try std.testing.expectEqualStrings("vext-mcp", n); +} + +test "boj_cartridge_init returns 0" { + try std.testing.expectEqual(@as(c_int, 0), boj_cartridge_init()); +} + +test "invoke: each declared tool succeeds" { + var buf: [256]u8 = undefined; + const tools = [_][]const u8{ + "vext_verify_message", + "vext_check_attestation", + "vext_append_chain", + }; + for (tools) |t| { + var len: usize = buf.len; + const rc = boj_cartridge_invoke(t.ptr, "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, 0), rc); + try std.testing.expect(std.mem.indexOf(u8, buf[0..len], "result") != null); + } +} + +test "invoke: unknown tool returns -1" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("nope", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -1), rc); +} + +test "invoke: buffer too small returns -3" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("vext_verify_message", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -3), rc); + try std.testing.expect(len > 4); +} diff --git a/cartridges/domains/security/vext-mcp/mod.js b/cartridges/domains/security/vext-mcp/mod.js new file mode 100644 index 0000000..cfdaee8 --- /dev/null +++ b/cartridges/domains/security/vext-mcp/mod.js @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// vext-mcp/mod.js -- vext gateway + +const BASE_URL = Deno.env.get("VEXT_BACKEND_URL") ?? "http://127.0.0.1:7711"; + +async function post(path, payload) { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), 15000); + try { + const r = await fetch(`${BASE_URL}${path}`, { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), signal: ctrl.signal, + }); + const data = await r.json().catch(() => ({ success: false, error: "non-JSON response" })); + return { status: r.status, data }; + } catch (e) { + if (e.name === "AbortError") return { status: 504, data: { success: false, error: "vext-mcp backend timed out" } }; + return { status: 503, data: { success: false, error: `vext-mcp backend unavailable: ${e.message}` } }; + } finally { clearTimeout(t); } +} + +export async function handleTool(toolName, args) { + switch (toolName) { + case "vext_verify_message": { + const { msg, sig } = args ?? {}; + if (!msg || !sig) return { status: 400, data: { error: "msg is required" } }; + const payload = { msg, sig }; + return post("/api/v1/verify-message", payload); + } + + case "vext_check_attestation": { + const { issuer } = args ?? {}; + if (!issuer) return { status: 400, data: { error: "issuer is required" } }; + const payload = { issuer }; + return post("/api/v1/check-attestation", payload); + } + + case "vext_append_chain": { + const { payload } = args ?? {}; + if (!payload) return { status: 400, data: { error: "payload is required" } }; + const payload = { payload }; + return post("/api/v1/append-chain", payload); + } + default: + return { status: 404, data: { error: `Unknown tool: ${toolName}` } }; + } +} diff --git a/cartridges/domains/security/vext-mcp/panels/manifest.json b/cartridges/domains/security/vext-mcp/panels/manifest.json new file mode 100644 index 0000000..cccb48c --- /dev/null +++ b/cartridges/domains/security/vext-mcp/panels/manifest.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://panll.dev/schemas/panel-manifest/v1.json", + "spdx": "MPL-2.0", + "cartridge": "vext-mcp", + "domain": "verification", + "version": "0.1.0", + "panels": [ + { + "id": "vext-verification-status", + "title": "Verification Status", + "description": "Message verification chain with attestation depth and tamper indicators.", + "type": "status-indicator", + "entrypoint": "panels/vext-verification-status.js", + "size": { "cols": 3, "rows": 2 }, + "refresh_interval_ms": 5000, + "data_sources": [ + { "op": "VerifyMessage", "on": "event" }, + { "op": "CheckAttestation", "interval_ms": 15000 } + ] + } + ] +} diff --git a/cartridges/domains/security/vordr-mcp/README.adoc b/cartridges/domains/security/vordr-mcp/README.adoc new file mode 100644 index 0000000..bdc92bd --- /dev/null +++ b/cartridges/domains/security/vordr-mcp/README.adoc @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += vordr-mcp +Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk> +:spdx: MPL-2.0 +:tier: Ayo +:domain: Security +:protocols: MCP, REST + +== Overview + +Vordr container integrity monitor. BLAKE3 hashing to detect tampering and drift in container images. + +== Tools (4) + +[cols="2,4"] +|=== +| Tool | Description + +| `vordr_scan` | Scan a container image for integrity (BLAKE3 hash comparison against baseline). +| `vordr_set_baseline` | Set the known-good BLAKE3 baseline for a container image. +| `vordr_alerts` | List containers with integrity drift or suspected tampering. +| `vordr_compare` | Compare two container image digests directly. +|=== + +== Architecture + +Three-layer stack per the BoJ cartridge standard: + +[cols="1,1,3"] +|=== +| Layer | Language | Purpose + +| ABI | Idris2 | Formally verified state machine with dependent-type proofs +| FFI | Zig | C-ABI implementation: session pool, action metrics, thread safety +| Adapter| Zig | Adapter bridge to the BoJ unified adapter protocol (zig predecessors sidelined) +|=== + +== Building + +[source,bash] +---- +# ABI (Idris2) +cd abi && idris2 --check *.idr + +# FFI (Zig) +cd ffi && zig build +cd ffi && zig build test + +# Adapter (Zig) +cd adapter && zig build +---- + +== Status + +Alpha (CRG D). Cartridge metadata declared in `cartridge.json`; 4 tool(s) exposed. +Not yet dogfooded externally. See `docs/READINESS.md` at repo root for the +project-wide grade table and `docs/practice/DOGFOOD-LOG.adoc` for usage evidence. diff --git a/cartridges/domains/security/vordr-mcp/abi/README.adoc b/cartridges/domains/security/vordr-mcp/abi/README.adoc new file mode 100644 index 0000000..1189d36 --- /dev/null +++ b/cartridges/domains/security/vordr-mcp/abi/README.adoc @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += vordr-mcp / abi β€” Idris2 ABI layer +:orientation: shallow + +== Purpose + +Cartridge-local protocol types for `vordr-mcp`. Defines the data, tool, and +transition vocabulary this cartridge speaks. Inherits safety lemmas and the +matrix-cell identifier scheme from the trunk ABI at `src/abi/`. + +== Files + +1 Idris2 module(s), ~62 lines total. Lead module: +`SafeVordr.idr`. + +No `.ipkg` β€” consumed directly by wider BoJ callers; type-checks inside the +trunk `boj.ipkg`. + +== Invariants + +* `%default total` at every module top. +* Zero `believe_me`, `assert_total`, `postulate`, `Admitted`, or + `sorry` (the trunk holds the four declared `believe_me` sites; leaf + cartridges carry none). + +== Test/proof surface + +Thin module(s), no inline tests. Proof surface = type-check success under the +trunk `idris2 --check`. + +== Read-first + +. `SafeVordr.idr` β€” cartridge-local data records and enums. +. Trunk `src/abi/Boj/Catalogue.idr` for how this cell participates in the + 2D matrix. diff --git a/cartridges/domains/security/vordr-mcp/abi/VordrMcp/SafeVordr.idr b/cartridges/domains/security/vordr-mcp/abi/VordrMcp/SafeVordr.idr new file mode 100644 index 0000000..417f2fe --- /dev/null +++ b/cartridges/domains/security/vordr-mcp/abi/VordrMcp/SafeVordr.idr @@ -0,0 +1,62 @@ +-- SPDX-License-Identifier: MPL-2.0 +-- Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +||| VordrMcp.SafeVordr: Formally verified container hash state monitoring. +||| +||| Cartridge: vordr-mcp (v0.5 Shield) +||| Monitors running container image hashes against known-good digests. +||| Detects runtime container replacement, layer tampering, and drift. +||| +||| Safety guarantees: +||| - Container digests are BLAKE3 hashes (not MD5/SHA-1) +||| - State transitions are monotonic (healthy β†’ drifted β†’ tampered, never back) +||| - Alert thresholds are positive (cannot set 0 tolerance) +module VordrMcp.SafeVordr + +%default total + +-- ═══════════════════════════════════════════════════════════════════════════ +-- Container Integrity State Machine +-- ═══════════════════════════════════════════════════════════════════════════ + +||| Container integrity states. Monotonically degrading β€” once tampered, +||| the container must be replaced, not "healed" back to healthy. +public export +data IntegrityState = Healthy | Drifted | Tampered | Unknown + +||| A container digest β€” always BLAKE3, never weak hashes. +public export +record ContainerDigest where + constructor MkDigest + imageRef : String + blake3Hash : String -- 64 hex chars + layerCount : Nat + +||| A monitoring observation β€” snapshot of container state at a point in time. +public export +record Observation where + constructor MkObs + container : ContainerDigest + state : IntegrityState + timestamp : Nat + +||| Monotonicity proof: state can only degrade, never improve. +public export +data MonotonicDegradation : IntegrityState -> IntegrityState -> Type where + StayHealthy : MonotonicDegradation Healthy Healthy + HealthyDrift : MonotonicDegradation Healthy Drifted + HealthyTamp : MonotonicDegradation Healthy Tampered + DriftedStay : MonotonicDegradation Drifted Drifted + DriftedTamp : MonotonicDegradation Drifted Tampered + TamperedStay : MonotonicDegradation Tampered Tampered + +-- ═══════════════════════════════════════════════════════════════════════════ +-- FFI Interface +-- ═══════════════════════════════════════════════════════════════════════════ + +public export +interface VordrFFI where + scanContainer : String -> IO (Either String Observation) + compareDigest : ContainerDigest -> ContainerDigest -> IO IntegrityState + listMonitored : IO (List ContainerDigest) + setBaseline : String -> ContainerDigest -> IO () + getAlerts : IO (List Observation) diff --git a/cartridges/domains/security/vordr-mcp/adapter/README.adoc b/cartridges/domains/security/vordr-mcp/adapter/README.adoc new file mode 100644 index 0000000..19e3370 --- /dev/null +++ b/cartridges/domains/security/vordr-mcp/adapter/README.adoc @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += vordr-mcp / adapter β€” protocol bridge +:orientation: shallow + +== Purpose + +Exposes this cartridge's FFI over the wire protocols declared in +`cartridge.json`. Dispatches tool invocations to the C-ABI exports at +`../ffi/` and formats responses per protocol. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” depends on the sibling `ffi` target. +| `vordr_adapter.zig` | Protocol dispatch (121 lines). Tool catalogue matches `cartridge.json`. +| `SIDELINED-*.v.adoc` | zig predecessor(s). Not built β€” zig banned 2026-04-10. +|=== + +== Invariants + +* **Stateless** β€” all state lives behind the FFI, never in the adapter. +* **Response passthrough** β€” whatever the FFI returns goes back to the wire + unmodified (no embellishment, no silent recovery). +* **Cartridge.json is source of truth** for the tool catalogue; drift between + adapter and manifest is a CI failure. + +== Test/proof surface + +No inline tests at the adapter layer β€” protocol routing is exercised by the +trunk seam tests at `ffi/zig/src/seams.zig`. + +== Read-first + +. `vordr_adapter.zig` β€” dispatch function and per-protocol routers. +. `../cartridge.json` β€” the tool manifest the dispatcher mirrors. diff --git a/cartridges/domains/security/vordr-mcp/adapter/build.zig b/cartridges/domains/security/vordr-mcp/adapter/build.zig new file mode 100644 index 0000000..051414c --- /dev/null +++ b/cartridges/domains/security/vordr-mcp/adapter/build.zig @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// vordr-mcp/adapter/build.zig + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + const ffi_mod = b.createModule(.{ + .root_source_file = b.path("../ffi/vordr_ffi.zig"), + .target = target, .optimize = optimize, + }); + const adapter = b.addExecutable(.{ + .name = "vordr_adapter", + .root_source_file = b.path("vordr_adapter.zig"), + .target = target, .optimize = optimize, + }); + adapter.root_module.addImport("vordr_ffi", ffi_mod); + b.installArtifact(adapter); + const run_step = b.step("run", "Run the vordr-mcp adapter"); + run_step.dependOn(&b.addRunArtifact(adapter).step); + const tests = b.addTest(.{ + .root_source_file = b.path("vordr_adapter.zig"), + .target = target, .optimize = optimize, + }); + tests.root_module.addImport("vordr_ffi", ffi_mod); + const test_step = b.step("test", "Run vordr-mcp adapter tests"); + test_step.dependOn(&b.addRunArtifact(tests).step); +} diff --git a/cartridges/domains/security/vordr-mcp/adapter/vordr_adapter.zig b/cartridges/domains/security/vordr-mcp/adapter/vordr_adapter.zig new file mode 100644 index 0000000..c2864f8 --- /dev/null +++ b/cartridges/domains/security/vordr-mcp/adapter/vordr_adapter.zig @@ -0,0 +1,121 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// vordr-mcp/adapter/vordr_adapter.zig -- Unified three-protocol adapter. +// +// Replaces the banned vordr_adapter.v (zig, removed 2026-04-12). +// +// REST :9136 gRPC-compat :9137 GraphQL :9138 +// Vordr container integrity monitor. BLAKE3 hashing to detect tampering and drift in container images. +// Tools: vordr_scan, vordr_set_baseline, vordr_alerts, vordr_compare + +const std = @import("std"); +const ffi = @import("vordr_ffi"); + +const REST_PORT: u16 = 9136; +const GRPC_PORT: u16 = 9137; +const GQL_PORT: u16 = 9138; +const MAX_CONN_BUF: usize = 16 * 1024; + +fn okJson(buf: []u8, msg: []const u8) []u8 { + const n = std.fmt.bufPrint(buf, "{{\"success\":true,\"message\":\"{s}\"}}", .{msg}) catch return buf[0..0]; + return n; +} +fn errJson(buf: []u8, msg: []const u8) []u8 { + const n = std.fmt.bufPrint(buf, "{{\"success\":false,\"error\":\"{s}\"}}", .{msg}) catch return buf[0..0]; + return n; +} +fn statusJson(buf: []u8) []u8 { + const n = std.fmt.bufPrint(buf, "{{\"success\":true,\"state\":\"ready\",\"service\":\"vordr-mcp\"}}", .{}) catch return buf[0..0]; + return n; +} + +const Response = struct { status: u16, body: []u8 }; + +fn dispatch(tool: []const u8, body: []const u8, resp: []u8) Response { + _ = body; + if (std.mem.eql(u8, tool, "vordr_scan")) return .{ .status = 200, .body = okJson(resp, "vordr_scan forwarded") }; + if (std.mem.eql(u8, tool, "vordr_set_baseline")) return .{ .status = 200, .body = okJson(resp, "vordr_set_baseline forwarded") }; + if (std.mem.eql(u8, tool, "vordr_alerts")) return .{ .status = 200, .body = okJson(resp, "vordr_alerts forwarded") }; + if (std.mem.eql(u8, tool, "vordr_compare")) return .{ .status = 200, .body = okJson(resp, "vordr_compare forwarded") }; + if (std.mem.eql(u8, tool, "status") or std.mem.eql(u8, tool, "health")) + return .{ .status = 200, .body = statusJson(resp) }; + return .{ .status = 404, .body = errJson(resp, "Unknown tool") }; +} + +fn dispatchRest(path: []const u8, body: []const u8, resp: []u8) Response { + if (std.mem.startsWith(u8, path, "/tools/")) return dispatch(path["/tools/".len..], body, resp); + if (std.mem.eql(u8, path, "/status") or std.mem.eql(u8, path, "/health")) return .{ .status = 200, .body = statusJson(resp) }; + return .{ .status = 404, .body = errJson(resp, "Not found") }; +} + +fn dispatchGrpc(path: []const u8, body: []const u8, resp: []u8) Response { + const prefix = "/Vordrservice/"; + if (!std.mem.startsWith(u8, path, prefix)) + return .{ .status = 404, .body = errJson(resp, "Not a recognized gRPC path") }; + const method = path[prefix.len..]; + const tool = blk: { + if (std.mem.eql(u8, method, "vordr_scan")) break :blk "vordr_scan"; + if (std.mem.eql(u8, method, "vordr_set_baseline")) break :blk "vordr_set_baseline"; + if (std.mem.eql(u8, method, "vordr_alerts")) break :blk "vordr_alerts"; + if (std.mem.eql(u8, method, "vordr_compare")) break :blk "vordr_compare"; + return .{ .status = 404, .body = errJson(resp, "Unknown gRPC method") }; + }; + return dispatch(tool, body, resp); +} + +fn dispatchGraphql(body: []const u8, resp: []u8) Response { + if (std.mem.indexOf(u8, body, "__schema") != null) return .{ .status = 200, .body = okJson(resp, "schema not supported") }; + if (std.mem.indexOf(u8, body, "scan") != null) return dispatch("vordr_scan", body, resp); + if (std.mem.indexOf(u8, body, "set_baseline") != null) return dispatch("vordr_set_baseline", body, resp); + if (std.mem.indexOf(u8, body, "alerts") != null) return dispatch("vordr_alerts", body, resp); + if (std.mem.indexOf(u8, body, "compare") != null) return dispatch("vordr_compare", body, resp); + return .{ .status = 200, .body = errJson(resp, "Unrecognised GraphQL operation") }; +} + +const Protocol = enum { rest, grpc, graphql }; + +fn handleConnection(conn: std.net.Server.Connection, proto: Protocol) void { + defer conn.stream.close(); + var in_buf: [MAX_CONN_BUF]u8 = undefined; + const n = conn.stream.read(&in_buf) catch return; + const req = in_buf[0..n]; + var path: []const u8 = "/"; + var body: []const u8 = ""; + if (n > 4) { + const line_end = std.mem.indexOf(u8, req, "\r\n") orelse req.len; + const first_line = req[0..line_end]; + const sp1 = std.mem.indexOfScalar(u8, first_line, ' ') orelse 0; + const rest_of = first_line[sp1 + 1 ..]; + const sp2 = std.mem.indexOfScalar(u8, rest_of, ' ') orelse rest_of.len; + path = rest_of[0..sp2]; + const body_sep = std.mem.indexOf(u8, req, "\r\n\r\n") orelse n; + body = req[@min(body_sep + 4, n)..]; + } + var resp_buf: [MAX_CONN_BUF]u8 = undefined; + const result = switch (proto) { + .rest => dispatchRest(path, body, &resp_buf), + .grpc => dispatchGrpc(path, body, &resp_buf), + .graphql => dispatchGraphql(body, &resp_buf), + }; + const ct: []const u8 = if (proto == .grpc) "application/grpc+json" else "application/json"; + var hdr_buf: [256]u8 = undefined; + const hdr = std.fmt.bufPrint(&hdr_buf, "HTTP/1.1 {d} OK\r\nContent-Type: {s}\r\nContent-Length: {d}\r\nConnection: close\r\n\r\n", .{ result.status, ct, result.body.len }) catch return; + _ = conn.stream.writeAll(hdr) catch return; + _ = conn.stream.writeAll(result.body) catch return; +} + +fn listenLoop(port: u16, proto: Protocol) void { + const addr = std.net.Address.initIp4(.{ 127, 0, 0, 1 }, port) catch return; + var server = addr.listen(.{ .reuse_address = true }) catch return; + defer server.deinit(); + while (true) { const conn = server.accept() catch continue; handleConnection(conn, proto); } +} + +pub fn main() !void { + ffi.vordr_init(); + const t1 = try std.Thread.spawn(.{}, listenLoop, .{ REST_PORT, Protocol.rest }); + const t2 = try std.Thread.spawn(.{}, listenLoop, .{ GRPC_PORT, Protocol.grpc }); + const t3 = try std.Thread.spawn(.{}, listenLoop, .{ GQL_PORT, Protocol.graphql }); + t1.join(); t2.join(); t3.join(); +} diff --git a/cartridges/domains/security/vordr-mcp/cartridge.json b/cartridges/domains/security/vordr-mcp/cartridge.json new file mode 100644 index 0000000..3fbe47c --- /dev/null +++ b/cartridges/domains/security/vordr-mcp/cartridge.json @@ -0,0 +1,98 @@ +{ + "$schema": "https://boj.dev/schemas/cartridge/v1.json", + "spdx": "MPL-2.0", + "copyright": "Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath)", + "name": "vordr-mcp", + "version": "0.1.0", + "description": "Vordr container integrity monitor. BLAKE3 hashing to detect tampering and drift in container images.", + "domain": "Security", + "tier": "Ayo", + "protocols": [ + "MCP", + "REST" + ], + "auth": { + "method": "none", + "env_var": null, + "credential_source": null + }, + "api": { + "base_url": "local://vordr-mcp", + "content_type": "application/json" + }, + "tools": [ + { + "name": "vordr_scan", + "description": "Scan a container image for integrity (BLAKE3 hash comparison against baseline).", + "inputSchema": { + "type": "object", + "properties": { + "image_ref": { + "type": "string", + "description": "Container image reference" + } + }, + "required": [ + "image_ref" + ] + } + }, + { + "name": "vordr_set_baseline", + "description": "Set the known-good BLAKE3 baseline for a container image.", + "inputSchema": { + "type": "object", + "properties": { + "image_ref": { + "type": "string", + "description": "Container image reference to baseline" + } + }, + "required": [ + "image_ref" + ] + } + }, + { + "name": "vordr_alerts", + "description": "List containers with integrity drift or suspected tampering.", + "inputSchema": { + "type": "object", + "properties": {}, + "required": [] + } + }, + { + "name": "vordr_compare", + "description": "Compare two container image digests directly.", + "inputSchema": { + "type": "object", + "properties": { + "image_a": { + "type": "string", + "description": "First container image reference" + }, + "image_b": { + "type": "string", + "description": "Second container image reference" + } + }, + "required": [ + "image_a", + "image_b" + ] + } + } + ], + "ffi": { + "so_path": "ffi/zig-out/lib/libvordr_mcp.so", + "abi_version": "ADR-0006", + "symbols": [ + "boj_cartridge_init", + "boj_cartridge_deinit", + "boj_cartridge_name", + "boj_cartridge_version", + "boj_cartridge_invoke" + ] + } +} diff --git a/cartridges/domains/security/vordr-mcp/ffi/README.adoc b/cartridges/domains/security/vordr-mcp/ffi/README.adoc new file mode 100644 index 0000000..f43c91d --- /dev/null +++ b/cartridges/domains/security/vordr-mcp/ffi/README.adoc @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += vordr-mcp / ffi β€” Zig FFI layer **(stub)** +:orientation: shallow + +== Purpose + +C-ABI exports bridging this cartridge's Idris2 ABI to the runtime. Consumed by +the adapter for protocol dispatch and by the trunk loader for mount. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” produces `zig-out/lib/libvordr.so`. +| `vordr_ffi.zig` | C-ABI exports (5 exports, 2 inline tests, 66 lines). +|=== + +== Invariants + +* **Null-pointer rejection** on `[*c]const u8` arguments before any + dereference. +* **Negative return values** carry documented error meanings; zero is + success (matching the trunk error-code convention). +* **No allocator leaks** β€” every allocation path pairs with `defer free`. + +== Test/proof surface + +2 inline `test "..."` block(s) in `vordr_ffi.zig`. Run with +`cd ffi && zig build test`. + +== Read-first + +. `vordr_ffi.zig` β€” C-ABI signatures and guards. +. Trunk `ffi/zig/src/catalogue.zig` for how this `.so` is loaded. + +== Promotion gate + +Wire these exports to real backing state (not `return 0` / `return 1` +placeholders) before promoting past D. diff --git a/cartridges/domains/security/vordr-mcp/ffi/build.zig b/cartridges/domains/security/vordr-mcp/ffi/build.zig new file mode 100644 index 0000000..bb1b6b2 --- /dev/null +++ b/cartridges/domains/security/vordr-mcp/ffi/build.zig @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + // Shared ADR-0006 invoke-shim module (relative path up to boj-server trunk). + + const shim_mod = b.addModule("cartridge_shim", .{ + + .root_source_file = b.path("../../../ffi/zig/src/cartridge_shim.zig"), + + .target = target, + + .optimize = optimize, + + }); + + const ffi_mod = b.addModule("vordr_mcp", .{ + .root_source_file = b.path("vordr_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + ffi_mod.addImport("cartridge_shim", shim_mod); + + const lib = b.addLibrary(.{ + .name = "vordr_mcp", + .root_module = ffi_mod, + .linkage = .dynamic, + }); + lib.linkLibC(); + b.installArtifact(lib); + + const tests = b.addTest(.{ .root_module = ffi_mod }); + tests.linkLibC(); + const run_tests = b.addRunArtifact(tests); + const test_step = b.step("test", "Run FFI tests"); + test_step.dependOn(&run_tests.step); +} + diff --git a/cartridges/domains/security/vordr-mcp/ffi/cartridge_shim.zig b/cartridges/domains/security/vordr-mcp/ffi/cartridge_shim.zig new file mode 100644 index 0000000..0e399db --- /dev/null +++ b/cartridges/domains/security/vordr-mcp/ffi/cartridge_shim.zig @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// cartridge_shim.zig β€” Shared helpers for the ADR-0006 five-symbol +// cartridge ABI (`boj_cartridge_init / deinit / name / version / invoke`). +// +// The shim centralises the seven-code return convention, NUL-argument +// guards, tool-name comparison, and the buffer-too-small path so each +// cartridge's `boj_cartridge_invoke` can stay short β€” typically a tool +// table plus `shim.writeResult(...)`. +// +// Cartridges import this file by relative path (no build-graph change +// needed). Example: +// +// const shim = @import("../../../ffi/zig/src/cartridge_shim.zig"); +// +// export fn boj_cartridge_invoke( +// tool_name: [*c]const u8, +// json_args: [*c]const u8, +// out_buf: [*c]u8, +// in_out_len: [*c]usize, +// ) callconv(.c) i32 { +// _ = json_args; +// if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; +// const body = if (shim.toolIs(tool_name, "foo")) "{\"result\":{}}" +// else return shim.RC_UNKNOWN_TOOL; +// return shim.writeResult(out_buf, in_out_len, body); +// } + +const std = @import("std"); + +// ── Return codes (ADR-0006 Β§Return codes) ──────────────────────────── +// +// Frozen by ADR-0006. New failure modes compose these via the error +// JSON body β€” the integer surface does not grow without a follow-up ADR. + +pub const RC_SUCCESS: i32 = 0; +pub const RC_UNKNOWN_TOOL: i32 = -1; +pub const RC_BAD_ARGS: i32 = -2; +pub const RC_BUFFER_TOO_SMALL: i32 = -3; +pub const RC_RUNTIME_ERROR: i32 = -4; +pub const RC_PANIC: i32 = -5; +pub const RC_AUTH_DENIED: i32 = -6; + +// ── Invoke-path helpers ────────────────────────────────────────────── + +/// True if any of the three mandatory `boj_cartridge_invoke` output-path +/// pointers is null. Use at the top of every invoke to short-circuit to +/// `RC_BAD_ARGS`. +pub fn invokeArgsNull( + tool_name: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) bool { + return tool_name == null or out_buf == null or in_out_len == null; +} + +/// Compare a C-NUL-terminated tool-name pointer against a Zig string +/// literal. Caller must have already verified `tool_name` is non-null +/// (usually via `invokeArgsNull`). +pub fn toolIs(tool_name: [*c]const u8, expected: []const u8) bool { + const s = std.mem.span(@as([*:0]const u8, @ptrCast(tool_name))); + return std.mem.eql(u8, s, expected); +} + +/// Copy `body` into `out_buf[0..*in_out_len]` (as a capacity) and update +/// `*in_out_len` to the number of bytes written. Returns `RC_SUCCESS`. +/// +/// If `body.len` exceeds the current capacity stored in `*in_out_len`, +/// sets `*in_out_len` to the required size and returns +/// `RC_BUFFER_TOO_SMALL` β€” the caller is then expected to re-allocate +/// and retry, per ADR-0006 Β§Memory ownership. +/// +/// Caller must have already verified that `out_buf` and `in_out_len` +/// are non-null. +pub fn writeResult( + out_buf: [*c]u8, + in_out_len: [*c]usize, + body: []const u8, +) i32 { + const cap = in_out_len.*; + if (body.len > cap) { + in_out_len.* = body.len; + return RC_BUFFER_TOO_SMALL; + } + @memcpy(out_buf[0..body.len], body); + in_out_len.* = body.len; + return RC_SUCCESS; +} + +// ── Tests ──────────────────────────────────────────────────────────── + +test "writeResult: body fits, writes and sets length" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); + try std.testing.expectEqualStrings("hello", buf[0..len]); +} + +test "writeResult: too small returns -3 and sets required length" { + var buf: [2]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_BUFFER_TOO_SMALL, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: exact-fit succeeds" { + var buf: [5]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: empty body" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, ""); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 0), len); +} + +test "toolIs: matches and rejects" { + const name: [*:0]const u8 = "foo"; + try std.testing.expect(toolIs(@ptrCast(name), "foo")); + try std.testing.expect(!toolIs(@ptrCast(name), "bar")); + try std.testing.expect(!toolIs(@ptrCast(name), "foobar")); + try std.testing.expect(!toolIs(@ptrCast(name), "fo")); +} + +test "invokeArgsNull: detects each null slot" { + var buf: [4]u8 = undefined; + var len: usize = 4; + const name: [*:0]const u8 = "x"; + try std.testing.expect(!invokeArgsNull(@ptrCast(name), &buf, &len)); + try std.testing.expect(invokeArgsNull(null, &buf, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), null, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), &buf, null)); +} diff --git a/cartridges/domains/security/vordr-mcp/ffi/vordr_ffi.zig b/cartridges/domains/security/vordr-mcp/ffi/vordr_ffi.zig new file mode 100644 index 0000000..c11e624 --- /dev/null +++ b/cartridges/domains/security/vordr-mcp/ffi/vordr_ffi.zig @@ -0,0 +1,157 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// vordr_ffi.zig β€” Container hash state monitoring via BLAKE3 digests. + +const std = @import("std"); + +pub const IntegrityState = enum(u8) { healthy = 0, drifted = 1, tampered = 2, unknown = 3 }; + +pub const ContainerDigest = extern struct { + image_ref: [*:0]const u8, + blake3_hash: [*:0]const u8, + layer_count: u32, +}; + +pub const Observation = extern struct { + digest: ContainerDigest, + state: IntegrityState, + timestamp: u64, +}; + +/// Scan a running container and return its current integrity state. +export fn vordr_scan_container(image_ref: [*:0]const u8, obs: *Observation) i32 { + obs.digest.image_ref = image_ref; + obs.digest.blake3_hash = "0000000000000000000000000000000000000000000000000000000000000000"; + obs.digest.layer_count = 1; + obs.state = .healthy; + obs.timestamp = 0; + return 0; +} + +/// Compare two digests β€” returns integrity state of the second relative to the first (baseline). +export fn vordr_compare_digest(baseline: *const ContainerDigest, current: *const ContainerDigest) IntegrityState { + _ = baseline; + _ = current; + return .healthy; +} + +/// Set a known-good baseline digest for a container image. +export fn vordr_set_baseline(image_ref: [*:0]const u8, digest: *const ContainerDigest) i32 { + _ = image_ref; + _ = digest; + return 0; +} + +/// Get the number of pending alerts (containers with state != healthy). +export fn vordr_alert_count() u32 { + return 0; +} + +export fn vordr_version() [*:0]const u8 { + return "0.5.0"; +} + +// ═══════════════════════════════════════════════════════════════════════ +// Standard ABI (ADR-0005 four symbols + ADR-0006 invoke) +// ═══════════════════════════════════════════════════════════════════════ + +const shim = @import("cartridge_shim.zig"); + +const CARTRIDGE_NAME_PTR: [*:0]const u8 = "vordr-mcp"; +const CARTRIDGE_VERSION_PTR: [*:0]const u8 = "0.1.0"; + +export fn boj_cartridge_init() callconv(.c) c_int { + return 0; +} + +export fn boj_cartridge_deinit() callconv(.c) void {} + +export fn boj_cartridge_name() callconv(.c) [*:0]const u8 { + return CARTRIDGE_NAME_PTR; +} + +export fn boj_cartridge_version() callconv(.c) [*:0]const u8 { + return CARTRIDGE_VERSION_PTR; +} + +/// Dispatch the cartridge.json MCP tools. Grade D Alpha stubs. +export fn boj_cartridge_invoke( + tool_name: [*c]const u8, + json_args: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) callconv(.c) i32 { + _ = json_args; + if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; + + const body: []const u8 = if (shim.toolIs(tool_name, "vordr_scan")) + "{\"result\":{\"matches\":[],\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "vordr_set_baseline")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "vordr_alerts")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "vordr_compare")) + "{\"result\":{\"status\":\"stub\"}}" +else + return shim.RC_UNKNOWN_TOOL; + + return shim.writeResult(out_buf, in_out_len, body); +} + +test "scan returns healthy" { + var obs: Observation = undefined; + const status = vordr_scan_container("nginx:latest", &obs); + try std.testing.expectEqual(@as(i32, 0), status); + try std.testing.expectEqual(IntegrityState.healthy, obs.state); +} + +test "compare identical returns healthy" { + const d = ContainerDigest{ .image_ref = "a", .blake3_hash = "x", .layer_count = 1 }; + const state = vordr_compare_digest(&d, &d); + try std.testing.expectEqual(IntegrityState.healthy, state); +} + +// ═══════════════════════════════════════════════════════════════════════ +// ADR-0006 invoke dispatch tests +// ═══════════════════════════════════════════════════════════════════════ + +test "boj_cartridge_name returns vordr-mcp" { + const n = std.mem.span(boj_cartridge_name()); + try std.testing.expectEqualStrings("vordr-mcp", n); +} + +test "boj_cartridge_init returns 0" { + try std.testing.expectEqual(@as(c_int, 0), boj_cartridge_init()); +} + +test "invoke: each declared tool succeeds" { + var buf: [256]u8 = undefined; + const tools = [_][]const u8{ + "vordr_scan", + "vordr_set_baseline", + "vordr_alerts", + "vordr_compare", + }; + for (tools) |t| { + var len: usize = buf.len; + const rc = boj_cartridge_invoke(t.ptr, "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, 0), rc); + try std.testing.expect(std.mem.indexOf(u8, buf[0..len], "result") != null); + } +} + +test "invoke: unknown tool returns -1" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("nope", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -1), rc); +} + +test "invoke: buffer too small returns -3" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("vordr_scan", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -3), rc); + try std.testing.expect(len > 4); +} diff --git a/cartridges/domains/security/vordr-mcp/mod.js b/cartridges/domains/security/vordr-mcp/mod.js new file mode 100644 index 0000000..6f468b7 --- /dev/null +++ b/cartridges/domains/security/vordr-mcp/mod.js @@ -0,0 +1,55 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// vordr-mcp/mod.js -- vordr gateway + +const BASE_URL = Deno.env.get("VORDR_BACKEND_URL") ?? "http://127.0.0.1:7712"; + +async function post(path, payload) { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), 15000); + try { + const r = await fetch(`${BASE_URL}${path}`, { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), signal: ctrl.signal, + }); + const data = await r.json().catch(() => ({ success: false, error: "non-JSON response" })); + return { status: r.status, data }; + } catch (e) { + if (e.name === "AbortError") return { status: 504, data: { success: false, error: "vordr-mcp backend timed out" } }; + return { status: 503, data: { success: false, error: `vordr-mcp backend unavailable: ${e.message}` } }; + } finally { clearTimeout(t); } +} + +export async function handleTool(toolName, args) { + switch (toolName) { + case "vordr_scan": { + const { image_ref } = args ?? {}; + if (!image_ref) return { status: 400, data: { error: "image_ref is required" } }; + const payload = { image_ref }; + return post("/api/v1/scan", payload); + } + + case "vordr_set_baseline": { + const { image_ref } = args ?? {}; + if (!image_ref) return { status: 400, data: { error: "image_ref is required" } }; + const payload = { image_ref }; + return post("/api/v1/set-baseline", payload); + } + + case "vordr_alerts": { + const { _args } = args ?? {}; + const payload = { }; + return post("/api/v1/alerts", payload); + } + + case "vordr_compare": { + const { image_a, image_b } = args ?? {}; + if (!image_a || !image_b) return { status: 400, data: { error: "image_a is required" } }; + const payload = { image_a, image_b }; + return post("/api/v1/compare", payload); + } + default: + return { status: 404, data: { error: `Unknown tool: ${toolName}` } }; + } +} diff --git a/cartridges/domains/vector/chromadb-mcp/cartridge.json b/cartridges/domains/vector/chromadb-mcp/cartridge.json new file mode 100644 index 0000000..6326f08 --- /dev/null +++ b/cartridges/domains/vector/chromadb-mcp/cartridge.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://boj.dev/schemas/cartridge/v1.json", + "spdx": "MPL-2.0", + "copyright": "Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk>", + "name": "chromadb-mcp", + "version": "0.1.0", + "status": "stub", + "description": "Chroma vector DB β€” embedded (local persistent) or client/server; LLM-app-focused; metadata + document storage alongside vectors.", + "domain": "vector", + "tier": "Teranga", + "protocols": ["MCP", "REST"], + "auth": { "method": "optional_bearer", "env_var": "CHROMA_AUTH_TOKEN", "credential_source": "Optional β€” embedded mode has no auth; client/server may use bearer token." }, + "api": { "base_url": "local://chromadb-mcp", "content_type": "application/json" }, + "tools": [ + { "name": "vector_authenticate", "description": "Store Chroma endpoint URL (or 'embedded' for in-process) + optional bearer.", "inputSchema": { "type": "object", "properties": { "endpoint": { "type": "string" }, "auth_token": { "type": "string" }, "embedding_function": { "type": "string" } }, "required": ["endpoint"] } }, + { "name": "vector_list_collections", "description": "List collections.", "inputSchema": { "type": "object", "properties": {} } }, + { "name": "vector_create_collection", "description": "Create a collection with optional embedding function + metadata.", "inputSchema": { "type": "object", "properties": { "name": { "type": "string" }, "metadata": { "type": "object" }, "embedding_function": { "type": "string" } }, "required": ["name"] } }, + { "name": "vector_delete_collection", "description": "Delete a collection.", "inputSchema": { "type": "object", "properties": { "name": { "type": "string" } }, "required": ["name"] } }, + { "name": "vector_upsert", "description": "Add documents + vectors + metadata. Chroma computes embeddings if not provided.", "inputSchema": { "type": "object", "properties": { "collection": { "type": "string" }, "ids": { "type": "array" }, "documents": { "type": "array" }, "embeddings": { "type": "array" }, "metadatas": { "type": "array" } }, "required": ["collection", "ids"] } }, + { "name": "vector_query", "description": "Query by text or embedding with where-filter + document-content filter.", "inputSchema": { "type": "object", "properties": { "collection": { "type": "string" }, "query_texts": { "type": "array" }, "query_embeddings": { "type": "array" }, "n_results": { "type": "integer" }, "where": { "type": "object" }, "where_document": { "type": "object" } }, "required": ["collection"] } }, + { "name": "vector_delete", "description": "Delete documents by id or where-filter.", "inputSchema": { "type": "object", "properties": { "collection": { "type": "string" }, "ids": { "type": "array" }, "where": { "type": "object" } }, "required": ["collection"] } } + ] +} diff --git a/cartridges/domains/vector/pinecone-mcp/cartridge.json b/cartridges/domains/vector/pinecone-mcp/cartridge.json new file mode 100644 index 0000000..9256bb7 --- /dev/null +++ b/cartridges/domains/vector/pinecone-mcp/cartridge.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://boj.dev/schemas/cartridge/v1.json", + "spdx": "MPL-2.0", + "copyright": "Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk>", + "name": "pinecone-mcp", + "version": "0.1.0", + "status": "stub", + "description": "Pinecone hosted vector DB β€” serverless indexes, upsert, similarity search, namespaces, metadata filtering.", + "domain": "vector", + "tier": "Teranga", + "protocols": ["MCP", "REST"], + "auth": { "method": "api_key", "env_var": "PINECONE_API_KEY", "credential_source": "Pinecone console; environment-scoped." }, + "api": { "base_url": "local://pinecone-mcp", "content_type": "application/json" }, + "tools": [ + { "name": "vector_authenticate", "description": "Store Pinecone API key.", "inputSchema": { "type": "object", "properties": { "api_key": { "type": "string" }, "environment": { "type": "string" } }, "required": ["api_key"] } }, + { "name": "vector_list_collections", "description": "List indexes (Pinecone calls them indexes).", "inputSchema": { "type": "object", "properties": {} } }, + { "name": "vector_create_collection", "description": "Create a new index. Pinecone-specific: dimension + metric required.", "inputSchema": { "type": "object", "properties": { "name": { "type": "string" }, "dimension": { "type": "integer" }, "metric": { "type": "string", "enum": ["cosine", "euclidean", "dotproduct"] } }, "required": ["name", "dimension"] } }, + { "name": "vector_delete_collection", "description": "Delete an index.", "inputSchema": { "type": "object", "properties": { "name": { "type": "string" } }, "required": ["name"] } }, + { "name": "vector_upsert", "description": "Insert/update vectors with optional metadata + namespace.", "inputSchema": { "type": "object", "properties": { "collection": { "type": "string" }, "vectors": { "type": "array" }, "namespace": { "type": "string" } }, "required": ["collection", "vectors"] } }, + { "name": "vector_query", "description": "Similarity search. Supports metadata filter + namespace.", "inputSchema": { "type": "object", "properties": { "collection": { "type": "string" }, "query": { "type": "array" }, "top_k": { "type": "integer" }, "namespace": { "type": "string" }, "filter": { "type": "object" } }, "required": ["collection", "query"] } }, + { "name": "vector_delete", "description": "Delete vectors by id, namespace, or filter.", "inputSchema": { "type": "object", "properties": { "collection": { "type": "string" }, "ids": { "type": "array" }, "namespace": { "type": "string" }, "filter": { "type": "object" } }, "required": ["collection"] } } + ] +} diff --git a/cartridges/domains/vector/qdrant-mcp/cartridge.json b/cartridges/domains/vector/qdrant-mcp/cartridge.json new file mode 100644 index 0000000..5a39216 --- /dev/null +++ b/cartridges/domains/vector/qdrant-mcp/cartridge.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://boj.dev/schemas/cartridge/v1.json", + "spdx": "MPL-2.0", + "copyright": "Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk>", + "name": "qdrant-mcp", + "version": "0.1.0", + "status": "stub", + "description": "Qdrant vector DB β€” Rust-native; payloads + filtering; sparse + dense vectors; self-host or Qdrant Cloud.", + "domain": "vector", + "tier": "Teranga", + "protocols": ["MCP", "REST", "gRPC"], + "auth": { "method": "api_key", "env_var": "QDRANT_API_KEY", "credential_source": "Qdrant Cloud dashboard or self-hosted bearer." }, + "api": { "base_url": "local://qdrant-mcp", "content_type": "application/json" }, + "tools": [ + { "name": "vector_authenticate", "description": "Store Qdrant URL + API key.", "inputSchema": { "type": "object", "properties": { "url": { "type": "string" }, "api_key": { "type": "string" } }, "required": ["url"] } }, + { "name": "vector_list_collections", "description": "List collections.", "inputSchema": { "type": "object", "properties": {} } }, + { "name": "vector_create_collection", "description": "Create a collection. Vector params: size (dim) + distance metric.", "inputSchema": { "type": "object", "properties": { "name": { "type": "string" }, "vector_size": { "type": "integer" }, "distance": { "type": "string", "enum": ["Cosine", "Euclid", "Dot"] } }, "required": ["name", "vector_size"] } }, + { "name": "vector_delete_collection", "description": "Delete a collection.", "inputSchema": { "type": "object", "properties": { "name": { "type": "string" } }, "required": ["name"] } }, + { "name": "vector_upsert", "description": "Insert/update points with payload.", "inputSchema": { "type": "object", "properties": { "collection": { "type": "string" }, "points": { "type": "array" } }, "required": ["collection", "points"] } }, + { "name": "vector_query", "description": "Search by vector with payload filter + score threshold.", "inputSchema": { "type": "object", "properties": { "collection": { "type": "string" }, "vector": { "type": "array" }, "limit": { "type": "integer" }, "filter": { "type": "object" }, "score_threshold": { "type": "number" } }, "required": ["collection", "vector"] } }, + { "name": "vector_delete", "description": "Delete points by id or filter.", "inputSchema": { "type": "object", "properties": { "collection": { "type": "string" }, "ids": { "type": "array" }, "filter": { "type": "object" } }, "required": ["collection"] } } + ] +} diff --git a/cartridges/domains/vector/weaviate-mcp/cartridge.json b/cartridges/domains/vector/weaviate-mcp/cartridge.json new file mode 100644 index 0000000..2e32559 --- /dev/null +++ b/cartridges/domains/vector/weaviate-mcp/cartridge.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://boj.dev/schemas/cartridge/v1.json", + "spdx": "MPL-2.0", + "copyright": "Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk>", + "name": "weaviate-mcp", + "version": "0.1.0", + "status": "stub", + "description": "Weaviate vector DB β€” hybrid (vector + BM25 + filter) search, schema-driven classes, modular vectorisers; self-host or cloud.", + "domain": "vector", + "tier": "Teranga", + "protocols": ["MCP", "REST", "gRPC", "GraphQL"], + "auth": { "method": "api_key", "env_var": "WEAVIATE_API_KEY", "credential_source": "Weaviate Cloud Console or self-hosted instance bearer." }, + "api": { "base_url": "local://weaviate-mcp", "content_type": "application/json" }, + "tools": [ + { "name": "vector_authenticate", "description": "Store Weaviate endpoint URL + API key.", "inputSchema": { "type": "object", "properties": { "endpoint": { "type": "string" }, "api_key": { "type": "string" } }, "required": ["endpoint"] } }, + { "name": "vector_list_collections", "description": "List classes (Weaviate calls collections 'classes').", "inputSchema": { "type": "object", "properties": {} } }, + { "name": "vector_create_collection", "description": "Create a class with property schema + vectoriser.", "inputSchema": { "type": "object", "properties": { "name": { "type": "string" }, "properties": { "type": "array" }, "vectorizer": { "type": "string" } }, "required": ["name"] } }, + { "name": "vector_delete_collection", "description": "Delete a class and all its objects.", "inputSchema": { "type": "object", "properties": { "name": { "type": "string" } }, "required": ["name"] } }, + { "name": "vector_upsert", "description": "Insert/update objects; vectoriser computes embedding if not provided.", "inputSchema": { "type": "object", "properties": { "collection": { "type": "string" }, "objects": { "type": "array" } }, "required": ["collection", "objects"] } }, + { "name": "vector_query", "description": "Hybrid (vector + BM25 + filter) search. Set alpha to weight vector vs BM25.", "inputSchema": { "type": "object", "properties": { "collection": { "type": "string" }, "query": { "type": "string" }, "vector": { "type": "array" }, "alpha": { "type": "number" }, "limit": { "type": "integer" }, "where": { "type": "object" } }, "required": ["collection"] } }, + { "name": "vector_delete", "description": "Delete objects by id or where-filter.", "inputSchema": { "type": "object", "properties": { "collection": { "type": "string" }, "ids": { "type": "array" }, "where": { "type": "object" } }, "required": ["collection"] } } + ] +} diff --git a/cartridges/domains/web/ssg-mcp/README.adoc b/cartridges/domains/web/ssg-mcp/README.adoc new file mode 100644 index 0000000..791419c --- /dev/null +++ b/cartridges/domains/web/ssg-mcp/README.adoc @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += ssg-mcp +Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk> +:spdx: MPL-2.0 +:tier: Ayo +:domain: web +:protocols: MCP, REST + +== Overview + +Static site generator (Hugo, Zola, Astro, Casket) + +== Tools (5) + +[cols="2,4"] +|=== +| Tool | Description + +| `ssg_load_content` | Load content from a source directory +| `ssg_build` | Build the static site +| `ssg_preview` | Start development preview server +| `ssg_deploy` | Deploy the built site +| `ssg_clean` | Clean build artifacts +|=== + +== Architecture + +Three-layer stack per the BoJ cartridge standard: + +[cols="1,1,3"] +|=== +| Layer | Language | Purpose + +| ABI | Idris2 | Formally verified state machine with dependent-type proofs +| FFI | Zig | C-ABI implementation: session pool, action metrics, thread safety +| Adapter| Zig | Adapter bridge to the BoJ unified adapter protocol (zig predecessors sidelined) +|=== + +== Building + +[source,bash] +---- +# ABI (Idris2) +cd abi && idris2 --check *.idr + +# FFI (Zig) +cd ffi && zig build +cd ffi && zig build test + +# Adapter (Zig) +cd adapter && zig build +---- + +== Status + +Alpha (CRG D). Cartridge metadata declared in `cartridge.json`; 5 tool(s) exposed. +Not yet dogfooded externally. See `docs/READINESS.md` at repo root for the +project-wide grade table and `docs/practice/DOGFOOD-LOG.adoc` for usage evidence. diff --git a/cartridges/domains/web/ssg-mcp/abi/README.adoc b/cartridges/domains/web/ssg-mcp/abi/README.adoc new file mode 100644 index 0000000..1ff9084 --- /dev/null +++ b/cartridges/domains/web/ssg-mcp/abi/README.adoc @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += ssg-mcp / abi β€” Idris2 ABI layer +:orientation: shallow + +== Purpose + +Cartridge-local protocol types for `ssg-mcp`. Defines the data, tool, and +transition vocabulary this cartridge speaks. Inherits safety lemmas and the +matrix-cell identifier scheme from the trunk ABI at `src/abi/`. + +== Files + +1 Idris2 module(s), ~174 lines total. Lead module: +`SafeSsg.idr`. + +No `.ipkg` β€” consumed directly by wider BoJ callers; type-checks inside the +trunk `boj.ipkg`. + +== Invariants + +* `%default total` at every module top. +* Zero `believe_me`, `assert_total`, `postulate`, `Admitted`, or + `sorry` (the trunk holds the four declared `believe_me` sites; leaf + cartridges carry none). + +== Test/proof surface + +Thin module(s), no inline tests. Proof surface = type-check success under the +trunk `idris2 --check`. + +== Read-first + +. `SafeSsg.idr` β€” cartridge-local data records and enums. +. Trunk `src/abi/Boj/Catalogue.idr` for how this cell participates in the + 2D matrix. diff --git a/cartridges/domains/web/ssg-mcp/abi/SsgMcp/SafeSsg.idr b/cartridges/domains/web/ssg-mcp/abi/SsgMcp/SafeSsg.idr new file mode 100644 index 0000000..3ac3b43 --- /dev/null +++ b/cartridges/domains/web/ssg-mcp/abi/SsgMcp/SafeSsg.idr @@ -0,0 +1,174 @@ +-- SPDX-License-Identifier: MPL-2.0 +-- Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +||| SsgMcp.SafeSsg: Formally verified static site generation operations. +||| +||| Cartridge: ssg-mcp +||| Matrix cell: SSG domain x {MCP, LSP} protocols +||| +||| This module defines a build pipeline state machine that prevents: +||| - Deploying unbuilt sites +||| - Skipping preview before deployment +||| - Previewing without a successful build +||| +||| State machine: Empty -> ContentLoaded -> Built -> Previewing -> ReadyToDeploy -> Deployed +module SsgMcp.SafeSsg + +import Data.List + +%default total + +-- ═══════════════════════════════════════════════════════════════════════════ +-- SSG State Machine +-- ═══════════════════════════════════════════════════════════════════════════ + +||| SSG site lifecycle states. +||| A site progresses: Empty -> ContentLoaded -> Built -> Previewing -> ReadyToDeploy -> Deployed +public export +data SsgState = Empty | ContentLoaded | Built | Previewing | ReadyToDeploy | Deployed | SsgError + +||| Equality for SSG states. +public export +Eq SsgState where + Empty == Empty = True + ContentLoaded == ContentLoaded = True + Built == Built = True + Previewing == Previewing = True + ReadyToDeploy == ReadyToDeploy = True + Deployed == Deployed = True + SsgError == SsgError = True + _ == _ = False + +||| Valid state transitions (enforced at the type level). +||| Critically, ContentLoaded -> Previewing is NOT valid (must build first). +||| And Built -> Deployed is NOT valid (must preview first). +public export +data ValidTransition : SsgState -> SsgState -> Type where + LoadContent : ValidTransition Empty ContentLoaded + Build : ValidTransition ContentLoaded Built + Rebuild : ValidTransition Built Built + StartPreview : ValidTransition Built Previewing + EndPreview : ValidTransition Previewing ReadyToDeploy + Deploy : ValidTransition ReadyToDeploy Deployed + Clean : ValidTransition Deployed Empty + CleanReady : ValidTransition ReadyToDeploy Empty + BuildError : ValidTransition ContentLoaded SsgError + DeployError : ValidTransition ReadyToDeploy SsgError + Recover : ValidTransition SsgError Empty + +||| Runtime transition validator. +public export +canTransition : SsgState -> SsgState -> Bool +canTransition Empty ContentLoaded = True +canTransition ContentLoaded Built = True +canTransition Built Built = True -- rebuild +canTransition Built Previewing = True +canTransition Previewing ReadyToDeploy = True +canTransition ReadyToDeploy Deployed = True +canTransition Deployed Empty = True -- clean +canTransition ReadyToDeploy Empty = True -- clean without deploying +canTransition ContentLoaded SsgError = True -- build error +canTransition ReadyToDeploy SsgError = True -- deploy error +canTransition SsgError Empty = True -- recover +canTransition _ _ = False + +-- ═══════════════════════════════════════════════════════════════════════════ +-- SSG Engine Types +-- ═══════════════════════════════════════════════════════════════════════════ + +||| Supported SSG engines. +public export +data SsgEngine + = Hugo -- Hugo static site generator + | Zola -- Zola (Rust-based SSG) + | Astro -- Astro framework + | Casket -- Casket (hyperpolymath SSG) + | Custom String -- User-defined engine + +||| C-ABI encoding. +public export +engineToInt : SsgEngine -> Int +engineToInt Hugo = 1 +engineToInt Zola = 2 +engineToInt Astro = 3 +engineToInt Casket = 4 +engineToInt (Custom _) = 99 + +-- ═══════════════════════════════════════════════════════════════════════════ +-- MCP Tool Definitions +-- ═══════════════════════════════════════════════════════════════════════════ + +||| MCP tools exposed by this cartridge. +||| These map to MCP tool definitions that AI agents can call. +public export +data McpTool + = ToolLoadContent -- Load content into the site + | ToolBuild -- Build the static site + | ToolPreview -- Start a local preview server + | ToolDeploy -- Deploy to production + | ToolClean -- Clean build artifacts and reset + | ToolStatus -- Site pipeline health check + | ToolListTemplates -- List available templates + | ToolValidateContent -- Validate content files (frontmatter, links) + +||| MCP tool name (for JSON-RPC method name). +public export +toolName : McpTool -> String +toolName ToolLoadContent = "ssg/load" +toolName ToolBuild = "ssg/build" +toolName ToolPreview = "ssg/preview" +toolName ToolDeploy = "ssg/deploy" +toolName ToolClean = "ssg/clean" +toolName ToolStatus = "ssg/status" +toolName ToolListTemplates = "ssg/templates" +toolName ToolValidateContent = "ssg/validate" + +||| Which tools require a successful build. +public export +toolRequiresBuild : McpTool -> Bool +toolRequiresBuild ToolPreview = True +toolRequiresBuild ToolDeploy = True +toolRequiresBuild _ = False + +-- ═══════════════════════════════════════════════════════════════════════════ +-- C-ABI Exports +-- ═══════════════════════════════════════════════════════════════════════════ + +||| SSG state to integer. +public export +ssgStateToInt : SsgState -> Int +ssgStateToInt Empty = 0 +ssgStateToInt ContentLoaded = 1 +ssgStateToInt Built = 2 +ssgStateToInt Previewing = 3 +ssgStateToInt ReadyToDeploy = 4 +ssgStateToInt Deployed = 5 +ssgStateToInt SsgError = 6 + +||| FFI: Validate a state transition. +export +ssg_can_transition : Int -> Int -> Int +ssg_can_transition from to = + let fromState = case from of + 0 => Empty + 1 => ContentLoaded + 2 => Built + 3 => Previewing + 4 => ReadyToDeploy + 5 => Deployed + _ => SsgError + toState = case to of + 0 => Empty + 1 => ContentLoaded + 2 => Built + 3 => Previewing + 4 => ReadyToDeploy + 5 => Deployed + _ => SsgError + in if canTransition fromState toState then 1 else 0 + +||| FFI: Check if a tool requires a successful build. +export +ssg_tool_requires_build : Int -> Int +ssg_tool_requires_build 3 = 1 -- ToolPreview +ssg_tool_requires_build 4 = 1 -- ToolDeploy +ssg_tool_requires_build _ = 0 -- All others do not require a build diff --git a/cartridges/domains/web/ssg-mcp/abi/ssg-mcp.ipkg b/cartridges/domains/web/ssg-mcp/abi/ssg-mcp.ipkg new file mode 100644 index 0000000..4a913cf --- /dev/null +++ b/cartridges/domains/web/ssg-mcp/abi/ssg-mcp.ipkg @@ -0,0 +1,11 @@ +-- SPDX-License-Identifier: MPL-2.0 +package ssgmcp + +authors = "Jonathan D.A. Jewell" +version = 0.1.0 +license = "MPL-2.0" +brief = "SSG MCP cartridge β€” build pipeline state machine for Hugo/Zola/Astro/Casket" + +sourcedir = "." +modules = SsgMcp.SafeSsg +depends = base, contrib diff --git a/cartridges/domains/web/ssg-mcp/adapter/README.adoc b/cartridges/domains/web/ssg-mcp/adapter/README.adoc new file mode 100644 index 0000000..90d8637 --- /dev/null +++ b/cartridges/domains/web/ssg-mcp/adapter/README.adoc @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += ssg-mcp / adapter β€” protocol bridge +:orientation: shallow + +== Purpose + +Exposes this cartridge's FFI over the wire protocols declared in +`cartridge.json`. Dispatches tool invocations to the C-ABI exports at +`../ffi/` and formats responses per protocol. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” depends on the sibling `ffi` target. +| `ssg_adapter.zig` | Protocol dispatch (134 lines). Tool catalogue matches `cartridge.json`. +| `SIDELINED-*.v.adoc` | zig predecessor(s). Not built β€” zig banned 2026-04-10. +|=== + +== Invariants + +* **Stateless** β€” all state lives behind the FFI, never in the adapter. +* **Response passthrough** β€” whatever the FFI returns goes back to the wire + unmodified (no embellishment, no silent recovery). +* **Cartridge.json is source of truth** for the tool catalogue; drift between + adapter and manifest is a CI failure. + +== Test/proof surface + +No inline tests at the adapter layer β€” protocol routing is exercised by the +trunk seam tests at `ffi/zig/src/seams.zig`. + +== Read-first + +. `ssg_adapter.zig` β€” dispatch function and per-protocol routers. +. `../cartridge.json` β€” the tool manifest the dispatcher mirrors. diff --git a/cartridges/domains/web/ssg-mcp/adapter/build.zig b/cartridges/domains/web/ssg-mcp/adapter/build.zig new file mode 100644 index 0000000..23935ad --- /dev/null +++ b/cartridges/domains/web/ssg-mcp/adapter/build.zig @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// ssg-mcp/adapter/build.zig + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const ffi_mod = b.createModule(.{ + .root_source_file = b.path("../ffi/ssg_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + const adapter = b.addExecutable(.{ + .name = "ssg_adapter", + .root_source_file = b.path("ssg_adapter.zig"), + .target = target, + .optimize = optimize, + }); + adapter.root_module.addImport("ssg_ffi", ffi_mod); + b.installArtifact(adapter); +} diff --git a/cartridges/domains/web/ssg-mcp/adapter/ssg_adapter.zig b/cartridges/domains/web/ssg-mcp/adapter/ssg_adapter.zig new file mode 100644 index 0000000..1f02571 --- /dev/null +++ b/cartridges/domains/web/ssg-mcp/adapter/ssg_adapter.zig @@ -0,0 +1,134 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// ssg-mcp/adapter/ssg_adapter.zig +// +// Three-protocol BoJ adapter: REST (port 9292), gRPC-compat (port 9293), +// GraphQL (port 9294). +// Replaces the banned zig adapter (ssg_adapter.v). + +const std = @import("std"); +const ffi = @import("ssg_ffi"); + +const REST_PORT: u16 = 9292; +const GRPC_PORT: u16 = 9293; +const GQL_PORT: u16 = 9294; + +const Response = struct { status: u16, body: []const u8 }; + +fn okJson(buf: []u8, msg: []const u8) []u8 { + return std.fmt.bufPrint(buf, "{{\"success\":true,\"message\":\"{s}\"}}", .{msg}) catch buf[0..0]; +} + +fn errJson(buf: []u8, msg: []const u8) []u8 { + return std.fmt.bufPrint(buf, "{{\"success\":false,\"error\":\"{s}\"}}", .{msg}) catch buf[0..0]; +} + +fn dispatch(tool: []const u8, _body: []const u8, resp: []u8) Response { + _ = _body; + if (std.mem.eql(u8, tool, "ssg_load_content")) { + return .{ .status = 200, .body = okJson(resp, "ssg_load_content forwarded") }; + } + if (std.mem.eql(u8, tool, "ssg_build")) { + return .{ .status = 200, .body = okJson(resp, "ssg_build forwarded") }; + } + if (std.mem.eql(u8, tool, "ssg_preview")) { + return .{ .status = 200, .body = okJson(resp, "ssg_preview forwarded") }; + } + if (std.mem.eql(u8, tool, "ssg_deploy")) { + return .{ .status = 200, .body = okJson(resp, "ssg_deploy forwarded") }; + } + if (std.mem.eql(u8, tool, "ssg_clean")) { + return .{ .status = 200, .body = okJson(resp, "ssg_clean forwarded") }; + } + return .{ .status = 404, .body = errJson(resp, "unknown tool") }; +} + +fn dispatchRest(path: []const u8, body: []const u8, resp: []u8) Response { + // Expect /tools/<tool_name> + const prefix = "/tools/"; + if (std.mem.startsWith(u8, path, prefix)) { + const tool = path[prefix.len..]; + return dispatch(tool, body, resp); + } + return .{ .status = 404, .body = errJson(resp, "not found") }; +} + +fn dispatchGrpc(path: []const u8, body: []const u8, resp: []u8) Response { + // Expect /<Service>/<Method> β€” derive tool from Method + var it = std.mem.splitScalar(u8, path, '/'); + _ = it.next(); // leading empty + _ = it.next(); // service + const method = it.next() orelse return .{ .status = 404, .body = errJson(resp, "bad gRPC path") }; + return dispatch(method, body, resp); +} + +fn dispatchGraphql(body: []const u8, resp: []u8) Response { + if (std.mem.indexOf(u8, body, "ssg_load_content") != null) + return dispatch("ssg_load_content", body, resp); + if (std.mem.indexOf(u8, body, "ssg_build") != null) + return dispatch("ssg_build", body, resp); + if (std.mem.indexOf(u8, body, "ssg_preview") != null) + return dispatch("ssg_preview", body, resp); + if (std.mem.indexOf(u8, body, "ssg_deploy") != null) + return dispatch("ssg_deploy", body, resp); + if (std.mem.indexOf(u8, body, "ssg_clean") != null) + return dispatch("ssg_clean", body, resp); + return .{ .status = 400, .body = errJson(resp, "unrecognised GraphQL operation") }; +} + +fn handleConnection(stream: std.net.Stream, port: u16) void { + defer stream.close(); + var buf: [4096]u8 = undefined; + var resp_buf: [4096]u8 = undefined; + const n = stream.read(&buf) catch return; + const req = buf[0..n]; + const result = switch (port) { + REST_PORT => blk: { + // Parse HTTP/1.1: first line = METHOD PATH HTTP/x.y + var lines = std.mem.splitScalar(u8, req, '\n'); + const first = lines.next() orelse break :blk Response{ .status = 400, .body = "" }; + var parts = std.mem.splitScalar(u8, std.mem.trim(u8, first, "\r"), ' '); + _ = parts.next(); // method + const path = parts.next() orelse break :blk Response{ .status = 400, .body = "" }; + break :blk dispatchRest(path, req, &resp_buf); + }, + GRPC_PORT => blk: { + var lines = std.mem.splitScalar(u8, req, '\n'); + const first = lines.next() orelse break :blk Response{ .status = 400, .body = "" }; + var parts = std.mem.splitScalar(u8, std.mem.trim(u8, first, "\r"), ' '); + _ = parts.next(); + const path = parts.next() orelse break :blk Response{ .status = 400, .body = "" }; + break :blk dispatchGrpc(path, req, &resp_buf); + }, + GQL_PORT => dispatchGraphql(req, &resp_buf), + else => Response{ .status = 500, .body = "" }, + }; + var http_resp: [512]u8 = undefined; + const http = std.fmt.bufPrint(&http_resp, + "HTTP/1.1 {d} OK\r\nContent-Length: {d}\r\nContent-Type: application/json\r\n\r\n", + .{ result.status, result.body.len }) catch return; + _ = stream.write(http) catch {}; + _ = stream.write(result.body) catch {}; +} + +fn listenLoop(port: u16) !void { + const addr = std.net.Address.initIp4(.{ 127, 0, 0, 1 }, port); + var server = try addr.listen(.{ .reuse_address = true }); + defer server.deinit(); + while (true) { + const conn = try server.accept(); + const t = try std.Thread.spawn(.{}, handleConnection, .{ conn.stream, port }); + t.detach(); + } +} + +pub fn main() !void { + ffi.ssg_init(); + const t1 = try std.Thread.spawn(.{}, listenLoop, .{REST_PORT}); + const t2 = try std.Thread.spawn(.{}, listenLoop, .{GRPC_PORT}); + const t3 = try std.Thread.spawn(.{}, listenLoop, .{GQL_PORT}); + t1.join(); + t2.join(); + t3.join(); +} diff --git a/cartridges/domains/web/ssg-mcp/cartridge.json b/cartridges/domains/web/ssg-mcp/cartridge.json new file mode 100644 index 0000000..257cacf --- /dev/null +++ b/cartridges/domains/web/ssg-mcp/cartridge.json @@ -0,0 +1,133 @@ +{ + "$schema": "https://boj.dev/schemas/cartridge/v1.json", + "spdx": "MPL-2.0", + "copyright": "Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk>", + "name": "ssg-mcp", + "version": "0.1.0", + "description": "Static site generator (Hugo, Zola, Astro, Casket)", + "domain": "web", + "tier": "Ayo", + "protocols": [ + "MCP", + "REST" + ], + "auth": { + "method": "none", + "env_var": null, + "credential_source": null + }, + "api": { + "base_url": "local://ssg-mcp", + "content_type": "application/json" + }, + "tools": [ + { + "name": "ssg_load_content", + "description": "Load content from a source directory", + "inputSchema": { + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "Content directory path" + }, + "engine": { + "type": "string", + "description": "SSG engine: hugo|zola|astro|casket" + } + }, + "required": [ + "path" + ] + } + }, + { + "name": "ssg_build", + "description": "Build the static site", + "inputSchema": { + "type": "object", + "properties": { + "session_id": { + "type": "integer", + "description": "Session index" + }, + "output_dir": { + "type": "string", + "description": "Output directory" + } + }, + "required": [ + "session_id" + ] + } + }, + { + "name": "ssg_preview", + "description": "Start development preview server", + "inputSchema": { + "type": "object", + "properties": { + "session_id": { + "type": "integer", + "description": "Session index" + }, + "port": { + "type": "integer", + "description": "Preview server port" + } + }, + "required": [ + "session_id" + ] + } + }, + { + "name": "ssg_deploy", + "description": "Deploy the built site", + "inputSchema": { + "type": "object", + "properties": { + "session_id": { + "type": "integer", + "description": "Session index" + }, + "target": { + "type": "string", + "description": "Deploy target: netlify|cloudflare|github-pages|custom" + } + }, + "required": [ + "session_id", + "target" + ] + } + }, + { + "name": "ssg_clean", + "description": "Clean build artifacts", + "inputSchema": { + "type": "object", + "properties": { + "session_id": { + "type": "integer", + "description": "Session index" + } + }, + "required": [ + "session_id" + ] + } + } + ], + "ffi": { + "so_path": "ffi/zig-out/lib/libssg_mcp.so", + "abi_version": "ADR-0006", + "symbols": [ + "boj_cartridge_init", + "boj_cartridge_deinit", + "boj_cartridge_name", + "boj_cartridge_version", + "boj_cartridge_invoke" + ] + } +} diff --git a/cartridges/domains/web/ssg-mcp/ffi/README.adoc b/cartridges/domains/web/ssg-mcp/ffi/README.adoc new file mode 100644 index 0000000..a11d311 --- /dev/null +++ b/cartridges/domains/web/ssg-mcp/ffi/README.adoc @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += ssg-mcp / ffi β€” Zig FFI layer +:orientation: shallow + +== Purpose + +C-ABI exports bridging this cartridge's Idris2 ABI to the runtime. Consumed by +the adapter for protocol dispatch and by the trunk loader for mount. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” produces `zig-out/lib/libssg.so`. +| `ssg_ffi.zig` | C-ABI exports (13 exports, 7 inline tests, 290 lines). +|=== + +== Invariants + +* **Null-pointer rejection** on `[*c]const u8` arguments before any + dereference. +* **Negative return values** carry documented error meanings; zero is + success (matching the trunk error-code convention). +* **No allocator leaks** β€” every allocation path pairs with `defer free`. + +== Test/proof surface + +7 inline `test "..."` block(s) in `ssg_ffi.zig`. Run with +`cd ffi && zig build test`. + +== Read-first + +. `ssg_ffi.zig` β€” C-ABI signatures and guards. +. Trunk `ffi/zig/src/catalogue.zig` for how this `.so` is loaded. diff --git a/cartridges/domains/web/ssg-mcp/ffi/build.zig b/cartridges/domains/web/ssg-mcp/ffi/build.zig new file mode 100644 index 0000000..35a3e71 --- /dev/null +++ b/cartridges/domains/web/ssg-mcp/ffi/build.zig @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// SSG-MCP Cartridge β€” Zig FFI build configuration (Zig 0.15+). + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + // Shared ADR-0006 invoke-shim module (relative path up to boj-server trunk). + + const shim_mod = b.addModule("cartridge_shim", .{ + + .root_source_file = b.path("../../../ffi/zig/src/cartridge_shim.zig"), + + .target = target, + + .optimize = optimize, + + }); + + const ssg_mod = b.addModule("ssg_ffi", .{ + .root_source_file = b.path("ssg_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + ssg_mod.addImport("cartridge_shim", shim_mod); + + // ── Tests ──────────────────────────────────────────────────────── + const ssg_tests = b.addTest(.{ + .root_module = ssg_mod, + }); + + const run_tests = b.addRunArtifact(ssg_tests); + + const test_step = b.step("test", "Run ssg-mcp FFI tests"); + test_step.dependOn(&run_tests.step); + + // ── Shared library ────────────────────────────────────────────── + const lib_mod = b.createModule(.{ + .root_source_file = b.path("ssg_ffi.zig"), + .target = target, + .optimize = optimize, + }); + lib_mod.addImport("cartridge_shim", shim_mod); + + const lib = b.addLibrary(.{ + .name = "ssg_mcp", + .root_module = lib_mod, + .linkage = .dynamic, + }); + b.installArtifact(lib); + + const lib_step = b.step("lib", "Build shared library"); + lib_step.dependOn(&lib.step); +} diff --git a/cartridges/domains/web/ssg-mcp/ffi/cartridge_shim.zig b/cartridges/domains/web/ssg-mcp/ffi/cartridge_shim.zig new file mode 100644 index 0000000..0e399db --- /dev/null +++ b/cartridges/domains/web/ssg-mcp/ffi/cartridge_shim.zig @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// cartridge_shim.zig β€” Shared helpers for the ADR-0006 five-symbol +// cartridge ABI (`boj_cartridge_init / deinit / name / version / invoke`). +// +// The shim centralises the seven-code return convention, NUL-argument +// guards, tool-name comparison, and the buffer-too-small path so each +// cartridge's `boj_cartridge_invoke` can stay short β€” typically a tool +// table plus `shim.writeResult(...)`. +// +// Cartridges import this file by relative path (no build-graph change +// needed). Example: +// +// const shim = @import("../../../ffi/zig/src/cartridge_shim.zig"); +// +// export fn boj_cartridge_invoke( +// tool_name: [*c]const u8, +// json_args: [*c]const u8, +// out_buf: [*c]u8, +// in_out_len: [*c]usize, +// ) callconv(.c) i32 { +// _ = json_args; +// if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; +// const body = if (shim.toolIs(tool_name, "foo")) "{\"result\":{}}" +// else return shim.RC_UNKNOWN_TOOL; +// return shim.writeResult(out_buf, in_out_len, body); +// } + +const std = @import("std"); + +// ── Return codes (ADR-0006 Β§Return codes) ──────────────────────────── +// +// Frozen by ADR-0006. New failure modes compose these via the error +// JSON body β€” the integer surface does not grow without a follow-up ADR. + +pub const RC_SUCCESS: i32 = 0; +pub const RC_UNKNOWN_TOOL: i32 = -1; +pub const RC_BAD_ARGS: i32 = -2; +pub const RC_BUFFER_TOO_SMALL: i32 = -3; +pub const RC_RUNTIME_ERROR: i32 = -4; +pub const RC_PANIC: i32 = -5; +pub const RC_AUTH_DENIED: i32 = -6; + +// ── Invoke-path helpers ────────────────────────────────────────────── + +/// True if any of the three mandatory `boj_cartridge_invoke` output-path +/// pointers is null. Use at the top of every invoke to short-circuit to +/// `RC_BAD_ARGS`. +pub fn invokeArgsNull( + tool_name: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) bool { + return tool_name == null or out_buf == null or in_out_len == null; +} + +/// Compare a C-NUL-terminated tool-name pointer against a Zig string +/// literal. Caller must have already verified `tool_name` is non-null +/// (usually via `invokeArgsNull`). +pub fn toolIs(tool_name: [*c]const u8, expected: []const u8) bool { + const s = std.mem.span(@as([*:0]const u8, @ptrCast(tool_name))); + return std.mem.eql(u8, s, expected); +} + +/// Copy `body` into `out_buf[0..*in_out_len]` (as a capacity) and update +/// `*in_out_len` to the number of bytes written. Returns `RC_SUCCESS`. +/// +/// If `body.len` exceeds the current capacity stored in `*in_out_len`, +/// sets `*in_out_len` to the required size and returns +/// `RC_BUFFER_TOO_SMALL` β€” the caller is then expected to re-allocate +/// and retry, per ADR-0006 Β§Memory ownership. +/// +/// Caller must have already verified that `out_buf` and `in_out_len` +/// are non-null. +pub fn writeResult( + out_buf: [*c]u8, + in_out_len: [*c]usize, + body: []const u8, +) i32 { + const cap = in_out_len.*; + if (body.len > cap) { + in_out_len.* = body.len; + return RC_BUFFER_TOO_SMALL; + } + @memcpy(out_buf[0..body.len], body); + in_out_len.* = body.len; + return RC_SUCCESS; +} + +// ── Tests ──────────────────────────────────────────────────────────── + +test "writeResult: body fits, writes and sets length" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); + try std.testing.expectEqualStrings("hello", buf[0..len]); +} + +test "writeResult: too small returns -3 and sets required length" { + var buf: [2]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_BUFFER_TOO_SMALL, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: exact-fit succeeds" { + var buf: [5]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: empty body" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, ""); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 0), len); +} + +test "toolIs: matches and rejects" { + const name: [*:0]const u8 = "foo"; + try std.testing.expect(toolIs(@ptrCast(name), "foo")); + try std.testing.expect(!toolIs(@ptrCast(name), "bar")); + try std.testing.expect(!toolIs(@ptrCast(name), "foobar")); + try std.testing.expect(!toolIs(@ptrCast(name), "fo")); +} + +test "invokeArgsNull: detects each null slot" { + var buf: [4]u8 = undefined; + var len: usize = 4; + const name: [*:0]const u8 = "x"; + try std.testing.expect(!invokeArgsNull(@ptrCast(name), &buf, &len)); + try std.testing.expect(invokeArgsNull(null, &buf, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), null, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), &buf, null)); +} diff --git a/cartridges/domains/web/ssg-mcp/ffi/ssg_ffi.zig b/cartridges/domains/web/ssg-mcp/ffi/ssg_ffi.zig new file mode 100644 index 0000000..719fd93 --- /dev/null +++ b/cartridges/domains/web/ssg-mcp/ffi/ssg_ffi.zig @@ -0,0 +1,359 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// SSG-MCP Cartridge β€” Zig FFI bridge for static site generation operations. +// +// Implements the build pipeline state machine from SafeSsg.idr. +// Ensures no deployment can execute without a preceding build and preview, +// and no preview can run without a successful build. + +const std = @import("std"); + +// ═══════════════════════════════════════════════════════════════════════ +// Types (must match SsgMcp.SafeSsg encodings) +// ═══════════════════════════════════════════════════════════════════════ + +pub const SsgState = enum(c_int) { + empty = 0, + content_loaded = 1, + built = 2, + previewing = 3, + ready_to_deploy = 4, + deployed = 5, + ssg_error = 6, +}; + +pub const SsgEngine = enum(c_int) { + hugo = 1, + zola = 2, + astro = 3, + casket = 4, + custom = 99, +}; + +// ═══════════════════════════════════════════════════════════════════════ +// Build Pipeline State Machine +// ═══════════════════════════════════════════════════════════════════════ + +const MAX_SITES: usize = 8; + +const SiteSlot = struct { + active: bool, + engine: SsgEngine, + state: SsgState, + content_hash: u32, // Hash of content for cache invalidation +}; + +var sites: [MAX_SITES]SiteSlot = [_]SiteSlot{.{ + .active = false, + .engine = .hugo, + .state = .empty, + .content_hash = 0, +}} ** MAX_SITES; + +var mutex: std.Thread.Mutex = .{}; + +/// Validate a state transition (matches Idris2 canTransition). +fn isValidTransition(from: SsgState, to: SsgState) bool { + return switch (from) { + .empty => to == .content_loaded, + .content_loaded => to == .built or to == .ssg_error, + .built => to == .previewing or to == .built, // rebuild allowed + .previewing => to == .ready_to_deploy, + .ready_to_deploy => to == .deployed or to == .empty or to == .ssg_error, + .deployed => to == .empty, + .ssg_error => to == .empty, + }; +} + +/// Load content into a new site. Returns slot index or -1 on failure. +pub export fn ssg_load_content(engine: c_int, content_hash: u32) c_int { + mutex.lock(); + defer mutex.unlock(); + for (&sites, 0..) |*slot, i| { + if (!slot.active) { + slot.active = true; + slot.engine = @enumFromInt(engine); + slot.state = .content_loaded; + slot.content_hash = content_hash; + return @intCast(i); + } + } + return -1; // No slots available +} + +/// Build the static site. +pub export fn ssg_build(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + if (slot_idx < 0 or slot_idx >= MAX_SITES) return -1; + const idx: usize = @intCast(slot_idx); + if (!sites[idx].active) return -1; + if (!isValidTransition(sites[idx].state, .built)) return -2; + + sites[idx].state = .built; + return 0; +} + +/// Start preview server. REQUIRES state to be Built (not ContentLoaded). +pub export fn ssg_preview(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + if (slot_idx < 0 or slot_idx >= MAX_SITES) return -1; + const idx: usize = @intCast(slot_idx); + if (!sites[idx].active) return -1; + // SAFETY: Must be in Built state β€” cannot preview unbuilt content + if (sites[idx].state != .built) return -2; + if (!isValidTransition(sites[idx].state, .previewing)) return -2; + + sites[idx].state = .previewing; + return 0; +} + +/// Mark preview as complete, transition to ReadyToDeploy. +pub export fn ssg_ready_deploy(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + if (slot_idx < 0 or slot_idx >= MAX_SITES) return -1; + const idx: usize = @intCast(slot_idx); + if (!sites[idx].active) return -1; + if (!isValidTransition(sites[idx].state, .ready_to_deploy)) return -2; + + sites[idx].state = .ready_to_deploy; + return 0; +} + +/// Deploy the site. REQUIRES state to be ReadyToDeploy (not Built). +/// This is the key safety invariant: you cannot deploy without previewing first. +pub export fn ssg_deploy(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + if (slot_idx < 0 or slot_idx >= MAX_SITES) return -1; + const idx: usize = @intCast(slot_idx); + if (!sites[idx].active) return -1; + // SAFETY: Must be in ReadyToDeploy state β€” cannot skip preview + if (sites[idx].state != .ready_to_deploy) return -2; + if (!isValidTransition(sites[idx].state, .deployed)) return -2; + + sites[idx].state = .deployed; + return 0; +} + +/// Clean build artifacts and reset to Empty. +pub export fn ssg_clean(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + if (slot_idx < 0 or slot_idx >= MAX_SITES) return -1; + const idx: usize = @intCast(slot_idx); + if (!sites[idx].active) return -1; + if (!isValidTransition(sites[idx].state, .empty)) return -2; + + sites[idx].active = false; + sites[idx].state = .empty; + sites[idx].content_hash = 0; + return 0; +} + +/// Get the state of a site. +pub export fn ssg_state(slot_idx: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + if (slot_idx < 0 or slot_idx >= MAX_SITES) return -1; + const idx: usize = @intCast(slot_idx); + if (!sites[idx].active) return @intFromEnum(SsgState.empty); + return @intFromEnum(sites[idx].state); +} + +/// Validate a state transition (C-ABI export). +pub export fn ssg_can_transition(from: c_int, to: c_int) c_int { + mutex.lock(); + defer mutex.unlock(); + const f: SsgState = @enumFromInt(from); + const t: SsgState = @enumFromInt(to); + return if (isValidTransition(f, t)) 1 else 0; +} + +/// Reset all sites (for testing). +pub export fn ssg_reset() void { + mutex.lock(); + defer mutex.unlock(); + for (&sites) |*slot| { + slot.active = false; + slot.state = .empty; + slot.content_hash = 0; + } +} + +// ═══════════════════════════════════════════════════════════════════════ +// Standard Cartridge Interface (loader expects these 4 C-ABI symbols) +// ═══════════════════════════════════════════════════════════════════════ + +/// Initialise the ssg-mcp cartridge. Resets all site slots. +pub export fn boj_cartridge_init() c_int { + ssg_reset(); + return 0; +} + +/// Deinitialise the ssg-mcp cartridge. Resets all site slots. +pub export fn boj_cartridge_deinit() void { + ssg_reset(); +} + +/// Return the cartridge name as a null-terminated C string. +pub export fn boj_cartridge_name() [*:0]const u8 { + mutex.lock(); + defer mutex.unlock(); + return "ssg-mcp"; +} + +/// Return the cartridge version as a null-terminated C string. +pub export fn boj_cartridge_version() [*:0]const u8 { + mutex.lock(); + defer mutex.unlock(); + return "0.1.0"; +} + +// ═══════════════════════════════════════════════════════════════════════ +// ADR-0006 dispatch (boj_cartridge_invoke, 5th standard symbol) +// ═══════════════════════════════════════════════════════════════════════ + +const shim = @import("cartridge_shim.zig"); + +/// Dispatch the cartridge.json MCP tools. Grade D Alpha β€” each arm +/// returns a stub JSON body shaped to the tool's intended response. +export fn boj_cartridge_invoke( + tool_name: [*c]const u8, + json_args: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) callconv(.c) i32 { + _ = json_args; + if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; + + const body: []const u8 = if (shim.toolIs(tool_name, "ssg_load_content")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "ssg_build")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "ssg_preview")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "ssg_deploy")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "ssg_clean")) + "{\"result\":{\"status\":\"stub\"}}" +else + return shim.RC_UNKNOWN_TOOL; + + return shim.writeResult(out_buf, in_out_len, body); +} + +// ═══════════════════════════════════════════════════════════════════════ +// Tests +// ═══════════════════════════════════════════════════════════════════════ + +test "load content and clean" { + ssg_reset(); + const slot = ssg_load_content(@intFromEnum(SsgEngine.hugo), 0xABCD); + try std.testing.expect(slot >= 0); + try std.testing.expectEqual(@as(c_int, @intFromEnum(SsgState.content_loaded)), ssg_state(slot)); + // Cannot clean from content_loaded (must build first) + try std.testing.expectEqual(@as(c_int, -2), ssg_clean(slot)); +} + +test "cannot deploy without preview" { + ssg_reset(); + const slot = ssg_load_content(@intFromEnum(SsgEngine.zola), 0x1234); + _ = ssg_build(slot); + // Attempt to deploy directly from built β€” MUST fail + try std.testing.expectEqual(@as(c_int, -2), ssg_deploy(slot)); + // State should remain built + try std.testing.expectEqual(@as(c_int, @intFromEnum(SsgState.built)), ssg_state(slot)); +} + +test "cannot preview without build" { + ssg_reset(); + const slot = ssg_load_content(@intFromEnum(SsgEngine.astro), 0x5678); + // Attempt to preview from content_loaded β€” MUST fail + try std.testing.expectEqual(@as(c_int, -2), ssg_preview(slot)); +} + +test "full pipeline: load -> build -> preview -> ready -> deploy" { + ssg_reset(); + const slot = ssg_load_content(@intFromEnum(SsgEngine.casket), 0xCAFE); + try std.testing.expectEqual(@as(c_int, 0), ssg_build(slot)); + try std.testing.expectEqual(@as(c_int, @intFromEnum(SsgState.built)), ssg_state(slot)); + try std.testing.expectEqual(@as(c_int, 0), ssg_preview(slot)); + try std.testing.expectEqual(@as(c_int, @intFromEnum(SsgState.previewing)), ssg_state(slot)); + try std.testing.expectEqual(@as(c_int, 0), ssg_ready_deploy(slot)); + try std.testing.expectEqual(@as(c_int, @intFromEnum(SsgState.ready_to_deploy)), ssg_state(slot)); + try std.testing.expectEqual(@as(c_int, 0), ssg_deploy(slot)); + try std.testing.expectEqual(@as(c_int, @intFromEnum(SsgState.deployed)), ssg_state(slot)); +} + +test "rebuild allowed" { + ssg_reset(); + const slot = ssg_load_content(@intFromEnum(SsgEngine.hugo), 0xAAAA); + try std.testing.expectEqual(@as(c_int, 0), ssg_build(slot)); + try std.testing.expectEqual(@as(c_int, 0), ssg_build(slot)); // rebuild + try std.testing.expectEqual(@as(c_int, @intFromEnum(SsgState.built)), ssg_state(slot)); +} + +test "clean after deploy" { + ssg_reset(); + const slot = ssg_load_content(@intFromEnum(SsgEngine.zola), 0xBBBB); + _ = ssg_build(slot); + _ = ssg_preview(slot); + _ = ssg_ready_deploy(slot); + _ = ssg_deploy(slot); + try std.testing.expectEqual(@as(c_int, 0), ssg_clean(slot)); + try std.testing.expectEqual(@as(c_int, @intFromEnum(SsgState.empty)), ssg_state(slot)); +} + +test "state transition validation" { + // Valid transitions + try std.testing.expectEqual(@as(c_int, 1), ssg_can_transition(0, 1)); // empty -> content_loaded + try std.testing.expectEqual(@as(c_int, 1), ssg_can_transition(1, 2)); // content_loaded -> built + try std.testing.expectEqual(@as(c_int, 1), ssg_can_transition(2, 3)); // built -> previewing + try std.testing.expectEqual(@as(c_int, 1), ssg_can_transition(3, 4)); // previewing -> ready_to_deploy + try std.testing.expectEqual(@as(c_int, 1), ssg_can_transition(4, 5)); // ready_to_deploy -> deployed + try std.testing.expectEqual(@as(c_int, 1), ssg_can_transition(5, 0)); // deployed -> empty + // Invalid transitions β€” the key safety invariants + try std.testing.expectEqual(@as(c_int, 0), ssg_can_transition(1, 3)); // content_loaded -> previewing (BLOCKED) + try std.testing.expectEqual(@as(c_int, 0), ssg_can_transition(2, 5)); // built -> deployed (BLOCKED) + try std.testing.expectEqual(@as(c_int, 0), ssg_can_transition(0, 5)); // empty -> deployed +} + +// ═══════════════════════════════════════════════════════════════════════ +// ADR-0006 invoke dispatch tests +// ═══════════════════════════════════════════════════════════════════════ + +test "invoke: each declared tool succeeds" { + var buf: [256]u8 = undefined; + const tools = [_][]const u8{ + "ssg_load_content", + "ssg_build", + "ssg_preview", + "ssg_deploy", + "ssg_clean", + }; + for (tools) |t| { + var len: usize = buf.len; + const rc = boj_cartridge_invoke(t.ptr, "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, 0), rc); + try std.testing.expect(std.mem.indexOf(u8, buf[0..len], "result") != null); + } +} + +test "invoke: unknown tool returns -1" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("nope", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -1), rc); +} + +test "invoke: buffer too small returns -3" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("ssg_load_content", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -3), rc); + try std.testing.expect(len > 4); +} diff --git a/cartridges/domains/web/ssg-mcp/mod.js b/cartridges/domains/web/ssg-mcp/mod.js new file mode 100644 index 0000000..b1f5ac5 --- /dev/null +++ b/cartridges/domains/web/ssg-mcp/mod.js @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// ssg-mcp/mod.js β€” Static site generator (Hugo, Zola, Astro, Casket) +// +// Delegates to backend at http://127.0.0.1:7742 (override with SSG_BACKEND_URL). + +const BASE_URL = Deno.env.get("SSG_BACKEND_URL") ?? "http://127.0.0.1:7742"; +const TIMEOUT_MS = 15_000; + +async function post(path, payload) { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), TIMEOUT_MS); + try { + const r = await fetch(`${BASE_URL}${path}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + signal: ctrl.signal, + }); + const data = await r.json().catch(() => ({ success: false, error: "non-JSON response" })); + return { status: r.status, data }; + } catch (e) { + if (e.name === "AbortError") return { status: 504, data: { success: false, error: "ssg-mcp backend timed out" } }; + return { status: 503, data: { success: false, error: `ssg-mcp backend unavailable: ${e.message}` } }; + } finally { clearTimeout(t); } +} + +async function get(path) { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), TIMEOUT_MS); + try { + const r = await fetch(`${BASE_URL}${path}`, { method: "GET", signal: ctrl.signal }); + const data = await r.json().catch(() => ({ success: false, error: "non-JSON response" })); + return { status: r.status, data }; + } catch (e) { + if (e.name === "AbortError") return { status: 504, data: { success: false, error: "ssg-mcp backend timed out" } }; + return { status: 503, data: { success: false, error: `ssg-mcp backend unavailable: ${e.message}` } }; + } finally { clearTimeout(t); } +} + +export async function handleTool(toolName, args) { + switch (toolName) { + case "ssg_load_content": + return post("/api/v1/ssg_load_content", args ?? {}); + case "ssg_build": + return post("/api/v1/ssg_build", args ?? {}); + case "ssg_preview": + return post("/api/v1/ssg_preview", args ?? {}); + case "ssg_deploy": + return post("/api/v1/ssg_deploy", args ?? {}); + case "ssg_clean": + return post("/api/v1/ssg_clean", args ?? {}); + default: + return { status: 404, data: { error: `Unknown tool: ${toolName}` } }; + } +} diff --git a/cartridges/domains/web/ssg-mcp/panels/manifest.json b/cartridges/domains/web/ssg-mcp/panels/manifest.json new file mode 100644 index 0000000..fbb54aa --- /dev/null +++ b/cartridges/domains/web/ssg-mcp/panels/manifest.json @@ -0,0 +1,67 @@ +{ + "$schema": "https://panll.dev/schemas/panel-manifest/v1.json", + "spdx": "MPL-2.0", + "cartridge": "ssg-mcp", + "domain": "Static Site Generation", + "version": "0.1.0", + "panels": [ + { + "id": "ssg-status", + "title": "SSG Engine Status", + "description": "Jekyll, Hugo, and Astro builder readiness", + "type": "status-indicator", + "data_source": { + "endpoint": "/cartridge/ssg-mcp/invoke", + "method": "POST", + "body": { "tool": "status" }, + "refresh_interval_ms": 5000 + }, + "widgets": [ + { + "type": "state-badge", + "field": "state", + "states": { + "ready": { "color": "#2ecc71", "icon": "file-code" }, + "degraded": { "color": "#f39c12", "icon": "alert-circle" }, + "error": { "color": "#e74c3c", "icon": "alert-triangle" } + } + }, + { "type": "text", "field": "version", "label": "Version" } + ] + }, + { + "id": "ssg-sites", + "title": "Managed Sites", + "description": "Tracked static sites, their generators, and last build status", + "type": "metric", + "data_source": { + "endpoint": "/cartridge/ssg-mcp/invoke", + "method": "POST", + "body": { "tool": "sites_summary" }, + "refresh_interval_ms": 30000 + }, + "widgets": [ + { "type": "counter", "field": "total_sites", "label": "Total Sites", "icon": "globe" }, + { "type": "counter", "field": "builds_passing", "label": "Builds Passing", "icon": "check-circle" }, + { "type": "counter", "field": "builds_failing", "label": "Builds Failing", "icon": "x-circle" } + ] + }, + { + "id": "ssg-build-metrics", + "title": "Build Metrics", + "description": "Average build time, page count, and asset sizes", + "type": "metric", + "data_source": { + "endpoint": "/cartridge/ssg-mcp/invoke", + "method": "POST", + "body": { "tool": "build_metrics" }, + "refresh_interval_ms": 30000 + }, + "widgets": [ + { "type": "text", "field": "avg_build_time", "label": "Avg Build Time" }, + { "type": "counter", "field": "total_pages", "label": "Total Pages", "icon": "file" }, + { "type": "text", "field": "total_asset_size", "label": "Asset Size" } + ] + } + ] +} diff --git a/cartridges/templates/gossamer-mcp/README.adoc b/cartridges/templates/gossamer-mcp/README.adoc new file mode 100644 index 0000000..4102c02 --- /dev/null +++ b/cartridges/templates/gossamer-mcp/README.adoc @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += gossamer-mcp +Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk> +:spdx: MPL-2.0 +:tier: Ayo +:domain: Desktop/UI +:protocols: MCP, REST + +== Overview + +Gossamer webview window manager. Creates and manages native desktop windows with panel loading and JavaScript evaluation. + +== Tools (4) + +[cols="2,4"] +|=== +| Tool | Description + +| `gossamer_create_window` | Create a new native desktop window. Returns a handle. +| `gossamer_load_panel` | Load a panel URI into an existing window handle. +| `gossamer_eval_js` | Evaluate a JavaScript snippet in the loaded panel context. +| `gossamer_get_version` | Get the Gossamer runtime version string. +|=== + +== Architecture + +Three-layer stack per the BoJ cartridge standard: + +[cols="1,1,3"] +|=== +| Layer | Language | Purpose + +| ABI | Idris2 | Formally verified state machine with dependent-type proofs +| FFI | Zig | C-ABI implementation: session pool, action metrics, thread safety +| Adapter| Zig | Adapter bridge to the BoJ unified adapter protocol (zig predecessors sidelined) +|=== + +== Building + +[source,bash] +---- +# ABI (Idris2) +cd abi && idris2 --check *.idr + +# FFI (Zig) +cd ffi && zig build +cd ffi && zig build test + +# Adapter (Zig) +cd adapter && zig build +---- + +== Status + +Alpha (CRG D). Cartridge metadata declared in `cartridge.json`; 4 tool(s) exposed. +Not yet dogfooded externally. See `docs/READINESS.md` at repo root for the +project-wide grade table and `docs/practice/DOGFOOD-LOG.adoc` for usage evidence. diff --git a/cartridges/templates/gossamer-mcp/abi/Gossamer/Protocol.idr b/cartridges/templates/gossamer-mcp/abi/Gossamer/Protocol.idr new file mode 100644 index 0000000..4adc994 --- /dev/null +++ b/cartridges/templates/gossamer-mcp/abi/Gossamer/Protocol.idr @@ -0,0 +1,54 @@ +-- SPDX-License-Identifier: MPL-2.0 +-- Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +-- +-- Gossamer ABI β€” webview shell protocol definitions. + +module Gossamer.Protocol + +import Data.Nat + +||| Gossamer operation codes. +public export +data GossamerOp + = CreateWindow + | LoadPanel + | EvalJS + | GetVersion + +||| Window handle β€” strictly positive identifier. +public export +record WindowHandle where + constructor MkWindowHandle + id : Nat + {auto prf : IsSucc id} + +||| Panel URI for loading into a Gossamer webview. +public export +record PanelURI where + constructor MkPanelURI + scheme : String + path : String + +||| Result of a JS evaluation. +public export +data EvalResult + = EvalOk String + | EvalErr String + +||| Gossamer version tuple. +public export +record Version where + constructor MkVersion + major : Nat + minor : Nat + patch : Nat + +||| Proof: a valid WindowHandle always has a non-zero id. +export +windowHandleNonZero : (h : WindowHandle) -> Not (h.id = Z) +windowHandleNonZero (MkWindowHandle (S _)) = absurd + +||| Proof: version ordering is reflexive. +export +versionEqRefl : (v : Version) -> (v.major = v.major, v.minor = v.minor) +versionEqRefl v = (Refl, Refl) diff --git a/cartridges/templates/gossamer-mcp/abi/README.adoc b/cartridges/templates/gossamer-mcp/abi/README.adoc new file mode 100644 index 0000000..22a3d08 --- /dev/null +++ b/cartridges/templates/gossamer-mcp/abi/README.adoc @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += gossamer-mcp / abi β€” Idris2 ABI layer +:orientation: shallow + +== Purpose + +Cartridge-local protocol types for `gossamer-mcp`. Defines the data, tool, and +transition vocabulary this cartridge speaks. Inherits safety lemmas and the +matrix-cell identifier scheme from the trunk ABI at `src/abi/`. + +== Files + +1 Idris2 module(s), ~54 lines total. Lead module: +`Protocol.idr`. + +No `.ipkg` β€” consumed directly by wider BoJ callers; type-checks inside the +trunk `boj.ipkg`. + +== Invariants + +* `%default total` at every module top. +* Zero `believe_me`, `assert_total`, `postulate`, `Admitted`, or + `sorry` (the trunk holds the four declared `believe_me` sites; leaf + cartridges carry none). + +== Test/proof surface + +Thin module(s), no inline tests. Proof surface = type-check success under the +trunk `idris2 --check`. + +== Read-first + +. `Protocol.idr` β€” cartridge-local data records and enums. +. Trunk `src/abi/Boj/Catalogue.idr` for how this cell participates in the + 2D matrix. diff --git a/cartridges/templates/gossamer-mcp/adapter/README.adoc b/cartridges/templates/gossamer-mcp/adapter/README.adoc new file mode 100644 index 0000000..2cc1e19 --- /dev/null +++ b/cartridges/templates/gossamer-mcp/adapter/README.adoc @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += gossamer-mcp / adapter β€” protocol bridge +:orientation: shallow + +== Purpose + +Exposes this cartridge's FFI over the wire protocols declared in +`cartridge.json`. Dispatches tool invocations to the C-ABI exports at +`../ffi/` and formats responses per protocol. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” depends on the sibling `ffi` target. +| `gossamer_adapter.zig` | Protocol dispatch (121 lines). Tool catalogue matches `cartridge.json`. +| `SIDELINED-*.v.adoc` | zig predecessor(s). Not built β€” zig banned 2026-04-10. +|=== + +== Invariants + +* **Stateless** β€” all state lives behind the FFI, never in the adapter. +* **Response passthrough** β€” whatever the FFI returns goes back to the wire + unmodified (no embellishment, no silent recovery). +* **Cartridge.json is source of truth** for the tool catalogue; drift between + adapter and manifest is a CI failure. + +== Test/proof surface + +No inline tests at the adapter layer β€” protocol routing is exercised by the +trunk seam tests at `ffi/zig/src/seams.zig`. + +== Read-first + +. `gossamer_adapter.zig` β€” dispatch function and per-protocol routers. +. `../cartridge.json` β€” the tool manifest the dispatcher mirrors. diff --git a/cartridges/templates/gossamer-mcp/adapter/build.zig b/cartridges/templates/gossamer-mcp/adapter/build.zig new file mode 100644 index 0000000..f83e9aa --- /dev/null +++ b/cartridges/templates/gossamer-mcp/adapter/build.zig @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// gossamer-mcp/adapter/build.zig + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + const ffi_mod = b.createModule(.{ + .root_source_file = b.path("../ffi/gossamer_ffi.zig"), + .target = target, .optimize = optimize, + }); + const adapter = b.addExecutable(.{ + .name = "gossamer_adapter", + .root_source_file = b.path("gossamer_adapter.zig"), + .target = target, .optimize = optimize, + }); + adapter.root_module.addImport("gossamer_ffi", ffi_mod); + b.installArtifact(adapter); + const run_step = b.step("run", "Run the gossamer-mcp adapter"); + run_step.dependOn(&b.addRunArtifact(adapter).step); + const tests = b.addTest(.{ + .root_source_file = b.path("gossamer_adapter.zig"), + .target = target, .optimize = optimize, + }); + tests.root_module.addImport("gossamer_ffi", ffi_mod); + const test_step = b.step("test", "Run gossamer-mcp adapter tests"); + test_step.dependOn(&b.addRunArtifact(tests).step); +} diff --git a/cartridges/templates/gossamer-mcp/adapter/gossamer_adapter.zig b/cartridges/templates/gossamer-mcp/adapter/gossamer_adapter.zig new file mode 100644 index 0000000..f84227b --- /dev/null +++ b/cartridges/templates/gossamer-mcp/adapter/gossamer_adapter.zig @@ -0,0 +1,121 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// gossamer-mcp/adapter/gossamer_adapter.zig -- Unified three-protocol adapter. +// +// Replaces the banned gossamer_adapter.v (zig, removed 2026-04-12). +// +// REST :9109 gRPC-compat :9110 GraphQL :9111 +// Gossamer webview window manager. Creates and manages native desktop windows with panel loading and J +// Tools: gossamer_create_window, gossamer_load_panel, gossamer_eval_js, gossamer_get_version + +const std = @import("std"); +const ffi = @import("gossamer_ffi"); + +const REST_PORT: u16 = 9109; +const GRPC_PORT: u16 = 9110; +const GQL_PORT: u16 = 9111; +const MAX_CONN_BUF: usize = 16 * 1024; + +fn okJson(buf: []u8, msg: []const u8) []u8 { + const n = std.fmt.bufPrint(buf, "{{\"success\":true,\"message\":\"{s}\"}}", .{msg}) catch return buf[0..0]; + return n; +} +fn errJson(buf: []u8, msg: []const u8) []u8 { + const n = std.fmt.bufPrint(buf, "{{\"success\":false,\"error\":\"{s}\"}}", .{msg}) catch return buf[0..0]; + return n; +} +fn statusJson(buf: []u8) []u8 { + const n = std.fmt.bufPrint(buf, "{{\"success\":true,\"state\":\"ready\",\"service\":\"gossamer-mcp\"}}", .{}) catch return buf[0..0]; + return n; +} + +const Response = struct { status: u16, body: []u8 }; + +fn dispatch(tool: []const u8, body: []const u8, resp: []u8) Response { + _ = body; + if (std.mem.eql(u8, tool, "gossamer_create_window")) return .{ .status = 200, .body = okJson(resp, "gossamer_create_window forwarded") }; + if (std.mem.eql(u8, tool, "gossamer_load_panel")) return .{ .status = 200, .body = okJson(resp, "gossamer_load_panel forwarded") }; + if (std.mem.eql(u8, tool, "gossamer_eval_js")) return .{ .status = 200, .body = okJson(resp, "gossamer_eval_js forwarded") }; + if (std.mem.eql(u8, tool, "gossamer_get_version")) return .{ .status = 200, .body = okJson(resp, "gossamer_get_version forwarded") }; + if (std.mem.eql(u8, tool, "status") or std.mem.eql(u8, tool, "health")) + return .{ .status = 200, .body = statusJson(resp) }; + return .{ .status = 404, .body = errJson(resp, "Unknown tool") }; +} + +fn dispatchRest(path: []const u8, body: []const u8, resp: []u8) Response { + if (std.mem.startsWith(u8, path, "/tools/")) return dispatch(path["/tools/".len..], body, resp); + if (std.mem.eql(u8, path, "/status") or std.mem.eql(u8, path, "/health")) return .{ .status = 200, .body = statusJson(resp) }; + return .{ .status = 404, .body = errJson(resp, "Not found") }; +} + +fn dispatchGrpc(path: []const u8, body: []const u8, resp: []u8) Response { + const prefix = "/Gossamerservice/"; + if (!std.mem.startsWith(u8, path, prefix)) + return .{ .status = 404, .body = errJson(resp, "Not a recognized gRPC path") }; + const method = path[prefix.len..]; + const tool = blk: { + if (std.mem.eql(u8, method, "gossamer_create_window")) break :blk "gossamer_create_window"; + if (std.mem.eql(u8, method, "gossamer_load_panel")) break :blk "gossamer_load_panel"; + if (std.mem.eql(u8, method, "gossamer_eval_js")) break :blk "gossamer_eval_js"; + if (std.mem.eql(u8, method, "gossamer_get_version")) break :blk "gossamer_get_version"; + return .{ .status = 404, .body = errJson(resp, "Unknown gRPC method") }; + }; + return dispatch(tool, body, resp); +} + +fn dispatchGraphql(body: []const u8, resp: []u8) Response { + if (std.mem.indexOf(u8, body, "__schema") != null) return .{ .status = 200, .body = okJson(resp, "schema not supported") }; + if (std.mem.indexOf(u8, body, "create_window") != null) return dispatch("gossamer_create_window", body, resp); + if (std.mem.indexOf(u8, body, "load_panel") != null) return dispatch("gossamer_load_panel", body, resp); + if (std.mem.indexOf(u8, body, "eval_js") != null) return dispatch("gossamer_eval_js", body, resp); + if (std.mem.indexOf(u8, body, "get_version") != null) return dispatch("gossamer_get_version", body, resp); + return .{ .status = 200, .body = errJson(resp, "Unrecognised GraphQL operation") }; +} + +const Protocol = enum { rest, grpc, graphql }; + +fn handleConnection(conn: std.net.Server.Connection, proto: Protocol) void { + defer conn.stream.close(); + var in_buf: [MAX_CONN_BUF]u8 = undefined; + const n = conn.stream.read(&in_buf) catch return; + const req = in_buf[0..n]; + var path: []const u8 = "/"; + var body: []const u8 = ""; + if (n > 4) { + const line_end = std.mem.indexOf(u8, req, "\r\n") orelse req.len; + const first_line = req[0..line_end]; + const sp1 = std.mem.indexOfScalar(u8, first_line, ' ') orelse 0; + const rest_of = first_line[sp1 + 1 ..]; + const sp2 = std.mem.indexOfScalar(u8, rest_of, ' ') orelse rest_of.len; + path = rest_of[0..sp2]; + const body_sep = std.mem.indexOf(u8, req, "\r\n\r\n") orelse n; + body = req[@min(body_sep + 4, n)..]; + } + var resp_buf: [MAX_CONN_BUF]u8 = undefined; + const result = switch (proto) { + .rest => dispatchRest(path, body, &resp_buf), + .grpc => dispatchGrpc(path, body, &resp_buf), + .graphql => dispatchGraphql(body, &resp_buf), + }; + const ct: []const u8 = if (proto == .grpc) "application/grpc+json" else "application/json"; + var hdr_buf: [256]u8 = undefined; + const hdr = std.fmt.bufPrint(&hdr_buf, "HTTP/1.1 {d} OK\r\nContent-Type: {s}\r\nContent-Length: {d}\r\nConnection: close\r\n\r\n", .{ result.status, ct, result.body.len }) catch return; + _ = conn.stream.writeAll(hdr) catch return; + _ = conn.stream.writeAll(result.body) catch return; +} + +fn listenLoop(port: u16, proto: Protocol) void { + const addr = std.net.Address.initIp4(.{ 127, 0, 0, 1 }, port) catch return; + var server = addr.listen(.{ .reuse_address = true }) catch return; + defer server.deinit(); + while (true) { const conn = server.accept() catch continue; handleConnection(conn, proto); } +} + +pub fn main() !void { + ffi.gossamer_init(); + const t1 = try std.Thread.spawn(.{}, listenLoop, .{ REST_PORT, Protocol.rest }); + const t2 = try std.Thread.spawn(.{}, listenLoop, .{ GRPC_PORT, Protocol.grpc }); + const t3 = try std.Thread.spawn(.{}, listenLoop, .{ GQL_PORT, Protocol.graphql }); + t1.join(); t2.join(); t3.join(); +} diff --git a/cartridges/templates/gossamer-mcp/cartridge.json b/cartridges/templates/gossamer-mcp/cartridge.json new file mode 100644 index 0000000..8b0cb03 --- /dev/null +++ b/cartridges/templates/gossamer-mcp/cartridge.json @@ -0,0 +1,109 @@ +{ + "$schema": "https://boj.dev/schemas/cartridge/v1.json", + "spdx": "MPL-2.0", + "copyright": "Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath)", + "name": "gossamer-mcp", + "version": "0.1.0", + "description": "Gossamer webview window manager. Creates and manages native desktop windows with panel loading and JavaScript evaluation.", + "domain": "Desktop/UI", + "tier": "Ayo", + "protocols": [ + "MCP", + "REST" + ], + "auth": { + "method": "none", + "env_var": null, + "credential_source": null + }, + "api": { + "base_url": "local://gossamer-mcp", + "content_type": "application/json" + }, + "tools": [ + { + "name": "gossamer_create_window", + "description": "Create a new native desktop window. Returns a handle.", + "inputSchema": { + "type": "object", + "properties": { + "width": { + "type": "integer", + "description": "Window width in pixels (default: 1280)" + }, + "height": { + "type": "integer", + "description": "Window height in pixels (default: 720)" + }, + "title": { + "type": "string", + "description": "Window title" + } + }, + "required": [] + } + }, + { + "name": "gossamer_load_panel", + "description": "Load a panel URI into an existing window handle.", + "inputSchema": { + "type": "object", + "properties": { + "handle": { + "type": "integer", + "description": "Window handle from gossamer_create_window" + }, + "uri": { + "type": "string", + "description": "Panel URI (panel://my-panel or https://...)" + } + }, + "required": [ + "handle", + "uri" + ] + } + }, + { + "name": "gossamer_eval_js", + "description": "Evaluate a JavaScript snippet in the loaded panel context.", + "inputSchema": { + "type": "object", + "properties": { + "handle": { + "type": "integer", + "description": "Window handle" + }, + "script": { + "type": "string", + "description": "JavaScript source to evaluate" + } + }, + "required": [ + "handle", + "script" + ] + } + }, + { + "name": "gossamer_get_version", + "description": "Get the Gossamer runtime version string.", + "inputSchema": { + "type": "object", + "properties": {}, + "required": [] + } + } + ], + "ffi": { + "so_path": "ffi/zig-out/lib/libgossamer_mcp.so", + "abi_version": "ADR-0006", + "symbols": [ + "boj_cartridge_init", + "boj_cartridge_deinit", + "boj_cartridge_name", + "boj_cartridge_version", + "boj_cartridge_invoke" + ] + } +} diff --git a/cartridges/templates/gossamer-mcp/ffi/README.adoc b/cartridges/templates/gossamer-mcp/ffi/README.adoc new file mode 100644 index 0000000..6807ed1 --- /dev/null +++ b/cartridges/templates/gossamer-mcp/ffi/README.adoc @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> += gossamer-mcp / ffi β€” Zig FFI layer **(stub)** +:orientation: shallow + +== Purpose + +C-ABI exports bridging this cartridge's Idris2 ABI to the runtime. Consumed by +the adapter for protocol dispatch and by the trunk loader for mount. + +== Files + +[cols="1,4"] +|=== +| File | Role + +| `build.zig` | Zig build graph β€” produces `zig-out/lib/libgossamer.so`. +| `gossamer_ffi.zig` | C-ABI exports (4 exports, 3 inline tests, 48 lines). +|=== + +== Invariants + +* **Null-pointer rejection** on `[*c]const u8` arguments before any + dereference. +* **Negative return values** carry documented error meanings; zero is + success (matching the trunk error-code convention). +* **No allocator leaks** β€” every allocation path pairs with `defer free`. + +== Test/proof surface + +3 inline `test "..."` block(s) in `gossamer_ffi.zig`. Run with +`cd ffi && zig build test`. + +== Read-first + +. `gossamer_ffi.zig` β€” C-ABI signatures and guards. +. Trunk `ffi/zig/src/catalogue.zig` for how this `.so` is loaded. + +== Promotion gate + +Wire these exports to real backing state (not `return 0` / `return 1` +placeholders) before promoting past D. diff --git a/cartridges/templates/gossamer-mcp/ffi/build.zig b/cartridges/templates/gossamer-mcp/ffi/build.zig new file mode 100644 index 0000000..65d7ad8 --- /dev/null +++ b/cartridges/templates/gossamer-mcp/ffi/build.zig @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + // Shared ADR-0006 invoke-shim module (relative path up to boj-server trunk). + + const shim_mod = b.addModule("cartridge_shim", .{ + + .root_source_file = b.path("../../../ffi/zig/src/cartridge_shim.zig"), + + .target = target, + + .optimize = optimize, + + }); + + const ffi_mod = b.addModule("gossamer_mcp", .{ + .root_source_file = b.path("gossamer_ffi.zig"), + .target = target, + .optimize = optimize, + }); + + ffi_mod.addImport("cartridge_shim", shim_mod); + + const lib = b.addLibrary(.{ + .name = "gossamer_mcp", + .root_module = ffi_mod, + .linkage = .dynamic, + }); + lib.linkLibC(); + b.installArtifact(lib); + + const tests = b.addTest(.{ .root_module = ffi_mod }); + tests.linkLibC(); + const run_tests = b.addRunArtifact(tests); + const test_step = b.step("test", "Run FFI tests"); + test_step.dependOn(&run_tests.step); +} + diff --git a/cartridges/templates/gossamer-mcp/ffi/cartridge_shim.zig b/cartridges/templates/gossamer-mcp/ffi/cartridge_shim.zig new file mode 100644 index 0000000..0e399db --- /dev/null +++ b/cartridges/templates/gossamer-mcp/ffi/cartridge_shim.zig @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// cartridge_shim.zig β€” Shared helpers for the ADR-0006 five-symbol +// cartridge ABI (`boj_cartridge_init / deinit / name / version / invoke`). +// +// The shim centralises the seven-code return convention, NUL-argument +// guards, tool-name comparison, and the buffer-too-small path so each +// cartridge's `boj_cartridge_invoke` can stay short β€” typically a tool +// table plus `shim.writeResult(...)`. +// +// Cartridges import this file by relative path (no build-graph change +// needed). Example: +// +// const shim = @import("../../../ffi/zig/src/cartridge_shim.zig"); +// +// export fn boj_cartridge_invoke( +// tool_name: [*c]const u8, +// json_args: [*c]const u8, +// out_buf: [*c]u8, +// in_out_len: [*c]usize, +// ) callconv(.c) i32 { +// _ = json_args; +// if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; +// const body = if (shim.toolIs(tool_name, "foo")) "{\"result\":{}}" +// else return shim.RC_UNKNOWN_TOOL; +// return shim.writeResult(out_buf, in_out_len, body); +// } + +const std = @import("std"); + +// ── Return codes (ADR-0006 Β§Return codes) ──────────────────────────── +// +// Frozen by ADR-0006. New failure modes compose these via the error +// JSON body β€” the integer surface does not grow without a follow-up ADR. + +pub const RC_SUCCESS: i32 = 0; +pub const RC_UNKNOWN_TOOL: i32 = -1; +pub const RC_BAD_ARGS: i32 = -2; +pub const RC_BUFFER_TOO_SMALL: i32 = -3; +pub const RC_RUNTIME_ERROR: i32 = -4; +pub const RC_PANIC: i32 = -5; +pub const RC_AUTH_DENIED: i32 = -6; + +// ── Invoke-path helpers ────────────────────────────────────────────── + +/// True if any of the three mandatory `boj_cartridge_invoke` output-path +/// pointers is null. Use at the top of every invoke to short-circuit to +/// `RC_BAD_ARGS`. +pub fn invokeArgsNull( + tool_name: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) bool { + return tool_name == null or out_buf == null or in_out_len == null; +} + +/// Compare a C-NUL-terminated tool-name pointer against a Zig string +/// literal. Caller must have already verified `tool_name` is non-null +/// (usually via `invokeArgsNull`). +pub fn toolIs(tool_name: [*c]const u8, expected: []const u8) bool { + const s = std.mem.span(@as([*:0]const u8, @ptrCast(tool_name))); + return std.mem.eql(u8, s, expected); +} + +/// Copy `body` into `out_buf[0..*in_out_len]` (as a capacity) and update +/// `*in_out_len` to the number of bytes written. Returns `RC_SUCCESS`. +/// +/// If `body.len` exceeds the current capacity stored in `*in_out_len`, +/// sets `*in_out_len` to the required size and returns +/// `RC_BUFFER_TOO_SMALL` β€” the caller is then expected to re-allocate +/// and retry, per ADR-0006 Β§Memory ownership. +/// +/// Caller must have already verified that `out_buf` and `in_out_len` +/// are non-null. +pub fn writeResult( + out_buf: [*c]u8, + in_out_len: [*c]usize, + body: []const u8, +) i32 { + const cap = in_out_len.*; + if (body.len > cap) { + in_out_len.* = body.len; + return RC_BUFFER_TOO_SMALL; + } + @memcpy(out_buf[0..body.len], body); + in_out_len.* = body.len; + return RC_SUCCESS; +} + +// ── Tests ──────────────────────────────────────────────────────────── + +test "writeResult: body fits, writes and sets length" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); + try std.testing.expectEqualStrings("hello", buf[0..len]); +} + +test "writeResult: too small returns -3 and sets required length" { + var buf: [2]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_BUFFER_TOO_SMALL, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: exact-fit succeeds" { + var buf: [5]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, "hello"); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 5), len); +} + +test "writeResult: empty body" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = writeResult(&buf, &len, ""); + try std.testing.expectEqual(RC_SUCCESS, rc); + try std.testing.expectEqual(@as(usize, 0), len); +} + +test "toolIs: matches and rejects" { + const name: [*:0]const u8 = "foo"; + try std.testing.expect(toolIs(@ptrCast(name), "foo")); + try std.testing.expect(!toolIs(@ptrCast(name), "bar")); + try std.testing.expect(!toolIs(@ptrCast(name), "foobar")); + try std.testing.expect(!toolIs(@ptrCast(name), "fo")); +} + +test "invokeArgsNull: detects each null slot" { + var buf: [4]u8 = undefined; + var len: usize = 4; + const name: [*:0]const u8 = "x"; + try std.testing.expect(!invokeArgsNull(@ptrCast(name), &buf, &len)); + try std.testing.expect(invokeArgsNull(null, &buf, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), null, &len)); + try std.testing.expect(invokeArgsNull(@ptrCast(name), &buf, null)); +} diff --git a/cartridges/templates/gossamer-mcp/ffi/gossamer_ffi.zig b/cartridges/templates/gossamer-mcp/ffi/gossamer_ffi.zig new file mode 100644 index 0000000..3d1f57a --- /dev/null +++ b/cartridges/templates/gossamer-mcp/ffi/gossamer_ffi.zig @@ -0,0 +1,139 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +// +// Gossamer FFI β€” C-compatible exports for the Gossamer webview shell. + +const std = @import("std"); + +/// Window handle (0 = invalid). +pub const WindowHandle = u32; + +/// Create a new webview window. Returns handle or 0 on failure. +export fn gossamer_create_window(width: u32, height: u32) WindowHandle { + if (width == 0 or height == 0) return 0; + // Stub: real impl delegates to libgossamer + return 1; +} + +/// Load a panel by URI into a window. Returns 0 on success, -1 on error. +export fn gossamer_load_panel(handle: WindowHandle, uri: [*c]const u8) i32 { + if (handle == 0 or uri == null) return -1; + return 0; +} + +/// Evaluate JavaScript in a window context. Returns 0 on success. +export fn gossamer_eval_js(handle: WindowHandle, script: [*c]const u8) i32 { + if (handle == 0 or script == null) return -1; + return 0; +} + +/// Get runtime version. Returns packed major.minor.patch. +export fn gossamer_get_version() u32 { + return (0 << 16) | (1 << 8) | 0; // 0.1.0 +} + +// ═══════════════════════════════════════════════════════════════════════ +// Standard ABI (ADR-0005 four symbols + ADR-0006 invoke) +// ═══════════════════════════════════════════════════════════════════════ + +const shim = @import("cartridge_shim.zig"); + +const CARTRIDGE_NAME_PTR: [*:0]const u8 = "gossamer-mcp"; +const CARTRIDGE_VERSION_PTR: [*:0]const u8 = "0.1.0"; + +export fn boj_cartridge_init() callconv(.c) c_int { + return 0; +} + +export fn boj_cartridge_deinit() callconv(.c) void {} + +export fn boj_cartridge_name() callconv(.c) [*:0]const u8 { + return CARTRIDGE_NAME_PTR; +} + +export fn boj_cartridge_version() callconv(.c) [*:0]const u8 { + return CARTRIDGE_VERSION_PTR; +} + +/// Dispatch the cartridge.json MCP tools. Grade D Alpha stubs. +export fn boj_cartridge_invoke( + tool_name: [*c]const u8, + json_args: [*c]const u8, + out_buf: [*c]u8, + in_out_len: [*c]usize, +) callconv(.c) i32 { + _ = json_args; + if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS; + + const body: []const u8 = if (shim.toolIs(tool_name, "gossamer_create_window")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "gossamer_load_panel")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "gossamer_eval_js")) + "{\"result\":{\"status\":\"stub\"}}" + else if (shim.toolIs(tool_name, "gossamer_get_version")) + "{\"result\":{\"metadata\":{},\"status\":\"stub\"}}" +else + return shim.RC_UNKNOWN_TOOL; + + return shim.writeResult(out_buf, in_out_len, body); +} + +// ── Tests ── + +test "create window rejects zero dimensions" { + try std.testing.expectEqual(@as(WindowHandle, 0), gossamer_create_window(0, 600)); + try std.testing.expectEqual(@as(WindowHandle, 0), gossamer_create_window(800, 0)); +} + +test "create window succeeds with valid dimensions" { + try std.testing.expect(gossamer_create_window(800, 600) != 0); +} + +test "load panel rejects null handle" { + try std.testing.expectEqual(@as(i32, -1), gossamer_load_panel(0, "panel://home")); +} + +// ═══════════════════════════════════════════════════════════════════════ +// ADR-0006 invoke dispatch tests +// ═══════════════════════════════════════════════════════════════════════ + +test "boj_cartridge_name returns gossamer-mcp" { + const n = std.mem.span(boj_cartridge_name()); + try std.testing.expectEqualStrings("gossamer-mcp", n); +} + +test "boj_cartridge_init returns 0" { + try std.testing.expectEqual(@as(c_int, 0), boj_cartridge_init()); +} + +test "invoke: each declared tool succeeds" { + var buf: [256]u8 = undefined; + const tools = [_][]const u8{ + "gossamer_create_window", + "gossamer_load_panel", + "gossamer_eval_js", + "gossamer_get_version", + }; + for (tools) |t| { + var len: usize = buf.len; + const rc = boj_cartridge_invoke(t.ptr, "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, 0), rc); + try std.testing.expect(std.mem.indexOf(u8, buf[0..len], "result") != null); + } +} + +test "invoke: unknown tool returns -1" { + var buf: [64]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("nope", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -1), rc); +} + +test "invoke: buffer too small returns -3" { + var buf: [4]u8 = undefined; + var len: usize = buf.len; + const rc = boj_cartridge_invoke("gossamer_create_window", "{}", &buf, &len); + try std.testing.expectEqual(@as(i32, -3), rc); + try std.testing.expect(len > 4); +} diff --git a/cartridges/templates/gossamer-mcp/mod.js b/cartridges/templates/gossamer-mcp/mod.js new file mode 100644 index 0000000..2dae1d4 --- /dev/null +++ b/cartridges/templates/gossamer-mcp/mod.js @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> +// +// gossamer-mcp/mod.js -- gossamer gateway + +const BASE_URL = Deno.env.get("GOSSAMER_BACKEND_URL") ?? "http://127.0.0.1:7703"; + +async function post(path, payload) { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), 15000); + try { + const r = await fetch(`${BASE_URL}${path}`, { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), signal: ctrl.signal, + }); + const data = await r.json().catch(() => ({ success: false, error: "non-JSON response" })); + return { status: r.status, data }; + } catch (e) { + if (e.name === "AbortError") return { status: 504, data: { success: false, error: "gossamer-mcp backend timed out" } }; + return { status: 503, data: { success: false, error: `gossamer-mcp backend unavailable: ${e.message}` } }; + } finally { clearTimeout(t); } +} + +export async function handleTool(toolName, args) { + switch (toolName) { + case "gossamer_create_window": { + const { width, height, title } = args ?? {}; + const payload = { }; + if (width !== undefined) payload.width = width; + if (height !== undefined) payload.height = height; + if (title !== undefined) payload.title = title; + return post("/api/v1/create-window", payload); + } + + case "gossamer_load_panel": { + const { handle, uri } = args ?? {}; + if (!handle || !uri) return { status: 400, data: { error: "handle is required" } }; + const payload = { handle, uri }; + return post("/api/v1/load-panel", payload); + } + + case "gossamer_eval_js": { + const { handle, script } = args ?? {}; + if (!handle || !script) return { status: 400, data: { error: "handle is required" } }; + const payload = { handle, script }; + return post("/api/v1/eval-js", payload); + } + + case "gossamer_get_version": { + const { _args } = args ?? {}; + const payload = { }; + return post("/api/v1/get-version", payload); + } + default: + return { status: 404, data: { error: `Unknown tool: ${toolName}` } }; + } +} diff --git a/cartridges/templates/gossamer-mcp/panels/manifest.json b/cartridges/templates/gossamer-mcp/panels/manifest.json new file mode 100644 index 0000000..f78cc2d --- /dev/null +++ b/cartridges/templates/gossamer-mcp/panels/manifest.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://panll.dev/schemas/panel-manifest/v1.json", + "spdx": "MPL-2.0", + "cartridge": "gossamer-mcp", + "domain": "webview", + "version": "0.1.0", + "panels": [ + { + "id": "gossamer-window-status", + "title": "Window Status", + "description": "Live indicator showing active Gossamer webview windows and their loaded panels.", + "type": "status-indicator", + "entrypoint": "panels/gossamer-window-status.js", + "size": { "cols": 2, "rows": 1 }, + "refresh_interval_ms": 3000, + "data_sources": [ + { "op": "GetVersion", "interval_ms": 30000 }, + { "op": "CreateWindow", "on": "event" } + ] + } + ] +} diff --git a/docs/decisions/ADR-001-taxonomy.adoc b/docs/decisions/ADR-001-taxonomy.adoc new file mode 100644 index 0000000..33c2c43 --- /dev/null +++ b/docs/decisions/ADR-001-taxonomy.adoc @@ -0,0 +1,89 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) += ADR-001: Cartridge taxonomy β€” Hybrid (domains + cross-cutting + templates) +:adr-status: Accepted +:adr-date: 2026-05-26 + +== Status + +*Accepted* 2026-05-26 (initial repo commit). + +== Context + +This repository holds 125 cartridges (and growing). A flat layout +(`cartridges/<name>/`) makes related cartridges hard to discover; a strictly +role-keyed layout (`cartridges/mcp/<name>/`) doesn't reflect that "a domain +has multiple cartridges across roles" is the dominant grouping pattern. + +The canonical cartridge spec lives in +link:https://github.com/hyperpolymath/standards/blob/main/cartridges/CARTRIDGE-FORMAT.adoc[standards/cartridges/CARTRIDGE-FORMAT.adoc] +(see also link:https://github.com/hyperpolymath/standards/blob/main/docs/decisions/ADR-002-cartridge-format-canonical-home.adoc[standards ADR-002]). + +== Decision + +Use a *Hybrid* taxonomy: + +[source] +---- +cartridges/ +β”œβ”€β”€ domains/<domain>/<cartridge-name>/ ← e.g. domains/cloud/aws-mcp/ +β”œβ”€β”€ cross-cutting/<category>/<cartridge-name>/ ← e.g. cross-cutting/agentic/agent-mcp/ +└── templates/<template-name>/ ← e.g. templates/gossamer-mcp/ +---- + +The cartridge's own `cartridge.json` carries the authoritative `category` +field (`domain` | `cross-cutting` | `template`) per the canonical schema. The +directory layout mirrors that field. + +=== Why each bucket + +* *domains/* β€” cartridges with a specific functional domain (cloud, database, + ci-cd, observability, …). Multiple cartridges may share a domain (e.g. + `database-mcp` + 11 provider-specific cartridges). They sit as siblings. + +* *cross-cutting/* β€” cartridges that do not belong to a single functional + domain. Initial categories: `agentic` (5), `nesy` (2), `build` (1, generic + BSP), `debug` (1, generic DAP), `fleet` (1, orchestration), `health` (1). + More categories may be added (e.g. `format`, `lint`) as cartridges arrive. + +* *templates/* β€” canonical scaffolds. `gossamer-mcp` is the reference + template; the minter tool (separate PR) consumes templates from here. + +=== Domain normalisation + +Initial migration normalised mixed-casing variants from boj-server's manifests: +`Cloud`/`cloud` β†’ `cloud`, `Database`/`database` β†’ `database`, +`Container Orchestration`/`container` β†’ `container`, `Package Management`/`Registry` +β†’ `registry`, etc. 30 normalised domains result from the 125 initial cartridges. + +== Consequences + +*Positive:* + +* Related cartridges are co-located; navigating to "all database cartridges" + is one `ls`. +* Adding the 8 server-role variants per domain (mcp/lsp/dap/bsp/format/lint/ + build/debug/nesy/agentic) doesn't trigger a re-layout β€” they all land in + the same `domains/<X>/` directory. +* The `category` field in `cartridge.json` is the source of truth; the + directory layout is a presentation choice that can evolve. + +*Negative:* + +* Two-level paths inside a domain (`domains/cloud/aws-mcp/`) are longer + than flat. Mitigated by tab-completion and grouping benefit. + +== Alternatives considered + +* *Flat* (`cartridges/<name>/`) β€” simplest to grep; chosen against because + domain grouping is the dominant query pattern at 125+ cartridges. +* *Collections + individual* (`collections/databases/postgres-mcp/` + + `individual/ssg-mcp/`) β€” captures "individual vs collection" phrasing but + introduces an axis ("is this a collection?") that's hard to apply + consistently. Most domains end up "collection-like" once they grow. + +== Migration + +Initial population: 2026-05-26 via `tools/migrate_cartridges.py` (preserved +in `tools/` for reference and future re-runs if boj-server adds cartridges +before its `cartridges/` dir is finally removed). diff --git a/schemas/SCHEMA-MIRROR.md b/schemas/SCHEMA-MIRROR.md new file mode 100644 index 0000000..3fc49de --- /dev/null +++ b/schemas/SCHEMA-MIRROR.md @@ -0,0 +1,31 @@ +<!-- SPDX-License-Identifier: MPL-2.0 --> +<!-- SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) --> + +# Cartridge schema mirror + +The file `cartridge-v1.json` in this directory is a **SHA-pinned mirror** of the canonical schema living at: + +- **Canonical home:** [`hyperpolymath/standards`](https://github.com/hyperpolymath/standards/blob/main/cartridges/cartridge-v1.json) +- **Canonical URL:** `https://hyperpolymath.dev/standards/cartridges/cartridge-v1.json` +- **Pinned commit:** _TBD β€” set on first sync after standards PR lands._ (Tracked via [PINNED-SHA](PINNED-SHA)) +- **Pinned SHA-256 of file:** _TBD β€” recorded in [PINNED-SHA](PINNED-SHA)._ + +## Why mirror + +Two reasons: + +1. **Offline validation.** Hosts (boj-server, panll) and cartridge authors need to validate `cartridge.json` without round-tripping to a network resource. +2. **Reproducibility.** A given snapshot of this repository must validate against a deterministic schema version, so CI / fetchers must read the bundled copy, not the canonical URL. + +## Refresh discipline + +When the canonical schema in `hyperpolymath/standards` advances: + +1. Open a PR here that updates `cartridge-v1.json` to the new content. +2. Update `PINNED-SHA` with the new commit SHA in standards and the new SHA-256 of the file. +3. The PR's description references the standards PR/commit that introduced the change. +4. Auto-merge once CI validates that all 125 cartridge manifests still parse against the new schema. + +## What if they disagree? + +Standards wins. Local mirror is always advancing toward standards. Cartridges authored against an older mirror remain valid as long as the schema change is backwards-compatible (the canonical schema is versioned; breaking changes ship as `cartridge-v2.json`). diff --git a/schemas/cartridge-v1.json b/schemas/cartridge-v1.json new file mode 100644 index 0000000..2cc334e --- /dev/null +++ b/schemas/cartridge-v1.json @@ -0,0 +1,153 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://hyperpolymath.dev/standards/cartridges/cartridge-v1.json", + "title": "BoJ Cartridge Manifest (canonical)", + "description": "Canonical schema for BoJ cartridge manifests. Mirrors the schema previously held in boj-server/schemas/cartridge-v1.json, expanded to admit the full BoJ server-role taxonomy (MCP, LSP, DAP, BSP, Debug, Format, Lint, Build, NeSy, Agentic, Fleet) per panll/src/abi/cartridge-schema.json v0.3.0. SPDX-License-Identifier: MPL-2.0.", + "type": "object", + "properties": { + "$schema": { + "type": "string", + "format": "uri", + "description": "Schema URL. Canonical form: https://hyperpolymath.dev/standards/cartridges/cartridge-v1.json" + }, + "spdx": { + "type": "string", + "description": "SPDX license identifier (e.g. MPL-2.0)" + }, + "copyright": { + "type": "string" + }, + "name": { + "type": "string", + "description": "Lowercase kebab-case cartridge name with role suffix. The suffix indicates the server role this cartridge implements.", + "pattern": "^[a-z0-9]+(-[a-z0-9]+)*-(mcp|lsp|dap|bsp|debug|format|lint|build|nesy|agentic|fleet)$" + }, + "version": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+(-[a-z0-9.-]+)?$", + "description": "Semantic version. Pre-release suffix permitted." + }, + "description": { + "type": "string" + }, + "domain": { + "type": "string", + "description": "Functional domain (e.g. cloud, database, git, k8s, observability, proof-verification, ssg, orchestration). For cross-cutting cartridges, use cross-cutting." + }, + "category": { + "type": "string", + "enum": ["domain", "cross-cutting", "template"], + "description": "Taxonomy category. Domain cartridges live under cartridges/domains/<domain>/. Cross-cutting under cartridges/cross-cutting/. Templates under cartridges/templates/." + }, + "tier": { + "type": "string", + "enum": ["Teranga", "Shield", "Ayo"], + "description": "Trust tier. Teranga = core/always-available. Shield = security-critical/elevated-trust. Ayo = community-contributed." + }, + "protocols": { + "type": "array", + "description": "Server protocols this cartridge speaks. A cartridge may expose more than one protocol if its role naturally spans them (e.g. MCP+REST). The role suffix in 'name' indicates the PRIMARY protocol.", + "items": { + "type": "string", + "enum": ["MCP", "LSP", "DAP", "BSP", "REST", "GraphQL", "gRPC", "NeSy", "Agentic", "Fleet", "Format", "Lint", "Build", "Debug"] + }, + "minItems": 1 + }, + "auth": { + "type": "object", + "properties": { + "method": { + "type": "string", + "enum": ["none", "api-key", "oauth2", "vault"] + }, + "env_var": { + "type": ["string", "null"] + }, + "credential_source": { + "type": ["string", "null"] + } + }, + "required": ["method", "env_var", "credential_source"] + }, + "api": { + "type": "object", + "properties": { + "base_url": { + "type": "string", + "description": "Loopback URL the cartridge backend listens on. Form: local://<cartridge-name> for the canonical loopback scheme, or http://127.0.0.1:<port> for explicit-port form." + }, + "content_type": { + "type": "string" + } + }, + "required": ["base_url", "content_type"] + }, + "ports": { + "type": "object", + "description": "Port permissions for the cartridge runtime.", + "properties": { + "allowed": { + "type": "array", + "items": { "type": "integer", "minimum": 1, "maximum": 65535 } + }, + "denied": { + "type": "array", + "items": { "type": "integer", "minimum": 1, "maximum": 65535 } + } + }, + "required": ["allowed", "denied"] + }, + "tools": { + "type": "array", + "description": "Tool surface exposed to the host (MCP-style; LSP/DAP/BSP cartridges may declare their protocol-native operations here too, with inputSchema describing the expected payload).", + "items": { + "type": "object", + "properties": { + "name": { "type": "string" }, + "description": { "type": "string" }, + "inputSchema": { "type": "object" } + }, + "required": ["name", "description", "inputSchema"] + } + }, + "states": { + "type": "array", + "description": "Optional state-machine states a cartridge backend transitions through (per panll v0.3.0). E.g. observe/orient/decide/act/halted for an Agentic cartridge.", + "items": { "type": "string" } + }, + "source": { + "type": "object", + "description": "Where this cartridge is fetched from when not bundled locally.", + "properties": { + "registry": { + "type": "string", + "description": "Registry identifier. Canonical default: hyperpolymath/boj-server-cartridges" + }, + "path": { + "type": "string", + "description": "Path within the registry, e.g. cartridges/domains/cloud/mcp.cartridge" + }, + "sha256": { + "type": "string", + "pattern": "^[a-f0-9]{64}$", + "description": "Optional content hash for verification of fetched cartridge artifact." + } + } + } + }, + "required": [ + "$schema", + "spdx", + "copyright", + "name", + "version", + "description", + "domain", + "category", + "tier", + "protocols", + "auth", + "api", + "tools" + ] +} diff --git a/tools/migrate_cartridges.py b/tools/migrate_cartridges.py new file mode 100644 index 0000000..ad65201 --- /dev/null +++ b/tools/migrate_cartridges.py @@ -0,0 +1,170 @@ +#!/usr/bin/env python3 +""" +One-shot migration: copy 125 cartridges from boj-server/cartridges/ into +boj-server-cartridges/cartridges/domains/<domain>/<role>.cartridge/ with +domain normalisation. Cross-cutting cartridges (agentic, nesy, ml, fleet +orchestration) go to cartridges/cross-cutting/. +""" +import json, os, re, shutil, sys +from pathlib import Path +from collections import defaultdict + +SRC = Path("/home/hyperpolymath/developer/repos/boj-server/cartridges") +DST = Path("/tmp/boj-server-cartridges/cartridges") + +# Cartridges that are intrinsically cross-cutting (not bound to a single domain) +CROSS_CUTTING = { + "agent-mcp": "agentic", + "claude-agents-power-mcp": "agentic", + "claude-ai-mcp": "agentic", + "local-coord-mcp": "agentic", + "model-router-mcp": "agentic", + "nesy-mcp": "nesy", + "ml-mcp": "nesy", + "fleet-mcp": "fleet", # fleet orchestration is cross-cutting + "boj-health": "health", # health/observability harness + "dap-mcp": "debug", # generic debug adapter + "bsp-mcp": "build", # generic build server +} + +# Templates (keep at templates/) +TEMPLATES = {"gossamer-mcp"} # canonical scaffold + +# Domain normalisation map +DOMAIN_MAP = { + "ai": "ai", "AI": "ai", + "agent orchestration": "ai", + "AI/NeSy": "nesy", # actually cross-cutting; cartridges in this domain handled by CROSS_CUTTING + "cloud": "cloud", "Cloud": "cloud", + "database": "database", "Database": "database", + "registry": "registry", "Registry": "registry", + "package management": "registry", "Package Management": "registry", + "container": "container", "Container": "container", + "container orchestration": "container", "Container Orchestration": "container", + "ci": "ci-cd", "CI/CD": "ci-cd", "CI/CD Intelligence": "ci-cd", + "development": "development", "Developer Tools": "development", + "code analysis": "code-quality", "Code Analysis": "code-quality", + "code quality": "code-quality", "Code Quality": "code-quality", + "communications": "communications", "Communications": "communications", + "communication": "communications", "Communication": "communications", + "compiler": "languages", "Compiler": "languages", + "dezig": "languages", + "languages": "languages", "Languages": "languages", + "language tools": "languages", "Language Tools": "languages", + "lsp": "languages", "LSP": "languages", + "security": "security", "Security": "security", + "productivity": "productivity", "Productivity": "productivity", + "research": "research", "Research": "research", + "infrastructure": "infrastructure", "Infrastructure": "infrastructure", + "monitoring": "observability", "Monitoring": "observability", + "observability": "observability", + "knowledge": "knowledge", "Knowledge": "knowledge", + "knowledge & memory": "knowledge", "Knowledge & Memory": "knowledge", + "formal verification": "formal-verification", "Formal Verification": "formal-verification", + "bioinformatics": "bioinformatics", "Bioinformatics": "bioinformatics", + "open data": "open-data", "Open Data": "open-data", + "education": "education", "Education": "education", + "legal": "legal", + "gaming": "gaming", + "project-management": "project-management", + "community": "community", + "automation": "automation", + "repository management": "repository-management", "Repository Management": "repository-management", + "desktop/ui": "desktop-ui", "Desktop/UI": "desktop-ui", + "multimodal": "multimodal", + "vector": "vector", + "messaging": "messaging", + "config": "config", "configuration": "config", + "web": "web", +} + +ROLE_RE = re.compile(r"-(mcp|lsp|dap|bsp|debug|format|lint|build|nesy|agentic|fleet)$") + +stats = defaultdict(int) +placements = [] +unmapped_domains = set() +unmapped_roles = [] + +for cart_dir in sorted(SRC.iterdir()): + if not cart_dir.is_dir(): + continue + name = cart_dir.name + manifest = cart_dir / "cartridge.json" + if not manifest.exists(): + # Not a cartridge dir (e.g. plain dir without manifest) β€” skip with note + stats["skipped_no_manifest"] += 1 + placements.append((name, None, "SKIP (no cartridge.json)")) + continue + + try: + with open(manifest) as f: + data = json.load(f) + except Exception as e: + stats["skipped_bad_json"] += 1 + placements.append((name, None, f"SKIP (bad JSON: {e})")) + continue + + role_match = ROLE_RE.search(name) + if not role_match: + # Allow cartridges without canonical role suffix to flow through; they + # need renaming in a follow-up but we still place them. + unmapped_roles.append(name) + + # Cross-cutting check first + if name in TEMPLATES: + target = DST / "templates" / name + category = "template" + elif name in CROSS_CUTTING: + target = DST / "cross-cutting" / CROSS_CUTTING[name] / name + category = "cross-cutting" + else: + domain_raw = data.get("domain", "unknown") + domain_norm = DOMAIN_MAP.get(domain_raw, None) + if not domain_norm: + domain_norm = re.sub(r"[^a-z0-9]+", "-", domain_raw.lower()).strip("-") or "unknown" + unmapped_domains.add(domain_raw) + # Cartridge dir = original cartridge name under the domain + target = DST / "domains" / domain_norm / name + category = "domain" + + stats[category] += 1 + placements.append((name, str(target.relative_to(DST.parent)), category)) + +# Print summary +print("=" * 80) +print(f"PLACEMENT PLAN ({len(placements)} cartridges)") +print("=" * 80) +print(f" Templates: {stats['template']}") +print(f" Cross-cutting: {stats['cross-cutting']}") +print(f" Domain-bound: {stats['domain']}") +print(f" Skipped (no manifest): {stats['skipped_no_manifest']}") +print(f" Skipped (bad JSON): {stats['skipped_bad_json']}") +print(f" Skipped (bad role): {stats['skipped_bad_role']}") +print() +if unmapped_domains: + print(f"Unmapped domain values (kebab-cased as fallback): {sorted(unmapped_domains)}") +if unmapped_roles: + print(f"Cartridges without canonical role suffix: {unmapped_roles}") +print() + +# Execute +if "--execute" in sys.argv: + for name, target_rel, cat in placements: + if target_rel is None: + continue + src = SRC / name + dst = DST.parent / target_rel + dst.parent.mkdir(parents=True, exist_ok=True) + shutil.copytree(src, dst) + print(f"COPIED {sum(1 for _,t,_ in placements if t)} cartridges into {DST}") +else: + # Dry-run: print first 30 placements + 5 per category + by_cat = defaultdict(list) + for n, t, c in placements: + by_cat[c].append((n, t)) + for cat in ("template", "cross-cutting", "domain"): + print(f"\n--- {cat.upper()} (first 8) ---") + for n, t in by_cat[cat][:8]: + print(f" {n:35} -> {t}") + if len(by_cat[cat]) > 8: + print(f" ... and {len(by_cat[cat]) - 8} more")