@contextstream/mcp-server 0.2.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/src/config.ts ADDED
@@ -0,0 +1,36 @@
1
+ import { z } from 'zod';
2
+
3
+ const configSchema = z.object({
4
+ apiUrl: z.string().url(),
5
+ apiKey: z.string().min(1).optional(),
6
+ jwt: z.string().min(1).optional(),
7
+ defaultWorkspaceId: z.string().uuid().optional(),
8
+ defaultProjectId: z.string().uuid().optional(),
9
+ userAgent: z.string().default('contextstream-mcp/0.1.0'),
10
+ });
11
+
12
+ export type Config = z.infer<typeof configSchema>;
13
+
14
+ export function loadConfig(): Config {
15
+ const parsed = configSchema.safeParse({
16
+ apiUrl: process.env.CONTEXTSTREAM_API_URL,
17
+ apiKey: process.env.CONTEXTSTREAM_API_KEY,
18
+ jwt: process.env.CONTEXTSTREAM_JWT,
19
+ defaultWorkspaceId: process.env.CONTEXTSTREAM_WORKSPACE_ID,
20
+ defaultProjectId: process.env.CONTEXTSTREAM_PROJECT_ID,
21
+ userAgent: process.env.CONTEXTSTREAM_USER_AGENT,
22
+ });
23
+
24
+ if (!parsed.success) {
25
+ const missing = parsed.error.errors.map((e) => e.path.join('.')).join(', ');
26
+ throw new Error(
27
+ `Invalid configuration. Set CONTEXTSTREAM_API_URL (and API key or JWT). Missing/invalid: ${missing}`
28
+ );
29
+ }
30
+
31
+ if (!parsed.data.apiKey && !parsed.data.jwt) {
32
+ throw new Error('Set CONTEXTSTREAM_API_KEY or CONTEXTSTREAM_JWT for authentication.');
33
+ }
34
+
35
+ return parsed.data;
36
+ }
package/src/files.ts ADDED
@@ -0,0 +1,261 @@
1
+ /**
2
+ * File reading utilities for code indexing
3
+ */
4
+
5
+ import * as fs from 'fs';
6
+ import * as path from 'path';
7
+
8
+ export interface FileToIngest {
9
+ path: string;
10
+ content: string;
11
+ language?: string;
12
+ }
13
+
14
+ // File extensions to include for indexing
15
+ const CODE_EXTENSIONS = new Set([
16
+ // Rust
17
+ 'rs',
18
+ // TypeScript/JavaScript
19
+ 'ts', 'tsx', 'js', 'jsx', 'mjs', 'cjs',
20
+ // Python
21
+ 'py', 'pyi',
22
+ // Go
23
+ 'go',
24
+ // Java/Kotlin
25
+ 'java', 'kt', 'kts',
26
+ // C/C++
27
+ 'c', 'h', 'cpp', 'hpp', 'cc', 'cxx',
28
+ // C#
29
+ 'cs',
30
+ // Ruby
31
+ 'rb',
32
+ // PHP
33
+ 'php',
34
+ // Swift
35
+ 'swift',
36
+ // Scala
37
+ 'scala',
38
+ // Shell
39
+ 'sh', 'bash', 'zsh',
40
+ // Config/Data
41
+ 'json', 'yaml', 'yml', 'toml', 'xml',
42
+ // SQL
43
+ 'sql',
44
+ // Markdown/Docs
45
+ 'md', 'markdown', 'rst', 'txt',
46
+ // HTML/CSS
47
+ 'html', 'htm', 'css', 'scss', 'sass', 'less',
48
+ // Other
49
+ 'graphql', 'proto', 'dockerfile',
50
+ ]);
51
+
52
+ // Directories to ignore
53
+ const IGNORE_DIRS = new Set([
54
+ 'node_modules',
55
+ '.git',
56
+ '.svn',
57
+ '.hg',
58
+ 'target',
59
+ 'dist',
60
+ 'build',
61
+ 'out',
62
+ '.next',
63
+ '.nuxt',
64
+ '__pycache__',
65
+ '.pytest_cache',
66
+ '.mypy_cache',
67
+ 'venv',
68
+ '.venv',
69
+ 'env',
70
+ '.env',
71
+ 'vendor',
72
+ 'coverage',
73
+ '.coverage',
74
+ '.idea',
75
+ '.vscode',
76
+ '.vs',
77
+ ]);
78
+
79
+ // Files to ignore
80
+ const IGNORE_FILES = new Set([
81
+ '.DS_Store',
82
+ 'Thumbs.db',
83
+ '.gitignore',
84
+ '.gitattributes',
85
+ 'package-lock.json',
86
+ 'yarn.lock',
87
+ 'pnpm-lock.yaml',
88
+ 'Cargo.lock',
89
+ 'poetry.lock',
90
+ 'Gemfile.lock',
91
+ 'composer.lock',
92
+ ]);
93
+
94
+ // Max file size to index (1MB)
95
+ const MAX_FILE_SIZE = 1024 * 1024;
96
+
97
+ // Max number of files to index in one batch
98
+ const MAX_FILES_PER_BATCH = 100;
99
+
100
+ /**
101
+ * Read all indexable files from a directory
102
+ */
103
+ export async function readFilesFromDirectory(
104
+ rootPath: string,
105
+ options: {
106
+ maxFiles?: number;
107
+ maxFileSize?: number;
108
+ } = {}
109
+ ): Promise<FileToIngest[]> {
110
+ const maxFiles = options.maxFiles ?? MAX_FILES_PER_BATCH;
111
+ const maxFileSize = options.maxFileSize ?? MAX_FILE_SIZE;
112
+ const files: FileToIngest[] = [];
113
+
114
+ async function walkDir(dir: string, relativePath: string = ''): Promise<void> {
115
+ if (files.length >= maxFiles) return;
116
+
117
+ let entries: fs.Dirent[];
118
+ try {
119
+ entries = await fs.promises.readdir(dir, { withFileTypes: true });
120
+ } catch {
121
+ return; // Skip directories we can't read
122
+ }
123
+
124
+ for (const entry of entries) {
125
+ if (files.length >= maxFiles) break;
126
+
127
+ const fullPath = path.join(dir, entry.name);
128
+ const relPath = path.join(relativePath, entry.name);
129
+
130
+ if (entry.isDirectory()) {
131
+ // Skip ignored directories
132
+ if (IGNORE_DIRS.has(entry.name)) continue;
133
+ await walkDir(fullPath, relPath);
134
+ } else if (entry.isFile()) {
135
+ // Skip ignored files
136
+ if (IGNORE_FILES.has(entry.name)) continue;
137
+
138
+ // Check extension
139
+ const ext = entry.name.split('.').pop()?.toLowerCase() ?? '';
140
+ if (!CODE_EXTENSIONS.has(ext)) continue;
141
+
142
+ // Check file size
143
+ try {
144
+ const stat = await fs.promises.stat(fullPath);
145
+ if (stat.size > maxFileSize) continue;
146
+
147
+ // Read file content
148
+ const content = await fs.promises.readFile(fullPath, 'utf-8');
149
+ files.push({
150
+ path: relPath,
151
+ content,
152
+ });
153
+ } catch {
154
+ // Skip files we can't read
155
+ }
156
+ }
157
+ }
158
+ }
159
+
160
+ await walkDir(rootPath);
161
+ return files;
162
+ }
163
+
164
+ /**
165
+ * Read ALL indexable files from a directory (no limit)
166
+ * Returns files in batches via async generator for memory efficiency
167
+ */
168
+ export async function* readAllFilesInBatches(
169
+ rootPath: string,
170
+ options: {
171
+ batchSize?: number;
172
+ maxFileSize?: number;
173
+ } = {}
174
+ ): AsyncGenerator<FileToIngest[], void, unknown> {
175
+ const batchSize = options.batchSize ?? 50;
176
+ const maxFileSize = options.maxFileSize ?? MAX_FILE_SIZE;
177
+ let batch: FileToIngest[] = [];
178
+
179
+ async function* walkDir(dir: string, relativePath: string = ''): AsyncGenerator<FileToIngest, void, unknown> {
180
+ let entries: fs.Dirent[];
181
+ try {
182
+ entries = await fs.promises.readdir(dir, { withFileTypes: true });
183
+ } catch {
184
+ return;
185
+ }
186
+
187
+ for (const entry of entries) {
188
+ const fullPath = path.join(dir, entry.name);
189
+ const relPath = path.join(relativePath, entry.name);
190
+
191
+ if (entry.isDirectory()) {
192
+ if (IGNORE_DIRS.has(entry.name)) continue;
193
+ yield* walkDir(fullPath, relPath);
194
+ } else if (entry.isFile()) {
195
+ if (IGNORE_FILES.has(entry.name)) continue;
196
+
197
+ const ext = entry.name.split('.').pop()?.toLowerCase() ?? '';
198
+ if (!CODE_EXTENSIONS.has(ext)) continue;
199
+
200
+ try {
201
+ const stat = await fs.promises.stat(fullPath);
202
+ if (stat.size > maxFileSize) continue;
203
+
204
+ const content = await fs.promises.readFile(fullPath, 'utf-8');
205
+ yield { path: relPath, content };
206
+ } catch {
207
+ // Skip unreadable files
208
+ }
209
+ }
210
+ }
211
+ }
212
+
213
+ for await (const file of walkDir(rootPath)) {
214
+ batch.push(file);
215
+ if (batch.length >= batchSize) {
216
+ yield batch;
217
+ batch = [];
218
+ }
219
+ }
220
+
221
+ if (batch.length > 0) {
222
+ yield batch;
223
+ }
224
+ }
225
+
226
+ /**
227
+ * Get language from file extension
228
+ */
229
+ export function detectLanguage(filePath: string): string {
230
+ const ext = filePath.split('.').pop()?.toLowerCase() ?? '';
231
+ const langMap: Record<string, string> = {
232
+ rs: 'rust',
233
+ ts: 'typescript',
234
+ tsx: 'typescript',
235
+ js: 'javascript',
236
+ jsx: 'javascript',
237
+ py: 'python',
238
+ go: 'go',
239
+ java: 'java',
240
+ kt: 'kotlin',
241
+ c: 'c',
242
+ h: 'c',
243
+ cpp: 'cpp',
244
+ hpp: 'cpp',
245
+ cs: 'csharp',
246
+ rb: 'ruby',
247
+ php: 'php',
248
+ swift: 'swift',
249
+ scala: 'scala',
250
+ sql: 'sql',
251
+ md: 'markdown',
252
+ json: 'json',
253
+ yaml: 'yaml',
254
+ yml: 'yaml',
255
+ toml: 'toml',
256
+ html: 'html',
257
+ css: 'css',
258
+ sh: 'shell',
259
+ };
260
+ return langMap[ext] ?? 'unknown';
261
+ }
package/src/http.ts ADDED
@@ -0,0 +1,154 @@
1
+ import type { Config } from './config.js';
2
+
3
+ export class HttpError extends Error {
4
+ status: number;
5
+ body: any;
6
+ code: string;
7
+
8
+ constructor(status: number, message: string, body?: any) {
9
+ super(message);
10
+ this.name = 'HttpError';
11
+ this.status = status;
12
+ this.body = body;
13
+ this.code = statusToCode(status);
14
+ }
15
+
16
+ toJSON() {
17
+ return {
18
+ error: this.message,
19
+ status: this.status,
20
+ code: this.code,
21
+ details: this.body,
22
+ };
23
+ }
24
+ }
25
+
26
+ function statusToCode(status: number): string {
27
+ switch (status) {
28
+ case 0: return 'NETWORK_ERROR';
29
+ case 400: return 'BAD_REQUEST';
30
+ case 401: return 'UNAUTHORIZED';
31
+ case 403: return 'FORBIDDEN';
32
+ case 404: return 'NOT_FOUND';
33
+ case 409: return 'CONFLICT';
34
+ case 422: return 'VALIDATION_ERROR';
35
+ case 429: return 'RATE_LIMITED';
36
+ case 500: return 'INTERNAL_ERROR';
37
+ case 502: return 'BAD_GATEWAY';
38
+ case 503: return 'SERVICE_UNAVAILABLE';
39
+ case 504: return 'GATEWAY_TIMEOUT';
40
+ default: return 'UNKNOWN_ERROR';
41
+ }
42
+ }
43
+
44
+ export interface RequestOptions {
45
+ method?: 'GET' | 'POST' | 'PUT' | 'DELETE';
46
+ body?: any;
47
+ signal?: AbortSignal;
48
+ retries?: number;
49
+ retryDelay?: number;
50
+ }
51
+
52
+ const RETRYABLE_STATUSES = new Set([408, 429, 500, 502, 503, 504]);
53
+ const MAX_RETRIES = 3;
54
+ const BASE_DELAY = 1000;
55
+
56
+ async function sleep(ms: number): Promise<void> {
57
+ return new Promise((resolve) => setTimeout(resolve, ms));
58
+ }
59
+
60
+ export async function request<T>(
61
+ config: Config,
62
+ path: string,
63
+ options: RequestOptions = {}
64
+ ): Promise<T> {
65
+ const { apiUrl, apiKey, jwt, userAgent } = config;
66
+ // Ensure path has /api/v1 prefix
67
+ const apiPath = path.startsWith('/api/') ? path : `/api/v1${path}`;
68
+ const url = `${apiUrl.replace(/\/$/, '')}${apiPath}`;
69
+ const maxRetries = options.retries ?? MAX_RETRIES;
70
+ const baseDelay = options.retryDelay ?? BASE_DELAY;
71
+
72
+ const headers: Record<string, string> = {
73
+ 'Content-Type': 'application/json',
74
+ 'User-Agent': userAgent,
75
+ };
76
+ if (apiKey) headers['X-API-Key'] = apiKey;
77
+ if (jwt) headers['Authorization'] = `Bearer ${jwt}`;
78
+
79
+ const fetchOptions: RequestInit = {
80
+ method: options.method || (options.body ? 'POST' : 'GET'),
81
+ headers,
82
+ };
83
+
84
+ if (options.body !== undefined) {
85
+ fetchOptions.body = JSON.stringify(options.body);
86
+ }
87
+
88
+ let lastError: HttpError | null = null;
89
+
90
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
91
+ const controller = new AbortController();
92
+ const timeout = setTimeout(() => controller.abort(), 180_000);
93
+
94
+ // Combine user signal with timeout
95
+ if (options.signal) {
96
+ options.signal.addEventListener('abort', () => controller.abort());
97
+ }
98
+ fetchOptions.signal = controller.signal;
99
+
100
+ let response: Response;
101
+ try {
102
+ response = await fetch(url, fetchOptions);
103
+ } catch (error: any) {
104
+ clearTimeout(timeout);
105
+
106
+ // Handle abort
107
+ if (error.name === 'AbortError') {
108
+ if (options.signal?.aborted) {
109
+ throw new HttpError(0, 'Request cancelled by user');
110
+ }
111
+ throw new HttpError(0, 'Request timeout after 180 seconds');
112
+ }
113
+
114
+ lastError = new HttpError(0, error?.message || 'Network error');
115
+
116
+ // Retry on network errors
117
+ if (attempt < maxRetries) {
118
+ const delay = baseDelay * Math.pow(2, attempt);
119
+ await sleep(delay);
120
+ continue;
121
+ }
122
+ throw lastError;
123
+ }
124
+
125
+ clearTimeout(timeout);
126
+
127
+ let payload: any = null;
128
+ const contentType = response.headers.get('content-type') || '';
129
+ if (contentType.includes('application/json')) {
130
+ payload = await response.json().catch(() => null);
131
+ } else {
132
+ payload = await response.text().catch(() => null);
133
+ }
134
+
135
+ if (!response.ok) {
136
+ const message = payload?.message || payload?.error || response.statusText;
137
+ lastError = new HttpError(response.status, message, payload);
138
+
139
+ // Retry on retryable status codes
140
+ if (RETRYABLE_STATUSES.has(response.status) && attempt < maxRetries) {
141
+ const retryAfter = response.headers.get('retry-after');
142
+ const delay = retryAfter ? parseInt(retryAfter, 10) * 1000 : baseDelay * Math.pow(2, attempt);
143
+ await sleep(delay);
144
+ continue;
145
+ }
146
+
147
+ throw lastError;
148
+ }
149
+
150
+ return payload as T;
151
+ }
152
+
153
+ throw lastError || new HttpError(0, 'Request failed after retries');
154
+ }
package/src/index.ts ADDED
@@ -0,0 +1,38 @@
1
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
3
+ import { loadConfig } from './config.js';
4
+ import { ContextStreamClient } from './client.js';
5
+ import { registerTools } from './tools.js';
6
+ import { registerResources } from './resources.js';
7
+ import { registerPrompts } from './prompts.js';
8
+
9
+ async function main() {
10
+ const config = loadConfig();
11
+ const client = new ContextStreamClient(config);
12
+
13
+ const server = new McpServer({
14
+ name: 'contextstream-mcp',
15
+ version: '0.2.0',
16
+ });
17
+
18
+ // Register all MCP components
19
+ registerTools(server, client);
20
+ registerResources(server, client, config.apiUrl);
21
+ registerPrompts(server);
22
+
23
+ // Log startup info (to stderr to not interfere with stdio protocol)
24
+ console.error(`ContextStream MCP server starting...`);
25
+ console.error(`API URL: ${config.apiUrl}`);
26
+ console.error(`Auth: ${config.apiKey ? 'API Key' : config.jwt ? 'JWT' : 'None'}`);
27
+
28
+ // Start stdio transport (works with Claude Code, Cursor, VS Code MCP config, Inspector)
29
+ const transport = new StdioServerTransport();
30
+ await server.connect(transport);
31
+
32
+ console.error('ContextStream MCP server connected and ready');
33
+ }
34
+
35
+ main().catch((err) => {
36
+ console.error('ContextStream MCP server failed to start:', err?.message || err);
37
+ process.exit(1);
38
+ });