Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
35 changes: 25 additions & 10 deletions packages/opencode/src/altimate/workspace/engine-overlay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ import {
clearsFloor,
describeExtensionServed,
describeMissing,
parseUnfulfilled,
reportedMissing,
describeRefusal,
engineEntry,
engineToolKeys,
Expand Down Expand Up @@ -338,6 +340,7 @@ function mcp() {
add: (name: string, cfg: LocalMcpConfig | McpEntry) => MCP.add(name, cfg as Parameters<typeof MCP.add>[1]),
remove: (name: string) => MCP.remove(name),
tools: () => MCP.tools() as Promise<Record<string, unknown>>,
listMeta: (name: string) => MCP.listMeta(name),
}
)
}
Expand Down Expand Up @@ -645,43 +648,55 @@ async function reconcile(sessionID: string, directory: string, state: DirectoryS
return
}

const present = engineToolKeys(await mcp().tools())
const missing = declared ? declared.keys.filter((k) => !present.has(k)) : undefined
const [tools, meta] = await Promise.all([mcp().tools(), mcp().listMeta(DATAMATE_KEY)])
const present = engineToolKeys(tools)
// The gaps come from the engine's own report, with reasons; this client no
// longer diffs the allowlist against what arrived. No report (nothing at or
// above the floor omits it) means no gap is claimed, not that there is none.
const unfulfilled = parseUnfulfilled(meta)
const missingReport = unfulfilled === undefined ? undefined : reportedMissing(unfulfilled)
const missing = missingReport?.map((u) => u.key)
// `available` is everything the engine serves under the key. The engine adds
// tools beyond the allowlist (knowledge, memory) when the workspace enables
// them, so the "N of M declared" line counts only the declared ones present.
const served = declared ? declared.keys.length - (missing?.length ?? 0) : present.size
const served = declared ? declared.keys.filter((k) => present.has(k)).length : present.size
// Extension-declared tools appear in `present` only while the engine holds a
// live IDE bridge; when they do they are real capability and the line names
// them, but their absence is the normal no-IDE case, never `missing`.
const extServed = declared ? declared.extensionKeys.filter((k) => present.has(k)).length : 0
const outcome: Outcome = {
kind: "attached",
available: present.size,
...(declared ? { declared: declared.keys.length, missing } : {}),
...(declared ? { declared: declared.keys.length } : {}),
...(missing === undefined ? {} : { missing }),
...(unfulfilled === undefined ? {} : { unfulfilled }),
}
const rec = record(sessionID, outcome)
// Keyed on the workspace too: a re-link with an identical inventory is still
// a new verdict the user should hear.
// extServed is part of what the user hears, so it is part of the signature:
// an equal-count tool swap that changes only the extension share must still
// re-announce. (bot review)
const signature = `attached:${workspace.key}:${outcome.available}:${outcome.declared ?? "?"}:${(missing ?? []).join(",")}:${extServed}`
// A gap whose reason changed (a connection fixed, a binary still absent)
// is a new verdict too, so the reasons are in the signature.
const gaps = (missingReport ?? []).map((u) => `${u.key}=${u.reason}`).join(",")
const signature = `attached:${workspace.key}:${outcome.available}:${outcome.declared ?? "?"}:${gaps}:${extServed}`
if (rec.announced === signature) return
rec.announced = signature
log.info("workspace engine attached", {
workspaceId: workspace.id,
available: outcome.available,
declared: outcome.declared,
missing,
unfulfilled,
})
if (isHeadless()) return
const headline = declared
? `${served} of ${declared.keys.length} declared integration tools available.`
: `${outcome.available} integration tools available.`
await notify({
title: `Workspace "${workspace.name}"`,
message: declared
? `${served} of ${declared.keys.length} declared integration tools available.${describeMissing(missing ?? [])}${describeExtensionServed(extServed)}`
: `${outcome.available} integration tools available.`,
variant: missing && missing.length > 0 ? "warning" : "info",
message: `${headline}${describeMissing(missingReport ?? [])}${describeExtensionServed(extServed)}`,
variant: missingReport !== undefined && missingReport.length > 0 ? "warning" : "info",
})
}

