@gemini-designer/mcp-server 0.1.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.
Files changed (153) hide show
  1. package/.prettierrc +9 -0
  2. package/dist/components/catalog.d.ts +24 -0
  3. package/dist/components/catalog.d.ts.map +1 -0
  4. package/dist/components/catalog.js +186 -0
  5. package/dist/components/catalog.js.map +1 -0
  6. package/dist/config/index.d.ts +60 -0
  7. package/dist/config/index.d.ts.map +1 -0
  8. package/dist/config/index.js +199 -0
  9. package/dist/config/index.js.map +1 -0
  10. package/dist/context/builder.d.ts +32 -0
  11. package/dist/context/builder.d.ts.map +1 -0
  12. package/dist/context/builder.js +194 -0
  13. package/dist/context/builder.js.map +1 -0
  14. package/dist/context/filter.d.ts +28 -0
  15. package/dist/context/filter.d.ts.map +1 -0
  16. package/dist/context/filter.js +136 -0
  17. package/dist/context/filter.js.map +1 -0
  18. package/dist/context/grounding.d.ts +27 -0
  19. package/dist/context/grounding.d.ts.map +1 -0
  20. package/dist/context/grounding.js +162 -0
  21. package/dist/context/grounding.js.map +1 -0
  22. package/dist/context/guards.d.ts +31 -0
  23. package/dist/context/guards.d.ts.map +1 -0
  24. package/dist/context/guards.js +76 -0
  25. package/dist/context/guards.js.map +1 -0
  26. package/dist/context/repo-hints.d.ts +12 -0
  27. package/dist/context/repo-hints.d.ts.map +1 -0
  28. package/dist/context/repo-hints.js +40 -0
  29. package/dist/context/repo-hints.js.map +1 -0
  30. package/dist/generation/gemini-client.d.ts +27 -0
  31. package/dist/generation/gemini-client.d.ts.map +1 -0
  32. package/dist/generation/gemini-client.js +64 -0
  33. package/dist/generation/gemini-client.js.map +1 -0
  34. package/dist/generation/litellm-client.d.ts +16 -0
  35. package/dist/generation/litellm-client.d.ts.map +1 -0
  36. package/dist/generation/litellm-client.js +98 -0
  37. package/dist/generation/litellm-client.js.map +1 -0
  38. package/dist/generation/remote-client.d.ts +20 -0
  39. package/dist/generation/remote-client.d.ts.map +1 -0
  40. package/dist/generation/remote-client.js +69 -0
  41. package/dist/generation/remote-client.js.map +1 -0
  42. package/dist/index.d.ts +9 -0
  43. package/dist/index.d.ts.map +1 -0
  44. package/dist/index.js +30 -0
  45. package/dist/index.js.map +1 -0
  46. package/dist/output/file-writer.d.ts +39 -0
  47. package/dist/output/file-writer.d.ts.map +1 -0
  48. package/dist/output/file-writer.js +153 -0
  49. package/dist/output/file-writer.js.map +1 -0
  50. package/dist/output/formatter.d.ts +26 -0
  51. package/dist/output/formatter.d.ts.map +1 -0
  52. package/dist/output/formatter.js +156 -0
  53. package/dist/output/formatter.js.map +1 -0
  54. package/dist/server.d.ts +9 -0
  55. package/dist/server.d.ts.map +1 -0
  56. package/dist/server.js +22 -0
  57. package/dist/server.js.map +1 -0
  58. package/dist/stack/detect.d.ts +49 -0
  59. package/dist/stack/detect.d.ts.map +1 -0
  60. package/dist/stack/detect.js +157 -0
  61. package/dist/stack/detect.js.map +1 -0
  62. package/dist/tokens/sync.d.ts +32 -0
  63. package/dist/tokens/sync.d.ts.map +1 -0
  64. package/dist/tokens/sync.js +188 -0
  65. package/dist/tokens/sync.js.map +1 -0
  66. package/dist/tools/analyze-screenshot-ui.d.ts +18 -0
  67. package/dist/tools/analyze-screenshot-ui.d.ts.map +1 -0
  68. package/dist/tools/analyze-screenshot-ui.js +133 -0
  69. package/dist/tools/analyze-screenshot-ui.js.map +1 -0
  70. package/dist/tools/analyze-tokens.d.ts +10 -0
  71. package/dist/tools/analyze-tokens.d.ts.map +1 -0
  72. package/dist/tools/analyze-tokens.js +107 -0
  73. package/dist/tools/analyze-tokens.js.map +1 -0
  74. package/dist/tools/catalog-components.d.ts +14 -0
  75. package/dist/tools/catalog-components.d.ts.map +1 -0
  76. package/dist/tools/catalog-components.js +85 -0
  77. package/dist/tools/catalog-components.js.map +1 -0
  78. package/dist/tools/create-ui.d.ts +10 -0
  79. package/dist/tools/create-ui.d.ts.map +1 -0
  80. package/dist/tools/create-ui.js +167 -0
  81. package/dist/tools/create-ui.js.map +1 -0
  82. package/dist/tools/detect-ui-stack.d.ts +15 -0
  83. package/dist/tools/detect-ui-stack.d.ts.map +1 -0
  84. package/dist/tools/detect-ui-stack.js +52 -0
  85. package/dist/tools/detect-ui-stack.js.map +1 -0
  86. package/dist/tools/generate-component-variants.d.ts +15 -0
  87. package/dist/tools/generate-component-variants.d.ts.map +1 -0
  88. package/dist/tools/generate-component-variants.js +199 -0
  89. package/dist/tools/generate-component-variants.js.map +1 -0
  90. package/dist/tools/generate-vibes.d.ts +10 -0
  91. package/dist/tools/generate-vibes.d.ts.map +1 -0
  92. package/dist/tools/generate-vibes.js +145 -0
  93. package/dist/tools/generate-vibes.js.map +1 -0
  94. package/dist/tools/index.d.ts +12 -0
  95. package/dist/tools/index.d.ts.map +1 -0
  96. package/dist/tools/index.js +36 -0
  97. package/dist/tools/index.js.map +1 -0
  98. package/dist/tools/modify-ui.d.ts +11 -0
  99. package/dist/tools/modify-ui.d.ts.map +1 -0
  100. package/dist/tools/modify-ui.js +207 -0
  101. package/dist/tools/modify-ui.js.map +1 -0
  102. package/dist/tools/scaffold-project.d.ts +10 -0
  103. package/dist/tools/scaffold-project.d.ts.map +1 -0
  104. package/dist/tools/scaffold-project.js +122 -0
  105. package/dist/tools/scaffold-project.js.map +1 -0
  106. package/dist/tools/snippet-ui.d.ts +11 -0
  107. package/dist/tools/snippet-ui.d.ts.map +1 -0
  108. package/dist/tools/snippet-ui.js +194 -0
  109. package/dist/tools/snippet-ui.js.map +1 -0
  110. package/dist/tools/sync-design-tokens.d.ts +14 -0
  111. package/dist/tools/sync-design-tokens.d.ts.map +1 -0
  112. package/dist/tools/sync-design-tokens.js +233 -0
  113. package/dist/tools/sync-design-tokens.js.map +1 -0
  114. package/dist/utils/walk.d.ts +15 -0
  115. package/dist/utils/walk.d.ts.map +1 -0
  116. package/dist/utils/walk.js +63 -0
  117. package/dist/utils/walk.js.map +1 -0
  118. package/eslint.config.js +37 -0
  119. package/package.json +56 -0
  120. package/src/__tests__/builder.test.ts +31 -0
  121. package/src/__tests__/config.test.ts +52 -0
  122. package/src/__tests__/filter.test.ts +109 -0
  123. package/src/components/catalog.ts +214 -0
  124. package/src/config/index.ts +237 -0
  125. package/src/context/builder.ts +233 -0
  126. package/src/context/filter.ts +164 -0
  127. package/src/context/grounding.ts +191 -0
  128. package/src/context/guards.ts +94 -0
  129. package/src/context/repo-hints.ts +43 -0
  130. package/src/generation/gemini-client.ts +94 -0
  131. package/src/generation/litellm-client.ts +121 -0
  132. package/src/generation/remote-client.ts +103 -0
  133. package/src/index.ts +36 -0
  134. package/src/output/file-writer.ts +181 -0
  135. package/src/output/formatter.ts +186 -0
  136. package/src/server.ts +28 -0
  137. package/src/stack/detect.ts +204 -0
  138. package/src/tokens/sync.ts +212 -0
  139. package/src/tools/analyze-screenshot-ui.ts +150 -0
  140. package/src/tools/analyze-tokens.ts +123 -0
  141. package/src/tools/catalog-components.ts +99 -0
  142. package/src/tools/create-ui.ts +194 -0
  143. package/src/tools/detect-ui-stack.ts +64 -0
  144. package/src/tools/generate-component-variants.ts +218 -0
  145. package/src/tools/generate-vibes.ts +177 -0
  146. package/src/tools/index.ts +42 -0
  147. package/src/tools/modify-ui.ts +230 -0
  148. package/src/tools/scaffold-project.ts +138 -0
  149. package/src/tools/snippet-ui.ts +222 -0
  150. package/src/tools/sync-design-tokens.ts +256 -0
  151. package/src/utils/walk.ts +75 -0
  152. package/tsconfig.json +34 -0
  153. package/vitest.config.ts +15 -0
