Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
94d8e8f
feat: structured lineage and column reach for dbt_lineage and impact_…
hkaltai Nov 2, 2026
e434c5b
fix: dbt_lineage uses the adapter only for the named manifest's own p…
hkaltai Nov 2, 2026
d29034f
fix: an oversized structured result keeps its report within the cap a…
hkaltai Nov 3, 2026
391fd7c
docs: the truncated flag says the report may be shortened too
hkaltai Nov 3, 2026
8521242
feat: dbt_lineage include_graph returns the model's whole upstream, c…
hkaltai Nov 3, 2026
9f92fa2
fix: include_graph leaves out the tests of upstream models
hkaltai Nov 3, 2026
3673697
fix: address review comments on lineage structured results
hkaltai Nov 3, 2026
4ac010e
fix: a column spelling that matches two columns differing only by cas…
hkaltai Nov 3, 2026
9ea14a0
Merge remote-tracking branch 'origin/feat/dbt-lineage-structured-resu…
hkaltai Nov 3, 2026
f270dbb
fix: an unsettled column spelling leaves the model's own dashboards u…
hkaltai Nov 3, 2026
7c89b71
fix: uncertainty for models the engine cannot fully trace, a token-sa…
hkaltai Nov 3, 2026
a0a47c0
fix: a model with no column edges is uncertain only when its SQL name…
hkaltai Nov 3, 2026
eac31e5
test: a model below a partly traced reader is a possible reader
hkaltai Nov 3, 2026
fe673cf
fix: a structured result passed through for a tool outside the declar…
hkaltai Nov 3, 2026
e80cf4c
refactor: the column-name scan runs once per model; test rules out a …
hkaltai Nov 3, 2026
353523d
style: indent the passthrough condition as Prettier does
hkaltai Nov 3, 2026
f8fb664
refactor: drop the structured passthrough no tool reaches; dbt_lineag…
hkaltai Nov 3, 2026
46bd1a0
fix: an uncompiled project's own manifest path keeps the dbt integrat…
hkaltai Nov 3, 2026
195435a
fix: a whole row is a row source passed whole to a call, so import CT…
hkaltai Nov 3, 2026
68689f2
fix: column reach flags a NATURAL JOIN only, and a manifest link is p…
hkaltai Nov 3, 2026
29b76e7
fix: every NATURAL join form counts and passes its doubt on, quoted n…
hkaltai Nov 3, 2026
46dc68a
fix: a NATURAL JOIN inside array brackets or after an escaped quote s…
hkaltai Nov 3, 2026
ce71d5a
fix: a NATURAL JOIN is found in the raw SQL, so no dialect's quoting …
hkaltai Nov 3, 2026
a4536a8
fix: comments between NATURAL and JOIN are read as gaps, with no pass…
hkaltai Nov 3, 2026
2a46155
fix: NATURAL JOIN lookup reads // and CR-ended comments, and stays bo…
hkaltai Nov 3, 2026
299bdf0
fix: Snowflake's DIRECTED NATURAL JOIN counts too
hkaltai Nov 3, 2026
5b67069
fix: a manifest is placed where the system opens it, through Filesyst…
hkaltai Nov 3, 2026
6d71706
fix: a manifest is placed as the manifest reader opens it, and only l…
hkaltai Nov 3, 2026
ef2797a
refactor: the requested SQL path is normalized once, and the link/.. …
hkaltai Nov 3, 2026
315685b
style: Prettier on the files this change left unformatted
hkaltai Nov 3, 2026
3c6774b
fix: a column named through Snowflake IDENTIFIER() is a possible read…
hkaltai Nov 3, 2026
4123f0c
fix: comments before or inside IDENTIFIER( are read as gaps, and its …
hkaltai Nov 3, 2026
0ac91d3
fix: any mention of a traced column inside IDENTIFIER('…') counts, so…
hkaltai Nov 3, 2026
72cc52d
fix: a doubled quote inside an IDENTIFIER() name is read as one quote
hkaltai Nov 3, 2026
7890152
fix: an IDENTIFIER() string that is not a plain name counts as naming…
hkaltai Nov 3, 2026
98c582c
fix: only an IDENTIFIER() whose whole argument is one plain string na…
hkaltai Nov 3, 2026
b84dd26
fix: a column reach that stopped early bounds every count, and the ot…
hkaltai Nov 3, 2026
a36f4be
docs: the stopped-early exposures note says some may not have been ch…
hkaltai Nov 3, 2026
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
88 changes: 82 additions & 6 deletions packages/opencode/src/altimate/mcp-server/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,81 @@ export async function serverInstructions(): Promise<string> {
}
}

