spm-mcp 0.3.1 → 0.4.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 CHANGED
@@ -21,7 +21,7 @@ Not just PRDs. Every document in the PM lifecycle:
21
21
  - **Execution:** User Stories, Feature Spec, Sprint Planning, PRD to Jira, Release Notes, Test Cases
22
22
  - **Discovery:** Problem Statement, Persona, Jobs-to-be-Done, Opportunity Assessment
23
23
 
24
- 30 built-in expert reviews. Or create your own with `spm_create_custom_nano_app`.
24
+ 30 built-in expert reviews. Or bring your own template with `spm_create_custom_nano_app` -- paste your favorite PRD or describe your sections, and SPM creates a personalized review in seconds.
25
25
 
26
26
  ## Quick start
27
27
 
@@ -60,12 +60,12 @@ For claude.ai web users, SPM is available as a remote MCP server:
60
60
 
61
61
  | Tool | What it does |
62
62
  |------|-------------|
63
- | `spm_list_nano_apps` | Discover available expert reviews (30 built-in + your custom ones) |
64
- | `spm_analyze` | Score a document against domain-specific expert expectations. Every gap scored 0-1.0 with evidence. |
65
- | `spm_clarify` | Get decision-forcing questions for the weakest gaps. Questions escalate when you give vague answers. |
63
+ | `spm_create_custom_nano_app` | **Start here.** Paste your favorite PRD, describe your template sections, or pass evaluation criteria. Creates a personalized review that uses your terminology and covers your sections. |
64
+ | `spm_list_nano_apps` | Browse 30 built-in expert reviews if you don't have your own template. |
65
+ | `spm_analyze` | Score a document against expert expectations. Every gap scored 0-1.0 with evidence. |
66
+ | `spm_clarify` | Decision-forcing questions for the weakest gaps. Questions escalate when you give vague answers. |
66
67
  | `spm_evaluate` | Re-score gaps after clarification rounds. Tracks progress. Use after every 3 rounds of `spm_clarify`. |
67
68
  | `spm_improve` | Generate paste-ready improvements grounded in your answers, not AI hallucination. |
68
- | `spm_create_custom_nano_app` | Create a custom expert review for any document type not covered by the built-in 30. |
69
69
 
70
70
  ## How it works
71
71
 
@@ -79,7 +79,24 @@ Your document
79
79
 
80
80
  The clarification questions are the product. They surface the assumptions you've been carrying without examining. When you dodge, they escalate: evidence first, then action directives, then assumptions made on your behalf. Like a principal PM review, not a chatbot.
81
81
 
82
- ## Example workflow
82
+ ## Example: Bring your own template
83
+
84
+ ```
85
+ You: Here's my team's PRD template [pastes PRD]
86
+ SPM: Detected 9 sections. Created "PRD Review" with 5 expectations:
87
+
88
+ Expectation | Rules | Checks
89
+ Problem & Goal | Problem, Goal | Data-backed problem, measurable goal
90
+ Users & Solution | Target Users, Proposed Solution| Specific personas, buildable solution
91
+ Metrics & Timeline | Success Metrics, Timeline | Baselines, phased delivery
92
+ Technical & Risks | Technical Approach, Risks | Dependencies, mitigations
93
+ Out of Scope | Out of Scope | Explicit boundary list
94
+
95
+ You: Looks good. Analyze this PRD.
96
+ SPM: [scores against YOUR expectations, not generic ones]
97
+ ```
98
+
99
+ ## Example: Built-in review
83
100
 
