diff --git a/README.md b/README.md index 385727b..bc06795 100644 --- a/README.md +++ b/README.md @@ -475,9 +475,9 @@ This adds write-capable MCP tools to the same `wiki-file-assets` server and bear The managed service config sets the OpenCode model server-side. By default ingest uses `OBSIDIAN_MCP_INGEST_OPENCODE_MODEL=openai/gpt-5.5`, `OBSIDIAN_MCP_INGEST_OPENCODE_VARIANT=high`, and an `OBSIDIAN_MCP_INGEST_JOB_TIMEOUT_SECONDS=1800` deadline. It also writes `OBSIDIAN_MCP_PUBLIC_BASE_URL` from the detected remote address so ticket responses can include a complete upload URL; set it manually when the service is behind a tunnel, reverse proxy, or custom DNS name. If the upload endpoint is exposed through a different public URL than `/mcp`, set `OBSIDIAN_MCP_INGEST_PUBLIC_UPLOAD_URL` explicitly and it will override the derived upload URL in capabilities, upload instructions, tickets, and `show-config`. Change these values in `.runtime/obsidian-wiki-mcp.env` on the server if needed; remote MCP clients do not choose the model per call. -For short text thoughts, notes, findings, or pasted context, call `ingest_text_note(text, title?, project?, doc_type_hint?, note?, start_job?)`. It uses `doc_type_hint="thought"` by default and returns a one-item Bundle, the created upload, and the queued job when `start_job=true`. +For short text thoughts, notes, findings, or pasted context, call `ingest_text_note(text, title?, project?, doc_type_hint?, note?, start_job?)`. It uses `doc_type_hint="thought"` and `start_job=false` by default, returning a one-item Bundle and the staged upload; it also returns the queued job when `start_job=true` is explicitly requested. -For binary files and larger source documents, remote agents should use upload tickets by default: call `create_ingest_upload_ticket(start_job=false)`, upload with the returned `upload_url` and `X-Obsidian-Wiki-Upload-Token`, call `preview_upload_metadata(upload_ids)`, then call `start_ingest_job(upload_ids)` when the preview looks correct. The same multipart POST can carry `body_text` plus repeated `files`; the body becomes the Bundle's primary Markdown source and the files become attachments: +For binary files and larger source documents, remote agents should use upload tickets by default: call `create_ingest_upload_ticket()` (also `start_job=false` by default), upload with the returned `upload_url` and `X-Obsidian-Wiki-Upload-Token`, call `preview_upload_metadata(upload_ids)`, then call `start_ingest_job(upload_ids)` when the preview looks correct. The same multipart POST can carry `body_text` plus repeated `files`; the body becomes the Bundle's primary Markdown source and the files become attachments: ```bash curl -H "X-Obsidian-Wiki-Upload-Token: " \ diff --git a/obsidian_wiki/mcp_ingest.py b/obsidian_wiki/mcp_ingest.py index 5073848..64fec7f 100644 --- a/obsidian_wiki/mcp_ingest.py +++ b/obsidian_wiki/mcp_ingest.py @@ -248,7 +248,7 @@ class IngestManager: bundle_id: str | None = None, source_type: str | None = None, label: str | None = None, - start_job: bool = True, + start_job: bool = False, ) -> dict[str, Any]: if expires_seconds <= 0 or expires_seconds > MAX_UPLOAD_TICKET_SECONDS: raise IngestError( diff --git a/obsidian_wiki/mcp_server.py b/obsidian_wiki/mcp_server.py index 869cfa0..bdff055 100644 --- a/obsidian_wiki/mcp_server.py +++ b/obsidian_wiki/mcp_server.py @@ -510,11 +510,11 @@ def create_server( "note", "start_job", ], - "start_job_default": True, + "start_job_default": False, "curl_example": ( "curl -H 'X-Obsidian-Wiki-Upload-Token: ' " "-F 'files=@drawing.pdf' -F 'project=bjd' " - "-F 'doc_type_hint=drawing' -F 'start_job=true' " + "-F 'doc_type_hint=drawing' -F 'start_job=false' " f"{public_upload_url or ''}" ), }, @@ -530,7 +530,7 @@ def create_server( "direct_text_tool": { "name": "ingest_text_note", "best_for": "short remote-agent thoughts, notes, findings, or pasted text", - "start_job_default": True, + "start_job_default": False, "notes": "wraps text as a temporary markdown upload, then uses the same fixed wiki-ingest job path", }, "limits": { @@ -557,7 +557,7 @@ def create_server( project: str | None = None, doc_type_hint: str | None = "thought", note: str | None = None, - start_job: bool = True, + start_job: bool = False, ) -> dict[str, Any]: """Ingest a short text note by wrapping it as a temporary markdown upload.""" payload = text_note_payload(text, title=title) @@ -593,7 +593,7 @@ def create_server( bundle_id: str | None = None, source_type: str | None = None, label: str | None = None, - start_job: bool = True, + start_job: bool = False, ) -> dict[str, Any]: """Create a short-lived HTTP upload ticket so remote agents can upload files without the main MCP token.""" ticket = ingest_manager.create_upload_ticket( @@ -1094,7 +1094,7 @@ class IngestUploadRouter: bundle_id = ticket_bound_field("bundle_id") source_type = ticket_bound_field("source_type") label = ticket_bound_field("label") - start_default = bool((upload_ticket or {}).get("start_job", True)) + start_default = bool((upload_ticket or {}).get("start_job", False)) requested_start = parse_bool(first_text_field("start_job"), default=start_default) start_job = requested_start if upload_ticket is None else start_default and requested_start has_body = bool(body_text and body_text.strip()) diff --git a/scripts/manage_mcp_service.py b/scripts/manage_mcp_service.py index afb4d0d..82a0991 100755 --- a/scripts/manage_mcp_service.py +++ b/scripts/manage_mcp_service.py @@ -22,7 +22,7 @@ REPO_ROOT = Path(__file__).resolve().parent.parent if str(REPO_ROOT) not in sys.path: sys.path.insert(0, str(REPO_ROOT)) -from obsidian_wiki.config import resolve_vault_path +from obsidian_wiki.config import load_config, resolve_vault_path SERVICE_NAME = "obsidian-wiki-mcp.service" @@ -37,6 +37,7 @@ PUBLIC_UPLOAD_URL_ENV = "OBSIDIAN_MCP_INGEST_PUBLIC_UPLOAD_URL" ALLOW_SOURCE_FILES_ENV = "OBSIDIAN_MCP_ALLOW_SOURCE_FILES" ALLOWED_HOSTS_ENV = "OBSIDIAN_MCP_ALLOWED_HOSTS" ALLOWED_ORIGINS_ENV = "OBSIDIAN_MCP_ALLOWED_ORIGINS" +ARCHIVE_SOURCES_ENV = "WIKI_ARCHIVE_SOURCES" DEFAULT_TOKEN_BYTES = 32 DEFAULT_UV_CACHE_DIR = "/tmp/uv-cache" DEFAULT_OPENCODE_PATH = "opencode" @@ -86,6 +87,11 @@ def parse_bool(value: str | None) -> bool: return str(value or "").strip().lower() in {"1", "true", "yes", "on", "enabled"} +def project_archive_sources(project_root: Path, *, default: bool = False) -> bool: + value = load_config(project_root).get(ARCHIVE_SOURCES_ENV) + return default if value in (None, "") else parse_bool(value) + + def parse_config_list(value: str | None) -> tuple[str, ...]: return tuple(item.strip() for item in str(value or "").split(",") if item.strip()) @@ -155,6 +161,7 @@ class ServiceConfig: public_upload_url: str = "" enabled_tools: tuple[str, ...] = DEFAULT_ENABLED_TOOLS allow_source_files: bool = False + archive_sources: bool = False allowed_hosts: tuple[str, ...] = () allowed_origins: tuple[str, ...] = () ingest_job_timeout_seconds: int = DEFAULT_INGEST_JOB_TIMEOUT_SECONDS @@ -295,6 +302,7 @@ def write_token_env_file( enable_ingest: bool | None = None, enable_query: bool | None = None, allow_source_files: bool | None = None, + archive_sources: bool = False, allowed_hosts: Sequence[str] | None = None, allowed_origins: Sequence[str] | None = None, ingest_job_timeout_seconds: int = DEFAULT_INGEST_JOB_TIMEOUT_SECONDS, @@ -307,6 +315,7 @@ def write_token_env_file( tools = update_ingest_tool(tools, enable_ingest) tools = update_query_tool(tools, enable_query) source_files_allowed = bool(allow_source_files) + archive_sources_enabled = bool(archive_sources) models = model_settings or OpenCodeModelSettings() normalized_public_base_url = normalize_public_base_url(public_base_url) normalized_public_upload_url = normalize_public_upload_url(public_upload_url) @@ -337,6 +346,7 @@ def write_token_env_file( if normalized_public_upload_url: env_file.write(f"{PUBLIC_UPLOAD_URL_ENV}={normalized_public_upload_url}\n") env_file.write(f"{ALLOW_SOURCE_FILES_ENV}={1 if source_files_allowed else 0}\n") + env_file.write(f"{ARCHIVE_SOURCES_ENV}={1 if archive_sources_enabled else 0}\n") env_file.write(f"{ALLOWED_HOSTS_ENV}={','.join(effective_allowed_hosts)}\n") env_file.write(f"{ALLOWED_ORIGINS_ENV}={','.join(effective_allowed_origins)}\n") env_file.write(f"OBSIDIAN_MCP_INGEST_JOB_TIMEOUT_SECONDS={max(1, int(ingest_job_timeout_seconds))}\n") @@ -402,6 +412,7 @@ def saved_service_config(project_root: Path) -> ServiceConfig: allow_source_files = parse_bool(values[ALLOW_SOURCE_FILES_ENV]) else: allow_source_files = TOOL_FILE_ASSETS in enabled_tools + archive_sources = parse_bool(values.get(ARCHIVE_SOURCES_ENV)) allowed_hosts = parse_config_list(values.get(ALLOWED_HOSTS_ENV)) if not allowed_hosts: allowed_hosts = derive_allowed_hosts( @@ -425,6 +436,7 @@ def saved_service_config(project_root: Path) -> ServiceConfig: public_upload_url=public_upload_url, enabled_tools=enabled_tools, allow_source_files=allow_source_files, + archive_sources=archive_sources, allowed_hosts=allowed_hosts, allowed_origins=allowed_origins, ingest_job_timeout_seconds=max( @@ -711,6 +723,7 @@ def write_service_config(env_path: Path, config: ServiceConfig) -> None: public_upload_url=config.public_upload_url, enabled_tools=config.enabled_tools, allow_source_files=config.allow_source_files, + archive_sources=config.archive_sources, allowed_hosts=config.allowed_hosts, allowed_origins=config.allowed_origins, ingest_job_timeout_seconds=config.ingest_job_timeout_seconds, @@ -778,6 +791,10 @@ def new_token( saved.allowed_origins if has_saved_config else (), derive_allowed_origins(effective_public_base_url, effective_public_upload_url), ) + archive_sources = project_archive_sources( + paths.project_root, + default=saved.archive_sources, + ) config = replace( saved, token=generate_token(token_bytes), @@ -786,6 +803,7 @@ def new_token( path=effective_path, public_base_url=effective_public_base_url, public_upload_url=effective_public_upload_url, + archive_sources=archive_sources, allowed_hosts=allowed_hosts, allowed_origins=allowed_origins, ) @@ -871,6 +889,10 @@ def start_service( saved.allowed_origins if has_saved_config else (), derive_allowed_origins(effective_public_base_url, effective_public_upload_url), ) + archive_sources = project_archive_sources( + paths.project_root, + default=saved.archive_sources, + ) config = replace( saved, host=effective_host, @@ -883,6 +905,7 @@ def start_service( allow_source_files=( saved.allow_source_files if allow_source_files is None else allow_source_files ), + archive_sources=archive_sources, allowed_hosts=allowed_hosts, allowed_origins=allowed_origins, ) diff --git a/tests/test_mcp_ingest.py b/tests/test_mcp_ingest.py index acde0a3..ad7efa8 100644 --- a/tests/test_mcp_ingest.py +++ b/tests/test_mcp_ingest.py @@ -268,7 +268,6 @@ class McpIngestTests(unittest.TestCase): bundle_id="drawing-package-1", source_type="report_package", label="Customer drawing package", - start_job=False, ) self.assertIn("upload_token", ticket) diff --git a/tests/test_mcp_server_remote.py b/tests/test_mcp_server_remote.py index f456b84..52e7366 100644 --- a/tests/test_mcp_server_remote.py +++ b/tests/test_mcp_server_remote.py @@ -209,6 +209,53 @@ class RemoteMcpServerTests(unittest.TestCase): self.assertEqual(data["uploads"][0]["sha256"], "sha256:" + hashlib.sha256(payload).hexdigest()) self.assertNotIn("path", data["uploads"][0]) + def test_http_upload_endpoint_stages_without_starting_job_by_default(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + manager = IngestManager( + vault_path=root / "vault", + project_root=root / "project", + runtime_root=root / "runtime", + ) + app = mcp_server.create_http_app( + FakeApp(), + "secret-token", + ingest_manager=manager, + ) + body, boundary = multipart_body( + {"project": "bjd"}, + [("files", "drawing.pdf", "application/pdf", b"drawing")], + ) + + with patch.object( + manager, + "start_ingest_job", + side_effect=AssertionError("default upload staging must not start a job"), + ) as start_job: + messages = run_asgi_http_request( + app, + method="POST", + path="/mcp-ingest/uploads", + headers=[ + (b"authorization", b"Bearer secret-token"), + ( + b"content-type", + f"multipart/form-data; boundary={boundary}".encode("ascii"), + ), + ], + body=body, + ) + + self.assertEqual(messages[0]["status"], 200) + response = b"".join( + message.get("body", b"") + for message in messages + if message["type"] == "http.response.body" + ) + data = json.loads(response.decode("utf-8")) + self.assertIsNone(data["job"]) + start_job.assert_not_called() + def test_http_upload_endpoint_creates_bundle_from_body_and_attachments(self): with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) @@ -411,7 +458,6 @@ class RemoteMcpServerTests(unittest.TestCase): bundle_id="drawing-package-1", source_type="report_package", label="Customer drawing package", - start_job=False, ) app = mcp_server.create_http_app(FakeApp(), "secret-token", ingest_manager=manager) payload = b"ticket uploaded drawing" @@ -1419,6 +1465,9 @@ class RemoteMcpServerTests(unittest.TestCase): self.assertEqual(ticket["label"], "Customer drawing package") self.assertFalse(ticket["start_job"]) self.assertEqual(instructions["preferred_upload_auth"], "upload_ticket") + self.assertFalse(instructions["http_upload"]["start_job_default"]) + self.assertFalse(instructions["direct_text_tool"]["start_job_default"]) + self.assertIn("start_job=false", instructions["http_upload"]["curl_example"]) self.assertEqual(instructions["http_upload"]["url"], "https://wiki.example.test/mcp-ingest/uploads") self.assertIn("body_text", instructions["http_upload"]["optional_fields"]) self.assertIn("bundle_id", instructions["http_upload"]["optional_fields"]) @@ -1588,14 +1637,19 @@ class RemoteMcpServerTests(unittest.TestCase): ingest_runtime_root=root / "runtime", ingest_manager=manager, ) - result = FakeMCP.tools["ingest_text_note"]( - text="Remote agent thought\n\nNeed to remember this.", - title="Customer thought: ACME / risk", - project="bjd", - doc_type_hint="thought", - note="captured from remote agent", - start_job=False, - ) + with patch.object( + manager, + "start_ingest_job", + side_effect=AssertionError("default text staging must not start a job"), + ) as start_job: + result = FakeMCP.tools["ingest_text_note"]( + text="Remote agent thought\n\nNeed to remember this.", + title="Customer thought: ACME / risk", + project="bjd", + doc_type_hint="thought", + note="captured from remote agent", + ) + start_job.assert_not_called() payload_path = Path(manager._load_upload(result["uploads"][0]["upload_id"])["path"]) payload = payload_path.read_text(encoding="utf-8") diff --git a/tests/test_mcp_service.py b/tests/test_mcp_service.py index 5fbc5b0..5d125ff 100644 --- a/tests/test_mcp_service.py +++ b/tests/test_mcp_service.py @@ -68,6 +68,7 @@ class McpServiceConfigTests(unittest.TestCase): public_upload_url="https://uploads.example.test/custom/upload", enable_ingest=True, allow_source_files=False, + archive_sources=True, ingest_job_timeout_seconds=111, query_job_timeout_seconds=22, ingest_max_request_bytes=333, @@ -81,6 +82,7 @@ class McpServiceConfigTests(unittest.TestCase): self.assertIn("OBSIDIAN_MCP_PUBLIC_BASE_URL=http://192.168.1.10:8765/mcp\n", content) self.assertIn("OBSIDIAN_MCP_INGEST_PUBLIC_UPLOAD_URL=https://uploads.example.test/custom/upload\n", content) self.assertIn("OBSIDIAN_MCP_ALLOW_SOURCE_FILES=0\n", content) + self.assertIn("WIKI_ARCHIVE_SOURCES=1\n", content) self.assertIn("OBSIDIAN_MCP_ALLOWED_HOSTS=", content) self.assertIn("192.168.1.10:*", content) self.assertIn("OBSIDIAN_MCP_ALLOWED_ORIGINS=", content) @@ -112,6 +114,7 @@ class McpServiceConfigTests(unittest.TestCase): public_upload_url="https://uploads.example.test/saved", enabled_tools=("file-assets", "query"), allow_source_files=False, + archive_sources=True, ingest_job_timeout_seconds=444, query_job_timeout_seconds=55, ingest_max_request_bytes=666, @@ -128,6 +131,7 @@ class McpServiceConfigTests(unittest.TestCase): self.assertEqual(config.public_upload_url, "https://uploads.example.test/saved") self.assertEqual(config.enabled_tools, ("file-assets", "query")) self.assertFalse(config.allow_source_files) + self.assertTrue(config.archive_sources) self.assertEqual(config.ingest_job_timeout_seconds, 444) self.assertEqual(config.query_job_timeout_seconds, 55) self.assertEqual(config.ingest_max_request_bytes, 666) @@ -491,6 +495,11 @@ class McpServiceConfigTests(unittest.TestCase): with tempfile.TemporaryDirectory() as tmp: project_root = Path(tmp) / "project" project_root.mkdir() + (project_root / ".env").write_text( + "OBSIDIAN_VAULT_PATH=/data/bjd-vault\n" + "WIKI_ARCHIVE_SOURCES=true\n", + encoding="utf-8", + ) config_home = Path(tmp) / "config" manage_mcp_service.write_token_env_file( project_root / ".runtime" / "obsidian-wiki-mcp.env", @@ -519,6 +528,7 @@ class McpServiceConfigTests(unittest.TestCase): 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) + self.assertIn("WIKI_ARCHIVE_SOURCES=1\n", env_content) self.assertNotIn("OBSIDIAN_MCP_PUBLIC_BASE_URL=", env_content) self.assertNotIn("OBSIDIAN_MCP_INGEST_PUBLIC_UPLOAD_URL=", env_content) generate_token.assert_not_called()