spm-mcp 0.3.2 → 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 +23 -6
- package/dist/src/index.js +55 -14
- package/dist/src/tools/workspace.d.ts +18 -0
- package/dist/src/tools/workspace.js +172 -0
- package/package.json +2 -2
- package/src/index.ts +87 -35
- package/src/tools/workspace.ts +231 -0
- package/test-mcp-client.mjs +44 -0
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
|
|
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
|
-
| `
|
|
64
|
-
| `
|
|
65
|
-
| `
|
|
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
|
|
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,6 +19,7 @@ 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';
|
|
23
24
|
function logToolCall(tool, input) {
|
|
24
25
|
const summary = {};
|
|
@@ -42,9 +43,15 @@ export function createSpmMcpServer(options) {
|
|
|
42
43
|
});
|
|
43
44
|
const apiKey = options?.apiKey;
|
|
44
45
|
// Tool: spm_list_nano_apps
|
|
45
|
-
server.tool('spm_list_nano_apps', 'List
|
|
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' +
|
|
46
53
|
'Returns template keys, names, descriptions, and categories. ' +
|
|
47
|
-
'
|
|
54
|
+
'DISPLAY: Show the top 5-6 most popular first. Only show the full list if the user asks for it.', {}, async () => {
|
|
48
55
|
try {
|
|
49
56
|
logToolCall('spm_list_nano_apps', {});
|
|
50
57
|
const result = await handleListNanoApps(apiKey);
|
|
@@ -166,18 +173,31 @@ export function createSpmMcpServer(options) {
|
|
|
166
173
|
}
|
|
167
174
|
});
|
|
168
175
|
// Tool: spm_create_custom_nano_app
|
|
169
|
-
server.tool('spm_create_custom_nano_app', 'Create a
|
|
170
|
-
'
|
|
171
|
-
'
|
|
172
|
-
'
|
|
173
|
-
'
|
|
174
|
-
'
|
|
175
|
-
'
|
|
176
|
-
'
|
|
177
|
-
'
|
|
178
|
-
'
|
|
179
|
-
'
|
|
180
|
-
description:
|
|
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").'),
|
|
181
201
|
name: z.string().optional().describe('Human-readable name for the nano app (auto-generated if omitted)'),
|
|
182
202
|
preferences: z.object({
|
|
183
203
|
domain_lens: z.string().optional().describe('Domain context and expertise lens'),
|
|
@@ -197,6 +217,27 @@ export function createSpmMcpServer(options) {
|
|
|
197
217
|
return errorResponse(err);
|
|
198
218
|
}
|
|
199
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
|
+
});
|
|
200
241
|
return server;
|
|
201
242
|
}
|
|
202
243
|
function errorResponse(err) {
|
|
@@ -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,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "spm-mcp",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Super Product Manager MCP Server - AI-powered product document analysis for PRDs, roadmaps, and
|
|
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",
|
package/src/index.ts
CHANGED
|
@@ -20,6 +20,7 @@ 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
|
|
|
25
26
|
function logToolCall(tool: string, input: Record<string, unknown>) {
|
|
@@ -47,9 +48,15 @@ export function createSpmMcpServer(options?: { apiKey?: string }): McpServer {
|
|
|
47
48
|
// Tool: spm_list_nano_apps
|
|
48
49
|
server.tool(
|
|
49
50
|
'spm_list_nano_apps',
|
|
50
|
-
'List
|
|
51
|
-
|
|
52
|
-
|
|
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.',
|
|
53
60
|
{},
|
|
54
61
|
async () => {
|
|
55
62
|
try {
|
|
@@ -68,11 +75,11 @@ export function createSpmMcpServer(options?: { apiKey?: string }): McpServer {
|
|
|
68
75
|
server.tool(
|
|
69
76
|
'spm_analyze',
|
|
70
77
|
'Analyze a product document against SPM expert expectations. ' +
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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.',
|
|
76
83
|
{
|
|
77
84
|
document: z.string().describe('The product document text to analyze'),
|
|
78
85
|
nano_app_id: z.string().describe(
|
|
@@ -96,13 +103,13 @@ export function createSpmMcpServer(options?: { apiKey?: string }): McpServer {
|
|
|
96
103
|
server.tool(
|
|
97
104
|
'spm_clarify',
|
|
98
105
|
'Get clarification questions for gaps identified in an SPM analysis. ' +
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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.',
|
|
106
113
|
{
|
|
107
114
|
document: z.string().describe('The original product document text'),
|
|
108
115
|
nano_app_id: z.string().describe('The nano app template key used in the analysis'),
|
|
@@ -143,9 +150,9 @@ export function createSpmMcpServer(options?: { apiKey?: string }): McpServer {
|
|
|
143
150
|
server.tool(
|
|
144
151
|
'spm_evaluate',
|
|
145
152
|
'Re-score sub-expectations after clarification rounds. ' +
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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.',
|
|
149
156
|
{
|
|
150
157
|
document: z.string().describe('The original product document text'),
|
|
151
158
|
nano_app_id: z.string().describe('The nano app template key used in the analysis'),
|
|
@@ -183,11 +190,11 @@ export function createSpmMcpServer(options?: { apiKey?: string }): McpServer {
|
|
|
183
190
|
server.tool(
|
|
184
191
|
'spm_improve',
|
|
185
192
|
'Generate improved document content for a specific gap. ' +
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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.',
|
|
191
198
|
{
|
|
192
199
|
document: z.string().describe('The original product document text'),
|
|
193
200
|
nano_app_id: z.string().describe('The nano app template key used in the analysis'),
|
|
@@ -222,20 +229,33 @@ export function createSpmMcpServer(options?: { apiKey?: string }): McpServer {
|
|
|
222
229
|
// Tool: spm_create_custom_nano_app
|
|
223
230
|
server.tool(
|
|
224
231
|
'spm_create_custom_nano_app',
|
|
225
|
-
'Create a
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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.',
|
|
236
254
|
{
|
|
237
255
|
description: z.string().describe(
|
|
238
|
-
'
|
|
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").',
|
|
239
259
|
),
|
|
240
260
|
name: z.string().optional().describe(
|
|
241
261
|
'Human-readable name for the nano app (auto-generated if omitted)',
|
|
@@ -260,6 +280,38 @@ export function createSpmMcpServer(options?: { apiKey?: string }): McpServer {
|
|
|
260
280
|
},
|
|
261
281
|
);
|
|
262
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
|
+
|
|
263
315
|
return server;
|
|
264
316
|
}
|
|
265
317
|
|
|
@@ -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();
|