MCP Server AI Integration

Complete guide to integrating AI services (Gemini, Anthropic, OpenAI) into MCP servers with search grounding and artifact persistence.

This guide covers integrating AI services into MCP servers, including Gemini with Google Search grounding, content generation, and artifact persistence.

Prerequisites

  • Working MCP server (see MCP Extensions)
  • systemprompt facade crate with features = ["full"]
  • Configured AI providers in services/ai/config.yaml

Dependencies

[dependencies]
systemprompt = { workspace = true, features = ["full"] }
rmcp = { workspace = true }
axum = { workspace = true }
tokio = { workspace = true }
anyhow = { workspace = true }
tracing = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
uuid = { workspace = true }
chrono = { workspace = true }

Import Reference

The systemprompt facade crate re-exports modules from systemprompt-core:

// AI Services
use systemprompt::ai::{
    AiService,           // Main AI service
    AiConfig,            // Provider configuration
    AiMessage,           // Message for conversations
    AiRequest,           // Request builder
    AiResponse,          // Standard response
    MessageRole,         // User, System, Assistant
    GoogleSearchParams,  // Search grounding params
    SearchGroundedResponse, // Response with sources
    NoopToolProvider,    // Dummy tool provider
};

// Agent Services
use systemprompt::agent::services::SkillService;
use systemprompt::agent::repository::content::ArtifactRepository;
use systemprompt::agent::{Artifact, ArtifactMetadata, DataPart, Part};

// Identifiers
use systemprompt::identifiers::{
    ArtifactId,
    ContextId,
    TaskId,
    McpServerId,
};

// Config Loading
use systemprompt::loader::EnhancedConfigLoader;

// Database
use systemprompt::database::DbPool;

Server Constructor with AI Service

use std::sync::Arc;
use anyhow::{Context, Result};
use systemprompt::ai::{AiService, NoopToolProvider};
use systemprompt::agent::services::SkillService;
use systemprompt::agent::repository::content::ArtifactRepository;
use systemprompt::database::DbPool;
use systemprompt::identifiers::McpServerId;
use systemprompt::loader::EnhancedConfigLoader;
use systemprompt::system::AppContext;

#[derive(Clone)]
pub struct MyAiServer {
    pub db_pool: DbPool,
    pub service_id: McpServerId,
    pub ai_service: Arc<AiService>,
    pub skill_loader: Arc<SkillService>,
    pub artifact_repo: ArtifactRepository,
}

impl MyAiServer {
    pub fn new(
        db_pool: DbPool,
        service_id: McpServerId,
        _ctx: Arc<AppContext>,
    ) -> Result<Self> {
        // Load services configuration
        let config_loader = EnhancedConfigLoader::from_env()
            .context("Failed to create config loader")?;
        let services_config = config_loader.load()
            .context("Failed to load services config")?;

        // Create AI service with NoopToolProvider
        // NoopToolProvider is required even if not using tool execution
        let tool_provider = Arc::new(NoopToolProvider::new());
        let ai_service = Arc::new(
            AiService::new(
                db_pool.clone(),
                &services_config.ai,
                tool_provider,
                None,  // No session provider
            ).context("Failed to initialize AiService")?
        );

        // Initialize other services
        let skill_loader = Arc::new(SkillService::new(db_pool.clone()));
        let artifact_repo = ArtifactRepository::new(db_pool.clone());

        Ok(Self {
            db_pool,
            service_id,
            ai_service,
            skill_loader,
            artifact_repo,
        })
    }
}

Building AiMessage

AiMessage requires three fields: role, content, and parts.

use systemprompt::ai::{AiMessage, MessageRole};

// Method 1: Full struct construction
let message = AiMessage {
    role: MessageRole::System,
    content: "You are a helpful assistant.".to_string(),
    parts: vec![],  // Required, even if empty
};

// Method 2: Helper methods (recommended)
let system_msg = AiMessage::system("You are a helpful assistant.");
let user_msg = AiMessage::user("What is MCP?");

Gemini with Google Search Grounding

Use generate_with_google_search() to leverage Google Search for real-time information:

use systemprompt::ai::{GoogleSearchParams, SearchGroundedResponse, AiMessage};

pub async fn research_topic(
    ai_service: &AiService,
    skill_content: &str,
    topic: &str,
) -> Result<SearchGroundedResponse> {
    // Build messages
    let messages = vec![
        AiMessage::system(skill_content),
        AiMessage::user(&format!(
            "Research the following topic thoroughly using web search:\n\n{}",
            topic
        )),
    ];

    // Configure search params
    let params = GoogleSearchParams {
        messages,
        sampling: None,
        max_output_tokens: 8192,
        model: Some("gemini-2.5-flash"),
        urls: None,  // Optional: specific URLs to analyze
        response_schema: None,  // Optional: structured output schema
    };

    // Execute search-grounded generation
    let response = ai_service
        .generate_with_google_search(params)
        .await
        .context("Gemini search failed")?;

    Ok(response)
}

