fenix-mcp 1.14.0__py3-none-any.whl → 2.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.
@@ -5,7 +5,7 @@ from __future__ import annotations
5
5
 
6
6
  import asyncio
7
7
  from dataclasses import dataclass
8
- from typing import Any, Dict, Iterable, List, Optional
8
+ from typing import Any, Dict, List, Optional
9
9
 
10
10
  from fenix_mcp.infrastructure.fenix_api.client import FenixApiClient
11
11
 
@@ -15,270 +15,80 @@ class IntelligenceService:
15
15
  api: FenixApiClient
16
16
  logger: Any
17
17
 
18
- async def smart_create_memory(
18
+ async def save_memory(
19
19
  self,
20
20
  *,
21
21
  title: str,
22
22
  content: str,
23
- metadata: str,
24
- context: Optional[str],
25
- source: str,
26
- importance: str,
27
23
  tags: List[str],
24
+ documentation_item_id: Optional[str] = None,
25
+ work_item_id: Optional[str] = None,
26
+ sprint_id: Optional[str] = None,
28
27
  ) -> Dict[str, Any]:
29
- # Validate required parameters
30
- if not metadata or not metadata.strip():
31
- raise ValueError("metadata is required and cannot be empty")
32
-
33
- if not source or not source.strip():
34
- raise ValueError("source is required and cannot be empty")
35
-
28
+ """
29
+ Smart save memory - creates or updates based on semantic similarity.
30
+
31
+ Required:
32
+ - title: Memory title
33
+ - content: Memory content
34
+ - tags: List of tags for categorization
35
+
36
+ Optional:
37
+ - documentation_item_id: Related documentation
38
+ - work_item_id: Related work item
39
+ - sprint_id: Related sprint
40
+ """
41
+ if not title or not title.strip():
42
+ raise ValueError("title is required")
43
+ if not content or not content.strip():
44
+ raise ValueError("content is required")
36
45
  if not tags or not isinstance(tags, list) or len(tags) == 0:
37
- raise ValueError("tags is required and must be a non-empty List[str]")
38
-
39
- # Validate all tags are strings
40
- for i, tag in enumerate(tags):
41
- if not isinstance(tag, str) or not tag.strip():
42
- raise ValueError(
43
- f"All tags must be non-empty strings, got {type(tag)} at index {i}"
44
- )
46
+ raise ValueError("tags is required and must be a non-empty list")
45
47
 
46
- importance_value = importance or "medium"
47
- metadata_str = build_metadata(
48
- metadata,
49
- importance=importance_value,
50
- tags=tags,
51
- context=context,
52
- source=source,
53
- )
54
48
  payload = {
55
- "title": title,
56
- "content": content,
57
- "metadata": metadata_str,
58
- "priority_score": _importance_to_priority(importance_value),
59
- "tags": tags,
49
+ "title": title.strip(),
50
+ "content": content.strip(),
51
+ "tags": [t.strip() for t in tags if t.strip()],
60
52
  }
61
- return await self._call(self.api.smart_create_memory, _strip_none(payload))
62
53
 
63
- async def query_memories(self, **filters: Any) -> List[Dict[str, Any]]:
64
- params = _strip_none(filters)
65
- include_content = _coerce_bool(
66
- params.pop("include_content", params.pop("content", None))
67
- )
68
- include_metadata = _coerce_bool(
69
- params.pop("include_metadata", params.pop("metadata", None))
70
- )
71
- allowed_keys = {
72
- "limit",
73
- "offset",
74
- "query",
75
- "tags",
76
- "modeId",
77
- "ruleId",
78
- "workItemId",
79
- "sprintId",
80
- "documentationItemId",
81
- "importance",
82
- }
83
- cleaned_params = {key: params[key] for key in allowed_keys if key in params}
84
- return (
85
- await self._call(
86
- self.api.list_memories,
87
- include_content=include_content,
88
- include_metadata=include_metadata,
89
- **cleaned_params,
90
- )
91
- or []
92
- )
93
-
94
- async def similar_memories(
95
- self, *, content: str, threshold: float, max_results: int
96
- ) -> List[Dict[str, Any]]:
97
- payload = {
98
- "content": content,
99
- "threshold": threshold,
100
- }
101
- result = (
102
- await self._call(self.api.find_similar_memories, _strip_none(payload)) or []
103
- )
104
- if isinstance(result, list) and max_results:
105
- return result[:max_results]
106
- return result
54
+ if documentation_item_id:
55
+ payload["documentationItemId"] = documentation_item_id
56
+ if work_item_id:
57
+ payload["workItemId"] = work_item_id
58
+ if sprint_id:
59
+ payload["sprintId"] = sprint_id
107
60
 
108
- async def consolidate_memories(
109
- self, *, memory_ids: Iterable[str], strategy: str
110
- ) -> Dict[str, Any]:
111
- payload = {
112
- "memoryIds": list(memory_ids),
113
- "strategy": strategy,
114
- }
115
- return await self._call(self.api.consolidate_memories, payload)
61
+ return await self._call(self.api.save_memory, payload)
116
62
 
117
- async def update_memory(self, memory_id: str, **fields: Any) -> Dict[str, Any]:
118
- payload = _strip_none(fields)
119
- if "importance" in payload:
120
- payload["priority_score"] = _importance_to_priority(
121
- payload.pop("importance")
122
- )
123
- mapping = {
124
- "documentation_item_id": "documentationItemId",
125
- "mode_id": "modeId",
126
- "rule_id": "ruleId",
127
- "work_item_id": "workItemId",
128
- "sprint_id": "sprintId",
129
- }
130
- for old_key, new_key in mapping.items():
131
- if old_key in payload:
132
- payload[new_key] = payload.pop(old_key)
133
- return await self._call(self.api.update_memory, memory_id, payload)
134
-
135
- async def delete_memory(self, memory_id: str) -> None:
136
- await self._call(self.api.delete_memory, memory_id)
137
-
138
- async def _call(self, func, *args, **kwargs):
139
- return await asyncio.to_thread(func, *args, **kwargs)
140
-
141
- async def get_memory(
63
+ async def search_memories(
142
64
  self,
143
- memory_id: str,
144
65
  *,
145
- include_content: bool = False,
146
- include_metadata: bool = False,
147
- ) -> Dict[str, Any]:
148
- return await self._call(
149
- self.api.get_memory,
150
- memory_id,
151
- include_content=include_content,
152
- include_metadata=include_metadata,
153
- )
154
-
155
-
156
- def _importance_to_priority(importance: Optional[str]) -> float:
157
- mapping = {
158
- "low": 0.2,
159
- "medium": 0.5,
160
- "high": 0.7,
161
- "critical": 0.9,
162
- }
163
- if importance is None:
164
- return 0.5
165
- return mapping.get(importance.lower(), 0.5)
166
-
167
-
168
- def _coerce_bool(value: Any, default: bool = False) -> bool:
169
- if value is None or value == "":
170
- return default
171
- if isinstance(value, bool):
172
- return value
173
- if isinstance(value, (int, float)):
174
- return bool(value)
175
- if isinstance(value, str):
176
- normalized = value.strip().lower()
177
- if normalized in {"true", "1", "yes", "y"}:
178
- return True
179
- if normalized in {"false", "0", "no", "n"}:
180
- return False
181
- return bool(value)
182
-
183
-
184
- def _strip_none(data: Dict[str, Any]) -> Dict[str, Any]:
185
- return {key: value for key, value in data.items() if value not in (None, "")}
186
-
187
-
188
- def build_metadata(
189
- explicit: str,
190
- *,
191
- importance: Optional[str],
192
- tags: List[str],
193
- context: Optional[str] = None,
194
- source: str,
195
- existing: Optional[str] = None,
196
- ) -> str:
197
- if explicit and explicit.strip():
198
- return explicit.strip()
199
-
200
- existing_map = _parse_metadata(existing) if existing else {}
201
- metadata_map: Dict[str, str] = {}
202
-
203
- metadata_map["t"] = existing_map.get("t", "memory")
204
- metadata_map["src"] = _slugify(source) if source else existing_map.get("src", "mcp")
205
-
206
- ctx_value = _slugify(context) if context else existing_map.get("ctx")
207
- if ctx_value:
208
- metadata_map["ctx"] = ctx_value
209
-
210
- priority_key = importance.lower() if importance else existing_map.get("p")
211
- if priority_key:
212
- metadata_map["p"] = priority_key
213
-
214
- tag_string = _format_tags(tags)
215
- if tag_string:
216
- metadata_map["tags"] = tag_string
217
- elif "tags" in existing_map:
218
- metadata_map["tags"] = existing_map["tags"]
219
-
220
- for key, value in existing_map.items():
221
- if key not in metadata_map:
222
- metadata_map[key] = value
223
-
224
- if not metadata_map:
225
- metadata_map["t"] = "memory"
226
- metadata_map["src"] = "mcp"
227
-
228
- return "|".join(f"{key}:{metadata_map[key]}" for key in metadata_map)
229
-
230
-
231
- def _parse_metadata(metadata: str) -> Dict[str, str]:
232
- items = {}
233
- for entry in metadata.split("|"):
234
- if ":" not in entry:
235
- continue
236
- key, value = entry.split(":", 1)
237
- key = key.strip()
238
- value = value.strip()
239
- if key:
240
- items[key] = value
241
- return items
242
-
243
-
244
- def _slugify(value: Optional[str]) -> str:
245
- if not value:
246
- return ""
247
- sanitized = value.replace("|", " ").replace(":", " ")
248
- return "-".join(part for part in sanitized.split() if part).lower()
249
-
250
-
251
- def _format_tags(tags: List[str]) -> str:
252
- """
253
- Format tags list into a comma-separated string for metadata.
254
-
255
- Args:
256
- tags: List of string tags (required)
66
+ query: str,
67
+ limit: int = 5,
68
+ tags: Optional[List[str]] = None,
69
+ ) -> List[Dict[str, Any]]:
70
+ """
71
+ Search memories using semantic similarity (embeddings).
257
72
 
258
- Returns:
259
- Comma-separated string of tags
73
+ Required:
74
+ - query: Natural language search query
260
75
 
261
- Raises:
262
- TypeError: If tags is not a List[str]
263
- ValueError: If tags is empty
264
- """
265
- if not isinstance(tags, list):
266
- raise TypeError(f"tags must be List[str], got {type(tags)}")
76
+ Optional:
77
+ - limit: Maximum results (default 5)
78
+ - tags: Filter by tags
79
+ """
80
+ if not query or not query.strip():
81
+ raise ValueError("query is required")
267
82
 
268
- if not tags:
269
- raise ValueError("tags cannot be empty")
83
+ payload = {
84
+ "query": query.strip(),
85
+ "limit": limit,
86
+ }
270
87
 
271
- # Clean and validate tags
272
- cleaned_tags = []
273
- for tag in tags:
274
- if isinstance(tag, str) and tag.strip():
275
- cleaned_tags.append(tag.strip())
276
- else:
277
- raise ValueError(
278
- f"All tags must be non-empty strings, got {type(tag)}: {tag}"
279
- )
88
+ if tags:
89
+ payload["tags"] = [t.strip() for t in tags if t.strip()]
280
90
 
281
- if not cleaned_tags:
282
- raise ValueError("No valid tags found after cleaning")
91
+ return await self._call(self.api.search_memories, payload) or []
283
92
 
284
- return ",".join(sorted(set(cleaned_tags)))
93
+ async def _call(self, func, *args, **kwargs):
94
+ return await asyncio.to_thread(func, *args, **kwargs)
@@ -466,6 +466,28 @@ class KnowledgeService:
466
466
  result = await self._call(self.api.export_rule, rule_id, format)
467
467
  return result if isinstance(result, str) else str(result or "")
468
468
 
469
+ # ------------------------------------------------------------------
470
+ # API Catalog
471
+ # ------------------------------------------------------------------
472
+ async def api_catalog_list(self, **filters: Any) -> List[Dict[str, Any]]:
473
+ return await self._call_list(self.api.list_api_catalog, **_strip_none(filters))
474
+
475
+ async def api_catalog_get(self, spec_id: str) -> Dict[str, Any]:
476
+ return await self._call_dict(self.api.get_api_catalog, spec_id)
477
+
478
+ async def api_catalog_search(
479
+ self,
480
+ *,
481
+ query: str,
482
+ limit: int = 10,
483
+ ) -> Dict[str, Any]:
484
+ result = await self._call(
485
+ self.api.search_api_catalog_semantic,
486
+ query=query,
487
+ limit=limit,
488
+ )
489
+ return _ensure_dict(result) if isinstance(result, dict) else {"data": result}
490
+
469
491
 
470
492
  __all__ = [
471
493
  "KnowledgeService",
@@ -228,9 +228,6 @@ class FenixApiClient:
228
228
  # Memories
229
229
  # ------------------------------------------------------------------
230
230
 
231
- def create_memory(self, payload: Mapping[str, Any]) -> Any:
232
- return self._request("POST", "/api/memories", json=payload)
233
-
234
231
  def list_memories(
235
232
  self,
236
233
  *,
@@ -259,30 +256,16 @@ class FenixApiClient:
259
256
  def update_memory(self, memory_id: str, payload: Mapping[str, Any]) -> Any:
260
257
  return self._request("PATCH", f"/api/memories/{memory_id}", json=payload)
261
258
 
262
- def delete_memory(self, memory_id: str) -> Any:
263
- return self._request("DELETE", f"/api/memories/{memory_id}")
264
-
265
- def list_memories_by_tags(self, *, tags: str) -> Any:
266
- params = self._build_params(required={"tags": tags})
267
- return self._request("GET", "/api/memories/tags", params=params)
268
-
269
259
  def record_memory_access(self, memory_id: str) -> Any:
270
260
  return self._request("POST", f"/api/memories/{memory_id}/access")
271
261
 
272
- def find_similar_memories(self, payload: Mapping[str, Any]) -> Any:
273
- return self._request(
274
- "POST", "/api/memory-intelligence/similarity", json=payload
275
- )
276
-
277
- def consolidate_memories(self, payload: Mapping[str, Any]) -> Any:
278
- return self._request(
279
- "POST", "/api/memory-intelligence/consolidate", json=payload
280
- )
262
+ def save_memory(self, payload: Mapping[str, Any]) -> Any:
263
+ """Smart save memory - creates or updates based on semantic similarity."""
264
+ return self._request("POST", "/api/memories/save", json=payload)
281
265
 
282
- def smart_create_memory(self, payload: Mapping[str, Any]) -> Any:
283
- return self._request(
284
- "POST", "/api/memory-intelligence/smart-create", json=payload
285
- )
266
+ def search_memories(self, payload: Mapping[str, Any]) -> Any:
267
+ """Search memories using semantic similarity (embeddings)."""
268
+ return self._request("POST", "/api/memories/search", json=payload)
286
269
 
287
270
  # ------------------------------------------------------------------
288
271
  # Knowledge: documentation
@@ -642,3 +625,105 @@ class FenixApiClient:
642
625
  def export_merged_rules(self, format: str) -> Any:
643
626
  params = self._build_params(required={"format": format})
644
627
  return self._request("GET", "/api/rules/export/merged", params=params)
628
+
629
+ # ------------------------------------------------------------------
630
+ # API Catalog
631
+ # ------------------------------------------------------------------
632
+
633
+ def list_api_catalog(self, **filters: Any) -> Any:
634
+ """List API specifications with optional filters."""
635
+ return self._request("GET", "/api/api-catalog", params=_strip_none(filters))
636
+
637
+ def get_api_catalog(self, spec_id: str) -> Any:
638
+ """Get API specification details by ID."""
639
+ return self._request("GET", f"/api/api-catalog/{spec_id}")
640
+
641
+ def search_api_catalog_text(
642
+ self,
643
+ *,
644
+ query: str,
645
+ limit: int = 20,
646
+ offset: int = 0,
647
+ status: Optional[str] = None,
648
+ tags: Optional[List[str]] = None,
649
+ ) -> Any:
650
+ """Full-text search in API specifications."""
651
+ params = self._build_params(
652
+ required={"q": query},
653
+ optional={
654
+ "limit": limit,
655
+ "offset": offset,
656
+ "status": status,
657
+ "tags": ",".join(tags) if tags else None,
658
+ },
659
+ )
660
+ return self._request("GET", "/api/api-catalog/search/apis", params=params)
661
+
662
+ def search_api_catalog_endpoints_text(
663
+ self,
664
+ *,
665
+ query: str,
666
+ limit: int = 20,
667
+ offset: int = 0,
668
+ specification_id: Optional[str] = None,
669
+ method: Optional[str] = None,
670
+ ) -> Any:
671
+ """Full-text search in API endpoints."""
672
+ params = self._build_params(
673
+ required={"q": query},
674
+ optional={
675
+ "limit": limit,
676
+ "offset": offset,
677
+ "specificationId": specification_id,
678
+ "method": method,
679
+ },
680
+ )
681
+ return self._request("GET", "/api/api-catalog/search/endpoints", params=params)
682
+
683
+ def search_api_catalog_semantic(
684
+ self,
685
+ *,
686
+ query: str,
687
+ limit: int = 20,
688
+ offset: int = 0,
689
+ threshold: Optional[float] = None,
690
+ status: Optional[str] = None,
691
+ tags: Optional[List[str]] = None,
692
+ ) -> Any:
693
+ """Semantic search in API specifications using embeddings."""
694
+ params = self._build_params(
695
+ required={"q": query},
696
+ optional={
697
+ "limit": limit,
698
+ "offset": offset,
699
+ "threshold": threshold,
700
+ "status": status,
701
+ "tags": ",".join(tags) if tags else None,
702
+ },
703
+ )
704
+ return self._request("GET", "/api/api-catalog/semantic/apis", params=params)
705
+
706
+ def search_api_catalog_endpoints_semantic(
707
+ self,
708
+ *,
709
+ query: str,
710
+ limit: int = 20,
711
+ offset: int = 0,
712
+ threshold: Optional[float] = None,
713
+ specification_id: Optional[str] = None,
714
+ method: Optional[str] = None,
715
+ ) -> Any:
716
+ """Semantic search in API endpoints using embeddings."""
717
+ params = self._build_params(
718
+ required={"q": query},
719
+ optional={
720
+ "limit": limit,
721
+ "offset": offset,
722
+ "threshold": threshold,
723
+ "specificationId": specification_id,
724
+ "method": method,
725
+ },
726
+ )
727
+ return self._request(
728
+ "GET", "/api/api-catalog/semantic/endpoints", params=params
729
+ )
@@ -63,6 +63,18 @@ class SimpleMcpServer:
63
63
  # Notifications do not require a response
64
64
  return None
65
65
 
66
+ if method == "notifications/cancelled":
67
+ # Client cancelled a request - no response needed
68
+ return None
69
+
70
+ if method == "logging/setLevel":
71
+ # Acknowledge log level change request (we don't actually change anything)
72
+ return {
73
+ "jsonrpc": "2.0",
74
+ "id": request_id,
75
+ "result": {},
76
+ }
77
+
66
78
  raise McpServerError(f"Unsupported method: {method}")
67
79
 
68
80