@mandujs/mcp 0.38.2 → 0.38.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mandujs/mcp",
3
- "version": "0.38.2",
3
+ "version": "0.38.4",
4
4
  "description": "Mandu MCP Server - Agent-native interface for Mandu framework operations",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -34,7 +34,7 @@
34
34
  "access": "public"
35
35
  },
36
36
  "dependencies": {
37
- "@mandujs/core": "^0.54.8",
37
+ "@mandujs/core": "^0.54.10",
38
38
  "@mandujs/ate": "^0.26.1",
39
39
  "@mandujs/skills": "^0.20.1",
40
40
  "@modelcontextprotocol/sdk": "^1.25.3"
@@ -35,6 +35,23 @@ export interface McpErrorResponse {
35
35
  export interface McpToolResponse {
36
36
  content: Array<{ type: string; text: string }>;
37
37
  isError?: boolean;
38
+ _meta?: McpResponseMeta;
39
+ }
40
+
41
+ export type McpNextActionKind = "none" | "inspect" | "verify" | "repair" | "retry";
42
+
43
+ export interface McpNextAction {
44
+ kind: McpNextActionKind;
45
+ reason: string;
46
+ command?: string;
47
+ tool?: string;
48
+ input?: unknown;
49
+ }
50
+
51
+ export interface McpResponseMeta {
52
+ toolName: string;
53
+ ok: boolean;
54
+ nextAction: McpNextAction;
38
55
  }
39
56
 
40
57
  /**
@@ -186,6 +203,51 @@ function isSoftErrorResult(result: unknown): boolean {
186
203
  return false;
187
204
  }
188
205
 
206
+ function inferNextAction(toolName: string, result: unknown, errorResponse?: McpErrorResponse): McpNextAction {
207
+ if (errorResponse) {
208
+ return {
209
+ kind: errorResponse.retryable ? "retry" : "repair",
210
+ reason: errorResponse.suggestion ?? errorResponse.error,
211
+ };
212
+ }
213
+
214
+ if (result && typeof result === "object") {
215
+ const obj = result as Record<string, unknown>;
216
+ if (isSoftErrorResult(result)) {
217
+ return { kind: "repair", reason: String(obj.error ?? "Tool returned an error result.") };
218
+ }
219
+ if (typeof obj.nextVerifyCommand === "string") {
220
+ return { kind: "verify", reason: "Tool returned an explicit verification command.", command: obj.nextVerifyCommand };
221
+ }
222
+ if (typeof obj.nextRepairInput === "string") {
223
+ return {
224
+ kind: "repair",
225
+ reason: "Tool returned a repair input artifact.",
226
+ command: `mandu agent repair --from ${obj.nextRepairInput}`,
227
+ input: obj.nextRepairInput,
228
+ };
229
+ }
230
+ if (obj.dryRun === true) {
231
+ return { kind: "inspect", reason: "Dry-run completed; review the preview before applying changes." };
232
+ }
233
+ if (Array.isArray(obj.nextSteps) && obj.nextSteps.length > 0) {
234
+ return { kind: "inspect", reason: "Tool returned follow-up steps.", input: obj.nextSteps };
235
+ }
236
+ if (typeof obj.tip === "string") {
237
+ return { kind: "inspect", reason: obj.tip };
238
+ }
239
+ }
240
+
241
+ return { kind: "none", reason: `${toolName} completed successfully.` };
242
+ }
243
+
244
+ function attachMeta(payload: unknown, meta: McpResponseMeta): unknown {
245
+ if (payload && typeof payload === "object" && !Array.isArray(payload)) {
246
+ return { ...(payload as Record<string, unknown>), _meta: meta };
247
+ }
248
+ return { data: payload, _meta: meta };
249
+ }
250
+
189
251
  export function createToolResponse(
190
252
  toolName: string,
191
253
  result: unknown,
@@ -193,28 +255,40 @@ export function createToolResponse(
193
255
  ): McpToolResponse {
194
256
  if (error) {
195
257
  const errorResponse = formatMcpError(error, toolName);
258
+ const meta: McpResponseMeta = {
259
+ toolName,
260
+ ok: false,
261
+ nextAction: inferNextAction(toolName, null, errorResponse),
262
+ };
196
263
  return {
197
264
  content: [
198
265
  {
199
266
  type: "text",
200
- text: JSON.stringify(errorResponse, null, 2),
267
+ text: JSON.stringify(attachMeta(errorResponse, meta), null, 2),
201
268
  },
202
269
  ],
203
270
  isError: true,
271
+ _meta: meta,
204
272
  };
205
273
  }
206
274
 
207
275
  // Detect soft errors returned by handlers (e.g. { error: "Route not found" })
208
276
  const softError = isSoftErrorResult(result);
277
+ const meta: McpResponseMeta = {
278
+ toolName,
279
+ ok: !softError,
280
+ nextAction: inferNextAction(toolName, result),
281
+ };
209
282
 
210
283
  return {
211
284
  content: [
212
285
  {
213
286
  type: "text",
214
- text: JSON.stringify(result, null, 2),
287
+ text: JSON.stringify(attachMeta(result, meta), null, 2),
215
288
  },
216
289
  ],
217
290
  ...(softError && { isError: true }),
291
+ _meta: meta,
218
292
  };
219
293
  }
220
294
 
package/src/profiles.ts CHANGED
@@ -1,57 +1,119 @@
1
- /**
2
- * MCP Tool Profiles
3
- *
4
- * Controls how many tool categories are exposed to AI agents.
5
- * - agent-core: Canonical agent workflow plus docs grounding (default)
6
- * - agent-full: Agent workflow plus Mandu domain tools
7
- * - internal: All categories, no filtering
8
- */
9
-
10
- export type McpProfile = "agent-core" | "agent-full" | "internal";
11
-
12
- export const PROFILE_CATEGORIES: Record<McpProfile, string[] | null> = {
13
- "agent-core": ["agent", "docs"],
14
- "agent-full": [
15
- "agent",
16
- "docs",
17
- "spec",
18
- "generate",
19
- "slot",
20
- "slot-validation",
21
- "hydration",
22
- "contract",
23
- "guard",
24
- "run-tests",
25
- "lint",
26
- ],
27
- internal: null,
28
- };
1
+ /**
2
+ * MCP Tool Profiles
3
+ *
4
+ * Controls how many tool categories are exposed to AI agents.
5
+ * - agent-core: Canonical agent workflow plus docs grounding (default)
6
+ * - agent-full: Agent workflow plus Mandu domain tools
7
+ * - internal: All categories, no filtering
8
+ */
9
+
10
+ export type McpProfile = "agent-core" | "agent-full" | "internal";
11
+
12
+ export const PROFILE_CATEGORIES: Record<McpProfile, string[] | null> = {
13
+ "agent-core": ["agent", "docs"],
14
+ "agent-full": [
15
+ // Official agent loop
16
+ "agent",
17
+ "docs",
18
+ // Code generation / scaffolding
19
+ "spec",
20
+ "generate",
21
+ "composite",
22
+ // Domain primitives
23
+ "slot",
24
+ "slot-validation",
25
+ "hydration",
26
+ "contract",
27
+ "design",
28
+ "seo",
29
+ // Quality / validation
30
+ "guard",
31
+ "lint",
32
+ "run-tests",
33
+ "ate",
34
+ // Deploy
35
+ "deploy-plan",
36
+ "deploy-preview",
37
+ // AI refactor
38
+ "refactor-barrel",
39
+ "refactor-routes",
40
+ "refactor-contract",
41
+ ],
42
+ internal: null,
43
+ };
44
+
45
+ /**
46
+ * Categories intentionally hidden from every agent-facing profile.
47
+ *
48
+ * Every new tool category in `TOOL_MODULES` MUST be classified into one of:
49
+ * - `PROFILE_CATEGORIES["agent-core"]` — canonical agent loop
50
+ * - `PROFILE_CATEGORIES["agent-full"]` — domain work for agents
51
+ * - `EXPERT_ONLY_CATEGORIES` — internal plumbing
52
+ *
53
+ * `profile-coverage.test.ts` fails CI if a new category is left unclassified,
54
+ * preventing silent default-profile bloat over time.
55
+ */
56
+ export const EXPERT_ONLY_CATEGORIES: ReadonlySet<string> = new Set([
57
+ // Transactional state / framework internals
58
+ "transaction",
59
+ "history",
60
+ "decisions",
61
+ "negotiate",
62
+ // Runtime / project introspection
63
+ "brain",
64
+ "runtime",
65
+ "project",
66
+ "resource",
67
+ // Devtools / kitchen
68
+ "kitchen",
69
+ "component",
70
+ // Agent loop internal helpers (used by mandu.agent.*, not by agents directly)
71
+ "ai-brief",
72
+ "loop-close",
73
+ // Specialized ATE phases (core "ate" is exposed in agent-full)
74
+ "ate-phase5",
75
+ "ate-context",
76
+ "ate-run",
77
+ "ate-flakes",
78
+ "ate-prompt",
79
+ "ate-exemplar",
80
+ "ate-save",
81
+ "ate-boundary-probe",
82
+ "ate-recall",
83
+ "ate-remember",
84
+ "ate-coverage",
85
+ "ate-mutate",
86
+ "ate-mutation-report",
87
+ "ate-oracle-pending",
88
+ "ate-oracle-verdict",
89
+ "ate-oracle-replay",
90
+ ]);
29
91
 
30
92
  /**
31
93
  * Returns allowed category names for a profile, or null if all categories are allowed.
32
94
  */
33
- export function getProfileCategories(profile: McpProfile): string[] | null {
34
- return PROFILE_CATEGORIES[profile] ?? null;
35
- }
36
-
95
+ export function getProfileCategories(profile: McpProfile): string[] | null {
96
+ return PROFILE_CATEGORIES[profile] ?? null;
97
+ }
98
+
37
99
  /**
38
100
  * Type guard for valid profile strings.
39
101
  */
40
- export function isValidProfile(value: string): value is McpProfile {
41
- return value === "agent-core" || value === "agent-full" || value === "internal";
42
- }
43
-
44
- /**
45
- * Resolve current and legacy profile names to the new official profile set.
46
- */
47
- export function resolveMcpProfile(
48
- value: string | undefined,
49
- fallback: McpProfile = "agent-core",
50
- ): McpProfile {
51
- if (!value) return fallback;
52
- if (isValidProfile(value)) return value;
53
- if (value === "minimal") return "agent-core";
54
- if (value === "standard") return "agent-full";
55
- if (value === "full") return "internal";
56
- return fallback;
57
- }
102
+ export function isValidProfile(value: string): value is McpProfile {
103
+ return value === "agent-core" || value === "agent-full" || value === "internal";
104
+ }
105
+
106
+ /**
107
+ * Resolve current and legacy profile names to the new official profile set.
108
+ */
109
+ export function resolveMcpProfile(
110
+ value: string | undefined,
111
+ fallback: McpProfile = "agent-core",
112
+ ): McpProfile {
113
+ if (!value) return fallback;
114
+ if (isValidProfile(value)) return value;
115
+ if (value === "minimal") return "agent-core";
116
+ if (value === "standard") return "agent-full";
117
+ if (value === "full") return "internal";
118
+ return fallback;
119
+ }
@@ -138,7 +138,25 @@ export function guardTools(projectRoot: string) {
138
138
  }
139
139
  };
140
140
 
141
+ const guardViolationCode = (ruleId: string | undefined, type?: string) =>
142
+ `MANDU_GUARD_${(ruleId || type || "VIOLATION").toUpperCase().replace(/[^A-Z0-9]+/g, "_")}`;
143
+
144
+ const explainViolation = (input: {
145
+ type?: string;
146
+ ruleId?: string;
147
+ fromLayer?: string;
148
+ toLayer?: string;
149
+ suggestion?: string;
150
+ }): string => {
151
+ const source = input.fromLayer ? ` from ${input.fromLayer}` : "";
152
+ const target = input.toLayer ? ` to ${input.toLayer}` : "";
153
+ const rule = input.ruleId ?? input.type ?? "architecture rule";
154
+ const fix = input.suggestion ? ` Suggested fix: ${input.suggestion}` : "";
155
+ return `Violation of ${rule}${source}${target}.${fix}`.trim();
156
+ };
157
+
141
158
  const summarizeArchitectureViolation = (violation: Violation) => ({
159
+ code: guardViolationCode(violation.ruleName, violation.type),
142
160
  ruleId: violation.ruleName,
143
161
  type: violation.type,
144
162
  file: path.relative(projectRoot, violation.filePath).replace(/\\/g, "/") || violation.filePath,
@@ -146,16 +164,25 @@ export function guardTools(projectRoot: string) {
146
164
  column: violation.column,
147
165
  message: violation.ruleDescription,
148
166
  suggestion: violation.suggestions[0],
167
+ explanation: explainViolation({
168
+ type: violation.type,
169
+ ruleId: violation.ruleName,
170
+ fromLayer: violation.fromLayer,
171
+ toLayer: violation.toLayer,
172
+ suggestion: violation.suggestions[0],
173
+ }),
149
174
  fromLayer: violation.fromLayer,
150
175
  toLayer: violation.toLayer,
151
176
  importStatement: violation.importStatement,
152
177
  });
153
178
 
154
179
  const summarizeLegacyViolation = (v: Awaited<ReturnType<typeof runGuardCheck>>["violations"][number]) => ({
180
+ code: guardViolationCode(v.ruleId),
155
181
  ruleId: v.ruleId,
156
182
  file: v.file,
157
183
  message: v.message,
158
184
  suggestion: v.suggestion,
185
+ explanation: explainViolation({ ruleId: v.ruleId, suggestion: v.suggestion }),
159
186
  });
160
187
 
161
188
  const handlers: Record<string, (args: Record<string, unknown>) => Promise<unknown>> = {
@@ -521,10 +548,18 @@ export function guardTools(projectRoot: string) {
521
548
  preset: config.preset,
522
549
  violations: items.map((item) => ({
523
550
  // Violation info
551
+ code: guardViolationCode(item.violation.ruleName, item.violation.type),
524
552
  type: item.violation.type,
525
553
  file: item.violation.filePath,
526
554
  line: item.violation.line,
527
555
  message: item.violation.ruleDescription,
556
+ explanation: explainViolation({
557
+ type: item.violation.type,
558
+ ruleId: item.violation.ruleName,
559
+ fromLayer: item.violation.fromLayer,
560
+ toLayer: item.violation.toLayer,
561
+ suggestion: item.healing.primary.explanation,
562
+ }),
528
563
  fromLayer: item.violation.fromLayer,
529
564
  toLayer: item.violation.toLayer,
530
565
  importStatement: item.violation.importStatement,
@@ -290,9 +290,10 @@ export const TOOL_MODULES: ToolModule[] = [
290
290
  export function validateBuiltinToolModules(
291
291
  modules: readonly ToolModule[] = TOOL_MODULES
292
292
  ): string[] {
293
- const issues: string[] = [];
294
- const categories = new Set<string>();
295
- const toolNames = new Map<string, string>();
293
+ const issues: string[] = [];
294
+ const categories = new Set<string>();
295
+ const toolNames = new Map<string, string>();
296
+ const descriptions = new Map<string, string>();
296
297
 
297
298
  for (const module of modules) {
298
299
  if (categories.has(module.category)) {
@@ -305,7 +306,20 @@ export function validateBuiltinToolModules(
305
306
  }
306
307
 
307
308
  for (const definition of module.definitions) {
308
- const previousCategory = toolNames.get(definition.name);
309
+ if (!definition.description?.trim()) {
310
+ issues.push(`tool definition is missing description: ${definition.name} in ${module.category}`);
311
+ }
312
+ const normalizedDescription = definition.description?.trim().replace(/\s+/g, " ").toLowerCase();
313
+ if (normalizedDescription) {
314
+ const previousTool = descriptions.get(normalizedDescription);
315
+ if (previousTool) {
316
+ issues.push(
317
+ `duplicate tool description: ${definition.name} and ${previousTool} both describe the same action`
318
+ );
319
+ }
320
+ descriptions.set(normalizedDescription, definition.name);
321
+ }
322
+ const previousCategory = toolNames.get(definition.name);
309
323
  if (previousCategory) {
310
324
  issues.push(
311
325
  `duplicate tool definition: ${definition.name} in ${previousCategory} and ${module.category}`
@@ -6,7 +6,7 @@ import {
6
6
  getTransactionStatus,
7
7
  hasActiveTransaction,
8
8
  } from "@mandujs/core";
9
- import { acquireLock, releaseLock, checkLock } from "../tx-lock.js";
9
+ import { acquireLock, releaseLock, checkLock, requireLock } from "../tx-lock.js";
10
10
 
11
11
  export const transactionToolDefinitions: Tool[] = [
12
12
  {
@@ -99,9 +99,15 @@ export function transactionTools(projectRoot: string) {
99
99
  return { error: lock.error };
100
100
  }
101
101
 
102
- const change = await beginChange(projectRoot, {
103
- message: message || "MCP transaction",
104
- });
102
+ let change: Awaited<ReturnType<typeof beginChange>>;
103
+ try {
104
+ change = await beginChange(projectRoot, {
105
+ message: message || "MCP transaction",
106
+ });
107
+ } catch (error) {
108
+ if (lock.lockId) releaseLock(lock.lockId);
109
+ return { error: error instanceof Error ? error.message : String(error) };
110
+ }
105
111
 
106
112
  return {
107
113
  success: true,
@@ -122,6 +128,10 @@ export function transactionTools(projectRoot: string) {
122
128
  error: "No active transaction to commit",
123
129
  };
124
130
  }
131
+ const lockCheck = requireLock(lockId);
132
+ if (!lockCheck.allowed) {
133
+ return { error: lockCheck.error };
134
+ }
125
135
 
126
136
  const result = await commitChange(projectRoot);
127
137
  if (lockId) releaseLock(lockId);
@@ -142,6 +152,10 @@ export function transactionTools(projectRoot: string) {
142
152
  error: "No active transaction to rollback. Provide a changeId to rollback a specific change.",
143
153
  };
144
154
  }
155
+ const lockCheck = requireLock(lockId);
156
+ if (!lockCheck.allowed) {
157
+ return { error: lockCheck.error };
158
+ }
145
159
 
146
160
  const result = await rollbackChange(projectRoot, changeId);
147
161
  if (lockId) releaseLock(lockId);