Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 89 additions & 1 deletion packages/core/src/behaviors/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1053,6 +1053,8 @@ impl SchemaNodeBehavior {

// Validate title_template syntax: every '{' must have a matching '}' and a non-empty field name
if let Some(template) = &schema.title_template {
let defined_fields: HashSet<&str> =
schema.fields.iter().map(|f| f.name.as_str()).collect();
let bytes = template.as_bytes();
let mut i = 0;
while i < bytes.len() {
Expand All @@ -1071,6 +1073,13 @@ impl SchemaNodeBehavior {
"title_template contains an empty '{}' placeholder".to_string(),
));
}
// Cross-check: token must reference a defined field
if !defined_fields.contains(field_name) {
return Err(NodeValidationError::InvalidProperties(format!(
"title_template references undefined field '{}' — add it to schema.fields",
field_name
)));
}
i += 1 + end + 1; // skip past '}'
continue;
}
Expand Down Expand Up @@ -2902,7 +2911,10 @@ mod tests {
"schemaVersion": 1,
"description": "",
"titleTemplate": "{first_name} {last_name}",
"fields": [],
"fields": [
{"name": "first_name", "type": "string", "protection": "user", "indexed": false},
{"name": "last_name", "type": "string", "protection": "user", "indexed": false}
],
"relationships": []
}),
);
Expand Down Expand Up @@ -2991,6 +3003,82 @@ mod tests {
);
}

#[test]
fn test_title_template_undefined_field_rejected() {
use crate::models::SchemaNode;
let behavior = SchemaNodeBehavior;

// title_template references a field "nonexistent" that is not in schema.fields
let node = Node::new(
"schema".to_string(),
"Customer".to_string(),
json!({
"isCore": false,
"schemaVersion": 1,
"description": "Customer schema",
"fields": [
{
"name": "first_name",
"type": "string",
"protection": "user",
"indexed": false
}
],
"relationships": [],
"titleTemplate": "{nonexistent}"
}),
);
let schema = SchemaNode::from_node(node).unwrap();
let result = behavior.validate_schema_node(&schema);
assert!(
result.is_err(),
"Undefined field in title_template should fail validation"
);
assert!(matches!(
result,
Err(NodeValidationError::InvalidProperties(ref msg))
if msg.contains("undefined field") && msg.contains("nonexistent")
));
}

#[test]
fn test_title_template_defined_field_accepted() {
use crate::models::SchemaNode;
let behavior = SchemaNodeBehavior;

// title_template references fields that exist in schema.fields
let node = Node::new(
"schema".to_string(),
"Customer".to_string(),
json!({
"isCore": false,
"schemaVersion": 1,
"description": "Customer schema",
"fields": [
{
"name": "first_name",
"type": "string",
"protection": "user",
"indexed": false
},
{
"name": "last_name",
"type": "string",
"protection": "user",
"indexed": false
}
],
"relationships": [],
"titleTemplate": "{first_name} {last_name}"
}),
);
let schema = SchemaNode::from_node(node).unwrap();
assert!(
behavior.validate_schema_node(&schema).is_ok(),
"title_template referencing defined fields should pass validation"
);
}

// =========================================================================
// TaskNode from_node Format Tests
// =========================================================================
Expand Down
48 changes: 43 additions & 5 deletions packages/core/src/mcp/handlers/schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@
//! Supports both explicit field/relationship definitions and natural language descriptions
//! with intelligent type inference.

use crate::behaviors::SchemaNodeBehavior;
use crate::mcp::types::MCPError;
use crate::models::schema::{EnumValue, SchemaField, SchemaProtectionLevel};
use crate::models::NodeUpdate;
use crate::models::{Node, NodeUpdate, SchemaNode};
use crate::services::{CreateNodeParams, NodeService};
use serde::{Deserialize, Serialize};
use serde_json::Value;
Expand Down Expand Up @@ -44,6 +45,11 @@ pub struct CreateSchemaParams {
/// Optional additional constraints for explicit type hints (used with description)
#[serde(default)]
pub additional_constraints: Option<AdditionalConstraints>,
/// Optional template for computing display title from field values.
/// Use `{field_name}` tokens that reference fields defined in `fields`.
/// Example: `"{first_name} {last_name}"` for a customer schema.
#[serde(default)]
pub title_template: Option<String>,
}

/// Additional constraints for schema creation
Expand Down Expand Up @@ -162,13 +168,16 @@ pub async fn handle_create_schema(
.description
.clone()
.unwrap_or_else(|| format!("Schema for {}", params.name));
let properties = serde_json::json!({
let mut properties = serde_json::json!({
"isCore": false,
"schemaVersion": 1,
"description": &description_text,
"fields": &namespaced_fields,
"relationships": &relationships
});
if let Some(ref template) = params.title_template {
properties["titleTemplate"] = serde_json::Value::String(template.clone());
}

// Create schema node params
let schema_node_params = CreateNodeParams {
Expand Down Expand Up @@ -251,6 +260,11 @@ pub struct UpdateSchemaParams {
/// New description (optional)
#[serde(default)]
pub description: Option<String>,
/// Set or update the title template. Pass `null` (absent) to leave unchanged.
/// Use `{field_name}` tokens referencing fields defined in the schema.
/// Example: `"{first_name} {last_name}"`
#[serde(default)]
pub title_template: Option<String>,
}

/// Output for schema update operations
Expand Down Expand Up @@ -494,14 +508,34 @@ pub async fn handle_update_schema(
// Update description if provided
let description = params.description.unwrap_or(schema.description);

// Update schema node
let properties = serde_json::json!({
// Resolve title_template: use new value if provided, otherwise keep existing
let title_template = params.title_template.or(schema.title_template);

// Build updated properties
let mut properties = serde_json::json!({
"isCore": schema.is_core,
"version": schema.schema_version,
"schemaVersion": schema.schema_version,
"description": description,
"fields": fields,
"relationships": relationships
});
if let Some(ref template) = title_template {
properties["titleTemplate"] = serde_json::Value::String(template.clone());
}

// Validate the updated schema before saving (update_node_unchecked bypasses the behavior
// pipeline, so we run SchemaNodeBehavior validation explicitly here)
let temp_node = Node::new(
"schema".to_string(),
description.clone(),
properties.clone(),
);
let updated_schema = SchemaNode::from_node(temp_node).map_err(|e| {
MCPError::invalid_params(format!("Failed to build schema for validation: {}", e))
})?;
SchemaNodeBehavior
.validate_schema_node(&updated_schema)
.map_err(|e| MCPError::invalid_params(format!("Schema validation failed: {}", e)))?;

let update = NodeUpdate {
properties: Some(properties),
Expand Down Expand Up @@ -925,6 +959,10 @@ fn normalize_schema_id(entity_name: &str) -> String {
normalize_field_name(entity_name)
}

#[cfg(test)]
#[path = "schema_test.rs"]
mod schema_test;

#[cfg(test)]
mod tests {
use super::*;
Expand Down
Loading