@vibescope/mcp-server 0.4.6 → 0.4.7
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/api-client.d.ts +3 -1
- package/dist/api-client.js +24 -7
- package/dist/cli-init.js +4 -3
- package/dist/handlers/version.js +1 -1
- package/dist/index.js +86 -14
- package/dist/setup.js +13 -7
- package/dist/version.d.ts +9 -3
- package/dist/version.js +56 -8
- package/docs/TOOLS.md +1 -1
- package/package.json +1 -1
- package/src/api-client.ts +23 -7
- package/src/cli-init.ts +4 -3
- package/src/handlers/version.ts +1 -1
- package/src/index.ts +92 -15
- package/src/setup.test.ts +16 -6
- package/src/setup.ts +13 -7
- package/src/version.ts +61 -8
package/dist/api-client.d.ts
CHANGED
|
@@ -27,7 +27,9 @@ export declare class VibescopeApiClient {
|
|
|
27
27
|
private retryConfig;
|
|
28
28
|
constructor(config: ApiClientConfig);
|
|
29
29
|
private request;
|
|
30
|
-
validateAuth(
|
|
30
|
+
validateAuth(options?: {
|
|
31
|
+
timeoutMs?: number;
|
|
32
|
+
}): Promise<ApiResponse<{
|
|
31
33
|
valid: boolean;
|
|
32
34
|
user_id: string;
|
|
33
35
|
api_key_id: string;
|
package/dist/api-client.js
CHANGED
|
@@ -47,21 +47,30 @@ export class VibescopeApiClient {
|
|
|
47
47
|
retryStatusCodes: config.retry?.retryStatusCodes ?? DEFAULT_RETRY_STATUS_CODES,
|
|
48
48
|
};
|
|
49
49
|
}
|
|
50
|
-
async request(method, path, body) {
|
|
50
|
+
async request(method, path, body, options) {
|
|
51
51
|
const url = `${this.baseUrl}${path}`;
|
|
52
52
|
const { maxRetries, baseDelayMs, maxDelayMs, retryStatusCodes } = this.retryConfig;
|
|
53
53
|
let lastError = null;
|
|
54
54
|
let lastResponse = null;
|
|
55
55
|
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
56
|
+
let timeoutId;
|
|
56
57
|
try {
|
|
57
|
-
const
|
|
58
|
+
const fetchOptions = {
|
|
58
59
|
method,
|
|
59
60
|
headers: {
|
|
60
61
|
'Content-Type': 'application/json',
|
|
61
62
|
'X-API-Key': this.apiKey
|
|
62
63
|
},
|
|
63
|
-
body: body ? JSON.stringify(body) : undefined
|
|
64
|
-
}
|
|
64
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
65
|
+
};
|
|
66
|
+
if (options?.timeoutMs) {
|
|
67
|
+
const controller = new AbortController();
|
|
68
|
+
timeoutId = setTimeout(() => controller.abort(), options.timeoutMs);
|
|
69
|
+
fetchOptions.signal = controller.signal;
|
|
70
|
+
}
|
|
71
|
+
const response = await fetch(url, fetchOptions);
|
|
72
|
+
if (timeoutId)
|
|
73
|
+
clearTimeout(timeoutId);
|
|
65
74
|
// Check if we should retry this status code
|
|
66
75
|
if (retryStatusCodes.includes(response.status) && attempt < maxRetries) {
|
|
67
76
|
lastResponse = response;
|
|
@@ -94,7 +103,15 @@ export class VibescopeApiClient {
|
|
|
94
103
|
};
|
|
95
104
|
}
|
|
96
105
|
catch (err) {
|
|
97
|
-
|
|
106
|
+
if (timeoutId)
|
|
107
|
+
clearTimeout(timeoutId);
|
|
108
|
+
// Detect AbortError from timeout
|
|
109
|
+
if (err instanceof Error && err.name === 'AbortError' && options?.timeoutMs) {
|
|
110
|
+
lastError = new Error(`Request timed out after ${options.timeoutMs}ms`);
|
|
111
|
+
}
|
|
112
|
+
else {
|
|
113
|
+
lastError = err instanceof Error ? err : new Error('Network error');
|
|
114
|
+
}
|
|
98
115
|
// Retry on network errors (connection failures, timeouts)
|
|
99
116
|
if (attempt < maxRetries) {
|
|
100
117
|
const delay = calculateBackoffDelay(attempt, baseDelayMs, maxDelayMs);
|
|
@@ -130,10 +147,10 @@ export class VibescopeApiClient {
|
|
|
130
147
|
};
|
|
131
148
|
}
|
|
132
149
|
// Auth endpoints
|
|
133
|
-
async validateAuth() {
|
|
150
|
+
async validateAuth(options) {
|
|
134
151
|
return this.request('POST', '/api/mcp/auth/validate', {
|
|
135
152
|
api_key: this.apiKey
|
|
136
|
-
});
|
|
153
|
+
}, options);
|
|
137
154
|
}
|
|
138
155
|
// Session endpoints
|
|
139
156
|
async startSession(params) {
|
package/dist/cli-init.js
CHANGED
|
@@ -236,16 +236,17 @@ function writeJsonFile(path, data) {
|
|
|
236
236
|
}
|
|
237
237
|
function buildMcpServerConfig(apiKey) {
|
|
238
238
|
const isWindows = platform() === 'win32';
|
|
239
|
+
// Prefer globally installed binary (instant start) with npx fallback
|
|
239
240
|
if (isWindows) {
|
|
240
241
|
return {
|
|
241
242
|
command: 'cmd',
|
|
242
|
-
args: ['/c', 'npx
|
|
243
|
+
args: ['/c', 'where vibescope-mcp >nul 2>&1 && vibescope-mcp || npx -y -p @vibescope/mcp-server@latest vibescope-mcp'],
|
|
243
244
|
env: { VIBESCOPE_API_KEY: apiKey },
|
|
244
245
|
};
|
|
245
246
|
}
|
|
246
247
|
return {
|
|
247
|
-
command: '
|
|
248
|
-
args: ['-
|
|
248
|
+
command: 'bash',
|
|
249
|
+
args: ['-c', 'command -v vibescope-mcp >/dev/null 2>&1 && exec vibescope-mcp || exec npx -y -p @vibescope/mcp-server@latest vibescope-mcp'],
|
|
249
250
|
env: { VIBESCOPE_API_KEY: apiKey },
|
|
250
251
|
};
|
|
251
252
|
}
|
package/dist/handlers/version.js
CHANGED
|
@@ -7,7 +7,7 @@ import { checkVersion, getLocalVersion } from '../version.js';
|
|
|
7
7
|
const PACKAGE_NAME = '@vibescope/mcp-server';
|
|
8
8
|
export const versionHandlers = {
|
|
9
9
|
check_mcp_version: async (_args, _ctx) => {
|
|
10
|
-
const info = await checkVersion();
|
|
10
|
+
const info = await checkVersion({ bypassCache: true });
|
|
11
11
|
if (info.error) {
|
|
12
12
|
return success({
|
|
13
13
|
current_version: info.current,
|
package/dist/index.js
CHANGED
|
@@ -141,9 +141,9 @@ initApiClient({ apiKey: API_KEY });
|
|
|
141
141
|
// ============================================================================
|
|
142
142
|
// Authentication
|
|
143
143
|
// ============================================================================
|
|
144
|
-
async function validateApiKey() {
|
|
144
|
+
async function validateApiKey(timeoutMs) {
|
|
145
145
|
const apiClient = getApiClient();
|
|
146
|
-
const response = await apiClient.validateAuth();
|
|
146
|
+
const response = await apiClient.validateAuth(timeoutMs ? { timeoutMs } : undefined);
|
|
147
147
|
if (!response.ok || !response.data?.valid) {
|
|
148
148
|
return null;
|
|
149
149
|
}
|
|
@@ -153,6 +153,61 @@ async function validateApiKey() {
|
|
|
153
153
|
scope: 'personal', // API handles authorization, scope not needed locally
|
|
154
154
|
};
|
|
155
155
|
}
|
|
156
|
+
// Deferred auth: started eagerly but awaited on first tool call
|
|
157
|
+
let deferredAuthPromise = null;
|
|
158
|
+
let resolvedAuth = null;
|
|
159
|
+
let authError = null;
|
|
160
|
+
function startDeferredAuth() {
|
|
161
|
+
deferredAuthPromise = validateApiKey(10000)
|
|
162
|
+
.then((auth) => {
|
|
163
|
+
resolvedAuth = auth;
|
|
164
|
+
if (!auth)
|
|
165
|
+
authError = 'Invalid API key';
|
|
166
|
+
return auth;
|
|
167
|
+
})
|
|
168
|
+
.catch((err) => {
|
|
169
|
+
authError = err instanceof Error ? err.message : 'Auth validation failed';
|
|
170
|
+
return null;
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
async function getAuth() {
|
|
174
|
+
// If already resolved, return immediately
|
|
175
|
+
if (resolvedAuth)
|
|
176
|
+
return resolvedAuth;
|
|
177
|
+
// Await the deferred promise
|
|
178
|
+
if (deferredAuthPromise) {
|
|
179
|
+
const auth = await deferredAuthPromise;
|
|
180
|
+
if (auth)
|
|
181
|
+
return auth;
|
|
182
|
+
}
|
|
183
|
+
// Auth failed — return structured error
|
|
184
|
+
const troubleshooting = [
|
|
185
|
+
'MCP auth validation failed.',
|
|
186
|
+
authError ? `Reason: ${authError}` : '',
|
|
187
|
+
'Troubleshooting:',
|
|
188
|
+
'1. Check your API key is valid at https://vibescope.dev/dashboard/settings',
|
|
189
|
+
'2. Verify VIBESCOPE_API_KEY environment variable is set correctly',
|
|
190
|
+
'3. Check network connectivity to vibescope.dev',
|
|
191
|
+
'4. Restart Claude Code and try again',
|
|
192
|
+
].filter(Boolean).join('\n');
|
|
193
|
+
throw new Error(troubleshooting);
|
|
194
|
+
}
|
|
195
|
+
// Deferred update warning: fire-and-forget, delivered on first tool call
|
|
196
|
+
let updateWarningPromise = null;
|
|
197
|
+
let resolvedUpdateWarning = undefined; // undefined = not yet resolved
|
|
198
|
+
function startDeferredUpdateCheck() {
|
|
199
|
+
updateWarningPromise = getUpdateWarning().catch(() => null);
|
|
200
|
+
updateWarningPromise.then((warning) => {
|
|
201
|
+
resolvedUpdateWarning = warning;
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
function consumeUpdateWarning() {
|
|
205
|
+
if (resolvedUpdateWarning === undefined)
|
|
206
|
+
return null; // not ready yet
|
|
207
|
+
const warning = resolvedUpdateWarning;
|
|
208
|
+
resolvedUpdateWarning = null; // deliver only once
|
|
209
|
+
return warning;
|
|
210
|
+
}
|
|
156
211
|
// Tool definitions imported from tools.ts
|
|
157
212
|
// ============================================================================
|
|
158
213
|
// Tool Handlers
|
|
@@ -205,17 +260,10 @@ async function handleTool(auth, name, args) {
|
|
|
205
260
|
// Server Setup
|
|
206
261
|
// ============================================================================
|
|
207
262
|
async function main() {
|
|
208
|
-
//
|
|
209
|
-
|
|
210
|
-
if
|
|
211
|
-
|
|
212
|
-
process.exit(1);
|
|
213
|
-
}
|
|
214
|
-
// Check for updates (non-blocking, with timeout)
|
|
215
|
-
const updateWarning = await getUpdateWarning();
|
|
216
|
-
const serverInstructions = updateWarning
|
|
217
|
-
? `${updateWarning}\n\nVibescope MCP server - AI project tracking and coordination tools.`
|
|
218
|
-
: 'Vibescope MCP server - AI project tracking and coordination tools.';
|
|
263
|
+
// Start auth validation eagerly in background (10s timeout) — don't block startup
|
|
264
|
+
startDeferredAuth();
|
|
265
|
+
// Start update check in background — delivered on first tool call if available
|
|
266
|
+
startDeferredUpdateCheck();
|
|
219
267
|
const server = new Server({
|
|
220
268
|
name: 'vibescope',
|
|
221
269
|
version: '0.1.0',
|
|
@@ -223,7 +271,7 @@ async function main() {
|
|
|
223
271
|
capabilities: {
|
|
224
272
|
tools: {},
|
|
225
273
|
},
|
|
226
|
-
instructions:
|
|
274
|
+
instructions: 'Vibescope MCP server - AI project tracking and coordination tools.',
|
|
227
275
|
});
|
|
228
276
|
// List available tools
|
|
229
277
|
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
@@ -242,6 +290,22 @@ async function main() {
|
|
|
242
290
|
// Handle tool calls
|
|
243
291
|
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
244
292
|
const { name, arguments: args } = request.params;
|
|
293
|
+
// Await deferred auth on first tool call (usually already resolved)
|
|
294
|
+
let auth;
|
|
295
|
+
try {
|
|
296
|
+
auth = await getAuth();
|
|
297
|
+
}
|
|
298
|
+
catch (err) {
|
|
299
|
+
return {
|
|
300
|
+
content: [
|
|
301
|
+
{
|
|
302
|
+
type: 'text',
|
|
303
|
+
text: err instanceof Error ? err.message : 'Auth validation failed',
|
|
304
|
+
},
|
|
305
|
+
],
|
|
306
|
+
isError: true,
|
|
307
|
+
};
|
|
308
|
+
}
|
|
245
309
|
// Check rate limit
|
|
246
310
|
const rateCheck = rateLimiter.check(auth.apiKeyId);
|
|
247
311
|
if (!rateCheck.allowed) {
|
|
@@ -267,6 +331,14 @@ async function main() {
|
|
|
267
331
|
text: JSON.stringify(result, null, 2),
|
|
268
332
|
},
|
|
269
333
|
];
|
|
334
|
+
// Deliver update warning on first tool call (if available)
|
|
335
|
+
const updateWarning = consumeUpdateWarning();
|
|
336
|
+
if (updateWarning) {
|
|
337
|
+
content.push({
|
|
338
|
+
type: 'text',
|
|
339
|
+
text: `\n--- ${updateWarning} ---`,
|
|
340
|
+
});
|
|
341
|
+
}
|
|
270
342
|
// Include reminder nudge if applicable
|
|
271
343
|
const reminder = getReminder(name);
|
|
272
344
|
if (reminder) {
|
package/dist/setup.js
CHANGED
|
@@ -176,13 +176,19 @@ export function writeConfig(configPath, config) {
|
|
|
176
176
|
writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n');
|
|
177
177
|
}
|
|
178
178
|
export function generateMcpConfig(apiKey, ide) {
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
179
|
+
// Prefer globally installed binary (instant start) with npx fallback
|
|
180
|
+
const isWindows = platform() === 'win32';
|
|
181
|
+
const vibescopeServer = isWindows
|
|
182
|
+
? {
|
|
183
|
+
command: 'cmd',
|
|
184
|
+
args: ['/c', 'where vibescope-mcp >nul 2>&1 && vibescope-mcp || npx -y -p @vibescope/mcp-server@latest vibescope-mcp'],
|
|
185
|
+
env: { VIBESCOPE_API_KEY: apiKey },
|
|
186
|
+
}
|
|
187
|
+
: {
|
|
188
|
+
command: 'bash',
|
|
189
|
+
args: ['-c', 'command -v vibescope-mcp >/dev/null 2>&1 && exec vibescope-mcp || exec npx -y -p @vibescope/mcp-server@latest vibescope-mcp'],
|
|
190
|
+
env: { VIBESCOPE_API_KEY: apiKey },
|
|
191
|
+
};
|
|
186
192
|
// Gemini CLI uses a different config format with additional options
|
|
187
193
|
if (ide.configFormat === 'settings-json') {
|
|
188
194
|
return {
|
package/dist/version.d.ts
CHANGED
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
* Version checking utilities
|
|
3
3
|
*
|
|
4
4
|
* Compares the locally installed version against the latest published
|
|
5
|
-
* version on npm to detect available updates.
|
|
5
|
+
* version on npm to detect available updates. Uses a file-based cache
|
|
6
|
+
* (~/.vibescope/version-cache.json) with 1-hour TTL to avoid hitting
|
|
7
|
+
* the npm registry on every startup.
|
|
6
8
|
*/
|
|
7
9
|
export interface VersionInfo {
|
|
8
10
|
current: string;
|
|
@@ -17,11 +19,15 @@ export declare function getLocalVersion(): string;
|
|
|
17
19
|
/**
|
|
18
20
|
* Fetch the latest published version from npm registry
|
|
19
21
|
*/
|
|
20
|
-
export declare function getLatestVersion(
|
|
22
|
+
export declare function getLatestVersion(options?: {
|
|
23
|
+
bypassCache?: boolean;
|
|
24
|
+
}): Promise<string | null>;
|
|
21
25
|
/**
|
|
22
26
|
* Check if an update is available
|
|
23
27
|
*/
|
|
24
|
-
export declare function checkVersion(
|
|
28
|
+
export declare function checkVersion(options?: {
|
|
29
|
+
bypassCache?: boolean;
|
|
30
|
+
}): Promise<VersionInfo>;
|
|
25
31
|
/**
|
|
26
32
|
* Get the update warning message for server instructions, or null if up to date
|
|
27
33
|
*/
|
package/dist/version.js
CHANGED
|
@@ -2,13 +2,19 @@
|
|
|
2
2
|
* Version checking utilities
|
|
3
3
|
*
|
|
4
4
|
* Compares the locally installed version against the latest published
|
|
5
|
-
* version on npm to detect available updates.
|
|
5
|
+
* version on npm to detect available updates. Uses a file-based cache
|
|
6
|
+
* (~/.vibescope/version-cache.json) with 1-hour TTL to avoid hitting
|
|
7
|
+
* the npm registry on every startup.
|
|
6
8
|
*/
|
|
7
|
-
import { readFileSync } from 'fs';
|
|
8
|
-
import { resolve, dirname } from 'path';
|
|
9
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
|
|
10
|
+
import { resolve, dirname, join } from 'path';
|
|
9
11
|
import { fileURLToPath } from 'url';
|
|
12
|
+
import { homedir } from 'os';
|
|
10
13
|
const NPM_REGISTRY_URL = 'https://registry.npmjs.org/@vibescope/mcp-server';
|
|
11
14
|
const PACKAGE_NAME = '@vibescope/mcp-server';
|
|
15
|
+
const CACHE_DIR = join(homedir(), '.vibescope');
|
|
16
|
+
const CACHE_PATH = join(CACHE_DIR, 'version-cache.json');
|
|
17
|
+
const CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
|
|
12
18
|
/**
|
|
13
19
|
* Get the current locally installed version from package.json
|
|
14
20
|
*/
|
|
@@ -23,13 +29,51 @@ export function getLocalVersion() {
|
|
|
23
29
|
return 'unknown';
|
|
24
30
|
}
|
|
25
31
|
}
|
|
32
|
+
/**
|
|
33
|
+
* Read version from file-based cache if still valid
|
|
34
|
+
*/
|
|
35
|
+
function readVersionCache() {
|
|
36
|
+
try {
|
|
37
|
+
if (!existsSync(CACHE_PATH))
|
|
38
|
+
return null;
|
|
39
|
+
const raw = JSON.parse(readFileSync(CACHE_PATH, 'utf-8'));
|
|
40
|
+
if (Date.now() - raw.fetchedAt < CACHE_TTL_MS) {
|
|
41
|
+
return raw.latestVersion;
|
|
42
|
+
}
|
|
43
|
+
return null; // expired
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Write version to file-based cache
|
|
51
|
+
*/
|
|
52
|
+
function writeVersionCache(version) {
|
|
53
|
+
try {
|
|
54
|
+
if (!existsSync(CACHE_DIR)) {
|
|
55
|
+
mkdirSync(CACHE_DIR, { recursive: true });
|
|
56
|
+
}
|
|
57
|
+
const cache = { latestVersion: version, fetchedAt: Date.now() };
|
|
58
|
+
writeFileSync(CACHE_PATH, JSON.stringify(cache, null, 2) + '\n');
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
// Non-critical — silently ignore write failures
|
|
62
|
+
}
|
|
63
|
+
}
|
|
26
64
|
/**
|
|
27
65
|
* Fetch the latest published version from npm registry
|
|
28
66
|
*/
|
|
29
|
-
export async function getLatestVersion() {
|
|
67
|
+
export async function getLatestVersion(options) {
|
|
68
|
+
// Check cache first (unless bypassed)
|
|
69
|
+
if (!options?.bypassCache) {
|
|
70
|
+
const cached = readVersionCache();
|
|
71
|
+
if (cached)
|
|
72
|
+
return cached;
|
|
73
|
+
}
|
|
30
74
|
try {
|
|
31
75
|
const controller = new AbortController();
|
|
32
|
-
const timeout = setTimeout(() => controller.abort(),
|
|
76
|
+
const timeout = setTimeout(() => controller.abort(), 3000);
|
|
33
77
|
const response = await fetch(`${NPM_REGISTRY_URL}/latest`, {
|
|
34
78
|
signal: controller.signal,
|
|
35
79
|
headers: { 'Accept': 'application/json' },
|
|
@@ -38,7 +82,11 @@ export async function getLatestVersion() {
|
|
|
38
82
|
if (!response.ok)
|
|
39
83
|
return null;
|
|
40
84
|
const data = (await response.json());
|
|
41
|
-
|
|
85
|
+
const version = data.version ?? null;
|
|
86
|
+
// Write to cache on successful fetch
|
|
87
|
+
if (version)
|
|
88
|
+
writeVersionCache(version);
|
|
89
|
+
return version;
|
|
42
90
|
}
|
|
43
91
|
catch {
|
|
44
92
|
return null;
|
|
@@ -62,9 +110,9 @@ function isNewer(current, latest) {
|
|
|
62
110
|
/**
|
|
63
111
|
* Check if an update is available
|
|
64
112
|
*/
|
|
65
|
-
export async function checkVersion() {
|
|
113
|
+
export async function checkVersion(options) {
|
|
66
114
|
const current = getLocalVersion();
|
|
67
|
-
const latest = await getLatestVersion();
|
|
115
|
+
const latest = await getLatestVersion(options);
|
|
68
116
|
if (!latest) {
|
|
69
117
|
return {
|
|
70
118
|
current,
|
package/docs/TOOLS.md
CHANGED
package/package.json
CHANGED
package/src/api-client.ts
CHANGED
|
@@ -81,22 +81,32 @@ export class VibescopeApiClient {
|
|
|
81
81
|
};
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
-
private async request<T>(method: string, path: string, body?: unknown): Promise<ApiResponse<T>> {
|
|
84
|
+
private async request<T>(method: string, path: string, body?: unknown, options?: { timeoutMs?: number }): Promise<ApiResponse<T>> {
|
|
85
85
|
const url = `${this.baseUrl}${path}`;
|
|
86
86
|
const { maxRetries, baseDelayMs, maxDelayMs, retryStatusCodes } = this.retryConfig;
|
|
87
87
|
let lastError: Error | null = null;
|
|
88
88
|
let lastResponse: Response | null = null;
|
|
89
89
|
|
|
90
90
|
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
91
|
+
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
|
91
92
|
try {
|
|
92
|
-
const
|
|
93
|
+
const fetchOptions: RequestInit = {
|
|
93
94
|
method,
|
|
94
95
|
headers: {
|
|
95
96
|
'Content-Type': 'application/json',
|
|
96
97
|
'X-API-Key': this.apiKey
|
|
97
98
|
},
|
|
98
|
-
body: body ? JSON.stringify(body) : undefined
|
|
99
|
-
}
|
|
99
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
if (options?.timeoutMs) {
|
|
103
|
+
const controller = new AbortController();
|
|
104
|
+
timeoutId = setTimeout(() => controller.abort(), options.timeoutMs);
|
|
105
|
+
fetchOptions.signal = controller.signal;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const response = await fetch(url, fetchOptions);
|
|
109
|
+
if (timeoutId) clearTimeout(timeoutId);
|
|
100
110
|
|
|
101
111
|
// Check if we should retry this status code
|
|
102
112
|
if (retryStatusCodes.includes(response.status) && attempt < maxRetries) {
|
|
@@ -132,7 +142,13 @@ export class VibescopeApiClient {
|
|
|
132
142
|
data
|
|
133
143
|
};
|
|
134
144
|
} catch (err) {
|
|
135
|
-
|
|
145
|
+
if (timeoutId) clearTimeout(timeoutId);
|
|
146
|
+
// Detect AbortError from timeout
|
|
147
|
+
if (err instanceof Error && err.name === 'AbortError' && options?.timeoutMs) {
|
|
148
|
+
lastError = new Error(`Request timed out after ${options.timeoutMs}ms`);
|
|
149
|
+
} else {
|
|
150
|
+
lastError = err instanceof Error ? err : new Error('Network error');
|
|
151
|
+
}
|
|
136
152
|
// Retry on network errors (connection failures, timeouts)
|
|
137
153
|
if (attempt < maxRetries) {
|
|
138
154
|
const delay = calculateBackoffDelay(attempt, baseDelayMs, maxDelayMs);
|
|
@@ -170,7 +186,7 @@ export class VibescopeApiClient {
|
|
|
170
186
|
}
|
|
171
187
|
|
|
172
188
|
// Auth endpoints
|
|
173
|
-
async validateAuth(): Promise<ApiResponse<{
|
|
189
|
+
async validateAuth(options?: { timeoutMs?: number }): Promise<ApiResponse<{
|
|
174
190
|
valid: boolean;
|
|
175
191
|
user_id: string;
|
|
176
192
|
api_key_id: string;
|
|
@@ -178,7 +194,7 @@ export class VibescopeApiClient {
|
|
|
178
194
|
}>> {
|
|
179
195
|
return this.request('POST', '/api/mcp/auth/validate', {
|
|
180
196
|
api_key: this.apiKey
|
|
181
|
-
});
|
|
197
|
+
}, options);
|
|
182
198
|
}
|
|
183
199
|
|
|
184
200
|
// Session endpoints
|
package/src/cli-init.ts
CHANGED
|
@@ -275,16 +275,17 @@ function writeJsonFile(path: string, data: Record<string, unknown>): void {
|
|
|
275
275
|
|
|
276
276
|
function buildMcpServerConfig(apiKey: string): Record<string, unknown> {
|
|
277
277
|
const isWindows = platform() === 'win32';
|
|
278
|
+
// Prefer globally installed binary (instant start) with npx fallback
|
|
278
279
|
if (isWindows) {
|
|
279
280
|
return {
|
|
280
281
|
command: 'cmd',
|
|
281
|
-
args: ['/c', 'npx
|
|
282
|
+
args: ['/c', 'where vibescope-mcp >nul 2>&1 && vibescope-mcp || npx -y -p @vibescope/mcp-server@latest vibescope-mcp'],
|
|
282
283
|
env: { VIBESCOPE_API_KEY: apiKey },
|
|
283
284
|
};
|
|
284
285
|
}
|
|
285
286
|
return {
|
|
286
|
-
command: '
|
|
287
|
-
args: ['-
|
|
287
|
+
command: 'bash',
|
|
288
|
+
args: ['-c', 'command -v vibescope-mcp >/dev/null 2>&1 && exec vibescope-mcp || exec npx -y -p @vibescope/mcp-server@latest vibescope-mcp'],
|
|
288
289
|
env: { VIBESCOPE_API_KEY: apiKey },
|
|
289
290
|
};
|
|
290
291
|
}
|
package/src/handlers/version.ts
CHANGED
|
@@ -11,7 +11,7 @@ const PACKAGE_NAME = '@vibescope/mcp-server';
|
|
|
11
11
|
|
|
12
12
|
export const versionHandlers: HandlerRegistry = {
|
|
13
13
|
check_mcp_version: async (_args, _ctx) => {
|
|
14
|
-
const info = await checkVersion();
|
|
14
|
+
const info = await checkVersion({ bypassCache: true });
|
|
15
15
|
|
|
16
16
|
if (info.error) {
|
|
17
17
|
return success({
|
package/src/index.ts
CHANGED
|
@@ -574,9 +574,9 @@ initApiClient({ apiKey: API_KEY });
|
|
|
574
574
|
// Authentication
|
|
575
575
|
// ============================================================================
|
|
576
576
|
|
|
577
|
-
async function validateApiKey(): Promise<AuthContext | null> {
|
|
577
|
+
async function validateApiKey(timeoutMs?: number): Promise<AuthContext | null> {
|
|
578
578
|
const apiClient = getApiClient();
|
|
579
|
-
const response = await apiClient.validateAuth();
|
|
579
|
+
const response = await apiClient.validateAuth(timeoutMs ? { timeoutMs } : undefined);
|
|
580
580
|
|
|
581
581
|
if (!response.ok || !response.data?.valid) {
|
|
582
582
|
return null;
|
|
@@ -589,6 +589,66 @@ async function validateApiKey(): Promise<AuthContext | null> {
|
|
|
589
589
|
};
|
|
590
590
|
}
|
|
591
591
|
|
|
592
|
+
// Deferred auth: started eagerly but awaited on first tool call
|
|
593
|
+
let deferredAuthPromise: Promise<AuthContext | null> | null = null;
|
|
594
|
+
let resolvedAuth: AuthContext | null = null;
|
|
595
|
+
let authError: string | null = null;
|
|
596
|
+
|
|
597
|
+
function startDeferredAuth(): void {
|
|
598
|
+
deferredAuthPromise = validateApiKey(10000)
|
|
599
|
+
.then((auth) => {
|
|
600
|
+
resolvedAuth = auth;
|
|
601
|
+
if (!auth) authError = 'Invalid API key';
|
|
602
|
+
return auth;
|
|
603
|
+
})
|
|
604
|
+
.catch((err) => {
|
|
605
|
+
authError = err instanceof Error ? err.message : 'Auth validation failed';
|
|
606
|
+
return null;
|
|
607
|
+
});
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
async function getAuth(): Promise<AuthContext> {
|
|
611
|
+
// If already resolved, return immediately
|
|
612
|
+
if (resolvedAuth) return resolvedAuth;
|
|
613
|
+
|
|
614
|
+
// Await the deferred promise
|
|
615
|
+
if (deferredAuthPromise) {
|
|
616
|
+
const auth = await deferredAuthPromise;
|
|
617
|
+
if (auth) return auth;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
// Auth failed — return structured error
|
|
621
|
+
const troubleshooting = [
|
|
622
|
+
'MCP auth validation failed.',
|
|
623
|
+
authError ? `Reason: ${authError}` : '',
|
|
624
|
+
'Troubleshooting:',
|
|
625
|
+
'1. Check your API key is valid at https://vibescope.dev/dashboard/settings',
|
|
626
|
+
'2. Verify VIBESCOPE_API_KEY environment variable is set correctly',
|
|
627
|
+
'3. Check network connectivity to vibescope.dev',
|
|
628
|
+
'4. Restart Claude Code and try again',
|
|
629
|
+
].filter(Boolean).join('\n');
|
|
630
|
+
|
|
631
|
+
throw new Error(troubleshooting);
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
// Deferred update warning: fire-and-forget, delivered on first tool call
|
|
635
|
+
let updateWarningPromise: Promise<string | null> | null = null;
|
|
636
|
+
let resolvedUpdateWarning: string | null | undefined = undefined; // undefined = not yet resolved
|
|
637
|
+
|
|
638
|
+
function startDeferredUpdateCheck(): void {
|
|
639
|
+
updateWarningPromise = getUpdateWarning().catch(() => null);
|
|
640
|
+
updateWarningPromise.then((warning) => {
|
|
641
|
+
resolvedUpdateWarning = warning;
|
|
642
|
+
});
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
function consumeUpdateWarning(): string | null {
|
|
646
|
+
if (resolvedUpdateWarning === undefined) return null; // not ready yet
|
|
647
|
+
const warning = resolvedUpdateWarning;
|
|
648
|
+
resolvedUpdateWarning = null; // deliver only once
|
|
649
|
+
return warning;
|
|
650
|
+
}
|
|
651
|
+
|
|
592
652
|
// Tool definitions imported from tools.ts
|
|
593
653
|
|
|
594
654
|
|
|
@@ -648,19 +708,11 @@ async function handleTool(
|
|
|
648
708
|
// ============================================================================
|
|
649
709
|
|
|
650
710
|
async function main() {
|
|
651
|
-
//
|
|
652
|
-
|
|
653
|
-
if (!auth) {
|
|
654
|
-
console.error('Invalid API key');
|
|
655
|
-
process.exit(1);
|
|
656
|
-
}
|
|
657
|
-
|
|
658
|
-
// Check for updates (non-blocking, with timeout)
|
|
659
|
-
const updateWarning = await getUpdateWarning();
|
|
711
|
+
// Start auth validation eagerly in background (10s timeout) — don't block startup
|
|
712
|
+
startDeferredAuth();
|
|
660
713
|
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
: 'Vibescope MCP server - AI project tracking and coordination tools.';
|
|
714
|
+
// Start update check in background — delivered on first tool call if available
|
|
715
|
+
startDeferredUpdateCheck();
|
|
664
716
|
|
|
665
717
|
const server = new Server(
|
|
666
718
|
{
|
|
@@ -671,7 +723,7 @@ async function main() {
|
|
|
671
723
|
capabilities: {
|
|
672
724
|
tools: {},
|
|
673
725
|
},
|
|
674
|
-
instructions:
|
|
726
|
+
instructions: 'Vibescope MCP server - AI project tracking and coordination tools.',
|
|
675
727
|
}
|
|
676
728
|
);
|
|
677
729
|
|
|
@@ -695,6 +747,22 @@ async function main() {
|
|
|
695
747
|
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
696
748
|
const { name, arguments: args } = request.params;
|
|
697
749
|
|
|
750
|
+
// Await deferred auth on first tool call (usually already resolved)
|
|
751
|
+
let auth: AuthContext;
|
|
752
|
+
try {
|
|
753
|
+
auth = await getAuth();
|
|
754
|
+
} catch (err) {
|
|
755
|
+
return {
|
|
756
|
+
content: [
|
|
757
|
+
{
|
|
758
|
+
type: 'text',
|
|
759
|
+
text: err instanceof Error ? err.message : 'Auth validation failed',
|
|
760
|
+
},
|
|
761
|
+
],
|
|
762
|
+
isError: true,
|
|
763
|
+
};
|
|
764
|
+
}
|
|
765
|
+
|
|
698
766
|
// Check rate limit
|
|
699
767
|
const rateCheck = rateLimiter.check(auth.apiKeyId);
|
|
700
768
|
if (!rateCheck.allowed) {
|
|
@@ -728,6 +796,15 @@ async function main() {
|
|
|
728
796
|
},
|
|
729
797
|
];
|
|
730
798
|
|
|
799
|
+
// Deliver update warning on first tool call (if available)
|
|
800
|
+
const updateWarning = consumeUpdateWarning();
|
|
801
|
+
if (updateWarning) {
|
|
802
|
+
content.push({
|
|
803
|
+
type: 'text',
|
|
804
|
+
text: `\n--- ${updateWarning} ---`,
|
|
805
|
+
});
|
|
806
|
+
}
|
|
807
|
+
|
|
731
808
|
// Include reminder nudge if applicable
|
|
732
809
|
const reminder = getReminder(name);
|
|
733
810
|
if (reminder) {
|
package/src/setup.test.ts
CHANGED
|
@@ -186,8 +186,11 @@ describe('Setup module', () => {
|
|
|
186
186
|
const mcpServers = config.mcpServers as Record<string, unknown>;
|
|
187
187
|
const vibescope = mcpServers.vibescope as Record<string, unknown>;
|
|
188
188
|
|
|
189
|
-
|
|
190
|
-
expect(
|
|
189
|
+
// Config uses shell wrapper to prefer global binary with npx fallback
|
|
190
|
+
expect(['cmd', 'bash']).toContain(vibescope.command);
|
|
191
|
+
const argsStr = JSON.stringify(vibescope.args);
|
|
192
|
+
expect(argsStr).toContain('vibescope-mcp');
|
|
193
|
+
expect(argsStr).toContain('npx');
|
|
191
194
|
expect((vibescope.env as Record<string, string>).VIBESCOPE_API_KEY).toBe('test-api-key');
|
|
192
195
|
// Standard MCP config should NOT have timeout/trust
|
|
193
196
|
expect(vibescope.timeout).toBeUndefined();
|
|
@@ -207,14 +210,16 @@ describe('Setup module', () => {
|
|
|
207
210
|
const mcpServers = config.mcpServers as Record<string, unknown>;
|
|
208
211
|
const vibescope = mcpServers.vibescope as Record<string, unknown>;
|
|
209
212
|
|
|
210
|
-
expect(vibescope.command)
|
|
211
|
-
|
|
213
|
+
expect(['cmd', 'bash']).toContain(vibescope.command);
|
|
214
|
+
const argsStr = JSON.stringify(vibescope.args);
|
|
215
|
+
expect(argsStr).toContain('vibescope-mcp');
|
|
216
|
+
expect(argsStr).toContain('npx');
|
|
212
217
|
expect((vibescope.env as Record<string, string>).VIBESCOPE_API_KEY).toBe('test-api-key');
|
|
213
218
|
expect(vibescope.timeout).toBe(30000);
|
|
214
219
|
expect(vibescope.trust).toBe(true);
|
|
215
220
|
});
|
|
216
221
|
|
|
217
|
-
it('should use
|
|
222
|
+
it('should use shell wrapper with global binary fallback', () => {
|
|
218
223
|
const ide: IdeConfig = {
|
|
219
224
|
name: 'claude-code',
|
|
220
225
|
displayName: 'Claude Code (CLI)',
|
|
@@ -226,8 +231,13 @@ describe('Setup module', () => {
|
|
|
226
231
|
const config = generateMcpConfig('my-key', ide);
|
|
227
232
|
const mcpServers = config.mcpServers as Record<string, unknown>;
|
|
228
233
|
const vibescope = mcpServers.vibescope as Record<string, unknown>;
|
|
234
|
+
const args = vibescope.args as string[];
|
|
229
235
|
|
|
230
|
-
|
|
236
|
+
// Should check for global binary first, then fall back to npx
|
|
237
|
+
const shellCmd = args.join(' ');
|
|
238
|
+
expect(shellCmd).toContain('vibescope-mcp');
|
|
239
|
+
expect(shellCmd).toContain('npx');
|
|
240
|
+
expect(shellCmd).toContain('@vibescope/mcp-server@latest');
|
|
231
241
|
});
|
|
232
242
|
});
|
|
233
243
|
});
|
package/src/setup.ts
CHANGED
|
@@ -210,13 +210,19 @@ export function writeConfig(configPath: string, config: Record<string, unknown>)
|
|
|
210
210
|
}
|
|
211
211
|
|
|
212
212
|
export function generateMcpConfig(apiKey: string, ide: IdeConfig): Record<string, unknown> {
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
213
|
+
// Prefer globally installed binary (instant start) with npx fallback
|
|
214
|
+
const isWindows = platform() === 'win32';
|
|
215
|
+
const vibescopeServer = isWindows
|
|
216
|
+
? {
|
|
217
|
+
command: 'cmd',
|
|
218
|
+
args: ['/c', 'where vibescope-mcp >nul 2>&1 && vibescope-mcp || npx -y -p @vibescope/mcp-server@latest vibescope-mcp'],
|
|
219
|
+
env: { VIBESCOPE_API_KEY: apiKey },
|
|
220
|
+
}
|
|
221
|
+
: {
|
|
222
|
+
command: 'bash',
|
|
223
|
+
args: ['-c', 'command -v vibescope-mcp >/dev/null 2>&1 && exec vibescope-mcp || exec npx -y -p @vibescope/mcp-server@latest vibescope-mcp'],
|
|
224
|
+
env: { VIBESCOPE_API_KEY: apiKey },
|
|
225
|
+
};
|
|
220
226
|
|
|
221
227
|
// Gemini CLI uses a different config format with additional options
|
|
222
228
|
if (ide.configFormat === 'settings-json') {
|
package/src/version.ts
CHANGED
|
@@ -2,15 +2,26 @@
|
|
|
2
2
|
* Version checking utilities
|
|
3
3
|
*
|
|
4
4
|
* Compares the locally installed version against the latest published
|
|
5
|
-
* version on npm to detect available updates.
|
|
5
|
+
* version on npm to detect available updates. Uses a file-based cache
|
|
6
|
+
* (~/.vibescope/version-cache.json) with 1-hour TTL to avoid hitting
|
|
7
|
+
* the npm registry on every startup.
|
|
6
8
|
*/
|
|
7
9
|
|
|
8
|
-
import { readFileSync } from 'fs';
|
|
9
|
-
import { resolve, dirname } from 'path';
|
|
10
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
|
|
11
|
+
import { resolve, dirname, join } from 'path';
|
|
10
12
|
import { fileURLToPath } from 'url';
|
|
13
|
+
import { homedir } from 'os';
|
|
11
14
|
|
|
12
15
|
const NPM_REGISTRY_URL = 'https://registry.npmjs.org/@vibescope/mcp-server';
|
|
13
16
|
const PACKAGE_NAME = '@vibescope/mcp-server';
|
|
17
|
+
const CACHE_DIR = join(homedir(), '.vibescope');
|
|
18
|
+
const CACHE_PATH = join(CACHE_DIR, 'version-cache.json');
|
|
19
|
+
const CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
|
|
20
|
+
|
|
21
|
+
interface VersionCache {
|
|
22
|
+
latestVersion: string;
|
|
23
|
+
fetchedAt: number; // epoch ms
|
|
24
|
+
}
|
|
14
25
|
|
|
15
26
|
export interface VersionInfo {
|
|
16
27
|
current: string;
|
|
@@ -33,13 +44,50 @@ export function getLocalVersion(): string {
|
|
|
33
44
|
}
|
|
34
45
|
}
|
|
35
46
|
|
|
47
|
+
/**
|
|
48
|
+
* Read version from file-based cache if still valid
|
|
49
|
+
*/
|
|
50
|
+
function readVersionCache(): string | null {
|
|
51
|
+
try {
|
|
52
|
+
if (!existsSync(CACHE_PATH)) return null;
|
|
53
|
+
const raw = JSON.parse(readFileSync(CACHE_PATH, 'utf-8')) as VersionCache;
|
|
54
|
+
if (Date.now() - raw.fetchedAt < CACHE_TTL_MS) {
|
|
55
|
+
return raw.latestVersion;
|
|
56
|
+
}
|
|
57
|
+
return null; // expired
|
|
58
|
+
} catch {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Write version to file-based cache
|
|
65
|
+
*/
|
|
66
|
+
function writeVersionCache(version: string): void {
|
|
67
|
+
try {
|
|
68
|
+
if (!existsSync(CACHE_DIR)) {
|
|
69
|
+
mkdirSync(CACHE_DIR, { recursive: true });
|
|
70
|
+
}
|
|
71
|
+
const cache: VersionCache = { latestVersion: version, fetchedAt: Date.now() };
|
|
72
|
+
writeFileSync(CACHE_PATH, JSON.stringify(cache, null, 2) + '\n');
|
|
73
|
+
} catch {
|
|
74
|
+
// Non-critical — silently ignore write failures
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
36
78
|
/**
|
|
37
79
|
* Fetch the latest published version from npm registry
|
|
38
80
|
*/
|
|
39
|
-
export async function getLatestVersion(): Promise<string | null> {
|
|
81
|
+
export async function getLatestVersion(options?: { bypassCache?: boolean }): Promise<string | null> {
|
|
82
|
+
// Check cache first (unless bypassed)
|
|
83
|
+
if (!options?.bypassCache) {
|
|
84
|
+
const cached = readVersionCache();
|
|
85
|
+
if (cached) return cached;
|
|
86
|
+
}
|
|
87
|
+
|
|
40
88
|
try {
|
|
41
89
|
const controller = new AbortController();
|
|
42
|
-
const timeout = setTimeout(() => controller.abort(),
|
|
90
|
+
const timeout = setTimeout(() => controller.abort(), 3000);
|
|
43
91
|
|
|
44
92
|
const response = await fetch(`${NPM_REGISTRY_URL}/latest`, {
|
|
45
93
|
signal: controller.signal,
|
|
@@ -51,7 +99,12 @@ export async function getLatestVersion(): Promise<string | null> {
|
|
|
51
99
|
if (!response.ok) return null;
|
|
52
100
|
|
|
53
101
|
const data = (await response.json()) as { version?: string };
|
|
54
|
-
|
|
102
|
+
const version = data.version ?? null;
|
|
103
|
+
|
|
104
|
+
// Write to cache on successful fetch
|
|
105
|
+
if (version) writeVersionCache(version);
|
|
106
|
+
|
|
107
|
+
return version;
|
|
55
108
|
} catch {
|
|
56
109
|
return null;
|
|
57
110
|
}
|
|
@@ -75,9 +128,9 @@ function isNewer(current: string, latest: string): boolean {
|
|
|
75
128
|
/**
|
|
76
129
|
* Check if an update is available
|
|
77
130
|
*/
|
|
78
|
-
export async function checkVersion(): Promise<VersionInfo> {
|
|
131
|
+
export async function checkVersion(options?: { bypassCache?: boolean }): Promise<VersionInfo> {
|
|
79
132
|
const current = getLocalVersion();
|
|
80
|
-
const latest = await getLatestVersion();
|
|
133
|
+
const latest = await getLatestVersion(options);
|
|
81
134
|
|
|
82
135
|
if (!latest) {
|
|
83
136
|
return {
|