@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 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
- return await runOpenSpec(['init', '--no-interactive']);
164
+ const res = await runOpenSpec(['init', '--no-interactive']);
165
+ refreshCache(cache, cwd);
166
+ return res;
134
167
  }
135
168
  case 'openspec_update': {
136
- return await runOpenSpec(['update']);
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
- return await runOpenSpec(cmdArgs);
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
- return await runOpenSpec(cmdArgs);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@igor-olikh/openspec-mcp-server",
3
- "version": "2.1.3",
3
+ "version": "2.1.5",
4
4
  "description": "An MCP server that connects OpenSpec to AI assistants like Codex, Claude, and Cursor.",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",