From 0d772dd3b6aceb38653a84417dc7e90509f011f9 Mon Sep 17 00:00:00 2001 From: Dmytro Klymentiev <226637896+dklymentiev@users.noreply.github.com> Date: Fri, 27 Mar 2026 07:57:31 -0500 Subject: [PATCH 1/2] fix: accept tags as string or list in MCP tools LLM agents sometimes pass tags as comma-separated string instead of array. All tag parameters now accept both formats via _parse_tags() helper. Fully backward-compatible -- arrays work as before. Co-Authored-By: Claude Opus 4.6 (1M context) --- mcp_server.py | 39 +++++++++++++++++++++++++++------------ 1 file changed, 27 insertions(+), 12 deletions(-) diff --git a/mcp_server.py b/mcp_server.py index 35324c7..563af81 100755 --- a/mcp_server.py +++ b/mcp_server.py @@ -115,6 +115,15 @@ def _schema_summary(schema: dict) -> str: return "\n".join(lines) +def _parse_tags(tags: list[str] | str | None) -> list[str] | None: + """Accept tags as list or comma/space-separated string.""" + if tags is None: + return None + if isinstance(tags, str): + return [t.strip() for t in tags.replace(",", " ").split() if t.strip()] + return tags + + # ────────────────────────────────────────── # MCP Server # ────────────────────────────────────────── @@ -176,7 +185,7 @@ async def mesh_focus(workspace: str, prefetch: bool = True, limit: int = 5) -> s @mcp.tool() -async def mesh_search(query: str, limit: int = 10, tags: list[str] | None = None, +async def mesh_search(query: str, limit: int = 10, tags: list[str] | str | None = None, workspaces: dict[str, float] | None = None, workspace: str | None = None) -> str: """Semantic search across all documents in Mesh memory. @@ -184,10 +193,11 @@ async def mesh_search(query: str, limit: int = 10, tags: list[str] | None = None Args: query: What to search for (natural language) limit: Max results (default 10) - tags: Optional tag filter (AND logic) + tags: Optional tag filter (AND logic). List or comma-separated string. workspaces: Weighted multi-workspace search, e.g. {"sysadmin": 0.7, "security": 0.2} workspace: Target workspace (uses default if not set). Ignored if workspaces is set. """ + tags = _parse_tags(tags) body = {"query": query, "limit": limit} if tags: body["tags"] = tags @@ -212,7 +222,7 @@ async def mesh_search(query: str, limit: int = 10, tags: list[str] | None = None @mcp.tool() -async def mesh_add(content: str, tags: list[str] | None = None, source: str = "mcp", +async def mesh_add(content: str, tags: list[str] | str | None = None, source: str = "mcp", workspace: str | None = None) -> str: """Add a new document to Mesh memory. @@ -222,10 +232,11 @@ async def mesh_add(content: str, tags: list[str] | None = None, source: str = "m Args: content: Document text (min 10 chars) - tags: Optional tags. Auto-tags won't override these. + tags: Optional tags as list or comma-separated string. Auto-tags won't override these. source: Origin (default: "mcp") workspace: Target workspace (uses default if not set) """ + tags = _parse_tags(tags) body = {"content": content, "source": source} if tags: body["tags"] = tags @@ -243,9 +254,9 @@ async def mesh_add(content: str, tags: list[str] | None = None, source: str = "m async def mesh_update( guid: str, content: str | None = None, - tags: list[str] | None = None, - add_tags: list[str] | None = None, - remove_tags: list[str] | None = None, + tags: list[str] | str | None = None, + add_tags: list[str] | str | None = None, + remove_tags: list[str] | str | None = None, pinned: bool | None = None, workspace: str | None = None, ) -> str: @@ -254,12 +265,15 @@ async def mesh_update( Args: guid: Document GUID (doc_XXXXXXXX) content: New content (replaces existing) - tags: Replace all tags with these - add_tags: Add tags to existing - remove_tags: Remove tags from existing + tags: Replace all tags with these. List or comma-separated string. + add_tags: Add tags to existing. List or comma-separated string. + remove_tags: Remove tags from existing. List or comma-separated string. pinned: Pin document to top of workspace (True/False) workspace: Target workspace (uses default if not set) """ + tags = _parse_tags(tags) + add_tags = _parse_tags(add_tags) + remove_tags = _parse_tags(remove_tags) body = {} if content is not None: body["content"] = content @@ -318,16 +332,17 @@ async def mesh_get(guid: str, workspace: str | None = None) -> str: @mcp.tool() -async def mesh_bytag(tags: list[str], limit: int = 10, offset: int = 0, +async def mesh_bytag(tags: list[str] | str, limit: int = 10, offset: int = 0, workspace: str | None = None) -> str: """List documents filtered by tags (AND logic). Args: - tags: Tags to filter by + tags: Tags to filter by. List or comma-separated string. limit: Max documents (default 10) offset: Skip N documents for pagination workspace: Target workspace (uses default if not set) """ + tags = _parse_tags(tags) or [] params = {"limit": limit, "offset": offset} for t in tags: params.setdefault("tag", []) From 8b05ff47b3b847ff3e755d782e1c7a37da9c8a0a Mon Sep 17 00:00:00 2001 From: Dmytro Klymentiev <226637896+dklymentiev@users.noreply.github.com> Date: Sat, 11 Apr 2026 17:28:58 -0500 Subject: [PATCH 2/2] fix: param binding in search_similar_chunks with filters (regression from #655) When any of tags/date_from/date_to was set in SearchRequest, the filter branch of search_similar_chunks bound the LIMIT integer to the workspace_id slot (and vice versa), causing: invalid input for query argument $N: 30 (expected str, got int) and a 500 on every /search that carried a filter. Root cause: #655 added a workspace filter inside the inner WHERE of the filter branch, but left `params.append(limit)` above the workspace append. Because this branch labels parameters through a running `idx`, the early `limit` push shifted every subsequent slot by one: $idx intended actually ---- ---------------- ---------------- $5 c.workspace_id limit (int) <- 500: expected str $6 outer LIMIT ws (str) Fix: append `limit` after the workspace parameter and reference it via an explicit `outer_limit_ref` so the textual query and *params stay in sync. No change to the no-filter branch (it pins positions 1..5 literally and is unaffected). Reproduce before fix: curl -X POST http://localhost:8000/search \ -H "X-Workspace: any-ws" \ -d '{"query":"x","date_from":"2026-01-01T00:00:00"}' # 500 Internal Server Error After fix: 200 with results scoped to the requested workspace. Existing tests mock crud.EmbeddingCRUD at the class level (tests/conftest.py), so the real SQL path was not covered; an integration test for the filter branches is left to a follow-up. Co-Authored-By: Claude Opus 4.6 (1M context) --- mesh/crud.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/mesh/crud.py b/mesh/crud.py index 91ce1cc..168763f 100644 --- a/mesh/crud.py +++ b/mesh/crud.py @@ -423,7 +423,6 @@ async def search_similar_chunks( params.append(date_to) idx += 1 - params.append(limit) inner_where = " AND ".join(inner_conditions) # Add workspace filter to avoid HNSW + RLS issue (#655) @@ -432,6 +431,16 @@ async def search_similar_chunks( params.append(ws) idx += 1 + # Outer LIMIT must be appended AFTER workspace so that + # every ${N} in the built query maps to the right slot + # in *params. Previously `limit` was appended before the + # workspace filter, which shifted the binding and caused + # `expected str, got int` on the workspace_id slot + # whenever any filter (tags/date_from/date_to) was set. + outer_limit_ref = f"${idx}" + params.append(limit) + idx += 1 + rows = await conn.fetch( f""" SELECT guid, MAX(sim) AS similarity_score @@ -447,7 +456,7 @@ async def search_similar_chunks( WHERE sim >= $2 GROUP BY guid ORDER BY similarity_score DESC - LIMIT ${idx} + LIMIT {outer_limit_ref} """, *params )