From 714ba012bcdd63b2d7bd0486a3da70d3eccca054 Mon Sep 17 00:00:00 2001 From: Alex Hoffer Date: Tue, 5 May 2026 16:12:38 -0700 Subject: [PATCH 1/3] Surface AskBill sources in search_documentation MCP tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The AskBill websocket service returns both `answer` and `sources`, but the handler was only forwarding `answer`. Without citations, callers (and the LLMs consuming this tool) have no way to verify URLs the answer text mentions — in practice, hallucinated links slip through unchallenged. Append a `## Sources` section to the response, formatted from the `SourceMetadata` shape (title, url) defined in finn-mvp. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../tools/tool_search_documentation.py | 23 ++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/sandbox/src/mcp_server_plaid/tools/tool_search_documentation.py b/sandbox/src/mcp_server_plaid/tools/tool_search_documentation.py index 4d1e526..25ff211 100644 --- a/sandbox/src/mcp_server_plaid/tools/tool_search_documentation.py +++ b/sandbox/src/mcp_server_plaid/tools/tool_search_documentation.py @@ -33,12 +33,33 @@ ) +def _format_sources(sources: List[Dict[str, Any]]) -> str: + seen_urls = set() + lines = [] + for source in sources: + url = source.get("url", "") + title = source.get("title", "") or url + if url and url in seen_urls: + continue + if url: + seen_urls.add(url) + lines.append(f"- [{title}]({url})" if title != url else f"- {url}") + elif title: + lines.append(f"- {title}") + return "\n".join(lines) + + # Tool handler async def handle_search_documentation( arguments: Dict[str, Any], *, bill_client: AskBillClient, **_ ) -> List[types.TextContent | types.ImageContent | types.EmbeddedResource]: response = await bill_client.ask_question(question=arguments["question"]) - return [types.TextContent(type="text", text=str(response["answer"]))] + answer = str(response["answer"]) + sources = response.get("sources") or [] + formatted_sources = _format_sources(sources) + if formatted_sources: + answer = f"{answer}\n\n## Sources\n{formatted_sources}" + return [types.TextContent(type="text", text=answer)] registry.register(SEARCH_DOCUMENTATION_TOOL, handle_search_documentation) From 7012f51b60feb6438204617925ad34b20bbf03dd Mon Sep 17 00:00:00 2001 From: Alex Hoffer Date: Tue, 5 May 2026 16:17:57 -0700 Subject: [PATCH 2/3] Escape markdown brackets, use angle-bracket URLs, trim answer trailing whitespace - Escape `\`, `[`, `]` in source titles so titles like "Auth [v2]" don't break the rendered markdown link. - Wrap URLs in angle brackets (``) so URLs containing parens (e.g. Wikipedia's `Plaid_(company)`) still render correctly. - `rstrip()` the answer before appending the Sources section so trailing newlines in the AskBill response don't produce 3+ blank lines. - Add a docstring to `_format_sources` to match the rest of the file. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../tools/tool_search_documentation.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/sandbox/src/mcp_server_plaid/tools/tool_search_documentation.py b/sandbox/src/mcp_server_plaid/tools/tool_search_documentation.py index 25ff211..47156de 100644 --- a/sandbox/src/mcp_server_plaid/tools/tool_search_documentation.py +++ b/sandbox/src/mcp_server_plaid/tools/tool_search_documentation.py @@ -33,7 +33,13 @@ ) +def _escape_md_brackets(text: str) -> str: + """Escape characters that would break a markdown link's title text.""" + return text.replace("\\", "\\\\").replace("[", "\\[").replace("]", "\\]") + + def _format_sources(sources: List[Dict[str, Any]]) -> str: + """Format AskBill source metadata as a deduplicated markdown bullet list.""" seen_urls = set() lines = [] for source in sources: @@ -43,9 +49,12 @@ def _format_sources(sources: List[Dict[str, Any]]) -> str: continue if url: seen_urls.add(url) - lines.append(f"- [{title}]({url})" if title != url else f"- {url}") + if title != url: + lines.append(f"- [{_escape_md_brackets(title)}](<{url}>)") + else: + lines.append(f"- <{url}>") elif title: - lines.append(f"- {title}") + lines.append(f"- {_escape_md_brackets(title)}") return "\n".join(lines) @@ -58,7 +67,7 @@ async def handle_search_documentation( sources = response.get("sources") or [] formatted_sources = _format_sources(sources) if formatted_sources: - answer = f"{answer}\n\n## Sources\n{formatted_sources}" + answer = f"{answer.rstrip()}\n\n## Sources\n{formatted_sources}" return [types.TextContent(type="text", text=answer)] From fc6a607375c79d27edc16e7b6cc8fd2980206837 Mon Sep 17 00:00:00 2001 From: Alex Hoffer Date: Tue, 5 May 2026 16:46:53 -0700 Subject: [PATCH 3/3] Fix pytest config in pyproject.toml The `[tool.pytest]` section was non-canonical (should be `[tool.pytest.ini_options]`) and `python_files` was a bare string instead of a list. Older pytest tolerated both; newer pytest (9.x) hard-fails: TypeError: pyproject.toml: config option 'python_files' expects a list for type 'args', got str: 'test_*.py' Last green CI run was October 2025 against pytest 8.x; this PR is the first run since pytest 9 made it into the environment. Co-Authored-By: Claude Opus 4.7 (1M context) --- sandbox/pyproject.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sandbox/pyproject.toml b/sandbox/pyproject.toml index 029ae86..2d0a5e7 100644 --- a/sandbox/pyproject.toml +++ b/sandbox/pyproject.toml @@ -22,9 +22,9 @@ dependencies = [ [project.scripts] mcp-server-plaid = "mcp_server_plaid:main" -[tool.pytest] +[tool.pytest.ini_options] testpaths = ["src/mcp_server_plaid/test"] -python_files = "test_*.py" +python_files = ["test_*.py"] [project.optional-dependencies] test = [