@capagents/forgeqe 1.1.6 → 1.1.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/HOW_TO_USE.md +18 -8
- package/docs/user-guide.html +19 -10
- package/package.json +1 -1
- package/prompts/brain.md +4 -2
- package/src/brain/index.ts +4 -0
- package/src/capabilities/registry.ts +6 -2
- package/src/cases/api.ts +75 -22
- package/src/code/runtime.ts +3 -0
- package/src/mcp/reporttools.ts +205 -14
- package/src/mcp/server.ts +22 -7
- package/src/mcp/tools.ts +16 -5
- package/src/report/evidence.ts +313 -0
- package/src/report/html.ts +7 -3
- package/src/report/template.html +46 -45
- package/src/tui/App.tsx +22 -9
- package/src/tui/inspector.tsx +76 -25
- package/src/tui/transcript.tsx +19 -1
package/src/mcp/reporttools.ts
CHANGED
|
@@ -1,11 +1,29 @@
|
|
|
1
1
|
import { mkdirSync, writeFileSync } from "node:fs"
|
|
2
2
|
import path from "node:path"
|
|
3
3
|
|
|
4
|
+
import { queryApi, type ApiQuery } from "../cases/api.ts"
|
|
5
|
+
import { reportPage } from "../report/html.ts"
|
|
6
|
+
import {
|
|
7
|
+
chooseColumns,
|
|
8
|
+
chooseFilters,
|
|
9
|
+
dedupe,
|
|
10
|
+
describeCall,
|
|
11
|
+
EvidenceLog,
|
|
12
|
+
evidenceList,
|
|
13
|
+
pickEvidence,
|
|
14
|
+
rowsOf,
|
|
15
|
+
type Cell,
|
|
16
|
+
type Column,
|
|
17
|
+
type EvidenceEntry,
|
|
18
|
+
type Row,
|
|
19
|
+
} from "../report/evidence.ts"
|
|
4
20
|
import type { McpTool } from "./tools.ts"
|
|
5
21
|
|
|
6
22
|
/**
|
|
7
|
-
* Reports the agent writes from what it found (
|
|
8
|
-
*
|
|
23
|
+
* Reports the agent writes from what it found (open defects, release status, pipeline health). Every table in one is
|
|
24
|
+
* built from evidence: the output of tool calls in this session, or a read-only query ForgeQE runs itself. The model
|
|
25
|
+
* writes only the summary. The report is a page (report-ui, with filters, charts, and the sources it came from) and
|
|
26
|
+
* the same report as Markdown; the raw results are kept beside them.
|
|
9
27
|
*/
|
|
10
28
|
|
|
11
29
|
/** "Release status: all projects" → "release-status-all-projects". */
|
|
@@ -21,7 +39,7 @@ export function reportSlug(title: string): string {
|
|
|
21
39
|
|
|
22
40
|
const escapeHtml = (s: string) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """)
|
|
23
41
|
|
|
24
|
-
/**
|
|
42
|
+
/** A Markdown document as a plain page (forgeqe bi's page without Evidence): the Markdown rendered, with raw HTML in it shown as text so a report can never run script. */
|
|
25
43
|
export function reportHtml(title: string, markdown: string, written: Date): string {
|
|
26
44
|
const safe = markdown.replace(/<(?=[A-Za-z/!?])/g, "<")
|
|
27
45
|
const body = Bun.markdown.html(safe)
|
|
@@ -66,36 +84,209 @@ ${body}
|
|
|
66
84
|
`
|
|
67
85
|
}
|
|
68
86
|
|
|
87
|
+
/** The summary as HTML, with raw HTML in it shown as text so a report can never run script. */
|
|
88
|
+
export function summaryHtml(markdown: string): string {
|
|
89
|
+
return Bun.markdown.html(markdown.replace(/<(?=[A-Za-z/!?])/g, "<"))
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const MAX_ROWS = 5000
|
|
93
|
+
const MD_ROWS = 200
|
|
94
|
+
|
|
95
|
+
export type DatasetSpec = { name?: string; from?: string; match?: string; query?: ApiQuery; columns?: string[]; filters?: string[] }
|
|
96
|
+
|
|
97
|
+
export type Dataset = { name: string; columns: Column[]; filters: string[]; rows: Row[]; sources: string[]; total: number; note?: string }
|
|
98
|
+
export type Source = { id: string; tool: string; call: string; at: string; rows: number | null; file: string }
|
|
99
|
+
|
|
100
|
+
/** The rows of one dataset, and the evidence they came from. */
|
|
101
|
+
export async function buildDataset(spec: DatasetSpec, log: EvidenceLog, readApi: (q: ApiQuery) => Promise<unknown>): Promise<{ dataset: Dataset; used: EvidenceEntry[] }> {
|
|
102
|
+
const name = String(spec.name || "Data").trim()
|
|
103
|
+
let used: EvidenceEntry[]
|
|
104
|
+
if (spec.query) {
|
|
105
|
+
const q = spec.query
|
|
106
|
+
const out = await readApi(q)
|
|
107
|
+
const entry = log.record("query_api", { ...q }, typeof out === "string" ? out : JSON.stringify(out))
|
|
108
|
+
if (!entry) throw new Error(`Dataset "${name}": the query returned nothing.`)
|
|
109
|
+
used = [entry]
|
|
110
|
+
} else if (spec.from) {
|
|
111
|
+
used = pickEvidence(log.all(), String(spec.from), spec.match ? String(spec.match) : undefined)
|
|
112
|
+
if (!used.length) throw new Error(`Dataset "${name}": no tool result matches "${spec.from}"${spec.match ? ` with "${spec.match}"` : ""}. Results in this session:\n${evidenceList(log.all())}`)
|
|
113
|
+
} else {
|
|
114
|
+
throw new Error(`Dataset "${name}" needs "from" (a tool result of this session, e.g. E3 or the tool's name) or "query" (a read-only query ForgeQE runs).`)
|
|
115
|
+
}
|
|
116
|
+
const parts = used.map((e) => ({ e, rows: rowsOf(e.output) }))
|
|
117
|
+
const noData = parts.filter((p) => p.rows === null)
|
|
118
|
+
if (noData.length === parts.length) {
|
|
119
|
+
throw new Error(`Dataset "${name}": ${noData.map((p) => p.e.id).join(", ")} returned no data (no JSON records). Results in this session:\n${evidenceList(log.all())}`)
|
|
120
|
+
}
|
|
121
|
+
const all = dedupe(parts.flatMap((p) => p.rows || []))
|
|
122
|
+
const rows = all.slice(0, MAX_ROWS)
|
|
123
|
+
const columns = rows.length ? chooseColumns(rows, spec.columns) : []
|
|
124
|
+
const totals = parts.map((p) => {
|
|
125
|
+
const v = (() => {
|
|
126
|
+
try {
|
|
127
|
+
return JSON.parse(p.e.output) as { total?: unknown; count?: unknown }
|
|
128
|
+
} catch {
|
|
129
|
+
return null
|
|
130
|
+
}
|
|
131
|
+
})()
|
|
132
|
+
return typeof v?.total === "number" ? v.total : 0
|
|
133
|
+
})
|
|
134
|
+
const total = Math.max(all.length, ...totals)
|
|
135
|
+
const notes = [
|
|
136
|
+
all.length > MAX_ROWS ? `The page holds the first ${MAX_ROWS} of ${all.length} rows.` : "",
|
|
137
|
+
total > all.length ? `The query matched ${total}; the first ${all.length} were read.` : "",
|
|
138
|
+
].filter(Boolean)
|
|
139
|
+
return {
|
|
140
|
+
dataset: { name, columns, filters: chooseFilters(rows, columns, spec.filters), rows, sources: used.map((e) => e.id), total, ...(notes.length ? { note: notes.join(" ") } : {}) },
|
|
141
|
+
used,
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const mdCell = (v: Cell | undefined) => (v === null || v === undefined ? "" : String(v).replace(/\|/g, "\\|").replace(/\s+/g, " "))
|
|
146
|
+
const csvCell = (v: Cell | undefined) => {
|
|
147
|
+
const s = v === null || v === undefined ? "" : String(v)
|
|
148
|
+
return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Counts of each value of a column, most first. */
|
|
152
|
+
export function countBy(rows: Row[], key: string): [string, number][] {
|
|
153
|
+
const m = new Map<string, number>()
|
|
154
|
+
for (const r of rows) {
|
|
155
|
+
const v = r[key] === null || r[key] === undefined || r[key] === "" ? "(none)" : String(r[key])
|
|
156
|
+
m.set(v, (m.get(v) || 0) + 1)
|
|
157
|
+
}
|
|
158
|
+
return [...m].sort((a, b) => b[1] - a[1])
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** The report as Markdown: the summary, each dataset's counts and table, and the sources. */
|
|
162
|
+
export function reportMarkdown(title: string, summary: string, datasets: Dataset[], sources: Source[], written: Date): string {
|
|
163
|
+
const out = [`# ${title}`, "", `_Written by ForgeQE on ${written.toISOString().slice(0, 16).replace("T", " ")} UTC, from ${sources.length === 1 ? "1 source" : `${sources.length} sources`}._`, "", summary, ""]
|
|
164
|
+
for (const d of datasets) {
|
|
165
|
+
out.push(`## ${d.name}`, "", `**${d.rows.length}** ${d.rows.length === 1 ? "row" : "rows"}${d.note ? ` (${d.note})` : ""} · from ${d.sources.join(", ")}`, "")
|
|
166
|
+
for (const f of d.filters.slice(0, 3)) out.push(`- By ${f}: ${countBy(d.rows, f).map(([v, n]) => `${v} ${n}`).join(", ")}`)
|
|
167
|
+
if (d.filters.length) out.push("")
|
|
168
|
+
if (d.rows.length) {
|
|
169
|
+
const cols = d.columns.filter((c) => c.key !== "Link")
|
|
170
|
+
const idCol = cols.find((c) => c.key === "ID" || c.key === "Key")
|
|
171
|
+
out.push(`| ${cols.map((c) => c.label).join(" | ")} |`, `| ${cols.map(() => "---").join(" | ")} |`)
|
|
172
|
+
for (const r of d.rows.slice(0, MD_ROWS)) {
|
|
173
|
+
out.push(`| ${cols.map((c) => (c === idCol && r.Link ? `[${mdCell(r[c.key])}](${r.Link})` : mdCell(r[c.key]))).join(" | ")} |`)
|
|
174
|
+
}
|
|
175
|
+
if (d.rows.length > MD_ROWS) out.push("", `The first ${MD_ROWS} rows; the page and ${csvName(d.name)} have all ${d.rows.length}.`)
|
|
176
|
+
out.push("")
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
out.push("## Sources", "", ...sources.map((s) => `- ${s.id} · ${s.call} · ${s.at.slice(0, 16).replace("T", " ")} UTC · ${s.rows === null ? "no data" : `${s.rows} rows`} · [raw](${s.file})`), "")
|
|
180
|
+
return out.join("\n")
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const csvName = (name: string) => `${reportSlug(name)}.csv`
|
|
184
|
+
|
|
69
185
|
export const REPORT_TOOLS: McpTool[] = [
|
|
70
186
|
{
|
|
71
187
|
name: "write_report",
|
|
72
188
|
description:
|
|
73
|
-
'Save a report the user asks for ("create a
|
|
189
|
+
'Save a report the user asks for ("create a defect report for all open defects", "release status for all projects", "a report I can share") as a page with filters, charts, and its sources, and as Markdown. A report is built from evidence: each dataset is the output of tool calls you made in this session (from: the tool\'s name, e.g. wit_get_work_items_batch_by_ids or query_api, or an evidence id like E3; match narrows to calls whose input mentions it), or a read-only query ForgeQE runs itself (query: same fields as query_api; a WIQL query reads up to 1000 work items with project, state, priority, severity, assignee, area, and links, so prefer it for "all" of something). ForgeQE takes the rows from those results, not from you; columns and filters are optional (default: the usual ones in the data, such as Project, State, Priority, Assigned To). Write only the summary yourself: one short paragraph of what the data shows, with no figures the datasets do not hold. The user is shown the report paths as links, so reply with three to five lines of summary only.',
|
|
74
190
|
inputSchema: {
|
|
75
191
|
type: "object",
|
|
76
192
|
properties: {
|
|
77
|
-
title: { type: "string", description: "The report's title, e.g.
|
|
78
|
-
|
|
193
|
+
title: { type: "string", description: "The report's title, e.g. Open defects: all projects, 13 October 2026" },
|
|
194
|
+
summary: { type: "string", description: "One short Markdown paragraph (or a few bullets) on what the data shows; the tables, counts, and charts are made from the datasets" },
|
|
195
|
+
datasets: {
|
|
196
|
+
type: "array",
|
|
197
|
+
minItems: 1,
|
|
198
|
+
items: {
|
|
199
|
+
type: "object",
|
|
200
|
+
properties: {
|
|
201
|
+
name: { type: "string", description: "e.g. Open defects" },
|
|
202
|
+
from: { type: "string", description: "Evidence ids (E3, or E3,E4) or the name of a tool called in this session; every successful call of it is used" },
|
|
203
|
+
match: { type: "string", description: "With a tool name: only its calls whose input contains this text" },
|
|
204
|
+
query: {
|
|
205
|
+
type: "object",
|
|
206
|
+
description: "A read-only query ForgeQE runs for this dataset (instead of from)",
|
|
207
|
+
properties: {
|
|
208
|
+
system: { type: "string", enum: ["azure_devops", "jira", "xray", "confluence"] },
|
|
209
|
+
wiql: { type: "string", description: "azure_devops: e.g. SELECT [System.Id] FROM WorkItems WHERE [System.WorkItemType] = 'Bug' AND [System.State] NOT IN ('Closed','Done','Removed'). Across projects, leave out @project" },
|
|
210
|
+
project: { type: "string" },
|
|
211
|
+
path: { type: "string", description: "A REST path, as for query_api (e.g. Jira /rest/api/3/search/jql?jql=...&fields=summary,status,priority,assignee,project)" },
|
|
212
|
+
query: { type: "string", description: "xray: a GraphQL query" },
|
|
213
|
+
variables: { type: "object" },
|
|
214
|
+
},
|
|
215
|
+
required: ["system"],
|
|
216
|
+
},
|
|
217
|
+
columns: { type: "array", items: { type: "string" }, description: "Columns to show, in order (default: the usual ones in the data)" },
|
|
218
|
+
filters: { type: "array", items: { type: "string" }, description: "Columns to filter by (default: Project, State, Priority, Assigned To, … when present)" },
|
|
219
|
+
},
|
|
220
|
+
required: ["name"],
|
|
221
|
+
},
|
|
222
|
+
},
|
|
79
223
|
},
|
|
80
|
-
required: ["title", "
|
|
224
|
+
required: ["title", "datasets"],
|
|
81
225
|
},
|
|
82
226
|
async run(args, ctx) {
|
|
83
227
|
const title = String(args.title || "Report").trim()
|
|
84
|
-
const
|
|
85
|
-
|
|
228
|
+
const summary = String(args.summary ?? args.markdown ?? "").trim()
|
|
229
|
+
const specs = (Array.isArray(args.datasets) ? args.datasets : []) as DatasetSpec[]
|
|
230
|
+
const log = ctx.evidence || new EvidenceLog()
|
|
231
|
+
if (!specs.length) {
|
|
232
|
+
throw new Error(`A report is built from evidence: pass datasets, each from a tool result of this session or a read-only query. Results in this session:\n${evidenceList(log.all())}`)
|
|
233
|
+
}
|
|
234
|
+
const readApi = (q: ApiQuery) => queryApi(ctx.config.sources, q, { full: true })
|
|
235
|
+
const built: Dataset[] = []
|
|
236
|
+
const used = new Map<string, EvidenceEntry>()
|
|
237
|
+
for (const spec of specs) {
|
|
238
|
+
const { dataset, used: entries } = await buildDataset(spec, log, readApi)
|
|
239
|
+
built.push(dataset)
|
|
240
|
+
for (const e of entries) used.set(e.id, e)
|
|
241
|
+
}
|
|
242
|
+
|
|
86
243
|
const written = new Date()
|
|
87
244
|
const stamp = written.toISOString().replace(/[-:]/g, "").replace("T", "-").slice(0, 15)
|
|
88
245
|
// Beside the run reports, so the shell links it and outDir publishing (notify.report_url) serves it too.
|
|
89
246
|
const dir = path.resolve(ctx.config.export.outDir, `${reportSlug(title)}-${stamp}`)
|
|
90
|
-
mkdirSync(dir, { recursive: true })
|
|
247
|
+
mkdirSync(path.join(dir, "evidence"), { recursive: true })
|
|
248
|
+
const sources: Source[] = [...used.values()].map((e) => {
|
|
249
|
+
const file = `evidence/${e.id}.json`
|
|
250
|
+
writeFileSync(path.join(dir, file), JSON.stringify({ id: e.id, tool: e.tool, input: e.input, at: e.at, output: e.output }, null, 2))
|
|
251
|
+
const rows = rowsOf(e.output)
|
|
252
|
+
return { id: e.id, tool: e.tool, call: describeCall(e), at: e.at, rows: rows ? rows.length : null, file }
|
|
253
|
+
})
|
|
254
|
+
for (const d of built) {
|
|
255
|
+
const cols = d.columns
|
|
256
|
+
writeFileSync(path.join(dir, csvName(d.name)), [cols.map((c) => csvCell(c.label)).join(","), ...d.rows.map((r) => cols.map((c) => csvCell(r[c.key])).join(","))].join("\n") + "\n")
|
|
257
|
+
}
|
|
91
258
|
const md = path.join(dir, "report.md")
|
|
92
259
|
const html = path.join(dir, "report.html")
|
|
93
|
-
writeFileSync(md,
|
|
94
|
-
writeFileSync(
|
|
260
|
+
writeFileSync(md, reportMarkdown(title, summary, built, sources, written))
|
|
261
|
+
writeFileSync(
|
|
262
|
+
html,
|
|
263
|
+
reportPage(title, {
|
|
264
|
+
kind: "data",
|
|
265
|
+
title,
|
|
266
|
+
id: path.basename(dir),
|
|
267
|
+
startedAt: written.toISOString(),
|
|
268
|
+
ms: 0,
|
|
269
|
+
stopped: false,
|
|
270
|
+
generatedAt: written.toISOString(),
|
|
271
|
+
cases: [],
|
|
272
|
+
posted: [],
|
|
273
|
+
history: [],
|
|
274
|
+
report: { summary, summaryHtml: summary ? summaryHtml(summary) : "", datasets: built.map((d) => ({ ...d, csv: csvName(d.name) })), sources },
|
|
275
|
+
}),
|
|
276
|
+
)
|
|
95
277
|
const show = (p: string) => path.relative(process.cwd(), p) || p
|
|
278
|
+
const rows = built.reduce((n, d) => n + d.rows.length, 0)
|
|
96
279
|
ctx.emit?.({ type: "report", title, html, markdown: md })
|
|
97
|
-
ctx.emit?.({
|
|
98
|
-
|
|
280
|
+
ctx.emit?.({
|
|
281
|
+
type: "card",
|
|
282
|
+
card: { status: "ok", title: `Report: ${title}`, body: [`Page ${show(html)}`, `Markdown ${show(md)}`, `Evidence ${rows} rows from ${sources.map((s) => s.id).join(", ")}`] },
|
|
283
|
+
})
|
|
284
|
+
return {
|
|
285
|
+
html: show(html),
|
|
286
|
+
markdown: show(md),
|
|
287
|
+
datasets: built.map((d) => ({ name: d.name, rows: d.rows.length, total: d.total, columns: d.columns.map((c) => c.key), filters: d.filters, counts: Object.fromEntries(d.filters.map((f) => [f, Object.fromEntries(countBy(d.rows, f).slice(0, 8))])), ...(d.note ? { note: d.note } : {}) })),
|
|
288
|
+
sources: sources.map((s) => `${s.id} ${s.call}`),
|
|
289
|
+
}
|
|
99
290
|
},
|
|
100
291
|
},
|
|
101
292
|
]
|
package/src/mcp/server.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createHash, randomBytes, timingSafeEqual } from "node:crypto"
|
|
2
2
|
|
|
3
|
+
import { EvidenceLog } from "../report/evidence.ts"
|
|
3
4
|
import { logEvent } from "../sessionLog.ts"
|
|
4
5
|
import type { McpTool, ToolContext, ToolEvent } from "./tools.ts"
|
|
5
6
|
|
|
@@ -24,14 +25,23 @@ export type McpServerOptions = {
|
|
|
24
25
|
progressMs?: number
|
|
25
26
|
}
|
|
26
27
|
|
|
28
|
+
/** A ForgeQE tool call that has no line of its own, named for the Activity panel by its tool and its subject. */
|
|
29
|
+
export function callLabel(name: string, args: Record<string, unknown>): string {
|
|
30
|
+
const subject = [args.title, args.goal, args.source, args.sources, args.run_dir, args.command].find((v) => typeof v === "string" && v.trim()) as string | undefined
|
|
31
|
+
const flat = subject?.replace(/\s+/g, " ").trim()
|
|
32
|
+
return `ForgeQE · ${name.replace(/_/g, " ")}${flat ? ` (${flat.length > 70 ? `${flat.slice(0, 69)}…` : flat})` : ""}`
|
|
33
|
+
}
|
|
34
|
+
|
|
27
35
|
/** JSON-RPC dispatch for MCP: initialize, ping, tools/list, tools/call, and cancellation. */
|
|
28
36
|
export function createMcpServer(opts: McpServerOptions) {
|
|
29
37
|
const running = new Map<string | number, AbortController>()
|
|
38
|
+
/** Every result of this server's tools, for write_report; the agent adds the results of the other MCP servers. */
|
|
39
|
+
const evidence = new EvidenceLog()
|
|
30
40
|
|
|
31
41
|
async function callTool(params: Json, id: string | number, send: Send, outer?: AbortSignal): Promise<Json> {
|
|
32
42
|
const tool = opts.tools.find((t) => t.name === params.name)
|
|
33
43
|
if (!tool) return { content: [{ type: "text", text: `Unknown tool ${params.name}` }], isError: true }
|
|
34
|
-
const base = opts.context()
|
|
44
|
+
const base = { evidence, ...opts.context() }
|
|
35
45
|
const controller = new AbortController()
|
|
36
46
|
const abort = () => controller.abort()
|
|
37
47
|
base.signal?.addEventListener("abort", abort, { once: true })
|
|
@@ -63,10 +73,13 @@ export function createMcpServer(opts: McpServerOptions) {
|
|
|
63
73
|
}
|
|
64
74
|
: undefined
|
|
65
75
|
const callId = `${tool.name}-${String(id)}-${started}`
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
}
|
|
69
|
-
|
|
76
|
+
// Every call is said as it starts and ends. A tool with no line of its own shows its result another way (a card,
|
|
77
|
+
// a run), so its call is named for the Activity panel only: "ForgeQE · write report (Open defects, 13 October)".
|
|
78
|
+
const args = params.arguments || {}
|
|
79
|
+
const label = tool.line ? tool.line(args) : callLabel(tool.name, args)
|
|
80
|
+
const quiet = tool.line ? {} : { quiet: true }
|
|
81
|
+
const said = (ok: boolean) => emit?.({ type: "call", text: label, ok, id: callId, ...quiet })
|
|
82
|
+
emit?.({ type: "call-start", text: label, id: callId, ...quiet })
|
|
70
83
|
let out: unknown
|
|
71
84
|
try {
|
|
72
85
|
out = await tool.run(params.arguments || {}, { ...base, emit, signal: controller.signal })
|
|
@@ -76,7 +89,9 @@ export function createMcpServer(opts: McpServerOptions) {
|
|
|
76
89
|
}
|
|
77
90
|
if (controller.signal.aborted) return finish({ content: [{ type: "text", text: "Stopped by the user." }], isError: true })
|
|
78
91
|
said(true)
|
|
79
|
-
|
|
92
|
+
const text = typeof out === "string" ? out : JSON.stringify(out, null, 2)
|
|
93
|
+
base.evidence.record(tool.name, args, text)
|
|
94
|
+
return finish({ content: [{ type: "text", text }] })
|
|
80
95
|
} catch (err) {
|
|
81
96
|
return finish({ content: [{ type: "text", text: controller.signal.aborted ? "Stopped by the user." : (err as Error).message || String(err) }], isError: true })
|
|
82
97
|
} finally {
|
|
@@ -116,7 +131,7 @@ export function createMcpServer(opts: McpServerOptions) {
|
|
|
116
131
|
}
|
|
117
132
|
}
|
|
118
133
|
|
|
119
|
-
return { handle, abortAll: () => running.forEach((c) => c.abort()) }
|
|
134
|
+
return { handle, evidence, abortAll: () => running.forEach((c) => c.abort()) }
|
|
120
135
|
}
|
|
121
136
|
|
|
122
137
|
export type McpServer = ReturnType<typeof createMcpServer>
|
package/src/mcp/tools.ts
CHANGED
|
@@ -6,6 +6,7 @@ import type { Card } from "../card.ts"
|
|
|
6
6
|
import { privateEnvNames } from "../childEnv.ts"
|
|
7
7
|
import { usageLine } from "../usage.ts"
|
|
8
8
|
import { queryApi } from "../cases/api.ts"
|
|
9
|
+
import type { EvidenceLog } from "../report/evidence.ts"
|
|
9
10
|
import { createAdoDashboard, type DashboardWidget } from "../cases/dashboards.ts"
|
|
10
11
|
import { writeAdoWikiPage } from "../cases/wiki.ts"
|
|
11
12
|
import { readConfluence } from "../cases/confluence.ts"
|
|
@@ -102,9 +103,12 @@ export type ToolEvent =
|
|
|
102
103
|
/** write_report saved a report: its page and its Markdown. */
|
|
103
104
|
| { type: "report"; title: string; html: string; markdown: string }
|
|
104
105
|
/** A finished call of a tool that has no card of its own, as one line: "Azure DevOps API · QI-Claims/_apis/build/builds (top 5)". */
|
|
105
|
-
| { type: "call"; text: string; ok: boolean; id?: string }
|
|
106
|
-
/**
|
|
107
|
-
|
|
106
|
+
| { type: "call"; text: string; ok: boolean; id?: string; quiet?: boolean }
|
|
107
|
+
/**
|
|
108
|
+
* The same call as it starts, so a long one is visible while it runs; its `call` with the same `id` follows.
|
|
109
|
+
* `quiet`: a tool that shows its own result (a card, a run): the call is for the Activity panel only.
|
|
110
|
+
*/
|
|
111
|
+
| { type: "call-start"; text: string; id: string; quiet?: boolean }
|
|
108
112
|
|
|
109
113
|
const API_LABEL: Record<string, string> = { azure_devops: "Azure DevOps", jira: "Jira", xray: "Xray", confluence: "Confluence" }
|
|
110
114
|
|
|
@@ -114,7 +118,12 @@ const API_LABEL: Record<string, string> = { azure_devops: "Azure DevOps", jira:
|
|
|
114
118
|
*/
|
|
115
119
|
export function queryLine(args: Record<string, any>): string {
|
|
116
120
|
const name = `${API_LABEL[String(args.system)] || String(args.system || "API")} API`
|
|
117
|
-
if (args.wiql)
|
|
121
|
+
if (args.wiql) {
|
|
122
|
+
// The project first: it is what tells one search from the next, and the @project clause then says nothing more.
|
|
123
|
+
const where = String(args.wiql).replace(/\s+/g, " ").replace(/^SELECT .*? FROM WorkItems WHERE /i, "")
|
|
124
|
+
const query = args.project ? where.replace(/^\[System\.TeamProject\] = @project AND /i, "") : where
|
|
125
|
+
return `${name} · WIQL${args.project ? ` · ${args.project}` : ""} (${clip(query, 90)})`
|
|
126
|
+
}
|
|
118
127
|
if (args.query) return `${name} · GraphQL (${clip(String(args.query).replace(/\s+/g, " "), 90)})`
|
|
119
128
|
const raw = String(args.path || "")
|
|
120
129
|
const url = /^https?:\/\//.test(raw) ? raw.replace(/^https?:\/\/[^/]+\/(?:[^/]+\/(?=[^/]+\/_apis\/))?/, "") : raw.replace(/^\//, "")
|
|
@@ -156,6 +165,8 @@ export type ToolContext = {
|
|
|
156
165
|
openSession?: (o: any) => Promise<OpencodeSession<any>>
|
|
157
166
|
/** Replaces write_test's confirming run in tests. */
|
|
158
167
|
runTest?: typeof runTestCommand
|
|
168
|
+
/** The session's tool results, which write_report builds its tables from. */
|
|
169
|
+
evidence?: EvidenceLog
|
|
159
170
|
}
|
|
160
171
|
|
|
161
172
|
/** `lines`: context shown above the choice (the list being confirmed, sprint dates). */
|
|
@@ -705,7 +716,7 @@ export const TOOLS: McpTool[] = [
|
|
|
705
716
|
wiql: {
|
|
706
717
|
type: "string",
|
|
707
718
|
description:
|
|
708
|
-
"azure_devops: a WIQL query to search work items (instead of path), e.g. SELECT [System.Id] FROM WorkItems WHERE [System.TeamProject] = @project AND [System.WorkItemType] = 'User Story' ORDER BY [System.ChangedDate] DESC. Returns the first 50 with id, type, title, state, assignee, changed
|
|
719
|
+
"azure_devops: a WIQL query to search work items (instead of path), e.g. SELECT [System.Id] FROM WorkItems WHERE [System.TeamProject] = @project AND [System.WorkItemType] = 'User Story' ORDER BY [System.ChangedDate] DESC. Returns the first 50 with id, project, type, title, state, priority, severity, assignee, area, iteration, tags, created and changed dates, and a link. Use it to find or list work items; the Analytics (_odata) endpoints need a token with the Analytics scope.",
|
|
709
720
|
},
|
|
710
721
|
project: { type: "string", description: "azure_devops wiql: the project @project refers to (default: the configured project)" },
|
|
711
722
|
variables: { type: "object", description: "xray: GraphQL variables" },
|