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.
Files changed (2) hide show
  1. package/package.json +7 -0
  2. package/server.py +398 -0
package/package.json ADDED
@@ -0,0 +1,7 @@
1
+ {
2
+ "name": "agentpay-patent-search-mcp",
3
+ "version": "1.2.0",
4
+ "description": "MCP server with rate-limited free tier. 50 free calls, then Pro subscription at $19/mo.",
5
+ "type": "module",
6
+ "license": "MIT"
7
+ }
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()