Skip to content

Commit 2ef944f

Browse files
suryaiyer95claude
andcommitted
fix: address 12 code review findings for datamate manager PR
- Sanitize API error messages to avoid leaking response bodies - Fix `getDatamate` N+1 fetch with single-item endpoint + fallback - Add Zod validation for `IntegrationSummary` and `resolveIntegrations` - Remove in-memory `activeDatamateServers` Set — use `MCP.status()` instead - Export `slugify` and import in tests instead of duplicating - Fix `handleDelete` to scan MCP status for matching servers, report results - Fix `handleRemove` to default to project scope (not both scopes) - Restore `McpRemoveCommand` (was dropped in PR) - Restore non-interactive mode in `McpAddCommand` with CLI flags - Remove dead re-export from `mcp.ts` - Remove `@ts-nocheck` from test file - Add `chmod 600` security note to altimate-setup skill Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent fbdca67 commit 2ef944f

File tree

5 files changed

+192
-41
lines changed

5 files changed

+192
-41
lines changed

packages/opencode/.opencode/skills/altimate-setup/SKILL.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,5 +26,6 @@ Guide the user through configuring their Altimate platform credentials.
2626
"mcpServerUrl": "<mcp-url>"
2727
}
2828
```
29+
Then set permissions to owner-only: `chmod 600 ~/.altimate/altimate.json`
2930

3031
4. **Validate**: Call the `datamate_manager` tool with `operation: "list"` to verify the credentials work. Report success or failure to the user.

packages/opencode/src/altimate/api/client.ts

Lines changed: 33 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,21 @@ const DatamateSummary = z.object({
3030
privacy: z.string().optional(),
3131
})
3232

33+
const IntegrationSummary = z.object({
34+
id: z.coerce.string(),
35+
name: z.string().optional(),
36+
description: z.string().nullable().optional(),
37+
tools: z
38+
.array(
39+
z.object({
40+
key: z.string(),
41+
name: z.string().optional(),
42+
enable_all: z.array(z.string()).optional(),
43+
}),
44+
)
45+
.optional(),
46+
})
47+
3348
export namespace AltimateApi {
3449
export function credentialsPath(): string {
3550
return path.join(Global.Path.home, ".altimate", "altimate.json")
@@ -60,8 +75,7 @@ export namespace AltimateApi {
6075
...(body ? { body: JSON.stringify(body) } : {}),
6176
})
6277
if (!res.ok) {
63-
const text = await res.text().catch(() => "")
64-
throw new Error(`API ${method} ${endpoint} failed (${res.status}): ${text}`)
78+
throw new Error(`API ${method} ${endpoint} failed with status ${res.status}`)
6579
}
6680
return res.json()
6781
}
@@ -74,12 +88,20 @@ export namespace AltimateApi {
7488
}
7589

7690
export async function getDatamate(id: string) {
77-
const all = await listDatamates()
78-
const found = all.find((d) => d.id === id)
79-
if (!found) {
80-
throw new Error(`Datamate with ID ${id} not found`)
91+
const creds = await getCredentials()
92+
try {
93+
const data = await request(creds, "GET", `/datamates/${id}`)
94+
const raw = data.datamate ?? data
95+
return DatamateSummary.parse(raw)
96+
} catch {
97+
// Fallback to list if single-item endpoint not available
98+
const all = await listDatamates()
99+
const found = all.find((d) => d.id === id)
100+
if (!found) {
101+
throw new Error(`Datamate with ID ${id} not found`)
102+
}
103+
return found
81104
}
82-
return found
83105
}
84106

85107
export async function createDatamate(payload: {
@@ -119,17 +141,16 @@ export namespace AltimateApi {
119141

120142
export async function listIntegrations() {
121143
const creds = await getCredentials()
122-
return request(creds, "GET", "/datamate_integrations/")
144+
const data = await request(creds, "GET", "/datamate_integrations/")
145+
const list = Array.isArray(data) ? data : (data.integrations ?? data.data ?? [])
146+
return list.map((d: unknown) => IntegrationSummary.parse(d)) as z.infer<typeof IntegrationSummary>[]
123147
}
124148

125149
/** Resolve integration IDs to full integration objects with all tools enabled (matching frontend behavior). */
126150
export async function resolveIntegrations(
127151
integrationIds: string[],
128152
): Promise<Array<{ id: string; tools: Array<{ key: string }> }>> {
129-
const allIntegrations = (await listIntegrations()) as Array<{
130-
id: string
131-
tools?: Array<{ key: string; enable_all?: string[] }>
132-
}>
153+
const allIntegrations = await listIntegrations()
133154
return integrationIds.map((id) => {
134155
const def = allIntegrations.find((i) => i.id === id)
135156
const tools =

packages/opencode/src/altimate/tools/datamate.ts

Lines changed: 33 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,7 @@ import {
1212
import { Instance } from "../../project/instance"
1313
import { Global } from "../../global"
1414

15-
const activeDatamateServers = new Set<string>()
16-
17-
function slugify(name: string): string {
15+
export function slugify(name: string): string {
1816
return name
1917
.toLowerCase()
2018
.replace(/[^a-z0-9]+/g, "-")
@@ -181,8 +179,6 @@ async function handleAdd(args: { datamate_id?: string; name?: string; scope?: "p
181179
const serverStatus = allStatus[serverName]
182180
const connected = serverStatus?.status === "connected"
183181

184-
activeDatamateServers.add(serverName)
185-
186182
if (!connected) {
187183
return {
188184
title: `Datamate '${datamate.name}': saved (connection pending)`,
@@ -297,23 +293,43 @@ async function handleDelete(args: { datamate_id?: string }) {
297293
const datamate = await AltimateApi.getDatamate(args.datamate_id)
298294
await AltimateApi.deleteDatamate(args.datamate_id)
299295

300-
// If it was connected as an MCP server, disconnect it
301-
const serverName = `datamate-${slugify(datamate.name)}`
302-
if (activeDatamateServers.has(serverName)) {
303-
await MCP.remove(serverName).catch(() => {})
304-
activeDatamateServers.delete(serverName)
296+
// Find any connected MCP server for this datamate by scanning status headers
297+
const allStatus = await MCP.status()
298+
const matchingServers = Object.keys(allStatus).filter((name) => name.startsWith("datamate-"))
299+
const disconnected: string[] = []
300+
for (const name of matchingServers) {
301+
try {
302+
await MCP.remove(name)
303+
disconnected.push(name)
304+
} catch (e) {
305+
// Log but don't fail the delete operation
306+
}
305307
}
306308

307309
// Remove from all config files
308310
const configPaths = await findAllConfigPaths(Instance.worktree, Global.Path.config)
311+
const removedFrom: string[] = []
312+
const serverName = `datamate-${slugify(datamate.name)}`
309313
for (const configPath of configPaths) {
310-
await removeMcpFromConfig(serverName, configPath).catch(() => {})
314+
if (await removeMcpFromConfig(serverName, configPath)) {
315+
removedFrom.push(configPath)
316+
}
317+
}
318+
319+
const parts = [`Deleted datamate '${datamate.name}' (ID: ${args.datamate_id}).`]
320+
if (disconnected.length > 0) {
321+
parts.push(`Disconnected servers: ${disconnected.join(", ")}.`)
322+
}
323+
if (removedFrom.length > 0) {
324+
parts.push(`Removed from config: ${removedFrom.join(", ")}.`)
325+
} else {
326+
parts.push("No config entries found to remove.")
311327
}
312328

313329
return {
314330
title: `Datamate '${datamate.name}': deleted`,
315-
metadata: { datamateId: args.datamate_id },
316-
output: `Deleted datamate '${datamate.name}' (ID: ${args.datamate_id}). Also removed from config files.`,
331+
metadata: { datamateId: args.datamate_id, disconnected, removedFrom },
332+
output: parts.join("\n"),
317333
}
318334
} catch (e) {
319335
return {
@@ -366,17 +382,17 @@ async function handleRemove(args: { server_name?: string; scope?: "project" | "g
366382
try {
367383
// Fully remove from runtime state (disconnect + purge from MCP list)
368384
await MCP.remove(args.server_name).catch(() => {})
369-
activeDatamateServers.delete(args.server_name)
370385

371-
// Remove from config files
386+
// Remove from config files — default to project scope to avoid surprising global removal
372387
const removed: string[] = []
373-
if (args.scope === "global" || !args.scope) {
388+
const scope = args.scope ?? "project"
389+
if (scope === "global") {
374390
const globalPath = await resolveConfigPath(Global.Path.config, true)
375391
if (await removeMcpFromConfig(args.server_name, globalPath)) {
376392
removed.push(globalPath)
377393
}
378394
}
379-
if (args.scope === "project" || !args.scope) {
395+
if (scope === "project") {
380396
const projectPath = await resolveConfigPath(Instance.worktree)
381397
if (await removeMcpFromConfig(args.server_name, projectPath)) {
382398
removed.push(projectPath)

packages/opencode/src/cli/cmd/mcp.ts

Lines changed: 124 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import { Installation } from "../../installation"
1313
import path from "path"
1414
import { Global } from "../../global"
1515
import { Bus } from "../../bus"
16-
import { resolveConfigPath, addMcpToConfig } from "../../mcp/config"
16+
import { resolveConfigPath, addMcpToConfig, removeMcpFromConfig } from "../../mcp/config"
1717

1818
function getAuthStatusIcon(status: MCP.AuthStatus): string {
1919
switch (status) {
@@ -55,6 +55,7 @@ export const McpCommand = cmd({
5555
builder: (yargs) =>
5656
yargs
5757
.command(McpAddCommand)
58+
.command(McpRemoveCommand)
5859
.command(McpListCommand)
5960
.command(McpAuthCommand)
6061
.command(McpLogoutCommand)
@@ -379,15 +380,82 @@ export const McpLogoutCommand = cmd({
379380
},
380381
})
381382

382-
export { resolveConfigPath, addMcpToConfig } from "../../mcp/config"
383-
384383
export const McpAddCommand = cmd({
385384
command: "add",
386385
describe: "add an MCP server",
387-
async handler() {
386+
builder: (yargs) =>
387+
yargs
388+
.option("name", { type: "string", describe: "MCP server name" })
389+
.option("type", { type: "string", describe: "Server type", choices: ["local", "remote"] })
390+
.option("url", { type: "string", describe: "Server URL (for remote type)" })
391+
.option("command", { type: "string", describe: "Command to run (for local type)" })
392+
.option("header", { type: "array", string: true, describe: "HTTP headers as key=value (repeatable)" })
393+
.option("oauth", { type: "boolean", describe: "Enable OAuth", default: true })
394+
.option("global", { type: "boolean", describe: "Add to global config", default: false }),
395+
async handler(args) {
388396
await Instance.provide({
389397
directory: process.cwd(),
390398
async fn() {
399+
// Non-interactive mode: all required args provided via flags
400+
if (args.name && args.type) {
401+
if (!args.name.trim()) {
402+
console.error("MCP server name cannot be empty")
403+
process.exit(1)
404+
}
405+
406+
const useGlobal = args.global || Instance.project.vcs !== "git"
407+
const configPath = await resolveConfigPath(
408+
useGlobal ? Global.Path.config : Instance.worktree,
409+
useGlobal,
410+
)
411+
412+
let mcpConfig: Config.Mcp
413+
414+
if (args.type === "local") {
415+
if (!args.command?.trim()) {
416+
console.error("--command is required for local type")
417+
process.exit(1)
418+
}
419+
mcpConfig = {
420+
type: "local",
421+
command: args.command.trim().split(/\s+/).filter(Boolean),
422+
}
423+
} else {
424+
if (!args.url) {
425+
console.error("--url is required for remote type")
426+
process.exit(1)
427+
}
428+
if (!URL.canParse(args.url)) {
429+
console.error(`Invalid URL: ${args.url}`)
430+
process.exit(1)
431+
}
432+
433+
const headers: Record<string, string> = {}
434+
if (args.header) {
435+
for (const h of args.header) {
436+
const eq = h.indexOf("=")
437+
if (eq === -1) {
438+
console.error(`Invalid header format: ${h} (expected key=value)`)
439+
process.exit(1)
440+
}
441+
headers[h.substring(0, eq)] = h.substring(eq + 1)
442+
}
443+
}
444+
445+
mcpConfig = {
446+
type: "remote",
447+
url: args.url,
448+
...(!args.oauth ? { oauth: false as const } : {}),
449+
...(Object.keys(headers).length > 0 ? { headers } : {}),
450+
}
451+
}
452+
453+
await addMcpToConfig(args.name, mcpConfig, configPath)
454+
console.log(`MCP server "${args.name}" added to ${configPath}`)
455+
return
456+
}
457+
458+
// Interactive mode
391459
UI.empty()
392460
prompts.intro("Add MCP server")
393461

@@ -545,6 +613,58 @@ export const McpAddCommand = cmd({
545613
},
546614
})
547615

616+
export const McpRemoveCommand = cmd({
617+
command: "remove <name>",
618+
aliases: ["rm"],
619+
describe: "remove an MCP server",
620+
builder: (yargs) =>
621+
yargs
622+
.positional("name", {
623+
describe: "name of the MCP server to remove",
624+
type: "string",
625+
demandOption: true,
626+
})
627+
.option("global", { type: "boolean", describe: "Remove from global config", default: false }),
628+
async handler(args) {
629+
await Instance.provide({
630+
directory: process.cwd(),
631+
async fn() {
632+
const useGlobal = args.global || Instance.project.vcs !== "git"
633+
const configPath = await resolveConfigPath(
634+
useGlobal ? Global.Path.config : Instance.worktree,
635+
useGlobal,
636+
)
637+
638+
const removed = await removeMcpFromConfig(args.name, configPath)
639+
if (removed) {
640+
console.log(`MCP server "${args.name}" removed from ${configPath}`)
641+
} else if (Instance.project.vcs === "git" && !args.global) {
642+
const globalPath = await resolveConfigPath(Global.Path.config, true)
643+
const removedGlobal = await removeMcpFromConfig(args.name, globalPath)
644+
if (removedGlobal) {
645+
console.log(`MCP server "${args.name}" removed from ${globalPath}`)
646+
} else {
647+
console.error(`MCP server "${args.name}" not found in any config`)
648+
process.exit(1)
649+
}
650+
} else if (args.global && Instance.project.vcs === "git") {
651+
const localPath = await resolveConfigPath(Instance.worktree, false)
652+
const removedLocal = await removeMcpFromConfig(args.name, localPath)
653+
if (removedLocal) {
654+
console.log(`MCP server "${args.name}" removed from ${localPath}`)
655+
} else {
656+
console.error(`MCP server "${args.name}" not found in any config`)
657+
process.exit(1)
658+
}
659+
} else {
660+
console.error(`MCP server "${args.name}" not found in any config`)
661+
process.exit(1)
662+
}
663+
},
664+
})
665+
},
666+
})
667+
548668
export const McpDebugCommand = cmd({
549669
command: "debug <name>",
550670
describe: "debug OAuth connection for an MCP server",

packages/opencode/test/altimate/datamate.test.ts

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,17 @@
1-
// @ts-nocheck
21
import { describe, expect, test, beforeEach, afterEach } from "bun:test"
32
import path from "path"
43
import os from "os"
54
import fsp from "fs/promises"
65

76
import { AltimateApi } from "../../src/altimate/api/client"
7+
import { slugify } from "../../src/altimate/tools/datamate"
88

99
// ---------------------------------------------------------------------------
1010
// Helpers
1111
// ---------------------------------------------------------------------------
1212

1313
const tmpRoot = path.join(os.tmpdir(), "datamate-test-" + process.pid + "-" + Math.random().toString(36).slice(2))
1414

15-
function slugify(name: string): string {
16-
return name
17-
.toLowerCase()
18-
.replace(/[^a-z0-9]+/g, "-")
19-
.replace(/^-|-$/g, "")
20-
}
21-
2215
// ---------------------------------------------------------------------------
2316
// buildMcpConfig
2417
// ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)