Skip to content

Commit 761bd70

Browse files
committed
fix(mcp): handle command-type integrations in detail page and prevent name collisions
1 parent b16e7af commit 761bd70

File tree

8 files changed

+291
-39
lines changed

8 files changed

+291
-39
lines changed

alembic/versions/cde97a0df056_add_mcp_command_server_fields.py renamed to alembic/versions/2bedc5514ca9_add_mcp_command_server_fields.py

Lines changed: 7 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
"""add_mcp_command_server_fields
22
3-
Revision ID: cde97a0df056
4-
Revises: 5a3b7c8d9e0f
5-
Create Date: 2026-01-23 16:29:45.546977
3+
Revision ID: 2bedc5514ca9
4+
Revises: c8d7e5a4b321
5+
Create Date: 2026-02-02 14:42:10.977157
66
77
"""
88

@@ -14,14 +14,13 @@
1414
from alembic import op
1515

1616
# revision identifiers, used by Alembic.
17-
revision: str = "cde97a0df056"
18-
down_revision: str | None = "5a3b7c8d9e0f"
17+
revision: str = "2bedc5514ca9"
18+
down_revision: str | None = "c8d7e5a4b321"
1919
branch_labels: str | Sequence[str] | None = None
2020
depends_on: str | Sequence[str] | None = None
2121

2222

