raise-cli 2.2.1__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.
- raise_cli/__init__.py +38 -0
- raise_cli/__main__.py +30 -0
- raise_cli/adapters/__init__.py +91 -0
- raise_cli/adapters/declarative/__init__.py +26 -0
- raise_cli/adapters/declarative/adapter.py +267 -0
- raise_cli/adapters/declarative/discovery.py +94 -0
- raise_cli/adapters/declarative/expressions.py +150 -0
- raise_cli/adapters/declarative/reference/__init__.py +1 -0
- raise_cli/adapters/declarative/reference/github.yaml +143 -0
- raise_cli/adapters/declarative/schema.py +98 -0
- raise_cli/adapters/filesystem.py +299 -0
- raise_cli/adapters/mcp_bridge.py +10 -0
- raise_cli/adapters/mcp_confluence.py +246 -0
- raise_cli/adapters/mcp_jira.py +405 -0
- raise_cli/adapters/models.py +205 -0
- raise_cli/adapters/protocols.py +180 -0
- raise_cli/adapters/registry.py +90 -0
- raise_cli/adapters/sync.py +149 -0
- raise_cli/agents/__init__.py +14 -0
- raise_cli/agents/antigravity.yaml +8 -0
- raise_cli/agents/claude.yaml +8 -0
- raise_cli/agents/copilot.yaml +8 -0
- raise_cli/agents/copilot_plugin.py +124 -0
- raise_cli/agents/cursor.yaml +7 -0
- raise_cli/agents/roo.yaml +8 -0
- raise_cli/agents/windsurf.yaml +8 -0
- raise_cli/artifacts/__init__.py +30 -0
- raise_cli/artifacts/models.py +43 -0
- raise_cli/artifacts/reader.py +55 -0
- raise_cli/artifacts/renderer.py +104 -0
- raise_cli/artifacts/story_design.py +69 -0
- raise_cli/artifacts/writer.py +45 -0
- raise_cli/backlog/__init__.py +1 -0
- raise_cli/backlog/sync.py +115 -0
- raise_cli/cli/__init__.py +3 -0
- raise_cli/cli/commands/__init__.py +3 -0
- raise_cli/cli/commands/_resolve.py +153 -0
- raise_cli/cli/commands/adapters.py +362 -0
- raise_cli/cli/commands/artifact.py +137 -0
- raise_cli/cli/commands/backlog.py +333 -0
- raise_cli/cli/commands/base.py +31 -0
- raise_cli/cli/commands/discover.py +551 -0
- raise_cli/cli/commands/docs.py +130 -0
- raise_cli/cli/commands/doctor.py +177 -0
- raise_cli/cli/commands/gate.py +223 -0
- raise_cli/cli/commands/graph.py +1086 -0
- raise_cli/cli/commands/info.py +81 -0
- raise_cli/cli/commands/init.py +746 -0
- raise_cli/cli/commands/journal.py +167 -0
- raise_cli/cli/commands/mcp.py +524 -0
- raise_cli/cli/commands/memory.py +467 -0
- raise_cli/cli/commands/pattern.py +348 -0
- raise_cli/cli/commands/profile.py +59 -0
- raise_cli/cli/commands/publish.py +80 -0
- raise_cli/cli/commands/release.py +338 -0
- raise_cli/cli/commands/session.py +528 -0
- raise_cli/cli/commands/signal.py +410 -0
- raise_cli/cli/commands/skill.py +350 -0
- raise_cli/cli/commands/skill_set.py +145 -0
- raise_cli/cli/error_handler.py +158 -0
- raise_cli/cli/main.py +163 -0
- raise_cli/compat.py +66 -0
- raise_cli/config/__init__.py +41 -0
- raise_cli/config/agent_plugin.py +105 -0
- raise_cli/config/agent_registry.py +233 -0
- raise_cli/config/agents.py +120 -0
- raise_cli/config/ide.py +32 -0
- raise_cli/config/paths.py +379 -0
- raise_cli/config/settings.py +180 -0
- raise_cli/context/__init__.py +42 -0
- raise_cli/context/analyzers/__init__.py +16 -0
- raise_cli/context/analyzers/models.py +36 -0
- raise_cli/context/analyzers/protocol.py +43 -0
- raise_cli/context/analyzers/python.py +292 -0
- raise_cli/context/builder.py +1569 -0
- raise_cli/context/diff.py +213 -0
- raise_cli/context/extractors/__init__.py +13 -0
- raise_cli/context/extractors/skills.py +121 -0
- raise_cli/core/__init__.py +37 -0
- raise_cli/core/files.py +66 -0
- raise_cli/core/text.py +174 -0
- raise_cli/core/tools.py +441 -0
- raise_cli/discovery/__init__.py +50 -0
- raise_cli/discovery/analyzer.py +691 -0
- raise_cli/discovery/drift.py +355 -0
- raise_cli/discovery/scanner.py +1687 -0
- raise_cli/doctor/__init__.py +4 -0
- raise_cli/doctor/checks/__init__.py +1 -0
- raise_cli/doctor/checks/environment.py +110 -0
- raise_cli/doctor/checks/project.py +238 -0
- raise_cli/doctor/fix.py +80 -0
- raise_cli/doctor/models.py +56 -0
- raise_cli/doctor/protocol.py +43 -0
- raise_cli/doctor/registry.py +100 -0
- raise_cli/doctor/report.py +141 -0
- raise_cli/doctor/runner.py +95 -0
- raise_cli/engines/__init__.py +3 -0
- raise_cli/exceptions.py +215 -0
- raise_cli/gates/__init__.py +19 -0
- raise_cli/gates/builtin/__init__.py +1 -0
- raise_cli/gates/builtin/coverage.py +52 -0
- raise_cli/gates/builtin/lint.py +48 -0
- raise_cli/gates/builtin/tests.py +48 -0
- raise_cli/gates/builtin/types.py +48 -0
- raise_cli/gates/models.py +40 -0
- raise_cli/gates/protocol.py +41 -0
- raise_cli/gates/registry.py +141 -0
- raise_cli/governance/__init__.py +11 -0
- raise_cli/governance/extractor.py +412 -0
- raise_cli/governance/models.py +134 -0
- raise_cli/governance/parsers/__init__.py +35 -0
- raise_cli/governance/parsers/_convert.py +38 -0
- raise_cli/governance/parsers/adr.py +274 -0
- raise_cli/governance/parsers/backlog.py +356 -0
- raise_cli/governance/parsers/constitution.py +119 -0
- raise_cli/governance/parsers/epic.py +323 -0
- raise_cli/governance/parsers/glossary.py +316 -0
- raise_cli/governance/parsers/guardrails.py +345 -0
- raise_cli/governance/parsers/prd.py +112 -0
- raise_cli/governance/parsers/roadmap.py +118 -0
- raise_cli/governance/parsers/vision.py +116 -0
- raise_cli/graph/__init__.py +1 -0
- raise_cli/graph/backends/__init__.py +57 -0
- raise_cli/graph/backends/api.py +137 -0
- raise_cli/graph/backends/dual.py +139 -0
- raise_cli/graph/backends/pending.py +84 -0
- raise_cli/handlers/__init__.py +3 -0
- raise_cli/hooks/__init__.py +54 -0
- raise_cli/hooks/builtin/__init__.py +1 -0
- raise_cli/hooks/builtin/backlog.py +216 -0
- raise_cli/hooks/builtin/gate_bridge.py +83 -0
- raise_cli/hooks/builtin/jira_sync.py +127 -0
- raise_cli/hooks/builtin/memory.py +117 -0
- raise_cli/hooks/builtin/telemetry.py +72 -0
- raise_cli/hooks/emitter.py +184 -0
- raise_cli/hooks/events.py +262 -0
- raise_cli/hooks/protocol.py +38 -0
- raise_cli/hooks/registry.py +117 -0
- raise_cli/mcp/__init__.py +33 -0
- raise_cli/mcp/bridge.py +218 -0
- raise_cli/mcp/models.py +43 -0
- raise_cli/mcp/registry.py +77 -0
- raise_cli/mcp/schema.py +41 -0
- raise_cli/memory/__init__.py +58 -0
- raise_cli/memory/loader.py +247 -0
- raise_cli/memory/migration.py +241 -0
- raise_cli/memory/models.py +169 -0
- raise_cli/memory/writer.py +598 -0
- raise_cli/onboarding/__init__.py +103 -0
- raise_cli/onboarding/bootstrap.py +324 -0
- raise_cli/onboarding/claudemd.py +17 -0
- raise_cli/onboarding/conventions.py +742 -0
- raise_cli/onboarding/detection.py +374 -0
- raise_cli/onboarding/governance.py +443 -0
- raise_cli/onboarding/instructions.py +672 -0
- raise_cli/onboarding/manifest.py +201 -0
- raise_cli/onboarding/memory_md.py +399 -0
- raise_cli/onboarding/migration.py +207 -0
- raise_cli/onboarding/profile.py +624 -0
- raise_cli/onboarding/skill_conflict.py +100 -0
- raise_cli/onboarding/skill_manifest.py +176 -0
- raise_cli/onboarding/skills.py +437 -0
- raise_cli/onboarding/workflows.py +101 -0
- raise_cli/output/__init__.py +28 -0
- raise_cli/output/console.py +394 -0
- raise_cli/output/formatters/__init__.py +9 -0
- raise_cli/output/formatters/adapters.py +135 -0
- raise_cli/output/formatters/discover.py +439 -0
- raise_cli/output/formatters/skill.py +298 -0
- raise_cli/publish/__init__.py +3 -0
- raise_cli/publish/changelog.py +80 -0
- raise_cli/publish/check.py +179 -0
- raise_cli/publish/version.py +172 -0
- raise_cli/rai_base/__init__.py +22 -0
- raise_cli/rai_base/framework/__init__.py +7 -0
- raise_cli/rai_base/framework/methodology.yaml +233 -0
- raise_cli/rai_base/governance/__init__.py +1 -0
- raise_cli/rai_base/governance/architecture/__init__.py +1 -0
- raise_cli/rai_base/governance/architecture/domain-model.md +20 -0
- raise_cli/rai_base/governance/architecture/system-context.md +34 -0
- raise_cli/rai_base/governance/architecture/system-design.md +24 -0
- raise_cli/rai_base/governance/backlog.md +8 -0
- raise_cli/rai_base/governance/guardrails.md +17 -0
- raise_cli/rai_base/governance/prd.md +25 -0
- raise_cli/rai_base/governance/vision.md +16 -0
- raise_cli/rai_base/identity/__init__.py +8 -0
- raise_cli/rai_base/identity/core.md +119 -0
- raise_cli/rai_base/identity/perspective.md +119 -0
- raise_cli/rai_base/memory/__init__.py +7 -0
- raise_cli/rai_base/memory/patterns-base.jsonl +55 -0
- raise_cli/schemas/__init__.py +3 -0
- raise_cli/schemas/journal.py +49 -0
- raise_cli/schemas/session_state.py +117 -0
- raise_cli/session/__init__.py +5 -0
- raise_cli/session/bundle.py +820 -0
- raise_cli/session/close.py +268 -0
- raise_cli/session/journal.py +119 -0
- raise_cli/session/resolver.py +126 -0
- raise_cli/session/state.py +187 -0
- raise_cli/skills/__init__.py +44 -0
- raise_cli/skills/locator.py +141 -0
- raise_cli/skills/name_checker.py +199 -0
- raise_cli/skills/parser.py +145 -0
- raise_cli/skills/scaffold.py +212 -0
- raise_cli/skills/schema.py +132 -0
- raise_cli/skills/skillsets.py +195 -0
- raise_cli/skills/validator.py +197 -0
- raise_cli/skills_base/__init__.py +80 -0
- raise_cli/skills_base/contract-template.md +60 -0
- raise_cli/skills_base/preamble.md +37 -0
- raise_cli/skills_base/rai-architecture-review/SKILL.md +137 -0
- raise_cli/skills_base/rai-debug/SKILL.md +171 -0
- raise_cli/skills_base/rai-discover/SKILL.md +167 -0
- raise_cli/skills_base/rai-discover-document/SKILL.md +128 -0
- raise_cli/skills_base/rai-discover-scan/SKILL.md +147 -0
- raise_cli/skills_base/rai-discover-start/SKILL.md +145 -0
- raise_cli/skills_base/rai-discover-validate/SKILL.md +142 -0
- raise_cli/skills_base/rai-docs-update/SKILL.md +142 -0
- raise_cli/skills_base/rai-doctor/SKILL.md +120 -0
- raise_cli/skills_base/rai-epic-close/SKILL.md +165 -0
- raise_cli/skills_base/rai-epic-close/templates/retrospective.md +68 -0
- raise_cli/skills_base/rai-epic-design/SKILL.md +146 -0
- raise_cli/skills_base/rai-epic-design/templates/design.md +24 -0
- raise_cli/skills_base/rai-epic-design/templates/scope.md +76 -0
- raise_cli/skills_base/rai-epic-plan/SKILL.md +153 -0
- raise_cli/skills_base/rai-epic-plan/_references/sequencing-strategies.md +67 -0
- raise_cli/skills_base/rai-epic-plan/templates/plan-section.md +49 -0
- raise_cli/skills_base/rai-epic-run/SKILL.md +208 -0
- raise_cli/skills_base/rai-epic-start/SKILL.md +136 -0
- raise_cli/skills_base/rai-epic-start/templates/brief.md +34 -0
- raise_cli/skills_base/rai-mcp-add/SKILL.md +176 -0
- raise_cli/skills_base/rai-mcp-remove/SKILL.md +120 -0
- raise_cli/skills_base/rai-mcp-status/SKILL.md +147 -0
- raise_cli/skills_base/rai-problem-shape/SKILL.md +138 -0
- raise_cli/skills_base/rai-project-create/SKILL.md +144 -0
- raise_cli/skills_base/rai-project-onboard/SKILL.md +162 -0
- raise_cli/skills_base/rai-quality-review/SKILL.md +189 -0
- raise_cli/skills_base/rai-research/SKILL.md +143 -0
- raise_cli/skills_base/rai-research/references/research-prompt-template.md +317 -0
- raise_cli/skills_base/rai-session-close/SKILL.md +176 -0
- raise_cli/skills_base/rai-session-start/SKILL.md +110 -0
- raise_cli/skills_base/rai-story-close/SKILL.md +198 -0
- raise_cli/skills_base/rai-story-design/SKILL.md +203 -0
- raise_cli/skills_base/rai-story-design/references/tech-design-story-v2.md +293 -0
- raise_cli/skills_base/rai-story-implement/SKILL.md +115 -0
- raise_cli/skills_base/rai-story-plan/SKILL.md +135 -0
- raise_cli/skills_base/rai-story-review/SKILL.md +178 -0
- raise_cli/skills_base/rai-story-run/SKILL.md +282 -0
- raise_cli/skills_base/rai-story-start/SKILL.md +166 -0
- raise_cli/skills_base/rai-story-start/templates/story.md +38 -0
- raise_cli/skills_base/rai-welcome/SKILL.md +134 -0
- raise_cli/telemetry/__init__.py +42 -0
- raise_cli/telemetry/schemas.py +285 -0
- raise_cli/telemetry/writer.py +217 -0
- raise_cli/tier/__init__.py +0 -0
- raise_cli/tier/context.py +134 -0
- raise_cli/viz/__init__.py +7 -0
- raise_cli/viz/generator.py +406 -0
- raise_cli-2.2.1.dist-info/METADATA +433 -0
- raise_cli-2.2.1.dist-info/RECORD +264 -0
- raise_cli-2.2.1.dist-info/WHEEL +4 -0
- raise_cli-2.2.1.dist-info/entry_points.txt +40 -0
- raise_cli-2.2.1.dist-info/licenses/LICENSE +190 -0
- raise_cli-2.2.1.dist-info/licenses/NOTICE +4 -0
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
"""Typed lifecycle event definitions.
|
|
2
|
+
|
|
3
|
+
Each event is a frozen dataclass with a specific payload type.
|
|
4
|
+
Events use frozen dataclasses (not Pydantic) because they are internal
|
|
5
|
+
infrastructure, not boundary objects.
|
|
6
|
+
|
|
7
|
+
Architecture: ADR-039 §2 (Typed events as frozen dataclasses)
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from datetime import UTC, datetime
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Literal
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _now_utc() -> datetime:
|
|
19
|
+
"""Return current UTC datetime."""
|
|
20
|
+
return datetime.now(UTC)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class HookEvent:
|
|
25
|
+
"""Base class for all lifecycle events.
|
|
26
|
+
|
|
27
|
+
Subclasses set ``event_name`` via a class-level default with ``init=False``.
|
|
28
|
+
``timestamp`` defaults to now(UTC) but can be overridden for testing.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
event_name: str = field(init=False, default="")
|
|
32
|
+
timestamp: datetime = field(default_factory=_now_utc)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class HookResult:
|
|
37
|
+
"""Result returned by a hook handler.
|
|
38
|
+
|
|
39
|
+
Attributes:
|
|
40
|
+
status: ``ok`` (success), ``abort`` (request operation abort,
|
|
41
|
+
only valid for ``before:`` events), or ``error`` (handler failed).
|
|
42
|
+
message: Human-readable detail (required for abort/error).
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
status: Literal["ok", "abort", "error"] = "ok"
|
|
46
|
+
message: str = ""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True)
|
|
50
|
+
class EmitResult:
|
|
51
|
+
"""Aggregate result from dispatching an event to all handlers.
|
|
52
|
+
|
|
53
|
+
Attributes:
|
|
54
|
+
aborted: True if a handler requested abort on a ``before:`` event.
|
|
55
|
+
abort_message: Reason for abort.
|
|
56
|
+
handler_errors: Error messages from handlers that raised exceptions.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
aborted: bool = False
|
|
60
|
+
abort_message: str = ""
|
|
61
|
+
handler_errors: tuple[str, ...] = ()
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# ---------------------------------------------------------------------------
|
|
65
|
+
# Concrete events
|
|
66
|
+
# ---------------------------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass(frozen=True)
|
|
70
|
+
class SessionStartEvent(HookEvent):
|
|
71
|
+
"""Emitted after a session starts."""
|
|
72
|
+
|
|
73
|
+
event_name: Literal["session:start"] = field( # type: ignore[assignment]
|
|
74
|
+
default="session:start", init=False
|
|
75
|
+
)
|
|
76
|
+
session_id: str = ""
|
|
77
|
+
developer: str = ""
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass(frozen=True)
|
|
81
|
+
class SessionCloseEvent(HookEvent):
|
|
82
|
+
"""Emitted after a session closes."""
|
|
83
|
+
|
|
84
|
+
event_name: Literal["session:close"] = field( # type: ignore[assignment]
|
|
85
|
+
default="session:close", init=False
|
|
86
|
+
)
|
|
87
|
+
session_id: str = ""
|
|
88
|
+
outcome: str = ""
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass(frozen=True)
|
|
92
|
+
class GraphBuildEvent(HookEvent):
|
|
93
|
+
"""Emitted after a graph build completes."""
|
|
94
|
+
|
|
95
|
+
event_name: Literal["graph:build"] = field( # type: ignore[assignment]
|
|
96
|
+
default="graph:build", init=False
|
|
97
|
+
)
|
|
98
|
+
project_path: Path = field(default_factory=lambda: Path("."))
|
|
99
|
+
node_count: int = 0
|
|
100
|
+
edge_count: int = 0
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@dataclass(frozen=True)
|
|
104
|
+
class PatternAddedEvent(HookEvent):
|
|
105
|
+
"""Emitted after a pattern is added to memory."""
|
|
106
|
+
|
|
107
|
+
event_name: Literal["pattern:added"] = field( # type: ignore[assignment]
|
|
108
|
+
default="pattern:added", init=False
|
|
109
|
+
)
|
|
110
|
+
pattern_id: str = ""
|
|
111
|
+
content: str = ""
|
|
112
|
+
context: str = ""
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@dataclass(frozen=True)
|
|
116
|
+
class DiscoverScanEvent(HookEvent):
|
|
117
|
+
"""Emitted after a discovery scan completes."""
|
|
118
|
+
|
|
119
|
+
event_name: Literal["discover:scan"] = field( # type: ignore[assignment]
|
|
120
|
+
default="discover:scan", init=False
|
|
121
|
+
)
|
|
122
|
+
project_path: Path = field(default_factory=lambda: Path("."))
|
|
123
|
+
language: str = ""
|
|
124
|
+
component_count: int = 0
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@dataclass(frozen=True)
|
|
128
|
+
class InitCompleteEvent(HookEvent):
|
|
129
|
+
"""Emitted after project initialization completes."""
|
|
130
|
+
|
|
131
|
+
event_name: Literal["init:complete"] = field( # type: ignore[assignment]
|
|
132
|
+
default="init:complete", init=False
|
|
133
|
+
)
|
|
134
|
+
project_path: Path = field(default_factory=lambda: Path("."))
|
|
135
|
+
project_name: str = ""
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@dataclass(frozen=True)
|
|
139
|
+
class AdapterLoadedEvent(HookEvent):
|
|
140
|
+
"""Emitted after an adapter is successfully loaded from entry points."""
|
|
141
|
+
|
|
142
|
+
event_name: Literal["adapter:loaded"] = field( # type: ignore[assignment]
|
|
143
|
+
default="adapter:loaded", init=False
|
|
144
|
+
)
|
|
145
|
+
adapter_name: str = ""
|
|
146
|
+
group: str = ""
|
|
147
|
+
adapter_type: str = ""
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
@dataclass(frozen=True)
|
|
151
|
+
class AdapterFailedEvent(HookEvent):
|
|
152
|
+
"""Emitted when an adapter fails to load from entry points."""
|
|
153
|
+
|
|
154
|
+
event_name: Literal["adapter:failed"] = field( # type: ignore[assignment]
|
|
155
|
+
default="adapter:failed", init=False
|
|
156
|
+
)
|
|
157
|
+
adapter_name: str = ""
|
|
158
|
+
group: str = ""
|
|
159
|
+
error: str = ""
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
@dataclass(frozen=True)
|
|
163
|
+
class ReleasePublishEvent(HookEvent):
|
|
164
|
+
"""Emitted after a release is published."""
|
|
165
|
+
|
|
166
|
+
event_name: Literal["release:publish"] = field( # type: ignore[assignment]
|
|
167
|
+
default="release:publish", init=False
|
|
168
|
+
)
|
|
169
|
+
version: str = ""
|
|
170
|
+
project_path: Path = field(default_factory=lambda: Path("."))
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
@dataclass(frozen=True)
|
|
174
|
+
class WorkLifecycleEvent(HookEvent):
|
|
175
|
+
"""Emitted when a work lifecycle signal is recorded.
|
|
176
|
+
|
|
177
|
+
Bridges ``rai signal emit-work`` to the hook system so hooks like
|
|
178
|
+
BacklogHook can react to story/epic lifecycle transitions.
|
|
179
|
+
"""
|
|
180
|
+
|
|
181
|
+
event_name: Literal["work:lifecycle"] = field( # type: ignore[assignment]
|
|
182
|
+
default="work:lifecycle", init=False
|
|
183
|
+
)
|
|
184
|
+
work_type: str = "" # "story" or "epic"
|
|
185
|
+
work_id: str = "" # e.g. "S325.4", "E325"
|
|
186
|
+
event: str = "" # "start", "complete", "blocked", etc.
|
|
187
|
+
phase: str = "" # "design", "plan", "implement", "review", "close"
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
# ---------------------------------------------------------------------------
|
|
191
|
+
# Work lifecycle events (S301.6: auto-sync hooks)
|
|
192
|
+
# ---------------------------------------------------------------------------
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
@dataclass(frozen=True)
|
|
196
|
+
class WorkStartEvent(HookEvent):
|
|
197
|
+
"""Emitted when a work item (story/epic) starts."""
|
|
198
|
+
|
|
199
|
+
event_name: Literal["work:start"] = field( # type: ignore[assignment]
|
|
200
|
+
default="work:start", init=False
|
|
201
|
+
)
|
|
202
|
+
work_type: str = ""
|
|
203
|
+
work_id: str = ""
|
|
204
|
+
issue_key: str = ""
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
@dataclass(frozen=True)
|
|
208
|
+
class WorkCloseEvent(HookEvent):
|
|
209
|
+
"""Emitted when a work item (story/epic) closes."""
|
|
210
|
+
|
|
211
|
+
event_name: Literal["work:close"] = field( # type: ignore[assignment]
|
|
212
|
+
default="work:close", init=False
|
|
213
|
+
)
|
|
214
|
+
work_type: str = ""
|
|
215
|
+
work_id: str = ""
|
|
216
|
+
issue_key: str = ""
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
# ---------------------------------------------------------------------------
|
|
220
|
+
# Before-variant events (AD-6: only release:publish and session:close)
|
|
221
|
+
# ---------------------------------------------------------------------------
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
@dataclass(frozen=True)
|
|
225
|
+
class BeforeSessionCloseEvent(HookEvent):
|
|
226
|
+
"""Emitted before a session closes. Handlers can abort."""
|
|
227
|
+
|
|
228
|
+
event_name: Literal["before:session:close"] = field( # type: ignore[assignment]
|
|
229
|
+
default="before:session:close", init=False
|
|
230
|
+
)
|
|
231
|
+
session_id: str = ""
|
|
232
|
+
outcome: str = ""
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
@dataclass(frozen=True)
|
|
236
|
+
class BeforeReleasePublishEvent(HookEvent):
|
|
237
|
+
"""Emitted before a release is published. Handlers can abort."""
|
|
238
|
+
|
|
239
|
+
event_name: Literal["before:release:publish"] = field( # type: ignore[assignment]
|
|
240
|
+
default="before:release:publish", init=False
|
|
241
|
+
)
|
|
242
|
+
version: str = ""
|
|
243
|
+
project_path: Path = field(default_factory=lambda: Path("."))
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
# ---------------------------------------------------------------------------
|
|
247
|
+
# MCP events (E338: MCP Platform)
|
|
248
|
+
# ---------------------------------------------------------------------------
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
@dataclass(frozen=True)
|
|
252
|
+
class McpCallEvent(HookEvent):
|
|
253
|
+
"""Emitted after an MCP tool call completes (success or failure)."""
|
|
254
|
+
|
|
255
|
+
event_name: Literal["mcp:call"] = field( # type: ignore[assignment]
|
|
256
|
+
default="mcp:call", init=False
|
|
257
|
+
)
|
|
258
|
+
server: str = ""
|
|
259
|
+
tool: str = ""
|
|
260
|
+
success: bool = True
|
|
261
|
+
latency_ms: int = 0
|
|
262
|
+
error: str = ""
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""LifecycleHook Protocol — contract for hook implementations.
|
|
2
|
+
|
|
3
|
+
Hooks react to CLI lifecycle events (telemetry, notifications, compliance).
|
|
4
|
+
A hook failure is logged and skipped — hooks never crash the CLI.
|
|
5
|
+
|
|
6
|
+
Architecture: ADR-039 §1 (LifecycleHook Protocol)
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import ClassVar, Protocol, runtime_checkable
|
|
12
|
+
|
|
13
|
+
from raise_cli.hooks.events import HookEvent, HookResult
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@runtime_checkable
|
|
17
|
+
class LifecycleHook(Protocol):
|
|
18
|
+
"""Contract for lifecycle hook implementations.
|
|
19
|
+
|
|
20
|
+
Attributes:
|
|
21
|
+
events: Event names this hook subscribes to (e.g. ``["session:start"]``).
|
|
22
|
+
priority: Dispatch order — higher runs first. Default ``0``.
|
|
23
|
+
|
|
24
|
+
Example::
|
|
25
|
+
|
|
26
|
+
class TelemetryHook:
|
|
27
|
+
events = ["session:start", "graph:build"]
|
|
28
|
+
priority = 0
|
|
29
|
+
|
|
30
|
+
def handle(self, event: HookEvent) -> HookResult:
|
|
31
|
+
# write to signals.jsonl...
|
|
32
|
+
return HookResult(status="ok")
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
events: ClassVar[list[str]]
|
|
36
|
+
priority: ClassVar[int]
|
|
37
|
+
|
|
38
|
+
def handle(self, event: HookEvent) -> HookResult: ...
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Hook registry with entry point discovery.
|
|
2
|
+
|
|
3
|
+
Discovers LifecycleHook implementations registered via Python entry points
|
|
4
|
+
(``[project.entry-points."rai.hooks"]`` in pyproject.toml). Validates
|
|
5
|
+
Protocol conformance before accepting hooks.
|
|
6
|
+
|
|
7
|
+
Architecture: ADR-039 §3 (Entry point discovery, same as RAISE-211)
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import inspect
|
|
13
|
+
import logging
|
|
14
|
+
from importlib.metadata import entry_points
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from raise_cli.hooks.protocol import LifecycleHook
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
EP_HOOKS: str = "rai.hooks"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _dist_name(ep: Any) -> str:
|
|
25
|
+
"""Best-effort extraction of the distribution name for an entry point."""
|
|
26
|
+
try:
|
|
27
|
+
return ep.dist.name # type: ignore[union-attr]
|
|
28
|
+
except AttributeError:
|
|
29
|
+
return "unknown"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class HookRegistry:
|
|
33
|
+
"""Discovers and manages LifecycleHook implementations.
|
|
34
|
+
|
|
35
|
+
Example::
|
|
36
|
+
|
|
37
|
+
registry = HookRegistry()
|
|
38
|
+
registry.discover() # loads from rai.hooks entry points
|
|
39
|
+
|
|
40
|
+
for hook in registry.hooks:
|
|
41
|
+
print(f"{hook.__class__.__name__}: priority={hook.priority}")
|
|
42
|
+
|
|
43
|
+
handlers = registry.get_hooks_for_event("session:start")
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
def __init__(self) -> None:
|
|
47
|
+
self._hooks: list[LifecycleHook] = []
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def hooks(self) -> list[LifecycleHook]:
|
|
51
|
+
"""Return a copy of registered hooks."""
|
|
52
|
+
return list(self._hooks)
|
|
53
|
+
|
|
54
|
+
def discover(self) -> None:
|
|
55
|
+
"""Load hooks from ``rai.hooks`` entry points.
|
|
56
|
+
|
|
57
|
+
Skips entry points that:
|
|
58
|
+
- Fail to load (ImportError, etc.)
|
|
59
|
+
- Are not classes
|
|
60
|
+
- Don't conform to the LifecycleHook Protocol
|
|
61
|
+
"""
|
|
62
|
+
for ep in entry_points(group=EP_HOOKS):
|
|
63
|
+
try:
|
|
64
|
+
loaded: Any = ep.load()
|
|
65
|
+
except Exception as exc: # noqa: BLE001
|
|
66
|
+
logger.warning(
|
|
67
|
+
"Skipping hook entry point '%s' from '%s': %s",
|
|
68
|
+
ep.name,
|
|
69
|
+
_dist_name(ep),
|
|
70
|
+
exc,
|
|
71
|
+
)
|
|
72
|
+
continue
|
|
73
|
+
|
|
74
|
+
if not inspect.isclass(loaded):
|
|
75
|
+
logger.warning(
|
|
76
|
+
"Skipping hook entry point '%s' from '%s': expected a class, got %s",
|
|
77
|
+
ep.name,
|
|
78
|
+
_dist_name(ep),
|
|
79
|
+
type(loaded).__name__,
|
|
80
|
+
)
|
|
81
|
+
continue
|
|
82
|
+
|
|
83
|
+
instance = loaded()
|
|
84
|
+
if not isinstance(instance, LifecycleHook):
|
|
85
|
+
logger.warning(
|
|
86
|
+
"Skipping hook entry point '%s' from '%s': "
|
|
87
|
+
"does not conform to LifecycleHook Protocol",
|
|
88
|
+
ep.name,
|
|
89
|
+
_dist_name(ep),
|
|
90
|
+
)
|
|
91
|
+
continue
|
|
92
|
+
|
|
93
|
+
self._hooks.append(instance)
|
|
94
|
+
logger.debug(
|
|
95
|
+
"Loaded hook '%s' (priority=%d, events=%s)",
|
|
96
|
+
ep.name,
|
|
97
|
+
instance.priority,
|
|
98
|
+
instance.events,
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
def register(self, hook: LifecycleHook | Any) -> None:
|
|
102
|
+
"""Manually register a hook instance (useful for testing).
|
|
103
|
+
|
|
104
|
+
Silently skips non-compliant objects.
|
|
105
|
+
"""
|
|
106
|
+
if not isinstance(hook, LifecycleHook):
|
|
107
|
+
logger.warning(
|
|
108
|
+
"Skipping manual hook registration: %s does not conform to LifecycleHook Protocol",
|
|
109
|
+
type(hook).__name__,
|
|
110
|
+
)
|
|
111
|
+
return
|
|
112
|
+
self._hooks.append(hook)
|
|
113
|
+
|
|
114
|
+
def get_hooks_for_event(self, event_name: str) -> list[LifecycleHook]:
|
|
115
|
+
"""Return hooks subscribed to ``event_name``, sorted by priority (highest first)."""
|
|
116
|
+
matching = [h for h in self._hooks if event_name in h.events]
|
|
117
|
+
return sorted(matching, key=lambda h: h.priority, reverse=True)
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""MCP infrastructure layer — independent of domain adapters.
|
|
2
|
+
|
|
3
|
+
Provides generic MCP server management: bridge, models, schema, registry.
|
|
4
|
+
Domain adapters (PM, Docs) live in ``raise_cli.adapters`` and consume this layer.
|
|
5
|
+
|
|
6
|
+
Architecture: ADR-042, E338
|
|
7
|
+
|
|
8
|
+
Note: Bridge imports are lazy because the ``mcp`` SDK and ``logfire-api``
|
|
9
|
+
are optional dependencies. Eager import would crash CLI startup when
|
|
10
|
+
these packages are not installed.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from typing import TYPE_CHECKING
|
|
16
|
+
|
|
17
|
+
from raise_cli.mcp.models import McpHealthResult, McpToolInfo, McpToolResult
|
|
18
|
+
from raise_cli.mcp.registry import discover_mcp_servers
|
|
19
|
+
from raise_cli.mcp.schema import McpServerConfig, ServerConnection
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from raise_cli.mcp.bridge import McpBridge, McpBridgeError
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"McpBridge",
|
|
26
|
+
"McpBridgeError",
|
|
27
|
+
"McpHealthResult",
|
|
28
|
+
"McpServerConfig",
|
|
29
|
+
"McpToolInfo",
|
|
30
|
+
"McpToolResult",
|
|
31
|
+
"ServerConnection",
|
|
32
|
+
"discover_mcp_servers",
|
|
33
|
+
]
|
raise_cli/mcp/bridge.py
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"""Generic MCP server bridge using the official MCP Python SDK.
|
|
2
|
+
|
|
3
|
+
Manages server process lifecycle, async session, and RaiSE telemetry.
|
|
4
|
+
Works with any MCP server via stdio transport.
|
|
5
|
+
|
|
6
|
+
Architecture: E301 epic design (D1, D3, D4), moved to raise_cli.mcp (ADR-042, E338)
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import contextlib
|
|
12
|
+
import json
|
|
13
|
+
import logging
|
|
14
|
+
import subprocess
|
|
15
|
+
import time
|
|
16
|
+
from contextlib import AsyncExitStack
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
try:
|
|
20
|
+
import logfire_api as logfire
|
|
21
|
+
except ModuleNotFoundError: # pragma: no cover
|
|
22
|
+
logfire = None # type: ignore[assignment]
|
|
23
|
+
from mcp import ClientSession, StdioServerParameters
|
|
24
|
+
from mcp.client.stdio import stdio_client
|
|
25
|
+
from mcp.types import TextContent
|
|
26
|
+
|
|
27
|
+
from raise_cli.mcp.models import McpHealthResult, McpToolInfo, McpToolResult
|
|
28
|
+
|
|
29
|
+
logger = logging.getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class McpBridgeError(Exception):
|
|
33
|
+
"""Raised when MCP bridge operations fail."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class McpBridge:
|
|
37
|
+
"""Generic async bridge to any MCP server via official Python SDK.
|
|
38
|
+
|
|
39
|
+
Manages server process lifecycle, session, and RaiSE telemetry.
|
|
40
|
+
Session is lazy — created on first call, reused if alive,
|
|
41
|
+
reconnected if dead.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
server_command: Command to start MCP server (e.g. "mcp-atlassian").
|
|
45
|
+
server_args: Optional args for the server command.
|
|
46
|
+
env: Optional environment variables. None = inherit parent env.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
def __init__(
|
|
50
|
+
self,
|
|
51
|
+
server_command: str,
|
|
52
|
+
server_args: list[str] | None = None,
|
|
53
|
+
env: dict[str, str] | None = None,
|
|
54
|
+
) -> None:
|
|
55
|
+
self._server_command = server_command
|
|
56
|
+
self._server_args = server_args or []
|
|
57
|
+
self._env = env # None = subprocess inherits os.environ
|
|
58
|
+
self._session: ClientSession | None = None
|
|
59
|
+
self._cm_stack: AsyncExitStack | None = None
|
|
60
|
+
|
|
61
|
+
async def call(self, tool_name: str, arguments: dict[str, Any]) -> McpToolResult:
|
|
62
|
+
"""Call a tool on the MCP server.
|
|
63
|
+
|
|
64
|
+
Wraps ClientSession.call_tool() with telemetry and error handling.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
tool_name: MCP tool name (e.g. "jira_get_issue").
|
|
68
|
+
arguments: Tool arguments dict.
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
Parsed McpToolResult.
|
|
72
|
+
|
|
73
|
+
Raises:
|
|
74
|
+
McpBridgeError: On connection or tool call failure.
|
|
75
|
+
"""
|
|
76
|
+
span_cm = (
|
|
77
|
+
logfire.span(
|
|
78
|
+
"mcp.tool_call",
|
|
79
|
+
mcp_server=self._server_command,
|
|
80
|
+
mcp_tool=tool_name,
|
|
81
|
+
)
|
|
82
|
+
if logfire is not None
|
|
83
|
+
else contextlib.nullcontext()
|
|
84
|
+
)
|
|
85
|
+
with span_cm as span:
|
|
86
|
+
start = time.monotonic()
|
|
87
|
+
session = await self._ensure_session()
|
|
88
|
+
try:
|
|
89
|
+
result = await session.call_tool(tool_name, arguments)
|
|
90
|
+
elapsed_ms = int((time.monotonic() - start) * 1000)
|
|
91
|
+
if span is not None:
|
|
92
|
+
span.set_attribute("duration_ms", elapsed_ms)
|
|
93
|
+
span.set_attribute("success", not result.isError)
|
|
94
|
+
return self._parse_result(result)
|
|
95
|
+
except Exception as exc:
|
|
96
|
+
elapsed_ms = int((time.monotonic() - start) * 1000)
|
|
97
|
+
if span is not None:
|
|
98
|
+
span.set_attribute("duration_ms", elapsed_ms)
|
|
99
|
+
span.set_attribute("success", False)
|
|
100
|
+
raise McpBridgeError(f"Tool call '{tool_name}' failed: {exc}") from exc
|
|
101
|
+
|
|
102
|
+
async def list_tools(self) -> list[McpToolInfo]:
|
|
103
|
+
"""List available tools on the server."""
|
|
104
|
+
session = await self._ensure_session()
|
|
105
|
+
result = await session.list_tools()
|
|
106
|
+
return [
|
|
107
|
+
McpToolInfo(name=t.name, description=t.description or "")
|
|
108
|
+
for t in result.tools
|
|
109
|
+
]
|
|
110
|
+
|
|
111
|
+
async def health(self) -> McpHealthResult:
|
|
112
|
+
"""Check server connectivity and tool availability."""
|
|
113
|
+
start = time.monotonic()
|
|
114
|
+
try:
|
|
115
|
+
tools = await self.list_tools()
|
|
116
|
+
elapsed_ms = int((time.monotonic() - start) * 1000)
|
|
117
|
+
return McpHealthResult(
|
|
118
|
+
server_name=self._server_command,
|
|
119
|
+
healthy=True,
|
|
120
|
+
message=f"OK, {len(tools)} tools",
|
|
121
|
+
latency_ms=elapsed_ms,
|
|
122
|
+
tool_count=len(tools),
|
|
123
|
+
)
|
|
124
|
+
except Exception as exc:
|
|
125
|
+
elapsed_ms = int((time.monotonic() - start) * 1000)
|
|
126
|
+
return McpHealthResult(
|
|
127
|
+
server_name=self._server_command,
|
|
128
|
+
healthy=False,
|
|
129
|
+
message=str(exc),
|
|
130
|
+
latency_ms=elapsed_ms,
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
async def _ensure_session(self) -> ClientSession:
|
|
134
|
+
"""Lazy connect. Reconnects if session is closed/dead."""
|
|
135
|
+
if self._session is not None:
|
|
136
|
+
try:
|
|
137
|
+
await self._session.list_tools()
|
|
138
|
+
return self._session
|
|
139
|
+
except Exception:
|
|
140
|
+
await self._cleanup()
|
|
141
|
+
|
|
142
|
+
params = StdioServerParameters(
|
|
143
|
+
command=self._server_command,
|
|
144
|
+
args=self._server_args,
|
|
145
|
+
env=self._env,
|
|
146
|
+
)
|
|
147
|
+
stack = AsyncExitStack()
|
|
148
|
+
try:
|
|
149
|
+
# Redirect MCP server stderr to devnull to suppress banner/warning noise.
|
|
150
|
+
# Server errors are captured via MCP protocol (isError), not stderr.
|
|
151
|
+
# subprocess.DEVNULL is an int constant (-3) accepted by anyio.open_process;
|
|
152
|
+
# avoids sync open() in async context (RAISE-436).
|
|
153
|
+
read, write = await stack.enter_async_context(
|
|
154
|
+
stdio_client(params, errlog=subprocess.DEVNULL) # type: ignore[arg-type]
|
|
155
|
+
)
|
|
156
|
+
session = await stack.enter_async_context(ClientSession(read, write))
|
|
157
|
+
await session.initialize()
|
|
158
|
+
except FileNotFoundError as exc:
|
|
159
|
+
await stack.aclose()
|
|
160
|
+
raise McpBridgeError(
|
|
161
|
+
f"MCP server '{self._server_command}' not found. "
|
|
162
|
+
f"Install the server and ensure it's on PATH."
|
|
163
|
+
) from exc
|
|
164
|
+
except Exception as exc:
|
|
165
|
+
await stack.aclose()
|
|
166
|
+
raise McpBridgeError(
|
|
167
|
+
f"Failed to connect to MCP server '{self._server_command}': {exc}"
|
|
168
|
+
) from exc
|
|
169
|
+
|
|
170
|
+
self._session = session
|
|
171
|
+
self._cm_stack = stack
|
|
172
|
+
return session
|
|
173
|
+
|
|
174
|
+
async def aclose(self) -> None:
|
|
175
|
+
"""Close session and exit stack.
|
|
176
|
+
|
|
177
|
+
Must be called within the same event loop that created the session.
|
|
178
|
+
Prevents asyncgen finalizer tracebacks from stdio_client (RAISE-324).
|
|
179
|
+
"""
|
|
180
|
+
if self._cm_stack:
|
|
181
|
+
with contextlib.suppress(Exception):
|
|
182
|
+
await self._cm_stack.aclose()
|
|
183
|
+
self._session = None
|
|
184
|
+
self._cm_stack = None
|
|
185
|
+
|
|
186
|
+
async def _cleanup(self) -> None:
|
|
187
|
+
"""Alias for aclose() — used internally by _ensure_session."""
|
|
188
|
+
await self.aclose()
|
|
189
|
+
|
|
190
|
+
@staticmethod
|
|
191
|
+
def _parse_result(result: Any) -> McpToolResult:
|
|
192
|
+
"""Parse CallToolResult into McpToolResult.
|
|
193
|
+
|
|
194
|
+
Handles: empty content, multiple text items, non-text content,
|
|
195
|
+
isError flag, and JSON auto-parsing.
|
|
196
|
+
"""
|
|
197
|
+
if result.isError:
|
|
198
|
+
texts = [c.text for c in result.content if isinstance(c, TextContent)]
|
|
199
|
+
error_text = "\n".join(texts) if texts else "Unknown error"
|
|
200
|
+
return McpToolResult(is_error=True, error_message=error_text)
|
|
201
|
+
|
|
202
|
+
# Collect all text content, ignore non-text
|
|
203
|
+
texts = [c.text for c in result.content if isinstance(c, TextContent)]
|
|
204
|
+
text = "\n".join(texts) if texts else ""
|
|
205
|
+
|
|
206
|
+
# Try parse as JSON for structured access
|
|
207
|
+
data: dict[str, Any] = {}
|
|
208
|
+
if text:
|
|
209
|
+
try:
|
|
210
|
+
parsed: Any = json.loads(text)
|
|
211
|
+
if isinstance(parsed, dict):
|
|
212
|
+
data = parsed # type: ignore[assignment]
|
|
213
|
+
elif isinstance(parsed, list):
|
|
214
|
+
data = {"items": parsed}
|
|
215
|
+
except json.JSONDecodeError:
|
|
216
|
+
pass
|
|
217
|
+
|
|
218
|
+
return McpToolResult(text=text, data=data)
|