84
101
  ```
85
102
  You: Analyze this PRD with prd_critique
package/dist/src/index.js CHANGED
@@ -19,18 +19,41 @@ import { handleClarify } from './tools/clarify.js';
19
19
  import { handleEvaluate } from './tools/evaluate.js';
20
20
  import { handleImprove } from './tools/improve.js';
21
21
  import { handleCreateCustomNanoApp } from './tools/create-custom-nano-app.js';
22
+ import { handleWorkspace } from './tools/workspace.js';
22
23
  import { SpmApiError } from './client/spm-api.js';
24
+ function logToolCall(tool, input) {
25
+ const summary = {};
26
+ for (const [k, v] of Object.entries(input)) {
27
+ if (typeof v === 'string' && v.length > 100) {
28
+ summary[k] = `[${v.length} chars]`;
29
+ }
30
+ else if (Array.isArray(v)) {
31
+ summary[k] = `[${v.length} items]`;
32
+ }
33
+ else {
34
+ summary[k] = v ?? '(empty)';
35
+ }
36
+ }
37
+ console.log(`[MCP] ${tool}:`, JSON.stringify(summary));
38
+ }
23
39
  export function createSpmMcpServer(options) {
24
40
  const server = new McpServer({
25
41
  name: 'Super Product Manager',
26
- version: '0.1.0',
42
+ version: '0.3.1',
27
43
  });
28
44
  const apiKey = options?.apiKey;
29
45
  // Tool: spm_list_nano_apps
30
- server.tool('spm_list_nano_apps', 'List all available SPM nano app templates for product document analysis. ' +
46
+ server.tool('spm_list_nano_apps', 'List available SPM nano app templates. ' +
47
+ 'IMPORTANT: Do NOT call this as your first action. Instead:\n' +
48
+ '1. Ask the user what document or task they are working on.\n' +
49
+ '2. Check if they have an existing template, PRD, or evaluation criteria ' +
50
+ '(look in workspace files, Claude project instructions, or ask).\n' +
51
+ '3. If they have their own template or a favorite PRD, use spm_create_custom_nano_app instead.\n' +
52
+ '4. Only call this tool if the user explicitly wants to browse available templates.\n' +
31
53
  'Returns template keys, names, descriptions, and categories. ' +
32
- 'Use this to discover which analysis types are available before calling spm_analyze.', {}, async () => {
54
+ 'DISPLAY: Show the top 5-6 most popular first. Only show the full list if the user asks for it.', {}, async () => {
33
55
  try {
56
+ logToolCall('spm_list_nano_apps', {});
34
57
  const result = await handleListNanoApps(apiKey);
35
58
  return {
36
59
  content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
@@ -51,6 +74,7 @@ export function createSpmMcpServer(options) {
51
74
  nano_app_id: z.string().describe('The nano app template key (e.g., "prd_critique"). Call spm_list_nano_apps to see available options.'),
52
75
  }, async (input) => {
53
76
  try {
77
+ logToolCall('spm_analyze', input);
54
78
  const result = await handleAnalyze(input, apiKey);
55
79
  return {
56
80
  content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
@@ -82,6 +106,7 @@ export function createSpmMcpServer(options) {
82
106
  about_company: z.string().optional().describe('Context about the company/product: stage, team size, market, existing users, business model.'),
83
107
  }, async (input) => {
84
108
  try {
109
+ logToolCall('spm_clarify', input);
85
110
  const result = await handleClarify(input, apiKey);
86
111
  return {
87
112
  content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
@@ -108,6 +133,7 @@ export function createSpmMcpServer(options) {
108
133
  about_company: z.string().optional().describe('Context about the company/product: stage, team size, market, existing users, business model.'),
109
134
  }, async (input) => {
110
135
  try {
136
+ logToolCall('spm_evaluate', input);
111
137
  const result = await handleEvaluate(input, apiKey);
112
138
  return {
113
139
  content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
@@ -136,6 +162,7 @@ export function createSpmMcpServer(options) {
136
162
  current_content: z.string().optional().describe('The current generated content (live_artifact) to improve upon. Empty for first generation.'),
137
163
  }, async (input) => {
138
164
  try {
165
+ logToolCall('spm_improve', input);
139
166
  const result = await handleImprove(input, apiKey);
140
167
  return {
141
168
  content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
@@ -146,18 +173,31 @@ export function createSpmMcpServer(options) {
146
173
  }
147
174
  });
148
175
  // Tool: spm_create_custom_nano_app
149
- server.tool('spm_create_custom_nano_app', 'Create a custom nano app when the user\'s document doesn\'t match any existing nano app ' +
150
- '(call spm_list_nano_apps first to check).\n\n' +
151
- 'BEFORE calling this tool, gather preferences from the user:\n' +
152
- '1. WHAT TO EVALUATE → expectations + rules\n' +
153
- '2. DOMAIN LENS domainContext\n' +
154
- '3. GOOD vs BAD SIGNALS reviewer examples\n' +
155
- '4. CLARIFICATION STYLE clarification examples + instructions\n' +
156
- '5. IMPROVEMENT FORMAT improviser instructions\n\n' +
157
- 'IMPORTANT: Do NOT run a rigid 5-question interview. Extract what you can from the user\'s ' +
158
- 'initial message. Only ask 1-2 clarifying questions for what\'s genuinely missing. ' +
159
- 'Bias toward ACTION over interrogation. The endpoint handles missing preferences gracefully.', {
160
- description: z.string().describe('What kind of document this nano app analyzes and what it should evaluate'),
176
+ server.tool('spm_create_custom_nano_app', 'Create a personalized nano app from the user\'s own template, PRD, or evaluation criteria. ' +
177
+ 'This is the PREFERRED path for new users who have their own standards.\n\n' +
178
+ 'The endpoint accepts MULTIPLE input types prepare the best input:\n\n' +
179
+ 'BEST (structured description + preferences):\n' +
180
+ ' description: "Evaluate PRDs with 9 sections: Problem, Goal, Target Users, Solution, ' +
181
+ 'Success Metrics, Timeline, Technical Approach, Risks, Out of Scope"\n' +
182
+ ' preferences: { domain_lens: "...", good_bad_signals: "..." }\n\n' +
183
+ 'GOOD (raw PRD or document pasted as description):\n' +
184
+ ' description: <paste the user\'s actual PRD or template document here>\n' +
185
+ ' The endpoint will extract sections and create generic rules from the structure.\n\n' +
186
+ 'OK (bare description):\n' +
187
+ ' description: "Evaluate product strategy for investment readiness"\n\n' +
188
+ 'INPUT PREPARATION — How to build the best input:\n' +
189
+ '1. If the user has a template/PRD, read it and list its sections explicitly in the description ' +
190
+ '(e.g., "9 sections: Problem, Goal, Target Users..."). This produces the best results.\n' +
191
+ '2. Add preferences.domain_lens with who is reviewing and what matters.\n' +
192
+ '3. Add preferences.good_bad_signals with concrete examples of quality.\n' +
193
+ '4. If the user provides explicit evaluation criteria or rules, pass them in the description — ' +
194
+ 'the endpoint will use them as-is, not reinterpret.\n\n' +
195
+ 'DISPLAY: After creation, show a table: Expectation | Rules | What it checks. ' +
196
+ 'Ask the user to confirm before proceeding to spm_analyze. Do NOT show the scoring rubric — ' +
197
+ 'users see it in action when they analyze a document.', {
198
+ description: z.string().describe('BEST: List sections explicitly ("Evaluate PRDs with 9 sections: Problem, Goal, ..."). ' +
199
+ 'ALSO WORKS: Paste a raw PRD/template document — sections will be auto-extracted. ' +
200
+ 'ALSO WORKS: Short description ("Evaluate product strategy for investment readiness").'),
161
201
  name: z.string().optional().describe('Human-readable name for the nano app (auto-generated if omitted)'),
162
202
  preferences: z.object({
163
203
  domain_lens: z.string().optional().describe('Domain context and expertise lens'),
@@ -167,6 +207,7 @@ export function createSpmMcpServer(options) {
167
207
  }).optional().describe('Optional preferences to customize the nano app behavior'),
168
208
  }, async (input) => {
169
209
  try {
210
+ logToolCall('spm_create_custom_nano_app', input);
170
211
  const result = await handleCreateCustomNanoApp(input, apiKey);
171
212
  return {
172
213
  content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
@@ -176,6 +217,27 @@ export function createSpmMcpServer(options) {
176
217
  return errorResponse(err);
177
218
  }
178
219
  });
220
+ // Tool: spm_read_workspace
221
+ server.tool('spm_read_workspace', 'Read or write files from the user\'s local workspace to gather project context or record decisions. ' +
222
+ 'Three modes: (1) "scan" — auto-discover known config files (CLAUDE.md, SKILL.md, README.md)\n' +
223
+ '(2) "read" — read a specific .md file by relative path\n' +
224
+ '(3) "write" — write content to a .md file by relative path (e.g., to record a system-of-record decision summary)\n' +
225
+ 'All paths are restricted to the project directory for security, and ONLY .md files are allowed.', {
226
+ mode: z.enum(['scan', 'read', 'write']).describe('"scan" to discover files, "read" to get contents, "write" to save contents'),
227
+ path: z.string().optional().describe('Relative path to the file (required when mode is "read" or "write"). Must be a .md file.'),
228
+ content: z.string().optional().describe('The markdown content to write to the file (required when mode is "write").'),
229
+ }, async (input) => {
230
+ try {
231
+ logToolCall('spm_read_workspace', input);
232
+ const result = await handleWorkspace(input);
233
+ return {
234
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
235
+ };
236
+ }
237
+ catch (err) {
238
+ return errorResponse(err);
239
+ }
240
+ });
179
241
  return server;
180
242
  }
181
243
  function errorResponse(err) {
package/dist/src/stdio.js CHANGED
@@ -94,13 +94,12 @@ if (wantsSetup || (isInteractive() && !hasApiKey())) {
94
94
  const ok = await runSetup();
95
95
  process.exit(ok ? 0 : 1);
96
96
  }
97
- else if (!hasApiKey() && isInteractive()) {
98
- // No key and interactive — guide the user
99
- process.stderr.write('\n No SPM_API_KEY found. Run: npx spm-mcp --setup\n\n');
100
- process.exit(1);
101
- }
102
97
  else {
103
98
  // MCP server mode (non-interactive, started by AI tool)
99
+ if (!hasApiKey()) {
100
+ process.stderr.write('[spm-mcp] Warning: No API key found. Tool calls will fail with 401.\n' +
101
+ ' Fix: run "npx spm-mcp --setup" or set SPM_API_KEY env var.\n');
102
+ }
104
103
  const server = createSpmMcpServer({ apiKey: config.apiKey });
105
104
  const transport = new StdioServerTransport();
106
105
  await server.connect(transport);
@@ -0,0 +1,18 @@
1
+ /**
2
+ * spm_read_workspace — Local workspace file reader.
3
+ *
4
+ * Reads files from the user's workspace (cwd) to provide project context.
5
+ * Two modes:
6
+ * - scan: auto-discover known config/doc files and return a manifest
7
+ * - read: read a specific file by relative path
8
+ *
9
+ * Security:
10
+ * - All paths resolved and validated to stay within cwd
11
+ * - Only .md files are allowed (no .env, .json, source code, etc.)
12
+ */
13
+ export interface WorkspaceInput {
14
+ mode: 'scan' | 'read' | 'write';
15
+ path?: string;
16
+ content?: string;
17
+ }
18
+ export declare function handleWorkspace(input: WorkspaceInput): Promise<unknown>;
@@ -0,0 +1,172 @@
1
+ /**
2
+ * spm_read_workspace — Local workspace file reader.
3
+ *
4
+ * Reads files from the user's workspace (cwd) to provide project context.
5
+ * Two modes:
6
+ * - scan: auto-discover known config/doc files and return a manifest
7
+ * - read: read a specific file by relative path
8
+ *
9
+ * Security:
10
+ * - All paths resolved and validated to stay within cwd
11
+ * - Only .md files are allowed (no .env, .json, source code, etc.)
12
+ */
13
+ import { readFileSync, existsSync, readdirSync, statSync, writeFileSync, mkdirSync } from 'fs';
14
+ import { resolve, relative, join, extname, dirname } from 'path';
15
+ const log = (msg) => process.stderr.write(`[spm-mcp:workspace] ${msg}\n`);
16
+ /** Only markdown files are allowed */
17
+ const ALLOWED_EXTENSIONS = ['.md'];
18
+ /** Files to auto-discover in scan mode */
19
+ const KNOWN_FILES = [
20
+ 'CLAUDE.md',
21
+ 'claude.md',
22
+ 'README.md',
23
+ '.github/copilot-instructions.md',
24
+ ];
25
+ /** Directories to scan for skill files */
26
+ const SKILL_DIRS = [
27
+ '.claude/skills',
28
+ '.gemini/skills',
29
+ ];
30
+ /** Max file size to read (100 KB) — skip huge files */
31
+ const MAX_FILE_SIZE = 100 * 1024;
32
+ // ─── Security ───────────────────────────────────────────────────────
33
+ function safePath(relativePath) {
34
+ const cwd = process.cwd();
35
+ const resolved = resolve(cwd, relativePath);
36
+ const rel = relative(cwd, resolved);
37
+ if (rel.startsWith('..') || rel === resolved) {
38
+ log(`BLOCKED path traversal: ${relativePath}`);
39
+ return null;
40
+ }
41
+ return resolved;
42
+ }
43
+ function scanWorkspace() {
44
+ const cwd = process.cwd();
45
+ const found = [];
46
+ // Check known files
47
+ for (const file of KNOWN_FILES) {
48
+ const full = join(cwd, file);
49
+ if (existsSync(full)) {
50
+ const stat = statSync(full);
51
+ if (stat.isFile()) {
52
+ found.push({ path: file, size: stat.size, type: 'config' });
53
+ }
54
+ }
55
+ }
56
+ // Scan skill directories for SKILL.md files
57
+ for (const dir of SKILL_DIRS) {
58
+ const fullDir = join(cwd, dir);
59
+ if (existsSync(fullDir) && statSync(fullDir).isDirectory()) {
60
+ try {
61
+ const entries = readdirSync(fullDir);
62
+ for (const entry of entries) {
63
+ const skillPath = join(fullDir, entry, 'SKILL.md');
64
+ if (existsSync(skillPath)) {
65
+ const stat = statSync(skillPath);
66
+ found.push({
67
+ path: join(dir, entry, 'SKILL.md'),
68
+ size: stat.size,
69
+ type: 'skill',
70
+ });
71
+ }
72
+ }
73
+ }
74
+ catch {
75
+ log(`Could not scan skill dir: ${dir}`);
76
+ }
77
+ }
78
+ }
79
+ log(`Scan complete: found ${found.length} files`);
80
+ return found;
81
+ }
82
+ function readWorkspaceFile(relativePath) {
83
+ // Only allow .md files
84
+ const ext = extname(relativePath).toLowerCase();
85
+ if (!ALLOWED_EXTENSIONS.includes(ext)) {
86
+ log(`BLOCKED non-markdown file: ${relativePath}`);
87
+ throw new Error(`Only .md files are allowed. Got: "${relativePath}"`);
88
+ }
89
+ const fullPath = safePath(relativePath);
90
+ if (!fullPath) {
91
+ throw new Error(`Path not allowed: "${relativePath}" — must be within project directory`);
92
+ }
93
+ if (!existsSync(fullPath)) {
94
+ throw new Error(`File not found: "${relativePath}"`);
95
+ }
96
+ const stat = statSync(fullPath);
97
+ if (!stat.isFile()) {
98
+ throw new Error(`Not a file: "${relativePath}"`);
99
+ }
100
+ let content;
101
+ let truncated = false;
102
+ if (stat.size > MAX_FILE_SIZE) {
103
+ content = readFileSync(fullPath, 'utf-8').slice(0, MAX_FILE_SIZE);
104
+ truncated = true;
105
+ log(`Read ${relativePath} (${stat.size} bytes, truncated to ${MAX_FILE_SIZE})`);
106
+ }
107
+ else {
108
+ content = readFileSync(fullPath, 'utf-8');
109
+ log(`Read ${relativePath} (${stat.size} bytes)`);
110
+ }
111
+ return {
112
+ path: relativePath,
113
+ content,
114
+ size: stat.size,
115
+ truncated,
116
+ };
117
+ }
118
+ function writeWorkspaceFile(relativePath, content) {
119
+ // Only allow .md files
120
+ const ext = extname(relativePath).toLowerCase();
121
+ if (!ALLOWED_EXTENSIONS.includes(ext)) {
122
+ log(`BLOCKED write to non-markdown file: ${relativePath}`);
123
+ throw new Error(`Only .md files are allowed to be written. Got: "${relativePath}"`);
124
+ }
125
+ const fullPath = safePath(relativePath);
126
+ if (!fullPath) {
127
+ throw new Error(`Path not allowed: "${relativePath}" — must be within project directory`);
128
+ }
129
+ // Ensure parent directory exists
130
+ const dir = dirname(fullPath);
131
+ if (!existsSync(dir)) {
132
+ mkdirSync(dir, { recursive: true });
133
+ }
134
+ // We allow creating new files or overwriting existing ones.
135
+ // In a production scenario, you might want to differentiate or append.
136
+ writeFileSync(fullPath, content, 'utf-8');
137
+ log(`Wrote ${relativePath} (${Buffer.byteLength(content, 'utf-8')} bytes)`);
138
+ return {
139
+ path: relativePath,
140
+ size: Buffer.byteLength(content, 'utf-8'),
141
+ message: `Successfully wrote to ${relativePath}`,
142
+ };
143
+ }
144
+ export async function handleWorkspace(input) {
145
+ if (input.mode === 'scan') {
146
+ const files = scanWorkspace();
147
+ return {
148
+ workspace: process.cwd(),
149
+ files,
150
+ hint: 'Use mode "read" with a path from this list to get file contents.',
151
+ };
152
+ }
153
+ if (input.mode === 'read') {
154
+ if (!input.path) {
155
+ throw new Error('path is required when mode is "read"');
156
+ }
157
+ const result = readWorkspaceFile(input.path);
158
+ return result;
159
+ }
160
+ if (input.mode === 'write') {
161
+ if (!input.path) {
162
+ throw new Error('path is required when mode is "write"');
163
+ }
164
+ if (input.content === undefined) {
165
+ throw new Error('content is required when mode is "write"');
166
+ }
167
+ const result = writeWorkspaceFile(input.path, input.content);
168
+ return result;
169
+ }
170
+ throw new Error(`Unknown mode: "${input.mode}". Use "scan", "read", or "write".`);
171
+ }
172
+ //# sourceMappingURL=workspace.js.map
package/package.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "spm-mcp",
3
- "version": "0.3.1",
4
- "description": "Super Product Manager MCP Server - AI-powered product document analysis for PRDs, roadmaps, and 30 PM document types",
3
+ "version": "0.4.0",
4
+ "description": "Super Product Manager MCP Server - AI-powered product document analysis. Bring your own template or use 30 built-in expert reviews for PRDs, roadmaps, and PM documents.",
5
5
  "author": "Super Product Manager <chiranjeevi.gunturi@superproductmanager.ai>",
6
6
  "homepage": "https://superproductmanager.ai",
7
7
  "type": "module",
8
- "main": "dist/index.js",
8
+ "main": "dist/src/index.js",
9
9
  "bin": {
10
10
  "spm-mcp": "dist/src/stdio.js"
11
11
  },
package/src/index.ts CHANGED
@@ -20,12 +20,27 @@ import { handleClarify } from './tools/clarify.js';
20
20
  import { handleEvaluate } from './tools/evaluate.js';
21
21
  import { handleImprove } from './tools/improve.js';
22
22
  import { handleCreateCustomNanoApp } from './tools/create-custom-nano-app.js';
23
+ import { handleWorkspace } from './tools/workspace.js';
23
24
  import { SpmApiError } from './client/spm-api.js';
24
25
 
26
+ function logToolCall(tool: string, input: Record<string, unknown>) {
27
+ const summary: Record<string, unknown> = {};
28
+ for (const [k, v] of Object.entries(input)) {
29
+ if (typeof v === 'string' && v.length > 100) {
30
+ summary[k] = `[${v.length} chars]`;
31
+ } else if (Array.isArray(v)) {
32
+ summary[k] = `[${v.length} items]`;
33
+ } else {
34
+ summary[k] = v ?? '(empty)';
35
+ }
36
+ }
37
+ console.log(`[MCP] ${tool}:`, JSON.stringify(summary));
38
+ }
39
+
25
40
  export function createSpmMcpServer(options?: { apiKey?: string }): McpServer {
26
41
  const server = new McpServer({
27
42
  name: 'Super Product Manager',
28
- version: '0.1.0',
43
+ version: '0.3.1',
29
44
  });
30
45
 
31
46
  const apiKey = options?.apiKey;
@@ -33,12 +48,19 @@ export function createSpmMcpServer(options?: { apiKey?: string }): McpServer {
33
48
  // Tool: spm_list_nano_apps
34
49
  server.tool(
35
50
  'spm_list_nano_apps',
36
- 'List all available SPM nano app templates for product document analysis. ' +
37
- 'Returns template keys, names, descriptions, and categories. ' +
38
- 'Use this to discover which analysis types are available before calling spm_analyze.',
51
+ 'List available SPM nano app templates. ' +
52
+ 'IMPORTANT: Do NOT call this as your first action. Instead:\n' +
53
+ '1. Ask the user what document or task they are working on.\n' +
54
+ '2. Check if they have an existing template, PRD, or evaluation criteria ' +
55
+ '(look in workspace files, Claude project instructions, or ask).\n' +
56
+ '3. If they have their own template or a favorite PRD, use spm_create_custom_nano_app instead.\n' +
57
+ '4. Only call this tool if the user explicitly wants to browse available templates.\n' +
58
+ 'Returns template keys, names, descriptions, and categories. ' +
59
+ 'DISPLAY: Show the top 5-6 most popular first. Only show the full list if the user asks for it.',
39
60
  {},
40
61
  async () => {
41
62
  try {
63
+ logToolCall('spm_list_nano_apps', {});
42
64
  const result = await handleListNanoApps(apiKey);
43
65
  return {
44
66
  content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }],
@@ -53,11 +75,11 @@ export function createSpmMcpServer(options?: { apiKey?: string }): McpServer {
53
75
  server.tool(
54
76
  'spm_analyze',
55
77
  'Analyze a product document against SPM expert expectations. ' +
56
- 'Pass a document and a nano_app_id (e.g., "prd_critique", "user_story", "growth_strategy") ' +
57
- 'to get an expert analysis with expectations, sub-expectations, scores, and evidence. ' +
58
- 'Call spm_list_nano_apps first if you need to discover available templates. ' +
59
- 'IMPORTANT: Present results as a clear scorecard table (Expectation | Score | Verdict). ' +
60
- 'Highlight critical gaps (score < 50%) and suggest running spm_clarify on the weakest gap next.',
78
+ 'Pass a document and a nano_app_id (e.g., "prd_critique", "user_story", "growth_strategy") ' +
79
+ 'to get an expert analysis with expectations, sub-expectations, scores, and evidence. ' +
80
+ 'Call spm_list_nano_apps first if you need to discover available templates. ' +
81
+ 'IMPORTANT: Present results as a clear scorecard table (Expectation | Score | Verdict). ' +
82
+ 'Highlight critical gaps (score < 50%) and suggest running spm_clarify on the weakest gap next.',
61
83
  {
62
84
  document: z.string().describe('The product document text to analyze'),
63
85
  nano_app_id: z.string().describe(
@@ -66,6 +88,7 @@ export function createSpmMcpServer(options?: { apiKey?: string }): McpServer {
66
88
  },
67
89
  async (input) => {
68
90
  try {
91
+ logToolCall('spm_analyze', input);
69
92
  const result = await handleAnalyze(input, apiKey);
70
93
  return {
71
94
  content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }],
@@ -80,13 +103,13 @@ export function createSpmMcpServer(options?: { apiKey?: string }): McpServer {
80
103
  server.tool(
81
104
  'spm_clarify',
82
105
  'Get clarification questions for gaps identified in an SPM analysis. ' +
83
- 'Pass the original document, the expert analysis context, and a target gap. ' +
84
- 'Returns targeted questions with suggested answers to close document gaps. ' +
85
- 'IMPORTANT: When presenting results to the user, minimize cognitive load. ' +
86
- 'Show the question prominently, then present each answer option as a clear ' +
87
- 'numbered choice the user can pick (1, 2, 3...). Mark the [recommended] option. ' +
88
- 'If you have native interactive UI (buttons, selectable options, AskUserQuestion), use it. ' +
89
- 'The user should be able to pick an option or type their own answer with minimal effort.',
106
+ 'Pass the original document, the expert analysis context, and a target gap. ' +
107
+ 'Returns targeted questions with suggested answers to close document gaps. ' +
108
+ 'IMPORTANT: When presenting results to the user, minimize cognitive load. ' +
109
+ 'Show the question prominently, then present each answer option as a clear ' +
110
+ 'numbered choice the user can pick (1, 2, 3...). Mark the [recommended] option. ' +
111
+ 'If you have native interactive UI (buttons, selectable options, AskUserQuestion), use it. ' +
112
+ 'The user should be able to pick an option or type their own answer with minimal effort.',
90
113
  {
91
114
  document: z.string().describe('The original product document text'),
92
115
  nano_app_id: z.string().describe('The nano app template key used in the analysis'),
@@ -112,6 +135,7 @@ export function createSpmMcpServer(options?: { apiKey?: string }): McpServer {
112
135
  },
113
136
  async (input) => {
114
137
  try {
138
+ logToolCall('spm_clarify', input);
115
139
  const result = await handleClarify(input, apiKey);
116
140
  return {
117
141
  content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }],
@@ -126,9 +150,9 @@ export function createSpmMcpServer(options?: { apiKey?: string }): McpServer {
126
150
  server.tool(
127
151
  'spm_evaluate',
128
152
  'Re-score sub-expectations after clarification rounds. ' +
129
- 'Pass the original document, the expectations from spm_analyze, and all Q&A pairs from spm_clarify. ' +
130
- 'Returns updated scores showing which gaps are now covered (>=0.81) and which still need work. ' +
131
- 'Use after every 3 rounds of spm_clarify to measure progress.',
153
+ 'Pass the original document, the expectations from spm_analyze, and all Q&A pairs from spm_clarify. ' +
154
+ 'Returns updated scores showing which gaps are now covered (>=0.81) and which still need work. ' +
155
+ 'Use after every 3 rounds of spm_clarify to measure progress.',
132
156
  {
133
157
  document: z.string().describe('The original product document text'),
134
158
  nano_app_id: z.string().describe('The nano app template key used in the analysis'),
@@ -151,6 +175,7 @@ export function createSpmMcpServer(options?: { apiKey?: string }): McpServer {
151
175
  },
152
176
  async (input) => {
153
177
  try {
178
+ logToolCall('spm_evaluate', input);
154
179
  const result = await handleEvaluate(input, apiKey);
155
180
  return {
156
181
  content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }],
@@ -165,11 +190,11 @@ export function createSpmMcpServer(options?: { apiKey?: string }): McpServer {
165
190
  server.tool(
166
191
  'spm_improve',
167
192
  'Generate improved document content for a specific gap. ' +
168
- 'Pass the document, the target sub-expectation, and all clarification Q&A pairs. ' +
169
- 'Returns paste-ready content the PM can insert into their document. ' +
170
- 'Use after spm_evaluate confirms the gap is sufficiently covered by clarification answers. ' +
171
- 'IMPORTANT: Present the generated content in clean markdown the user can copy-paste directly. ' +
172
- 'If there are [ACTION NEEDED] markers, highlight them so the user knows what to fill in.',
193
+ 'Pass the document, the target sub-expectation, and all clarification Q&A pairs. ' +
194
+ 'Returns paste-ready content the PM can insert into their document. ' +
195
+ 'Use after spm_evaluate confirms the gap is sufficiently covered by clarification answers. ' +
196
+ 'IMPORTANT: Present the generated content in clean markdown the user can copy-paste directly. ' +
197
+ 'If there are [ACTION NEEDED] markers, highlight them so the user knows what to fill in.',
173
198
  {
174
199
  document: z.string().describe('The original product document text'),
175
200
  nano_app_id: z.string().describe('The nano app template key used in the analysis'),
@@ -190,6 +215,7 @@ export function createSpmMcpServer(options?: { apiKey?: string }): McpServer {
190
215
  },
191
216
  async (input) => {
192
217
  try {
218
+ logToolCall('spm_improve', input);
193
219
  const result = await handleImprove(input, apiKey);
194
220
  return {
195
221
  content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }],
@@ -203,20 +229,33 @@ export function createSpmMcpServer(options?: { apiKey?: string }): McpServer {
203
229
  // Tool: spm_create_custom_nano_app
204
230
  server.tool(
205
231
  'spm_create_custom_nano_app',
206
- 'Create a custom nano app when the user\'s document doesn\'t match any existing nano app ' +
207
- '(call spm_list_nano_apps first to check).\n\n' +
208
- 'BEFORE calling this tool, gather preferences from the user:\n' +
209
- '1. WHAT TO EVALUATE → expectations + rules\n' +
210
- '2. DOMAIN LENS domainContext\n' +
211
- '3. GOOD vs BAD SIGNALS reviewer examples\n' +
212
- '4. CLARIFICATION STYLE clarification examples + instructions\n' +
213
- '5. IMPROVEMENT FORMAT improviser instructions\n\n' +
214
- 'IMPORTANT: Do NOT run a rigid 5-question interview. Extract what you can from the user\'s ' +
215
- 'initial message. Only ask 1-2 clarifying questions for what\'s genuinely missing. ' +
216
- 'Bias toward ACTION over interrogation. The endpoint handles missing preferences gracefully.',
232
+ 'Create a personalized nano app from the user\'s own template, PRD, or evaluation criteria. ' +
233
+ 'This is the PREFERRED path for new users who have their own standards.\n\n' +
234
+ 'The endpoint accepts MULTIPLE input types prepare the best input:\n\n' +
235
+ 'BEST (structured description + preferences):\n' +
236
+ ' description: "Evaluate PRDs with 9 sections: Problem, Goal, Target Users, Solution, ' +
237
+ 'Success Metrics, Timeline, Technical Approach, Risks, Out of Scope"\n' +
238
+ ' preferences: { domain_lens: "...", good_bad_signals: "..." }\n\n' +
239
+ 'GOOD (raw PRD or document pasted as description):\n' +
240
+ ' description: <paste the user\'s actual PRD or template document here>\n' +
241
+ ' The endpoint will extract sections and create generic rules from the structure.\n\n' +
242
+ 'OK (bare description):\n' +
243
+ ' description: "Evaluate product strategy for investment readiness"\n\n' +
244
+ 'INPUT PREPARATION — How to build the best input:\n' +
245
+ '1. If the user has a template/PRD, read it and list its sections explicitly in the description ' +
246
+ '(e.g., "9 sections: Problem, Goal, Target Users..."). This produces the best results.\n' +
247
+ '2. Add preferences.domain_lens with who is reviewing and what matters.\n' +
248
+ '3. Add preferences.good_bad_signals with concrete examples of quality.\n' +
249
+ '4. If the user provides explicit evaluation criteria or rules, pass them in the description — ' +
250
+ 'the endpoint will use them as-is, not reinterpret.\n\n' +
251
+ 'DISPLAY: After creation, show a table: Expectation | Rules | What it checks. ' +
252
+ 'Ask the user to confirm before proceeding to spm_analyze. Do NOT show the scoring rubric — ' +
253
+ 'users see it in action when they analyze a document.',
217
254
  {
218
255
  description: z.string().describe(
219
- 'What kind of document this nano app analyzes and what it should evaluate',
256
+ 'BEST: List sections explicitly ("Evaluate PRDs with 9 sections: Problem, Goal, ..."). ' +
257
+ 'ALSO WORKS: Paste a raw PRD/template document — sections will be auto-extracted. ' +
258
+ 'ALSO WORKS: Short description ("Evaluate product strategy for investment readiness").',
220
259
  ),
221
260
  name: z.string().optional().describe(
222
261
  'Human-readable name for the nano app (auto-generated if omitted)',
@@ -230,6 +269,7 @@ export function createSpmMcpServer(options?: { apiKey?: string }): McpServer {
230
269
  },
231
270
  async (input) => {
232
271
  try {
272
+ logToolCall('spm_create_custom_nano_app', input);
233
273
  const result = await handleCreateCustomNanoApp(input, apiKey);
234
274
  return {
235
275
  content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }],
@@ -240,6 +280,38 @@ export function createSpmMcpServer(options?: { apiKey?: string }): McpServer {
240
280
  },
241
281
  );
242
282
 
283
+ // Tool: spm_read_workspace
284
+ server.tool(
285
+ 'spm_read_workspace',
286
+ 'Read or write files from the user\'s local workspace to gather project context or record decisions. ' +
287
+ 'Three modes: (1) "scan" — auto-discover known config files (CLAUDE.md, SKILL.md, README.md)\n' +
288
+ '(2) "read" — read a specific .md file by relative path\n' +
289
+ '(3) "write" — write content to a .md file by relative path (e.g., to record a system-of-record decision summary)\n' +
290
+ 'All paths are restricted to the project directory for security, and ONLY .md files are allowed.',
291
+ {
292
+ mode: z.enum(['scan', 'read', 'write']).describe(
293
+ '"scan" to discover files, "read" to get contents, "write" to save contents',
294
+ ),
295
+ path: z.string().optional().describe(
296
+ 'Relative path to the file (required when mode is "read" or "write"). Must be a .md file.',
297
+ ),
298
+ content: z.string().optional().describe(
299
+ 'The markdown content to write to the file (required when mode is "write").',
300
+ ),
301
+ },
302
+ async (input) => {
303
+ try {
304
+ logToolCall('spm_read_workspace', input);
305
+ const result = await handleWorkspace(input);
306
+ return {
307
+ content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }],
308
+ };
309
+ } catch (err) {
310
+ return errorResponse(err);
311
+ }
312
+ },
313
+ );
314
+
243
315
  return server;
244
316
  }
245
317
 
package/src/stdio.ts CHANGED
@@ -107,12 +107,14 @@ if (wantsSetup || (isInteractive() && !hasApiKey())) {
107
107
  // Interactive setup mode
108
108
  const ok = await runSetup();
109
109
  process.exit(ok ? 0 : 1);
110
- } else if (!hasApiKey() && isInteractive()) {
111
- // No key and interactive — guide the user
112
- process.stderr.write('\n No SPM_API_KEY found. Run: npx spm-mcp --setup\n\n');
113
- process.exit(1);
114
110
  } else {
115
111
  // MCP server mode (non-interactive, started by AI tool)
112
+ if (!hasApiKey()) {
113
+ process.stderr.write(
114
+ '[spm-mcp] Warning: No API key found. Tool calls will fail with 401.\n' +
115
+ ' Fix: run "npx spm-mcp --setup" or set SPM_API_KEY env var.\n'
116
+ );
117
+ }
116
118
  const server = createSpmMcpServer({ apiKey: config.apiKey });
117
119
  const transport = new StdioServerTransport();
118
120
  await server.connect(transport);
@@ -0,0 +1,231 @@
1
+ /**
2
+ * spm_read_workspace — Local workspace file reader.
3
+ *
4
+ * Reads files from the user's workspace (cwd) to provide project context.
5
+ * Two modes:
6
+ * - scan: auto-discover known config/doc files and return a manifest
7
+ * - read: read a specific file by relative path
8
+ *
9
+ * Security:
10
+ * - All paths resolved and validated to stay within cwd
11
+ * - Only .md files are allowed (no .env, .json, source code, etc.)
12
+ */
13
+
14
+ import { readFileSync, existsSync, readdirSync, statSync, writeFileSync, mkdirSync } from 'fs';
15
+ import { resolve, relative, join, basename, extname, dirname } from 'path';
16
+
17
+ const log = (msg: string) => process.stderr.write(`[spm-mcp:workspace] ${msg}\n`);
18
+
19
+ /** Only markdown files are allowed */
20
+ const ALLOWED_EXTENSIONS = ['.md'];
21
+
22
+ /** Files to auto-discover in scan mode */
23
+ const KNOWN_FILES = [
24
+ 'CLAUDE.md',
25
+ 'claude.md',
26
+ 'README.md',
27
+ '.github/copilot-instructions.md',
28
+ ];
29
+
30
+ /** Directories to scan for skill files */
31
+ const SKILL_DIRS = [
32
+ '.claude/skills',
33
+ '.gemini/skills',
34
+ ];
35
+
36
+ /** Max file size to read (100 KB) — skip huge files */
37
+ const MAX_FILE_SIZE = 100 * 1024;
38
+
39
+ // ─── Security ───────────────────────────────────────────────────────
40
+
41
+ function safePath(relativePath: string): string | null {
42
+ const cwd = process.cwd();
43
+ const resolved = resolve(cwd, relativePath);
44
+ const rel = relative(cwd, resolved);
45
+ if (rel.startsWith('..') || rel === resolved) {
46
+ log(`BLOCKED path traversal: ${relativePath}`);
47
+ return null;
48
+ }
49
+ return resolved;
50
+ }
51
+
52
+ // ─── Scan Mode ──────────────────────────────────────────────────────
53
+
54
+ interface FileEntry {
55
+ path: string;
56
+ size: number;
57
+ type: 'config' | 'skill' | 'doc';
58
+ }
59
+
60
+ function scanWorkspace(): FileEntry[] {
61
+ const cwd = process.cwd();
62
+ const found: FileEntry[] = [];
63
+
64
+ // Check known files
65
+ for (const file of KNOWN_FILES) {
66
+ const full = join(cwd, file);
67
+ if (existsSync(full)) {
68
+ const stat = statSync(full);
69
+ if (stat.isFile()) {
70
+ found.push({ path: file, size: stat.size, type: 'config' });
71
+ }
72
+ }
73
+ }
74
+
75
+ // Scan skill directories for SKILL.md files
76
+ for (const dir of SKILL_DIRS) {
77
+ const fullDir = join(cwd, dir);
78
+ if (existsSync(fullDir) && statSync(fullDir).isDirectory()) {
79
+ try {
80
+ const entries = readdirSync(fullDir);
81
+ for (const entry of entries) {
82
+ const skillPath = join(fullDir, entry, 'SKILL.md');
83
+ if (existsSync(skillPath)) {
84
+ const stat = statSync(skillPath);
85
+ found.push({
86
+ path: join(dir, entry, 'SKILL.md'),
87
+ size: stat.size,
88
+ type: 'skill',
89
+ });
90
+ }
91
+ }
92
+ } catch {
93
+ log(`Could not scan skill dir: ${dir}`);
94
+ }
95
+ }
96
+ }
97
+
98
+ log(`Scan complete: found ${found.length} files`);
99
+ return found;
100
+ }
101
+
102
+ // ─── Read Mode ──────────────────────────────────────────────────────
103
+
104
+ interface ReadResult {
105
+ path: string;
106
+ content: string;
107
+ size: number;
108
+ truncated: boolean;
109
+ }
110
+
111
+ function readWorkspaceFile(relativePath: string): ReadResult {
112
+ // Only allow .md files
113
+ const ext = extname(relativePath).toLowerCase();
114
+ if (!ALLOWED_EXTENSIONS.includes(ext)) {
115
+ log(`BLOCKED non-markdown file: ${relativePath}`);
116
+ throw new Error(`Only .md files are allowed. Got: "${relativePath}"`);
117
+ }
118
+
119
+ const fullPath = safePath(relativePath);
120
+ if (!fullPath) {
121
+ throw new Error(`Path not allowed: "${relativePath}" — must be within project directory`);
122
+ }
123
+
124
+ if (!existsSync(fullPath)) {
125
+ throw new Error(`File not found: "${relativePath}"`);
126
+ }
127
+
128
+ const stat = statSync(fullPath);
129
+ if (!stat.isFile()) {
130
+ throw new Error(`Not a file: "${relativePath}"`);
131
+ }
132
+
133
+ let content: string;
134
+ let truncated = false;
135
+
136
+ if (stat.size > MAX_FILE_SIZE) {
137
+ content = readFileSync(fullPath, 'utf-8').slice(0, MAX_FILE_SIZE);
138
+ truncated = true;
139
+ log(`Read ${relativePath} (${stat.size} bytes, truncated to ${MAX_FILE_SIZE})`);
140
+ } else {
141
+ content = readFileSync(fullPath, 'utf-8');
142
+ log(`Read ${relativePath} (${stat.size} bytes)`);
143
+ }
144
+
145
+ return {
146
+ path: relativePath,
147
+ content,
148
+ size: stat.size,
149
+ truncated,
150
+ };
151
+ }
152
+
153
+ // ─── Write Mode ─────────────────────────────────────────────────────
154
+
155
+ interface WriteResult {
156
+ path: string;
157
+ size: number;
158
+ message: string;
159
+ }
160
+
161
+ function writeWorkspaceFile(relativePath: string, content: string): WriteResult {
162
+ // Only allow .md files
163
+ const ext = extname(relativePath).toLowerCase();
164
+ if (!ALLOWED_EXTENSIONS.includes(ext)) {
165
+ log(`BLOCKED write to non-markdown file: ${relativePath}`);
166
+ throw new Error(`Only .md files are allowed to be written. Got: "${relativePath}"`);
167
+ }
168
+
169
+ const fullPath = safePath(relativePath);
170
+ if (!fullPath) {
171
+ throw new Error(`Path not allowed: "${relativePath}" — must be within project directory`);
172
+ }
173
+
174
+ // Ensure parent directory exists
175
+ const dir = dirname(fullPath);
176
+ if (!existsSync(dir)) {
177
+ mkdirSync(dir, { recursive: true });
178
+ }
179
+
180
+ // We allow creating new files or overwriting existing ones.
181
+ // In a production scenario, you might want to differentiate or append.
182
+ writeFileSync(fullPath, content, 'utf-8');
183
+
184
+ log(`Wrote ${relativePath} (${Buffer.byteLength(content, 'utf-8')} bytes)`);
185
+
186
+ return {
187
+ path: relativePath,
188
+ size: Buffer.byteLength(content, 'utf-8'),
189
+ message: `Successfully wrote to ${relativePath}`,
190
+ };
191
+ }
192
+
193
+ // ─── Exported Handler ───────────────────────────────────────────────
194
+
195
+ export interface WorkspaceInput {
196
+ mode: 'scan' | 'read' | 'write';
197
+ path?: string;
198
+ content?: string;
199
+ }
200
+
201
+ export async function handleWorkspace(input: WorkspaceInput): Promise<unknown> {
202
+ if (input.mode === 'scan') {
203
+ const files = scanWorkspace();
204
+ return {
205
+ workspace: process.cwd(),
206
+ files,
207
+ hint: 'Use mode "read" with a path from this list to get file contents.',
208
+ };
209
+ }
210
+
211
+ if (input.mode === 'read') {
212
+ if (!input.path) {
213
+ throw new Error('path is required when mode is "read"');
214
+ }
215
+ const result = readWorkspaceFile(input.path);
216
+ return result;
217
+ }
218
+
219
+ if (input.mode === 'write') {
220
+ if (!input.path) {
221
+ throw new Error('path is required when mode is "write"');
222
+ }
223
+ if (input.content === undefined) {
224
+ throw new Error('content is required when mode is "write"');
225
+ }
226
+ const result = writeWorkspaceFile(input.path, input.content);
227
+ return result;
228
+ }
229
+
230
+ throw new Error(`Unknown mode: "${input.mode}". Use "scan", "read", or "write".`);
231
+ }
@@ -0,0 +1,44 @@
1
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
2
+ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
3
+ import { unlinkSync } from "fs";
4
+
5
+ async function run() {
6
+ const transport = new StdioClientTransport({
7
+ command: "node",
8
+ args: ["./dist/src/stdio.js"]
9
+ });
10
+
11
+ const client = new Client({ name: "test-client", version: "1.0.0" }, { capabilities: {} });
12
+ await client.connect(transport);
13
+
14
+ console.log("Connected to MCP Server via stdio!");
15
+
16
+ try {
17
+ console.log("\nCalling spm_read_workspace (mode: write)...");
18
+ const result = await client.callTool({
19
+ name: "spm_read_workspace",
20
+ arguments: {
21
+ mode: "write",
22
+ path: "mcp_protocol_test.md",
23
+ content: "# Real MCP Test\n\nThis was written via the actual MCP JSON-RPC protocol!"
24
+ }
25
+ });
26
+ console.log("Tool Result:", JSON.stringify(result, null, 2));
27
+
28
+ // read it back
29
+ console.log("\nCalling spm_read_workspace (mode: read)...");
30
+ const readResult = await client.callTool({
31
+ name: "spm_read_workspace",
32
+ arguments: { mode: "read", path: "mcp_protocol_test.md" }
33
+ });
34
+ console.log("Read Result text:\n" + readResult.content[0].text);
35
+
36
+ } catch (err) {
37
+ console.error("Error:", err);
38
+ } finally {
39
+ try { unlinkSync('mcp_protocol_test.md'); } catch (e) { }
40
+ console.log("\nCleanup done. Test complete.");
41
+ process.exit(0);
42
+ }
43
+ }
44
+ run();
package/spm-mcp-0.1.0.tgz DELETED
Binary file