Skip to content
64 changes: 64 additions & 0 deletions rust-executor/src/mcp/tools/languages.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
//! Language tools — introspect installed languages and their metadata.

use super::Ad4mMcpHandler;
use crate::agent::capabilities::{check_capability, defs::LANGUAGE_READ_CAPABILITY};
use crate::languages::LanguageController;
use rmcp::{handler::server::wrapper::Parameters, tool};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use serde_json::json;

// ============================================================================
// Parameter types
// ============================================================================

/// Parameters for getting language metadata
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
pub struct LanguageMetaParams {
/// Address (hash) of the language to get metadata for
pub address: String,
}

// ============================================================================
// Tool implementations
// ============================================================================

impl Ad4mMcpHandler {
/// Get metadata about a language by address
#[tool(
description = "Get metadata about an installed language by its address. Returns name, description, author, source code link, and template parameters. Useful for inspecting link languages, expression languages, or any language in the AD4M ecosystem."
)]
pub async fn language_meta(&self, params: Parameters<LanguageMetaParams>) -> String {
let p = &params.0;

let capabilities = self.get_capabilities().await;
if let Err(e) = check_capability(&capabilities, &LANGUAGE_READ_CAPABILITY) {
return format!("Capability error: {}", e);
}

let controller = LanguageController::global_instance();
match controller.get_language_expression(&p.address).await {
Ok(meta) => {
let result = json!({
"address": meta.address,
"name": meta.name,
"description": meta.description,
"author": meta.author,
"possible_template_params": meta.possible_template_params,
"source_code_link": meta.source_code_link,
"template_applied_params": meta.template_applied_params,
"template_source_language_address": meta.template_source_language_address,
});
serde_json::to_string_pretty(&result)
.unwrap_or_else(|e| format!("Error serializing: {}", e))
}
Err(e) => {
json!({"error": format!("Failed to get language meta for '{}': {}", p.address, e)})
.to_string()
}
}
}
}

