|
|
|
|
@ -0,0 +1,538 @@
|
|
|
|
|
//! Human-gated MCP discovery surface for external programming AIs.
|
|
|
|
|
//!
|
|
|
|
|
//! MCP exposes discovery and readable capability metadata only. Durable sessions,
|
|
|
|
|
//! environment frames and any later mutation stay on HOLOLAKE_TERMINAL_LINK/3.
|
|
|
|
|
|
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
|
use serde_json::{json, Value};
|
|
|
|
|
use std::collections::BTreeMap;
|
|
|
|
|
use std::fs::{self, OpenOptions};
|
|
|
|
|
use std::io::{self, BufRead, Write};
|
|
|
|
|
#[cfg(unix)]
|
|
|
|
|
use std::os::unix::fs::OpenOptionsExt;
|
|
|
|
|
use std::path::{Path, PathBuf};
|
|
|
|
|
use std::time::{SystemTime, UNIX_EPOCH};
|
|
|
|
|
use tauri::{AppHandle, Manager};
|
|
|
|
|
use uuid::Uuid;
|
|
|
|
|
|
|
|
|
|
const CONFIG_NAME: &str = "external-ai-gateway-v1.json";
|
|
|
|
|
const CAPABILITY_CACHE_NAME: &str = "external-ai-capabilities-v1.json";
|
|
|
|
|
const BROKER_DESCRIPTOR_NAME: &str = "direct-local-broker-v1.json";
|
|
|
|
|
const MCP_PROTOCOL_VERSION: &str = "2025-06-18";
|
|
|
|
|
|
|
|
|
|
#[derive(Clone, Debug, Deserialize)]
|
|
|
|
|
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
|
|
|
|
pub struct SetGatewayExposureInput {
|
|
|
|
|
pub enabled: bool,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
|
|
|
|
#[serde(rename_all = "camelCase")]
|
|
|
|
|
struct GatewayConfig {
|
|
|
|
|
schema: String,
|
|
|
|
|
enabled: bool,
|
|
|
|
|
authorized_by_human_number: String,
|
|
|
|
|
updated_at_unix_ms: u64,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
|
|
|
|
#[serde(rename_all = "camelCase")]
|
|
|
|
|
pub struct GatewaySkill {
|
|
|
|
|
pub name: String,
|
|
|
|
|
pub summary: String,
|
|
|
|
|
pub version: String,
|
|
|
|
|
pub state: String,
|
|
|
|
|
pub read_only: bool,
|
|
|
|
|
pub execution_authority: bool,
|
|
|
|
|
pub triggers: Vec<String>,
|
|
|
|
|
pub method: Vec<String>,
|
|
|
|
|
pub constraints: Vec<String>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
|
|
|
|
#[serde(rename_all = "camelCase")]
|
|
|
|
|
pub struct GatewayIntegration {
|
|
|
|
|
pub name: String,
|
|
|
|
|
pub summary: String,
|
|
|
|
|
pub state: String,
|
|
|
|
|
pub exposed: bool,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
|
|
|
|
#[serde(rename_all = "camelCase")]
|
|
|
|
|
pub struct ExternalAiGatewayStatus {
|
|
|
|
|
pub schema: String,
|
|
|
|
|
pub state: String,
|
|
|
|
|
pub exposure: String,
|
|
|
|
|
pub human_authorization_required: bool,
|
|
|
|
|
pub mcp_transport: String,
|
|
|
|
|
pub mcp_protocol_version: String,
|
|
|
|
|
pub mcp_command: String,
|
|
|
|
|
pub direct_protocol: String,
|
|
|
|
|
pub direct_connector_command: String,
|
|
|
|
|
pub broker_state: String,
|
|
|
|
|
pub registered_skill_count: usize,
|
|
|
|
|
pub active_skill_count: usize,
|
|
|
|
|
pub connected_integration_count: usize,
|
|
|
|
|
pub integrations: Vec<GatewayIntegration>,
|
|
|
|
|
pub skills: Vec<GatewaySkill>,
|
|
|
|
|
pub observed_at_unix_ms: u64,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn now_unix_ms() -> u64 {
|
|
|
|
|
SystemTime::now()
|
|
|
|
|
.duration_since(UNIX_EPOCH)
|
|
|
|
|
.map(|duration| duration.as_millis() as u64)
|
|
|
|
|
.unwrap_or(0)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn gateway_root() -> Result<PathBuf, String> {
|
|
|
|
|
if let Some(path) = std::env::var_os("HOLOLAKE_EXTERNAL_AI_GATEWAY_ROOT") {
|
|
|
|
|
return Ok(PathBuf::from(path));
|
|
|
|
|
}
|
|
|
|
|
dirs::data_dir()
|
|
|
|
|
.map(|root| root.join("world.guanghu.hololake"))
|
|
|
|
|
.ok_or_else(|| "HOLOLAKE_APP_DATA_UNAVAILABLE".to_string())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn root_for_app(app: &AppHandle) -> Result<PathBuf, String> {
|
|
|
|
|
app.path()
|
|
|
|
|
.app_data_dir()
|
|
|
|
|
.map_err(|error| format!("HOLOLAKE_APP_DATA_UNAVAILABLE: {error}"))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn config_path(root: &Path) -> PathBuf {
|
|
|
|
|
root.join(CONFIG_NAME)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn capability_cache_path(root: &Path) -> PathBuf {
|
|
|
|
|
root.join(CAPABILITY_CACHE_NAME)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn read_config(root: &Path) -> Result<GatewayConfig, String> {
|
|
|
|
|
let path = config_path(root);
|
|
|
|
|
if !path.exists() {
|
|
|
|
|
return Ok(GatewayConfig {
|
|
|
|
|
schema: "hololake.external-ai-gateway-config/v1".into(),
|
|
|
|
|
enabled: false,
|
|
|
|
|
authorized_by_human_number: String::new(),
|
|
|
|
|
updated_at_unix_ms: 0,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
let config: GatewayConfig = serde_json::from_slice(
|
|
|
|
|
&fs::read(path).map_err(|error| format!("HOLOLAKE_GATEWAY_CONFIG_READ_FAILED: {error}"))?,
|
|
|
|
|
)
|
|
|
|
|
.map_err(|error| format!("HOLOLAKE_GATEWAY_CONFIG_INVALID: {error}"))?;
|
|
|
|
|
if config.schema != "hololake.external-ai-gateway-config/v1" {
|
|
|
|
|
return Err("HOLOLAKE_GATEWAY_CONFIG_UNSUPPORTED".into());
|
|
|
|
|
}
|
|
|
|
|
Ok(config)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn write_json_atomic<T: Serialize>(path: &Path, value: &T) -> Result<(), String> {
|
|
|
|
|
let parent = path.parent().ok_or("HOLOLAKE_GATEWAY_PATH_INVALID")?;
|
|
|
|
|
fs::create_dir_all(parent).map_err(|error| format!("HOLOLAKE_GATEWAY_DIR_FAILED: {error}"))?;
|
|
|
|
|
let temporary = parent.join(format!(".gateway-{}.tmp", Uuid::new_v4()));
|
|
|
|
|
let bytes = serde_json::to_vec_pretty(value)
|
|
|
|
|
.map_err(|error| format!("HOLOLAKE_GATEWAY_SERIALIZE_FAILED: {error}"))?;
|
|
|
|
|
let mut options = OpenOptions::new();
|
|
|
|
|
options.write(true).create_new(true);
|
|
|
|
|
#[cfg(unix)]
|
|
|
|
|
options.mode(0o600);
|
|
|
|
|
let mut file = options
|
|
|
|
|
.open(&temporary)
|
|
|
|
|
.map_err(|error| format!("HOLOLAKE_GATEWAY_WRITE_FAILED: {error}"))?;
|
|
|
|
|
file.write_all(&bytes)
|
|
|
|
|
.and_then(|_| file.sync_all())
|
|
|
|
|
.map_err(|error| format!("HOLOLAKE_GATEWAY_WRITE_FAILED: {error}"))?;
|
|
|
|
|
fs::rename(&temporary, path).map_err(|error| format!("HOLOLAKE_GATEWAY_WRITE_FAILED: {error}"))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn executable_commands() -> (String, String) {
|
|
|
|
|
let executable = std::env::current_exe()
|
|
|
|
|
.map(|path| path.to_string_lossy().into_owned())
|
|
|
|
|
.unwrap_or_else(|_| "HoloLake".into());
|
|
|
|
|
(
|
|
|
|
|
format!("{} --mcp", shell_display(&executable)),
|
|
|
|
|
format!("{} --connector", shell_display(&executable)),
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn shell_display(value: &str) -> String {
|
|
|
|
|
if value.contains(' ') {
|
|
|
|
|
format!("\"{}\"", value.replace('"', "\\\""))
|
|
|
|
|
} else {
|
|
|
|
|
value.into()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn broker_state(root: &Path) -> String {
|
|
|
|
|
let descriptor = fs::read(root.join(BROKER_DESCRIPTOR_NAME))
|
|
|
|
|
.ok()
|
|
|
|
|
.and_then(|raw| serde_json::from_slice::<Value>(&raw).ok());
|
|
|
|
|
match descriptor
|
|
|
|
|
.as_ref()
|
|
|
|
|
.and_then(|value| value.get("state"))
|
|
|
|
|
.and_then(Value::as_str)
|
|
|
|
|
{
|
|
|
|
|
Some("LISTENING") => "READY".into(),
|
|
|
|
|
_ => "WAITING_FOR_LOGIN".into(),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub async fn get_gateway_status(app: AppHandle) -> Result<ExternalAiGatewayStatus, String> {
|
|
|
|
|
let root = root_for_app(&app)?;
|
|
|
|
|
let config = read_config(&root)?;
|
|
|
|
|
let marketplace = crate::online_marketplace::get_marketplace_snapshot(app.clone())
|
|
|
|
|
.await
|
|
|
|
|
.unwrap_or_default();
|
|
|
|
|
let active = crate::online_marketplace::get_active_cognitive_skills(app)
|
|
|
|
|
.await
|
|
|
|
|
.unwrap_or_default();
|
|
|
|
|
let active_by_number = active
|
|
|
|
|
.into_iter()
|
|
|
|
|
.map(|skill| (skill.skill_number.clone(), skill))
|
|
|
|
|
.collect::<BTreeMap<_, _>>();
|
|
|
|
|
let skills = marketplace
|
|
|
|
|
.items
|
|
|
|
|
.iter()
|
|
|
|
|
.filter(|item| item.artifact_kind == "COGNITIVE_SKILL")
|
|
|
|
|
.map(|item| {
|
|
|
|
|
let active = active_by_number.get(&item.item_number);
|
|
|
|
|
GatewaySkill {
|
|
|
|
|
name: item.display_name.clone(),
|
|
|
|
|
summary: item.summary.clone(),
|
|
|
|
|
version: item.version.clone(),
|
|
|
|
|
state: item.installed_state.clone(),
|
|
|
|
|
read_only: true,
|
|
|
|
|
execution_authority: false,
|
|
|
|
|
triggers: active
|
|
|
|
|
.map(|value| value.payload.triggers.clone())
|
|
|
|
|
.unwrap_or_default(),
|
|
|
|
|
method: active
|
|
|
|
|
.map(|value| value.payload.method.clone())
|
|
|
|
|
.unwrap_or_default(),
|
|
|
|
|
constraints: active
|
|
|
|
|
.map(|value| value.payload.constraints.clone())
|
|
|
|
|
.unwrap_or_default(),
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
.collect::<Vec<_>>();
|
|
|
|
|
let exposure = if config.enabled { "OPEN" } else { "CLOSED" };
|
|
|
|
|
let broker = broker_state(&root);
|
|
|
|
|
let integrations = vec![
|
|
|
|
|
GatewayIntegration {
|
|
|
|
|
name: "MCP 标准入口".into(),
|
|
|
|
|
summary: "供外部编程 AI 发现 HoloLake 与读取已注册能力;不承载长期上下文。".into(),
|
|
|
|
|
state: if config.enabled {
|
|
|
|
|
"已开放"
|
|
|
|
|
} else {
|
|
|
|
|
"已关闭"
|
|
|
|
|
}
|
|
|
|
|
.into(),
|
|
|
|
|
exposed: config.enabled,
|
|
|
|
|
},
|
|
|
|
|
GatewayIntegration {
|
|
|
|
|
name: "HoloLake 本地直连协议".into(),
|
|
|
|
|
summary: "MCP 发现后切换到本机私有连接,持续会话与环境回执由 HoloLake 承载。".into(),
|
|
|
|
|
state: if broker == "READY" {
|
|
|
|
|
"已就绪"
|
|
|
|
|
} else {
|
|
|
|
|
"等待登录"
|
|
|
|
|
}
|
|
|
|
|
.into(),
|
|
|
|
|
exposed: config.enabled && broker == "READY",
|
|
|
|
|
},
|
|
|
|
|
GatewayIntegration {
|
|
|
|
|
name: "编号 IPC 授权桥".into(),
|
|
|
|
|
summary: "前端与本机能力只通过完整编号坐标通信,未知路径关闭。".into(),
|
|
|
|
|
state: "已接通".into(),
|
|
|
|
|
exposed: true,
|
|
|
|
|
},
|
|
|
|
|
GatewayIntegration {
|
|
|
|
|
name: "线上模块与技能商城".into(),
|
|
|
|
|
summary: "展示双签目录中的成品模块和只读思维技能。".into(),
|
|
|
|
|
state: if marketplace.state == "ACTIVE_VERIFIED_CATALOG" {
|
|
|
|
|
"目录已验证"
|
|
|
|
|
} else {
|
|
|
|
|
"等待目录同步"
|
|
|
|
|
}
|
|
|
|
|
.into(),
|
|
|
|
|
exposed: marketplace.state == "ACTIVE_VERIFIED_CATALOG",
|
|
|
|
|
},
|
|
|
|
|
];
|
|
|
|
|
let (mcp_command, direct_connector_command) = executable_commands();
|
|
|
|
|
let status = ExternalAiGatewayStatus {
|
|
|
|
|
schema: "hololake.external-ai-gateway-status/v1".into(),
|
|
|
|
|
state: "HUMAN_GATED".into(),
|
|
|
|
|
exposure: exposure.into(),
|
|
|
|
|
human_authorization_required: true,
|
|
|
|
|
mcp_transport: "STDIO_JSON_RPC".into(),
|
|
|
|
|
mcp_protocol_version: MCP_PROTOCOL_VERSION.into(),
|
|
|
|
|
mcp_command,
|
|
|
|
|
direct_protocol: "HOLOLAKE_TERMINAL_LINK/3".into(),
|
|
|
|
|
direct_connector_command,
|
|
|
|
|
broker_state: broker,
|
|
|
|
|
registered_skill_count: skills.len(),
|
|
|
|
|
active_skill_count: skills
|
|
|
|
|
.iter()
|
|
|
|
|
.filter(|skill| skill.state == "ACTIVE_READONLY")
|
|
|
|
|
.count(),
|
|
|
|
|
connected_integration_count: integrations.iter().filter(|item| item.exposed).count(),
|
|
|
|
|
integrations,
|
|
|
|
|
skills,
|
|
|
|
|
observed_at_unix_ms: now_unix_ms(),
|
|
|
|
|
};
|
|
|
|
|
write_json_atomic(&capability_cache_path(&root), &status)?;
|
|
|
|
|
Ok(status)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub async fn set_gateway_exposure(
|
|
|
|
|
app: AppHandle,
|
|
|
|
|
input: SetGatewayExposureInput,
|
|
|
|
|
human_number: &str,
|
|
|
|
|
) -> Result<ExternalAiGatewayStatus, String> {
|
|
|
|
|
let root = root_for_app(&app)?;
|
|
|
|
|
write_json_atomic(
|
|
|
|
|
&config_path(&root),
|
|
|
|
|
&GatewayConfig {
|
|
|
|
|
schema: "hololake.external-ai-gateway-config/v1".into(),
|
|
|
|
|
enabled: input.enabled,
|
|
|
|
|
authorized_by_human_number: human_number.into(),
|
|
|
|
|
updated_at_unix_ms: now_unix_ms(),
|
|
|
|
|
},
|
|
|
|
|
)?;
|
|
|
|
|
get_gateway_status(app).await
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn load_cached_status(root: &Path) -> Result<ExternalAiGatewayStatus, String> {
|
|
|
|
|
serde_json::from_slice(
|
|
|
|
|
&fs::read(capability_cache_path(root))
|
|
|
|
|
.map_err(|error| format!("HOLOLAKE_GATEWAY_CAPABILITY_CACHE_UNAVAILABLE: {error}"))?,
|
|
|
|
|
)
|
|
|
|
|
.map_err(|error| format!("HOLOLAKE_GATEWAY_CAPABILITY_CACHE_INVALID: {error}"))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn jsonrpc_result(id: Value, result: Value) -> Value {
|
|
|
|
|
json!({"jsonrpc": "2.0", "id": id, "result": result})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn jsonrpc_error(id: Value, code: i64, message: &str) -> Value {
|
|
|
|
|
json!({"jsonrpc": "2.0", "id": id, "error": {"code": code, "message": message}})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn tool_result(value: Value) -> Value {
|
|
|
|
|
json!({
|
|
|
|
|
"content": [{"type": "text", "text": serde_json::to_string_pretty(&value).unwrap_or_else(|_| "{}".into())}],
|
|
|
|
|
"isError": false
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn tools_list() -> Value {
|
|
|
|
|
json!({"tools": [
|
|
|
|
|
{"name": "hololake_discover", "description": "发现本机 HoloLake,并取得切换到稳定直连协议的方法。", "inputSchema": {"type": "object", "additionalProperties": false}},
|
|
|
|
|
{"name": "hololake_connection_status", "description": "读取 MCP 开放状态与 HoloLake 本地直连入口状态。", "inputSchema": {"type": "object", "additionalProperties": false}},
|
|
|
|
|
{"name": "hololake_registered_capabilities", "description": "读取人类可理解的官方技能与集成目录;只读技能不获得现实执行权限。", "inputSchema": {"type": "object", "additionalProperties": false}}
|
|
|
|
|
]})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn resources_list(status: &ExternalAiGatewayStatus) -> Value {
|
|
|
|
|
json!({"resources": status.skills.iter().enumerate().map(|(index, skill)| json!({
|
|
|
|
|
"uri": format!("hololake://skills/{index}"),
|
|
|
|
|
"name": skill.name,
|
|
|
|
|
"description": skill.summary,
|
|
|
|
|
"mimeType": "application/json"
|
|
|
|
|
})).collect::<Vec<_>>()})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn handle_mcp_request(root: &Path, request: &Value) -> Option<Value> {
|
|
|
|
|
let id = request.get("id").cloned();
|
|
|
|
|
let method = request
|
|
|
|
|
.get("method")
|
|
|
|
|
.and_then(Value::as_str)
|
|
|
|
|
.unwrap_or_default();
|
|
|
|
|
if id.is_none() {
|
|
|
|
|
return None;
|
|
|
|
|
}
|
|
|
|
|
let id = id.unwrap_or(Value::Null);
|
|
|
|
|
let result = match method {
|
|
|
|
|
"initialize" => json!({
|
|
|
|
|
"protocolVersion": MCP_PROTOCOL_VERSION,
|
|
|
|
|
"capabilities": {"tools": {"listChanged": false}, "resources": {"subscribe": false, "listChanged": false}},
|
|
|
|
|
"serverInfo": {"name": "HoloLake", "version": env!("CARGO_PKG_VERSION")},
|
|
|
|
|
"instructions": "MCP 只负责发现与能力目录。持续协作请切换到 HOLOLAKE_TERMINAL_LINK/3;任何执行仍受人类授权与编号路由约束。"
|
|
|
|
|
}),
|
|
|
|
|
"ping" => json!({}),
|
|
|
|
|
"tools/list" => tools_list(),
|
|
|
|
|
"tools/call" => {
|
|
|
|
|
if let Some(arguments) = request.pointer("/params/arguments") {
|
|
|
|
|
let empty_object = arguments
|
|
|
|
|
.as_object()
|
|
|
|
|
.map(|value| value.is_empty())
|
|
|
|
|
.unwrap_or(false);
|
|
|
|
|
if !empty_object {
|
|
|
|
|
return Some(jsonrpc_error(
|
|
|
|
|
id,
|
|
|
|
|
-32602,
|
|
|
|
|
"HOLOLAKE_MCP_TOOL_ARGUMENTS_NOT_EMPTY",
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
let name = request
|
|
|
|
|
.pointer("/params/name")
|
|
|
|
|
.and_then(Value::as_str)
|
|
|
|
|
.unwrap_or_default();
|
|
|
|
|
let status = match load_cached_status(root) {
|
|
|
|
|
Ok(status) => status,
|
|
|
|
|
Err(error) => return Some(jsonrpc_error(id, -32002, &error)),
|
|
|
|
|
};
|
|
|
|
|
match name {
|
|
|
|
|
"hololake_discover" => tool_result(json!({
|
|
|
|
|
"service": "HoloLake",
|
|
|
|
|
"mcpRole": "DISCOVERY_AND_CAPABILITY_CATALOG",
|
|
|
|
|
"nextProtocol": status.direct_protocol,
|
|
|
|
|
"directConnectorCommand": status.direct_connector_command,
|
|
|
|
|
"continuityOwner": "HOLOLAKE",
|
|
|
|
|
"executionAuthorityGranted": false
|
|
|
|
|
})),
|
|
|
|
|
"hololake_connection_status" => {
|
|
|
|
|
tool_result(serde_json::to_value(&status).unwrap_or(Value::Null))
|
|
|
|
|
}
|
|
|
|
|
"hololake_registered_capabilities" => tool_result(
|
|
|
|
|
json!({"integrations": status.integrations, "skills": status.skills}),
|
|
|
|
|
),
|
|
|
|
|
_ => return Some(jsonrpc_error(id, -32602, "HOLOLAKE_MCP_TOOL_UNKNOWN")),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
"resources/list" => match load_cached_status(root) {
|
|
|
|
|
Ok(status) => resources_list(&status),
|
|
|
|
|
Err(error) => return Some(jsonrpc_error(id, -32002, &error)),
|
|
|
|
|
},
|
|
|
|
|
"resources/read" => {
|
|
|
|
|
let uri = request
|
|
|
|
|
.pointer("/params/uri")
|
|
|
|
|
.and_then(Value::as_str)
|
|
|
|
|
.unwrap_or_default();
|
|
|
|
|
let index = uri
|
|
|
|
|
.strip_prefix("hololake://skills/")
|
|
|
|
|
.and_then(|value| value.parse::<usize>().ok());
|
|
|
|
|
let status = match load_cached_status(root) {
|
|
|
|
|
Ok(status) => status,
|
|
|
|
|
Err(error) => return Some(jsonrpc_error(id, -32002, &error)),
|
|
|
|
|
};
|
|
|
|
|
let Some(skill) = index.and_then(|index| status.skills.get(index)) else {
|
|
|
|
|
return Some(jsonrpc_error(id, -32003, "HOLOLAKE_MCP_RESOURCE_UNKNOWN"));
|
|
|
|
|
};
|
|
|
|
|
json!({"contents": [{"uri": uri, "mimeType": "application/json", "text": serde_json::to_string_pretty(skill).unwrap_or_else(|_| "{}".into())}]})
|
|
|
|
|
}
|
|
|
|
|
_ => return Some(jsonrpc_error(id, -32601, "HOLOLAKE_MCP_METHOD_UNKNOWN")),
|
|
|
|
|
};
|
|
|
|
|
Some(jsonrpc_result(id, result))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn run_mcp() -> Result<(), String> {
|
|
|
|
|
let root = gateway_root()?;
|
|
|
|
|
if !read_config(&root)?.enabled {
|
|
|
|
|
return Err("HOLOLAKE_MCP_EXPOSURE_CLOSED".into());
|
|
|
|
|
}
|
|
|
|
|
let stdin = io::stdin();
|
|
|
|
|
let mut stdout = io::stdout().lock();
|
|
|
|
|
for line in stdin.lock().lines() {
|
|
|
|
|
let line = line.map_err(|error| format!("HOLOLAKE_MCP_INPUT_FAILED: {error}"))?;
|
|
|
|
|
if line.trim().is_empty() {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
let request: Value = match serde_json::from_str(&line) {
|
|
|
|
|
Ok(value) => value,
|
|
|
|
|
Err(_) => {
|
|
|
|
|
serde_json::to_writer(
|
|
|
|
|
&mut stdout,
|
|
|
|
|
&jsonrpc_error(Value::Null, -32700, "Parse error"),
|
|
|
|
|
)
|
|
|
|
|
.map_err(|error| format!("HOLOLAKE_MCP_OUTPUT_FAILED: {error}"))?;
|
|
|
|
|
stdout
|
|
|
|
|
.write_all(b"\n")
|
|
|
|
|
.map_err(|error| format!("HOLOLAKE_MCP_OUTPUT_FAILED: {error}"))?;
|
|
|
|
|
stdout
|
|
|
|
|
.flush()
|
|
|
|
|
.map_err(|error| format!("HOLOLAKE_MCP_OUTPUT_FAILED: {error}"))?;
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
if let Some(response) = handle_mcp_request(&root, &request) {
|
|
|
|
|
serde_json::to_writer(&mut stdout, &response)
|
|
|
|
|
.map_err(|error| format!("HOLOLAKE_MCP_OUTPUT_FAILED: {error}"))?;
|
|
|
|
|
stdout
|
|
|
|
|
.write_all(b"\n")
|
|
|
|
|
.map_err(|error| format!("HOLOLAKE_MCP_OUTPUT_FAILED: {error}"))?;
|
|
|
|
|
stdout
|
|
|
|
|
.flush()
|
|
|
|
|
.map_err(|error| format!("HOLOLAKE_MCP_OUTPUT_FAILED: {error}"))?;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
|
|
|
|
use super::*;
|
|
|
|
|
use tempfile::TempDir;
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn missing_config_fails_closed() {
|
|
|
|
|
let root = TempDir::new().unwrap();
|
|
|
|
|
assert!(!read_config(root.path()).unwrap().enabled);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn unknown_mcp_tool_fails_closed() {
|
|
|
|
|
let root = TempDir::new().unwrap();
|
|
|
|
|
let status = ExternalAiGatewayStatus {
|
|
|
|
|
schema: "hololake.external-ai-gateway-status/v1".into(),
|
|
|
|
|
state: "HUMAN_GATED".into(),
|
|
|
|
|
exposure: "OPEN".into(),
|
|
|
|
|
human_authorization_required: true,
|
|
|
|
|
mcp_transport: "STDIO_JSON_RPC".into(),
|
|
|
|
|
mcp_protocol_version: MCP_PROTOCOL_VERSION.into(),
|
|
|
|
|
mcp_command: "HoloLake --mcp".into(),
|
|
|
|
|
direct_protocol: "HOLOLAKE_TERMINAL_LINK/3".into(),
|
|
|
|
|
direct_connector_command: "HoloLake --connector".into(),
|
|
|
|
|
broker_state: "READY".into(),
|
|
|
|
|
registered_skill_count: 0,
|
|
|
|
|
active_skill_count: 0,
|
|
|
|
|
connected_integration_count: 0,
|
|
|
|
|
integrations: vec![],
|
|
|
|
|
skills: vec![],
|
|
|
|
|
observed_at_unix_ms: 1,
|
|
|
|
|
};
|
|
|
|
|
write_json_atomic(&capability_cache_path(root.path()), &status).unwrap();
|
|
|
|
|
let response = handle_mcp_request(
|
|
|
|
|
root.path(),
|
|
|
|
|
&json!({"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"shell"}}),
|
|
|
|
|
)
|
|
|
|
|
.unwrap();
|
|
|
|
|
assert_eq!(
|
|
|
|
|
response.pointer("/error/message").and_then(Value::as_str),
|
|
|
|
|
Some("HOLOLAKE_MCP_TOOL_UNKNOWN")
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn tool_arguments_fail_closed() {
|
|
|
|
|
let root = TempDir::new().unwrap();
|
|
|
|
|
let response = handle_mcp_request(
|
|
|
|
|
root.path(),
|
|
|
|
|
&json!({
|
|
|
|
|
"jsonrpc":"2.0",
|
|
|
|
|
"id":1,
|
|
|
|
|
"method":"tools/call",
|
|
|
|
|
"params":{"name":"hololake_discover","arguments":{"shell":true}}
|
|
|
|
|
}),
|
|
|
|
|
)
|
|
|
|
|
.unwrap();
|
|
|
|
|
assert_eq!(
|
|
|
|
|
response.pointer("/error/message").and_then(Value::as_str),
|
|
|
|
|
Some("HOLOLAKE_MCP_TOOL_ARGUMENTS_NOT_EMPTY")
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|