// altimate_change start — lineage tools answer with structured content as well as text
// Lineage tools also return their metadata as structured content, so a client that draws lineage
// (the Claude Code plugin's lineage pane) reads edges and downstream lists without parsing text.
// Claude Code shows structured content to the model in place of the text, so `report` carries the
// (already truncated) text (dbt_lineage: its header, its mappings travel as data), only the lineage
// fields are passed, and the whole is capped.
const STRUCTURED_FIELDS: Record<string, readonly string[]> = {
dbt_lineage: ["model_name", "model_unique_id", "confidence", "confidence_factors", "upstream", "columns", "edges", "error"],

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[WARNING]: include_graph is a no-op for MCP clients

STRUCTURED_FIELDS.dbt_lineage omits graph, so structuredResult drops it, and the text report does not render it either (formatDbtLineage in tools/dbt-lineage.ts only prints mappings and edges). A client that calls dbt_lineage with include_graph: true therefore receives the graph nowhere: not in structuredContent, not in the text. That contradicts the stated purpose in native/dbt/graph.ts ("clients that cannot read a large manifest ... get it from dbt_lineage with include_graph") and the PR description. Add "graph" to the field list — the 100 KB fallback already covers an oversized graph — or render it in the report.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not changed: the graph is CLI-only by design (see the reply on the other include_graph thread); clients that need it use altimate tool run dbt_lineage --json. graph.ts now says so.

impact_analysis: [
"model",
"column",
"change_type",
"severity",
"direct_count",
"transitive_count",
"test_count",
"downstream",
"exposures",
"column_reach",
"column_reach_error",
"exposures_error",
"error",
],
}
export const STRUCTURED_TOOLS: ReadonlySet<string> = new Set(Object.keys(STRUCTURED_FIELDS))
const STRUCTURED_OUTPUT_SCHEMA = {
type: "object" as const,
properties: {
report: { type: "string", description: "The tool's text report" },
truncated: { type: "boolean", description: "True when the lineage fields were left out for size; the report is kept, shortened if it is too large on its own" },
},
required: ["report"],
additionalProperties: true,
}
// The structured result reaches the model in Claude Code, so it stays within the same order of
// size as a text result: past this, only the report is sent. Claude Code caps an MCP result at
// 25k tokens by default and JSON of ids and edges runs ~3-4 characters a token, so this stays under that.
const STRUCTURED_MAX_BYTES = 60_000

