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.
- diffbot_mcp/__init__.py +3 -0
- diffbot_mcp/api.py +128 -0
- diffbot_mcp/app.py +11 -0
- diffbot_mcp/auth.py +82 -0
- diffbot_mcp/dql.py +382 -0
- diffbot_mcp/dql_builder.py +223 -0
- diffbot_mcp/schema.py +34 -0
- diffbot_mcp/server.py +22 -0
- diffbot_mcp/tools/__init__.py +1 -0
- diffbot_mcp/tools/crawl.py +140 -0
- diffbot_mcp/tools/deals.py +234 -0
- diffbot_mcp/tools/dql.py +28 -0
- diffbot_mcp/tools/dql_ontology.py +50 -0
- diffbot_mcp/tools/dql_probe.py +18 -0
- diffbot_mcp/tools/extract.py +38 -0
- diffbot_mcp/tools/news.py +121 -0
- diffbot_mcp/tools/organizations.py +191 -0
- diffbot_mcp/tools/people.py +254 -0
- diffbot_mcp/tools/places.py +109 -0
- diffbot_mcp/tools/resolve_entities.py +52 -0
- diffbot_mcp/tools/search_web.py +47 -0
- diffbot_mcp-0.2.0.dist-info/METADATA +233 -0
- diffbot_mcp-0.2.0.dist-info/RECORD +28 -0
- diffbot_mcp-0.2.0.dist-info/WHEEL +5 -0
- diffbot_mcp-0.2.0.dist-info/entry_points.txt +2 -0
- diffbot_mcp-0.2.0.dist-info/licenses/LICENSE +21 -0
- diffbot_mcp-0.2.0.dist-info/top_level.txt +2 -0
- diffbot_mcp_server.py +11 -0
diffbot_mcp/tools/dql.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from typing import Annotated, Literal, Optional
|
|
2
|
+
|
|
3
|
+
from ..app import READ_ONLY, mcp
|
|
4
|
+
from ..dql import run
|
|
5
|
+
|
|
6
|
+
DESCRIPTION = (
|
|
7
|
+
"Runs a raw DQL query against the Diffbot Knowledge Graph. Prefer for entity types and query shapes the typed "
|
|
8
|
+
"search tools do not cover: products, patents, job posts, facet aggregations, cross-entity queries, or an id "
|
|
9
|
+
"filter from resolve_entities. Each typed tool returns the DQL it ran, which can be refined here. Conditions "
|
|
10
|
+
"on one nested list object go inside one {} block, e.g. employments.{employer.name:X isCurrent:true}. Confirm "
|
|
11
|
+
"field paths and taxonomy spellings with `dql_ontology`, since an unknown path is silently ignored. Do not add "
|
|
12
|
+
"a sort unless ordering is the question. Check hit counts with `dql_probe` before pulling rows. For a CSV "
|
|
13
|
+
"file, do not page rows: GET https://kg.diffbot.com/kg/v3/dql?token=..&format=csv&size=<hits>"
|
|
14
|
+
"&exportspec=<fields>&query=<DQL> for a download URL or save to disk."
|
|
15
|
+
" API docs: https://www.diffbot.com/docs/dql/"
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@mcp.tool(name="dql", title="Run DQL Query", description=DESCRIPTION, annotations=READ_ONLY)
|
|
20
|
+
async def dql(
|
|
21
|
+
query: Annotated[str, "DQL, starting with type:X"],
|
|
22
|
+
size: Annotated[int, "Max rows or buckets"] = 10,
|
|
23
|
+
offset: Annotated[int, "Rows to skip"] = 0,
|
|
24
|
+
fields: Annotated[Optional[str], "CSV columns as 'path,Label;path,Label'; default per entity type"] = None,
|
|
25
|
+
format: Annotated[Literal["csv", "json"], "'json' for full records; add get:a,b to query to limit"] = "csv",
|
|
26
|
+
hits_only: Annotated[bool, "Count only"] = False,
|
|
27
|
+
) -> dict:
|
|
28
|
+
return await run(query, size=size, offset=offset, fields=fields, format=format, hits_only=hits_only)
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from typing import Annotated, Literal, Optional
|
|
3
|
+
|
|
4
|
+
from fastmcp.exceptions import ToolError
|
|
5
|
+
|
|
6
|
+
from ..app import READ_ONLY, mcp
|
|
7
|
+
from ..dql import enum_values, find_named, get_ontology, ontology_fields, ontology_names, rank_names, taxonomy_values
|
|
8
|
+
|
|
9
|
+
DESCRIPTION = (
|
|
10
|
+
"Looks up the Knowledge Graph ontology: entity types, their fields, and taxonomy or enum values. Use before "
|
|
11
|
+
"raw dql to confirm a field path exists and to get the exact spelling of a value (a guessed path is silently "
|
|
12
|
+
"ignored, a guessed value returns zero). Results are ranked best match first."
|
|
13
|
+
" API docs: https://docs.diffbot.com/docs/ontology"
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@mcp.tool(name="dql_ontology", title="Look Up DQL Ontology", description=DESCRIPTION, annotations=READ_ONLY)
|
|
18
|
+
async def dql_ontology(
|
|
19
|
+
action: Annotated[Literal["types", "composites", "enums", "taxonomies", "fields", "taxonomy", "enum", "search"], "types and composites list entity types and nested object types; enums and taxonomies list value-set names; fields lists a type's or composite's fields (needs name); taxonomy or enum lists one value set (needs name); search matches names across everything (needs search)"],
|
|
20
|
+
name: Annotated[Optional[str], "Type or composite (fields), or taxonomy/enum name"] = None,
|
|
21
|
+
search: Annotated[Optional[str], "Case-insensitive regex filter"] = None,
|
|
22
|
+
limit: Annotated[int, "Max results; total is always reported"] = 25,
|
|
23
|
+
) -> dict:
|
|
24
|
+
ontology = await get_ontology()
|
|
25
|
+
pattern = re.compile(search, re.IGNORECASE) if search else None
|
|
26
|
+
|
|
27
|
+
if action in ("fields", "taxonomy", "enum") and not name:
|
|
28
|
+
raise ToolError(f"name is required for action '{action}'")
|
|
29
|
+
if action == "search" and pattern is None:
|
|
30
|
+
raise ToolError("search is required for action 'search'")
|
|
31
|
+
|
|
32
|
+
if action in ("types", "composites", "enums", "taxonomies"):
|
|
33
|
+
results = ontology_names(ontology, action)
|
|
34
|
+
elif action == "fields":
|
|
35
|
+
results = ontology_fields(ontology, name, pattern)
|
|
36
|
+
elif action == "taxonomy":
|
|
37
|
+
results = taxonomy_values(ontology, name, pattern)
|
|
38
|
+
if pattern is not None:
|
|
39
|
+
results = rank_names(results, pattern)
|
|
40
|
+
elif action == "enum":
|
|
41
|
+
results = enum_values(ontology, name)
|
|
42
|
+
else:
|
|
43
|
+
results = find_named(ontology, pattern)
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
"action": action,
|
|
47
|
+
"total": len(results),
|
|
48
|
+
"returned": min(len(results), limit),
|
|
49
|
+
"results": results[:limit],
|
|
50
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from typing import Annotated, List
|
|
2
|
+
|
|
3
|
+
from ..app import READ_ONLY, mcp
|
|
4
|
+
from ..dql import probe
|
|
5
|
+
|
|
6
|
+
DESCRIPTION = (
|
|
7
|
+
"Hit counts for several DQL queries at once, to compare variants before pulling rows (co-constrained {} vs "
|
|
8
|
+
"loose, category vs descriptors). Zero hits means a wrong value (check dql_ontology); a count equal to the bare "
|
|
9
|
+
"type:X count means a field path was ignored. similarTo cannot be probed; never probe an unfiltered type:Article."
|
|
10
|
+
" API docs: https://docs.diffbot.com/docs/dql-quickstart"
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@mcp.tool(name="dql_probe", title="Probe DQL Hit Counts", description=DESCRIPTION, annotations=READ_ONLY)
|
|
15
|
+
async def dql_probe(
|
|
16
|
+
queries: Annotated[List[str], "DQL queries, each starting with type:X"],
|
|
17
|
+
) -> dict:
|
|
18
|
+
return {"results": await probe(queries)}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
from typing import Annotated, Literal, Optional
|
|
2
|
+
|
|
3
|
+
from fastmcp.exceptions import ToolError
|
|
4
|
+
|
|
5
|
+
from ..api import request, resolve_token
|
|
6
|
+
from ..app import READ_ONLY, mcp
|
|
7
|
+
|
|
8
|
+
EXTRACT_BASE = "https://api.diffbot.com/v3"
|
|
9
|
+
PageType = Literal["article", "product", "discussion", "image", "video", "list", "event", "job", "faq"]
|
|
10
|
+
|
|
11
|
+
DESCRIPTION = (
|
|
12
|
+
"Fetches a live web page and returns its main content as markdown or a token efficient typed JSON record "
|
|
13
|
+
"(article, product, discussion, job, event, list). Renders Javascript, so it works on more sites than a plain "
|
|
14
|
+
"fetch. Prefer over a generic fetch tool for any public page."
|
|
15
|
+
" API docs: https://www.diffbot.com/docs/extract/"
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def normalize_url(url: str) -> str:
|
|
20
|
+
url = url.strip()
|
|
21
|
+
return url if url.startswith("http") else f"https://{url}"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@mcp.tool(name="extract", title="Extract Page", description=DESCRIPTION, annotations=READ_ONLY)
|
|
25
|
+
async def extract(
|
|
26
|
+
url: Annotated[str, "Page URL; https:// added if missing"],
|
|
27
|
+
page_type: Annotated[Optional[PageType], "Override auto-detection; rarely needed"] = None,
|
|
28
|
+
format: Annotated[Literal["markdown", "json"], "`markdown` for readable text; `json` for typed fields such as title, author, date, price, images"] = "markdown",
|
|
29
|
+
) -> dict:
|
|
30
|
+
params = {"token": resolve_token(), "url": normalize_url(url), "timeout": 30000}
|
|
31
|
+
if format == "markdown":
|
|
32
|
+
params["mode"] = "llm"
|
|
33
|
+
|
|
34
|
+
data = await request(f"{EXTRACT_BASE}/{page_type or 'analyze'}", params=params)
|
|
35
|
+
# the API reports a failed extraction inside a 200 body
|
|
36
|
+
if isinstance(data, dict) and "errorCode" in data:
|
|
37
|
+
raise ToolError(f"Extraction error {data['errorCode']}: {data.get('error', '')}. The page may be behind a login, a bot wall, or a JavaScript app; try a specific page_type such as 'article'.")
|
|
38
|
+
return data
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
from typing import Annotated, List, Optional
|
|
2
|
+
|
|
3
|
+
from fastmcp.exceptions import ToolError
|
|
4
|
+
|
|
5
|
+
from ..app import READ_ONLY, mcp
|
|
6
|
+
from ..dql import DEFAULT_FIELDS, resolve_value, run
|
|
7
|
+
from ..dql_builder import build, clause, compare, date_range, or_clause, subquery
|
|
8
|
+
|
|
9
|
+
DESCRIPTION = (
|
|
10
|
+
"News and article structured search over the Diffbot Knowledge Graph. Prefer for highly relevant results on "
|
|
11
|
+
"named entities or uncommon keywords. Filter by mentioned organization/person/brand/product, topic, publisher, "
|
|
12
|
+
"language, country, date range, or sentiment. Results are chronological newest first, with no semantic "
|
|
13
|
+
"reranking. Prefer over a generic web search for any news, including breaking stories. mentions is the "
|
|
14
|
+
"strongest signal filter; if it returns nothing, retry the same words as text."
|
|
15
|
+
" API docs: https://www.diffbot.com/docs/dql/"
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def build_news_query(
|
|
20
|
+
*,
|
|
21
|
+
mentions: Optional[List[str]] = None,
|
|
22
|
+
topic: Optional[str] = None,
|
|
23
|
+
text: Optional[str] = None,
|
|
24
|
+
title: Optional[str] = None,
|
|
25
|
+
publisher: Optional[str] = None,
|
|
26
|
+
language: Optional[str] = None,
|
|
27
|
+
publisher_country: Optional[str] = None,
|
|
28
|
+
quoted_speaker: Optional[str] = None,
|
|
29
|
+
since: Optional[str] = None,
|
|
30
|
+
until: Optional[str] = None,
|
|
31
|
+
sentiment_min: Optional[float] = None,
|
|
32
|
+
sentiment_max: Optional[float] = None,
|
|
33
|
+
mention_sentiment_max: Optional[float] = None,
|
|
34
|
+
) -> tuple:
|
|
35
|
+
"""Return (dql, notes). topic must already be an exact ArticleCategory value."""
|
|
36
|
+
clauses = ["type:Article"]
|
|
37
|
+
notes: List[str] = []
|
|
38
|
+
|
|
39
|
+
if mentions:
|
|
40
|
+
if mention_sentiment_max is not None and len(mentions) == 1:
|
|
41
|
+
# per-entity sentiment lives on the tag, so co-constrain label and score on one tag
|
|
42
|
+
clauses.append(subquery("tags", [clause("label", mentions[0]), compare("sentiment", "<", mention_sentiment_max)]))
|
|
43
|
+
else:
|
|
44
|
+
clauses.append(or_clause("tags.label", mentions))
|
|
45
|
+
if mention_sentiment_max is not None:
|
|
46
|
+
notes.append("mention_sentiment_max applies only with exactly one mention and was ignored; use sentiment_max for the document-level score.")
|
|
47
|
+
elif mention_sentiment_max is not None:
|
|
48
|
+
notes.append("mention_sentiment_max needs a mention and was ignored.")
|
|
49
|
+
|
|
50
|
+
if topic:
|
|
51
|
+
clauses.append(clause("categories.name", topic))
|
|
52
|
+
if text:
|
|
53
|
+
clauses.append(clause("text", text))
|
|
54
|
+
if title:
|
|
55
|
+
clauses.append(clause("title", title))
|
|
56
|
+
if publisher:
|
|
57
|
+
clauses.append(clause("siteName", publisher))
|
|
58
|
+
if language:
|
|
59
|
+
clauses.append(clause("language", language))
|
|
60
|
+
if publisher_country:
|
|
61
|
+
clauses.append(clause("publisherCountry", publisher_country))
|
|
62
|
+
if quoted_speaker:
|
|
63
|
+
clauses.append(clause("quotes.speaker", quoted_speaker))
|
|
64
|
+
clauses.extend(date_range("date", since, until))
|
|
65
|
+
if sentiment_min is not None:
|
|
66
|
+
clauses.append(compare("sentiment", ">=", sentiment_min))
|
|
67
|
+
if sentiment_max is not None:
|
|
68
|
+
clauses.append(compare("sentiment", "<=", sentiment_max))
|
|
69
|
+
|
|
70
|
+
if len(clauses) == 1:
|
|
71
|
+
raise ToolError("search_news needs at least one narrowing parameter (mentions, topic, text, title, publisher, quoted_speaker, or a date range); an unfiltered article query times out.")
|
|
72
|
+
|
|
73
|
+
clauses.append("sortBy:date")
|
|
74
|
+
return build(clauses), notes
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@mcp.tool(name="search_news", title="Search News", description=DESCRIPTION, annotations=READ_ONLY)
|
|
78
|
+
async def search_news(
|
|
79
|
+
mentions: Annotated[Optional[List[str]], "Organizations, people, brand, product or entities the article must mention (OR)"] = None,
|
|
80
|
+
topic: Annotated[Optional[str], "Topic category; free text ok"] = None,
|
|
81
|
+
text: Annotated[Optional[str], "Phrase in article body. Use for uncommon phrases or as a fallback to mentions"] = None,
|
|
82
|
+
title: Annotated[Optional[str], "Phrase in the headline"] = None,
|
|
83
|
+
publisher: Annotated[Optional[str], "Publisher site, e.g. Reuters"] = None,
|
|
84
|
+
language: Annotated[Optional[str], "ISO code, e.g. en"] = None,
|
|
85
|
+
publisher_country: Annotated[Optional[str], "Publisher's country, e.g. United States"] = None,
|
|
86
|
+
quoted_speaker: Annotated[Optional[str], "Person quoted in the article"] = None,
|
|
87
|
+
since: Annotated[Optional[str], "Earliest date, YYYY-MM-DD"] = None,
|
|
88
|
+
until: Annotated[Optional[str], "Latest date, YYYY-MM-DD"] = None,
|
|
89
|
+
sentiment_min: Annotated[Optional[float], "Document sentiment floor, -1..1"] = None,
|
|
90
|
+
sentiment_max: Annotated[Optional[float], "Document sentiment ceiling, -1..1"] = None,
|
|
91
|
+
mention_sentiment_max: Annotated[Optional[float], "Sentiment ceiling toward the one mention"] = None,
|
|
92
|
+
size: Annotated[int, "Max rows"] = 10,
|
|
93
|
+
offset: Annotated[int, "Rows to skip"] = 0,
|
|
94
|
+
) -> dict:
|
|
95
|
+
notes: List[str] = []
|
|
96
|
+
if topic:
|
|
97
|
+
resolved = await resolve_value("ArticleCategory", topic)
|
|
98
|
+
if resolved.value is None:
|
|
99
|
+
if resolved.candidates:
|
|
100
|
+
return {"dql": None, "candidates": {"topic": resolved.candidates}, "notes": [resolved.note]}
|
|
101
|
+
notes.append(f"{resolved.note}; searched the article text for '{topic}' instead.")
|
|
102
|
+
text = f"{text} {topic}".strip() if text else topic
|
|
103
|
+
topic = None
|
|
104
|
+
else:
|
|
105
|
+
topic = resolved.value
|
|
106
|
+
if resolved.note:
|
|
107
|
+
notes.append(resolved.note)
|
|
108
|
+
|
|
109
|
+
query, build_notes = build_news_query(
|
|
110
|
+
mentions=mentions, topic=topic, text=text, title=title, publisher=publisher, language=language,
|
|
111
|
+
publisher_country=publisher_country, quoted_speaker=quoted_speaker, since=since, until=until,
|
|
112
|
+
sentiment_min=sentiment_min, sentiment_max=sentiment_max, mention_sentiment_max=mention_sentiment_max,
|
|
113
|
+
)
|
|
114
|
+
notes.extend(build_notes)
|
|
115
|
+
|
|
116
|
+
result = await run(query, size=size, offset=offset, fields=DEFAULT_FIELDS["Article"])
|
|
117
|
+
if result.get("hits") == 0 and mentions and not text:
|
|
118
|
+
notes.append("No article carries that tag. Tag values are entity names that may not match; retry with the same words in text.")
|
|
119
|
+
if notes:
|
|
120
|
+
result["notes"] = result.get("notes", []) + notes
|
|
121
|
+
return result
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
from typing import Annotated, List, Literal, Optional, Union
|
|
2
|
+
|
|
3
|
+
from fastmcp.exceptions import ToolError
|
|
4
|
+
|
|
5
|
+
from ..app import READ_ONLY, mcp
|
|
6
|
+
from ..dql import DEFAULT_FIELDS, resolve_value, run
|
|
7
|
+
from ..dql_builder import build, clause, compare, looks_like_domain, near_clause, or_clause, sort_clause, validate_date, subquery, parse_date_range, parse_range, split_near
|
|
8
|
+
|
|
9
|
+
DESCRIPTION = (
|
|
10
|
+
"Company and organization structured search over the Diffbot Knowledge Graph. Prefer for prospect lists, "
|
|
11
|
+
"market maps, competitor sets, and firmographic facts on a market or named company(ies). Filter by industry, "
|
|
12
|
+
"location, headcount, revenue, founding date, public status, founder, investor, acquirer, parent company. "
|
|
13
|
+
"Prefer over a generic web search when answer involves structured company data. For investment data use "
|
|
14
|
+
"search_deals; for people use search_people."
|
|
15
|
+
" API docs: https://www.diffbot.com/docs/dql/"
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
Sort = Literal["employees", "revenue", "founded", "importance"]
|
|
19
|
+
SORT_FIELDS = {"employees": "nbEmployees", "revenue": "revenue.value", "founded": "foundingDate", "importance": "importance"}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def build_organizations_query(
|
|
23
|
+
*,
|
|
24
|
+
name: Optional[Union[str, List[str]]] = None,
|
|
25
|
+
name_match: Literal["strict", "contains"] = "strict",
|
|
26
|
+
homepage: Optional[str] = None,
|
|
27
|
+
industry: Optional[List[str]] = None,
|
|
28
|
+
primary_industry: bool = True,
|
|
29
|
+
descriptors: Optional[str] = None,
|
|
30
|
+
city: Optional[Union[str, List[str]]] = None,
|
|
31
|
+
region: Optional[str] = None,
|
|
32
|
+
country: Optional[str] = None,
|
|
33
|
+
any_office: bool = False,
|
|
34
|
+
near: Optional[str] = None,
|
|
35
|
+
radius: Optional[str] = None,
|
|
36
|
+
min_employees: Optional[int] = None,
|
|
37
|
+
max_employees: Optional[int] = None,
|
|
38
|
+
min_revenue: Optional[float] = None,
|
|
39
|
+
max_revenue: Optional[float] = None,
|
|
40
|
+
is_public: Optional[bool] = None,
|
|
41
|
+
founded_after: Optional[str] = None,
|
|
42
|
+
founded_before: Optional[str] = None,
|
|
43
|
+
founder: Optional[str] = None,
|
|
44
|
+
investor: Optional[str] = None,
|
|
45
|
+
series: Optional[str] = None,
|
|
46
|
+
acquired_by: Optional[str] = None,
|
|
47
|
+
subsidiary: Optional[str] = None,
|
|
48
|
+
part_of: Optional[str] = None,
|
|
49
|
+
similar_to: Optional[str] = None,
|
|
50
|
+
sort: Optional[Sort] = None,
|
|
51
|
+
facet: Optional[str] = None,
|
|
52
|
+
) -> str:
|
|
53
|
+
"""industry values must already be exact OrganizationCategory names."""
|
|
54
|
+
clauses = ["type:Organization"]
|
|
55
|
+
|
|
56
|
+
if similar_to:
|
|
57
|
+
anchor = clause("homepageUri", similar_to) if looks_like_domain(similar_to) else clause("name", similar_to)
|
|
58
|
+
clauses.append(f"similarTo({anchor})")
|
|
59
|
+
if name:
|
|
60
|
+
names = [name] if isinstance(name, str) else list(name)
|
|
61
|
+
clauses.append(or_clause("name", names, strict=name_match == "strict"))
|
|
62
|
+
if homepage:
|
|
63
|
+
clauses.append(clause("homepageUri", homepage))
|
|
64
|
+
if industry:
|
|
65
|
+
if primary_industry:
|
|
66
|
+
# categories.{...} co-constrains name and isPrimary on the same category entry;
|
|
67
|
+
# the loose categories.name form also matches secondary tags (hospitals tagged software).
|
|
68
|
+
clauses.append(subquery("categories", [or_clause("name", industry), "isPrimary:true"]))
|
|
69
|
+
else:
|
|
70
|
+
clauses.append(or_clause("categories.name", industry))
|
|
71
|
+
if descriptors:
|
|
72
|
+
clauses.append(clause("descriptors", descriptors))
|
|
73
|
+
|
|
74
|
+
# singular location is the headquarters; plural locations is any office
|
|
75
|
+
prefix = "locations" if any_office else "location"
|
|
76
|
+
for level, value in (("city", city), ("region", region), ("country", country)):
|
|
77
|
+
if value:
|
|
78
|
+
values = [value] if isinstance(value, str) else list(value)
|
|
79
|
+
clauses.append(or_clause(f"{prefix}.{level}.name", values))
|
|
80
|
+
if near:
|
|
81
|
+
clauses.append(near_clause(near, radius))
|
|
82
|
+
|
|
83
|
+
if min_employees is not None:
|
|
84
|
+
clauses.append(compare("nbEmployees", ">=", min_employees))
|
|
85
|
+
if max_employees is not None:
|
|
86
|
+
clauses.append(compare("nbEmployees", "<=", max_employees))
|
|
87
|
+
if min_revenue is not None:
|
|
88
|
+
clauses.append(compare("revenue.value", ">=", min_revenue))
|
|
89
|
+
if max_revenue is not None:
|
|
90
|
+
clauses.append(compare("revenue.value", "<=", max_revenue))
|
|
91
|
+
if is_public is not None:
|
|
92
|
+
clauses.append(f"isPublic:{'true' if is_public else 'false'}")
|
|
93
|
+
if founded_after:
|
|
94
|
+
clauses.append(compare("foundingDate", ">=", validate_date(founded_after, "founded_after")))
|
|
95
|
+
if founded_before:
|
|
96
|
+
clauses.append(compare("foundingDate", "<=", validate_date(founded_before, "founded_before")))
|
|
97
|
+
if founder:
|
|
98
|
+
clauses.append(clause("founders.name", founder))
|
|
99
|
+
if investor:
|
|
100
|
+
clauses.append(clause("investments.investors.name", investor))
|
|
101
|
+
if series:
|
|
102
|
+
clauses.append(clause("investments.series", series))
|
|
103
|
+
if acquired_by:
|
|
104
|
+
clauses.append("isAcquired:true")
|
|
105
|
+
clauses.append(clause("acquiredBy.name", acquired_by))
|
|
106
|
+
if subsidiary:
|
|
107
|
+
clauses.append(clause("subsidiaries.name", subsidiary))
|
|
108
|
+
if part_of:
|
|
109
|
+
clauses.append(clause("partOf.name", part_of))
|
|
110
|
+
|
|
111
|
+
if len(clauses) == 1:
|
|
112
|
+
raise ToolError("search_organizations needs at least one filter.")
|
|
113
|
+
|
|
114
|
+
if facet:
|
|
115
|
+
clauses.append(f"facet:{facet}")
|
|
116
|
+
elif sort:
|
|
117
|
+
clauses.append(sort_clause(SORT_FIELDS[sort], descending=True))
|
|
118
|
+
return build(clauses)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@mcp.tool(name="search_organizations", title="Search Organizations", description=DESCRIPTION, annotations=READ_ONLY)
|
|
122
|
+
async def search_organizations(
|
|
123
|
+
name: Annotated[Optional[Union[str, List[str]]], "Company name(s) or homepage domain"] = None,
|
|
124
|
+
industry: Annotated[Optional[List[str]], "Industries (OR); free text ok"] = None,
|
|
125
|
+
primary_industry: Annotated[bool, "False: also match secondary tags"] = True,
|
|
126
|
+
descriptors: Annotated[Optional[str], "Free-text niche, e.g. 'plant-based'"] = None,
|
|
127
|
+
city: Annotated[Optional[Union[str, List[str]]], "HQ city or cities"] = None,
|
|
128
|
+
region: Annotated[Optional[str], "HQ state/region"] = None,
|
|
129
|
+
country: Annotated[Optional[str], "HQ country"] = None,
|
|
130
|
+
near: Annotated[Optional[str], "Place and radius, e.g. 'Austin 30mi'"] = None,
|
|
131
|
+
revenue: Annotated[Optional[str], "Range, e.g. '10M-75M', '1B+'"] = None,
|
|
132
|
+
employees: Annotated[Optional[str], "Range, e.g. '50-500', '<=20', '500+'"] = None,
|
|
133
|
+
founded: Annotated[Optional[str], "Year or range, e.g. '2015-2020'"] = None,
|
|
134
|
+
is_public: Annotated[Optional[bool], "Publicly traded"] = None,
|
|
135
|
+
founder: Annotated[Optional[str], "Founder name: companies they founded"] = None,
|
|
136
|
+
investor: Annotated[Optional[str], "Investor: its portfolio"] = None,
|
|
137
|
+
series: Annotated[Optional[str], "Funding round raised, e.g. 'Series B'"] = None,
|
|
138
|
+
acquired_by: Annotated[Optional[str], "Acquirer: its acquisitions"] = None,
|
|
139
|
+
subsidiary: Annotated[Optional[str], "Subsidiary name: its parent"] = None,
|
|
140
|
+
part_of: Annotated[Optional[str], "Index or group, e.g. 'S&P 500'"] = None,
|
|
141
|
+
similar_to: Annotated[Optional[str], "Anchor company for lookalikes"] = None,
|
|
142
|
+
sort: Annotated[Optional[Sort], "Only if ordering is the question"] = None,
|
|
143
|
+
facet: Annotated[Optional[str], "Bucket by field, e.g. categories.name"] = None,
|
|
144
|
+
size: Annotated[int, "Max rows"] = 10,
|
|
145
|
+
offset: Annotated[int, "Rows to skip"] = 0,
|
|
146
|
+
) -> dict:
|
|
147
|
+
min_revenue, max_revenue = parse_range(revenue, "revenue")
|
|
148
|
+
min_employees, max_employees = parse_range(employees, "employees", integer=True)
|
|
149
|
+
founded_after, founded_before = parse_date_range(founded, "founded")
|
|
150
|
+
near, radius = split_near(near)
|
|
151
|
+
any_office = False # headquarters only; raw dql with locations.* matches any office
|
|
152
|
+
homepage = name if isinstance(name, str) and looks_like_domain(name) else None
|
|
153
|
+
name = None if homepage else name
|
|
154
|
+
|
|
155
|
+
notes: List[str] = []
|
|
156
|
+
resolved_industry: List[str] = []
|
|
157
|
+
for value in industry or []:
|
|
158
|
+
resolved = await resolve_value("OrganizationCategory", value)
|
|
159
|
+
if resolved.value is None:
|
|
160
|
+
if resolved.candidates:
|
|
161
|
+
return {"dql": None, "candidates": {"industry": resolved.candidates}, "notes": [resolved.note]}
|
|
162
|
+
notes.append(f"{resolved.note}; matched descriptors on '{value}' instead.")
|
|
163
|
+
descriptors = descriptors or value
|
|
164
|
+
else:
|
|
165
|
+
resolved_industry.append(resolved.value)
|
|
166
|
+
if resolved.note:
|
|
167
|
+
notes.append(resolved.note)
|
|
168
|
+
|
|
169
|
+
query = build_organizations_query(
|
|
170
|
+
name=name, name_match="strict", homepage=homepage, industry=resolved_industry, primary_industry=primary_industry, descriptors=descriptors,
|
|
171
|
+
city=city, region=region, country=country, any_office=any_office, near=near, radius=radius,
|
|
172
|
+
min_employees=min_employees, max_employees=max_employees, min_revenue=min_revenue, max_revenue=max_revenue,
|
|
173
|
+
is_public=is_public,
|
|
174
|
+
founded_after=founded_after, founded_before=founded_before, founder=founder, investor=investor, series=series, acquired_by=acquired_by,
|
|
175
|
+
subsidiary=subsidiary, part_of=part_of, similar_to=similar_to, sort=sort, facet=facet,
|
|
176
|
+
)
|
|
177
|
+
result = await run(query, size=size, offset=offset, fields=DEFAULT_FIELDS["Organization"], bare_type_check=True)
|
|
178
|
+
if name and not similar_to and not facet and result.get("hits") == 0:
|
|
179
|
+
# exact name missed: retry with contains-matching, which over-matches but beats an empty answer
|
|
180
|
+
query = build_organizations_query(
|
|
181
|
+
name=name, name_match="contains", industry=resolved_industry, descriptors=descriptors,
|
|
182
|
+
city=city, region=region, country=country, any_office=any_office, near=near, radius=radius,
|
|
183
|
+
min_employees=min_employees, max_employees=max_employees, min_revenue=min_revenue, max_revenue=max_revenue,
|
|
184
|
+
is_public=is_public, founded_after=founded_after, founded_before=founded_before, founder=founder, investor=investor, series=series,
|
|
185
|
+
acquired_by=acquired_by, subsidiary=subsidiary, part_of=part_of, primary_industry=primary_industry, sort=sort,
|
|
186
|
+
)
|
|
187
|
+
result = await run(query, size=size, offset=offset, fields=DEFAULT_FIELDS["Organization"], bare_type_check=True)
|
|
188
|
+
notes.append(f"No exact match for '{name}'; showing contains-matches, which may include unrelated companies.")
|
|
189
|
+
if notes:
|
|
190
|
+
result["notes"] = result.get("notes", []) + notes
|
|
191
|
+
return result
|