feat(mcp): configure enabled tool groups
Add OBSIDIAN_MCP_ENABLED_TOOLS as the service config source for MCP tool groups while keeping OBSIDIAN_MCP_ENABLE_INGEST as a legacy fallback. Add mcp_service.sh tools list/enable/disable commands, preserve enabled tools across normal start and token rotation, and let start --enable-ingest/--disable-ingest update the same config file. Teach the MCP runtime to derive file-assets and ingest registration from enabled tool groups so file asset tools can be disabled separately from ingest. Validation: python3 -m unittest discover -v; python3 -m py_compile scripts/manage_mcp_service.py obsidian_wiki/mcp_server.py tests/test_mcp_service.py tests/test_mcp_server_remote.py; CLI tools enable/disable dry run; git diff --check.
This commit is contained in:
@@ -78,6 +78,20 @@ class RemoteMcpServerTests(unittest.TestCase):
|
||||
with patch.dict(os.environ, {"OBSIDIAN_MCP_PORT": "not-an-int"}, clear=True):
|
||||
self.assertEqual(mcp_server.runtime_config_int("OBSIDIAN_MCP_PORT", 8765), 8765)
|
||||
|
||||
def test_enabled_tools_config_prefers_tool_list_over_legacy_ingest_flag(self):
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"OBSIDIAN_MCP_ENABLED_TOOLS": "file-assets",
|
||||
"OBSIDIAN_MCP_ENABLE_INGEST": "1",
|
||||
},
|
||||
clear=True,
|
||||
):
|
||||
self.assertEqual(mcp_server.enabled_tool_names(), {"file-assets"})
|
||||
|
||||
with patch.dict(os.environ, {"OBSIDIAN_MCP_ENABLE_INGEST": "1"}, clear=True):
|
||||
self.assertEqual(mcp_server.enabled_tool_names(), {"file-assets", "ingest"})
|
||||
|
||||
def test_create_http_app_wraps_streamable_http_app_with_auth(self):
|
||||
class FakeServer:
|
||||
def __init__(self):
|
||||
@@ -188,6 +202,47 @@ class RemoteMcpServerTests(unittest.TestCase):
|
||||
self.assertIn("cancel_ingest_job", FakeMCP.tool_names)
|
||||
self.assertIn("check_ingest_opencode_readiness", FakeMCP.tool_names)
|
||||
|
||||
def test_create_server_can_disable_file_asset_tools(self):
|
||||
class FakeMCP:
|
||||
tool_names = []
|
||||
prompt_names = []
|
||||
|
||||
def __init__(self, _name, **_kwargs):
|
||||
pass
|
||||
|
||||
def tool(self):
|
||||
def decorator(func):
|
||||
self.tool_names.append(func.__name__)
|
||||
return func
|
||||
|
||||
return decorator
|
||||
|
||||
def resource(self, *_args, **_kwargs):
|
||||
def decorator(func):
|
||||
return func
|
||||
|
||||
return decorator
|
||||
|
||||
def prompt(self, name=None, **_kwargs):
|
||||
def decorator(func):
|
||||
self.prompt_names.append(name or func.__name__)
|
||||
return func
|
||||
|
||||
return decorator
|
||||
|
||||
with (
|
||||
tempfile.TemporaryDirectory() as tmp,
|
||||
patch.dict("sys.modules", {"mcp.server.fastmcp": type("Mod", (), {"FastMCP": FakeMCP})()}),
|
||||
):
|
||||
server = mcp_server.create_server(vault_path=Path(tmp), enable_file_assets=False)
|
||||
|
||||
self.assertIsInstance(server, FakeMCP)
|
||||
self.assertIn("file_index_status", FakeMCP.tool_names)
|
||||
self.assertNotIn("search_file_assets", FakeMCP.tool_names)
|
||||
self.assertNotIn("list_file_assets", FakeMCP.tool_names)
|
||||
self.assertNotIn("get_file_asset_metadata", FakeMCP.tool_names)
|
||||
self.assertNotIn("how_to_use_wiki_file_assets", FakeMCP.prompt_names)
|
||||
|
||||
def test_run_http_server_fails_before_server_creation_without_token(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
|
||||
@@ -67,6 +67,7 @@ class McpServiceConfigTests(unittest.TestCase):
|
||||
self.assertIn("OBSIDIAN_MCP_HOST=0.0.0.0\n", content)
|
||||
self.assertIn("OBSIDIAN_MCP_PORT=8765\n", content)
|
||||
self.assertIn("OBSIDIAN_MCP_PATH=/mcp\n", content)
|
||||
self.assertIn("OBSIDIAN_MCP_ENABLED_TOOLS=file-assets,ingest\n", content)
|
||||
self.assertIn("OBSIDIAN_MCP_ENABLE_INGEST=1\n", content)
|
||||
self.assertEqual(stat.S_IMODE(os.stat(env_path).st_mode), 0o600)
|
||||
self.assertEqual(stat.S_IMODE(os.stat(env_path.parent).st_mode), 0o700)
|
||||
@@ -176,6 +177,8 @@ class McpServiceConfigTests(unittest.TestCase):
|
||||
self.assertIn("EnvironmentFile=", unit_content)
|
||||
self.assertIn("http://10.0.0.2:8765/mcp", output)
|
||||
self.assertIn("Bearer saved-token", output)
|
||||
env_content = (project_root / ".runtime" / "obsidian-wiki-mcp.env").read_text(encoding="utf-8")
|
||||
self.assertIn("OBSIDIAN_MCP_ENABLED_TOOLS=file-assets\n", env_content)
|
||||
generate_token.assert_not_called()
|
||||
prepare_runtime.assert_called_once_with(project_root, uv_path=Path("/home/nac/.local/bin/uv"))
|
||||
run_systemctl.assert_has_calls(
|
||||
@@ -212,6 +215,7 @@ class McpServiceConfigTests(unittest.TestCase):
|
||||
self.assertIn("OBSIDIAN_MCP_TOKEN=created-token\n", env_content)
|
||||
self.assertIn("OBSIDIAN_MCP_PORT=9876\n", env_content)
|
||||
self.assertIn("OBSIDIAN_MCP_PATH=/wiki-mcp\n", env_content)
|
||||
self.assertIn("OBSIDIAN_MCP_ENABLED_TOOLS=file-assets,ingest\n", env_content)
|
||||
self.assertIn("OBSIDIAN_MCP_ENABLE_INGEST=1\n", env_content)
|
||||
self.assertIn("http://10.0.0.4:9876/wiki-mcp", output)
|
||||
self.assertIn("Authorization: Bearer created-token", output)
|
||||
@@ -225,6 +229,83 @@ class McpServiceConfigTests(unittest.TestCase):
|
||||
]
|
||||
)
|
||||
|
||||
def test_start_service_preserves_saved_enabled_tools_when_flag_is_omitted(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
project_root = Path(tmp) / "project"
|
||||
project_root.mkdir()
|
||||
config_home = Path(tmp) / "config"
|
||||
manage_mcp_service.write_token_env_file(
|
||||
project_root / ".runtime" / "obsidian-wiki-mcp.env",
|
||||
"saved-token",
|
||||
host="0.0.0.0",
|
||||
port=8765,
|
||||
path="/mcp",
|
||||
enabled_tools=("file-assets", "ingest"),
|
||||
)
|
||||
|
||||
with (
|
||||
patch.dict("os.environ", {"XDG_CONFIG_HOME": str(config_home)}),
|
||||
patch.object(manage_mcp_service, "detect_remote_hosts", return_value=["10.0.0.2"]),
|
||||
patch.object(manage_mcp_service, "find_uv_path", return_value=Path("/home/nac/.local/bin/uv")),
|
||||
patch.object(manage_mcp_service, "resolve_vault_for_service", return_value=Path("/data/bjd-vault")),
|
||||
patch.object(manage_mcp_service, "prepare_runtime"),
|
||||
patch.object(manage_mcp_service, "run_systemctl"),
|
||||
):
|
||||
manage_mcp_service.start_service(project_root=project_root)
|
||||
|
||||
env_content = (project_root / ".runtime" / "obsidian-wiki-mcp.env").read_text(encoding="utf-8")
|
||||
self.assertIn("OBSIDIAN_MCP_ENABLED_TOOLS=file-assets,ingest\n", env_content)
|
||||
self.assertIn("OBSIDIAN_MCP_ENABLE_INGEST=1\n", env_content)
|
||||
|
||||
def test_main_tools_enable_disable_updates_enabled_tools_in_config(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
project_root = Path(tmp) / "project"
|
||||
project_root.mkdir()
|
||||
config_home = Path(tmp) / "config"
|
||||
|
||||
with (
|
||||
patch.dict("os.environ", {"XDG_CONFIG_HOME": str(config_home)}),
|
||||
patch.object(manage_mcp_service, "generate_token", return_value="created-token"),
|
||||
patch.object(manage_mcp_service, "detect_remote_hosts", return_value=["10.0.0.2"]),
|
||||
patch("builtins.print") as print_fn,
|
||||
):
|
||||
enable_status = manage_mcp_service.main(
|
||||
["tools", "enable", "ingest", "--project-root", str(project_root)]
|
||||
)
|
||||
|
||||
self.assertEqual(enable_status, 0)
|
||||
env_content = (project_root / ".runtime" / "obsidian-wiki-mcp.env").read_text(encoding="utf-8")
|
||||
self.assertIn("OBSIDIAN_MCP_ENABLED_TOOLS=file-assets,ingest\n", env_content)
|
||||
self.assertIn("OBSIDIAN_MCP_ENABLE_INGEST=1\n", env_content)
|
||||
self.assertIn("ingest: enabled", print_fn.call_args.args[0])
|
||||
|
||||
with (
|
||||
patch.dict("os.environ", {"XDG_CONFIG_HOME": str(config_home)}),
|
||||
patch("builtins.print") as print_fn,
|
||||
):
|
||||
disable_status = manage_mcp_service.main(
|
||||
["tools", "disable", "ingest", "--project-root", str(project_root)]
|
||||
)
|
||||
|
||||
self.assertEqual(disable_status, 0)
|
||||
env_content = (project_root / ".runtime" / "obsidian-wiki-mcp.env").read_text(encoding="utf-8")
|
||||
self.assertIn("OBSIDIAN_MCP_ENABLED_TOOLS=file-assets\n", env_content)
|
||||
self.assertIn("OBSIDIAN_MCP_ENABLE_INGEST=0\n", env_content)
|
||||
self.assertIn("ingest: disabled", print_fn.call_args.args[0])
|
||||
|
||||
with (
|
||||
patch.dict("os.environ", {"XDG_CONFIG_HOME": str(config_home)}),
|
||||
patch("builtins.print") as print_fn,
|
||||
):
|
||||
disable_files_status = manage_mcp_service.main(
|
||||
["tools", "disable", "file-assets", "--project-root", str(project_root)]
|
||||
)
|
||||
|
||||
self.assertEqual(disable_files_status, 0)
|
||||
env_content = (project_root / ".runtime" / "obsidian-wiki-mcp.env").read_text(encoding="utf-8")
|
||||
self.assertIn("OBSIDIAN_MCP_ENABLED_TOOLS=\n", env_content)
|
||||
self.assertIn("file-assets: disabled", print_fn.call_args.args[0])
|
||||
|
||||
def test_main_start_passes_enable_ingest_to_start_service(self):
|
||||
with (
|
||||
patch.object(manage_mcp_service, "start_service", return_value="config\n") as start_service,
|
||||
|
||||
Reference in New Issue
Block a user