nia-mcp-server 1.0.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.
Potentially problematic release.
This version of nia-mcp-server might be problematic. Click here for more details.
- nia_mcp_server/__init__.py +5 -0
- nia_mcp_server/__main__.py +11 -0
- nia_mcp_server/api_client.py +477 -0
- nia_mcp_server/server.py +804 -0
- nia_mcp_server-1.0.0.dist-info/METADATA +200 -0
- nia_mcp_server-1.0.0.dist-info/RECORD +9 -0
- nia_mcp_server-1.0.0.dist-info/WHEEL +4 -0
- nia_mcp_server-1.0.0.dist-info/entry_points.txt +2 -0
- nia_mcp_server-1.0.0.dist-info/licenses/LICENSE +21 -0
nia_mcp_server/server.py
ADDED
|
@@ -0,0 +1,804 @@
|
|
|
1
|
+
"""
|
|
2
|
+
NIA MCP Proxy Server - Lightweight server that communicates with NIA API
|
|
3
|
+
"""
|
|
4
|
+
import os
|
|
5
|
+
import logging
|
|
6
|
+
import json
|
|
7
|
+
import asyncio
|
|
8
|
+
from typing import List, Optional, Dict, Any
|
|
9
|
+
from datetime import datetime
|
|
10
|
+
|
|
11
|
+
from mcp.server.fastmcp import FastMCP
|
|
12
|
+
from mcp.types import TextContent, Resource
|
|
13
|
+
from .api_client import NIAApiClient, APIError
|
|
14
|
+
|
|
15
|
+
# Configure logging
|
|
16
|
+
logging.basicConfig(
|
|
17
|
+
level=logging.INFO,
|
|
18
|
+
format='%(asctime)s [%(levelname)s] %(name)s: %(message)s',
|
|
19
|
+
)
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
# Create the MCP server
|
|
23
|
+
mcp = FastMCP("nia-knowledge-agent")
|
|
24
|
+
|
|
25
|
+
# Global API client instance
|
|
26
|
+
api_client: Optional[NIAApiClient] = None
|
|
27
|
+
|
|
28
|
+
def get_api_key() -> str:
|
|
29
|
+
"""Get API key from environment."""
|
|
30
|
+
api_key = os.getenv("NIA_API_KEY")
|
|
31
|
+
if not api_key:
|
|
32
|
+
raise ValueError(
|
|
33
|
+
"NIA_API_KEY environment variable not set. "
|
|
34
|
+
"Get your API key at https://trynia.ai/api-keys"
|
|
35
|
+
)
|
|
36
|
+
return api_key
|
|
37
|
+
|
|
38
|
+
async def ensure_api_client() -> NIAApiClient:
|
|
39
|
+
"""Ensure API client is initialized."""
|
|
40
|
+
global api_client
|
|
41
|
+
if not api_client:
|
|
42
|
+
api_key = get_api_key()
|
|
43
|
+
api_client = NIAApiClient(api_key)
|
|
44
|
+
# Validate the API key
|
|
45
|
+
if not await api_client.validate_api_key():
|
|
46
|
+
# The validation error is already logged, just raise a generic error
|
|
47
|
+
raise ValueError("Failed to validate API key. Check logs for details.")
|
|
48
|
+
return api_client
|
|
49
|
+
|
|
50
|
+
# Tools
|
|
51
|
+
|
|
52
|
+
@mcp.tool()
|
|
53
|
+
async def index_repository(
|
|
54
|
+
repo_url: str,
|
|
55
|
+
branch: Optional[str] = None
|
|
56
|
+
) -> List[TextContent]:
|
|
57
|
+
"""
|
|
58
|
+
Index a GitHub repository for intelligent code search.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
repo_url: GitHub repository URL (e.g., https://github.com/owner/repo)
|
|
62
|
+
branch: Branch to index (optional, defaults to main branch)
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
Status of the indexing operation
|
|
66
|
+
"""
|
|
67
|
+
try:
|
|
68
|
+
client = await ensure_api_client()
|
|
69
|
+
|
|
70
|
+
# Start indexing
|
|
71
|
+
logger.info(f"Starting to index repository: {repo_url}")
|
|
72
|
+
result = await client.index_repository(repo_url, branch)
|
|
73
|
+
|
|
74
|
+
repository = result.get("repository", repo_url)
|
|
75
|
+
status = result.get("status", "unknown")
|
|
76
|
+
|
|
77
|
+
if status == "completed":
|
|
78
|
+
return [TextContent(
|
|
79
|
+
type="text",
|
|
80
|
+
text=f"ā
Repository already indexed: {repository}\n"
|
|
81
|
+
f"Branch: {result.get('branch', 'main')}\n"
|
|
82
|
+
f"You can now search this codebase!"
|
|
83
|
+
)]
|
|
84
|
+
else:
|
|
85
|
+
# Wait for indexing to complete
|
|
86
|
+
return [TextContent(
|
|
87
|
+
type="text",
|
|
88
|
+
text=f"ā³ Indexing started for: {repository}\n"
|
|
89
|
+
f"Branch: {branch or 'default'}\n"
|
|
90
|
+
f"Status: {status}\n\n"
|
|
91
|
+
f"Use `check_repository_status` to monitor progress."
|
|
92
|
+
)]
|
|
93
|
+
|
|
94
|
+
except APIError as e:
|
|
95
|
+
logger.error(f"API Error indexing repository: {e} (status_code={e.status_code}, detail={e.detail})")
|
|
96
|
+
if e.status_code == 403 or "free tier limit" in str(e).lower() or "free api requests" in str(e).lower():
|
|
97
|
+
if e.detail and "25 free API requests" in e.detail:
|
|
98
|
+
return [TextContent(
|
|
99
|
+
type="text",
|
|
100
|
+
text=f"ā {e.detail}\n\nš” Tip: Upgrade to Pro at https://trynia.ai/billing for unlimited API access."
|
|
101
|
+
)]
|
|
102
|
+
else:
|
|
103
|
+
return [TextContent(
|
|
104
|
+
type="text",
|
|
105
|
+
text=f"ā {str(e)}\n\nš” Tip: You've reached the free tier limit. Upgrade to Pro for unlimited access."
|
|
106
|
+
)]
|
|
107
|
+
else:
|
|
108
|
+
return [TextContent(type="text", text=f"ā {str(e)}")]
|
|
109
|
+
except Exception as e:
|
|
110
|
+
logger.error(f"Unexpected error indexing repository: {e}")
|
|
111
|
+
error_msg = str(e)
|
|
112
|
+
if "free api requests" in error_msg.lower() or "lifetime limit" in error_msg.lower():
|
|
113
|
+
return [TextContent(
|
|
114
|
+
type="text",
|
|
115
|
+
text=f"ā {error_msg}\n\nš” Tip: Upgrade to Pro at https://trynia.ai/billing for unlimited API access."
|
|
116
|
+
)]
|
|
117
|
+
return [TextContent(
|
|
118
|
+
type="text",
|
|
119
|
+
text=f"ā Error indexing repository: {error_msg}"
|
|
120
|
+
)]
|
|
121
|
+
|
|
122
|
+
@mcp.tool()
|
|
123
|
+
async def search_codebase(
|
|
124
|
+
query: str,
|
|
125
|
+
repositories: Optional[List[str]] = None,
|
|
126
|
+
include_sources: bool = True
|
|
127
|
+
) -> List[TextContent]:
|
|
128
|
+
"""
|
|
129
|
+
Search indexed repositories using natural language.
|
|
130
|
+
|
|
131
|
+
Args:
|
|
132
|
+
query: Natural language search query
|
|
133
|
+
repositories: List of repositories to search (owner/repo format). If not specified, searches all indexed repos.
|
|
134
|
+
include_sources: Whether to include source code in results
|
|
135
|
+
|
|
136
|
+
Returns:
|
|
137
|
+
Search results with relevant code snippets and explanations
|
|
138
|
+
"""
|
|
139
|
+
try:
|
|
140
|
+
client = await ensure_api_client()
|
|
141
|
+
|
|
142
|
+
# Get all indexed repositories if not specified
|
|
143
|
+
if not repositories:
|
|
144
|
+
all_repos = await client.list_repositories()
|
|
145
|
+
repositories = [repo["repository"] for repo in all_repos if repo.get("status") == "completed"]
|
|
146
|
+
if not repositories:
|
|
147
|
+
return [TextContent(
|
|
148
|
+
type="text",
|
|
149
|
+
text="ā No indexed repositories found. Use `index_repository` to index a codebase first."
|
|
150
|
+
)]
|
|
151
|
+
|
|
152
|
+
# Build messages for the query
|
|
153
|
+
messages = [
|
|
154
|
+
{"role": "user", "content": query}
|
|
155
|
+
]
|
|
156
|
+
|
|
157
|
+
logger.info(f"Searching {len(repositories)} repositories")
|
|
158
|
+
|
|
159
|
+
# Stream the response using unified query
|
|
160
|
+
response_parts = []
|
|
161
|
+
sources_parts = []
|
|
162
|
+
|
|
163
|
+
async for chunk in client.query_unified(
|
|
164
|
+
messages=messages,
|
|
165
|
+
repositories=repositories,
|
|
166
|
+
data_sources=[], # No documentation sources
|
|
167
|
+
search_mode="unified", # Use unified for full answers
|
|
168
|
+
stream=True,
|
|
169
|
+
include_sources=include_sources
|
|
170
|
+
):
|
|
171
|
+
try:
|
|
172
|
+
data = json.loads(chunk)
|
|
173
|
+
|
|
174
|
+
if "content" in data and data["content"] and data["content"] != "[DONE]":
|
|
175
|
+
response_parts.append(data["content"])
|
|
176
|
+
|
|
177
|
+
if "sources" in data and data["sources"]:
|
|
178
|
+
sources_parts.extend(data["sources"])
|
|
179
|
+
|
|
180
|
+
except json.JSONDecodeError:
|
|
181
|
+
continue
|
|
182
|
+
|
|
183
|
+
# Format the response
|
|
184
|
+
response_text = "".join(response_parts)
|
|
185
|
+
|
|
186
|
+
if sources_parts and include_sources:
|
|
187
|
+
response_text += "\n\n## Sources\n\n"
|
|
188
|
+
for i, source in enumerate(sources_parts[:5], 1): # Limit to 5 sources
|
|
189
|
+
response_text += f"### Source {i}\n"
|
|
190
|
+
if "repository" in source:
|
|
191
|
+
response_text += f"**Repository:** {source['repository']}\n"
|
|
192
|
+
if "file" in source:
|
|
193
|
+
response_text += f"**File:** `{source['file']}`\n"
|
|
194
|
+
if "preview" in source:
|
|
195
|
+
response_text += f"```\n{source['preview']}\n```\n\n"
|
|
196
|
+
|
|
197
|
+
return [TextContent(type="text", text=response_text)]
|
|
198
|
+
|
|
199
|
+
except APIError as e:
|
|
200
|
+
logger.error(f"API Error searching codebase: {e} (status_code={e.status_code}, detail={e.detail})")
|
|
201
|
+
if e.status_code == 403 or "free tier limit" in str(e).lower() or "free api requests" in str(e).lower():
|
|
202
|
+
if e.detail and "25 free API requests" in e.detail:
|
|
203
|
+
return [TextContent(
|
|
204
|
+
type="text",
|
|
205
|
+
text=f"ā {e.detail}\n\nš” Tip: Upgrade to Pro at https://trynia.ai/billing for unlimited API access."
|
|
206
|
+
)]
|
|
207
|
+
else:
|
|
208
|
+
return [TextContent(
|
|
209
|
+
type="text",
|
|
210
|
+
text=f"ā {str(e)}\n\nš” Tip: You've reached the free tier limit. Upgrade to Pro for unlimited access."
|
|
211
|
+
)]
|
|
212
|
+
else:
|
|
213
|
+
return [TextContent(type="text", text=f"ā {str(e)}")]
|
|
214
|
+
except Exception as e:
|
|
215
|
+
logger.error(f"Unexpected error searching codebase: {e}")
|
|
216
|
+
error_msg = str(e)
|
|
217
|
+
if "free api requests" in error_msg.lower() or "lifetime limit" in error_msg.lower():
|
|
218
|
+
return [TextContent(
|
|
219
|
+
type="text",
|
|
220
|
+
text=f"ā {error_msg}\n\nš” Tip: Upgrade to Pro at https://trynia.ai/billing for unlimited API access."
|
|
221
|
+
)]
|
|
222
|
+
return [TextContent(
|
|
223
|
+
type="text",
|
|
224
|
+
text=f"ā Error searching codebase: {error_msg}"
|
|
225
|
+
)]
|
|
226
|
+
|
|
227
|
+
@mcp.tool()
|
|
228
|
+
async def search_documentation(
|
|
229
|
+
query: str,
|
|
230
|
+
sources: Optional[List[str]] = None,
|
|
231
|
+
include_sources: bool = True
|
|
232
|
+
) -> List[TextContent]:
|
|
233
|
+
"""
|
|
234
|
+
Search indexed documentation using natural language.
|
|
235
|
+
|
|
236
|
+
Args:
|
|
237
|
+
query: Natural language search query
|
|
238
|
+
sources: List of documentation source IDs to search. If not specified, searches all indexed documentation.
|
|
239
|
+
include_sources: Whether to include source references in results
|
|
240
|
+
|
|
241
|
+
Returns:
|
|
242
|
+
Search results with relevant documentation excerpts
|
|
243
|
+
"""
|
|
244
|
+
try:
|
|
245
|
+
client = await ensure_api_client()
|
|
246
|
+
|
|
247
|
+
# Get all indexed documentation sources if not specified
|
|
248
|
+
if not sources:
|
|
249
|
+
all_sources = await client.list_data_sources()
|
|
250
|
+
sources = [source["id"] for source in all_sources if source.get("status") == "completed"]
|
|
251
|
+
if not sources:
|
|
252
|
+
return [TextContent(
|
|
253
|
+
type="text",
|
|
254
|
+
text="ā No indexed documentation found. Use `index_documentation` to index documentation first."
|
|
255
|
+
)]
|
|
256
|
+
|
|
257
|
+
# Build messages for the query
|
|
258
|
+
messages = [
|
|
259
|
+
{"role": "user", "content": query}
|
|
260
|
+
]
|
|
261
|
+
|
|
262
|
+
logger.info(f"Searching {len(sources)} documentation sources")
|
|
263
|
+
|
|
264
|
+
# Stream the response using unified query
|
|
265
|
+
response_parts = []
|
|
266
|
+
sources_parts = []
|
|
267
|
+
|
|
268
|
+
async for chunk in client.query_unified(
|
|
269
|
+
messages=messages,
|
|
270
|
+
repositories=[], # No repositories
|
|
271
|
+
data_sources=sources,
|
|
272
|
+
search_mode="unified", # Use unified for full answers with documentation
|
|
273
|
+
stream=True,
|
|
274
|
+
include_sources=include_sources
|
|
275
|
+
):
|
|
276
|
+
try:
|
|
277
|
+
data = json.loads(chunk)
|
|
278
|
+
|
|
279
|
+
if "content" in data and data["content"] and data["content"] != "[DONE]":
|
|
280
|
+
response_parts.append(data["content"])
|
|
281
|
+
|
|
282
|
+
if "sources" in data and data["sources"]:
|
|
283
|
+
sources_parts.extend(data["sources"])
|
|
284
|
+
|
|
285
|
+
except json.JSONDecodeError:
|
|
286
|
+
continue
|
|
287
|
+
|
|
288
|
+
# Format the response
|
|
289
|
+
response_text = "".join(response_parts)
|
|
290
|
+
|
|
291
|
+
if sources_parts and include_sources:
|
|
292
|
+
response_text += "\n\n## Sources\n\n"
|
|
293
|
+
for i, source in enumerate(sources_parts[:5], 1): # Limit to 5 sources
|
|
294
|
+
response_text += f"### Source {i}\n"
|
|
295
|
+
if "url" in source:
|
|
296
|
+
response_text += f"**URL:** {source['url']}\n"
|
|
297
|
+
elif "file" in source:
|
|
298
|
+
response_text += f"**Page:** {source['file']}\n"
|
|
299
|
+
if "preview" in source:
|
|
300
|
+
response_text += f"```\n{source['preview']}\n```\n\n"
|
|
301
|
+
|
|
302
|
+
return [TextContent(type="text", text=response_text)]
|
|
303
|
+
|
|
304
|
+
except APIError as e:
|
|
305
|
+
logger.error(f"API Error searching documentation: {e}")
|
|
306
|
+
error_msg = f"ā {str(e)}"
|
|
307
|
+
if e.status_code == 403 and "lifetime limit" in str(e).lower():
|
|
308
|
+
error_msg += "\n\nš” Tip: You've reached the free tier limit of 25 API requests. Upgrade to Pro for unlimited access."
|
|
309
|
+
return [TextContent(type="text", text=error_msg)]
|
|
310
|
+
except Exception as e:
|
|
311
|
+
logger.error(f"Error searching documentation: {e}")
|
|
312
|
+
return [TextContent(
|
|
313
|
+
type="text",
|
|
314
|
+
text=f"ā Error searching documentation: {str(e)}"
|
|
315
|
+
)]
|
|
316
|
+
|
|
317
|
+
@mcp.tool()
|
|
318
|
+
async def list_repositories() -> List[TextContent]:
|
|
319
|
+
"""
|
|
320
|
+
List all indexed repositories.
|
|
321
|
+
|
|
322
|
+
Returns:
|
|
323
|
+
List of indexed repositories with their status
|
|
324
|
+
"""
|
|
325
|
+
try:
|
|
326
|
+
client = await ensure_api_client()
|
|
327
|
+
repositories = await client.list_repositories()
|
|
328
|
+
|
|
329
|
+
if not repositories:
|
|
330
|
+
return [TextContent(
|
|
331
|
+
type="text",
|
|
332
|
+
text="No indexed repositories found.\n\n"
|
|
333
|
+
"Get started by indexing a repository:\n"
|
|
334
|
+
"Use `index_repository` with a GitHub URL."
|
|
335
|
+
)]
|
|
336
|
+
|
|
337
|
+
# Format repository list
|
|
338
|
+
lines = ["# Indexed Repositories\n"]
|
|
339
|
+
|
|
340
|
+
for repo in repositories:
|
|
341
|
+
status_icon = "ā
" if repo.get("status") == "completed" else "ā³"
|
|
342
|
+
lines.append(f"\n## {status_icon} {repo['repository']}")
|
|
343
|
+
lines.append(f"- **Branch:** {repo.get('branch', 'main')}")
|
|
344
|
+
lines.append(f"- **Status:** {repo.get('status', 'unknown')}")
|
|
345
|
+
if repo.get("indexed_at"):
|
|
346
|
+
lines.append(f"- **Indexed:** {repo['indexed_at']}")
|
|
347
|
+
if repo.get("error"):
|
|
348
|
+
lines.append(f"- **Error:** {repo['error']}")
|
|
349
|
+
|
|
350
|
+
return [TextContent(type="text", text="\n".join(lines))]
|
|
351
|
+
|
|
352
|
+
except APIError as e:
|
|
353
|
+
logger.error(f"API Error listing repositories: {e} (status_code={e.status_code}, detail={e.detail})")
|
|
354
|
+
# Check for free tier limit errors
|
|
355
|
+
if e.status_code == 403 or "free tier limit" in str(e).lower() or "free api requests" in str(e).lower():
|
|
356
|
+
# Extract the specific limit message
|
|
357
|
+
if e.detail and "25 free API requests" in e.detail:
|
|
358
|
+
return [TextContent(
|
|
359
|
+
type="text",
|
|
360
|
+
text=f"ā {e.detail}\n\nš” Tip: Upgrade to Pro at https://trynia.ai/billing for unlimited API access."
|
|
361
|
+
)]
|
|
362
|
+
else:
|
|
363
|
+
return [TextContent(
|
|
364
|
+
type="text",
|
|
365
|
+
text=f"ā {str(e)}\n\nš” Tip: You've reached the free tier limit. Upgrade to Pro for unlimited access."
|
|
366
|
+
)]
|
|
367
|
+
else:
|
|
368
|
+
return [TextContent(type="text", text=f"ā {str(e)}")]
|
|
369
|
+
except Exception as e:
|
|
370
|
+
logger.error(f"Unexpected error listing repositories (type={type(e).__name__}): {e}")
|
|
371
|
+
# Check if this looks like an API limit error that wasn't caught properly
|
|
372
|
+
error_msg = str(e)
|
|
373
|
+
if "free api requests" in error_msg.lower() or "lifetime limit" in error_msg.lower():
|
|
374
|
+
return [TextContent(
|
|
375
|
+
type="text",
|
|
376
|
+
text=f"ā {error_msg}\n\nš” Tip: Upgrade to Pro at https://trynia.ai/billing for unlimited API access."
|
|
377
|
+
)]
|
|
378
|
+
return [TextContent(
|
|
379
|
+
type="text",
|
|
380
|
+
text=f"ā Error listing repositories: {error_msg}"
|
|
381
|
+
)]
|
|
382
|
+
|
|
383
|
+
@mcp.tool()
|
|
384
|
+
async def check_repository_status(repository: str) -> List[TextContent]:
|
|
385
|
+
"""
|
|
386
|
+
Check the indexing status of a repository.
|
|
387
|
+
|
|
388
|
+
Args:
|
|
389
|
+
repository: Repository in owner/repo format
|
|
390
|
+
|
|
391
|
+
Returns:
|
|
392
|
+
Current status of the repository
|
|
393
|
+
"""
|
|
394
|
+
try:
|
|
395
|
+
client = await ensure_api_client()
|
|
396
|
+
status = await client.get_repository_status(repository)
|
|
397
|
+
|
|
398
|
+
if not status:
|
|
399
|
+
return [TextContent(
|
|
400
|
+
type="text",
|
|
401
|
+
text=f"ā Repository '{repository}' not found."
|
|
402
|
+
)]
|
|
403
|
+
|
|
404
|
+
# Format status
|
|
405
|
+
status_icon = {
|
|
406
|
+
"completed": "ā
",
|
|
407
|
+
"indexing": "ā³",
|
|
408
|
+
"failed": "ā",
|
|
409
|
+
"pending": "š"
|
|
410
|
+
}.get(status["status"], "ā")
|
|
411
|
+
|
|
412
|
+
lines = [
|
|
413
|
+
f"# Repository Status: {repository}\n",
|
|
414
|
+
f"{status_icon} **Status:** {status['status']}",
|
|
415
|
+
f"**Branch:** {status.get('branch', 'main')}"
|
|
416
|
+
]
|
|
417
|
+
|
|
418
|
+
if status.get("progress"):
|
|
419
|
+
progress = status["progress"]
|
|
420
|
+
if isinstance(progress, dict):
|
|
421
|
+
lines.append(f"**Progress:** {progress.get('percentage', 0)}%")
|
|
422
|
+
if progress.get("stage"):
|
|
423
|
+
lines.append(f"**Stage:** {progress['stage']}")
|
|
424
|
+
|
|
425
|
+
if status.get("indexed_at"):
|
|
426
|
+
lines.append(f"**Indexed:** {status['indexed_at']}")
|
|
427
|
+
|
|
428
|
+
if status.get("error"):
|
|
429
|
+
lines.append(f"**Error:** {status['error']}")
|
|
430
|
+
|
|
431
|
+
return [TextContent(type="text", text="\n".join(lines))]
|
|
432
|
+
|
|
433
|
+
except APIError as e:
|
|
434
|
+
logger.error(f"API Error checking repository status: {e}")
|
|
435
|
+
error_msg = f"ā {str(e)}"
|
|
436
|
+
if e.status_code == 403 and "lifetime limit" in str(e).lower():
|
|
437
|
+
error_msg += "\n\nš” Tip: You've reached the free tier limit of 25 API requests. Upgrade to Pro for unlimited access."
|
|
438
|
+
return [TextContent(type="text", text=error_msg)]
|
|
439
|
+
except Exception as e:
|
|
440
|
+
logger.error(f"Error checking repository status: {e}")
|
|
441
|
+
return [TextContent(
|
|
442
|
+
type="text",
|
|
443
|
+
text=f"ā Error checking repository status: {str(e)}"
|
|
444
|
+
)]
|
|
445
|
+
|
|
446
|
+
@mcp.tool()
|
|
447
|
+
async def index_documentation(
|
|
448
|
+
url: str,
|
|
449
|
+
url_patterns: Optional[List[str]] = None,
|
|
450
|
+
max_age: Optional[int] = None,
|
|
451
|
+
only_main_content: Optional[bool] = True
|
|
452
|
+
) -> List[TextContent]:
|
|
453
|
+
"""
|
|
454
|
+
Index documentation or website for intelligent search.
|
|
455
|
+
|
|
456
|
+
Args:
|
|
457
|
+
url: URL of the documentation site to index
|
|
458
|
+
url_patterns: Optional list of URL patterns to include in crawling (e.g., ["/docs/*", "/guide/*"])
|
|
459
|
+
max_age: Maximum age of cached content in seconds (for fast scraping mode)
|
|
460
|
+
only_main_content: Extract only main content (removes navigation, ads, etc.)
|
|
461
|
+
|
|
462
|
+
Returns:
|
|
463
|
+
Status of the indexing operation
|
|
464
|
+
"""
|
|
465
|
+
try:
|
|
466
|
+
client = await ensure_api_client()
|
|
467
|
+
|
|
468
|
+
# Create and start indexing
|
|
469
|
+
logger.info(f"Starting to index documentation: {url}")
|
|
470
|
+
result = await client.create_data_source(
|
|
471
|
+
url=url,
|
|
472
|
+
url_patterns=url_patterns,
|
|
473
|
+
max_age=max_age,
|
|
474
|
+
only_main_content=only_main_content
|
|
475
|
+
)
|
|
476
|
+
|
|
477
|
+
source_id = result.get("id")
|
|
478
|
+
status = result.get("status", "unknown")
|
|
479
|
+
|
|
480
|
+
if status == "completed":
|
|
481
|
+
return [TextContent(
|
|
482
|
+
type="text",
|
|
483
|
+
text=f"ā
Documentation already indexed: {url}\n"
|
|
484
|
+
f"Source ID: {source_id}\n"
|
|
485
|
+
f"You can now search this documentation!"
|
|
486
|
+
)]
|
|
487
|
+
else:
|
|
488
|
+
return [TextContent(
|
|
489
|
+
type="text",
|
|
490
|
+
text=f"ā³ Documentation indexing started: {url}\n"
|
|
491
|
+
f"Source ID: {source_id}\n"
|
|
492
|
+
f"Status: {status}\n\n"
|
|
493
|
+
f"Use `check_documentation_status` to monitor progress."
|
|
494
|
+
)]
|
|
495
|
+
|
|
496
|
+
except APIError as e:
|
|
497
|
+
logger.error(f"API Error indexing documentation: {e}")
|
|
498
|
+
error_msg = f"ā {str(e)}"
|
|
499
|
+
if e.status_code == 403 and "lifetime limit" in str(e).lower():
|
|
500
|
+
error_msg += "\n\nš” Tip: You've reached the free tier limit of 25 API requests. Upgrade to Pro for unlimited access."
|
|
501
|
+
return [TextContent(type="text", text=error_msg)]
|
|
502
|
+
except Exception as e:
|
|
503
|
+
logger.error(f"Error indexing documentation: {e}")
|
|
504
|
+
return [TextContent(
|
|
505
|
+
type="text",
|
|
506
|
+
text=f"ā Error indexing documentation: {str(e)}"
|
|
507
|
+
)]
|
|
508
|
+
|
|
509
|
+
@mcp.tool()
|
|
510
|
+
async def list_documentation() -> List[TextContent]:
|
|
511
|
+
"""
|
|
512
|
+
List all indexed documentation sources.
|
|
513
|
+
|
|
514
|
+
Returns:
|
|
515
|
+
List of indexed documentation with their status
|
|
516
|
+
"""
|
|
517
|
+
try:
|
|
518
|
+
client = await ensure_api_client()
|
|
519
|
+
sources = await client.list_data_sources()
|
|
520
|
+
|
|
521
|
+
if not sources:
|
|
522
|
+
return [TextContent(
|
|
523
|
+
type="text",
|
|
524
|
+
text="No indexed documentation found.\n\n"
|
|
525
|
+
"Get started by indexing documentation:\n"
|
|
526
|
+
"Use `index_documentation` with a URL."
|
|
527
|
+
)]
|
|
528
|
+
|
|
529
|
+
# Format source list
|
|
530
|
+
lines = ["# Indexed Documentation\n"]
|
|
531
|
+
|
|
532
|
+
for source in sources:
|
|
533
|
+
status_icon = "ā
" if source.get("status") == "completed" else "ā³"
|
|
534
|
+
lines.append(f"\n## {status_icon} {source.get('url', 'Unknown URL')}")
|
|
535
|
+
lines.append(f"- **ID:** {source['id']}")
|
|
536
|
+
lines.append(f"- **Status:** {source.get('status', 'unknown')}")
|
|
537
|
+
lines.append(f"- **Type:** {source.get('source_type', 'web')}")
|
|
538
|
+
if source.get("page_count", 0) > 0:
|
|
539
|
+
lines.append(f"- **Pages:** {source['page_count']}")
|
|
540
|
+
if source.get("created_at"):
|
|
541
|
+
lines.append(f"- **Created:** {source['created_at']}")
|
|
542
|
+
|
|
543
|
+
return [TextContent(type="text", text="\n".join(lines))]
|
|
544
|
+
|
|
545
|
+
except APIError as e:
|
|
546
|
+
logger.error(f"API Error listing documentation: {e}")
|
|
547
|
+
error_msg = f"ā {str(e)}"
|
|
548
|
+
if e.status_code == 403 and "lifetime limit" in str(e).lower():
|
|
549
|
+
error_msg += "\n\nš” Tip: You've reached the free tier limit of 25 API requests. Upgrade to Pro for unlimited access."
|
|
550
|
+
return [TextContent(type="text", text=error_msg)]
|
|
551
|
+
except Exception as e:
|
|
552
|
+
logger.error(f"Error listing documentation: {e}")
|
|
553
|
+
return [TextContent(
|
|
554
|
+
type="text",
|
|
555
|
+
text=f"ā Error listing documentation: {str(e)}"
|
|
556
|
+
)]
|
|
557
|
+
|
|
558
|
+
@mcp.tool()
|
|
559
|
+
async def check_documentation_status(source_id: str) -> List[TextContent]:
|
|
560
|
+
"""
|
|
561
|
+
Check the indexing status of a documentation source.
|
|
562
|
+
|
|
563
|
+
Args:
|
|
564
|
+
source_id: Documentation source ID
|
|
565
|
+
|
|
566
|
+
Returns:
|
|
567
|
+
Current status of the documentation source
|
|
568
|
+
"""
|
|
569
|
+
try:
|
|
570
|
+
client = await ensure_api_client()
|
|
571
|
+
status = await client.get_data_source_status(source_id)
|
|
572
|
+
|
|
573
|
+
if not status:
|
|
574
|
+
return [TextContent(
|
|
575
|
+
type="text",
|
|
576
|
+
text=f"ā Documentation source '{source_id}' not found."
|
|
577
|
+
)]
|
|
578
|
+
|
|
579
|
+
# Format status
|
|
580
|
+
status_icon = {
|
|
581
|
+
"completed": "ā
",
|
|
582
|
+
"processing": "ā³",
|
|
583
|
+
"failed": "ā",
|
|
584
|
+
"pending": "š"
|
|
585
|
+
}.get(status["status"], "ā")
|
|
586
|
+
|
|
587
|
+
lines = [
|
|
588
|
+
f"# Documentation Status: {status.get('url', 'Unknown URL')}\n",
|
|
589
|
+
f"{status_icon} **Status:** {status['status']}",
|
|
590
|
+
f"**Source ID:** {source_id}"
|
|
591
|
+
]
|
|
592
|
+
|
|
593
|
+
if status.get("page_count", 0) > 0:
|
|
594
|
+
lines.append(f"**Pages Indexed:** {status['page_count']}")
|
|
595
|
+
|
|
596
|
+
if status.get("details"):
|
|
597
|
+
details = status["details"]
|
|
598
|
+
if details.get("progress"):
|
|
599
|
+
lines.append(f"**Progress:** {details['progress']}%")
|
|
600
|
+
if details.get("stage"):
|
|
601
|
+
lines.append(f"**Stage:** {details['stage']}")
|
|
602
|
+
|
|
603
|
+
if status.get("created_at"):
|
|
604
|
+
lines.append(f"**Created:** {status['created_at']}")
|
|
605
|
+
|
|
606
|
+
if status.get("error"):
|
|
607
|
+
lines.append(f"**Error:** {status['error']}")
|
|
608
|
+
|
|
609
|
+
return [TextContent(type="text", text="\n".join(lines))]
|
|
610
|
+
|
|
611
|
+
except APIError as e:
|
|
612
|
+
logger.error(f"API Error checking documentation status: {e}")
|
|
613
|
+
error_msg = f"ā {str(e)}"
|
|
614
|
+
if e.status_code == 403 and "lifetime limit" in str(e).lower():
|
|
615
|
+
error_msg += "\n\nš” Tip: You've reached the free tier limit of 25 API requests. Upgrade to Pro for unlimited access."
|
|
616
|
+
return [TextContent(type="text", text=error_msg)]
|
|
617
|
+
except Exception as e:
|
|
618
|
+
logger.error(f"Error checking documentation status: {e}")
|
|
619
|
+
return [TextContent(
|
|
620
|
+
type="text",
|
|
621
|
+
text=f"ā Error checking documentation status: {str(e)}"
|
|
622
|
+
)]
|
|
623
|
+
|
|
624
|
+
@mcp.tool()
|
|
625
|
+
async def delete_documentation(source_id: str) -> List[TextContent]:
|
|
626
|
+
"""
|
|
627
|
+
Delete an indexed documentation source.
|
|
628
|
+
|
|
629
|
+
Args:
|
|
630
|
+
source_id: Documentation source ID to delete
|
|
631
|
+
|
|
632
|
+
Returns:
|
|
633
|
+
Confirmation of deletion
|
|
634
|
+
"""
|
|
635
|
+
try:
|
|
636
|
+
client = await ensure_api_client()
|
|
637
|
+
success = await client.delete_data_source(source_id)
|
|
638
|
+
|
|
639
|
+
if success:
|
|
640
|
+
return [TextContent(
|
|
641
|
+
type="text",
|
|
642
|
+
text=f"ā
Successfully deleted documentation source: {source_id}"
|
|
643
|
+
)]
|
|
644
|
+
else:
|
|
645
|
+
return [TextContent(
|
|
646
|
+
type="text",
|
|
647
|
+
text=f"ā Failed to delete documentation source: {source_id}"
|
|
648
|
+
)]
|
|
649
|
+
|
|
650
|
+
except APIError as e:
|
|
651
|
+
logger.error(f"API Error deleting documentation: {e}")
|
|
652
|
+
error_msg = f"ā {str(e)}"
|
|
653
|
+
if e.status_code == 403 and "lifetime limit" in str(e).lower():
|
|
654
|
+
error_msg += "\n\nš” Tip: You've reached the free tier limit of 25 API requests. Upgrade to Pro for unlimited access."
|
|
655
|
+
return [TextContent(type="text", text=error_msg)]
|
|
656
|
+
except Exception as e:
|
|
657
|
+
logger.error(f"Error deleting documentation: {e}")
|
|
658
|
+
return [TextContent(
|
|
659
|
+
type="text",
|
|
660
|
+
text=f"ā Error deleting documentation: {str(e)}"
|
|
661
|
+
)]
|
|
662
|
+
|
|
663
|
+
@mcp.tool()
|
|
664
|
+
async def delete_repository(repository: str) -> List[TextContent]:
|
|
665
|
+
"""
|
|
666
|
+
Delete an indexed repository.
|
|
667
|
+
|
|
668
|
+
Args:
|
|
669
|
+
repository: Repository in owner/repo format
|
|
670
|
+
|
|
671
|
+
Returns:
|
|
672
|
+
Confirmation of deletion
|
|
673
|
+
"""
|
|
674
|
+
try:
|
|
675
|
+
client = await ensure_api_client()
|
|
676
|
+
success = await client.delete_repository(repository)
|
|
677
|
+
|
|
678
|
+
if success:
|
|
679
|
+
return [TextContent(
|
|
680
|
+
type="text",
|
|
681
|
+
text=f"ā
Successfully deleted repository: {repository}"
|
|
682
|
+
)]
|
|
683
|
+
else:
|
|
684
|
+
return [TextContent(
|
|
685
|
+
type="text",
|
|
686
|
+
text=f"ā Failed to delete repository: {repository}"
|
|
687
|
+
)]
|
|
688
|
+
|
|
689
|
+
except APIError as e:
|
|
690
|
+
logger.error(f"API Error deleting repository: {e}")
|
|
691
|
+
error_msg = f"ā {str(e)}"
|
|
692
|
+
if e.status_code == 403 and "lifetime limit" in str(e).lower():
|
|
693
|
+
error_msg += "\n\nš” Tip: You've reached the free tier limit of 25 API requests. Upgrade to Pro for unlimited access."
|
|
694
|
+
return [TextContent(type="text", text=error_msg)]
|
|
695
|
+
except Exception as e:
|
|
696
|
+
logger.error(f"Error deleting repository: {e}")
|
|
697
|
+
return [TextContent(
|
|
698
|
+
type="text",
|
|
699
|
+
text=f"ā Error deleting repository: {str(e)}"
|
|
700
|
+
)]
|
|
701
|
+
|
|
702
|
+
# Resources
|
|
703
|
+
|
|
704
|
+
# Note: FastMCP doesn't have list_resources or read_resource decorators
|
|
705
|
+
# Resources should be registered individually using @mcp.resource()
|
|
706
|
+
# For now, commenting out these functions as they use incorrect decorators
|
|
707
|
+
|
|
708
|
+
# @mcp.list_resources
|
|
709
|
+
# async def list_resources() -> List[Resource]:
|
|
710
|
+
# """List available repositories as resources."""
|
|
711
|
+
# try:
|
|
712
|
+
# client = await ensure_api_client()
|
|
713
|
+
# repositories = await client.list_repositories()
|
|
714
|
+
#
|
|
715
|
+
# resources = []
|
|
716
|
+
# for repo in repositories:
|
|
717
|
+
# if repo.get("status") == "completed":
|
|
718
|
+
# resources.append(Resource(
|
|
719
|
+
# uri=f"nia://repository/{repo['repository']}",
|
|
720
|
+
# name=repo["repository"],
|
|
721
|
+
# description=f"Indexed repository at branch {repo.get('branch', 'main')}",
|
|
722
|
+
# mimeType="application/x-nia-repository"
|
|
723
|
+
# ))
|
|
724
|
+
#
|
|
725
|
+
# return resources
|
|
726
|
+
# except Exception as e:
|
|
727
|
+
# logger.error(f"Error listing resources: {e}")
|
|
728
|
+
# return []
|
|
729
|
+
|
|
730
|
+
# @mcp.read_resource
|
|
731
|
+
# async def read_resource(uri: str) -> TextContent:
|
|
732
|
+
# """Read information about a repository resource."""
|
|
733
|
+
# if not uri.startswith("nia://repository/"):
|
|
734
|
+
# return TextContent(
|
|
735
|
+
# type="text",
|
|
736
|
+
# text=f"Unknown resource URI: {uri}"
|
|
737
|
+
# )
|
|
738
|
+
#
|
|
739
|
+
# repository = uri.replace("nia://repository/", "")
|
|
740
|
+
#
|
|
741
|
+
# try:
|
|
742
|
+
# client = await ensure_api_client()
|
|
743
|
+
# status = await client.get_repository_status(repository)
|
|
744
|
+
#
|
|
745
|
+
# if not status:
|
|
746
|
+
# return TextContent(
|
|
747
|
+
# type="text",
|
|
748
|
+
# text=f"Repository not found: {repository}"
|
|
749
|
+
# )
|
|
750
|
+
#
|
|
751
|
+
# # Format repository information
|
|
752
|
+
# lines = [
|
|
753
|
+
# f"# Repository: {repository}",
|
|
754
|
+
# "",
|
|
755
|
+
# f"**Status:** {status['status']}",
|
|
756
|
+
# f"**Branch:** {status.get('branch', 'main')}",
|
|
757
|
+
# ]
|
|
758
|
+
#
|
|
759
|
+
# if status.get("indexed_at"):
|
|
760
|
+
# lines.append(f"**Indexed:** {status['indexed_at']}")
|
|
761
|
+
#
|
|
762
|
+
# lines.extend([
|
|
763
|
+
# "",
|
|
764
|
+
# "## Usage",
|
|
765
|
+
# f"Search this repository using the `search_codebase` tool with:",
|
|
766
|
+
# f'`repositories=["{repository}"]`'
|
|
767
|
+
# ])
|
|
768
|
+
#
|
|
769
|
+
# return TextContent(type="text", text="\n".join(lines))
|
|
770
|
+
#
|
|
771
|
+
# except Exception as e:
|
|
772
|
+
# logger.error(f"Error reading resource: {e}")
|
|
773
|
+
# return TextContent(
|
|
774
|
+
# type="text",
|
|
775
|
+
# text=f"Error reading resource: {str(e)}"
|
|
776
|
+
# )
|
|
777
|
+
|
|
778
|
+
# Server lifecycle
|
|
779
|
+
|
|
780
|
+
async def cleanup():
|
|
781
|
+
"""Cleanup resources on shutdown."""
|
|
782
|
+
global api_client
|
|
783
|
+
if api_client:
|
|
784
|
+
await api_client.close()
|
|
785
|
+
api_client = None
|
|
786
|
+
|
|
787
|
+
def run():
|
|
788
|
+
"""Run the MCP server."""
|
|
789
|
+
try:
|
|
790
|
+
# Check for API key early
|
|
791
|
+
get_api_key()
|
|
792
|
+
|
|
793
|
+
logger.info("Starting NIA MCP Server")
|
|
794
|
+
mcp.run(transport='stdio')
|
|
795
|
+
except KeyboardInterrupt:
|
|
796
|
+
logger.info("Server shutdown requested")
|
|
797
|
+
except Exception as e:
|
|
798
|
+
logger.error(f"Server error: {e}")
|
|
799
|
+
raise
|
|
800
|
+
finally:
|
|
801
|
+
# Run cleanup
|
|
802
|
+
loop = asyncio.new_event_loop()
|
|
803
|
+
loop.run_until_complete(cleanup())
|
|
804
|
+
loop.close()
|