clink-cli 0.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.
- clink/__init__.py +19 -0
- clink/agents/__init__.py +30 -0
- clink/agents/base.py +70 -0
- clink/agents/claude.py +7 -0
- clink/agents/codex.py +7 -0
- clink/agents/gemini.py +7 -0
- clink/allowed_roots.py +356 -0
- clink/backend.py +765 -0
- clink/cli.py +594 -0
- clink/constants.py +78 -0
- clink/contracts.py +167 -0
- clink/files.py +70 -0
- clink/generation.py +274 -0
- clink/generation_capabilities.py +207 -0
- clink/generation_contracts.py +214 -0
- clink/generation_policy.py +174 -0
- clink/models.py +243 -0
- clink/native.py +409 -0
- clink/native_adapters.py +244 -0
- clink/native_contracts.py +68 -0
- clink/native_generation.py +421 -0
- clink/native_generation_claude_policy.py +59 -0
- clink/native_generation_contracts.py +264 -0
- clink/native_generation_families.py +218 -0
- clink/native_generation_family_policy.py +14 -0
- clink/native_generation_files.py +153 -0
- clink/native_generation_policy.py +82 -0
- clink/native_policy.py +86 -0
- clink/parsers/__init__.py +34 -0
- clink/parsers/agy.py +53 -0
- clink/parsers/agy_native.py +161 -0
- clink/parsers/agy_native_generation.py +189 -0
- clink/parsers/base.py +209 -0
- clink/parsers/claude.py +104 -0
- clink/parsers/claude_native.py +113 -0
- clink/parsers/claude_native_generation.py +346 -0
- clink/parsers/codex.py +172 -0
- clink/parsers/codex_generation.py +212 -0
- clink/parsers/codex_native.py +236 -0
- clink/parsers/codex_native_generation.py +367 -0
- clink/parsers/gemini.py +110 -0
- clink/parsers/grok.py +63 -0
- clink/parsers/grok_native.py +91 -0
- clink/parsers/grok_native_generation.py +157 -0
- clink/policy.py +180 -0
- clink/registry.py +544 -0
- clink/resources/CODEX-LICENSE +201 -0
- clink/resources/CODEX-NOTICE +6 -0
- clink/resources/PROVENANCE.md +16 -0
- clink/resources/codex-0.166.0-models-direct.json +1284 -0
- clink/resources/codex-0.166.0-models-generation.json +1269 -0
- clink/resources/native-agy-1.4.12-admission.json +746 -0
- clink/resources/native-claude-2.1.304-admission.json +99 -0
- clink/resources/native-generation-claude-2.1.304-admission.json +277 -0
- clink/resources/native-generation-codex-0.166.0-admission.json +309 -0
- clink/resources/native-generation-grok-1.0.70-admission.json +329 -0
- clink/resources/native-grok-1.0.70-admission.json +644 -0
- clink/runner.py +303 -0
- clink/setup.py +399 -0
- clink/tasks.py +695 -0
- clink_cli-0.2.1.dist-info/METADATA +225 -0
- clink_cli-0.2.1.dist-info/RECORD +141 -0
- clink_cli-0.2.1.dist-info/WHEEL +5 -0
- clink_cli-0.2.1.dist-info/entry_points.txt +3 -0
- clink_cli-0.2.1.dist-info/licenses/LICENSE +197 -0
- clink_cli-0.2.1.dist-info/licenses/clink/resources/CODEX-LICENSE +201 -0
- clink_cli-0.2.1.dist-info/licenses/clink/resources/CODEX-NOTICE +6 -0
- clink_cli-0.2.1.dist-info/top_level.txt +7 -0
- conf/__init__.py +1 -0
- conf/cli_clients/agy.json +40 -0
- conf/cli_clients/claude.json +39 -0
- conf/cli_clients/codex.json +40 -0
- conf/cli_clients/gemini.json +23 -0
- conf/cli_clients/grok.json +40 -0
- config.py +40 -0
- server.py +896 -0
- systemprompts/__init__.py +35 -0
- systemprompts/analyze_prompt.py +87 -0
- systemprompts/chat_prompt.py +57 -0
- systemprompts/clink/codex_codereviewer.txt +8 -0
- systemprompts/clink/default.txt +8 -0
- systemprompts/clink/default_codereviewer.txt +8 -0
- systemprompts/clink/default_planner.txt +7 -0
- systemprompts/clink/native_codereviewer.txt +1 -0
- systemprompts/clink/native_default.txt +1 -0
- systemprompts/clink/native_planner.txt +1 -0
- systemprompts/codereview_prompt.py +91 -0
- systemprompts/consensus_prompt.py +120 -0
- systemprompts/debug_prompt.py +150 -0
- systemprompts/docgen_prompt.py +252 -0
- systemprompts/generate_code_prompt.py +181 -0
- systemprompts/planner_prompt.py +127 -0
- systemprompts/precommit_prompt.py +179 -0
- systemprompts/refactor_prompt.py +323 -0
- systemprompts/secaudit_prompt.py +420 -0
- systemprompts/testgen_prompt.py +133 -0
- systemprompts/thinkdeep_prompt.py +55 -0
- systemprompts/tracer_prompt.py +145 -0
- tools/__init__.py +43 -0
- tools/analyze.py +583 -0
- tools/apilookup.py +97 -0
- tools/challenge.py +198 -0
- tools/chat.py +187 -0
- tools/clink.py +130 -0
- tools/codereview.py +724 -0
- tools/consensus.py +790 -0
- tools/debug.py +573 -0
- tools/docgen.py +577 -0
- tools/listmodels.py +48 -0
- tools/models.py +373 -0
- tools/planner.py +533 -0
- tools/precommit.py +776 -0
- tools/refactor.py +645 -0
- tools/secaudit.py +685 -0
- tools/shared/__init__.py +19 -0
- tools/shared/base_models.py +167 -0
- tools/shared/base_tool.py +659 -0
- tools/shared/exceptions.py +20 -0
- tools/shared/schema_builders.py +159 -0
- tools/simple/__init__.py +18 -0
- tools/simple/base.py +539 -0
- tools/testgen.py +513 -0
- tools/thinkdeep.py +586 -0
- tools/tracer.py +763 -0
- tools/version.py +53 -0
- tools/workflow/__init__.py +22 -0
- tools/workflow/base.py +448 -0
- tools/workflow/schema_builders.py +166 -0
- tools/workflow/workflow_mixin.py +1195 -0
- utils/__init__.py +21 -0
- utils/call_state.py +349 -0
- utils/client_info.py +293 -0
- utils/consensus_manifest.py +131 -0
- utils/conversation_memory.py +582 -0
- utils/env.py +111 -0
- utils/file_types.py +271 -0
- utils/file_utils.py +803 -0
- utils/image_utils.py +94 -0
- utils/security_config.py +163 -0
- utils/storage_backend.py +177 -0
- utils/token_utils.py +54 -0
utils/__init__.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Utility functions for PAL MCP Server
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from .file_types import CODE_EXTENSIONS, FILE_CATEGORIES, PROGRAMMING_EXTENSIONS, TEXT_EXTENSIONS
|
|
6
|
+
from .file_utils import expand_paths, read_file_content, read_files
|
|
7
|
+
from .security_config import EXCLUDED_DIRS
|
|
8
|
+
from .token_utils import check_token_limit, estimate_tokens
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"read_files",
|
|
12
|
+
"read_file_content",
|
|
13
|
+
"expand_paths",
|
|
14
|
+
"CODE_EXTENSIONS",
|
|
15
|
+
"PROGRAMMING_EXTENSIONS",
|
|
16
|
+
"TEXT_EXTENSIONS",
|
|
17
|
+
"FILE_CATEGORIES",
|
|
18
|
+
"EXCLUDED_DIRS",
|
|
19
|
+
"estimate_tokens",
|
|
20
|
+
"check_token_limit",
|
|
21
|
+
]
|
utils/call_state.py
ADDED
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
"""Atomic per-continuation MCP calls, canonical replay, and workflow transitions.
|
|
2
|
+
|
|
3
|
+
Lock ownership spans state read, external execution, and terminal publication.
|
|
4
|
+
Only JSON-ready bounded outcomes are retained; native CLI session IDs are never
|
|
5
|
+
used as continuations. Server dispatch is the sole normal transaction owner.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import contextvars
|
|
10
|
+
import hashlib
|
|
11
|
+
import json
|
|
12
|
+
import time
|
|
13
|
+
import uuid
|
|
14
|
+
from datetime import datetime, timezone
|
|
15
|
+
from typing import Any, Optional
|
|
16
|
+
|
|
17
|
+
from pydantic import BaseModel
|
|
18
|
+
|
|
19
|
+
from utils.conversation_memory import (
|
|
20
|
+
CONVERSATION_TIMEOUT_SECONDS,
|
|
21
|
+
MAX_CONVERSATION_TURNS,
|
|
22
|
+
MAX_HISTORY_BYTES,
|
|
23
|
+
ConversationTurn,
|
|
24
|
+
ThreadContext,
|
|
25
|
+
_is_valid_uuid,
|
|
26
|
+
build_conversation_history,
|
|
27
|
+
build_replay_turns,
|
|
28
|
+
get_storage,
|
|
29
|
+
has_invalidated_consensus,
|
|
30
|
+
successful_turn_count,
|
|
31
|
+
trim_history,
|
|
32
|
+
)
|
|
33
|
+
from utils.storage_backend import StorageCapacityError
|
|
34
|
+
|
|
35
|
+
MAX_TERMINAL_BYTES = 256 * 1024
|
|
36
|
+
MAX_WORKFLOW_BYTES = 1024 * 1024
|
|
37
|
+
# Reserve all possible growth before external work, including JSON envelope overhead.
|
|
38
|
+
CALL_RESERVATION_BYTES = MAX_TERMINAL_BYTES + MAX_WORKFLOW_BYTES + MAX_HISTORY_BYTES + 64 * 1024
|
|
39
|
+
_current_call: contextvars.ContextVar[Optional["ConversationCall"]] = contextvars.ContextVar("clink_call", default=None)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class StateError(RuntimeError):
|
|
43
|
+
"""An explicit continuation admission or transition failure."""
|
|
44
|
+
|
|
45
|
+
def __init__(self, code: str, message: str):
|
|
46
|
+
super().__init__(message)
|
|
47
|
+
self.code = code
|
|
48
|
+
self.message = message
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def get_current_call() -> Optional["ConversationCall"]:
|
|
52
|
+
return _current_call.get()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _json_ready(value: Any) -> Any:
|
|
56
|
+
if isinstance(value, BaseModel):
|
|
57
|
+
return value.model_dump(mode="json", by_alias=True)
|
|
58
|
+
if isinstance(value, dict):
|
|
59
|
+
return {str(key): _json_ready(item) for key, item in value.items()}
|
|
60
|
+
if isinstance(value, (list, tuple)):
|
|
61
|
+
return [_json_ready(item) for item in value]
|
|
62
|
+
return value
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def canonical_input(arguments: dict[str, Any]) -> str:
|
|
66
|
+
"""Digest validated original inputs, independent of key order and transport IDs."""
|
|
67
|
+
clean = {key: value for key, value in arguments.items() if not key.startswith("_") and key != "continuation_id"}
|
|
68
|
+
try:
|
|
69
|
+
return json.dumps(
|
|
70
|
+
_json_ready(clean), ensure_ascii=False, sort_keys=True, separators=(",", ":"), allow_nan=False
|
|
71
|
+
)
|
|
72
|
+
except (TypeError, ValueError) as exc:
|
|
73
|
+
raise StateError("input_limit", "Conversation input must contain finite JSON values.") from exc
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def transition_address(tool_name: str, arguments: dict[str, Any]) -> str | None:
|
|
77
|
+
"""Respect existing step, planner branch, and explicit revision coordinates."""
|
|
78
|
+
if "step_number" not in arguments:
|
|
79
|
+
return None
|
|
80
|
+
address = {
|
|
81
|
+
"tool": tool_name,
|
|
82
|
+
"step_number": arguments.get("step_number"),
|
|
83
|
+
"branch_id": arguments.get("branch_id"),
|
|
84
|
+
"branch_from_step": arguments.get("branch_from_step"),
|
|
85
|
+
"is_step_revision": arguments.get("is_step_revision", False),
|
|
86
|
+
"is_branch_point": arguments.get("is_branch_point", False),
|
|
87
|
+
"revises_step_number": arguments.get("revises_step_number"),
|
|
88
|
+
}
|
|
89
|
+
return json.dumps(address, sort_keys=True, separators=(",", ":"))
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class ConversationCall:
|
|
93
|
+
"""A reserved continuation transaction exposed to server and tool bridges."""
|
|
94
|
+
|
|
95
|
+
def __init__(
|
|
96
|
+
self,
|
|
97
|
+
tool_name: str,
|
|
98
|
+
arguments: dict[str, Any],
|
|
99
|
+
*,
|
|
100
|
+
deadline: float | None = None,
|
|
101
|
+
admitted_at: float | None = None,
|
|
102
|
+
request_id: str | None = None,
|
|
103
|
+
):
|
|
104
|
+
self.tool_name = tool_name
|
|
105
|
+
self.arguments = _json_ready(arguments)
|
|
106
|
+
self.admitted_at = time.monotonic() if admitted_at is None else admitted_at
|
|
107
|
+
self.deadline = self.admitted_at + 1800 if deadline is None else deadline
|
|
108
|
+
self.request_id = request_id or str(uuid.uuid4())
|
|
109
|
+
self.continuation_id = arguments.get("continuation_id") or str(uuid.uuid4())
|
|
110
|
+
self._existing = bool(arguments.get("continuation_id"))
|
|
111
|
+
self._key = f"thread:{self.continuation_id}"
|
|
112
|
+
self._address = transition_address(tool_name, self.arguments)
|
|
113
|
+
self._digest = hashlib.sha256(canonical_input(self.arguments).encode("utf-8")).hexdigest()
|
|
114
|
+
self._storage = get_storage()
|
|
115
|
+
self._serialization = None
|
|
116
|
+
self._lock_entered = False
|
|
117
|
+
self._token = None
|
|
118
|
+
self._reserved = False
|
|
119
|
+
self._committed = False
|
|
120
|
+
self._staged_turns: list[ConversationTurn] = []
|
|
121
|
+
self.cached_result: Any = None
|
|
122
|
+
self.context: ThreadContext
|
|
123
|
+
self.workflow_state: dict[str, Any] = {}
|
|
124
|
+
self._replay_metadata: dict[str, Any] = {}
|
|
125
|
+
|
|
126
|
+
async def __aenter__(self) -> "ConversationCall":
|
|
127
|
+
if get_current_call() is not None:
|
|
128
|
+
raise RuntimeError("Nested conversation transactions are not supported")
|
|
129
|
+
if not _is_valid_uuid(self.continuation_id):
|
|
130
|
+
raise StateError(
|
|
131
|
+
"continuation_unavailable", "Continuation is invalid or unavailable; start a new conversation."
|
|
132
|
+
)
|
|
133
|
+
self._serialization = self._storage.serialize(self._key, self.deadline)
|
|
134
|
+
try:
|
|
135
|
+
await self._serialization.__aenter__()
|
|
136
|
+
self._lock_entered = True
|
|
137
|
+
data = self._storage.get(self._key)
|
|
138
|
+
if self._existing and data is None:
|
|
139
|
+
raise StateError(
|
|
140
|
+
"continuation_unavailable", "Continuation is unknown or expired; start a new conversation."
|
|
141
|
+
)
|
|
142
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
143
|
+
self.context = (
|
|
144
|
+
ThreadContext.model_validate_json(data)
|
|
145
|
+
if data is not None
|
|
146
|
+
else ThreadContext(
|
|
147
|
+
thread_id=self.continuation_id,
|
|
148
|
+
created_at=now,
|
|
149
|
+
last_updated_at=now,
|
|
150
|
+
tool_name=self.tool_name,
|
|
151
|
+
turns=[],
|
|
152
|
+
initial_context={},
|
|
153
|
+
)
|
|
154
|
+
)
|
|
155
|
+
self.workflow_state = _json_ready(self.context.workflow_states.get(self.tool_name, {}))
|
|
156
|
+
previous = self.context.transitions.get(self._address) if self._address is not None else None
|
|
157
|
+
if (
|
|
158
|
+
self.tool_name != "consensus"
|
|
159
|
+
and (self.workflow_state or previous is not None)
|
|
160
|
+
and has_invalidated_consensus(self.context)
|
|
161
|
+
):
|
|
162
|
+
raise StateError(
|
|
163
|
+
"consensus_invalidated",
|
|
164
|
+
"Saved workflow state or cached results may depend on invalidated consensus; "
|
|
165
|
+
"start a new conversation without continuation_id.",
|
|
166
|
+
)
|
|
167
|
+
if previous is not None:
|
|
168
|
+
if previous["digest"] != self._digest:
|
|
169
|
+
raise StateError("workflow_conflict", "This workflow transition already has different input.")
|
|
170
|
+
self.cached_result = previous.get("result")
|
|
171
|
+
elif successful_turn_count(self.context) + 2 > MAX_CONVERSATION_TURNS:
|
|
172
|
+
raise StateError(
|
|
173
|
+
"conversation_capacity",
|
|
174
|
+
"Conversation cannot reserve another user/assistant pair; start a new conversation.",
|
|
175
|
+
)
|
|
176
|
+
# Cache validation can invalidate and replace a completed outcome, so it also needs reservation.
|
|
177
|
+
self._storage.reserve(
|
|
178
|
+
self._key, CONVERSATION_TIMEOUT_SECONDS, self.context.model_dump_json(), CALL_RESERVATION_BYTES
|
|
179
|
+
)
|
|
180
|
+
self._reserved = True
|
|
181
|
+
self._token = _current_call.set(self)
|
|
182
|
+
return self
|
|
183
|
+
except BaseException as exc:
|
|
184
|
+
if self._lock_entered:
|
|
185
|
+
await self._serialization.__aexit__(None, None, None)
|
|
186
|
+
self._lock_entered = False
|
|
187
|
+
self._serialization = None
|
|
188
|
+
if isinstance(exc, StorageCapacityError):
|
|
189
|
+
raise StateError("conversation_capacity", str(exc)) from exc
|
|
190
|
+
if isinstance(exc, asyncio.TimeoutError):
|
|
191
|
+
raise StateError("timeout", "Request deadline expired while waiting for conversation state.") from exc
|
|
192
|
+
raise
|
|
193
|
+
|
|
194
|
+
async def __aexit__(self, exc_type, exc, traceback) -> bool:
|
|
195
|
+
try:
|
|
196
|
+
if not self._committed and self.cached_result is None and self._reserved:
|
|
197
|
+
code = "cancelled" if isinstance(exc, asyncio.CancelledError) else "interrupted"
|
|
198
|
+
if isinstance(exc, StateError):
|
|
199
|
+
code = exc.code
|
|
200
|
+
terminal = [{"type": "text", "text": json.dumps({"status": "error", "error_code": code})}]
|
|
201
|
+
try:
|
|
202
|
+
self.commit(terminal, success=False)
|
|
203
|
+
except StateError:
|
|
204
|
+
# Malformed/oversized transient tool state must not erase the
|
|
205
|
+
# terminal failed address and permit an implicit re-launch.
|
|
206
|
+
self.workflow_state = self.context.workflow_states.get(self.tool_name, {})
|
|
207
|
+
self.commit(terminal, success=False)
|
|
208
|
+
finally:
|
|
209
|
+
if self._reserved:
|
|
210
|
+
self._storage.release_reservation(self._key)
|
|
211
|
+
self._reserved = False
|
|
212
|
+
if self._token is not None:
|
|
213
|
+
_current_call.reset(self._token)
|
|
214
|
+
if self._serialization is not None:
|
|
215
|
+
await self._serialization.__aexit__(exc_type, exc, traceback)
|
|
216
|
+
self._serialization = None
|
|
217
|
+
return False
|
|
218
|
+
|
|
219
|
+
def history(self) -> str:
|
|
220
|
+
return build_conversation_history(self.context, metadata=self._replay_metadata)[0]
|
|
221
|
+
|
|
222
|
+
def history_turns(self) -> tuple[str, ...]:
|
|
223
|
+
"""Return whole replay messages independently from required request content."""
|
|
224
|
+
return build_replay_turns(self.context, metadata=self._replay_metadata)
|
|
225
|
+
|
|
226
|
+
@property
|
|
227
|
+
def replay_metadata(self) -> dict[str, Any]:
|
|
228
|
+
"""Expose history pruning to the caller as well as the delegated prompt."""
|
|
229
|
+
self.history_turns()
|
|
230
|
+
return dict(self._replay_metadata)
|
|
231
|
+
|
|
232
|
+
def set_workflow_state(self, state: dict[str, Any]) -> None:
|
|
233
|
+
ready = _json_ready(state)
|
|
234
|
+
try:
|
|
235
|
+
size = len(json.dumps(ready, ensure_ascii=False, allow_nan=False).encode("utf-8"))
|
|
236
|
+
except (TypeError, ValueError) as exc:
|
|
237
|
+
raise StateError("input_limit", "Workflow state must contain finite JSON values.") from exc
|
|
238
|
+
if size > MAX_WORKFLOW_BYTES:
|
|
239
|
+
raise StateError("conversation_capacity", "Workflow state exceeds the admitted conversation limit.")
|
|
240
|
+
self.workflow_state = ready
|
|
241
|
+
|
|
242
|
+
def stage_turn(self, turn: ConversationTurn) -> None:
|
|
243
|
+
"""Support old helper callers without publishing before terminal commit."""
|
|
244
|
+
self._staged_turns.append(turn)
|
|
245
|
+
|
|
246
|
+
def commit(
|
|
247
|
+
self,
|
|
248
|
+
result: Any,
|
|
249
|
+
*,
|
|
250
|
+
success: bool = True,
|
|
251
|
+
retain_failure_context: bool = False,
|
|
252
|
+
user_content: str | None = None,
|
|
253
|
+
assistant_content: str | None = None,
|
|
254
|
+
files: list[str] | None = None,
|
|
255
|
+
images: list[str] | None = None,
|
|
256
|
+
backend_identity: dict[str, Any] | None = None,
|
|
257
|
+
workflow_state: dict[str, Any] | None = None,
|
|
258
|
+
) -> None:
|
|
259
|
+
"""Atomically publish outcome, owned tool state, attempt, and successful turns."""
|
|
260
|
+
if self._committed:
|
|
261
|
+
raise RuntimeError("Conversation call was already committed")
|
|
262
|
+
ready = _json_ready(result)
|
|
263
|
+
try:
|
|
264
|
+
size = len(json.dumps(ready, ensure_ascii=False, allow_nan=False).encode("utf-8"))
|
|
265
|
+
except (TypeError, ValueError) as exc:
|
|
266
|
+
raise StateError("input_limit", "Terminal result must contain finite JSON values.") from exc
|
|
267
|
+
if size > MAX_TERMINAL_BYTES:
|
|
268
|
+
raise StateError("conversation_capacity", "Terminal result exceeds the admitted conversation limit.")
|
|
269
|
+
if workflow_state is not None:
|
|
270
|
+
self.set_workflow_state(workflow_state)
|
|
271
|
+
else:
|
|
272
|
+
self.set_workflow_state(self.workflow_state)
|
|
273
|
+
updated = self.context.model_copy(deep=True)
|
|
274
|
+
updated.workflow_states[self.tool_name] = self.workflow_state
|
|
275
|
+
if (
|
|
276
|
+
self.tool_name == "consensus"
|
|
277
|
+
and updated.consensus_started_at_turn is None
|
|
278
|
+
and not self.context.workflow_states.get("consensus")
|
|
279
|
+
and isinstance(self.workflow_state.get("original_proposal"), str)
|
|
280
|
+
and not self.workflow_state.get("invalidated", False)
|
|
281
|
+
):
|
|
282
|
+
# Absolute message ordinals survive oldest-first history pruning.
|
|
283
|
+
updated.consensus_started_at_turn = self.context.omitted_turns + len(self.context.turns)
|
|
284
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
285
|
+
attempt = {
|
|
286
|
+
"request_id": self.request_id,
|
|
287
|
+
"tool": self.tool_name,
|
|
288
|
+
"success": success,
|
|
289
|
+
"status": "success" if success else "partial" if retain_failure_context else "error",
|
|
290
|
+
"timestamp": now,
|
|
291
|
+
}
|
|
292
|
+
if self._address is None and not success:
|
|
293
|
+
# Non-workflow requests are explicit new calls, but their terminal
|
|
294
|
+
# failures still belong to canonical server-owned attempt state.
|
|
295
|
+
attempt["outcome"] = ready
|
|
296
|
+
if backend_identity:
|
|
297
|
+
attempt["backend"] = _json_ready(backend_identity)
|
|
298
|
+
updated.attempts.append(attempt)
|
|
299
|
+
# Keep bounded attempt metadata independently from successful turn count.
|
|
300
|
+
updated.attempts = updated.attempts[-MAX_CONVERSATION_TURNS:]
|
|
301
|
+
if self._address is not None:
|
|
302
|
+
updated.transitions[self._address] = {"digest": self._digest, "result": ready, "success": success}
|
|
303
|
+
if (success or retain_failure_context) and self.cached_result is None:
|
|
304
|
+
status = "success" if success else "partial"
|
|
305
|
+
turns = [turn.model_copy(update={"attempt_status": status}) for turn in self._staged_turns]
|
|
306
|
+
for role, content in (("user", user_content), ("assistant", assistant_content)):
|
|
307
|
+
if content is not None and not any(turn.role == role and turn.content == content for turn in turns):
|
|
308
|
+
turns.append(
|
|
309
|
+
ConversationTurn(
|
|
310
|
+
role=role,
|
|
311
|
+
content=content,
|
|
312
|
+
timestamp=now,
|
|
313
|
+
files=files,
|
|
314
|
+
images=images,
|
|
315
|
+
tool_name=self.tool_name,
|
|
316
|
+
model_metadata=backend_identity,
|
|
317
|
+
attempt_status=status,
|
|
318
|
+
)
|
|
319
|
+
)
|
|
320
|
+
prior_count = successful_turn_count(updated)
|
|
321
|
+
if len(turns) > 2 or (success and prior_count + len(turns) > MAX_CONVERSATION_TURNS):
|
|
322
|
+
raise StateError(
|
|
323
|
+
"conversation_capacity", "Conversation exceeded its reserved user/assistant turn slots."
|
|
324
|
+
)
|
|
325
|
+
updated.turns.extend(turns)
|
|
326
|
+
updated.successful_turns = prior_count + (len(turns) if success else 0)
|
|
327
|
+
trim_history(updated)
|
|
328
|
+
updated.last_updated_at = now
|
|
329
|
+
try:
|
|
330
|
+
self._storage.commit_reserved(self._key, CONVERSATION_TIMEOUT_SECONDS, updated.model_dump_json())
|
|
331
|
+
except StorageCapacityError as exc:
|
|
332
|
+
raise StateError("conversation_capacity", str(exc)) from exc
|
|
333
|
+
self.context = updated
|
|
334
|
+
self._reserved = False
|
|
335
|
+
self._committed = True
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def conversation_call(
|
|
339
|
+
tool_name: str,
|
|
340
|
+
validated_arguments: dict[str, Any],
|
|
341
|
+
*,
|
|
342
|
+
deadline: float | None = None,
|
|
343
|
+
admitted_at: float | None = None,
|
|
344
|
+
request_id: str | None = None,
|
|
345
|
+
) -> ConversationCall:
|
|
346
|
+
"""Create the server-owned transaction before replay or delegated execution."""
|
|
347
|
+
return ConversationCall(
|
|
348
|
+
tool_name, validated_arguments, deadline=deadline, admitted_at=admitted_at, request_id=request_id
|
|
349
|
+
)
|
utils/client_info.py
ADDED
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Client Information Utility for MCP Server
|
|
3
|
+
|
|
4
|
+
This module provides utilities to extract and format client information
|
|
5
|
+
from the MCP protocol's clientInfo sent during initialization.
|
|
6
|
+
|
|
7
|
+
It also provides friendly name mapping and caching for consistent client
|
|
8
|
+
identification across the application.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
from typing import Any, Optional
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
# Global cache for client information
|
|
17
|
+
_client_info_cache: Optional[dict[str, Any]] = None
|
|
18
|
+
|
|
19
|
+
# Mapping of known client names to friendly names
|
|
20
|
+
# This is case-insensitive and checks if the key is contained in the client name
|
|
21
|
+
CLIENT_NAME_MAPPINGS = {
|
|
22
|
+
# Claude variants
|
|
23
|
+
"claude-ai": "Claude",
|
|
24
|
+
"claude": "Claude",
|
|
25
|
+
"claude-desktop": "Claude",
|
|
26
|
+
"claude-code": "Claude",
|
|
27
|
+
"anthropic": "Claude",
|
|
28
|
+
# Gemini variants
|
|
29
|
+
"gemini-cli-mcp-client": "Gemini",
|
|
30
|
+
"gemini-cli": "Gemini",
|
|
31
|
+
"gemini": "Gemini",
|
|
32
|
+
"google": "Gemini",
|
|
33
|
+
# Other known clients
|
|
34
|
+
"cursor": "Cursor",
|
|
35
|
+
"vscode": "VS Code",
|
|
36
|
+
"codeium": "Codeium",
|
|
37
|
+
"copilot": "GitHub Copilot",
|
|
38
|
+
# Generic MCP clients
|
|
39
|
+
"mcp-client": "MCP Client",
|
|
40
|
+
"test-client": "Test Client",
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
# Default friendly name when no match is found
|
|
44
|
+
DEFAULT_FRIENDLY_NAME = "Claude"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def get_friendly_name(client_name: str) -> str:
|
|
48
|
+
"""
|
|
49
|
+
Map a client name to a friendly name.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
client_name: The raw client name from clientInfo
|
|
53
|
+
|
|
54
|
+
Returns:
|
|
55
|
+
A friendly name for display (e.g., "Claude", "Gemini")
|
|
56
|
+
"""
|
|
57
|
+
if not client_name:
|
|
58
|
+
return DEFAULT_FRIENDLY_NAME
|
|
59
|
+
|
|
60
|
+
# Convert to lowercase for case-insensitive matching
|
|
61
|
+
client_name_lower = client_name.lower()
|
|
62
|
+
|
|
63
|
+
# Check each mapping - using 'in' to handle partial matches
|
|
64
|
+
for key, friendly_name in CLIENT_NAME_MAPPINGS.items():
|
|
65
|
+
if key.lower() in client_name_lower:
|
|
66
|
+
return friendly_name
|
|
67
|
+
|
|
68
|
+
# If no match found, return the default
|
|
69
|
+
return DEFAULT_FRIENDLY_NAME
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def get_cached_client_info() -> Optional[dict[str, Any]]:
|
|
73
|
+
"""
|
|
74
|
+
Get cached client information if available.
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
Cached client info dictionary or None
|
|
78
|
+
"""
|
|
79
|
+
global _client_info_cache
|
|
80
|
+
return _client_info_cache
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def get_client_info_from_context(server: Any) -> Optional[dict[str, Any]]:
|
|
84
|
+
"""
|
|
85
|
+
Extract client information from the MCP server's request context.
|
|
86
|
+
|
|
87
|
+
The MCP protocol sends clientInfo during initialization containing:
|
|
88
|
+
- name: The client application name (e.g., "Claude Code", "Claude Desktop")
|
|
89
|
+
- version: The client version string
|
|
90
|
+
|
|
91
|
+
This function also adds a friendly_name field and caches the result.
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
server: The MCP server instance
|
|
95
|
+
|
|
96
|
+
Returns:
|
|
97
|
+
Dictionary with client info or None if not available:
|
|
98
|
+
{
|
|
99
|
+
"name": "claude-ai",
|
|
100
|
+
"version": "1.0.0",
|
|
101
|
+
"friendly_name": "Claude"
|
|
102
|
+
}
|
|
103
|
+
"""
|
|
104
|
+
global _client_info_cache
|
|
105
|
+
|
|
106
|
+
# Return cached info if available
|
|
107
|
+
if _client_info_cache is not None:
|
|
108
|
+
return _client_info_cache
|
|
109
|
+
|
|
110
|
+
try:
|
|
111
|
+
# Try to access the request context and session
|
|
112
|
+
if not server:
|
|
113
|
+
return None
|
|
114
|
+
|
|
115
|
+
# Check if server has request_context property
|
|
116
|
+
request_context = None
|
|
117
|
+
try:
|
|
118
|
+
request_context = server.request_context
|
|
119
|
+
except AttributeError:
|
|
120
|
+
logger.debug("Server does not have request_context property")
|
|
121
|
+
return None
|
|
122
|
+
|
|
123
|
+
if not request_context:
|
|
124
|
+
logger.debug("Request context is None")
|
|
125
|
+
return None
|
|
126
|
+
|
|
127
|
+
# Try to access session from request context
|
|
128
|
+
session = None
|
|
129
|
+
try:
|
|
130
|
+
session = request_context.session
|
|
131
|
+
except AttributeError:
|
|
132
|
+
logger.debug("Request context does not have session property")
|
|
133
|
+
return None
|
|
134
|
+
|
|
135
|
+
if not session:
|
|
136
|
+
logger.debug("Session is None")
|
|
137
|
+
return None
|
|
138
|
+
|
|
139
|
+
# Try to access client params from session
|
|
140
|
+
client_params = None
|
|
141
|
+
try:
|
|
142
|
+
# The clientInfo is stored in _client_params.clientInfo
|
|
143
|
+
client_params = session._client_params
|
|
144
|
+
except AttributeError:
|
|
145
|
+
logger.debug("Session does not have _client_params property")
|
|
146
|
+
return None
|
|
147
|
+
|
|
148
|
+
if not client_params:
|
|
149
|
+
logger.debug("Client params is None")
|
|
150
|
+
return None
|
|
151
|
+
|
|
152
|
+
# Try to extract clientInfo
|
|
153
|
+
client_info = None
|
|
154
|
+
try:
|
|
155
|
+
client_info = client_params.clientInfo
|
|
156
|
+
except AttributeError:
|
|
157
|
+
logger.debug("Client params does not have clientInfo property")
|
|
158
|
+
return None
|
|
159
|
+
|
|
160
|
+
if not client_info:
|
|
161
|
+
logger.debug("Client info is None")
|
|
162
|
+
return None
|
|
163
|
+
|
|
164
|
+
# Extract name and version
|
|
165
|
+
result = {}
|
|
166
|
+
|
|
167
|
+
try:
|
|
168
|
+
result["name"] = client_info.name
|
|
169
|
+
except AttributeError:
|
|
170
|
+
logger.debug("Client info does not have name property")
|
|
171
|
+
|
|
172
|
+
try:
|
|
173
|
+
result["version"] = client_info.version
|
|
174
|
+
except AttributeError:
|
|
175
|
+
logger.debug("Client info does not have version property")
|
|
176
|
+
|
|
177
|
+
if not result:
|
|
178
|
+
return None
|
|
179
|
+
|
|
180
|
+
# Add friendly name
|
|
181
|
+
raw_name = result.get("name", "")
|
|
182
|
+
result["friendly_name"] = get_friendly_name(raw_name)
|
|
183
|
+
|
|
184
|
+
# Cache the result
|
|
185
|
+
_client_info_cache = result
|
|
186
|
+
logger.debug(f"Cached client info: {result}")
|
|
187
|
+
|
|
188
|
+
return result
|
|
189
|
+
|
|
190
|
+
except Exception as e:
|
|
191
|
+
logger.debug(f"Error extracting client info: {e}")
|
|
192
|
+
return None
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def format_client_info(client_info: Optional[dict[str, Any]], use_friendly_name: bool = True) -> str:
|
|
196
|
+
"""
|
|
197
|
+
Format client information for display.
|
|
198
|
+
|
|
199
|
+
Args:
|
|
200
|
+
client_info: Dictionary with client info or None
|
|
201
|
+
use_friendly_name: If True, use the friendly name instead of raw name
|
|
202
|
+
|
|
203
|
+
Returns:
|
|
204
|
+
Formatted string like "Claude v1.0.0" or "Claude"
|
|
205
|
+
"""
|
|
206
|
+
if not client_info:
|
|
207
|
+
return DEFAULT_FRIENDLY_NAME
|
|
208
|
+
|
|
209
|
+
if use_friendly_name:
|
|
210
|
+
name = client_info.get("friendly_name", client_info.get("name", DEFAULT_FRIENDLY_NAME))
|
|
211
|
+
else:
|
|
212
|
+
name = client_info.get("name", "Unknown")
|
|
213
|
+
|
|
214
|
+
version = client_info.get("version", "")
|
|
215
|
+
|
|
216
|
+
if version and not use_friendly_name:
|
|
217
|
+
return f"{name} v{version}"
|
|
218
|
+
else:
|
|
219
|
+
# For friendly names, we just return the name without version
|
|
220
|
+
return name
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def get_client_friendly_name() -> str:
|
|
224
|
+
"""
|
|
225
|
+
Get the cached client's friendly name.
|
|
226
|
+
|
|
227
|
+
This is a convenience function that returns just the friendly name
|
|
228
|
+
from the cached client info, defaulting to "Claude" if not available.
|
|
229
|
+
|
|
230
|
+
Returns:
|
|
231
|
+
The friendly name (e.g., "Claude", "Gemini")
|
|
232
|
+
"""
|
|
233
|
+
cached_info = get_cached_client_info()
|
|
234
|
+
if cached_info:
|
|
235
|
+
return cached_info.get("friendly_name", DEFAULT_FRIENDLY_NAME)
|
|
236
|
+
return DEFAULT_FRIENDLY_NAME
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def log_client_info(server: Any, logger_instance: Optional[logging.Logger] = None) -> None:
|
|
240
|
+
"""
|
|
241
|
+
Log client information extracted from the server.
|
|
242
|
+
|
|
243
|
+
Args:
|
|
244
|
+
server: The MCP server instance
|
|
245
|
+
logger_instance: Optional logger to use (defaults to module logger)
|
|
246
|
+
"""
|
|
247
|
+
log = logger_instance or logger
|
|
248
|
+
|
|
249
|
+
client_info = get_client_info_from_context(server)
|
|
250
|
+
if client_info:
|
|
251
|
+
# Log with both raw and friendly names for debugging
|
|
252
|
+
raw_name = client_info.get("name", "Unknown")
|
|
253
|
+
friendly_name = client_info.get("friendly_name", DEFAULT_FRIENDLY_NAME)
|
|
254
|
+
version = client_info.get("version", "")
|
|
255
|
+
|
|
256
|
+
if raw_name != friendly_name:
|
|
257
|
+
log.info(f"MCP Client Connected: {friendly_name} (raw: {raw_name} v{version})")
|
|
258
|
+
else:
|
|
259
|
+
log.info(f"MCP Client Connected: {friendly_name} v{version}")
|
|
260
|
+
|
|
261
|
+
# Log to activity logger as well
|
|
262
|
+
try:
|
|
263
|
+
activity_logger = logging.getLogger("mcp_activity")
|
|
264
|
+
activity_logger.info(f"CLIENT_IDENTIFIED: {friendly_name} (name={raw_name}, version={version})")
|
|
265
|
+
except Exception:
|
|
266
|
+
pass
|
|
267
|
+
else:
|
|
268
|
+
log.debug("Could not extract client info from MCP protocol")
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
# Example usage in tools:
|
|
272
|
+
#
|
|
273
|
+
# from utils.client_info import get_client_friendly_name, get_cached_client_info
|
|
274
|
+
#
|
|
275
|
+
# # In a tool's execute method:
|
|
276
|
+
# def execute(self, arguments: dict[str, Any]) -> list[TextContent]:
|
|
277
|
+
# # Get the friendly name of the connected client
|
|
278
|
+
# client_name = get_client_friendly_name() # Returns "Claude" or "Gemini" etc.
|
|
279
|
+
#
|
|
280
|
+
# # Or get full cached info if needed
|
|
281
|
+
# client_info = get_cached_client_info()
|
|
282
|
+
# if client_info:
|
|
283
|
+
# raw_name = client_info['name'] # e.g., "claude-ai"
|
|
284
|
+
# version = client_info['version'] # e.g., "1.0.0"
|
|
285
|
+
# friendly = client_info['friendly_name'] # e.g., "Claude"
|
|
286
|
+
#
|
|
287
|
+
# # Customize response based on client
|
|
288
|
+
# if client_name == "Claude":
|
|
289
|
+
# response = f"Hello from PAL MCP Server to {client_name}!"
|
|
290
|
+
# elif client_name == "Gemini":
|
|
291
|
+
# response = f"Greetings {client_name}, welcome to PAL MCP Server!"
|
|
292
|
+
# else:
|
|
293
|
+
# response = f"Welcome {client_name}!"
|