diffbot-mcp 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,223 @@
1
+ """Pure helpers for assembling DQL strings. No I/O, so the typed tools' queries are unit-testable."""
2
+
3
+ import re
4
+ import unicodedata
5
+ from typing import Iterable, List, Optional, Tuple
6
+
7
+ from fastmcp.exceptions import ToolError
8
+
9
+ DATE_PATTERN = re.compile(r"^\d{4}-\d{2}-\d{2}$")
10
+ RADIUS_PATTERN = re.compile(r"^\d+(\.\d+)?\s?(mi|km)$")
11
+
12
+ # Titles are free text and an abbreviation is not a substring of its expansion, so a
13
+ # C-suite search that omits either form silently under-counts by ~5x. Board seats are
14
+ # titled "Director" far more often than "Board Member" (904 vs 5 at Nvidia).
15
+ TITLE_EXPANSIONS = {
16
+ "ceo": ["Chief Executive Officer", "CEO"],
17
+ "chief executive officer": ["Chief Executive Officer", "CEO"],
18
+ "cto": ["Chief Technology Officer", "CTO"],
19
+ "chief technology officer": ["Chief Technology Officer", "CTO"],
20
+ "cfo": ["Chief Financial Officer", "CFO"],
21
+ "chief financial officer": ["Chief Financial Officer", "CFO"],
22
+ "coo": ["Chief Operating Officer", "COO"],
23
+ "chief operating officer": ["Chief Operating Officer", "COO"],
24
+ "cio": ["Chief Information Officer", "CIO"],
25
+ "chief information officer": ["Chief Information Officer", "CIO"],
26
+ "cmo": ["Chief Marketing Officer", "CMO"],
27
+ "chief marketing officer": ["Chief Marketing Officer", "CMO"],
28
+ "board member": ["Board Member", "Board of Directors", "Director"],
29
+ "board of directors": ["Board Member", "Board of Directors", "Director"],
30
+ "board": ["Board Member", "Board of Directors", "Director"],
31
+ }
32
+
33
+
34
+ def quote(value: str) -> str:
35
+ escaped = str(value).replace("\\", "\\\\").replace('"', '\\"')
36
+ return f'"{escaped}"'
37
+
38
+
39
+ def clause(field: str, value: str, strict: bool = False) -> str:
40
+ return f"{'strict:' if strict else ''}{field}:{quote(value)}"
41
+
42
+
43
+ def dedupe(values: Iterable[str]) -> List[str]:
44
+ seen = set()
45
+ out = []
46
+ for value in values:
47
+ value = str(value).strip()
48
+ if value and value not in seen:
49
+ seen.add(value)
50
+ out.append(value)
51
+ return out
52
+
53
+
54
+ def or_clause(field: str, values: Iterable[str], strict: bool = False) -> str:
55
+ values = dedupe(values)
56
+ if not values:
57
+ return ""
58
+ if len(values) == 1:
59
+ return clause(field, values[0], strict)
60
+ return f"{'strict:' if strict else ''}{field}:or({','.join(quote(v) for v in values)})"
61
+
62
+
63
+ def subquery(field: str, clauses: Iterable[str]) -> str:
64
+ """field.{a b c}: co-constrain several conditions on the same nested list object."""
65
+ inner = " ".join(c for c in clauses if c)
66
+ return f"{field}.{{{inner}}}" if inner else ""
67
+
68
+
69
+ def number(value: float) -> str:
70
+ return str(int(value)) if float(value).is_integer() else str(value)
71
+
72
+
73
+ def compare(field: str, operator: str, value) -> str:
74
+ if isinstance(value, (int, float)) and not isinstance(value, bool):
75
+ return f"{field}{operator}{number(value)}"
76
+ return f"{field}{operator}{quote(str(value))}"
77
+
78
+
79
+ def validate_date(value: Optional[str], name: str) -> Optional[str]:
80
+ if value is None:
81
+ return None
82
+ value = value.strip()
83
+ if not DATE_PATTERN.match(value):
84
+ raise ToolError(f"{name} must be a full date in YYYY-MM-DD form, got '{value}'")
85
+ return value
86
+
87
+
88
+ def date_range(field: str, since: Optional[str], until: Optional[str]) -> List[str]:
89
+ clauses = []
90
+ if since:
91
+ clauses.append(compare(field, ">=", validate_date(since, "since")))
92
+ if until:
93
+ clauses.append(compare(field, "<=", validate_date(until, "until")))
94
+ return clauses
95
+
96
+
97
+ def sort_clause(field: str, descending: bool = True) -> str:
98
+ return f"{'revSortBy' if descending else 'sortBy'}:{field}"
99
+
100
+
101
+ def near_clause(anchor: str, radius: Optional[str] = None) -> str:
102
+ if radius:
103
+ radius = radius.strip().replace(" ", "")
104
+ if not RADIUS_PATTERN.match(radius):
105
+ raise ToolError(f"radius must be a number followed by mi or km, e.g. '30mi', got '{radius}'")
106
+ return f"near(type:Place name:{quote(anchor)}, {radius})"
107
+ return f"near(type:Place name:{quote(anchor)})"
108
+
109
+
110
+ def expand_titles(titles: Iterable[str]) -> List[str]:
111
+ expanded: List[str] = []
112
+ for title in titles:
113
+ expanded.extend(TITLE_EXPANSIONS.get(title.strip().lower(), [title]))
114
+ return dedupe(expanded)
115
+
116
+
117
+ def ascii_variant(text: str) -> Optional[str]:
118
+ """The ASCII spelling of an accented name, or None when it is already ASCII. Diacritics are not normalized in the KG and coverage of each spelling is inconsistent."""
119
+ folded = unicodedata.normalize("NFKD", text).encode("ascii", "ignore").decode("ascii")
120
+ return folded if folded and folded != text else None
121
+
122
+
123
+ def name_variants(text: str) -> List[str]:
124
+ variant = ascii_variant(text)
125
+ return [text, variant] if variant else [text]
126
+
127
+
128
+ def looks_like_domain(text: str) -> bool:
129
+ text = text.strip()
130
+ return " " not in text and "." in text and not text.endswith(".")
131
+
132
+
133
+ def build(clauses: Iterable[str]) -> str:
134
+ return " ".join(c for c in clauses if c)
135
+
136
+ _UNITS = {"k": 1e3, "m": 1e6, "b": 1e9}
137
+ _NUMBER = re.compile(r"^\$?([0-9][0-9,]*\.?[0-9]*)\s*([kmb])?$", re.IGNORECASE)
138
+
139
+
140
+ def parse_amount(text: str, name: str) -> float:
141
+ """'10M' -> 10000000, '$1.5b' -> 1500000000, '50,000' -> 50000."""
142
+ match = _NUMBER.match(text.strip())
143
+ if not match:
144
+ raise ToolError(f"{name}: cannot read '{text}'; use a number with an optional K/M/B suffix, e.g. '10M'.")
145
+ value = float(match.group(1).replace(",", ""))
146
+ unit = (match.group(2) or "").lower()
147
+ return value * _UNITS.get(unit, 1)
148
+
149
+
150
+ def parse_range(text: Optional[str], name: str, parse=parse_amount, integer: bool = False) -> Tuple[Optional[float], Optional[float]]:
151
+ """One range string -> inclusive (low, high). Forms: '10M-75M', '500+', '>=500', '>500', '<5000', '<=5000', '500' (exact).
152
+
153
+ Bounds are rendered as >= and <=, so with integer=True an exclusive '<20' becomes <=19 and '>20' becomes >=21.
154
+ """
155
+ if text is None:
156
+ return None, None
157
+ text = text.strip().replace(" ", "")
158
+ if not text:
159
+ return None, None
160
+ for op in (">=", "<=", ">", "<"):
161
+ if text.startswith(op):
162
+ value = parse(text[len(op):], name)
163
+ if integer:
164
+ value = int(value) + (1 if op == ">" else -1 if op == "<" else 0)
165
+ if op[0] == ">":
166
+ return value, None
167
+ return None, value
168
+ if integer:
169
+ parse_one = parse
170
+ parse = lambda t, n: int(parse_one(t, n)) # noqa: E731
171
+ if text.endswith("+"):
172
+ return parse(text[:-1], name), None
173
+ # split on a dash that is between two bounds, not a leading minus
174
+ parts = re.split(r"(?<=[0-9a-zA-Z])-(?=[\$0-9])", text, maxsplit=1)
175
+ if len(parts) == 2:
176
+ low, high = parse(parts[0], name), parse(parts[1], name)
177
+ if low > high:
178
+ raise ToolError(f"{name}: low bound {parts[0]} is above high bound {parts[1]}.")
179
+ return low, high
180
+ value = parse(text, name)
181
+ return value, value
182
+
183
+
184
+ def parse_year_or_date(text: str, name: str) -> str:
185
+ """'2015' -> '2015' as-is for the caller to pad; full dates pass validate_date."""
186
+ text = text.strip()
187
+ if re.fullmatch(r"[0-9]{4}", text):
188
+ return text
189
+ return validate_date(text, name) or text
190
+
191
+
192
+ def parse_date_range(text: Optional[str], name: str) -> Tuple[Optional[str], Optional[str]]:
193
+ """'2015-2020' -> ('2015-01-01', '2020-12-31'); '2019' -> that whole year; '>=2020-06-01' -> (that, None)."""
194
+ if text is None or not text.strip():
195
+ return None, None
196
+ text = text.strip().replace(" ", "")
197
+ def pad(value: Optional[str], end: bool) -> Optional[str]:
198
+ if value is None:
199
+ return None
200
+ return f"{value}-12-31" if end and len(value) == 4 else f"{value}-01-01" if len(value) == 4 else value
201
+ for op in (">=", "<=", ">", "<"):
202
+ if text.startswith(op):
203
+ value = parse_year_or_date(text[len(op):], name)
204
+ return (pad(value, False), None) if op[0] == ">" else (None, pad(value, True))
205
+ if text.endswith("+"):
206
+ return pad(parse_year_or_date(text[:-1], name), False), None
207
+ # year-year, or date-date (dates contain dashes, so split only on a dash followed by a 4-digit year)
208
+ parts = re.split(r"(?<=[0-9])-(?=[0-9]{4}(?:-|$))", text, maxsplit=1)
209
+ if len(parts) == 2 and parts[0] and parts[1]:
210
+ low, high = parse_year_or_date(parts[0], name), parse_year_or_date(parts[1], name)
211
+ return pad(low, False), pad(high, True)
212
+ value = parse_year_or_date(text, name)
213
+ return pad(value, False), pad(value, True)
214
+
215
+
216
+ def split_near(text: Optional[str]) -> Tuple[Optional[str], Optional[str]]:
217
+ """'Austin 30mi' -> ('Austin', '30mi'); 'Austin' -> ('Austin', None)."""
218
+ if not text:
219
+ return None, None
220
+ match = re.match(r"^(.*?)[\s,]+([0-9]+(?:\.[0-9]+)?\s*(?:mi|miles|km|m))\.?$", text.strip(), re.IGNORECASE)
221
+ if match:
222
+ return match.group(1).strip(), match.group(2).replace(" ", "").replace("miles", "mi")
223
+ return text.strip(), None
diffbot_mcp/schema.py ADDED
@@ -0,0 +1,34 @@
1
+ """Slims tool schemas on the way out of tools/list.
2
+
3
+ Pydantic renders every Optional parameter as anyOf:[{type:X},{type:null}] with default:null, roughly
4
+ 55 bytes of structure per parameter that tells the model nothing it does not already know from the
5
+ parameter being optional. Collapsing it is the largest single saving on the always-on schema cost.
6
+ Validation of incoming arguments still runs against the function signature, so None stays accepted.
7
+ """
8
+
9
+ from typing import Any, Sequence
10
+
11
+ from fastmcp.server.middleware import CallNext, Middleware, MiddlewareContext
12
+ from fastmcp.tools import Tool
13
+
14
+
15
+ def slim(node: Any) -> Any:
16
+ if isinstance(node, list):
17
+ return [slim(item) for item in node]
18
+ if not isinstance(node, dict):
19
+ return node
20
+ node = {key: slim(value) for key, value in node.items() if not (key == "default" and value is None)}
21
+ options = node.get("anyOf")
22
+ if isinstance(options, list):
23
+ non_null = [option for option in options if option != {"type": "null"}]
24
+ if len(non_null) == 1 and len(non_null) < len(options):
25
+ merged = dict(non_null[0])
26
+ merged.update({key: value for key, value in node.items() if key != "anyOf"})
27
+ return merged
28
+ return node
29
+
30
+
31
+ class SlimSchemas(Middleware):
32
+ async def on_list_tools(self, context: MiddlewareContext, call_next: CallNext) -> Sequence[Tool]:
33
+ tools = await call_next(context)
34
+ return [tool.model_copy(update={"parameters": slim(tool.parameters)}) for tool in tools]
diffbot_mcp/server.py ADDED
@@ -0,0 +1,22 @@
1
+ """Composition root: importing the tool modules registers them."""
2
+
3
+ from .app import mcp
4
+ from .schema import SlimSchemas
5
+ from .tools import ( # noqa: F401 (imported for their registration side effects)
6
+ extract,
7
+ search_web,
8
+ news,
9
+ organizations,
10
+ people,
11
+ places,
12
+ deals,
13
+ crawl,
14
+ resolve_entities,
15
+ dql,
16
+ dql_ontology,
17
+ dql_probe,
18
+ )
19
+
20
+ mcp.add_middleware(SlimSchemas())
21
+
22
+ __all__ = ["mcp"]
@@ -0,0 +1 @@
1
+ """Tool modules. Importing a module registers its tool on the shared FastMCP instance."""
@@ -0,0 +1,140 @@
1
+ import csv
2
+ import io
3
+ import time
4
+ from typing import Annotated, Literal, Optional
5
+
6
+ from fastmcp.exceptions import ToolError
7
+ from mcp.types import ToolAnnotations
8
+
9
+ from ..api import request, resolve_token
10
+ from ..app import mcp
11
+ from .extract import PageType, normalize_url
12
+
13
+ CRAWL_ENDPOINT = "https://api.diffbot.com/v3/crawl"
14
+ EXTRACT_BASE = "https://api.diffbot.com/v3"
15
+ # jobStatus.status codes 0 and 7 mean the job is still in progress
16
+ RUNNING_STATUS_CODES = (0, 7)
17
+
18
+ DESCRIPTION = (
19
+ "Crawls a website from a seed URL and extracts every page it finds into markdown or typed JSON. Prefer when a "
20
+ "task needs to discover a catalog of pages from an entire site, or deep search a site for structured data "
21
+ "(e.g. all product inventory, all press releases, PDFs). Crawls run in the background. `start` returns a job "
22
+ "name, poll `status` until is_running is false, then read `results` or `urls`. `list` and `delete` manage jobs."
23
+ " API docs: https://www.diffbot.com/docs/crawl/"
24
+ )
25
+
26
+
27
+ def job_summary(job: dict) -> dict:
28
+ status = job.get("jobStatus") or {}
29
+ code = status.get("status")
30
+ return {
31
+ "job_name": job.get("name"),
32
+ "status": status.get("message"),
33
+ "status_code": code,
34
+ "is_running": code in RUNNING_STATUS_CODES,
35
+ "pages_crawled": job.get("pageCrawlSuccesses"),
36
+ "pages_processed": job.get("pageProcessSuccesses"),
37
+ "objects_found": job.get("objectsFound"),
38
+ "created": job.get("jobCreationTimeUTC"),
39
+ }
40
+
41
+
42
+ @mcp.tool(name="crawl", title="Crawl Website", description=DESCRIPTION, annotations=ToolAnnotations(readOnlyHint=False, destructiveHint=True, idempotentHint=False, openWorldHint=True))
43
+ async def crawl(
44
+ action: Annotated[Literal["start", "status", "urls", "results", "list", "delete"], "start needs url; others need job_name"] = "start",
45
+ url: Annotated[Optional[str], "Seed URL (start)"] = None,
46
+ job_name: Annotated[Optional[str], "Job handle; generated on start if omitted"] = None,
47
+ hops: Annotated[int, "Link depth from seed"] = 2,
48
+ max_to_crawl: Annotated[int, "Max pages visited"] = 100,
49
+ max_to_process: Annotated[int, "Max pages extracted"] = 100,
50
+ restrict_domain: Annotated[bool, "Stay on the seed's domain"] = True,
51
+ url_crawl_pattern: Annotated[Optional[str], "Only visit URLs containing this"] = None,
52
+ url_process_pattern: Annotated[Optional[str], "Only extract URLs containing this, e.g. /blog/"] = None,
53
+ obey_robots: Annotated[bool, "Honor robots.txt"] = False,
54
+ use_proxies: Annotated[bool, "Crawl with Diffbot proxy; try when site blocks the crawler"] = False,
55
+ crawl_delay: Annotated[Optional[float], "Seconds between requests per domain"] = None,
56
+ custom_headers: Annotated[Optional[str], "Extra HTTP headers, newline separated"] = None,
57
+ page_type: Annotated[Optional[PageType], "Override auto-detection; rarely needed"] = None,
58
+ format: Annotated[Literal["markdown", "json"], "`markdown` for readable text; `json` for typed fields such as title, author, date, price, images"] = "markdown",
59
+ max_results: Annotated[int, "Max rows (urls, results)"] = 10,
60
+ offset: Annotated[int, "Rows to skip (urls, results)"] = 0,
61
+ ) -> dict:
62
+ token = resolve_token()
63
+
64
+ if action not in ("start", "list") and not job_name:
65
+ raise ToolError(f"job_name is required for action '{action}'")
66
+
67
+ if action == "start":
68
+ if not url:
69
+ raise ToolError("url is required to start a crawl")
70
+ url = normalize_url(url)
71
+ job_name = job_name or f"crawl-{int(time.time())}"
72
+
73
+ api_url = f"{EXTRACT_BASE}/{page_type or 'analyze'}"
74
+ if format == "markdown":
75
+ api_url = f"{api_url}?mode=llm"
76
+
77
+ params = {
78
+ "token": token,
79
+ "name": job_name,
80
+ "seeds": url,
81
+ "apiUrl": api_url,
82
+ "maxHops": hops,
83
+ "maxToCrawl": max_to_crawl,
84
+ "maxToProcess": max_to_process,
85
+ "restrictDomain": restrict_domain,
86
+ "obeyRobots": obey_robots,
87
+ "useProxies": use_proxies,
88
+ "urlCrawlPattern": url_crawl_pattern,
89
+ "urlProcessPattern": url_process_pattern,
90
+ "customHeaders": custom_headers,
91
+ "crawlDelay": crawl_delay if crawl_delay and crawl_delay > 0 else None,
92
+ }
93
+ response = await request(CRAWL_ENDPOINT, params=params)
94
+ return {"job_name": job_name, "response": response}
95
+
96
+ if action == "status":
97
+ response = await request(CRAWL_ENDPOINT, params={"token": token, "name": job_name})
98
+ jobs = response.get("jobs") or []
99
+ if not jobs:
100
+ raise ToolError(f"No crawl job named '{job_name}'")
101
+ return job_summary(jobs[0])
102
+
103
+ if action == "list":
104
+ response = await request(CRAWL_ENDPOINT, params={"token": token})
105
+ return {"jobs": [job_summary(job) for job in response.get("jobs") or []]}
106
+
107
+ if action == "delete":
108
+ response = await request(CRAWL_ENDPOINT, params={"token": token, "name": job_name, "delete": 1})
109
+ return {"job_name": job_name, "response": response}
110
+
111
+ if action == "urls":
112
+ text = await request(f"{CRAWL_ENDPOINT}/data", params={"token": token, "name": job_name, "type": "urls"}, as_text=True)
113
+ rows = []
114
+ for row in csv.DictReader(io.StringIO(text or "")):
115
+ page_url = (row.get("Url") or "").strip().strip('"')
116
+ if page_url:
117
+ rows.append({
118
+ "url": page_url,
119
+ "status": (row.get("Crawl Status") or "unknown").strip(),
120
+ "crawled": (row.get("Crawled Time") or "").strip() or None,
121
+ })
122
+ return {
123
+ "job_name": job_name,
124
+ "total_urls": len(rows),
125
+ "offset": offset,
126
+ "urls": rows[offset:offset + max_results],
127
+ }
128
+
129
+ response = await request(f"{CRAWL_ENDPOINT}/data", params={"token": token, "name": job_name, "format": "json"})
130
+ if isinstance(response, dict):
131
+ if response.get("error"):
132
+ raise ToolError(f"{response['error']} (job '{job_name}')")
133
+ response = response.get("objects") or []
134
+
135
+ return {
136
+ "job_name": job_name,
137
+ "total_results": len(response),
138
+ "offset": offset,
139
+ "results": response[offset:offset + max_results],
140
+ }
@@ -0,0 +1,234 @@
1
+ from typing import Annotated, List, Literal, Optional
2
+
3
+ from fastmcp.exceptions import ToolError
4
+
5
+ from ..app import READ_ONLY, mcp
6
+ from ..dql import DEFAULT_FIELDS, clean_value, resolve_value, run
7
+ from ..dql_builder import build, clause, compare, date_range, sort_clause, subquery, parse_range
8
+
9
+ DESCRIPTION = (
10
+ "Funding rounds, investments, and acquisitions in the Diffbot Knowledge Graph by company, investor, acquirer, "
11
+ "round series, amount, date, or the company's industry. Prefer for deal flow, a company's funding history, an "
12
+ "investor's portfolio activity, M&A lists, and the largest deals in a period. For related company data use "
13
+ "search_organizations."
14
+ " API docs: https://www.diffbot.com/docs/dql/"
15
+ )
16
+
17
+ Kind = Literal["funding", "acquisitions", "any"]
18
+ Sort = Literal["newest", "largest"]
19
+ Facet = Literal["series", "investors", "amount"]
20
+
21
+ ACQUISITION_FIELDS = (
22
+ "name,Target;acquiredBy.name,Acquirer;acquiredBy.amount.value,Amount;acquiredBy.amount.currency,Currency;"
23
+ "acquiredBy.date.str,Date;categories.name,Industry;id,Id"
24
+ )
25
+ INDUSTRY_GET = "get:id,name,investments,categories"
26
+
27
+
28
+ def build_deals_query(
29
+ *,
30
+ kind: Kind = "funding",
31
+ company: Optional[str] = None,
32
+ investor: Optional[str] = None,
33
+ acquirer: Optional[str] = None,
34
+ series: Optional[str] = None,
35
+ min_amount: Optional[float] = None,
36
+ max_amount: Optional[float] = None,
37
+ currency: Optional[str] = "USD",
38
+ since: Optional[str] = None,
39
+ until: Optional[str] = None,
40
+ industry: Optional[str] = None,
41
+ sort: Optional[Sort] = None,
42
+ facet: Optional[Facet] = None,
43
+ ) -> tuple:
44
+ """Return (dql, fields, mode) where mode is 'rows' or 'industry' (company-side json read). industry must already be an exact OrganizationCategory value."""
45
+ sort = sort or "newest"
46
+ if kind == "acquisitions":
47
+ clauses = ["type:Organization", "isAcquired:true"]
48
+ if company:
49
+ clauses.append(clause("name", company, strict=True))
50
+ if acquirer:
51
+ clauses.append(clause("acquiredBy.name", acquirer))
52
+ if investor:
53
+ raise ToolError("investor does not apply to acquisitions; use acquirer.")
54
+ if series:
55
+ raise ToolError("series does not apply to acquisitions.")
56
+ if industry:
57
+ clauses.append(clause("categories.name", industry))
58
+ if min_amount is not None:
59
+ clauses.append(compare("acquiredBy.amount.value", ">=", min_amount))
60
+ if max_amount is not None:
61
+ clauses.append(compare("acquiredBy.amount.value", "<=", max_amount))
62
+ if (min_amount is not None or max_amount is not None) and currency:
63
+ clauses.append(clause("acquiredBy.amount.currency", currency))
64
+ clauses.extend(date_range("acquiredBy.date", since, until))
65
+ if facet:
66
+ facet_field = {"investors": "acquiredBy.name", "amount": "acquiredBy.amount.value"}.get(facet)
67
+ if not facet_field:
68
+ raise ToolError("facet 'series' does not apply to acquisitions.")
69
+ clauses.append(f"facet:{facet_field}")
70
+ elif sort:
71
+ clauses.append(sort_clause("acquiredBy.date" if sort == "newest" else "acquiredBy.amount.value"))
72
+ return build(clauses), ACQUISITION_FIELDS, "rows"
73
+
74
+ if kind == "funding" and industry:
75
+ # investee is a bare LinkedEntity with no categories, so the industry lives on the
76
+ # company; the round's conditions are co-constrained on one investment with {}
77
+ round_conditions = []
78
+ if series:
79
+ round_conditions.append(clause("series", series))
80
+ if investor:
81
+ round_conditions.append(clause("investors.name", investor))
82
+ if min_amount is not None:
83
+ round_conditions.append(compare("amount.value", ">=", min_amount))
84
+ if max_amount is not None:
85
+ round_conditions.append(compare("amount.value", "<=", max_amount))
86
+ if (min_amount is not None or max_amount is not None) and currency:
87
+ round_conditions.append(clause("amount.currency", currency))
88
+ round_conditions.extend(date_range("date", since, until))
89
+ clauses = ["type:Organization", clause("categories.name", industry)]
90
+ if company:
91
+ clauses.append(clause("name", company, strict=True))
92
+ clauses.append(subquery("investments", round_conditions) if round_conditions else "has:investments")
93
+ if facet:
94
+ raise ToolError("facet does not combine with industry; drop industry or facet.")
95
+ if sort:
96
+ clauses.append(sort_clause("investments.date" if sort == "newest" else "investments.amount.value"))
97
+ return build(clauses), None, "industry"
98
+
99
+ if kind == "funding":
100
+ clauses = ["type:Investment"]
101
+ company_field, investor_field, amount_field, date_field = "investee.name", "investment.investors.name", "investment.amount", "investment.date"
102
+ else:
103
+ clauses = ["type:Transaction"]
104
+ company_field, investor_field, amount_field, date_field = "payee.name", "payers.name", "amount", "date"
105
+ if series:
106
+ raise ToolError("series applies to kind 'funding' only.")
107
+ if industry:
108
+ raise ToolError("industry applies to kind 'funding' or 'acquisitions'.")
109
+
110
+ if company:
111
+ clauses.append(clause(company_field, company))
112
+ if investor or acquirer:
113
+ clauses.append(clause(investor_field, investor or acquirer))
114
+ if series:
115
+ clauses.append(clause("investment.series", series))
116
+ if min_amount is not None:
117
+ clauses.append(compare(f"{amount_field}.value", ">=", min_amount))
118
+ if max_amount is not None:
119
+ clauses.append(compare(f"{amount_field}.value", "<=", max_amount))
120
+ if (min_amount is not None or max_amount is not None) and currency:
121
+ clauses.append(clause(f"{amount_field}.currency", currency))
122
+ clauses.extend(date_range(date_field, since, until))
123
+
124
+ if len(clauses) == 1:
125
+ raise ToolError("search_deals needs at least one filter.")
126
+
127
+ if facet:
128
+ facet_field = {"series": "investment.series", "investors": investor_field, "amount": f"{amount_field}.value"}[facet]
129
+ if kind == "any" and facet == "series":
130
+ raise ToolError("facet 'series' applies to kind 'funding' only.")
131
+ clauses.append(f"facet:{facet_field}")
132
+ elif sort:
133
+ clauses.append(sort_clause(date_field if sort == "newest" else f"{amount_field}.value"))
134
+ fields = DEFAULT_FIELDS["Investment" if kind == "funding" else "Transaction"]
135
+ return build(clauses), fields, "rows"
136
+
137
+
138
+ def plain_number(value):
139
+ """json amounts arrive as floats; render integral ones as ints so rows match the csv form."""
140
+ if isinstance(value, float) and value.is_integer():
141
+ return int(value)
142
+ return value
143
+
144
+
145
+ def investment_matches(investment: dict, *, series, investor, min_amount, max_amount, currency, since, until) -> bool:
146
+ amount = investment.get("amount") or {}
147
+ value = amount.get("value")
148
+ if min_amount is not None and (value is None or value < min_amount):
149
+ return False
150
+ if max_amount is not None and (value is None or value > max_amount):
151
+ return False
152
+ if (min_amount is not None or max_amount is not None) and currency and amount.get("currency") and amount["currency"] != currency:
153
+ return False
154
+ if series and series.lower() not in (investment.get("series") or "").lower():
155
+ return False
156
+ if investor and not any(investor.lower() in (i.get("name") or "").lower() for i in investment.get("investors") or []):
157
+ return False
158
+ date = clean_value((investment.get("date") or {}).get("str") or "")[:10]
159
+ if since and (not date or date < since):
160
+ return False
161
+ if until and (not date or date > until):
162
+ return False
163
+ return True
164
+
165
+
166
+ def render_company_rounds(entity: dict, **conditions) -> List[dict]:
167
+ rows = []
168
+ for investment in entity.get("investments") or []:
169
+ if not investment_matches(investment, **conditions):
170
+ continue
171
+ amount = investment.get("amount") or {}
172
+ row = {
173
+ "Company": entity.get("name"),
174
+ "Round": investment.get("series"),
175
+ "Amount": plain_number(amount.get("value")),
176
+ "Currency": amount.get("currency"),
177
+ "Date": clean_value((investment.get("date") or {}).get("str") or "")[:10] or None,
178
+ "Investors": ", ".join(i.get("name") for i in investment.get("investors") or [] if i.get("name")) or None,
179
+ "Company Id": entity.get("id"),
180
+ }
181
+ rows.append({key: value for key, value in row.items() if value is not None and value != ""})
182
+ return rows
183
+
184
+
185
+ @mcp.tool(name="search_deals", title="Search Deals", description=DESCRIPTION, annotations=READ_ONLY)
186
+ async def search_deals(
187
+ kind: Annotated[Kind, "funding | acquisitions | any"] = "funding",
188
+ company: Annotated[Optional[str], "Company raising or acquired"] = None,
189
+ investor: Annotated[Optional[str], "Investor name"] = None,
190
+ acquirer: Annotated[Optional[str], "Acquirer name"] = None,
191
+ series: Annotated[Optional[str], "Round, e.g. 'Series A'"] = None,
192
+ amount: Annotated[Optional[str], "Deal size range, e.g. '50M+', '1M-10M'"] = None,
193
+ currency: Annotated[Optional[str], "Currency of amount"] = "USD",
194
+ since: Annotated[Optional[str], "Earliest date, YYYY-MM-DD"] = None,
195
+ until: Annotated[Optional[str], "Latest date, YYYY-MM-DD"] = None,
196
+ industry: Annotated[Optional[str], "Company's industry; free text ok"] = None,
197
+ sort: Annotated[Optional[Sort], "Default newest; `largest` for biggest deals first"] = None,
198
+ facet: Annotated[Optional[Facet], "Bucket by series, investors, or amount"] = None,
199
+ size: Annotated[int, "Max rows or buckets"] = 10,
200
+ offset: Annotated[int, "Rows to skip"] = 0,
201
+ ) -> dict:
202
+ min_amount, max_amount = parse_range(amount, "amount")
203
+ notes: List[str] = []
204
+ if industry:
205
+ resolved = await resolve_value("OrganizationCategory", industry)
206
+ if resolved.value is None:
207
+ if resolved.candidates:
208
+ return {"dql": None, "candidates": {"industry": resolved.candidates}, "notes": [resolved.note]}
209
+ raise ToolError(resolved.note)
210
+ industry = resolved.value
211
+ if resolved.note:
212
+ notes.append(resolved.note)
213
+
214
+ query, fields, mode = build_deals_query(
215
+ kind=kind, company=company, investor=investor, acquirer=acquirer, series=series, min_amount=min_amount,
216
+ max_amount=max_amount, currency=currency, since=since, until=until, industry=industry, sort=sort, facet=facet,
217
+ )
218
+
219
+ if mode == "industry":
220
+ result = await run(f"{query} {INDUSTRY_GET}", size=size, offset=offset, format="json", bare_type_check=True)
221
+ conditions = dict(series=series, investor=investor, min_amount=min_amount, max_amount=max_amount, currency=currency, since=since, until=until)
222
+ rows = []
223
+ for entity in result.get("results") or []:
224
+ rows.extend(render_company_rounds(entity, **conditions))
225
+ result["dql"] = query
226
+ result["results"] = rows
227
+ result["returned"] = len(rows)
228
+ notes.append("hits counts companies in the industry with a matching round; results list each matching round per company.")
229
+ else:
230
+ result = await run(query, size=size, offset=offset, fields=fields, bare_type_check=True)
231
+
232
+ if notes:
233
+ result["notes"] = result.get("notes", []) + notes
234
+ return result