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
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()
|