Expand Down
1 change: 1 addition & 0 deletions packages/opencode/src/altimate/workspace/engine-seams.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ export const syncInternals: {
add: (name: string, cfg: LocalMcpConfig | McpEntry) => Promise<unknown>
remove: (name: string) => Promise<unknown>
tools: () => Promise<Record<string, unknown>>
listMeta: (name: string) => Promise<Record<string, unknown> | undefined>
}
config?: {
invalidate: () => Promise<void>
Expand Down
109 changes: 101 additions & 8 deletions packages/opencode/src/altimate/workspace/engine-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,11 @@ import { DATAMATE_KEY } from "@/altimate/datamate-transport"
* workspace promise rests on: integrations configured purely in the workspace
* UI must produce working tools with no local files. It also passes the
* resolved connection to MCP-type handlers, so their credential placeholders
* resolve. A 0.7.0 engine holds the pin but serves none of those tools, which
* is why the floor is 0.7.1. */
export const MIN_ENGINE_VERSION = "0.7.1"
* resolve. A 0.7.0 engine holds the pin but serves none of those tools. 0.7.2
* is the first that reports, on every tools/list, the allowlist keys it could
* not serve and why (`UNFULFILLED_META_KEY`); this client no longer diffs the
* allowlist itself, so below 0.7.2 it would announce no gaps at all. */
export const MIN_ENGINE_VERSION = "0.7.2"
export const ENGINE_PACKAGE = "@altimateai/datamate"
export const ENGINE_BINARY = "datamate"
export const INSTALL_COMMAND = `npm i -g ${ENGINE_PACKAGE}@${MIN_ENGINE_VERSION}`
Expand All @@ -29,7 +31,16 @@ export const TOOL_PREFIX = `${DATAMATE_KEY}_`
export type Outcome =
| { kind: "disabled" }
| { kind: "unbound" }
| { kind: "attached"; available: number; declared?: number; missing?: string[] }
| {
kind: "attached"
available: number
declared?: number
/** Keys of `unfulfilled` that count as gaps (see `reportedMissing`). */
missing?: string[]
/** The engine's full report, `no-bridge` entries included; absent when
* the engine sent none. */
unfulfilled?: Unfulfilled[]
}
| { kind: "engine-missing"; declared?: number }
/** `found` is null when the binary ran but printed nothing usable — broken
* rather than old; the message says so. */
Expand Down Expand Up @@ -207,11 +218,93 @@ export function describeRefusal(
)
}

