minder-cli 0.2.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.
- minder/__init__.py +12 -0
- minder/api/routers/prompts.py +177 -0
- minder/application/__init__.py +1 -0
- minder/application/admin/__init__.py +11 -0
- minder/application/admin/dto.py +453 -0
- minder/application/admin/jobs.py +327 -0
- minder/application/admin/use_cases.py +1895 -0
- minder/auth/__init__.py +12 -0
- minder/auth/context.py +26 -0
- minder/auth/middleware.py +70 -0
- minder/auth/principal.py +59 -0
- minder/auth/rate_limiter.py +89 -0
- minder/auth/rbac.py +60 -0
- minder/auth/service.py +541 -0
- minder/bootstrap/__init__.py +9 -0
- minder/bootstrap/providers.py +109 -0
- minder/bootstrap/transport.py +807 -0
- minder/cache/__init__.py +10 -0
- minder/cache/providers.py +140 -0
- minder/chunking/__init__.py +4 -0
- minder/chunking/code_splitter.py +184 -0
- minder/chunking/splitter.py +136 -0
- minder/cli.py +1542 -0
- minder/config.py +179 -0
- minder/continuity.py +363 -0
- minder/dev.py +160 -0
- minder/embedding/__init__.py +9 -0
- minder/embedding/base.py +7 -0
- minder/embedding/local.py +65 -0
- minder/embedding/openai.py +7 -0
- minder/graph/__init__.py +11 -0
- minder/graph/edges.py +13 -0
- minder/graph/executor.py +127 -0
- minder/graph/graph.py +263 -0
- minder/graph/nodes/__init__.py +27 -0
- minder/graph/nodes/evaluator.py +21 -0
- minder/graph/nodes/guard.py +64 -0
- minder/graph/nodes/llm.py +59 -0
- minder/graph/nodes/planning.py +30 -0
- minder/graph/nodes/reasoning.py +87 -0
- minder/graph/nodes/reranker.py +141 -0
- minder/graph/nodes/retriever.py +86 -0
- minder/graph/nodes/verification.py +230 -0
- minder/graph/nodes/workflow_planner.py +250 -0
- minder/graph/runtime.py +15 -0
- minder/graph/state.py +26 -0
- minder/llm/__init__.py +5 -0
- minder/llm/base.py +14 -0
- minder/llm/local.py +381 -0
- minder/llm/openai.py +89 -0
- minder/models/__init__.py +109 -0
- minder/models/base.py +10 -0
- minder/models/client.py +137 -0
- minder/models/document.py +34 -0
- minder/models/error.py +32 -0
- minder/models/graph.py +114 -0
- minder/models/history.py +32 -0
- minder/models/job.py +62 -0
- minder/models/prompt.py +41 -0
- minder/models/repository.py +62 -0
- minder/models/rule.py +68 -0
- minder/models/session.py +51 -0
- minder/models/skill.py +52 -0
- minder/models/user.py +41 -0
- minder/models/workflow.py +35 -0
- minder/observability/__init__.py +57 -0
- minder/observability/audit.py +243 -0
- minder/observability/logging.py +253 -0
- minder/observability/metrics.py +448 -0
- minder/observability/tracing.py +215 -0
- minder/presentation/__init__.py +1 -0
- minder/presentation/http/__init__.py +1 -0
- minder/presentation/http/admin/__init__.py +3 -0
- minder/presentation/http/admin/api.py +1309 -0
- minder/presentation/http/admin/context.py +94 -0
- minder/presentation/http/admin/dashboard.py +111 -0
- minder/presentation/http/admin/jobs.py +208 -0
- minder/presentation/http/admin/memories.py +185 -0
- minder/presentation/http/admin/prompts.py +219 -0
- minder/presentation/http/admin/routes.py +127 -0
- minder/presentation/http/admin/runtime.py +650 -0
- minder/presentation/http/admin/search.py +368 -0
- minder/presentation/http/admin/skills.py +230 -0
- minder/prompts/__init__.py +646 -0
- minder/prompts/formatter.py +142 -0
- minder/resources/__init__.py +318 -0
- minder/retrieval/__init__.py +5 -0
- minder/retrieval/hybrid.py +178 -0
- minder/retrieval/mmr.py +116 -0
- minder/retrieval/multi_hop.py +115 -0
- minder/runtime.py +15 -0
- minder/server.py +145 -0
- minder/store/__init__.py +64 -0
- minder/store/document.py +115 -0
- minder/store/error.py +82 -0
- minder/store/feedback.py +114 -0
- minder/store/graph.py +588 -0
- minder/store/history.py +57 -0
- minder/store/interfaces.py +512 -0
- minder/store/milvus/__init__.py +11 -0
- minder/store/milvus/client.py +26 -0
- minder/store/milvus/collections.py +15 -0
- minder/store/milvus/vector_store.py +232 -0
- minder/store/mongodb/__init__.py +11 -0
- minder/store/mongodb/client.py +49 -0
- minder/store/mongodb/indexes.py +90 -0
- minder/store/mongodb/operational_store.py +993 -0
- minder/store/relational.py +1087 -0
- minder/store/repo_state.py +58 -0
- minder/store/rule.py +93 -0
- minder/store/vector.py +79 -0
- minder/tools/__init__.py +47 -0
- minder/tools/auth.py +94 -0
- minder/tools/graph.py +839 -0
- minder/tools/ingest.py +353 -0
- minder/tools/memory.py +381 -0
- minder/tools/query.py +307 -0
- minder/tools/registry.py +269 -0
- minder/tools/repo_scanner.py +1266 -0
- minder/tools/search.py +15 -0
- minder/tools/session.py +316 -0
- minder/tools/skills.py +899 -0
- minder/tools/workflow.py +215 -0
- minder/transport/__init__.py +4 -0
- minder/transport/base.py +286 -0
- minder/transport/sse.py +252 -0
- minder/transport/stdio.py +29 -0
- minder_cli-0.2.0.dist-info/METADATA +318 -0
- minder_cli-0.2.0.dist-info/RECORD +132 -0
- minder_cli-0.2.0.dist-info/WHEEL +4 -0
- minder_cli-0.2.0.dist-info/entry_points.txt +2 -0
- minder_cli-0.2.0.dist-info/licenses/LICENSE +201 -0
minder/tools/workflow.py
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import uuid
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from minder.continuity import build_continuity_brief, build_instruction_envelope
|
|
7
|
+
from minder.observability.metrics import (
|
|
8
|
+
record_continuity_gate,
|
|
9
|
+
record_continuity_packet,
|
|
10
|
+
)
|
|
11
|
+
from minder.store.interfaces import IOperationalStore
|
|
12
|
+
from minder.store.repo_state import RepoStateStore
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class WorkflowTools:
|
|
16
|
+
def __init__(
|
|
17
|
+
self, store: IOperationalStore, repo_state_store: RepoStateStore
|
|
18
|
+
) -> None:
|
|
19
|
+
self._store = store
|
|
20
|
+
self._repo_state = repo_state_store
|
|
21
|
+
|
|
22
|
+
async def minder_workflow_get(
|
|
23
|
+
self, *, repo_id: uuid.UUID, repo_path: str
|
|
24
|
+
) -> dict[str, Any]:
|
|
25
|
+
repo = await self._require_repo(repo_id)
|
|
26
|
+
workflow = await self._require_workflow(repo.workflow_id)
|
|
27
|
+
state = await self._store.get_workflow_state_by_repo(repo_id)
|
|
28
|
+
relationships = (
|
|
29
|
+
dict(repo.relationships) if isinstance(repo.relationships, dict) else {}
|
|
30
|
+
)
|
|
31
|
+
await self._repo_state.write_relationships(repo_path, relationships)
|
|
32
|
+
if state is not None:
|
|
33
|
+
await self._repo_state.write_workflow_state(
|
|
34
|
+
repo_path,
|
|
35
|
+
{
|
|
36
|
+
"current_step": state.current_step,
|
|
37
|
+
"completed_steps": list(state.completed_steps),
|
|
38
|
+
"blocked_by": list(state.blocked_by),
|
|
39
|
+
"next_step": state.next_step,
|
|
40
|
+
},
|
|
41
|
+
)
|
|
42
|
+
return {
|
|
43
|
+
"workflow": {
|
|
44
|
+
"id": str(workflow.id),
|
|
45
|
+
"name": workflow.name,
|
|
46
|
+
"steps": list(workflow.steps),
|
|
47
|
+
"policies": dict(workflow.policies),
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async def minder_workflow_step(
|
|
52
|
+
self, *, repo_id: uuid.UUID, repo_path: str
|
|
53
|
+
) -> dict[str, Any]:
|
|
54
|
+
repo = await self._require_repo(repo_id)
|
|
55
|
+
workflow = await self._require_workflow(repo.workflow_id)
|
|
56
|
+
state = await self._require_workflow_state(repo_id)
|
|
57
|
+
payload = {
|
|
58
|
+
"current_step": state.current_step,
|
|
59
|
+
"completed_steps": list(state.completed_steps),
|
|
60
|
+
"blocked_by": list(state.blocked_by),
|
|
61
|
+
"next_step": state.next_step,
|
|
62
|
+
"instruction_envelope": build_instruction_envelope(
|
|
63
|
+
workflow=workflow,
|
|
64
|
+
workflow_state=state,
|
|
65
|
+
),
|
|
66
|
+
}
|
|
67
|
+
await self._repo_state.write_workflow_state(repo_path, payload)
|
|
68
|
+
return payload
|
|
69
|
+
|
|
70
|
+
async def minder_workflow_update(
|
|
71
|
+
self,
|
|
72
|
+
*,
|
|
73
|
+
repo_id: uuid.UUID,
|
|
74
|
+
repo_path: str,
|
|
75
|
+
completed_step: str,
|
|
76
|
+
artifact_name: str | None = None,
|
|
77
|
+
artifact_content: str | None = None,
|
|
78
|
+
) -> dict[str, Any]:
|
|
79
|
+
repo = await self._require_repo(repo_id)
|
|
80
|
+
workflow = await self._require_workflow(repo.workflow_id)
|
|
81
|
+
state = await self._require_workflow_state(repo_id)
|
|
82
|
+
step_names = [
|
|
83
|
+
step["name"]
|
|
84
|
+
for step in workflow.steps
|
|
85
|
+
if isinstance(step, dict) and "name" in step
|
|
86
|
+
]
|
|
87
|
+
completed_steps = list(state.completed_steps)
|
|
88
|
+
if completed_step not in completed_steps:
|
|
89
|
+
completed_steps.append(completed_step)
|
|
90
|
+
next_step = self._next_step(completed_step, step_names)
|
|
91
|
+
artifacts = dict(state.artifacts)
|
|
92
|
+
if artifact_name and artifact_content is not None:
|
|
93
|
+
artifacts[artifact_name] = artifact_content
|
|
94
|
+
await self._repo_state.write_artifact(
|
|
95
|
+
repo_path, artifact_name, artifact_content
|
|
96
|
+
)
|
|
97
|
+
updated = await self._store.update_workflow_state(
|
|
98
|
+
state.id,
|
|
99
|
+
completed_steps=completed_steps,
|
|
100
|
+
current_step=next_step or completed_step,
|
|
101
|
+
next_step=self._next_step(next_step or completed_step, step_names),
|
|
102
|
+
artifacts=artifacts,
|
|
103
|
+
)
|
|
104
|
+
if updated is None:
|
|
105
|
+
raise ValueError(f"Workflow state not found for repo: {repo_id}")
|
|
106
|
+
await self._repo_state.write_workflow_state(
|
|
107
|
+
repo_path,
|
|
108
|
+
{
|
|
109
|
+
"current_step": updated.current_step,
|
|
110
|
+
"completed_steps": list(updated.completed_steps),
|
|
111
|
+
"blocked_by": list(updated.blocked_by),
|
|
112
|
+
"next_step": updated.next_step,
|
|
113
|
+
},
|
|
114
|
+
)
|
|
115
|
+
await self._repo_state.write_relationships(
|
|
116
|
+
repo_path,
|
|
117
|
+
dict(repo.relationships) if isinstance(repo.relationships, dict) else {},
|
|
118
|
+
)
|
|
119
|
+
return {
|
|
120
|
+
"current_step": updated.current_step,
|
|
121
|
+
"completed_steps": list(updated.completed_steps),
|
|
122
|
+
"next_step": updated.next_step,
|
|
123
|
+
"artifacts": dict(updated.artifacts),
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async def minder_workflow_guard(
|
|
127
|
+
self,
|
|
128
|
+
*,
|
|
129
|
+
repo_id: uuid.UUID,
|
|
130
|
+
requested_step: str,
|
|
131
|
+
action: str | None = None,
|
|
132
|
+
) -> dict[str, Any]:
|
|
133
|
+
repo = await self._require_repo(repo_id)
|
|
134
|
+
workflow = await self._require_workflow(repo.workflow_id)
|
|
135
|
+
state = await self._require_workflow_state(repo_id)
|
|
136
|
+
step_names = [
|
|
137
|
+
step["name"]
|
|
138
|
+
for step in workflow.steps
|
|
139
|
+
if isinstance(step, dict) and "name" in step
|
|
140
|
+
]
|
|
141
|
+
expected_next = self._next_step(state.current_step, step_names)
|
|
142
|
+
allowed = requested_step == state.current_step or (
|
|
143
|
+
requested_step == expected_next
|
|
144
|
+
and state.current_step in list(state.completed_steps)
|
|
145
|
+
and not list(state.blocked_by)
|
|
146
|
+
)
|
|
147
|
+
violations: list[str] = []
|
|
148
|
+
if list(state.blocked_by):
|
|
149
|
+
violations.append("workflow_blocked")
|
|
150
|
+
if requested_step not in {state.current_step, expected_next}:
|
|
151
|
+
violations.append("step_skip_detected")
|
|
152
|
+
if action and requested_step != state.current_step:
|
|
153
|
+
violations.append("action_outside_current_step")
|
|
154
|
+
reason = (
|
|
155
|
+
None
|
|
156
|
+
if allowed
|
|
157
|
+
else f"Requested step '{requested_step}' is not allowed from '{state.current_step}'"
|
|
158
|
+
)
|
|
159
|
+
record_continuity_gate("passed" if allowed else "blocked")
|
|
160
|
+
record_continuity_packet("workflow_guard")
|
|
161
|
+
return {
|
|
162
|
+
"allowed": allowed,
|
|
163
|
+
"reason": reason,
|
|
164
|
+
"expected_next": expected_next,
|
|
165
|
+
"violations": violations,
|
|
166
|
+
"instruction_envelope": build_instruction_envelope(
|
|
167
|
+
workflow=workflow,
|
|
168
|
+
workflow_state=state,
|
|
169
|
+
),
|
|
170
|
+
"continuity_brief": build_continuity_brief(
|
|
171
|
+
session=type(
|
|
172
|
+
"WorkflowSessionView",
|
|
173
|
+
(),
|
|
174
|
+
{
|
|
175
|
+
"id": getattr(state, "session_id", ""),
|
|
176
|
+
"state": {},
|
|
177
|
+
"project_context": {},
|
|
178
|
+
"active_skills": {},
|
|
179
|
+
},
|
|
180
|
+
)(),
|
|
181
|
+
workflow_state=state,
|
|
182
|
+
workflow=workflow,
|
|
183
|
+
),
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async def _require_repo(self, repo_id: uuid.UUID): # noqa: ANN202
|
|
187
|
+
repo = await self._store.get_repository_by_id(repo_id)
|
|
188
|
+
if repo is None:
|
|
189
|
+
raise ValueError(f"Repository not found: {repo_id}")
|
|
190
|
+
return repo
|
|
191
|
+
|
|
192
|
+
async def _require_workflow(self, workflow_id: uuid.UUID | None): # noqa: ANN202
|
|
193
|
+
if workflow_id is None:
|
|
194
|
+
raise ValueError("Repository has no workflow assigned")
|
|
195
|
+
workflow = await self._store.get_workflow_by_id(workflow_id)
|
|
196
|
+
if workflow is None:
|
|
197
|
+
raise ValueError(f"Workflow not found: {workflow_id}")
|
|
198
|
+
return workflow
|
|
199
|
+
|
|
200
|
+
async def _require_workflow_state(self, repo_id: uuid.UUID): # noqa: ANN202
|
|
201
|
+
state = await self._store.get_workflow_state_by_repo(repo_id)
|
|
202
|
+
if state is None:
|
|
203
|
+
raise ValueError(f"Workflow state not found for repo: {repo_id}")
|
|
204
|
+
return state
|
|
205
|
+
|
|
206
|
+
@staticmethod
|
|
207
|
+
def _next_step(current_step: str, steps: list[str]) -> str | None:
|
|
208
|
+
try:
|
|
209
|
+
index = steps.index(current_step)
|
|
210
|
+
except ValueError:
|
|
211
|
+
return steps[0] if steps else None
|
|
212
|
+
next_index = index + 1
|
|
213
|
+
if next_index >= len(steps):
|
|
214
|
+
return None
|
|
215
|
+
return steps[next_index]
|
minder/transport/base.py
ADDED
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import inspect
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Any, Callable
|
|
5
|
+
|
|
6
|
+
from mcp.server.fastmcp import FastMCP
|
|
7
|
+
|
|
8
|
+
from minder.auth.middleware import AuthMiddleware
|
|
9
|
+
from minder.auth.service import AuthError
|
|
10
|
+
from minder.auth.rate_limiter import RateLimiter
|
|
11
|
+
from minder.auth.principal import AdminUserPrincipal, ClientPrincipal, Principal
|
|
12
|
+
from minder.auth.service import AuthService
|
|
13
|
+
from minder.config import MinderConfig
|
|
14
|
+
from minder.auth.context import get_current_principal
|
|
15
|
+
from minder.store.interfaces import ICacheProvider, IOperationalStore
|
|
16
|
+
from minder.tools.registry import ALWAYS_AVAILABLE_FOR_CLIENTS
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
ToolHandler = Callable[..., Any]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(slots=True)
|
|
24
|
+
class RegisteredTool:
|
|
25
|
+
name: str
|
|
26
|
+
handler: ToolHandler
|
|
27
|
+
require_auth: bool
|
|
28
|
+
description: str | None = None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class BaseTransport:
|
|
32
|
+
transport_name = "base"
|
|
33
|
+
|
|
34
|
+
def __init__(
|
|
35
|
+
self,
|
|
36
|
+
*,
|
|
37
|
+
config: MinderConfig,
|
|
38
|
+
auth_service: AuthService | None = None,
|
|
39
|
+
cache_provider: ICacheProvider,
|
|
40
|
+
store: IOperationalStore | None = None,
|
|
41
|
+
) -> None:
|
|
42
|
+
self._config = config
|
|
43
|
+
self._store = store
|
|
44
|
+
self._middleware = AuthMiddleware(auth_service) if auth_service is not None else None
|
|
45
|
+
self._rate_limiter = RateLimiter(cache=cache_provider, config=config)
|
|
46
|
+
self._server = FastMCP(
|
|
47
|
+
name=config.server.name,
|
|
48
|
+
host=config.server.host,
|
|
49
|
+
port=config.server.port,
|
|
50
|
+
)
|
|
51
|
+
self._tools: dict[str, RegisteredTool] = {}
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def app(self) -> FastMCP:
|
|
55
|
+
return self._server
|
|
56
|
+
|
|
57
|
+
def register_tool(
|
|
58
|
+
self,
|
|
59
|
+
name: str,
|
|
60
|
+
handler: ToolHandler,
|
|
61
|
+
*,
|
|
62
|
+
require_auth: bool = True,
|
|
63
|
+
description: str | None = None,
|
|
64
|
+
) -> None:
|
|
65
|
+
effective_description = description or inspect.getdoc(handler) or self._describe_tool(name)
|
|
66
|
+
self._tools[name] = RegisteredTool(
|
|
67
|
+
name=name,
|
|
68
|
+
handler=handler,
|
|
69
|
+
require_auth=require_auth,
|
|
70
|
+
description=effective_description,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
# We need to wrap the handler to inject auth logic, but we must
|
|
74
|
+
# preserve the original signature so FastMCP can generate the tool schema correctly.
|
|
75
|
+
import functools
|
|
76
|
+
|
|
77
|
+
@functools.wraps(handler)
|
|
78
|
+
async def wrapped_tool(*args: Any, **kwargs: Any) -> Any:
|
|
79
|
+
# Authorization might come from minder_authorization in MCP CallTool params
|
|
80
|
+
authorization = kwargs.pop("minder_authorization", None)
|
|
81
|
+
client_key = kwargs.pop("minder_client_key", None)
|
|
82
|
+
|
|
83
|
+
# Reconstruct arguments for call_tool
|
|
84
|
+
sig = inspect.signature(handler)
|
|
85
|
+
bound = sig.bind_partial(*args, **kwargs)
|
|
86
|
+
return await self.call_tool(
|
|
87
|
+
name,
|
|
88
|
+
arguments=bound.arguments,
|
|
89
|
+
authorization=authorization,
|
|
90
|
+
client_key=client_key,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
# Modify the signature of wrapped_tool to remove 'user' parameter
|
|
94
|
+
# so FastMCP doesn't try to validate its presence in client requests.
|
|
95
|
+
orig_sig = inspect.signature(handler)
|
|
96
|
+
new_params = [
|
|
97
|
+
p for p in orig_sig.parameters.values()
|
|
98
|
+
if p.name not in {"user", "principal"}
|
|
99
|
+
]
|
|
100
|
+
# New parameters to inject (must be before VAR_KEYWORD if present)
|
|
101
|
+
injected = [
|
|
102
|
+
inspect.Parameter(
|
|
103
|
+
"minder_authorization",
|
|
104
|
+
kind=inspect.Parameter.KEYWORD_ONLY,
|
|
105
|
+
default=None,
|
|
106
|
+
annotation=str | None,
|
|
107
|
+
),
|
|
108
|
+
inspect.Parameter(
|
|
109
|
+
"minder_client_key",
|
|
110
|
+
kind=inspect.Parameter.KEYWORD_ONLY,
|
|
111
|
+
default=None,
|
|
112
|
+
annotation=str | None,
|
|
113
|
+
),
|
|
114
|
+
]
|
|
115
|
+
|
|
116
|
+
final_params = []
|
|
117
|
+
var_kw = None
|
|
118
|
+
for p in new_params:
|
|
119
|
+
if p.kind == inspect.Parameter.VAR_KEYWORD:
|
|
120
|
+
var_kw = p
|
|
121
|
+
else:
|
|
122
|
+
final_params.append(p)
|
|
123
|
+
|
|
124
|
+
final_params.extend(injected)
|
|
125
|
+
if var_kw:
|
|
126
|
+
final_params.append(var_kw)
|
|
127
|
+
|
|
128
|
+
wrapped_tool.__signature__ = orig_sig.replace(parameters=final_params) # type: ignore
|
|
129
|
+
|
|
130
|
+
self._server.add_tool(
|
|
131
|
+
wrapped_tool,
|
|
132
|
+
name=name,
|
|
133
|
+
description=effective_description,
|
|
134
|
+
structured_output=False,
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
def list_tools(self) -> list[str]:
|
|
138
|
+
return sorted(self._tools)
|
|
139
|
+
|
|
140
|
+
def _default_client_key(self) -> str | None:
|
|
141
|
+
return None
|
|
142
|
+
|
|
143
|
+
async def call_tool(
|
|
144
|
+
self,
|
|
145
|
+
name: str,
|
|
146
|
+
*,
|
|
147
|
+
arguments: dict[str, Any] | None = None,
|
|
148
|
+
authorization: str | None = None,
|
|
149
|
+
client_key: str | None = None,
|
|
150
|
+
) -> Any:
|
|
151
|
+
if name not in self._tools:
|
|
152
|
+
raise KeyError(f"Unknown tool: {name}")
|
|
153
|
+
|
|
154
|
+
registered = self._tools[name]
|
|
155
|
+
kwargs = dict(arguments or {})
|
|
156
|
+
import time
|
|
157
|
+
start = time.perf_counter()
|
|
158
|
+
outcome = "error"
|
|
159
|
+
principal = None
|
|
160
|
+
try:
|
|
161
|
+
effective_client_key = client_key or self._default_client_key()
|
|
162
|
+
principal = await self._authenticate_if_required(registered, authorization, effective_client_key)
|
|
163
|
+
if principal is not None:
|
|
164
|
+
if (
|
|
165
|
+
isinstance(principal, ClientPrincipal)
|
|
166
|
+
and name not in ALWAYS_AVAILABLE_FOR_CLIENTS
|
|
167
|
+
and name not in principal.scopes
|
|
168
|
+
):
|
|
169
|
+
outcome = "denied"
|
|
170
|
+
raise AuthError(
|
|
171
|
+
"AUTH_FORBIDDEN",
|
|
172
|
+
f"Client is not allowed to call tool '{name}'",
|
|
173
|
+
)
|
|
174
|
+
if self._rate_limiter.enabled():
|
|
175
|
+
await self._rate_limiter.enforce(principal=principal, tool_name=name)
|
|
176
|
+
kwargs["principal"] = principal
|
|
177
|
+
if isinstance(principal, AdminUserPrincipal) and principal.user is not None:
|
|
178
|
+
kwargs["user"] = principal.user
|
|
179
|
+
|
|
180
|
+
outcome = "success"
|
|
181
|
+
return await _invoke(registered.handler, **kwargs)
|
|
182
|
+
except Exception as exc:
|
|
183
|
+
if isinstance(exc, AuthError):
|
|
184
|
+
outcome = "denied"
|
|
185
|
+
elif outcome == "success": # Should not happen if we caught an exception
|
|
186
|
+
outcome = "error"
|
|
187
|
+
raise
|
|
188
|
+
finally:
|
|
189
|
+
elapsed = time.perf_counter() - start
|
|
190
|
+
|
|
191
|
+
client_id = "unknown"
|
|
192
|
+
actor_id = "unknown"
|
|
193
|
+
actor_type = "unknown"
|
|
194
|
+
if principal is not None:
|
|
195
|
+
client_id = getattr(principal, "client_slug") if hasattr(principal, "client_slug") else "unknown"
|
|
196
|
+
actor_id = str(principal.principal_id)
|
|
197
|
+
actor_type = principal.principal_type
|
|
198
|
+
|
|
199
|
+
# Record metrics (Prometheus - in-memory)
|
|
200
|
+
try:
|
|
201
|
+
from minder.observability.metrics import record_tool_call
|
|
202
|
+
record_tool_call(name, outcome, elapsed, client_id=client_id)
|
|
203
|
+
except Exception:
|
|
204
|
+
pass
|
|
205
|
+
|
|
206
|
+
# Record audit log (Persistent)
|
|
207
|
+
if self._store is not None:
|
|
208
|
+
try:
|
|
209
|
+
await self._store.create_audit_log(
|
|
210
|
+
actor_type=actor_type,
|
|
211
|
+
actor_id=actor_id,
|
|
212
|
+
event_type="tool_call",
|
|
213
|
+
tool_name=name,
|
|
214
|
+
resource_type="tool",
|
|
215
|
+
resource_id=name,
|
|
216
|
+
outcome=outcome,
|
|
217
|
+
audit_metadata={
|
|
218
|
+
"client_id": client_id,
|
|
219
|
+
"latency_ms": round(elapsed * 1000, 2),
|
|
220
|
+
"arguments": arguments,
|
|
221
|
+
}
|
|
222
|
+
)
|
|
223
|
+
except Exception:
|
|
224
|
+
# Don't let audit logging fail the whole tool call
|
|
225
|
+
pass
|
|
226
|
+
|
|
227
|
+
@staticmethod
|
|
228
|
+
def _describe_tool(name: str) -> str:
|
|
229
|
+
words = name.replace("minder_", "").replace("_", " ")
|
|
230
|
+
return f"Run the {words} tool in Minder."
|
|
231
|
+
|
|
232
|
+
async def _authenticate_if_required(
|
|
233
|
+
self,
|
|
234
|
+
registered: RegisteredTool,
|
|
235
|
+
authorization: str | None,
|
|
236
|
+
client_key: str | None,
|
|
237
|
+
) -> Principal | None:
|
|
238
|
+
if not registered.require_auth:
|
|
239
|
+
return None
|
|
240
|
+
|
|
241
|
+
# 1. Try context first (set by middleware or previous call in same task)
|
|
242
|
+
context_principal = get_current_principal()
|
|
243
|
+
if context_principal is not None:
|
|
244
|
+
logger.info(
|
|
245
|
+
"BaseTransport: found principal in context: %s",
|
|
246
|
+
context_principal.principal_type,
|
|
247
|
+
)
|
|
248
|
+
return context_principal
|
|
249
|
+
|
|
250
|
+
# 2. Fallback to explicit authorization header if provided
|
|
251
|
+
if self._middleware is None:
|
|
252
|
+
logger.error("BaseTransport: Transport requires auth but no AuthService was configured")
|
|
253
|
+
raise RuntimeError("Transport requires auth but no AuthService was configured")
|
|
254
|
+
|
|
255
|
+
logger.debug(
|
|
256
|
+
"BaseTransport: no principal in context, checking auth header/client key",
|
|
257
|
+
)
|
|
258
|
+
principal = await self._middleware.authenticate_principal(
|
|
259
|
+
authorization,
|
|
260
|
+
client_key=client_key,
|
|
261
|
+
)
|
|
262
|
+
logger.info(
|
|
263
|
+
"BaseTransport: authenticated principal from request: %s",
|
|
264
|
+
principal.principal_type,
|
|
265
|
+
)
|
|
266
|
+
return principal
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
async def _invoke(handler: ToolHandler, **kwargs: Any) -> Any:
|
|
270
|
+
signature = inspect.signature(handler)
|
|
271
|
+
accepts_var_kw = any(
|
|
272
|
+
parameter.kind == inspect.Parameter.VAR_KEYWORD
|
|
273
|
+
for parameter in signature.parameters.values()
|
|
274
|
+
)
|
|
275
|
+
if accepts_var_kw:
|
|
276
|
+
filtered_kwargs = kwargs
|
|
277
|
+
else:
|
|
278
|
+
filtered_kwargs = {
|
|
279
|
+
key: value
|
|
280
|
+
for key, value in kwargs.items()
|
|
281
|
+
if key in signature.parameters
|
|
282
|
+
}
|
|
283
|
+
result = handler(**filtered_kwargs)
|
|
284
|
+
if inspect.isawaitable(result):
|
|
285
|
+
return await result
|
|
286
|
+
return result
|