foundry-mcp 0.8.22__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.
Potentially problematic release.
This version of foundry-mcp might be problematic. Click here for more details.
- foundry_mcp/__init__.py +13 -0
- foundry_mcp/cli/__init__.py +67 -0
- foundry_mcp/cli/__main__.py +9 -0
- foundry_mcp/cli/agent.py +96 -0
- foundry_mcp/cli/commands/__init__.py +37 -0
- foundry_mcp/cli/commands/cache.py +137 -0
- foundry_mcp/cli/commands/dashboard.py +148 -0
- foundry_mcp/cli/commands/dev.py +446 -0
- foundry_mcp/cli/commands/journal.py +377 -0
- foundry_mcp/cli/commands/lifecycle.py +274 -0
- foundry_mcp/cli/commands/modify.py +824 -0
- foundry_mcp/cli/commands/plan.py +640 -0
- foundry_mcp/cli/commands/pr.py +393 -0
- foundry_mcp/cli/commands/review.py +667 -0
- foundry_mcp/cli/commands/session.py +472 -0
- foundry_mcp/cli/commands/specs.py +686 -0
- foundry_mcp/cli/commands/tasks.py +807 -0
- foundry_mcp/cli/commands/testing.py +676 -0
- foundry_mcp/cli/commands/validate.py +982 -0
- foundry_mcp/cli/config.py +98 -0
- foundry_mcp/cli/context.py +298 -0
- foundry_mcp/cli/logging.py +212 -0
- foundry_mcp/cli/main.py +44 -0
- foundry_mcp/cli/output.py +122 -0
- foundry_mcp/cli/registry.py +110 -0
- foundry_mcp/cli/resilience.py +178 -0
- foundry_mcp/cli/transcript.py +217 -0
- foundry_mcp/config.py +1454 -0
- foundry_mcp/core/__init__.py +144 -0
- foundry_mcp/core/ai_consultation.py +1773 -0
- foundry_mcp/core/batch_operations.py +1202 -0
- foundry_mcp/core/cache.py +195 -0
- foundry_mcp/core/capabilities.py +446 -0
- foundry_mcp/core/concurrency.py +898 -0
- foundry_mcp/core/context.py +540 -0
- foundry_mcp/core/discovery.py +1603 -0
- foundry_mcp/core/error_collection.py +728 -0
- foundry_mcp/core/error_store.py +592 -0
- foundry_mcp/core/health.py +749 -0
- foundry_mcp/core/intake.py +933 -0
- foundry_mcp/core/journal.py +700 -0
- foundry_mcp/core/lifecycle.py +412 -0
- foundry_mcp/core/llm_config.py +1376 -0
- foundry_mcp/core/llm_patterns.py +510 -0
- foundry_mcp/core/llm_provider.py +1569 -0
- foundry_mcp/core/logging_config.py +374 -0
- foundry_mcp/core/metrics_persistence.py +584 -0
- foundry_mcp/core/metrics_registry.py +327 -0
- foundry_mcp/core/metrics_store.py +641 -0
- foundry_mcp/core/modifications.py +224 -0
- foundry_mcp/core/naming.py +146 -0
- foundry_mcp/core/observability.py +1216 -0
- foundry_mcp/core/otel.py +452 -0
- foundry_mcp/core/otel_stubs.py +264 -0
- foundry_mcp/core/pagination.py +255 -0
- foundry_mcp/core/progress.py +387 -0
- foundry_mcp/core/prometheus.py +564 -0
- foundry_mcp/core/prompts/__init__.py +464 -0
- foundry_mcp/core/prompts/fidelity_review.py +691 -0
- foundry_mcp/core/prompts/markdown_plan_review.py +515 -0
- foundry_mcp/core/prompts/plan_review.py +627 -0
- foundry_mcp/core/providers/__init__.py +237 -0
- foundry_mcp/core/providers/base.py +515 -0
- foundry_mcp/core/providers/claude.py +472 -0
- foundry_mcp/core/providers/codex.py +637 -0
- foundry_mcp/core/providers/cursor_agent.py +630 -0
- foundry_mcp/core/providers/detectors.py +515 -0
- foundry_mcp/core/providers/gemini.py +426 -0
- foundry_mcp/core/providers/opencode.py +718 -0
- foundry_mcp/core/providers/opencode_wrapper.js +308 -0
- foundry_mcp/core/providers/package-lock.json +24 -0
- foundry_mcp/core/providers/package.json +25 -0
- foundry_mcp/core/providers/registry.py +607 -0
- foundry_mcp/core/providers/test_provider.py +171 -0
- foundry_mcp/core/providers/validation.py +857 -0
- foundry_mcp/core/rate_limit.py +427 -0
- foundry_mcp/core/research/__init__.py +68 -0
- foundry_mcp/core/research/memory.py +528 -0
- foundry_mcp/core/research/models.py +1234 -0
- foundry_mcp/core/research/providers/__init__.py +40 -0
- foundry_mcp/core/research/providers/base.py +242 -0
- foundry_mcp/core/research/providers/google.py +507 -0
- foundry_mcp/core/research/providers/perplexity.py +442 -0
- foundry_mcp/core/research/providers/semantic_scholar.py +544 -0
- foundry_mcp/core/research/providers/tavily.py +383 -0
- foundry_mcp/core/research/workflows/__init__.py +25 -0
- foundry_mcp/core/research/workflows/base.py +298 -0
- foundry_mcp/core/research/workflows/chat.py +271 -0
- foundry_mcp/core/research/workflows/consensus.py +539 -0
- foundry_mcp/core/research/workflows/deep_research.py +4142 -0
- foundry_mcp/core/research/workflows/ideate.py +682 -0
- foundry_mcp/core/research/workflows/thinkdeep.py +405 -0
- foundry_mcp/core/resilience.py +600 -0
- foundry_mcp/core/responses.py +1624 -0
- foundry_mcp/core/review.py +366 -0
- foundry_mcp/core/security.py +438 -0
- foundry_mcp/core/spec.py +4119 -0
- foundry_mcp/core/task.py +2463 -0
- foundry_mcp/core/testing.py +839 -0
- foundry_mcp/core/validation.py +2357 -0
- foundry_mcp/dashboard/__init__.py +32 -0
- foundry_mcp/dashboard/app.py +119 -0
- foundry_mcp/dashboard/components/__init__.py +17 -0
- foundry_mcp/dashboard/components/cards.py +88 -0
- foundry_mcp/dashboard/components/charts.py +177 -0
- foundry_mcp/dashboard/components/filters.py +136 -0
- foundry_mcp/dashboard/components/tables.py +195 -0
- foundry_mcp/dashboard/data/__init__.py +11 -0
- foundry_mcp/dashboard/data/stores.py +433 -0
- foundry_mcp/dashboard/launcher.py +300 -0
- foundry_mcp/dashboard/views/__init__.py +12 -0
- foundry_mcp/dashboard/views/errors.py +217 -0
- foundry_mcp/dashboard/views/metrics.py +164 -0
- foundry_mcp/dashboard/views/overview.py +96 -0
- foundry_mcp/dashboard/views/providers.py +83 -0
- foundry_mcp/dashboard/views/sdd_workflow.py +255 -0
- foundry_mcp/dashboard/views/tool_usage.py +139 -0
- foundry_mcp/prompts/__init__.py +9 -0
- foundry_mcp/prompts/workflows.py +525 -0
- foundry_mcp/resources/__init__.py +9 -0
- foundry_mcp/resources/specs.py +591 -0
- foundry_mcp/schemas/__init__.py +38 -0
- foundry_mcp/schemas/intake-schema.json +89 -0
- foundry_mcp/schemas/sdd-spec-schema.json +414 -0
- foundry_mcp/server.py +150 -0
- foundry_mcp/tools/__init__.py +10 -0
- foundry_mcp/tools/unified/__init__.py +92 -0
- foundry_mcp/tools/unified/authoring.py +3620 -0
- foundry_mcp/tools/unified/context_helpers.py +98 -0
- foundry_mcp/tools/unified/documentation_helpers.py +268 -0
- foundry_mcp/tools/unified/environment.py +1341 -0
- foundry_mcp/tools/unified/error.py +479 -0
- foundry_mcp/tools/unified/health.py +225 -0
- foundry_mcp/tools/unified/journal.py +841 -0
- foundry_mcp/tools/unified/lifecycle.py +640 -0
- foundry_mcp/tools/unified/metrics.py +777 -0
- foundry_mcp/tools/unified/plan.py +876 -0
- foundry_mcp/tools/unified/pr.py +294 -0
- foundry_mcp/tools/unified/provider.py +589 -0
- foundry_mcp/tools/unified/research.py +1283 -0
- foundry_mcp/tools/unified/review.py +1042 -0
- foundry_mcp/tools/unified/review_helpers.py +314 -0
- foundry_mcp/tools/unified/router.py +102 -0
- foundry_mcp/tools/unified/server.py +565 -0
- foundry_mcp/tools/unified/spec.py +1283 -0
- foundry_mcp/tools/unified/task.py +3846 -0
- foundry_mcp/tools/unified/test.py +431 -0
- foundry_mcp/tools/unified/verification.py +520 -0
- foundry_mcp-0.8.22.dist-info/METADATA +344 -0
- foundry_mcp-0.8.22.dist-info/RECORD +153 -0
- foundry_mcp-0.8.22.dist-info/WHEEL +4 -0
- foundry_mcp-0.8.22.dist-info/entry_points.txt +3 -0
- foundry_mcp-0.8.22.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,630 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Cursor Agent CLI provider implementation.
|
|
3
|
+
|
|
4
|
+
Adapts the `cursor-agent` command-line tool to the ProviderContext contract,
|
|
5
|
+
including availability checks, streaming normalization, and response parsing.
|
|
6
|
+
Enforces read-only restrictions via Cursor's permission configuration system.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import logging
|
|
13
|
+
import os
|
|
14
|
+
import shutil
|
|
15
|
+
import subprocess
|
|
16
|
+
import time
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any, Dict, List, Optional, Protocol, Sequence, Tuple
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
from .base import (
|
|
23
|
+
ProviderCapability,
|
|
24
|
+
ProviderContext,
|
|
25
|
+
ProviderExecutionError,
|
|
26
|
+
ProviderHooks,
|
|
27
|
+
ProviderMetadata,
|
|
28
|
+
ProviderRequest,
|
|
29
|
+
ProviderResult,
|
|
30
|
+
ProviderStatus,
|
|
31
|
+
ProviderTimeoutError,
|
|
32
|
+
ProviderUnavailableError,
|
|
33
|
+
StreamChunk,
|
|
34
|
+
TokenUsage,
|
|
35
|
+
)
|
|
36
|
+
from .detectors import detect_provider_availability
|
|
37
|
+
from .registry import register_provider
|
|
38
|
+
|
|
39
|
+
DEFAULT_BINARY = "cursor-agent"
|
|
40
|
+
DEFAULT_TIMEOUT_SECONDS = 360
|
|
41
|
+
AVAILABILITY_OVERRIDE_ENV = "CURSOR_AGENT_CLI_AVAILABLE_OVERRIDE"
|
|
42
|
+
CUSTOM_BINARY_ENV = "CURSOR_AGENT_CLI_BINARY"
|
|
43
|
+
|
|
44
|
+
# Read-only tools allowed for Cursor Agent
|
|
45
|
+
# Note: Cursor Agent uses config files for permissions, not command-line flags
|
|
46
|
+
# These lists serve as documentation and validation
|
|
47
|
+
ALLOWED_TOOLS = [
|
|
48
|
+
# File operations (read-only)
|
|
49
|
+
"Read",
|
|
50
|
+
"Grep",
|
|
51
|
+
"Glob",
|
|
52
|
+
"List",
|
|
53
|
+
# Task delegation
|
|
54
|
+
"Task",
|
|
55
|
+
# Shell commands - file viewing
|
|
56
|
+
"Shell(cat)",
|
|
57
|
+
"Shell(head)",
|
|
58
|
+
"Shell(tail)",
|
|
59
|
+
"Shell(bat)",
|
|
60
|
+
# Shell commands - directory listing/navigation
|
|
61
|
+
"Shell(ls)",
|
|
62
|
+
"Shell(tree)",
|
|
63
|
+
"Shell(pwd)",
|
|
64
|
+
"Shell(which)",
|
|
65
|
+
"Shell(whereis)",
|
|
66
|
+
# Shell commands - search/find
|
|
67
|
+
"Shell(grep)",
|
|
68
|
+
"Shell(rg)",
|
|
69
|
+
"Shell(ag)",
|
|
70
|
+
"Shell(find)",
|
|
71
|
+
"Shell(fd)",
|
|
72
|
+
# Shell commands - git operations (read-only)
|
|
73
|
+
"Shell(git log)",
|
|
74
|
+
"Shell(git show)",
|
|
75
|
+
"Shell(git diff)",
|
|
76
|
+
"Shell(git status)",
|
|
77
|
+
"Shell(git grep)",
|
|
78
|
+
"Shell(git blame)",
|
|
79
|
+
"Shell(git branch)",
|
|
80
|
+
"Shell(git rev-parse)",
|
|
81
|
+
"Shell(git describe)",
|
|
82
|
+
"Shell(git ls-tree)",
|
|
83
|
+
# Shell commands - text processing
|
|
84
|
+
"Shell(wc)",
|
|
85
|
+
"Shell(cut)",
|
|
86
|
+
"Shell(paste)",
|
|
87
|
+
"Shell(column)",
|
|
88
|
+
"Shell(sort)",
|
|
89
|
+
"Shell(uniq)",
|
|
90
|
+
# Shell commands - data formats
|
|
91
|
+
"Shell(jq)",
|
|
92
|
+
"Shell(yq)",
|
|
93
|
+
# Shell commands - file analysis
|
|
94
|
+
"Shell(file)",
|
|
95
|
+
"Shell(stat)",
|
|
96
|
+
"Shell(du)",
|
|
97
|
+
"Shell(df)",
|
|
98
|
+
# Shell commands - checksums/hashing
|
|
99
|
+
"Shell(md5sum)",
|
|
100
|
+
"Shell(shasum)",
|
|
101
|
+
"Shell(sha256sum)",
|
|
102
|
+
"Shell(sha512sum)",
|
|
103
|
+
]
|
|
104
|
+
|
|
105
|
+
# Tools that should be explicitly blocked
|
|
106
|
+
DISALLOWED_TOOLS = [
|
|
107
|
+
"Write",
|
|
108
|
+
"Edit",
|
|
109
|
+
"Patch",
|
|
110
|
+
"Delete",
|
|
111
|
+
# Web operations (data exfiltration risk)
|
|
112
|
+
"WebFetch",
|
|
113
|
+
# Dangerous file operations
|
|
114
|
+
"Shell(rm)",
|
|
115
|
+
"Shell(rmdir)",
|
|
116
|
+
"Shell(dd)",
|
|
117
|
+
"Shell(mkfs)",
|
|
118
|
+
"Shell(fdisk)",
|
|
119
|
+
# File modifications
|
|
120
|
+
"Shell(touch)",
|
|
121
|
+
"Shell(mkdir)",
|
|
122
|
+
"Shell(mv)",
|
|
123
|
+
"Shell(cp)",
|
|
124
|
+
"Shell(chmod)",
|
|
125
|
+
"Shell(chown)",
|
|
126
|
+
"Shell(sed)",
|
|
127
|
+
"Shell(awk)",
|
|
128
|
+
# Git write operations
|
|
129
|
+
"Shell(git add)",
|
|
130
|
+
"Shell(git commit)",
|
|
131
|
+
"Shell(git push)",
|
|
132
|
+
"Shell(git pull)",
|
|
133
|
+
"Shell(git merge)",
|
|
134
|
+
"Shell(git rebase)",
|
|
135
|
+
"Shell(git reset)",
|
|
136
|
+
"Shell(git checkout)",
|
|
137
|
+
# Package installations
|
|
138
|
+
"Shell(npm install)",
|
|
139
|
+
"Shell(pip install)",
|
|
140
|
+
"Shell(apt install)",
|
|
141
|
+
"Shell(brew install)",
|
|
142
|
+
# System operations
|
|
143
|
+
"Shell(sudo)",
|
|
144
|
+
"Shell(halt)",
|
|
145
|
+
"Shell(reboot)",
|
|
146
|
+
"Shell(shutdown)",
|
|
147
|
+
]
|
|
148
|
+
|
|
149
|
+
# System prompt warning about Cursor Agent security limitations
|
|
150
|
+
SHELL_COMMAND_WARNING = """
|
|
151
|
+
IMPORTANT SECURITY NOTE: This session is running in read-only mode with the following restrictions:
|
|
152
|
+
1. File write operations (Write, Edit, Patch, Delete) are disabled via Cursor Agent config
|
|
153
|
+
2. Only approved read-only shell commands are permitted
|
|
154
|
+
3. Cursor Agent's security model is weaker than other CLIs - be cautious
|
|
155
|
+
4. Configuration is enforced via ~/.cursor/cli-config.json (original config backed up and restored automatically)
|
|
156
|
+
5. Note: This uses allowlist mode for maximum security - only explicitly allowed operations are permitted
|
|
157
|
+
"""
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
class RunnerProtocol(Protocol):
|
|
161
|
+
"""Callable signature used for executing cursor-agent CLI commands."""
|
|
162
|
+
|
|
163
|
+
def __call__(
|
|
164
|
+
self,
|
|
165
|
+
command: Sequence[str],
|
|
166
|
+
*,
|
|
167
|
+
timeout: Optional[int] = None,
|
|
168
|
+
env: Optional[Dict[str, str]] = None,
|
|
169
|
+
input_data: Optional[str] = None,
|
|
170
|
+
) -> subprocess.CompletedProcess[str]:
|
|
171
|
+
raise NotImplementedError
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _default_runner(
|
|
175
|
+
command: Sequence[str],
|
|
176
|
+
*,
|
|
177
|
+
timeout: Optional[int] = None,
|
|
178
|
+
env: Optional[Dict[str, str]] = None,
|
|
179
|
+
input_data: Optional[str] = None,
|
|
180
|
+
) -> subprocess.CompletedProcess[str]:
|
|
181
|
+
"""Invoke the cursor-agent CLI via subprocess."""
|
|
182
|
+
return subprocess.run( # noqa: S603,S607 - intentional CLI invocation
|
|
183
|
+
list(command),
|
|
184
|
+
capture_output=True,
|
|
185
|
+
text=True,
|
|
186
|
+
input=input_data,
|
|
187
|
+
timeout=timeout,
|
|
188
|
+
env=env,
|
|
189
|
+
check=False,
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
CURSOR_METADATA = ProviderMetadata(
|
|
194
|
+
provider_id="cursor-agent",
|
|
195
|
+
display_name="Cursor Agent CLI",
|
|
196
|
+
models=[], # Model validation delegated to CLI
|
|
197
|
+
default_model="composer-1",
|
|
198
|
+
capabilities={ProviderCapability.TEXT, ProviderCapability.FUNCTION_CALLING, ProviderCapability.STREAMING},
|
|
199
|
+
security_flags={"writes_allowed": False, "read_only": True},
|
|
200
|
+
extra={
|
|
201
|
+
"cli": "cursor-agent",
|
|
202
|
+
"command": "cursor-agent --print --output-format json",
|
|
203
|
+
"allowed_tools": ALLOWED_TOOLS,
|
|
204
|
+
"config_based_permissions": True,
|
|
205
|
+
},
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
class CursorAgentProvider(ProviderContext):
|
|
210
|
+
"""ProviderContext implementation backed by cursor-agent with read-only restrictions."""
|
|
211
|
+
|
|
212
|
+
def __init__(
|
|
213
|
+
self,
|
|
214
|
+
metadata: ProviderMetadata,
|
|
215
|
+
hooks: ProviderHooks,
|
|
216
|
+
*,
|
|
217
|
+
model: Optional[str] = None,
|
|
218
|
+
binary: Optional[str] = None,
|
|
219
|
+
runner: Optional[RunnerProtocol] = None,
|
|
220
|
+
env: Optional[Dict[str, str]] = None,
|
|
221
|
+
timeout: Optional[int] = None,
|
|
222
|
+
):
|
|
223
|
+
super().__init__(metadata, hooks)
|
|
224
|
+
self._runner = runner or _default_runner
|
|
225
|
+
self._binary = binary or os.environ.get(CUSTOM_BINARY_ENV, DEFAULT_BINARY)
|
|
226
|
+
self._env = env
|
|
227
|
+
self._timeout = timeout or DEFAULT_TIMEOUT_SECONDS
|
|
228
|
+
self._model = model or metadata.default_model or "composer-1"
|
|
229
|
+
self._config_backup_path: Optional[Path] = None
|
|
230
|
+
self._original_config_existed: bool = False
|
|
231
|
+
self._cleanup_done: bool = False
|
|
232
|
+
|
|
233
|
+
def __del__(self) -> None:
|
|
234
|
+
"""Clean up temporary config directory on provider destruction."""
|
|
235
|
+
self._cleanup_config_file()
|
|
236
|
+
|
|
237
|
+
def _create_readonly_config(self) -> Path:
|
|
238
|
+
"""
|
|
239
|
+
Backup and replace ~/.cursor/cli-config.json with read-only permissions.
|
|
240
|
+
|
|
241
|
+
Cursor Agent uses a permission configuration system with the format:
|
|
242
|
+
- {"allow": "Read(**)"}: Allow read access to all paths
|
|
243
|
+
- {"allow": "Shell(command)"}: Allow specific shell commands
|
|
244
|
+
- {"deny": "Write(**)"}: Deny write access
|
|
245
|
+
|
|
246
|
+
Returns:
|
|
247
|
+
Path to the HOME .cursor directory
|
|
248
|
+
|
|
249
|
+
Note:
|
|
250
|
+
This method backs up the original config to a unique timestamped file,
|
|
251
|
+
then writes a read-only config. The backup is restored by _cleanup_config_file().
|
|
252
|
+
"""
|
|
253
|
+
# Get HOME .cursor config path
|
|
254
|
+
cursor_dir = Path.home() / ".cursor"
|
|
255
|
+
config_path = cursor_dir / "cli-config.json"
|
|
256
|
+
|
|
257
|
+
# Create unique backup path for thread-safety
|
|
258
|
+
backup_suffix = f".sdd-backup.{os.getpid()}.{int(time.time())}"
|
|
259
|
+
backup_path = Path(str(config_path) + backup_suffix)
|
|
260
|
+
|
|
261
|
+
# Backup original config if it exists
|
|
262
|
+
self._original_config_existed = config_path.exists()
|
|
263
|
+
if self._original_config_existed:
|
|
264
|
+
shutil.copy2(config_path, backup_path)
|
|
265
|
+
self._config_backup_path = backup_path
|
|
266
|
+
|
|
267
|
+
# Build permission list in new format
|
|
268
|
+
permissions = []
|
|
269
|
+
|
|
270
|
+
# Allow read access to all paths
|
|
271
|
+
permissions.append({"allow": "Read(**)"})
|
|
272
|
+
permissions.append({"allow": "Grep(**)"})
|
|
273
|
+
permissions.append({"allow": "Glob(**)"})
|
|
274
|
+
permissions.append({"allow": "List(**)"})
|
|
275
|
+
|
|
276
|
+
# Add allowed shell commands (extract command names from ALLOWED_TOOLS)
|
|
277
|
+
for tool in ALLOWED_TOOLS:
|
|
278
|
+
if tool.startswith("Shell(") and tool.endswith(")"):
|
|
279
|
+
# Extract command: "Shell(git log)" -> "git log"
|
|
280
|
+
command = tool[6:-1]
|
|
281
|
+
# Cursor Agent Shell permissions use first token only
|
|
282
|
+
# "git log" becomes "git" in the config
|
|
283
|
+
base_command = command.split()[0]
|
|
284
|
+
permissions.append({"allow": f"Shell({base_command})"})
|
|
285
|
+
|
|
286
|
+
# Create read-only config file
|
|
287
|
+
cursor_dir.mkdir(parents=True, exist_ok=True)
|
|
288
|
+
config_data = {
|
|
289
|
+
"permissions": permissions,
|
|
290
|
+
"description": "Read-only mode enforced by foundry-mcp",
|
|
291
|
+
"approvalMode": "allowlist", # Use allowlist mode for security
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
with open(config_path, "w") as f:
|
|
295
|
+
json.dump(config_data, f, indent=2)
|
|
296
|
+
|
|
297
|
+
return cursor_dir
|
|
298
|
+
|
|
299
|
+
def _cleanup_config_file(self) -> None:
|
|
300
|
+
"""Restore original ~/.cursor/cli-config.json from backup."""
|
|
301
|
+
# Prevent double-cleanup (e.g., from finally block + __del__)
|
|
302
|
+
if hasattr(self, "_cleanup_done") and self._cleanup_done:
|
|
303
|
+
return
|
|
304
|
+
|
|
305
|
+
cursor_dir = Path.home() / ".cursor"
|
|
306
|
+
config_path = cursor_dir / "cli-config.json"
|
|
307
|
+
|
|
308
|
+
try:
|
|
309
|
+
# Restore original config from backup if it existed
|
|
310
|
+
if (
|
|
311
|
+
hasattr(self, "_config_backup_path")
|
|
312
|
+
and self._config_backup_path is not None
|
|
313
|
+
and self._config_backup_path.exists()
|
|
314
|
+
):
|
|
315
|
+
shutil.move(self._config_backup_path, config_path)
|
|
316
|
+
elif (
|
|
317
|
+
hasattr(self, "_original_config_existed")
|
|
318
|
+
and not self._original_config_existed
|
|
319
|
+
and config_path.exists()
|
|
320
|
+
):
|
|
321
|
+
# No original config existed - remove our temporary one
|
|
322
|
+
config_path.unlink()
|
|
323
|
+
|
|
324
|
+
# Clean up any leftover backup files
|
|
325
|
+
if (
|
|
326
|
+
hasattr(self, "_config_backup_path")
|
|
327
|
+
and self._config_backup_path is not None
|
|
328
|
+
and self._config_backup_path.exists()
|
|
329
|
+
):
|
|
330
|
+
self._config_backup_path.unlink()
|
|
331
|
+
|
|
332
|
+
# Clean up any .bad files created by cursor-agent CLI
|
|
333
|
+
bad_config_path = Path(str(config_path) + ".bad")
|
|
334
|
+
if bad_config_path.exists():
|
|
335
|
+
bad_config_path.unlink()
|
|
336
|
+
|
|
337
|
+
except (OSError, FileNotFoundError):
|
|
338
|
+
# Files already removed or don't exist, ignore
|
|
339
|
+
pass
|
|
340
|
+
finally:
|
|
341
|
+
# Mark cleanup as done to prevent double-cleanup
|
|
342
|
+
if hasattr(self, "_cleanup_done"):
|
|
343
|
+
self._cleanup_done = True
|
|
344
|
+
if hasattr(self, "_config_backup_path"):
|
|
345
|
+
self._config_backup_path = None
|
|
346
|
+
if hasattr(self, "_original_config_existed"):
|
|
347
|
+
self._original_config_existed = False
|
|
348
|
+
|
|
349
|
+
def _build_command(
|
|
350
|
+
self,
|
|
351
|
+
request: ProviderRequest,
|
|
352
|
+
model: str,
|
|
353
|
+
) -> Tuple[List[str], str]:
|
|
354
|
+
"""
|
|
355
|
+
Assemble the cursor-agent CLI invocation with read-only config.
|
|
356
|
+
|
|
357
|
+
Args:
|
|
358
|
+
request: Generation request
|
|
359
|
+
model: Model ID to use
|
|
360
|
+
|
|
361
|
+
Returns:
|
|
362
|
+
Tuple of (command args, prompt to pass via stdin)
|
|
363
|
+
|
|
364
|
+
Note:
|
|
365
|
+
Config is read from ~/.cursor/cli-config.json (managed by _create_readonly_config).
|
|
366
|
+
Uses --print mode for non-interactive execution with JSON output.
|
|
367
|
+
Prompt is passed via stdin to avoid CLI argument length limits.
|
|
368
|
+
"""
|
|
369
|
+
# cursor-agent in headless mode: --print --output-format json
|
|
370
|
+
command = [self._binary, "--print", "--output-format", "json"]
|
|
371
|
+
|
|
372
|
+
if model:
|
|
373
|
+
command.extend(["--model", model])
|
|
374
|
+
|
|
375
|
+
# Note: cursor-agent doesn't support --temperature or --max-tokens in --print mode
|
|
376
|
+
# These flags are silently ignored if provided
|
|
377
|
+
|
|
378
|
+
extra_flags = (request.metadata or {}).get("cursor_agent_flags")
|
|
379
|
+
if isinstance(extra_flags, list):
|
|
380
|
+
for flag in extra_flags:
|
|
381
|
+
if isinstance(flag, str) and flag.strip():
|
|
382
|
+
command.append(flag.strip())
|
|
383
|
+
|
|
384
|
+
# Build full prompt with system context (passed via stdin)
|
|
385
|
+
full_prompt = request.prompt
|
|
386
|
+
if request.system_prompt:
|
|
387
|
+
full_prompt = f"{request.system_prompt.strip()}\n\n{SHELL_COMMAND_WARNING.strip()}\n\n{request.prompt}"
|
|
388
|
+
else:
|
|
389
|
+
full_prompt = f"{SHELL_COMMAND_WARNING.strip()}\n\n{request.prompt}"
|
|
390
|
+
|
|
391
|
+
return command, full_prompt
|
|
392
|
+
|
|
393
|
+
def _run(
|
|
394
|
+
self,
|
|
395
|
+
command: Sequence[str],
|
|
396
|
+
*,
|
|
397
|
+
timeout: Optional[float],
|
|
398
|
+
input_data: Optional[str] = None,
|
|
399
|
+
) -> subprocess.CompletedProcess[str]:
|
|
400
|
+
try:
|
|
401
|
+
return self._runner(
|
|
402
|
+
command, timeout=int(timeout) if timeout else None, env=self._env, input_data=input_data
|
|
403
|
+
)
|
|
404
|
+
except FileNotFoundError as exc:
|
|
405
|
+
raise ProviderUnavailableError(
|
|
406
|
+
f"Cursor Agent CLI '{self._binary}' is not available on PATH.",
|
|
407
|
+
provider=self.metadata.provider_id,
|
|
408
|
+
) from exc
|
|
409
|
+
except subprocess.TimeoutExpired as exc:
|
|
410
|
+
raise ProviderTimeoutError(
|
|
411
|
+
f"Command timed out after {exc.timeout} seconds",
|
|
412
|
+
provider=self.metadata.provider_id,
|
|
413
|
+
) from exc
|
|
414
|
+
|
|
415
|
+
def _run_with_retry(
|
|
416
|
+
self,
|
|
417
|
+
command: Sequence[str],
|
|
418
|
+
timeout: Optional[float],
|
|
419
|
+
input_data: Optional[str] = None,
|
|
420
|
+
) -> Tuple[subprocess.CompletedProcess[str], bool]:
|
|
421
|
+
"""
|
|
422
|
+
Execute the command and retry without --output-format json when the CLI lacks support.
|
|
423
|
+
"""
|
|
424
|
+
completed = self._run(command, timeout=timeout, input_data=input_data)
|
|
425
|
+
if completed.returncode == 0:
|
|
426
|
+
return completed, True
|
|
427
|
+
|
|
428
|
+
stderr_text = (completed.stderr or "").lower()
|
|
429
|
+
# Check if --output-format flag is in command
|
|
430
|
+
has_json_flag = "--output-format" in command
|
|
431
|
+
if has_json_flag and any(phrase in stderr_text for phrase in ("unknown option", "unrecognized option")):
|
|
432
|
+
# Remove --output-format json from command for retry
|
|
433
|
+
retry_command = []
|
|
434
|
+
skip_next = False
|
|
435
|
+
for part in command:
|
|
436
|
+
if skip_next:
|
|
437
|
+
skip_next = False
|
|
438
|
+
continue
|
|
439
|
+
if part == "--output-format":
|
|
440
|
+
skip_next = True # Skip next arg (the "json" value)
|
|
441
|
+
continue
|
|
442
|
+
retry_command.append(part)
|
|
443
|
+
|
|
444
|
+
retry_process = self._run(retry_command, timeout=timeout, input_data=input_data)
|
|
445
|
+
if retry_process.returncode == 0:
|
|
446
|
+
return retry_process, False
|
|
447
|
+
|
|
448
|
+
stderr_text = (retry_process.stderr or stderr_text).strip()
|
|
449
|
+
# Cursor Agent outputs errors to stdout as plain text, not stderr
|
|
450
|
+
stdout_text = (retry_process.stdout or "").strip()
|
|
451
|
+
logger.debug(f"Cursor Agent CLI stderr (retry): {stderr_text or 'no stderr'}")
|
|
452
|
+
error_msg = f"Cursor Agent CLI exited with code {retry_process.returncode}"
|
|
453
|
+
if stdout_text and not stdout_text.startswith("{"):
|
|
454
|
+
# Plain text error in stdout (not JSON response)
|
|
455
|
+
error_msg += f": {stdout_text[:500]}"
|
|
456
|
+
elif stderr_text:
|
|
457
|
+
error_msg += f": {stderr_text[:500]}"
|
|
458
|
+
raise ProviderExecutionError(
|
|
459
|
+
error_msg,
|
|
460
|
+
provider=self.metadata.provider_id,
|
|
461
|
+
)
|
|
462
|
+
|
|
463
|
+
stderr_text = (completed.stderr or "").strip()
|
|
464
|
+
# Cursor Agent outputs errors to stdout as plain text, not stderr
|
|
465
|
+
stdout_text = (completed.stdout or "").strip()
|
|
466
|
+
logger.debug(f"Cursor Agent CLI stderr: {stderr_text or 'no stderr'}")
|
|
467
|
+
error_msg = f"Cursor Agent CLI exited with code {completed.returncode}"
|
|
468
|
+
if stdout_text and not stdout_text.startswith("{"):
|
|
469
|
+
# Plain text error in stdout (not JSON response)
|
|
470
|
+
error_msg += f": {stdout_text[:500]}"
|
|
471
|
+
elif stderr_text:
|
|
472
|
+
error_msg += f": {stderr_text[:500]}"
|
|
473
|
+
raise ProviderExecutionError(
|
|
474
|
+
error_msg,
|
|
475
|
+
provider=self.metadata.provider_id,
|
|
476
|
+
)
|
|
477
|
+
|
|
478
|
+
def _parse_json_payload(self, raw: str) -> Dict[str, Any]:
|
|
479
|
+
text = raw.strip()
|
|
480
|
+
if not text:
|
|
481
|
+
raise ProviderExecutionError(
|
|
482
|
+
"Cursor Agent CLI returned empty output.",
|
|
483
|
+
provider=self.metadata.provider_id,
|
|
484
|
+
)
|
|
485
|
+
try:
|
|
486
|
+
payload = json.loads(text)
|
|
487
|
+
except json.JSONDecodeError as exc:
|
|
488
|
+
logger.debug(f"Cursor Agent CLI JSON parse error: {exc}")
|
|
489
|
+
raise ProviderExecutionError(
|
|
490
|
+
"Cursor Agent CLI returned invalid JSON response",
|
|
491
|
+
provider=self.metadata.provider_id,
|
|
492
|
+
) from exc
|
|
493
|
+
if not isinstance(payload, dict):
|
|
494
|
+
raise ProviderExecutionError(
|
|
495
|
+
"Cursor Agent CLI returned an unexpected payload.",
|
|
496
|
+
provider=self.metadata.provider_id,
|
|
497
|
+
)
|
|
498
|
+
return payload
|
|
499
|
+
|
|
500
|
+
def _usage_from_payload(self, payload: Dict[str, Any]) -> TokenUsage:
|
|
501
|
+
usage = payload.get("usage") or {}
|
|
502
|
+
return TokenUsage(
|
|
503
|
+
input_tokens=int(usage.get("input_tokens") or usage.get("prompt_tokens") or 0),
|
|
504
|
+
output_tokens=int(usage.get("output_tokens") or usage.get("completion_tokens") or 0),
|
|
505
|
+
total_tokens=int(usage.get("total_tokens") or 0),
|
|
506
|
+
)
|
|
507
|
+
|
|
508
|
+
def _emit_stream_if_requested(self, content: str, *, stream: bool) -> None:
|
|
509
|
+
if not stream or not content:
|
|
510
|
+
return
|
|
511
|
+
self._emit_stream_chunk(StreamChunk(content=content, index=0))
|
|
512
|
+
|
|
513
|
+
def _execute(self, request: ProviderRequest) -> ProviderResult:
|
|
514
|
+
if request.attachments:
|
|
515
|
+
raise ProviderExecutionError(
|
|
516
|
+
"Cursor Agent CLI does not support attachments.",
|
|
517
|
+
provider=self.metadata.provider_id,
|
|
518
|
+
)
|
|
519
|
+
|
|
520
|
+
# Resolve model: request.model takes precedence, then metadata, then instance default
|
|
521
|
+
model = (
|
|
522
|
+
request.model
|
|
523
|
+
or (str(request.metadata.get("model")) if request.metadata and "model" in request.metadata else None)
|
|
524
|
+
or self._model
|
|
525
|
+
)
|
|
526
|
+
|
|
527
|
+
# Backup and replace HOME config with read-only version
|
|
528
|
+
self._create_readonly_config()
|
|
529
|
+
|
|
530
|
+
try:
|
|
531
|
+
# Build command (config is read from ~/.cursor/cli-config.json)
|
|
532
|
+
# Prompt is passed via stdin to avoid CLI argument length limits
|
|
533
|
+
command, prompt = self._build_command(request, model)
|
|
534
|
+
timeout = request.timeout or self._timeout
|
|
535
|
+
completed, json_mode = self._run_with_retry(command, timeout, input_data=prompt)
|
|
536
|
+
finally:
|
|
537
|
+
# Always restore original config, even if command fails
|
|
538
|
+
self._cleanup_config_file()
|
|
539
|
+
|
|
540
|
+
if json_mode:
|
|
541
|
+
payload = self._parse_json_payload(completed.stdout)
|
|
542
|
+
# cursor-agent returns content in "result" field
|
|
543
|
+
content = str(payload.get("result") or payload.get("content") or "").strip()
|
|
544
|
+
if not content and payload.get("messages"):
|
|
545
|
+
content = " ".join(
|
|
546
|
+
str(message.get("content") or "") for message in payload["messages"] if isinstance(message, dict)
|
|
547
|
+
).strip()
|
|
548
|
+
if not content:
|
|
549
|
+
content = (payload.get("raw") or "").strip()
|
|
550
|
+
usage = self._usage_from_payload(payload)
|
|
551
|
+
self._emit_stream_if_requested(content, stream=request.stream)
|
|
552
|
+
return ProviderResult(
|
|
553
|
+
content=content,
|
|
554
|
+
provider_id=self.metadata.provider_id,
|
|
555
|
+
model_used=f"{self.metadata.provider_id}:{payload.get('model') or model}",
|
|
556
|
+
status=ProviderStatus.SUCCESS,
|
|
557
|
+
tokens=usage,
|
|
558
|
+
stderr=(completed.stderr or "").strip() or None,
|
|
559
|
+
raw_payload=payload,
|
|
560
|
+
)
|
|
561
|
+
|
|
562
|
+
# Fallback mode (no JSON flag)
|
|
563
|
+
content = completed.stdout.strip()
|
|
564
|
+
self._emit_stream_if_requested(content, stream=request.stream)
|
|
565
|
+
metadata = {
|
|
566
|
+
"raw_text": content,
|
|
567
|
+
"json_mode": False,
|
|
568
|
+
}
|
|
569
|
+
return ProviderResult(
|
|
570
|
+
content=content,
|
|
571
|
+
provider_id=self.metadata.provider_id,
|
|
572
|
+
model_used=f"{self.metadata.provider_id}:{model}",
|
|
573
|
+
status=ProviderStatus.SUCCESS,
|
|
574
|
+
tokens=TokenUsage(),
|
|
575
|
+
stderr=(completed.stderr or "").strip() or None,
|
|
576
|
+
raw_payload=metadata,
|
|
577
|
+
)
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
def is_cursor_agent_available() -> bool:
|
|
581
|
+
"""Cursor Agent CLI availability check."""
|
|
582
|
+
return detect_provider_availability("cursor-agent")
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
def create_provider(
|
|
586
|
+
*,
|
|
587
|
+
hooks: ProviderHooks,
|
|
588
|
+
model: Optional[str] = None,
|
|
589
|
+
dependencies: Optional[Dict[str, object]] = None,
|
|
590
|
+
overrides: Optional[Dict[str, object]] = None,
|
|
591
|
+
) -> CursorAgentProvider:
|
|
592
|
+
"""
|
|
593
|
+
Factory used by the provider registry.
|
|
594
|
+
"""
|
|
595
|
+
dependencies = dependencies or {}
|
|
596
|
+
overrides = overrides or {}
|
|
597
|
+
runner = dependencies.get("runner")
|
|
598
|
+
env = dependencies.get("env")
|
|
599
|
+
binary = overrides.get("binary") or dependencies.get("binary")
|
|
600
|
+
timeout = overrides.get("timeout")
|
|
601
|
+
selected_model = overrides.get("model") if overrides.get("model") else model
|
|
602
|
+
|
|
603
|
+
return CursorAgentProvider(
|
|
604
|
+
metadata=CURSOR_METADATA,
|
|
605
|
+
hooks=hooks,
|
|
606
|
+
model=selected_model, # type: ignore[arg-type]
|
|
607
|
+
binary=binary, # type: ignore[arg-type]
|
|
608
|
+
runner=runner if runner is not None else None, # type: ignore[arg-type]
|
|
609
|
+
env=env if env is not None else None, # type: ignore[arg-type]
|
|
610
|
+
timeout=timeout if timeout is not None else None, # type: ignore[arg-type]
|
|
611
|
+
)
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
register_provider(
|
|
615
|
+
"cursor-agent",
|
|
616
|
+
factory=create_provider,
|
|
617
|
+
metadata=CURSOR_METADATA,
|
|
618
|
+
availability_check=is_cursor_agent_available,
|
|
619
|
+
description="Cursor Agent CLI adapter with read-only restrictions via config files",
|
|
620
|
+
tags=("cli", "text", "function_calling", "read-only"),
|
|
621
|
+
replace=True,
|
|
622
|
+
)
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
__all__ = [
|
|
626
|
+
"CursorAgentProvider",
|
|
627
|
+
"create_provider",
|
|
628
|
+
"is_cursor_agent_available",
|
|
629
|
+
"CURSOR_METADATA",
|
|
630
|
+
]
|