mcp-agentic-pipelines 1.0.1

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 (119) hide show
  1. package/.env.example +93 -0
  2. package/README.md +258 -0
  3. package/package.json +70 -0
  4. package/packages/clinical/package.json +22 -0
  5. package/packages/clinical/src/index.ts +262 -0
  6. package/packages/clinical/tsconfig.json +13 -0
  7. package/packages/core/package.json +21 -0
  8. package/packages/core/src/config.ts +138 -0
  9. package/packages/core/src/errors.ts +100 -0
  10. package/packages/core/src/index.ts +104 -0
  11. package/packages/core/src/llm-config.ts +213 -0
  12. package/packages/core/src/logging.ts +66 -0
  13. package/packages/core/src/python-bridge.ts +384 -0
  14. package/packages/core/src/rate-limiter.ts +136 -0
  15. package/packages/core/src/types.ts +203 -0
  16. package/packages/core/src/validation.ts +101 -0
  17. package/packages/core/tsconfig.json +10 -0
  18. package/packages/deeppipe/package.json +21 -0
  19. package/packages/deeppipe/src/index.ts +424 -0
  20. package/packages/deeppipe/tsconfig.json +13 -0
  21. package/packages/piste/package.json +20 -0
  22. package/packages/piste/src/index.ts +48 -0
  23. package/packages/piste/tsconfig.json +13 -0
  24. package/packages/precis/package.json +20 -0
  25. package/packages/precis/src/index.ts +67 -0
  26. package/packages/precis/tsconfig.json +13 -0
  27. package/packages/server/package.json +31 -0
  28. package/packages/server/src/index.ts +427 -0
  29. package/packages/server/tsconfig.json +17 -0
  30. package/setup.mjs +141 -0
  31. package/test.mjs +337 -0
  32. package/vendors/clinical-intake/pipeline.mjs +349 -0
  33. package/vendors/clinical-intake/questions/en.txt +9 -0
  34. package/vendors/clinical-intake/questions/fr.txt +9 -0
  35. package/vendors/piste/.env.example +73 -0
  36. package/vendors/piste/app/core/__init__.py +4 -0
  37. package/vendors/piste/app/core/config.py +83 -0
  38. package/vendors/piste/app/core/debuglog.py +16 -0
  39. package/vendors/piste/app/core/middleware.py +40 -0
  40. package/vendors/piste/bridge_piste.py +301 -0
  41. package/vendors/piste/pipeline/__init__.py +4 -0
  42. package/vendors/piste/pipeline/compiler.py +68 -0
  43. package/vendors/piste/pipeline/offline/__init__.py +28 -0
  44. package/vendors/piste/pipeline/offline/verifaid_pipeline.py +247 -0
  45. package/vendors/piste/pipeline/replay.py +15 -0
  46. package/vendors/piste/pipeline/replay_engine.py +249 -0
  47. package/vendors/piste/pipeline/signatures/__init__.py +4 -0
  48. package/vendors/piste/pipeline/signatures/signatures.py +136 -0
  49. package/vendors/piste/pipeline/stage1/__init__.py +21 -0
  50. package/vendors/piste/pipeline/stage1/atomic_decomposer.py +61 -0
  51. package/vendors/piste/pipeline/stage1/check_worthiness.py +100 -0
  52. package/vendors/piste/pipeline/stage1/orchestrator.py +175 -0
  53. package/vendors/piste/pipeline/stage1/test_stage1.py +162 -0
  54. package/vendors/piste/pipeline/stage2/__init__.py +34 -0
  55. package/vendors/piste/pipeline/stage2/blind_retriever.py +303 -0
  56. package/vendors/piste/pipeline/stage2/canonical_mapper.py +124 -0
  57. package/vendors/piste/pipeline/stage2/credibility_scorer.py +85 -0
  58. package/vendors/piste/pipeline/stage2/orchestrator.py +311 -0
  59. package/vendors/piste/pipeline/stage2/query_refiner.py +88 -0
  60. package/vendors/piste/pipeline/stage2/search_decision.py +69 -0
  61. package/vendors/piste/pipeline/stage2/test_stage2.py +265 -0
  62. package/vendors/piste/pipeline/stage3/__init__.py +20 -0
  63. package/vendors/piste/pipeline/stage3/classifier.py +79 -0
  64. package/vendors/piste/pipeline/stage3/orchestrator.py +225 -0
  65. package/vendors/piste/pipeline/stage3/test_stage3.py +101 -0
  66. package/vendors/piste/pipeline/stage4/__init__.py +33 -0
  67. package/vendors/piste/pipeline/stage4/criticality_gate.py +177 -0
  68. package/vendors/piste/pipeline/stage4/orchestrator.py +269 -0
  69. package/vendors/piste/pipeline/stage4/test_stage4.py +192 -0
  70. package/vendors/piste/pipeline/stage4/verdict_aggregator.py +157 -0
  71. package/vendors/piste/requirements.txt +53 -0
  72. package/vendors/precis/backend/__init__.py +6 -0
  73. package/vendors/precis/backend/agents/__init__.py +3 -0
  74. package/vendors/precis/backend/agents/data_synthesis.py +105 -0
  75. package/vendors/precis/backend/agents/dist_free_synth.py +97 -0
  76. package/vendors/precis/backend/agents/exact_hash_retriever.py +327 -0
  77. package/vendors/precis/backend/agents/fusion_ranker.py +64 -0
  78. package/vendors/precis/backend/agents/guardrail.py +175 -0
  79. package/vendors/precis/backend/agents/query_expander.py +89 -0
  80. package/vendors/precis/backend/agents/radial_interpol.py +99 -0
  81. package/vendors/precis/backend/agents/report_generator.py +92 -0
  82. package/vendors/precis/backend/agents/semantic_reranker.py +135 -0
  83. package/vendors/precis/backend/agents/stat_anomaly.py +93 -0
  84. package/vendors/precis/backend/agents/vector_index.py +123 -0
  85. package/vendors/precis/backend/agents/veri_score.py +341 -0
  86. package/vendors/precis/backend/agents/work_order_extractor.py +205 -0
  87. package/vendors/precis/backend/api/__init__.py +3 -0
  88. package/vendors/precis/backend/api/routes/__init__.py +3 -0
  89. package/vendors/precis/backend/config.py +88 -0
  90. package/vendors/precis/backend/core/__init__.py +13 -0
  91. package/vendors/precis/backend/core/hashing.py +22 -0
  92. package/vendors/precis/backend/core/metrics.py +77 -0
  93. package/vendors/precis/backend/core/multitoken.py +166 -0
  94. package/vendors/precis/backend/core/pmi.py +54 -0
  95. package/vendors/precis/backend/core/stemming.py +74 -0
  96. package/vendors/precis/backend/core/tracing.py +150 -0
  97. package/vendors/precis/backend/data/__init__.py +3 -0
  98. package/vendors/precis/backend/data/chunker.py +57 -0
  99. package/vendors/precis/backend/data/pdf_parser.py +42 -0
  100. package/vendors/precis/backend/db/__init__.py +3 -0
  101. package/vendors/precis/backend/db/models.py +173 -0
  102. package/vendors/precis/backend/db/repository.py +269 -0
  103. package/vendors/precis/backend/llm/__init__.py +3 -0
  104. package/vendors/precis/backend/llm/anthropic_provider.py +39 -0
  105. package/vendors/precis/backend/llm/base.py +147 -0
  106. package/vendors/precis/backend/llm/deepseek_provider.py +43 -0
  107. package/vendors/precis/backend/llm/factory.py +60 -0
  108. package/vendors/precis/backend/llm/google_provider.py +39 -0
  109. package/vendors/precis/backend/llm/ollama_provider.py +54 -0
  110. package/vendors/precis/backend/llm/openai_provider.py +50 -0
  111. package/vendors/precis/backend/main.py +677 -0
  112. package/vendors/precis/backend/orchestrator/__init__.py +3 -0
  113. package/vendors/precis/backend/orchestrator/planner.py +81 -0
  114. package/vendors/precis/backend/orchestrator/router.py +319 -0
  115. package/vendors/precis/backend/orchestrator/types.py +58 -0
  116. package/vendors/precis/bridge_precis.py +185 -0
  117. package/vendors/precis/data/sample_reports/README.md +8 -0
  118. package/vendors/precis/data/seed_data.py +115 -0
  119. package/vendors/precis/requirements.txt +19 -0
