| name | create-builtin-tool |
| description | Guide for creating builtin MCP tools in LibrAgent. Use when creating a new builtin MCP server, adding tools to existing builtin servers, refactoring tool implementations to follow best practices, or auditing tool compliance with the Tool Design Manifesto v2.1. |
Create Builtin MCP Tool Skill
When to Use This Skill
Use this skill when:
- Creating a new builtin MCP server for LibrAgent
- Adding tools to existing builtin servers
- Refactoring tool implementations to follow best practices
- Auditing tool compliance with the Tool Design Manifesto v2.1
Prerequisites: Understanding of Rust async/await, Tauri backend architecture, and MCP protocol basics.
Architecture Overview
3-Layer Architecture
┌─────────────────────────────────────────────────────────┐
│ React Frontend (src/lib/backend/, src/features/) │
│ - TypeScript API wrappers (builtin-tools.ts) │
│ - UI components (BuiltInToolsEditor.tsx) │
└────────────────┬────────────────────────────────────────┘
│ Tauri Commands (invoke)
┌────────────────▼────────────────────────────────────────┐
│ Proxy Layer (src-tauri/src/mcp/service_proxy_manager/) │
│ - MCPServiceProxyManager (session routing) │
│ - MCPServiceProxy (per-session instances) │
└────────────────┬────────────────────────────────────────┘
│ BuiltinMCPServer trait
┌────────────────▼────────────────────────────────────────┐
│ Rust Backend (src-tauri/src/mcp/builtin/) │
│ - Tool definitions (tools/*.rs) │
│ - Tool handlers (handlers/*.rs, mod.rs) │
│ - Business logic (*.rs modules) │
└─────────────────────────────────────────────────────────┘
Step-by-Step Implementation Guide
Step 1: Create Server Module Structure
src-tauri/src/mcp/builtin/
└── your_server/
├── mod.rs
├── tools.rs
├── handlers.rs
└── types.rs
Step 2: Define the Server Struct
File: src-tauri/src/mcp/builtin/your_server/mod.rs
use async_trait::async_trait;
use serde_json::{json, Value};
use std::sync::Arc;
use super::BuiltinMCPServer;
use crate::mcp::types::{MCPResult, ServiceContext};
use crate::mcp::MCPTool;
pub mod handlers;
pub mod tools;
#[derive(Debug)]
pub struct YourServer {
pub(crate) session_id: String,
}
impl YourServer {
pub fn new(session_id: String ) -> Self {
Self {
session_id,
}
}
pub fn tools_static() -> Vec<MCPTool> {
vec![
tools::create_your_tool(),
]
}
pub fn metadata_static() -> crate::mcp::types::BuiltinServerMetadata {
crate::mcp::types::BuiltinServerMetadata {
display_name: "Your Server".to_string(),
description: "Brief description for UI".to_string(),
icon: None,
}
}
}
Step 3: Implement BuiltinMCPServer Trait
Critical: declare pub const NAME as the single source of truth for the server name.
This constant is referenced by both fn name() and the regression tests in agent/tools.rs.
A typo in a string literal would compile silently; a typo here breaks the build immediately.
pub const NAME: &str = "your_server";
#[async_trait]
impl BuiltinMCPServer for YourServer {
fn name(&self) -> &str {
NAME
}
fn description(&self) -> &str {
"Detailed description of server capabilities"
}
fn display_name(&self) -> String {
"Your Server".to_string()
}
fn tools(&self) -> Vec<MCPTool> {
Self::tools_static()
}
async fn get_service_context(&self, options: Option<&Value>) -> ServiceContext {
let session_id = if let Some(opts) = options {
opts.get("session_id")
.and_then(|v| v.as_str())
.unwrap_or(&self.session_id)
.to_string()
} else {
self.session_id.clone()
};
let context_prompt = format!(
"## Your Server
**Session**: {}
**Status**: Active
**Recent Activity**:
- Resource 'A' created (ID: 123)
- Resource 'B' updated: 'Refactoring login flow' (ID: 456)
**Available Features**: Feature A, Feature B
💡 Use listYourResources() to see available items.",
session_id
);
ServiceContext {
context_prompt,
structured_state: Some(json!({
"session_id": session_id,
"server_type": "your_server",
"initialized": true
})),
}
}
async fn call_tool(
&self,
tool_name: &str,
args: Value,
session_id: Option<String>,
) -> Result<MCPResult, String> {
match tool_name {
"yourTool" => handlers::handle_your_tool(self, args, session_id).await,
"listResources" => handlers::handle_list_resources(self, args, session_id).await,
_ => Err(format!("Tool '{}' not found", tool_name)),
}
}
}
Step 4: Define Tool Schemas (Following Manifesto Rules)
Critical rule for tool docs:
input_schema property descriptions are the single source of truth for parameter semantics.
- Tool
description should focus on purpose, prerequisites, workflow, and next actions.
- Do not add
PARAMETERS: blocks in tool description when the same details already exist in schema.
Parameter ordering (LLM generation):
- Use
SchemaProperties / props.insert() order deliberately — it is preserved through Rust serialization and the frontend tool pipeline.
- Insert fields as: identifiers → enums/flags → small scalars → structured arrays/objects → large text blobs last (e.g.
path, mode, content for writeFile).
- Models often emit tool arguments in schema order; putting large
content fields first makes later enums/paths easier to omit.
File: src-tauri/src/mcp/builtin/your_server/tools.rs
use crate::mcp::{utils::schema_builder::*, MCPTool};
use serde_json::json;
use std::collections::HashMap;
pub fn create_your_resource_tool() -> MCPTool {
let mut props = HashMap::new();
props.insert(
"name".to_string(),
string_prop(
Some(1),
Some(100),
Some("Resource name (required)")
),
);
props.insert(
"description".to_string(),
string_prop(
None,
Some(500),
Some("Resource description (optional)")
),
);
MCPTool {
name: "createResource".to_string(),
title: Some("Create Resource".to_string()),
description: "Create a new resource in the system.
⚠️ WORKFLOW:
1. System generates unique ID automatically
2. Returns ID in response for future operations
3. Use listResources() to verify creation
RESPONSE:
- Created resource with system-generated ID
- Use returned ID for updateResource() or deleteResource()
💡 NEXT STEPS: Use getResource(id) to verify or updateResource(id) to modify"
.to_string(),
input_schema: object_schema(props, vec!["name".to_string()]),
output_schema: None,
annotations: None,
}
}
pub fn create_update_resource_tool() -> MCPTool {
let mut props = HashMap::new();
props.insert(
"id".to_string(),
string_prop(
Some(1),
Some(50),
Some("Resource ID (from createResource or listResources)")
),
);
props.insert(
"name".to_string(),
string_prop(
Some(1),
Some(100),
Some("New resource name")
),
);
props.insert(
"summary".to_string(),
string_prop(
None,
Some(200),
Some("Optional context: why this change is being made (e.g., 'Refactoring X', 'Fixing bug Y')")
),
);
MCPTool {
name: "updateResource".to_string(),
title: Some("Update Resource".to_string()),
description: "Update an existing resource.
⚠️ PREREQUISITE: Obtain valid ID from:
- createResource() response
- listResources() output
ERROR HANDLING:
- If ID not found, returns error with suggestion to use listResources()
💡 WORKFLOW:
1. Call listResources() to find the resource
2. Extract the 'id' field from the result
3. Pass exact ID to this tool"
.to_string(),
input_schema: object_schema(props, vec!["id".to_string(), "name".to_string()]),
output_schema: None,
annotations: None,
}
}
pub fn create_list_resources_tool() -> MCPTool {
let props = HashMap::new();
MCPTool {
name: "listResources".to_string(),
title: Some("List Resources".to_string()),
description: "List all available resources with their IDs.
RETURNS:
- Complete list of resources with IDs and details
- Use returned IDs for updateResource() or deleteResource()
OUTPUT FORMAT (text):
Each resource shows: ID | Name | Status
💡 Use getResource(id) to see full details of a specific resource"
.to_string(),
input_schema: object_schema(props, vec![]),
output_schema: None,
annotations: None,
}
}
Step 5: Implement Tool Handlers (Following Manifesto Rules)
File: src-tauri/src/mcp/builtin/your_server/handlers.rs
use super::YourServer;
use crate::mcp::builtin::error_guidance::{operation_failed_error, ToolGroup};
use crate::mcp::types::{text, MCPContent, MCPResult};
use crate::mcp::utils::success_hint::SuccessHint;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct CreateResourceArgs {
name: String,
description: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct UpdateResourceArgs {
id: String,
name: String,
summary: Option<String>,
}
pub async fn handle_create_resource(
server: &YourServer,
args: Value,
_session_id: Option<String>,
) -> Result<MCPResult, String> {
let args: CreateResourceArgs = serde_json::from_value(args)
.map_err(|e| format!("Invalid arguments: {}", e))?;
let resource_id = generate_id();
let result_text = format!(
"Resource created successfully (ID: {}).\n\n\
Name: {}\n\
Description: {}\n\n\
💡 NEXT STEPS:\n\
- Use getResource(\"{}\") to view details\n\
- Use updateResource(\"{}\", ...) to modify\n\
- Use listResources() to see all resources",
resource_id,
args.name,
args.description.as_deref().unwrap_or("None"),
resource_id,
resource_id
);
let structured_data = json!({
"resource_id": resource_id,
"name": args.name,
"description": args.description,
"created_at": chrono::Utc::now().to_rfc3339()
});
Ok(MCPResult {
content: vec![text(result_text)],
structured_content: Some(structured_data),
is_error: Some(false),
})
}
pub async fn handle_update_resource(
server: &YourServer,
args: Value,
_session_id: Option<String>,
) -> Result<MCPResult, String> {
let args: UpdateResourceArgs = serde_json::from_value(args)
.map_err(|e| format!("Invalid arguments: {}", e))?;
let exists = false;
if !exists {
return Ok(operation_failed_error(
"Update Resource",
&format!("Resource '{}' not found", args.id),
vec![
"Use listResources() to find the correct resource ID".to_string(),
"IDs are case-sensitive and must match exactly".to_string(),
format!("You provided: '{}'", args.id)
],
ToolGroup::YourServer
));
}
let summary_echo = args.summary.as_deref().unwrap_or("No summary provided");
let result_text = format!(
"Resource updated successfully (ID: {}).\n\n\
New name: {}\n\
Context: {}\n\n\
💡 Use getResource(\"{}\") to view updated details",
args.id,
args.name,
summary_echo,
args.id
);
Ok(SuccessHint::new(
result_text,
vec![format!("Use getResource(\"{}\") to verify changes", args.id)]
).to_mcp_result_with_data(Some(json!({
"resource_id": args.id,
"updated_fields": ["name"],
"timestamp": chrono::Utc::now().to_rfc3339()
}))))
}
pub async fn handle_list_resources(
server: &YourServer,
_args: Value,
_session_id: Option<String>,
) -> Result<MCPResult, String> {
let resources = vec![
("res_abc123", "Example Resource 1", "Active"),
("res_def456", "Example Resource 2", "Inactive"),
];
let resource_list = resources.iter()
.map(|(id, name, status)| format!(" • {} | {} | {}", id, name, status))
.collect::<Vec<_>>()
.join("\n");
let result_text = format!(
"Found {} resource(s):\n\n{}\n\n\
💡 NEXT STEPS:\n\
- Use getResource(id) for full details\n\
- Use updateResource(id, ...) to modify\n\
- Use deleteResource(id) to remove",
resources.len(),
resource_list
);
let structured_data = json!({
"resources": resources.iter().map(|(id, name, status)| json!({
"id": id,
"name": name,
"status": status
})).collect::<Vec<_>>(),
"total": resources.len()
});
Ok(MCPResult {
content: vec![text(result_text)],
structured_content: Some(structured_data),
is_error: Some(false),
})
}
fn generate_id() -> String {
use rand::Rng;
let mut rng = rand::thread_rng();
format!("res_{:x}", rng.gen::<u32>())
}
Step 6: Register Server in Global Registry
Registration requires three coordinated edits. Miss any one and the regression tests
will fail immediately.
6a. Module declaration — src-tauri/src/mcp/builtin/mod.rs
pub mod your_server;
6b. Service registry — src-tauri/src/agent/tools.rs
This is the single source of truth for every service canonical name.
pub(crate) const BUILTIN_SERVICE_REGISTRY: &[BuiltinServiceEntry] = &[
BuiltinServiceEntry { canonical: "your_server", optional: false },
];
Set optional: true if the service should only be enabled when the agent config
explicitly lists it (e.g. browser, bootstrap). Core services use false.
6c. Regression test list — src-tauri/src/agent/tools.rs (tests module)
fn each_builtin_server_name_is_in_registry() {
use crate::mcp::builtin;
let all_names: &[&str] = &[
builtin::your_server::NAME,
];
}
All four regression tests share the same all_names pattern. Update each one.
6d. Session instantiation — src-tauri/src/mcp/builtin/mod.rs
impl BuiltinServerRegistry {
pub fn new_session_instance(
&self,
server_id: &str,
session_id: String,
) -> Option<Arc<dyn BuiltinMCPServer>> {
match server_id {
"your_server" => Some(Arc::new(YourServer::new(session_id))),
_ => None,
}
}
}
Why three places? mod.rs wires the module into the build; BUILTIN_SERVICE_REGISTRY
drives runtime routing and alias resolution; the regression test list enforces that every
registered canonical has a concrete server NAME backing it — and vice-versa.
Step 7: Add Integration Tests
File: src-tauri/src/mcp/builtin/your_server/mod.rs
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_create_resource_generates_id() {
let server = YourServer::new("test_session".to_string());
let args = json!({
"name": "Test Resource",
"description": "Test description"
});
let result = handlers::handle_create_resource(&server, args, None).await.unwrap();
let text_content = result.content.iter()
.find_map(|c| match c {
MCPContent::Text { text } => Some(text.as_str()),
_ => None
})
.expect("Expected text content");
assert!(text_content.contains("ID:"), "ID must be in text for AI");
assert!(text_content.contains("res_"), "ID must be visible in output");
assert!(result.structured_content.is_some());
let data = result.structured_content.unwrap();
assert!(data.get("resource_id").is_some(), "ID must be in structured data");
}
#[tokio::test]
async fn test_update_validates_id_existence() {
let server = YourServer::new("test_session".to_string());
let args = json!({
"id": "nonexistent_id",
"name": "Updated Name"
});
let result = handlers::handle_update_resource(&server, args, None).await.unwrap();
assert_eq!(result.is_error, Some(true), "Should return error for invalid ID");
let text_content = result.content.iter()
.find_map(|c| match c {
MCPContent::Text { text } => Some(text.as_str()),
_ => None
})
.unwrap();
assert!(text_content.contains("listResources"), "Error should suggest recovery tool");
}
#[test]
fn test_tools_have_no_id_in_create_schema() {
let tools = YourServer::tools_static();
let create_tool = tools.iter()
.find(|t| t.name.starts_with("create"))
.expect("Should have create tool");
let props = &create_tool.input_schema.properties;
assert!(!props.contains_key("id"), "Create tool must NOT have ID parameter");
}
}
Design Manifesto Compliance Checklist
Use this checklist before submitting any builtin tool implementation:
Rule 1: The Immutable ID Rule ✅
Rule 2: The Hallucination Firewall ✅
Rule 3: The Dual-Channel Response Rule ✅
Rule 4: AI-Native Descriptions ✅
Rule 5: The Success Hint Pattern ✅
Rule 6: Memory-Augmented Mutation Rule (New) ✅
Rule 7: The State Echo Rule (New) ✅
Rule 8: Outcome-Conditioned Augmentation ✅
Common Pitfalls to Avoid
❌ Pitfall 1: Hidden IDs (Rule 3 Violation)
let result_text = "Resource created successfully.";
let data = json!({ "id": resource_id });
let result_text = format!(
"Resource created (ID: {}).\n\nUse getResource(\"{}\") to view",
resource_id, resource_id
);
let data = json!({ "id": resource_id });
❌ Pitfall 2: Trusting Agent IDs (Rule 2 Violation)
db.resources.update(&args.id, data).await?;
if !db.resources.exists(&args.id).await? {
return Ok(operation_failed_error(...));
}
db.resources.update(&args.id, data).await?;
❌ Pitfall 3: Human-Centric Language (Rule 4 Violation)
description: "Click the resource to select it, then copy the ID"
description: "Extract resource ID from listResources() output for subsequent operations"
❌ Pitfall 4: Dead-End Errors (Rule 5 Violation)
Err(format!("Resource '{}' not found", id))
Ok(operation_failed_error(
"Get Resource",
&format!("Resource '{}' not found", id),
vec!["Use listResources() to see available IDs".to_string()],
ToolGroup::YourServer
))
❌ Pitfall 5: Duplicated Parameter Docs (Schema Drift Risk)
description: "Update resource.\n\nPARAMETERS:\n- id: Resource ID\n- name: New name"
props.insert("id".to_string(), string_prop(Some(1), Some(50), Some("Resource ID")));
props.insert("name".to_string(), string_prop(Some(1), Some(100), Some("New resource name")));
description: "Update an existing resource.\n\nPREREQUISITE: get valid ID from listResources()."
❌ Pitfall 6: Fixed Follow-Up Spam (Rule 8 Violation)
SuccessHint::new(message, vec!["Use listItems() to see the list".to_string()])
vec!["Retry with corrected parameters".to_string()]
match classify_outcome(&before, &after, &error_history) {
Outcome::Steady => SuccessHint::new(message, vec![]),
Outcome::PhaseBoundary => SuccessHint::new(
message,
vec!["Milestone reached — run the in-domain review/consolidate action.".to_string()],
),
Outcome::RepeatedFailure => guided_error(...).with_guidance(vec![
"Same failure repeated — stop retrying; diagnose root cause or reset strategy.".to_string(),
]),
}
🚫 Critical Anti-Patterns
Check for these subtle design flaws that cripple agent reasoning:
1. The "State Amnesia" Pattern
- Symptom: Agent completes a task but loses the reason context.
- Example:
completeTask(id) (No summary)
- Result: Agent forgets how it solved the problem. If user asks "How did we fix X?", the agent has to search chat history or Hallucinate.
- Fix: Always include optional
summary, reason, or context parameters in state-changing tools (Rule 6).
2. The "Implementation Gap"
- Symptom: Tool design docs specify rich parameters, but implementation omits them as "optional/skippable".
- Example: Design says
checkTodo(id, summary), code implements checkTodo(id).
- Result: Agent tries to use the planned feature but fails silently or gives poor data.
- Fix: Treat the Tool Schema as a binding contract. Optional parameters are functional requirements for AI reasoning.
3. The "Blind Alley" Response
- Symptom: Tool returns
void or generic "Success".
- Result: Agent is left in a void, unsure if the action persisted.
- Fix: Dual-channel response with IDs and status in text (Rule 3). Always return an "Echo" of the new state (Rule 7).
4. The "Retry Treadmill" Pattern
- Symptom: Every failure returns the same "retry with X" hint; every success promotes the same sibling tool.
- Result: Agent loops at the same abstraction level instead of consolidating wins or resetting strategy after stuck failures.
- Fix: Outcome-conditioned augmentation (Rule 8) — lean on steady progress, escalate at phase boundaries, escalate on repeated identical failures.
Code Templates
Template: Error Response with Hints
use crate::mcp::builtin::error_guidance::{operation_failed_error, ToolGroup};
return Ok(operation_failed_error(
"Operation Name",
"Specific error description",
vec![
"Use suggestedTool() to find correct value".to_string(),
"Additional context or constraint".to_string(),
],
ToolGroup::YourServer
));
Template: Success Response with Dual Channels
use crate::mcp::utils::success_hint::SuccessHint;
let result_text = format!(
"Operation completed (ID: {}).\n\n\
Details: {}\n\n\
💡 Use nextTool(\"{}\") to continue workflow",
generated_id, details, generated_id
);
Ok(SuccessHint::new(
result_text,
vec![format!("Use verifyTool(\"{}\") to check status", generated_id)]
).to_mcp_result_with_data(Some(json!({
"id": generated_id,
"metadata": additional_data
}))))
Template: ID Generation
fn generate_id() -> String {
use rand::Rng;
let mut rng = rand::thread_rng();
format!("prefix_{:x}", rng.gen::<u32>())
}
fn generate_id() -> String {
uuid::Uuid::new_v4().to_string()
}
fn generate_id() -> String {
use crate::utils::generate_short_id;
generate_short_id()
}
Testing Strategy
Unit Tests (Per Tool)
#[tokio::test]
async fn test_create_tool_dual_channel_response() {
let result = handlers::handle_create(...).await.unwrap();
let text = extract_text(&result);
assert!(text.contains("ID:"), "Must show ID in text");
let data = result.structured_content.unwrap();
assert!(data.get("id").is_some(), "Must have ID in JSON");
}
#[tokio::test]
async fn test_update_tool_hallucination_firewall() {
let result = handlers::handle_update(invalid_id_args).await.unwrap();
assert_eq!(result.is_error, Some(true));
let text = extract_text(&result);
assert!(text.contains("listResources"), "Must suggest recovery");
}
Integration Tests (Full Workflow)
#[tokio::test]
async fn test_create_update_delete_workflow() {
let server = YourServer::new("test_session".to_string());
let create_result = server.call_tool(
"createResource",
json!({"name": "Test"}),
None
).await.unwrap();
let id = extract_id_from_text(&create_result);
let update_result = server.call_tool(
"updateResource",
json!({"id": id, "name": "Updated"}),
None
).await.unwrap();
assert_eq!(update_result.is_error, Some(false));
}
Frontend Integration
After implementing the Rust backend, add TypeScript wrappers:
File: src/lib/backend/your-server-api.ts
import { safeInvoke } from './core';
import type { MCPResponse } from '@/lib/mcp';
export async function createYourResource(
serverName: string,
name: string,
description?: string,
): Promise<MCPResponse<unknown>> {
return safeInvoke('call_builtin_tool', {
serverName,
toolName: 'createResource',
arguments: { name, description },
});
}
export async function listYourResources(
serverName: string,
): Promise<MCPResponse<unknown>> {
return safeInvoke('call_builtin_tool', {
serverName,
toolName: 'listResources',
arguments: {},
});
}
Summary
Key Principles:
- No ID inputs for CREATE - System generates, agent receives
- Validate before writes - Hallucination firewall on all ID-based operations
- Dual-channel responses - IDs visible in BOTH text (AI) and JSON (UI)
- AI-native descriptions - Data operations, not UI actions
- Recovery hints - Every error suggests a path forward
Quality Gates:
When in doubt: Look at existing implementations (workspace, planning, knowledge) and follow their patterns for session management, error handling, and response formatting.