smart-context-mcp 0.8.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.
@@ -0,0 +1,3 @@
1
+ import { runDevctxServer } from './server.js';
2
+
3
+ await runDevctxServer();
package/src/metrics.js ADDED
@@ -0,0 +1,65 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { countTokens } from './tokenCounter.js';
4
+ import { devctxRoot, projectRoot } from './utils/paths.js';
5
+
6
+ const defaultMetricsDir = () => path.join(projectRoot, '.devctx');
7
+ const defaultMetricsFile = () => path.join(defaultMetricsDir(), 'metrics.jsonl');
8
+ const legacyMetricsFile = path.join(devctxRoot, '.devctx', 'metrics.jsonl');
9
+
10
+ export const getMetricsFilePath = () => process.env.DEVCTX_METRICS_FILE ?? defaultMetricsFile();
11
+ export const getLegacyMetricsFilePath = () => legacyMetricsFile;
12
+
13
+ let lastEnsuredDir = null;
14
+
15
+ const ensureMetricsDir = async (filePath) => {
16
+ const dir = path.dirname(filePath);
17
+ if (dir === lastEnsuredDir) return;
18
+ await fs.mkdir(dir, { recursive: true });
19
+ lastEnsuredDir = dir;
20
+ };
21
+
22
+ export const buildMetrics = ({ tool, target, rawText, compressedText }) => {
23
+ const rawTokens = countTokens(rawText);
24
+ const compressedTokens = countTokens(compressedText);
25
+ const savedTokens = Math.max(0, rawTokens - compressedTokens);
26
+ const savingsPct = rawTokens === 0 ? 0 : Number(((savedTokens / rawTokens) * 100).toFixed(2));
27
+
28
+ return {
29
+ tool,
30
+ target,
31
+ rawTokens,
32
+ compressedTokens,
33
+ savedTokens,
34
+ savingsPct,
35
+ timestamp: new Date().toISOString(),
36
+ };
37
+ };
38
+
39
+ export const MAX_METRICS_BYTES = 1024 * 1024;
40
+ export const KEEP_LINES_AFTER_ROTATION = 500;
41
+
42
+ const rotateIfNeeded = async (filePath) => {
43
+ try {
44
+ const stat = await fs.stat(filePath);
45
+ if (stat.size <= MAX_METRICS_BYTES) return;
46
+
47
+ const content = await fs.readFile(filePath, 'utf8');
48
+ const lines = content.trim().split('\n');
49
+ const kept = lines.slice(-KEEP_LINES_AFTER_ROTATION).join('\n') + '\n';
50
+ await fs.writeFile(filePath, kept, 'utf8');
51
+ } catch {
52
+ // file might not exist yet
53
+ }
54
+ };
55
+
56
+ export const persistMetrics = async (entry) => {
57
+ try {
58
+ const filePath = getMetricsFilePath();
59
+ await ensureMetricsDir(filePath);
60
+ await fs.appendFile(filePath, `${JSON.stringify(entry)}\n`, 'utf8');
61
+ await rotateIfNeeded(filePath);
62
+ } catch {
63
+ // best-effort — never fail a tool call for metrics
64
+ }
65
+ };
package/src/server.js ADDED
@@ -0,0 +1,143 @@
1
+ import { createRequire } from 'node:module';
2
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
3
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
4
+ import { z } from 'zod';
5
+ import { buildIndex, buildIndexIncremental, persistIndex } from './index.js';
6
+ import { smartRead } from './tools/smart-read.js';
7
+ import { smartSearch } from './tools/smart-search.js';
8
+ import { smartContext } from './tools/smart-context.js';
9
+ import { smartReadBatch } from './tools/smart-read-batch.js';
10
+ import { smartShell } from './tools/smart-shell.js';
11
+ import { projectRoot, projectRootSource } from './utils/paths.js';
12
+
13
+ const require = createRequire(import.meta.url);
14
+ const { version } = require('../package.json');
15
+
16
+ export const asTextResult = (result) => ({
17
+ content: [
18
+ {
19
+ type: 'text',
20
+ text: JSON.stringify(result, null, 2),
21
+ },
22
+ ],
23
+ });
24
+
25
+ export const createDevctxServer = () => {
26
+ const server = new McpServer({
27
+ name: 'devctx',
28
+ version,
29
+ });
30
+
31
+ server.tool(
32
+ 'smart_read',
33
+ 'Read a file with token-efficient modes. outline/signatures: compact structure (~90% savings). range: specific line range with line numbers. symbol: extract function/class/method by name (string or array for batch). full: file content capped at 12k chars. maxTokens: token budget — auto-selects the most detailed mode that fits (full -> outline -> signatures -> truncated). context=true (symbol mode only): includes callers, tests, and referenced types from the dependency graph; returns graphCoverage (imports/tests: full|partial|none) so the agent knows how reliable the cross-file context is. Responses are cached in memory per session and invalidated by file mtime; cached=true when served from cache. Every response includes a unified confidence block: { parser, truncated, cached, graphCoverage? }. Supports JS/TS, Python, Go, Rust, Java, C#, Kotlin, PHP, Swift, shell, Terraform, Dockerfile, SQL, JSON, TOML, YAML.',
34
+ {
35
+ filePath: z.string(),
36
+ mode: z.enum(['full', 'outline', 'signatures', 'range', 'symbol']).optional(),
37
+ startLine: z.number().optional(),
38
+ endLine: z.number().optional(),
39
+ symbol: z.union([z.string(), z.array(z.string())]).optional(),
40
+ maxTokens: z.number().int().min(1).optional(),
41
+ context: z.boolean().optional(),
42
+ },
43
+ async ({ filePath, mode = 'outline', startLine, endLine, symbol, maxTokens, context }) =>
44
+ asTextResult(await smartRead({ filePath, mode, startLine, endLine, symbol, maxTokens, context })),
45
+ );
46
+
47
+ server.tool(
48
+ 'smart_read_batch',
49
+ 'Read multiple files in one call. Each item accepts path, mode, symbol, startLine, endLine, maxTokens (per-file budget). Optional global maxTokens budget with early stop when exceeded. Max 20 files per call.',
50
+ {
51
+ files: z.array(z.object({
52
+ path: z.string(),
53
+ mode: z.enum(['full', 'outline', 'signatures', 'range', 'symbol']).optional(),
54
+ symbol: z.union([z.string(), z.array(z.string())]).optional(),
55
+ startLine: z.number().optional(),
56
+ endLine: z.number().optional(),
57
+ maxTokens: z.number().int().min(1).optional(),
58
+ })).min(1).max(20),
59
+ maxTokens: z.number().int().min(1).optional(),
60
+ },
61
+ async ({ files, maxTokens }) =>
62
+ asTextResult(await smartReadBatch({ files, maxTokens })),
63
+ );
64
+
65
+ server.tool(
66
+ 'smart_search',
67
+ 'Search code across the project using ripgrep (with filesystem fallback). Returns grouped, ranked results. Optional intent (implementation/debug/tests/config/docs/explore) adjusts ranking: tests boosts test files, config boosts config files, docs reduces penalty on READMEs. Includes a unified confidence block: { level, indexFreshness } plus retrievalConfidence and provenance metadata.',
68
+ {
69
+ query: z.string(),
70
+ cwd: z.string().optional(),
71
+ intent: z.enum(['implementation', 'debug', 'tests', 'config', 'docs', 'explore']).optional(),
72
+ },
73
+ async ({ query, cwd = '.', intent }) => asTextResult(await smartSearch({ query, cwd, intent })),
74
+ );
75
+
76
+ server.tool(
77
+ 'smart_context',
78
+ 'Get curated context for a task in one call. Combines smart_search + smart_read + graph expansion. Returns relevant files, evidence for why each file was included, related tests, dependencies, symbol previews from the index, and symbol details — optimized for tokens. Includes a unified confidence block: { indexFreshness, graphCoverage } indicating index state and how complete the relational context is. Replaces the manual search → read → read cycle. Optional intent override, token budget, diff mode (pass diff=true for HEAD or diff="main" to scope context to changed files only), detail mode (minimal=index+signatures+snippets, balanced=default, deep=full content), and include array to control which fields are returned (["content","graph","hints","symbolDetail"]).',
79
+ {
80
+ task: z.string(),
81
+ intent: z.enum(['implementation', 'debug', 'tests', 'config', 'docs', 'explore']).optional(),
82
+ maxTokens: z.number().optional(),
83
+ entryFile: z.string().optional(),
84
+ diff: z.union([z.boolean(), z.string()]).optional(),
85
+ detail: z.enum(['minimal', 'balanced', 'deep']).optional(),
86
+ include: z.array(z.enum(['content', 'graph', 'hints', 'symbolDetail'])).optional(),
87
+ },
88
+ async ({ task, intent, maxTokens, entryFile, diff, detail, include }) =>
89
+ asTextResult(await smartContext({ task, intent, maxTokens, entryFile, diff, detail, include })),
90
+ );
91
+
92
+ server.tool(
93
+ 'smart_shell',
94
+ 'Run a diagnostic shell command from an allowlist. Allowed: pwd, ls, find, rg, git (status/diff/show/log/branch/rev-parse), npm/pnpm/yarn/bun (test/run/lint/build/typecheck/check). Blocks shell operators, pipes, and unsafe commands. Includes a unified confidence block: { blocked, timedOut }.',
95
+ {
96
+ command: z.string(),
97
+ },
98
+ async ({ command }) => asTextResult(await smartShell({ command })),
99
+ );
100
+
101
+ server.tool(
102
+ 'build_index',
103
+ 'Build a lightweight symbol index for the project. Speeds up smart_search ranking and smart_read symbol lookups. Pass incremental=true to only reindex files with changed mtime (much faster for large repos). Without incremental, rebuilds from scratch.',
104
+ {
105
+ incremental: z.boolean().optional(),
106
+ },
107
+ async ({ incremental }) => {
108
+ if (incremental) {
109
+ const { index, stats } = buildIndexIncremental(projectRoot);
110
+ await persistIndex(index, projectRoot);
111
+ const symbolCount = Object.values(index.files).reduce((sum, f) => sum + f.symbols.length, 0);
112
+ return asTextResult({ status: 'ok', files: stats.total, symbols: symbolCount, ...stats });
113
+ }
114
+
115
+ const index = buildIndex(projectRoot);
116
+ await persistIndex(index, projectRoot);
117
+ const fileCount = Object.keys(index.files).length;
118
+ const symbolCount = Object.values(index.files).reduce((sum, f) => sum + f.symbols.length, 0);
119
+ return asTextResult({ status: 'ok', files: fileCount, symbols: symbolCount });
120
+ },
121
+ );
122
+
123
+ return server;
124
+ };
125
+
126
+ export const runDevctxServer = async () => {
127
+ if (process.env.DEVCTX_DEBUG === '1') {
128
+ process.stderr.write(`devctx project root (${projectRootSource}): ${projectRoot}\n`);
129
+ }
130
+
131
+ const server = createDevctxServer();
132
+ const transport = new StdioServerTransport();
133
+ await server.connect(transport);
134
+
135
+ const shutdown = () => {
136
+ transport.close().catch(() => {}).finally(() => process.exit(0));
137
+ };
138
+
139
+ process.on('SIGINT', shutdown);
140
+ process.on('SIGTERM', shutdown);
141
+
142
+ return server;
143
+ };
@@ -0,0 +1,12 @@
1
+ import { encodingForModel } from 'js-tiktoken';
2
+
3
+ const fallbackModel = 'gpt-4o-mini';
4
+ const encoder = encodingForModel(fallbackModel);
5
+
6
+ export const countTokens = (text = '') => {
7
+ if (!text) {
8
+ return 0;
9
+ }
10
+
11
+ return encoder.encode(String(text)).length;
12
+ };