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.
Files changed (141) hide show
  1. clink/__init__.py +19 -0
  2. clink/agents/__init__.py +30 -0
  3. clink/agents/base.py +70 -0
  4. clink/agents/claude.py +7 -0
  5. clink/agents/codex.py +7 -0
  6. clink/agents/gemini.py +7 -0
  7. clink/allowed_roots.py +356 -0
  8. clink/backend.py +765 -0
  9. clink/cli.py +594 -0
  10. clink/constants.py +78 -0
  11. clink/contracts.py +167 -0
  12. clink/files.py +70 -0
  13. clink/generation.py +274 -0
  14. clink/generation_capabilities.py +207 -0
  15. clink/generation_contracts.py +214 -0
  16. clink/generation_policy.py +174 -0
  17. clink/models.py +243 -0
  18. clink/native.py +409 -0
  19. clink/native_adapters.py +244 -0
  20. clink/native_contracts.py +68 -0
  21. clink/native_generation.py +421 -0
  22. clink/native_generation_claude_policy.py +59 -0
  23. clink/native_generation_contracts.py +264 -0
  24. clink/native_generation_families.py +218 -0
  25. clink/native_generation_family_policy.py +14 -0
  26. clink/native_generation_files.py +153 -0
  27. clink/native_generation_policy.py +82 -0
  28. clink/native_policy.py +86 -0
  29. clink/parsers/__init__.py +34 -0
  30. clink/parsers/agy.py +53 -0
  31. clink/parsers/agy_native.py +161 -0
  32. clink/parsers/agy_native_generation.py +189 -0
  33. clink/parsers/base.py +209 -0
  34. clink/parsers/claude.py +104 -0
  35. clink/parsers/claude_native.py +113 -0
  36. clink/parsers/claude_native_generation.py +346 -0
  37. clink/parsers/codex.py +172 -0
  38. clink/parsers/codex_generation.py +212 -0
  39. clink/parsers/codex_native.py +236 -0
  40. clink/parsers/codex_native_generation.py +367 -0
  41. clink/parsers/gemini.py +110 -0
  42. clink/parsers/grok.py +63 -0
  43. clink/parsers/grok_native.py +91 -0
  44. clink/parsers/grok_native_generation.py +157 -0
  45. clink/policy.py +180 -0
  46. clink/registry.py +544 -0
  47. clink/resources/CODEX-LICENSE +201 -0
  48. clink/resources/CODEX-NOTICE +6 -0
  49. clink/resources/PROVENANCE.md +16 -0
  50. clink/resources/codex-0.166.0-models-direct.json +1284 -0
  51. clink/resources/codex-0.166.0-models-generation.json +1269 -0
  52. clink/resources/native-agy-1.4.12-admission.json +746 -0
  53. clink/resources/native-claude-2.1.304-admission.json +99 -0
  54. clink/resources/native-generation-claude-2.1.304-admission.json +277 -0
  55. clink/resources/native-generation-codex-0.166.0-admission.json +309 -0
  56. clink/resources/native-generation-grok-1.0.70-admission.json +329 -0
  57. clink/resources/native-grok-1.0.70-admission.json +644 -0
  58. clink/runner.py +303 -0
  59. clink/setup.py +399 -0
  60. clink/tasks.py +695 -0
  61. clink_cli-0.2.1.dist-info/METADATA +225 -0
  62. clink_cli-0.2.1.dist-info/RECORD +141 -0
  63. clink_cli-0.2.1.dist-info/WHEEL +5 -0
  64. clink_cli-0.2.1.dist-info/entry_points.txt +3 -0
  65. clink_cli-0.2.1.dist-info/licenses/LICENSE +197 -0
  66. clink_cli-0.2.1.dist-info/licenses/clink/resources/CODEX-LICENSE +201 -0
  67. clink_cli-0.2.1.dist-info/licenses/clink/resources/CODEX-NOTICE +6 -0
  68. clink_cli-0.2.1.dist-info/top_level.txt +7 -0
  69. conf/__init__.py +1 -0
  70. conf/cli_clients/agy.json +40 -0
  71. conf/cli_clients/claude.json +39 -0
  72. conf/cli_clients/codex.json +40 -0
  73. conf/cli_clients/gemini.json +23 -0
  74. conf/cli_clients/grok.json +40 -0
  75. config.py +40 -0
  76. server.py +896 -0
  77. systemprompts/__init__.py +35 -0
  78. systemprompts/analyze_prompt.py +87 -0
  79. systemprompts/chat_prompt.py +57 -0
  80. systemprompts/clink/codex_codereviewer.txt +8 -0
  81. systemprompts/clink/default.txt +8 -0
  82. systemprompts/clink/default_codereviewer.txt +8 -0
  83. systemprompts/clink/default_planner.txt +7 -0
  84. systemprompts/clink/native_codereviewer.txt +1 -0
  85. systemprompts/clink/native_default.txt +1 -0
  86. systemprompts/clink/native_planner.txt +1 -0
  87. systemprompts/codereview_prompt.py +91 -0
  88. systemprompts/consensus_prompt.py +120 -0
  89. systemprompts/debug_prompt.py +150 -0
  90. systemprompts/docgen_prompt.py +252 -0
  91. systemprompts/generate_code_prompt.py +181 -0
  92. systemprompts/planner_prompt.py +127 -0
  93. systemprompts/precommit_prompt.py +179 -0
  94. systemprompts/refactor_prompt.py +323 -0
  95. systemprompts/secaudit_prompt.py +420 -0
  96. systemprompts/testgen_prompt.py +133 -0
  97. systemprompts/thinkdeep_prompt.py +55 -0
  98. systemprompts/tracer_prompt.py +145 -0
  99. tools/__init__.py +43 -0
  100. tools/analyze.py +583 -0
  101. tools/apilookup.py +97 -0
  102. tools/challenge.py +198 -0
  103. tools/chat.py +187 -0
  104. tools/clink.py +130 -0
  105. tools/codereview.py +724 -0
  106. tools/consensus.py +790 -0
  107. tools/debug.py +573 -0
  108. tools/docgen.py +577 -0
  109. tools/listmodels.py +48 -0
  110. tools/models.py +373 -0
  111. tools/planner.py +533 -0
  112. tools/precommit.py +776 -0
  113. tools/refactor.py +645 -0
  114. tools/secaudit.py +685 -0
  115. tools/shared/__init__.py +19 -0
  116. tools/shared/base_models.py +167 -0
  117. tools/shared/base_tool.py +659 -0
  118. tools/shared/exceptions.py +20 -0
  119. tools/shared/schema_builders.py +159 -0
  120. tools/simple/__init__.py +18 -0
  121. tools/simple/base.py +539 -0
  122. tools/testgen.py +513 -0
  123. tools/thinkdeep.py +586 -0
  124. tools/tracer.py +763 -0
  125. tools/version.py +53 -0
  126. tools/workflow/__init__.py +22 -0
  127. tools/workflow/base.py +448 -0
  128. tools/workflow/schema_builders.py +166 -0
  129. tools/workflow/workflow_mixin.py +1195 -0
  130. utils/__init__.py +21 -0
  131. utils/call_state.py +349 -0
  132. utils/client_info.py +293 -0
  133. utils/consensus_manifest.py +131 -0
  134. utils/conversation_memory.py +582 -0
  135. utils/env.py +111 -0
  136. utils/file_types.py +271 -0
  137. utils/file_utils.py +803 -0
  138. utils/image_utils.py +94 -0
  139. utils/security_config.py +163 -0
  140. utils/storage_backend.py +177 -0
  141. 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}!"