export function structuredResult(tool: string, metadata: Record<string, unknown>, report: string): Record<string, unknown> {
// A tool that builds its own structured result (dbt_lineage from the dbt integration) hands it
// over whole; otherwise the lineage fields of its metadata are passed.
const own = metadata.structuredContent
if (own && typeof own === "object" && !Array.isArray(own)) {
// The text repeats every edge that `columnLineage` already carries: only its header goes with them.
const head = report.split(/\n(?:Column edges \(|Trace of )/)[0]!.trim()
const full = { ...(own as Record<string, unknown>), report: head }
return Buffer.byteLength(JSON.stringify(full)) <= STRUCTURED_MAX_BYTES ? full : capReport(report, true)
}
const fields = Object.fromEntries((STRUCTURED_FIELDS[tool] ?? []).filter((key) => key in metadata).map((key) => [key, metadata[key]]))
// dbt_lineage's text prints the same mappings and edges as `columns` and `edges`: keep its header.
const head = tool === "dbt_lineage" && "columns" in fields ? report.split(/\n(?:Column Mappings|Lineage Edges):/)[0]!.trim() : report
const full = { ...fields, report: head }
return Buffer.byteLength(JSON.stringify(full)) <= STRUCTURED_MAX_BYTES ? full : capReport(report, true)
}
/** An error report held to the same cap as a result, so a huge exception message cannot exceed it. */
export function structuredError(message: string): Record<string, unknown> {
return capReport(message, false)
}
// `dropped`: the result's other fields were left out, so it is marked truncated even when the
// report itself fits.
function capReport(message: string, dropped: boolean): Record<string, unknown> {
// Measured as sent: JSON escaping can grow the text (a newline is two bytes, a control
// character six), so the cap applies to the serialized result, shortening until it fits.
const size = (value: Record<string, unknown>) => Buffer.byteLength(JSON.stringify(value))
const whole = dropped ? { report: message, truncated: true } : { report: message }
if (size(whole) <= STRUCTURED_MAX_BYTES) return whole
let report = message
while (report.length > 0 && size({ report, truncated: true }) > STRUCTURED_MAX_BYTES) {
report = report.slice(0, Math.floor(report.length * 0.9))
}
return { report, truncated: true }
}
// altimate_change end

/** Spilled results: a resource_link next to the text, which keeps the preview, row count and path. */
function resultLinks(metadata: Record<string, unknown>) {
// A batched sql_execute call reports one saved file per spilled query.
Expand Down Expand Up @@ -97,6 +172,7 @@ export async function createServer(options: ServeOptions = {}) {
name,
description: descriptions[name] ?? tool.description,
inputSchema: tool.jsonSchema as McpTool["inputSchema"],
...(STRUCTURED_TOOLS.has(name) && { outputSchema: STRUCTURED_OUTPUT_SCHEMA }),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[SUGGESTION]: Declaring outputSchema makes the error path non-conformant

MCP requires a tool that declares an output schema to return structuredContent that conforms to it. The catch block below (and the early workspace.execute return) still answers with text only, so argument-validation failures or adapter errors for dbt_lineage/impact_analysis produce a result with no structuredContent at all. Emitting structuredContent: { report: message } in that path keeps every response of these two tools valid.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed: the catch path in server.ts now returns structuredContent { report } for dbt_lineage and impact_analysis, so every response of those tools matches the declared output schema.

})), ...(workspace?.tools ?? [])],
}))
// Ask the user once per session when the model weighed two readings and the client can ask; any
Expand Down Expand Up @@ -146,19 +222,19 @@ export async function createServer(options: ServeOptions = {}) {
const clarified = request.params.name === "answer_commit" ? await clarifyReading(request.params.arguments, result, extra.signal) : undefined
if (clarified) return { content: [{ type: "text" as const, text: clarified }], isError: true }
const isError = resultFailed(result)
// A tool that builds a structured result (dbt_lineage) hands it over beside its text.
const structured = result.metadata?.structuredContent
return {
content: [{ type: "text" as const, text: result.output }, ...resultLinks(result.metadata)],
...(structured && typeof structured === "object" && !isError
? { structuredContent: structured as Record<string, unknown> }
: {}),
isError,
...(STRUCTURED_TOOLS.has(request.params.name) && {
structuredContent: structuredResult(request.params.name, result.metadata, result.output),
}),
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
return {
content: [{ type: "text" as const, text: error instanceof Error ? error.message : String(error) }],
content: [{ type: "text" as const, text: message }],
isError: true,
...(STRUCTURED_TOOLS.has(request.params.name) && { structuredContent: structuredError(message) }),
}
}
}
Expand Down
Loading
Loading