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,254 @@
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, expand_titles, looks_like_domain, or_clause, parse_range, sort_clause, subquery
8
+
9
+ DESCRIPTION = (
10
+ "People structured search over the Diffbot Knowledge Graph. Prefer for leadership teams, executive and role "
11
+ "lookups, alumni lists, skill-based sourcing, and profile facts on a named person. Filter by job title, "
12
+ "employer, employer industry or size, school, major, skills, residence, nationality, or net worth. Prefer over "
13
+ "a generic web search when the answer involves structured people data. Covers people with a public "
14
+ "professional presence only. For companies use search_organizations."
15
+ " API docs: https://www.diffbot.com/docs/dql/"
16
+ )
17
+
18
+ Sort = Literal["importance", "net_worth"]
19
+ SORT_FIELDS = {"importance": "importance", "net_worth": "netWorth.value"}
20
+ LEADERSHIP_GET = "get:name,ceo,founders"
21
+ MATCHED_GET = "get:id,name,employments,location,linkedInUri"
22
+
23
+
24
+ def place_clause(prefix: str, place: Optional[str]) -> str:
25
+ """A place that may be a city or a country: or(prefix.city.name:X, prefix.country.name:X)."""
26
+ if not place:
27
+ return ""
28
+ return f'or({clause(f"{prefix}.city.name", place)}, {clause(f"{prefix}.country.name", place)})'
29
+
30
+
31
+ def build_people_query(
32
+ *,
33
+ name: Optional[Union[str, List[str]]] = None,
34
+ title: Optional[List[str]] = None,
35
+ employer: Optional[str] = None,
36
+ employer_industry: Optional[str] = None,
37
+ employer_country: Optional[str] = None,
38
+ employer_min_employees: Optional[int] = None,
39
+ employer_max_employees: Optional[int] = None,
40
+ job_category: Optional[str] = None,
41
+ job_location: Optional[str] = None,
42
+ current: bool = True,
43
+ school: Optional[str] = None,
44
+ school_location: Optional[str] = None,
45
+ major: Optional[str] = None,
46
+ degree: Optional[str] = None,
47
+ skill: Optional[List[str]] = None,
48
+ language: Optional[str] = None,
49
+ city: Optional[Union[str, List[str]]] = None,
50
+ region: Optional[str] = None,
51
+ country: Optional[str] = None,
52
+ nationality: Optional[str] = None,
53
+ gender: Optional[str] = None,
54
+ min_net_worth: Optional[float] = None,
55
+ sort: Optional[Sort] = None,
56
+ ) -> str:
57
+ """employer_industry, job_category, language and gender must already be exact taxonomy/enum values."""
58
+ clauses = ["type:Person"]
59
+ if name:
60
+ names = [name] if isinstance(name, str) else list(name)
61
+ clauses.append(or_clause("name", names, strict=True))
62
+
63
+ # every condition about one job goes inside one {}; written separately they match
64
+ # different jobs and inflate the count roughly nine times
65
+ employment = []
66
+ if title:
67
+ employment.append(or_clause("title", expand_titles(title)))
68
+ if employer:
69
+ employment.append(clause("employer.name", employer))
70
+ if employer_industry:
71
+ employment.append(clause("employer.categories.name", employer_industry))
72
+ if employer_country:
73
+ employment.append(clause("employer.location.country.name", employer_country))
74
+ if employer_min_employees is not None:
75
+ employment.append(compare("employer.nbEmployees", ">=", employer_min_employees))
76
+ if employer_max_employees is not None:
77
+ employment.append(compare("employer.nbEmployees", "<=", employer_max_employees))
78
+ if job_category:
79
+ employment.append(clause("categories.name", job_category))
80
+ if job_location:
81
+ employment.append(place_clause("location", job_location))
82
+ if employment and current:
83
+ employment.append("isCurrent:true")
84
+ clauses.append(subquery("employments", employment))
85
+
86
+ education = []
87
+ if school:
88
+ education.append(clause("institution.name", school))
89
+ if major:
90
+ education.append(clause("major.name", major))
91
+ if degree:
92
+ education.append(clause("degree.name", degree))
93
+ if school_location:
94
+ education.append(place_clause("location", school_location))
95
+ clauses.append(subquery("educations", education))
96
+
97
+ if skill:
98
+ clauses.append(or_clause("skills.name", skill))
99
+ if language:
100
+ clauses.append(clause("languages", language))
101
+ for level, value in (("city", city), ("region", region), ("country", country)):
102
+ if value:
103
+ values = [value] if isinstance(value, str) else list(value)
104
+ clauses.append(or_clause(f"location.{level}.name", values))
105
+ if nationality:
106
+ clauses.append(clause("nationalities.name", nationality))
107
+ if gender:
108
+ clauses.append(clause("gender", gender))
109
+ if min_net_worth is not None:
110
+ clauses.append(compare("netWorth.value", ">=", min_net_worth))
111
+
112
+ if len([c for c in clauses if c]) == 1:
113
+ raise ToolError("search_people needs at least one filter.")
114
+ if sort:
115
+ clauses.append(sort_clause(SORT_FIELDS[sort], descending=True))
116
+ return build(clauses)
117
+
118
+
119
+ def build_leadership_query(company: str) -> str:
120
+ # the domain plus the default ranking is right for apple.com, tesla.com, openai.com and
121
+ # friends, where strict:name misses (Apple Inc.) and a sort surfaces the wrong record
122
+ anchor = clause("homepageUri", company) if looks_like_domain(company) else clause("name", company)
123
+ return build(["type:Organization", anchor, LEADERSHIP_GET])
124
+
125
+
126
+ def pick_employment(employments: List[dict], employer: Optional[str], titles: List[str], current: bool) -> dict:
127
+ """The employment that best matches the filters, so rendered columns reflect the matched job rather than the primary one."""
128
+ def score(job: dict) -> int:
129
+ points = 0
130
+ employer_name = ((job.get("employer") or {}).get("name") or "").lower()
131
+ if employer and employer.lower() in employer_name:
132
+ points += 4
133
+ job_title = (job.get("title") or "").lower()
134
+ if titles and any(t.lower() in job_title for t in titles):
135
+ points += 2
136
+ if current and job.get("isCurrent"):
137
+ points += 1
138
+ return points
139
+
140
+ return max(employments, key=score) if employments else {}
141
+
142
+
143
+ def render_person(entity: dict, employer: Optional[str], titles: List[str], current: bool) -> dict:
144
+ job = pick_employment(entity.get("employments") or [], employer, titles, current)
145
+ location = entity.get("location") or {}
146
+ row = {
147
+ "Name": entity.get("name"),
148
+ "Title": job.get("title"),
149
+ "Employer": (job.get("employer") or {}).get("name"),
150
+ "City": (location.get("city") or {}).get("name"),
151
+ "Country": (location.get("country") or {}).get("name"),
152
+ "LinkedIn": entity.get("linkedInUri"),
153
+ "Id": entity.get("id"),
154
+ }
155
+ return {key: value for key, value in row.items() if value}
156
+
157
+
158
+ @mcp.tool(name="search_people", title="Search People", description=DESCRIPTION, annotations=READ_ONLY)
159
+ async def search_people(
160
+ name: Annotated[Optional[Union[str, List[str]]], "Person name(s), exact"] = None,
161
+ title: Annotated[Optional[List[str]], "Job titles (OR); C-suite expanded"] = None,
162
+ employer: Annotated[Optional[str], "Employer name"] = None,
163
+ employer_industry: Annotated[Optional[str], "Employer's industry; free text ok"] = None,
164
+ employer_employees: Annotated[Optional[str], "Employer headcount range, e.g. '<=50'"] = None,
165
+ employer_country: Annotated[Optional[str], "Employer HQ country"] = None,
166
+ job_category: Annotated[Optional[str], "Occupation group, e.g. 'Business', 'Religion'"] = None,
167
+ job_location: Annotated[Optional[str], "City or country where the job is"] = None,
168
+ current: Annotated[bool, "False: include past jobs"] = True,
169
+ school: Annotated[Optional[str], "Institution name"] = None,
170
+ school_location: Annotated[Optional[str], "City or country of the school"] = None,
171
+ major: Annotated[Optional[str], "Field of study"] = None,
172
+ degree: Annotated[Optional[str], "Degree, e.g. 'MBA'"] = None,
173
+ skill: Annotated[Optional[List[str]], "Skills (OR)"] = None,
174
+ language: Annotated[Optional[str], "Language spoken"] = None,
175
+ city: Annotated[Optional[Union[str, List[str]]], "Residence city or cities"] = None,
176
+ region: Annotated[Optional[str], "Residence state/region"] = None,
177
+ country: Annotated[Optional[str], "Residence country"] = None,
178
+ nationality: Annotated[Optional[str], "Nationality as country name"] = None,
179
+ gender: Annotated[Optional[str], "Gender"] = None,
180
+ net_worth: Annotated[Optional[str], "Minimum net worth, e.g. '1B'"] = None,
181
+ sort: Annotated[Optional[Sort], "Only if ordering is the question"] = None,
182
+ leadership_of: Annotated[Optional[str], "Company domain or name: its CEO and founders"] = None,
183
+ size: Annotated[int, "Max rows"] = 10,
184
+ offset: Annotated[int, "Rows to skip"] = 0,
185
+ ) -> dict:
186
+ if leadership_of:
187
+ result = await run(build_leadership_query(leadership_of), size=1, format="json")
188
+ records = result.get("results") or []
189
+ if not records:
190
+ return {"dql": result["dql"], "hits": result.get("hits"), "company": None, "ceo": None, "founders": [], "notes": ["No organization matched. Prefer the homepage domain over the name."]}
191
+ entity = records[0]
192
+ return {
193
+ "dql": result["dql"],
194
+ "hits": result.get("hits"),
195
+ "company": entity.get("name"),
196
+ "ceo": (entity.get("ceo") or {}).get("name"),
197
+ "founders": [f.get("name") for f in entity.get("founders") or [] if f.get("name")],
198
+ }
199
+
200
+ notes: List[str] = []
201
+ if employer_industry:
202
+ resolved = await resolve_value("OrganizationCategory", employer_industry)
203
+ if resolved.value is None:
204
+ if resolved.candidates:
205
+ return {"dql": None, "candidates": {"employer_industry": resolved.candidates}, "notes": [resolved.note]}
206
+ raise ToolError(resolved.note)
207
+ employer_industry = resolved.value
208
+ if resolved.note:
209
+ notes.append(resolved.note)
210
+ if job_category:
211
+ resolved = await resolve_value("EmploymentCategory", job_category)
212
+ if resolved.value is None:
213
+ if resolved.candidates:
214
+ return {"dql": None, "candidates": {"job_category": resolved.candidates}, "notes": [resolved.note]}
215
+ raise ToolError(resolved.note)
216
+ job_category = resolved.value
217
+ if resolved.note:
218
+ notes.append(resolved.note)
219
+ for label, kind_name in (("gender", "Gender"), ("language", "Language")):
220
+ value = gender if label == "gender" else language
221
+ if value:
222
+ resolved = await resolve_value(kind_name, value, kind="enum")
223
+ if resolved.value is None:
224
+ if resolved.candidates:
225
+ return {"dql": None, "candidates": {label: resolved.candidates}, "notes": [resolved.note]}
226
+ raise ToolError(resolved.note)
227
+ if label == "gender":
228
+ gender = resolved.value
229
+ else:
230
+ language = resolved.value
231
+ employer_min, employer_max = parse_range(employer_employees, "employer_employees", integer=True)
232
+ min_net_worth, _ = parse_range(net_worth, "net_worth")
233
+
234
+ query = build_people_query(
235
+ name=name, title=title, employer=employer, employer_industry=employer_industry, employer_country=employer_country,
236
+ employer_min_employees=employer_min, employer_max_employees=employer_max, job_category=job_category,
237
+ job_location=job_location, current=current, school=school, school_location=school_location, major=major,
238
+ degree=degree, skill=skill, language=language, city=city, region=region, country=country,
239
+ nationality=nationality, gender=gender, min_net_worth=min_net_worth, sort=sort,
240
+ )
241
+
242
+ # with an employer or title filter the rendered job must be the one that matched, not
243
+ # the primary one, so the records are read as json and the matching employment picked
244
+ if employer or title:
245
+ result = await run(f"{query} {MATCHED_GET}", size=size, offset=offset, format="json", bare_type_check=True)
246
+ titles = expand_titles(title or [])
247
+ result["results"] = [render_person(entity, employer, titles, current) for entity in result.get("results") or []]
248
+ result["dql"] = query
249
+ else:
250
+ result = await run(query, size=size, offset=offset, fields=DEFAULT_FIELDS["Person"], bare_type_check=True)
251
+
252
+ if notes:
253
+ result["notes"] = result.get("notes", []) + notes
254
+ return result
@@ -0,0 +1,109 @@
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 PLACE_FIELDS, run
7
+ from ..dql_builder import build, clause, compare, name_variants, near_clause, or_clause, sort_clause, parse_range, split_near
8
+
9
+ DESCRIPTION = (
10
+ "Cities, counties, states, provinces, countries, and points of interest in the Diffbot Knowledge Graph by "
11
+ "name, population, containing place, or proximity. Prefer for facts on places, rather than recalling from "
12
+ "memory."
13
+ " API docs: https://www.diffbot.com/docs/dql/"
14
+ )
15
+
16
+ Kind = Literal["city", "subregion", "region", "country", "administrative_area", "poi", "any"]
17
+ KIND_CLAUSES = {
18
+ "city": "type:City",
19
+ "subregion": "type:Subregion",
20
+ "region": "type:Region",
21
+ "country": "type:Country",
22
+ "administrative_area": "type:AdministrativeArea",
23
+ "poi": 'type:Place not(types:"AdministrativeArea")',
24
+ "any": "type:Place",
25
+ }
26
+ Sort = Literal["population", "importance"]
27
+
28
+
29
+ def build_places_query(
30
+ *,
31
+ kind: Kind = "any",
32
+ name: Optional[str] = None,
33
+ descriptors: Optional[str] = None,
34
+ country: Optional[str] = None,
35
+ region: Optional[str] = None,
36
+ subregion: Optional[str] = None,
37
+ within: Optional[str] = None,
38
+ continent: Optional[str] = None,
39
+ near: Optional[str] = None,
40
+ radius: Optional[str] = None,
41
+ min_population: Optional[int] = None,
42
+ max_population: Optional[int] = None,
43
+ sort: Optional[Sort] = None,
44
+ facet: Optional[Literal["placeType"]] = None,
45
+ ) -> tuple:
46
+ """Return (dql, notes)."""
47
+ clauses = [KIND_CLAUSES[kind]]
48
+ notes: List[str] = []
49
+
50
+ if name:
51
+ # diacritics are not normalized and coverage of each spelling is inconsistent
52
+ clauses.append(or_clause("name", name_variants(name), strict=True))
53
+ if descriptors:
54
+ clauses.append(clause("descriptors", descriptors))
55
+ for level, value in (("country", country), ("region", region), ("subregion", subregion)):
56
+ if value:
57
+ clauses.append(clause(f"location.{level}.name", value))
58
+ if within:
59
+ clauses.append(clause("isPartOf.name", within))
60
+ if continent:
61
+ clauses.append(clause("description", f"in {continent}"))
62
+ notes.append(f"There is no continent field; '{continent}' was matched against the prose description, which is over-inclusive and double-counts duplicate records. Filter the list before presenting it and say it was text-matched.")
63
+ if near:
64
+ clauses.append(near_clause(near, radius))
65
+ if min_population is not None:
66
+ clauses.append(compare("population", ">=", min_population))
67
+ if max_population is not None:
68
+ clauses.append(compare("population", "<=", max_population))
69
+
70
+ if len(clauses) == 1 and kind in ("any", "administrative_area", "poi"):
71
+ raise ToolError("search_places needs a name, containing place, or other filter for this kind.")
72
+
73
+ if facet:
74
+ clauses.append(f"facet:{facet}")
75
+ elif sort:
76
+ clauses.append(sort_clause(sort, descending=True))
77
+ return build(clauses), notes
78
+
79
+
80
+ @mcp.tool(name="search_places", title="Search Places", description=DESCRIPTION, annotations=READ_ONLY)
81
+ async def search_places(
82
+ kind: Annotated[Kind, "country | region | subregion | city | administrative_area | poi | any"] = "any",
83
+ name: Annotated[Optional[str], "Place name, exact"] = None,
84
+ descriptors: Annotated[Optional[str], "Free text, e.g. 'national park' (poi)"] = None,
85
+ country: Annotated[Optional[str], "Containing country"] = None,
86
+ region: Annotated[Optional[str], "Containing state/region"] = None,
87
+ subregion: Annotated[Optional[str], "Containing county/district"] = None,
88
+ within: Annotated[Optional[str], "Containing place, any level"] = None,
89
+ continent: Annotated[Optional[str], "Continent (text match, loose)"] = None,
90
+ near: Annotated[Optional[str], "Place and radius, e.g. 'Paris 50km'"] = None,
91
+ population: Annotated[Optional[str], "Range, e.g. '1M+', '10000-50000'"] = None,
92
+ sort: Annotated[Optional[Sort], "Only if ordering is the question"] = None,
93
+ facet: Annotated[Optional[Literal["placeType"]], "Bucket by place type"] = None,
94
+ size: Annotated[int, "Max rows"] = 10,
95
+ offset: Annotated[int, "Rows to skip"] = 0,
96
+ ) -> dict:
97
+ near, radius = split_near(near)
98
+ min_population, max_population = parse_range(population, "population", integer=True)
99
+ query, notes = build_places_query(
100
+ kind=kind, name=name, descriptors=descriptors, country=country, region=region, subregion=subregion,
101
+ within=within, continent=continent, near=near, radius=radius, min_population=min_population,
102
+ max_population=max_population, sort=sort, facet=facet,
103
+ )
104
+ result = await run(query, size=size, offset=offset, fields=PLACE_FIELDS, bare_type_check=True)
105
+ if result.get("hits") == 0 and name:
106
+ notes.append("Zero hits for both spellings of the name. Try kind 'any', or match on country/region without the name.")
107
+ if notes:
108
+ result["notes"] = result.get("notes", []) + notes
109
+ return result
@@ -0,0 +1,52 @@
1
+ from typing import Annotated, Optional
2
+
3
+ from ..api import request, resolve_token
4
+ from ..app import READ_ONLY, mcp
5
+
6
+ NLP_ENDPOINT = "https://nl.diffbot.com/v1/"
7
+
8
+ DESCRIPTION = (
9
+ "Named entity recognition and linking. Finds people, organizations, places, and products mentioned in text. "
10
+ "Each resolves to a Diffbot Knowledge Graph entity with confidence, salience, and sentiment. Prefer for "
11
+ "post-processing plain text for entity mentions. The response includes dql_filter, an id:or(...) clause for "
12
+ "dql to fetch the full records. Entities without an id were recognized but not matched."
13
+ " API docs: https://www.diffbot.com/docs/natural-language/"
14
+ )
15
+
16
+
17
+ def entity_id(entity: dict) -> Optional[str]:
18
+ uri = entity.get("id") or entity.get("diffbotUri") or ""
19
+ return uri.rstrip("/").rsplit("/", 1)[-1] or None
20
+
21
+
22
+ @mcp.tool(name="resolve_entities", title="Resolve Entities", description=DESCRIPTION, annotations=READ_ONLY)
23
+ async def resolve_entities(
24
+ text: Annotated[str, "Plain text to analyze"],
25
+ lang: Annotated[Optional[str], "ISO code, e.g. en; default auto"] = None,
26
+ ) -> dict:
27
+ params = {"token": resolve_token(), "fields": "entities,sentiment"}
28
+ payload = [{"lang": lang or "auto", "format": "plain text", "content": text}]
29
+
30
+ data = await request(NLP_ENDPOINT, method="POST", params=params, json_body=payload)
31
+ # the NLP API answers with one result object per submitted document
32
+ if isinstance(data, list):
33
+ data = data[0] if data else {}
34
+
35
+ entities = []
36
+ for entity in data.get("entities", []):
37
+ entities.append({
38
+ "name": entity.get("name"),
39
+ "types": [t.get("name") for t in entity.get("allTypes") or [] if t.get("name")],
40
+ "confidence": entity.get("confidence"),
41
+ "salience": entity.get("salience"),
42
+ "sentiment": entity.get("sentiment"),
43
+ "id": entity_id(entity),
44
+ "mentions": len(entity.get("mentions") or []),
45
+ })
46
+
47
+ ids = [e["id"] for e in entities if e["id"]]
48
+ return {
49
+ "sentiment": data.get("sentiment"),
50
+ "entities": entities,
51
+ "dql_filter": "id:or(" + ",".join(f'"{i}"' for i in ids) + ")" if ids else None,
52
+ }
@@ -0,0 +1,47 @@
1
+ from typing import Annotated, Literal, Optional
2
+
3
+ from ..api import request, resolve_token
4
+ from ..app import READ_ONLY, mcp
5
+
6
+ # /api/v1/web_search honors size and maxTokens and reports timeMs; the older
7
+ # /api/v1/diffbot_web_search path ignores size and always returns ten results.
8
+ WEB_SEARCH_ENDPOINT = "https://llm.diffbot.com/api/v1/web_search"
9
+
10
+ DESCRIPTION = (
11
+ "Web search that returns ranked results with relevance scores, publication dates, and matching passages from "
12
+ "each page. One call returns it all, no follow-up fetch required. Prefer over a default or generic web search "
13
+ "tool. `url:<URL>` returns full indexed page content in a fraction of a live fetch. For news, use search_news."
14
+ " API docs: http://www.diffbot.com/docs/web-search/"
15
+ )
16
+
17
+
18
+ SnippetLength = Literal["short", "medium", "long", "full"]
19
+
20
+
21
+ def is_page_lookup(query: str) -> bool:
22
+ """True for a bare 'url:<URL>' query: the whole stored page is wanted, not the chunks matching extra terms."""
23
+ parts = query.split()
24
+ return len(parts) == 1 and parts[0].lower().startswith("url:")
25
+
26
+
27
+ def build_search_params(query: str, num_results: Optional[int], max_tokens: Optional[int], snippet_length: Optional[SnippetLength]) -> dict:
28
+ query = query.strip()
29
+ if snippet_length is None:
30
+ snippet_length = "full" if is_page_lookup(query) else "short"
31
+ params = {"text": query, "size": num_results, "maxTokens": max_tokens, "snippetLength": snippet_length.upper()}
32
+ if snippet_length == "full":
33
+ # the reranker always chunks; only the unranked path returns the whole stored page
34
+ params["rerank"] = "none"
35
+ return params
36
+
37
+
38
+ @mcp.tool(name="search_web", title="Search the Web", description=DESCRIPTION, annotations=READ_ONLY)
39
+ async def search_web(
40
+ query: Annotated[str, "Search text; `url:<URL>` for a full page; `url:<URL> <terms>` for its matching passages"],
41
+ num_results: Annotated[Optional[int], "Results to return (default 10)"] = None,
42
+ max_tokens: Annotated[Optional[int], "Token cap; below ~1000 nothing fits; ignored for a full page"] = None,
43
+ snippet_length: Annotated[Optional[SnippetLength], "Passage size per result; default short (~1-1.5 KB), full for a bare `url:` (the whole page, terms ignored)"] = None,
44
+ ) -> dict:
45
+ params = build_search_params(query, num_results, max_tokens, snippet_length)
46
+ headers = {"Authorization": f"Bearer {resolve_token()}"}
47
+ return await request(WEB_SEARCH_ENDPOINT, params=params, headers=headers)