fenix-mcp 1.12.0__tar.gz → 1.14.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. {fenix_mcp-1.12.0 → fenix_mcp-1.14.0}/PKG-INFO +1 -1
  2. {fenix_mcp-1.12.0 → fenix_mcp-1.14.0}/fenix_mcp/__init__.py +1 -1
  3. {fenix_mcp-1.12.0 → fenix_mcp-1.14.0}/fenix_mcp/application/tools/knowledge.py +230 -252
  4. {fenix_mcp-1.12.0 → fenix_mcp-1.14.0}/fenix_mcp/domain/knowledge.py +34 -117
  5. {fenix_mcp-1.12.0 → fenix_mcp-1.14.0}/fenix_mcp/infrastructure/fenix_api/client.py +50 -99
  6. {fenix_mcp-1.12.0 → fenix_mcp-1.14.0}/fenix_mcp/infrastructure/http_client.py +7 -0
  7. fenix_mcp-1.14.0/fenix_mcp/infrastructure/request_context.py +37 -0
  8. {fenix_mcp-1.12.0 → fenix_mcp-1.14.0}/fenix_mcp/interface/transports.py +11 -1
  9. {fenix_mcp-1.12.0 → fenix_mcp-1.14.0}/fenix_mcp.egg-info/PKG-INFO +1 -1
  10. {fenix_mcp-1.12.0 → fenix_mcp-1.14.0}/fenix_mcp.egg-info/SOURCES.txt +1 -0
  11. {fenix_mcp-1.12.0 → fenix_mcp-1.14.0}/README.md +0 -0
  12. {fenix_mcp-1.12.0 → fenix_mcp-1.14.0}/fenix_mcp/application/presenters.py +0 -0
  13. {fenix_mcp-1.12.0 → fenix_mcp-1.14.0}/fenix_mcp/application/tool_base.py +0 -0
  14. {fenix_mcp-1.12.0 → fenix_mcp-1.14.0}/fenix_mcp/application/tool_registry.py +0 -0
  15. {fenix_mcp-1.12.0 → fenix_mcp-1.14.0}/fenix_mcp/application/tools/__init__.py +0 -0
  16. {fenix_mcp-1.12.0 → fenix_mcp-1.14.0}/fenix_mcp/application/tools/health.py +0 -0
  17. {fenix_mcp-1.12.0 → fenix_mcp-1.14.0}/fenix_mcp/application/tools/initialize.py +0 -0
  18. {fenix_mcp-1.12.0 → fenix_mcp-1.14.0}/fenix_mcp/application/tools/intelligence.py +0 -0
  19. {fenix_mcp-1.12.0 → fenix_mcp-1.14.0}/fenix_mcp/application/tools/productivity.py +0 -0
  20. {fenix_mcp-1.12.0 → fenix_mcp-1.14.0}/fenix_mcp/application/tools/user_config.py +0 -0
  21. {fenix_mcp-1.12.0 → fenix_mcp-1.14.0}/fenix_mcp/domain/initialization.py +0 -0
  22. {fenix_mcp-1.12.0 → fenix_mcp-1.14.0}/fenix_mcp/domain/intelligence.py +0 -0
  23. {fenix_mcp-1.12.0 → fenix_mcp-1.14.0}/fenix_mcp/domain/productivity.py +0 -0
  24. {fenix_mcp-1.12.0 → fenix_mcp-1.14.0}/fenix_mcp/domain/user_config.py +0 -0
  25. {fenix_mcp-1.12.0 → fenix_mcp-1.14.0}/fenix_mcp/infrastructure/config.py +0 -0
  26. {fenix_mcp-1.12.0 → fenix_mcp-1.14.0}/fenix_mcp/infrastructure/context.py +0 -0
  27. {fenix_mcp-1.12.0 → fenix_mcp-1.14.0}/fenix_mcp/infrastructure/logging.py +0 -0
  28. {fenix_mcp-1.12.0 → fenix_mcp-1.14.0}/fenix_mcp/interface/mcp_server.py +0 -0
  29. {fenix_mcp-1.12.0 → fenix_mcp-1.14.0}/fenix_mcp/main.py +0 -0
  30. {fenix_mcp-1.12.0 → fenix_mcp-1.14.0}/fenix_mcp.egg-info/dependency_links.txt +0 -0
  31. {fenix_mcp-1.12.0 → fenix_mcp-1.14.0}/fenix_mcp.egg-info/entry_points.txt +0 -0
  32. {fenix_mcp-1.12.0 → fenix_mcp-1.14.0}/fenix_mcp.egg-info/requires.txt +0 -0
  33. {fenix_mcp-1.12.0 → fenix_mcp-1.14.0}/fenix_mcp.egg-info/top_level.txt +0 -0
  34. {fenix_mcp-1.12.0 → fenix_mcp-1.14.0}/pyproject.toml +0 -0
  35. {fenix_mcp-1.12.0 → fenix_mcp-1.14.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fenix-mcp
3
- Version: 1.12.0
3
+ Version: 1.14.0
4
4
  Summary: Fênix Cloud MCP server implemented in Python
5
5
  Author: Fenix Inc
6
6
  Requires-Python: >=3.10
@@ -8,4 +8,4 @@ Fênix Cloud MCP Server (Python edition).
8
8
  __all__ = ["__version__"]
9
9
 
10
10
 
11
- __version__ = "1.12.0"
11
+ __version__ = "1.14.0"
@@ -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 = ("rule_create", "Creates a rule with content and metadata.")
123
- RULE_LIST = ("rule_list", "Lists registered rules.")
124
- RULE_GET = ("rule_get", "Gets rule details.")
125
- RULE_UPDATE = ("rule_update", "Updates an existing rule.")
126
- RULE_DELETE = ("rule_delete", "Removes a rule.")
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 = "Fenix Cloud knowledge operations (Work Items, Boards, Sprints, Modes, Rules, Docs)."
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 or not payload.rule_content:
958
- return text("❌ Provide rule_name and rule_content.")
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
- "description": payload.rule_description,
853
+ "slug": sanitize_null(payload.rule_slug),
854
+ "description": sanitize_null(payload.rule_description),
963
855
  "content": payload.rule_content,
964
- "is_default": payload.rule_is_default,
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
- return_description=payload.return_description,
973
- return_metadata=payload.return_metadata,
974
- return_modes=payload.return_metadata,
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("📋 No rules found.")
869
+ return text("📜 No rules found.")
978
870
  body = "\n\n".join(_format_rule(rule) for rule in rules)
979
- return text(f"📋 **Rules ({len(rules)}):**\n\n{body}")
871
+ return text(f"📜 **Rules ({len(rules)}):**\n\n{body}")
980
872
 
981
873
  if action is KnowledgeAction.RULE_GET:
982
- if not payload.rule_id:
983
- return text("❌ Provide rule_id to get details.")
984
- rule = await self._service.rule_get(
985
- payload.rule_id,
986
- return_description=payload.return_description,
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
- if not payload.rule_id:
994
- return text("❌ Provide rule_id to update.")
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
- payload.rule_id,
885
+ rule_id,
997
886
  {
998
- "name": payload.rule_name,
999
- "description": payload.rule_description,
1000
- "content": payload.rule_content,
1001
- "is_default": payload.rule_is_default,
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
- if not payload.rule_id:
1009
- return text("❌ Provide rule_id to remove.")
1010
- await self._service.rule_delete(payload.rule_id)
1011
- return text(f"🗑️ Rule {payload.rule_id} removed.")
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 _format_mode(
1365
- mode: Dict[str, Any],
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"🎭 **{mode.get('name', 'Unnamed')}**",
1377
- f"ID: {mode.get('id', 'N/A')}",
1378
- f"Default: {mode.get('is_default', False)}",
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 mode.get("description"):
1382
- lines.append(f"Description: {mode['description']}")
1383
- if show_content and mode.get("content"):
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(mode.get("content"))
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"📋 **{rule.get('name', 'Unnamed')}**",
1353
+ f"{scope_emoji} **{name}**",
1403
1354
  f"ID: {rule.get('id', 'N/A')}",
1404
- f"Default: {rule.get('is_default', False)}",
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['description']}")
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 _format_doc(
1417
- doc: Dict[str, Any],
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
- # Show content only when explicitly requested (e.g., doc_get)
1440
- if show_content and doc.get("content"):
1441
- lines.append("")
1442
- lines.append("**Content:**")
1443
- lines.append(doc.get("content"))
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
 
@@ -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
- self._server.set_personal_access_token(token)
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,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fenix-mcp
3
- Version: 1.12.0
3
+ Version: 1.14.0
4
4
  Summary: Fênix Cloud MCP server implemented in Python
5
5
  Author: Fenix Inc
6
6
  Requires-Python: >=3.10
@@ -27,6 +27,7 @@ fenix_mcp/infrastructure/config.py
27
27
  fenix_mcp/infrastructure/context.py
28
28
  fenix_mcp/infrastructure/http_client.py
29
29
  fenix_mcp/infrastructure/logging.py
30
+ fenix_mcp/infrastructure/request_context.py
30
31
  fenix_mcp/infrastructure/fenix_api/client.py
31
32
  fenix_mcp/interface/mcp_server.py
32
33
  fenix_mcp/interface/transports.py
File without changes
File without changes
File without changes
File without changes