SearchGroundedResponse Fields

pub struct SearchGroundedResponse {
    pub content: String,                    // Generated text
    pub sources: Vec<WebSource>,            // Search result sources
    pub confidence_scores: Vec<f32>,        // Confidence per source
    pub web_search_queries: Vec<String>,    // Queries used
    pub url_context_metadata: Option<Vec<UrlMetadata>>,
    pub tokens_used: Option<u32>,
    pub latency_ms: u64,
    pub finish_reason: Option<String>,
    pub safety_ratings: Option<Vec<serde_json::Value>>,
}

pub struct WebSource {
    pub title: String,
    pub uri: String,
    pub relevance: f32,
}

Standard AI Generation

For content generation without search:

use systemprompt::ai::{AiRequest, AiRequestBuilder, AiResponse};

pub async fn generate_content(
    ai_service: &AiService,
    system_prompt: &str,
    user_prompt: &str,
    ctx: &RequestContext,
) -> Result<String> {
    let request = AiRequestBuilder::new(
        vec![
            AiMessage::system(system_prompt),
            AiMessage::user(user_prompt),
        ],
        "gemini",           // provider
        "gemini-2.5-pro",   // model
        32768,              // max_output_tokens
        ctx.clone(),
    ).build();

    let response = ai_service
        .generate(&request)
        .await
        .context("Content generation failed")?;

    Ok(response.content)
}

Artifact Storage

Store results as artifacts for later retrieval:

use systemprompt::agent::{Artifact, ArtifactMetadata, DataPart, Part};
use systemprompt::identifiers::{ArtifactId, ContextId, TaskId};

pub async fn store_research_artifact(
    artifact_repo: &ArtifactRepository,
    topic: &str,
    response: &SearchGroundedResponse,
    context_id: &str,
    skill_id: &str,
) -> Result<String> {
    let artifact_id = ArtifactId::generate();
    let task_id = TaskId::generate();
    let context_id = ContextId::new(context_id);

    // Build artifact data
    let research_data = serde_json::json!({
        "topic": topic,
        "summary": response.content,
        "sources": response.sources.iter().map(|s| {
            serde_json::json!({
                "title": s.title,
                "uri": s.uri,
                "relevance": s.relevance
            })
        }).collect::<Vec<_>>(),
        "queries": response.web_search_queries,
        "tokens_used": response.tokens_used,
    });

    let artifact = Artifact {
        id: artifact_id.clone(),
        name: Some(format!("Research: {}", topic)),
        description: Some("Blog research with sources".to_string()),
        parts: vec![Part::Data(DataPart {
            data: research_data.as_object().unwrap().clone(),
        })],
        extensions: vec![],
        metadata: ArtifactMetadata {
            artifact_type: "research".to_string(),
            context_id: context_id.clone(),
            task_id: task_id.clone(),
            created_at: chrono::Utc::now().to_rfc3339(),
            tool_name: Some("research_topic".to_string()),
            skill_id: Some(skill_id.to_string()),
            skill_name: Some("Research".to_string()),
            ..Default::default()
        },
    };

    artifact_repo
        .create_artifact(&task_id, &context_id, &artifact)
        .await
        .context("Failed to store artifact")?;

    Ok(artifact_id.to_string())
}

Loading Artifacts

Retrieve previously stored artifacts:

pub async fn load_artifact(
    artifact_repo: &ArtifactRepository,
    artifact_id: &str,
) -> Result<serde_json::Map<String, serde_json::Value>> {
    let artifact = artifact_repo
        .get_artifact_by_id(&ArtifactId::new(artifact_id))
        .await?
        .ok_or_else(|| anyhow::anyhow!(
            "Artifact not found: {}. Use artifact_id from previous tool call.",
            artifact_id
        ))?;

    // Extract data from artifact parts
    let data = artifact.parts.iter()
        .find_map(|p| match p {
            Part::Data(DataPart { data }) => Some(data.clone()),
            _ => None,
        })
        .ok_or_else(|| anyhow::anyhow!("Invalid artifact format"))?;

    Ok(data)
}

Error Handling

Convert between AI errors and MCP errors:

use rmcp::ErrorData as McpError;

// Helper function for error conversion
fn ai_error_to_mcp(e: anyhow::Error, context: &str) -> McpError {
    McpError::internal_error(
        format!("{}: {}", context, e),
        None
    )
}

// Usage in tool handler
pub async fn handle_my_tool(
    server: &MyAiServer,
    request: CallToolRequestParams,
) -> Result<CallToolResult, McpError> {
    let response = server.ai_service
        .generate_with_google_search(params)
        .await
        .map_err(|e| ai_error_to_mcp(e, "Research failed"))?;

    // ... rest of handler
}

