@plune-ai/cli 0.33.0 → 0.34.1
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/README.md +28 -1
- package/dist/cli.cjs +1191 -131
- package/dist/index.cjs +2 -1
- package/dist/index.js +2 -1
- package/dist/mcp-tools.cjs +932 -0
- package/dist/mcp-tools.d.cts +894 -0
- package/dist/mcp-tools.d.ts +894 -0
- package/dist/mcp-tools.js +895 -0
- package/package.json +8 -2
|
@@ -0,0 +1,932 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/mcp-tools/index.ts
|
|
21
|
+
var mcp_tools_exports = {};
|
|
22
|
+
__export(mcp_tools_exports, {
|
|
23
|
+
BATCH_MAX: () => BATCH_MAX,
|
|
24
|
+
CONFIRM_TTL_MS: () => CONFIRM_TTL_MS,
|
|
25
|
+
MAX_VISIBLE_TOOLS: () => MAX_VISIBLE_TOOLS,
|
|
26
|
+
PLUNE_TOOLS: () => PLUNE_TOOLS,
|
|
27
|
+
PLUNE_TOOL_NAMES: () => PLUNE_TOOL_NAMES,
|
|
28
|
+
PluneApiError: () => PluneApiError,
|
|
29
|
+
PluneNetworkError: () => PluneNetworkError,
|
|
30
|
+
createPluneToolExecutor: () => createPluneToolExecutor,
|
|
31
|
+
defaultVisibleTools: () => defaultVisibleTools,
|
|
32
|
+
isPluneToolName: () => isPluneToolName,
|
|
33
|
+
pluneTool: () => pluneTool
|
|
34
|
+
});
|
|
35
|
+
module.exports = __toCommonJS(mcp_tools_exports);
|
|
36
|
+
|
|
37
|
+
// src/mcp-tools/contract.ts
|
|
38
|
+
var import_zod2 = require("zod");
|
|
39
|
+
|
|
40
|
+
// src/mcp-tools/schemas.ts
|
|
41
|
+
var import_zod = require("zod");
|
|
42
|
+
var BATCH_MAX = 100;
|
|
43
|
+
var isHttp = (s) => {
|
|
44
|
+
try {
|
|
45
|
+
const u = new URL(s);
|
|
46
|
+
return u.protocol === "http:" || u.protocol === "https:";
|
|
47
|
+
} catch {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
var httpLink = (what, max) => import_zod.z.string().trim().min(1, `${what} is empty`).max(max).refine(isHttp, `${what} must be an http or https address`);
|
|
52
|
+
var id = (what) => import_zod.z.string().min(1).describe(what);
|
|
53
|
+
var dryRun = import_zod.z.boolean().optional().describe("true: answer what would be sent, and send nothing");
|
|
54
|
+
var MISSION_STATES = ["draft", "active", "done"];
|
|
55
|
+
var MISSION_DOCUMENTATION = ["unknown", "provided", "none"];
|
|
56
|
+
var MISSION_LINK_KINDS = ["url", "openapi", "repo", "ticket"];
|
|
57
|
+
var missionName = import_zod.z.string().trim().min(1, "a mission needs a name").max(200);
|
|
58
|
+
var missionSummary = import_zod.z.string().trim().max(2e3).describe("What the product is, in 3-5 sentences");
|
|
59
|
+
var missionUrl = httpLink("url", 2048).nullable().describe("Where the product runs; null takes it off");
|
|
60
|
+
var missionFields = {
|
|
61
|
+
name: missionName,
|
|
62
|
+
summary: missionSummary.optional(),
|
|
63
|
+
url: missionUrl.optional(),
|
|
64
|
+
state: import_zod.z.enum(MISSION_STATES).optional(),
|
|
65
|
+
documentation: import_zod.z.enum(MISSION_DOCUMENTATION).optional().describe("Is there documentation: provided (it is among the sources), none, or unknown")
|
|
66
|
+
};
|
|
67
|
+
var sourceTitle = import_zod.z.string().trim().min(1).max(200);
|
|
68
|
+
var sourceVersion = import_zod.z.string().trim().min(1).max(200).describe("A commit, a tag, a revision, an ETag");
|
|
69
|
+
var linkSource = import_zod.z.strictObject({
|
|
70
|
+
missionId: id("The mission"),
|
|
71
|
+
kind: import_zod.z.enum(MISSION_LINK_KINDS),
|
|
72
|
+
ref: httpLink("ref", 2048).describe("The link"),
|
|
73
|
+
title: sourceTitle.optional(),
|
|
74
|
+
version: sourceVersion.optional(),
|
|
75
|
+
dry_run: dryRun
|
|
76
|
+
});
|
|
77
|
+
var fileSource = import_zod.z.strictObject({
|
|
78
|
+
missionId: id("The mission"),
|
|
79
|
+
kind: import_zod.z.literal("file"),
|
|
80
|
+
name: import_zod.z.string().trim().min(1).max(200).regex(/\.(txt|md|markdown|json)$/i, "a source file is .txt, .md, .markdown or .json").describe("The file name; its extension sets the type"),
|
|
81
|
+
text: import_zod.z.string().max(1048576).describe("The whole text of the file, UTF-8, at most 1 MiB"),
|
|
82
|
+
title: sourceTitle.optional(),
|
|
83
|
+
version: sourceVersion.optional(),
|
|
84
|
+
dry_run: dryRun
|
|
85
|
+
});
|
|
86
|
+
var ACCESS_KINDS = ["openapi", "repo", "staging", "har"];
|
|
87
|
+
var questionText = import_zod.z.string().trim().min(1).max(2e3);
|
|
88
|
+
var questionInput = import_zod.z.strictObject({
|
|
89
|
+
template: import_zod.z.enum(["plain", "access"]).describe("plain: a question with up to three options; access: ask for an API description, the code, a staging host or a HAR file"),
|
|
90
|
+
text: questionText,
|
|
91
|
+
missionId: import_zod.z.string().min(1).optional(),
|
|
92
|
+
options: import_zod.z.array(import_zod.z.string().trim().min(1).max(200)).min(1).max(3).refine((o) => new Set(o).size === o.length, { message: "no two options are the same" }).optional().describe("plain only: what the person may pick from"),
|
|
93
|
+
access: import_zod.z.array(import_zod.z.enum(ACCESS_KINDS)).min(1).max(ACCESS_KINDS.length).refine((a) => new Set(a).size === a.length, { message: "an access kind is named once" }).optional().describe("access only: what you ask to be given"),
|
|
94
|
+
dry_run: dryRun
|
|
95
|
+
});
|
|
96
|
+
var STRATEGY_SOURCE_KINDS = ["mission-source", "doc-section", "screen", "endpoint", "requirement", "url"];
|
|
97
|
+
var TEST_LEVELS = ["component", "component-integration", "system", "system-integration", "acceptance"];
|
|
98
|
+
var QUALITY_CHARACTERISTICS = [
|
|
99
|
+
"functional-suitability",
|
|
100
|
+
"performance-efficiency",
|
|
101
|
+
"compatibility",
|
|
102
|
+
"interaction-capability",
|
|
103
|
+
"reliability",
|
|
104
|
+
"security",
|
|
105
|
+
"maintainability",
|
|
106
|
+
"flexibility",
|
|
107
|
+
"safety"
|
|
108
|
+
];
|
|
109
|
+
var TEST_TECHNIQUES = [
|
|
110
|
+
"equivalence-partitioning",
|
|
111
|
+
"boundary-value",
|
|
112
|
+
"decision-table",
|
|
113
|
+
"state-transition",
|
|
114
|
+
"statement",
|
|
115
|
+
"branch",
|
|
116
|
+
"error-guessing",
|
|
117
|
+
"exploratory",
|
|
118
|
+
"checklist"
|
|
119
|
+
];
|
|
120
|
+
var RISK_SCALE = ["low", "medium", "high"];
|
|
121
|
+
var itemFields = {
|
|
122
|
+
text: import_zod.z.string().trim().min(1).max(1e3).describe("Markdown"),
|
|
123
|
+
source: import_zod.z.strictObject({ kind: import_zod.z.enum(STRATEGY_SOURCE_KINDS), ref: import_zod.z.string().trim().min(1).max(300) }).optional().describe("Where it was read"),
|
|
124
|
+
assumption: import_zod.z.literal(true).optional().describe("You assumed it: a person confirms"),
|
|
125
|
+
waitingOn: import_zod.z.string().min(1).optional().describe("The id of an open question of this mission it waits for")
|
|
126
|
+
};
|
|
127
|
+
var strategyItem = import_zod.z.strictObject(itemFields);
|
|
128
|
+
var items = import_zod.z.array(strategyItem).min(1).max(25);
|
|
129
|
+
var unique = (values) => import_zod.z.array(import_zod.z.enum(values)).min(1).refine((a) => new Set(a).size === a.length, { message: "named once" });
|
|
130
|
+
var linkIds = import_zod.z.array(import_zod.z.string().min(1)).max(50).refine((a) => new Set(a).size === a.length, { message: "an id is named once" });
|
|
131
|
+
var share = import_zod.z.number().int().min(0).max(100);
|
|
132
|
+
var strategyContent = import_zod.z.strictObject({
|
|
133
|
+
objectives: items,
|
|
134
|
+
scope: import_zod.z.strictObject({ in: items, out: items.describe("What is deliberately not tested, and why") }),
|
|
135
|
+
approach: items,
|
|
136
|
+
profile: import_zod.z.strictObject({
|
|
137
|
+
levels: unique(TEST_LEVELS),
|
|
138
|
+
characteristics: unique(QUALITY_CHARACTERISTICS),
|
|
139
|
+
techniques: unique(TEST_TECHNIQUES),
|
|
140
|
+
split: import_zod.z.strictObject({ api: share, ui: share, manual: share }).refine((s) => s.api + s.ui + s.manual === 100, { message: "the split of api, ui and manual adds up to 100" }),
|
|
141
|
+
notes: items
|
|
142
|
+
}),
|
|
143
|
+
risks: import_zod.z.array(
|
|
144
|
+
import_zod.z.strictObject({
|
|
145
|
+
...itemFields,
|
|
146
|
+
likelihood: import_zod.z.enum(RISK_SCALE),
|
|
147
|
+
impact: import_zod.z.enum(RISK_SCALE),
|
|
148
|
+
caseIds: linkIds.optional(),
|
|
149
|
+
requirementIds: linkIds.optional()
|
|
150
|
+
})
|
|
151
|
+
).min(1).max(50),
|
|
152
|
+
entryCriteria: items,
|
|
153
|
+
exitCriteria: items
|
|
154
|
+
});
|
|
155
|
+
var SOURCE_REF_KINDS = ["url", "openapi-operation", "requirement", "pasted-text"];
|
|
156
|
+
var proposedCase = import_zod.z.strictObject({
|
|
157
|
+
title: import_zod.z.string().trim().min(1).max(300),
|
|
158
|
+
steps: import_zod.z.array(import_zod.z.string().trim().min(1).max(400)).min(1).max(20),
|
|
159
|
+
expected: import_zod.z.string().trim().min(1).max(2e3),
|
|
160
|
+
execution: import_zod.z.enum(["auto", "manual"]).optional().describe("auto: can be automated; manual (default): stays with a person"),
|
|
161
|
+
technique: import_zod.z.string().min(1).max(32).optional(),
|
|
162
|
+
priority: import_zod.z.string().min(1).optional(),
|
|
163
|
+
source: import_zod.z.strictObject({ kind: import_zod.z.enum(SOURCE_REF_KINDS), value: import_zod.z.string().min(1).max(100) }).optional().describe("What you read to design it"),
|
|
164
|
+
rationale: import_zod.z.string().min(1).max(400).optional().describe("Why the case is worth having")
|
|
165
|
+
});
|
|
166
|
+
var API_METHODS = ["GET", "PUT", "POST", "DELETE", "OPTIONS", "HEAD", "PATCH", "TRACE"];
|
|
167
|
+
var proposedEndpoint = import_zod.z.strictObject({
|
|
168
|
+
method: import_zod.z.enum(API_METHODS),
|
|
169
|
+
path: import_zod.z.string().min(1).max(512).regex(/^\/[^?#\s]*$/, "a path template starts with / and has no query").describe("A template: /users/{id}, never /users/42"),
|
|
170
|
+
source: import_zod.z.enum(["spec", "code", "traffic"]),
|
|
171
|
+
trust: import_zod.z.enum(["confirmed", "derived"]).optional(),
|
|
172
|
+
summary: import_zod.z.string().trim().min(1).max(500).optional(),
|
|
173
|
+
auth: import_zod.z.array(import_zod.z.string().trim().min(1).max(100)).max(10).optional(),
|
|
174
|
+
environment: import_zod.z.string().trim().min(1).max(100).optional(),
|
|
175
|
+
seenAt: import_zod.z.string().optional().describe("ISO 8601 date and time with an offset")
|
|
176
|
+
});
|
|
177
|
+
var REVIEW_STATES = ["pending", "changes_requested", "approved", "rejected"];
|
|
178
|
+
var REVIEW_KINDS = ["result", "proposal", "discovered", "endpoint", "strategy", "report", "memory_rule"];
|
|
179
|
+
var REJECTION_CATEGORIES = ["duplicate", "incorrect", "out-of-scope", "low-value", "not-testable", "other"];
|
|
180
|
+
var entity = import_zod.z.looseObject({ id: import_zod.z.string().optional() });
|
|
181
|
+
var plannedRequest = import_zod.z.object({
|
|
182
|
+
method: import_zod.z.string(),
|
|
183
|
+
path: import_zod.z.string(),
|
|
184
|
+
query: import_zod.z.record(import_zod.z.string(), import_zod.z.string()).optional(),
|
|
185
|
+
body: import_zod.z.unknown().optional()
|
|
186
|
+
});
|
|
187
|
+
var writeOutput = import_zod.z.object({
|
|
188
|
+
dryRun: import_zod.z.boolean(),
|
|
189
|
+
request: plannedRequest.optional(),
|
|
190
|
+
result: entity.optional()
|
|
191
|
+
});
|
|
192
|
+
var batchOutput = import_zod.z.object({
|
|
193
|
+
dryRun: import_zod.z.boolean(),
|
|
194
|
+
requested: import_zod.z.number().int(),
|
|
195
|
+
applied: import_zod.z.number().int(),
|
|
196
|
+
requests: import_zod.z.array(plannedRequest).optional(),
|
|
197
|
+
results: import_zod.z.array(
|
|
198
|
+
import_zod.z.object({
|
|
199
|
+
ok: import_zod.z.boolean(),
|
|
200
|
+
status: import_zod.z.number().int().optional(),
|
|
201
|
+
id: import_zod.z.string().optional(),
|
|
202
|
+
error: import_zod.z.string().optional(),
|
|
203
|
+
code: import_zod.z.string().optional()
|
|
204
|
+
})
|
|
205
|
+
).optional(),
|
|
206
|
+
report: import_zod.z.looseObject({}).optional()
|
|
207
|
+
});
|
|
208
|
+
var bulkPreview = import_zod.z.looseObject({
|
|
209
|
+
total: import_zod.z.number().int(),
|
|
210
|
+
confirm: import_zod.z.string(),
|
|
211
|
+
expiresInSeconds: import_zod.z.number().int()
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
// src/mcp-tools/contract.ts
|
|
215
|
+
var MAX_VISIBLE_TOOLS = 20;
|
|
216
|
+
var define = (tool) => tool;
|
|
217
|
+
var listPage = {
|
|
218
|
+
limit: import_zod2.z.number().int().min(1).max(BATCH_MAX).optional(),
|
|
219
|
+
cursor: import_zod2.z.string().min(1).optional().describe("nextCursor of the previous page")
|
|
220
|
+
};
|
|
221
|
+
var missionId = id("The mission id");
|
|
222
|
+
var PLUNE_TOOLS = [
|
|
223
|
+
define({
|
|
224
|
+
name: "plune_project_context",
|
|
225
|
+
title: "Project context",
|
|
226
|
+
description: "Start here: learn the project you are working in with one call \u2014 its projects, the suite tree, how many test cases it has, its testing missions, and how much waits in the review queue.",
|
|
227
|
+
input: import_zod2.z.strictObject({}),
|
|
228
|
+
output: import_zod2.z.object({
|
|
229
|
+
projectId: import_zod2.z.string().nullable(),
|
|
230
|
+
projects: import_zod2.z.array(entity),
|
|
231
|
+
suites: import_zod2.z.array(entity),
|
|
232
|
+
cases: import_zod2.z.object({ total: import_zod2.z.number().int() }),
|
|
233
|
+
missions: import_zod2.z.array(entity),
|
|
234
|
+
reviewQueue: import_zod2.z.object({ pending: import_zod2.z.number().int(), more: import_zod2.z.boolean() })
|
|
235
|
+
}),
|
|
236
|
+
write: false,
|
|
237
|
+
visibility: "core",
|
|
238
|
+
endpoints: ["GET /v1/projects", "GET /v1/suites", "GET /v1/test-cases", "GET /v1/missions", "GET /v1/review-items"]
|
|
239
|
+
}),
|
|
240
|
+
define({
|
|
241
|
+
name: "plune_get",
|
|
242
|
+
title: "Read one object",
|
|
243
|
+
description: "Read one test case, run or review queue entry in full when you already hold its id \u2014 steps, state, history fields and everything the platform keeps on it.",
|
|
244
|
+
input: import_zod2.z.strictObject({ kind: import_zod2.z.enum(["test-case", "run", "review-item"]), id: id("Its id") }),
|
|
245
|
+
output: import_zod2.z.object({ kind: import_zod2.z.string(), item: entity }),
|
|
246
|
+
write: false,
|
|
247
|
+
visibility: "core",
|
|
248
|
+
endpoints: ["GET /v1/test-cases/:id", "GET /v1/runs/:id", "GET /v1/review-items/:id"]
|
|
249
|
+
}),
|
|
250
|
+
define({
|
|
251
|
+
name: "plune_cases_search",
|
|
252
|
+
title: "Find test cases",
|
|
253
|
+
description: "Find test cases of the project by filter \u2014 suite, state, tag, priority, text and the other filters of the case list \u2014 one page at a time, with the total and how many match.",
|
|
254
|
+
input: import_zod2.z.strictObject({
|
|
255
|
+
filters: import_zod2.z.record(import_zod2.z.string(), import_zod2.z.union([import_zod2.z.string(), import_zod2.z.array(import_zod2.z.string())])).optional().describe('Query filters of GET /v1/test-cases: a list is OR inside one key, keys are AND-ed (e.g. {"suiteId": "s1", "tag": ["smoke"]})'),
|
|
256
|
+
...listPage
|
|
257
|
+
}),
|
|
258
|
+
output: import_zod2.z.object({
|
|
259
|
+
testCases: import_zod2.z.array(entity),
|
|
260
|
+
total: import_zod2.z.number().int().optional(),
|
|
261
|
+
matching: import_zod2.z.number().int().optional(),
|
|
262
|
+
nextCursor: import_zod2.z.string().nullable()
|
|
263
|
+
}),
|
|
264
|
+
write: false,
|
|
265
|
+
visibility: "core",
|
|
266
|
+
endpoints: ["GET /v1/test-cases?filters"]
|
|
267
|
+
}),
|
|
268
|
+
define({
|
|
269
|
+
name: "plune_review_list",
|
|
270
|
+
title: "Read the review queue",
|
|
271
|
+
description: "See what waits for a decision in the review queue \u2014 proposed cases, discovered tests, endpoints, strategies, reports \u2014 narrowed by state and kind, newest first, one page at a time.",
|
|
272
|
+
input: import_zod2.z.strictObject({
|
|
273
|
+
state: import_zod2.z.enum(REVIEW_STATES).optional(),
|
|
274
|
+
kinds: import_zod2.z.array(import_zod2.z.enum(REVIEW_KINDS)).min(1).optional().describe("Questions are read with the question tool"),
|
|
275
|
+
...listPage
|
|
276
|
+
}),
|
|
277
|
+
output: import_zod2.z.object({ items: import_zod2.z.array(entity), nextCursor: import_zod2.z.string().nullable() }),
|
|
278
|
+
write: false,
|
|
279
|
+
visibility: "core",
|
|
280
|
+
endpoints: ["GET /v1/review-items"]
|
|
281
|
+
}),
|
|
282
|
+
define({
|
|
283
|
+
name: "plune_review_decide",
|
|
284
|
+
title: "Decide queue entries",
|
|
285
|
+
description: "Approve or reject up to 100 review queue entries in one decision. The first call changes nothing and returns a preview with a confirm value; repeat the same call with confirm to apply it.",
|
|
286
|
+
input: import_zod2.z.strictObject({
|
|
287
|
+
itemIds: import_zod2.z.array(import_zod2.z.string().min(1)).min(1).max(BATCH_MAX).refine((a) => new Set(a).size === a.length, { message: "an id is named once" }),
|
|
288
|
+
verdict: import_zod2.z.enum(["approved", "rejected"]),
|
|
289
|
+
rejectionCategory: import_zod2.z.enum(REJECTION_CATEGORIES).optional().describe("Required to reject"),
|
|
290
|
+
note: import_zod2.z.string().trim().min(1).max(2e3).optional().describe("Why \u2014 required to reject"),
|
|
291
|
+
confirm: import_zod2.z.string().min(1).optional().describe("The confirm value of the preview")
|
|
292
|
+
}),
|
|
293
|
+
output: import_zod2.z.object({
|
|
294
|
+
requested: import_zod2.z.number().int(),
|
|
295
|
+
applied: import_zod2.z.number().int(),
|
|
296
|
+
preview: bulkPreview.optional(),
|
|
297
|
+
report: import_zod2.z.looseObject({}).optional()
|
|
298
|
+
}),
|
|
299
|
+
write: true,
|
|
300
|
+
visibility: "core",
|
|
301
|
+
endpoints: ["POST /v1/review-items/decisions"]
|
|
302
|
+
}),
|
|
303
|
+
define({
|
|
304
|
+
name: "plune_case_propose",
|
|
305
|
+
title: "Propose test cases",
|
|
306
|
+
description: "Propose up to 100 new test cases for a person to review: they land in the review queue as proposals and become cases only when someone approves them. Nothing is created silently.",
|
|
307
|
+
input: import_zod2.z.strictObject({
|
|
308
|
+
target: import_zod2.z.string().trim().min(1).max(2048).describe("What the cases test: the product address, or the mission name"),
|
|
309
|
+
cases: import_zod2.z.array(proposedCase).min(1).max(BATCH_MAX),
|
|
310
|
+
dry_run: dryRun
|
|
311
|
+
}),
|
|
312
|
+
output: batchOutput,
|
|
313
|
+
write: true,
|
|
314
|
+
visibility: "core",
|
|
315
|
+
endpoints: ["POST /v1/ingest/cairn"]
|
|
316
|
+
}),
|
|
317
|
+
define({
|
|
318
|
+
name: "plune_mission_create",
|
|
319
|
+
title: "Create a mission",
|
|
320
|
+
description: "Create a testing mission for a product: its name, a short description of what the product is, where it runs, and whether documentation exists. It starts as a draft you fill in.",
|
|
321
|
+
input: import_zod2.z.strictObject({ ...missionFields, dry_run: dryRun }),
|
|
322
|
+
output: writeOutput,
|
|
323
|
+
write: true,
|
|
324
|
+
visibility: "core",
|
|
325
|
+
endpoints: ["POST /v1/missions"]
|
|
326
|
+
}),
|
|
327
|
+
define({
|
|
328
|
+
name: "plune_mission_get",
|
|
329
|
+
title: "Read a mission",
|
|
330
|
+
description: "Read a testing mission with everything it holds \u2014 its brief, state, documentation answer and every source attached to it \u2014 before planning or continuing the work on it.",
|
|
331
|
+
input: import_zod2.z.strictObject({ missionId }),
|
|
332
|
+
output: import_zod2.z.object({ mission: entity }),
|
|
333
|
+
write: false,
|
|
334
|
+
visibility: "core",
|
|
335
|
+
endpoints: ["GET /v1/missions/:id"]
|
|
336
|
+
}),
|
|
337
|
+
define({
|
|
338
|
+
name: "plune_mission_update",
|
|
339
|
+
title: "Update a mission",
|
|
340
|
+
description: "Change a testing mission: rename it, rewrite the brief, set or remove the address, answer whether documentation exists, or move it between draft, active and done.",
|
|
341
|
+
input: import_zod2.z.strictObject({
|
|
342
|
+
missionId,
|
|
343
|
+
name: missionFields.name.optional(),
|
|
344
|
+
summary: missionFields.summary,
|
|
345
|
+
url: missionFields.url,
|
|
346
|
+
state: missionFields.state,
|
|
347
|
+
documentation: missionFields.documentation,
|
|
348
|
+
dry_run: dryRun
|
|
349
|
+
}).refine((b) => ["name", "summary", "url", "state", "documentation"].some((k) => b[k] !== void 0), {
|
|
350
|
+
message: "nothing to change"
|
|
351
|
+
}),
|
|
352
|
+
output: writeOutput,
|
|
353
|
+
write: true,
|
|
354
|
+
visibility: "core",
|
|
355
|
+
endpoints: ["PATCH /v1/missions/:id"]
|
|
356
|
+
}),
|
|
357
|
+
define({
|
|
358
|
+
name: "plune_source_add",
|
|
359
|
+
title: "Add a source to a mission",
|
|
360
|
+
description: "Attach something to read to a mission: a link (page, OpenAPI document, repository, ticket) or the text of a file of the project \u2014 Markdown, plain text or JSON, up to 1 MiB.",
|
|
361
|
+
input: import_zod2.z.strictObject({
|
|
362
|
+
missionId,
|
|
363
|
+
kind: import_zod2.z.enum([...MISSION_LINK_KINDS, "file"]),
|
|
364
|
+
ref: import_zod2.z.string().optional().describe("The link, for url, openapi, repo and ticket"),
|
|
365
|
+
name: import_zod2.z.string().optional().describe("The file name, for file (.md, .markdown, .txt, .json)"),
|
|
366
|
+
text: import_zod2.z.string().optional().describe("The file text, for file"),
|
|
367
|
+
title: import_zod2.z.string().optional(),
|
|
368
|
+
version: import_zod2.z.string().optional(),
|
|
369
|
+
dry_run: dryRun
|
|
370
|
+
}).superRefine((b, ctx) => {
|
|
371
|
+
if (b.kind === "file" && (b.name === void 0 || b.text === void 0))
|
|
372
|
+
ctx.addIssue({ code: "custom", message: "a file source carries name and text" });
|
|
373
|
+
if (b.kind !== "file" && b.ref === void 0) ctx.addIssue({ code: "custom", message: "a link source carries ref" });
|
|
374
|
+
}),
|
|
375
|
+
output: writeOutput,
|
|
376
|
+
write: true,
|
|
377
|
+
visibility: "core",
|
|
378
|
+
endpoints: ["POST /v1/missions/:id/sources", "POST /v1/missions/:id/files"]
|
|
379
|
+
}),
|
|
380
|
+
define({
|
|
381
|
+
name: "plune_question_ask",
|
|
382
|
+
title: "Ask a person",
|
|
383
|
+
description: "Ask a person what you do not know instead of guessing: a plain question with up to three options, or a request for access to an API description, the code, staging or a HAR file.",
|
|
384
|
+
input: questionInput.superRefine((b, ctx) => {
|
|
385
|
+
if (b.template === "plain" && b.access !== void 0) ctx.addIssue({ code: "custom", message: "access belongs to an access question" });
|
|
386
|
+
if (b.template === "access" && b.options !== void 0) ctx.addIssue({ code: "custom", message: "options belong to a plain question" });
|
|
387
|
+
if (b.template === "access" && b.access === void 0) ctx.addIssue({ code: "custom", message: "an access question names the access" });
|
|
388
|
+
}),
|
|
389
|
+
output: writeOutput,
|
|
390
|
+
write: true,
|
|
391
|
+
visibility: "core",
|
|
392
|
+
endpoints: ["POST /v1/review-items/questions"]
|
|
393
|
+
}),
|
|
394
|
+
define({
|
|
395
|
+
name: "plune_question_list",
|
|
396
|
+
title: "Read questions and answers",
|
|
397
|
+
description: "Check the questions asked to people and the answers they gave \u2014 all of them or one mission's, waiting or answered \u2014 so the work continues from what a person actually said.",
|
|
398
|
+
input: import_zod2.z.strictObject({ missionId: import_zod2.z.string().min(1).optional(), state: import_zod2.z.enum(REVIEW_STATES).optional(), ...listPage }),
|
|
399
|
+
output: import_zod2.z.object({ questions: import_zod2.z.array(entity), nextCursor: import_zod2.z.string().nullable() }),
|
|
400
|
+
write: false,
|
|
401
|
+
visibility: "core",
|
|
402
|
+
endpoints: ["GET /v1/review-items?kind=question"]
|
|
403
|
+
}),
|
|
404
|
+
define({
|
|
405
|
+
name: "plune_strategy_propose",
|
|
406
|
+
title: "Propose a test strategy",
|
|
407
|
+
description: "Propose a test strategy for a mission in ISTQB sections \u2014 objectives, scope in and out, approach, levels and techniques, risks, entry and exit criteria \u2014 for a person to accept.",
|
|
408
|
+
input: import_zod2.z.strictObject({ missionId, content: strategyContent, dry_run: dryRun }),
|
|
409
|
+
output: writeOutput,
|
|
410
|
+
write: true,
|
|
411
|
+
visibility: "core",
|
|
412
|
+
endpoints: ["POST /v1/missions/:id/strategy"]
|
|
413
|
+
}),
|
|
414
|
+
define({
|
|
415
|
+
name: "plune_report_request",
|
|
416
|
+
title: "Request the mission report",
|
|
417
|
+
description: "Ask the platform to build the completion report of a mission from its own data and queue it for a person to approve; it needs an accepted strategy first.",
|
|
418
|
+
input: import_zod2.z.strictObject({ missionId, dry_run: dryRun }),
|
|
419
|
+
output: writeOutput,
|
|
420
|
+
write: true,
|
|
421
|
+
visibility: "core",
|
|
422
|
+
endpoints: ["POST /v1/missions/:id/report"]
|
|
423
|
+
}),
|
|
424
|
+
define({
|
|
425
|
+
name: "plune_discover_tools",
|
|
426
|
+
title: "Find more tools",
|
|
427
|
+
description: "Look for a capability that is not in your tool list \u2014 editing cases, writing results, starting runs, cataloguing API endpoints, a raw API call \u2014 and switch the ones you need on for this session.",
|
|
428
|
+
input: import_zod2.z.strictObject({
|
|
429
|
+
query: import_zod2.z.string().trim().min(1).max(200).optional().describe('Words of the task, e.g. "write results"'),
|
|
430
|
+
names: import_zod2.z.array(import_zod2.z.string().min(1)).min(1).max(MAX_VISIBLE_TOOLS).optional().describe("Tools to switch on")
|
|
431
|
+
}),
|
|
432
|
+
output: import_zod2.z.object({
|
|
433
|
+
revealed: import_zod2.z.array(import_zod2.z.string()),
|
|
434
|
+
available: import_zod2.z.array(import_zod2.z.object({ name: import_zod2.z.string(), title: import_zod2.z.string(), description: import_zod2.z.string(), write: import_zod2.z.boolean() })),
|
|
435
|
+
visible: import_zod2.z.array(import_zod2.z.string())
|
|
436
|
+
}),
|
|
437
|
+
write: false,
|
|
438
|
+
visibility: "core",
|
|
439
|
+
endpoints: ["LOCAL /discover"]
|
|
440
|
+
}),
|
|
441
|
+
define({
|
|
442
|
+
name: "plune_cases_update",
|
|
443
|
+
title: "Edit test cases",
|
|
444
|
+
description: "Edit up to 100 existing test cases in one call \u2014 title, steps, expected result, priority, tags, suite \u2014 each applied on its own, with a count of how many were applied.",
|
|
445
|
+
input: import_zod2.z.strictObject({
|
|
446
|
+
cases: import_zod2.z.array(import_zod2.z.strictObject({ id: id("The case id"), changes: import_zod2.z.record(import_zod2.z.string(), import_zod2.z.unknown()).describe("Fields of PATCH /v1/test-cases/:id") })).min(1).max(BATCH_MAX),
|
|
447
|
+
dry_run: dryRun
|
|
448
|
+
}),
|
|
449
|
+
output: batchOutput,
|
|
450
|
+
write: true,
|
|
451
|
+
visibility: "discoverable",
|
|
452
|
+
endpoints: ["PATCH /v1/test-cases/:id"]
|
|
453
|
+
}),
|
|
454
|
+
define({
|
|
455
|
+
name: "plune_run_create",
|
|
456
|
+
title: "Start a run",
|
|
457
|
+
description: "Start a test run on the platform to record results into \u2014 named, with its environment and the cases it covers \u2014 using the body of POST /v1/runs (schemaVersion 2).",
|
|
458
|
+
input: import_zod2.z.strictObject({ run: import_zod2.z.record(import_zod2.z.string(), import_zod2.z.unknown()).describe("The body of POST /v1/runs"), dry_run: dryRun }),
|
|
459
|
+
output: writeOutput,
|
|
460
|
+
write: true,
|
|
461
|
+
visibility: "discoverable",
|
|
462
|
+
endpoints: ["POST /v1/runs"]
|
|
463
|
+
}),
|
|
464
|
+
define({
|
|
465
|
+
name: "plune_results_write",
|
|
466
|
+
title: "Write results into a run",
|
|
467
|
+
description: "Record up to 100 test results into an open run in one request \u2014 passed, failed, skipped, with their keys and evidence \u2014 and learn how many the platform accepted.",
|
|
468
|
+
input: import_zod2.z.strictObject({
|
|
469
|
+
runId: id("The run id"),
|
|
470
|
+
results: import_zod2.z.array(import_zod2.z.record(import_zod2.z.string(), import_zod2.z.unknown())).min(1).max(BATCH_MAX).describe("Results as POST /v1/runs/:id/results takes them"),
|
|
471
|
+
combination: import_zod2.z.number().int().min(0).optional(),
|
|
472
|
+
configuration: import_zod2.z.record(import_zod2.z.string(), import_zod2.z.string()).optional(),
|
|
473
|
+
dry_run: dryRun
|
|
474
|
+
}),
|
|
475
|
+
output: batchOutput,
|
|
476
|
+
write: true,
|
|
477
|
+
visibility: "discoverable",
|
|
478
|
+
endpoints: ["POST /v1/runs/:id/results"]
|
|
479
|
+
}),
|
|
480
|
+
define({
|
|
481
|
+
name: "plune_endpoint_propose",
|
|
482
|
+
title: "Propose API endpoints",
|
|
483
|
+
description: "Propose up to 100 API endpoints you found in a spec, the code or traffic for the project's API catalog; each waits in the review queue until a person approves it.",
|
|
484
|
+
input: import_zod2.z.strictObject({ endpoints: import_zod2.z.array(proposedEndpoint).min(1).max(BATCH_MAX), dry_run: dryRun }),
|
|
485
|
+
output: batchOutput,
|
|
486
|
+
write: true,
|
|
487
|
+
visibility: "discoverable",
|
|
488
|
+
endpoints: ["POST /v1/review-items/endpoints"]
|
|
489
|
+
}),
|
|
490
|
+
define({
|
|
491
|
+
name: "plune_api_request",
|
|
492
|
+
title: "Call the REST API directly",
|
|
493
|
+
description: "Reach any Plune REST endpoint under /v1 that no other tool covers. Every guard still holds: a deletion is previewed first and needs confirm, and the platform refuses an agent what it refuses over HTTP.",
|
|
494
|
+
input: import_zod2.z.strictObject({
|
|
495
|
+
method: import_zod2.z.enum(["GET", "POST", "PUT", "PATCH", "DELETE"]),
|
|
496
|
+
path: import_zod2.z.string().min(1).max(2048).describe("A path under /v1, e.g. /v1/suites"),
|
|
497
|
+
query: import_zod2.z.record(import_zod2.z.string(), import_zod2.z.string()).optional(),
|
|
498
|
+
body: import_zod2.z.unknown().optional(),
|
|
499
|
+
confirm: import_zod2.z.string().min(1).optional().describe("The confirm value of a preview"),
|
|
500
|
+
dry_run: dryRun
|
|
501
|
+
}),
|
|
502
|
+
output: import_zod2.z.object({
|
|
503
|
+
status: import_zod2.z.number().int().optional(),
|
|
504
|
+
body: import_zod2.z.unknown().optional(),
|
|
505
|
+
dryRun: import_zod2.z.boolean().optional(),
|
|
506
|
+
request: plannedRequest.optional(),
|
|
507
|
+
preview: import_zod2.z.looseObject({ method: import_zod2.z.string(), path: import_zod2.z.string(), confirm: import_zod2.z.string(), expiresInSeconds: import_zod2.z.number().int() }).optional()
|
|
508
|
+
}),
|
|
509
|
+
write: true,
|
|
510
|
+
visibility: "discoverable",
|
|
511
|
+
endpoints: ["ANY /v1/*"]
|
|
512
|
+
})
|
|
513
|
+
];
|
|
514
|
+
var PLUNE_TOOL_NAMES = PLUNE_TOOLS.map((t) => t.name);
|
|
515
|
+
var BY_NAME = new Map(PLUNE_TOOLS.map((t) => [t.name, t]));
|
|
516
|
+
function pluneTool(name) {
|
|
517
|
+
const tool = BY_NAME.get(name);
|
|
518
|
+
if (tool === void 0) throw new Error(`unknown tool: ${name}`);
|
|
519
|
+
return tool;
|
|
520
|
+
}
|
|
521
|
+
function isPluneToolName(name) {
|
|
522
|
+
return BY_NAME.has(name);
|
|
523
|
+
}
|
|
524
|
+
function defaultVisibleTools(scope) {
|
|
525
|
+
return PLUNE_TOOLS.filter((t) => t.visibility === "core" && !(scope === "read" && t.write)).map((t) => t.name);
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// src/mcp-tools/execute.ts
|
|
529
|
+
var import_node_crypto = require("crypto");
|
|
530
|
+
|
|
531
|
+
// src/mcp-tools/client.ts
|
|
532
|
+
var PluneApiError = class extends Error {
|
|
533
|
+
constructor(status, body) {
|
|
534
|
+
const error = bodyField(body, "error");
|
|
535
|
+
super(error ?? `HTTP ${status}`);
|
|
536
|
+
this.status = status;
|
|
537
|
+
this.body = body;
|
|
538
|
+
this.name = "PluneApiError";
|
|
539
|
+
}
|
|
540
|
+
status;
|
|
541
|
+
body;
|
|
542
|
+
get code() {
|
|
543
|
+
return bodyField(this.body, "code");
|
|
544
|
+
}
|
|
545
|
+
};
|
|
546
|
+
var PluneNetworkError = class extends Error {
|
|
547
|
+
constructor(apiUrl) {
|
|
548
|
+
super(`could not reach Plune at ${apiUrl}`);
|
|
549
|
+
this.apiUrl = apiUrl;
|
|
550
|
+
this.name = "PluneNetworkError";
|
|
551
|
+
}
|
|
552
|
+
apiUrl;
|
|
553
|
+
};
|
|
554
|
+
function bodyField(body, key) {
|
|
555
|
+
if (body === null || typeof body !== "object") return void 0;
|
|
556
|
+
const value = body[key];
|
|
557
|
+
return typeof value === "string" ? value : void 0;
|
|
558
|
+
}
|
|
559
|
+
function apiBase(apiUrl) {
|
|
560
|
+
return apiUrl.replace(/\/+$/, "");
|
|
561
|
+
}
|
|
562
|
+
function buildUrl(apiUrl, req) {
|
|
563
|
+
const url = new URL(apiBase(apiUrl) + req.path);
|
|
564
|
+
for (const [key, value] of Object.entries(req.query ?? {})) {
|
|
565
|
+
for (const v of typeof value === "string" ? [value] : value) url.searchParams.append(key, v);
|
|
566
|
+
}
|
|
567
|
+
return url;
|
|
568
|
+
}
|
|
569
|
+
function createPluneApi(opts) {
|
|
570
|
+
const doFetch = opts.fetchImpl ?? fetch;
|
|
571
|
+
const headers = {
|
|
572
|
+
authorization: `Bearer ${opts.token}`,
|
|
573
|
+
accept: "application/json",
|
|
574
|
+
"x-plune-session": opts.sessionId,
|
|
575
|
+
...opts.projectId === void 0 ? {} : { "x-plune-project": opts.projectId },
|
|
576
|
+
...opts.userAgent === void 0 ? {} : { "user-agent": opts.userAgent }
|
|
577
|
+
};
|
|
578
|
+
return async function request(req) {
|
|
579
|
+
const url = buildUrl(opts.apiUrl, req);
|
|
580
|
+
let res;
|
|
581
|
+
try {
|
|
582
|
+
res = await doFetch(url.toString(), {
|
|
583
|
+
method: req.method,
|
|
584
|
+
headers: req.body === void 0 ? headers : { ...headers, "content-type": "application/json" },
|
|
585
|
+
...req.body === void 0 ? {} : { body: JSON.stringify(req.body) },
|
|
586
|
+
// Host pinning (#1283): a redirect elsewhere would carry the token there.
|
|
587
|
+
redirect: "manual"
|
|
588
|
+
});
|
|
589
|
+
} catch {
|
|
590
|
+
throw new PluneNetworkError(apiBase(opts.apiUrl));
|
|
591
|
+
}
|
|
592
|
+
const text = await res.text();
|
|
593
|
+
let body = text;
|
|
594
|
+
if (text !== "") {
|
|
595
|
+
try {
|
|
596
|
+
body = JSON.parse(text);
|
|
597
|
+
} catch {
|
|
598
|
+
}
|
|
599
|
+
} else {
|
|
600
|
+
body = void 0;
|
|
601
|
+
}
|
|
602
|
+
if (res.status < 200 || res.status >= 300) throw new PluneApiError(res.status, body);
|
|
603
|
+
return { status: res.status, body };
|
|
604
|
+
};
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
// src/mcp-tools/execute.ts
|
|
608
|
+
var CONFIRM_TTL_MS = 5 * 60 * 1e3;
|
|
609
|
+
var READ_ONLY_REFUSAL = "read-only token";
|
|
610
|
+
var readScopeMessage = (what) => `This token has the read scope, so ${what}. Use a token with the write scope (Plune \u2192 Account \u2192 API tokens) and restart the server.`;
|
|
611
|
+
var fail = (code, message, extra = {}) => ({
|
|
612
|
+
ok: false,
|
|
613
|
+
error: { code, message, ...extra }
|
|
614
|
+
});
|
|
615
|
+
function errorOf(err) {
|
|
616
|
+
if (err instanceof PluneApiError) {
|
|
617
|
+
if (err.status === 401)
|
|
618
|
+
return { status: 401, code: "token_rejected", message: `Plune rejected the token (401): ${err.message}. Run \`plune login\` or set PLUNE_TOKEN to a live token.` };
|
|
619
|
+
if (err.status === 403 && err.message === READ_ONLY_REFUSAL)
|
|
620
|
+
return { status: 403, code: "read_only_token", message: `Plune refused (403): ${readScopeMessage("it cannot write")}` };
|
|
621
|
+
const code = err.code ?? `http_${err.status}`;
|
|
622
|
+
return { status: err.status, code, message: `Plune refused (${err.status} ${code}): ${err.message}`, details: err.body };
|
|
623
|
+
}
|
|
624
|
+
if (err instanceof PluneNetworkError) return { code: "network", message: `${err.message}. Check PLUNE_API_URL and the network.` };
|
|
625
|
+
throw err;
|
|
626
|
+
}
|
|
627
|
+
var zodMessage = (error) => error.issues.map((i) => `${i.path.join(".") || "(input)"}: ${i.message}`).join("; ");
|
|
628
|
+
var path = (template, ...ids) => {
|
|
629
|
+
let i = 0;
|
|
630
|
+
return template.replace(/:[a-z]+/gi, () => encodeURIComponent(ids[i++] ?? ""));
|
|
631
|
+
};
|
|
632
|
+
var withoutUndefined = (o) => Object.fromEntries(Object.entries(o).filter(([, v]) => v !== void 0));
|
|
633
|
+
var asRecord = (v) => v !== null && typeof v === "object" && !Array.isArray(v) ? v : {};
|
|
634
|
+
var asArray = (v) => Array.isArray(v) ? v : [];
|
|
635
|
+
var cursorOf = (v) => typeof v === "string" ? v : null;
|
|
636
|
+
function isDeletion(req) {
|
|
637
|
+
if (req.method === "DELETE") return true;
|
|
638
|
+
if (asRecord(asRecord(req.body)["action"])["kind"] === "delete") return true;
|
|
639
|
+
return /\/(bulk-delete|purge|empty)$/.test(req.path);
|
|
640
|
+
}
|
|
641
|
+
var rawWrites = (method, p) => method !== "GET" && !(method === "POST" && p === "/v1/test-cases/resolve");
|
|
642
|
+
function safePath(apiUrl, raw) {
|
|
643
|
+
if (!/^\/v1\/[^\\\s]*$/.test(raw) || raw.startsWith("//") || raw.includes("?") || raw.includes("#")) return void 0;
|
|
644
|
+
if (raw.split("/").some((s) => s === "." || s === ".." || /^%2e/i.test(s))) return void 0;
|
|
645
|
+
const base = new URL(apiUrl);
|
|
646
|
+
const url = new URL(raw, base);
|
|
647
|
+
return url.origin === base.origin && url.pathname.startsWith("/v1/") ? raw : void 0;
|
|
648
|
+
}
|
|
649
|
+
function createPluneToolExecutor(opts) {
|
|
650
|
+
const sessionId = opts.sessionId ?? (0, import_node_crypto.randomUUID)();
|
|
651
|
+
const now = opts.now ?? Date.now;
|
|
652
|
+
const request = createPluneApi({ ...opts, sessionId });
|
|
653
|
+
let scope = opts.scope;
|
|
654
|
+
const revealed = /* @__PURE__ */ new Set();
|
|
655
|
+
const confirms = /* @__PURE__ */ new Map();
|
|
656
|
+
let proposals = 0;
|
|
657
|
+
const visibleTools = () => {
|
|
658
|
+
const core = defaultVisibleTools(scope);
|
|
659
|
+
const extra = [...revealed].filter((n) => !(scope === "read" && pluneTool(n).write) && !core.includes(n));
|
|
660
|
+
return [...core, ...extra];
|
|
661
|
+
};
|
|
662
|
+
async function call(req) {
|
|
663
|
+
try {
|
|
664
|
+
return await request(req);
|
|
665
|
+
} catch (err) {
|
|
666
|
+
if (err instanceof PluneApiError && err.status === 403 && err.message === READ_ONLY_REFUSAL) scope = "read";
|
|
667
|
+
throw err;
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
const planned = (req) => withoutUndefined({ method: req.method, path: req.path, query: req.query, body: req.body });
|
|
671
|
+
async function write(req, dry) {
|
|
672
|
+
if (dry === true) return { dryRun: true, request: planned(req) };
|
|
673
|
+
const res = await call(req);
|
|
674
|
+
return { dryRun: false, result: asRecord(res.body) };
|
|
675
|
+
}
|
|
676
|
+
async function batch(reqs, dry, idOf) {
|
|
677
|
+
if (dry === true) return { dryRun: true, requested: reqs.length, applied: 0, requests: reqs.map(planned) };
|
|
678
|
+
const results = [];
|
|
679
|
+
for (const [i, req] of reqs.entries()) {
|
|
680
|
+
try {
|
|
681
|
+
const res = await call(req);
|
|
682
|
+
results.push(withoutUndefined({ ok: true, status: res.status, id: idOf(res.body, i) }));
|
|
683
|
+
} catch (err) {
|
|
684
|
+
const e = errorOf(err);
|
|
685
|
+
if (e.code === "token_rejected" || e.code === "read_only_token" || e.code === "network") throw err;
|
|
686
|
+
results.push(withoutUndefined({ ok: false, status: e.status, error: e.message, code: e.code }));
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
return { dryRun: false, requested: reqs.length, applied: results.filter((r) => r["ok"] === true).length, results };
|
|
690
|
+
}
|
|
691
|
+
const fingerprint = (req) => JSON.stringify([req.method, req.path, req.query ?? {}, req.body ?? null]);
|
|
692
|
+
const str = (v) => v;
|
|
693
|
+
const handlers = {
|
|
694
|
+
async plune_project_context() {
|
|
695
|
+
const [projects, suites, cases, missions, queue] = await Promise.all([
|
|
696
|
+
call({ method: "GET", path: "/v1/projects" }),
|
|
697
|
+
call({ method: "GET", path: "/v1/suites" }),
|
|
698
|
+
call({ method: "GET", path: "/v1/test-cases", query: { limit: "1", stability: "0" } }),
|
|
699
|
+
call({ method: "GET", path: "/v1/missions" }),
|
|
700
|
+
call({ method: "GET", path: "/v1/review-items", query: { state: "pending", limit: "100" } })
|
|
701
|
+
]);
|
|
702
|
+
const projectList = asArray(asRecord(projects.body)["projects"]);
|
|
703
|
+
const only = projectList.length === 1 ? asRecord(projectList[0])["id"] : void 0;
|
|
704
|
+
const projectId = opts.projectId ?? (typeof only === "string" ? only : null);
|
|
705
|
+
const total = asRecord(cases.body)["total"];
|
|
706
|
+
return {
|
|
707
|
+
projectId,
|
|
708
|
+
projects: projectList,
|
|
709
|
+
suites: asArray(asRecord(suites.body)["suites"]),
|
|
710
|
+
cases: { total: typeof total === "number" ? total : 0 },
|
|
711
|
+
missions: asArray(asRecord(missions.body)["missions"]),
|
|
712
|
+
reviewQueue: { pending: asArray(asRecord(queue.body)["items"]).length, more: cursorOf(asRecord(queue.body)["nextCursor"]) !== null }
|
|
713
|
+
};
|
|
714
|
+
},
|
|
715
|
+
async plune_get(i) {
|
|
716
|
+
const route = { "test-case": "/v1/test-cases/:id", run: "/v1/runs/:id", "review-item": "/v1/review-items/:id" }[str(i["kind"])];
|
|
717
|
+
const res = await call({ method: "GET", path: path(route, str(i["id"])) });
|
|
718
|
+
return { kind: str(i["kind"]), item: asRecord(res.body) };
|
|
719
|
+
},
|
|
720
|
+
async plune_cases_search(i) {
|
|
721
|
+
const query = withoutUndefined({
|
|
722
|
+
...i["filters"],
|
|
723
|
+
limit: i["limit"] === void 0 ? void 0 : String(i["limit"]),
|
|
724
|
+
cursor: i["cursor"],
|
|
725
|
+
stability: "0"
|
|
726
|
+
});
|
|
727
|
+
const body = asRecord((await call({ method: "GET", path: "/v1/test-cases", query })).body);
|
|
728
|
+
return withoutUndefined({
|
|
729
|
+
testCases: asArray(body["testCases"]),
|
|
730
|
+
total: typeof body["total"] === "number" ? body["total"] : void 0,
|
|
731
|
+
matching: typeof body["matching"] === "number" ? body["matching"] : void 0,
|
|
732
|
+
nextCursor: cursorOf(body["nextCursor"])
|
|
733
|
+
});
|
|
734
|
+
},
|
|
735
|
+
async plune_review_list(i) {
|
|
736
|
+
const kinds = i["kinds"];
|
|
737
|
+
const query = withoutUndefined({
|
|
738
|
+
state: i["state"],
|
|
739
|
+
kind: kinds?.join(","),
|
|
740
|
+
limit: i["limit"] === void 0 ? void 0 : String(i["limit"]),
|
|
741
|
+
cursor: i["cursor"]
|
|
742
|
+
});
|
|
743
|
+
const body = asRecord((await call({ method: "GET", path: "/v1/review-items", query })).body);
|
|
744
|
+
return { items: asArray(body["items"]), nextCursor: cursorOf(body["nextCursor"]) };
|
|
745
|
+
},
|
|
746
|
+
async plune_review_decide(i) {
|
|
747
|
+
const itemIds = i["itemIds"];
|
|
748
|
+
const res = await call({
|
|
749
|
+
method: "POST",
|
|
750
|
+
path: "/v1/review-items/decisions",
|
|
751
|
+
...i["confirm"] === void 0 ? {} : { query: { confirm: str(i["confirm"]) } },
|
|
752
|
+
body: withoutUndefined({ itemIds, state: i["verdict"], rejectionCategory: i["rejectionCategory"], note: i["note"] })
|
|
753
|
+
});
|
|
754
|
+
const body = asRecord(res.body);
|
|
755
|
+
if (body["preview"] !== void 0) return { requested: itemIds.length, applied: 0, preview: asRecord(body["preview"]) };
|
|
756
|
+
const applied = body["applied"];
|
|
757
|
+
return { requested: itemIds.length, applied: typeof applied === "number" ? applied : 0, report: body };
|
|
758
|
+
},
|
|
759
|
+
async plune_case_propose(i) {
|
|
760
|
+
const cases = i["cases"].map((c) => {
|
|
761
|
+
const steps = c["steps"];
|
|
762
|
+
const stableId = (0, import_node_crypto.createHash)("sha256").update([c["title"], ...steps, c["expected"]].join("\n")).digest("hex").slice(0, 32);
|
|
763
|
+
return withoutUndefined({ stableId, execution: "manual", ...c });
|
|
764
|
+
});
|
|
765
|
+
proposals += 1;
|
|
766
|
+
const req = {
|
|
767
|
+
method: "POST",
|
|
768
|
+
path: "/v1/ingest/cairn",
|
|
769
|
+
body: { schemaVersion: 1, run: { runId: `mcp-${sessionId}-${proposals}`, mode: "design", target: str(i["target"]) }, cases }
|
|
770
|
+
};
|
|
771
|
+
if (i["dry_run"] === true) return { dryRun: true, requested: cases.length, applied: 0, requests: [planned(req)] };
|
|
772
|
+
const summary = asRecord((await call(req)).body);
|
|
773
|
+
const proposed = summary["proposed"];
|
|
774
|
+
return { dryRun: false, requested: cases.length, applied: typeof proposed === "number" ? proposed : 0, report: summary };
|
|
775
|
+
},
|
|
776
|
+
async plune_mission_create(i) {
|
|
777
|
+
const { dry_run, ...body } = i;
|
|
778
|
+
return write({ method: "POST", path: "/v1/missions", body: withoutUndefined(body) }, dry_run);
|
|
779
|
+
},
|
|
780
|
+
async plune_mission_get(i) {
|
|
781
|
+
const res = await call({ method: "GET", path: path("/v1/missions/:id", str(i["missionId"])) });
|
|
782
|
+
return { mission: asRecord(res.body) };
|
|
783
|
+
},
|
|
784
|
+
async plune_mission_update(i) {
|
|
785
|
+
const { dry_run, missionId: missionId2, ...body } = i;
|
|
786
|
+
return write({ method: "PATCH", path: path("/v1/missions/:id", str(missionId2)), body: withoutUndefined(body) }, dry_run);
|
|
787
|
+
},
|
|
788
|
+
async plune_source_add(i) {
|
|
789
|
+
const parsed = (i["kind"] === "file" ? fileSource : linkSource).safeParse(withoutUndefined(i));
|
|
790
|
+
if (!parsed.success) return fail("invalid_input", `The input does not fit: ${zodMessage(parsed.error)}`);
|
|
791
|
+
const { dry_run, missionId: missionId2, kind, ...rest } = parsed.data;
|
|
792
|
+
const req = kind === "file" ? { method: "POST", path: path("/v1/missions/:id/files", str(missionId2)), body: withoutUndefined(rest) } : { method: "POST", path: path("/v1/missions/:id/sources", str(missionId2)), body: withoutUndefined({ kind, ...rest }) };
|
|
793
|
+
return write(req, dry_run);
|
|
794
|
+
},
|
|
795
|
+
async plune_question_ask(i) {
|
|
796
|
+
const { dry_run, ...body } = i;
|
|
797
|
+
return write({ method: "POST", path: "/v1/review-items/questions", body: withoutUndefined(body) }, dry_run);
|
|
798
|
+
},
|
|
799
|
+
async plune_question_list(i) {
|
|
800
|
+
const query = withoutUndefined({
|
|
801
|
+
kind: "question",
|
|
802
|
+
state: i["state"],
|
|
803
|
+
limit: i["limit"] === void 0 ? void 0 : String(i["limit"]),
|
|
804
|
+
cursor: i["cursor"]
|
|
805
|
+
});
|
|
806
|
+
const body = asRecord((await call({ method: "GET", path: "/v1/review-items", query })).body);
|
|
807
|
+
const missionId2 = i["missionId"];
|
|
808
|
+
const questions = asArray(body["items"]).filter(
|
|
809
|
+
(item) => missionId2 === void 0 || asRecord(asRecord(item)["question"])["missionId"] === missionId2
|
|
810
|
+
);
|
|
811
|
+
return { questions, nextCursor: cursorOf(body["nextCursor"]) };
|
|
812
|
+
},
|
|
813
|
+
async plune_strategy_propose(i) {
|
|
814
|
+
return write({ method: "POST", path: path("/v1/missions/:id/strategy", str(i["missionId"])), body: i["content"] }, i["dry_run"]);
|
|
815
|
+
},
|
|
816
|
+
async plune_report_request(i) {
|
|
817
|
+
return write({ method: "POST", path: path("/v1/missions/:id/report", str(i["missionId"])), body: {} }, i["dry_run"]);
|
|
818
|
+
},
|
|
819
|
+
async plune_discover_tools(i) {
|
|
820
|
+
const names = i["names"] ?? [];
|
|
821
|
+
const unknown = names.filter((n) => !isPluneToolName(n));
|
|
822
|
+
if (unknown.length > 0) return fail("invalid_input", `No such tool: ${unknown.join(", ")}. Call this tool without names to see what there is.`);
|
|
823
|
+
const refused = names.filter((n) => scope === "read" && pluneTool(n).write);
|
|
824
|
+
if (refused.length > 0) return fail("read_only_token", readScopeMessage(`${refused.join(", ")} cannot be switched on`));
|
|
825
|
+
for (const n of names) revealed.add(n);
|
|
826
|
+
const words = (i["query"] ?? "").toLowerCase().split(/\s+/).filter(Boolean);
|
|
827
|
+
const visible = visibleTools();
|
|
828
|
+
const available = PLUNE_TOOLS.filter((t) => !visible.includes(t.name) && !(scope === "read" && t.write)).filter((t) => words.every((w) => `${t.name} ${t.title} ${t.description}`.toLowerCase().includes(w))).map((t) => ({ name: t.name, title: t.title, description: t.description, write: t.write }));
|
|
829
|
+
return { revealed: names, available, visible };
|
|
830
|
+
},
|
|
831
|
+
async plune_cases_update(i) {
|
|
832
|
+
const cases = i["cases"];
|
|
833
|
+
return batch(
|
|
834
|
+
cases.map((c) => ({ method: "PATCH", path: path("/v1/test-cases/:id", c.id), body: c.changes })),
|
|
835
|
+
i["dry_run"],
|
|
836
|
+
(_b, n) => cases[n]?.id
|
|
837
|
+
);
|
|
838
|
+
},
|
|
839
|
+
async plune_run_create(i) {
|
|
840
|
+
return write({ method: "POST", path: "/v1/runs", body: i["run"] }, i["dry_run"]);
|
|
841
|
+
},
|
|
842
|
+
async plune_results_write(i) {
|
|
843
|
+
const results = i["results"];
|
|
844
|
+
const req = {
|
|
845
|
+
method: "POST",
|
|
846
|
+
path: path("/v1/runs/:id/results", str(i["runId"])),
|
|
847
|
+
body: withoutUndefined({ results, combination: i["combination"], configuration: i["configuration"] })
|
|
848
|
+
};
|
|
849
|
+
if (i["dry_run"] === true) return { dryRun: true, requested: results.length, applied: 0, requests: [planned(req)] };
|
|
850
|
+
const report = asRecord((await call(req)).body);
|
|
851
|
+
const accepted = asRecord(report["counts"])["accepted"];
|
|
852
|
+
return { dryRun: false, requested: results.length, applied: typeof accepted === "number" ? accepted : 0, report };
|
|
853
|
+
},
|
|
854
|
+
async plune_endpoint_propose(i) {
|
|
855
|
+
return batch(
|
|
856
|
+
i["endpoints"].map((e) => ({ method: "POST", path: "/v1/review-items/endpoints", body: withoutUndefined(e) })),
|
|
857
|
+
i["dry_run"],
|
|
858
|
+
(b) => asRecord(b)["id"]
|
|
859
|
+
);
|
|
860
|
+
},
|
|
861
|
+
async plune_api_request(i) {
|
|
862
|
+
const method = str(i["method"]);
|
|
863
|
+
const p = safePath(opts.apiUrl, str(i["path"]));
|
|
864
|
+
if (p === void 0) return fail("invalid_input", `path must be a path under /v1 on ${new URL(opts.apiUrl).origin}, e.g. /v1/suites \u2014 no host, no dot segments, no query (use query)`);
|
|
865
|
+
const query = i["query"];
|
|
866
|
+
const req = withoutUndefined({ method, path: p, query, body: i["body"] });
|
|
867
|
+
if (i["dry_run"] === true) return { dryRun: true, request: planned(req) };
|
|
868
|
+
const confirm = i["confirm"];
|
|
869
|
+
if (isDeletion(req)) {
|
|
870
|
+
const t = now();
|
|
871
|
+
for (const [k, v] of confirms) if (v.expires <= t) confirms.delete(k);
|
|
872
|
+
if (confirm === void 0) {
|
|
873
|
+
const value = (0, import_node_crypto.randomBytes)(24).toString("base64url");
|
|
874
|
+
confirms.set(value, { fingerprint: fingerprint(req), expires: t + CONFIRM_TTL_MS });
|
|
875
|
+
return {
|
|
876
|
+
preview: {
|
|
877
|
+
action: "delete",
|
|
878
|
+
method,
|
|
879
|
+
path: p,
|
|
880
|
+
confirm: value,
|
|
881
|
+
expiresInSeconds: CONFIRM_TTL_MS / 1e3,
|
|
882
|
+
note: "Nothing was sent. Repeat the same call with confirm to send it. The platform refuses an agent any deletion (agent_cannot_delete, bulk_delete_needs_person): a person deletes from the dashboard."
|
|
883
|
+
}
|
|
884
|
+
};
|
|
885
|
+
}
|
|
886
|
+
const held = confirms.get(confirm);
|
|
887
|
+
confirms.delete(confirm);
|
|
888
|
+
if (held === void 0 || held.fingerprint !== fingerprint(req))
|
|
889
|
+
return fail("confirm_invalid", "confirm is unknown, expired, used, or for another request \u2014 repeat the call without it to get a new preview");
|
|
890
|
+
} else if (confirm !== void 0) {
|
|
891
|
+
req.query = { ...query, confirm };
|
|
892
|
+
}
|
|
893
|
+
const res = await call(req);
|
|
894
|
+
return { status: res.status, body: res.body };
|
|
895
|
+
}
|
|
896
|
+
};
|
|
897
|
+
return {
|
|
898
|
+
sessionId,
|
|
899
|
+
scope: () => scope,
|
|
900
|
+
visibleTools,
|
|
901
|
+
async execute(name, input) {
|
|
902
|
+
const handler = isPluneToolName(name) ? handlers[name] : void 0;
|
|
903
|
+
if (handler === void 0) return fail("unknown_tool", `No such tool: ${name}.`);
|
|
904
|
+
const tool = pluneTool(name);
|
|
905
|
+
const parsed = tool.input.safeParse(input ?? {});
|
|
906
|
+
if (!parsed.success) return fail("invalid_input", `The input does not fit: ${zodMessage(parsed.error)}`);
|
|
907
|
+
const args = parsed.data;
|
|
908
|
+
const writes = name === "plune_api_request" ? rawWrites(str(args["method"]), str(args["path"])) : tool.write;
|
|
909
|
+
if (writes && scope === "read" && args["dry_run"] !== true) return fail("read_only_token", readScopeMessage(`${name} is refused before any request`));
|
|
910
|
+
try {
|
|
911
|
+
const out = await handler(args);
|
|
912
|
+
return "ok" in out && typeof out["ok"] === "boolean" && ("error" in out || "data" in out) ? out : { ok: true, data: out };
|
|
913
|
+
} catch (err) {
|
|
914
|
+
return { ok: false, error: errorOf(err) };
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
};
|
|
918
|
+
}
|
|
919
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
920
|
+
0 && (module.exports = {
|
|
921
|
+
BATCH_MAX,
|
|
922
|
+
CONFIRM_TTL_MS,
|
|
923
|
+
MAX_VISIBLE_TOOLS,
|
|
924
|
+
PLUNE_TOOLS,
|
|
925
|
+
PLUNE_TOOL_NAMES,
|
|
926
|
+
PluneApiError,
|
|
927
|
+
PluneNetworkError,
|
|
928
|
+
createPluneToolExecutor,
|
|
929
|
+
defaultVisibleTools,
|
|
930
|
+
isPluneToolName,
|
|
931
|
+
pluneTool
|
|
932
|
+
});
|