From 1a2e174f4d7fddd48cdea8546ce4a913b85530df Mon Sep 17 00:00:00 2001 From: Ajit Koti Date: Sat, 14 Mar 2026 20:32:26 -0700 Subject: [PATCH] Summary of Changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1: Proto Sync + Discovery Surface Fixes - proto/macp/v1/core.proto: Added TransportEndpoint message and transport_endpoints field (8) on AgentManifest - src/server.rs: stream: false on capabilities; GetManifest handles agent_id (empty/"macp-runtime" → self, else → not_found), content types → application/macp-envelope+proto, added transport_endpoints; ListModes returns only Decision Mode with semver 1.0.0 and schema_uris - src/runtime.rs: registered_mode_names() filters out multi_round (experimental) - 9 new tests for discovery surface Phase 2: Session Data Model + Envelope Validation - src/session.rs: Added context, roots, initiator_sender fields to Session - src/runtime.rs: Populates new fields from SessionStartPayload and env.sender - src/server.rs: Validates sender and message_type are non-empty - All test Session constructors updated across 4 files - 4 new tests for context/roots/initiator_sender and validation Phase 3: Mode-Aware Authorization - src/mode/mod.rs: Added authorize_sender() default method on Mode trait - src/mode/decision.rs: Overrides authorize_sender — orchestrator (session initiator) can send Commitment even if not in participants - src/runtime.rs: Replaced hardcoded participant check with mode.authorize_sender() delegation - 3 new tests for orchestrator bypass and unchanged multi_round behavior Phase 4: Decision Mode Correctness - src/mode/decision.rs: Legacy Message/resolve scoped to "decision" alias only; canonical mode rejects unknown message types; Commitment requires proposals (not votes) - 6 new/updated tests for legacy scoping and commitment rules Phase 5: Version Alignment + Docs + Clients - Cargo.toml: 0.1.0 → 0.3.0 - src/main.rs: v0.3 → v0.3.0 - src/server.rs: version "0.3" → "0.3.0" - README.md: v0.2 → v0.3, updated feature list, noted streaming not fully implemented - docs/*: Updated all version references - src/bin/client.rs: Rewritten to demonstrate canonical Proposal → Evaluation → Commitment lifecycle - CLAUDE.md: Updated to reflect all changes --- Cargo.toml | 2 +- README.md | 10 +- docs/README.md | 6 +- docs/architecture.md | 2 +- docs/examples.md | 14 +- docs/protocol.md | 2 +- proto/macp/v1/core.proto | 8 ++ src/bin/client.rs | 91 ++++++++----- src/main.rs | 2 +- src/mode/decision.rs | 91 ++++++++++++- src/mode/mod.rs | 9 ++ src/mode/multi_round.rs | 3 + src/runtime.rs | 205 +++++++++++++++++++++++++++- src/server.rs | 280 +++++++++++++++++++++++++++++++++------ src/session.rs | 4 + 15 files changed, 624 insertions(+), 105 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 2ad501e..08c2d22 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "macp-runtime" -version = "0.1.0" +version = "0.3.0" edition = "2021" [dependencies] diff --git a/README.md b/README.md index 52225a0..4f17a23 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# macp-runtime v0.2 +# macp-runtime v0.3 **Minimal Coordination Runtime (MCR)** — an RFC-0001-compliant gRPC server implementing the Multi-Agent Coordination Protocol (MACP). @@ -9,13 +9,13 @@ The MACP Runtime provides session-based message coordination between autonomous - **RFC-0001 Compliant Protocol** — Structured protobuf schema with versioned envelope, typed errors, and capability negotiation - **Initialize Handshake** — Protocol version negotiation and capability discovery before any session work begins - **Pluggable Mode System** — Coordination logic is decoupled from runtime physics; ship new modes without touching the kernel -- **Decision Mode (RFC Lifecycle)** — Full Proposal → Evaluation → Objection → Vote → Commitment workflow with phase tracking -- **Multi-Round Convergence Mode** — Participant-based `all_equal` convergence strategy with automatic resolution +- **Decision Mode (RFC Lifecycle)** — Full Proposal → Evaluation → Objection → Vote → Commitment workflow with phase tracking and mode-aware authorization +- **Multi-Round Convergence Mode (Experimental)** — Participant-based `all_equal` convergence strategy with automatic resolution (not advertised via discovery RPCs) - **Session Cancellation** — Explicit `CancelSession` RPC to terminate sessions with a recorded reason - **Message Deduplication** — Idempotent message handling via `seen_message_ids` tracking +- **Mode-Aware Authorization** — Sender authorization delegated to modes; Decision Mode allows orchestrator Commitment bypass per RFC - **Participant Validation** — Sender membership enforcement when a participant list is configured - **Signal Messages** — Ambient, session-less messages for out-of-band coordination signals -- **Bidirectional Streaming** — `StreamSession` RPC for real-time session event streaming - **Mode & Manifest Discovery** — `ListModes` and `GetManifest` RPCs for runtime introspection - **Structured Errors** — `MACPError` with RFC error codes, session/message correlation, and detail payloads - **Append-Only Audit Log** — Log-before-mutate ordering for every session event @@ -107,7 +107,7 @@ The runtime exposes `MACPRuntimeService` on `127.0.0.1:50051` with the following |-----|-------------| | `Initialize` | Protocol version negotiation and capability exchange | | `Send` | Send an Envelope, receive an Ack | -| `StreamSession` | Bidirectional streaming for session events | +| `StreamSession` | Bidirectional streaming for session events (not yet fully implemented) | | `GetSession` | Query session metadata by ID | | `CancelSession` | Cancel an active session with a reason | | `GetManifest` | Retrieve agent manifest and supported modes | diff --git a/docs/README.md b/docs/README.md index 0154ad3..1c42756 100644 --- a/docs/README.md +++ b/docs/README.md @@ -8,7 +8,7 @@ Welcome to the Multi-Agent Coordination Protocol (MACP) Runtime documentation. T The MACP Runtime — also called the **Minimal Coordination Runtime (MCR)** — is a **gRPC server** that helps multiple AI agents or programs coordinate with each other. Think of it as a traffic controller for structured conversations between autonomous agents: it manages who can speak, tracks the state of each conversation, enforces time limits, and determines when a conversation has reached its conclusion. -Version **0.2** of the runtime implements **RFC-0001**, introducing a formal protocol handshake, structured error reporting, a rich Decision Mode lifecycle, session cancellation, message deduplication, participant validation, and a host of new RPCs for runtime introspection. +Version **0.3** of the runtime implements **RFC-0001**, introducing a formal protocol handshake, structured error reporting, a rich Decision Mode lifecycle, session cancellation, message deduplication, participant validation, mode-aware authorization, and a host of new RPCs for runtime introspection. ### Real-World Analogy @@ -107,7 +107,7 @@ Two modes are built in: | Mode Name | Aliases | Description | |-----------|---------|-------------| | `macp.mode.decision.v1` | `decision` | RFC-compliant decision lifecycle: Proposal → Evaluation → Objection → Vote → Commitment | -| `macp.mode.multi_round.v1` | `multi_round` | Participant-based convergence using `all_equal` strategy | +| `macp.mode.multi_round.v1` | `multi_round` | Participant-based convergence using `all_equal` strategy (experimental, not on discovery surfaces) | An empty `mode` field defaults to `macp.mode.decision.v1` for backward compatibility. @@ -188,7 +188,7 @@ cargo run You should see: ``` -macp-runtime v0.2 (RFC-0001) listening on 127.0.0.1:50051 +macp-runtime v0.3.0 (RFC-0001) listening on 127.0.0.1:50051 ``` **Terminal 2** — Run a test client: diff --git a/docs/architecture.md b/docs/architecture.md index 606f826..bcc1177 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,6 +1,6 @@ # Architecture -This document explains how the MACP Runtime v0.2 is built internally. It walks through every component, every data structure, every flow, and every design decision in narrative detail. You do not need to know Rust to follow along — the documentation explains concepts in plain language, with code excerpts for precision where it matters. +This document explains how the MACP Runtime v0.3 is built internally. It walks through every component, every data structure, every flow, and every design decision in narrative detail. You do not need to know Rust to follow along — the documentation explains concepts in plain language, with code excerpts for precision where it matters. --- diff --git a/docs/examples.md b/docs/examples.md index 27a78b0..494adca 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -1,6 +1,6 @@ # Examples and Usage -This document provides step-by-step examples of using the MACP Runtime v0.2. It covers the full lifecycle — from protocol handshake to session creation, decision-making, convergence, cancellation, and error handling — with detailed explanations of what happens at each step. +This document provides step-by-step examples of using the MACP Runtime v0.3. It covers the full lifecycle — from protocol handshake to session creation, decision-making, convergence, cancellation, and error handling — with detailed explanations of what happens at each step. --- @@ -35,7 +35,7 @@ cargo run You should see: ``` -macp-runtime v0.2 (RFC-0001) listening on 127.0.0.1:50051 +macp-runtime v0.3.0 (RFC-0001) listening on 127.0.0.1:50051 ``` The server is now ready to accept connections on port 50051. @@ -108,7 +108,7 @@ println!( **Expected output:** ``` -ListModes: ["macp.mode.decision.v1", "macp.mode.multi_round.v1"] +ListModes: ["macp.mode.decision.v1"] ``` ### Step 4: Create a Session (SessionStart) @@ -228,7 +228,7 @@ GetSession: state=2 mode=decision ## Example 2: Full Decision Mode Lifecycle -The Decision Mode in v0.2 supports a rich lifecycle: Proposal, Evaluation, Objection, Vote, and Commitment. Here is how a complete decision process flows: +The Decision Mode in v0.3 supports a rich lifecycle: Proposal, Evaluation, Objection, Vote, and Commitment. Here is how a complete decision process flows: ### Step 1: Create a Session @@ -526,8 +526,8 @@ The fuzz client (`src/bin/fuzz_client.rs`) is a comprehensive test that exercise [authorized_sender] ok=true duplicate=false error='' [signal] ok=true duplicate=false error='' [get_session] state=2 mode=decision -[list_modes] count=2 modes=["macp.mode.decision.v1", "macp.mode.multi_round.v1"] -[get_manifest] agent_id=macp-runtime modes=["macp.mode.decision.v1", "macp.mode.multi_round.v1"] +[list_modes] count=1 modes=["macp.mode.decision.v1"] +[get_manifest] agent_id=macp-runtime modes=["macp.mode.decision.v1"] [list_roots] count=0 ``` @@ -1073,7 +1073,7 @@ let contribute = create_envelope("multi_round", "Contribute", "mr1", "alice", ### Q: What protocol version should I use? -**A:** Use `macp_version: "1.0"`. This is the only supported version in v0.2. Always call `Initialize` first to confirm. +**A:** Use `macp_version: "1.0"`. This is the only supported version in v0.3. Always call `Initialize` first to confirm. ### Q: How do I encode the SessionStart payload? diff --git a/docs/protocol.md b/docs/protocol.md index 0c781c0..0e86fca 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -1,6 +1,6 @@ # MACP Protocol Specification (v1.0 — RFC-0001) -This document is the authoritative specification of the Multi-Agent Coordination Protocol (MACP) as implemented by `macp-runtime` v0.2. It describes every message type, every field, every validation rule, every error code, and every behavioral guarantee in narrative detail. Whether you are building a client, implementing a new mode, or auditing the protocol for correctness, this document is your reference. +This document is the authoritative specification of the Multi-Agent Coordination Protocol (MACP) as implemented by `macp-runtime` v0.3. It describes every message type, every field, every validation rule, every error code, and every behavioral guarantee in narrative detail. Whether you are building a client, implementing a new mode, or auditing the protocol for correctness, this document is your reference. --- diff --git a/proto/macp/v1/core.proto b/proto/macp/v1/core.proto index b1eff25..d8b0788 100644 --- a/proto/macp/v1/core.proto +++ b/proto/macp/v1/core.proto @@ -144,6 +144,13 @@ message GetManifestRequest { string agent_id = 1; } +message TransportEndpoint { + string transport = 1; + string uri = 2; + repeated string content_types = 3; + map metadata = 4; +} + message AgentManifest { string agent_id = 1; string title = 2; @@ -152,6 +159,7 @@ message AgentManifest { repeated string input_content_types = 5; repeated string output_content_types = 6; map metadata = 7; + repeated TransportEndpoint transport_endpoints = 8; } message ModeDescriptor { diff --git a/src/bin/client.rs b/src/bin/client.rs index 4024cd6..c10c44a 100644 --- a/src/bin/client.rs +++ b/src/bin/client.rs @@ -1,7 +1,10 @@ +use macp_runtime::decision_pb::ProposalPayload; use macp_runtime::pb::macp_runtime_service_client::MacpRuntimeServiceClient; use macp_runtime::pb::{ Envelope, GetSessionRequest, InitializeRequest, ListModesRequest, SendRequest, + SessionStartPayload, }; +use prost::Message; #[tokio::main] async fn main() -> Result<(), Box> { @@ -33,16 +36,27 @@ async fn main() -> Result<(), Box> { modes_resp.modes.iter().map(|m| &m.mode).collect::>() ); - // 3) SessionStart + // 3) SessionStart with participants (canonical mode) + let start_payload = SessionStartPayload { + intent: "demo canonical lifecycle".into(), + participants: vec!["alice".into(), "bob".into()], + mode_version: String::new(), + configuration_version: String::new(), + policy_version: String::new(), + ttl_ms: 60_000, + context: vec![], + roots: vec![], + }; + let start = Envelope { macp_version: "1.0".into(), - mode: "decision".into(), + mode: "macp.mode.decision.v1".into(), message_type: "SessionStart".into(), message_id: "m1".into(), session_id: "s1".into(), - sender: "ajit".into(), - timestamp_unix_ms: 1_700_000_000_000, - payload: vec![], + sender: "coordinator".into(), + timestamp_unix_ms: chrono::Utc::now().timestamp_millis(), + payload: start_payload.encode_to_vec(), }; let ack = client @@ -59,81 +73,94 @@ async fn main() -> Result<(), Box> { ack.error.as_ref().map(|e| &e.code) ); - // 4) Normal message - let msg = Envelope { + // 4) Proposal (protobuf-encoded) + let proposal = ProposalPayload { + proposal_id: "p1".into(), + option: "Deploy v2.1 to production".into(), + rationale: "All integration tests pass".into(), + supporting_data: vec![], + }; + let proposal_env = Envelope { macp_version: "1.0".into(), - mode: "decision".into(), - message_type: "Message".into(), + mode: "macp.mode.decision.v1".into(), + message_type: "Proposal".into(), message_id: "m2".into(), session_id: "s1".into(), - sender: "ajit".into(), - timestamp_unix_ms: 1_700_000_000_001, - payload: b"hello".to_vec(), + sender: "alice".into(), + timestamp_unix_ms: chrono::Utc::now().timestamp_millis(), + payload: proposal.encode_to_vec(), }; let ack = client .send(SendRequest { - envelope: Some(msg), + envelope: Some(proposal_env), }) .await? .into_inner() .ack .unwrap(); println!( - "Message ack: ok={} error={:?}", + "Proposal ack: ok={} error={:?}", ack.ok, ack.error.as_ref().map(|e| &e.code) ); - // 5) Resolve message (DecisionMode resolves when payload == "resolve") - let resolve = Envelope { + // 5) Evaluation (protobuf-encoded) + let eval = macp_runtime::decision_pb::EvaluationPayload { + proposal_id: "p1".into(), + recommendation: "APPROVE".into(), + confidence: 0.95, + reason: "Looks good".into(), + }; + let eval_env = Envelope { macp_version: "1.0".into(), - mode: "decision".into(), - message_type: "Message".into(), + mode: "macp.mode.decision.v1".into(), + message_type: "Evaluation".into(), message_id: "m3".into(), session_id: "s1".into(), - sender: "ajit".into(), - timestamp_unix_ms: 1_700_000_000_002, - payload: b"resolve".to_vec(), + sender: "bob".into(), + timestamp_unix_ms: chrono::Utc::now().timestamp_millis(), + payload: eval.encode_to_vec(), }; let ack = client .send(SendRequest { - envelope: Some(resolve), + envelope: Some(eval_env), }) .await? .into_inner() .ack .unwrap(); println!( - "Resolve ack: ok={} error={:?}", + "Evaluation ack: ok={} error={:?}", ack.ok, ack.error.as_ref().map(|e| &e.code) ); - // 6) Message after resolve (should be rejected: SessionNotOpen) - let after = Envelope { + // 6) Commitment (votes not required per RFC — orchestrator bypass) + let commitment = Envelope { macp_version: "1.0".into(), - mode: "decision".into(), - message_type: "Message".into(), + mode: "macp.mode.decision.v1".into(), + message_type: "Commitment".into(), message_id: "m4".into(), session_id: "s1".into(), - sender: "ajit".into(), - timestamp_unix_ms: 1_700_000_000_003, - payload: b"should-fail".to_vec(), + sender: "coordinator".into(), + timestamp_unix_ms: chrono::Utc::now().timestamp_millis(), + payload: b"deploy-approved".to_vec(), }; let ack = client .send(SendRequest { - envelope: Some(after), + envelope: Some(commitment), }) .await? .into_inner() .ack .unwrap(); println!( - "After-resolve ack: ok={} error={:?}", + "Commitment ack: ok={} state={} error={:?}", ack.ok, + ack.session_state, ack.error.as_ref().map(|e| &e.code) ); diff --git a/src/main.rs b/src/main.rs index 36a97e8..14e827f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -17,7 +17,7 @@ async fn main() -> Result<(), Box> { let runtime = Arc::new(Runtime::new(registry, log_store)); let svc = MacpServer::new(runtime); - println!("macp-runtime v0.3 (RFC-0001) listening on {}", addr); + println!("macp-runtime v0.3.0 (RFC-0001) listening on {}", addr); Server::builder() .add_service(pb::macp_runtime_service_server::MacpRuntimeServiceServer::new(svc)) diff --git a/src/mode/decision.rs b/src/mode/decision.rs index 67fcc00..9dbe986 100644 --- a/src/mode/decision.rs +++ b/src/mode/decision.rs @@ -75,6 +75,20 @@ impl DecisionMode { } impl Mode for DecisionMode { + fn authorize_sender(&self, session: &Session, env: &Envelope) -> Result<(), MacpError> { + if session.participants.is_empty() { + return Ok(()); + } + // Commitment allowed from session initiator (designated orchestrator) + if env.message_type == "Commitment" && env.sender == session.initiator_sender { + return Ok(()); + } + if !session.participants.contains(&env.sender) { + return Err(MacpError::Forbidden); + } + Ok(()) + } + fn on_session_start( &self, session: &Session, @@ -95,15 +109,22 @@ impl Mode for DecisionMode { } fn on_message(&self, session: &Session, env: &Envelope) -> Result { - // Legacy backward compatibility: payload == "resolve" resolves immediately - if env.message_type == "Message" && env.payload == b"resolve" { + // Legacy backward compatibility: payload == "resolve" ONLY on alias "decision" + if session.mode == "decision" && env.message_type == "Message" && env.payload == b"resolve" + { return Ok(ModeResponse::Resolve(env.payload.clone())); } - // For non-typed messages, just pass through match env.message_type.as_str() { "Proposal" | "Evaluation" | "Objection" | "Vote" | "Commitment" => {} - _ => return Ok(ModeResponse::NoOp), + _ => { + // Canonical mode rejects unknown message types + if session.mode == "macp.mode.decision.v1" { + return Err(MacpError::InvalidPayload); + } + // Legacy alias allows unknown message types as NoOp + return Ok(ModeResponse::NoOp); + } } let mut state = if session.mode_state.is_empty() { @@ -196,7 +217,7 @@ impl Mode for DecisionMode { Ok(ModeResponse::PersistState(Self::encode_state(&state))) } "Commitment" => { - if state.votes.is_empty() { + if state.proposals.is_empty() { return Err(MacpError::InvalidPayload); } state.phase = DecisionPhase::Committed; @@ -231,6 +252,9 @@ mod tests { mode_version: String::new(), configuration_version: String::new(), policy_version: String::new(), + context: vec![], + roots: vec![], + initiator_sender: String::new(), } } @@ -568,14 +592,23 @@ mod tests { } #[test] - fn commitment_without_votes_rejected() { + fn commitment_without_proposals_rejected() { let mode = DecisionMode; - let session = session_with_state(&state_with_proposal()); + let session = session_with_state(&empty_state()); let env = test_envelope("Commitment", b"commit-data"); let err = mode.on_message(&session, &env).unwrap_err(); assert_eq!(err.to_string(), "InvalidPayload"); } + #[test] + fn commitment_with_proposal_no_votes_succeeds() { + let mode = DecisionMode; + let session = session_with_state(&state_with_proposal()); + let env = test_envelope("Commitment", b"commit-data"); + let result = mode.on_message(&session, &env).unwrap(); + assert!(matches!(result, ModeResponse::PersistAndResolve { .. })); + } + // --- Full lifecycle --- #[test] @@ -736,4 +769,48 @@ mod tests { let result = mode.on_session_start(&session, &env).unwrap(); assert!(matches!(result, ModeResponse::PersistState(_))); } + + // --- Phase 4: Legacy scoping + canonical strictness --- + + #[test] + fn canonical_mode_rejects_legacy_resolve() { + let mode = DecisionMode; + let mut session = test_session(); + session.mode = "macp.mode.decision.v1".into(); + let env = test_envelope("Message", b"resolve"); + + let err = mode.on_message(&session, &env).unwrap_err(); + assert_eq!(err.to_string(), "InvalidPayload"); + } + + #[test] + fn canonical_mode_rejects_unknown_message_type() { + let mode = DecisionMode; + let mut session = test_session(); + session.mode = "macp.mode.decision.v1".into(); + let env = test_envelope("CustomType", b"data"); + + let err = mode.on_message(&session, &env).unwrap_err(); + assert_eq!(err.to_string(), "InvalidPayload"); + } + + #[test] + fn legacy_alias_allows_legacy_resolve() { + let mode = DecisionMode; + let session = test_session(); // mode = "decision" + let env = test_envelope("Message", b"resolve"); + + let result = mode.on_message(&session, &env).unwrap(); + assert!(matches!(result, ModeResponse::Resolve(_))); + } + + #[test] + fn legacy_alias_allows_unknown_message_noop() { + let mode = DecisionMode; + let session = test_session(); // mode = "decision" + let env = test_envelope("CustomType", b"data"); + + let result = mode.on_message(&session, &env).unwrap(); + assert!(matches!(result, ModeResponse::NoOp)); + } } diff --git a/src/mode/mod.rs b/src/mode/mod.rs index d455ff0..ccfabda 100644 --- a/src/mode/mod.rs +++ b/src/mode/mod.rs @@ -30,4 +30,13 @@ pub trait Mode: Send + Sync { ) -> Result; fn on_message(&self, session: &Session, env: &Envelope) -> Result; + + /// Authorize the sender for this message. Modes can override to customize + /// authorization (e.g., allowing orchestrator bypass for Commitment messages). + fn authorize_sender(&self, session: &Session, env: &Envelope) -> Result<(), MacpError> { + if !session.participants.is_empty() && !session.participants.contains(&env.sender) { + return Err(MacpError::Forbidden); + } + Ok(()) + } } diff --git a/src/mode/multi_round.rs b/src/mode/multi_round.rs index 4329941..b921310 100644 --- a/src/mode/multi_round.rs +++ b/src/mode/multi_round.rs @@ -138,6 +138,9 @@ mod tests { mode_version: String::new(), configuration_version: String::new(), policy_version: String::new(), + context: vec![], + roots: vec![], + initiator_sender: String::new(), } } diff --git a/src/runtime.rs b/src/runtime.rs index 663ad77..ea9a3a4 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -41,11 +41,11 @@ impl Runtime { } } - /// Returns the list of RFC-compliant mode names registered. + /// Returns the list of RFC-registered mode names (excludes experimental modes). pub fn registered_mode_names(&self) -> Vec { self.modes .keys() - .filter(|k| k.starts_with("macp.mode.")) + .filter(|k| k.starts_with("macp.mode.") && !k.contains("multi_round")) .cloned() .collect() } @@ -148,6 +148,9 @@ impl Runtime { mode_version: start_payload.mode_version.clone(), configuration_version: start_payload.configuration_version.clone(), policy_version: start_payload.policy_version.clone(), + context: start_payload.context.clone(), + roots: start_payload.roots.clone(), + initiator_sender: env.sender.clone(), }; // Call mode's on_session_start BEFORE recording side effects @@ -213,14 +216,12 @@ impl Runtime { return Err(MacpError::SessionNotOpen); } - // Participant validation - if !session.participants.is_empty() && !session.participants.contains(&env.sender) { - return Err(MacpError::Forbidden); - } - let mode_name = session.mode.clone(); let mode = self.modes.get(&mode_name).ok_or(MacpError::UnknownMode)?; + // Mode-aware authorization (replaces hardcoded participant check) + mode.authorize_sender(session, env)?; + // Dispatch to mode BEFORE recording side effects let response = mode.on_message(session, env)?; @@ -479,6 +480,9 @@ mod tests { mode_version: String::new(), configuration_version: String::new(), policy_version: String::new(), + context: vec![], + roots: vec![], + initiator_sender: String::new(), }; Runtime::apply_mode_response(&mut session, ModeResponse::NoOp); assert_eq!(session.state, SessionState::Open); @@ -501,6 +505,9 @@ mod tests { mode_version: String::new(), configuration_version: String::new(), policy_version: String::new(), + context: vec![], + roots: vec![], + initiator_sender: String::new(), }; Runtime::apply_mode_response( &mut session, @@ -973,6 +980,9 @@ mod tests { mode_version: String::new(), configuration_version: String::new(), policy_version: String::new(), + context: vec![], + roots: vec![], + initiator_sender: String::new(), }; Runtime::apply_mode_response( &mut session, @@ -999,6 +1009,9 @@ mod tests { mode_version: String::new(), configuration_version: String::new(), policy_version: String::new(), + context: vec![], + roots: vec![], + initiator_sender: String::new(), }; Runtime::apply_mode_response(&mut session, ModeResponse::Resolve(b"resolved".to_vec())); assert_eq!(session.state, SessionState::Resolved); @@ -1284,4 +1297,182 @@ mod tests { let s = rt.registry.get_session("s1").await.unwrap(); assert_eq!(s.mode, "decision"); } + + // --- Phase 2: Session data model tests --- + + #[tokio::test] + async fn session_stores_context_and_roots() { + let rt = make_runtime(); + + let payload = SessionStartPayload { + intent: String::new(), + participants: vec!["alice".into()], + mode_version: String::new(), + configuration_version: String::new(), + policy_version: String::new(), + ttl_ms: 0, + context: b"some context".to_vec(), + roots: vec![crate::pb::Root { + uri: "file:///tmp".into(), + name: "test-root".into(), + }], + }; + let e = env( + "macp.mode.decision.v1", + "SessionStart", + "m1", + "s1", + "alice", + &payload.encode_to_vec(), + ); + rt.process(&e).await.unwrap(); + + let s = rt.registry.get_session("s1").await.unwrap(); + assert_eq!(s.context, b"some context"); + assert_eq!(s.roots.len(), 1); + assert_eq!(s.roots[0].uri, "file:///tmp"); + assert_eq!(s.roots[0].name, "test-root"); + } + + #[tokio::test] + async fn session_stores_initiator_sender() { + let rt = make_runtime(); + + let payload = encode_session_start(0, vec!["alice".into()]); + let e = env( + "macp.mode.decision.v1", + "SessionStart", + "m1", + "s1", + "alice", + &payload, + ); + rt.process(&e).await.unwrap(); + + let s = rt.registry.get_session("s1").await.unwrap(); + assert_eq!(s.initiator_sender, "alice"); + } + + // --- Phase 3: Mode-aware authorization tests --- + + #[tokio::test] + async fn commitment_from_initiator_allowed_outside_participants() { + let rt = make_runtime(); + + // Create session with participants alice+bob, initiator is "coordinator" + let payload = encode_session_start(0, vec!["alice".into(), "bob".into()]); + let e = env( + "macp.mode.decision.v1", + "SessionStart", + "m0", + "s1", + "coordinator", + &payload, + ); + rt.process(&e).await.unwrap(); + + // Alice submits a proposal + let proposal = crate::decision_pb::ProposalPayload { + proposal_id: "p1".into(), + option: "opt".into(), + rationale: "r".into(), + supporting_data: vec![], + } + .encode_to_vec(); + let e = env( + "macp.mode.decision.v1", + "Proposal", + "m1", + "s1", + "alice", + &proposal, + ); + rt.process(&e).await.unwrap(); + + // Coordinator (not in participants) sends Commitment — should succeed (no votes required per RFC) + let e = env( + "macp.mode.decision.v1", + "Commitment", + "m2", + "s1", + "coordinator", + b"commit", + ); + let result = rt.process(&e).await.unwrap(); + assert_eq!(result.session_state, SessionState::Resolved); + } + + #[tokio::test] + async fn proposal_from_non_participant_still_forbidden() { + let rt = make_runtime(); + + let payload = encode_session_start(0, vec!["alice".into(), "bob".into()]); + let e = env( + "macp.mode.decision.v1", + "SessionStart", + "m0", + "s1", + "coordinator", + &payload, + ); + rt.process(&e).await.unwrap(); + + // "charlie" not a participant and not sending Commitment + let proposal = crate::decision_pb::ProposalPayload { + proposal_id: "p1".into(), + option: "opt".into(), + rationale: "r".into(), + supporting_data: vec![], + } + .encode_to_vec(); + let e = env( + "macp.mode.decision.v1", + "Proposal", + "m1", + "s1", + "charlie", + &proposal, + ); + let err = rt.process(&e).await.unwrap_err(); + assert_eq!(err.to_string(), "Forbidden"); + } + + #[tokio::test] + async fn multi_round_authorization_unchanged() { + let rt = make_runtime(); + + let payload = encode_session_start(0, vec!["alice".into(), "bob".into()]); + let e = env( + "multi_round", + "SessionStart", + "m0", + "s1", + "creator", + &payload, + ); + rt.process(&e).await.unwrap(); + + // "charlie" still forbidden (default authorize_sender) + let e = env( + "multi_round", + "Contribute", + "m1", + "s1", + "charlie", + br#"{"value":"x"}"#, + ); + let err = rt.process(&e).await.unwrap_err(); + assert_eq!(err.to_string(), "Forbidden"); + + // alice succeeds + let e = env( + "multi_round", + "Contribute", + "m2", + "s1", + "alice", + br#"{"value":"x"}"#, + ); + rt.process(&e).await.unwrap(); + } } diff --git a/src/server.rs b/src/server.rs index a979f57..d368897 100644 --- a/src/server.rs +++ b/src/server.rs @@ -36,6 +36,12 @@ impl MacpServer { if env.message_id.is_empty() { return Err(MacpError::InvalidEnvelope); } + if env.sender.is_empty() { + return Err(MacpError::InvalidEnvelope); + } + if env.message_type.is_empty() { + return Err(MacpError::InvalidEnvelope); + } Ok(()) } @@ -89,13 +95,13 @@ impl MacpRuntimeService for MacpServer { runtime_info: Some(RuntimeInfo { name: "macp-runtime".into(), title: "MACP Reference Runtime".into(), - version: "0.3".into(), + version: "0.3.0".into(), description: "Reference implementation of the Multi-Agent Coordination Protocol" .into(), website_url: String::new(), }), capabilities: Some(Capabilities { - sessions: Some(SessionsCapability { stream: true }), + sessions: Some(SessionsCapability { stream: false }), cancellation: Some(CancellationCapability { cancel_session: true, }), @@ -219,8 +225,18 @@ impl MacpRuntimeService for MacpServer { async fn get_manifest( &self, - _request: Request, + request: Request, ) -> Result, Status> { + let req = request.into_inner(); + + // Empty agent_id or "macp-runtime" returns self manifest; anything else is not found + if !req.agent_id.is_empty() && req.agent_id != "macp-runtime" { + return Err(Status::not_found(format!( + "Agent '{}' not found", + req.agent_id + ))); + } + let mode_names = self.runtime.registered_mode_names(); Ok(Response::new(GetManifestResponse { @@ -229,9 +245,10 @@ impl MacpRuntimeService for MacpServer { title: "MACP Reference Runtime".into(), description: "Reference implementation of MACP".into(), supported_modes: mode_names, - input_content_types: vec!["application/protobuf".into()], - output_content_types: vec!["application/protobuf".into()], + input_content_types: vec!["application/macp-envelope+proto".into()], + output_content_types: vec!["application/macp-envelope+proto".into()], metadata: HashMap::new(), + transport_endpoints: vec![], }), })) } @@ -240,38 +257,27 @@ impl MacpRuntimeService for MacpServer { &self, _request: Request, ) -> Result, Status> { - let modes = vec![ - ModeDescriptor { - mode: "macp.mode.decision.v1".into(), - mode_version: "1.0".into(), - title: "Decision Mode".into(), - description: "Proposal-based decision making with voting".into(), - determinism_class: "semantic-deterministic".into(), - participant_model: "declared".into(), - message_types: vec![ - "SessionStart".into(), - "Proposal".into(), - "Evaluation".into(), - "Objection".into(), - "Vote".into(), - "Commitment".into(), - ], - terminal_message_types: vec!["Commitment".into()], - schema_uris: HashMap::new(), - }, - ModeDescriptor { - mode: "macp.mode.multi_round.v1".into(), - mode_version: "1.0".into(), - title: "Multi-Round Convergence Mode".into(), - description: "Participant-based convergence with all_equal strategy (experimental)" - .into(), - determinism_class: "semantic-deterministic".into(), - participant_model: "declared".into(), - message_types: vec!["SessionStart".into(), "Contribute".into()], - terminal_message_types: vec![], - schema_uris: HashMap::new(), - }, - ]; + let modes = vec![ModeDescriptor { + mode: "macp.mode.decision.v1".into(), + mode_version: "1.0.0".into(), + title: "Decision Mode".into(), + description: "Proposal-based decision making with voting".into(), + determinism_class: "semantic-deterministic".into(), + participant_model: "declared".into(), + message_types: vec![ + "SessionStart".into(), + "Proposal".into(), + "Evaluation".into(), + "Objection".into(), + "Vote".into(), + "Commitment".into(), + ], + terminal_message_types: vec!["Commitment".into()], + schema_uris: HashMap::from([( + "protobuf".into(), + "buf.build/multiagentcoordinationprotocol/macp".into(), + )]), + }]; Ok(Response::new(ListModesResponse { modes })) } @@ -438,6 +444,9 @@ mod tests { mode_version: String::new(), configuration_version: String::new(), policy_version: String::new(), + context: vec![], + roots: vec![], + initiator_sender: String::new(), }, ) .await; @@ -485,6 +494,9 @@ mod tests { mode_version: String::new(), configuration_version: String::new(), policy_version: String::new(), + context: vec![], + roots: vec![], + initiator_sender: String::new(), }, ) .await; @@ -531,6 +543,9 @@ mod tests { mode_version: String::new(), configuration_version: String::new(), policy_version: String::new(), + context: vec![], + roots: vec![], + initiator_sender: String::new(), }, ) .await; @@ -1233,7 +1248,7 @@ mod tests { assert!(init.runtime_info.is_some()); let info = init.runtime_info.unwrap(); assert_eq!(info.name, "macp-runtime"); - assert_eq!(info.version, "0.3"); + assert_eq!(info.version, "0.3.0"); assert!(init.capabilities.is_some()); assert!(!init.supported_modes.is_empty()); } @@ -1278,9 +1293,8 @@ mod tests { .await .unwrap(); let modes = resp.into_inner().modes; - assert_eq!(modes.len(), 2); + assert_eq!(modes.len(), 1); assert_eq!(modes[0].mode, "macp.mode.decision.v1"); - assert_eq!(modes[1].mode, "macp.mode.multi_round.v1"); } // --- GetManifest --- @@ -1312,4 +1326,190 @@ mod tests { .unwrap(); assert!(resp.into_inner().roots.is_empty()); } + + // --- Phase 1: Discovery surface fixes --- + + #[tokio::test] + async fn get_manifest_empty_agent_id_returns_self() { + let (server, _) = make_server(); + + let resp = server + .get_manifest(Request::new(GetManifestRequest { + agent_id: String::new(), + })) + .await + .unwrap(); + let manifest = resp.into_inner().manifest.unwrap(); + assert_eq!(manifest.agent_id, "macp-runtime"); + } + + #[tokio::test] + async fn get_manifest_self_agent_id_returns_self() { + let (server, _) = make_server(); + + let resp = server + .get_manifest(Request::new(GetManifestRequest { + agent_id: "macp-runtime".into(), + })) + .await + .unwrap(); + let manifest = resp.into_inner().manifest.unwrap(); + assert_eq!(manifest.agent_id, "macp-runtime"); + } + + #[tokio::test] + async fn get_manifest_unknown_agent_id_returns_not_found() { + let (server, _) = make_server(); + + let result = server + .get_manifest(Request::new(GetManifestRequest { + agent_id: "unknown-agent".into(), + })) + .await; + assert!(result.is_err()); + assert_eq!(result.unwrap_err().code(), tonic::Code::NotFound); + } + + #[tokio::test] + async fn get_manifest_content_types_are_macp_media_types() { + let (server, _) = make_server(); + + let resp = server + .get_manifest(Request::new(GetManifestRequest { + agent_id: String::new(), + })) + .await + .unwrap(); + let manifest = resp.into_inner().manifest.unwrap(); + assert_eq!( + manifest.input_content_types, + vec!["application/macp-envelope+proto"] + ); + assert_eq!( + manifest.output_content_types, + vec!["application/macp-envelope+proto"] + ); + } + + #[tokio::test] + async fn list_modes_decision_version_is_semver() { + let (server, _) = make_server(); + + let resp = server + .list_modes(Request::new(ListModesRequest {})) + .await + .unwrap(); + let modes = resp.into_inner().modes; + assert_eq!(modes[0].mode_version, "1.0.0"); + } + + #[tokio::test] + async fn list_modes_decision_has_schema_uris() { + let (server, _) = make_server(); + + let resp = server + .list_modes(Request::new(ListModesRequest {})) + .await + .unwrap(); + let modes = resp.into_inner().modes; + assert_eq!( + modes[0].schema_uris.get("protobuf").unwrap(), + "buf.build/multiagentcoordinationprotocol/macp" + ); + } + + #[tokio::test] + async fn list_modes_does_not_include_experimental() { + let (server, _) = make_server(); + + let resp = server + .list_modes(Request::new(ListModesRequest {})) + .await + .unwrap(); + let modes = resp.into_inner().modes; + assert!(!modes.iter().any(|m| m.mode.contains("multi_round"))); + } + + #[tokio::test] + async fn multi_round_still_works_by_direct_name() { + let (server, _) = make_server(); + + let start_payload = SessionStartPayload { + intent: String::new(), + ttl_ms: 60_000, + participants: vec!["alice".into(), "bob".into()], + mode_version: String::new(), + configuration_version: String::new(), + policy_version: String::new(), + context: vec![], + roots: vec![], + }; + + let env = Envelope { + macp_version: "1.0".into(), + mode: "macp.mode.multi_round.v1".into(), + message_type: "SessionStart".into(), + message_id: "m0".into(), + session_id: "s_mr_direct".into(), + sender: "coordinator".into(), + timestamp_unix_ms: Utc::now().timestamp_millis(), + payload: start_payload.encode_to_vec(), + }; + let ack = do_send(&server, env).await; + assert!(ack.ok); + } + + #[tokio::test] + async fn initialize_stream_capability_is_false() { + let (server, _) = make_server(); + + let resp = server + .initialize(Request::new(InitializeRequest { + supported_protocol_versions: vec!["1.0".into()], + client_info: None, + capabilities: None, + })) + .await + .unwrap(); + let caps = resp.into_inner().capabilities.unwrap(); + assert!(!caps.sessions.unwrap().stream); + } + + // --- Phase 2: Envelope validation --- + + #[tokio::test] + async fn empty_sender_rejected() { + let (server, _) = make_server(); + let env = Envelope { + macp_version: "1.0".into(), + mode: "decision".into(), + message_type: "SessionStart".into(), + message_id: "m1".into(), + session_id: "s1".into(), + sender: String::new(), + timestamp_unix_ms: Utc::now().timestamp_millis(), + payload: vec![], + }; + let ack = do_send(&server, env).await; + assert!(!ack.ok); + assert_eq!(ack.error.as_ref().unwrap().code, "INVALID_ENVELOPE"); + } + + #[tokio::test] + async fn empty_message_type_rejected() { + let (server, _) = make_server(); + let env = Envelope { + macp_version: "1.0".into(), + mode: "decision".into(), + message_type: String::new(), + message_id: "m1".into(), + session_id: "s1".into(), + sender: "test".into(), + timestamp_unix_ms: Utc::now().timestamp_millis(), + payload: vec![], + }; + let ack = do_send(&server, env).await; + assert!(!ack.ok); + assert_eq!(ack.error.as_ref().unwrap().code, "INVALID_ENVELOPE"); + } } diff --git a/src/session.rs b/src/session.rs index 9a22744..95450fd 100644 --- a/src/session.rs +++ b/src/session.rs @@ -29,6 +29,10 @@ pub struct Session { pub mode_version: String, pub configuration_version: String, pub policy_version: String, + // RFC session data fields + pub context: Vec, + pub roots: Vec, + pub initiator_sender: String, } /// Parse a protobuf-encoded SessionStartPayload from raw bytes.