@@ -0,0 +1,213 @@
1
+ /**
2
+ * Multi-Provider LLM Configuration
3
+ *
4
+ * Supports: OpenAI, Anthropic, Google Gemini, DeepSeek, Groq,
5
+ * Ollama, OpenRouter, Azure OpenAI, and any OpenAI-compatible custom endpoint.
6
+ *
7
+ * Each integration package can use a different provider or inherit the default.
8
+ * Environment variables follow a layered pattern:
9
+ * 1. Per-component override (e.g. DEEPPIPE_LLM_PROVIDER)
10
+ * 2. Default LLM config (LLM_DEFAULT_PROVIDER)
11
+ * 3. Hard-coded fallback (openai)
12
+ */
13
+
14
+ import { z } from 'zod';
15
+
16
+ // ── Provider definitions ─────────────────────────────────────────────
17
+
18
+ export const LLM_PROVIDERS = [
19
+ 'openai',
20
+ 'anthropic',
21
+ 'google',
22
+ 'deepseek',
23
+ 'groq',
24
+ 'ollama',
25
+ 'openrouter',
26
+ 'azure',
27
+ 'custom',
28
+ ] as const;
29
+
30
+ export type LLMProvider = (typeof LLM_PROVIDERS)[number];
31
+
32
+ /** Auto-configuration per provider — base URL and default model. */
33
+ export const PROVIDER_DEFAULTS: Record<LLMProvider, { baseUrl: string; defaultModel: string }> = {
34
+ openai: { baseUrl: 'https://api.openai.com/v1', defaultModel: 'gpt-4o-mini' },
35
+ anthropic: { baseUrl: 'https://api.anthropic.com/v1', defaultModel: 'claude-3-haiku-20240307' },
36
+ google: { baseUrl: 'https://generativelanguage.googleapis.com/v1beta', defaultModel: 'gemini-2.0-flash' },
37
+ deepseek: { baseUrl: 'https://api.deepseek.com', defaultModel: 'deepseek-chat' },
38
+ groq: { baseUrl: 'https://api.groq.com/openai/v1', defaultModel: 'llama-3.3-70b-versatile' },
39
+ ollama: { baseUrl: 'http://localhost:11434/v1', defaultModel: 'llama3.2' },
40
+ openrouter: { baseUrl: 'https://openrouter.ai/api/v1', defaultModel: 'openai/gpt-4o-mini' },
41
+ azure: { baseUrl: '', defaultModel: 'gpt-4o-mini' },
42
+ custom: { baseUrl: '', defaultModel: '' },
43
+ };
44
+
45
+ /** Which providers use the OpenAI-compatible chat completions API? */
46
+ export const OPENAI_COMPATIBLE_PROVIDERS: Set<LLMProvider> = new Set([
47
+ 'openai', 'deepseek', 'groq', 'ollama', 'openrouter', 'azure', 'custom',
48
+ ]);
49
+
50
+ /** Providers that use native SDKs (not OpenAI-compatible). */
51
+ export const NATIVE_SDK_PROVIDERS: Set<LLMProvider> = new Set([
52
+ 'anthropic', 'google',
53
+ ]);
54
+
55
+ // ── Resolved LLM config for a single component ───────────────────────
56
+
57
+ export interface ResolvedLLMConfig {
58
+ provider: LLMProvider;
59
+ apiKey: string;
60
+ baseUrl: string;
61
+ model: string;
62
+ /** True if this endpoint uses OpenAI-compatible chat completions. */
63
+ isOpenAICompatible: boolean;
64
+ /** Azure-specific fields (only set when provider=azure). */
65
+ azure?: {
66
+ endpoint: string;
67
+ apiVersion: string;
68
+ deployment: string;
69
+ };
70
+ /** Anthropic-specific (only set when provider=anthropic). */
71
+ anthropic?: {
72
+ baseUrl: string;
73
+ };
74
+ /** Google-specific (only set when provider=google). */
75
+ google?: {
76
+ apiKey: string;
77
+ };
78
+ }
79
+
80
+ // ── Resolution logic ─────────────────────────────────────────────────
81
+
82
+ /**
83
+ * Resolve the effective LLM configuration for a component.
84
+ *
85
+ * Layering (highest to lowest priority):
86
+ * 1. Per-component env vars (e.g. DEEPPIPE_LLM_PROVIDER, DEEPPIPE_LLM_API_KEY)
87
+ * 2. Default LLM env vars (LLM_DEFAULT_PROVIDER, LLM_DEFAULT_API_KEY)
88
+ * 3. Hard-coded fallback (openai, empty key)
89
+ *
90
+ * @param overrides - Per-component environment variable values.
91
+ */
92
+ export function resolveLLMConfig(overrides?: {
93
+ provider?: string;
94
+ apiKey?: string;
95
+ baseUrl?: string;
96
+ model?: string;
97
+ }): ResolvedLLMConfig {
98
+ // ── Determine provider ──────────────────────────────────
99
+ const rawProvider = (
100
+ overrides?.provider ||
101
+ process.env.LLM_DEFAULT_PROVIDER ||
102
+ 'openai'
103
+ ).toLowerCase().trim();
104
+
105
+ const provider: LLMProvider = LLM_PROVIDERS.includes(rawProvider as LLMProvider)
106
+ ? (rawProvider as LLMProvider)
107
+ : (rawProvider === 'gemini' ? 'google' : 'custom');
108
+
109
+ // ── Resolve base URL ────────────────────────────────────
110
+ const defaults = PROVIDER_DEFAULTS[provider];
111
+ let baseUrl = '';
112
+
113
+ if (provider === 'azure') {
114
+ baseUrl = process.env.AZURE_OPENAI_ENDPOINT || '';
115
+ } else if (provider === 'ollama') {
116
+ baseUrl = process.env.OLLAMA_HOST || defaults.baseUrl;
117
+ } else if (provider === 'anthropic') {
118
+ baseUrl = process.env.ANTHROPIC_BASE_URL || defaults.baseUrl;
119
+ } else if (provider === 'google') {
120
+ baseUrl = defaults.baseUrl;
121
+ } else if (provider === 'custom') {
122
+ baseUrl = overrides?.baseUrl || process.env.LLM_DEFAULT_BASE_URL || '';
123
+ } else {
124
+ baseUrl = overrides?.baseUrl || process.env.LLM_DEFAULT_BASE_URL || defaults.baseUrl;
125
+ }
126
+ // Strip trailing slash
127
+ baseUrl = baseUrl.replace(/\/+$/, '');
128
+
129
+ // ── Resolve API key ─────────────────────────────────────
130
+ let apiKey = '';
131
+ if (provider === 'anthropic') {
132
+ apiKey = overrides?.apiKey || process.env.ANTHROPIC_API_KEY || process.env.LLM_DEFAULT_API_KEY || '';
133
+ } else if (provider === 'google') {
134
+ apiKey = overrides?.apiKey || process.env.GOOGLE_API_KEY || process.env.LLM_DEFAULT_API_KEY || '';
135
+ } else if (provider === 'azure') {
136
+ apiKey = process.env.AZURE_OPENAI_API_KEY || process.env.LLM_DEFAULT_API_KEY || '';
137
+ } else {
138
+ apiKey = overrides?.apiKey || process.env.LLM_DEFAULT_API_KEY || '';
139
+ }
140
+
141
+ // ── Resolve model ───────────────────────────────────────
142
+ let model = '';
143
+ if (provider === 'azure') {
144
+ model = process.env.AZURE_OPENAI_DEPLOYMENT || defaults.defaultModel;
145
+ } else if (provider === 'custom') {
146
+ model = overrides?.model || process.env.LLM_DEFAULT_MODEL || '';
147
+ } else {
148
+ model = overrides?.model || process.env.LLM_DEFAULT_MODEL || defaults.defaultModel;
149
+ }
150
+
151
+ // ── Build result ────────────────────────────────────────
152
+ const result: ResolvedLLMConfig = {
153
+ provider,
154
+ apiKey,
155
+ baseUrl,
156
+ model,
157
+ isOpenAICompatible: OPENAI_COMPATIBLE_PROVIDERS.has(provider),
158
+ };
159
+
160
+ if (provider === 'azure') {
161
+ result.azure = {
162
+ endpoint: baseUrl,
163
+ apiVersion: process.env.AZURE_OPENAI_API_VERSION || '2024-08-01-preview',
164
+ deployment: model,
165
+ };
166
+ }
167
+ if (provider === 'anthropic') {
168
+ result.anthropic = { baseUrl };
169
+ }
170
+ if (provider === 'google') {
171
+ result.google = { apiKey };
172
+ }
173
+
174
+ return result;
175
+ }
176
+
177
+ /**
178
+ * Returns human-readable summary of all available LLM providers.
179
+ * Useful for MCP prompts and help text.
180
+ */
181
+ export function listProviders(): string[] {
182
+ return [...LLM_PROVIDERS];
183
+ }
184
+
185
+ /**
186
+ * Validate that a given provider string is recognized.
187
+ */
188
+ export function isValidProvider(raw: string): raw is LLMProvider {
189
+ return LLM_PROVIDERS.includes(raw.toLowerCase().trim() as LLMProvider);
190
+ }
191
+
192
+ // ── Zod schema for runtime validation ─────────────────────────────────
193
+
194
+ export const llmProviderSchema = z.enum(LLM_PROVIDERS);
195
+
196
+ export const llmConfigSchema = z.object({
197
+ provider: llmProviderSchema,
198
+ apiKey: z.string(),
199
+ baseUrl: z.string(),
200
+ model: z.string(),
201
+ isOpenAICompatible: z.boolean(),
202
+ azure: z.object({
203
+ endpoint: z.string(),
204
+ apiVersion: z.string(),
205
+ deployment: z.string(),
206
+ }).optional(),
207
+ anthropic: z.object({
208
+ baseUrl: z.string(),
209
+ }).optional(),
210
+ google: z.object({
211
+ apiKey: z.string(),
212
+ }).optional(),
213
+ });
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Structured Logger
3
+ *
4
+ * JSON-formatted logging to stderr (safe for stdio MCP transport).
5
+ * stdout is reserved for MCP protocol messages.
6
+ */
7
+
8
+ export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
9
+
10
+ const LOG_LEVEL_PRIORITY: Record<LogLevel, number> = {
11
+ debug: 0,
12
+ info: 1,
13
+ warn: 2,
14
+ error: 3,
15
+ };
16
+
17
+ export interface LogEntry {
18
+ timestamp: string;
19
+ level: LogLevel;
20
+ tool?: string;
21
+ message: string;
22
+ data?: unknown;
23
+ }
24
+
25
+ export class Logger {
26
+ constructor(private readonly minLevel: LogLevel = 'info') {}
27
+
28
+ debug(message: string, tool?: string, data?: unknown): void {
29
+ this.log('debug', message, tool, data);
30
+ }
31
+
32
+ info(message: string, tool?: string, data?: unknown): void {
33
+ this.log('info', message, tool, data);
34
+ }
35
+
36
+ warn(message: string, tool?: string, data?: unknown): void {
37
+ this.log('warn', message, tool, data);
38
+ }
39
+
40
+ error(message: string, tool?: string, data?: unknown): void {
41
+ this.log('error', message, tool, data);
42
+ }
43
+
44
+ private log(level: LogLevel, message: string, tool?: string, data?: unknown): void {
45
+ if (LOG_LEVEL_PRIORITY[level] < LOG_LEVEL_PRIORITY[this.minLevel]) return;
46
+
47
+ const entry: LogEntry = {
48
+ timestamp: new Date().toISOString(),
49
+ level,
50
+ message,
51
+ };
52
+ if (tool) entry.tool = tool;
53
+ if (data !== undefined) entry.data = data;
54
+
55
+ // Write to stderr so stdout stays clean for MCP protocol
56
+ process.stderr.write(JSON.stringify(entry) + '\n');
57
+ }
58
+ }
59
+
60
+ /** Create a logger with the configured minimum level. */
61
+ export function createLogger(level: LogLevel): Logger {
62
+ return new Logger(level);
63
+ }
64
+
65
+ /** Default logger instance (info level). */
66
+ export const defaultLogger = new Logger('info');
@@ -0,0 +1,384 @@
1
+ /**
2
+ * Python Service Manager
3
+ *
4
+ * Spawns and manages Python backend processes (piste, precis).
5
+ * Backends auto-start on first tool call and are killed on MCP server exit.
6
+ * Uses stdin/stdout JSON protocol for fast communication after initial import.
7
+ */
8
+
9
+ import { spawn, execSync, type ChildProcess } from 'node:child_process';
10
+ import { resolve } from 'node:path';
11
+ import { existsSync } from 'node:fs';
12
+ import { Logger } from './logging.js';
13
+
14
+ // ═══════════════════════════════════════════════════════════════════════
15
+ // Auto-detect Python — tries multiple candidates, returns first working.
16
+ // ═══════════════════════════════════════════════════════════════════════
17
+
18
+ let _cachedPython: string | null = null;
19
+
20
+ /** Find a working Python executable. Caches result after first successful find. */
21
+ export function findPython(logger?: Logger): string | null {
22
+ if (_cachedPython) return _cachedPython;
23
+
24
+ const candidates = buildCandidates();
25
+ logger?.debug(`Python: trying ${candidates.length} candidates...`);
26
+
27
+ for (const candidate of candidates) {
28
+ try {
29
+ const result = execSync(`"${candidate}" --version`, {
30
+ stdio: ['ignore', 'pipe', 'ignore'],
31
+ timeout: 5000,
32
+ windowsHide: true,
33
+ });
34
+ const version = result.toString().trim();
35
+ if (version.toLowerCase().includes('python')) {
36
+ _cachedPython = candidate;
37
+ logger?.info(`Python found: ${candidate} (${version})`);
38
+ return candidate;
39
+ }
40
+ } catch {
41
+ // Try next candidate
42
+ }
43
+ }
44
+
45
+ logger?.warn('Python not found automatically. Install from https://python.org');
46
+ logger?.warn('Candidates tried: ' + candidates.join(', '));
47
+ return null;
48
+ }
49
+
50
+ /** Reset cached Python path (useful for testing). */
51
+ export function resetPythonCache(): void {
52
+ _cachedPython = null;
53
+ }
54
+
55
+ /** Build the list of Python candidates in priority order. */
56
+ function buildCandidates(): string[] {
57
+ const isWin = process.platform === 'win32';
58
+ const candidates: string[] = [];
59
+
60
+ if (isWin) {
61
+ // Windows: try py launcher first (installed with official Python)
62
+ candidates.push('py', 'python', 'python3');
63
+
64
+ const localAppData = process.env.LOCALAPPDATA || '';
65
+ const programFiles = process.env.ProgramFiles || 'C:\\Program Files';
66
+ const programFilesX86 = process.env['ProgramFiles(x86)'] || 'C:\\Program Files (x86)';
67
+
68
+ // 1st: Official Python.org installs (working SSL, short paths)
69
+ for (const ver of ['312', '311', '310', '313', '39', '38']) {
70
+ candidates.push(
71
+ `${programFiles}\\Python${ver}\\python.exe`,
72
+ `${programFilesX86}\\Python${ver}\\python.exe`,
73
+ );
74
+ if (localAppData) {
75
+ candidates.push(`${localAppData}\\Programs\\Python\\Python${ver}\\python.exe`);
76
+ }
77
+ }
78
+
79
+ // 2nd: Conda / Miniconda
80
+ const userProfile = process.env.USERPROFILE || '';
81
+ if (userProfile) {
82
+ for (const conda of ['Anaconda3', 'miniconda3', 'Miniconda3']) {
83
+ candidates.push(
84
+ `${userProfile}\\${conda}\\python.exe`,
85
+ `${userProfile}\\${conda}\\Scripts\\python.exe`,
86
+ );
87
+ }
88
+ }
89
+
90
+ // 3rd: Chocolatey / winget
91
+ candidates.push('C:\\Python312\\python.exe', 'C:\\Python311\\python.exe', 'C:\\Python310\\python.exe');
92
+
93
+ // Last resort: Microsoft Store Python (⚠ broken SSL, sandboxed)
94
+ // Only used if no other Python is found — tools may fail with SSL errors
95
+ if (localAppData) {
96
+ candidates.push(
97
+ `${localAppData}\\Microsoft\\WindowsApps\\python.exe`,
98
+ `${localAppData}\\Microsoft\\WindowsApps\\python3.exe`,
99
+ );
100
+ }
101
+ } else {
102
+ // macOS / Linux
103
+ candidates.push(
104
+ 'python3', 'python',
105
+ '/usr/bin/python3', '/usr/bin/python',
106
+ '/usr/local/bin/python3', '/usr/local/bin/python',
107
+ '/opt/homebrew/bin/python3',
108
+ );
109
+ }
110
+
111
+ // Deduplicate
112
+ return [...new Set(candidates)];
113
+ }
114
+
115
+ export interface PythonServiceOptions {
116
+ /** Unique name for this service (e.g. "precis", "piste") */
117
+ name: string;
118
+ /** Absolute path to the Python bridge script */
119
+ scriptPath: string;
120
+ /** Working directory for the Python process */
121
+ cwd: string;
122
+ /** Environment variables to pass to Python */
123
+ env?: Record<string, string>;
124
+ /** Timeout in ms for initial health check */
125
+ healthTimeout?: number;
126
+ /** Whether to auto-start on first request */
127
+ autoStart?: boolean;
128
+ }
129
+
130
+ export class PythonService {
131
+ private process: ChildProcess | null = null;
132
+ private pending = new Map<number, { resolve: (v: any) => void; reject: (e: Error) => void }>();
133
+ private nextId = 1;
134
+ private buffer = '';
135
+ private started = false;
136
+ private healthy = false;
137
+
138
+ constructor(
139
+ private options: PythonServiceOptions,
140
+ private logger: Logger,
141
+ ) {}
142
+
143
+ get name(): string { return this.options.name; }
144
+ get isHealthy(): boolean { return this.healthy; }
145
+
146
+ /** Start the Python process. Idempotent — only starts once. */
147
+ async start(): Promise<void> {
148
+ if (this.started) return;
149
+ this.started = true;
150
+
151
+ this.logger.info(`Starting ${this.options.name} backend...`);
152
+
153
+ // Prefer uv-managed Python (proper SSL) over system Python
154
+ const uvPath = process.platform === 'win32'
155
+ ? `${process.cwd()}\\.vendor\\uv.exe`
156
+ : `${process.cwd()}/.vendor/uv`;
157
+
158
+ let cmd = '';
159
+ let args: string[] = [];
160
+
161
+ if (existsSync(uvPath)) {
162
+ // Check if uv has a managed Python with working SSL
163
+ try {
164
+ const managed = execSync(`"${uvPath}" python find 3.11 --no-python-downloads`, {
165
+ encoding: 'utf8', stdio: 'pipe', timeout: 10_000, windowsHide: true,
166
+ }).trim();
167
+ if (managed && !managed.includes('WindowsApps') && existsSync(managed)) {
168
+ cmd = managed;
169
+ args = [this.options.scriptPath];
170
+ this.logger.info(`${this.options.name}: using uv-managed Python → ${managed}`);
171
+ }
172
+ } catch { /* fall through to system Python */ }
173
+ }
174
+
175
+ if (!cmd) {
176
+ const pythonCmd = findPython(this.logger);
177
+ if (!pythonCmd) {
178
+ this.logger.error(`${this.options.name}: Python not found. Install from https://python.org`);
179
+ this.healthy = false;
180
+ return;
181
+ }
182
+ cmd = pythonCmd;
183
+ args = [this.options.scriptPath];
184
+ }
185
+
186
+ const env = { ...process.env, ...this.options.env, PYTHONUNBUFFERED: '1' };
187
+
188
+ // Include .python-packages (created by setup.mjs — short path, no MAX_PATH)
189
+ // pip --target installs directly into the directory
190
+ const targetPackages = `${process.cwd()}${process.platform === 'win32' ? '\\.python-packages' : '/.python-packages'}`;
191
+ if (env.PYTHONPATH) {
192
+ env.PYTHONPATH = process.platform === 'win32'
193
+ ? `${targetPackages};${env.PYTHONPATH}`
194
+ : `${targetPackages}:${env.PYTHONPATH}`;
195
+ } else {
196
+ env.PYTHONPATH = targetPackages;
197
+ }
198
+
199
+ this.process = spawn(cmd, args, {
200
+ cwd: this.options.cwd,
201
+ stdio: ['pipe', 'pipe', 'pipe'],
202
+ env,
203
+ windowsHide: true,
204
+ });
205
+
206
+ this.process.stdout?.on('data', (chunk: Buffer) => {
207
+ this.buffer += chunk.toString();
208
+ this.processBuffer();
209
+ });
210
+
211
+ this.process.stderr?.on('data', (chunk: Buffer) => {
212
+ const msg = chunk.toString().trim();
213
+ if (msg) this.logger.debug(`[${this.options.name}] ${msg}`);
214
+ });
215
+
216
+ this.process.on('error', (err) => {
217
+ this.logger.error(`${this.options.name} process error: ${err.message}`);
218
+ this.healthy = false;
219
+ });
220
+
221
+ this.process.on('exit', (code) => {
222
+ this.logger.warn(`${this.options.name} process exited with code ${code}`);
223
+ this.healthy = false;
224
+ this.started = false;
225
+ });
226
+
227
+ // Wait for health check
228
+ try {
229
+ await this.waitForHealth(this.options.healthTimeout ?? 15000);
230
+ this.healthy = true;
231
+ this.logger.info(`${this.options.name} backend is ready`);
232
+ } catch (err: any) {
233
+ this.logger.warn(`${this.options.name} health check failed: ${err.message}. Will retry on first request.`);
234
+ }
235
+ }
236
+
237
+ /** Send a request to the Python backend and get the response. */
238
+ async call(action: string, params: Record<string, unknown> = {}): Promise<any> {
239
+ if (!this.started) {
240
+ await this.start();
241
+ }
242
+
243
+ if (!this.healthy || !this.process || this.process.killed) {
244
+ throw new Error(`${this.options.name}: Python backend is not running. Check that setup.mjs completed successfully.`);
245
+ }
246
+
247
+ const id = this.nextId++;
248
+ const request = JSON.stringify({ id, action, params });
249
+
250
+ return new Promise((resolve, reject) => {
251
+ const timer = setTimeout(() => {
252
+ this.pending.delete(id);
253
+ reject(new Error(`${this.options.name}: timeout waiting for response to "${action}"`));
254
+ }, 60000);
255
+
256
+ this.pending.set(id, {
257
+ resolve: (v: any) => { clearTimeout(timer); resolve(v); },
258
+ reject: (e: Error) => { clearTimeout(timer); reject(e); },
259
+ });
260
+
261
+ try {
262
+ this.process!.stdin!.write(request + '\n');
263
+ } catch (err: any) {
264
+ clearTimeout(timer);
265
+ this.pending.delete(id);
266
+ reject(new Error(`${this.options.name}: failed to send request: ${err.message}`));
267
+ }
268
+ });
269
+ }
270
+
271
+ /** Stop the Python process. */
272
+ stop(): void {
273
+ if (this.process) {
274
+ this.process.kill('SIGTERM');
275
+ // Force kill after 5s
276
+ setTimeout(() => { if (this.process && !this.process.killed) this.process.kill('SIGKILL'); }, 5000);
277
+ this.process = null;
278
+ this.started = false;
279
+ this.healthy = false;
280
+ }
281
+ }
282
+
283
+ // ── Private ──────────────────────────────────────────────────────
284
+
285
+ private processBuffer(): void {
286
+ const lines = this.buffer.split('\n');
287
+ this.buffer = lines.pop() || '';
288
+
289
+ for (const line of lines) {
290
+ const trimmed = line.trim();
291
+ if (!trimmed) continue;
292
+
293
+ // Check for health signal
294
+ if (trimmed === '__READY__') {
295
+ this.healthy = true;
296
+ this.resolvePendingHealth();
297
+ continue;
298
+ }
299
+
300
+ try {
301
+ const msg = JSON.parse(trimmed);
302
+ if (msg.id && this.pending.has(msg.id)) {
303
+ const { resolve, reject } = this.pending.get(msg.id)!;
304
+ this.pending.delete(msg.id);
305
+ if (msg.error) {
306
+ reject(new Error(msg.error));
307
+ } else {
308
+ resolve(msg.result);
309
+ }
310
+ }
311
+ } catch {
312
+ // Not JSON — might be a log line
313
+ }
314
+ }
315
+ }
316
+
317
+ private healthResolve: (() => void) | null = null;
318
+
319
+ private async waitForHealth(timeout: number): Promise<void> {
320
+ return new Promise((resolve, reject) => {
321
+ const timer = setTimeout(() => reject(new Error('Health check timed out')), timeout);
322
+ this.healthResolve = () => {
323
+ clearTimeout(timer);
324
+ resolve();
325
+ };
326
+ // If already healthy
327
+ if (this.healthy) {
328
+ clearTimeout(timer);
329
+ resolve();
330
+ }
331
+ });
332
+ }
333
+
334
+ private resolvePendingHealth(): void {
335
+ if (this.healthResolve) {
336
+ this.healthResolve();
337
+ this.healthResolve = null;
338
+ }
339
+ }
340
+ }
341
+
342
+ // ═══════════════════════════════════════════════════════════════════════
343
+
344
+ export class PythonServiceManager {
345
+ private services = new Map<string, PythonService>();
346
+
347
+ constructor(private logger: Logger) {}
348
+
349
+ register(options: PythonServiceOptions): PythonService {
350
+ const service = new PythonService(options, this.logger);
351
+ this.services.set(options.name, service);
352
+
353
+ if (options.autoStart !== false) {
354
+ // Auto-start in background — don't block
355
+ service.start().catch(() => {});
356
+ }
357
+
358
+ return service;
359
+ }
360
+
361
+ get(name: string): PythonService | undefined {
362
+ return this.services.get(name);
363
+ }
364
+
365
+ /** Returns the best available Python interpreter path, or throws. */
366
+ getPythonPath(): string {
367
+ const python = findPython(this.logger);
368
+ if (!python) {
369
+ throw new Error(
370
+ 'No working Python interpreter found. Install Python 3.10+ from https://python.org ' +
371
+ 'and ensure it is on your PATH.'
372
+ );
373
+ }
374
+ return python;
375
+ }
376
+
377
+ /** Stop all services. Call on MCP server shutdown. */
378
+ stopAll(): void {
379
+ for (const [name, service] of this.services) {
380
+ this.logger.info(`Stopping ${name} backend...`);
381
+ service.stop();
382
+ }
383
+ }
384
+ }