@vitest-agent/mcp 4.3.2 → 5.0.0
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 +5 -4
- package/idempotency.js +1 -1
- package/index.d.ts +2235 -2128
- package/index.js +2 -2
- package/main.d.ts +15 -16
- package/main.js +58 -96
- package/package.json +5 -3
- package/server.js +16 -28
- package/toolkit.js +12 -8
- package/tools/_tdd-error-envelope.js +5 -14
- package/tools/cache-health.js +2 -1
- package/tools/configure.js +2 -1
- package/tools/coverage.js +2 -1
- package/tools/failure-signature-get.js +2 -1
- package/tools/file-coverage.js +3 -2
- package/tools/hypothesis.js +33 -23
- package/tools/inventory.js +6 -5
- package/tools/note.js +6 -5
- package/tools/overview.js +2 -1
- package/tools/ping.js +15 -5
- package/tools/register-agent.js +2 -1
- package/tools/run-tests.js +18 -8
- package/tools/status.js +2 -1
- package/tools/tdd-behavior.js +8 -10
- package/tools/tdd-goal.js +8 -10
- package/tools/tdd-phase-transition-request.js +21 -14
- package/tools/tdd-task.js +25 -13
- package/tools/test.js +6 -5
- package/tools/trends.js +2 -1
- package/tsdoc-metadata.json +1 -1
- package/version.js +1 -1
- package/register-toolkit.js +0 -328
- package/utils/crash-guards.js +0 -13
- package/utils/tool-error-envelope.js +0 -61
package/tools/tdd-task.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { IdempotentReplayMarker } from "../utils/replay-marker.js";
|
|
2
|
+
import { McpToolkit, ToolFailure, ToolOutputSchema, ToolRefusal } from "@effected/mcp";
|
|
2
3
|
import { Effect, Match, Option, Schema } from "effect";
|
|
3
4
|
import { DataReader, DataStore } from "@vitest-agent/engine";
|
|
4
5
|
import { Tool } from "effect/unstable/ai";
|
|
@@ -82,14 +83,14 @@ const TddTaskResumeMissing = Schema.Struct({
|
|
|
82
83
|
found: Schema.Literal(false),
|
|
83
84
|
tddTaskId: Schema.Number
|
|
84
85
|
}).annotate({ identifier: "TddTaskResumeMissing" });
|
|
85
|
-
const TddTaskResult = Schema.Union([
|
|
86
|
+
const TddTaskResult = ToolOutputSchema.objectRooted(Schema.Union([
|
|
86
87
|
TddTaskStartOk,
|
|
87
88
|
TddTaskEndOk,
|
|
88
89
|
TddTaskGetFound,
|
|
89
90
|
TddTaskGetMissing,
|
|
90
91
|
TddTaskResumeFound,
|
|
91
92
|
TddTaskResumeMissing
|
|
92
|
-
]).annotate({
|
|
93
|
+
])).annotate({
|
|
93
94
|
identifier: "TddTaskResult",
|
|
94
95
|
title: "tdd_task result",
|
|
95
96
|
description: "Discriminate on `action`. `get` and `resume` further discriminate on `found`. `get` carries the full nested task tree."
|
|
@@ -134,8 +135,9 @@ const TddTaskInput = Schema.Union([
|
|
|
134
135
|
]);
|
|
135
136
|
/**
|
|
136
137
|
* Handler for {@link tddTaskTool}. A `start` with neither `sessionId` nor
|
|
137
|
-
* `chatId`, an unknown `chatId`, or a blank `runId` fails
|
|
138
|
-
* `
|
|
138
|
+
* `chatId`, an unknown `sessionId` or `chatId`, or a blank `runId` fails with a
|
|
139
|
+
* {@link ToolRefusal} (an `isError` result naming the fix). A store failure is
|
|
140
|
+
* a defect.
|
|
139
141
|
*
|
|
140
142
|
* @public
|
|
141
143
|
*/
|
|
@@ -144,13 +146,23 @@ const handleTddTask = (input) => Match.value(input).pipe(Match.discriminatorsExh
|
|
|
144
146
|
const reader = yield* DataReader;
|
|
145
147
|
const store = yield* DataStore;
|
|
146
148
|
let sessionId;
|
|
147
|
-
if (variant.sessionId !== void 0)
|
|
148
|
-
|
|
149
|
+
if (variant.sessionId !== void 0) {
|
|
150
|
+
if (Option.isNone(yield* reader.getSessionById(variant.sessionId))) return yield* Effect.fail(ToolRefusal.refuse(`Unknown sessionId ${variant.sessionId}.`, { hint: "Pass chatId (the host chat UUID) instead, or a sessionId that exists." }));
|
|
151
|
+
sessionId = variant.sessionId;
|
|
152
|
+
} else if (variant.chatId !== void 0) {
|
|
149
153
|
const opt = yield* reader.getSessionByChatId(variant.chatId);
|
|
150
|
-
if (Option.isNone(opt)) return yield* Effect.fail(
|
|
154
|
+
if (Option.isNone(opt)) return yield* Effect.fail(ToolRefusal.refuse(`Unknown chatId ${ToolFailure.truncate(variant.chatId)}: no session is recorded for it.`, { hint: "The plugin's SessionStart hook records the session (vitest-agent agent record session-start); check that it ran for this chat, or pass a sessionId that exists." }));
|
|
151
155
|
sessionId = opt.value.id;
|
|
152
|
-
} else return yield* Effect.fail(
|
|
153
|
-
|
|
156
|
+
} else return yield* Effect.fail(ToolRefusal.refuse("tdd_task action='start' needs a session.", {
|
|
157
|
+
hint: "Pass sessionId (sessions.id) or chatId (the host chat UUID).",
|
|
158
|
+
suggestedTool: "tdd_task",
|
|
159
|
+
suggestedArgs: { action: "start" }
|
|
160
|
+
}));
|
|
161
|
+
if (variant.runId !== void 0 && variant.runId.trim().length === 0) return yield* Effect.fail(ToolRefusal.refuse("tdd_task action='start': runId must not be blank.", {
|
|
162
|
+
hint: "Omit runId, or pass a non-empty dispatch id.",
|
|
163
|
+
suggestedTool: "tdd_task",
|
|
164
|
+
suggestedArgs: { action: "start" }
|
|
165
|
+
}));
|
|
154
166
|
return {
|
|
155
167
|
action: "start",
|
|
156
168
|
tddTaskId: yield* store.writeTddTask({
|
|
@@ -234,18 +246,18 @@ const handleTddTask = (input) => Match.value(input).pipe(Match.discriminatorsExh
|
|
|
234
246
|
artifactsRecorded: tdd.artifacts.length
|
|
235
247
|
};
|
|
236
248
|
})
|
|
237
|
-
})).pipe(Effect.
|
|
249
|
+
})).pipe(Effect.catchTag("DataStoreError", Effect.die));
|
|
238
250
|
/**
|
|
239
251
|
* The Effect-native `tdd_task` tool.
|
|
240
252
|
*
|
|
241
253
|
* @public
|
|
242
254
|
*/
|
|
243
|
-
const tddTaskTool =
|
|
255
|
+
const tddTaskTool = McpToolkit.unionTool("tdd_task", {
|
|
244
256
|
description: "Use to manage a TDD task lifecycle, with an action discriminator: action='start' (goal, sessionId|chatId, parentTddTaskId?, startedAt?, runId?) opens a new task; action='end' (tddTaskId, outcome, summaryNoteId?) closes one; action='get' (tddTaskId) returns the full task detail; action='resume' (tddTaskId) returns a compact digest.",
|
|
245
257
|
parameters: TddTaskInput,
|
|
246
258
|
success: TddTaskResult,
|
|
247
|
-
|
|
248
|
-
}).annotate(Tool.Title, "TDD task").annotate(Tool.Readonly, false).annotate(Tool.Destructive, false).annotate(Tool.OpenWorld, false).annotate(Tool.Idempotent, true);
|
|
259
|
+
failure: ToolRefusal
|
|
260
|
+
}).addDependency(DataReader).addDependency(DataStore).annotate(Tool.Title, "TDD task").annotate(Tool.Readonly, false).annotate(Tool.Destructive, false).annotate(Tool.OpenWorld, false).annotate(Tool.Idempotent, true);
|
|
249
261
|
|
|
250
262
|
//#endregion
|
|
251
263
|
export { TddTaskInput, TddTaskResult, handleTddTask, tddTaskTool };
|
package/tools/test.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { collectProjectRows, resolveProjectTargets } from "./_project-groups.js";
|
|
2
|
+
import { McpToolkit, ToolOutputSchema, ToolRefusal } from "@effected/mcp";
|
|
2
3
|
import { Effect, Match, Option, Schema } from "effect";
|
|
3
4
|
import { DataReader } from "@vitest-agent/engine";
|
|
4
5
|
import { Tool } from "effect/unstable/ai";
|
|
@@ -149,7 +150,7 @@ const TestArtifactsResult = Schema.Struct({
|
|
|
149
150
|
*
|
|
150
151
|
* @public
|
|
151
152
|
*/
|
|
152
|
-
const TestResult = Schema.Union([
|
|
153
|
+
const TestResult = ToolOutputSchema.objectRooted(Schema.Union([
|
|
153
154
|
TestListResult,
|
|
154
155
|
TestGetFound,
|
|
155
156
|
TestGetMissing,
|
|
@@ -157,7 +158,7 @@ const TestResult = Schema.Union([
|
|
|
157
158
|
TestForTagResult,
|
|
158
159
|
TestAnnotationsResult,
|
|
159
160
|
TestArtifactsResult
|
|
160
|
-
]).annotate({
|
|
161
|
+
])).annotate({
|
|
161
162
|
identifier: "TestResult",
|
|
162
163
|
title: "test result",
|
|
163
164
|
description: "Discriminate on `action`. `get` further discriminates on `found`. `list`, `for_file`, `for_tag`, `annotations`, and `artifacts` all carry counted arrays — `list` and `for_tag` group by project; `annotations` and `artifacts` are scoped to one test and return attachment descriptors — an inline `body` comes back only when `maxBytes` is passed and the running total stays inside it."
|
|
@@ -335,12 +336,12 @@ const handleTest = (input) => Match.value(input).pipe(Match.discriminatorsExhaus
|
|
|
335
336
|
*
|
|
336
337
|
* @public
|
|
337
338
|
*/
|
|
338
|
-
const testTool =
|
|
339
|
+
const testTool = McpToolkit.unionTool("test", {
|
|
339
340
|
description: "Use to inspect tests, with an action discriminator: action='list' (project?, state?, module?, limit?) returns matching tests; action='get' (fullName, project?, modulePath?) returns details + errors + run history — a fullName that exists in more than one module returns found=false with ambiguous=true and candidateModules[], so pass modulePath to disambiguate; action='for_file' (filePath) returns test modules covering a source file; action='for_tag' (tag, project?) returns tests carrying a tag, grouped by project; action='annotations' (fullName, project?, modulePath?) returns the test annotations the author recorded via context.annotate; action='artifacts' (fullName, project?, modulePath?) returns the test artifacts recorded for the test — both return attachment descriptors (contentType, path, byteSize) and omit inline bodies unless maxBytes (a non-negative integer byte budget for the whole response, default 0) is passed, and neither has anything to do with TDD artifacts (see tdd_artifact_list). structuredContent carries the typed payload (discriminate on `action`, then on `found` for get).",
|
|
340
341
|
parameters: TestInput,
|
|
341
342
|
success: TestResult,
|
|
342
|
-
|
|
343
|
-
}).annotate(Tool.Title, "Test").annotate(Tool.Readonly, true).annotate(Tool.Destructive, false).annotate(Tool.OpenWorld, false).annotate(Tool.Idempotent, true);
|
|
343
|
+
failure: ToolRefusal
|
|
344
|
+
}).addDependency(DataReader).annotate(Tool.Title, "Test").annotate(Tool.Readonly, true).annotate(Tool.Destructive, false).annotate(Tool.OpenWorld, false).annotate(Tool.Idempotent, true);
|
|
344
345
|
|
|
345
346
|
//#endregion
|
|
346
347
|
export { TestInput, TestResult, handleTest, testTool };
|
package/tools/trends.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { ToolOutputSchema } from "@effected/mcp";
|
|
1
2
|
import { Effect, Option, Schema } from "effect";
|
|
2
3
|
import { DataReader } from "@vitest-agent/engine";
|
|
3
4
|
import { Tool } from "effect/unstable/ai";
|
|
@@ -18,7 +19,7 @@ const TrendsAbsent = Schema.Struct({
|
|
|
18
19
|
*
|
|
19
20
|
* @public
|
|
20
21
|
*/
|
|
21
|
-
const TestTrendsResult = Schema.Union([TrendsAvailable, TrendsAbsent]).annotate({
|
|
22
|
+
const TestTrendsResult = ToolOutputSchema.objectRooted(Schema.Union([TrendsAvailable, TrendsAbsent])).annotate({
|
|
22
23
|
identifier: "TestTrendsResult",
|
|
23
24
|
title: "test_trends result",
|
|
24
25
|
description: "Coverage trend record per project. Discriminate on `dataAvailable` to handle the cold-start case."
|
package/tsdoc-metadata.json
CHANGED
package/version.js
CHANGED
package/register-toolkit.js
DELETED
|
@@ -1,328 +0,0 @@
|
|
|
1
|
-
import { buildUnexpectedToolErrorEnvelope } from "./utils/tool-error-envelope.js";
|
|
2
|
-
import { Cause, Context, Effect, ErrorReporter, Layer, Option, References, Result, Schema, Stream } from "effect";
|
|
3
|
-
import { AiError, McpSchema, McpServer, Tool, Toolkit } from "effect/unstable/ai";
|
|
4
|
-
import { HttpServerRequest } from "effect/unstable/http";
|
|
5
|
-
|
|
6
|
-
//#region src/register-toolkit.ts
|
|
7
|
-
const isPlainObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
8
|
-
/** Top-level discriminants a union of parameter shapes may dispatch on. */
|
|
9
|
-
const DISCRIMINANT_KEYS = ["action", "kind"];
|
|
10
|
-
/** Length cap for every echoed unknown key, so a hostile payload cannot balloon the error. */
|
|
11
|
-
const ECHO_LIMIT = 200;
|
|
12
|
-
const truncate = (value) => value.length > ECHO_LIMIT ? `${value.slice(0, ECHO_LIMIT)}…` : value;
|
|
13
|
-
const literalValues = (node) => {
|
|
14
|
-
if (!isPlainObject(node)) return void 0;
|
|
15
|
-
if (Array.isArray(node.enum)) return node.enum;
|
|
16
|
-
if ("const" in node) return [node.const];
|
|
17
|
-
};
|
|
18
|
-
/**
|
|
19
|
-
* The discriminant every member of a union shares as a literal-valued
|
|
20
|
-
* property, or `undefined` when the members do not form such a union.
|
|
21
|
-
*/
|
|
22
|
-
const findDiscriminant = (members) => {
|
|
23
|
-
if (members.length === 0) return void 0;
|
|
24
|
-
for (const key of DISCRIMINANT_KEYS) if (members.every((member) => {
|
|
25
|
-
if (!isPlainObject(member) || !isPlainObject(member.properties)) return false;
|
|
26
|
-
return literalValues(member.properties[key]) !== void 0;
|
|
27
|
-
})) return key;
|
|
28
|
-
};
|
|
29
|
-
const REF_PREFIX = "#/$defs/";
|
|
30
|
-
const lookupRef = (ref, root) => {
|
|
31
|
-
if (typeof ref !== "string" || !ref.startsWith(REF_PREFIX)) return void 0;
|
|
32
|
-
const defs = root.$defs;
|
|
33
|
-
if (!isPlainObject(defs)) return void 0;
|
|
34
|
-
const target = defs[ref.slice(8)];
|
|
35
|
-
return isPlainObject(target) ? target : void 0;
|
|
36
|
-
};
|
|
37
|
-
/**
|
|
38
|
-
* Replace a `$ref` node with its `$defs` target merged under the node's
|
|
39
|
-
* own keywords (the node's keys win). Follows chained refs; gives up on a
|
|
40
|
-
* cycle or a dangling ref and returns the node as-is.
|
|
41
|
-
*/
|
|
42
|
-
const inlineRef = (node, root, seen = /* @__PURE__ */ new Set()) => {
|
|
43
|
-
const target = lookupRef(node.$ref, root);
|
|
44
|
-
if (target === void 0 || seen.has(node.$ref)) return node;
|
|
45
|
-
const { $ref, ...rest } = node;
|
|
46
|
-
return inlineRef({
|
|
47
|
-
...target,
|
|
48
|
-
...rest
|
|
49
|
-
}, root, /* @__PURE__ */ new Set([...seen, $ref]));
|
|
50
|
-
};
|
|
51
|
-
/**
|
|
52
|
-
* A schema whose root (or whose top-level union members) is a `$ref` —
|
|
53
|
-
* what Effect emits for any schema carrying an `identifier` annotation —
|
|
54
|
-
* is inlined so the object / discriminant checks below see the real
|
|
55
|
-
* shape. `$defs` is kept for any nested references.
|
|
56
|
-
*
|
|
57
|
-
* @internal
|
|
58
|
-
*/
|
|
59
|
-
const inlineRootRefs = (schema) => {
|
|
60
|
-
const root = inlineRef(schema, schema);
|
|
61
|
-
const combinator = Array.isArray(root.anyOf) ? "anyOf" : Array.isArray(root.oneOf) ? "oneOf" : void 0;
|
|
62
|
-
if (combinator === void 0) return root;
|
|
63
|
-
const members = root[combinator].map((member) => isPlainObject(member) ? inlineRef(member, schema) : member);
|
|
64
|
-
return {
|
|
65
|
-
...root,
|
|
66
|
-
[combinator]: members
|
|
67
|
-
};
|
|
68
|
-
};
|
|
69
|
-
/**
|
|
70
|
-
* Make an input JSON Schema servable as an MCP tool input: inline a `$ref`
|
|
71
|
-
* root (and `$ref` members of a top-level union), and rewrite a top-level
|
|
72
|
-
* union of discriminated object shapes to `{ type: "object", oneOf,
|
|
73
|
-
* "x-discriminator" }` so it satisfies MCP's object-root requirement. The
|
|
74
|
-
* object nodes themselves are left as Effect emitted them — closed, when the
|
|
75
|
-
* document was built with `onExcessProperty: "error"`.
|
|
76
|
-
*
|
|
77
|
-
* @internal
|
|
78
|
-
*/
|
|
79
|
-
const objectRootedInputSchema = (schema) => {
|
|
80
|
-
const root = inlineRootRefs(schema);
|
|
81
|
-
const members = root.anyOf ?? root.oneOf;
|
|
82
|
-
if (Array.isArray(members) && root.type === void 0) {
|
|
83
|
-
const discriminant = findDiscriminant(members);
|
|
84
|
-
if (discriminant !== void 0) {
|
|
85
|
-
const { anyOf: _anyOf, oneOf: _oneOf, ...rest } = root;
|
|
86
|
-
return {
|
|
87
|
-
type: "object",
|
|
88
|
-
...rest,
|
|
89
|
-
oneOf: members,
|
|
90
|
-
"x-discriminator": discriminant
|
|
91
|
-
};
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
return root;
|
|
95
|
-
};
|
|
96
|
-
const resolveRef = (node, root) => lookupRef(node.$ref, root) ?? node;
|
|
97
|
-
/** Pick the union member a payload object matches, or `undefined` when none does unambiguously. */
|
|
98
|
-
const selectMember = (members, value, root) => {
|
|
99
|
-
const resolved = members.map((m) => isPlainObject(m) ? resolveRef(m, root) : m);
|
|
100
|
-
const discriminant = findDiscriminant(resolved);
|
|
101
|
-
if (discriminant !== void 0) {
|
|
102
|
-
const actual = value[discriminant];
|
|
103
|
-
return resolved.find((member) => {
|
|
104
|
-
if (!isPlainObject(member) || !isPlainObject(member.properties)) return false;
|
|
105
|
-
return literalValues(member.properties[discriminant])?.includes(actual) ?? false;
|
|
106
|
-
});
|
|
107
|
-
}
|
|
108
|
-
const objectMembers = resolved.filter((m) => isPlainObject(m) && isPlainObject(m.properties));
|
|
109
|
-
return objectMembers.length === 1 ? objectMembers[0] : void 0;
|
|
110
|
-
};
|
|
111
|
-
/**
|
|
112
|
-
* Walk `value` against `schema`, collecting — at every object level — the
|
|
113
|
-
* keys the payload carries that the schema does not declare.
|
|
114
|
-
*
|
|
115
|
-
* @internal
|
|
116
|
-
*/
|
|
117
|
-
const collectUnknownKeys = (value, schema) => {
|
|
118
|
-
const out = [];
|
|
119
|
-
const walk = (current, rawNode, path) => {
|
|
120
|
-
if (!isPlainObject(rawNode)) return;
|
|
121
|
-
const node = resolveRef(rawNode, schema);
|
|
122
|
-
if (Array.isArray(node.allOf)) {
|
|
123
|
-
const merged = {};
|
|
124
|
-
for (const member of node.allOf) {
|
|
125
|
-
const resolvedMember = isPlainObject(member) ? resolveRef(member, schema) : member;
|
|
126
|
-
if (isPlainObject(resolvedMember) && isPlainObject(resolvedMember.properties)) Object.assign(merged, resolvedMember.properties);
|
|
127
|
-
}
|
|
128
|
-
if (Object.keys(merged).length > 0) {
|
|
129
|
-
walk(current, {
|
|
130
|
-
...node,
|
|
131
|
-
allOf: void 0,
|
|
132
|
-
properties: {
|
|
133
|
-
...merged,
|
|
134
|
-
...isPlainObject(node.properties) ? node.properties : {}
|
|
135
|
-
}
|
|
136
|
-
}, path);
|
|
137
|
-
return;
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
if (isPlainObject(node.properties)) {
|
|
141
|
-
if (!isPlainObject(current)) return;
|
|
142
|
-
const accepted = Object.keys(node.properties);
|
|
143
|
-
const unknown = Object.keys(current).filter((key) => !accepted.includes(key));
|
|
144
|
-
if (unknown.length > 0 && node.additionalProperties !== true && !isPlainObject(node.additionalProperties)) out.push({
|
|
145
|
-
path,
|
|
146
|
-
unknown,
|
|
147
|
-
accepted
|
|
148
|
-
});
|
|
149
|
-
for (const key of accepted) if (key in current) walk(current[key], node.properties[key], [...path, key]);
|
|
150
|
-
if (isPlainObject(node.additionalProperties)) for (const key of unknown) walk(current[key], node.additionalProperties, [...path, key]);
|
|
151
|
-
return;
|
|
152
|
-
}
|
|
153
|
-
const members = node.oneOf ?? node.anyOf;
|
|
154
|
-
if (Array.isArray(members)) {
|
|
155
|
-
if (!isPlainObject(current)) return;
|
|
156
|
-
const member = selectMember(members, current, schema);
|
|
157
|
-
if (member !== void 0) walk(current, member, path);
|
|
158
|
-
return;
|
|
159
|
-
}
|
|
160
|
-
if (Array.isArray(current)) {
|
|
161
|
-
if (Array.isArray(node.prefixItems)) for (const [index, item] of node.prefixItems.entries()) walk(current[index], item, [...path, String(index)]);
|
|
162
|
-
if (isPlainObject(node.items)) for (const [index, item] of current.entries()) walk(item, node.items, [...path, String(index)]);
|
|
163
|
-
return;
|
|
164
|
-
}
|
|
165
|
-
if (isPlainObject(current) && node.type === "object" && node.additionalProperties === false) {
|
|
166
|
-
const unknown = Object.keys(current);
|
|
167
|
-
if (unknown.length > 0) out.push({
|
|
168
|
-
path,
|
|
169
|
-
unknown,
|
|
170
|
-
accepted: []
|
|
171
|
-
});
|
|
172
|
-
}
|
|
173
|
-
};
|
|
174
|
-
walk(value, schema, []);
|
|
175
|
-
return out;
|
|
176
|
-
};
|
|
177
|
-
/**
|
|
178
|
-
* The exact wording the pre-Effect `strict()` helper produced, so agents
|
|
179
|
-
* that learned to self-correct on it keep working.
|
|
180
|
-
*/
|
|
181
|
-
const formatUnknownKeys = (levels) => levels.map((level) => {
|
|
182
|
-
const qualify = (key) => truncate([...level.path, key].join("."));
|
|
183
|
-
const accepted = level.accepted.length === 0 ? "(none)" : level.accepted.join(", ");
|
|
184
|
-
return `Unrecognized parameter(s): ${level.unknown.map(qualify).join(", ")}. Accepted params: ${accepted}`;
|
|
185
|
-
}).join(" ");
|
|
186
|
-
/**
|
|
187
|
-
* MCP models `structuredContent` as a JSON object, so a `null`, array or
|
|
188
|
-
* primitive encoded result is omitted rather than sent through as-is.
|
|
189
|
-
*/
|
|
190
|
-
const toStructuredContent = (value) => isPlainObject(value) ? value : void 0;
|
|
191
|
-
const toolErrorResult = (message) => new McpSchema.CallToolResult({
|
|
192
|
-
isError: true,
|
|
193
|
-
content: [{
|
|
194
|
-
type: "text",
|
|
195
|
-
text: message
|
|
196
|
-
}]
|
|
197
|
-
});
|
|
198
|
-
const toolResultContent = (encoded) => encoded === void 0 ? [] : [{
|
|
199
|
-
type: "text",
|
|
200
|
-
text: JSON.stringify(encoded)
|
|
201
|
-
}];
|
|
202
|
-
const omitRequestServices = Context.omit(McpSchema.McpRequestContext, McpSchema.McpServerClient, HttpServerRequest.HttpServerRequest, References.CurrentLogLevel);
|
|
203
|
-
const isParameterValidationError = (error) => AiError.isAiError(error) && error.reason._tag === "ToolParameterValidationError";
|
|
204
|
-
/**
|
|
205
|
-
* The input JSON Schema a tool is served with: the document rc.116's
|
|
206
|
-
* `toolInputJsonSchema` builds for a strict tool (`Schema.toJsonSchemaDocument`
|
|
207
|
-
* with `onExcessProperty: "error"`, so every object node carries
|
|
208
|
-
* `additionalProperties: false`; definitions attached as `$defs`), made
|
|
209
|
-
* object-rooted by {@link objectRootedInputSchema}.
|
|
210
|
-
*
|
|
211
|
-
* @internal
|
|
212
|
-
*/
|
|
213
|
-
const servedInputJsonSchema = (schema) => {
|
|
214
|
-
const document = Schema.toJsonSchemaDocument(schema, { onExcessProperty: "error" });
|
|
215
|
-
return objectRootedInputSchema(Object.keys(document.definitions).length === 0 ? document.schema : {
|
|
216
|
-
...document.schema,
|
|
217
|
-
$defs: document.definitions
|
|
218
|
-
});
|
|
219
|
-
};
|
|
220
|
-
const envelopeResult = (toolName, err) => {
|
|
221
|
-
const envelope = buildUnexpectedToolErrorEnvelope(toolName, err);
|
|
222
|
-
return new McpSchema.CallToolResult({
|
|
223
|
-
isError: true,
|
|
224
|
-
structuredContent: envelope,
|
|
225
|
-
content: [{
|
|
226
|
-
type: "text",
|
|
227
|
-
text: JSON.stringify(envelope)
|
|
228
|
-
}]
|
|
229
|
-
});
|
|
230
|
-
};
|
|
231
|
-
/**
|
|
232
|
-
* Register every tool of `toolkit` with the ambient `McpServer` under the
|
|
233
|
-
* strict contract described in the module docs. A port of rc.116's
|
|
234
|
-
* `McpServer.registerToolkit`; the deviations are enumerated in the module
|
|
235
|
-
* header.
|
|
236
|
-
*
|
|
237
|
-
* @internal
|
|
238
|
-
*/
|
|
239
|
-
const registerStrictToolkitEffect = Effect.fnUntraced(function* (toolkit) {
|
|
240
|
-
const registry = yield* McpServer.McpServer;
|
|
241
|
-
const built = yield* toolkit.pipe(Effect.updateContext((context) => {
|
|
242
|
-
const services = new Map(context.mapUnsafe);
|
|
243
|
-
for (const tool of Object.values(toolkit.tools)) {
|
|
244
|
-
const handler = services.get(tool.id);
|
|
245
|
-
if (handler !== void 0) services.set(tool.id, {
|
|
246
|
-
...handler,
|
|
247
|
-
context: omitRequestServices(handler.context)
|
|
248
|
-
});
|
|
249
|
-
}
|
|
250
|
-
return Context.makeUnsafe(services);
|
|
251
|
-
}));
|
|
252
|
-
const services = omitRequestServices(yield* Effect.context());
|
|
253
|
-
const reportCause = (cause) => Effect.provideContext(ErrorReporter.report(cause), services);
|
|
254
|
-
const registrations = [];
|
|
255
|
-
for (const tool of Object.values(built.tools)) {
|
|
256
|
-
const internalToolError = (cause) => {
|
|
257
|
-
const failure = Cause.findFail(cause);
|
|
258
|
-
return Result.isFailure(failure) && !Cause.hasDies(cause) ? Effect.failCause(failure.failure) : Effect.logError(`tool ${tool.name} failed`, cause).pipe(Effect.andThen(reportCause(cause)), Effect.as(envelopeResult(tool.name, Result.isSuccess(failure) ? failure.success.error : Cause.squash(cause))));
|
|
259
|
-
};
|
|
260
|
-
if (Tool.isDynamic(tool)) return yield* Effect.die(`McpServer cannot strictly validate the raw JSON Schema for tool '${tool.name}'; use an Effect Schema instead`);
|
|
261
|
-
const decodeOptions = { onExcessProperty: "error" };
|
|
262
|
-
const annotations = tool.annotations;
|
|
263
|
-
const toolMeta = Context.getOrUndefined(annotations, Tool.Meta);
|
|
264
|
-
const isDeclaredFailure = Schema.is(tool.failureSchema);
|
|
265
|
-
const encodeFailure = Schema.encodeUnknownEffect(tool.failureSchema);
|
|
266
|
-
const declaredFailureResult = (error) => error instanceof Error ? Effect.succeed(toolErrorResult(error.message)) : Effect.map(encodeFailure(error), (encoded) => new McpSchema.CallToolResult({
|
|
267
|
-
isError: true,
|
|
268
|
-
content: toolResultContent(encoded)
|
|
269
|
-
}));
|
|
270
|
-
const handleCause = (cause) => {
|
|
271
|
-
const failure = Cause.findFail(cause);
|
|
272
|
-
if (Result.isSuccess(failure)) {
|
|
273
|
-
const error = failure.success.error;
|
|
274
|
-
const origin = Context.get(Cause.reasonAnnotations(failure.success), Toolkit.FailureOrigin);
|
|
275
|
-
if (origin === "parameters" && isParameterValidationError(error)) return Effect.fail(new McpSchema.InvalidParams({ message: error.reason.message }));
|
|
276
|
-
if (origin === "handler" && isDeclaredFailure(error)) return Effect.catchCause(declaredFailureResult(error), internalToolError);
|
|
277
|
-
}
|
|
278
|
-
return internalToolError(cause);
|
|
279
|
-
};
|
|
280
|
-
const outputJsonSchema = inlineRootRefs(Tool.getJsonSchemaFromSchema(tool.successSchema));
|
|
281
|
-
const outputSchema = outputJsonSchema.type === "object" ? yield* Schema.decodeUnknownEffect(McpSchema.ToolOutputJson)(outputJsonSchema).pipe(Effect.orDie) : void 0;
|
|
282
|
-
const servedInput = servedInputJsonSchema(tool.parametersSchema);
|
|
283
|
-
const inputSchema = yield* Schema.decodeUnknownEffect(McpSchema.ToolJson)(servedInput).pipe(Effect.orDie);
|
|
284
|
-
const description = Tool.getDescription(tool);
|
|
285
|
-
const mcpTool = new McpSchema.Tool({
|
|
286
|
-
name: tool.name,
|
|
287
|
-
...description === void 0 ? {} : { description },
|
|
288
|
-
inputSchema,
|
|
289
|
-
...outputSchema === void 0 ? {} : { outputSchema },
|
|
290
|
-
annotations: {
|
|
291
|
-
...Context.getOption(annotations, Tool.Title).pipe(Option.map((title) => ({ title })), Option.getOrUndefined),
|
|
292
|
-
readOnlyHint: Context.get(annotations, Tool.Readonly),
|
|
293
|
-
destructiveHint: Context.get(annotations, Tool.Destructive),
|
|
294
|
-
idempotentHint: Context.get(annotations, Tool.Idempotent),
|
|
295
|
-
openWorldHint: Context.get(annotations, Tool.OpenWorld)
|
|
296
|
-
},
|
|
297
|
-
...toolMeta === void 0 ? {} : { _meta: toolMeta }
|
|
298
|
-
});
|
|
299
|
-
registrations.push({
|
|
300
|
-
tool: mcpTool,
|
|
301
|
-
annotations,
|
|
302
|
-
handle(payload) {
|
|
303
|
-
const raw = payload ?? {};
|
|
304
|
-
const unknownKeys = collectUnknownKeys(raw, servedInput);
|
|
305
|
-
if (unknownKeys.length > 0) return Effect.fail(new McpSchema.InvalidParams({ message: formatUnknownKeys(unknownKeys) }));
|
|
306
|
-
return built.handle(tool.name, raw, void 0, decodeOptions).pipe(Stream.unwrap, Stream.runLast, Effect.flatMap(Effect.fromOption), Effect.flatMap((result) => result.isFailure && result.failureOrigin !== "handler" ? Effect.failCause(Cause.annotate(Cause.fail(result.result), Context.make(Toolkit.FailureOrigin, result.failureOrigin ?? "result"))) : Effect.succeed(new McpSchema.CallToolResult({
|
|
307
|
-
isError: result.isFailure,
|
|
308
|
-
structuredContent: result.isFailure ? void 0 : toStructuredContent(result.encodedResult),
|
|
309
|
-
content: toolResultContent(result.encodedResult)
|
|
310
|
-
}))), Effect.catchCause(handleCause), Effect.provideContext(services));
|
|
311
|
-
}
|
|
312
|
-
});
|
|
313
|
-
}
|
|
314
|
-
for (const registration of registrations) yield* registry.addTool(registration);
|
|
315
|
-
});
|
|
316
|
-
/**
|
|
317
|
-
* A layer that registers `toolkit` with the strict contract. Same shape
|
|
318
|
-
* as `McpServer.toolkit`: it requires the toolkit's handlers (from
|
|
319
|
-
* `kit.toLayer(handlers)`) and every service the tools declare in
|
|
320
|
-
* `dependencies`, and shares the memoized `McpServer` with the transport
|
|
321
|
-
* layer it is provided into.
|
|
322
|
-
*
|
|
323
|
-
* @public
|
|
324
|
-
*/
|
|
325
|
-
const registerStrictToolkit = (toolkit) => Layer.effectDiscard(registerStrictToolkitEffect(toolkit)).pipe(Layer.provide(McpServer.McpServer.layer));
|
|
326
|
-
|
|
327
|
-
//#endregion
|
|
328
|
-
export { collectUnknownKeys, inlineRootRefs, objectRootedInputSchema, registerStrictToolkit, registerStrictToolkitEffect, servedInputJsonSchema };
|
package/utils/crash-guards.js
DELETED
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
//#region src/utils/crash-guards.ts
|
|
2
|
-
/**
|
|
3
|
-
* @param transportConnected - whether `server.connect(transport)` has
|
|
4
|
-
* already resolved for this process
|
|
5
|
-
* @returns `true` when the process should exit rather than continue
|
|
6
|
-
* @public
|
|
7
|
-
*/
|
|
8
|
-
function shouldExitOnUncaughtException(transportConnected) {
|
|
9
|
-
return !transportConnected;
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
//#endregion
|
|
13
|
-
export { shouldExitOnUncaughtException };
|
|
@@ -1,61 +0,0 @@
|
|
|
1
|
-
//#region src/utils/tool-error-envelope.ts
|
|
2
|
-
/**
|
|
3
|
-
* Fixed fallback used when even *introspecting* the thrown value throws.
|
|
4
|
-
* Deliberately a constant — anything derived from `err` could throw again
|
|
5
|
-
* on the recovery path.
|
|
6
|
-
*/
|
|
7
|
-
const UNREADABLE_THROWN_VALUE = "<unreadable thrown value>";
|
|
8
|
-
/**
|
|
9
|
-
* Coerces an unknown thrown value into a display-safe message without
|
|
10
|
-
* risking a second throw (a getter-backed `.message` can itself throw —
|
|
11
|
-
* see `@vitest-agent/sdk`'s `coerceErrorField` for the same concern on
|
|
12
|
-
* raw Vitest error objects).
|
|
13
|
-
*
|
|
14
|
-
* The outer guard matters as much as the inner one: `err instanceof
|
|
15
|
-
* Error` walks the prototype chain, which a `Proxy` `getPrototypeOf`
|
|
16
|
-
* trap can hijack and throw from, and `String(err)` invokes
|
|
17
|
-
* `Symbol.toPrimitive` / `toString`, which a trap can hijack too. A
|
|
18
|
-
* throw here escapes the envelope builder entirely and the agent gets
|
|
19
|
-
* nothing structured back — the exact degradation this module exists to
|
|
20
|
-
* prevent (issue #243).
|
|
21
|
-
*/
|
|
22
|
-
function coerceThrownMessage(err) {
|
|
23
|
-
try {
|
|
24
|
-
if (typeof err === "string") return err;
|
|
25
|
-
if (err instanceof Error) try {
|
|
26
|
-
const message = err.message;
|
|
27
|
-
return typeof message === "string" ? message : String(message);
|
|
28
|
-
} catch {
|
|
29
|
-
return "<unreadable Error.message>";
|
|
30
|
-
}
|
|
31
|
-
return String(err);
|
|
32
|
-
} catch {
|
|
33
|
-
return UNREADABLE_THROWN_VALUE;
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
/**
|
|
37
|
-
* Builds the structured envelope a tool's catch-all wrapper returns
|
|
38
|
-
* when its handler throws unexpectedly.
|
|
39
|
-
*
|
|
40
|
-
* @param toolName - the MCP tool name under which the handler was registered
|
|
41
|
-
* @param err - the value thrown or the rejection reason
|
|
42
|
-
* @public
|
|
43
|
-
*/
|
|
44
|
-
function buildUnexpectedToolErrorEnvelope(toolName, err) {
|
|
45
|
-
return {
|
|
46
|
-
ok: false,
|
|
47
|
-
error: {
|
|
48
|
-
_tag: "UnexpectedToolError",
|
|
49
|
-
tool: toolName,
|
|
50
|
-
message: coerceThrownMessage(err),
|
|
51
|
-
remediation: {
|
|
52
|
-
suggestedTool: toolName,
|
|
53
|
-
suggestedArgs: {},
|
|
54
|
-
humanHint: `The "${toolName}" tool's handler threw before producing a result (unrelated to your input in most cases). Retry the call; if it persists, check the MCP server's stderr for the logged error.`
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
};
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
//#endregion
|
|
61
|
-
export { buildUnexpectedToolErrorEnvelope };
|