Logging

Use tracing for logging (LogService is not exported via facade):

use tracing::{info, warn, error, debug};

// In tool handlers
info!(topic = %topic, "Starting research");
debug!(model = "gemini-2.5-flash", "Calling AI service");

if response.sources.is_empty() {
    warn!("No sources returned from search");
}

if let Err(e) = result {
    error!(error = %e, "Tool execution failed");
}

Loading Skills

Load skill content for prompts:

use systemprompt::models::execution::context::RequestContext;

pub async fn load_skill_content(
    skill_loader: &SkillService,
    skill_id: &str,
    ctx: &RequestContext,
) -> Result<String, McpError> {
    skill_loader
        .load_skill(skill_id, ctx)
        .await
        .map_err(|e| McpError::internal_error(
            format!("Failed to load skill '{}': {}", skill_id, e),
            None
        ))
}

RBAC Enforcement

Enforce authentication before tool execution:

use systemprompt::mcp::middleware::enforce_rbac_from_registry;

pub async fn call_tool(
    &self,
    request: CallToolRequestParams,
    ctx: RequestContext<RoleServer>,
) -> Result<CallToolResult, McpError> {
    // Enforce RBAC - takes 2 arguments
    let auth_result = enforce_rbac_from_registry(
        &ctx,
        self.service_id.as_str()
    ).await?;

    // Get authenticated context
    let authenticated_ctx = auth_result
        .expect_authenticated("This MCP server requires authentication")?;

    let request_context = authenticated_ctx.context.clone();

    // ... execute tool with request_context
}

Complete Tool Handler Example

pub async fn handle_research_blog(
    server: &MyAiServer,
    request: CallToolRequestParams,
    ctx: RequestContext,
) -> Result<CallToolResult, McpError> {
    let args = request.arguments
        .ok_or_else(|| McpError::invalid_params("Missing arguments", None))?;

    let topic = args.get("topic")
        .and_then(|v| v.as_str())
        .ok_or_else(|| McpError::invalid_params("Missing: topic", None))?;

    let skill_id = args.get("skill_id")
        .and_then(|v| v.as_str())
        .ok_or_else(|| McpError::invalid_params("Missing: skill_id", None))?;

    // Load skill content
    let skill_content = server.skill_loader
        .load_skill(skill_id, &ctx)
        .await
        .map_err(|e| McpError::internal_error(
            format!("Failed to load skill: {}", e), None
        ))?;

    // Call Gemini with search
    let params = GoogleSearchParams {
        messages: vec![
            AiMessage::system(&skill_content),
            AiMessage::user(&format!("Research: {}", topic)),
        ],
        sampling: None,
        max_output_tokens: 8192,
        model: Some("gemini-2.5-flash"),
        urls: None,
        response_schema: None,
    };

    let response = server.ai_service
        .generate_with_google_search(params)
        .await
        .map_err(|e| McpError::internal_error(
            format!("Research failed: {}", e), None
        ))?;

    // Store artifact
    let artifact_id = store_research_artifact(
        &server.artifact_repo,
        topic,
        &response,
        ctx.context_id().as_str(),
        skill_id,
    ).await.map_err(|e| McpError::internal_error(
        format!("Failed to store artifact: {}", e), None
    ))?;

    // Return result
    Ok(CallToolResult {
        content: vec![McpContent::text(format!(
            "Research complete for '{}'. Found {} sources.\n\n**Artifact ID: {}**",
            topic,
            response.sources.len(),
            artifact_id
        ))],
        structured_content: Some(serde_json::json!({
            "artifact_id": artifact_id,
            "topic": topic,
            "source_count": response.sources.len(),
        })),
        is_error: Some(false),
        meta: None,
    })
}

AI Configuration

Ensure services/ai/config.yaml has Gemini configured:

ai:
  default_provider: gemini
  default_max_output_tokens: 8192

  providers:
    gemini:
      enabled: true
      api_key: ${GEMINI_API_KEY}
      endpoint: https://generativelanguage.googleapis.com/v1beta
      default_model: gemini-2.5-flash

Environment Variables

Variable Description Required
GEMINI_API_KEY Google AI API key Yes
DATABASE_URL PostgreSQL connection Yes
MCP_PORT Server port No
MCP_SERVICE_ID Service identifier No
SYSTEM_PATH SystemPrompt root Yes

Troubleshooting

"No provider with Google Search support"

Ensure Gemini is enabled in services/ai/config.yaml and GEMINI_API_KEY is set.

"Missing tool_provider"

AiService requires a ToolProvider even for non-tool operations. Use NoopToolProvider::new().

Empty Search Results

Check response.finish_reason for "SAFETY" or "RECITATION" which indicate blocked content.

Artifact Not Found

Artifacts require both task_id and context_id. Ensure both are provided when creating.