Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 27 additions & 12 deletions packages/opencode/src/altimate/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@ const DatamateSummary = z.object({
const IntegrationSummary = z.object({
id: z.coerce.string(),
name: z.string().optional(),
// altimate_change start — catalog `type` (tool | mcp | code | api | extension);
// extension-type integrations have no meaning on the CLI surface.
type: z.string().optional(),
// altimate_change end
description: z.string().nullable().optional(),
tools: z
.array(
Expand Down Expand Up @@ -227,19 +231,30 @@ export namespace AltimateApi {

async function request(creds: AltimateCredentials, method: string, endpoint: string, body?: unknown) {
const url = `${creds.altimateUrl}${endpoint}`
const res = await fetch(url, {
method,
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${creds.altimateApiKey}`,
"x-tenant": creds.altimateInstanceName,
},
...(body ? { body: JSON.stringify(body) } : {}),
})
if (!res.ok) {
throw new Error(`API ${method} ${endpoint} failed with status ${res.status}`)
// altimate_change start — upstream_fix: bound every API request. Without a
// signal a stalled server holds the caller indefinitely. The abort stays
// armed until the BODY is read: `fetch` resolves on headers.
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 15_000)
try {
const res = await fetch(url, {
signal: controller.signal,
method,
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${creds.altimateApiKey}`,
"x-tenant": creds.altimateInstanceName,
},
...(body ? { body: JSON.stringify(body) } : {}),
})
if (!res.ok) {
throw new Error(`API ${method} ${endpoint} failed with status ${res.status}`)
}
return await res.json()
} finally {
clearTimeout(timeout)
}
return res.json()
// altimate_change end
}

export async function listDatamates() {
Expand Down
81 changes: 75 additions & 6 deletions packages/opencode/src/altimate/tools/datamate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import { Instance } from "../../project/instance"
import { Global } from "../../global"
import { Log } from "@/altimate/util/log"
import { DATAMATE_KEY, readDatamateTransportFromIde } from "../datamate-transport"
// altimate_change - workspace mode owns the datamate key
import { managedWorkspaceLoaded } from "../workspace/engine-overlay"

const log = Log.create({ service: "datamate" })

Expand Down Expand Up @@ -138,22 +140,35 @@ async function handleList() {

async function handleListIntegrations() {
try {
const integrations = await AltimateApi.listIntegrations()
const catalog = await AltimateApi.listIntegrations()
// altimate_change start — extension-type integrations are RPC into a live VS
// Code host and cannot work from the CLI. Hide them from this surface (the
// workspace UI still offers them) and say how many were hidden.
const integrations = catalog.filter((i) => i.type !== "extension")
const hidden = catalog.length - integrations.length
const omitted =
hidden > 0
? `${hidden} extension-type integration${hidden === 1 ? " was" : "s were"} omitted — they require a live VS Code bridge and are not available from the CLI.`
: ""
if (integrations.length === 0) {
return {
title: "Integrations: none found",
metadata: { count: 0 },
output: "No integrations available.",
title: hidden > 0 ? `Integrations: none available on the CLI (${hidden} hidden)` : "Integrations: none found",
metadata: { count: 0, hidden },
output: omitted ? `No integrations available. ${omitted}` : "No integrations available.",
}
}
// altimate_change end
const lines = ["ID | Name | Tools", "---|------|------"]
for (const i of integrations) {
const tools = i.tools?.map((t) => t.key).join(", ") ?? "none"
lines.push(`${i.id} | ${i.name} | ${tools}`)
}
// altimate_change start
if (omitted) lines.push("", `(${omitted})`)
// altimate_change end
return {
title: `Integrations: ${integrations.length} available`,
metadata: { count: integrations.length },
metadata: { count: integrations.length, hidden },
output: lines.join("\n"),
}
} catch (e) {
Expand All @@ -176,11 +191,32 @@ async function handleAdd(args: { datamate_id?: string; name?: string; scope?: "p
}
}
try {
const datamate = await AltimateApi.getDatamate(args.datamate_id)
// readDatamateTransportFromIde returns the exact command from the IDE config so we
// reuse the same process the extension already manages, not a second one.
const transport = await readDatamateTransportFromIde(projectRoot())

// altimate_change start — in workspace mode the shared `datamate` key is the
// bound workspace's own engine, derived at config load. The add goes under
// that key on two routes — an IDE transport, or an explicit `name` of
// "datamate" — and both are refused, with the reason, before anything is
// looked up: the refusal must not depend on the API being reachable.
// Standalone `datamate-<name>` entries are a different key and stay the user's.
const wantsManagedKey = transport !== null || args.name === DATAMATE_KEY
const managed = wantsManagedKey ? await managedWorkspaceLoaded() : null
if (managed) {
return {
title: `Datamate add: '${DATAMATE_KEY}' is managed by workspace "${managed.name}"`,
metadata: { serverName: DATAMATE_KEY, managedBy: managed.id, datamateId: args.datamate_id },
output:
`This project is linked to workspace "${managed.name}", whose integrations are served by the ` +
`workspace's own engine under the '${DATAMATE_KEY}' MCP server. Adding datamate '${args.datamate_id}' ` +
`there is not applied. Unlink the project, or run without ALTIMATE_WORKSPACE, to manage that entry by hand.`,
}
}
// altimate_change end

const datamate = await AltimateApi.getDatamate(args.datamate_id)

if (transport !== null) {
log.info("handleAdd: IDE transport detected, entering single-gateway mode", {
serverName: DATAMATE_KEY,
Expand Down Expand Up @@ -325,6 +361,23 @@ async function handleCreate(args: {
}
}
try {
// altimate_change start — with an IDE transport the add that follows would go
// under the shared `datamate` key; in workspace mode that add is refused, so
// refuse here before creating an API datamate nothing would connect to.
if ((await readDatamateTransportFromIde(projectRoot())) !== null) {
const managedKey = await managedWorkspaceLoaded()
if (managedKey) {
return {
title: `Datamate create: '${DATAMATE_KEY}' is managed by workspace "${managedKey.name}"`,
metadata: { serverName: DATAMATE_KEY, managedBy: managedKey.id },
output:
`This project is linked to workspace "${managedKey.name}", whose integrations are served by the ` +
`workspace's own engine under the '${DATAMATE_KEY}' MCP server. Creating datamate '${args.name}' ` +
`here would not connect it. Unlink the project, or run without ALTIMATE_WORKSPACE, first.`,
}
}
}
// altimate_change end
const integrations = args.integration_ids
? await AltimateApi.resolveIntegrations(args.integration_ids)
: undefined
Expand Down Expand Up @@ -487,6 +540,22 @@ async function handleRemove(args: { server_name?: string; scope?: "project" | "g
}
}
try {
// altimate_change start — the workspace-managed `datamate` key is not the
// user's to remove either: it would stop the engine under a turn and delete
// the entry that unlinking hands back. Standalone `datamate-<name>` entries
// are unaffected.
const managedKey = args.server_name === DATAMATE_KEY ? await managedWorkspaceLoaded() : null
if (managedKey) {
return {
title: `Datamate remove: '${DATAMATE_KEY}' is managed by workspace "${managedKey.name}"`,
metadata: { serverName: DATAMATE_KEY, managedBy: managedKey.id },
output:
`This project is linked to workspace "${managedKey.name}", whose integrations are served by the ` +
`workspace's own engine under the '${DATAMATE_KEY}' MCP server. It is not removed. Unlink the project, ` +
`or run without ALTIMATE_WORKSPACE, to manage that entry by hand.`,
}
}
// altimate_change end
// Fully remove from runtime state (disconnect + purge from MCP list)
// altimate_change start — MCP.remove (was disconnect): delete the status entry + publish
// ToolsChanged so the removed server's tools stop being offered without a restart.
Expand Down
25 changes: 21 additions & 4 deletions packages/opencode/src/altimate/tools/mcp-discover.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import { Instance } from "../../project/instance"
import { Global } from "../../global"
import { MCP } from "../../mcp"
import { ConfigMCPV1 } from "@opencode-ai/core/v1/config/mcp"
import { DATAMATE_KEY } from "../datamate-transport"
import { managedWorkspaceLoaded } from "../workspace/engine-overlay"

/**
* Check which MCP server names are permanently configured on disk
Expand Down Expand Up @@ -125,7 +127,21 @@ export const McpDiscoverTool = Tool.define("mcp_discover", {
useGlobal,
)

const added: string[] = []
for (const name of toAdd) {
// In workspace mode the `datamate` key is the bound workspace's own engine,
// derived at config load; a discovered IDE entry under it is refused like
// every other in-process writer of that key, and the refusal is reported.
if (name === DATAMATE_KEY) {
const managed = await managedWorkspaceLoaded()
if (managed) {
lines.push(
`\n'${DATAMATE_KEY}' was not added: this project is linked to workspace "${managed.name}", ` +
`whose engine serves that server. Unlink the project, or run without ALTIMATE_WORKSPACE, to add it by hand.`,
)
continue
}
}
// strip the discovery-time flag. Project-scoped discovery sets
// as a security default (no auto-connect until user approves).
// When the user explicitly adds a server via this tool, it should be enabled.
Expand All @@ -134,14 +150,15 @@ export const McpDiscoverTool = Tool.define("mcp_discover", {
// Connect immediately so /mcps reflects the server status in the current session
// without requiring a restart.
await MCP.connect(name)
added.push(name)
}

lines.push(`\nAdded ${toAdd.length} server(s) to ${configPath}: ${toAdd.join(", ")}`)
lines.push("These servers are already active in the current session via auto-discovery.")
lines.push(`\nAdded ${added.length} server(s) to ${configPath}: ${added.join(", ")}`)
if (added.length > 0) lines.push("These servers are already active in the current session via auto-discovery.")

return {
title: `MCP Discover: added ${toAdd.length} server(s)`,
metadata: { discovered: discoveredNames.length, new: newServers.length, existing: alreadyAdded.length, added: toAdd.length },
title: `MCP Discover: added ${added.length} server(s)`,
metadata: { discovered: discoveredNames.length, new: newServers.length, existing: alreadyAdded.length, added: added.length },
output: lines.join("\n"),
}
},
Expand Down
Loading
Loading