@mandujs/mcp 0.9.16 → 0.9.18

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.9.16",
3
+ "version": "0.9.18",
4
4
  "description": "Mandu MCP Server - Agent-native interface for Mandu framework operations",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -32,7 +32,7 @@
32
32
  "access": "public"
33
33
  },
34
34
  "dependencies": {
35
- "@mandujs/core": "^0.9.16",
35
+ "@mandujs/core": "^0.9.25",
36
36
  "@modelcontextprotocol/sdk": "^1.25.3"
37
37
  },
38
38
  "engines": {
@@ -1,231 +1,231 @@
1
- /**
2
- * Mandu Activity Monitor
3
- *
4
- * Real-time terminal dashboard for MCP server activity.
5
- * Opens automatically when the MCP server starts.
6
- * Shows all tool calls, watch events, errors, and agent behavior.
7
- */
8
-
9
- import fs from "fs";
10
- import path from "path";
11
- import { spawn, type ChildProcess } from "child_process";
12
-
13
- const TOOL_ICONS: Record<string, string> = {
14
- // Spec
15
- mandu_list_routes: "SPEC",
16
- mandu_get_route: "SPEC",
17
- mandu_add_route: "SPEC+",
18
- mandu_update_route: "SPEC~",
19
- mandu_delete_route: "SPEC-",
20
- mandu_validate_spec: "SPEC?",
21
- // Generate
22
- mandu_generate: "GEN",
23
- mandu_generate_status: "GEN?",
24
- // Guard
25
- mandu_guard_check: "GUARD",
26
- // Slot
27
- mandu_read_slot: "SLOT",
28
- mandu_write_slot: "SLOT~",
29
- mandu_validate_slot: "SLOT?",
30
- // Contract
31
- mandu_list_contracts: "CONTRACT",
32
- mandu_get_contract: "CONTRACT",
33
- mandu_create_contract: "CONTRACT+",
34
- mandu_validate_contracts: "CONTRACT?",
35
- mandu_sync_contract_slot: "SYNC",
36
- mandu_generate_openapi: "OPENAPI",
37
- mandu_update_route_contract: "CONTRACT~",
38
- // Transaction
39
- mandu_begin: "TX-BEGIN",
40
- mandu_commit: "TX-COMMIT",
41
- mandu_rollback: "TX-ROLLBACK",
42
- mandu_tx_status: "TX?",
43
- // History
44
- mandu_list_history: "HISTORY",
45
- mandu_get_snapshot: "SNAPSHOT",
46
- mandu_prune_history: "PRUNE",
47
- // Brain
48
- mandu_doctor: "DOCTOR",
49
- mandu_watch_start: "WATCH+",
50
- mandu_watch_status: "WATCH?",
51
- mandu_watch_stop: "WATCH-",
52
- mandu_check_location: "ARCH?",
53
- mandu_check_import: "IMPORT?",
54
- mandu_get_architecture: "ARCH",
55
- // Build
56
- mandu_build: "BUILD",
57
- mandu_build_status: "BUILD?",
58
- mandu_list_islands: "ISLAND",
59
- mandu_set_hydration: "HYDRA~",
60
- mandu_add_client_slot: "CLIENT+",
61
- // Error
62
- mandu_analyze_error: "ERROR",
63
- };
64
-
65
- function getTime(): string {
66
- return new Date().toLocaleTimeString("ko-KR", { hour12: false });
67
- }
68
-
69
- function summarizeArgs(args: Record<string, unknown> | null | undefined): string {
70
- if (!args || Object.keys(args).length === 0) return "";
71
- const entries = Object.entries(args)
72
- .filter(([_, v]) => v !== undefined && v !== null)
73
- .map(([k, v]) => {
74
- const val = typeof v === "string"
75
- ? (v.length > 40 ? v.slice(0, 40) + "..." : v)
76
- : JSON.stringify(v);
77
- return `${k}=${val}`;
78
- });
79
- return entries.length > 0 ? ` (${entries.join(", ")})` : "";
80
- }
81
-
82
- function summarizeResult(result: unknown): string {
83
- if (!result || typeof result !== "object") return "";
84
- const obj = result as Record<string, unknown>;
85
-
86
- // Common patterns
87
- if (obj.error) return ` >> ERROR: ${obj.error}`;
88
- if (obj.success === true) return obj.message ? ` >> ${obj.message}` : " >> OK";
89
- if (obj.success === false) return ` >> FAILED: ${obj.message || "unknown"}`;
90
- if (Array.isArray(obj.routes)) return ` >> ${obj.routes.length} routes`;
91
- if (obj.passed === true) return obj.message ? ` >> ${obj.message}` : " >> PASSED";
92
- if (obj.passed === false) return ` >> FAILED (${(obj.violations as unknown[])?.length || 0} violations)`;
93
- if (obj.valid === true) return obj.message ? ` >> ${obj.message}` : " >> VALID";
94
- if (obj.valid === false) return ` >> INVALID (${(obj.violations as unknown[])?.length || 0} violations)`;
95
- if (obj.generated) return " >> Generated";
96
- if (obj.status) return ` >> ${JSON.stringify(obj.status).slice(0, 60)}`;
97
-
98
- return "";
99
- }
100
-
101
- export class ActivityMonitor {
102
- private logFile: string;
103
- private logStream: fs.WriteStream | null = null;
104
- private tailProcess: ChildProcess | null = null;
105
- private projectRoot: string;
106
- private callCount = 0;
107
-
108
- constructor(projectRoot: string) {
109
- this.projectRoot = projectRoot;
110
- const manduDir = path.join(projectRoot, ".mandu");
111
- if (!fs.existsSync(manduDir)) {
112
- fs.mkdirSync(manduDir, { recursive: true });
113
- }
114
- this.logFile = path.join(manduDir, "activity.log");
115
- }
116
-
117
- start(): void {
118
- // Create/overwrite log file
119
- this.logStream = fs.createWriteStream(this.logFile, { flags: "w" });
120
-
121
- const time = getTime();
122
- const header =
123
- `\n` +
124
- ` ╔══════════════════════════════════════════════╗\n` +
125
- ` ║ MANDU MCP Activity Monitor ║\n` +
126
- ` ║ ║\n` +
127
- ` ║ ${time} ║\n` +
128
- ` ║ ${this.projectRoot.slice(-40).padEnd(40)} ║\n` +
129
- ` ╚══════════════════════════════════════════════╝\n\n`;
130
-
131
- this.logStream.write(header);
132
-
133
- // Auto-open terminal
134
- this.openTerminal();
135
- }
136
-
137
- stop(): void {
138
- if (this.tailProcess) {
139
- this.tailProcess.kill();
140
- this.tailProcess = null;
141
- }
142
- if (this.logStream) {
143
- this.logStream.end();
144
- this.logStream = null;
145
- }
146
- }
147
-
148
- /**
149
- * Log a tool call (invocation)
150
- */
151
- logTool(
152
- name: string,
153
- args?: Record<string, unknown> | null,
154
- _result?: unknown,
155
- error?: string,
156
- ): void {
157
- this.callCount++;
158
- const time = getTime();
159
- const tag = TOOL_ICONS[name] || name.replace("mandu_", "").toUpperCase();
160
- const argsStr = summarizeArgs(args);
161
-
162
- let line: string;
163
- if (error) {
164
- line = `${time} ✗ [${tag}]${argsStr}\n ERROR: ${error}\n`;
165
- } else {
166
- line = `${time} → [${tag}]${argsStr}\n`;
167
- }
168
-
169
- this.write(line);
170
- }
171
-
172
- /**
173
- * Log a tool result
174
- */
175
- logResult(name: string, result: unknown): void {
176
- const time = getTime();
177
- const tag = TOOL_ICONS[name] || name.replace("mandu_", "").toUpperCase();
178
- const summary = summarizeResult(result);
179
-
180
- if (summary) {
181
- this.write(`${time} ✓ [${tag}]${summary}\n`);
182
- }
183
- }
184
-
185
- /**
186
- * Log a watch event (called from watcher)
187
- */
188
- logWatch(level: string, ruleId: string, file: string, message: string): void {
189
- const time = getTime();
190
- const icon = level === "info" ? "ℹ" : "⚠";
191
- this.write(`${time} ${icon} [WATCH:${ruleId}] ${file}\n ${message}\n`);
192
- }
193
-
194
- /**
195
- * Log a custom event
196
- */
197
- logEvent(category: string, message: string): void {
198
- const time = getTime();
199
- this.write(`${time} [${category}] ${message}\n`);
200
- }
201
-
202
- private write(text: string): void {
203
- if (this.logStream) {
204
- this.logStream.write(text);
205
- }
206
- }
207
-
208
- private openTerminal(): void {
209
- try {
210
- if (process.platform === "win32") {
211
- this.tailProcess = spawn("cmd", [
212
- "/c", "start",
213
- "Mandu Activity Monitor",
214
- "powershell", "-NoExit", "-Command",
215
- `[Console]::OutputEncoding = [System.Text.Encoding]::UTF8; chcp 65001 | Out-Null; Get-Content '${this.logFile}' -Wait -Encoding UTF8`,
216
- ], { cwd: this.projectRoot, detached: true, stdio: "ignore" });
217
- } else if (process.platform === "darwin") {
218
- this.tailProcess = spawn("osascript", [
219
- "-e", `tell application "Terminal" to do script "tail -f '${this.logFile}'"`,
220
- ], { detached: true, stdio: "ignore" });
221
- } else {
222
- this.tailProcess = spawn("x-terminal-emulator", [
223
- "-e", `tail -f '${this.logFile}'`,
224
- ], { cwd: this.projectRoot, detached: true, stdio: "ignore" });
225
- }
226
- this.tailProcess?.unref();
227
- } catch {
228
- // Terminal auto-open failed silently
229
- }
230
- }
231
- }
1
+ /**
2
+ * Mandu Activity Monitor
3
+ *
4
+ * Real-time terminal dashboard for MCP server activity.
5
+ * Opens automatically when the MCP server starts.
6
+ * Shows all tool calls, watch events, errors, and agent behavior.
7
+ */
8
+
9
+ import fs from "fs";
10
+ import path from "path";
11
+ import { spawn, type ChildProcess } from "child_process";
12
+
13
+ const TOOL_ICONS: Record<string, string> = {
14
+ // Spec
15
+ mandu_list_routes: "SPEC",
16
+ mandu_get_route: "SPEC",
17
+ mandu_add_route: "SPEC+",
18
+ mandu_update_route: "SPEC~",
19
+ mandu_delete_route: "SPEC-",
20
+ mandu_validate_spec: "SPEC?",
21
+ // Generate
22
+ mandu_generate: "GEN",
23
+ mandu_generate_status: "GEN?",
24
+ // Guard
25
+ mandu_guard_check: "GUARD",
26
+ // Slot
27
+ mandu_read_slot: "SLOT",
28
+ mandu_write_slot: "SLOT~",
29
+ mandu_validate_slot: "SLOT?",
30
+ // Contract
31
+ mandu_list_contracts: "CONTRACT",
32
+ mandu_get_contract: "CONTRACT",
33
+ mandu_create_contract: "CONTRACT+",
34
+ mandu_validate_contracts: "CONTRACT?",
35
+ mandu_sync_contract_slot: "SYNC",
36
+ mandu_generate_openapi: "OPENAPI",
37
+ mandu_update_route_contract: "CONTRACT~",
38
+ // Transaction
39
+ mandu_begin: "TX-BEGIN",
40
+ mandu_commit: "TX-COMMIT",
41
+ mandu_rollback: "TX-ROLLBACK",
42
+ mandu_tx_status: "TX?",
43
+ // History
44
+ mandu_list_history: "HISTORY",
45
+ mandu_get_snapshot: "SNAPSHOT",
46
+ mandu_prune_history: "PRUNE",
47
+ // Brain
48
+ mandu_doctor: "DOCTOR",
49
+ mandu_watch_start: "WATCH+",
50
+ mandu_watch_status: "WATCH?",
51
+ mandu_watch_stop: "WATCH-",
52
+ mandu_check_location: "ARCH?",
53
+ mandu_check_import: "IMPORT?",
54
+ mandu_get_architecture: "ARCH",
55
+ // Build
56
+ mandu_build: "BUILD",
57
+ mandu_build_status: "BUILD?",
58
+ mandu_list_islands: "ISLAND",
59
+ mandu_set_hydration: "HYDRA~",
60
+ mandu_add_client_slot: "CLIENT+",
61
+ // Error
62
+ mandu_analyze_error: "ERROR",
63
+ };
64
+
65
+ function getTime(): string {
66
+ return new Date().toLocaleTimeString("ko-KR", { hour12: false });
67
+ }
68
+
69
+ function summarizeArgs(args: Record<string, unknown> | null | undefined): string {
70
+ if (!args || Object.keys(args).length === 0) return "";
71
+ const entries = Object.entries(args)
72
+ .filter(([_, v]) => v !== undefined && v !== null)
73
+ .map(([k, v]) => {
74
+ const val = typeof v === "string"
75
+ ? (v.length > 40 ? v.slice(0, 40) + "..." : v)
76
+ : JSON.stringify(v);
77
+ return `${k}=${val}`;
78
+ });
79
+ return entries.length > 0 ? ` (${entries.join(", ")})` : "";
80
+ }
81
+
82
+ function summarizeResult(result: unknown): string {
83
+ if (!result || typeof result !== "object") return "";
84
+ const obj = result as Record<string, unknown>;
85
+
86
+ // Common patterns
87
+ if (obj.error) return ` >> ERROR: ${obj.error}`;
88
+ if (obj.success === true) return obj.message ? ` >> ${obj.message}` : " >> OK";
89
+ if (obj.success === false) return ` >> FAILED: ${obj.message || "unknown"}`;
90
+ if (Array.isArray(obj.routes)) return ` >> ${obj.routes.length} routes`;
91
+ if (obj.passed === true) return obj.message ? ` >> ${obj.message}` : " >> PASSED";
92
+ if (obj.passed === false) return ` >> FAILED (${(obj.violations as unknown[])?.length || 0} violations)`;
93
+ if (obj.valid === true) return obj.message ? ` >> ${obj.message}` : " >> VALID";
94
+ if (obj.valid === false) return ` >> INVALID (${(obj.violations as unknown[])?.length || 0} violations)`;
95
+ if (obj.generated) return " >> Generated";
96
+ if (obj.status) return ` >> ${JSON.stringify(obj.status).slice(0, 60)}`;
97
+
98
+ return "";
99
+ }
100
+
101
+ export class ActivityMonitor {
102
+ private logFile: string;
103
+ private logStream: fs.WriteStream | null = null;
104
+ private tailProcess: ChildProcess | null = null;
105
+ private projectRoot: string;
106
+ private callCount = 0;
107
+
108
+ constructor(projectRoot: string) {
109
+ this.projectRoot = projectRoot;
110
+ const manduDir = path.join(projectRoot, ".mandu");
111
+ if (!fs.existsSync(manduDir)) {
112
+ fs.mkdirSync(manduDir, { recursive: true });
113
+ }
114
+ this.logFile = path.join(manduDir, "activity.log");
115
+ }
116
+
117
+ start(): void {
118
+ // Create/overwrite log file
119
+ this.logStream = fs.createWriteStream(this.logFile, { flags: "w" });
120
+
121
+ const time = getTime();
122
+ const header =
123
+ `\n` +
124
+ ` ╔══════════════════════════════════════════════╗\n` +
125
+ ` ║ MANDU MCP Activity Monitor ║\n` +
126
+ ` ║ ║\n` +
127
+ ` ║ ${time} ║\n` +
128
+ ` ║ ${this.projectRoot.slice(-40).padEnd(40)} ║\n` +
129
+ ` ╚══════════════════════════════════════════════╝\n\n`;
130
+
131
+ this.logStream.write(header);
132
+
133
+ // Auto-open terminal
134
+ this.openTerminal();
135
+ }
136
+
137
+ stop(): void {
138
+ if (this.tailProcess) {
139
+ this.tailProcess.kill();
140
+ this.tailProcess = null;
141
+ }
142
+ if (this.logStream) {
143
+ this.logStream.end();
144
+ this.logStream = null;
145
+ }
146
+ }
147
+
148
+ /**
149
+ * Log a tool call (invocation)
150
+ */
151
+ logTool(
152
+ name: string,
153
+ args?: Record<string, unknown> | null,
154
+ _result?: unknown,
155
+ error?: string,
156
+ ): void {
157
+ this.callCount++;
158
+ const time = getTime();
159
+ const tag = TOOL_ICONS[name] || name.replace("mandu_", "").toUpperCase();
160
+ const argsStr = summarizeArgs(args);
161
+
162
+ let line: string;
163
+ if (error) {
164
+ line = `${time} ✗ [${tag}]${argsStr}\n ERROR: ${error}\n`;
165
+ } else {
166
+ line = `${time} → [${tag}]${argsStr}\n`;
167
+ }
168
+
169
+ this.write(line);
170
+ }
171
+
172
+ /**
173
+ * Log a tool result
174
+ */
175
+ logResult(name: string, result: unknown): void {
176
+ const time = getTime();
177
+ const tag = TOOL_ICONS[name] || name.replace("mandu_", "").toUpperCase();
178
+ const summary = summarizeResult(result);
179
+
180
+ if (summary) {
181
+ this.write(`${time} ✓ [${tag}]${summary}\n`);
182
+ }
183
+ }
184
+
185
+ /**
186
+ * Log a watch event (called from watcher)
187
+ */
188
+ logWatch(level: string, ruleId: string, file: string, message: string): void {
189
+ const time = getTime();
190
+ const icon = level === "info" ? "ℹ" : "⚠";
191
+ this.write(`${time} ${icon} [WATCH:${ruleId}] ${file}\n ${message}\n`);
192
+ }
193
+
194
+ /**
195
+ * Log a custom event
196
+ */
197
+ logEvent(category: string, message: string): void {
198
+ const time = getTime();
199
+ this.write(`${time} [${category}] ${message}\n`);
200
+ }
201
+
202
+ private write(text: string): void {
203
+ if (this.logStream) {
204
+ this.logStream.write(text);
205
+ }
206
+ }
207
+
208
+ private openTerminal(): void {
209
+ try {
210
+ if (process.platform === "win32") {
211
+ this.tailProcess = spawn("cmd", [
212
+ "/c", "start",
213
+ "Mandu Activity Monitor",
214
+ "powershell", "-NoExit", "-Command",
215
+ `[Console]::OutputEncoding = [System.Text.Encoding]::UTF8; chcp 65001 | Out-Null; Get-Content '${this.logFile}' -Wait -Encoding UTF8`,
216
+ ], { cwd: this.projectRoot, detached: true, stdio: "ignore" });
217
+ } else if (process.platform === "darwin") {
218
+ this.tailProcess = spawn("osascript", [
219
+ "-e", `tell application "Terminal" to do script "tail -f '${this.logFile}'"`,
220
+ ], { detached: true, stdio: "ignore" });
221
+ } else {
222
+ this.tailProcess = spawn("x-terminal-emulator", [
223
+ "-e", `tail -f '${this.logFile}'`,
224
+ ], { cwd: this.projectRoot, detached: true, stdio: "ignore" });
225
+ }
226
+ this.tailProcess?.unref();
227
+ } catch {
228
+ // Terminal auto-open failed silently
229
+ }
230
+ }
231
+ }
package/src/server.ts CHANGED
@@ -18,8 +18,10 @@ import { slotTools, slotToolDefinitions } from "./tools/slot.js";
18
18
  import { hydrationTools, hydrationToolDefinitions } from "./tools/hydration.js";
19
19
  import { contractTools, contractToolDefinitions } from "./tools/contract.js";
20
20
  import { brainTools, brainToolDefinitions } from "./tools/brain.js";
21
+ import { runtimeTools, runtimeToolDefinitions } from "./tools/runtime.js";
21
22
  import { resourceHandlers, resourceDefinitions } from "./resources/handlers.js";
22
23
  import { findProjectRoot } from "./utils/project.js";
24
+ import { applyWarningInjection } from "./utils/withWarnings.js";
23
25
  import { ActivityMonitor } from "./activity-monitor.js";
24
26
  import { startWatcher } from "../../core/src/index.js";
25
27
 
@@ -60,11 +62,12 @@ export class ManduMcpServer {
60
62
  ...hydrationToolDefinitions,
61
63
  ...contractToolDefinitions,
62
64
  ...brainToolDefinitions,
65
+ ...runtimeToolDefinitions,
63
66
  ];
64
67
  }
