@intutic/mcp-governance-proxy 1.0.3
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/.turbo/turbo-build.log +1 -0
- package/.turbo/turbo-test.log +51 -0
- package/.turbo/turbo-typecheck.log +1 -0
- package/LICENSE +21 -0
- package/README.md +87 -0
- package/dist/config.d.ts +40 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +100 -0
- package/dist/config.js.map +1 -0
- package/dist/daemon/healthMonitor.d.ts +18 -0
- package/dist/daemon/healthMonitor.d.ts.map +1 -0
- package/dist/daemon/healthMonitor.js +185 -0
- package/dist/daemon/healthMonitor.js.map +1 -0
- package/dist/daemon/index.d.ts +2 -0
- package/dist/daemon/index.d.ts.map +1 -0
- package/dist/daemon/index.js +77 -0
- package/dist/daemon/index.js.map +1 -0
- package/dist/daemon/policyCache.d.ts +20 -0
- package/dist/daemon/policyCache.d.ts.map +1 -0
- package/dist/daemon/policyCache.js +124 -0
- package/dist/daemon/policyCache.js.map +1 -0
- package/dist/daemon/socketServer.d.ts +13 -0
- package/dist/daemon/socketServer.d.ts.map +1 -0
- package/dist/daemon/socketServer.js +90 -0
- package/dist/daemon/socketServer.js.map +1 -0
- package/dist/daemon/telemetryBatcher.d.ts +15 -0
- package/dist/daemon/telemetryBatcher.d.ts.map +1 -0
- package/dist/daemon/telemetryBatcher.js +143 -0
- package/dist/daemon/telemetryBatcher.js.map +1 -0
- package/dist/daemonClient.d.ts +5 -0
- package/dist/daemonClient.d.ts.map +1 -0
- package/dist/daemonClient.js +67 -0
- package/dist/daemonClient.js.map +1 -0
- package/dist/dlp.d.ts +29 -0
- package/dist/dlp.d.ts.map +1 -0
- package/dist/dlp.js +58 -0
- package/dist/dlp.js.map +1 -0
- package/dist/emitter.d.ts +34 -0
- package/dist/emitter.d.ts.map +1 -0
- package/dist/emitter.js +121 -0
- package/dist/emitter.js.map +1 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +50 -0
- package/dist/index.js.map +1 -0
- package/dist/interceptor.d.ts +38 -0
- package/dist/interceptor.d.ts.map +1 -0
- package/dist/interceptor.js +93 -0
- package/dist/interceptor.js.map +1 -0
- package/dist/policy.d.ts +41 -0
- package/dist/policy.d.ts.map +1 -0
- package/dist/policy.js +130 -0
- package/dist/policy.js.map +1 -0
- package/dist/proxy.d.ts +43 -0
- package/dist/proxy.d.ts.map +1 -0
- package/dist/proxy.js +238 -0
- package/dist/proxy.js.map +1 -0
- package/dist/stderrLog.d.ts +32 -0
- package/dist/stderrLog.d.ts.map +1 -0
- package/dist/stderrLog.js +45 -0
- package/dist/stderrLog.js.map +1 -0
- package/dummy-path.jsonl +2 -0
- package/package.json +32 -0
- package/src/__tests__/config.test.ts +82 -0
- package/src/__tests__/dlp.test.ts +103 -0
- package/src/__tests__/integration/daemon-shim.test.ts +125 -0
- package/src/__tests__/interceptor.test.ts +229 -0
- package/src/__tests__/unit/policyCache.test.ts +95 -0
- package/src/__tests__/unit/socketServer.test.ts +220 -0
- package/src/__tests__/unit/telemetryBatcher.test.ts +78 -0
- package/src/config.ts +145 -0
- package/src/daemon/healthMonitor.ts +210 -0
- package/src/daemon/index.ts +76 -0
- package/src/daemon/policyCache.ts +143 -0
- package/src/daemon/socketServer.ts +111 -0
- package/src/daemon/telemetryBatcher.ts +147 -0
- package/src/daemonClient.ts +71 -0
- package/src/dlp.ts +72 -0
- package/src/emitter.ts +144 -0
- package/src/index.ts +53 -0
- package/src/interceptor.ts +103 -0
- package/src/policy.ts +152 -0
- package/src/proxy.ts +327 -0
- package/src/stderrLog.ts +56 -0
- package/tsconfig.json +10 -0
- package/tsconfig.tsbuildinfo +1 -0
- package/vitest.config.ts +9 -0
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
|
|
2
|
+
import * as http from 'node:http'
|
|
3
|
+
import * as net from 'node:net'
|
|
4
|
+
import * as os from 'node:os'
|
|
5
|
+
import * as path from 'node:path'
|
|
6
|
+
import * as fs from 'node:fs/promises'
|
|
7
|
+
|
|
8
|
+
describe('telemetryBatcher Unit Tests', () => {
|
|
9
|
+
let mockServer: http.Server
|
|
10
|
+
let port: number
|
|
11
|
+
let receivedPayloads: any[] = []
|
|
12
|
+
|
|
13
|
+
let enqueueEvent: any
|
|
14
|
+
let startBatcher: any
|
|
15
|
+
let stopBatcher: any
|
|
16
|
+
|
|
17
|
+
beforeAll(async () => {
|
|
18
|
+
// Clear stale disk buffer file to ensure hermetic tests
|
|
19
|
+
const bufPath = path.join(os.homedir(), '.intutic', 'telemetry-buffer.ndjson')
|
|
20
|
+
await fs.rm(bufPath, { force: true })
|
|
21
|
+
|
|
22
|
+
mockServer = http.createServer((req, res) => {
|
|
23
|
+
let body = ''
|
|
24
|
+
req.on('data', (c) => {
|
|
25
|
+
body += c.toString()
|
|
26
|
+
})
|
|
27
|
+
req.on('end', () => {
|
|
28
|
+
try {
|
|
29
|
+
receivedPayloads.push(JSON.parse(body))
|
|
30
|
+
} catch {}
|
|
31
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
32
|
+
res.end(JSON.stringify({ ok: true }))
|
|
33
|
+
})
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
await new Promise<void>((resolve) => {
|
|
37
|
+
mockServer.listen(0, '127.0.0.1', () => {
|
|
38
|
+
const addr = mockServer.address() as net.AddressInfo | null
|
|
39
|
+
port = addr?.port ?? 3001
|
|
40
|
+
process.env['CONTROL_PLANE_URL'] = `http://127.0.0.1:${port}`
|
|
41
|
+
process.env['MCP_DAEMON_TELEMETRY_FLUSH_MS'] = '100' // Flush fast for test
|
|
42
|
+
resolve()
|
|
43
|
+
})
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
// Import after process.env is set to ensure it uses the mock URL
|
|
47
|
+
const mod = await import('../../daemon/telemetryBatcher.js')
|
|
48
|
+
enqueueEvent = mod.enqueueEvent
|
|
49
|
+
startBatcher = mod.startBatcher
|
|
50
|
+
stopBatcher = mod.stopBatcher
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
afterAll(async () => {
|
|
54
|
+
await stopBatcher()
|
|
55
|
+
await new Promise<void>((resolve) => mockServer.close(() => resolve()))
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('batches and uploads enqueued telemetry events', async () => {
|
|
59
|
+
startBatcher()
|
|
60
|
+
|
|
61
|
+
const event = {
|
|
62
|
+
event: 'tool_blocked',
|
|
63
|
+
toolName: 'Bash',
|
|
64
|
+
workspaceId: 'ws_test_telemetry',
|
|
65
|
+
harnessType: 'mcp-governance-proxy',
|
|
66
|
+
timestamp: new Date().toISOString(),
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
enqueueEvent(event)
|
|
70
|
+
|
|
71
|
+
// Wait for the 100ms flush timer to trigger
|
|
72
|
+
await new Promise((resolve) => setTimeout(resolve, 250))
|
|
73
|
+
|
|
74
|
+
expect(receivedPayloads).toHaveLength(1)
|
|
75
|
+
expect(receivedPayloads[0].events).toHaveLength(1)
|
|
76
|
+
expect(receivedPayloads[0].events[0].toolName).toBe('Bash')
|
|
77
|
+
})
|
|
78
|
+
})
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
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()
|
|
@@ -0,0 +1,143 @@
|
|
|
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
|
+
}
|