2323
def upgrade() -> None:
24-
# ### commands auto generated by Alembic - please adjust! ###
2524
op.add_column(
2625
"mcp_integration",
2726
sa.Column(
@@ -39,27 +38,20 @@ def upgrade() -> None:
3938
)
4039
op.add_column(
4140
"mcp_integration",
42-
sa.Column(
43-
"command_env", postgresql.JSONB(astext_type=sa.Text()), nullable=True
44-
),
41+
sa.Column("encrypted_command_env", sa.LargeBinary(), nullable=True),
4542
)
4643
op.add_column("mcp_integration", sa.Column("timeout", sa.Integer(), nullable=True))
4744
op.alter_column(
4845
"mcp_integration", "server_uri", existing_type=sa.VARCHAR(), nullable=True
4946
)
50-
# ### end Alembic commands ###
5147

5248

5349
def downgrade() -> None:
54-
# ### commands auto generated by Alembic - please adjust! ###
55-
# Delete command-type integrations first (they have NULL server_uri)
56-
op.execute("DELETE FROM mcp_integration WHERE server_type = 'command'")
5750
op.alter_column(
5851
"mcp_integration", "server_uri", existing_type=sa.VARCHAR(), nullable=False
5952
)
6053
op.drop_column("mcp_integration", "timeout")
61-
op.drop_column("mcp_integration", "command_env")
54+
op.drop_column("mcp_integration", "encrypted_command_env")
6255
op.drop_column("mcp_integration", "command_args")
6356
op.drop_column("mcp_integration", "command")
6457
op.drop_column("mcp_integration", "server_type")
65-
# ### end Alembic commands ###

frontend/src/app/workspaces/[workspaceId]/integrations/mcp/[mcpIntegrationId]/page.tsx

Lines changed: 216 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,23 @@
11
"use client"
22

33
import { zodResolver } from "@hookform/resolvers/zod"
4-
import { AlertCircle, ChevronLeft, Loader2, Save, Trash2 } from "lucide-react"
4+
import {
5+
AlertCircle,
6+
ChevronLeft,
7+
Loader2,
8+
Pencil,
9+
Save,
10+
Terminal,
11+
Trash2,
12+
} from "lucide-react"
513
import Link from "next/link"
614
import { useParams, useRouter } from "next/navigation"
715
import { useCallback, useState } from "react"
816
import { useForm } from "react-hook-form"
917
import { z } from "zod"
1018
import type { MCPIntegrationRead } from "@/client"
1119
import { ProviderIcon } from "@/components/icons"
20+
import { MCPIntegrationDialog } from "@/components/integrations/mcp-integration-dialog"
1221
import { CenteredSpinner } from "@/components/loading/spinner"
1322
import {
1423
AlertDialog,
@@ -167,6 +176,212 @@ function McpIntegrationDetailContent({
167176
mcpIntegration: MCPIntegrationRead
168177
}) {
169178
const workspaceId = useWorkspaceId()
179+
180+
// Command-type integrations use the dialog for editing
181+
if (mcpIntegration.server_type === "command") {
182+
return (
183+
<CommandTypeIntegrationView
184+
mcpIntegration={mcpIntegration}
185+
workspaceId={workspaceId}
186+
/>
187+
)
188+
}
189+
190+
// URL-type integrations use the inline form
191+
return (
192+
<UrlTypeIntegrationForm
193+
mcpIntegration={mcpIntegration}
194+
workspaceId={workspaceId}
195+
/>
196+
)
197+
}
198+
199+
function CommandTypeIntegrationView({
200+
mcpIntegration,
201+
workspaceId,
202+
}: {
203+
mcpIntegration: MCPIntegrationRead
204+
workspaceId: string
205+
}) {
206+
const router = useRouter()
207+
const { deleteMcpIntegration, deleteMcpIntegrationIsPending } =
208+
useDeleteMcpIntegration(workspaceId)
209+
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
210+
const [deleteConfirmText, setDeleteConfirmText] = useState("")
211+
const [editDialogOpen, setEditDialogOpen] = useState(false)
212+
213+
const handleDelete = useCallback(async () => {
214+
if (deleteConfirmText !== mcpIntegration.name) return
215+
try {
216+
await deleteMcpIntegration(mcpIntegration.id)
217+
router.push(`/workspaces/${workspaceId}/integrations`)
218+
} catch (error) {
219+
console.error("Failed to delete MCP integration:", error)
220+
}
221+
}, [
222+
deleteConfirmText,
223+
mcpIntegration,
224+
deleteMcpIntegration,
225+
router,
226+
workspaceId,
227+
])
228+
229+
const handleDeleteDialogOpenChange = (open: boolean) => {
230+
setDeleteDialogOpen(open)
231+
if (!open) {
232+
setDeleteConfirmText("")
233+
}
234+
}
235+
236+
return (
237+
<div className="container mx-auto max-w-4xl p-6 mb-20 mt-12">
238+
{/* Header */}
239+
<div className="mb-8">
240+
<Link
241+
href={`/workspaces/${workspaceId}/integrations`}
242+
className="inline-flex items-center text-sm text-muted-foreground hover:text-foreground mb-4"
243+
>
244+
<ChevronLeft className="mr-1 size-4" />
245+
Back to integrations
246+
</Link>
247+
<div className="flex items-start justify-between">
248+
<div className="flex items-start gap-4">
249+
<div className="flex size-12 items-center justify-center rounded-lg border bg-muted">
250+
<Terminal className="size-6 text-muted-foreground" />
251+
</div>
252+
<div>
253+
<h1 className="text-3xl font-bold">{mcpIntegration.name}</h1>
254+
{mcpIntegration.description && (
255+
<p className="mt-1 text-muted-foreground">
256+
{mcpIntegration.description}
257+
</p>
258+
)}
259+
<div className="mt-2 flex gap-2">
260+
<Badge variant="outline">Command</Badge>
261+
</div>
262+
</div>
263+
</div>
264+
</div>
265+
</div>
266+
267+
{/* Configuration Card */}
268+
<Card>
269+
<CardContent className="pt-6 space-y-4">
270+
<div>
271+
<Label className="text-muted-foreground text-xs">Command</Label>
272+
<p className="font-mono text-sm mt-1">
273+
{mcpIntegration.command || "-"}
274+
</p>
275+
</div>
276+
{mcpIntegration.command_args &&
277+
mcpIntegration.command_args.length > 0 && (
278+
<div>
279+
<Label className="text-muted-foreground text-xs">
280+
Arguments
281+
</Label>
282+
<p className="font-mono text-sm mt-1">
283+
{mcpIntegration.command_args.join(" ")}
284+
</p>
285+
</div>
286+
)}
287+
{mcpIntegration.has_command_env && (
288+
<div>
289+
<Label className="text-muted-foreground text-xs">
290+
Environment
291+
</Label>
292+
<p className="text-sm text-muted-foreground mt-1">
293+
Environment variables configured (hidden for security)
294+
</p>
295+
</div>
296+
)}
297+
{mcpIntegration.timeout && (
298+
<div>
299+
<Label className="text-muted-foreground text-xs">Timeout</Label>
300+
<p className="text-sm mt-1">{mcpIntegration.timeout} seconds</p>
301+
</div>
302+
)}
303+
304+
<div className="flex gap-2 pt-4 border-t">
305+
<Button variant="outline" onClick={() => setEditDialogOpen(true)}>
306+
<Pencil className="mr-2 size-4" />
307+
Edit
308+
</Button>
309+
<MCPIntegrationDialog
310+
mcpIntegrationId={mcpIntegration.id}
311+
open={editDialogOpen}
312+
onOpenChange={setEditDialogOpen}
313+
hideTrigger
314+
/>
315+
<Button
316+
variant="destructive"
317+
onClick={() => setDeleteDialogOpen(true)}
318+
disabled={deleteMcpIntegrationIsPending}
319+
>
320+
<Trash2 className="mr-2 size-4" />
321+
Delete
322+
</Button>
323+
</div>
324+
</CardContent>
325+
</Card>
326+
327+
{/* Delete Dialog */}
328+
<AlertDialog
329+
open={deleteDialogOpen}
330+
onOpenChange={handleDeleteDialogOpenChange}
331+
>
332+
<AlertDialogContent>
333+
<AlertDialogHeader>
334+
<AlertDialogTitle>Delete MCP integration?</AlertDialogTitle>
335+
<AlertDialogDescription>
336+
This will permanently delete the MCP integration &quot;
337+
{mcpIntegration.name}&quot;. This action cannot be undone.
338+
</AlertDialogDescription>
339+
</AlertDialogHeader>
340+
<div className="py-4">
341+
<Label htmlFor="confirm-delete" className="text-sm">
342+
Type <strong>{mcpIntegration.name}</strong> to confirm
343+
</Label>
344+
<Input
345+
id="confirm-delete"
346+
value={deleteConfirmText}
347+
onChange={(e) => setDeleteConfirmText(e.target.value)}
348+
className="mt-2"
349+
placeholder={mcpIntegration.name}
350+
/>
351+
</div>
352+
<AlertDialogFooter>
353+
<AlertDialogCancel>Cancel</AlertDialogCancel>
354+
<AlertDialogAction
355+
onClick={handleDelete}
356+
disabled={
357+
deleteConfirmText !== mcpIntegration.name ||
358+
deleteMcpIntegrationIsPending
359+
}
360+
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
361+
>
362+
{deleteMcpIntegrationIsPending ? (
363+
<>
364+
<Loader2 className="mr-2 size-4 animate-spin" />
365+
Deleting...
366+
</>
367+
) : (
368+
"Delete integration"
369+
)}
370+
</AlertDialogAction>
371+
</AlertDialogFooter>
372+
</AlertDialogContent>
373+
</AlertDialog>
374+
</div>
375+
)
376+
}
377+
378+
function UrlTypeIntegrationForm({
379+
mcpIntegration,
380+
workspaceId,
381+
}: {
382+
mcpIntegration: MCPIntegrationRead
383+
workspaceId: string
384+
}) {
170385
const router = useRouter()
171386
const { updateMcpIntegration, updateMcpIntegrationIsPending } =
172387
useUpdateMcpIntegration(workspaceId)

frontend/src/components/integrations/mcp-integration-dialog.tsx

Lines changed: 22 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -193,20 +193,30 @@ const formSchema = z
193193
data.command_env.trim() !== ""
194194
) {
195195
try {
196-
const parsed = JSON.parse(data.command_env)
197-
return (
198-
typeof parsed === "object" &&
199-
parsed !== null &&
200-
!Array.isArray(parsed)
201-
)
196+
const parsed = JSON.parse(data.command_env) as unknown
197+
if (
198+
typeof parsed !== "object" ||
199+
parsed === null ||
200+
Array.isArray(parsed)
201+
) {
202+
return false
203+
}
204+
// Validate all values are strings (API expects Record<string, string>)
205+
for (const value of Object.values(parsed)) {
206+
if (typeof value !== "string") {
207+
return false
208+
}
209+
}
210+
return true
202211
} catch {
203212
return false
204213
}
205214
}
206215
return true
207216
},
208217
{
209-
message: "Environment variables must be a valid JSON object",
218+
message:
219+
"Environment variables must be a valid JSON object with string values only",
210220
path: ["command_env"],
211221
}
212222
)
@@ -333,10 +343,11 @@ export function MCPIntegrationDialog({
333343
.filter(Boolean)
334344
: undefined
335345

336-
// Parse command_env from JSON string to object
337-
const commandEnv = values.command_env?.trim()
338-
? (JSON.parse(values.command_env) as Record<string, string>)
339-
: undefined
346+
// Parse command_env from JSON string to object (only for command-type servers)
347+
const commandEnv =
348+
values.server_type === "command" && values.command_env?.trim()
349+
? (JSON.parse(values.command_env) as Record<string, string>)
350+
: undefined
340351

341352
const baseParams = {
342353
name: values.name.trim(),

tracecat/agent/preset/service.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -271,12 +271,15 @@ async def _resolve_mcp_integrations(
271271
)
272272
continue
273273

274+
# Decrypt command_env if present
275+
command_env = integrations_service.decrypt_command_env(mcp_integration)
276+
274277
# Re-validate command config at resolution time
275278
try:
276279
validate_mcp_command_config(
277280
command=mcp_integration.command,
278281
args=mcp_integration.command_args,
279-
env=mcp_integration.command_env,
282+
env=command_env,
280283
name=mcp_integration.slug,
281284
)
282285
except MCPValidationError as e:
@@ -297,8 +300,8 @@ async def _resolve_mcp_integrations(
297300
}
298301
if mcp_integration.command_args:
299302
command_config["args"] = mcp_integration.command_args
300-
if mcp_integration.command_env:
301-
command_config["env"] = mcp_integration.command_env
303+
if command_env:
304+
command_config["env"] = command_env
302305
if mcp_integration.timeout:
303306
command_config["timeout"] = mcp_integration.timeout
304307

tracecat/agent/runtime/claude_code/runtime.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -402,9 +402,13 @@ async def run(self, payload: RuntimeInitPayload) -> None:
402402
stderr_queue: asyncio.Queue[str] = asyncio.Queue()
403403
# Add command-based MCP servers (stdio)
404404
# These run as subprocesses inside the sandbox and require internet access
405+
reserved_names = frozenset(mcp_servers.keys())
405406
if payload.mcp_command_servers:
406407
for cmd_server in payload.mcp_command_servers:
407408
server_name = cmd_server["name"]
409+
# Avoid collision with reserved names by adding suffix
410+
if server_name in reserved_names:
411+
server_name = f"{server_name}-cmd"
408412
server_config: dict[str, Any] = {
409413
"command": cmd_server["command"],
410414
}

0 commit comments

Comments
 (0)