Lightdash MCP 查询结果契约(Result Contracts)完全指南:从 SQL 轮询到渲染 Chart 的类型化数据流
【免费下载链接】lightdashAgentic BI. Analytics at the speed of code ⚡️项目地址: https://gitcode.com/GitHub_Trending/li/lightdash
导读
Lightdash 的 MCP(Model Context Protocol)能力让 Claude 等 AI 客户端可以执行语义层查询(run_metric_query)、运行只读 SQL(run_sql)、轮询异步结果(get_query_result)并渲染图表(render_chart)。但当你在自定义 HTML/React Artifact 中消费这些工具时,真正需要处理的是structuredContent.result—— 一套精确的、区分「已完成 / 运行中 / 终态错误」的响应形状。本文基于仓库中的 result-contracts.md 文档,结合 McpService.ts 的真实实现,完整讲解每种响应形状的字段语义、CSV 文本块与类型化结构体之间的边界、轮询纪律以及链接与空值处理,帮你写出健壮的、能正确处理 0 行结果与长轮询的 Artifact 集成代码。
本文面向的场景是「自定义 Artifact 直接调用 Lightdash MCP 工具」,普通聊天对话或 Lightdash 内置 Chart App 不需要这些细节(SKILL.md 中明确说明了这一点)。
1. 理解外层信封:structuredContent.result与CallToolResult的关系
文档开篇就强调了一个最容易踩坑的前提:
这些形状描述的是
structuredContent.result,而不是整个CallToolResult。
一次完整的 MCP 工具调用结果(CallToolResult)可能包含:
content:一组带类型的文本块。查询工具会把 CSV 或状态/错误文本放在第一个 text 块中,后续块可能包含 applied-parameters 说明、queryUuid: <id>、[Scope: ...]或兼容性警告;structuredContent.result:宿主暴露的类型化结果,优先于解析 CSV使用;isError:工具失败标志,需要检查,但不能只看它——终态查询状态(error/cancelled/expired)可能以isError: false或完全缺省的方式正常返回;_meta:宿主/应用元数据,不要假设你的 Artifact 桥接层会暴露它。
从源码看,Lightdash 服务端正是按这套结构构造响应的。例如 McpService.ts#L1310-L1338 中构造指标查询结果时,content数组包含 CSV 文本块(或 0 行提示语)、可选的 applied-parameters 块、独立的queryUuid文本块,而structuredContent中则放入{ result: { status: 'done', queryUuid, rows, fields, exploreUrl } }。其中把queryUuid单独作为一个文本块发出,源码注释给出了明确动机:render_chart需要 queryUuid,而只暴露content(不暴露structuredContent)的客户端无法从中恢复它,可能臆造一个无效 ID(McpService.ts#L1304-L1307)。
两个必须区分的语义:
- 宿主可选地省略
structuredContent(纯文本桥接层); - 查询返回 0 行——这是成功状态下的空数据。
这两者完全不同:前者是协议层缺失,后者是业务结果为空。纯文本桥接层必须从状态文本中跟随状态,并且只对真正的数据块使用 CSV 解析器(引号定界符、引号与换行在 CSV 中都是合法的)。如果桥接层丢弃了必要的状态或 UUID 信息,应明确报集成错误,而不是靠猜测。
2. 已完成 SQL(run_sql同步完成 /get_query_result轮询完成)
run_sql返回如下形状:
{ status: "done", rows: Array<Record<string, unknown>>, columns: string[], rowCount: number, sqlRunnerUrl: string | null }关键语义:
rows以列名(column name)为键;columns给出它们的顺序;rowCount是返回的行数;- 初始同步完成的
run_sql不包含queryUuid——查询已完成时不要要求一个 UUID; - 如果 SQL 需要轮询,完成的
get_query_result会在这个形状上追加queryUuid; - 第一个 content 块包含带列名表头的 CSV;空结果时则是类似
Query returned 0 rows. Columns: ...的文本——不要把这句空结果提示语传给 CSV 解析器; - 结构化的空结果仍然包含
status: "done"、rows: []、columns、rowCount: 0和sqlRunnerUrl; - Lightdash 会对 SQL 施加请求的行数限制,因此请提交完整的 SELECT 语句;畸形 SQL 可能在生成的 LIMIT 附近产生错误。
源码佐证位于 McpService.ts#L1341-L1424 的buildSqlQueryResultResponse:columns取自result.columns的reference字段,rows通过cell.value.raw取出原始值;当rows.length === 0时,构造Query returned 0 rows.+Columns: ${columns.join(', ')}的提示文本,同时structuredContent.result仍携带status: 'done'、rows: []、columns、rowCount: 0与sqlRunnerUrl(McpService.ts#L1382-L1423)。另外注意includeStatus参数控制是否附加queryUuid——这正是文档所说「同步完成不带 queryUuid、轮询完成追加 queryUuid」的实现机制。
3. 已完成的指标查询(Metric Query)
run_metric_query以及get_query_result返回的指标查询完成结果:
{ status: "done", queryUuid: string, rows: Array<Record<string, unknown>>, fields: Record<string, unknown>, exploreUrl: string | null }关键语义:
rows以字段 ID(field ID)为键;fields是 Lightdash 的字段元数据映射。请用 ID 访问值与元数据,用于展示标签(display label)与格式化;- 第一个文本块是 CSV,但表头是展示标签(DISPLAY LABEL)而不是稳定的字段 ID,且重复的展示标签是可能的——不要靠猜把 CSV 列映射回字段 ID;
- 后续文本块可能包含 applied-parameters 说明和
queryUuid: <id>,请把它们与 CSV 分开;空结果使用Query returned 0 rows.和rows: []; - 调用
render_chart时,必须从该结果或其 UUID 文本块中复制本次查询的确切 UUID,绝不要复用其他查询/会话的 ID。
源码佐证:buildMetricQueryPollResult(McpService.ts#L1280-L1339)中,CSV 表头通过getItemLabelWithoutTableName(item)生成(即展示标签),rows为空时用Query returned 0 rows.作为 body,并始终追加queryUuid: ${queryUuid}文本块,structuredContent.result携带完整的rows(按字段 ID 键控)与fields元数据映射。对于 Artifact 渲染,SKILL.md 给出的原则是:数据访问用稳定 ID,展示用标签,且不要把标签当作唯一标识符。
4. 运行中状态:所有查询启动工具与get_query_result的轮询形状
{ status: "running", queryUuid: string, nextPollAfterMs: number, heartbeatAt: string }此刻还没有任何完成的行。文本响应中同样包含 UUID、等待说明,以及不要重新提交原始查询的警告。应遵从nextPollAfterMs(当前为 1000 ms)而不是硬编码立即重试循环。heartbeatAt是最近一次 Lightdash 状态检查的 ISO 时间戳。
服务端实现里,getRunningQueryResponse(McpService.ts#L822-L841)用new Date().toISOString()生成heartbeatAt,把MCP_QUERY_POLL_INTERVAL_MS同时写进文本提示与nextPollAfterMs;而状态映射getPollingStatus(McpService.ts#L803-L820)把后台的QueryHistoryStatus(PENDING/QUEUED/EXECUTING/READY/ERROR/CANCELLED/EXPIRED)翻译为running/done/error/cancelled/expired五态,这正是所有查询工具与get_query_result共享的状态机。
4.1 轮询纪律(Polling Discipline)
从 SKILL.md 与文档可以提炼出完整的轮询流程:
- 用
run_sql或run_metric_query启动一次查询,保存返回的queryUuid与原始 project/agent 作用域; - 先检查
isError;对结构化结果,按result.status分支; running:保留queryUuid,显示「查询仍在运行…」,等待nextPollAfterMs,再用同一个 UUID 与作用域调用get_query_result。永远不要发明一个 ID 或为查进度重启原查询;done:停止轮询并渲染数据。空的rows数组是成功完成但零行,不是解析失败;error/cancelled/expired:停止轮询并显示返回的错误/状态,这些可能是没有isError: true的正常 MCP 结果。
两个重要的超时语义:初次查询启动调用与每次轮询调用在服务端都最多可等待约 50 秒——请为宿主桥接层的 timeout 留出余量。这是服务端轮询的等待时间,不是仓库执行超时;仓库(warehouse)超时来自 Lightdash 连接配置。heartbeatAt记录的是 Lightdash 最近一次确认查询仍在运行的检查时间,不是仓库进度百分比。
此外:
- 轮询调用若断连或超时,只重试
get_query_result(同一个 UUID),使用有界的重试/退避;避免重叠轮询,在终态、Artifact 销毁或用户取消时停止。停止本地轮询并不会取消仓库执行。如果用户在一个旧调用还在途时启动了新查询,应忽略过期响应; - 如果初次启动调用在收到 UUID 前就丢失了响应,不要自动重新提交:仓库可能已经在执行它。应报告结果未知,重试可能造成重复执行;
- 校验失败应在启动下一个查询前修正;应用/仓库错误是终态的(直到被修正),不要把这类错误放进瞬时轮询重试循环。
测试佐证:McpService.queryPolling.test.ts 覆盖了轮询相关的行为与配置。
5. 终态错误:error/cancelled/expired
get_query_result可以用一个正常的 CallToolResult返回:
{ status: "error" | "cancelled" | "expired", queryUuid: string, error: string | null }这里isError可能缺省。停止轮询并展示终态状态/错误;绝不能仅仅因为isError为 false 或缺失就把终态误判为成功。
与之相对,启动期、校验、授权与执行期的异常则返回isError: true,错误文本在content[0],且没有 structuredContent——永远不要把这些文本当作 CSV 解析。纯文本桥接层必须同时浮现错误标志与状态文本;如果必要信息被隐藏,应明确失败而不是猜测。
源码佐证:run_metric_query的 catch 分支(McpService.ts#L3170-L3185)以及run_sql的同类分支都以{ content: [{ type: 'text', text: 'Error running ...' }], isError: true }返回;而终态状态则通过getPollingStatus正常映射进structuredContent.result,两者是截然不同的两条路径。
6. 值类型与链接语义
6.1 结构化行的值类型
结构化行中的数字与布尔值不会预先字符串化,不要盲目套parseFloat/parseInt。字符串、ISO 日期字符串与null都是合法值。CSV 只是格式化后的文本表示,不能替代带类型的结构化值。保留null,不要悄悄把它转成 0 或空字符串。
6.2 查询专属链接
sqlRunnerUrl与exploreUrl是 Lightdash 返回的可空、查询专属链接:
- 当 Artifact 提供「检查/编辑」动作时使用返回的链接;链接为
null时隐藏该动作; - 不要构造项目级链接来冒充本次查询(例如在 SQL Runner 与 Explore 场景下分别使用各自的
buildSqlRunnerUrl/buildMetricExploreUrl生成的、与 queryUuid 绑定的链接); - 不要在助手最终措辞里自行添加 SQL Runner 链接:平台已经提供了查询专属的延续动作,当一轮中有多个工具运行时,措辞链接可能指向错误的查询。
7.render_chart:内置 MCP App 的输出,而非 Artifact 图表数据
{ status: "done", queryUuid: string, exploreUrl: string | null, echartsOption: {} | null }- 空对象
{}是轻量占位符。完整的 chart option 与 rows/fields 存在于内置 MCP App 的_meta.result中;Artifact 桥接层可能拿不到这些元数据; null表示没有 ECharts option(例如表格或 0 行结果),不是查询失败;- content 里是简短的渲染状态消息,不是查询 CSV。
该工具只支持已完成的run_metric_query结果:它拒绝 SQL Runner /run_sql的查询 UUID,且不会执行或轮询查询。自定义 HTML/React Artifact 应从查询工具获取 rows 并自己渲染可视化,不要依赖内置图表框架,也不要依赖元数据送达模型。
源码佐证:render_chart与run_metric_query一样受runMetricQueryEnabled开关控制(McpService.ts#L3190-L3204),并在渲染空结果时返回Result rendered for queryUuid: <id>, but the query returned 0 rows.的提示(McpService.ts#L1187-L1207)。run_sql与 SQL 轮询只返回数据,从不返回 Lightdash chart 产物——自定义 Artifact 可以自行可视化这些数据,但绝不要向render_chart发送 SQL 查询 UUID。
8. 工具选择与行数限制的实践前提
在启动查询前应明确两条规则(详见 SKILL.md):
- 能用语义层表达的问题优先选
run_metric_query:先用grep_fields/get_metadata发现字段 ID,并显式提供必要参数——省略的参数可能静默采用默认值; - 其他仓库方言的只读 SELECT 用
run_sql; - 读取每个工具的 schema 了解其有效行数上限;部署配置可以改变上限。从源码看,指标查询的限制来自
this.lightdashConfig.ai.copilot.maxQueryLimit(McpService.ts#L879),并通过getValidAiQueryLimit校验后注入 MetricQuery。
另外,Artifact 自身应跟踪 loading / success / empty / error 四态——一个 pending 中的查询不是空数据集。
9. 安全渲染与收尾检查
把返回的标签、单元格值与错误消息都视为不可信的展示数据:使用转义文本或 React 的常规渲染(而不是原始 HTML 注入);使用结构化值类型而不是用parseFloat/parseInt强制转换一切;显式处理null与空结果。
一句话总结本文的核心契约:
| 场景 | 关键判断点 | 易错点 |
|---|---|---|
| SQL 同步完成 | status: "done",无queryUuid | 不要强求 UUID |
| SQL 轮询完成 | 追加queryUuid | CSV 表头是列名 |
| 指标查询完成 | rows按字段 ID 键控 +fields元数据 | CSV 表头是展示标签,可能重复 |
| 运行中 | nextPollAfterMs+heartbeatAt | 不要立即重试、不要重提原查询 |
| 终态错误 | error/cancelled/expired,isError可能缺省 | 不要误判为成功 |
| 工具异常 | isError: true,无 structuredContent | 不要解析错误文本为 CSV |
| 空结果 | status: "done"+rows: [] | 是成功,不是解析失败 |
| render_chart | 仅限已完成的 metric 查询 | {}/null是占位/无图,不是失败 |
如需按上述契约继续深入,建议进一步阅读同一技能目录下的 SKILL.md、契约原文 result-contracts.md,以及实现端 McpService.ts 与轮询测试 McpService.queryPolling.test.ts。
【免费下载链接】lightdashAgentic BI. Analytics at the speed of code ⚡️项目地址: https://gitcode.com/GitHub_Trending/li/lightdash
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考