@igor-olikh/openspec-mcp-server 2.1.3 → 2.1.5
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/dist/cache.js +78 -0
- package/dist/server.js +4 -1
- package/dist/tools.js +70 -5
- package/package.json +1 -1
package/dist/cache.js
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
/** Build a composite cache key to avoid collisions between changes and specs with the same name */
|
|
4
|
+
export function cacheKey(type, name) {
|
|
5
|
+
return `${type}:${name}`;
|
|
6
|
+
}
|
|
7
|
+
/** Look up a cache entry by name, with optional type filter. If type is omitted, prefers changes over specs. */
|
|
8
|
+
export function lookupEntry(cache, name, type) {
|
|
9
|
+
if (type) {
|
|
10
|
+
return cache.entries.get(cacheKey(type, name));
|
|
11
|
+
}
|
|
12
|
+
// Default: prefer change, fall back to spec
|
|
13
|
+
return cache.entries.get(cacheKey('change', name)) ?? cache.entries.get(cacheKey('spec', name));
|
|
14
|
+
}
|
|
15
|
+
export function buildCache(projectPath) {
|
|
16
|
+
const cache = {
|
|
17
|
+
entries: new Map(),
|
|
18
|
+
lastRefresh: Date.now()
|
|
19
|
+
};
|
|
20
|
+
refreshCache(cache, projectPath);
|
|
21
|
+
return cache;
|
|
22
|
+
}
|
|
23
|
+
export function refreshCache(cache, projectPath) {
|
|
24
|
+
cache.entries.clear();
|
|
25
|
+
cache.lastRefresh = Date.now();
|
|
26
|
+
const openspecDir = path.join(projectPath, 'openspec');
|
|
27
|
+
if (!fs.existsSync(openspecDir)) {
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
const dirsToScan = [
|
|
31
|
+
{ type: 'change', dirName: 'changes' },
|
|
32
|
+
{ type: 'spec', dirName: 'specs' }
|
|
33
|
+
];
|
|
34
|
+
for (const { type, dirName } of dirsToScan) {
|
|
35
|
+
const dir = path.join(openspecDir, dirName);
|
|
36
|
+
if (!fs.existsSync(dir))
|
|
37
|
+
continue;
|
|
38
|
+
try {
|
|
39
|
+
const subdirs = fs.readdirSync(dir, { withFileTypes: true })
|
|
40
|
+
.filter(dirent => dirent.isDirectory())
|
|
41
|
+
.map(dirent => dirent.name);
|
|
42
|
+
for (const name of subdirs) {
|
|
43
|
+
const entryPath = path.join(dir, name);
|
|
44
|
+
try {
|
|
45
|
+
const files = fs.readdirSync(entryPath, { withFileTypes: true })
|
|
46
|
+
.filter(dirent => dirent.isFile())
|
|
47
|
+
.map(dirent => dirent.name);
|
|
48
|
+
cache.entries.set(cacheKey(type, name), {
|
|
49
|
+
name,
|
|
50
|
+
type,
|
|
51
|
+
path: entryPath,
|
|
52
|
+
files
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
catch (err) {
|
|
56
|
+
// Ignore unreadable dirs
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
catch (err) {
|
|
61
|
+
// Ignore unreadable dirs
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
export function readSpecFile(cache, projectPath, name, fileType, type) {
|
|
66
|
+
const entry = lookupEntry(cache, name, type);
|
|
67
|
+
if (!entry)
|
|
68
|
+
return null;
|
|
69
|
+
if (!entry.files.includes(fileType))
|
|
70
|
+
return null;
|
|
71
|
+
const filePath = path.join(entry.path, fileType);
|
|
72
|
+
try {
|
|
73
|
+
return fs.readFileSync(filePath, 'utf-8');
|
|
74
|
+
}
|
|
75
|
+
catch (err) {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
}
|
package/dist/server.js
CHANGED
|
@@ -2,8 +2,10 @@ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
|
2
2
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
3
3
|
import { CallToolRequestSchema, ErrorCode, ListToolsRequestSchema, ListPromptsRequestSchema, GetPromptRequestSchema, McpError, } from '@modelcontextprotocol/sdk/types.js';
|
|
4
4
|
import { getTools, handleToolCall } from './tools.js';
|
|
5
|
+
import { buildCache } from './cache.js';
|
|
5
6
|
export class OpenSpecMCPServer {
|
|
6
7
|
server;
|
|
8
|
+
cache = null;
|
|
7
9
|
projectPath = process.cwd();
|
|
8
10
|
constructor() {
|
|
9
11
|
this.server = new Server({
|
|
@@ -62,7 +64,7 @@ Follow this workflow exactly:
|
|
|
62
64
|
});
|
|
63
65
|
this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
64
66
|
try {
|
|
65
|
-
const result = await handleToolCall(request.params.name, request.params.arguments || {}, this.projectPath);
|
|
67
|
+
const result = await handleToolCall(request.params.name, request.params.arguments || {}, this.projectPath, this.cache);
|
|
66
68
|
return {
|
|
67
69
|
content: [
|
|
68
70
|
{
|
|
@@ -82,6 +84,7 @@ Follow this workflow exactly:
|
|
|
82
84
|
}
|
|
83
85
|
async initialize(projectPath) {
|
|
84
86
|
this.projectPath = projectPath;
|
|
87
|
+
this.cache = buildCache(projectPath);
|
|
85
88
|
// Connect to stdio transport
|
|
86
89
|
const transport = new StdioServerTransport();
|
|
87
90
|
transport.onclose = async () => {
|
package/dist/tools.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { exec } from 'child_process';
|
|
2
2
|
import { promisify } from 'util';
|
|
3
|
+
import { readSpecFile, refreshCache, lookupEntry } from './cache.js';
|
|
3
4
|
const execAsync = promisify(exec);
|
|
4
5
|
const OPENSPEC_CMD = 'npx --yes @fission-ai/openspec';
|
|
5
6
|
export function getTools() {
|
|
@@ -109,10 +110,40 @@ export function getTools() {
|
|
|
109
110
|
},
|
|
110
111
|
required: ['artifact']
|
|
111
112
|
}
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
name: 'openspec_read_file',
|
|
116
|
+
description: 'Read any OpenSpec artifact directly. Much faster than show — use this when you need file contents.',
|
|
117
|
+
inputSchema: {
|
|
118
|
+
type: 'object',
|
|
119
|
+
properties: {
|
|
120
|
+
name: { type: 'string', description: 'Change or spec name (e.g. "add-dark-mode")' },
|
|
121
|
+
fileType: {
|
|
122
|
+
type: 'string',
|
|
123
|
+
description: 'File to read',
|
|
124
|
+
enum: ['proposal.md', 'design.md', 'tasks.md', '.openspec.yaml']
|
|
125
|
+
},
|
|
126
|
+
type: {
|
|
127
|
+
type: 'string',
|
|
128
|
+
description: 'Item type to disambiguate if a change and spec share the same name. If omitted, prefers changes.',
|
|
129
|
+
enum: ['change', 'spec']
|
|
130
|
+
}
|
|
131
|
+
},
|
|
132
|
+
required: ['name', 'fileType']
|
|
133
|
+
}
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
name: 'openspec_refresh_cache',
|
|
137
|
+
description: 'Force refresh the cached directory listing. Use if you suspect changes were made outside OpenSpec tools.',
|
|
138
|
+
inputSchema: {
|
|
139
|
+
type: 'object',
|
|
140
|
+
properties: {},
|
|
141
|
+
required: []
|
|
142
|
+
}
|
|
112
143
|
}
|
|
113
144
|
];
|
|
114
145
|
}
|
|
115
|
-
export async function handleToolCall(name, args, cwd) {
|
|
146
|
+
export async function handleToolCall(name, args, cwd, cache) {
|
|
116
147
|
const runOpenSpec = async (callArgs) => {
|
|
117
148
|
const cmd = `${OPENSPEC_CMD} ${callArgs.join(' ')}`;
|
|
118
149
|
try {
|
|
@@ -130,12 +161,27 @@ export async function handleToolCall(name, args, cwd) {
|
|
|
130
161
|
};
|
|
131
162
|
switch (name) {
|
|
132
163
|
case 'openspec_init': {
|
|
133
|
-
|
|
164
|
+
const res = await runOpenSpec(['init', '--no-interactive']);
|
|
165
|
+
refreshCache(cache, cwd);
|
|
166
|
+
return res;
|
|
134
167
|
}
|
|
135
168
|
case 'openspec_update': {
|
|
136
|
-
|
|
169
|
+
const res = await runOpenSpec(['update']);
|
|
170
|
+
refreshCache(cache, cwd);
|
|
171
|
+
return res;
|
|
137
172
|
}
|
|
138
173
|
case 'openspec_list': {
|
|
174
|
+
if (args.json !== false && cache && cache.entries.size > 0) {
|
|
175
|
+
const typeFilter = args.specs ? 'spec' : 'change';
|
|
176
|
+
const items = Array.from(cache.entries.values())
|
|
177
|
+
.filter(e => e.type === typeFilter)
|
|
178
|
+
.map(e => ({ name: e.name, type: e.type, files: e.files }));
|
|
179
|
+
return {
|
|
180
|
+
success: true,
|
|
181
|
+
stdout: JSON.stringify({ [args.specs ? 'specs' : 'changes']: items }, null, 2),
|
|
182
|
+
message: 'Served from cache'
|
|
183
|
+
};
|
|
184
|
+
}
|
|
139
185
|
const cmdArgs = ['list'];
|
|
140
186
|
if (args.specs)
|
|
141
187
|
cmdArgs.push('--specs');
|
|
@@ -167,13 +213,17 @@ export async function handleToolCall(name, args, cwd) {
|
|
|
167
213
|
const cmdArgs = ['archive', `"${args.changeName}"`, '--yes'];
|
|
168
214
|
if (args.skipSpecs)
|
|
169
215
|
cmdArgs.push('--skip-specs');
|
|
170
|
-
|
|
216
|
+
const res = await runOpenSpec(cmdArgs);
|
|
217
|
+
refreshCache(cache, cwd);
|
|
218
|
+
return res;
|
|
171
219
|
}
|
|
172
220
|
case 'openspec_new_change': {
|
|
173
221
|
const cmdArgs = ['new', 'change', `"${args.name}"`];
|
|
174
222
|
if (args.description)
|
|
175
223
|
cmdArgs.push('--description', `"${args.description}"`);
|
|
176
|
-
|
|
224
|
+
const res = await runOpenSpec(cmdArgs);
|
|
225
|
+
refreshCache(cache, cwd);
|
|
226
|
+
return res;
|
|
177
227
|
}
|
|
178
228
|
case 'openspec_status': {
|
|
179
229
|
const cmdArgs = ['status'];
|
|
@@ -191,6 +241,21 @@ export async function handleToolCall(name, args, cwd) {
|
|
|
191
241
|
cmdArgs.push('--json');
|
|
192
242
|
return await runOpenSpec(cmdArgs);
|
|
193
243
|
}
|
|
244
|
+
case 'openspec_read_file': {
|
|
245
|
+
const entry = lookupEntry(cache, args.name, args.type);
|
|
246
|
+
if (!entry) {
|
|
247
|
+
return { success: false, message: `No change or spec named '${args.name}' found. Run openspec_list to see available items.` };
|
|
248
|
+
}
|
|
249
|
+
const content = readSpecFile(cache, cwd, args.name, args.fileType, args.type);
|
|
250
|
+
if (content === null) {
|
|
251
|
+
return { success: false, message: `File '${args.fileType}' does not exist for '${args.name}'. Available files: ${entry.files.join(', ')}` };
|
|
252
|
+
}
|
|
253
|
+
return { success: true, stdout: content, message: `Read ${args.fileType} from ${entry.type}:${args.name}` };
|
|
254
|
+
}
|
|
255
|
+
case 'openspec_refresh_cache': {
|
|
256
|
+
refreshCache(cache, cwd);
|
|
257
|
+
return { success: true, message: 'Cache refreshed successfully.' };
|
|
258
|
+
}
|
|
194
259
|
default:
|
|
195
260
|
return { success: false, message: `Unknown tool: ${name}` };
|
|
196
261
|
}
|
package/package.json
CHANGED