diff --git a/dashboard/src/api/modules/connectors.ts b/dashboard/src/api/modules/connectors.ts index df2e39708..9a0ccbf0c 100644 --- a/dashboard/src/api/modules/connectors.ts +++ b/dashboard/src/api/modules/connectors.ts @@ -18,7 +18,7 @@ export interface ConnectorCatalogEntry { icon: string; color: string; phase: "available" | "coming_soon"; - mcp_mode: "remote" | "gateway" | "internal"; + mcp_mode: "remote" | "gateway" | "internal" | "stdio"; category: ConnectorCategory; quick_auth_url?: string | null; login_url?: string | null; diff --git a/src/octop/infra/connectors/builder.py b/src/octop/infra/connectors/builder.py index c93cd8fdc..828f912fa 100644 --- a/src/octop/infra/connectors/builder.py +++ b/src/octop/infra/connectors/builder.py @@ -118,6 +118,10 @@ def _build_stdio_spec(entry: ConnectorCatalogEntry, creds: dict[str, Any]) -> di creds.get("token") or creds.get("api_key") or creds.get("personal_token") or "" ).strip() base_url = str(creds.get("base_url") or "https://www.yuque.com/api/v2").strip() + # The Yuque Open API lives under /api/v2; a bare site root would send + # requests to the marketing homepage, so append the path when missing. + if not base_url.endswith("/api/v2"): + base_url = base_url.rstrip("/") + "/api/v2" return { "transport": "stdio", "command": "npx", @@ -529,6 +533,16 @@ def _redact_mcp_configs_for_log(configs: dict[str, Any]) -> dict[str, Any]: if key in redacted: redacted[key] = "***" entry["headers"] = redacted + env = entry.get("env") + if isinstance(env, dict): + redacted_env = dict(env) + for env_key in redacted_env: + if any( + marker in env_key.upper() + for marker in ("TOKEN", "SECRET", "KEY", "PASSWORD", "CREDENTIAL") + ): + redacted_env[env_key] = "***" + entry["env"] = redacted_env for key, value in list(entry.items()): if callable(value): entry[key] = f"" diff --git a/tests/unit/test_connectors.py b/tests/unit/test_connectors.py index 953ffe663..d30e4b891 100644 --- a/tests/unit/test_connectors.py +++ b/tests/unit/test_connectors.py @@ -287,6 +287,53 @@ def test_dify_builds_streamable_http_spec(): } +def test_yuque_mcp_builds_stdio_spec(): + entry = get_catalog_entry("yuque-mcp") + assert entry is not None + assert entry.mcp_mode == "stdio" + creds = validate_create_credentials("yuque-mcp", {"token": "yuque-token"}) + assert creds == {"token": "yuque-token"} + spec = build_http_mcp_spec( + entry=entry, + instance_id="x", + creds=creds, + config=OctopConfig(), + ) + assert spec == { + "transport": "stdio", + "command": "npx", + "args": ["-y", "yuque-mcp"], + "env": { + "YUQUE_PERSONAL_TOKEN": "yuque-token", + "YUQUE_BASE_URL": "https://www.yuque.com/api/v2", + }, + } + + +def test_yuque_mcp_stdio_spec_appends_api_v2_to_site_root(): + entry = get_catalog_entry("yuque-mcp") + assert entry is not None + spec = build_http_mcp_spec( + entry=entry, + instance_id="x", + creds={"token": "t", "base_url": "https://www.yuque.com"}, + config=OctopConfig(), + ) + assert spec["env"]["YUQUE_BASE_URL"] == "https://www.yuque.com/api/v2" + + +def test_yuque_mcp_stdio_spec_keeps_explicit_api_v2(): + entry = get_catalog_entry("yuque-mcp") + assert entry is not None + spec = build_http_mcp_spec( + entry=entry, + instance_id="x", + creds={"token": "t", "base_url": "https://www.yuque.com/api/v2"}, + config=OctopConfig(), + ) + assert spec["env"]["YUQUE_BASE_URL"] == "https://www.yuque.com/api/v2" + + def test_dify_rejects_non_server_url(): with pytest.raises(ValueError, match="Dify MCP Server URL"): validate_create_credentials("dify", {"mcp_url": "https://dify.example.com/v1/chat"}) @@ -306,6 +353,27 @@ def test_dify_server_code_is_redacted_from_logs(): assert redacted["dify__x"]["url"] == "https://dify.example.com/mcp/server/***/mcp" +def test_stdio_spec_env_tokens_are_redacted_from_logs(): + from octop.infra.connectors.builder import _redact_mcp_configs_for_log + + redacted = _redact_mcp_configs_for_log( + { + "yuque-mcp__x": { + "transport": "stdio", + "command": "npx", + "args": ["-y", "yuque-mcp"], + "env": { + "YUQUE_PERSONAL_TOKEN": "top-secret-token", + "YUQUE_BASE_URL": "https://www.yuque.com/api/v2", + }, + } + } + ) + env = redacted["yuque-mcp__x"]["env"] + assert env["YUQUE_PERSONAL_TOKEN"] == "***" + assert env["YUQUE_BASE_URL"] == "https://www.yuque.com/api/v2" + + def test_didi_builds_streamable_http_spec_from_user_key(): entry = get_catalog_entry("didi") assert entry is not None