@intutic/mcp-governance-proxy 1.4.1 → 1.6.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.
package/src/config.ts DELETED
@@ -1,145 +0,0 @@
1
- /**
2
- * config.ts — MCP Governance Proxy configuration.
3
- *
4
- * Reads from ~/.intutic/env/runtime.env (written by sync-daemon runtimeEnv.ts)
5
- * and from CLI arguments passed after `--`.
6
- *
7
- * @module
8
- */
9
-
10
- import * as node_fs from 'node:fs/promises'
11
- import * as node_os from 'node:os'
12
- import * as node_path from 'node:path'
13
-
14
- export interface ProxyConfig {
15
- /** Workspace ID for policy lookups and event attribution */
16
- workspaceId: string
17
- /** Control plane base URL (e.g. http://localhost:3001) */
18
- controlPlaneUrl: string
19
- /** API key for control plane requests */
20
- apiKey: string
21
- /** Real MCP server command + args (everything after --). Empty = standalone mode. */
22
- realServerCommand: string[]
23
- /** Path to write hook-events JSONL (Path B) */
24
- eventsFilePath: string
25
- /** Policy cache TTL in milliseconds (default: 60_000) */
26
- policyTtlMs: number
27
- /** Whether to fail-open when control plane is unreachable (default: true) */
28
- failOpen: boolean
29
- /**
30
- * MCP proxy deployment model ('per-session' | 'daemon').
31
- * WS-5 Q2 — stored and propagated but 'daemon' is not yet active in Phase 4.
32
- */
33
- mcpProxyMode: string
34
- /**
35
- * Standalone mode — when true, the proxy acts as the Intutic MCP server
36
- * directly (no downstream server to proxy). Used for the `intutic` harness
37
- * entry that exposes Intutic governance tools to the harness.
38
- */
39
- standalone: boolean
40
- }
41
-
42
- const DEFAULT_EVENTS_PATH = node_path.join(node_os.homedir(), '.intutic', 'events', 'hook-events.jsonl')
43
- const RUNTIME_ENV_PATH = node_path.join(node_os.homedir(), '.intutic', 'env', 'runtime.env')
44
-
45
- /**
46
- * Parse a .env-format file into a key→value record.
47
- * Lines starting with # are comments; blank lines are skipped.
48
- */
49
- async function parseEnvFile(filePath: string): Promise<Record<string, string>> {
50
- const result: Record<string, string> = {}
51
- try {
52
- const content = await node_fs.readFile(filePath, 'utf-8')
53
- for (const line of content.split('\n')) {
54
- const trimmed = line.trim()
55
- if (!trimmed || trimmed.startsWith('#')) continue
56
- const eq = trimmed.indexOf('=')
57
- if (eq === -1) continue
58
- const key = trimmed.slice(0, eq).trim()
59
- // Strip surrounding quotes from value
60
- let value = trimmed.slice(eq + 1).trim()
61
- if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
62
- value = value.slice(1, -1)
63
- }
64
- result[key] = value
65
- }
66
- } catch {
67
- // File may not exist — return empty record
68
- }
69
- return result
70
- }
71
-
72
- /**
73
- * Parse CLI arguments for --workspace-id and the real server command (after --).
74
- */
75
- function parseCliArgs(argv: string[]): { workspaceId?: string; realServerCommand: string[] } {
76
- let workspaceId: string | undefined
77
- let separatorIndex = -1
78
-
79
- for (let i = 0; i < argv.length; i++) {
80
- if (argv[i] === '--workspace-id' && i + 1 < argv.length) {
81
- workspaceId = argv[i + 1]
82
- i++ // skip value
83
- } else if (argv[i] === '--') {
84
- separatorIndex = i
85
- break
86
- }
87
- }
88
-
89
- const realServerCommand = separatorIndex !== -1 ? argv.slice(separatorIndex + 1) : []
90
- return { workspaceId, realServerCommand }
91
- }
92
-
93
- /**
94
- * Load the proxy configuration from runtime.env + CLI args + environment.
95
- */
96
- export async function loadConfig(argv: string[] = process.argv.slice(2)): Promise<ProxyConfig> {
97
- const runtimeEnv = await parseEnvFile(RUNTIME_ENV_PATH)
98
- const cli = parseCliArgs(argv)
99
-
100
- const controlPlaneUrl =
101
- process.env['INTUTIC_CONTROL_PLANE_URL'] ??
102
- runtimeEnv['INTUTIC_CONTROL_PLANE_URL'] ??
103
- 'http://localhost:3001'
104
-
105
- const apiKey =
106
- process.env['INTUTIC_API_KEY'] ??
107
- runtimeEnv['INTUTIC_API_KEY'] ??
108
- ''
109
-
110
- const workspaceId =
111
- cli.workspaceId ??
112
- process.env['INTUTIC_WORKSPACE_ID'] ??
113
- runtimeEnv['INTUTIC_WORKSPACE_ID'] ??
114
- 'unknown'
115
-
116
- const eventsFilePath =
117
- process.env['INTUTIC_EVENTS_FILE'] ??
118
- runtimeEnv['INTUTIC_EVENTS_FILE'] ??
119
- DEFAULT_EVENTS_PATH
120
-
121
- const failOpen =
122
- (runtimeEnv['INTUTIC_MCP_FAIL_OPEN'] ?? 'true').toLowerCase() !== 'false'
123
-
124
- const mcpProxyMode = runtimeEnv['INTUTIC_MCP_PROXY_MODE'] ?? 'per-session'
125
- if (mcpProxyMode === 'daemon') {
126
- console.warn(
127
- '[mcp-proxy] INTUTIC_MCP_PROXY_MODE=daemon is stored but not yet active — '
128
- + 'running in per-session mode (Phase 4). Will be activated in Phase 5.'
129
- )
130
- }
131
-
132
- const standalone = cli.realServerCommand.length === 0
133
-
134
- return {
135
- workspaceId,
136
- controlPlaneUrl,
137
- apiKey,
138
- realServerCommand: cli.realServerCommand,
139
- eventsFilePath,
140
- policyTtlMs: 60_000,
141
- failOpen,
142
- mcpProxyMode,
143
- standalone,
144
- }
145
- }
@@ -1,210 +0,0 @@
1
- /**
2
- * MCP Server Health Monitor
3
- *
4
- * Probes MCP servers every 30s and records health snapshots.
5
- * Emits mcp_daemon.mcp_server_down when a server becomes unreachable.
6
- *
7
- * LLD #28: MCP Daemon Mode, WS-5MCP
8
- * @module
9
- */
10
- import https from 'node:https'
11
- import http from 'node:http'
12
- import fs from 'node:fs'
13
- import path from 'node:path'
14
- import os from 'node:os'
15
- import { execSync } from 'node:child_process'
16
- import { createLogger } from '@intutic/logger'
17
-
18
- const logger = createLogger('mcp-proxy.healthMonitor')
19
-
20
- const HEARTBEAT_MS = 30_000
21
- const PROBE_TIMEOUT = 5_000
22
-
23
- const CP_URL = process.env['CONTROL_PLANE_URL'] ?? 'http://localhost:3001'
24
- const DAEMON_API_KEY = process.env['INTUTIC_API_KEY'] ?? ''
25
- const WORKSPACE_ID = process.env['INTUTIC_WORKSPACE_ID'] ?? ''
26
-
27
- export interface McpServerConfig {
28
- name: string
29
- url: string
30
- credentialExpiryAt?: Date
31
- }
32
-
33
- export interface McpServerHealth {
34
- serverName: string
35
- status: 'healthy' | 'degraded' | 'unreachable'
36
- p95LatencyMs?: number
37
- errorRatePct?: number
38
- credentialExpiryAt?: string
39
- checkedAt: string
40
- }
41
-
42
- const servers: McpServerConfig[] = []
43
- const latestHealth = new Map<string, McpServerHealth>()
44
- let timer: ReturnType<typeof setInterval> | null = null
45
-
46
- function discoverServers(): McpServerConfig[] {
47
- const discovered: McpServerConfig[] = []
48
- const homedir = os.homedir()
49
-
50
- const configPaths = [
51
- path.join(homedir, '.claude', 'mcp.json'),
52
- process.platform === 'darwin'
53
- ? path.join(homedir, 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json')
54
- : process.platform === 'win32'
55
- ? path.join(process.env['APPDATA'] ?? '', 'Claude', 'claude_desktop_config.json')
56
- : path.join(homedir, '.config', 'Claude', 'claude_desktop_config.json'),
57
- process.platform === 'darwin'
58
- ? path.join(homedir, 'Library', 'Application Support', 'Cursor', 'User', 'globalSettings.json')
59
- : process.platform === 'win32'
60
- ? path.join(process.env['APPDATA'] ?? '', 'Cursor', 'User', 'globalSettings.json')
61
- : path.join(homedir, '.config', 'Cursor', 'User', 'globalSettings.json'),
62
- ]
63
-
64
- for (const configPath of configPaths) {
65
- try {
66
- if (!fs.existsSync(configPath)) continue
67
- const raw = fs.readFileSync(configPath, 'utf8')
68
- const parsed = JSON.parse(raw)
69
- const mcpServers = parsed.mcpServers ?? parsed.mcp ?? {}
70
- for (const [name, entry] of Object.entries(mcpServers)) {
71
- if (name === 'intutic') continue // Skip self
72
- const serverEntry = entry as any
73
-
74
- let url = ''
75
- if (serverEntry.url) {
76
- url = serverEntry.url
77
- } else if (serverEntry.command) {
78
- url = `stdio://${serverEntry.command}`
79
- }
80
-
81
- if (url && !discovered.some(s => s.name === name)) {
82
- discovered.push({
83
- name,
84
- url,
85
- credentialExpiryAt: undefined
86
- })
87
- }
88
- }
89
- } catch {}
90
- }
91
-
92
- return discovered
93
- }
94
-
95
- async function probeServer(server: McpServerConfig): Promise<McpServerHealth> {
96
- const start = Date.now()
97
- if (server.url.startsWith('stdio://')) {
98
- const cmd = server.url.replace('stdio://', '')
99
- let status: 'healthy' | 'unreachable' = 'healthy'
100
- try {
101
- const whichCmd = process.platform === 'win32' ? `where ${cmd}` : `which ${cmd}`
102
- execSync(whichCmd, { stdio: 'ignore' })
103
- } catch {
104
- status = 'unreachable'
105
- }
106
- return {
107
- serverName: server.name,
108
- status,
109
- p95LatencyMs: Date.now() - start,
110
- credentialExpiryAt: server.credentialExpiryAt?.toISOString(),
111
- checkedAt: new Date().toISOString()
112
- }
113
- }
114
-
115
- return new Promise((resolve) => {
116
- try {
117
- const url = new URL(server.url)
118
- const isHttps = url.protocol === 'https:'
119
- const lib = isHttps ? https : http
120
- const req = lib.request(
121
- { hostname: url.hostname, port: url.port, path: url.pathname + url.search, method: 'GET' },
122
- (res) => {
123
- res.resume()
124
- const latency = Date.now() - start
125
- const status = res.statusCode && res.statusCode < 500 ? 'healthy' : 'degraded'
126
- resolve({ serverName: server.name, status, p95LatencyMs: latency,
127
- credentialExpiryAt: server.credentialExpiryAt?.toISOString(),
128
- checkedAt: new Date().toISOString() })
129
- }
130
- )
131
- req.on('error', () => resolve({ serverName: server.name, status: 'unreachable',
132
- checkedAt: new Date().toISOString() }))
133
- req.setTimeout(PROBE_TIMEOUT, () => { req.destroy(); resolve({
134
- serverName: server.name, status: 'unreachable', checkedAt: new Date().toISOString() }) })
135
- req.end()
136
- } catch {
137
- resolve({ serverName: server.name, status: 'unreachable', checkedAt: new Date().toISOString() })
138
- }
139
- })
140
- }
141
-
142
- async function uploadSnapshots(snapshots: McpServerHealth[]): Promise<void> {
143
- if (snapshots.length === 0 || !WORKSPACE_ID) return
144
- const body = JSON.stringify({ mcpServers: snapshots })
145
- const url = new URL('/api/v1/mcp-daemon/health-snapshot', CP_URL)
146
- const isHttps = url.protocol === 'https:'
147
- const lib = isHttps ? https : http
148
-
149
- return new Promise((resolve) => {
150
- const req = lib.request(
151
- {
152
- hostname: url.hostname,
153
- port: url.port,
154
- path: url.pathname,
155
- method: 'POST',
156
- headers: {
157
- 'Content-Type': 'application/json',
158
- 'Content-Length': Buffer.byteLength(body),
159
- 'Authorization': `Bearer ${DAEMON_API_KEY}`,
160
- 'x-workspace-id': WORKSPACE_ID
161
- }
162
- },
163
- (res: any) => {
164
- res.resume()
165
- resolve()
166
- }
167
- )
168
- req.on('error', () => resolve())
169
- req.setTimeout(5000, () => { req.destroy(); resolve() })
170
- req.write(body)
171
- req.end()
172
- })
173
- }
174
-
175
- export function registerServer(server: McpServerConfig): void {
176
- servers.push(server)
177
- }
178
-
179
- export function startHealthMonitor(): void {
180
- // Run config discovery
181
- const discovered = discoverServers()
182
- for (const s of discovered) {
183
- registerServer(s)
184
- }
185
-
186
- timer = setInterval(async () => {
187
- const snapshots: McpServerHealth[] = []
188
- for (const server of servers) {
189
- const health = await probeServer(server)
190
- const prev = latestHealth.get(server.name)
191
- latestHealth.set(server.name, health)
192
- snapshots.push(health)
193
- if (health.status === 'unreachable' && prev?.status !== 'unreachable') {
194
- logger.warn({ serverName: server.name }, 'mcp_daemon.mcp_server_down')
195
- }
196
- }
197
- if (snapshots.length > 0) {
198
- await uploadSnapshots(snapshots)
199
- }
200
- }, HEARTBEAT_MS)
201
- timer.unref()
202
- }
203
-
204
- export function stopHealthMonitor(): void {
205
- if (timer) { clearInterval(timer); timer = null }
206
- }
207
-
208
- export function getHealthSnapshot(): McpServerHealth[] {
209
- return Array.from(latestHealth.values())
210
- }
@@ -1,76 +0,0 @@
1
- /**
2
- * intutic-mcp-daemon entry point
3
- *
4
- * Long-lived process listening on ~/.intutic/mcp-proxy.sock.
5
- * Manages policy cache, telemetry batching, and MCP server health monitoring.
6
- *
7
- * LLD #28: MCP Daemon Mode, WS-5MCP
8
- *
9
- * Usage:
10
- * node dist/daemon/index.js
11
- * (managed by LaunchAgent / systemd via `intutic daemon mcp start`)
12
- *
13
- * @module
14
- */
15
- import fs from 'node:fs'
16
- import path from 'node:path'
17
- import os from 'node:os'
18
- import { createLogger } from '@intutic/logger'
19
- import { startBatcher, stopBatcher } from './telemetryBatcher.js'
20
- import { startHealthMonitor, stopHealthMonitor } from './healthMonitor.js'
21
- import { createSocketServer, getSocketPath } from './socketServer.js'
22
-
23
- const logger = createLogger('intutic-mcp-daemon')
24
- const PID_FILE = path.join(os.homedir(), '.intutic', 'mcp-daemon.pid')
25
-
26
- function ensureDir(dir: string): void {
27
- fs.mkdirSync(dir, { recursive: true, mode: 0o700 })
28
- }
29
-
30
- async function main(): Promise<void> {
31
- const socketPath = getSocketPath()
32
- logger.info({ pid: process.pid, socketPath }, 'mcp_daemon.starting')
33
-
34
- // Ensure ~/.intutic directory exists with correct permissions
35
- ensureDir(path.dirname(socketPath))
36
-
37
- // Remove stale socket file
38
- try { fs.unlinkSync(socketPath) } catch {}
39
-
40
- // Write PID file
41
- fs.writeFileSync(PID_FILE, String(process.pid), { mode: 0o600 })
42
-
43
- // Start subsystems
44
- startBatcher()
45
- startHealthMonitor()
46
-
47
- const server = createSocketServer()
48
- server.listen(socketPath, () => {
49
- // Restrict socket to owner only
50
- try { fs.chmodSync(socketPath, 0o600) } catch {}
51
- logger.info({ socketPath, pid: process.pid }, 'mcp_daemon.running')
52
- process.stdout.write(JSON.stringify({ event: 'mcp_daemon.started', pid: process.pid }) + '\n')
53
- })
54
-
55
- server.on('error', (err) => {
56
- logger.error({ err }, 'mcp_daemon.socket_error')
57
- process.exit(1)
58
- })
59
-
60
- // Graceful shutdown
61
- const shutdown = async (signal: string): Promise<void> => {
62
- logger.info({ signal }, 'mcp_daemon.stopping')
63
- server.close()
64
- stopHealthMonitor()
65
- await stopBatcher()
66
- try { fs.unlinkSync(socketPath) } catch {}
67
- try { fs.unlinkSync(PID_FILE) } catch {}
68
- logger.info('mcp_daemon.stopped')
69
- process.exit(0)
70
- }
71
-
72
- process.on('SIGTERM', () => { void shutdown('SIGTERM') })
73
- process.on('SIGINT', () => { void shutdown('SIGINT') })
74
- }
75
-
76
- void main()
@@ -1,143 +0,0 @@
1
- /**
2
- * Policy Cache — LRU in-memory + Valkey write-through
3
- *
4
- * Resolves PCAS permissions + active SOP rules for MCP daemon.
5
- * Cache hit: < 0.1ms (LRU). Valkey hit: < 2ms. Miss: HTTP to control-plane.
6
- *
7
- * LLD #28: MCP Daemon Mode, WS-5MCP
8
- * @module
9
- */
10
- import https from 'node:https'
11
- import http from 'node:http'
12
- import { Redis } from 'ioredis'
13
- import { createLogger } from '@intutic/logger'
14
-
15
- const logger = createLogger('mcp-proxy.policyCache')
16
-
17
- const getPolicyTtlMs = () => parseInt(process.env['MCP_DAEMON_POLICY_TTL_MS'] ?? '300000', 10)
18
- const getMaxEntries = () => parseInt(process.env['MCP_DAEMON_MAX_CACHE_ENTRIES'] ?? '500', 10)
19
- const getCpUrl = () => process.env['CONTROL_PLANE_URL'] ?? 'http://localhost:3001'
20
- const getDaemonApiKey = () => process.env['INTUTIC_API_KEY'] ?? ''
21
-
22
- const VALKEY_URL = process.env['VALKEY_URL'] ?? process.env['REDIS_URL'] ?? 'redis://localhost:6379'
23
- const valkey = new Redis(VALKEY_URL, {
24
- lazyConnect: true,
25
- maxRetriesPerRequest: 3,
26
- })
27
-
28
- valkey.on('error', (err: any) => {
29
- logger.warn({ err: err.message }, 'policyCache Valkey connection error')
30
- })
31
-
32
- export interface ResolvedPolicy {
33
- workspaceId: string
34
- sopRules: Record<string, unknown>[]
35
- dlpPatterns: string[]
36
- interventionMode: string
37
- cachedAt: number
38
- }
39
-
40
- // Simple LRU map (insertion-order eviction)
41
- const lru = new Map<string, ResolvedPolicy>()
42
-
43
- function evictIfFull(): void {
44
- if (lru.size >= getMaxEntries()) {
45
- const firstKey = lru.keys().next().value as string
46
- lru.delete(firstKey)
47
- }
48
- }
49
-
50
- function isStale(entry: ResolvedPolicy): boolean {
51
- return Date.now() - entry.cachedAt > getPolicyTtlMs()
52
- }
53
-
54
- async function fetchFromControlPlane(workspaceId: string): Promise<ResolvedPolicy | null> {
55
- return new Promise((resolve) => {
56
- const path = `/api/v1/policy/resolve?workspaceId=${encodeURIComponent(workspaceId)}`
57
- const url = new URL(path, getCpUrl())
58
- const lib = url.protocol === 'https:' ? https : http
59
- const req = lib.request(
60
- { hostname: url.hostname, port: url.port, path: url.pathname + url.search, method: 'GET',
61
- headers: { 'Authorization': `Bearer ${getDaemonApiKey()}`, 'Accept': 'application/json' } },
62
- (res: any) => {
63
- let data = ''
64
- res.on('data', (c: string) => { data += c })
65
- res.on('end', () => {
66
- try {
67
- const parsed = JSON.parse(data) as Partial<ResolvedPolicy>
68
- resolve({ workspaceId, sopRules: parsed.sopRules ?? [], dlpPatterns: parsed.dlpPatterns ?? [],
69
- interventionMode: parsed.interventionMode ?? 'BYPASS', cachedAt: Date.now() })
70
- } catch { resolve(null) }
71
- })
72
- }
73
- )
74
- req.on('error', () => resolve(null))
75
- req.setTimeout(5000, () => { req.destroy(); resolve(null) })
76
- req.end()
77
- })
78
- }
79
-
80
- /**
81
- * Resolves policy for a workspace.
82
- * Order: LRU (fresh) → LRU (stale, triggers async refresh) → Valkey → HTTP fetch
83
- */
84
- export async function resolvePolicy(workspaceId: string): Promise<ResolvedPolicy | null> {
85
- const cached = lru.get(workspaceId)
86
-
87
- if (cached && !isStale(cached)) {
88
- // Touch for LRU recency
89
- lru.delete(workspaceId)
90
- lru.set(workspaceId, cached)
91
- return cached
92
- }
93
-
94
- if (cached && isStale(cached)) {
95
- // Return stale, trigger async refresh
96
- void fetchFromControlPlane(workspaceId).then(async (fresh) => {
97
- if (fresh) {
98
- lru.delete(workspaceId)
99
- evictIfFull()
100
- lru.set(workspaceId, fresh)
101
- try {
102
- await valkey.set(`mcp_daemon:policy:${workspaceId}`, JSON.stringify(fresh), 'PX', getPolicyTtlMs())
103
- } catch {}
104
- }
105
- })
106
- return cached
107
- }
108
-
109
- // Cache miss in LRU — check Valkey
110
- try {
111
- const valkeyCached = await valkey.get(`mcp_daemon:policy:${workspaceId}`)
112
- if (valkeyCached) {
113
- const parsed = JSON.parse(valkeyCached) as ResolvedPolicy
114
- evictIfFull()
115
- lru.set(workspaceId, parsed)
116
- return parsed
117
- }
118
- } catch {}
119
-
120
- // Cache miss in Valkey too — fetch synchronously
121
- logger.debug({ workspaceId }, 'policy_cache.miss')
122
- const fresh = await fetchFromControlPlane(workspaceId)
123
- if (fresh) {
124
- evictIfFull()
125
- lru.set(workspaceId, fresh)
126
- try {
127
- await valkey.set(`mcp_daemon:policy:${workspaceId}`, JSON.stringify(fresh), 'PX', getPolicyTtlMs())
128
- } catch {}
129
- }
130
- return fresh
131
- }
132
-
133
- /** Invalidates policy cache for a workspace (called on SOP update). */
134
- export function invalidatePolicy(workspaceId: string): void {
135
- lru.delete(workspaceId)
136
- valkey.del(`mcp_daemon:policy:${workspaceId}`, `mcp_daemon:sop_rules:${workspaceId}`).catch(() => {})
137
- logger.info({ workspaceId }, 'policy_cache.invalidated')
138
- }
139
-
140
- /** Returns cache statistics. */
141
- export function getCacheStats(): { entries: number; hitRate: number } {
142
- return { entries: lru.size, hitRate: 0 } // hit rate tracked by metrics in production
143
- }
@@ -1,111 +0,0 @@
1
- /**
2
- * JSON-RPC over Unix Domain Socket server
3
- *
4
- * Accepts newline-delimited JSON requests from MCP shims.
5
- * Methods: proxy.tool_call, proxy.health_check, policy.get, telemetry.flush
6
- *
7
- * LLD #28: MCP Daemon Mode, WS-5MCP
8
- * @module
9
- */
10
- import net from 'node:net'
11
- import path from 'node:path'
12
- import os from 'node:os'
13
- import { createLogger } from '@intutic/logger'
14
- import { resolvePolicy, getCacheStats, invalidatePolicy } from './policyCache.js'
15
- import { enqueueEvent } from './telemetryBatcher.js'
16
- import { getHealthSnapshot } from './healthMonitor.js'
17
- import type { HookEvent } from './telemetryBatcher.js'
18
-
19
- const logger = createLogger('mcp-proxy.socketServer')
20
-
21
- export function getSocketPath(): string {
22
- return process.env['MCP_DAEMON_SOCKET'] ??
23
- path.join(os.homedir(), '.intutic', 'mcp-proxy.sock')
24
- }
25
-
26
- interface JsonRpcRequest {
27
- jsonrpc: '2.0'
28
- id: string | number
29
- method: string
30
- params: Record<string, unknown>
31
- }
32
-
33
- function respond(socket: net.Socket, id: string | number, result: unknown): void {
34
- socket.write(JSON.stringify({ jsonrpc: '2.0', id, result }) + '\n')
35
- }
36
-
37
- function respondError(socket: net.Socket, id: string | number, code: number, message: string): void {
38
- socket.write(JSON.stringify({ jsonrpc: '2.0', id, error: { code, message } }) + '\n')
39
- }
40
-
41
- async function handleRequest(socket: net.Socket, req: JsonRpcRequest): Promise<void> {
42
- const { id, method, params } = req
43
-
44
- switch (method) {
45
- case 'proxy.health_check':
46
- respond(socket, id, { status: 'ok', version: process.env['npm_package_version'] ?? '0.0.0',
47
- cacheStats: getCacheStats(), mcpServers: getHealthSnapshot() })
48
- break
49
-
50
- case 'proxy.tool_call': {
51
- const workspaceId = params['workspaceId'] as string
52
- const policy = await resolvePolicy(workspaceId)
53
- // Simplified enforcement: if policy loaded, apply it
54
- const allowed = policy !== null
55
- respond(socket, id, { allowed, policy: policy ?? null })
56
- break
57
- }
58
-
59
- case 'policy.get': {
60
- const workspaceId = params['workspaceId'] as string
61
- const policy = await resolvePolicy(workspaceId)
62
- respond(socket, id, policy)
63
- break
64
- }
65
-
66
- case 'policy.invalidate': {
67
- const workspaceId = params['workspaceId'] as string
68
- invalidatePolicy(workspaceId)
69
- respond(socket, id, { invalidated: true })
70
- break
71
- }
72
-
73
- case 'telemetry.enqueue': {
74
- enqueueEvent(params as unknown as HookEvent)
75
- respond(socket, id, { queued: true })
76
- break
77
- }
78
-
79
- default:
80
- respondError(socket, id, -32601, `Method not found: ${method}`)
81
- }
82
- }
83
-
84
- export function createSocketServer(): net.Server {
85
- const server = net.createServer((socket) => {
86
- socket.setEncoding('utf8')
87
- let buffer = ''
88
-
89
- socket.on('data', (chunk: string) => {
90
- buffer += chunk
91
- const lines = buffer.split('\n')
92
- buffer = lines.pop() ?? ''
93
- for (const line of lines) {
94
- const trimmed = line.trim()
95
- if (!trimmed) continue
96
- try {
97
- const req = JSON.parse(trimmed) as JsonRpcRequest
98
- void handleRequest(socket, req).catch(err =>
99
- respondError(socket, req.id, -32603, String(err)))
100
- } catch {
101
- socket.write(JSON.stringify({ jsonrpc: '2.0', id: null,
102
- error: { code: -32700, message: 'Parse error' } }) + '\n')
103
- }
104
- }
105
- })
106
-
107
- socket.on('error', (err) => logger.warn({ err }, 'socket.client_error'))
108
- })
109
-
110
- return server
111
- }