sirchmunk 0.0.1.post1__py3-none-any.whl → 0.0.2__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.
- sirchmunk/api/__init__.py +1 -0
- sirchmunk/api/chat.py +1123 -0
- sirchmunk/api/components/__init__.py +0 -0
- sirchmunk/api/components/history_storage.py +402 -0
- sirchmunk/api/components/monitor_tracker.py +518 -0
- sirchmunk/api/components/settings_storage.py +353 -0
- sirchmunk/api/history.py +254 -0
- sirchmunk/api/knowledge.py +411 -0
- sirchmunk/api/main.py +120 -0
- sirchmunk/api/monitor.py +219 -0
- sirchmunk/api/run_server.py +54 -0
- sirchmunk/api/search.py +230 -0
- sirchmunk/api/settings.py +309 -0
- sirchmunk/api/tools.py +315 -0
- sirchmunk/cli/__init__.py +11 -0
- sirchmunk/cli/cli.py +789 -0
- sirchmunk/learnings/knowledge_base.py +5 -2
- sirchmunk/llm/prompts.py +12 -1
- sirchmunk/retrieve/text_retriever.py +186 -2
- sirchmunk/scan/file_scanner.py +2 -2
- sirchmunk/schema/knowledge.py +119 -35
- sirchmunk/search.py +384 -26
- sirchmunk/storage/__init__.py +2 -2
- sirchmunk/storage/{knowledge_manager.py → knowledge_storage.py} +265 -60
- sirchmunk/utils/constants.py +7 -5
- sirchmunk/utils/embedding_util.py +217 -0
- sirchmunk/utils/tokenizer_util.py +36 -1
- sirchmunk/version.py +1 -1
- {sirchmunk-0.0.1.post1.dist-info → sirchmunk-0.0.2.dist-info}/METADATA +124 -9
- sirchmunk-0.0.2.dist-info/RECORD +69 -0
- {sirchmunk-0.0.1.post1.dist-info → sirchmunk-0.0.2.dist-info}/WHEEL +1 -1
- sirchmunk-0.0.2.dist-info/top_level.txt +2 -0
- sirchmunk_mcp/__init__.py +25 -0
- sirchmunk_mcp/cli.py +478 -0
- sirchmunk_mcp/config.py +276 -0
- sirchmunk_mcp/server.py +355 -0
- sirchmunk_mcp/service.py +327 -0
- sirchmunk_mcp/setup.py +15 -0
- sirchmunk_mcp/tools.py +410 -0
- sirchmunk-0.0.1.post1.dist-info/RECORD +0 -45
- sirchmunk-0.0.1.post1.dist-info/top_level.txt +0 -1
- {sirchmunk-0.0.1.post1.dist-info → sirchmunk-0.0.2.dist-info}/entry_points.txt +0 -0
- {sirchmunk-0.0.1.post1.dist-info → sirchmunk-0.0.2.dist-info}/licenses/LICENSE +0 -0
sirchmunk_mcp/config.py
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
# Copyright (c) ModelScope Contributors. All rights reserved.
|
|
2
|
+
"""
|
|
3
|
+
Configuration management for Sirchmunk MCP Server.
|
|
4
|
+
|
|
5
|
+
Handles loading and validation of configuration from environment variables,
|
|
6
|
+
configuration files, and default values.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any, Dict, Optional
|
|
12
|
+
|
|
13
|
+
from dotenv import load_dotenv
|
|
14
|
+
from pydantic import BaseModel, Field, field_validator
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class LLMConfig(BaseModel):
|
|
18
|
+
"""Configuration for LLM service."""
|
|
19
|
+
|
|
20
|
+
base_url: str = Field(
|
|
21
|
+
default="https://api.openai.com/v1",
|
|
22
|
+
description="LLM API base URL"
|
|
23
|
+
)
|
|
24
|
+
api_key: str = Field(
|
|
25
|
+
description="LLM API key (required)"
|
|
26
|
+
)
|
|
27
|
+
model_name: str = Field(
|
|
28
|
+
default="gpt-5.2",
|
|
29
|
+
description="LLM model name"
|
|
30
|
+
)
|
|
31
|
+
timeout: float = Field(
|
|
32
|
+
default=60.0,
|
|
33
|
+
description="Request timeout in seconds",
|
|
34
|
+
gt=0
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
@field_validator("api_key")
|
|
38
|
+
@classmethod
|
|
39
|
+
def validate_api_key(cls, v: str) -> str:
|
|
40
|
+
"""Validate API key is not empty."""
|
|
41
|
+
if not v or v.strip() == "":
|
|
42
|
+
raise ValueError("LLM API key cannot be empty")
|
|
43
|
+
return v
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class ClusterSimilarityConfig(BaseModel):
|
|
47
|
+
"""Configuration for cluster similarity search."""
|
|
48
|
+
|
|
49
|
+
threshold: float = Field(
|
|
50
|
+
default=0.85,
|
|
51
|
+
description="Similarity threshold for cluster reuse",
|
|
52
|
+
ge=0.0,
|
|
53
|
+
le=1.0
|
|
54
|
+
)
|
|
55
|
+
top_k: int = Field(
|
|
56
|
+
default=3,
|
|
57
|
+
description="Number of similar clusters to retrieve",
|
|
58
|
+
ge=1,
|
|
59
|
+
le=10
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class SearchDefaultsConfig(BaseModel):
|
|
64
|
+
"""Default configuration for search operations."""
|
|
65
|
+
|
|
66
|
+
max_depth: int = Field(
|
|
67
|
+
default=5,
|
|
68
|
+
description="Maximum directory depth to search",
|
|
69
|
+
ge=1,
|
|
70
|
+
le=20
|
|
71
|
+
)
|
|
72
|
+
top_k_files: int = Field(
|
|
73
|
+
default=3,
|
|
74
|
+
description="Number of top files to return",
|
|
75
|
+
ge=1,
|
|
76
|
+
le=20
|
|
77
|
+
)
|
|
78
|
+
keyword_levels: int = Field(
|
|
79
|
+
default=3,
|
|
80
|
+
description="Number of keyword granularity levels",
|
|
81
|
+
ge=1,
|
|
82
|
+
le=5
|
|
83
|
+
)
|
|
84
|
+
grep_timeout: float = Field(
|
|
85
|
+
default=60.0,
|
|
86
|
+
description="Timeout for grep operations in seconds",
|
|
87
|
+
gt=0
|
|
88
|
+
)
|
|
89
|
+
max_queries_per_cluster: int = Field(
|
|
90
|
+
default=5,
|
|
91
|
+
description="Maximum number of queries to keep per cluster (FIFO)",
|
|
92
|
+
ge=1,
|
|
93
|
+
le=20
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class SirchmunkConfig(BaseModel):
|
|
98
|
+
"""Configuration for Sirchmunk service."""
|
|
99
|
+
|
|
100
|
+
work_path: Path = Field(
|
|
101
|
+
default_factory=lambda: Path.home() / ".sirchmunk",
|
|
102
|
+
description="Working directory for Sirchmunk data"
|
|
103
|
+
)
|
|
104
|
+
verbose: bool = Field(
|
|
105
|
+
default=False,
|
|
106
|
+
description="Enable verbose logging"
|
|
107
|
+
)
|
|
108
|
+
enable_cluster_reuse: bool = Field(
|
|
109
|
+
default=True,
|
|
110
|
+
description="Enable knowledge cluster reuse with embeddings"
|
|
111
|
+
)
|
|
112
|
+
cluster_similarity: ClusterSimilarityConfig = Field(
|
|
113
|
+
default_factory=ClusterSimilarityConfig
|
|
114
|
+
)
|
|
115
|
+
search_defaults: SearchDefaultsConfig = Field(
|
|
116
|
+
default_factory=SearchDefaultsConfig
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
@field_validator("work_path")
|
|
120
|
+
@classmethod
|
|
121
|
+
def validate_work_path(cls, v: Path) -> Path:
|
|
122
|
+
"""Ensure work path is absolute and exists."""
|
|
123
|
+
v = v.expanduser().resolve()
|
|
124
|
+
v.mkdir(parents=True, exist_ok=True)
|
|
125
|
+
return v
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class MCPServerConfig(BaseModel):
|
|
129
|
+
"""Configuration for MCP server."""
|
|
130
|
+
|
|
131
|
+
server_name: str = Field(
|
|
132
|
+
default="sirchmunk",
|
|
133
|
+
description="MCP server name"
|
|
134
|
+
)
|
|
135
|
+
server_version: str = Field(
|
|
136
|
+
default="0.1.0",
|
|
137
|
+
description="MCP server version"
|
|
138
|
+
)
|
|
139
|
+
log_level: str = Field(
|
|
140
|
+
default="INFO",
|
|
141
|
+
description="Logging level"
|
|
142
|
+
)
|
|
143
|
+
transport: str = Field(
|
|
144
|
+
default="stdio",
|
|
145
|
+
description="MCP transport protocol (stdio or http)"
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
# HTTP-specific settings
|
|
149
|
+
host: str = Field(
|
|
150
|
+
default="localhost",
|
|
151
|
+
description="Host for HTTP transport"
|
|
152
|
+
)
|
|
153
|
+
port: int = Field(
|
|
154
|
+
default=8080,
|
|
155
|
+
description="Port for HTTP transport",
|
|
156
|
+
ge=1024,
|
|
157
|
+
le=65535
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
@field_validator("transport")
|
|
161
|
+
@classmethod
|
|
162
|
+
def validate_transport(cls, v: str) -> str:
|
|
163
|
+
"""Validate transport protocol."""
|
|
164
|
+
v = v.lower()
|
|
165
|
+
if v not in ("stdio", "http"):
|
|
166
|
+
raise ValueError(f"Invalid transport: {v}. Must be 'stdio' or 'http'")
|
|
167
|
+
return v
|
|
168
|
+
|
|
169
|
+
@field_validator("log_level")
|
|
170
|
+
@classmethod
|
|
171
|
+
def validate_log_level(cls, v: str) -> str:
|
|
172
|
+
"""Validate log level."""
|
|
173
|
+
v = v.upper()
|
|
174
|
+
valid_levels = ("DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL")
|
|
175
|
+
if v not in valid_levels:
|
|
176
|
+
raise ValueError(f"Invalid log level: {v}. Must be one of {valid_levels}")
|
|
177
|
+
return v
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
class Config(BaseModel):
|
|
181
|
+
"""Master configuration for Sirchmunk MCP Server."""
|
|
182
|
+
|
|
183
|
+
llm: LLMConfig
|
|
184
|
+
sirchmunk: SirchmunkConfig = Field(default_factory=SirchmunkConfig)
|
|
185
|
+
mcp: MCPServerConfig = Field(default_factory=MCPServerConfig)
|
|
186
|
+
|
|
187
|
+
@classmethod
|
|
188
|
+
def from_env(cls) -> "Config":
|
|
189
|
+
"""Load configuration from environment variables.
|
|
190
|
+
|
|
191
|
+
Automatically loads .mcp_env file from work_path (~/.sirchmunk/.mcp_env by default).
|
|
192
|
+
|
|
193
|
+
Environment variables:
|
|
194
|
+
LLM_BASE_URL: LLM API base URL
|
|
195
|
+
LLM_API_KEY: LLM API key (required)
|
|
196
|
+
LLM_MODEL_NAME: LLM model name
|
|
197
|
+
SIRCHMUNK_WORK_PATH: Sirchmunk working directory
|
|
198
|
+
SIRCHMUNK_VERBOSE: Enable verbose logging
|
|
199
|
+
SIRCHMUNK_ENABLE_CLUSTER_REUSE: Enable cluster reuse
|
|
200
|
+
CLUSTER_SIM_THRESHOLD: Similarity threshold
|
|
201
|
+
CLUSTER_SIM_TOP_K: Top-K similar clusters
|
|
202
|
+
DEFAULT_MAX_DEPTH: Default max directory depth
|
|
203
|
+
DEFAULT_TOP_K_FILES: Default top-K files
|
|
204
|
+
DEFAULT_KEYWORD_LEVELS: Default keyword levels
|
|
205
|
+
GREP_TIMEOUT: Grep operation timeout
|
|
206
|
+
MAX_QUERIES_PER_CLUSTER: Max queries per cluster
|
|
207
|
+
MCP_SERVER_NAME: MCP server name
|
|
208
|
+
MCP_SERVER_VERSION: MCP server version
|
|
209
|
+
MCP_LOG_LEVEL: Logging level
|
|
210
|
+
MCP_TRANSPORT: MCP transport protocol
|
|
211
|
+
MCP_HOST: MCP server host (HTTP mode)
|
|
212
|
+
MCP_PORT: MCP server port (HTTP mode)
|
|
213
|
+
|
|
214
|
+
Returns:
|
|
215
|
+
Config: Loaded configuration
|
|
216
|
+
|
|
217
|
+
Raises:
|
|
218
|
+
ValueError: If required configuration is missing or invalid
|
|
219
|
+
"""
|
|
220
|
+
# Load .mcp_env from work_path (default: ~/.sirchmunk/.mcp_env)
|
|
221
|
+
work_path = Path(os.getenv("SIRCHMUNK_WORK_PATH", str(Path.home() / ".sirchmunk")))
|
|
222
|
+
work_path = work_path.expanduser().resolve()
|
|
223
|
+
env_file = work_path / ".mcp_env"
|
|
224
|
+
|
|
225
|
+
if env_file.exists():
|
|
226
|
+
load_dotenv(env_file, override=False)
|
|
227
|
+
|
|
228
|
+
# LLM configuration
|
|
229
|
+
llm_config = LLMConfig(
|
|
230
|
+
base_url=os.getenv("LLM_BASE_URL", "https://api.openai.com/v1"),
|
|
231
|
+
api_key=os.getenv("LLM_API_KEY", ""),
|
|
232
|
+
model_name=os.getenv("LLM_MODEL_NAME", "gpt-5.2"),
|
|
233
|
+
timeout=float(os.getenv("LLM_TIMEOUT", "60.0")),
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
# Sirchmunk configuration
|
|
237
|
+
sirchmunk_config = SirchmunkConfig(
|
|
238
|
+
work_path=Path(os.getenv("SIRCHMUNK_WORK_PATH", str(Path.home() / ".sirchmunk"))),
|
|
239
|
+
verbose=os.getenv("SIRCHMUNK_VERBOSE", "false").lower() == "true",
|
|
240
|
+
enable_cluster_reuse=os.getenv("SIRCHMUNK_ENABLE_CLUSTER_REUSE", "true").lower() == "true",
|
|
241
|
+
cluster_similarity=ClusterSimilarityConfig(
|
|
242
|
+
threshold=float(os.getenv("CLUSTER_SIM_THRESHOLD", "0.85")),
|
|
243
|
+
top_k=int(os.getenv("CLUSTER_SIM_TOP_K", "3")),
|
|
244
|
+
),
|
|
245
|
+
search_defaults=SearchDefaultsConfig(
|
|
246
|
+
max_depth=int(os.getenv("DEFAULT_MAX_DEPTH", "5")),
|
|
247
|
+
top_k_files=int(os.getenv("DEFAULT_TOP_K_FILES", "3")),
|
|
248
|
+
keyword_levels=int(os.getenv("DEFAULT_KEYWORD_LEVELS", "3")),
|
|
249
|
+
grep_timeout=float(os.getenv("GREP_TIMEOUT", "60.0")),
|
|
250
|
+
max_queries_per_cluster=int(os.getenv("MAX_QUERIES_PER_CLUSTER", "5")),
|
|
251
|
+
),
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
# MCP server configuration
|
|
255
|
+
mcp_config = MCPServerConfig(
|
|
256
|
+
server_name=os.getenv("MCP_SERVER_NAME", "sirchmunk"),
|
|
257
|
+
server_version=os.getenv("MCP_SERVER_VERSION", "0.1.0"),
|
|
258
|
+
log_level=os.getenv("MCP_LOG_LEVEL", "INFO"),
|
|
259
|
+
transport=os.getenv("MCP_TRANSPORT", "stdio"),
|
|
260
|
+
host=os.getenv("MCP_HOST", "localhost"),
|
|
261
|
+
port=int(os.getenv("MCP_PORT", "8080")),
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
return cls(
|
|
265
|
+
llm=llm_config,
|
|
266
|
+
sirchmunk=sirchmunk_config,
|
|
267
|
+
mcp=mcp_config,
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
271
|
+
"""Convert configuration to dictionary.
|
|
272
|
+
|
|
273
|
+
Returns:
|
|
274
|
+
Dict[str, Any]: Configuration dictionary
|
|
275
|
+
"""
|
|
276
|
+
return self.model_dump()
|
sirchmunk_mcp/server.py
ADDED
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
# Copyright (c) ModelScope Contributors. All rights reserved.
|
|
2
|
+
"""
|
|
3
|
+
MCP Server implementation for Sirchmunk using FastMCP.
|
|
4
|
+
|
|
5
|
+
Provides the main MCP server that exposes Sirchmunk functionality
|
|
6
|
+
as MCP tools following the Model Context Protocol specification.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import logging
|
|
11
|
+
from typing import Any, Dict, List, Optional
|
|
12
|
+
|
|
13
|
+
from mcp.server.fastmcp import FastMCP
|
|
14
|
+
|
|
15
|
+
from .config import Config
|
|
16
|
+
from .service import SirchmunkService
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
# Global service instance (initialized when server starts)
|
|
22
|
+
_service: Optional[SirchmunkService] = None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def create_server(config: Config) -> FastMCP:
|
|
26
|
+
"""Create and configure FastMCP server instance.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
config: Configuration object
|
|
30
|
+
|
|
31
|
+
Returns:
|
|
32
|
+
Configured FastMCP server instance
|
|
33
|
+
"""
|
|
34
|
+
global _service
|
|
35
|
+
|
|
36
|
+
# Initialize service
|
|
37
|
+
_service = SirchmunkService(config)
|
|
38
|
+
|
|
39
|
+
# Create FastMCP server
|
|
40
|
+
mcp = FastMCP(
|
|
41
|
+
name=config.mcp.server_name,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
logger.info(
|
|
45
|
+
f"Creating MCP server: {config.mcp.server_name} v{config.mcp.server_version}"
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
# Register tools using decorators
|
|
49
|
+
@mcp.tool()
|
|
50
|
+
async def sirchmunk_search(
|
|
51
|
+
query: str,
|
|
52
|
+
search_paths: List[str],
|
|
53
|
+
mode: str = "DEEP",
|
|
54
|
+
max_depth: int = 5,
|
|
55
|
+
top_k_files: int = 3,
|
|
56
|
+
keyword_levels: int = 3,
|
|
57
|
+
include: Optional[List[str]] = None,
|
|
58
|
+
exclude: Optional[List[str]] = None,
|
|
59
|
+
return_cluster: bool = False,
|
|
60
|
+
) -> str:
|
|
61
|
+
"""Intelligent code and document search with multi-mode support.
|
|
62
|
+
|
|
63
|
+
DEEP mode provides comprehensive knowledge extraction with full context analysis.
|
|
64
|
+
FILENAME_ONLY mode performs fast filename pattern matching without content search.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
query: Search query or question (e.g., 'How does authentication work?')
|
|
68
|
+
search_paths: Paths to search in (files or directories)
|
|
69
|
+
mode: Search mode - DEEP (comprehensive, 10-30s) or FILENAME_ONLY (fast, <1s)
|
|
70
|
+
max_depth: Maximum directory depth to search (1-20, default: 5)
|
|
71
|
+
top_k_files: Number of top files to return (1-20, default: 3)
|
|
72
|
+
keyword_levels: Keyword granularity levels for DEEP mode (1-5, default: 3)
|
|
73
|
+
include: File patterns to include (glob, e.g., ['*.py', '*.md'])
|
|
74
|
+
exclude: File patterns to exclude (glob, e.g., ['*.pyc', '*.log'])
|
|
75
|
+
return_cluster: Return full KnowledgeCluster object (DEEP mode only)
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
Search results as formatted text
|
|
79
|
+
"""
|
|
80
|
+
if _service is None:
|
|
81
|
+
return "Error: Service not initialized"
|
|
82
|
+
|
|
83
|
+
logger.info(f"sirchmunk_search: mode={mode}, query='{query[:50]}...'")
|
|
84
|
+
|
|
85
|
+
try:
|
|
86
|
+
result = await _service.searcher.search(
|
|
87
|
+
query=query,
|
|
88
|
+
search_paths=search_paths,
|
|
89
|
+
mode=mode,
|
|
90
|
+
max_depth=max_depth,
|
|
91
|
+
top_k_files=top_k_files,
|
|
92
|
+
keyword_levels=keyword_levels,
|
|
93
|
+
include=include,
|
|
94
|
+
exclude=exclude,
|
|
95
|
+
return_cluster=return_cluster,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
if result is None:
|
|
99
|
+
return f"No results found for query: {query}"
|
|
100
|
+
|
|
101
|
+
if isinstance(result, str):
|
|
102
|
+
return result
|
|
103
|
+
|
|
104
|
+
if isinstance(result, list):
|
|
105
|
+
# FILENAME_ONLY mode returns list of file matches
|
|
106
|
+
return _format_filename_results(result, query)
|
|
107
|
+
|
|
108
|
+
if hasattr(result, "__str__"):
|
|
109
|
+
return str(result)
|
|
110
|
+
|
|
111
|
+
return str(result)
|
|
112
|
+
|
|
113
|
+
except Exception as e:
|
|
114
|
+
logger.error(f"Search failed: {e}", exc_info=True)
|
|
115
|
+
return f"Search failed: {str(e)}"
|
|
116
|
+
|
|
117
|
+
@mcp.tool()
|
|
118
|
+
async def sirchmunk_get_cluster(cluster_id: str) -> str:
|
|
119
|
+
"""Retrieve a previously saved knowledge cluster by its ID.
|
|
120
|
+
|
|
121
|
+
Knowledge clusters are automatically saved during DEEP mode searches
|
|
122
|
+
and contain rich information including evidences, patterns, and constraints.
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
cluster_id: Knowledge cluster ID (e.g., 'C1007')
|
|
126
|
+
|
|
127
|
+
Returns:
|
|
128
|
+
Full cluster information or error message
|
|
129
|
+
"""
|
|
130
|
+
if _service is None:
|
|
131
|
+
return "Error: Service not initialized"
|
|
132
|
+
|
|
133
|
+
logger.info(f"sirchmunk_get_cluster: cluster_id={cluster_id}")
|
|
134
|
+
|
|
135
|
+
try:
|
|
136
|
+
cluster = await _service.get_cluster(cluster_id)
|
|
137
|
+
|
|
138
|
+
if cluster is None:
|
|
139
|
+
return f"Cluster not found: {cluster_id}"
|
|
140
|
+
|
|
141
|
+
return str(cluster)
|
|
142
|
+
|
|
143
|
+
except Exception as e:
|
|
144
|
+
logger.error(f"Get cluster failed: {e}", exc_info=True)
|
|
145
|
+
return f"Failed to retrieve cluster: {str(e)}"
|
|
146
|
+
|
|
147
|
+
@mcp.tool()
|
|
148
|
+
async def sirchmunk_list_clusters(
|
|
149
|
+
limit: int = 10,
|
|
150
|
+
sort_by: str = "last_modified",
|
|
151
|
+
) -> str:
|
|
152
|
+
"""List all saved knowledge clusters with optional filtering and sorting.
|
|
153
|
+
|
|
154
|
+
Useful for discovering previously searched topics and reusing knowledge.
|
|
155
|
+
|
|
156
|
+
Args:
|
|
157
|
+
limit: Maximum number of clusters to return (1-100, default: 10)
|
|
158
|
+
sort_by: Sort field - hotness, confidence, or last_modified (default)
|
|
159
|
+
|
|
160
|
+
Returns:
|
|
161
|
+
List of cluster metadata
|
|
162
|
+
"""
|
|
163
|
+
if _service is None:
|
|
164
|
+
return "Error: Service not initialized"
|
|
165
|
+
|
|
166
|
+
logger.info(f"sirchmunk_list_clusters: limit={limit}, sort_by={sort_by}")
|
|
167
|
+
|
|
168
|
+
try:
|
|
169
|
+
clusters = await _service.list_clusters(limit=limit, sort_by=sort_by)
|
|
170
|
+
|
|
171
|
+
if not clusters:
|
|
172
|
+
return "No knowledge clusters found."
|
|
173
|
+
|
|
174
|
+
return _format_cluster_list(clusters, sort_by)
|
|
175
|
+
|
|
176
|
+
except Exception as e:
|
|
177
|
+
logger.error(f"List clusters failed: {e}", exc_info=True)
|
|
178
|
+
return f"Failed to list clusters: {str(e)}"
|
|
179
|
+
|
|
180
|
+
return mcp
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _format_filename_results(results: List[Dict[str, Any]], query: str) -> str:
|
|
184
|
+
"""Format FILENAME_ONLY mode results.
|
|
185
|
+
|
|
186
|
+
Args:
|
|
187
|
+
results: List of filename match dictionaries
|
|
188
|
+
query: Original query
|
|
189
|
+
|
|
190
|
+
Returns:
|
|
191
|
+
Formatted string representation
|
|
192
|
+
"""
|
|
193
|
+
lines = [
|
|
194
|
+
f"# Filename Search Results",
|
|
195
|
+
f"",
|
|
196
|
+
f"**Query**: `{query}`",
|
|
197
|
+
f"**Found**: {len(results)} matching file(s)",
|
|
198
|
+
f"",
|
|
199
|
+
]
|
|
200
|
+
|
|
201
|
+
for i, result in enumerate(results, 1):
|
|
202
|
+
lines.append(f"## {i}. {result.get('filename', 'unknown')}")
|
|
203
|
+
lines.append(f"- **Path**: `{result.get('path', 'unknown')}`")
|
|
204
|
+
if 'match_score' in result:
|
|
205
|
+
lines.append(f"- **Relevance**: {result['match_score']:.2f}")
|
|
206
|
+
if "matched_pattern" in result:
|
|
207
|
+
lines.append(f"- **Pattern**: `{result['matched_pattern']}`")
|
|
208
|
+
lines.append("")
|
|
209
|
+
|
|
210
|
+
return "\n".join(lines)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _format_cluster_list(clusters: List[Dict[str, Any]], sort_by: str) -> str:
|
|
214
|
+
"""Format cluster list.
|
|
215
|
+
|
|
216
|
+
Args:
|
|
217
|
+
clusters: List of cluster metadata dictionaries
|
|
218
|
+
sort_by: Sort field used
|
|
219
|
+
|
|
220
|
+
Returns:
|
|
221
|
+
Formatted string representation
|
|
222
|
+
"""
|
|
223
|
+
lines = [
|
|
224
|
+
f"# Knowledge Clusters",
|
|
225
|
+
f"",
|
|
226
|
+
f"**Total**: {len(clusters)} cluster(s)",
|
|
227
|
+
f"**Sorted by**: {sort_by}",
|
|
228
|
+
f"",
|
|
229
|
+
]
|
|
230
|
+
|
|
231
|
+
for i, cluster in enumerate(clusters, 1):
|
|
232
|
+
lines.append(f"## {i}. {cluster.get('name', 'Unnamed')}")
|
|
233
|
+
lines.append(f"- **ID**: `{cluster.get('id', 'unknown')}`")
|
|
234
|
+
lines.append(f"- **Lifecycle**: {cluster.get('lifecycle', 'unknown')}")
|
|
235
|
+
lines.append(f"- **Version**: {cluster.get('version', 0)}")
|
|
236
|
+
|
|
237
|
+
if cluster.get('confidence') is not None:
|
|
238
|
+
lines.append(f"- **Confidence**: {cluster['confidence']:.2f}")
|
|
239
|
+
|
|
240
|
+
if cluster.get('hotness') is not None:
|
|
241
|
+
lines.append(f"- **Hotness**: {cluster['hotness']:.2f}")
|
|
242
|
+
|
|
243
|
+
if cluster.get('last_modified'):
|
|
244
|
+
lines.append(f"- **Last Modified**: {cluster['last_modified']}")
|
|
245
|
+
|
|
246
|
+
if cluster.get('queries'):
|
|
247
|
+
queries_preview = ", ".join(f'"{q}"' for q in cluster['queries'][:3])
|
|
248
|
+
if len(cluster['queries']) > 3:
|
|
249
|
+
queries_preview += f" (+{len(cluster['queries']) - 3} more)"
|
|
250
|
+
lines.append(f"- **Related Queries**: {queries_preview}")
|
|
251
|
+
|
|
252
|
+
lines.append(f"- **Evidences**: {cluster.get('evidences_count', 0)}")
|
|
253
|
+
lines.append("")
|
|
254
|
+
|
|
255
|
+
return "\n".join(lines)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
async def run_stdio_server(config: Config) -> None:
|
|
259
|
+
"""Run MCP server with stdio transport.
|
|
260
|
+
|
|
261
|
+
This is the default transport mode for Claude Desktop and other
|
|
262
|
+
MCP clients that communicate via standard input/output.
|
|
263
|
+
|
|
264
|
+
Args:
|
|
265
|
+
config: Configuration object
|
|
266
|
+
|
|
267
|
+
Note:
|
|
268
|
+
This mode should be launched by an MCP client, not run directly
|
|
269
|
+
in an interactive terminal. Manual terminal input will cause
|
|
270
|
+
JSON parsing errors.
|
|
271
|
+
"""
|
|
272
|
+
logger.info("Starting MCP server with stdio transport")
|
|
273
|
+
|
|
274
|
+
# Create server
|
|
275
|
+
mcp = create_server(config)
|
|
276
|
+
|
|
277
|
+
# Run with stdio transport
|
|
278
|
+
logger.info("MCP server listening on stdio")
|
|
279
|
+
logger.info("Waiting for MCP client connection...")
|
|
280
|
+
|
|
281
|
+
await mcp.run_stdio_async()
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
async def run_http_server(config: Config) -> None:
|
|
285
|
+
"""Run MCP server with Streamable HTTP transport.
|
|
286
|
+
|
|
287
|
+
This transport mode runs an HTTP server that communicates via
|
|
288
|
+
HTTP with streaming support, suitable for web-based clients.
|
|
289
|
+
|
|
290
|
+
Args:
|
|
291
|
+
config: Configuration object
|
|
292
|
+
|
|
293
|
+
Note:
|
|
294
|
+
HTTP transport requires uvicorn to be installed.
|
|
295
|
+
"""
|
|
296
|
+
logger.info(
|
|
297
|
+
f"Starting MCP server with HTTP transport on {config.mcp.host}:{config.mcp.port}"
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
# Create server
|
|
301
|
+
mcp = create_server(config)
|
|
302
|
+
|
|
303
|
+
# Run with HTTP transport using uvicorn
|
|
304
|
+
try:
|
|
305
|
+
import uvicorn
|
|
306
|
+
uvicorn.run(
|
|
307
|
+
mcp.sse_app(),
|
|
308
|
+
host=config.mcp.host,
|
|
309
|
+
port=config.mcp.port,
|
|
310
|
+
log_level="info",
|
|
311
|
+
)
|
|
312
|
+
except ImportError:
|
|
313
|
+
raise RuntimeError(
|
|
314
|
+
"HTTP transport requires uvicorn. Install with: pip install uvicorn"
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
async def main() -> None:
|
|
319
|
+
"""Main entry point for MCP server.
|
|
320
|
+
|
|
321
|
+
Loads configuration and starts the appropriate transport server.
|
|
322
|
+
"""
|
|
323
|
+
# Configure logging
|
|
324
|
+
logging.basicConfig(
|
|
325
|
+
level=logging.INFO,
|
|
326
|
+
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
try:
|
|
330
|
+
# Load configuration from environment
|
|
331
|
+
config = Config.from_env()
|
|
332
|
+
|
|
333
|
+
# Set log level from config
|
|
334
|
+
logging.getLogger().setLevel(config.mcp.log_level)
|
|
335
|
+
|
|
336
|
+
logger.info(f"Loaded configuration: transport={config.mcp.transport}")
|
|
337
|
+
|
|
338
|
+
# Start appropriate transport server
|
|
339
|
+
if config.mcp.transport == "stdio":
|
|
340
|
+
await run_stdio_server(config)
|
|
341
|
+
elif config.mcp.transport == "http":
|
|
342
|
+
await run_http_server(config)
|
|
343
|
+
else:
|
|
344
|
+
raise ValueError(f"Unknown transport: {config.mcp.transport}")
|
|
345
|
+
|
|
346
|
+
except KeyboardInterrupt:
|
|
347
|
+
logger.info("Received interrupt signal, shutting down")
|
|
348
|
+
|
|
349
|
+
except Exception as e:
|
|
350
|
+
logger.error(f"Server error: {e}", exc_info=True)
|
|
351
|
+
raise
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
if __name__ == "__main__":
|
|
355
|
+
asyncio.run(main())
|