@@ -0,0 +1,164 @@
1
+ /**
2
+ * Context Filter
3
+ *
4
+ * Security patterns to prevent sensitive data from being shared.
5
+ * Ensures only UI-relevant files are included in context.
6
+ */
7
+
8
+ import * as path from 'node:path';
9
+
10
+ /**
11
+ * Files/directories that should NEVER be included in context
12
+ */
13
+ const SENSITIVE_PATTERNS = [
14
+ // Environment and secrets
15
+ /\.env/i,
16
+ /secrets?\./i,
17
+ /\.pem$/i,
18
+ /\.key$/i,
19
+ /\.crt$/i,
20
+ /credentials/i,
21
+ /\.htpasswd/i,
22
+
23
+ // Private keys and certificates
24
+ /id_rsa/i,
25
+ /id_ed25519/i,
26
+ /\.p12$/i,
27
+ /\.pfx$/i,
28
+
29
+ // Config with potential secrets
30
+ /\.npmrc$/i,
31
+ /\.pypirc$/i,
32
+ /kubeconfig/i,
33
+ /\.docker\/config\.json$/i,
34
+
35
+ // Database
36
+ /\.sqlite$/i,
37
+ /\.db$/i,
38
+ /migrations?\//i,
39
+ /seeds?\//i,
40
+
41
+ // Backend/server code (when isolating UI)
42
+ /\/api\//i,
43
+ /\/server\//i,
44
+ /\/backend\//i,
45
+ /\/functions\//i, // Serverless
46
+ /\/lambda\//i,
47
+ /\/middleware\//i,
48
+
49
+ // Auth-related
50
+ /\/auth\//i,
51
+ /passport/i,
52
+ /jwt/i,
53
+
54
+ // System and dependencies
55
+ /node_modules\//i,
56
+ /\.git\//i,
57
+ /vendor\//i,
58
+ /\.cache\//i,
59
+ /\.next\//i,
60
+ /\.nuxt\//i,
61
+ /dist\//i,
62
+ /build\//i,
63
+ ];
64
+
65
+ /**
66
+ * UI-relevant file patterns (for auto-discovery)
67
+ */
68
+ export const UI_INCLUDE_PATTERNS = [
69
+ // Stylesheets
70
+ /\.(css|scss|less|sass|styl)$/i,
71
+
72
+ // Components
73
+ /\.(tsx|jsx)$/i,
74
+ /\.(vue|svelte)$/i,
75
+
76
+ // Design tokens
77
+ /theme\./i,
78
+ /tokens?\./i,
79
+ /variables\./i,
80
+ /design[-_]?system/i,
81
+
82
+ // Config files for styling
83
+ /tailwind\.config/i,
84
+ /postcss\.config/i,
85
+ /styled-components/i,
86
+ ];
87
+
88
+ /**
89
+ * Check if a file path matches sensitive patterns
90
+ */
91
+ export function isSensitiveFile(filePath: string): boolean {
92
+ const normalized = filePath.replace(/\\/g, '/');
93
+
94
+ for (const pattern of SENSITIVE_PATTERNS) {
95
+ if (pattern.test(normalized)) {
96
+ return true;
97
+ }
98
+ }
99
+
100
+ return false;
101
+ }
102
+
103
+ /**
104
+ * Check if a file path is within allowed paths
105
+ */
106
+ export function isPathAllowed(filePath: string, allowedPaths: string[]): boolean {
107
+ const absPath = path.resolve(filePath);
108
+
109
+ for (const allowed of allowedPaths) {
110
+ const absAllowed = path.resolve(allowed);
111
+ if (absPath.startsWith(absAllowed)) {
112
+ return true;
113
+ }
114
+ }
115
+
116
+ return false;
117
+ }
118
+
119
+ /**
120
+ * Check if a file is UI-relevant
121
+ */
122
+ export function isUIRelevant(filePath: string): boolean {
123
+ const normalized = filePath.replace(/\\/g, '/');
124
+
125
+ for (const pattern of UI_INCLUDE_PATTERNS) {
126
+ if (pattern.test(normalized)) {
127
+ return true;
128
+ }
129
+ }
130
+
131
+ return false;
132
+ }
133
+
134
+ /**
135
+ * Sanitize file content to remove potential secrets
136
+ * This is a best-effort filter for dynamic content
137
+ */
138
+ export function sanitizeContent(content: string): string {
139
+ // Remove common secret patterns
140
+ const patterns = [
141
+ // API keys (generic patterns)
142
+ /(['"`])?(api[_-]?key|apikey|secret|password|token|auth)(['"`])?[\s]*[:=][\s]*['"`][^'"`]+['"`]/gi,
143
+
144
+ // Bearer tokens
145
+ /Bearer\s+[A-Za-z0-9\-_=]+\.[A-Za-z0-9\-_=]+\.?[A-Za-z0-9\-_.+/=]*/gi,
146
+
147
+ // AWS keys
148
+ /AKIA[0-9A-Z]{16}/g,
149
+
150
+ // Private keys
151
+ /-----BEGIN[\s\w]+PRIVATE KEY-----[\s\S]+?-----END[\s\w]+PRIVATE KEY-----/g,
152
+
153
+ // Connection strings
154
+ /(mongodb|postgresql|mysql|redis):\/\/[^\s'"]+/gi,
155
+ ];
156
+
157
+ let result = content;
158
+
159
+ for (const pattern of patterns) {
160
+ result = result.replace(pattern, '[REDACTED]');
161
+ }
162
+
163
+ return result;
164
+ }
@@ -0,0 +1,191 @@
1
+ /**
2
+ * grounding.ts
3
+ *
4
+ * Deterministic “repo grounding” injected into prompts to improve output quality
5
+ * without burning large context windows.
6
+ *
7
+ * This module is used internally by tools like modify_ui and
8
+ * generate_component_variants to:
9
+ * - auto-detect the UI stack (framework/styling/libs)
10
+ * - catalog available components for reuse
11
+ *
12
+ * The output is intentionally concise and stable.
13
+ */
14
+
15
+ import * as path from 'node:path';
16
+ import { Config } from '../config/index.js';
17
+ import { detectUiStack, type StackDetectionResult } from '../stack/detect.js';
18
+ import { walkFiles, toPosixPath } from '../utils/walk.js';
19
+ import { buildComponentCatalog, type CatalogResult, type ComponentExport } from '../components/catalog.js';
20
+
21
+ type Cached = {
22
+ ts: number;
23
+ stack: StackDetectionResult;
24
+ catalog: CatalogResult;
25
+ };
26
+
27
+ const CACHE_TTL_MS = 60_000;
28
+ const cache = new Map<string, Cached>();
29
+
30
+ function resolveRootForFile(absFile: string | null, config: Config): string {
31
+ const allowed = (config.allowedPaths || []).map((p) => path.resolve(p));
32
+ const fallback = allowed[0] || process.cwd();
33
+ if (!absFile) return fallback;
34
+
35
+ const abs = path.resolve(absFile);
36
+
37
+ // pick the deepest allowed root that contains the file
38
+ const matches = allowed
39
+ .filter((root) => abs === root || abs.startsWith(root + path.sep))
40
+ .sort((a, b) => b.length - a.length);
41
+
42
+ return matches[0] || fallback;
43
+ }
44
+
45
+ function summarizeStack(stack: StackDetectionResult): string {
46
+ const summary = {
47
+ framework: stack.framework,
48
+ typescript: stack.language.typescript,
49
+ styling: stack.styling,
50
+ uiLibraries: stack.uiLibraries,
51
+ iconLibraries: stack.iconLibraries,
52
+ tooling: stack.tooling,
53
+ conventions: {
54
+ srcDir: stack.conventions.srcDir,
55
+ hasAppDir: stack.conventions.hasAppDir,
56
+ hasPagesDir: stack.conventions.hasPagesDir,
57
+ tsconfigPaths: stack.conventions.tsconfigPaths ? Object.keys(stack.conventions.tsconfigPaths) : undefined,
58
+ },
59
+ files: {
60
+ tailwindConfig: stack.files.tailwindConfig,
61
+ componentsJson: stack.files.componentsJson,
62
+ storybookDir: stack.files.storybookDir,
63
+ },
64
+ warnings: stack.warnings,
65
+ };
66
+ return JSON.stringify(summary, null, 2);
67
+ }
68
+
69
+ function tokenizeInstruction(instruction?: string): Set<string> {
70
+ const set = new Set<string>();
71
+ if (!instruction) return set;
72
+ const tokens = instruction.match(/[A-Za-z_][A-Za-z0-9_]*/g) || [];
73
+ for (const t of tokens) set.add(t.toLowerCase());
74
+ return set;
75
+ }
76
+
77
+ function scoreComponent(
78
+ c: ComponentExport,
79
+ focusDirRel: string | null,
80
+ instructionTokens: Set<string>
81
+ ): number {
82
+ let score = 0;
83
+
84
+ // Mentioned explicitly in instruction
85
+ if (instructionTokens.has(c.name.toLowerCase())) score += 8;
86
+
87
+ // Same directory as focused file
88
+ if (focusDirRel && c.file.startsWith(focusDirRel + '/')) score += 6;
89
+
90
+ // Common reusable directories
91
+ if (
92
+ c.file.includes('/components/') ||
93
+ c.file.startsWith('components/') ||
94
+ c.file.includes('/ui/') ||
95
+ c.file.includes('/shared/')
96
+ ) {
97
+ score += 3;
98
+ }
99
+
100
+ // Prefer TSX
101
+ if (c.file.endsWith('.tsx')) score += 1;
102
+
103
+ return score;
104
+ }
105
+
106
+ function formatCatalogSubset(catalog: CatalogResult, subset: ComponentExport[]): string {
107
+ const lines: string[] = [];
108
+ const header = `scanned_files=${catalog.filesScanned}, total_exports=${catalog.components.length}`;
109
+ lines.push(header);
110
+ if (catalog.warnings.length) {
111
+ lines.push(`warnings: ${catalog.warnings.join(' | ')}`);
112
+ }
113
+ lines.push('');
114
+ lines.push('components:');
115
+ for (const c of subset) {
116
+ const extras: string[] = [];
117
+ if (c.exportType) extras.push(c.exportType);
118
+ if (c.propsType) extras.push(`props: ${c.propsType}`);
119
+ if (c.jsDoc) extras.push(`doc: ${c.jsDoc}`);
120
+ const extra = extras.length ? ` (${extras.join(', ')})` : '';
121
+ lines.push(`- ${c.name} — ${c.file}${extra}`);
122
+ }
123
+ return lines.join('\n');
124
+ }
125
+
126
+ async function getOrBuildRepoData(root: string): Promise<Cached> {
127
+ const now = Date.now();
128
+ const hit = cache.get(root);
129
+ if (hit && now - hit.ts < CACHE_TTL_MS) return hit;
130
+
131
+ const stack = detectUiStack(root);
132
+ const files = walkFiles(root, {
133
+ includeExtensions: ['.tsx', '.jsx'],
134
+ maxFiles: 5000,
135
+ });
136
+ const catalog = await buildComponentCatalog(root, files);
137
+
138
+ const fresh: Cached = { ts: now, stack, catalog };
139
+ cache.set(root, fresh);
140
+ return fresh;
141
+ }
142
+
143
+ export interface RepoGroundingOptions {
144
+ focusFileAbs?: string;
145
+ instruction?: string;
146
+ maxComponents?: number;
147
+ }
148
+
149
+ /**
150
+ * Build a concise deterministic “grounding” string.
151
+ *
152
+ * This is safe to include in prompts because it contains only local, deterministic
153
+ * project metadata (no secrets).
154
+ */
155
+ export async function buildRepoGrounding(config: Config, options: RepoGroundingOptions = {}): Promise<string> {
156
+ const maxComponents = typeof options.maxComponents === 'number' ? options.maxComponents : 120;
157
+
158
+ const root = resolveRootForFile(options.focusFileAbs ? path.resolve(options.focusFileAbs) : null, config);
159
+ const data = await getOrBuildRepoData(root);
160
+
161
+ const focusRel = options.focusFileAbs ? toPosixPath(path.relative(root, path.resolve(options.focusFileAbs))) : null;
162
+ const focusDirRel = focusRel ? toPosixPath(path.posix.dirname(focusRel)) : null;
163
+ const instructionTokens = tokenizeInstruction(options.instruction);
164
+
165
+ // Score and select a small subset for reuse.
166
+ const scored = data.catalog.components
167
+ .map((c) => ({ c, s: scoreComponent(c, focusDirRel, instructionTokens) }))
168
+ .sort((a, b) => b.s - a.s);
169
+
170
+ const subset: ComponentExport[] = [];
171
+ const seen = new Set<string>();
172
+ for (const item of scored) {
173
+ if (subset.length >= maxComponents) break;
174
+ const key = `${item.c.name}|${item.c.file}|${item.c.exportType}`;
175
+ if (seen.has(key)) continue;
176
+ // Skip ultra-low relevance if we already have enough
177
+ if (subset.length > 30 && item.s <= 0) break;
178
+ subset.push(item.c);
179
+ seen.add(key);
180
+ }
181
+
182
+ return [
183
+ 'AUTO PROJECT CONTEXT (deterministic):',
184
+ '',
185
+ 'STACK (json):',
186
+ summarizeStack(data.stack),
187
+ '',
188
+ 'COMPONENT CATALOG (subset for reuse):',
189
+ formatCatalogSubset(data.catalog, subset),
190
+ ].join('\n');
191
+ }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Path Guards
3
+ *
4
+ * Centralized helpers to validate and resolve file paths for tools.
5
+ *
6
+ * Goals:
7
+ * - Prevent accidental reads/writes outside the workspace
8
+ * - Avoid including sensitive files (e.g. .env, private keys)
9
+ * - Provide consistent absolute-path resolution
10
+ */
11
+
12
+ import * as fs from 'node:fs';
13
+ import * as path from 'node:path';
14
+ import { Config } from '../config/index.js';
15
+ import { isPathAllowed, isSensitiveFile } from './filter.js';
16
+
17
+ /**
18
+ * Resolve a path to an absolute path (relative paths are resolved from process.cwd()).
19
+ */
20
+ export function resolveToAbs(filePath: string): string {
21
+ return path.isAbsolute(filePath) ? filePath : path.resolve(process.cwd(), filePath);
22
+ }
23
+
24
+ /**
25
+ * Validate that a path is safe to read according to server config.
26
+ * Returns the resolved absolute path.
27
+ */
28
+ export function assertReadablePath(filePath: string, config: Config): string {
29
+ const absPath = resolveToAbs(filePath);
30
+
31
+ if (!isPathAllowed(absPath, config.allowedPaths)) {
32
+ throw new Error(`Path is outside allowedPaths: ${filePath}`);
33
+ }
34
+
35
+ if (isSensitiveFile(absPath)) {
36
+ throw new Error(`Refusing to read sensitive file: ${filePath}`);
37
+ }
38
+
39
+ if (!fs.existsSync(absPath)) {
40
+ throw new Error(`File not found: ${filePath}`);
41
+ }
42
+
43
+ const stat = fs.statSync(absPath);
44
+ if (!stat.isFile()) {
45
+ throw new Error(`Not a file: ${filePath}`);
46
+ }
47
+
48
+ return absPath;
49
+ }
50
+
51
+ /**
52
+ * Validate that a path is safe to write according to server config.
53
+ * Returns the resolved absolute path.
54
+ */
55
+ export function assertWritablePath(filePath: string, config: Config): string {
56
+ const absPath = resolveToAbs(filePath);
57
+
58
+ if (!isPathAllowed(absPath, config.allowedPaths)) {
59
+ throw new Error(`Path is outside allowedPaths: ${filePath}`);
60
+ }
61
+
62
+ if (isSensitiveFile(absPath)) {
63
+ throw new Error(`Refusing to write to sensitive path: ${filePath}`);
64
+ }
65
+
66
+ return absPath;
67
+ }
68
+
69
+ /**
70
+ * Validate that a directory path is safe to read according to server config.
71
+ * Returns the resolved absolute path.
72
+ */
73
+ export function assertReadableDir(dirPath: string, config: Config): string {
74
+ const absPath = resolveToAbs(dirPath);
75
+
76
+ if (!isPathAllowed(absPath, config.allowedPaths)) {
77
+ throw new Error(`Path is outside allowedPaths: ${dirPath}`);
78
+ }
79
+
80
+ if (isSensitiveFile(absPath)) {
81
+ throw new Error(`Refusing to read sensitive directory: ${dirPath}`);
82
+ }
83
+
84
+ if (!fs.existsSync(absPath)) {
85
+ throw new Error(`Directory not found: ${dirPath}`);
86
+ }
87
+
88
+ const stat = fs.statSync(absPath);
89
+ if (!stat.isDirectory()) {
90
+ throw new Error(`Not a directory: ${dirPath}`);
91
+ }
92
+
93
+ return absPath;
94
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Repo Hints
3
+ *
4
+ * Very small, deterministic context that can be injected into LLM prompts
5
+ * to significantly improve output quality while avoiding big token burns.
6
+ *
7
+ * - Detects framework + styling + common libs
8
+ * - Does NOT include file contents
9
+ */
10
+
11
+ import * as path from 'node:path';
12
+ import { Config } from '../config/index.js';
13
+ import { detectUiStack } from '../stack/detect.js';
14
+
15
+ export function buildRepoHints(config: Config, rootDir: string = process.cwd()): string {
16
+ try {
17
+ const stack = detectUiStack(rootDir);
18
+
19
+ const hints = {
20
+ root: path.basename(stack.root),
21
+ framework: stack.framework,
22
+ typescript: stack.language.typescript,
23
+ styling: stack.styling,
24
+ uiLibraries: stack.uiLibraries,
25
+ iconLibraries: stack.iconLibraries,
26
+ tooling: stack.tooling,
27
+ conventions: {
28
+ srcDir: stack.conventions.srcDir,
29
+ hasAppDir: stack.conventions.hasAppDir,
30
+ hasPagesDir: stack.conventions.hasPagesDir,
31
+ tsconfigPaths: stack.conventions.tsconfigPaths,
32
+ },
33
+ };
34
+
35
+ return `REPO_HINTS (deterministic):\n${JSON.stringify(hints, null, 2)}`;
36
+ } catch (error) {
37
+ if (config.debug) {
38
+ const msg = error instanceof Error ? error.message : 'unknown error';
39
+ console.error('[repo-hints] Failed to detect stack:', msg);
40
+ }
41
+ return '';
42
+ }
43
+ }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Gemini API Client
3
+ *
4
+ * Handles direct communication with Google Gemini API.
5
+ * Used in local mode with user's own API key.
6
+ */
7
+
8
+ import { GoogleGenerativeAI } from '@google/generative-ai';
9
+ import { Config } from '../config/index.js';
10
+
11
+ export type GeminiUserContent =
12
+ | string
13
+ | Array<
14
+ | { text: string }
15
+ | { inlineData: { mimeType: string; data: string } }
16
+ >;
17
+
18
+ // Cache the client instance
19
+ let genAI: GoogleGenerativeAI | null = null;
20
+
21
+ export interface GenerateOptions {
22
+ toolName?: string;
23
+ }
24
+
25
+ /**
26
+ * Get or create the Gemini client
27
+ */
28
+ function getClient(config: Config): GoogleGenerativeAI {
29
+ if (!config.apiKey) {
30
+ throw new Error('Gemini API key not configured. Set GEMINI_API_KEY environment variable.');
31
+ }
32
+
33
+ if (!genAI) {
34
+ genAI = new GoogleGenerativeAI(config.apiKey);
35
+ }
36
+
37
+ return genAI;
38
+ }
39
+
40
+ /**
41
+ * Generate content using Gemini
42
+ */
43
+ export async function generateWithGemini(
44
+ config: Config,
45
+ systemPrompt: string,
46
+ userPrompt: GeminiUserContent,
47
+ options?: GenerateOptions
48
+ ): Promise<string> {
49
+ // If remote mode, delegate to remote client
50
+ if (config.mode === 'remote') {
51
+ const { generateWithRemote } = await import('./remote-client.js');
52
+ return generateWithRemote(config, systemPrompt, userPrompt, options?.toolName);
53
+ }
54
+
55
+ // If local mode is configured to use an OpenAI-compatible proxy (e.g. LiteLLM)
56
+ if (config.localProvider === 'litellm') {
57
+ const { generateWithLiteLLM } = await import('./litellm-client.js');
58
+ return generateWithLiteLLM(config, systemPrompt, userPrompt, options?.toolName);
59
+ }
60
+
61
+ const client = getClient(config);
62
+
63
+ // Use model from config (default: gemini-2.5-flash-lite)
64
+ const model = client.getGenerativeModel({
65
+ model: config.model,
66
+ systemInstruction: systemPrompt,
67
+ });
68
+
69
+ const result = await model.generateContent(userPrompt as any);
70
+ const response = result.response;
71
+ const text = response.text();
72
+
73
+ if (config.debug) {
74
+ console.error('[gemini] Tool:', options?.toolName || '(unknown)');
75
+ console.error('[gemini] Response length:', text.length);
76
+ console.error('[gemini] Usage:', response.usageMetadata);
77
+ }
78
+
79
+ return text;
80
+ }
81
+
82
+ /**
83
+ * Count tokens for a given text (useful for quota tracking)
84
+ */
85
+ export async function countTokens(config: Config, text: string): Promise<number> {
86
+ if (config.localProvider === 'litellm') {
87
+ throw new Error('countTokens is not supported when localProvider=litellm');
88
+ }
89
+ const client = getClient(config);
90
+ const model = client.getGenerativeModel({ model: config.model });
91
+
92
+ const result = await model.countTokens(text);
93
+ return result.totalTokens;
94
+ }
@@ -0,0 +1,121 @@
1
+ /**
2
+ * LiteLLM (OpenAI-compatible) Client
3
+ *
4
+ * This client allows running the MCP server in local mode while routing
5
+ * requests through a self-hosted LiteLLM instance.
6
+ *
7
+ * Why LiteLLM?
8
+ * - Key management / key rotation
9
+ * - Load balancing across multiple upstream API keys
10
+ * - Unified OpenAI-compatible interface
11
+ * - Optional caching / retries / budgets
12
+ */
13
+
14
+ import { Config } from '../config/index.js';
15
+ import type { GeminiUserContent } from './gemini-client.js';
16
+
17
+ type ChatContentPart =
18
+ | { type: 'text'; text: string }
19
+ | { type: 'image_url'; image_url: { url: string } };
20
+
21
+ function normalizeBaseUrl(baseUrl: string): string {
22
+ const trimmed = baseUrl.replace(/\/+$/, '');
23
+ // Allow users to provide either http://host:port or http://host:port/v1
24
+ if (trimmed.endsWith('/v1')) return trimmed;
25
+ return `${trimmed}/v1`;
26
+ }
27
+
28
+ function inferMimeTypeFromInlineData(mimeType: string): string {
29
+ // LiteLLM expects a data URL. Ensure mime type is reasonable.
30
+ if (!mimeType || !mimeType.includes('/')) return 'application/octet-stream';
31
+ return mimeType;
32
+ }
33
+
34
+ function toOpenAIUserContent(userContent: GeminiUserContent): string | ChatContentPart[] {
35
+ if (typeof userContent === 'string') return userContent;
36
+
37
+ const parts: ChatContentPart[] = [];
38
+ for (const part of userContent) {
39
+ if ('text' in part) {
40
+ parts.push({ type: 'text', text: part.text });
41
+ continue;
42
+ }
43
+
44
+ if ('inlineData' in part) {
45
+ const mime = inferMimeTypeFromInlineData(part.inlineData.mimeType);
46
+ const url = `data:${mime};base64,${part.inlineData.data}`;
47
+ parts.push({ type: 'image_url', image_url: { url } });
48
+ continue;
49
+ }
50
+ }
51
+
52
+ // If the model only received one text part, prefer string (cheaper payload)
53
+ if (parts.length === 1 && parts[0].type === 'text') return parts[0].text;
54
+ return parts;
55
+ }
56
+
57
+ export async function generateWithLiteLLM(
58
+ config: Config,
59
+ systemPrompt: string,
60
+ userContent: GeminiUserContent,
61
+ toolName?: string
62
+ ): Promise<string> {
63
+ if (!config.litellmEndpoint) {
64
+ throw new Error('LiteLLM endpoint not configured. Set LITELLM_ENDPOINT.');
65
+ }
66
+
67
+ const baseUrl = normalizeBaseUrl(config.litellmEndpoint);
68
+ const url = `${baseUrl}/chat/completions`;
69
+
70
+ const model = config.litellmModel || config.model;
71
+
72
+ const body = {
73
+ model,
74
+ messages: [
75
+ { role: 'system', content: systemPrompt },
76
+ { role: 'user', content: toOpenAIUserContent(userContent) },
77
+ ],
78
+ // Keep default-ish sampling. Let downstream agents control via prompts.
79
+ temperature: 0.2,
80
+ };
81
+
82
+ const headers: Record<string, string> = {
83
+ 'Content-Type': 'application/json',
84
+ };
85
+
86
+ if (config.litellmApiKey) {
87
+ headers.Authorization = `Bearer ${config.litellmApiKey}`;
88
+ }
89
+
90
+ const resp = await fetch(url, {
91
+ method: 'POST',
92
+ headers,
93
+ body: JSON.stringify(body),
94
+ });
95
+
96
+ if (!resp.ok) {
97
+ const text = await resp.text();
98
+ throw new Error(`LiteLLM error (${resp.status}): ${text}`);
99
+ }
100
+
101
+ const data = (await resp.json()) as any;
102
+ const content: unknown = data?.choices?.[0]?.message?.content;
103
+
104
+ if (config.debug) {
105
+ const usage = data?.usage;
106
+ console.error('[litellm] Tool:', toolName || '(unknown)');
107
+ if (usage) console.error('[litellm] Usage:', usage);
108
+ console.error('[litellm] Model:', data?.model || model);
109
+ }
110
+
111
+ if (typeof content === 'string') return content;
112
+ // Some providers return arrays; stringify safely.
113
+ if (Array.isArray(content)) {
114
+ return content
115
+ .map((p) => (typeof p === 'string' ? p : (p?.text ?? '')))
116
+ .filter(Boolean)
117
+ .join('');
118
+ }
119
+
120
+ return '';
121
+ }