export function describeMissing(missing: string[]): string {
/** Where the engine (0.7.2+) reports the allowlist keys it could not serve,
* on every tools/list response, so the client never diffs the allowlist
* against what arrived: a diff can name the keys, never the reason. */
export const UNFULFILLED_META_KEY = "ai.altimate/unfulfilled"

export type UnfulfilledReason =
| "catalog-missing"
| "invalid-connection"
| "spawn-failed"
| "no-bridge"
| "unknown-key"
| "exception"

/** One declared key the engine did not serve, in the engine's own words. A
* reason outside the known set is kept verbatim: a newer engine may add one. */
export type Unfulfilled = {
key: string
integrationId: string
reason: UnfulfilledReason | (string & {})
detail?: string
}

/** The engine's report out of a tools/list `_meta`. Undefined when there is
* none, or it is malformed: the caller then knows nothing about gaps, which
* is not the same as knowing there are none. */
export function parseUnfulfilled(meta: Record<string, unknown> | undefined): Unfulfilled[] | undefined {
const raw = meta?.[UNFULFILLED_META_KEY]
if (!Array.isArray(raw)) return undefined
const out: Unfulfilled[] = []
for (const item of raw) {
if (typeof item !== "object" || item === null) return undefined
const { key, integrationId, reason, detail } = item as Record<string, unknown>
// Custom (tenant-created) integrations carry numeric ids; take them as strings.
const id = typeof integrationId === "number" ? String(integrationId) : integrationId
if (typeof key !== "string" || typeof id !== "string" || typeof reason !== "string") return undefined
out.push({ key, integrationId: id, reason, ...(typeof detail === "string" && detail !== "" ? { detail } : {}) })
}
return out
}

/** Absent extension tools without an IDE window are expected, not missing:
* `no-bridge` entries never join the "declared but not available" line.
* Everything else the engine reports is a real gap. */
export function reportedMissing(unfulfilled: Unfulfilled[]): Unfulfilled[] {
return unfulfilled.filter((u) => u.reason !== "no-bridge")
}

const REASON_PHRASE: Record<UnfulfilledReason, string> = {
"invalid-connection": "no usable connection",
"spawn-failed": "server failed to start",
"catalog-missing": "no longer in the catalog",
"unknown-key": "not offered by the integration",
exception: "failed to load",
"no-bridge": "needs a VS Code window",
}

const MISSING_SHOWN = 5
const DETAIL_CHARS = 60

/** The gaps, grouped by reason in report order, at most `MISSING_SHOWN` keys
* across the groups; a group's first detail (the engine's error text, e.g.
* `spawn docker ENOENT`) stands for the group. */
export function describeMissing(missing: Unfulfilled[]): string {
if (missing.length === 0) return ""
const shown = missing.slice(0, 5).join(", ")
const more = missing.length > 5 ? ` (+${missing.length - 5} more)` : ""
return ` Declared but not available: ${shown}${more}.`
const groups = new Map<string, { keys: string[]; detail?: string }>()
for (const u of missing) {
const group = groups.get(u.reason) ?? { keys: [] }
group.keys.push(u.key)
if (group.detail === undefined && u.detail) group.detail = u.detail
groups.set(u.reason, group)
}
let budget = MISSING_SHOWN
const parts: string[] = []
for (const [reason, group] of groups) {
if (budget <= 0) break
const shown = group.keys.slice(0, budget)
budget -= shown.length
const phrase = (REASON_PHRASE as Record<string, string>)[reason] ?? reason
const detail = group.detail === undefined ? "" : ` (${truncate(group.detail, DETAIL_CHARS)})`
parts.push(`${phrase}${detail}: ${shown.join(", ")}`)
}
const more = missing.length > MISSING_SHOWN ? ` (+${missing.length - MISSING_SHOWN} more)` : ""
return ` Declared but not available — ${parts.join("; ")}${more}.`
}

function truncate(text: string, max: number): string {
return text.length <= max ? text : `${text.slice(0, max - 1)}…`
}

/** Extension-declared tools a connected IDE bridge is actually serving. Zero
Expand Down
29 changes: 25 additions & 4 deletions packages/opencode/src/mcp/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,18 @@ import z from "zod/v4"
const DEFAULT_TIMEOUT = 30_000
const MAX_LIST_PAGES = 1_000

// altimate_change start — keep the `_meta` of a server's last tools/list page.
// `paginate` keeps only each page's items, so the result object — the sole
// carrier of `_meta` — is dropped. The workspace engine reports the allowlist
// keys it could not serve there (altimate/workspace/engine-types). Kept per
// client, cleared when a listing starts, set by any page that carries one.
const listMetaByClient = new WeakMap<Client, Record<string, unknown>>()

export function listMeta(client: Client): Record<string, unknown> | undefined {
return listMetaByClient.get(client)
}
// altimate_change end

// altimate_change start — Microsoft Fabric Core MCP returns `null` (instead of
// omitting the field) for `tool.annotations.{readOnlyHint,destructiveHint,
// idempotentHint,openWorldHint}`, which the SDK's strict schema (boolean,
Expand Down Expand Up @@ -150,8 +162,11 @@ export function resources(client: Client, timeout?: number) {

function listTools(client: Client, timeout: number) {
return Effect.tryPromise({
try: () =>
paginate(
// altimate_change start — a fresh listing starts with no `_meta` (see listMeta).
try: () => {
listMetaByClient.delete(client)
return paginate(
// altimate_change end
async (cursor) => {
const params = cursor === undefined ? undefined : { cursor }
try {
Expand All @@ -169,8 +184,14 @@ function listTools(client: Client, timeout: number) {
// altimate_change end
}
},
(result) => result.tools,
),
// altimate_change start — remember this page's `_meta` (see listMeta).
(result) => {
if (result._meta !== undefined) listMetaByClient.set(client, result._meta as Record<string, unknown>)
return result.tools
},
)
},
// altimate_change end
catch: (error) => (error instanceof Error ? error : new Error(String(error))),
})
}
Expand Down
22 changes: 22 additions & 0 deletions packages/opencode/src/mcp/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -332,6 +332,11 @@ export interface Interface {
// without re-deriving the merge.
readonly entry: (name: string) => Effect.Effect<ConfigMCPV1.Info | undefined>
// altimate_change end
// altimate_change start — the `_meta` of a connected server's last tools/list
// (undefined while not connected, or when the server sent none). The
// workspace engine reports the allowlist keys it could not serve there.
readonly listMeta: (name: string) => Effect.Effect<Record<string, unknown> | undefined>
// altimate_change end
}

export class Service extends Context.Service<Service, Interface>()("@opencode/MCP") {}
Expand Down Expand Up @@ -915,6 +920,15 @@ export const layer = Layer.effect(
return s.clients
})

// altimate_change start — see Interface.listMeta
const listMeta = Effect.fn("MCP.listMeta")(function* (name: string) {
const s = yield* InstanceState.get(state)
const client = s.clients[name]
if (!client || s.status[name]?.status !== "connected") return undefined
return McpCatalog.listMeta(client)
})
// altimate_change end

const createAndStore = Effect.fn("MCP.createAndStore")(function* (name: string, mcp: ConfigMCPV1.Info) {
const s = yield* InstanceState.get(state)
const result = yield* create(name, mcp)
Expand Down Expand Up @@ -1360,6 +1374,9 @@ export const layer = Layer.effect(
return Service.of({
status,
clients,
// altimate_change start
listMeta,
// altimate_change end
tools,
prompts,
resources,
Expand Down Expand Up @@ -1413,6 +1430,11 @@ export async function status() {
export async function tools() {
return runMcp((svc) => svc.tools())
}
// altimate_change start — see Interface.listMeta
export async function listMeta(name: string) {
return runMcp((svc) => svc.listMeta(name))
}
// altimate_change end
// altimate_change start — see Interface.entry
export async function entry(name: string) {
return runMcp((svc) => svc.entry(name))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ function install(opts: {
add: async () => {},
remove: async () => {},
tools: async () => ({}),
listMeta: async () => undefined,
}
return h
}
Expand Down
Loading
Loading