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
server.py ADDED
@@ -0,0 +1,896 @@
1
+ """
2
+ clink-mcp-server — CLI consultations and native task delegation
3
+
4
+ This module implements the core MCP (Model Context Protocol) server that provides
5
+ AI-powered tools for code analysis, review, and assistance using multiple AI models.
6
+
7
+ The server follows the MCP specification to expose various AI tools as callable functions
8
+ that can be used by MCP clients (like Claude). Each tool provides specialized functionality
9
+ such as code review, debugging, deep thinking, and general chat capabilities.
10
+
11
+ Key Components:
12
+ - MCP Server: Handles protocol communication and tool discovery
13
+ - Tool Registry: Maps tool names to their implementations
14
+ - Request Handler: Processes incoming tool calls and returns formatted responses
15
+ - Configuration: Manages CLI profiles and model selection
16
+
17
+ The server runs on stdio (standard input/output) and communicates using JSON-RPC messages
18
+ as defined by the MCP protocol.
19
+ """
20
+
21
+ import argparse
22
+ import asyncio
23
+ import json
24
+ import logging
25
+ import os
26
+ import re
27
+ import sys
28
+ import time
29
+ import uuid
30
+ from logging.handlers import RotatingFileHandler
31
+ from pathlib import Path
32
+ from typing import Any
33
+
34
+ from mcp.server import Server # noqa: E402
35
+ from mcp.server.models import InitializationOptions # noqa: E402
36
+ from mcp.server.stdio import stdio_server # noqa: E402
37
+ from mcp.types import ( # noqa: E402
38
+ GetPromptResult,
39
+ Prompt,
40
+ PromptMessage,
41
+ PromptsCapability,
42
+ ServerCapabilities,
43
+ TextContent,
44
+ Tool,
45
+ ToolAnnotations,
46
+ ToolsCapability,
47
+ )
48
+ from pydantic import ValidationError
49
+
50
+ from config import __version__
51
+ from tools import ( # noqa: E402
52
+ AnalyzeTool,
53
+ ChallengeTool,
54
+ ChatTool,
55
+ CLinkTool,
56
+ CodeReviewTool,
57
+ ConsensusTool,
58
+ DebugIssueTool,
59
+ DocgenTool,
60
+ ListModelsTool,
61
+ LookupTool,
62
+ PlannerTool,
63
+ PrecommitTool,
64
+ RefactorTool,
65
+ SecauditTool,
66
+ TestGenTool,
67
+ ThinkDeepTool,
68
+ TracerTool,
69
+ VersionTool,
70
+ )
71
+ from tools.shared.exceptions import ToolExecutionError # noqa: E402
72
+ from utils.env import env_override_enabled, get_env # noqa: E402
73
+
74
+ # Configure logging for server operations
75
+ # Can be controlled via LOG_LEVEL environment variable (DEBUG, INFO, WARNING, ERROR)
76
+ log_level = (get_env("LOG_LEVEL", "INFO") or "INFO").upper()
77
+
78
+ # Create timezone-aware formatter
79
+
80
+
81
+ class LocalTimeFormatter(logging.Formatter):
82
+ def formatTime(self, record, datefmt=None):
83
+ """Override to use local timezone instead of UTC"""
84
+ ct = self.converter(record.created)
85
+ if datefmt:
86
+ s = time.strftime(datefmt, ct)
87
+ else:
88
+ t = time.strftime("%Y-%m-%d %H:%M:%S", ct)
89
+ s = f"{t},{record.msecs:03.0f}"
90
+ return s
91
+
92
+
93
+ # Configure both console and file logging
94
+ log_format = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
95
+
96
+ # Clear any existing handlers first
97
+ root_logger = logging.getLogger()
98
+ root_logger.handlers.clear()
99
+
100
+ # Create and configure stderr handler explicitly
101
+ stderr_handler = logging.StreamHandler(sys.stderr)
102
+ stderr_handler.setLevel(getattr(logging, log_level, logging.INFO))
103
+ stderr_handler.setFormatter(LocalTimeFormatter(log_format))
104
+ root_logger.addHandler(stderr_handler)
105
+
106
+ # Note: MCP stdio_server interferes with stderr during tool execution
107
+ # All logs are properly written to logs/mcp_server.log for monitoring
108
+
109
+ # Set root logger level
110
+ root_logger.setLevel(getattr(logging, log_level, logging.INFO))
111
+
112
+ # Add rotating file handler for local log monitoring
113
+
114
+ try:
115
+ # Create logs directory in project root
116
+ log_dir = Path(__file__).parent / "logs"
117
+ log_dir.mkdir(exist_ok=True)
118
+
119
+ # Main server log with size-based rotation (20MB max per file)
120
+ # This ensures logs don't grow indefinitely and are properly managed
121
+ file_handler = RotatingFileHandler(
122
+ log_dir / "mcp_server.log",
123
+ maxBytes=20 * 1024 * 1024, # 20MB max file size
124
+ backupCount=5, # Keep 10 rotated files (100MB total)
125
+ encoding="utf-8",
126
+ )
127
+ file_handler.setLevel(getattr(logging, log_level, logging.INFO))
128
+ file_handler.setFormatter(LocalTimeFormatter(log_format))
129
+ logging.getLogger().addHandler(file_handler)
130
+
131
+ # Create a special logger for MCP activity tracking with size-based rotation
132
+ mcp_logger = logging.getLogger("mcp_activity")
133
+ mcp_file_handler = RotatingFileHandler(
134
+ log_dir / "mcp_activity.log",
135
+ maxBytes=10 * 1024 * 1024, # 20MB max file size
136
+ backupCount=2, # Keep 5 rotated files (20MB total)
137
+ encoding="utf-8",
138
+ )
139
+ mcp_file_handler.setLevel(logging.INFO)
140
+ mcp_file_handler.setFormatter(LocalTimeFormatter("%(asctime)s - %(message)s"))
141
+ mcp_logger.addHandler(mcp_file_handler)
142
+ mcp_logger.setLevel(logging.INFO)
143
+ # Ensure MCP activity also goes to stderr
144
+ mcp_logger.propagate = True
145
+
146
+ # Log setup info directly to root logger since logger isn't defined yet
147
+ logging.info(f"Logging to: {log_dir / 'mcp_server.log'}")
148
+ logging.info(f"Process PID: {os.getpid()}")
149
+
150
+ except Exception as e:
151
+ print(f"Warning: Could not set up file logging: {e}", file=sys.stderr)
152
+
153
+ logger = logging.getLogger(__name__)
154
+
155
+ # Log PAL_MCP_FORCE_ENV_OVERRIDE configuration for transparency
156
+ if env_override_enabled():
157
+ logger.info("PAL_MCP_FORCE_ENV_OVERRIDE enabled - .env file values will override system environment variables")
158
+ logger.debug("Environment override prevents conflicts between different AI tools passing cached API keys")
159
+ else:
160
+ logger.debug("PAL_MCP_FORCE_ENV_OVERRIDE disabled - system environment variables take precedence")
161
+
162
+
163
+ # Create the MCP server instance with a unique name identifier
164
+ # This name is used by MCP clients to identify and connect to this specific server
165
+ server: Server = Server("clink-mcp-server")
166
+
167
+
168
+ # Constants for tool filtering
169
+ ESSENTIAL_TOOLS = {"version", "listmodels"}
170
+
171
+
172
+ def parse_disabled_tools_env() -> set[str]:
173
+ """
174
+ Parse the DISABLED_TOOLS environment variable into a set of tool names.
175
+
176
+ Returns:
177
+ Set of lowercase tool names to disable, empty set if none specified
178
+ """
179
+ disabled_tools_env = (get_env("DISABLED_TOOLS", "") or "").strip()
180
+ if not disabled_tools_env:
181
+ return set()
182
+ return {t.strip().lower() for t in disabled_tools_env.split(",") if t.strip()}
183
+
184
+
185
+ def validate_disabled_tools(disabled_tools: set[str], all_tools: dict[str, Any]) -> None:
186
+ """
187
+ Validate the disabled tools list and log appropriate warnings.
188
+
189
+ Args:
190
+ disabled_tools: Set of tool names requested to be disabled
191
+ all_tools: Dictionary of all available tool instances
192
+ """
193
+ essential_disabled = disabled_tools & ESSENTIAL_TOOLS
194
+ if essential_disabled:
195
+ logger.warning(f"Cannot disable essential tools: {sorted(essential_disabled)}")
196
+ unknown_tools = disabled_tools - set(all_tools.keys())
197
+ if unknown_tools:
198
+ logger.warning(f"Unknown tools in DISABLED_TOOLS: {sorted(unknown_tools)}")
199
+
200
+
201
+ def apply_tool_filter(all_tools: dict[str, Any], disabled_tools: set[str]) -> dict[str, Any]:
202
+ """
203
+ Apply the disabled tools filter to create the final tools dictionary.
204
+
205
+ Args:
206
+ all_tools: Dictionary of all available tool instances
207
+ disabled_tools: Set of tool names to disable
208
+
209
+ Returns:
210
+ Dictionary containing only enabled tools
211
+ """
212
+ enabled_tools = {}
213
+ for tool_name, tool_instance in all_tools.items():
214
+ if tool_name in ESSENTIAL_TOOLS or tool_name not in disabled_tools:
215
+ enabled_tools[tool_name] = tool_instance
216
+ else:
217
+ logger.debug(f"Tool '{tool_name}' disabled via DISABLED_TOOLS")
218
+ return enabled_tools
219
+
220
+
221
+ def log_tool_configuration(disabled_tools: set[str], enabled_tools: dict[str, Any]) -> None:
222
+ """
223
+ Log the final tool configuration for visibility.
224
+
225
+ Args:
226
+ disabled_tools: Set of tool names that were requested to be disabled
227
+ enabled_tools: Dictionary of tools that remain enabled
228
+ """
229
+ if not disabled_tools:
230
+ logger.info("All tools enabled (DISABLED_TOOLS not set)")
231
+ return
232
+ actual_disabled = disabled_tools - ESSENTIAL_TOOLS
233
+ if actual_disabled:
234
+ logger.debug(f"Disabled tools: {sorted(actual_disabled)}")
235
+ logger.info(f"Active tools: {sorted(enabled_tools.keys())}")
236
+
237
+
238
+ def filter_disabled_tools(all_tools: dict[str, Any]) -> dict[str, Any]:
239
+ """
240
+ Filter tools based on DISABLED_TOOLS environment variable.
241
+
242
+ Args:
243
+ all_tools: Dictionary of all available tool instances
244
+
245
+ Returns:
246
+ dict: Filtered dictionary containing only enabled tools
247
+ """
248
+ disabled_tools = parse_disabled_tools_env()
249
+ if not disabled_tools:
250
+ log_tool_configuration(disabled_tools, all_tools)
251
+ return all_tools
252
+ validate_disabled_tools(disabled_tools, all_tools)
253
+ enabled_tools = apply_tool_filter(all_tools, disabled_tools)
254
+ log_tool_configuration(disabled_tools, enabled_tools)
255
+ return enabled_tools
256
+
257
+
258
+ # Initialize the tool registry with all available AI-powered tools
259
+ # Each tool provides specialized functionality for different development tasks
260
+ # These instances describe schemas; execution constructs a fresh instance per call.
261
+ TOOLS = {
262
+ "chat": ChatTool(), # Interactive development chat and brainstorming
263
+ "clink": CLinkTool(), # Bridge requests to configured AI CLIs
264
+ "thinkdeep": ThinkDeepTool(), # Step-by-step deep thinking workflow with expert analysis
265
+ "planner": PlannerTool(), # Interactive sequential planner using workflow architecture
266
+ "consensus": ConsensusTool(), # Step-by-step consensus workflow with multi-model analysis
267
+ "codereview": CodeReviewTool(), # Comprehensive step-by-step code review workflow with expert analysis
268
+ "precommit": PrecommitTool(), # Step-by-step pre-commit validation workflow
269
+ "debug": DebugIssueTool(), # Root cause analysis and debugging assistance
270
+ "secaudit": SecauditTool(), # Comprehensive security audit with OWASP Top 10 and compliance coverage
271
+ "docgen": DocgenTool(), # Step-by-step documentation generation with complexity analysis
272
+ "analyze": AnalyzeTool(), # General-purpose file and code analysis
273
+ "refactor": RefactorTool(), # Step-by-step refactoring analysis workflow with expert validation
274
+ "tracer": TracerTool(), # Static call path prediction and control flow analysis
275
+ "testgen": TestGenTool(), # Step-by-step test generation workflow with expert validation
276
+ "challenge": ChallengeTool(), # Critical challenge prompt wrapper to avoid automatic agreement
277
+ "apilookup": LookupTool(), # Quick web/API lookup instructions
278
+ "listmodels": ListModelsTool(), # List all available AI models by provider
279
+ "version": VersionTool(), # Display server version and system information
280
+ }
281
+ TOOLS = filter_disabled_tools(TOOLS)
282
+
283
+ # Rich prompt templates for all tools
284
+ PROMPT_TEMPLATES = {
285
+ "chat": {
286
+ "name": "chat",
287
+ "description": "Chat and brainstorm ideas",
288
+ "template": "Chat with {model} about this",
289
+ },
290
+ "clink": {
291
+ "name": "clink",
292
+ "description": "Forward a request to a configured AI CLI (e.g., Codex)",
293
+ "template": "Use clink with cli_name=<cli> to run this prompt",
294
+ },
295
+ "thinkdeep": {
296
+ "name": "thinkdeeper",
297
+ "description": "Step-by-step deep thinking workflow with expert analysis",
298
+ "template": "Start comprehensive deep thinking workflow with {model} using {thinking_mode} thinking mode",
299
+ },
300
+ "planner": {
301
+ "name": "planner",
302
+ "description": "Break down complex ideas, problems, or projects into multiple manageable steps",
303
+ "template": "Create a detailed plan with {model}",
304
+ },
305
+ "consensus": {
306
+ "name": "consensus",
307
+ "description": "Step-by-step consensus workflow with multi-model analysis",
308
+ "template": "Start comprehensive consensus workflow with {model}",
309
+ },
310
+ "codereview": {
311
+ "name": "review",
312
+ "description": "Perform a comprehensive code review",
313
+ "template": "Perform a comprehensive code review with {model}",
314
+ },
315
+ "precommit": {
316
+ "name": "precommit",
317
+ "description": "Step-by-step pre-commit validation workflow",
318
+ "template": "Start comprehensive pre-commit validation workflow with {model}",
319
+ },
320
+ "debug": {
321
+ "name": "debug",
322
+ "description": "Debug an issue or error",
323
+ "template": "Help debug this issue with {model}",
324
+ },
325
+ "secaudit": {
326
+ "name": "secaudit",
327
+ "description": "Comprehensive security audit with OWASP Top 10 coverage",
328
+ "template": "Perform comprehensive security audit with {model}",
329
+ },
330
+ "docgen": {
331
+ "name": "docgen",
332
+ "description": "Generate comprehensive code documentation with complexity analysis",
333
+ "template": "Generate comprehensive documentation with {model}",
334
+ },
335
+ "analyze": {
336
+ "name": "analyze",
337
+ "description": "Analyze files and code structure",
338
+ "template": "Analyze these files with {model}",
339
+ },
340
+ "refactor": {
341
+ "name": "refactor",
342
+ "description": "Refactor and improve code structure",
343
+ "template": "Refactor this code with {model}",
344
+ },
345
+ "tracer": {
346
+ "name": "tracer",
347
+ "description": "Trace code execution paths",
348
+ "template": "Generate tracer analysis with {model}",
349
+ },
350
+ "testgen": {
351
+ "name": "testgen",
352
+ "description": "Generate comprehensive tests",
353
+ "template": "Generate comprehensive tests with {model}",
354
+ },
355
+ "challenge": {
356
+ "name": "challenge",
357
+ "description": "Challenge a statement critically without automatic agreement",
358
+ "template": "Challenge this statement critically",
359
+ },
360
+ "apilookup": {
361
+ "name": "apilookup",
362
+ "description": "Look up the latest API or SDK information",
363
+ "template": "Lookup latest API docs for {model}",
364
+ },
365
+ "listmodels": {
366
+ "name": "listmodels",
367
+ "description": "List available AI models",
368
+ "template": "List all available models",
369
+ },
370
+ "version": {
371
+ "name": "version",
372
+ "description": "Show server version and system information",
373
+ "template": "Show PAL MCP Server version",
374
+ },
375
+ }
376
+
377
+
378
+ @server.list_tools()
379
+ async def handle_list_tools() -> list[Tool]:
380
+ """
381
+ List all available tools with their descriptions and input schemas.
382
+
383
+ This handler is called by MCP clients during initialization to discover
384
+ what tools are available. Each tool provides:
385
+ - name: Unique identifier for the tool
386
+ - description: Detailed explanation of what the tool does
387
+ - inputSchema: JSON Schema defining the expected parameters
388
+
389
+ Returns:
390
+ List of Tool objects representing all available tools
391
+ """
392
+ logger.debug("MCP client requested tool list")
393
+
394
+ # Try to log client info if available (this happens early in the handshake)
395
+ try:
396
+ from utils.client_info import format_client_info, get_client_info_from_context
397
+
398
+ client_info = get_client_info_from_context(server)
399
+ if client_info:
400
+ formatted = format_client_info(client_info)
401
+ logger.info(f"MCP Client Connected: {formatted}")
402
+
403
+ # Log to activity file as well
404
+ try:
405
+ mcp_activity_logger = logging.getLogger("mcp_activity")
406
+ friendly_name = client_info.get("friendly_name", "CLI Agent")
407
+ raw_name = client_info.get("name", "Unknown")
408
+ version = client_info.get("version", "Unknown")
409
+ mcp_activity_logger.info(f"MCP_CLIENT_INFO: {friendly_name} (raw={raw_name} v{version})")
410
+ except Exception:
411
+ pass
412
+ except Exception as e:
413
+ logger.debug(f"Could not log client info during list_tools: {e}")
414
+ tools = []
415
+
416
+ # Add all registered AI-powered tools from the TOOLS registry
417
+ for tool in TOOLS.values():
418
+ # Get optional annotations from the tool
419
+ annotations = tool.get_annotations()
420
+ tool_annotations = ToolAnnotations(**annotations) if annotations else None
421
+
422
+ tools.append(
423
+ Tool(
424
+ name=tool.name,
425
+ description=tool.description,
426
+ inputSchema=tool.get_input_schema(),
427
+ annotations=tool_annotations,
428
+ )
429
+ )
430
+
431
+ logger.debug(f"Returning {len(tools)} tools to MCP client")
432
+ return tools
433
+
434
+
435
+ def _json_response(payload: dict[str, Any]) -> list[TextContent]:
436
+ return [TextContent(type="text", text=json.dumps(payload, ensure_ascii=False))]
437
+
438
+
439
+ def _failure(
440
+ code: str, message: str, request_id: str, remedy: str = "", *, missing_environment_names: Any = None
441
+ ) -> list[TextContent]:
442
+ error = {"code": code, "message": message, "remedy": remedy}
443
+ if isinstance(missing_environment_names, list):
444
+ # Expose only bounded identifiers, never arbitrary backend metadata or values.
445
+ error["missing_environment_names"] = [
446
+ name
447
+ for name in missing_environment_names[:64]
448
+ if isinstance(name, str) and re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]{0,127}", name)
449
+ ]
450
+ return _json_response({"status": "error", "error": error, "request_id": request_id})
451
+
452
+
453
+ def _is_failure(result: list[TextContent]) -> bool:
454
+ for item in result:
455
+ try:
456
+ payload = json.loads(item.text)
457
+ except (ValueError, AttributeError):
458
+ continue
459
+ if isinstance(payload, dict) and (payload.get("status") == "error" or payload.get("error")):
460
+ return True
461
+ return False
462
+
463
+
464
+ def _records_success(result: list[TextContent]) -> bool:
465
+ if _is_failure(result):
466
+ return False
467
+ for item in result:
468
+ try:
469
+ payload = json.loads(item.text)
470
+ except ValueError:
471
+ continue
472
+ if isinstance(payload, dict) and payload.get("attempt_success") is False:
473
+ return False
474
+ return True
475
+
476
+
477
+ def _with_replay_metadata(result: list[TextContent], call) -> list[TextContent]:
478
+ enriched = []
479
+ for item in result:
480
+ try:
481
+ payload = json.loads(item.text)
482
+ except ValueError:
483
+ enriched.append(item)
484
+ continue
485
+ if isinstance(payload, dict):
486
+ payload.setdefault("metadata", {})["replay"] = {
487
+ **call.replay_metadata,
488
+ "files": "current filesystem state; prior file contents are not snapshots",
489
+ }
490
+ enriched.append(TextContent(type="text", text=json.dumps(payload, ensure_ascii=False)))
491
+ else:
492
+ enriched.append(item)
493
+ return enriched
494
+
495
+
496
+ def _backend_identity(result: list[TextContent]) -> dict[str, Any]:
497
+ for item in result:
498
+ try:
499
+ metadata = json.loads(item.text).get("metadata", {})
500
+ except (ValueError, AttributeError):
501
+ continue
502
+ if isinstance(metadata, dict) and metadata.get("backend"):
503
+ return {key: metadata[key] for key in ("backend", "requested_model", "actual_model") if key in metadata}
504
+ return {}
505
+
506
+
507
+ def _has_partial_context(result: list[TextContent]) -> bool:
508
+ for item in result:
509
+ try:
510
+ payload = json.loads(item.text)
511
+ except ValueError:
512
+ continue
513
+ if isinstance(payload, dict) and any(
514
+ outcome.get("status") == "success" for outcome in payload.get("accumulated_responses", [])
515
+ ):
516
+ return True
517
+ return False
518
+
519
+
520
+ def _bound_response(result: list[TextContent], request_id: str) -> list[TextContent]:
521
+ from config import MCP_RESPONSE_SIZE_LIMIT
522
+
523
+ size = len(json.dumps([item.model_dump() for item in result], ensure_ascii=False).encode("utf-8")) + 128
524
+ if size <= MCP_RESPONSE_SIZE_LIMIT:
525
+ return result
526
+ return _failure("output_limit", "The complete tool response exceeds the configured MCP response limit.", request_id)
527
+
528
+
529
+ def _replay_arguments(arguments: dict[str, Any], call) -> dict[str, Any]:
530
+ from utils.conversation_memory import get_conversation_file_list, get_conversation_image_list
531
+
532
+ prepared = dict(arguments)
533
+ prepared["_replay_history"] = call.history()
534
+ prepared["_replay_history_turns"] = call.history_turns()
535
+ prepared["_replay_omitted_turns"] = call.replay_metadata["omitted_turns"]
536
+ # Paths are revalidated by the selected adapter before any read or launch.
537
+ files = get_conversation_file_list(call.context)
538
+ if files:
539
+ key = "relevant_files" if "step_number" in arguments else "absolute_file_paths"
540
+ prepared[key] = list(dict.fromkeys([*arguments.get(key, []), *files]))
541
+ images = get_conversation_image_list(call.context)
542
+ if images:
543
+ prepared["images"] = list(dict.fromkeys([*arguments.get("images", []), *images]))
544
+ return prepared
545
+
546
+
547
+ async def _execute_call(name: str, arguments: dict[str, Any], request_id: str, admitted_at: float) -> list[TextContent]:
548
+ from clink.contracts import BackendError
549
+ from utils.call_state import StateError, conversation_call
550
+
551
+ if name not in TOOLS:
552
+ return _failure("unknown_tool", "Unknown or disabled tool.", request_id)
553
+ if not isinstance(arguments, dict) or any(str(key).startswith("_") for key in arguments):
554
+ return _failure("invalid_request", "Tool arguments must be an object without private fields.", request_id)
555
+ from config import MCP_PROMPT_SIZE_LIMIT
556
+
557
+ if len(json.dumps(arguments, ensure_ascii=False).encode("utf-8")) > MCP_PROMPT_SIZE_LIMIT:
558
+ return _failure("input_limit", "Required tool arguments exceed the 1 MiB UTF-8 input budget.", request_id)
559
+ # Discovery descriptors are never used for execution. Every call owns its tool state.
560
+ tool = type(TOOLS[name])()
561
+ try:
562
+ request = tool.get_request_model()(**arguments)
563
+ except ValidationError as exc:
564
+ errors = exc.errors(include_input=False, include_context=False, include_url=False)
565
+ fields = sorted({".".join(map(str, err["loc"]))[:80] for err in errors[:8]})
566
+ suffix = f" ({exc.error_count()} validation errors; first eight shown)" if len(errors) > 8 else ""
567
+ return _failure("invalid_request", "Invalid fields: " + ", ".join(fields) + suffix, request_id)
568
+ original = request.model_dump(exclude_unset=True)
569
+ schema = tool.get_input_schema()
570
+ if schema.get("additionalProperties") is False and set(arguments) - set(schema.get("properties", {})):
571
+ return _failure("invalid_request", "Unknown fields are not accepted by this tool.", request_id)
572
+ if name == "clink" and original.get("action", "consult") != "consult":
573
+ from clink.tasks import get_task_manager
574
+
575
+ manager = get_task_manager()
576
+ action = original["action"]
577
+ if action == "start":
578
+ payload = await manager.start(original, admitted_at=admitted_at, request_id=request_id)
579
+ elif action == "followup":
580
+ payload = await manager.followup(original, admitted_at=admitted_at, request_id=request_id)
581
+ elif action == "status":
582
+ payload = await manager.status(original)
583
+ else:
584
+ payload = await manager.cancel(original)
585
+ return _bound_response(_json_response(payload), request_id)
586
+ deadline = admitted_at + min(1800.0, float(original.get("timeout_seconds", 1800.0)))
587
+ internal = {
588
+ "_original_arguments": original,
589
+ "_request_id": request_id,
590
+ "_admitted_at": admitted_at,
591
+ "_deadline": deadline,
592
+ }
593
+ if name in {"challenge", "version", "listmodels"} and not original.get("continuation_id"):
594
+ return _bound_response(await tool.execute({**original, **internal}), request_id)
595
+ async with conversation_call(
596
+ name, original, deadline=deadline, admitted_at=admitted_at, request_id=request_id
597
+ ) as call:
598
+ try:
599
+ if call.cached_result is not None:
600
+ if name == "consensus":
601
+ await tool.validate_cached_result(call.workflow_state)
602
+ return [TextContent.model_validate(item) for item in call.cached_result]
603
+ prepared = original if name == "consensus" else _replay_arguments(original, call)
604
+ prepared = {**prepared, **internal, "continuation_id": call.continuation_id}
605
+ result = _bound_response(_with_replay_metadata(await tool.execute(prepared), call), request_id)
606
+ except (BackendError, StateError) as exc:
607
+ result = _failure(exc.code, str(exc), request_id, exc.remedy if isinstance(exc, BackendError) else "")
608
+ except ToolExecutionError as exc:
609
+ # ToolExecutionError already carries a public, structured failure.
610
+ try:
611
+ payload = json.loads(exc.payload)
612
+ except ValueError:
613
+ payload = {"status": "error", "error": {"code": "tool_error", "message": "Tool execution failed."}}
614
+ result = _bound_response(_json_response(payload), request_id)
615
+ except ValidationError:
616
+ result = _failure("invalid_request", "Tool context failed request validation.", request_id)
617
+ result = _bound_response(result, request_id)
618
+ success = _records_success(result)
619
+ call.commit(
620
+ result,
621
+ success=success,
622
+ user_content=tool.__dict__.get("_effective_user_prompt")
623
+ or original.get("prompt")
624
+ or original.get("step")
625
+ or "",
626
+ assistant_content="\n".join(item.text for item in result),
627
+ files=list(dict.fromkeys([*original.get("absolute_file_paths", []), *original.get("relevant_files", [])])),
628
+ images=original.get("images") or [],
629
+ backend_identity=_backend_identity(result),
630
+ retain_failure_context=name == "consensus" and not success and _has_partial_context(result),
631
+ )
632
+ return result
633
+
634
+
635
+ @server.call_tool()
636
+ async def handle_call_tool(name: str, arguments: dict[str, Any]) -> list[TextContent]:
637
+ """Execute with isolated per-call state and one serialized continuation transaction."""
638
+ from clink.contracts import BackendError
639
+ from utils.call_state import StateError
640
+
641
+ request_id = str(uuid.uuid4())
642
+ admitted_at = time.monotonic()
643
+ log_name = name if name in TOOLS else "unknown"
644
+ logger.info("tool_start request_id=%s tool=%s", request_id, log_name)
645
+ try:
646
+ result = await _execute_call(name, arguments, request_id, admitted_at)
647
+ except (BackendError, StateError) as exc:
648
+ result = _failure(
649
+ exc.code,
650
+ str(exc),
651
+ request_id,
652
+ exc.remedy if isinstance(exc, BackendError) else "",
653
+ missing_environment_names=(
654
+ exc.metadata.get("missing_environment_names") if isinstance(exc, BackendError) else None
655
+ ),
656
+ )
657
+ except asyncio.CancelledError:
658
+ logger.info("tool_cancelled request_id=%s tool=%s", request_id, log_name)
659
+ raise
660
+ except Exception as exc:
661
+ # Diagnostic type only: exception messages may contain prompts or credentials.
662
+ logger.error("tool_failure request_id=%s tool=%s type=%s", request_id, log_name, type(exc).__name__)
663
+ result = _failure("internal_error", "The tool could not complete the request.", request_id)
664
+ # Cached results and every early/caught failure pass the same public byte ceiling.
665
+ result = _bound_response(result, request_id)
666
+ logger.info(
667
+ "tool_end request_id=%s tool=%s duration=%.3f success=%s",
668
+ request_id,
669
+ log_name,
670
+ time.monotonic() - admitted_at,
671
+ not _is_failure(result),
672
+ )
673
+ if _is_failure(result):
674
+ raise ToolExecutionError(result[0].text)
675
+ return result
676
+
677
+
678
+ @server.list_prompts()
679
+ async def handle_list_prompts() -> list[Prompt]:
680
+ """
681
+ List all available prompts for CLI Code shortcuts.
682
+
683
+ This handler returns prompts that enable shortcuts like /pal:thinkdeeper.
684
+ We automatically generate prompts from all tools (1:1 mapping) plus add
685
+ a few marketing aliases with richer templates for commonly used tools.
686
+
687
+ Returns:
688
+ List of Prompt objects representing all available prompts
689
+ """
690
+ logger.debug("MCP client requested prompt list")
691
+ prompts = []
692
+
693
+ # Add a prompt for each tool with rich templates
694
+ for tool_name, tool in TOOLS.items():
695
+ if tool_name in PROMPT_TEMPLATES:
696
+ # Use the rich template
697
+ template_info = PROMPT_TEMPLATES[tool_name]
698
+ prompts.append(
699
+ Prompt(
700
+ name=template_info["name"],
701
+ description=template_info["description"],
702
+ arguments=[], # MVP: no structured args
703
+ )
704
+ )
705
+ else:
706
+ # Fallback for any tools without templates (shouldn't happen)
707
+ prompts.append(
708
+ Prompt(
709
+ name=tool_name,
710
+ description=f"Use {tool.name} tool",
711
+ arguments=[],
712
+ )
713
+ )
714
+
715
+ # Add special "continue" prompt
716
+ prompts.append(
717
+ Prompt(
718
+ name="continue",
719
+ description="Continue the previous conversation using the chat tool",
720
+ arguments=[],
721
+ )
722
+ )
723
+
724
+ logger.debug(f"Returning {len(prompts)} prompts to MCP client")
725
+ return prompts
726
+
727
+
728
+ @server.get_prompt()
729
+ async def handle_get_prompt(name: str, arguments: dict[str, Any] = None) -> GetPromptResult:
730
+ """
731
+ Get prompt details and generate the actual prompt text.
732
+
733
+ This handler is called when a user invokes a prompt (e.g., /pal:thinkdeeper or /pal:chat:gpt5).
734
+ It generates the appropriate text that CLI will then use to call the
735
+ underlying tool.
736
+
737
+ Supports structured prompt names like "chat:gpt5" where:
738
+ - "chat" is the tool name
739
+ - "gpt5" is the model to use
740
+
741
+ Args:
742
+ name: The name of the prompt to execute (can include model like "chat:gpt5")
743
+ arguments: Optional arguments for the prompt (e.g., model, thinking_mode)
744
+
745
+ Returns:
746
+ GetPromptResult with the prompt details and generated message
747
+
748
+ Raises:
749
+ ValueError: If the prompt name is unknown
750
+ """
751
+ logger.debug("MCP prompt requested name=%s", name)
752
+
753
+ # Handle special "continue" case
754
+ if name.lower() == "continue":
755
+ # This is "/pal:continue" - use chat tool as default for continuation
756
+ tool_name = "chat"
757
+ template_info = {
758
+ "name": "continue",
759
+ "description": "Continue the previous conversation",
760
+ "template": "Continue the conversation",
761
+ }
762
+ logger.debug("Using /pal:continue - defaulting to chat tool")
763
+ else:
764
+ # Find the corresponding tool by checking prompt names
765
+ tool_name = None
766
+ template_info = None
767
+
768
+ # Check if it's a known prompt name
769
+ for t_name, t_info in PROMPT_TEMPLATES.items():
770
+ if t_info["name"] == name:
771
+ tool_name = t_name
772
+ template_info = t_info
773
+ break
774
+
775
+ # If not found, check if it's a direct tool name
776
+ if not tool_name and name in TOOLS:
777
+ tool_name = name
778
+ template_info = {
779
+ "name": name,
780
+ "description": f"Use {name} tool",
781
+ "template": f"Use {name}",
782
+ }
783
+
784
+ if not tool_name:
785
+ logger.error(f"Unknown prompt requested: {name}")
786
+ raise ValueError(f"Unknown prompt: {name}")
787
+
788
+ # Get the template
789
+ template = template_info.get("template", f"Use {tool_name}")
790
+
791
+ # Safe template expansion with defaults
792
+ final_model = arguments.get("model", "auto") if arguments else "auto"
793
+
794
+ prompt_args = {
795
+ "model": final_model,
796
+ "thinking_mode": arguments.get("thinking_mode", "medium") if arguments else "medium",
797
+ }
798
+
799
+ logger.debug(f"Using model '{final_model}' for prompt '{name}'")
800
+
801
+ # Safely format the template
802
+ try:
803
+ prompt_text = template.format(**prompt_args)
804
+ except KeyError as e:
805
+ logger.warning(f"Missing template argument {e} for prompt {name}, using raw template")
806
+ prompt_text = template # Fallback to raw template
807
+
808
+ # Generate tool call instruction
809
+ if name.lower() == "continue":
810
+ # "/pal:continue" case
811
+ tool_instruction = (
812
+ f"Continue the previous conversation using the {tool_name} tool. "
813
+ "CRITICAL: You MUST provide the continuation_id from the previous response to maintain conversation context. "
814
+ "Additionally, you should reuse the same model that was used in the previous exchange for consistency, unless "
815
+ "the user specifically asks for a different model name to be used."
816
+ )
817
+ else:
818
+ # Simple prompt case
819
+ tool_instruction = prompt_text
820
+
821
+ return GetPromptResult(
822
+ prompt=Prompt(
823
+ name=name,
824
+ description=template_info["description"],
825
+ arguments=[],
826
+ ),
827
+ messages=[
828
+ PromptMessage(
829
+ role="user",
830
+ content={"type": "text", "text": tool_instruction},
831
+ )
832
+ ],
833
+ )
834
+
835
+
836
+ async def main():
837
+ """Start stdio MCP without provider credentials or installed model CLIs."""
838
+ from clink.backend import get_backend
839
+ from clink.tasks import shutdown_task_manager
840
+
841
+ logger.info("clink-mcp-server starting version=%s tools=%d", __version__, len(TOOLS))
842
+ backend = get_backend()
843
+ try:
844
+ async with stdio_server() as (read_stream, write_stream):
845
+ await server.run(
846
+ read_stream,
847
+ write_stream,
848
+ InitializationOptions(
849
+ server_name="clink-mcp-server",
850
+ server_version=__version__,
851
+ instructions=(
852
+ "Consultations and analytical tools delegate advisory analysis; default Codex/default. "
853
+ "Explicit clink start/status/followup/cancel actions manage native tasks that can edit files "
854
+ "and use native configured tools, hooks, MCP and subagents under native permissions. "
855
+ "Start requires cli_name, project_dir, prompt and a unique request_key; poll task_handle. "
856
+ "Pass native CLI model identifiers unchanged; omitted or auto uses configured/native defaults. "
857
+ "Use listmodels for local CLI configuration and capability discovery. "
858
+ "Consultations reuse continuation_id for PAL context; native actions reject it. "
859
+ "Native completed means execution ended, not verified fulfillment. Cancellation cannot undo effects."
860
+ ),
861
+ capabilities=ServerCapabilities(tools=ToolsCapability(), prompts=PromptsCapability()),
862
+ ),
863
+ )
864
+ finally:
865
+ try:
866
+ await shutdown_task_manager()
867
+ finally:
868
+ await backend.close()
869
+
870
+
871
+ def run(argv: list[str] | None = None):
872
+ """Console script entry point for clink-mcp-server."""
873
+ from clink.registry import RegistryLoadError, get_registry
874
+
875
+ parser = argparse.ArgumentParser(description="CLI consultations and native task delegation over MCP stdio.")
876
+ parser.add_argument(
877
+ "--project",
878
+ metavar="EXISTING_DIR",
879
+ help="Bind project manifests and default workspace roots to this directory.",
880
+ )
881
+ arguments = parser.parse_args(argv)
882
+ if arguments.project is not None and not arguments.project.strip():
883
+ parser.error("--project must be a nonempty directory path")
884
+ try:
885
+ get_registry(project_dir=Path(arguments.project) if arguments.project is not None else None)
886
+ except RegistryLoadError as exc:
887
+ parser.error(str(exc))
888
+ try:
889
+ asyncio.run(main())
890
+ except KeyboardInterrupt:
891
+ # Handle graceful shutdown
892
+ pass
893
+
894
+
895
+ if __name__ == "__main__":
896
+ run()