mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 07:20:19 +00:00
refactor(registry): move MCP servers from code to JSON manifests (#1144)
* refactor(registry): move MCP server entries from code to JSON manifests Move 8 hardcoded MCP server RegistryEntry structs from builtin_entries() into data-driven JSON files under registry/mcp-servers/, matching the existing pattern used by tools and channels. Exclude the GitHub MCP entry which conflicts with the WASM GitHub tool's OAuth flow. Extend ManifestKind with McpServer, make version/source optional on ExtensionManifest (MCP servers don't need them), and add url/auth fields for MCP-specific config. Update build.rs, embedded catalog, catalog loader, installer, and CLI display to handle the new kind and optional fields. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix(registry): address PR review — add missing slack-mcp, remove .expect(), fix fmt - Add missing slack-mcp.json (was dropped during migration) - Remove production .expect() in get_strict(), replace with .ok_or_else() - Clean up unwrap_or_default() in key_for() to use .next() directly - Log warning for MCP manifests missing url field instead of silent empty - Run cargo fmt to fix formatting diffs Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * ci: re-trigger CI with correct base branch (staging) Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix(ci): improve no-panics check to properly exclude test modules The grep-based filter only excluded lines literally containing #[cfg(test)], #[test], or 'mod tests' — not lines *inside* test modules. Use awk to track hunk context from diff @@ headers and skip all added lines within test module hunks. [skip-regression-check] Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * refactor(registry): remove slack-mcp MCP entry (conflicts with WASM slack tool) Remove slack-mcp.json alongside the already-excluded github MCP entry — both conflict with existing WASM tools of the same name. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix(registry): address re-review — skip invalid MCP entries, fix install order - to_registry_entry() now returns Option<RegistryEntry>; MCP manifests missing a url field are skipped with a warning instead of creating broken entries with empty URLs - Move McpServer early-return before require_source() in install paths so the error message is clear ("cannot install MCP servers") rather than the misleading "missing source spec" - Add test for MCP manifest with missing URL returning None Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> --------- Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
757d24bd90
commit
c916069dd2
@@ -89,19 +89,34 @@ jobs:
|
|||||||
- name: Check for .unwrap(), .expect(), assert!() in production code
|
- name: Check for .unwrap(), .expect(), assert!() in production code
|
||||||
run: |
|
run: |
|
||||||
BASE="${{ github.event.pull_request.base.sha }}"
|
BASE="${{ github.event.pull_request.base.sha }}"
|
||||||
# Get added lines in .rs files (production only, exclude tests/)
|
# Get the full diff for .rs files (production only, exclude tests/ directory)
|
||||||
ADDED=$(git diff "$BASE"...HEAD -- 'src/**/*.rs' 'crates/**/*.rs' \
|
DIFF=$(git diff "$BASE"...HEAD -- 'src/**/*.rs' 'crates/**/*.rs' || true)
|
||||||
| grep -E '^\+[^+]' || true)
|
|
||||||
|
|
||||||
if [ -z "$ADDED" ]; then
|
if [ -z "$DIFF" ]; then
|
||||||
echo "No production Rust changes detected."
|
echo "No production Rust changes detected."
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Match panic-inducing patterns, excluding test code and safety suppressions
|
# Extract added lines, skipping those inside test modules.
|
||||||
|
# Track whether we're inside a test module by watching hunk headers
|
||||||
|
# (lines starting with @@) whose context contains "mod tests" or "#[cfg(test)]".
|
||||||
|
ADDED=$(echo "$DIFF" | awk '
|
||||||
|
/^@@/ {
|
||||||
|
# Hunk context (after the second @@) tells us the function/module scope
|
||||||
|
in_test = (tolower($0) ~ /mod tests/ || $0 ~ /#\[cfg\(test\)\]/ || $0 ~ /#\[test\]/)
|
||||||
|
}
|
||||||
|
/^\+[^+]/ && !in_test { print }
|
||||||
|
' || true)
|
||||||
|
|
||||||
|
if [ -z "$ADDED" ]; then
|
||||||
|
echo "No production Rust changes detected (test-only changes excluded)."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Match panic-inducing patterns, excluding safety suppressions
|
||||||
VIOLATIONS=$(echo "$ADDED" \
|
VIOLATIONS=$(echo "$ADDED" \
|
||||||
| grep -E '\.(unwrap|expect)\(|[^_]assert(_eq|_ne)?!' \
|
| grep -E '\.(unwrap|expect)\(|[^_]assert(_eq|_ne)?!' \
|
||||||
| grep -Ev 'debug_assert|// safety:|#\[cfg\(test\)\]|#\[test\]|mod tests' \
|
| grep -Ev 'debug_assert|// safety:' \
|
||||||
|| true)
|
|| true)
|
||||||
|
|
||||||
if [ -n "$VIOLATIONS" ]; then
|
if [ -n "$VIOLATIONS" ]; then
|
||||||
|
|||||||
@@ -132,7 +132,7 @@ fn embed_registry_catalog(root: &Path) {
|
|||||||
// No registry dir: write empty catalog
|
// No registry dir: write empty catalog
|
||||||
fs::write(
|
fs::write(
|
||||||
&out_path,
|
&out_path,
|
||||||
r#"{"tools":[],"channels":[],"bundles":{"bundles":{}}}"#,
|
r#"{"tools":[],"channels":[],"mcp_servers":[],"bundles":{"bundles":{}}}"#,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
return;
|
return;
|
||||||
@@ -140,6 +140,7 @@ fn embed_registry_catalog(root: &Path) {
|
|||||||
|
|
||||||
let mut tools = Vec::new();
|
let mut tools = Vec::new();
|
||||||
let mut channels = Vec::new();
|
let mut channels = Vec::new();
|
||||||
|
let mut mcp_servers = Vec::new();
|
||||||
|
|
||||||
// Collect tool manifests
|
// Collect tool manifests
|
||||||
let tools_dir = registry_dir.join("tools");
|
let tools_dir = registry_dir.join("tools");
|
||||||
@@ -153,6 +154,12 @@ fn embed_registry_catalog(root: &Path) {
|
|||||||
collect_json_files(&channels_dir, &mut channels);
|
collect_json_files(&channels_dir, &mut channels);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Collect MCP server manifests
|
||||||
|
let mcp_servers_dir = registry_dir.join("mcp-servers");
|
||||||
|
if mcp_servers_dir.is_dir() {
|
||||||
|
collect_json_files(&mcp_servers_dir, &mut mcp_servers);
|
||||||
|
}
|
||||||
|
|
||||||
// Read bundles
|
// Read bundles
|
||||||
let bundles_path = registry_dir.join("_bundles.json");
|
let bundles_path = registry_dir.join("_bundles.json");
|
||||||
let bundles_raw = if bundles_path.is_file() {
|
let bundles_raw = if bundles_path.is_file() {
|
||||||
@@ -163,9 +170,10 @@ fn embed_registry_catalog(root: &Path) {
|
|||||||
|
|
||||||
// Build the combined JSON
|
// Build the combined JSON
|
||||||
let catalog = format!(
|
let catalog = format!(
|
||||||
r#"{{"tools":[{}],"channels":[{}],"bundles":{}}}"#,
|
r#"{{"tools":[{}],"channels":[{}],"mcp_servers":[{}],"bundles":{}}}"#,
|
||||||
tools.join(","),
|
tools.join(","),
|
||||||
channels.join(","),
|
channels.join(","),
|
||||||
|
mcp_servers.join(","),
|
||||||
bundles_raw,
|
bundles_raw,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"name": "asana",
|
||||||
|
"display_name": "Asana",
|
||||||
|
"kind": "mcp_server",
|
||||||
|
"description": "Connect to Asana for task management, projects, and team coordination",
|
||||||
|
"keywords": ["tasks", "projects", "management", "team"],
|
||||||
|
"url": "https://mcp.asana.com/v2/mcp",
|
||||||
|
"auth": "dcr"
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"name": "cloudflare",
|
||||||
|
"display_name": "Cloudflare",
|
||||||
|
"kind": "mcp_server",
|
||||||
|
"description": "Connect to Cloudflare for DNS, Workers, KV, and infrastructure management",
|
||||||
|
"keywords": ["cdn", "dns", "workers", "hosting", "infrastructure"],
|
||||||
|
"url": "https://mcp.cloudflare.com/mcp",
|
||||||
|
"auth": "dcr"
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"name": "intercom",
|
||||||
|
"display_name": "Intercom",
|
||||||
|
"kind": "mcp_server",
|
||||||
|
"description": "Connect to Intercom for customer messaging, support, and engagement",
|
||||||
|
"keywords": ["support", "customers", "messaging", "chat", "helpdesk"],
|
||||||
|
"url": "https://mcp.intercom.com/mcp",
|
||||||
|
"auth": "dcr"
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"name": "linear",
|
||||||
|
"display_name": "Linear",
|
||||||
|
"kind": "mcp_server",
|
||||||
|
"description": "Connect to Linear for issue tracking, project management, and team workflows",
|
||||||
|
"keywords": ["issues", "tickets", "project", "tracking", "bugs"],
|
||||||
|
"url": "https://mcp.linear.app/sse",
|
||||||
|
"auth": "dcr"
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"name": "notion",
|
||||||
|
"display_name": "Notion",
|
||||||
|
"kind": "mcp_server",
|
||||||
|
"description": "Connect to Notion for reading and writing pages, databases, and comments",
|
||||||
|
"keywords": ["notes", "wiki", "docs", "pages", "database"],
|
||||||
|
"url": "https://mcp.notion.com/mcp",
|
||||||
|
"auth": "dcr"
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"name": "sentry",
|
||||||
|
"display_name": "Sentry",
|
||||||
|
"kind": "mcp_server",
|
||||||
|
"description": "Connect to Sentry for error tracking, performance monitoring, and debugging",
|
||||||
|
"keywords": ["errors", "monitoring", "debugging", "crashes", "performance"],
|
||||||
|
"url": "https://mcp.sentry.dev/mcp",
|
||||||
|
"auth": "dcr"
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"name": "stripe",
|
||||||
|
"display_name": "Stripe",
|
||||||
|
"kind": "mcp_server",
|
||||||
|
"description": "Connect to Stripe for payment processing, subscriptions, and financial data",
|
||||||
|
"keywords": ["payments", "billing", "subscriptions", "invoices", "finance"],
|
||||||
|
"url": "https://mcp.stripe.com",
|
||||||
|
"auth": "dcr"
|
||||||
|
}
|
||||||
+1
-1
@@ -594,7 +594,7 @@ impl AppBuilder {
|
|||||||
let entries: Vec<_> = catalog
|
let entries: Vec<_> = catalog
|
||||||
.all()
|
.all()
|
||||||
.iter()
|
.iter()
|
||||||
.map(|m| m.to_registry_entry())
|
.filter_map(|m| m.to_registry_entry())
|
||||||
.collect();
|
.collect();
|
||||||
tracing::debug!(
|
tracing::debug!(
|
||||||
count = entries.len(),
|
count = entries.len(),
|
||||||
|
|||||||
+18
-6
@@ -127,7 +127,11 @@ fn cmd_list(
|
|||||||
.unwrap_or("none");
|
.unwrap_or("none");
|
||||||
println!(
|
println!(
|
||||||
"{:<20} {:<8} {:<8} {:<10} {}",
|
"{:<20} {:<8} {:<8} {:<10} {}",
|
||||||
m.name, m.kind, m.version, auth, m.description
|
m.name,
|
||||||
|
m.kind,
|
||||||
|
m.version.as_deref().unwrap_or("-"),
|
||||||
|
auth,
|
||||||
|
m.description
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
println!("{:<20} {:<8} {}", m.name, m.kind, m.description);
|
println!("{:<20} {:<8} {}", m.name, m.kind, m.description);
|
||||||
@@ -173,17 +177,25 @@ fn cmd_info(catalog: &RegistryCatalog, name: &str) -> anyhow::Result<()> {
|
|||||||
.map_err(|e| anyhow::anyhow!("{}", e))?;
|
.map_err(|e| anyhow::anyhow!("{}", e))?;
|
||||||
|
|
||||||
println!("{} ({})", manifest.display_name, manifest.kind);
|
println!("{} ({})", manifest.display_name, manifest.kind);
|
||||||
println!(" Version: {}", manifest.version);
|
if let Some(ref version) = manifest.version {
|
||||||
|
println!(" Version: {}", version);
|
||||||
|
}
|
||||||
println!(" {}", manifest.description);
|
println!(" {}", manifest.description);
|
||||||
|
|
||||||
if !manifest.keywords.is_empty() {
|
if !manifest.keywords.is_empty() {
|
||||||
println!(" Keywords: {}", manifest.keywords.join(", "));
|
println!(" Keywords: {}", manifest.keywords.join(", "));
|
||||||
}
|
}
|
||||||
|
|
||||||
println!("\nSource:");
|
if let Some(ref source) = manifest.source {
|
||||||
println!(" Directory: {}", manifest.source.dir);
|
println!("\nSource:");
|
||||||
println!(" Crate: {}", manifest.source.crate_name);
|
println!(" Directory: {}", source.dir);
|
||||||
println!(" Capabilities: {}", manifest.source.capabilities);
|
println!(" Crate: {}", source.crate_name);
|
||||||
|
println!(" Capabilities: {}", source.capabilities);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(ref url) = manifest.url {
|
||||||
|
println!("\nMCP Server URL: {}", url);
|
||||||
|
}
|
||||||
|
|
||||||
if let Some(artifact) = manifest.artifacts.get("wasm32-wasip2") {
|
if let Some(artifact) = manifest.artifacts.get("wasm32-wasip2") {
|
||||||
println!("\nArtifact (wasm32-wasip2):");
|
println!("\nArtifact (wasm32-wasip2):");
|
||||||
|
|||||||
+79
-226
@@ -232,198 +232,11 @@ pub fn builtin_entries() -> Vec<RegistryEntry> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Well-known extensions, with an optional relay URL for the channel-relay entry.
|
/// Well-known extensions, with an optional relay URL for the channel-relay entry.
|
||||||
|
///
|
||||||
|
/// MCP server entries are loaded from `registry/mcp-servers/*.json` via the catalog
|
||||||
|
/// system. Only runtime-dependent entries (like channel-relay) remain here.
|
||||||
pub fn builtin_entries_with_relay(relay_url: Option<String>) -> Vec<RegistryEntry> {
|
pub fn builtin_entries_with_relay(relay_url: Option<String>) -> Vec<RegistryEntry> {
|
||||||
let mut entries = vec![
|
let mut entries = vec![];
|
||||||
// -- MCP Servers --
|
|
||||||
RegistryEntry {
|
|
||||||
name: "notion".to_string(),
|
|
||||||
display_name: "Notion".to_string(),
|
|
||||||
kind: ExtensionKind::McpServer,
|
|
||||||
description: "Connect to Notion for reading and writing pages, databases, and comments"
|
|
||||||
.to_string(),
|
|
||||||
keywords: vec![
|
|
||||||
"notes".into(),
|
|
||||||
"wiki".into(),
|
|
||||||
"docs".into(),
|
|
||||||
"pages".into(),
|
|
||||||
"database".into(),
|
|
||||||
],
|
|
||||||
source: ExtensionSource::McpUrl {
|
|
||||||
url: "https://mcp.notion.com/mcp".to_string(),
|
|
||||||
},
|
|
||||||
fallback_source: None,
|
|
||||||
auth_hint: AuthHint::Dcr,
|
|
||||||
version: None,
|
|
||||||
},
|
|
||||||
RegistryEntry {
|
|
||||||
name: "linear".to_string(),
|
|
||||||
display_name: "Linear".to_string(),
|
|
||||||
kind: ExtensionKind::McpServer,
|
|
||||||
description:
|
|
||||||
"Connect to Linear for issue tracking, project management, and team workflows"
|
|
||||||
.to_string(),
|
|
||||||
keywords: vec![
|
|
||||||
"issues".into(),
|
|
||||||
"tickets".into(),
|
|
||||||
"project".into(),
|
|
||||||
"tracking".into(),
|
|
||||||
"bugs".into(),
|
|
||||||
],
|
|
||||||
source: ExtensionSource::McpUrl {
|
|
||||||
url: "https://mcp.linear.app/sse".to_string(),
|
|
||||||
},
|
|
||||||
fallback_source: None,
|
|
||||||
auth_hint: AuthHint::Dcr,
|
|
||||||
version: None,
|
|
||||||
},
|
|
||||||
RegistryEntry {
|
|
||||||
name: "github".to_string(),
|
|
||||||
display_name: "GitHub".to_string(),
|
|
||||||
kind: ExtensionKind::McpServer,
|
|
||||||
description:
|
|
||||||
"Connect to GitHub for repository management, issues, PRs, and code search"
|
|
||||||
.to_string(),
|
|
||||||
keywords: vec![
|
|
||||||
"git".into(),
|
|
||||||
"repos".into(),
|
|
||||||
"code".into(),
|
|
||||||
"pull-request".into(),
|
|
||||||
"issues".into(),
|
|
||||||
],
|
|
||||||
source: ExtensionSource::McpUrl {
|
|
||||||
url: "https://api.githubcopilot.com/mcp/".to_string(),
|
|
||||||
},
|
|
||||||
fallback_source: None,
|
|
||||||
auth_hint: AuthHint::Dcr,
|
|
||||||
version: None,
|
|
||||||
},
|
|
||||||
RegistryEntry {
|
|
||||||
name: "slack-mcp".to_string(),
|
|
||||||
display_name: "Slack MCP".to_string(),
|
|
||||||
kind: ExtensionKind::McpServer,
|
|
||||||
description:
|
|
||||||
"Connect to Slack via MCP for messaging, channel management, and team communication"
|
|
||||||
.to_string(),
|
|
||||||
keywords: vec![
|
|
||||||
"messaging".into(),
|
|
||||||
"chat".into(),
|
|
||||||
"channels".into(),
|
|
||||||
"team".into(),
|
|
||||||
"communication".into(),
|
|
||||||
],
|
|
||||||
source: ExtensionSource::McpUrl {
|
|
||||||
url: "https://mcp.slack.com".to_string(),
|
|
||||||
},
|
|
||||||
fallback_source: None,
|
|
||||||
auth_hint: AuthHint::Dcr,
|
|
||||||
version: None,
|
|
||||||
},
|
|
||||||
RegistryEntry {
|
|
||||||
name: "sentry".to_string(),
|
|
||||||
display_name: "Sentry".to_string(),
|
|
||||||
kind: ExtensionKind::McpServer,
|
|
||||||
description:
|
|
||||||
"Connect to Sentry for error tracking, performance monitoring, and debugging"
|
|
||||||
.to_string(),
|
|
||||||
keywords: vec![
|
|
||||||
"errors".into(),
|
|
||||||
"monitoring".into(),
|
|
||||||
"debugging".into(),
|
|
||||||
"crashes".into(),
|
|
||||||
"performance".into(),
|
|
||||||
],
|
|
||||||
source: ExtensionSource::McpUrl {
|
|
||||||
url: "https://mcp.sentry.dev/mcp".to_string(),
|
|
||||||
},
|
|
||||||
fallback_source: None,
|
|
||||||
auth_hint: AuthHint::Dcr,
|
|
||||||
version: None,
|
|
||||||
},
|
|
||||||
RegistryEntry {
|
|
||||||
name: "stripe".to_string(),
|
|
||||||
display_name: "Stripe".to_string(),
|
|
||||||
kind: ExtensionKind::McpServer,
|
|
||||||
description:
|
|
||||||
"Connect to Stripe for payment processing, subscriptions, and financial data"
|
|
||||||
.to_string(),
|
|
||||||
keywords: vec![
|
|
||||||
"payments".into(),
|
|
||||||
"billing".into(),
|
|
||||||
"subscriptions".into(),
|
|
||||||
"invoices".into(),
|
|
||||||
"finance".into(),
|
|
||||||
],
|
|
||||||
source: ExtensionSource::McpUrl {
|
|
||||||
url: "https://mcp.stripe.com".to_string(),
|
|
||||||
},
|
|
||||||
fallback_source: None,
|
|
||||||
auth_hint: AuthHint::Dcr,
|
|
||||||
version: None,
|
|
||||||
},
|
|
||||||
RegistryEntry {
|
|
||||||
name: "cloudflare".to_string(),
|
|
||||||
display_name: "Cloudflare".to_string(),
|
|
||||||
kind: ExtensionKind::McpServer,
|
|
||||||
description:
|
|
||||||
"Connect to Cloudflare for DNS, Workers, KV, and infrastructure management"
|
|
||||||
.to_string(),
|
|
||||||
keywords: vec![
|
|
||||||
"cdn".into(),
|
|
||||||
"dns".into(),
|
|
||||||
"workers".into(),
|
|
||||||
"hosting".into(),
|
|
||||||
"infrastructure".into(),
|
|
||||||
],
|
|
||||||
source: ExtensionSource::McpUrl {
|
|
||||||
url: "https://mcp.cloudflare.com/mcp".to_string(),
|
|
||||||
},
|
|
||||||
fallback_source: None,
|
|
||||||
auth_hint: AuthHint::Dcr,
|
|
||||||
version: None,
|
|
||||||
},
|
|
||||||
RegistryEntry {
|
|
||||||
name: "asana".to_string(),
|
|
||||||
display_name: "Asana".to_string(),
|
|
||||||
kind: ExtensionKind::McpServer,
|
|
||||||
description: "Connect to Asana for task management, projects, and team coordination"
|
|
||||||
.to_string(),
|
|
||||||
keywords: vec![
|
|
||||||
"tasks".into(),
|
|
||||||
"projects".into(),
|
|
||||||
"management".into(),
|
|
||||||
"team".into(),
|
|
||||||
],
|
|
||||||
source: ExtensionSource::McpUrl {
|
|
||||||
url: "https://mcp.asana.com/v2/mcp".to_string(),
|
|
||||||
},
|
|
||||||
fallback_source: None,
|
|
||||||
auth_hint: AuthHint::Dcr,
|
|
||||||
version: None,
|
|
||||||
},
|
|
||||||
RegistryEntry {
|
|
||||||
name: "intercom".to_string(),
|
|
||||||
display_name: "Intercom".to_string(),
|
|
||||||
kind: ExtensionKind::McpServer,
|
|
||||||
description: "Connect to Intercom for customer messaging, support, and engagement"
|
|
||||||
.to_string(),
|
|
||||||
keywords: vec![
|
|
||||||
"support".into(),
|
|
||||||
"customers".into(),
|
|
||||||
"messaging".into(),
|
|
||||||
"chat".into(),
|
|
||||||
"helpdesk".into(),
|
|
||||||
],
|
|
||||||
source: ExtensionSource::McpUrl {
|
|
||||||
url: "https://mcp.intercom.com/mcp".to_string(),
|
|
||||||
},
|
|
||||||
fallback_source: None,
|
|
||||||
auth_hint: AuthHint::Dcr,
|
|
||||||
version: None,
|
|
||||||
},
|
|
||||||
// WASM channels (telegram, slack, discord, whatsapp) come from the embedded
|
|
||||||
// registry catalog (registry/channels/*.json) with WasmDownload URLs pointing
|
|
||||||
// to GitHub release artifacts. See new_with_catalog() for merging.
|
|
||||||
];
|
|
||||||
|
|
||||||
// Conditionally add channel-relay entries when relay URL is configured
|
// Conditionally add channel-relay entries when relay URL is configured
|
||||||
if let Some(relay_url) = relay_url {
|
if let Some(relay_url) = relay_url {
|
||||||
@@ -545,9 +358,21 @@ mod tests {
|
|||||||
assert_eq!(score, 0, "No match should score 0");
|
assert_eq!(score, 0, "No match should score 0");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Helper to create a registry with catalog entries (MCP servers come from catalog now).
|
||||||
|
fn registry_with_catalog() -> ExtensionRegistry {
|
||||||
|
let catalog = crate::registry::catalog::RegistryCatalog::load_or_embedded()
|
||||||
|
.expect("catalog should load");
|
||||||
|
let catalog_entries: Vec<RegistryEntry> = catalog
|
||||||
|
.all()
|
||||||
|
.iter()
|
||||||
|
.filter_map(|m| m.to_registry_entry())
|
||||||
|
.collect();
|
||||||
|
ExtensionRegistry::new_with_catalog(catalog_entries)
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_search_returns_sorted() {
|
async fn test_search_returns_sorted() {
|
||||||
let registry = ExtensionRegistry::new();
|
let registry = registry_with_catalog();
|
||||||
let results = registry.search("notion").await;
|
let results = registry.search("notion").await;
|
||||||
|
|
||||||
assert!(!results.is_empty(), "Should find notion in registry");
|
assert!(!results.is_empty(), "Should find notion in registry");
|
||||||
@@ -556,7 +381,7 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_search_empty_query_returns_all() {
|
async fn test_search_empty_query_returns_all() {
|
||||||
let registry = ExtensionRegistry::new();
|
let registry = registry_with_catalog();
|
||||||
let results = registry.search("").await;
|
let results = registry.search("").await;
|
||||||
|
|
||||||
assert!(results.len() > 5, "Empty query should return all entries");
|
assert!(results.len() > 5, "Empty query should return all entries");
|
||||||
@@ -564,7 +389,7 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_search_by_keyword() {
|
async fn test_search_by_keyword() {
|
||||||
let registry = ExtensionRegistry::new();
|
let registry = registry_with_catalog();
|
||||||
let results = registry.search("issues tickets").await;
|
let results = registry.search("issues tickets").await;
|
||||||
|
|
||||||
assert!(
|
assert!(
|
||||||
@@ -578,7 +403,7 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_get_exact_name() {
|
async fn test_get_exact_name() {
|
||||||
let registry = ExtensionRegistry::new();
|
let registry = registry_with_catalog();
|
||||||
|
|
||||||
let entry = registry.get("notion").await;
|
let entry = registry.get("notion").await;
|
||||||
assert!(entry.is_some());
|
assert!(entry.is_some());
|
||||||
@@ -658,17 +483,30 @@ mod tests {
|
|||||||
auth_hint: AuthHint::CapabilitiesAuth,
|
auth_hint: AuthHint::CapabilitiesAuth,
|
||||||
version: None,
|
version: None,
|
||||||
},
|
},
|
||||||
// This shares a name with the builtin slack-mcp but has a different kind, so both should appear
|
// Two entries with same name but different kinds should coexist
|
||||||
RegistryEntry {
|
RegistryEntry {
|
||||||
name: "slack-mcp".to_string(),
|
name: "dual-ext".to_string(),
|
||||||
display_name: "Slack MCP WASM".to_string(),
|
display_name: "Dual MCP".to_string(),
|
||||||
|
kind: ExtensionKind::McpServer,
|
||||||
|
description: "Dual extension MCP server".to_string(),
|
||||||
|
keywords: vec!["messaging".into()],
|
||||||
|
source: ExtensionSource::McpUrl {
|
||||||
|
url: "https://mcp.example.com".to_string(),
|
||||||
|
},
|
||||||
|
fallback_source: None,
|
||||||
|
auth_hint: AuthHint::Dcr,
|
||||||
|
version: None,
|
||||||
|
},
|
||||||
|
RegistryEntry {
|
||||||
|
name: "dual-ext".to_string(),
|
||||||
|
display_name: "Dual WASM".to_string(),
|
||||||
kind: ExtensionKind::WasmTool,
|
kind: ExtensionKind::WasmTool,
|
||||||
description: "Slack WASM tool".to_string(),
|
description: "Dual extension WASM tool".to_string(),
|
||||||
keywords: vec!["messaging".into()],
|
keywords: vec!["messaging".into()],
|
||||||
source: ExtensionSource::WasmBuildable {
|
source: ExtensionSource::WasmBuildable {
|
||||||
source_dir: "tools-src/slack".to_string(),
|
source_dir: "tools-src/dual".to_string(),
|
||||||
build_dir: Some("tools-src/slack".to_string()),
|
build_dir: Some("tools-src/dual".to_string()),
|
||||||
crate_name: Some("slack-tool".to_string()),
|
crate_name: Some("dual-tool".to_string()),
|
||||||
},
|
},
|
||||||
fallback_source: None,
|
fallback_source: None,
|
||||||
auth_hint: AuthHint::CapabilitiesAuth,
|
auth_hint: AuthHint::CapabilitiesAuth,
|
||||||
@@ -683,41 +521,56 @@ mod tests {
|
|||||||
assert!(!results.is_empty(), "Should find telegram from catalog");
|
assert!(!results.is_empty(), "Should find telegram from catalog");
|
||||||
assert_eq!(results[0].entry.name, "telegram");
|
assert_eq!(results[0].entry.name, "telegram");
|
||||||
|
|
||||||
// Should have both builtin MCP slack-mcp and catalog WASM slack-mcp
|
// Should have both MCP and WASM entries with the same name
|
||||||
let results = registry.search("slack").await;
|
let results = registry.search("dual-ext").await;
|
||||||
let slack_mcp = results
|
let has_mcp = results
|
||||||
.iter()
|
.iter()
|
||||||
.any(|r| r.entry.name == "slack-mcp" && r.entry.kind == ExtensionKind::McpServer);
|
.any(|r| r.entry.name == "dual-ext" && r.entry.kind == ExtensionKind::McpServer);
|
||||||
let slack_wasm = results
|
let has_wasm = results
|
||||||
.iter()
|
.iter()
|
||||||
.any(|r| r.entry.name == "slack-mcp" && r.entry.kind == ExtensionKind::WasmTool);
|
.any(|r| r.entry.name == "dual-ext" && r.entry.kind == ExtensionKind::WasmTool);
|
||||||
assert!(slack_mcp, "Should have builtin MCP slack-mcp");
|
assert!(has_mcp, "Should have MCP dual-ext");
|
||||||
assert!(slack_wasm, "Should have catalog WASM slack-mcp");
|
assert!(has_wasm, "Should have WASM dual-ext");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_new_with_catalog_dedup_same_kind() {
|
async fn test_new_with_catalog_dedup_same_kind() {
|
||||||
// A catalog entry with same name AND kind as a builtin should be skipped
|
// When two catalog entries share name AND kind, only the first should be kept
|
||||||
let catalog_entries = vec![RegistryEntry {
|
let catalog_entries = vec![
|
||||||
name: "slack-mcp".to_string(),
|
RegistryEntry {
|
||||||
display_name: "Slack MCP Override".to_string(),
|
name: "test-ext".to_string(),
|
||||||
kind: ExtensionKind::McpServer, // same kind as builtin slack-mcp
|
display_name: "Test First".to_string(),
|
||||||
description: "Should be skipped".to_string(),
|
kind: ExtensionKind::McpServer,
|
||||||
keywords: vec![],
|
description: "First entry".to_string(),
|
||||||
source: ExtensionSource::McpUrl {
|
keywords: vec![],
|
||||||
url: "https://other.slack.com".to_string(),
|
source: ExtensionSource::McpUrl {
|
||||||
|
url: "https://first.example.com".to_string(),
|
||||||
|
},
|
||||||
|
fallback_source: None,
|
||||||
|
auth_hint: AuthHint::Dcr,
|
||||||
|
version: None,
|
||||||
},
|
},
|
||||||
fallback_source: None,
|
RegistryEntry {
|
||||||
auth_hint: AuthHint::Dcr,
|
name: "test-ext".to_string(),
|
||||||
version: None,
|
display_name: "Test Duplicate".to_string(),
|
||||||
}];
|
kind: ExtensionKind::McpServer, // same kind
|
||||||
|
description: "Should be skipped".to_string(),
|
||||||
|
keywords: vec![],
|
||||||
|
source: ExtensionSource::McpUrl {
|
||||||
|
url: "https://second.example.com".to_string(),
|
||||||
|
},
|
||||||
|
fallback_source: None,
|
||||||
|
auth_hint: AuthHint::Dcr,
|
||||||
|
version: None,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
let registry = ExtensionRegistry::new_with_catalog(catalog_entries);
|
let registry = ExtensionRegistry::new_with_catalog(catalog_entries);
|
||||||
|
|
||||||
let entry = registry.get("slack-mcp").await;
|
let entry = registry.get("test-ext").await;
|
||||||
assert!(entry.is_some());
|
assert!(entry.is_some());
|
||||||
// Should still be the builtin, not the override
|
// Should be the first entry, not the duplicate
|
||||||
assert_eq!(entry.unwrap().display_name, "Slack MCP");
|
assert_eq!(entry.unwrap().display_name, "Test First");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|||||||
+86
-31
@@ -192,6 +192,12 @@ impl RegistryCatalog {
|
|||||||
Self::load_manifests_from_dir(&channels_dir, "channels", &mut manifests)?;
|
Self::load_manifests_from_dir(&channels_dir, "channels", &mut manifests)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load MCP servers
|
||||||
|
let mcp_servers_dir = registry_dir.join("mcp-servers");
|
||||||
|
if mcp_servers_dir.is_dir() {
|
||||||
|
Self::load_manifests_from_dir(&mcp_servers_dir, "mcp-servers", &mut manifests)?;
|
||||||
|
}
|
||||||
|
|
||||||
// Load bundles
|
// Load bundles
|
||||||
let bundles_path = registry_dir.join("_bundles.json");
|
let bundles_path = registry_dir.join("_bundles.json");
|
||||||
let bundles = if bundles_path.is_file() {
|
let bundles = if bundles_path.is_file() {
|
||||||
@@ -280,8 +286,9 @@ impl RegistryCatalog {
|
|||||||
/// Get a manifest by name. Tries exact key match first ("tools/github"),
|
/// Get a manifest by name. Tries exact key match first ("tools/github"),
|
||||||
/// then searches by bare name ("github").
|
/// then searches by bare name ("github").
|
||||||
///
|
///
|
||||||
/// If a bare name matches both a tool and a channel, returns `None`.
|
/// If a bare name matches more than one prefix, returns `None`.
|
||||||
/// Use a qualified key ("tools/github" or "channels/telegram") to disambiguate.
|
/// Use a qualified key ("tools/github", "channels/telegram", or
|
||||||
|
/// "mcp-servers/notion") to disambiguate.
|
||||||
pub fn get(&self, name: &str) -> Option<&ExtensionManifest> {
|
pub fn get(&self, name: &str) -> Option<&ExtensionManifest> {
|
||||||
// Try exact key first
|
// Try exact key first
|
||||||
if let Some(m) = self.manifests.get(name) {
|
if let Some(m) = self.manifests.get(name) {
|
||||||
@@ -289,14 +296,15 @@ impl RegistryCatalog {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Try with kind prefix, detecting collisions
|
// Try with kind prefix, detecting collisions
|
||||||
let tool = self.manifests.get(&format!("tools/{}", name));
|
let candidates: Vec<_> = ["tools", "channels", "mcp-servers"]
|
||||||
let channel = self.manifests.get(&format!("channels/{}", name));
|
.iter()
|
||||||
|
.filter_map(|prefix| self.manifests.get(&format!("{}/{}", prefix, name)))
|
||||||
|
.collect();
|
||||||
|
|
||||||
match (tool, channel) {
|
if candidates.len() == 1 {
|
||||||
(Some(_), Some(_)) => None, // ambiguous
|
Some(candidates[0])
|
||||||
(Some(m), None) => Some(m),
|
} else {
|
||||||
(None, Some(m)) => Some(m),
|
None // ambiguous or not found
|
||||||
(None, None) => None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -308,37 +316,63 @@ impl RegistryCatalog {
|
|||||||
return Ok(m);
|
return Ok(m);
|
||||||
}
|
}
|
||||||
|
|
||||||
let has_tool = self.manifests.contains_key(&format!("tools/{}", name));
|
let prefixes: &[(&str, &str)] = &[
|
||||||
let has_channel = self.manifests.contains_key(&format!("channels/{}", name));
|
("tools", "tool"),
|
||||||
|
("channels", "channel"),
|
||||||
|
("mcp-servers", "mcp_server"),
|
||||||
|
];
|
||||||
|
|
||||||
match (has_tool, has_channel) {
|
let matches: Vec<_> = prefixes
|
||||||
(true, true) => Err(RegistryError::AmbiguousName {
|
.iter()
|
||||||
name: name.to_string(),
|
.filter(|(prefix, _)| self.manifests.contains_key(&format!("{}/{}", prefix, name)))
|
||||||
kind_a: "tool",
|
.collect();
|
||||||
prefix_a: "tools",
|
|
||||||
kind_b: "channel",
|
match matches.len() {
|
||||||
prefix_b: "channels",
|
0 => Err(RegistryError::ExtensionNotFound(name.to_string())),
|
||||||
}),
|
1 => {
|
||||||
(true, false) => Ok(self.manifests.get(&format!("tools/{}", name)).unwrap()),
|
let (prefix, _) = matches[0];
|
||||||
(false, true) => Ok(self.manifests.get(&format!("channels/{}", name)).unwrap()),
|
let key = format!("{}/{}", prefix, name);
|
||||||
(false, false) => Err(RegistryError::ExtensionNotFound(name.to_string())),
|
self.manifests
|
||||||
|
.get(&key)
|
||||||
|
.ok_or_else(|| RegistryError::ExtensionNotFound(name.to_string()))
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
let (prefix_a, kind_a) = matches[0];
|
||||||
|
let (prefix_b, kind_b) = matches[1];
|
||||||
|
Err(RegistryError::AmbiguousName {
|
||||||
|
name: name.to_string(),
|
||||||
|
kind_a,
|
||||||
|
prefix_a,
|
||||||
|
kind_b,
|
||||||
|
prefix_b,
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the full key ("tools/github" or "channels/telegram") for a manifest.
|
/// Get the full key ("tools/github", "channels/telegram", or
|
||||||
|
/// "mcp-servers/notion") for a manifest.
|
||||||
pub fn key_for(&self, name: &str) -> Option<String> {
|
pub fn key_for(&self, name: &str) -> Option<String> {
|
||||||
if self.manifests.contains_key(name) {
|
if self.manifests.contains_key(name) {
|
||||||
return Some(name.to_string());
|
return Some(name.to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
let has_tool = self.manifests.contains_key(&format!("tools/{}", name));
|
let matches: Vec<String> = ["tools", "channels", "mcp-servers"]
|
||||||
let has_channel = self.manifests.contains_key(&format!("channels/{}", name));
|
.iter()
|
||||||
|
.filter_map(|prefix| {
|
||||||
|
let key = format!("{}/{}", prefix, name);
|
||||||
|
if self.manifests.contains_key(&key) {
|
||||||
|
Some(key)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
match (has_tool, has_channel) {
|
if matches.len() == 1 {
|
||||||
(true, true) => None, // ambiguous
|
matches.into_iter().next()
|
||||||
(true, false) => Some(format!("tools/{}", name)),
|
} else {
|
||||||
(false, true) => Some(format!("channels/{}", name)),
|
None // ambiguous or not found
|
||||||
(false, false) => None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -476,8 +510,10 @@ mod tests {
|
|||||||
fn create_test_registry(dir: &Path) {
|
fn create_test_registry(dir: &Path) {
|
||||||
let tools_dir = dir.join("tools");
|
let tools_dir = dir.join("tools");
|
||||||
let channels_dir = dir.join("channels");
|
let channels_dir = dir.join("channels");
|
||||||
|
let mcp_dir = dir.join("mcp-servers");
|
||||||
fs::create_dir_all(&tools_dir).unwrap();
|
fs::create_dir_all(&tools_dir).unwrap();
|
||||||
fs::create_dir_all(&channels_dir).unwrap();
|
fs::create_dir_all(&channels_dir).unwrap();
|
||||||
|
fs::create_dir_all(&mcp_dir).unwrap();
|
||||||
|
|
||||||
fs::write(
|
fs::write(
|
||||||
tools_dir.join("slack.json"),
|
tools_dir.join("slack.json"),
|
||||||
@@ -540,6 +576,20 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
|
fs::write(
|
||||||
|
mcp_dir.join("notion.json"),
|
||||||
|
r#"{
|
||||||
|
"name": "notion",
|
||||||
|
"display_name": "Notion",
|
||||||
|
"kind": "mcp_server",
|
||||||
|
"description": "Connect to Notion for pages and databases",
|
||||||
|
"keywords": ["notes", "wiki"],
|
||||||
|
"url": "https://mcp.notion.com/mcp",
|
||||||
|
"auth": "dcr"
|
||||||
|
}"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
fs::write(
|
fs::write(
|
||||||
dir.join("_bundles.json"),
|
dir.join("_bundles.json"),
|
||||||
r#"{
|
r#"{
|
||||||
@@ -565,7 +615,7 @@ mod tests {
|
|||||||
create_test_registry(tmp.path());
|
create_test_registry(tmp.path());
|
||||||
|
|
||||||
let catalog = RegistryCatalog::load(tmp.path()).unwrap();
|
let catalog = RegistryCatalog::load(tmp.path()).unwrap();
|
||||||
assert_eq!(catalog.all().len(), 3);
|
assert_eq!(catalog.all().len(), 4);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -579,6 +629,9 @@ mod tests {
|
|||||||
|
|
||||||
let channels = catalog.list(Some(ManifestKind::Channel), None);
|
let channels = catalog.list(Some(ManifestKind::Channel), None);
|
||||||
assert_eq!(channels.len(), 1);
|
assert_eq!(channels.len(), 1);
|
||||||
|
|
||||||
|
let mcp_servers = catalog.list(Some(ManifestKind::McpServer), None);
|
||||||
|
assert_eq!(mcp_servers.len(), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -603,10 +656,12 @@ mod tests {
|
|||||||
|
|
||||||
// Full key
|
// Full key
|
||||||
assert!(catalog.get("tools/slack").is_some());
|
assert!(catalog.get("tools/slack").is_some());
|
||||||
|
assert!(catalog.get("mcp-servers/notion").is_some());
|
||||||
|
|
||||||
// Bare name
|
// Bare name
|
||||||
assert!(catalog.get("slack").is_some());
|
assert!(catalog.get("slack").is_some());
|
||||||
assert!(catalog.get("telegram").is_some());
|
assert!(catalog.get("telegram").is_some());
|
||||||
|
assert!(catalog.get("notion").is_some());
|
||||||
|
|
||||||
// Missing
|
// Missing
|
||||||
assert!(catalog.get("nonexistent").is_none());
|
assert!(catalog.get("nonexistent").is_none());
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ struct EmbeddedCatalogRaw {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
channels: Vec<ExtensionManifest>,
|
channels: Vec<ExtensionManifest>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
mcp_servers: Vec<ExtensionManifest>,
|
||||||
|
#[serde(default)]
|
||||||
bundles: BundlesFile,
|
bundles: BundlesFile,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,6 +54,10 @@ fn parsed_catalog() -> &'static ParsedCatalog {
|
|||||||
let key = format!("channels/{}", m.name);
|
let key = format!("channels/{}", m.name);
|
||||||
manifests.insert(key, m);
|
manifests.insert(key, m);
|
||||||
}
|
}
|
||||||
|
for m in raw.mcp_servers {
|
||||||
|
let key = format!("mcp-servers/{}", m.name);
|
||||||
|
manifests.insert(key, m);
|
||||||
|
}
|
||||||
|
|
||||||
ParsedCatalog {
|
ParsedCatalog {
|
||||||
manifests,
|
manifests,
|
||||||
|
|||||||
+76
-18
@@ -7,7 +7,7 @@ use tokio::fs;
|
|||||||
|
|
||||||
use crate::bootstrap::ironclaw_base_dir;
|
use crate::bootstrap::ironclaw_base_dir;
|
||||||
use crate::registry::catalog::RegistryError;
|
use crate::registry::catalog::RegistryError;
|
||||||
use crate::registry::manifest::{BundleDefinition, ExtensionManifest, ManifestKind};
|
use crate::registry::manifest::{BundleDefinition, ExtensionManifest, ManifestKind, SourceSpec};
|
||||||
|
|
||||||
// GitHub-only by design. New trusted hosts (e.g. a NEAR AI CDN) must be
|
// GitHub-only by design. New trusted hosts (e.g. a NEAR AI CDN) must be
|
||||||
// explicitly added here; unknown hosts fall back to source build with a
|
// explicitly added here; unknown hosts fall back to source build with a
|
||||||
@@ -98,12 +98,29 @@ fn validate_manifest_install_inputs(manifest: &ExtensionManifest) -> Result<(),
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MCP servers are not installed via this path
|
||||||
|
if manifest.kind == ManifestKind::McpServer {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let source = match &manifest.source {
|
||||||
|
Some(s) => s,
|
||||||
|
None => {
|
||||||
|
return Err(RegistryError::InvalidManifest {
|
||||||
|
name: manifest.name.clone(),
|
||||||
|
field: "source",
|
||||||
|
reason: "WASM extensions must have a source spec".to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
let expected_prefix = match manifest.kind {
|
let expected_prefix = match manifest.kind {
|
||||||
ManifestKind::Tool => "tools-src/",
|
ManifestKind::Tool => "tools-src/",
|
||||||
ManifestKind::Channel => "channels-src/",
|
ManifestKind::Channel => "channels-src/",
|
||||||
|
ManifestKind::McpServer => unreachable!(),
|
||||||
};
|
};
|
||||||
|
|
||||||
if !manifest.source.dir.starts_with(expected_prefix) {
|
if !source.dir.starts_with(expected_prefix) {
|
||||||
return Err(RegistryError::InvalidManifest {
|
return Err(RegistryError::InvalidManifest {
|
||||||
name: manifest.name.clone(),
|
name: manifest.name.clone(),
|
||||||
field: "source.dir",
|
field: "source.dir",
|
||||||
@@ -111,7 +128,7 @@ fn validate_manifest_install_inputs(manifest: &ExtensionManifest) -> Result<(),
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let source_path = Path::new(&manifest.source.dir);
|
let source_path = Path::new(&source.dir);
|
||||||
let has_unsafe_component = source_path.components().any(|component| {
|
let has_unsafe_component = source_path.components().any(|component| {
|
||||||
matches!(
|
matches!(
|
||||||
component,
|
component,
|
||||||
@@ -127,9 +144,9 @@ fn validate_manifest_install_inputs(manifest: &ExtensionManifest) -> Result<(),
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let has_path_separator = manifest.source.capabilities.contains('/')
|
let has_path_separator = source.capabilities.contains('/')
|
||||||
|| manifest.source.capabilities.contains('\\')
|
|| source.capabilities.contains('\\')
|
||||||
|| manifest.source.capabilities.contains("..");
|
|| source.capabilities.contains("..");
|
||||||
|
|
||||||
if has_path_separator {
|
if has_path_separator {
|
||||||
return Err(RegistryError::InvalidManifest {
|
return Err(RegistryError::InvalidManifest {
|
||||||
@@ -142,6 +159,18 @@ fn validate_manifest_install_inputs(manifest: &ExtensionManifest) -> Result<(),
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Extract the source spec from a manifest, returning an error if absent.
|
||||||
|
fn require_source(manifest: &ExtensionManifest) -> Result<&SourceSpec, RegistryError> {
|
||||||
|
manifest
|
||||||
|
.source
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| RegistryError::InvalidManifest {
|
||||||
|
name: manifest.name.clone(),
|
||||||
|
field: "source",
|
||||||
|
reason: "WASM extensions must have a source spec".to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn download_failure_reason(error: &reqwest::Error) -> String {
|
fn download_failure_reason(error: &reqwest::Error) -> String {
|
||||||
if error.is_timeout() {
|
if error.is_timeout() {
|
||||||
"request timed out".to_string()
|
"request timed out".to_string()
|
||||||
@@ -206,7 +235,17 @@ impl RegistryInstaller {
|
|||||||
) -> Result<InstallOutcome, RegistryError> {
|
) -> Result<InstallOutcome, RegistryError> {
|
||||||
validate_manifest_install_inputs(manifest)?;
|
validate_manifest_install_inputs(manifest)?;
|
||||||
|
|
||||||
let source_dir = self.repo_root.join(&manifest.source.dir);
|
if manifest.kind == ManifestKind::McpServer {
|
||||||
|
return Err(RegistryError::InvalidManifest {
|
||||||
|
name: manifest.name.clone(),
|
||||||
|
field: "kind",
|
||||||
|
reason: "MCP servers cannot be installed from source".to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let source = require_source(manifest)?;
|
||||||
|
|
||||||
|
let source_dir = self.repo_root.join(&source.dir);
|
||||||
if !source_dir.exists() {
|
if !source_dir.exists() {
|
||||||
return Err(RegistryError::ManifestRead {
|
return Err(RegistryError::ManifestRead {
|
||||||
path: source_dir.clone(),
|
path: source_dir.clone(),
|
||||||
@@ -217,6 +256,7 @@ impl RegistryInstaller {
|
|||||||
let target_dir = match manifest.kind {
|
let target_dir = match manifest.kind {
|
||||||
ManifestKind::Tool => &self.tools_dir,
|
ManifestKind::Tool => &self.tools_dir,
|
||||||
ManifestKind::Channel => &self.channels_dir,
|
ManifestKind::Channel => &self.channels_dir,
|
||||||
|
ManifestKind::McpServer => unreachable!(),
|
||||||
};
|
};
|
||||||
|
|
||||||
fs::create_dir_all(target_dir)
|
fs::create_dir_all(target_dir)
|
||||||
@@ -242,7 +282,7 @@ impl RegistryInstaller {
|
|||||||
manifest.display_name,
|
manifest.display_name,
|
||||||
source_dir.display()
|
source_dir.display()
|
||||||
);
|
);
|
||||||
let crate_name = &manifest.source.crate_name;
|
let crate_name = &source.crate_name;
|
||||||
let wasm_path =
|
let wasm_path =
|
||||||
crate::registry::artifacts::build_wasm_component(&source_dir, crate_name, true)
|
crate::registry::artifacts::build_wasm_component(&source_dir, crate_name, true)
|
||||||
.await
|
.await
|
||||||
@@ -258,7 +298,7 @@ impl RegistryInstaller {
|
|||||||
.map_err(RegistryError::Io)?;
|
.map_err(RegistryError::Io)?;
|
||||||
|
|
||||||
// Copy capabilities file
|
// Copy capabilities file
|
||||||
let caps_source = source_dir.join(&manifest.source.capabilities);
|
let caps_source = source_dir.join(&source.capabilities);
|
||||||
let target_caps = target_dir.join(format!("{}.capabilities.json", manifest.name));
|
let target_caps = target_dir.join(format!("{}.capabilities.json", manifest.name));
|
||||||
let has_capabilities = if caps_source.exists() {
|
let has_capabilities = if caps_source.exists() {
|
||||||
fs::copy(&caps_source, &target_caps)
|
fs::copy(&caps_source, &target_caps)
|
||||||
@@ -296,6 +336,16 @@ impl RegistryInstaller {
|
|||||||
// catch it first.
|
// catch it first.
|
||||||
validate_manifest_install_inputs(manifest)?;
|
validate_manifest_install_inputs(manifest)?;
|
||||||
|
|
||||||
|
if manifest.kind == ManifestKind::McpServer {
|
||||||
|
return Err(RegistryError::InvalidManifest {
|
||||||
|
name: manifest.name.clone(),
|
||||||
|
field: "kind",
|
||||||
|
reason: "MCP servers cannot be installed via the WASM installer".to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let source = require_source(manifest)?;
|
||||||
|
|
||||||
let has_artifact = manifest
|
let has_artifact = manifest
|
||||||
.artifacts
|
.artifacts
|
||||||
.get("wasm32-wasip2")
|
.get("wasm32-wasip2")
|
||||||
@@ -306,7 +356,7 @@ impl RegistryInstaller {
|
|||||||
return self.install_from_source(manifest, force).await;
|
return self.install_from_source(manifest, force).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
let source_dir = self.repo_root.join(&manifest.source.dir);
|
let source_dir = self.repo_root.join(&source.dir);
|
||||||
|
|
||||||
match self.install_from_artifact(manifest, force).await {
|
match self.install_from_artifact(manifest, force).await {
|
||||||
Ok(outcome) => Ok(outcome),
|
Ok(outcome) => Ok(outcome),
|
||||||
@@ -391,6 +441,13 @@ impl RegistryInstaller {
|
|||||||
let target_dir = match manifest.kind {
|
let target_dir = match manifest.kind {
|
||||||
ManifestKind::Tool => &self.tools_dir,
|
ManifestKind::Tool => &self.tools_dir,
|
||||||
ManifestKind::Channel => &self.channels_dir,
|
ManifestKind::Channel => &self.channels_dir,
|
||||||
|
ManifestKind::McpServer => {
|
||||||
|
return Err(RegistryError::InvalidManifest {
|
||||||
|
name: manifest.name.clone(),
|
||||||
|
field: "kind",
|
||||||
|
reason: "MCP servers cannot be installed as artifacts".to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
fs::create_dir_all(target_dir)
|
fs::create_dir_all(target_dir)
|
||||||
@@ -458,12 +515,9 @@ impl RegistryInstaller {
|
|||||||
false
|
false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else if let Some(ref source) = manifest.source {
|
||||||
// Legacy fallback: try source tree
|
// Legacy fallback: try source tree
|
||||||
let caps_source = self
|
let caps_source = self.repo_root.join(&source.dir).join(&source.capabilities);
|
||||||
.repo_root
|
|
||||||
.join(&manifest.source.dir)
|
|
||||||
.join(&manifest.source.capabilities);
|
|
||||||
if caps_source.exists() {
|
if caps_source.exists() {
|
||||||
fs::copy(&caps_source, &target_caps)
|
fs::copy(&caps_source, &target_caps)
|
||||||
.await
|
.await
|
||||||
@@ -472,6 +526,8 @@ impl RegistryInstaller {
|
|||||||
} else {
|
} else {
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
false
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -775,17 +831,19 @@ mod tests {
|
|||||||
name: name.to_string(),
|
name: name.to_string(),
|
||||||
display_name: name.to_string(),
|
display_name: name.to_string(),
|
||||||
kind,
|
kind,
|
||||||
version: "0.1.0".to_string(),
|
version: Some("0.1.0".to_string()),
|
||||||
description: "test manifest".to_string(),
|
description: "test manifest".to_string(),
|
||||||
keywords: Vec::new(),
|
keywords: Vec::new(),
|
||||||
source: SourceSpec {
|
source: Some(SourceSpec {
|
||||||
dir: source_dir.to_string(),
|
dir: source_dir.to_string(),
|
||||||
capabilities: format!("{}.capabilities.json", name),
|
capabilities: format!("{}.capabilities.json", name),
|
||||||
crate_name: name.to_string(),
|
crate_name: name.to_string(),
|
||||||
},
|
}),
|
||||||
artifacts,
|
artifacts,
|
||||||
auth_summary: None,
|
auth_summary: None,
|
||||||
tags: Vec::new(),
|
tags: Vec::new(),
|
||||||
|
url: None,
|
||||||
|
auth: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+192
-21
@@ -7,7 +7,7 @@ use serde::{Deserialize, Serialize};
|
|||||||
|
|
||||||
use crate::extensions::{AuthHint, ExtensionKind, ExtensionSource, RegistryEntry};
|
use crate::extensions::{AuthHint, ExtensionKind, ExtensionSource, RegistryEntry};
|
||||||
|
|
||||||
/// A single extension manifest loaded from `registry/{tools,channels}/<name>.json`.
|
/// A single extension manifest loaded from `registry/{tools,channels,mcp-servers}/<name>.json`.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct ExtensionManifest {
|
pub struct ExtensionManifest {
|
||||||
/// Unique identifier (matches crate name stem, e.g. "slack").
|
/// Unique identifier (matches crate name stem, e.g. "slack").
|
||||||
@@ -16,11 +16,12 @@ pub struct ExtensionManifest {
|
|||||||
/// Human-readable name (e.g. "Slack").
|
/// Human-readable name (e.g. "Slack").
|
||||||
pub display_name: String,
|
pub display_name: String,
|
||||||
|
|
||||||
/// Whether this is a tool or channel.
|
/// Whether this is a tool, channel, or MCP server.
|
||||||
pub kind: ManifestKind,
|
pub kind: ManifestKind,
|
||||||
|
|
||||||
/// Semver version from Cargo.toml.
|
/// Semver version from Cargo.toml. Optional for MCP server manifests.
|
||||||
pub version: String,
|
#[serde(default)]
|
||||||
|
pub version: Option<String>,
|
||||||
|
|
||||||
/// One-line description.
|
/// One-line description.
|
||||||
pub description: String,
|
pub description: String,
|
||||||
@@ -29,8 +30,9 @@ pub struct ExtensionManifest {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub keywords: Vec<String>,
|
pub keywords: Vec<String>,
|
||||||
|
|
||||||
/// Source code location and build info.
|
/// Source code location and build info. Absent for MCP server manifests.
|
||||||
pub source: SourceSpec,
|
#[serde(default)]
|
||||||
|
pub source: Option<SourceSpec>,
|
||||||
|
|
||||||
/// Pre-built binary artifacts keyed by target triple.
|
/// Pre-built binary artifacts keyed by target triple.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -43,6 +45,15 @@ pub struct ExtensionManifest {
|
|||||||
/// Tags for filtering (e.g. "default", "messaging", "google").
|
/// Tags for filtering (e.g. "default", "messaging", "google").
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub tags: Vec<String>,
|
pub tags: Vec<String>,
|
||||||
|
|
||||||
|
/// MCP server URL. Only present for `McpServer` manifests.
|
||||||
|
#[serde(default)]
|
||||||
|
pub url: Option<String>,
|
||||||
|
|
||||||
|
/// MCP auth method: "dcr", "oauth_pre_configured:<setup_url>", or "none".
|
||||||
|
/// Only present for `McpServer` manifests.
|
||||||
|
#[serde(default)]
|
||||||
|
pub auth: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Extension kind as declared in manifests.
|
/// Extension kind as declared in manifests.
|
||||||
@@ -51,6 +62,7 @@ pub struct ExtensionManifest {
|
|||||||
pub enum ManifestKind {
|
pub enum ManifestKind {
|
||||||
Tool,
|
Tool,
|
||||||
Channel,
|
Channel,
|
||||||
|
McpServer,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<ManifestKind> for ExtensionKind {
|
impl From<ManifestKind> for ExtensionKind {
|
||||||
@@ -58,6 +70,7 @@ impl From<ManifestKind> for ExtensionKind {
|
|||||||
match kind {
|
match kind {
|
||||||
ManifestKind::Tool => ExtensionKind::WasmTool,
|
ManifestKind::Tool => ExtensionKind::WasmTool,
|
||||||
ManifestKind::Channel => ExtensionKind::WasmChannel,
|
ManifestKind::Channel => ExtensionKind::WasmChannel,
|
||||||
|
ManifestKind::McpServer => ExtensionKind::McpServer,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -67,6 +80,7 @@ impl std::fmt::Display for ManifestKind {
|
|||||||
match self {
|
match self {
|
||||||
ManifestKind::Tool => write!(f, "tool"),
|
ManifestKind::Tool => write!(f, "tool"),
|
||||||
ManifestKind::Channel => write!(f, "channel"),
|
ManifestKind::Channel => write!(f, "channel"),
|
||||||
|
ManifestKind::McpServer => write!(f, "mcp_server"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -153,12 +167,64 @@ pub struct BundlesFile {
|
|||||||
impl ExtensionManifest {
|
impl ExtensionManifest {
|
||||||
/// Convert this manifest into a [`RegistryEntry`] for use with the in-chat
|
/// Convert this manifest into a [`RegistryEntry`] for use with the in-chat
|
||||||
/// extension discovery system.
|
/// extension discovery system.
|
||||||
pub fn to_registry_entry(&self) -> RegistryEntry {
|
///
|
||||||
let buildable = ExtensionSource::WasmBuildable {
|
/// Returns `None` for MCP server manifests missing a `url` field.
|
||||||
source_dir: self.source.dir.clone(),
|
pub fn to_registry_entry(&self) -> Option<RegistryEntry> {
|
||||||
build_dir: Some(self.source.dir.clone()),
|
if self.kind == ManifestKind::McpServer {
|
||||||
crate_name: Some(self.source.crate_name.clone()),
|
return self.to_mcp_registry_entry();
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(self.to_wasm_registry_entry())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a [`RegistryEntry`] for an MCP server manifest.
|
||||||
|
fn to_mcp_registry_entry(&self) -> Option<RegistryEntry> {
|
||||||
|
let url = match &self.url {
|
||||||
|
Some(u) => u.clone(),
|
||||||
|
None => {
|
||||||
|
tracing::warn!(
|
||||||
|
"MCP server manifest '{}' is missing 'url' field, skipping",
|
||||||
|
self.name
|
||||||
|
);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
let auth_hint = match self.auth.as_deref() {
|
||||||
|
Some("dcr") | None => AuthHint::Dcr,
|
||||||
|
Some("none") => AuthHint::None,
|
||||||
|
Some(other) if other.starts_with("oauth_pre_configured:") => {
|
||||||
|
AuthHint::OAuthPreConfigured {
|
||||||
|
setup_url: other
|
||||||
|
.strip_prefix("oauth_pre_configured:")
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => AuthHint::Dcr,
|
||||||
|
};
|
||||||
|
|
||||||
|
Some(RegistryEntry {
|
||||||
|
name: self.name.clone(),
|
||||||
|
display_name: self.display_name.clone(),
|
||||||
|
kind: ExtensionKind::McpServer,
|
||||||
|
description: self.description.clone(),
|
||||||
|
keywords: self.keywords.clone(),
|
||||||
|
source: ExtensionSource::McpUrl { url },
|
||||||
|
fallback_source: None,
|
||||||
|
auth_hint,
|
||||||
|
version: self.version.clone(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a [`RegistryEntry`] for a WASM tool or channel manifest.
|
||||||
|
fn to_wasm_registry_entry(&self) -> RegistryEntry {
|
||||||
|
let source_spec = self.source.as_ref();
|
||||||
|
|
||||||
|
let buildable = source_spec.map(|s| ExtensionSource::WasmBuildable {
|
||||||
|
source_dir: s.dir.clone(),
|
||||||
|
build_dir: Some(s.dir.clone()),
|
||||||
|
crate_name: Some(s.crate_name.clone()),
|
||||||
|
});
|
||||||
|
|
||||||
// Prefer pre-built artifact download when a URL is available,
|
// Prefer pre-built artifact download when a URL is available,
|
||||||
// with build-from-source as fallback in case the download fails (e.g., 404).
|
// with build-from-source as fallback in case the download fails (e.g., 404).
|
||||||
@@ -170,13 +236,32 @@ impl ExtensionManifest {
|
|||||||
wasm_url: url.clone(),
|
wasm_url: url.clone(),
|
||||||
capabilities_url: artifact.capabilities_url.clone(),
|
capabilities_url: artifact.capabilities_url.clone(),
|
||||||
},
|
},
|
||||||
Some(Box::new(buildable)),
|
buildable.map(Box::new),
|
||||||
)
|
)
|
||||||
|
} else if let Some(b) = buildable {
|
||||||
|
(b, None)
|
||||||
} else {
|
} else {
|
||||||
(buildable, None)
|
// No source spec and no download URL — use a placeholder
|
||||||
|
(
|
||||||
|
ExtensionSource::WasmBuildable {
|
||||||
|
source_dir: String::new(),
|
||||||
|
build_dir: None,
|
||||||
|
crate_name: None,
|
||||||
|
},
|
||||||
|
None,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
} else if let Some(b) = buildable {
|
||||||
|
(b, None)
|
||||||
} else {
|
} else {
|
||||||
(buildable, None)
|
(
|
||||||
|
ExtensionSource::WasmBuildable {
|
||||||
|
source_dir: String::new(),
|
||||||
|
build_dir: None,
|
||||||
|
crate_name: None,
|
||||||
|
},
|
||||||
|
None,
|
||||||
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
let auth_hint = match self.auth_summary.as_ref().and_then(|a| a.method.as_deref()) {
|
let auth_hint = match self.auth_summary.as_ref().and_then(|a| a.method.as_deref()) {
|
||||||
@@ -195,7 +280,7 @@ impl ExtensionManifest {
|
|||||||
source,
|
source,
|
||||||
fallback_source,
|
fallback_source,
|
||||||
auth_hint,
|
auth_hint,
|
||||||
version: Some(self.version.clone()),
|
version: self.version.clone(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -234,10 +319,10 @@ mod tests {
|
|||||||
let manifest: ExtensionManifest = serde_json::from_str(json).expect("parse manifest");
|
let manifest: ExtensionManifest = serde_json::from_str(json).expect("parse manifest");
|
||||||
assert_eq!(manifest.name, "slack");
|
assert_eq!(manifest.name, "slack");
|
||||||
assert_eq!(manifest.kind, ManifestKind::Tool);
|
assert_eq!(manifest.kind, ManifestKind::Tool);
|
||||||
assert_eq!(manifest.version, "0.1.0");
|
assert_eq!(manifest.version.as_deref(), Some("0.1.0"));
|
||||||
assert!(manifest.tags.contains(&"default".to_string()));
|
assert!(manifest.tags.contains(&"default".to_string()));
|
||||||
|
|
||||||
let entry = manifest.to_registry_entry();
|
let entry = manifest.to_registry_entry().unwrap();
|
||||||
assert_eq!(entry.kind, ExtensionKind::WasmTool);
|
assert_eq!(entry.kind, ExtensionKind::WasmTool);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -262,7 +347,7 @@ mod tests {
|
|||||||
assert!(manifest.auth_summary.is_none());
|
assert!(manifest.auth_summary.is_none());
|
||||||
assert!(manifest.artifacts.is_empty());
|
assert!(manifest.artifacts.is_empty());
|
||||||
|
|
||||||
let entry = manifest.to_registry_entry();
|
let entry = manifest.to_registry_entry().unwrap();
|
||||||
assert_eq!(entry.kind, ExtensionKind::WasmChannel);
|
assert_eq!(entry.kind, ExtensionKind::WasmChannel);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -296,6 +381,7 @@ mod tests {
|
|||||||
fn test_manifest_kind_display() {
|
fn test_manifest_kind_display() {
|
||||||
assert_eq!(ManifestKind::Tool.to_string(), "tool");
|
assert_eq!(ManifestKind::Tool.to_string(), "tool");
|
||||||
assert_eq!(ManifestKind::Channel.to_string(), "channel");
|
assert_eq!(ManifestKind::Channel.to_string(), "channel");
|
||||||
|
assert_eq!(ManifestKind::McpServer.to_string(), "mcp_server");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// When a manifest has a download URL in artifacts, to_registry_entry()
|
/// When a manifest has a download URL in artifacts, to_registry_entry()
|
||||||
@@ -324,7 +410,7 @@ mod tests {
|
|||||||
}"#;
|
}"#;
|
||||||
|
|
||||||
let manifest: ExtensionManifest = serde_json::from_str(json).expect("parse manifest");
|
let manifest: ExtensionManifest = serde_json::from_str(json).expect("parse manifest");
|
||||||
let entry = manifest.to_registry_entry();
|
let entry = manifest.to_registry_entry().unwrap();
|
||||||
|
|
||||||
// Primary source should be WasmDownload
|
// Primary source should be WasmDownload
|
||||||
assert!(
|
assert!(
|
||||||
@@ -374,7 +460,7 @@ mod tests {
|
|||||||
}"#;
|
}"#;
|
||||||
|
|
||||||
let manifest: ExtensionManifest = serde_json::from_str(json).expect("parse manifest");
|
let manifest: ExtensionManifest = serde_json::from_str(json).expect("parse manifest");
|
||||||
let entry = manifest.to_registry_entry();
|
let entry = manifest.to_registry_entry().unwrap();
|
||||||
|
|
||||||
assert!(
|
assert!(
|
||||||
matches!(&entry.source, ExtensionSource::WasmBuildable { .. }),
|
matches!(&entry.source, ExtensionSource::WasmBuildable { .. }),
|
||||||
@@ -405,7 +491,7 @@ mod tests {
|
|||||||
}"#;
|
}"#;
|
||||||
|
|
||||||
let manifest: ExtensionManifest = serde_json::from_str(json).expect("parse manifest");
|
let manifest: ExtensionManifest = serde_json::from_str(json).expect("parse manifest");
|
||||||
let entry = manifest.to_registry_entry();
|
let entry = manifest.to_registry_entry().unwrap();
|
||||||
|
|
||||||
assert!(
|
assert!(
|
||||||
matches!(&entry.source, ExtensionSource::WasmBuildable { .. }),
|
matches!(&entry.source, ExtensionSource::WasmBuildable { .. }),
|
||||||
@@ -416,4 +502,89 @@ mod tests {
|
|||||||
"Should have no fallback when already using WasmBuildable"
|
"Should have no fallback when already using WasmBuildable"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_mcp_server_manifest() {
|
||||||
|
let json = r#"{
|
||||||
|
"name": "notion",
|
||||||
|
"display_name": "Notion",
|
||||||
|
"kind": "mcp_server",
|
||||||
|
"description": "Connect to Notion for reading and writing pages, databases, and comments",
|
||||||
|
"keywords": ["notes", "wiki", "docs", "pages", "database"],
|
||||||
|
"url": "https://mcp.notion.com/mcp",
|
||||||
|
"auth": "dcr"
|
||||||
|
}"#;
|
||||||
|
|
||||||
|
let manifest: ExtensionManifest = serde_json::from_str(json).expect("parse manifest");
|
||||||
|
assert_eq!(manifest.name, "notion");
|
||||||
|
assert_eq!(manifest.kind, ManifestKind::McpServer);
|
||||||
|
assert!(manifest.version.is_none());
|
||||||
|
assert!(manifest.source.is_none());
|
||||||
|
assert_eq!(manifest.url.as_deref(), Some("https://mcp.notion.com/mcp"));
|
||||||
|
assert_eq!(manifest.auth.as_deref(), Some("dcr"));
|
||||||
|
|
||||||
|
let entry = manifest.to_registry_entry().unwrap();
|
||||||
|
assert_eq!(entry.kind, ExtensionKind::McpServer);
|
||||||
|
assert!(
|
||||||
|
matches!(&entry.source, ExtensionSource::McpUrl { url } if url == "https://mcp.notion.com/mcp")
|
||||||
|
);
|
||||||
|
assert!(matches!(&entry.auth_hint, AuthHint::Dcr));
|
||||||
|
assert!(entry.fallback_source.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_mcp_server_oauth_pre_configured() {
|
||||||
|
let json = r#"{
|
||||||
|
"name": "custom-mcp",
|
||||||
|
"display_name": "Custom MCP",
|
||||||
|
"kind": "mcp_server",
|
||||||
|
"description": "Custom MCP server",
|
||||||
|
"keywords": [],
|
||||||
|
"url": "https://mcp.example.com",
|
||||||
|
"auth": "oauth_pre_configured:https://example.com/setup"
|
||||||
|
}"#;
|
||||||
|
|
||||||
|
let manifest: ExtensionManifest = serde_json::from_str(json).expect("parse manifest");
|
||||||
|
let entry = manifest.to_registry_entry().unwrap();
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
&entry.auth_hint,
|
||||||
|
AuthHint::OAuthPreConfigured { setup_url } if setup_url == "https://example.com/setup"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_mcp_server_auth_none() {
|
||||||
|
let json = r#"{
|
||||||
|
"name": "local-mcp",
|
||||||
|
"display_name": "Local MCP",
|
||||||
|
"kind": "mcp_server",
|
||||||
|
"description": "Local MCP server",
|
||||||
|
"keywords": [],
|
||||||
|
"url": "http://localhost:8080/mcp",
|
||||||
|
"auth": "none"
|
||||||
|
}"#;
|
||||||
|
|
||||||
|
let manifest: ExtensionManifest = serde_json::from_str(json).expect("parse manifest");
|
||||||
|
let entry = manifest.to_registry_entry().unwrap();
|
||||||
|
|
||||||
|
assert!(matches!(&entry.auth_hint, AuthHint::None));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_mcp_server_missing_url_returns_none() {
|
||||||
|
let json = r#"{
|
||||||
|
"name": "broken-mcp",
|
||||||
|
"display_name": "Broken MCP",
|
||||||
|
"kind": "mcp_server",
|
||||||
|
"description": "MCP server with no URL",
|
||||||
|
"keywords": []
|
||||||
|
}"#;
|
||||||
|
|
||||||
|
let manifest: ExtensionManifest = serde_json::from_str(json).expect("parse manifest");
|
||||||
|
assert!(
|
||||||
|
manifest.to_registry_entry().is_none(),
|
||||||
|
"MCP manifest without url should return None"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user