agentpay-patent-search-mcp 1.2.0
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.
- package/package.json +7 -0
- package/server.py +398 -0
package/package.json
ADDED
package/server.py
ADDED
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
USPTO Patent Search MCP Server
|
|
4
|
+
|
|
5
|
+
Provides tools for searching and retrieving patent data from Google Patents API.
|
|
6
|
+
Includes search_patents, get_patent_details, search_by_assignee, and
|
|
7
|
+
search_by_classification tools.
|
|
8
|
+
|
|
9
|
+
Usage:
|
|
10
|
+
python3 server.py # Free tier (50 calls/instance)
|
|
11
|
+
python3 server.py --pro-key PROL_XXX # Pro tier (unlimited)
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import re
|
|
16
|
+
import sys
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
import httpx
|
|
20
|
+
from mcp.server import Server, stdio_server
|
|
21
|
+
from mcp.types import Tool, TextContent
|
|
22
|
+
|
|
23
|
+
# ── Constants ─────────────────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
GOOGLE_PATENTS_API = "https://patents.google.com/api/patents"
|
|
26
|
+
USPTO_API_BASE = "https://developer.uspto.gov/ds-api/"
|
|
27
|
+
|
|
28
|
+
# ── Rate Limiting & Pro Key ────────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
FREE_LIMIT = 50
|
|
31
|
+
PRO_KEYS = {"PROL_AGENTPAY_DEMO": "demo"} # Demo key for testing
|
|
32
|
+
|
|
33
|
+
# Parse --pro-key from command line
|
|
34
|
+
PRO_KEY = None
|
|
35
|
+
for i, arg in enumerate(sys.argv):
|
|
36
|
+
if arg == "--pro-key" and i + 1 < len(sys.argv):
|
|
37
|
+
PRO_KEY = sys.argv[i + 1]
|
|
38
|
+
break
|
|
39
|
+
|
|
40
|
+
IS_PRO = PRO_KEY in PRO_KEYS
|
|
41
|
+
call_counter = 0
|
|
42
|
+
|
|
43
|
+
STRIPE_LINK = "https://buy.stripe.com/7sYeVf6Pl2Ju1jqdjl1oI0n" # $19/mo (Patent Search MCP Pro)
|
|
44
|
+
|
|
45
|
+
def check_rate_limit():
|
|
46
|
+
"""Check if free tier has exceeded limit. Returns error dict or None."""
|
|
47
|
+
global call_counter
|
|
48
|
+
if IS_PRO:
|
|
49
|
+
return None
|
|
50
|
+
call_counter += 1
|
|
51
|
+
if call_counter > FREE_LIMIT:
|
|
52
|
+
remaining = call_counter - FREE_LIMIT
|
|
53
|
+
return {
|
|
54
|
+
"error": f"Free tier limit reached ({FREE_LIMIT} calls). Upgrade to Pro for unlimited access.",
|
|
55
|
+
"isError": True,
|
|
56
|
+
"next_steps": [
|
|
57
|
+
f"Purchase Pro at {STRIPE_LINK} ($19/mo, unlimited)",
|
|
58
|
+
"Restart the server to reset the free counter",
|
|
59
|
+
"Use --pro-key PROL_XXX to run in Pro mode"
|
|
60
|
+
],
|
|
61
|
+
"calls_used": call_counter,
|
|
62
|
+
"limit": FREE_LIMIT,
|
|
63
|
+
"over_by": remaining
|
|
64
|
+
}
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
# ── HTTP Client ───────────────────────────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
_client = httpx.Client(
|
|
70
|
+
timeout=30.0,
|
|
71
|
+
headers={
|
|
72
|
+
"User-Agent": (
|
|
73
|
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
|
74
|
+
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
|
75
|
+
"Chrome/120.0.0.0 Safari/537.36"
|
|
76
|
+
),
|
|
77
|
+
"Accept": "application/json",
|
|
78
|
+
},
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _search_google_patents(query: str, limit: int = 10) -> list[dict[str, Any]]:
|
|
83
|
+
"""Search Google Patents API and return parsed results."""
|
|
84
|
+
params: dict[str, Any] = {"q": query, "num": min(limit, 50), "format": "json"}
|
|
85
|
+
resp = _client.get(GOOGLE_PATENTS_API, params=params)
|
|
86
|
+
resp.raise_for_status()
|
|
87
|
+
data = resp.json()
|
|
88
|
+
|
|
89
|
+
results: list[dict[str, Any]] = []
|
|
90
|
+
raw_results = _safe_get(data, ["results"], [])
|
|
91
|
+
if not raw_results:
|
|
92
|
+
raw_results = _safe_get(data, ["patents"], [])
|
|
93
|
+
if not raw_results:
|
|
94
|
+
raw_results = _safe_get(data, ["items"], [])
|
|
95
|
+
|
|
96
|
+
for entry in raw_results[:limit]:
|
|
97
|
+
patent = _parse_patent_entry(entry)
|
|
98
|
+
if patent.get("patent_id"):
|
|
99
|
+
results.append(patent)
|
|
100
|
+
|
|
101
|
+
return results
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _get_patent_details_google(patent_id: str) -> dict[str, Any]:
|
|
105
|
+
"""Fetch detailed information for a specific patent."""
|
|
106
|
+
params = {"q": patent_id, "num": 1, "format": "json"}
|
|
107
|
+
resp = _client.get(GOOGLE_PATENTS_API, params=params)
|
|
108
|
+
resp.raise_for_status()
|
|
109
|
+
data = resp.json()
|
|
110
|
+
|
|
111
|
+
raw_results = _safe_get(data, ["results"], [])
|
|
112
|
+
if not raw_results:
|
|
113
|
+
raw_results = _safe_get(data, ["patents"], [])
|
|
114
|
+
if not raw_results:
|
|
115
|
+
raw_results = _safe_get(data, ["items"], [])
|
|
116
|
+
|
|
117
|
+
if not raw_results:
|
|
118
|
+
return {"patent_id": patent_id, "error": "Patent not found"}
|
|
119
|
+
|
|
120
|
+
entry = raw_results[0]
|
|
121
|
+
details = _parse_patent_entry(entry)
|
|
122
|
+
details["claims"] = _safe_get(entry, ["claims"], "")
|
|
123
|
+
details["description"] = _safe_get(entry, ["description"], "")
|
|
124
|
+
details["priority_date"] = _safe_get(entry, ["priority_date"], "")
|
|
125
|
+
details["ipc_classifications"] = _safe_get(entry, ["ipc"], "")
|
|
126
|
+
details["status"] = _safe_get(entry, ["status"], "")
|
|
127
|
+
|
|
128
|
+
if isinstance(details.get("claims"), list):
|
|
129
|
+
details["claims"] = "\n".join(details["claims"])
|
|
130
|
+
if isinstance(details.get("description"), list):
|
|
131
|
+
details["description"] = "\n".join(details["description"])
|
|
132
|
+
|
|
133
|
+
return details
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _parse_patent_entry(entry: dict[str, Any]) -> dict[str, Any]:
|
|
137
|
+
"""Parse a raw patent entry from Google Patents API into a clean dict."""
|
|
138
|
+
patent_id = _safe_get(entry, ["patent_id"], "")
|
|
139
|
+
if not patent_id:
|
|
140
|
+
patent_id = _safe_get(entry, ["id"], "")
|
|
141
|
+
if not patent_id:
|
|
142
|
+
patent_id = _safe_get(entry, ["publication_number"], "")
|
|
143
|
+
|
|
144
|
+
title = _safe_get(entry, ["title"], "")
|
|
145
|
+
abstract = _safe_get(entry, ["abstract"], "")
|
|
146
|
+
|
|
147
|
+
assignee = _safe_get(entry, ["assignee"], "")
|
|
148
|
+
if not assignee:
|
|
149
|
+
assignee = _safe_get(entry, ["assignee_original"], "")
|
|
150
|
+
if isinstance(assignee, list):
|
|
151
|
+
assignee = "; ".join(assignee)
|
|
152
|
+
|
|
153
|
+
inventors = _safe_get(entry, ["inventor"], "")
|
|
154
|
+
if not inventors:
|
|
155
|
+
inventors = _safe_get(entry, ["inventor_name"], "")
|
|
156
|
+
if isinstance(inventors, list):
|
|
157
|
+
inventors = "; ".join(inventors)
|
|
158
|
+
|
|
159
|
+
filing_date = _safe_get(entry, ["filing_date"], "")
|
|
160
|
+
publication_date = _safe_get(entry, ["publication_date"], "")
|
|
161
|
+
cpc = _safe_get(entry, ["cpc_classification"], "")
|
|
162
|
+
if isinstance(cpc, list):
|
|
163
|
+
cpc = "; ".join(cpc)
|
|
164
|
+
|
|
165
|
+
url = f"https://patents.google.com/patent/{patent_id}/en"
|
|
166
|
+
|
|
167
|
+
return {
|
|
168
|
+
"patent_id": patent_id,
|
|
169
|
+
"title": title,
|
|
170
|
+
"abstract": abstract,
|
|
171
|
+
"assignee": assignee,
|
|
172
|
+
"inventors": inventors,
|
|
173
|
+
"filing_date": filing_date,
|
|
174
|
+
"publication_date": publication_date,
|
|
175
|
+
"cpc_classifications": cpc,
|
|
176
|
+
"url": url,
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _safe_get(obj: dict[str, Any], keys: list[str], default: Any = "") -> Any:
|
|
181
|
+
"""Safely traverse nested dict keys."""
|
|
182
|
+
for key in keys:
|
|
183
|
+
if isinstance(obj, dict):
|
|
184
|
+
obj = obj.get(key, default)
|
|
185
|
+
else:
|
|
186
|
+
return default
|
|
187
|
+
return obj if obj is not None else default
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _extract_json_from_html(text: str) -> dict[str, Any]:
|
|
191
|
+
"""Fallback: try to extract JSON-LD from HTML response."""
|
|
192
|
+
match = re.search(
|
|
193
|
+
r'<script[^>]*type="application/ld\+json"[^>]*>(.*?)</script>',
|
|
194
|
+
text,
|
|
195
|
+
re.DOTALL,
|
|
196
|
+
)
|
|
197
|
+
if match:
|
|
198
|
+
try:
|
|
199
|
+
return json.loads(match.group(1))
|
|
200
|
+
except json.JSONDecodeError:
|
|
201
|
+
pass
|
|
202
|
+
return {}
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _flatten_text(value: Any) -> str:
|
|
206
|
+
"""Convert a value to a plain string, flattening lists."""
|
|
207
|
+
if isinstance(value, list):
|
|
208
|
+
return "\n".join(str(v) for v in value if v)
|
|
209
|
+
return str(value) if value else ""
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
# ── MCP Server ────────────────────────────────────────────────────────────
|
|
213
|
+
|
|
214
|
+
server = Server("patent-search")
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
@server.list_tools()
|
|
218
|
+
async def list_tools() -> list[Tool]:
|
|
219
|
+
return [
|
|
220
|
+
Tool(
|
|
221
|
+
name="search_patents",
|
|
222
|
+
description="Search patents by keyword query using Google Patents API. "
|
|
223
|
+
"Returns patent ID, title, abstract, assignee, inventors, dates, "
|
|
224
|
+
"and CPC classifications.",
|
|
225
|
+
inputSchema={
|
|
226
|
+
"type": "object",
|
|
227
|
+
"properties": {
|
|
228
|
+
"query": {
|
|
229
|
+
"type": "string",
|
|
230
|
+
"description": "Patent search query (e.g. 'machine learning', 'USPTO')",
|
|
231
|
+
},
|
|
232
|
+
"limit": {
|
|
233
|
+
"type": "integer",
|
|
234
|
+
"description": "Maximum number of results (default 10, max 50)",
|
|
235
|
+
"default": 10,
|
|
236
|
+
},
|
|
237
|
+
},
|
|
238
|
+
"required": ["query"],
|
|
239
|
+
},
|
|
240
|
+
),
|
|
241
|
+
Tool(
|
|
242
|
+
name="get_patent_details",
|
|
243
|
+
description="Get detailed information for a specific patent by patent ID. "
|
|
244
|
+
"Includes full abstract, claims, assignee, inventors, classifications, "
|
|
245
|
+
"and description when available.",
|
|
246
|
+
inputSchema={
|
|
247
|
+
"type": "object",
|
|
248
|
+
"properties": {
|
|
249
|
+
"patent_id": {
|
|
250
|
+
"type": "string",
|
|
251
|
+
"description": "Patent ID (e.g. US10529241B2, US20200012345A1)",
|
|
252
|
+
},
|
|
253
|
+
},
|
|
254
|
+
"required": ["patent_id"],
|
|
255
|
+
},
|
|
256
|
+
),
|
|
257
|
+
Tool(
|
|
258
|
+
name="search_by_assignee",
|
|
259
|
+
description="Search patents by assignee (company or organization name). "
|
|
260
|
+
"Returns patents assigned to the specified entity.",
|
|
261
|
+
inputSchema={
|
|
262
|
+
"type": "object",
|
|
263
|
+
"properties": {
|
|
264
|
+
"assignee": {
|
|
265
|
+
"type": "string",
|
|
266
|
+
"description": "Company or organization name (e.g. 'Apple', 'Microsoft')",
|
|
267
|
+
},
|
|
268
|
+
"limit": {
|
|
269
|
+
"type": "integer",
|
|
270
|
+
"description": "Maximum number of results (default 10, max 50)",
|
|
271
|
+
"default": 10,
|
|
272
|
+
},
|
|
273
|
+
},
|
|
274
|
+
"required": ["assignee"],
|
|
275
|
+
},
|
|
276
|
+
),
|
|
277
|
+
Tool(
|
|
278
|
+
name="search_by_classification",
|
|
279
|
+
description="Search patents by CPC classification code. "
|
|
280
|
+
"Returns patents matching the given CPC class.",
|
|
281
|
+
inputSchema={
|
|
282
|
+
"type": "object",
|
|
283
|
+
"properties": {
|
|
284
|
+
"class_code": {
|
|
285
|
+
"type": "string",
|
|
286
|
+
"description": "CPC classification code (e.g. 'G06N', 'G06F', 'H04L')",
|
|
287
|
+
},
|
|
288
|
+
"limit": {
|
|
289
|
+
"type": "integer",
|
|
290
|
+
"description": "Maximum number of results (default 10, max 50)",
|
|
291
|
+
"default": 10,
|
|
292
|
+
},
|
|
293
|
+
},
|
|
294
|
+
"required": ["class_code"],
|
|
295
|
+
},
|
|
296
|
+
),
|
|
297
|
+
]
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
@server.call_tool()
|
|
301
|
+
async def call_tool(
|
|
302
|
+
name: str, arguments: dict[str, Any]
|
|
303
|
+
) -> list[TextContent]:
|
|
304
|
+
# Rate limit check
|
|
305
|
+
limit_check = check_rate_limit()
|
|
306
|
+
if limit_check:
|
|
307
|
+
return [TextContent(type="text", text=json.dumps(limit_check, indent=2))]
|
|
308
|
+
try:
|
|
309
|
+
if name == "search_patents":
|
|
310
|
+
query = arguments.get("query", "")
|
|
311
|
+
limit = min(int(arguments.get("limit", 10)), 50)
|
|
312
|
+
results = _search_google_patents(query, limit)
|
|
313
|
+
return [TextContent(type="text", text=json.dumps(results, indent=2))]
|
|
314
|
+
|
|
315
|
+
elif name == "get_patent_details":
|
|
316
|
+
patent_id = arguments.get("patent_id", "")
|
|
317
|
+
details = _get_patent_details_google(patent_id)
|
|
318
|
+
return [TextContent(type="text", text=json.dumps(details, indent=2))]
|
|
319
|
+
|
|
320
|
+
elif name == "search_by_assignee":
|
|
321
|
+
assignee = arguments.get("assignee", "")
|
|
322
|
+
limit = min(int(arguments.get("limit", 10)), 50)
|
|
323
|
+
query = f'assignee:"{assignee}"'
|
|
324
|
+
results = _search_google_patents(query, limit)
|
|
325
|
+
return [TextContent(type="text", text=json.dumps(results, indent=2))]
|
|
326
|
+
|
|
327
|
+
elif name == "search_by_classification":
|
|
328
|
+
class_code = arguments.get("class_code", "")
|
|
329
|
+
limit = min(int(arguments.get("limit", 10)), 50)
|
|
330
|
+
query = f'cpc:"{class_code}"'
|
|
331
|
+
results = _search_google_patents(query, limit)
|
|
332
|
+
return [TextContent(type="text", text=json.dumps(results, indent=2))]
|
|
333
|
+
|
|
334
|
+
else:
|
|
335
|
+
return [TextContent(type="text", text=json.dumps({"error": f"Unknown tool: {name}"}))]
|
|
336
|
+
|
|
337
|
+
except httpx.HTTPStatusError as e:
|
|
338
|
+
return [
|
|
339
|
+
TextContent(
|
|
340
|
+
type="text",
|
|
341
|
+
text=json.dumps(
|
|
342
|
+
{
|
|
343
|
+
"error": f"Google Patents API error: {e.response.status_code}",
|
|
344
|
+
"details": str(e),
|
|
345
|
+
},
|
|
346
|
+
indent=2,
|
|
347
|
+
),
|
|
348
|
+
)
|
|
349
|
+
]
|
|
350
|
+
except httpx.RequestError as e:
|
|
351
|
+
return [
|
|
352
|
+
TextContent(
|
|
353
|
+
type="text",
|
|
354
|
+
text=json.dumps(
|
|
355
|
+
{"error": "Network error contacting Google Patents API", "details": str(e)},
|
|
356
|
+
indent=2,
|
|
357
|
+
),
|
|
358
|
+
)
|
|
359
|
+
]
|
|
360
|
+
except Exception as e:
|
|
361
|
+
return [
|
|
362
|
+
TextContent(
|
|
363
|
+
type="text",
|
|
364
|
+
text=json.dumps(
|
|
365
|
+
{"error": f"Unexpected error: {type(e).__name__}", "details": str(e)},
|
|
366
|
+
indent=2,
|
|
367
|
+
),
|
|
368
|
+
)
|
|
369
|
+
]
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
# ── Entry Point ───────────────────────────────────────────────────────────
|
|
373
|
+
|
|
374
|
+
def main() -> None:
|
|
375
|
+
"""Run the MCP server using stdio transport."""
|
|
376
|
+
try:
|
|
377
|
+
import anyio
|
|
378
|
+
from mcp.server.stdio import stdio_server
|
|
379
|
+
|
|
380
|
+
async def _run():
|
|
381
|
+
async with stdio_server() as (read, write):
|
|
382
|
+
await server.run(read, write, server.create_initialization_options())
|
|
383
|
+
|
|
384
|
+
anyio.run(_run)
|
|
385
|
+
except ImportError:
|
|
386
|
+
# Fallback for older versions
|
|
387
|
+
from mcp.server.stdio import stdio_server
|
|
388
|
+
|
|
389
|
+
async def _run():
|
|
390
|
+
async with stdio_server() as (read, write):
|
|
391
|
+
await server.run(read, write, server.create_initialization_options())
|
|
392
|
+
|
|
393
|
+
import asyncio
|
|
394
|
+
asyncio.run(_run())
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
if __name__ == "__main__":
|
|
398
|
+
main()
|