Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/1c6z.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
**MCP `edgar_fund` silently picked one BDC when a name matched several.** "Golub" returned GOLUB CAPITAL BDC though four Golub BDCs score 99.4-100. A name now resolves only on one match equal to it (ignoring a suffix like Corp or Inc., so "Ares Capital" is still ARES CAPITAL CORP) or a clear top hit; otherwise the tool returns `AMBIGUOUS_BDC` with up to five candidates and their CIKs. (bead edgartools-1c6z)
50 changes: 46 additions & 4 deletions edgar/ai/mcp/tools/fund.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from __future__ import annotations

import logging
import re
from decimal import Decimal
from typing import Any, Optional

Expand Down Expand Up @@ -496,6 +497,37 @@ async def _bdc_search(query: str, limit: int) -> Any:
return error(str(e), suggestions=get_error_suggestions(e))


_MAX_AMBIGUOUS_BDCS = 5
_BDC_NAME_SUFFIXES = {"corp", "corporation", "inc", "incorporated", "co", "company", "llc", "lp", "l.p", "ltd"}


def _bdc_name_key(name: str) -> str:
"""A name without case, punctuation or a trailing corporate suffix: "ARES CAPITAL CORP" -> "ares capital"."""
words = re.sub(r"[^\w\s.]", " ", str(name).casefold()).split()
while len(words) > 1 and words[-1].rstrip(".") in _BDC_NAME_SUFFIXES:
words.pop()
return " ".join(words)


def _pick_bdc_search_winner(query: str, rows: list[dict]) -> Optional[int]:
"""The CIK a name search may resolve to without asking, from score-ordered rows, or None.

Every whole-word hit scores ~100 ("Golub" matches four Golub BDCs at 99.4-100),
so the top hit alone proves nothing. One name equal to the query, ignoring a
corporate suffix, wins ("Ares Capital" is ARES CAPITAL CORP, not its 99.3-scoring
sibling funds). Otherwise the top hit needs >= 95 with the runner-up under 90.
"""
wanted = _bdc_name_key(query)
exact = [row for row in rows if _bdc_name_key(row["name"]) == wanted]
if len(exact) == 1:
return int(exact[0]["cik"])
top = float(rows[0]["score"])
runner_up = float(rows[1]["score"]) if len(rows) > 1 else None
if top >= 95 and (runner_up is None or runner_up < 90):
return int(rows[0]["cik"])
return None


def _bdc_investment_record(inv) -> dict[str, Any]:
"""One PortfolioInvestment as a record. Every key is always present; a missing
value is None, never 0, so an absent figure cannot read as a zero position."""
Expand Down Expand Up @@ -537,11 +569,21 @@ async def _bdc_portfolio(identifier: str, limit: int) -> Any:
except (ValueError, TypeError):
pass

# Try search as fallback
# Try search as fallback; it must identify one BDC, never guess between several
if bdc is None:
search_results = find_bdc(identifier, top_n=1)
if not search_results.empty:
bdc = search_results[0]
rows = find_bdc(identifier, top_n=6).results.to_dict("records")
if rows:
winner = _pick_bdc_search_winner(identifier, rows)
if winner is None:
return error(
f"'{identifier}' matches more than one BDC; pass a ticker or CIK",
suggestions=[
f"{row['name']}: identifier='{int(row['cik'])}'"
for row in rows[:_MAX_AMBIGUOUS_BDCS]
],
error_code="AMBIGUOUS_BDC",
)
bdc = bdcs.get_by_cik(winner)

if bdc is None:
return error(
Expand Down
64 changes: 64 additions & 0 deletions tests/bdc/test_bdc_mcp_portfolio.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,3 +81,67 @@ async def test_every_record_has_the_same_keys_and_a_missing_figure_is_none():
# Bolder Panther's cost is not tagged: None, never a zero position
assert records[1]["cost"] is None
assert records[1]["principal_amount"] is None


# ---------------------------------------------------------------------------
# Ambiguous names (edgartools-1c6z). Scores are find_bdc's on 2026-10-01: every
# whole-word hit lands near 100, so the top hit alone identified nothing and
# "Golub" silently became GOLUB CAPITAL BDC.
# ---------------------------------------------------------------------------

def _rows(*hits):
return [{"cik": cik, "ticker": None, "name": name, "state": None, "is_active": True, "score": score}
for cik, name, score in hits]


GOLUB = _rows((1476765, "GOLUB CAPITAL BDC, Inc.", 100.0), (1901612, "Golub Capital BDC 4, Inc.", 99.8),
(1868878, "GOLUB CAPITAL DIRECT LENDING CORP", 99.4), (1930087, "Golub Capital Private Credit Fund", 99.4))
ARES = _rows((1287750, "ARES CAPITAL CORP", 100.0), (1918712, "ARES STRATEGIC INCOME FUND", 99.3),
(1990049, "Ares Core Infrastructure Fund", 99.3))
MAIN_STREET = _rows((1396440, "Main Street Capital CORP", 100.0), (1999999, "Phillip Street BDC LLC", 70.6))


@pytest.mark.fast
@pytest.mark.parametrize("query, rows, expected", [
("Golub", GOLUB, None),
("Ares Capital", ARES, 1287750), # equal to ARES CAPITAL CORP once the suffix goes
("ares capital corp.", ARES, 1287750),
("Ares", ARES, None), # three Ares funds; nothing says which
("Main Street", MAIN_STREET, 1396440), # clear winner: 100 against 70.6
("Golub Capital BDC", GOLUB, 1476765), # "Inc." dropped; BDC 4 differs by its "4"
])
def test_a_name_resolves_only_when_it_identifies_one_bdc(query, rows, expected):
from edgar.ai.mcp.tools.fund import _pick_bdc_search_winner
assert _pick_bdc_search_winner(query, rows) == expected


@pytest.mark.fast
def test_a_single_weak_hit_is_not_used():
from edgar.ai.mcp.tools.fund import _pick_bdc_search_winner
assert _pick_bdc_search_winner("Phillip", _rows((1999999, "Phillip Street BDC LLC", 72.0))) is None


@pytest.mark.fast
@pytest.mark.asyncio
async def test_an_ambiguous_name_lists_the_candidates_instead_of_picking_one():
import pandas as pd

from edgar.ai.mcp.tools.fund import _bdc_portfolio

bdcs = MagicMock()
bdcs.get_by_ticker.return_value = None
search = MagicMock()
search.results = pd.DataFrame(GOLUB)
with patch("edgar.bdc.reference.get_bdc_list", return_value=bdcs), \
patch("edgar.bdc.search.find_bdc", return_value=search):
response = await _bdc_portfolio(identifier="Golub", limit=5)

assert not response.success
assert response.error_code == "AMBIGUOUS_BDC"
assert response.suggestions == [
"GOLUB CAPITAL BDC, Inc.: identifier='1476765'",
"Golub Capital BDC 4, Inc.: identifier='1901612'",
"GOLUB CAPITAL DIRECT LENDING CORP: identifier='1868878'",
"Golub Capital Private Credit Fund: identifier='1930087'",
]
bdcs.portfolio_investments.assert_not_called()
Loading