fenix-mcp 1.12.0__py3-none-any.whl → 1.14.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.
- fenix_mcp/__init__.py +1 -1
- fenix_mcp/application/tools/knowledge.py +230 -252
- fenix_mcp/domain/knowledge.py +34 -117
- fenix_mcp/infrastructure/fenix_api/client.py +50 -99
- fenix_mcp/infrastructure/http_client.py +7 -0
- fenix_mcp/infrastructure/request_context.py +37 -0
- fenix_mcp/interface/transports.py +11 -1
- {fenix_mcp-1.12.0.dist-info → fenix_mcp-1.14.0.dist-info}/METADATA +1 -1
- {fenix_mcp-1.12.0.dist-info → fenix_mcp-1.14.0.dist-info}/RECORD +12 -11
- {fenix_mcp-1.12.0.dist-info → fenix_mcp-1.14.0.dist-info}/WHEEL +0 -0
- {fenix_mcp-1.12.0.dist-info → fenix_mcp-1.14.0.dist-info}/entry_points.txt +0 -0
- {fenix_mcp-1.12.0.dist-info → fenix_mcp-1.14.0.dist-info}/top_level.txt +0 -0
fenix_mcp/__init__.py
CHANGED
|
@@ -105,25 +105,39 @@ class KnowledgeAction(str, Enum):
|
|
|
105
105
|
"Lists work items linked to a sprint.",
|
|
106
106
|
)
|
|
107
107
|
|
|
108
|
-
# Modes
|
|
109
|
-
MODE_CREATE = ("mode_create", "Creates a mode with content and optional metadata.")
|
|
110
|
-
MODE_LIST = ("mode_list", "Lists registered modes.")
|
|
111
|
-
MODE_GET = ("mode_get", "Gets full details of a mode.")
|
|
112
|
-
MODE_UPDATE = ("mode_update", "Updates properties of an existing mode.")
|
|
113
|
-
MODE_DELETE = ("mode_delete", "Removes a mode.")
|
|
114
|
-
MODE_RULE_ADD = ("mode_rule_add", "Associates a rule with a mode.")
|
|
115
|
-
MODE_RULE_REMOVE = (
|
|
116
|
-
"mode_rule_remove",
|
|
117
|
-
"Removes the association of a rule with a mode.",
|
|
118
|
-
)
|
|
119
|
-
MODE_RULES = ("mode_rules", "Lists rules associated with a mode.")
|
|
120
|
-
|
|
121
108
|
# Rules
|
|
122
|
-
RULE_CREATE = (
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
109
|
+
RULE_CREATE = (
|
|
110
|
+
"rule_create",
|
|
111
|
+
"Creates a new rule. REQUIRED: rule_scope (personal|team|organization|marketplace), rule_name, rule_content. Optional: rule_description, rule_slug. If scope=team, rule_team_id is also required.",
|
|
112
|
+
)
|
|
113
|
+
RULE_LIST = (
|
|
114
|
+
"rule_list",
|
|
115
|
+
"Lists rules accessible to the current user. Optional filters: rule_scope, query, limit, offset.",
|
|
116
|
+
)
|
|
117
|
+
RULE_GET = (
|
|
118
|
+
"rule_get",
|
|
119
|
+
"Gets full details of a rule. REQUIRED: rule_id (or id).",
|
|
120
|
+
)
|
|
121
|
+
RULE_UPDATE = (
|
|
122
|
+
"rule_update",
|
|
123
|
+
"Updates an existing rule. REQUIRED: rule_id (or id). Optional: rule_name, rule_description, rule_content, rule_is_active.",
|
|
124
|
+
)
|
|
125
|
+
RULE_DELETE = (
|
|
126
|
+
"rule_delete",
|
|
127
|
+
"Deletes a rule. REQUIRED: rule_id (or id).",
|
|
128
|
+
)
|
|
129
|
+
RULE_MARKETPLACE = (
|
|
130
|
+
"rule_marketplace",
|
|
131
|
+
"Lists public marketplace rules. Optional filters: query, limit, offset.",
|
|
132
|
+
)
|
|
133
|
+
RULE_FORK = (
|
|
134
|
+
"rule_fork",
|
|
135
|
+
"Forks a rule to a new scope. REQUIRED: rule_id (or id), rule_scope (personal|team|organization). Optional: rule_name, rule_team_id (required if scope=team).",
|
|
136
|
+
)
|
|
137
|
+
RULE_EXPORT = (
|
|
138
|
+
"rule_export",
|
|
139
|
+
"Exports a rule to a specific format. REQUIRED: rule_id (or id), rule_export_format (cursor|claude|copilot|windsurf).",
|
|
140
|
+
)
|
|
127
141
|
|
|
128
142
|
# Documentation - Navigation workflow: doc_full_tree -> doc_children -> doc_get
|
|
129
143
|
DOC_CREATE = (
|
|
@@ -283,40 +297,6 @@ class KnowledgeRequest(ToolRequest):
|
|
|
283
297
|
),
|
|
284
298
|
)
|
|
285
299
|
|
|
286
|
-
# Mode fields
|
|
287
|
-
mode_id: Optional[UUIDStr] = Field(
|
|
288
|
-
default=None, description="Related mode ID (UUID)."
|
|
289
|
-
)
|
|
290
|
-
mode_name: Optional[TitleStr] = Field(default=None, description="Mode name.")
|
|
291
|
-
mode_description: Optional[DescriptionStr] = Field(
|
|
292
|
-
default=None, description="Mode description."
|
|
293
|
-
)
|
|
294
|
-
mode_content: Optional[MarkdownStr] = Field(
|
|
295
|
-
default=None, description="Mode content (Markdown)."
|
|
296
|
-
)
|
|
297
|
-
mode_is_default: Optional[bool] = Field(
|
|
298
|
-
default=None, description="Indicates if the mode is default."
|
|
299
|
-
)
|
|
300
|
-
mode_metadata: Optional[MarkdownStr] = Field(
|
|
301
|
-
default=None, description="Mode metadata for AI processing."
|
|
302
|
-
)
|
|
303
|
-
|
|
304
|
-
# Rule fields
|
|
305
|
-
rule_id: Optional[UUIDStr] = Field(
|
|
306
|
-
default=None, description="Related rule ID (UUID)."
|
|
307
|
-
)
|
|
308
|
-
rule_name: Optional[TitleStr] = Field(default=None, description="Rule name.")
|
|
309
|
-
rule_description: Optional[DescriptionStr] = Field(
|
|
310
|
-
default=None, description="Rule description."
|
|
311
|
-
)
|
|
312
|
-
rule_content: Optional[MarkdownStr] = Field(
|
|
313
|
-
default=None, description="Rule content (Markdown)."
|
|
314
|
-
)
|
|
315
|
-
rule_is_default: Optional[bool] = Field(default=None, description="Default rule.")
|
|
316
|
-
rule_metadata: Optional[MarkdownStr] = Field(
|
|
317
|
-
default=None, description="Rule metadata for AI processing."
|
|
318
|
-
)
|
|
319
|
-
|
|
320
300
|
# Documentation fields
|
|
321
301
|
doc_title: Optional[TitleStr] = Field(
|
|
322
302
|
default=None, description="Documentation title."
|
|
@@ -369,10 +349,39 @@ class KnowledgeRequest(ToolRequest):
|
|
|
369
349
|
default=None, description="Whether the document is public."
|
|
370
350
|
)
|
|
371
351
|
|
|
352
|
+
# Rule fields
|
|
353
|
+
rule_id: Optional[UUIDStr] = Field(default=None, description="Rule ID (UUID).")
|
|
354
|
+
rule_scope: Optional[str] = Field(
|
|
355
|
+
default=None,
|
|
356
|
+
description="Rule scope. Values: personal, team, organization, marketplace.",
|
|
357
|
+
)
|
|
358
|
+
rule_name: Optional[TitleStr] = Field(default=None, description="Rule name.")
|
|
359
|
+
rule_slug: Optional[str] = Field(
|
|
360
|
+
default=None, description="Rule slug (auto-generated if not provided)."
|
|
361
|
+
)
|
|
362
|
+
rule_description: Optional[DescriptionStr] = Field(
|
|
363
|
+
default=None, description="Rule description."
|
|
364
|
+
)
|
|
365
|
+
rule_content: Optional[MarkdownStr] = Field(
|
|
366
|
+
default=None, description=f"Rule content (Markdown).{MERMAID_HINT}"
|
|
367
|
+
)
|
|
368
|
+
rule_team_id: Optional[UUIDStr] = Field(
|
|
369
|
+
default=None, description="Team ID for team-scoped rules (UUID)."
|
|
370
|
+
)
|
|
371
|
+
rule_export_format: Optional[str] = Field(
|
|
372
|
+
default=None,
|
|
373
|
+
description="Export format. Values: cursor, claude, copilot, windsurf.",
|
|
374
|
+
)
|
|
375
|
+
rule_is_active: Optional[bool] = Field(
|
|
376
|
+
default=None, description="Whether the rule is active."
|
|
377
|
+
)
|
|
378
|
+
|
|
372
379
|
|
|
373
380
|
class KnowledgeTool(Tool):
|
|
374
381
|
name = "knowledge"
|
|
375
|
-
description =
|
|
382
|
+
description = (
|
|
383
|
+
"Fenix Cloud knowledge operations (Work Items, Boards, Sprints, Rules, Docs)."
|
|
384
|
+
)
|
|
376
385
|
request_model = KnowledgeRequest
|
|
377
386
|
|
|
378
387
|
def __init__(self, context: AppContext):
|
|
@@ -389,8 +398,6 @@ class KnowledgeTool(Tool):
|
|
|
389
398
|
return await self._run_board(payload)
|
|
390
399
|
if action.value.startswith("sprint_"):
|
|
391
400
|
return await self._run_sprint(payload)
|
|
392
|
-
if action.value.startswith("mode_"):
|
|
393
|
-
return await self._run_mode(payload)
|
|
394
401
|
if action.value.startswith("rule_"):
|
|
395
402
|
return await self._run_rule(payload)
|
|
396
403
|
if action.value.startswith("doc_"):
|
|
@@ -820,195 +827,123 @@ class KnowledgeTool(Tool):
|
|
|
820
827
|
)
|
|
821
828
|
)
|
|
822
829
|
|
|
823
|
-
# ------------------------------------------------------------------
|
|
824
|
-
# Modes
|
|
825
|
-
# ------------------------------------------------------------------
|
|
826
|
-
async def _run_mode(self, payload: KnowledgeRequest):
|
|
827
|
-
action = payload.action
|
|
828
|
-
if action is KnowledgeAction.MODE_CREATE:
|
|
829
|
-
if not payload.mode_name:
|
|
830
|
-
return text("❌ Provide mode_name to create the mode.")
|
|
831
|
-
mode = await self._service.mode_create(
|
|
832
|
-
{
|
|
833
|
-
"name": payload.mode_name,
|
|
834
|
-
"description": payload.mode_description,
|
|
835
|
-
"content": payload.mode_content,
|
|
836
|
-
"is_default": payload.mode_is_default,
|
|
837
|
-
"metadata": payload.mode_metadata,
|
|
838
|
-
}
|
|
839
|
-
)
|
|
840
|
-
return text(_format_mode(mode, header="✅ Mode created"))
|
|
841
|
-
|
|
842
|
-
if action is KnowledgeAction.MODE_LIST:
|
|
843
|
-
modes = await self._service.mode_list(
|
|
844
|
-
include_rules=payload.return_metadata,
|
|
845
|
-
return_description=payload.return_description,
|
|
846
|
-
return_metadata=payload.return_metadata,
|
|
847
|
-
)
|
|
848
|
-
if not modes:
|
|
849
|
-
return text("🎭 No modes found.")
|
|
850
|
-
body = "\n\n".join(_format_mode(mode) for mode in modes)
|
|
851
|
-
return text(f"🎭 **Modes ({len(modes)}):**\n\n{body}")
|
|
852
|
-
|
|
853
|
-
if action is KnowledgeAction.MODE_GET:
|
|
854
|
-
if not payload.mode_id:
|
|
855
|
-
return text("❌ Provide mode_id to get details.")
|
|
856
|
-
mode = await self._service.mode_get(
|
|
857
|
-
payload.mode_id,
|
|
858
|
-
return_description=payload.return_description,
|
|
859
|
-
return_metadata=payload.return_metadata,
|
|
860
|
-
)
|
|
861
|
-
# Buscar rules associadas ao mode
|
|
862
|
-
associations = await self._service.mode_rules(payload.mode_id)
|
|
863
|
-
rules = [assoc.get("rule", assoc) for assoc in associations]
|
|
864
|
-
|
|
865
|
-
# Formatar resposta com rules (incluindo content)
|
|
866
|
-
output = _format_mode(mode, header="🎭 Mode details", show_content=True)
|
|
867
|
-
if rules:
|
|
868
|
-
rules_parts = []
|
|
869
|
-
for r in rules:
|
|
870
|
-
rule_text = f"### 📋 {r.get('name', 'Unnamed')}\n"
|
|
871
|
-
rule_text += f"ID: {r.get('id')}\n"
|
|
872
|
-
if r.get("content"):
|
|
873
|
-
rule_text += f"\n{r.get('content')}"
|
|
874
|
-
rules_parts.append(rule_text)
|
|
875
|
-
output += f"\n\n**Rules ({len(rules)}):**\n\n" + "\n\n---\n\n".join(
|
|
876
|
-
rules_parts
|
|
877
|
-
)
|
|
878
|
-
return text(output)
|
|
879
|
-
|
|
880
|
-
if action is KnowledgeAction.MODE_UPDATE:
|
|
881
|
-
if not payload.mode_id:
|
|
882
|
-
return text("❌ Provide mode_id to update.")
|
|
883
|
-
mode = await self._service.mode_update(
|
|
884
|
-
payload.mode_id,
|
|
885
|
-
{
|
|
886
|
-
"name": payload.mode_name,
|
|
887
|
-
"description": payload.mode_description,
|
|
888
|
-
"content": payload.mode_content,
|
|
889
|
-
"is_default": payload.mode_is_default,
|
|
890
|
-
"metadata": payload.mode_metadata,
|
|
891
|
-
},
|
|
892
|
-
)
|
|
893
|
-
return text(_format_mode(mode, header="✅ Mode updated"))
|
|
894
|
-
|
|
895
|
-
if action is KnowledgeAction.MODE_DELETE:
|
|
896
|
-
if not payload.mode_id:
|
|
897
|
-
return text("❌ Provide mode_id to remove.")
|
|
898
|
-
await self._service.mode_delete(payload.mode_id)
|
|
899
|
-
return text(f"🗑️ Mode {payload.mode_id} removed.")
|
|
900
|
-
|
|
901
|
-
if action is KnowledgeAction.MODE_RULE_ADD:
|
|
902
|
-
if not payload.mode_id or not payload.rule_id:
|
|
903
|
-
return text("❌ Provide mode_id and rule_id to associate.")
|
|
904
|
-
link = await self._service.mode_rule_add(payload.mode_id, payload.rule_id)
|
|
905
|
-
return text(
|
|
906
|
-
"\n".join(
|
|
907
|
-
[
|
|
908
|
-
"🔗 **Rule associated with mode!**",
|
|
909
|
-
f"Mode: {link.get('modeId', payload.mode_id)}",
|
|
910
|
-
f"Rule: {link.get('ruleId', payload.rule_id)}",
|
|
911
|
-
]
|
|
912
|
-
)
|
|
913
|
-
)
|
|
914
|
-
|
|
915
|
-
if action is KnowledgeAction.MODE_RULE_REMOVE:
|
|
916
|
-
if not payload.mode_id or not payload.rule_id:
|
|
917
|
-
return text("❌ Provide mode_id and rule_id to remove the association.")
|
|
918
|
-
await self._service.mode_rule_remove(payload.mode_id, payload.rule_id)
|
|
919
|
-
return text("🔗 Association removed.")
|
|
920
|
-
|
|
921
|
-
if action is KnowledgeAction.MODE_RULES:
|
|
922
|
-
if payload.mode_id:
|
|
923
|
-
associations = await self._service.mode_rules(payload.mode_id)
|
|
924
|
-
context_label = f"mode {payload.mode_id}"
|
|
925
|
-
# API retorna [{id, mode_id, rule_id, rule: {...}}] - extrair rule
|
|
926
|
-
items = [assoc.get("rule", assoc) for assoc in associations]
|
|
927
|
-
elif payload.rule_id:
|
|
928
|
-
associations = await self._service.mode_rules_for_rule(payload.rule_id)
|
|
929
|
-
context_label = f"rule {payload.rule_id}"
|
|
930
|
-
# API retorna [{id, mode_id, rule_id, mode: {...}}] - extrair mode
|
|
931
|
-
items = [assoc.get("mode", assoc) for assoc in associations]
|
|
932
|
-
else:
|
|
933
|
-
return text("❌ Provide mode_id or rule_id to list associations.")
|
|
934
|
-
if not items:
|
|
935
|
-
return text("🔗 No associations found.")
|
|
936
|
-
body = "\n".join(
|
|
937
|
-
f"- {item.get('name', 'Unnamed')} (ID: {item.get('id')})"
|
|
938
|
-
for item in items
|
|
939
|
-
)
|
|
940
|
-
return text(f"🔗 **Associations for {context_label}:**\n{body}")
|
|
941
|
-
|
|
942
|
-
return text(
|
|
943
|
-
"❌ Unsupported mode action.\n\nChoose one of the values:\n"
|
|
944
|
-
+ "\n".join(
|
|
945
|
-
f"- `{value}`"
|
|
946
|
-
for value in KnowledgeAction.choices()
|
|
947
|
-
if value.startswith("mode_")
|
|
948
|
-
)
|
|
949
|
-
)
|
|
950
|
-
|
|
951
830
|
# ------------------------------------------------------------------
|
|
952
831
|
# Rules
|
|
953
832
|
# ------------------------------------------------------------------
|
|
954
833
|
async def _run_rule(self, payload: KnowledgeRequest):
|
|
955
834
|
action = payload.action
|
|
835
|
+
|
|
956
836
|
if action is KnowledgeAction.RULE_CREATE:
|
|
957
|
-
if not payload.rule_name
|
|
958
|
-
return text("❌ Provide rule_name
|
|
837
|
+
if not payload.rule_name:
|
|
838
|
+
return text("❌ Provide rule_name to create the rule.")
|
|
839
|
+
if not payload.rule_content:
|
|
840
|
+
return text("❌ Provide rule_content to create the rule.")
|
|
841
|
+
if not payload.rule_scope:
|
|
842
|
+
return text(
|
|
843
|
+
"❌ Provide rule_scope to create the rule. "
|
|
844
|
+
"Values: personal, team, organization, marketplace."
|
|
845
|
+
)
|
|
846
|
+
if payload.rule_scope == "team" and not payload.rule_team_id:
|
|
847
|
+
return text("❌ Provide rule_team_id for team-scoped rules.")
|
|
848
|
+
|
|
959
849
|
rule = await self._service.rule_create(
|
|
960
850
|
{
|
|
851
|
+
"scope": payload.rule_scope,
|
|
961
852
|
"name": payload.rule_name,
|
|
962
|
-
"
|
|
853
|
+
"slug": sanitize_null(payload.rule_slug),
|
|
854
|
+
"description": sanitize_null(payload.rule_description),
|
|
963
855
|
"content": payload.rule_content,
|
|
964
|
-
"
|
|
965
|
-
"metadata": payload.rule_metadata,
|
|
856
|
+
"team_id": sanitize_null(payload.rule_team_id),
|
|
966
857
|
}
|
|
967
858
|
)
|
|
968
859
|
return text(_format_rule(rule, header="✅ Rule created"))
|
|
969
860
|
|
|
970
861
|
if action is KnowledgeAction.RULE_LIST:
|
|
971
862
|
rules = await self._service.rule_list(
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
863
|
+
limit=payload.limit,
|
|
864
|
+
offset=payload.offset,
|
|
865
|
+
scope=sanitize_null(payload.rule_scope),
|
|
866
|
+
query=sanitize_null(payload.query),
|
|
975
867
|
)
|
|
976
868
|
if not rules:
|
|
977
|
-
return text("
|
|
869
|
+
return text("📜 No rules found.")
|
|
978
870
|
body = "\n\n".join(_format_rule(rule) for rule in rules)
|
|
979
|
-
return text(f"
|
|
871
|
+
return text(f"📜 **Rules ({len(rules)}):**\n\n{body}")
|
|
980
872
|
|
|
981
873
|
if action is KnowledgeAction.RULE_GET:
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
return_metadata=payload.return_metadata,
|
|
988
|
-
return_modes=payload.return_metadata,
|
|
989
|
-
)
|
|
990
|
-
return text(_format_rule(rule, header="📋 Rule details", show_content=True))
|
|
874
|
+
rule_id = payload.rule_id or payload.id
|
|
875
|
+
if not rule_id:
|
|
876
|
+
return text("❌ Provide rule_id or id to get the rule.")
|
|
877
|
+
rule = await self._service.rule_get(rule_id)
|
|
878
|
+
return text(_format_rule(rule, header="📜 Rule details", show_content=True))
|
|
991
879
|
|
|
992
880
|
if action is KnowledgeAction.RULE_UPDATE:
|
|
993
|
-
|
|
994
|
-
|
|
881
|
+
rule_id = payload.rule_id or payload.id
|
|
882
|
+
if not rule_id:
|
|
883
|
+
return text("❌ Provide rule_id or id to update the rule.")
|
|
995
884
|
rule = await self._service.rule_update(
|
|
996
|
-
|
|
885
|
+
rule_id,
|
|
997
886
|
{
|
|
998
|
-
"name": payload.rule_name,
|
|
999
|
-
"description": payload.rule_description,
|
|
1000
|
-
"content": payload.rule_content,
|
|
1001
|
-
"
|
|
1002
|
-
"metadata": payload.rule_metadata,
|
|
887
|
+
"name": sanitize_null(payload.rule_name),
|
|
888
|
+
"description": sanitize_null(payload.rule_description),
|
|
889
|
+
"content": sanitize_null(payload.rule_content),
|
|
890
|
+
"is_active": payload.rule_is_active,
|
|
1003
891
|
},
|
|
1004
892
|
)
|
|
1005
893
|
return text(_format_rule(rule, header="✅ Rule updated"))
|
|
1006
894
|
|
|
1007
895
|
if action is KnowledgeAction.RULE_DELETE:
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
896
|
+
rule_id = payload.rule_id or payload.id
|
|
897
|
+
if not rule_id:
|
|
898
|
+
return text("❌ Provide rule_id or id to delete the rule.")
|
|
899
|
+
await self._service.rule_delete(rule_id)
|
|
900
|
+
return text(f"🗑️ Rule {rule_id} removed.")
|
|
901
|
+
|
|
902
|
+
if action is KnowledgeAction.RULE_MARKETPLACE:
|
|
903
|
+
rules = await self._service.rule_marketplace(
|
|
904
|
+
limit=payload.limit,
|
|
905
|
+
offset=payload.offset,
|
|
906
|
+
query=sanitize_null(payload.query),
|
|
907
|
+
)
|
|
908
|
+
if not rules:
|
|
909
|
+
return text("🏪 No marketplace rules found.")
|
|
910
|
+
body = "\n\n".join(_format_marketplace_rule(rule) for rule in rules)
|
|
911
|
+
return text(f"🏪 **Marketplace Rules ({len(rules)}):**\n\n{body}")
|
|
912
|
+
|
|
913
|
+
if action is KnowledgeAction.RULE_FORK:
|
|
914
|
+
rule_id = payload.rule_id or payload.id
|
|
915
|
+
if not rule_id:
|
|
916
|
+
return text("❌ Provide rule_id or id to fork the rule.")
|
|
917
|
+
if not payload.rule_scope:
|
|
918
|
+
return text(
|
|
919
|
+
"❌ Provide rule_scope for the forked rule. "
|
|
920
|
+
"Values: personal, team, organization."
|
|
921
|
+
)
|
|
922
|
+
rule = await self._service.rule_fork(
|
|
923
|
+
rule_id,
|
|
924
|
+
{
|
|
925
|
+
"scope": payload.rule_scope,
|
|
926
|
+
"name": sanitize_null(payload.rule_name),
|
|
927
|
+
"team_id": sanitize_null(payload.rule_team_id),
|
|
928
|
+
},
|
|
929
|
+
)
|
|
930
|
+
return text(_format_rule(rule, header="✅ Rule forked"))
|
|
931
|
+
|
|
932
|
+
if action is KnowledgeAction.RULE_EXPORT:
|
|
933
|
+
rule_id = payload.rule_id or payload.id
|
|
934
|
+
if not rule_id:
|
|
935
|
+
return text("❌ Provide rule_id or id to export the rule.")
|
|
936
|
+
if not payload.rule_export_format:
|
|
937
|
+
return text(
|
|
938
|
+
"❌ Provide rule_export_format. "
|
|
939
|
+
"Values: cursor, claude, copilot, windsurf."
|
|
940
|
+
)
|
|
941
|
+
content = await self._service.rule_export(
|
|
942
|
+
rule_id, payload.rule_export_format
|
|
943
|
+
)
|
|
944
|
+
return text(
|
|
945
|
+
f"📤 **Export ({payload.rule_export_format}):**\n\n```\n{content}\n```"
|
|
946
|
+
)
|
|
1012
947
|
|
|
1013
948
|
return text(
|
|
1014
949
|
"❌ Unsupported rule action.\n\nChoose one of the values:\n"
|
|
@@ -1361,8 +1296,8 @@ def _format_sprint(sprint: Dict[str, Any], header: Optional[str] = None) -> str:
|
|
|
1361
1296
|
return "\n".join(lines)
|
|
1362
1297
|
|
|
1363
1298
|
|
|
1364
|
-
def
|
|
1365
|
-
|
|
1299
|
+
def _format_doc(
|
|
1300
|
+
doc: Dict[str, Any],
|
|
1366
1301
|
*,
|
|
1367
1302
|
header: Optional[str] = None,
|
|
1368
1303
|
show_content: bool = False,
|
|
@@ -1373,17 +1308,23 @@ def _format_mode(
|
|
|
1373
1308
|
lines.append("")
|
|
1374
1309
|
lines.extend(
|
|
1375
1310
|
[
|
|
1376
|
-
f"
|
|
1377
|
-
f"ID: {
|
|
1378
|
-
f"
|
|
1311
|
+
f"📄 **{doc.get('title') or doc.get('name', 'Untitled')}**",
|
|
1312
|
+
f"ID: {doc.get('id', 'N/A')}",
|
|
1313
|
+
f"Status: {doc.get('status', 'N/A')}",
|
|
1314
|
+
f"Team: {doc.get('team_id', 'N/A')}",
|
|
1379
1315
|
]
|
|
1380
1316
|
)
|
|
1381
|
-
if
|
|
1382
|
-
lines.append(
|
|
1383
|
-
|
|
1317
|
+
if doc.get("updated_at") or doc.get("updatedAt"):
|
|
1318
|
+
lines.append(
|
|
1319
|
+
f"Updated at: {_format_date(doc.get('updated_at') or doc.get('updatedAt'))}"
|
|
1320
|
+
)
|
|
1321
|
+
|
|
1322
|
+
# Show content only when explicitly requested (e.g., doc_get)
|
|
1323
|
+
if show_content and doc.get("content"):
|
|
1384
1324
|
lines.append("")
|
|
1385
1325
|
lines.append("**Content:**")
|
|
1386
|
-
lines.append(
|
|
1326
|
+
lines.append(doc.get("content"))
|
|
1327
|
+
|
|
1387
1328
|
return "\n".join(lines)
|
|
1388
1329
|
|
|
1389
1330
|
|
|
@@ -1397,50 +1338,87 @@ def _format_rule(
|
|
|
1397
1338
|
if header:
|
|
1398
1339
|
lines.append(header)
|
|
1399
1340
|
lines.append("")
|
|
1341
|
+
|
|
1342
|
+
name = rule.get("name", "Untitled")
|
|
1343
|
+
scope = rule.get("scope", "unknown")
|
|
1344
|
+
scope_emoji = {
|
|
1345
|
+
"personal": "👤",
|
|
1346
|
+
"team": "👥",
|
|
1347
|
+
"organization": "🏢",
|
|
1348
|
+
"marketplace": "🌍",
|
|
1349
|
+
}.get(scope, "📜")
|
|
1350
|
+
|
|
1400
1351
|
lines.extend(
|
|
1401
1352
|
[
|
|
1402
|
-
f"
|
|
1353
|
+
f"{scope_emoji} **{name}**",
|
|
1403
1354
|
f"ID: {rule.get('id', 'N/A')}",
|
|
1404
|
-
f"
|
|
1355
|
+
f"Scope: {scope}",
|
|
1356
|
+
f"Version: {rule.get('version', '1.0')}",
|
|
1405
1357
|
]
|
|
1406
1358
|
)
|
|
1359
|
+
|
|
1407
1360
|
if rule.get("description"):
|
|
1408
|
-
lines.append(f"Description: {rule
|
|
1361
|
+
lines.append(f"Description: {rule.get('description')}")
|
|
1362
|
+
|
|
1363
|
+
# Author info
|
|
1364
|
+
author = rule.get("author")
|
|
1365
|
+
if isinstance(author, dict):
|
|
1366
|
+
lines.append(f"Author: {author.get('name', 'Unknown')}")
|
|
1367
|
+
|
|
1368
|
+
# Forked from info
|
|
1369
|
+
forked_from = rule.get("forked_from")
|
|
1370
|
+
if isinstance(forked_from, dict):
|
|
1371
|
+
forked_author = forked_from.get("author", {})
|
|
1372
|
+
author_name = (
|
|
1373
|
+
forked_author.get("name", "Unknown")
|
|
1374
|
+
if isinstance(forked_author, dict)
|
|
1375
|
+
else "Unknown"
|
|
1376
|
+
)
|
|
1377
|
+
lines.append(
|
|
1378
|
+
f"Forked from: {forked_from.get('name', 'Unknown')} by {author_name}"
|
|
1379
|
+
)
|
|
1380
|
+
|
|
1381
|
+
if rule.get("is_active") is False:
|
|
1382
|
+
lines.append("Status: Inactive")
|
|
1383
|
+
|
|
1384
|
+
if rule.get("updated_at") or rule.get("updatedAt"):
|
|
1385
|
+
lines.append(
|
|
1386
|
+
f"Updated: {_format_date(rule.get('updated_at') or rule.get('updatedAt'))}"
|
|
1387
|
+
)
|
|
1388
|
+
|
|
1389
|
+
# Show content only when explicitly requested
|
|
1409
1390
|
if show_content and rule.get("content"):
|
|
1410
1391
|
lines.append("")
|
|
1411
1392
|
lines.append("**Content:**")
|
|
1412
1393
|
lines.append(rule.get("content"))
|
|
1394
|
+
|
|
1413
1395
|
return "\n".join(lines)
|
|
1414
1396
|
|
|
1415
1397
|
|
|
1416
|
-
def
|
|
1417
|
-
|
|
1418
|
-
*,
|
|
1419
|
-
header: Optional[str] = None,
|
|
1420
|
-
show_content: bool = False,
|
|
1421
|
-
) -> str:
|
|
1398
|
+
def _format_marketplace_rule(rule: Dict[str, Any]) -> str:
|
|
1399
|
+
"""Format a marketplace rule with download/rating info."""
|
|
1422
1400
|
lines: List[str] = []
|
|
1423
|
-
if header:
|
|
1424
|
-
lines.append(header)
|
|
1425
|
-
lines.append("")
|
|
1426
|
-
lines.extend(
|
|
1427
|
-
[
|
|
1428
|
-
f"📄 **{doc.get('title') or doc.get('name', 'Untitled')}**",
|
|
1429
|
-
f"ID: {doc.get('id', 'N/A')}",
|
|
1430
|
-
f"Status: {doc.get('status', 'N/A')}",
|
|
1431
|
-
f"Team: {doc.get('team_id', 'N/A')}",
|
|
1432
|
-
]
|
|
1433
|
-
)
|
|
1434
|
-
if doc.get("updated_at") or doc.get("updatedAt"):
|
|
1435
|
-
lines.append(
|
|
1436
|
-
f"Updated at: {_format_date(doc.get('updated_at') or doc.get('updatedAt'))}"
|
|
1437
|
-
)
|
|
1438
1401
|
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1402
|
+
name = rule.get("name", "Untitled")
|
|
1403
|
+
downloads = rule.get("downloads", 0)
|
|
1404
|
+
rating = rule.get("rating")
|
|
1405
|
+
|
|
1406
|
+
lines.append(f"🌍 **{name}**")
|
|
1407
|
+
lines.append(f"ID: {rule.get('id', 'N/A')}")
|
|
1408
|
+
|
|
1409
|
+
if rule.get("description"):
|
|
1410
|
+
lines.append(f"Description: {rule.get('description')}")
|
|
1411
|
+
|
|
1412
|
+
# Author info
|
|
1413
|
+
author = rule.get("author")
|
|
1414
|
+
if isinstance(author, dict):
|
|
1415
|
+
lines.append(f"Author: {author.get('name', 'Unknown')}")
|
|
1416
|
+
|
|
1417
|
+
# Stats
|
|
1418
|
+
stats_parts = [f"⬇️ {downloads}"]
|
|
1419
|
+
if rating is not None:
|
|
1420
|
+
stats_parts.append(f"⭐ {float(rating):.1f}")
|
|
1421
|
+
lines.append(" | ".join(stats_parts))
|
|
1444
1422
|
|
|
1445
1423
|
return "\n".join(lines)
|
|
1446
1424
|
|
fenix_mcp/domain/knowledge.py
CHANGED
|
@@ -360,123 +360,6 @@ class KnowledgeService:
|
|
|
360
360
|
async def sprint_cancel(self, sprint_id: str) -> Dict[str, Any]:
|
|
361
361
|
return await self._call(self.api.cancel_sprint, sprint_id)
|
|
362
362
|
|
|
363
|
-
# ------------------------------------------------------------------
|
|
364
|
-
# Modes and rules
|
|
365
|
-
# ------------------------------------------------------------------
|
|
366
|
-
async def mode_create(self, payload: Dict[str, Any]) -> Dict[str, Any]:
|
|
367
|
-
result = await self._call(self.api.create_mode, _strip_none(payload))
|
|
368
|
-
# API returns { message, mode } - extract mode
|
|
369
|
-
if isinstance(result, dict) and "mode" in result:
|
|
370
|
-
return result["mode"]
|
|
371
|
-
return result or {}
|
|
372
|
-
|
|
373
|
-
async def mode_list(
|
|
374
|
-
self,
|
|
375
|
-
*,
|
|
376
|
-
include_rules: Optional[bool] = None,
|
|
377
|
-
return_description: Optional[bool] = None,
|
|
378
|
-
return_metadata: Optional[bool] = None,
|
|
379
|
-
) -> List[Dict[str, Any]]:
|
|
380
|
-
return (
|
|
381
|
-
await self._call(
|
|
382
|
-
self.api.list_modes,
|
|
383
|
-
include_rules=include_rules,
|
|
384
|
-
return_description=return_description,
|
|
385
|
-
return_metadata=return_metadata,
|
|
386
|
-
)
|
|
387
|
-
or []
|
|
388
|
-
)
|
|
389
|
-
|
|
390
|
-
async def mode_get(
|
|
391
|
-
self,
|
|
392
|
-
mode_id: str,
|
|
393
|
-
*,
|
|
394
|
-
return_description: Optional[bool] = None,
|
|
395
|
-
return_metadata: Optional[bool] = None,
|
|
396
|
-
) -> Dict[str, Any]:
|
|
397
|
-
return await self._call(
|
|
398
|
-
self.api.get_mode,
|
|
399
|
-
mode_id,
|
|
400
|
-
return_description=return_description,
|
|
401
|
-
return_metadata=return_metadata,
|
|
402
|
-
)
|
|
403
|
-
|
|
404
|
-
async def mode_update(
|
|
405
|
-
self, mode_id: str, payload: Dict[str, Any]
|
|
406
|
-
) -> Dict[str, Any]:
|
|
407
|
-
result = await self._call(self.api.update_mode, mode_id, _strip_none(payload))
|
|
408
|
-
# API returns { message, mode } - extract mode
|
|
409
|
-
if isinstance(result, dict) and "mode" in result:
|
|
410
|
-
return result["mode"]
|
|
411
|
-
return result or {}
|
|
412
|
-
|
|
413
|
-
async def mode_delete(self, mode_id: str) -> None:
|
|
414
|
-
await self._call(self.api.delete_mode, mode_id)
|
|
415
|
-
|
|
416
|
-
async def mode_rule_add(self, mode_id: str, rule_id: str) -> Dict[str, Any]:
|
|
417
|
-
return await self._call(self.api.add_mode_rule, mode_id, rule_id)
|
|
418
|
-
|
|
419
|
-
async def mode_rule_remove(self, mode_id: str, rule_id: str) -> None:
|
|
420
|
-
await self._call(self.api.remove_mode_rule, mode_id, rule_id)
|
|
421
|
-
|
|
422
|
-
async def mode_rules(self, mode_id: str) -> List[Dict[str, Any]]:
|
|
423
|
-
return await self._call(self.api.list_rules_by_mode, mode_id) or []
|
|
424
|
-
|
|
425
|
-
async def mode_rules_for_rule(self, rule_id: str) -> List[Dict[str, Any]]:
|
|
426
|
-
return await self._call(self.api.list_modes_by_rule, rule_id) or []
|
|
427
|
-
|
|
428
|
-
async def rule_create(self, payload: Dict[str, Any]) -> Dict[str, Any]:
|
|
429
|
-
result = await self._call(self.api.create_rule, _strip_none(payload))
|
|
430
|
-
# API returns { message, rule } - extract rule
|
|
431
|
-
if isinstance(result, dict) and "rule" in result:
|
|
432
|
-
return result["rule"]
|
|
433
|
-
return result or {}
|
|
434
|
-
|
|
435
|
-
async def rule_list(
|
|
436
|
-
self,
|
|
437
|
-
*,
|
|
438
|
-
return_description: Optional[bool] = None,
|
|
439
|
-
return_metadata: Optional[bool] = None,
|
|
440
|
-
return_modes: Optional[bool] = None,
|
|
441
|
-
) -> List[Dict[str, Any]]:
|
|
442
|
-
return (
|
|
443
|
-
await self._call(
|
|
444
|
-
self.api.list_rules,
|
|
445
|
-
return_description=return_description,
|
|
446
|
-
return_metadata=return_metadata,
|
|
447
|
-
return_modes=return_modes,
|
|
448
|
-
)
|
|
449
|
-
or []
|
|
450
|
-
)
|
|
451
|
-
|
|
452
|
-
async def rule_get(
|
|
453
|
-
self,
|
|
454
|
-
rule_id: str,
|
|
455
|
-
*,
|
|
456
|
-
return_description: Optional[bool] = None,
|
|
457
|
-
return_metadata: Optional[bool] = None,
|
|
458
|
-
return_modes: Optional[bool] = None,
|
|
459
|
-
) -> Dict[str, Any]:
|
|
460
|
-
return await self._call(
|
|
461
|
-
self.api.get_rule,
|
|
462
|
-
rule_id,
|
|
463
|
-
return_description=return_description,
|
|
464
|
-
return_metadata=return_metadata,
|
|
465
|
-
return_modes=return_modes,
|
|
466
|
-
)
|
|
467
|
-
|
|
468
|
-
async def rule_update(
|
|
469
|
-
self, rule_id: str, payload: Dict[str, Any]
|
|
470
|
-
) -> Dict[str, Any]:
|
|
471
|
-
result = await self._call(self.api.update_rule, rule_id, _strip_none(payload))
|
|
472
|
-
# API returns { message, rule } - extract rule
|
|
473
|
-
if isinstance(result, dict) and "rule" in result:
|
|
474
|
-
return result["rule"]
|
|
475
|
-
return result or {}
|
|
476
|
-
|
|
477
|
-
async def rule_delete(self, rule_id: str) -> None:
|
|
478
|
-
await self._call(self.api.delete_rule, rule_id)
|
|
479
|
-
|
|
480
363
|
# ------------------------------------------------------------------
|
|
481
364
|
# Documentation
|
|
482
365
|
# ------------------------------------------------------------------
|
|
@@ -549,6 +432,40 @@ class KnowledgeService:
|
|
|
549
432
|
self.api.duplicate_documentation_item, doc_id, _strip_none(payload)
|
|
550
433
|
)
|
|
551
434
|
|
|
435
|
+
# ------------------------------------------------------------------
|
|
436
|
+
# Rules
|
|
437
|
+
# ------------------------------------------------------------------
|
|
438
|
+
async def rule_create(self, payload: Dict[str, Any]) -> Dict[str, Any]:
|
|
439
|
+
return await self._call_dict(self.api.create_rule, _strip_none(payload))
|
|
440
|
+
|
|
441
|
+
async def rule_list(self, **filters: Any) -> List[Dict[str, Any]]:
|
|
442
|
+
return await self._call_list(self.api.list_rules, **_strip_none(filters))
|
|
443
|
+
|
|
444
|
+
async def rule_get(self, rule_id: str) -> Dict[str, Any]:
|
|
445
|
+
return await self._call_dict(self.api.get_rule, rule_id)
|
|
446
|
+
|
|
447
|
+
async def rule_update(
|
|
448
|
+
self, rule_id: str, payload: Dict[str, Any]
|
|
449
|
+
) -> Dict[str, Any]:
|
|
450
|
+
return await self._call_dict(
|
|
451
|
+
self.api.update_rule, rule_id, _strip_none(payload)
|
|
452
|
+
)
|
|
453
|
+
|
|
454
|
+
async def rule_delete(self, rule_id: str) -> None:
|
|
455
|
+
await self._call(self.api.delete_rule, rule_id)
|
|
456
|
+
|
|
457
|
+
async def rule_marketplace(self, **filters: Any) -> List[Dict[str, Any]]:
|
|
458
|
+
return await self._call_list(
|
|
459
|
+
self.api.list_marketplace_rules, **_strip_none(filters)
|
|
460
|
+
)
|
|
461
|
+
|
|
462
|
+
async def rule_fork(self, rule_id: str, payload: Dict[str, Any]) -> Dict[str, Any]:
|
|
463
|
+
return await self._call_dict(self.api.fork_rule, rule_id, _strip_none(payload))
|
|
464
|
+
|
|
465
|
+
async def rule_export(self, rule_id: str, format: str) -> str:
|
|
466
|
+
result = await self._call(self.api.export_rule, rule_id, format)
|
|
467
|
+
return result if isinstance(result, str) else str(result or "")
|
|
468
|
+
|
|
552
469
|
|
|
553
470
|
__all__ = [
|
|
554
471
|
"KnowledgeService",
|
|
@@ -284,105 +284,6 @@ class FenixApiClient:
|
|
|
284
284
|
"POST", "/api/memory-intelligence/smart-create", json=payload
|
|
285
285
|
)
|
|
286
286
|
|
|
287
|
-
# ------------------------------------------------------------------
|
|
288
|
-
# Configuration: modes and rules
|
|
289
|
-
# ------------------------------------------------------------------
|
|
290
|
-
|
|
291
|
-
def list_modes(
|
|
292
|
-
self,
|
|
293
|
-
*,
|
|
294
|
-
include_rules: Optional[bool] = None,
|
|
295
|
-
return_description: Optional[bool] = None,
|
|
296
|
-
return_metadata: Optional[bool] = None,
|
|
297
|
-
) -> Any:
|
|
298
|
-
params = self._build_params(
|
|
299
|
-
optional={
|
|
300
|
-
"includeRules": include_rules,
|
|
301
|
-
"returnDescription": return_description,
|
|
302
|
-
"returnMetadata": return_metadata,
|
|
303
|
-
}
|
|
304
|
-
)
|
|
305
|
-
return self._request("GET", "/api/modes", params=params)
|
|
306
|
-
|
|
307
|
-
def get_mode(
|
|
308
|
-
self,
|
|
309
|
-
mode_id: str,
|
|
310
|
-
*,
|
|
311
|
-
return_description: Optional[bool] = None,
|
|
312
|
-
return_metadata: Optional[bool] = None,
|
|
313
|
-
) -> Any:
|
|
314
|
-
params = self._build_params(
|
|
315
|
-
optional={
|
|
316
|
-
"returnDescription": return_description,
|
|
317
|
-
"returnMetadata": return_metadata,
|
|
318
|
-
}
|
|
319
|
-
)
|
|
320
|
-
return self._request("GET", f"/api/modes/{mode_id}", params=params)
|
|
321
|
-
|
|
322
|
-
def create_mode(self, payload: Mapping[str, Any]) -> Any:
|
|
323
|
-
return self._request("POST", "/api/modes", json=payload)
|
|
324
|
-
|
|
325
|
-
def update_mode(self, mode_id: str, payload: Mapping[str, Any]) -> Any:
|
|
326
|
-
return self._request("PATCH", f"/api/modes/{mode_id}", json=payload)
|
|
327
|
-
|
|
328
|
-
def delete_mode(self, mode_id: str) -> Any:
|
|
329
|
-
return self._request("DELETE", f"/api/modes/{mode_id}")
|
|
330
|
-
|
|
331
|
-
def list_rules(
|
|
332
|
-
self,
|
|
333
|
-
*,
|
|
334
|
-
return_description: Optional[bool] = None,
|
|
335
|
-
return_metadata: Optional[bool] = None,
|
|
336
|
-
return_modes: Optional[bool] = None,
|
|
337
|
-
) -> Any:
|
|
338
|
-
params = self._build_params(
|
|
339
|
-
optional={
|
|
340
|
-
"returnDescription": return_description,
|
|
341
|
-
"returnMetadata": return_metadata,
|
|
342
|
-
"returnModes": return_modes,
|
|
343
|
-
}
|
|
344
|
-
)
|
|
345
|
-
return self._request("GET", "/api/rules", params=params)
|
|
346
|
-
|
|
347
|
-
def get_rule(
|
|
348
|
-
self,
|
|
349
|
-
rule_id: str,
|
|
350
|
-
*,
|
|
351
|
-
return_description: Optional[bool] = None,
|
|
352
|
-
return_metadata: Optional[bool] = None,
|
|
353
|
-
return_modes: Optional[bool] = None,
|
|
354
|
-
) -> Any:
|
|
355
|
-
params = self._build_params(
|
|
356
|
-
optional={
|
|
357
|
-
"returnDescription": return_description,
|
|
358
|
-
"returnMetadata": return_metadata,
|
|
359
|
-
"returnModes": return_modes,
|
|
360
|
-
}
|
|
361
|
-
)
|
|
362
|
-
return self._request("GET", f"/api/rules/{rule_id}", params=params)
|
|
363
|
-
|
|
364
|
-
def create_rule(self, payload: Mapping[str, Any]) -> Any:
|
|
365
|
-
return self._request("POST", "/api/rules", json=payload)
|
|
366
|
-
|
|
367
|
-
def update_rule(self, rule_id: str, payload: Mapping[str, Any]) -> Any:
|
|
368
|
-
return self._request("PATCH", f"/api/rules/{rule_id}", json=payload)
|
|
369
|
-
|
|
370
|
-
def delete_rule(self, rule_id: str) -> Any:
|
|
371
|
-
return self._request("DELETE", f"/api/rules/{rule_id}")
|
|
372
|
-
|
|
373
|
-
def add_mode_rule(self, mode_id: str, rule_id: str) -> Any:
|
|
374
|
-
payload = {"modeId": mode_id, "ruleId": rule_id}
|
|
375
|
-
return self._request("POST", "/api/mode-rules", json=payload)
|
|
376
|
-
|
|
377
|
-
def remove_mode_rule(self, mode_id: str, rule_id: str) -> Any:
|
|
378
|
-
return self._request("DELETE", f"/api/mode-rules/mode/{mode_id}/rule/{rule_id}")
|
|
379
|
-
|
|
380
|
-
def list_rules_by_mode(self, mode_id: str) -> Any:
|
|
381
|
-
return self._request("GET", f"/api/mode-rules/mode/{mode_id}/rules")
|
|
382
|
-
|
|
383
|
-
def list_modes_by_rule(self, rule_id: str) -> Any:
|
|
384
|
-
return self._request("GET", f"/api/mode-rules/rule/{rule_id}/modes")
|
|
385
|
-
|
|
386
287
|
# ------------------------------------------------------------------
|
|
387
288
|
# Knowledge: documentation
|
|
388
289
|
# ------------------------------------------------------------------
|
|
@@ -691,3 +592,53 @@ class FenixApiClient:
|
|
|
691
592
|
|
|
692
593
|
def cancel_sprint(self, sprint_id: str) -> Any:
|
|
693
594
|
return self._request("PATCH", f"/api/sprints/{sprint_id}/cancel")
|
|
595
|
+
|
|
596
|
+
# ------------------------------------------------------------------
|
|
597
|
+
# Rules
|
|
598
|
+
# ------------------------------------------------------------------
|
|
599
|
+
|
|
600
|
+
def create_rule(self, payload: Mapping[str, Any]) -> Any:
|
|
601
|
+
return self._request("POST", "/api/rules", json=payload)
|
|
602
|
+
|
|
603
|
+
def list_rules(self, **filters: Any) -> Any:
|
|
604
|
+
return self._request("GET", "/api/rules", params=_strip_none(filters))
|
|
605
|
+
|
|
606
|
+
def get_rule(self, rule_id: str) -> Any:
|
|
607
|
+
return self._request("GET", f"/api/rules/{rule_id}")
|
|
608
|
+
|
|
609
|
+
def update_rule(self, rule_id: str, payload: Mapping[str, Any]) -> Any:
|
|
610
|
+
return self._request("PATCH", f"/api/rules/{rule_id}", json=payload)
|
|
611
|
+
|
|
612
|
+
def delete_rule(self, rule_id: str) -> Any:
|
|
613
|
+
return self._request("DELETE", f"/api/rules/{rule_id}")
|
|
614
|
+
|
|
615
|
+
def list_marketplace_rules(self, **filters: Any) -> Any:
|
|
616
|
+
return self._request(
|
|
617
|
+
"GET", "/api/rules/marketplace/list", params=_strip_none(filters)
|
|
618
|
+
)
|
|
619
|
+
|
|
620
|
+
def search_marketplace_rules(self, *, query: str, limit: int = 20) -> Any:
|
|
621
|
+
params = self._build_params(required={"q": query}, optional={"limit": limit})
|
|
622
|
+
return self._request("GET", "/api/rules/marketplace/search", params=params)
|
|
623
|
+
|
|
624
|
+
def get_top_marketplace_rules(self, *, limit: int = 10) -> Any:
|
|
625
|
+
params = self._build_params(optional={"limit": limit})
|
|
626
|
+
return self._request("GET", "/api/rules/marketplace/top", params=params)
|
|
627
|
+
|
|
628
|
+
def fork_rule(self, rule_id: str, payload: Mapping[str, Any]) -> Any:
|
|
629
|
+
return self._request("POST", f"/api/rules/{rule_id}/fork", json=payload)
|
|
630
|
+
|
|
631
|
+
def download_rule(self, rule_id: str) -> Any:
|
|
632
|
+
return self._request("POST", f"/api/rules/{rule_id}/download")
|
|
633
|
+
|
|
634
|
+
def rate_rule(self, rule_id: str, rating: float) -> Any:
|
|
635
|
+
return self._request(
|
|
636
|
+
"POST", f"/api/rules/{rule_id}/rate", json={"rating": rating}
|
|
637
|
+
)
|
|
638
|
+
|
|
639
|
+
def export_rule(self, rule_id: str, format: str) -> Any:
|
|
640
|
+
return self._request("GET", f"/api/rules/{rule_id}/export/{format}")
|
|
641
|
+
|
|
642
|
+
def export_merged_rules(self, format: str) -> Any:
|
|
643
|
+
params = self._build_params(required={"format": format})
|
|
644
|
+
return self._request("GET", "/api/rules/export/merged", params=params)
|
|
@@ -12,6 +12,8 @@ from requests import Response, Session
|
|
|
12
12
|
from requests.adapters import HTTPAdapter
|
|
13
13
|
from urllib3.util.retry import Retry
|
|
14
14
|
|
|
15
|
+
from fenix_mcp.infrastructure.request_context import get_request_token
|
|
16
|
+
|
|
15
17
|
|
|
16
18
|
@dataclass(slots=True)
|
|
17
19
|
class HttpClient:
|
|
@@ -53,6 +55,11 @@ class HttpClient:
|
|
|
53
55
|
merged_headers = dict(self.default_headers or {})
|
|
54
56
|
merged_headers.update(headers or {})
|
|
55
57
|
|
|
58
|
+
# Use request-scoped token if available (overrides default)
|
|
59
|
+
request_token = get_request_token()
|
|
60
|
+
if request_token:
|
|
61
|
+
merged_headers["Authorization"] = f"Bearer {request_token}"
|
|
62
|
+
|
|
56
63
|
self._logger.debug(
|
|
57
64
|
"HTTP request",
|
|
58
65
|
extra={
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
"""Request-scoped context using contextvars for async isolation."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from contextvars import ContextVar
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
# Context variable for the current request's token
|
|
11
|
+
# This is automatically isolated per async task/request
|
|
12
|
+
_current_token: ContextVar[Optional[str]] = ContextVar("current_token", default=None)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(slots=True, frozen=True)
|
|
16
|
+
class RequestContext:
|
|
17
|
+
"""Holds request-scoped data like the user's token."""
|
|
18
|
+
|
|
19
|
+
token: Optional[str] = None
|
|
20
|
+
user_id: Optional[str] = None
|
|
21
|
+
tenant_id: Optional[str] = None
|
|
22
|
+
team_id: Optional[str] = None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def set_request_token(token: Optional[str]) -> None:
|
|
26
|
+
"""Set the token for the current async context/request."""
|
|
27
|
+
_current_token.set(token)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def get_request_token() -> Optional[str]:
|
|
31
|
+
"""Get the token for the current async context/request."""
|
|
32
|
+
return _current_token.get()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def clear_request_token() -> None:
|
|
36
|
+
"""Clear the token for the current async context/request."""
|
|
37
|
+
_current_token.set(None)
|
|
@@ -14,6 +14,10 @@ from typing import Iterable, List, Protocol
|
|
|
14
14
|
from aiohttp import web
|
|
15
15
|
|
|
16
16
|
from fenix_mcp.infrastructure.config import Settings
|
|
17
|
+
from fenix_mcp.infrastructure.request_context import (
|
|
18
|
+
set_request_token,
|
|
19
|
+
clear_request_token,
|
|
20
|
+
)
|
|
17
21
|
|
|
18
22
|
|
|
19
23
|
class Transport(Protocol):
|
|
@@ -147,17 +151,20 @@ class HttpTransport:
|
|
|
147
151
|
return self._with_cors(web.Response(status=204))
|
|
148
152
|
|
|
149
153
|
async def _handle_jsonrpc(self, request: web.Request) -> web.StreamResponse:
|
|
154
|
+
# Extract token from Authorization header (per-request isolation)
|
|
150
155
|
auth_header = request.headers.get("Authorization") or request.headers.get(
|
|
151
156
|
"authorization"
|
|
152
157
|
)
|
|
153
158
|
if auth_header and auth_header.lower().startswith("bearer "):
|
|
154
159
|
token = auth_header.split(" ", 1)[1].strip()
|
|
155
160
|
if token:
|
|
156
|
-
|
|
161
|
+
# Set token in contextvar (isolated per async request)
|
|
162
|
+
set_request_token(token)
|
|
157
163
|
|
|
158
164
|
try:
|
|
159
165
|
payload = await request.json()
|
|
160
166
|
except Exception: # pragma: no cover - defensive
|
|
167
|
+
clear_request_token()
|
|
161
168
|
return self._with_cors(
|
|
162
169
|
web.json_response(
|
|
163
170
|
{"error": {"code": -32700, "message": "Invalid JSON"}}, status=400
|
|
@@ -175,6 +182,9 @@ class HttpTransport:
|
|
|
175
182
|
{"error": {"code": -32000, "message": str(exc)}}, status=500
|
|
176
183
|
)
|
|
177
184
|
)
|
|
185
|
+
finally:
|
|
186
|
+
# Clear token after request completes
|
|
187
|
+
clear_request_token()
|
|
178
188
|
|
|
179
189
|
if response is None:
|
|
180
190
|
return self._with_cors(web.Response(status=204))
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
fenix_mcp/__init__.py,sha256=
|
|
1
|
+
fenix_mcp/__init__.py,sha256=7SOxDUsDtFE-qmtfTKRnl_9P983TudQ0R2VuCUxSCRI,181
|
|
2
2
|
fenix_mcp/main.py,sha256=iJV-9btNMDJMObvcn7wBQdbLLKjkYCQ1ANGEwHGHlMU,2857
|
|
3
3
|
fenix_mcp/application/presenters.py,sha256=fGME54PdCDhTBhXO-JUB9yLdBHiE1aeXLTC2fCuxnxM,689
|
|
4
4
|
fenix_mcp/application/tool_base.py,sha256=ZCb9g4ij5Hbb0410NEZTYXvPWq-Zkg8ZCsinTa3gCY4,4741
|
|
@@ -7,23 +7,24 @@ fenix_mcp/application/tools/__init__.py,sha256=Gi1YvYh-KdL9HD8gLVrknHrxiKKEOhHBE
|
|
|
7
7
|
fenix_mcp/application/tools/health.py,sha256=m5DxhoRbdwl6INzd6PISxv1NAv-ljCrezsr773VB0wE,834
|
|
8
8
|
fenix_mcp/application/tools/initialize.py,sha256=_yVhjB4R6h0XAXyqnoe27F1UpidvcJstlsropsPf7q8,6449
|
|
9
9
|
fenix_mcp/application/tools/intelligence.py,sha256=fb004UlYiSlhL8pblG3AEx6cwrVMxTL_arloJshacSw,16173
|
|
10
|
-
fenix_mcp/application/tools/knowledge.py,sha256=
|
|
10
|
+
fenix_mcp/application/tools/knowledge.py,sha256=MniFgzpt216FulnvARNxiUWGuOFOLwegOHgUYpYgKuQ,58995
|
|
11
11
|
fenix_mcp/application/tools/productivity.py,sha256=Wmefwg6yuXkHwwQT999d9D4lQf0UY_jnCTDlBe2YRTg,11273
|
|
12
12
|
fenix_mcp/application/tools/user_config.py,sha256=O5AVg7IUKL9uIoUoBSFovBDHl9jofhKWzhFK7CnKi4s,6470
|
|
13
13
|
fenix_mcp/domain/initialization.py,sha256=AZhdSNITQ7O3clELBuqGvjJc-c8pFKc7zQz-XR2xXPc,6933
|
|
14
14
|
fenix_mcp/domain/intelligence.py,sha256=j1kkxT-pjuzLQeAGDd2H8gd3O1aeUIRgHFnMGvNwQYg,8636
|
|
15
|
-
fenix_mcp/domain/knowledge.py,sha256=
|
|
15
|
+
fenix_mcp/domain/knowledge.py,sha256=lVztUAvIvBe874unbagQSZscCh486Lc8VW2BL1jl3pQ,18415
|
|
16
16
|
fenix_mcp/domain/productivity.py,sha256=PzY664eRPuBCfZGUY_Uv1GNeyMWsw6xqC54C-nobQns,6799
|
|
17
17
|
fenix_mcp/domain/user_config.py,sha256=8rzhJCNqIArfaCoKxxQXFoemCU7qww3hq0RDanIf_2Y,2028
|
|
18
18
|
fenix_mcp/infrastructure/config.py,sha256=zhJ3hhsP-bRfICcdq8rIDh5NGDe_u7AGpcgjcc2U1nY,1908
|
|
19
19
|
fenix_mcp/infrastructure/context.py,sha256=kiDiamiPbHZpTGyZMylcQwtLhfaDXrxAkWSst_DWQNw,470
|
|
20
|
-
fenix_mcp/infrastructure/http_client.py,sha256=
|
|
20
|
+
fenix_mcp/infrastructure/http_client.py,sha256=uJwt_iBGSFa1XPFBeqtm7eznkEm8aZ1v2DSxYjloAQs,2753
|
|
21
21
|
fenix_mcp/infrastructure/logging.py,sha256=bHrWlSi_0HshRe3--BK_5nzUszW-gh37q6jsd0ShS2Y,1371
|
|
22
|
-
fenix_mcp/infrastructure/
|
|
22
|
+
fenix_mcp/infrastructure/request_context.py,sha256=hAHXHh-SKizBN7-YgdaRv0JsRYXBdurO2sr9btHPjKI,1101
|
|
23
|
+
fenix_mcp/infrastructure/fenix_api/client.py,sha256=DFbrcNGX3Or8Y_JmlKkq9RkmBWGNUGruUhjd2G2MwY0,26347
|
|
23
24
|
fenix_mcp/interface/mcp_server.py,sha256=5UM2NJuNbwHkmCEprIFataJ5nFZiO8efTtP_oW3_iX0,2331
|
|
24
|
-
fenix_mcp/interface/transports.py,sha256=
|
|
25
|
-
fenix_mcp-1.
|
|
26
|
-
fenix_mcp-1.
|
|
27
|
-
fenix_mcp-1.
|
|
28
|
-
fenix_mcp-1.
|
|
29
|
-
fenix_mcp-1.
|
|
25
|
+
fenix_mcp/interface/transports.py,sha256=2zJtc-L73zasyiwQoZbvFJ0yT1bggL5WAa7Nm7zID3k,8502
|
|
26
|
+
fenix_mcp-1.14.0.dist-info/METADATA,sha256=nQXkVhuAe3qB15gSFqIImQCxCF6v9GRTGvn2fsTNysw,7261
|
|
27
|
+
fenix_mcp-1.14.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
28
|
+
fenix_mcp-1.14.0.dist-info/entry_points.txt,sha256=o52x_YHBupEd-1Z1GSfUjv3gJrx5_I-EkHhCgt1WBaE,49
|
|
29
|
+
fenix_mcp-1.14.0.dist-info/top_level.txt,sha256=2G1UtKpwjaIGQyE7sRoHecxaGLeuexfjrOUjv9DDKh4,10
|
|
30
|
+
fenix_mcp-1.14.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|