// Note: Integration tests for language tools are in tests/js/tests/mcp-neighbourhood.test.ts
// These test the actual MCP endpoints with a running executor.
7 changes: 7 additions & 0 deletions rust-executor/src/mcp/tools/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ pub mod auth;
pub mod children;
pub mod dynamic;
pub mod flows;
pub mod languages;
pub mod neighbourhoods;
pub mod perspectives;
pub mod profiles;
Expand Down Expand Up @@ -275,7 +276,13 @@ impl Ad4mMcpHandler {
Self::get_mention_waker_config_tool_attr(),
Self::get_mention_waker_config,
))
// languages.rs
.with_route((Self::language_meta_tool_attr(), Self::language_meta))
// neighbourhoods.rs
.with_route((
Self::list_link_language_templates_tool_attr(),
Self::list_link_language_templates,
))
.with_route((
Self::neighbourhood_publish_from_perspective_tool_attr(),
Self::neighbourhood_publish_from_perspective,
Expand Down
204 changes: 199 additions & 5 deletions rust-executor/src/mcp/tools/neighbourhoods.rs
Original file line number Diff line number Diff line change
@@ -1,12 +1,20 @@
//! Neighbourhood tools — publish perspectives as neighbourhoods and join existing ones.
//!
//! High-level tools for P2P collaboration. Agents select from known link language
//! templates; the system handles cloning and unique instance creation automatically.

use super::Ad4mMcpHandler;
use crate::agent::capabilities::{
check_capability,
defs::{NEIGHBOURHOOD_CREATE_CAPABILITY, NEIGHBOURHOOD_READ_CAPABILITY},
defs::{
NEIGHBOURHOOD_CREATE_CAPABILITY, NEIGHBOURHOOD_READ_CAPABILITY,
RUNTIME_KNOWN_LINK_LANGUAGES_READ_CAPABILITY,
},
};
use crate::graphql::graphql_types::Perspective;
use crate::languages::LanguageController;
use crate::neighbourhoods;
use crate::runtime_service::RuntimeService;
use rmcp::{handler::server::wrapper::Parameters, tool};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
Expand All @@ -21,8 +29,19 @@ use serde_json::json;
pub struct NeighbourhoodPublishParams {
/// UUID of the local perspective to publish as a shared neighbourhood
pub perspective_uuid: String,
/// Address of the link language used for P2P synchronization (e.g. a perspective-diff-sync language)
/// Address of a link language to use for this neighbourhood.
/// Can be a template address (will be cloned) or an already-cloned language.
/// Use `list_link_language_templates` to see available templates.
#[serde(alias = "link_language_template")]
pub link_language: String,
/// Optional human-readable name for this neighbourhood (used as the cloned language name).
/// If not provided, a default name will be generated.
#[serde(default = "default_neighbourhood_name")]
pub name: String,
}

fn default_neighbourhood_name() -> String {
"Neighbourhood".to_string()
}

/// Parameters for joining a neighbourhood
Expand All @@ -37,9 +56,169 @@ pub struct NeighbourhoodJoinParams {
// ============================================================================

impl Ad4mMcpHandler {
/// List available link language templates for neighbourhood creation
#[tool(
description = "List available link language templates that can be used when publishing a neighbourhood. Each template is a P2P synchronization engine. Returns address, name, and description for each template. Pass the address as `link_language_template` when calling `neighbourhood_publish_from_perspective`."
)]
pub async fn list_link_language_templates(&self) -> String {
let capabilities = self.get_capabilities().await;
if let Err(e) =
check_capability(&capabilities, &RUNTIME_KNOWN_LINK_LANGUAGES_READ_CAPABILITY)
{
return format!("Capability error: {}", e);
}

let addresses = match RuntimeService::with_global_instance(|runtime_service| {
Ok::<Vec<String>, String>(runtime_service.get_know_link_languages())
}) {
Ok(addrs) => addrs,
Err(e) => {
return json!({"error": format!("Failed to get link language templates: {}", e)})
.to_string()
}
};

// Fetch meta information for each template
let controller = LanguageController::global_instance();
let mut templates = Vec::new();
for address in &addresses {
let meta = controller.get_language_expression(address).await;
match meta {
Ok(m) => {
templates.push(json!({
"address": m.address,
"name": m.name,
"description": m.description,
"author": m.author,
"possible_template_params": m.possible_template_params,
}));
}
Err(_) => {
// Fallback: try to get at least the name from local runtime
let name = controller.get_language_name(address).await;
templates.push(json!({
"address": address,
"name": name,
"description": null,
"author": null,
"possible_template_params": null,
}));
}
}
}

let result = json!({
"templates": templates,
"count": templates.len(),
"hint": "Pass the address of a template as link_language_template when publishing a neighbourhood."
});
serde_json::to_string_pretty(&result).unwrap_or_else(|e| format!("Error: {}", e))
}

/// Clone a link language template and publish the cloned instance.
/// Returns the new language address.
async fn clone_link_language(
&self,
template_address: &str,
name: &str,
) -> Result<String, String> {
let controller = LanguageController::global_instance();

// Check if language language is available
let language_language_address = {
let sys = controller.system_addresses.lock().await;
sys.language_language
.clone()
.ok_or("Language language not loaded — cannot clone link language template")?
};

// Build template data with unique ID and name
let template_map: serde_json::Map<String, serde_json::Value> = {
let mut m = serde_json::Map::new();
m.insert(
"uid".to_string(),
serde_json::Value::String(uuid::Uuid::new_v4().to_string()),
);
m.insert(
"name".to_string(),
serde_json::Value::String(name.to_string()),
);
m
};

// Apply template to generate unique language source
let input = controller
.language_apply_template_on_source(template_address, template_map)
.await
.map_err(|e| {
format!(
"Failed to clone template '{}': {}. Use `list_link_language_templates` to see available templates.",
template_address, e
)
})?;

let input_name = input.meta.name.clone();

// Save locally and get the path
let (saved_hash, bundle_path) = controller
.save_language_bundle(&input.bundle, None)
.map_err(|e| format!("Failed to save cloned language bundle locally: {}", e))?;

// Publish via the language language
let input_json = serde_json::to_string(&input)
.map_err(|e| format!("Failed to serialize cloned language: {}", e))?;

let publish_script = format!(
r#"await globalThis.__ad4m_language_instance__.expressionAdapter.putAdapter.createPublic({})"#,
input_json
);

let address_raw = controller
.execute_on_language(&language_language_address, &publish_script)
.await
.map_err(|e| format!("Failed to publish cloned language: {}", e))?;

let address = address_raw.trim().trim_matches('"').to_string();

// Load into runtime - use the saved bundle path
// Verify the saved hash matches the published address
if saved_hash != address {
log::warn!(
"Saved language hash ({}) doesn't match published address ({}). Using published address.",
saved_hash, address
);
}

if bundle_path.exists() {
controller
.load_language(bundle_path, false)
.await
.map_err(|e| {
format!(
"Failed to load cloned language into runtime: {}. The language was published but cannot be used locally.",
e
)
})?;
} else {
return Err(format!(
"Language bundle not found at expected path: {:?}",
bundle_path
));
}

log::info!(
"Cloned link language template '{}' → '{}' (name: {})",
template_address,
address,
input_name
);

Ok(address)
}

/// Publish a local perspective as a shared neighbourhood for P2P collaboration
#[tool(
description = "Publish a local perspective as a shared neighbourhood for P2P collaboration. Requires a link language address (e.g. perspective-diff-sync) that handles synchronization between peers. Returns the neighbourhood URL that others can use to join."
description = "Publish a local perspective as a shared neighbourhood. Automatically clones the given link language template to create a unique sync instance. Returns the neighbourhood URL that others can use to join via `neighbourhood_join_from_url`. Use `list_link_language_templates` first to find available templates."
)]
pub async fn neighbourhood_publish_from_perspective(
&self,
Expand Down Expand Up @@ -71,11 +250,17 @@ impl Ad4mMcpHandler {
return json!({"error": "Perspective not found or not accessible"}).to_string();
}

// Clone the link language template
let cloned_address = match self.clone_link_language(&p.link_language, &p.name).await {
Ok(addr) => addr,
Err(e) => return json!({"error": e}).to_string(),
};

let meta = Perspective::default();

match neighbourhoods::neighbourhood_publish_from_perspective_with_context(
&p.perspective_uuid,
p.link_language.clone(),
cloned_address.clone(),
meta,
&agent_context,
)
Expand All @@ -85,6 +270,8 @@ impl Ad4mMcpHandler {
"success": true,
"neighbourhood_url": url,
"perspective_uuid": p.perspective_uuid,
"cloned_link_language": cloned_address,
"name": p.name,
"message": "Perspective published as neighbourhood. Share the neighbourhood_url for others to join."
})
.to_string(),
Expand All @@ -96,7 +283,7 @@ impl Ad4mMcpHandler {

/// Join an existing neighbourhood by its URL
#[tool(
description = "Join an existing neighbourhood by its URL. Creates a local perspective that syncs with the shared neighbourhood via its link language. Returns the perspective handle for the joined neighbourhood."
description = "Join an existing neighbourhood by URL. Creates a local perspective that syncs with the shared neighbourhood. Returns the perspective UUID for interacting with the neighbourhood's data."
)]
pub async fn neighbourhood_join_from_url(
&self,
Expand Down Expand Up @@ -131,3 +318,10 @@ impl Ad4mMcpHandler {
}
}
}

