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:
@@ -48,6 +48,7 @@ OBSIDIAN_MCP_TOKEN=
|
||||
OBSIDIAN_MCP_HOST=127.0.0.1
|
||||
OBSIDIAN_MCP_PORT=8765
|
||||
OBSIDIAN_MCP_PATH=/mcp
|
||||
OBSIDIAN_MCP_ENABLED_TOOLS=file-assets
|
||||
|
||||
# Optional write-capable MCP ingest tools. Default is disabled.
|
||||
# When enabled, remote MCP clients sharing this token can upload files and
|
||||
@@ -57,6 +58,8 @@ OBSIDIAN_MCP_PATH=/mcp
|
||||
# project wiki-ingest skill without editing files.
|
||||
# Keep this runtime root outside OBSIDIAN_VAULT_PATH; uploads are staging inputs,
|
||||
# and wiki-ingest decides how sources are archived into the vault.
|
||||
# Prefer OBSIDIAN_MCP_ENABLED_TOOLS=file-assets,ingest for new configs. This
|
||||
# legacy flag is still read when OBSIDIAN_MCP_ENABLED_TOOLS is absent.
|
||||
OBSIDIAN_MCP_ENABLE_INGEST=0
|
||||
OBSIDIAN_MCP_INGEST_MAX_FILE_BYTES=104857600
|
||||
OBSIDIAN_MCP_INGEST_MAX_CHUNK_BYTES=4194304
|
||||
|
||||
@@ -130,7 +130,7 @@ See `wiki-query` and `wiki-export` skills for how the filter is applied.
|
||||
- **Keep product lookup fields explicit.** Carry forward `bjd_part_numbers`, `competitor_part_numbers`, `series`, `pitch`, `pin_count`, `product_lines`, `customers`, `competitors`, `standards`, `business_categories`, `related_pages`, and `catalog_paths` whenever they are known.
|
||||
- **Merge, do not overwrite.** Automatic identifier extraction may add terms, but it must not discard curated registry metadata. Registry values are the durable source for future index rebuilds and remote MCP lookup.
|
||||
- **Prefer precise identifiers in search ranking.** Exact or prefix matches in structured part-number and series fields should rank above incidental filename substrings such as timestamps or archive hashes.
|
||||
- **Keep remote ingest gated.** MCP ingest tools are write-capable and must remain disabled unless `OBSIDIAN_MCP_ENABLE_INGEST=1` or `mcp_service.sh start --enable-ingest` is explicitly used. Uploaded files must stage outside the vault under the MCP runtime root; `wiki-ingest` is responsible for accepting, archiving, and indexing sources inside the vault. They may launch local OpenCode only through the fixed `/wiki-ingest` prompt; never expose arbitrary prompt or shell execution through MCP. Remote ingest must keep the OpenCode readiness preflight in front of job launch, and `check_ingest_opencode_readiness(run_smoke_test=true)` should stay available for operators to verify that OpenCode can load the project `wiki-ingest` skill before accepting uploads.
|
||||
- **Keep remote ingest gated.** MCP ingest tools are write-capable and must remain disabled unless the service config explicitly enables the `ingest` tool group via `OBSIDIAN_MCP_ENABLED_TOOLS=file-assets,ingest`, `mcp_service.sh tools enable ingest`, or `mcp_service.sh start --enable-ingest`. Uploaded files must stage outside the vault under the MCP runtime root; `wiki-ingest` is responsible for accepting, archiving, and indexing sources inside the vault. They may launch local OpenCode only through the fixed `/wiki-ingest` prompt; never expose arbitrary prompt or shell execution through MCP. Remote ingest must keep the OpenCode readiness preflight in front of job launch, and `check_ingest_opencode_readiness(run_smoke_test=true)` should stay available for operators to verify that OpenCode can load the project `wiki-ingest` skill before accepting uploads.
|
||||
|
||||
## Core Principles
|
||||
|
||||
|
||||
@@ -368,7 +368,7 @@ For long-running access from another machine, use the managed user service:
|
||||
bash scripts/mcp_service.sh start
|
||||
```
|
||||
|
||||
`mcp_service.sh start` creates `.runtime/obsidian-wiki-mcp.env` with `0600` permissions if it does not exist, writes `~/.config/systemd/user/obsidian-wiki-mcp.service`, starts the service, and prints the remote URL, `Authorization` header, and client JSON to copy into another agent. It serves the configured `OBSIDIAN_VAULT_PATH` by default; pass `--vault /path/to/vault` to override. The service defaults to `0.0.0.0:8765/mcp` because it is intended for remote clients, but it still requires the bearer token. Prefer SSH tunnel, Tailscale, WireGuard, or another trusted network boundary instead of directly exposing the port to the public internet.
|
||||
`mcp_service.sh start` creates `.runtime/obsidian-wiki-mcp.env` with `0600` permissions if it does not exist, writes `~/.config/systemd/user/obsidian-wiki-mcp.service`, starts the service, and prints the remote URL, `Authorization` header, enabled tool groups, config file path, and client JSON to copy into another agent. It serves the configured `OBSIDIAN_VAULT_PATH` by default; pass `--vault /path/to/vault` to override. The service defaults to `0.0.0.0:8765/mcp` because it is intended for remote clients, but it still requires the bearer token. Prefer SSH tunnel, Tailscale, WireGuard, or another trusted network boundary instead of directly exposing the port to the public internet.
|
||||
|
||||
Customize the first startup if needed:
|
||||
|
||||
@@ -376,12 +376,28 @@ Customize the first startup if needed:
|
||||
bash scripts/mcp_service.sh start --vault /path/to/vault --host 0.0.0.0 --port 9876 --path /wiki-mcp
|
||||
```
|
||||
|
||||
To let a remote MCP client upload files and ask this machine's local OpenCode to run the normal `wiki-ingest` workflow, start the same service with ingest tools enabled:
|
||||
Tool groups are controlled by the same config file:
|
||||
|
||||
```bash
|
||||
OBSIDIAN_MCP_ENABLED_TOOLS=file-assets
|
||||
```
|
||||
|
||||
Edit that line directly, or use the service command:
|
||||
|
||||
```bash
|
||||
bash scripts/mcp_service.sh tools list
|
||||
bash scripts/mcp_service.sh tools enable ingest
|
||||
bash scripts/mcp_service.sh tools disable ingest
|
||||
```
|
||||
|
||||
To let a remote MCP client upload files and ask this machine's local OpenCode to run the normal `wiki-ingest` workflow, enable the `ingest` tool group and restart/start the same service:
|
||||
|
||||
```bash
|
||||
bash scripts/mcp_service.sh start --enable-ingest
|
||||
```
|
||||
|
||||
After `ingest` is enabled once, later `bash scripts/mcp_service.sh start` preserves the config file setting. Use `bash scripts/mcp_service.sh start --disable-ingest` or `bash scripts/mcp_service.sh tools disable ingest` to turn it off.
|
||||
|
||||
This adds write-capable MCP tools to the same `wiki-file-assets` server and bearer token. The server is tool-first: some MCP clients may show empty `resources` or `prompts` lists until a concrete `asset://source/sha256/...` URI is returned by `get_file_asset_metadata`, so use the tool list as the source of truth.
|
||||
|
||||
- `create_ingest_upload` — reserves a controlled runtime staging path for one uploaded file.
|
||||
@@ -405,6 +421,9 @@ Useful service commands:
|
||||
|
||||
```bash
|
||||
bash scripts/mcp_service.sh show-config
|
||||
bash scripts/mcp_service.sh tools list
|
||||
bash scripts/mcp_service.sh tools enable ingest
|
||||
bash scripts/mcp_service.sh tools disable ingest
|
||||
bash scripts/mcp_service.sh stop
|
||||
bash scripts/mcp_service.sh uninstall
|
||||
bash scripts/mcp_service.sh status
|
||||
|
||||
+138
-85
@@ -38,6 +38,46 @@ DEFAULT_HTTP_PORT = 8765
|
||||
DEFAULT_HTTP_PATH = "/mcp"
|
||||
DEFAULT_TOKEN_ENV = "OBSIDIAN_MCP_TOKEN"
|
||||
MCP_SERVER_NAME = "wiki-file-assets"
|
||||
TOOL_FILE_ASSETS = "file-assets"
|
||||
TOOL_INGEST = "ingest"
|
||||
DEFAULT_ENABLED_TOOLS = {TOOL_FILE_ASSETS}
|
||||
AVAILABLE_TOOLS = {TOOL_FILE_ASSETS, TOOL_INGEST}
|
||||
|
||||
|
||||
def normalize_tool_name(name: str) -> str:
|
||||
normalized = name.strip().lower().replace("_", "-")
|
||||
aliases = {
|
||||
"file": TOOL_FILE_ASSETS,
|
||||
"files": TOOL_FILE_ASSETS,
|
||||
"file-assets": TOOL_FILE_ASSETS,
|
||||
"source-file": TOOL_FILE_ASSETS,
|
||||
"source-files": TOOL_FILE_ASSETS,
|
||||
"ingest": TOOL_INGEST,
|
||||
"upload": TOOL_INGEST,
|
||||
"uploads": TOOL_INGEST,
|
||||
"opencode-ingest": TOOL_INGEST,
|
||||
}
|
||||
if normalized not in aliases:
|
||||
raise ValueError(f"unknown MCP tool group: {name}")
|
||||
return aliases[normalized]
|
||||
|
||||
|
||||
def parse_enabled_tools(raw: str | None) -> set[str]:
|
||||
if raw is None or not raw.strip():
|
||||
return set(DEFAULT_ENABLED_TOOLS)
|
||||
if raw.strip().lower() in {"none", "off", "disabled"}:
|
||||
return set()
|
||||
return {normalize_tool_name(part) for part in raw.split(",") if part.strip()}
|
||||
|
||||
|
||||
def enabled_tool_names() -> set[str]:
|
||||
configured = runtime_config_value("OBSIDIAN_MCP_ENABLED_TOOLS", "")
|
||||
if configured:
|
||||
return parse_enabled_tools(configured)
|
||||
enabled = set(DEFAULT_ENABLED_TOOLS)
|
||||
if parse_bool(runtime_config_value("OBSIDIAN_MCP_ENABLE_INGEST", "0")):
|
||||
enabled.add(TOOL_INGEST)
|
||||
return enabled
|
||||
|
||||
|
||||
def bearer_token(authorization: str) -> str:
|
||||
@@ -89,6 +129,7 @@ def create_server(
|
||||
streamable_http_path: str = DEFAULT_HTTP_PATH,
|
||||
allowed_hosts: list[str] | None = None,
|
||||
allowed_origins: list[str] | None = None,
|
||||
enable_file_assets: bool = True,
|
||||
enable_ingest: bool = False,
|
||||
project_root: str | Path | None = None,
|
||||
ingest_runtime_root: str | Path | None = None,
|
||||
@@ -144,6 +185,10 @@ def create_server(
|
||||
"profile_name": profile_name,
|
||||
"max_file_bytes": max_file_bytes,
|
||||
"expose_paths": expose_paths,
|
||||
"enabled_tools": sorted(
|
||||
([TOOL_FILE_ASSETS] if enable_file_assets else [])
|
||||
+ ([TOOL_INGEST] if enable_ingest else [])
|
||||
),
|
||||
"ingest_enabled": enable_ingest,
|
||||
}
|
||||
|
||||
@@ -211,99 +256,101 @@ def create_server(
|
||||
"""Check whether local OpenCode can load the wiki-ingest skill for MCP ingest jobs."""
|
||||
return ingest_manager.check_opencode_readiness(run_smoke_test=run_smoke_test)
|
||||
|
||||
@server.tool()
|
||||
def search_file_assets(
|
||||
query: str,
|
||||
doc_type: str | None = None,
|
||||
source_type: str | None = None,
|
||||
product_line: str | None = None,
|
||||
customer: str | None = None,
|
||||
competitor: str | None = None,
|
||||
limit: int = 10,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Search safe engineering source files such as drawings, 3D models, specs, and test reports."""
|
||||
return _as_mcp_results(
|
||||
core_search_file_assets(
|
||||
vault,
|
||||
query,
|
||||
db_path=db,
|
||||
limit=limit,
|
||||
doc_type=doc_type,
|
||||
source_type=source_type,
|
||||
product_line=product_line,
|
||||
customer=customer,
|
||||
competitor=competitor,
|
||||
profile_name=profile_name,
|
||||
expose_paths=expose_paths,
|
||||
)
|
||||
)
|
||||
if enable_file_assets:
|
||||
|
||||
@server.tool()
|
||||
def list_file_assets(
|
||||
doc_type: str | None = None,
|
||||
source_type: str | None = None,
|
||||
product_line: str | None = None,
|
||||
customer: str | None = None,
|
||||
competitor: str | None = None,
|
||||
limit: int = 10,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""List safe source files matching optional filters. Use search_file_assets when the user gives keywords such as product codes, sizes, or drawing names."""
|
||||
return _as_mcp_results(
|
||||
core_search_file_assets(
|
||||
vault,
|
||||
"",
|
||||
db_path=db,
|
||||
limit=limit,
|
||||
doc_type=doc_type,
|
||||
source_type=source_type,
|
||||
product_line=product_line,
|
||||
customer=customer,
|
||||
competitor=competitor,
|
||||
profile_name=profile_name,
|
||||
expose_paths=expose_paths,
|
||||
@server.tool()
|
||||
def search_file_assets(
|
||||
query: str,
|
||||
doc_type: str | None = None,
|
||||
source_type: str | None = None,
|
||||
product_line: str | None = None,
|
||||
customer: str | None = None,
|
||||
competitor: str | None = None,
|
||||
limit: int = 10,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Search safe engineering source files such as drawings, 3D models, specs, and test reports."""
|
||||
return _as_mcp_results(
|
||||
core_search_file_assets(
|
||||
vault,
|
||||
query,
|
||||
db_path=db,
|
||||
limit=limit,
|
||||
doc_type=doc_type,
|
||||
source_type=source_type,
|
||||
product_line=product_line,
|
||||
customer=customer,
|
||||
competitor=competitor,
|
||||
profile_name=profile_name,
|
||||
expose_paths=expose_paths,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
@server.tool()
|
||||
def get_file_asset_metadata(file_id: str) -> dict[str, Any]:
|
||||
"""Return safe metadata and the resource URI for one indexed source file."""
|
||||
return _as_mcp_result(
|
||||
core_get_file_asset_metadata(
|
||||
@server.tool()
|
||||
def list_file_assets(
|
||||
doc_type: str | None = None,
|
||||
source_type: str | None = None,
|
||||
product_line: str | None = None,
|
||||
customer: str | None = None,
|
||||
competitor: str | None = None,
|
||||
limit: int = 10,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""List safe source files matching optional filters. Use search_file_assets when the user gives keywords such as product codes, sizes, or drawing names."""
|
||||
return _as_mcp_results(
|
||||
core_search_file_assets(
|
||||
vault,
|
||||
"",
|
||||
db_path=db,
|
||||
limit=limit,
|
||||
doc_type=doc_type,
|
||||
source_type=source_type,
|
||||
product_line=product_line,
|
||||
customer=customer,
|
||||
competitor=competitor,
|
||||
profile_name=profile_name,
|
||||
expose_paths=expose_paths,
|
||||
)
|
||||
)
|
||||
|
||||
@server.tool()
|
||||
def get_file_asset_metadata(file_id: str) -> dict[str, Any]:
|
||||
"""Return safe metadata and the resource URI for one indexed source file."""
|
||||
return _as_mcp_result(
|
||||
core_get_file_asset_metadata(
|
||||
vault,
|
||||
file_id,
|
||||
db_path=db,
|
||||
profile_name=profile_name,
|
||||
expose_paths=expose_paths,
|
||||
)
|
||||
)
|
||||
|
||||
@server.resource("asset://source/sha256/{digest}", mime_type="application/octet-stream")
|
||||
def read_file_asset(digest: str) -> bytes:
|
||||
"""Read one allowed source file as a binary MCP resource."""
|
||||
resource = read_file_asset_resource(
|
||||
vault,
|
||||
file_id,
|
||||
f"asset://source/sha256/{digest}",
|
||||
db_path=db,
|
||||
profile_name=profile_name,
|
||||
expose_paths=expose_paths,
|
||||
max_file_bytes=max_file_bytes,
|
||||
)
|
||||
)
|
||||
return resource["bytes"]
|
||||
|
||||
@server.resource("asset://source/sha256/{digest}", mime_type="application/octet-stream")
|
||||
def read_file_asset(digest: str) -> bytes:
|
||||
"""Read one allowed source file as a binary MCP resource."""
|
||||
resource = read_file_asset_resource(
|
||||
vault,
|
||||
f"asset://source/sha256/{digest}",
|
||||
db_path=db,
|
||||
profile_name=profile_name,
|
||||
max_file_bytes=max_file_bytes,
|
||||
)
|
||||
return resource["bytes"]
|
||||
|
||||
@server.prompt(
|
||||
"how_to_use_wiki_file_assets",
|
||||
description="Explain how to search and retrieve original source files from the wiki-file-assets MCP server.",
|
||||
)
|
||||
def how_to_use_wiki_file_assets() -> str:
|
||||
return (
|
||||
"wiki-file-assets serves original source files archived from an Obsidian wiki vault. "
|
||||
"Use search_file_assets for keyword searches such as product codes, drawing names, sizes, "
|
||||
"or document types. Use list_file_assets to browse allowed engineering files with optional "
|
||||
"filters. Use get_file_asset_metadata with a file_id from search/list results to get the "
|
||||
"asset://source/sha256/... URI, then read that resource to fetch the original file bytes. "
|
||||
"This MCP is intentionally limited to safe engineering files such as drawings, CAD/3D models, "
|
||||
"specifications, product parameters, and test reports; it blocks raw orders, prices, email "
|
||||
"correspondence, complaint evidence, claims, and personal data."
|
||||
@server.prompt(
|
||||
"how_to_use_wiki_file_assets",
|
||||
description="Explain how to search and retrieve original source files from the wiki-file-assets MCP server.",
|
||||
)
|
||||
def how_to_use_wiki_file_assets() -> str:
|
||||
return (
|
||||
"wiki-file-assets serves original source files archived from an Obsidian wiki vault. "
|
||||
"Use search_file_assets for keyword searches such as product codes, drawing names, sizes, "
|
||||
"or document types. Use list_file_assets to browse allowed engineering files with optional "
|
||||
"filters. Use get_file_asset_metadata with a file_id from search/list results to get the "
|
||||
"asset://source/sha256/... URI, then read that resource to fetch the original file bytes. "
|
||||
"This MCP is intentionally limited to safe engineering files such as drawings, CAD/3D models, "
|
||||
"specifications, product parameters, and test reports; it blocks raw orders, prices, email "
|
||||
"correspondence, complaint evidence, claims, and personal data."
|
||||
)
|
||||
|
||||
return server
|
||||
|
||||
@@ -348,6 +395,7 @@ def run_http_server(
|
||||
streamable_http_path: str,
|
||||
allowed_hosts: list[str] | None,
|
||||
allowed_origins: list[str] | None,
|
||||
enable_file_assets: bool = True,
|
||||
enable_ingest: bool = False,
|
||||
project_root: str | Path | None = None,
|
||||
ingest_runtime_root: str | Path | None = None,
|
||||
@@ -373,6 +421,7 @@ def run_http_server(
|
||||
streamable_http_path=streamable_http_path,
|
||||
allowed_hosts=allowed_hosts,
|
||||
allowed_origins=allowed_origins,
|
||||
enable_file_assets=enable_file_assets,
|
||||
enable_ingest=enable_ingest,
|
||||
project_root=project_root,
|
||||
ingest_runtime_root=ingest_runtime_root,
|
||||
@@ -430,7 +479,9 @@ def main(argv: list[str] | None = None) -> int:
|
||||
port = args.port if args.port is not None else runtime_config_int("OBSIDIAN_MCP_PORT", DEFAULT_HTTP_PORT)
|
||||
http_path = args.path or runtime_config_value("OBSIDIAN_MCP_PATH", DEFAULT_HTTP_PATH)
|
||||
token_env = args.token_env or runtime_config_value("OBSIDIAN_MCP_TOKEN_ENV", DEFAULT_TOKEN_ENV)
|
||||
enable_ingest = parse_bool(runtime_config_value("OBSIDIAN_MCP_ENABLE_INGEST", "0"))
|
||||
enabled_tools = enabled_tool_names()
|
||||
enable_file_assets = TOOL_FILE_ASSETS in enabled_tools
|
||||
enable_ingest = TOOL_INGEST in enabled_tools
|
||||
ingest_max_file_bytes = runtime_config_int("OBSIDIAN_MCP_INGEST_MAX_FILE_BYTES", DEFAULT_INGEST_MAX_FILE_BYTES)
|
||||
ingest_max_chunk_bytes = runtime_config_int("OBSIDIAN_MCP_INGEST_MAX_CHUNK_BYTES", DEFAULT_INGEST_MAX_CHUNK_BYTES)
|
||||
ingest_max_files_per_job = runtime_config_int(
|
||||
@@ -450,6 +501,7 @@ def main(argv: list[str] | None = None) -> int:
|
||||
profile_name=options["profile_name"],
|
||||
max_file_bytes=options["max_file_bytes"],
|
||||
expose_paths=options["expose_paths"],
|
||||
enable_file_assets=enable_file_assets,
|
||||
enable_ingest=enable_ingest,
|
||||
project_root=Path.cwd(),
|
||||
ingest_runtime_root=ingest_runtime_root,
|
||||
@@ -477,6 +529,7 @@ def main(argv: list[str] | None = None) -> int:
|
||||
streamable_http_path=http_path,
|
||||
allowed_hosts=args.allowed_hosts,
|
||||
allowed_origins=args.allowed_origins,
|
||||
enable_file_assets=enable_file_assets,
|
||||
enable_ingest=enable_ingest,
|
||||
project_root=Path.cwd(),
|
||||
ingest_runtime_root=ingest_runtime_root,
|
||||
|
||||
+203
-14
@@ -30,6 +30,64 @@ DEFAULT_PORT = 8765
|
||||
DEFAULT_PATH = "/mcp"
|
||||
DEFAULT_TOKEN_BYTES = 32
|
||||
DEFAULT_UV_CACHE_DIR = "/tmp/uv-cache"
|
||||
TOOL_FILE_ASSETS = "file-assets"
|
||||
TOOL_INGEST = "ingest"
|
||||
AVAILABLE_TOOL_GROUPS = (TOOL_FILE_ASSETS, TOOL_INGEST)
|
||||
DEFAULT_ENABLED_TOOLS = (TOOL_FILE_ASSETS,)
|
||||
|
||||
|
||||
def normalize_tool_name(name: str) -> str:
|
||||
normalized = name.strip().lower().replace("_", "-")
|
||||
aliases = {
|
||||
"file": TOOL_FILE_ASSETS,
|
||||
"files": TOOL_FILE_ASSETS,
|
||||
"file-assets": TOOL_FILE_ASSETS,
|
||||
"source-file": TOOL_FILE_ASSETS,
|
||||
"source-files": TOOL_FILE_ASSETS,
|
||||
"ingest": TOOL_INGEST,
|
||||
"upload": TOOL_INGEST,
|
||||
"uploads": TOOL_INGEST,
|
||||
"opencode-ingest": TOOL_INGEST,
|
||||
}
|
||||
if normalized not in aliases:
|
||||
available = ", ".join(AVAILABLE_TOOL_GROUPS)
|
||||
raise ValueError(f"unknown MCP tool group: {name}. Available: {available}")
|
||||
return aliases[normalized]
|
||||
|
||||
|
||||
def parse_bool(value: str | None) -> bool:
|
||||
return str(value or "").strip().lower() in {"1", "true", "yes", "on", "enabled"}
|
||||
|
||||
|
||||
def normalize_enabled_tools(tools: Sequence[str]) -> tuple[str, ...]:
|
||||
enabled = {normalize_tool_name(tool) for tool in tools if str(tool).strip()}
|
||||
return tuple(tool for tool in AVAILABLE_TOOL_GROUPS if tool in enabled)
|
||||
|
||||
|
||||
def enabled_tools_from_config(values: dict[str, str]) -> tuple[str, ...]:
|
||||
raw_tools = values.get("OBSIDIAN_MCP_ENABLED_TOOLS", "").strip()
|
||||
if raw_tools:
|
||||
if raw_tools.lower() in {"none", "off", "disabled"}:
|
||||
return ()
|
||||
return normalize_enabled_tools(raw_tools.split(","))
|
||||
|
||||
enabled = set(DEFAULT_ENABLED_TOOLS)
|
||||
if parse_bool(values.get("OBSIDIAN_MCP_ENABLE_INGEST")):
|
||||
enabled.add(TOOL_INGEST)
|
||||
return tuple(tool for tool in AVAILABLE_TOOL_GROUPS if tool in enabled)
|
||||
|
||||
|
||||
def format_enabled_tools(enabled_tools: Sequence[str]) -> str:
|
||||
return ",".join(normalize_enabled_tools(enabled_tools))
|
||||
|
||||
|
||||
def update_ingest_tool(enabled_tools: Sequence[str], enable_ingest: bool | None) -> tuple[str, ...]:
|
||||
current = set(normalize_enabled_tools(enabled_tools))
|
||||
if enable_ingest is True:
|
||||
current.add(TOOL_INGEST)
|
||||
elif enable_ingest is False:
|
||||
current.discard(TOOL_INGEST)
|
||||
return tuple(tool for tool in AVAILABLE_TOOL_GROUPS if tool in current)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -155,8 +213,11 @@ def write_token_env_file(
|
||||
host: str = DEFAULT_HOST,
|
||||
port: int = DEFAULT_PORT,
|
||||
path: str = DEFAULT_PATH,
|
||||
enable_ingest: bool = False,
|
||||
enabled_tools: Sequence[str] | None = None,
|
||||
enable_ingest: bool | None = None,
|
||||
) -> None:
|
||||
tools = normalize_enabled_tools(DEFAULT_ENABLED_TOOLS if enabled_tools is None else enabled_tools)
|
||||
tools = update_ingest_tool(tools, enable_ingest)
|
||||
env_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
env_path.parent.chmod(0o700)
|
||||
fd = os.open(env_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
||||
@@ -165,7 +226,8 @@ def write_token_env_file(
|
||||
env_file.write(f"OBSIDIAN_MCP_HOST={host}\n")
|
||||
env_file.write(f"OBSIDIAN_MCP_PORT={port}\n")
|
||||
env_file.write(f"OBSIDIAN_MCP_PATH={path}\n")
|
||||
env_file.write(f"OBSIDIAN_MCP_ENABLE_INGEST={1 if enable_ingest else 0}\n")
|
||||
env_file.write(f"OBSIDIAN_MCP_ENABLED_TOOLS={format_enabled_tools(tools)}\n")
|
||||
env_file.write(f"OBSIDIAN_MCP_ENABLE_INGEST={1 if TOOL_INGEST in tools else 0}\n")
|
||||
env_path.chmod(0o600)
|
||||
|
||||
|
||||
@@ -184,7 +246,7 @@ def read_service_env_file(env_path: Path) -> dict[str, str]:
|
||||
return values
|
||||
|
||||
|
||||
def saved_service_config(project_root: Path) -> tuple[str, str, int, str]:
|
||||
def saved_service_config(project_root: Path) -> tuple[str, str, int, str, tuple[str, ...]]:
|
||||
paths = resolve_service_paths(project_root)
|
||||
values = read_service_env_file(paths.env_path)
|
||||
token = values.get("OBSIDIAN_MCP_TOKEN", "").strip()
|
||||
@@ -193,7 +255,8 @@ def saved_service_config(project_root: Path) -> tuple[str, str, int, str]:
|
||||
host = values.get("OBSIDIAN_MCP_HOST") or DEFAULT_HOST
|
||||
port = int(values.get("OBSIDIAN_MCP_PORT") or DEFAULT_PORT)
|
||||
path = values.get("OBSIDIAN_MCP_PATH") or DEFAULT_PATH
|
||||
return token, host, port, path
|
||||
enabled_tools = enabled_tools_from_config(values)
|
||||
return token, host, port, path, enabled_tools
|
||||
|
||||
|
||||
def write_service_unit(
|
||||
@@ -249,17 +312,25 @@ def render_remote_config(
|
||||
token: str,
|
||||
port: int = DEFAULT_PORT,
|
||||
path: str = DEFAULT_PATH,
|
||||
config_path: Path | None = None,
|
||||
enabled_tools: Sequence[str] | None = None,
|
||||
) -> str:
|
||||
host = hosts[0] if hosts else "<server-ip>"
|
||||
url = f"http://{host}:{port}{path}"
|
||||
alternatives = "\n".join(f"- http://{candidate}:{port}{path}" for candidate in hosts[1:])
|
||||
alternatives_section = f"\nOther detected addresses:\n{alternatives}\n" if alternatives else ""
|
||||
config_section = f"\nConfig file:\n{config_path}\n" if config_path is not None else ""
|
||||
enabled_section = ""
|
||||
if enabled_tools is not None:
|
||||
enabled_section = f"\nEnabled tool groups:\n{format_enabled_tools(enabled_tools) or 'none'}\n"
|
||||
return (
|
||||
"MCP service is running.\n"
|
||||
"\n"
|
||||
"Use this URL from another machine:\n"
|
||||
f"{url}\n"
|
||||
f"{alternatives_section}"
|
||||
f"{config_section}"
|
||||
f"{enabled_section}"
|
||||
"\n"
|
||||
"Use this header:\n"
|
||||
f"Authorization: Bearer {token}\n"
|
||||
@@ -280,8 +351,16 @@ def render_remote_config(
|
||||
|
||||
|
||||
def show_config(project_root: Path) -> str:
|
||||
token, _host, port, path = saved_service_config(project_root)
|
||||
return render_remote_config(hosts=detect_remote_hosts(), token=token, port=port, path=path)
|
||||
paths = resolve_service_paths(project_root)
|
||||
token, _host, port, path, enabled_tools = saved_service_config(project_root)
|
||||
return render_remote_config(
|
||||
hosts=detect_remote_hosts(),
|
||||
token=token,
|
||||
port=port,
|
||||
path=path,
|
||||
config_path=paths.env_path,
|
||||
enabled_tools=enabled_tools,
|
||||
)
|
||||
|
||||
|
||||
def run_systemctl(args: Sequence[str], *, check: bool = True) -> subprocess.CompletedProcess[str]:
|
||||
@@ -304,10 +383,22 @@ def new_token(
|
||||
token_bytes: int = DEFAULT_TOKEN_BYTES,
|
||||
) -> str:
|
||||
paths = resolve_service_paths(project_root)
|
||||
enabled_tools = DEFAULT_ENABLED_TOOLS
|
||||
try:
|
||||
_old_token, _old_host, _old_port, _old_path, enabled_tools = saved_service_config(project_root)
|
||||
except RuntimeError:
|
||||
pass
|
||||
token = generate_token(token_bytes)
|
||||
write_token_env_file(paths.env_path, token, host=host, port=port, path=path)
|
||||
write_token_env_file(paths.env_path, token, host=host, port=port, path=path, enabled_tools=enabled_tools)
|
||||
restart_service_if_installed(paths)
|
||||
return render_remote_config(hosts=detect_remote_hosts(), token=token, port=port, path=path)
|
||||
return render_remote_config(
|
||||
hosts=detect_remote_hosts(),
|
||||
token=token,
|
||||
port=port,
|
||||
path=path,
|
||||
config_path=paths.env_path,
|
||||
enabled_tools=enabled_tools,
|
||||
)
|
||||
|
||||
|
||||
def start_service(
|
||||
@@ -318,16 +409,18 @@ def start_service(
|
||||
port: int = DEFAULT_PORT,
|
||||
path: str = DEFAULT_PATH,
|
||||
token_bytes: int = DEFAULT_TOKEN_BYTES,
|
||||
enable_ingest: bool = False,
|
||||
enable_ingest: bool | None = None,
|
||||
) -> str:
|
||||
paths = resolve_service_paths(project_root)
|
||||
try:
|
||||
token, host, port, path = saved_service_config(project_root)
|
||||
token, host, port, path, enabled_tools = saved_service_config(project_root)
|
||||
except RuntimeError:
|
||||
if paths.env_path.exists():
|
||||
raise
|
||||
token = generate_token(token_bytes)
|
||||
write_token_env_file(paths.env_path, token, host=host, port=port, path=path, enable_ingest=enable_ingest)
|
||||
enabled_tools = DEFAULT_ENABLED_TOOLS
|
||||
enabled_tools = update_ingest_tool(enabled_tools, enable_ingest)
|
||||
write_token_env_file(paths.env_path, token, host=host, port=port, path=path, enabled_tools=enabled_tools)
|
||||
uv_path = find_uv_path()
|
||||
prepare_runtime(paths.project_root, uv_path=uv_path)
|
||||
resolved_vault = resolve_vault_for_service(paths.project_root, vault_path=vault_path)
|
||||
@@ -336,7 +429,70 @@ def start_service(
|
||||
run_systemctl(["daemon-reload"])
|
||||
run_systemctl(["enable", SERVICE_NAME])
|
||||
run_systemctl(["restart", SERVICE_NAME])
|
||||
return render_remote_config(hosts=detect_remote_hosts(), token=token, port=port, path=path)
|
||||
return render_remote_config(
|
||||
hosts=detect_remote_hosts(),
|
||||
token=token,
|
||||
port=port,
|
||||
path=path,
|
||||
config_path=paths.env_path,
|
||||
enabled_tools=enabled_tools,
|
||||
)
|
||||
|
||||
|
||||
def render_tool_status(
|
||||
*,
|
||||
config_path: Path,
|
||||
enabled_tools: Sequence[str],
|
||||
restarted: bool = False,
|
||||
) -> str:
|
||||
enabled = set(normalize_enabled_tools(enabled_tools))
|
||||
lines = [
|
||||
"MCP tool configuration.",
|
||||
"",
|
||||
f"Config file: {config_path}",
|
||||
f"Enabled tool groups: {format_enabled_tools(enabled_tools) or 'none'}",
|
||||
"",
|
||||
"Available tool groups:",
|
||||
]
|
||||
for tool in AVAILABLE_TOOL_GROUPS:
|
||||
state = "enabled" if tool in enabled else "disabled"
|
||||
lines.append(f"- {tool}: {state}")
|
||||
lines.append("")
|
||||
lines.append(f"Service restarted: {'yes' if restarted else 'no'}")
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def ensure_service_config(project_root: Path) -> tuple[ServicePaths, str, str, int, str, tuple[str, ...]]:
|
||||
paths = resolve_service_paths(project_root)
|
||||
try:
|
||||
token, host, port, path, enabled_tools = saved_service_config(project_root)
|
||||
except RuntimeError:
|
||||
token = generate_token()
|
||||
host = DEFAULT_HOST
|
||||
port = DEFAULT_PORT
|
||||
path = DEFAULT_PATH
|
||||
enabled_tools = DEFAULT_ENABLED_TOOLS
|
||||
write_token_env_file(paths.env_path, token, host=host, port=port, path=path, enabled_tools=enabled_tools)
|
||||
return paths, token, host, port, path, enabled_tools
|
||||
|
||||
|
||||
def list_tools(project_root: Path) -> str:
|
||||
paths, _token, _host, _port, _path, enabled_tools = ensure_service_config(project_root)
|
||||
return render_tool_status(config_path=paths.env_path, enabled_tools=enabled_tools)
|
||||
|
||||
|
||||
def set_tool_enabled(project_root: Path, tool: str, enabled: bool) -> str:
|
||||
tool_name = normalize_tool_name(tool)
|
||||
paths, token, host, port, path, enabled_tools = ensure_service_config(project_root)
|
||||
current = set(enabled_tools)
|
||||
if enabled:
|
||||
current.add(tool_name)
|
||||
else:
|
||||
current.discard(tool_name)
|
||||
updated_tools = tuple(item for item in AVAILABLE_TOOL_GROUPS if item in current)
|
||||
write_token_env_file(paths.env_path, token, host=host, port=port, path=path, enabled_tools=updated_tools)
|
||||
restarted = restart_service_if_installed(paths)
|
||||
return render_tool_status(config_path=paths.env_path, enabled_tools=updated_tools, restarted=restarted)
|
||||
|
||||
|
||||
def stop_service() -> None:
|
||||
@@ -380,10 +536,19 @@ def main(argv: Sequence[str] | None = None) -> int:
|
||||
start_parser.add_argument("--port", default=DEFAULT_PORT, type=int)
|
||||
start_parser.add_argument("--path", default=DEFAULT_PATH)
|
||||
start_parser.add_argument("--token-bytes", default=DEFAULT_TOKEN_BYTES, type=int)
|
||||
start_parser.add_argument(
|
||||
ingest_group = start_parser.add_mutually_exclusive_group()
|
||||
ingest_group.add_argument(
|
||||
"--enable-ingest",
|
||||
dest="enable_ingest",
|
||||
action="store_true",
|
||||
help="Expose write-capable upload and local OpenCode ingest MCP tools. Defaults to off.",
|
||||
default=None,
|
||||
help="Expose write-capable upload and local OpenCode ingest MCP tools.",
|
||||
)
|
||||
ingest_group.add_argument(
|
||||
"--disable-ingest",
|
||||
dest="enable_ingest",
|
||||
action="store_false",
|
||||
help="Disable write-capable upload and local OpenCode ingest MCP tools.",
|
||||
)
|
||||
|
||||
subparsers.add_parser("stop", help="Stop the user service without deleting service files.")
|
||||
@@ -396,6 +561,20 @@ def main(argv: Sequence[str] | None = None) -> int:
|
||||
|
||||
subparsers.add_parser("status", help="Show systemd status for the managed service.")
|
||||
|
||||
tools_parser = subparsers.add_parser("tools", help="List or update enabled MCP tool groups in the service config.")
|
||||
tools_subparsers = tools_parser.add_subparsers(dest="tools_command", required=True)
|
||||
|
||||
tools_list_parser = tools_subparsers.add_parser("list", help="List configured MCP tool groups.")
|
||||
tools_list_parser.add_argument("--project-root", default=Path(__file__).resolve().parent.parent, type=Path)
|
||||
|
||||
tools_enable_parser = tools_subparsers.add_parser("enable", help="Enable an MCP tool group.")
|
||||
tools_enable_parser.add_argument("tool", choices=AVAILABLE_TOOL_GROUPS)
|
||||
tools_enable_parser.add_argument("--project-root", default=Path(__file__).resolve().parent.parent, type=Path)
|
||||
|
||||
tools_disable_parser = tools_subparsers.add_parser("disable", help="Disable an MCP tool group.")
|
||||
tools_disable_parser.add_argument("tool", choices=AVAILABLE_TOOL_GROUPS)
|
||||
tools_disable_parser.add_argument("--project-root", default=Path(__file__).resolve().parent.parent, type=Path)
|
||||
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
if args.command == "new-token":
|
||||
@@ -438,6 +617,16 @@ def main(argv: Sequence[str] | None = None) -> int:
|
||||
if args.command == "status":
|
||||
service_status()
|
||||
return 0
|
||||
if args.command == "tools":
|
||||
if args.tools_command == "list":
|
||||
print(list_tools(args.project_root), end="")
|
||||
return 0
|
||||
if args.tools_command == "enable":
|
||||
print(set_tool_enabled(args.project_root, args.tool, True), end="")
|
||||
return 0
|
||||
if args.tools_command == "disable":
|
||||
print(set_tool_enabled(args.project_root, args.tool, False), end="")
|
||||
return 0
|
||||
raise AssertionError(f"unhandled command: {args.command}")
|
||||
|
||||
|
||||
|
||||
@@ -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