swarm repositories / source
aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authormain <main@swarm.moe>2026-03-20 20:14:50 -0400
committermain <main@swarm.moe>2026-03-20 20:14:50 -0400
commitbb92a05eb5446e07c6288e266bd06d7b5899eee5 (patch)
tree56846c754fdf7c89c4e090d18d8269a2ce9f53f7
parent84e898d9ba699451d5d13fe384e7bbe220564bc1 (diff)
downloadlibmcp-bb92a05eb5446e07c6288e266bd06d7b5899eee5.zip
Add projection traits and doctrine testkit
-rw-r--r--Cargo.lock11
-rw-r--r--Cargo.toml5
-rw-r--r--assets/codex-skills/mcp-bootstrap/references/checklist.md4
-rw-r--r--crates/libmcp-derive/Cargo.toml19
-rw-r--r--crates/libmcp-derive/src/lib.rs259
-rw-r--r--crates/libmcp-testkit/Cargo.toml1
-rw-r--r--crates/libmcp-testkit/src/lib.rs130
-rw-r--r--crates/libmcp/Cargo.toml1
-rw-r--r--crates/libmcp/src/lib.rs9
-rw-r--r--crates/libmcp/src/projection.rs308
-rw-r--r--docs/spec.md12
11 files changed, 758 insertions, 1 deletions
diff --git a/Cargo.lock b/Cargo.lock
index afbd841..8de3b10 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -256,6 +256,7 @@ checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d"
name = "libmcp"
version = "1.1.0"
dependencies = [
+ "libmcp-derive",
"schemars",
"serde",
"serde_json",
@@ -266,9 +267,19 @@ dependencies = [
]
[[package]]
+name = "libmcp-derive"
+version = "1.1.0"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
name = "libmcp-testkit"
version = "1.1.0"
dependencies = [
+ "libmcp",
"serde",
"serde_json",
]
diff --git a/Cargo.toml b/Cargo.toml
index a5a0dbb..b071541 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,5 +1,5 @@
[workspace]
-members = ["crates/libmcp", "crates/libmcp-testkit"]
+members = ["crates/libmcp", "crates/libmcp-derive", "crates/libmcp-testkit"]
resolver = "3"
[workspace.package]
@@ -15,9 +15,12 @@ version = "1.1.0"
[workspace.dependencies]
assert_matches = "1.5.0"
+proc-macro2 = "1.0.103"
+quote = "1.0.41"
schemars = "1.1.0"
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.145"
+syn = { version = "2.0.108", features = ["full"] }
tempfile = "3.23.0"
thiserror = "2.0.17"
tokio = { version = "1.48.0", features = ["io-util", "macros", "rt", "rt-multi-thread", "sync", "time"] }
diff --git a/assets/codex-skills/mcp-bootstrap/references/checklist.md b/assets/codex-skills/mcp-bootstrap/references/checklist.md
index babb157..6fc6c82 100644
--- a/assets/codex-skills/mcp-bootstrap/references/checklist.md
+++ b/assets/codex-skills/mcp-bootstrap/references/checklist.md
@@ -8,9 +8,13 @@ Use this checklist when reviewing a `libmcp` consumer.
- Is worker fragility isolated behind an explicit replay policy?
- Are replay contracts typed and local to the request surface?
- Are faults typed and connected to recovery semantics?
+- Do tool surfaces cross an explicit projection boundary rather than serializing
+ raw domain/store structs directly?
- Do nontrivial tools default to porcelain output?
- Are `render` and `detail` treated as orthogonal controls?
- Does `detail=concise` return an actual summary rather than the full payload?
+- Are the projection traits or derive-macro happy path used on hot surfaces,
+ with generic JSON fallbacks reserved for explicit escape hatches?
- Are library render helpers used where bespoke porcelain has not yet been
justified?
- Is structured JSON still available where exact consumers need it?
diff --git a/crates/libmcp-derive/Cargo.toml b/crates/libmcp-derive/Cargo.toml
new file mode 100644
index 0000000..7777392
--- /dev/null
+++ b/crates/libmcp-derive/Cargo.toml
@@ -0,0 +1,19 @@
+[package]
+name = "libmcp-derive"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+rust-version.workspace = true
+description.workspace = true
+readme.workspace = true
+
+[lib]
+proc-macro = true
+
+[dependencies]
+proc-macro2.workspace = true
+quote.workspace = true
+syn.workspace = true
+
+[lints]
+workspace = true
diff --git a/crates/libmcp-derive/src/lib.rs b/crates/libmcp-derive/src/lib.rs
new file mode 100644
index 0000000..bee9185
--- /dev/null
+++ b/crates/libmcp-derive/src/lib.rs
@@ -0,0 +1,259 @@
+//! Derive macros for `libmcp` projection traits.
+
+use proc_macro::TokenStream;
+use quote::quote;
+use syn::{Data, DeriveInput, Field, Fields, Ident, LitStr, Result, parse_macro_input};
+
+/// Derives `libmcp::StructuredProjection` and `libmcp::SurfacePolicy`.
+#[proc_macro_derive(ToolProjection, attributes(libmcp))]
+pub fn derive_tool_projection(input: TokenStream) -> TokenStream {
+ expand_tool_projection(parse_macro_input!(input as DeriveInput))
+ .unwrap_or_else(syn::Error::into_compile_error)
+ .into()
+}
+
+/// Derives `libmcp::SelectorProjection`.
+#[proc_macro_derive(SelectorProjection, attributes(libmcp))]
+pub fn derive_selector_projection(input: TokenStream) -> TokenStream {
+ expand_selector_projection(parse_macro_input!(input as DeriveInput))
+ .unwrap_or_else(syn::Error::into_compile_error)
+ .into()
+}
+
+fn expand_tool_projection(input: DeriveInput) -> Result<proc_macro2::TokenStream> {
+ let ident = input.ident;
+ let data = match input.data {
+ Data::Struct(data) => data,
+ _ => {
+ return Err(syn::Error::new_spanned(
+ ident,
+ "ToolProjection only supports structs",
+ ));
+ }
+ };
+ let fields = match data.fields {
+ Fields::Named(fields) => fields.named.into_iter().collect::<Vec<_>>(),
+ _ => {
+ return Err(syn::Error::new_spanned(
+ ident,
+ "ToolProjection requires named fields",
+ ));
+ }
+ };
+ let container = parse_container_attrs(&input.attrs)?;
+ let kind = surface_kind_expr(&container.kind);
+ let reference_only = container.reference_only;
+ let forbid_opaque_ids = !container.allow_opaque_ids;
+
+ let concise_entries = fields
+ .iter()
+ .filter(|field| include_in_concise(field))
+ .map(project_field)
+ .collect::<Result<Vec<_>>>()?;
+ let full_entries = fields
+ .iter()
+ .filter(|field| include_in_full(field))
+ .map(project_field)
+ .collect::<Result<Vec<_>>>()?;
+
+ Ok(quote! {
+ impl ::libmcp::StructuredProjection for #ident {
+ fn concise_projection(
+ &self,
+ ) -> ::std::result::Result<::serde_json::Value, ::libmcp::ProjectionError> {
+ let mut object = ::serde_json::Map::new();
+ #(#concise_entries)*
+ Ok(::serde_json::Value::Object(object))
+ }
+
+ fn full_projection(
+ &self,
+ ) -> ::std::result::Result<::serde_json::Value, ::libmcp::ProjectionError> {
+ let mut object = ::serde_json::Map::new();
+ #(#full_entries)*
+ Ok(::serde_json::Value::Object(object))
+ }
+ }
+
+ impl ::libmcp::SurfacePolicy for #ident {
+ const KIND: ::libmcp::SurfaceKind = #kind;
+ const FORBID_OPAQUE_IDS: bool = #forbid_opaque_ids;
+ const REFERENCE_ONLY: bool = #reference_only;
+ }
+ })
+}
+
+fn expand_selector_projection(input: DeriveInput) -> Result<proc_macro2::TokenStream> {
+ let ident = input.ident;
+ let data = match input.data {
+ Data::Struct(data) => data,
+ _ => {
+ return Err(syn::Error::new_spanned(
+ ident,
+ "SelectorProjection only supports structs",
+ ));
+ }
+ };
+ let fields = match data.fields {
+ Fields::Named(fields) => fields.named.into_iter().collect::<Vec<_>>(),
+ _ => {
+ return Err(syn::Error::new_spanned(
+ ident,
+ "SelectorProjection requires named fields",
+ ));
+ }
+ };
+
+ let slug_field = fields
+ .iter()
+ .find(|field| has_field_flag(field, "selector") || field_ident(field) == "slug")
+ .ok_or_else(|| syn::Error::new_spanned(&ident, "SelectorProjection needs a slug field"))?;
+ let title_field = fields
+ .iter()
+ .find(|field| has_field_flag(field, "title") || field_ident(field) == "title");
+
+ let slug_ident = slug_field.ident.as_ref().ok_or_else(|| {
+ syn::Error::new_spanned(slug_field, "SelectorProjection needs named fields")
+ })?;
+ let title_tokens = if let Some(field) = title_field {
+ let title_ident = field.ident.as_ref().ok_or_else(|| {
+ syn::Error::new_spanned(field, "SelectorProjection needs named fields")
+ })?;
+ quote! { title: ::std::option::Option::Some(self.#title_ident.clone().into()) }
+ } else {
+ quote! { title: ::std::option::Option::None }
+ };
+
+ Ok(quote! {
+ impl ::libmcp::SelectorProjection for #ident {
+ fn selector_ref(&self) -> ::libmcp::SelectorRef {
+ ::libmcp::SelectorRef {
+ slug: self.#slug_ident.clone(),
+ #title_tokens,
+ }
+ }
+ }
+ })
+}
+
+fn project_field(field: &Field) -> Result<proc_macro2::TokenStream> {
+ let ident = field
+ .ident
+ .as_ref()
+ .ok_or_else(|| syn::Error::new_spanned(field, "ToolProjection requires named fields"))?;
+ let key = LitStr::new(field_ident(field).as_str(), ident.span());
+ if has_field_flag(field, "skip_none") {
+ Ok(quote! {
+ if let ::std::option::Option::Some(value) = &self.#ident {
+ object.insert(
+ #key.to_owned(),
+ ::serde_json::to_value(value).map_err(::libmcp::ProjectionError::from)?,
+ );
+ }
+ })
+ } else {
+ Ok(quote! {
+ object.insert(
+ #key.to_owned(),
+ ::serde_json::to_value(&self.#ident).map_err(::libmcp::ProjectionError::from)?,
+ );
+ })
+ }
+}
+
+fn include_in_concise(field: &Field) -> bool {
+ !has_field_flag(field, "skip")
+ && !has_field_flag(field, "full_only")
+ && !has_field_flag(field, "full")
+}
+
+fn include_in_full(field: &Field) -> bool {
+ !has_field_flag(field, "skip") && !has_field_flag(field, "concise_only")
+}
+
+fn field_ident(field: &Field) -> String {
+ field
+ .ident
+ .as_ref()
+ .map_or_else(String::new, ToString::to_string)
+}
+
+#[derive(Default)]
+struct ContainerAttrs {
+ kind: Option<Ident>,
+ reference_only: bool,
+ allow_opaque_ids: bool,
+}
+
+fn parse_container_attrs(attrs: &[syn::Attribute]) -> Result<ContainerAttrs> {
+ let mut parsed = ContainerAttrs::default();
+ for attr in attrs.iter().filter(|attr| attr.path().is_ident("libmcp")) {
+ attr.parse_nested_meta(|meta| {
+ if meta.path.is_ident("kind") {
+ let value = meta.value()?;
+ let kind = value.parse::<LitStr>()?;
+ parsed.kind = Some(Ident::new(
+ normalize_surface_kind(kind.value()).as_str(),
+ kind.span(),
+ ));
+ return Ok(());
+ }
+ if meta.path.is_ident("reference_only") {
+ parsed.reference_only = true;
+ return Ok(());
+ }
+ if meta.path.is_ident("allow_opaque_ids") {
+ parsed.allow_opaque_ids = true;
+ return Ok(());
+ }
+ Err(meta.error("unsupported libmcp container attribute"))
+ })?;
+ }
+ Ok(parsed)
+}
+
+fn has_field_flag(field: &Field, flag: &str) -> bool {
+ field
+ .attrs
+ .iter()
+ .filter(|attr| attr.path().is_ident("libmcp"))
+ .any(|attr| attr_has_flag(attr, flag))
+}
+
+fn attr_has_flag(attr: &syn::Attribute, flag: &str) -> bool {
+ let mut found = false;
+ let _ = attr.parse_nested_meta(|meta| {
+ if meta.path.is_ident(flag) {
+ found = true;
+ return Ok(());
+ }
+ Ok(())
+ });
+ found
+}
+
+fn surface_kind_expr(kind: &Option<Ident>) -> proc_macro2::TokenStream {
+ let ident = kind
+ .clone()
+ .unwrap_or_else(|| Ident::new("Read", proc_macro2::Span::call_site()));
+ quote!(::libmcp::SurfaceKind::#ident)
+}
+
+fn normalize_surface_kind(kind: String) -> String {
+ let normalized = kind.trim().replace('-', "_");
+ let mut out = String::new();
+ let mut uppercase_next = true;
+ for ch in normalized.chars() {
+ if ch == '_' {
+ uppercase_next = true;
+ continue;
+ }
+ if uppercase_next {
+ out.extend(ch.to_uppercase());
+ uppercase_next = false;
+ } else {
+ out.push(ch);
+ }
+ }
+ out
+}
diff --git a/crates/libmcp-testkit/Cargo.toml b/crates/libmcp-testkit/Cargo.toml
index b8e68b1..a958650 100644
--- a/crates/libmcp-testkit/Cargo.toml
+++ b/crates/libmcp-testkit/Cargo.toml
@@ -8,6 +8,7 @@ description.workspace = true
readme.workspace = true
[dependencies]
+libmcp = { path = "../libmcp" }
serde.workspace = true
serde_json.workspace = true
diff --git a/crates/libmcp-testkit/src/lib.rs b/crates/libmcp-testkit/src/lib.rs
index 9f33643..3b61bd5 100644
--- a/crates/libmcp-testkit/src/lib.rs
+++ b/crates/libmcp-testkit/src/lib.rs
@@ -1,6 +1,8 @@
//! Shared test helpers for `libmcp` consumers.
+use libmcp::{SurfaceKind, ToolProjection};
use serde::de::DeserializeOwned;
+use serde_json::Value;
use std::{
fs::File,
io::{self, BufRead, BufReader},
@@ -30,3 +32,131 @@ where
}
Ok(records)
}
+
+/// Assertion failure for projection doctrine checks.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct ProjectionAssertion {
+ path: String,
+ message: String,
+}
+
+impl ProjectionAssertion {
+ fn new(path: impl Into<String>, message: impl Into<String>) -> Self {
+ Self {
+ path: path.into(),
+ message: message.into(),
+ }
+ }
+}
+
+impl std::fmt::Display for ProjectionAssertion {
+ fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ write!(formatter, "{}: {}", self.path, self.message)
+ }
+}
+
+impl std::error::Error for ProjectionAssertion {}
+
+/// Asserts that a projection obeys the doctrine implied by its surface policy.
+pub fn assert_projection_doctrine<T>(projection: &T) -> Result<(), ProjectionAssertion>
+where
+ T: ToolProjection,
+{
+ let concise = projection
+ .concise_projection()
+ .map_err(|error| ProjectionAssertion::new("$concise", error.to_string()))?;
+ let full = projection
+ .full_projection()
+ .map_err(|error| ProjectionAssertion::new("$full", error.to_string()))?;
+ let policy = projection.projection_policy();
+ if policy.forbid_opaque_ids {
+ assert_no_opaque_ids(&concise)?;
+ assert_no_opaque_ids(&full)?;
+ }
+ if policy.reference_only {
+ assert_reference_only(&concise)?;
+ assert_reference_only(&full)?;
+ }
+ if matches!(policy.kind, SurfaceKind::List) {
+ assert_list_shape(&concise)?;
+ assert_list_shape(&full)?;
+ }
+ Ok(())
+}
+
+/// Asserts that a JSON value does not leak opaque database identifiers.
+pub fn assert_no_opaque_ids(value: &Value) -> Result<(), ProjectionAssertion> {
+ walk(value, "$", &mut |path, value| {
+ if let Value::Object(object) = value {
+ for key in object.keys() {
+ if key == "id" || key.ends_with("_id") {
+ return Err(ProjectionAssertion::new(
+ format!("{path}.{key}"),
+ "opaque identifier field leaked into model-facing projection",
+ ));
+ }
+ }
+ }
+ Ok(())
+ })
+}
+
+/// Asserts that a list-like surface does not inline prose bodies.
+pub fn assert_list_shape(value: &Value) -> Result<(), ProjectionAssertion> {
+ walk(value, "$", &mut |path, value| {
+ if let Value::Object(object) = value {
+ for key in object.keys() {
+ if matches!(
+ key.as_str(),
+ "body" | "payload_preview" | "analysis" | "rationale"
+ ) {
+ return Err(ProjectionAssertion::new(
+ format!("{path}.{key}"),
+ "list surface leaked body-like content",
+ ));
+ }
+ }
+ }
+ Ok(())
+ })
+}
+
+/// Asserts that a reference-only surface does not inline large textual content.
+pub fn assert_reference_only(value: &Value) -> Result<(), ProjectionAssertion> {
+ walk(value, "$", &mut |path, value| match value {
+ Value::Object(object) => {
+ for key in object.keys() {
+ if matches!(key.as_str(), "body" | "content" | "text" | "bytes") {
+ return Err(ProjectionAssertion::new(
+ format!("{path}.{key}"),
+ "reference-only surface inlined artifact content",
+ ));
+ }
+ }
+ Ok(())
+ }
+ _ => Ok(()),
+ })
+}
+
+fn walk(
+ value: &Value,
+ path: &str,
+ visitor: &mut impl FnMut(&str, &Value) -> Result<(), ProjectionAssertion>,
+) -> Result<(), ProjectionAssertion> {
+ visitor(path, value)?;
+ match value {
+ Value::Array(items) => {
+ for (index, item) in items.iter().enumerate() {
+ walk(item, format!("{path}[{index}]").as_str(), visitor)?;
+ }
+ }
+ Value::Object(object) => {
+ for (key, child) in object {
+ walk(child, format!("{path}.{key}").as_str(), visitor)?;
+ }
+ }
+ Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) => {}
+ }
+ Ok(())
+}
diff --git a/crates/libmcp/Cargo.toml b/crates/libmcp/Cargo.toml
index 02ea9db..c087ab8 100644
--- a/crates/libmcp/Cargo.toml
+++ b/crates/libmcp/Cargo.toml
@@ -8,6 +8,7 @@ description.workspace = true
readme.workspace = true
[dependencies]
+libmcp-derive = { path = "../libmcp-derive" }
schemars.workspace = true
serde.workspace = true
serde_json.workspace = true
diff --git a/crates/libmcp/src/lib.rs b/crates/libmcp/src/lib.rs
index 2f4e3b1..049cffe 100644
--- a/crates/libmcp/src/lib.rs
+++ b/crates/libmcp/src/lib.rs
@@ -1,10 +1,13 @@
//! `libmcp` is the shared operational spine for hardened MCP servers.
+extern crate self as libmcp;
+
pub mod fault;
pub mod health;
pub mod host;
pub mod jsonrpc;
pub mod normalize;
+pub mod projection;
pub mod render;
pub mod replay;
pub mod telemetry;
@@ -30,6 +33,10 @@ pub use normalize::{
NumericParseError, PathNormalizeError, normalize_ascii_token, normalize_local_path,
parse_human_unsigned_u64, saturating_u64_to_usize,
};
+pub use projection::{
+ FallbackJsonProjection, ProjectionError, ProjectionPolicy, SelectorProjection, SelectorRef,
+ StructuredProjection, SurfaceKind, SurfacePolicy, ToolProjection,
+};
pub use render::{
DetailLevel, JsonPorcelainConfig, PathStyle, RenderConfig, RenderMode, TruncatedText,
collapse_inline_whitespace, render_json_porcelain, with_presentation_properties,
@@ -37,3 +44,5 @@ pub use render::{
pub use replay::ReplayContract;
pub use telemetry::{TelemetryLog, ToolErrorDetail, ToolOutcome};
pub use types::{Generation, InvariantViolation};
+
+pub use libmcp_derive::{SelectorProjection, ToolProjection};
diff --git a/crates/libmcp/src/projection.rs b/crates/libmcp/src/projection.rs
new file mode 100644
index 0000000..a6216db
--- /dev/null
+++ b/crates/libmcp/src/projection.rs
@@ -0,0 +1,308 @@
+//! Model-facing projection traits.
+
+use crate::render::{DetailLevel, JsonPorcelainConfig, render_json_porcelain};
+use schemars::JsonSchema;
+use serde::{Deserialize, Serialize};
+use serde_json::Value;
+use thiserror::Error;
+
+const OVERVIEW_CONCISE_CONFIG: JsonPorcelainConfig = JsonPorcelainConfig {
+ max_lines: 10,
+ max_inline_chars: 144,
+};
+const OVERVIEW_FULL_CONFIG: JsonPorcelainConfig = JsonPorcelainConfig {
+ max_lines: 28,
+ max_inline_chars: 240,
+};
+const LIST_CONCISE_CONFIG: JsonPorcelainConfig = JsonPorcelainConfig {
+ max_lines: 16,
+ max_inline_chars: 128,
+};
+const LIST_FULL_CONFIG: JsonPorcelainConfig = JsonPorcelainConfig {
+ max_lines: 32,
+ max_inline_chars: 176,
+};
+const READ_CONCISE_CONFIG: JsonPorcelainConfig = JsonPorcelainConfig {
+ max_lines: 18,
+ max_inline_chars: 176,
+};
+const READ_FULL_CONFIG: JsonPorcelainConfig = JsonPorcelainConfig {
+ max_lines: 40,
+ max_inline_chars: 320,
+};
+const MUTATION_CONCISE_CONFIG: JsonPorcelainConfig = JsonPorcelainConfig {
+ max_lines: 12,
+ max_inline_chars: 160,
+};
+const MUTATION_FULL_CONFIG: JsonPorcelainConfig = JsonPorcelainConfig {
+ max_lines: 24,
+ max_inline_chars: 256,
+};
+const OPS_CONCISE_CONFIG: JsonPorcelainConfig = JsonPorcelainConfig {
+ max_lines: 8,
+ max_inline_chars: 160,
+};
+const OPS_FULL_CONFIG: JsonPorcelainConfig = JsonPorcelainConfig {
+ max_lines: 24,
+ max_inline_chars: 240,
+};
+
+/// Projection failure.
+#[derive(Debug, Error)]
+pub enum ProjectionError {
+ /// Serialization failed while materializing the projection.
+ #[error("failed to serialize projection: {0}")]
+ Serialize(#[from] serde_json::Error),
+}
+
+/// Model-facing surface kind.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
+#[serde(rename_all = "snake_case")]
+pub enum SurfaceKind {
+ /// One bounded snapshot answering “where are we now?”
+ Overview,
+ /// Enumeration surfaces such as lists and summaries.
+ List,
+ /// Focused one-object reads.
+ Read,
+ /// Mutation receipts.
+ Mutation,
+ /// Operational and health surfaces.
+ Ops,
+}
+
+/// Projection policy derived from the surface kind.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct ProjectionPolicy {
+ /// Declared surface kind.
+ pub kind: SurfaceKind,
+ /// Whether opaque database identifiers are forbidden by doctrine.
+ pub forbid_opaque_ids: bool,
+ /// Whether the surface is reference-only and must not inline bodies.
+ pub reference_only: bool,
+ /// Concise porcelain bounds.
+ pub concise_porcelain: JsonPorcelainConfig,
+ /// Full porcelain bounds.
+ pub full_porcelain: JsonPorcelainConfig,
+}
+
+impl ProjectionPolicy {
+ /// Builds a policy from the type-level doctrine.
+ #[must_use]
+ pub fn from_surface(kind: SurfaceKind, forbid_opaque_ids: bool, reference_only: bool) -> Self {
+ let (concise_porcelain, full_porcelain) = match kind {
+ SurfaceKind::Overview => (OVERVIEW_CONCISE_CONFIG, OVERVIEW_FULL_CONFIG),
+ SurfaceKind::List => (LIST_CONCISE_CONFIG, LIST_FULL_CONFIG),
+ SurfaceKind::Read => (READ_CONCISE_CONFIG, READ_FULL_CONFIG),
+ SurfaceKind::Mutation => (MUTATION_CONCISE_CONFIG, MUTATION_FULL_CONFIG),
+ SurfaceKind::Ops => (OPS_CONCISE_CONFIG, OPS_FULL_CONFIG),
+ };
+ Self {
+ kind,
+ forbid_opaque_ids,
+ reference_only,
+ concise_porcelain,
+ full_porcelain,
+ }
+ }
+}
+
+/// Slug-first selector projection for model-facing references.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
+pub struct SelectorRef {
+ /// Stable human-facing selector.
+ pub slug: String,
+ /// Optional human-facing title.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub title: Option<String>,
+}
+
+/// Anything that can produce a selector reference.
+pub trait SelectorProjection {
+ /// Builds the selector reference.
+ fn selector_ref(&self) -> SelectorRef;
+}
+
+/// Type-level surface doctrine.
+pub trait SurfacePolicy {
+ /// Declared surface kind.
+ const KIND: SurfaceKind;
+ /// Whether opaque database identifiers are forbidden by doctrine.
+ const FORBID_OPAQUE_IDS: bool = true;
+ /// Whether the surface is reference-only.
+ const REFERENCE_ONLY: bool = false;
+
+ /// Materializes the policy.
+ #[must_use]
+ fn projection_policy(&self) -> ProjectionPolicy {
+ ProjectionPolicy::from_surface(Self::KIND, Self::FORBID_OPAQUE_IDS, Self::REFERENCE_ONLY)
+ }
+}
+
+/// Structured concise/full projections.
+pub trait StructuredProjection {
+ /// Concise structured projection.
+ fn concise_projection(&self) -> Result<Value, ProjectionError>;
+ /// Full structured projection.
+ fn full_projection(&self) -> Result<Value, ProjectionError>;
+}
+
+/// Happy-path trait for model-facing tool outputs.
+pub trait ToolProjection: StructuredProjection + SurfacePolicy {
+ /// Returns the structured projection for the chosen detail level.
+ fn structured_projection(&self, detail: DetailLevel) -> Result<Value, ProjectionError> {
+ match detail {
+ DetailLevel::Concise => self.concise_projection(),
+ DetailLevel::Full => self.full_projection(),
+ }
+ }
+
+ /// Renders porcelain for the chosen detail level.
+ fn porcelain_projection(&self, detail: DetailLevel) -> Result<String, ProjectionError> {
+ let policy = self.projection_policy();
+ let (value, config) = match detail {
+ DetailLevel::Concise => (self.concise_projection()?, policy.concise_porcelain),
+ DetailLevel::Full => (self.full_projection()?, policy.full_porcelain),
+ };
+ Ok(render_json_porcelain(&value, config))
+ }
+}
+
+impl<T> ToolProjection for T where T: StructuredProjection + SurfacePolicy {}
+
+/// Explicit escape hatch for already-curated JSON projections.
+#[derive(Debug, Clone)]
+pub struct FallbackJsonProjection {
+ concise: Value,
+ full: Value,
+ kind: SurfaceKind,
+ forbid_opaque_ids: bool,
+ reference_only: bool,
+}
+
+impl FallbackJsonProjection {
+ /// Builds a fallback projection from serialized values.
+ pub fn new(
+ concise: impl Serialize,
+ full: impl Serialize,
+ kind: SurfaceKind,
+ ) -> Result<Self, ProjectionError> {
+ Ok(Self {
+ concise: serde_json::to_value(concise)?,
+ full: serde_json::to_value(full)?,
+ kind,
+ forbid_opaque_ids: true,
+ reference_only: false,
+ })
+ }
+
+ /// Builds a fallback projection with explicit doctrine flags.
+ pub fn with_policy(
+ concise: impl Serialize,
+ full: impl Serialize,
+ kind: SurfaceKind,
+ forbid_opaque_ids: bool,
+ reference_only: bool,
+ ) -> Result<Self, ProjectionError> {
+ Ok(Self {
+ concise: serde_json::to_value(concise)?,
+ full: serde_json::to_value(full)?,
+ kind,
+ forbid_opaque_ids,
+ reference_only,
+ })
+ }
+}
+
+impl StructuredProjection for FallbackJsonProjection {
+ fn concise_projection(&self) -> Result<Value, ProjectionError> {
+ Ok(self.concise.clone())
+ }
+
+ fn full_projection(&self) -> Result<Value, ProjectionError> {
+ Ok(self.full.clone())
+ }
+}
+
+impl SurfacePolicy for FallbackJsonProjection {
+ const KIND: SurfaceKind = SurfaceKind::Read;
+
+ fn projection_policy(&self) -> ProjectionPolicy {
+ ProjectionPolicy::from_surface(self.kind, self.forbid_opaque_ids, self.reference_only)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::{StructuredProjection as _, SurfaceKind, SurfacePolicy as _};
+ use crate::{DetailLevel, SelectorProjection, SelectorRef, ToolProjection};
+
+ #[derive(Clone, SelectorProjection)]
+ struct HypothesisSelector {
+ slug: String,
+ title: String,
+ }
+
+ #[derive(ToolProjection)]
+ #[libmcp(kind = "read")]
+ struct ExperimentProjection {
+ slug: String,
+ title: String,
+ hypothesis: SelectorRef,
+ #[libmcp(skip_none)]
+ summary: Option<String>,
+ #[libmcp(full_only)]
+ analysis: String,
+ }
+
+ #[test]
+ fn derived_projection_shapes_detail_levels() {
+ let owner = HypothesisSelector {
+ slug: "native-lp-sink".to_owned(),
+ title: "Native LP sink".to_owned(),
+ };
+ let projection = ExperimentProjection {
+ slug: "matched-lp-site-traces".to_owned(),
+ title: "Matched LP site traces".to_owned(),
+ hypothesis: owner.selector_ref(),
+ summary: Some("Node LP work dominates traced native spend.".to_owned()),
+ analysis: "Native LP spends most traced wallclock in node reoptimization.".to_owned(),
+ };
+
+ assert_eq!(ExperimentProjection::KIND, SurfaceKind::Read);
+
+ let concise = projection.concise_projection();
+ assert!(concise.is_ok());
+ let concise = match concise {
+ Ok(value) => value,
+ Err(_) => return,
+ };
+ let full = projection.full_projection();
+ assert!(full.is_ok());
+ let full = match full {
+ Ok(value) => value,
+ Err(_) => return,
+ };
+
+ assert!(concise.get("analysis").is_none());
+ assert_eq!(
+ concise
+ .get("hypothesis")
+ .and_then(|value| value.get("slug"))
+ .and_then(serde_json::Value::as_str),
+ Some("native-lp-sink")
+ );
+ assert_eq!(
+ full.get("analysis").and_then(serde_json::Value::as_str),
+ Some("Native LP spends most traced wallclock in node reoptimization.")
+ );
+
+ let porcelain = projection.porcelain_projection(DetailLevel::Concise);
+ assert!(porcelain.is_ok());
+ let porcelain = match porcelain {
+ Ok(value) => value,
+ Err(_) => return,
+ };
+ assert!(porcelain.contains("slug: \"matched-lp-site-traces\""));
+ }
+}
diff --git a/docs/spec.md b/docs/spec.md
index 80e464b..6d1f3c6 100644
--- a/docs/spec.md
+++ b/docs/spec.md
@@ -180,6 +180,8 @@ The library should therefore provide reusable primitives for:
- render mode selection
- detail selection
+- explicit projection traits separating domain records from model-facing output
+- derive-macro happy paths for concise/full projection structs
- bounded/truncated text shaping
- stable note emission
- path rendering
@@ -187,6 +189,16 @@ The library should therefore provide reusable primitives for:
- generic JSON-to-porcelain projection for consumers that have not yet earned
bespoke renderers
+The intended happy path is not “serialize whatever domain object you already
+have.” The intended happy path is:
+
+1. define a model-facing projection
+2. declare its surface policy
+3. render porcelain from that projection
+
+Consumers may still use generic JSON fallbacks, but they should feel like an
+explicit escape hatch rather than the primary design path.
+
`libmcp` standardizes only the minimal shared detail axis
`concise|full`. Consumers may add richer local taxonomies when their tool
surface actually needs them.