// Note: Integration tests for neighbourhood tools are in tests/js/tests/mcp-neighbourhood.test.ts
// These test the actual MCP endpoints with a running executor, including:
// - Tool availability and parameter validation
// - Error handling for missing perspectives
// - Error handling for invalid URLs
// - Backward compatibility with 'link_language' parameter name
33 changes: 33 additions & 0 deletions skills/ad4m/references/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,39 @@ These are always available regardless of SDNA:
| `remove_link` | Remove a link from a perspective |
| `get_models` | List available subject classes (SHACL shapes) |
| `add_model` | Add SHACL SDNA to a perspective |
| `infer` | Run Prolog queries for complex reasoning |
| `language_meta` | Get metadata about a language by address |
| `list_link_language_templates` | List available P2P sync templates for neighbourhoods |
| `neighbourhood_publish_from_perspective` | Publish a perspective as a shared neighbourhood (auto-clones template) |
| `neighbourhood_join_from_url` | Join an existing neighbourhood by URL |

## Language & Neighbourhood Tools

### Inspecting Languages

Use `language_meta` to get information about any language address — name, description, author, template params, source code link.

### Publishing Neighbourhoods

To share a perspective as a P2P neighbourhood:

```
1. list_link_language_templates → get available sync engines
2. neighbourhood_publish_from_perspective(
perspective_uuid,
link_language_template: templates[0].address,
name: "My Neighbourhood"
) → auto-clones template, publishes, returns URL
```

The tool handles link language cloning automatically. Each neighbourhood gets a unique sync instance derived from the template.

### Joining Neighbourhoods

```
neighbourhood_join_from_url(url: "neighbourhood://Qm...")
→ creates local perspective synced with the neighbourhood
```

## Dynamic Tools (from SHACL)

Expand Down