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 +23 -6
- package/dist/src/index.js +77 -15
- package/dist/src/stdio.js +4 -5
- package/dist/src/tools/workspace.d.ts +18 -0
- package/dist/src/tools/workspace.js +172 -0
- package/package.json +3 -3
- package/src/index.ts +108 -36
- package/src/stdio.ts +6 -4
- package/src/tools/workspace.ts +231 -0
- package/test-mcp-client.mjs +44 -0
- package/spm-mcp-0.1.0.tgz +0 -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,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
|
|
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
|
|
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
|
-
'
|
|
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
|
|
150
|
-
'
|
|
151
|
-
'
|
|
152
|
-
'
|
|
153
|
-
'
|
|
154
|
-
'
|
|
155
|
-
'
|
|
156
|
-
'
|
|
157
|
-
'
|
|
158
|
-
'
|
|
159
|
-
'
|
|
160
|
-
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").'),
|
|
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.
|
|
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",
|
|
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
|
|
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
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
-
'
|
|
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
|