harn-serve 0.8.84

Shared outbound workflow server core for Harn adapters
Documentation
use std::fs;
use std::path::{Path, PathBuf};

use serde_json::{json, Value as JsonValue};

use crate::mcp_prompts::FilePromptCatalog;

#[derive(Clone, Debug, Default)]
pub(crate) struct McpContextCatalog {
    resources: Vec<McpContextResource>,
    prompt_catalog: FilePromptCatalog,
}

#[derive(Clone, Debug)]
struct McpContextResource {
    uri: String,
    name: String,
    title: Option<String>,
    description: Option<String>,
    mime_type: String,
    text: String,
}

impl McpContextCatalog {
    pub(crate) fn discover(script_path: &Path) -> Self {
        let project_root = project_root_for_script(script_path);
        let manifest_path = project_root.join("harn.toml");
        let manifest_source = fs::read_to_string(&manifest_path).unwrap_or_default();
        let prompt_catalog = FilePromptCatalog::discover(&project_root, &manifest_source);

        let mut resources = Vec::new();
        if let Ok(source) = fs::read_to_string(script_path) {
            resources.push(McpContextResource {
                uri: "harn://package/source".to_string(),
                name: "Harn Source".to_string(),
                title: Some("Harn Source".to_string()),
                description: Some(format!(
                    "Source for {}",
                    display_relative_path(&project_root, script_path)
                )),
                mime_type: "text/x-harn".to_string(),
                text: source,
            });
        }

        if !manifest_source.is_empty() {
            resources.push(McpContextResource {
                uri: "harn://package/manifest".to_string(),
                name: "Package Manifest".to_string(),
                title: Some("Package Manifest".to_string()),
                description: Some("Nearest harn.toml manifest for this MCP server".to_string()),
                mime_type: "application/toml".to_string(),
                text: manifest_source,
            });
        }

        let readme_path = project_root.join("README.md");
        if let Ok(readme) = fs::read_to_string(&readme_path) {
            resources.push(McpContextResource {
                uri: "harn://package/readme".to_string(),
                name: "Package README".to_string(),
                title: Some("Package README".to_string()),
                description: Some("README.md from this Harn package".to_string()),
                mime_type: "text/markdown".to_string(),
                text: readme,
            });
        }

        for prompt in prompt_catalog.sources() {
            resources.push(McpContextResource {
                uri: format!("harn://prompt/{}/source", prompt.name),
                name: format!("Prompt {}", prompt.name),
                title: prompt.title,
                description: prompt
                    .description
                    .or_else(|| Some(format!("Source for prompt '{}'", prompt.name))),
                mime_type: "text/x-harn-prompt".to_string(),
                text: prompt.body,
            });
        }

        Self {
            resources,
            prompt_catalog,
        }
    }

    pub(crate) fn has_resources(&self) -> bool {
        !self.resources.is_empty()
    }

    pub(crate) fn has_prompts(&self) -> bool {
        !self.prompt_catalog.is_empty()
    }

    pub(crate) fn resource_entries(&self) -> Vec<JsonValue> {
        self.resources
            .iter()
            .map(McpContextResource::to_resource_entry)
            .collect()
    }

    pub(crate) fn read_resource(&self, uri: &str) -> Option<(String, String)> {
        self.resources
            .iter()
            .find(|resource| resource.uri == uri)
            .map(|resource| (resource.text.clone(), resource.mime_type.clone()))
    }

    pub(crate) fn resource_templates(&self) -> Vec<JsonValue> {
        let mut templates = Vec::new();
        if self
            .resources
            .iter()
            .any(|resource| resource.uri.starts_with("harn://package/"))
        {
            templates.push(json!({
                "uriTemplate": "harn://package/{artifact}",
                "name": "package-artifact",
                "title": "Package Artifact",
                "description": "Read package artifacts exposed by this Harn MCP server.",
                "mimeType": "application/octet-stream",
            }));
        }
        if !self.prompt_catalog.is_empty() {
            templates.push(json!({
                "uriTemplate": "harn://prompt/{name}/source",
                "name": "prompt-source",
                "title": "Prompt Source",
                "description": "Read the source for a file-backed Harn prompt.",
                "mimeType": "text/x-harn-prompt",
            }));
        }
        templates
    }

    pub(crate) fn prompt_entries(&self) -> Vec<JsonValue> {
        self.prompt_catalog.list()
    }

    pub(crate) fn get_prompt(
        &self,
        name: &str,
        arguments: &JsonValue,
    ) -> Result<JsonValue, String> {
        self.prompt_catalog.get(name, arguments)
    }

    pub(crate) fn complete_prompt(
        &self,
        name: &str,
        argument_name: &str,
        value: &str,
    ) -> Result<JsonValue, String> {
        self.prompt_catalog.complete(name, argument_name, value)
    }

    pub(crate) fn complete_resource_template(
        &self,
        uri_template: &str,
        argument_name: &str,
        value: &str,
    ) -> Result<JsonValue, String> {
        let candidates = match (uri_template, argument_name) {
            ("harn://package/{artifact}", "artifact") => self.package_artifacts(),
            ("harn://prompt/{name}/source", "name") => self.prompt_catalog.names(),
            ("harn://package/{artifact}", other) => {
                return Err(format!("Unknown package resource argument: {other}"));
            }
            ("harn://prompt/{name}/source", other) => {
                return Err(format!("Unknown prompt source argument: {other}"));
            }
            (other, _) => return Err(format!("Unknown resource template: {other}")),
        };
        Ok(harn_vm::mcp_protocol::completion_payload(candidates, value))
    }

    fn package_artifacts(&self) -> Vec<String> {
        self.resources
            .iter()
            .filter_map(|resource| resource.uri.strip_prefix("harn://package/"))
            .map(str::to_string)
            .collect()
    }
}

impl McpContextResource {
    fn to_resource_entry(&self) -> JsonValue {
        let mut entry = json!({
            "uri": self.uri,
            "name": self.name,
            "mimeType": self.mime_type,
        });
        if let Some(title) = &self.title {
            entry["title"] = json!(title);
        }
        if let Some(description) = &self.description {
            entry["description"] = json!(description);
        }
        entry
    }
}

fn project_root_for_script(script_path: &Path) -> PathBuf {
    let mut cursor = script_path.parent().unwrap_or_else(|| Path::new("."));
    loop {
        if cursor.join("harn.toml").is_file() {
            return cursor.to_path_buf();
        }
        let Some(parent) = cursor.parent() else {
            return script_path
                .parent()
                .unwrap_or_else(|| Path::new("."))
                .to_path_buf();
        };
        cursor = parent;
    }
}

fn display_relative_path(root: &Path, path: &Path) -> String {
    path.strip_prefix(root)
        .unwrap_or(path)
        .to_string_lossy()
        .replace('\\', "/")
}