65
68
 
66
69
  private getAllToolHandlers(): Record<string, (args: Record<string, unknown>) => Promise<unknown>> {
67
- return {
70
+ const handlers = {
68
71
  ...specTools(this.projectRoot),
69
72
  ...generateTools(this.projectRoot),
70
73
  ...transactionTools(this.projectRoot),
@@ -74,7 +77,10 @@ export class ManduMcpServer {
74
77
  ...hydrationTools(this.projectRoot),
75
78
  ...contractTools(this.projectRoot),
76
79
  ...brainTools(this.projectRoot, this.server, this.monitor),
80
+ ...runtimeTools(this.projectRoot),
77
81
  };
82
+
83
+ return applyWarningInjection(handlers);
78
84
  }
79
85
 
80
86
  private registerToolHandlers(): void {
@@ -318,8 +318,8 @@ export function hydrationTools(projectRoot: string) {
318
318
  };
319
319
  }
320
320
 
321
- // Create client slot file
322
- const clientModulePath = `spec/slots/${routeId}.client.ts`;
321
+ // Create client slot file in apps/web/components/ (not spec/slots/)
322
+ const clientModulePath = `apps/web/components/${routeId}.client.tsx`;
323
323
  const clientFilePath = path.join(projectRoot, clientModulePath);
324
324
 
325
325
  // Check if file already exists
@@ -7,3 +7,4 @@ export { slotTools, slotToolDefinitions } from "./slot.js";
7
7
  export { hydrationTools, hydrationToolDefinitions } from "./hydration.js";
8
8
  export { contractTools, contractToolDefinitions } from "./contract.js";
9
9
  export { brainTools, brainToolDefinitions } from "./brain.js";
10
+ export { runtimeTools, runtimeToolDefinitions } from "./runtime.js";
@@ -0,0 +1,497 @@
1
+ /**
2
+ * Mandu MCP Runtime Tools
3
+ * Runtime 설정 조회 및 관리 도구
4
+ *
5
+ * - Logger 설정 조회/변경
6
+ * - Normalize 설정 조회
7
+ * - Contract 옵션 확인
8
+ */
9
+
10
+ import type { Tool } from "@modelcontextprotocol/sdk/types.js";
11
+ import { getProjectPaths, readJsonFile } from "../utils/project.js";
12
+ import { loadManifest } from "@mandujs/core";
13
+ import path from "path";
14
+ import fs from "fs/promises";
15
+
16
+ export const runtimeToolDefinitions: Tool[] = [
17
+ {
18
+ name: "mandu_get_runtime_config",
19
+ description:
20
+ "Get current runtime configuration including logger and normalize settings. " +
21
+ "Shows default values and any overrides from contracts.",
22
+ inputSchema: {
23
+ type: "object",
24
+ properties: {},
25
+ required: [],
26
+ },
27
+ },
28
+ {
29
+ name: "mandu_get_contract_options",
30
+ description:
31
+ "Get normalize and coerce options for a specific contract. " +
32
+ "These options control how request data is sanitized and type-converted.",
33
+ inputSchema: {
34
+ type: "object",
35
+ properties: {
36
+ routeId: {
37
+ type: "string",
38
+ description: "The route ID to get contract options for",
39
+ },
40
+ },
41
+ required: ["routeId"],
42
+ },
43
+ },
44
+ {
45
+ name: "mandu_set_contract_normalize",
46
+ description:
47
+ "Set normalize mode for a contract. " +
48
+ "Modes: 'strip' (remove undefined fields, default), 'strict' (error on undefined), 'passthrough' (allow all).",
49
+ inputSchema: {
50
+ type: "object",
51
+ properties: {
52
+ routeId: {
53
+ type: "string",
54
+ description: "The route ID to update",
55
+ },
56
+ normalize: {
57
+ type: "string",
58
+ enum: ["strip", "strict", "passthrough"],
59
+ description:
60
+ "Normalize mode: strip (Mass Assignment 방지), strict (에러 발생), passthrough (모두 허용)",
61
+ },
62
+ coerceQueryParams: {
63
+ type: "boolean",
64
+ description: "Whether to auto-convert query string types (default: true)",
65
+ },
66
+ },
67
+ required: ["routeId"],
68
+ },
69
+ },
70
+ {
71
+ name: "mandu_list_logger_options",
72
+ description:
73
+ "List available logger configuration options and their descriptions. " +
74
+ "Useful for understanding how to configure logging in Mandu.",
75
+ inputSchema: {
76
+ type: "object",
77
+ properties: {},
78
+ required: [],
79
+ },
80
+ },
81
+ {
82
+ name: "mandu_generate_logger_config",
83
+ description:
84
+ "Generate logger configuration code based on requirements. " +
85
+ "Returns TypeScript code that can be added to the project.",
86
+ inputSchema: {
87
+ type: "object",
88
+ properties: {
89
+ environment: {
90
+ type: "string",
91
+ enum: ["development", "production", "testing"],
92
+ description: "Target environment (default: development)",
93
+ },
94
+ includeHeaders: {
95
+ type: "boolean",
96
+ description: "Whether to log request headers (default: false for security)",
97
+ },
98
+ includeBody: {
99
+ type: "boolean",
100
+ description: "Whether to log request body (default: false for security)",
101
+ },
102
+ format: {
103
+ type: "string",
104
+ enum: ["pretty", "json"],
105
+ description: "Log output format (pretty for dev, json for prod)",
106
+ },
107
+ customRedact: {
108
+ type: "array",
109
+ items: { type: "string" },
110
+ description: "Additional fields to redact from logs",
111
+ },
112
+ },
113
+ required: [],
114
+ },
115
+ },
116
+ ];
117
+
118
+ async function readFileContent(filePath: string): Promise<string | null> {
119
+ try {
120
+ return await Bun.file(filePath).text();
121
+ } catch {
122
+ return null;
123
+ }
124
+ }
125
+
126
+ export function runtimeTools(projectRoot: string) {
127
+ const paths = getProjectPaths(projectRoot);
128
+
129
+ return {
130
+ mandu_get_runtime_config: async () => {
131
+ return {
132
+ defaults: {
133
+ logger: {
134
+ format: "pretty",
135
+ level: "info",
136
+ includeHeaders: false,
137
+ includeBody: false,
138
+ maxBodyBytes: 1024,
139
+ sampleRate: 1,
140
+ slowThresholdMs: 1000,
141
+ redact: [
142
+ "authorization",
143
+ "cookie",
144
+ "set-cookie",
145
+ "x-api-key",
146
+ "password",
147
+ "token",
148
+ "secret",
149
+ "bearer",
150
+ "credential",
151
+ ],
152
+ },
153
+ normalize: {
154
+ mode: "strip",
155
+ coerceQueryParams: true,
156
+ deep: true,
157
+ },
158
+ },
159
+ description: {
160
+ logger: {
161
+ format: "Log output format: 'pretty' (colored, dev) or 'json' (structured, prod)",
162
+ level: "Minimum log level: 'debug' | 'info' | 'warn' | 'error'",
163
+ includeHeaders: "⚠️ Security risk if true - logs request headers",
164
+ includeBody: "⚠️ Security risk if true - logs request body",
165
+ maxBodyBytes: "Maximum body size to log (truncates larger bodies)",
166
+ sampleRate: "Sampling rate 0-1 (1 = 100% logging)",
167
+ slowThresholdMs: "Requests slower than this get detailed logging",
168
+ redact: "Header/field names to mask in logs",
169
+ },
170
+ normalize: {
171
+ mode: "strip: remove undefined fields (Mass Assignment 방지), strict: error on undefined, passthrough: allow all",
172
+ coerceQueryParams: "Auto-convert query string '123' → number 123",
173
+ deep: "Apply normalization to nested objects",
174
+ },
175
+ },
176
+ usage: {
177
+ logger: `import { logger, devLogger, prodLogger } from "@mandujs/core";
178
+
179
+ // Development
180
+ app.use(devLogger());
181
+
182
+ // Production
183
+ app.use(prodLogger({ sampleRate: 0.1 }));`,
184
+ normalize: `// In contract definition
185
+ export default Mandu.contract({
186
+ normalize: "strip", // or "strict" | "passthrough"
187
+ coerceQueryParams: true,
188
+ request: { ... },
189
+ response: { ... },
190
+ });`,
191
+ },
192
+ };
193
+ },
194
+
195
+ mandu_get_contract_options: async (args: Record<string, unknown>) => {
196
+ const { routeId } = args as { routeId: string };
197
+
198
+ const result = await loadManifest(paths.manifestPath);
199
+ if (!result.success || !result.data) {
200
+ return { error: result.errors };
201
+ }
202
+
203
+ const route = result.data.routes.find((r) => r.id === routeId);
204
+ if (!route) {
205
+ return { error: `Route not found: ${routeId}` };
206
+ }
207
+
208
+ if (!route.contractModule) {
209
+ return {
210
+ routeId,
211
+ hasContract: false,
212
+ defaults: {
213
+ normalize: "strip",
214
+ coerceQueryParams: true,
215
+ },
216
+ suggestion: `Create a contract with: mandu_create_contract({ routeId: "${routeId}" })`,
217
+ };
218
+ }
219
+
220
+ // Read contract file and extract options
221
+ const contractPath = path.join(projectRoot, route.contractModule);
222
+ const contractContent = await readFileContent(contractPath);
223
+
224
+ if (!contractContent) {
225
+ return {
226
+ routeId,
227
+ contractModule: route.contractModule,
228
+ error: "Contract file not found",
229
+ };
230
+ }
231
+
232
+ // Parse normalize and coerceQueryParams from content
233
+ const normalizeMatch = contractContent.match(/normalize\s*:\s*["'](\w+)["']/);
234
+ const coerceMatch = contractContent.match(/coerceQueryParams\s*:\s*(true|false)/);
235
+
236
+ return {
237
+ routeId,
238
+ contractModule: route.contractModule,
239
+ options: {
240
+ normalize: normalizeMatch?.[1] || "strip (default)",
241
+ coerceQueryParams: coerceMatch ? coerceMatch[1] === "true" : "true (default)",
242
+ },
243
+ explanation: {
244
+ normalize: {
245
+ strip: "정의되지 않은 필드 제거 (Mass Assignment 공격 방지)",
246
+ strict: "정의되지 않은 필드 있으면 400 에러",
247
+ passthrough: "모든 필드 허용 (검증만, 필터링 안 함)",
248
+ },
249
+ coerceQueryParams: "URL query string은 항상 문자열이므로, 스키마 타입으로 자동 변환",
250
+ },
251
+ };
252
+ },
253
+
254
+ mandu_set_contract_normalize: async (args: Record<string, unknown>) => {
255
+ const { routeId, normalize, coerceQueryParams } = args as {
256
+ routeId: string;
257
+ normalize?: "strip" | "strict" | "passthrough";
258
+ coerceQueryParams?: boolean;
259
+ };
260
+
261
+ const result = await loadManifest(paths.manifestPath);
262
+ if (!result.success || !result.data) {
263
+ return { error: result.errors };
264
+ }
265
+
266
+ const route = result.data.routes.find((r) => r.id === routeId);
267
+ if (!route) {
268
+ return { error: `Route not found: ${routeId}` };
269
+ }
270
+
271
+ if (!route.contractModule) {
272
+ return {
273
+ error: "Route has no contract module",
274
+ suggestion: `Create a contract first: mandu_create_contract({ routeId: "${routeId}" })`,
275
+ };
276
+ }
277
+
278
+ const contractPath = path.join(projectRoot, route.contractModule);
279
+ let content = await readFileContent(contractPath);
280
+
281
+ if (!content) {
282
+ return { error: `Contract file not found: ${route.contractModule}` };
283
+ }
284
+
285
+ const changes: string[] = [];
286
+
287
+ // Update normalize option
288
+ if (normalize) {
289
+ if (content.includes("normalize:")) {
290
+ content = content.replace(
291
+ /normalize\s*:\s*["']\w+["']/,
292
+ `normalize: "${normalize}"`
293
+ );
294
+ changes.push(`normalize: "${normalize}"`);
295
+ } else {
296
+ // Add normalize option after description or tags
297
+ const insertPoint =
298
+ content.indexOf("request:") ||
299
+ content.indexOf("response:");
300
+ if (insertPoint > 0) {
301
+ const before = content.slice(0, insertPoint);
302
+ const after = content.slice(insertPoint);
303
+ content = before + `normalize: "${normalize}",\n ` + after;
304
+ changes.push(`normalize: "${normalize}" (added)`);
305
+ }
306
+ }
307
+ }
308
+
309
+ // Update coerceQueryParams option
310
+ if (coerceQueryParams !== undefined) {
311
+ if (content.includes("coerceQueryParams:")) {
312
+ content = content.replace(
313
+ /coerceQueryParams\s*:\s*(true|false)/,
314
+ `coerceQueryParams: ${coerceQueryParams}`
315
+ );
316
+ changes.push(`coerceQueryParams: ${coerceQueryParams}`);
317
+ } else if (insertAfter(content, "normalize:")) {
318
+ content = content.replace(
319
+ /(normalize\s*:\s*["']\w+["']),?/,
320
+ `$1,\n coerceQueryParams: ${coerceQueryParams},`
321
+ );
322
+ changes.push(`coerceQueryParams: ${coerceQueryParams} (added)`);
323
+ }
324
+ }
325
+
326
+ if (changes.length === 0) {
327
+ return {
328
+ success: false,
329
+ message: "No changes to apply",
330
+ currentContent: content.slice(0, 500) + "...",
331
+ };
332
+ }
333
+
334
+ // Write updated content
335
+ await Bun.write(contractPath, content);
336
+
337
+ return {
338
+ success: true,
339
+ contractModule: route.contractModule,
340
+ changes,
341
+ message: `Updated ${route.contractModule}`,
342
+ securityNote:
343
+ normalize === "passthrough"
344
+ ? "⚠️ passthrough 모드는 Mass Assignment 공격에 취약할 수 있습니다. 신뢰할 수 있는 입력에만 사용하세요."
345
+ : normalize === "strict"
346
+ ? "strict 모드는 클라이언트가 추가 필드를 보내면 400 에러를 반환합니다."
347
+ : "strip 모드 (권장): 정의되지 않은 필드는 자동 제거됩니다.",
348
+ };
349
+ },
350
+
351
+ mandu_list_logger_options: async () => {
352
+ return {
353
+ options: [
354
+ {
355
+ name: "format",
356
+ type: '"pretty" | "json"',
357
+ default: "pretty",
358
+ description: "로그 출력 형식. pretty는 개발용 컬러 출력, json은 운영용 구조화 로그",
359
+ },
360
+ {
361
+ name: "level",
362
+ type: '"debug" | "info" | "warn" | "error"',
363
+ default: "info",
364
+ description: "최소 로그 레벨. debug는 모든 요청 상세, error는 에러만",
365
+ },
366
+ {
367
+ name: "includeHeaders",
368
+ type: "boolean",
369
+ default: false,
370
+ description: "⚠️ 요청 헤더 로깅. 민감 정보 노출 위험",
371
+ },
372
+ {
373
+ name: "includeBody",
374
+ type: "boolean",
375
+ default: false,
376
+ description: "⚠️ 요청 바디 로깅. 민감 정보 노출 + 스트림 문제",
377
+ },
378
+ {
379
+ name: "maxBodyBytes",
380
+ type: "number",
381
+ default: 1024,
382
+ description: "바디 로깅 시 최대 크기 (초과분 truncate)",
383
+ },
384
+ {
385
+ name: "redact",
386
+ type: "string[]",
387
+ default: '["authorization", "cookie", "password", ...]',
388
+ description: "로그에서 마스킹할 헤더/필드명",
389
+ },
390
+ {
391
+ name: "requestId",
392
+ type: '"auto" | ((ctx) => string)',
393
+ default: "auto",
394
+ description: "요청 ID 생성 방식. auto는 UUID 또는 타임스탬프 기반",
395
+ },
396
+ {
397
+ name: "sampleRate",
398
+ type: "number (0-1)",
399
+ default: 1,
400
+ description: "샘플링 비율. 운영에서 로그 양 조절 (0.1 = 10%)",
401
+ },
402
+ {
403
+ name: "slowThresholdMs",
404
+ type: "number",
405
+ default: 1000,
406
+ description: "느린 요청 임계값. 초과 시 warn 레벨로 상세 출력",
407
+ },
408
+ {
409
+ name: "includeTraceOnSlow",
410
+ type: "boolean",
411
+ default: true,
412
+ description: "느린 요청에 Trace 리포트 포함",
413
+ },
414
+ {
415
+ name: "sink",
416
+ type: "(entry: LogEntry) => void",
417
+ default: "console",
418
+ description: "커스텀 로그 출력 (Pino, CloudWatch 등 연동)",
419
+ },
420
+ {
421
+ name: "skip",
422
+ type: "(string | RegExp)[]",
423
+ default: "[]",
424
+ description: '로깅 제외 경로 패턴. 예: ["/health", /^\\/static\\//]',
425
+ },
426
+ ],
427
+ presets: {
428
+ devLogger: "개발용: debug 레벨, pretty 포맷, 헤더 포함",
429
+ prodLogger: "운영용: info 레벨, json 포맷, 헤더/바디 미포함",
430
+ },
431
+ };
432
+ },
433
+
434
+ mandu_generate_logger_config: async (args: Record<string, unknown>) => {
435
+ const {
436
+ environment = "development",
437
+ includeHeaders = false,
438
+ includeBody = false,
439
+ format,
440
+ customRedact = [],
441
+ } = args as {
442
+ environment?: "development" | "production" | "testing";
443
+ includeHeaders?: boolean;
444
+ includeBody?: boolean;
445
+ format?: "pretty" | "json";
446
+ customRedact?: string[];
447
+ };
448
+
449
+ const isDev = environment === "development";
450
+ const isProd = environment === "production";
451
+
452
+ const config = {
453
+ format: format || (isDev ? "pretty" : "json"),
454
+ level: isDev ? "debug" : "info",
455
+ includeHeaders: isDev ? includeHeaders : false,
456
+ includeBody: isDev ? includeBody : false,
457
+ maxBodyBytes: 1024,
458
+ sampleRate: isProd ? 0.1 : 1,
459
+ slowThresholdMs: isDev ? 500 : 1000,
460
+ ...(customRedact.length > 0 && { redact: customRedact }),
461
+ };
462
+
463
+ const code = `import { logger } from "@mandujs/core";
464
+
465
+ // ${environment} environment logger configuration
466
+ export const appLogger = logger(${JSON.stringify(config, null, 2)});
467
+
468
+ // Usage in your app:
469
+ // app.use(appLogger);
470
+ `;
471
+
472
+ const warnings: string[] = [];
473
+ if (includeHeaders && isProd) {
474
+ warnings.push("⚠️ includeHeaders: true는 프로덕션에서 민감 정보 노출 위험이 있습니다.");
475
+ }
476
+ if (includeBody && isProd) {
477
+ warnings.push("⚠️ includeBody: true는 프로덕션에서 민감 정보 노출 위험이 있습니다.");
478
+ }
479
+
480
+ return {
481
+ environment,
482
+ config,
483
+ code,
484
+ warnings: warnings.length > 0 ? warnings : undefined,
485
+ tips: [
486
+ "devLogger() 또는 prodLogger() 프리셋을 사용할 수도 있습니다.",
487
+ "sink 옵션으로 Pino, CloudWatch 등 외부 시스템 연동 가능",
488
+ "skip 옵션으로 /health, /metrics 등 제외 가능",
489
+ ],
490
+ };
491
+ },
492
+ };
493
+ }
494
+
495
+ function insertAfter(content: string, search: string): boolean {
496
+ return content.includes(search);
497
+ }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Watcher Warning Injection for Mutation Tools
3
+ *
4
+ * Mutation 도구(write_slot, add_route, generate 등) 실행 후
5
+ * watcher 경고를 자동으로 응답에 포함시킨다.
6
+ *
7
+ * MCP notification이 Claude Code에 전달되지 않는 문제를 해결.
8
+ */
9
+
10
+ import { getWatcher } from "../../../core/src/index.js";
11
+
12
+ const MUTATION_TOOLS = new Set([
13
+ "mandu_write_slot",
14
+ "mandu_add_route",
15
+ "mandu_update_route",
16
+ "mandu_delete_route",
17
+ "mandu_generate",
18
+ "mandu_build",
19
+ "mandu_commit",
20
+ "mandu_add_client_slot",
21
+ "mandu_set_hydration",
22
+ "mandu_create_contract",
23
+ "mandu_update_route_contract",
24
+ "mandu_sync_contract_slot",
25
+ ]);
26
+
27
+ /** watcher debounce(300ms) + 여유분 */
28
+ const WARNING_WAIT_MS = 400;
29
+
30
+ type ToolHandler = (args: Record<string, unknown>) => Promise<unknown>;
31
+
32
+ /**
33
+ * Mutation 도구 핸들러를 감싸서, 실행 후 발생한 watcher 경고를
34
+ * 응답 객체의 `_warnings` 필드에 자동 포함시킨다.
35
+ */
36
+ export function applyWarningInjection(
37
+ handlers: Record<string, ToolHandler>
38
+ ): Record<string, ToolHandler> {
39
+ const wrapped: Record<string, ToolHandler> = {};
40
+
41
+ for (const [name, handler] of Object.entries(handlers)) {
42
+ if (MUTATION_TOOLS.has(name)) {
43
+ wrapped[name] = wrapWithWarnings(handler);
44
+ } else {
45
+ wrapped[name] = handler;
46
+ }
47
+ }
48
+
49
+ return wrapped;
50
+ }
51
+
52
+ function wrapWithWarnings(handler: ToolHandler): ToolHandler {
53
+ return async (args: Record<string, unknown>) => {
54
+ const watcher = getWatcher();
55
+ const beforeCount = watcher?.getRecentWarnings(100).length ?? 0;
56
+
57
+ const result = await handler(args);
58
+
59
+ // watcher debounce 대기
60
+ await new Promise((resolve) => setTimeout(resolve, WARNING_WAIT_MS));
61
+
62
+ const allWarnings = watcher?.getRecentWarnings(100) ?? [];
63
+ const newWarnings = allWarnings.slice(beforeCount);
64
+
65
+ if (
66
+ newWarnings.length > 0 &&
67
+ typeof result === "object" &&
68
+ result !== null
69
+ ) {
70
+ return {
71
+ ...(result as Record<string, unknown>),
72
+ _warnings: newWarnings.map((w) => ({
73
+ ruleId: w.ruleId,
74
+ file: w.file,
75
+ message: w.message,
76
+ level: w.level ?? "warn",
77
+ })),
78
+ };
79
+ }
80
+
81
+ return result;
82
+ };
83
+ }