@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,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* interceptor.ts — tools/call decision engine.
|
|
3
|
+
*
|
|
4
|
+
* Given a tool name and arguments, evaluates:
|
|
5
|
+
* 1. DLP scan (credential / destructive pattern detection)
|
|
6
|
+
* 2. SOP policy rules (fetched from control plane via PolicyClient)
|
|
7
|
+
*
|
|
8
|
+
* Returns an allow / block / redact decision.
|
|
9
|
+
*
|
|
10
|
+
* @module
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { createStderrLogger as createLogger } from './stderrLog.js'
|
|
14
|
+
import { scanToolInput, formatDlpBlockReason } from './dlp.js'
|
|
15
|
+
import type { PolicyClient } from './policy.js'
|
|
16
|
+
import type { GovernanceEmitter } from './emitter.js'
|
|
17
|
+
|
|
18
|
+
const log = createLogger('mcp-proxy-interceptor')
|
|
19
|
+
|
|
20
|
+
export type Decision =
|
|
21
|
+
| { action: 'allow' }
|
|
22
|
+
| { action: 'block'; reason: string }
|
|
23
|
+
| { action: 'redact'; reason: string; redactedInput: unknown }
|
|
24
|
+
|
|
25
|
+
export class ToolCallInterceptor {
|
|
26
|
+
constructor(
|
|
27
|
+
private readonly policy: PolicyClient,
|
|
28
|
+
private readonly emitter: GovernanceEmitter,
|
|
29
|
+
private readonly failOpen: boolean = true
|
|
30
|
+
) {}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Evaluate a tools/call request and return a governance decision.
|
|
34
|
+
*
|
|
35
|
+
* @param toolName - The MCP tool name (e.g. "mcp__filesystem__read_file" or "Bash")
|
|
36
|
+
* @param toolInput - The tool_input / arguments object
|
|
37
|
+
* @returns Decision: allow, block, or redact
|
|
38
|
+
*/
|
|
39
|
+
async decide(toolName: string, toolInput: unknown): Promise<Decision> {
|
|
40
|
+
log.debug({ action: 'interceptor_decide', toolName }, 'Evaluating tool call')
|
|
41
|
+
|
|
42
|
+
// 1. DLP scan
|
|
43
|
+
try {
|
|
44
|
+
const dlp = scanToolInput(toolInput)
|
|
45
|
+
if (dlp.hasFinding) {
|
|
46
|
+
const reason = formatDlpBlockReason(dlp.findings)
|
|
47
|
+
log.warn({ action: 'dlp_block', toolName, findings: dlp.findings }, 'DLP block')
|
|
48
|
+
this.emitter.emit('tool_blocked', toolName, toolInput, reason)
|
|
49
|
+
return { action: 'block', reason }
|
|
50
|
+
}
|
|
51
|
+
} catch (err) {
|
|
52
|
+
log.error({ action: 'dlp_error', err: (err as Error).message }, 'DLP scan error — skipping')
|
|
53
|
+
if (!this.failOpen) {
|
|
54
|
+
return {
|
|
55
|
+
action: 'block',
|
|
56
|
+
reason:
|
|
57
|
+
'Governance check failed — Intutic control plane unreachable. ' +
|
|
58
|
+
'Tool call blocked by workspace policy (fail-closed mode). ' +
|
|
59
|
+
'Contact your administrator or update mcpProxyFailBehavior to open.',
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// 2. SOP policy rule match
|
|
65
|
+
try {
|
|
66
|
+
const toolInputJson = JSON.stringify(toolInput ?? {})
|
|
67
|
+
const rule = this.policy.matchRule(toolName, toolInputJson)
|
|
68
|
+
if (rule) {
|
|
69
|
+
if (rule.action === 'block') {
|
|
70
|
+
log.warn({ action: 'policy_block', toolName, ruleId: rule.id, reason: rule.reason }, 'Policy block')
|
|
71
|
+
this.emitter.emit('tool_blocked', toolName, toolInput, rule.reason)
|
|
72
|
+
return { action: 'block', reason: rule.reason }
|
|
73
|
+
}
|
|
74
|
+
if (rule.action === 'warn') {
|
|
75
|
+
log.warn({ action: 'policy_warn', toolName, ruleId: rule.id, reason: rule.reason }, 'Policy warning (allowing)')
|
|
76
|
+
// Fall through to allow — warnings are logged only
|
|
77
|
+
}
|
|
78
|
+
// 'require_approval' treated as block in headless proxy (no interactive UI)
|
|
79
|
+
if (rule.action === 'require_approval') {
|
|
80
|
+
const reason = `Tool requires human approval per SOP rule ${rule.id}: ${rule.reason}`
|
|
81
|
+
log.warn({ action: 'policy_approval_required', toolName, ruleId: rule.id }, reason)
|
|
82
|
+
this.emitter.emit('tool_blocked', toolName, toolInput, reason)
|
|
83
|
+
return { action: 'block', reason }
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
} catch (err) {
|
|
87
|
+
log.error({ action: 'policy_error', err: (err as Error).message }, 'Policy evaluation error')
|
|
88
|
+
if (!this.failOpen) {
|
|
89
|
+
return {
|
|
90
|
+
action: 'block',
|
|
91
|
+
reason:
|
|
92
|
+
'Governance check failed — Intutic control plane unreachable. ' +
|
|
93
|
+
'Tool call blocked by workspace policy (fail-closed mode). ' +
|
|
94
|
+
'Contact your administrator or update mcpProxyFailBehavior to open.',
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// 3. Allow — emit telemetry event
|
|
100
|
+
this.emitter.emit('tool_allowed', toolName, toolInput)
|
|
101
|
+
return { action: 'allow' }
|
|
102
|
+
}
|
|
103
|
+
}
|
package/src/policy.ts
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* policy.ts — PolicyClient: fetches and caches SOP governance rules from the Intutic control plane.
|
|
3
|
+
*
|
|
4
|
+
* Rules are refreshed every policyTtlMs (default: 60s). On fetch failure, the
|
|
5
|
+
* last-known-good set is retained (fail-open behaviour).
|
|
6
|
+
*
|
|
7
|
+
* @module
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import * as node_https from 'node:https'
|
|
11
|
+
import * as node_http from 'node:http'
|
|
12
|
+
import { createStderrLogger as createLogger } from './stderrLog.js'
|
|
13
|
+
import { callDaemonSocket } from './daemonClient.js'
|
|
14
|
+
|
|
15
|
+
const log = createLogger('mcp-proxy-policy')
|
|
16
|
+
|
|
17
|
+
export interface SopRule {
|
|
18
|
+
id: string
|
|
19
|
+
/** Regex pattern matched against tool name (e.g. "mcp__github__.*" or "Bash") */
|
|
20
|
+
toolPattern: string
|
|
21
|
+
/** Optional regex matched against JSON.stringify(tool_input) */
|
|
22
|
+
argPattern?: string
|
|
23
|
+
/** Action to take when the rule matches */
|
|
24
|
+
action: 'block' | 'warn' | 'require_approval'
|
|
25
|
+
/** Human-readable reason reported back to harness */
|
|
26
|
+
reason: string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface SopRulesResponse {
|
|
30
|
+
rules: SopRule[]
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export class PolicyClient {
|
|
34
|
+
private rules: SopRule[] = []
|
|
35
|
+
private lastFetchAt = 0
|
|
36
|
+
private refreshTimer: NodeJS.Timeout | null = null
|
|
37
|
+
|
|
38
|
+
constructor(
|
|
39
|
+
private readonly controlPlaneUrl: string,
|
|
40
|
+
private readonly apiKey: string,
|
|
41
|
+
private readonly workspaceId: string,
|
|
42
|
+
private readonly ttlMs: number = 60_000,
|
|
43
|
+
private readonly mcpProxyMode: string = 'per-session'
|
|
44
|
+
) {}
|
|
45
|
+
|
|
46
|
+
/** Start background refresh timer. */
|
|
47
|
+
start(): void {
|
|
48
|
+
this.refreshTimer = setInterval(() => {
|
|
49
|
+
void this.refresh().catch(() => {
|
|
50
|
+
// Errors already logged inside refresh()
|
|
51
|
+
})
|
|
52
|
+
}, this.ttlMs)
|
|
53
|
+
// Don't block Node.js exit on this timer
|
|
54
|
+
this.refreshTimer.unref()
|
|
55
|
+
|
|
56
|
+
// Kick off initial fetch (non-blocking — proxy starts immediately)
|
|
57
|
+
void this.refresh().catch(() => {})
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Stop the background refresh timer. */
|
|
61
|
+
stop(): void {
|
|
62
|
+
if (this.refreshTimer) {
|
|
63
|
+
clearInterval(this.refreshTimer)
|
|
64
|
+
this.refreshTimer = null
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Return the current cached rule set. */
|
|
69
|
+
getRules(): readonly SopRule[] {
|
|
70
|
+
return this.rules
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Find the first matching rule for a given tool name + serialized args. */
|
|
74
|
+
matchRule(toolName: string, toolInputJson: string): SopRule | null {
|
|
75
|
+
for (const rule of this.rules) {
|
|
76
|
+
try {
|
|
77
|
+
const toolRegex = new RegExp(rule.toolPattern)
|
|
78
|
+
if (!toolRegex.test(toolName)) continue
|
|
79
|
+
if (rule.argPattern) {
|
|
80
|
+
const argRegex = new RegExp(rule.argPattern)
|
|
81
|
+
if (!argRegex.test(toolInputJson)) continue
|
|
82
|
+
}
|
|
83
|
+
return rule
|
|
84
|
+
} catch {
|
|
85
|
+
// Malformed regex in rule — skip silently
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return null
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Fetch fresh rules from the control plane. */
|
|
92
|
+
async refresh(): Promise<void> {
|
|
93
|
+
if (this.mcpProxyMode === 'daemon') {
|
|
94
|
+
try {
|
|
95
|
+
const policy = await callDaemonSocket('policy.get', { workspaceId: this.workspaceId })
|
|
96
|
+
if (policy) {
|
|
97
|
+
this.rules = (policy.sopRules || []) as SopRule[]
|
|
98
|
+
this.lastFetchAt = Date.now()
|
|
99
|
+
log.info({ action: 'policy_refreshed_from_daemon', ruleCount: this.rules.length }, 'SOP rules refreshed from daemon')
|
|
100
|
+
return
|
|
101
|
+
}
|
|
102
|
+
} catch (err: any) {
|
|
103
|
+
log.warn({ action: 'policy_daemon_failed', err: err.message }, 'Failed to refresh policy from daemon socket — falling back to HTTP')
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const url = `${this.controlPlaneUrl}/api/v1/sop/rules?workspaceId=${encodeURIComponent(this.workspaceId)}&active=true`
|
|
108
|
+
log.debug({ action: 'policy_refresh', url }, 'Fetching SOP rules from control plane')
|
|
109
|
+
|
|
110
|
+
const body = await httpGet(url, this.apiKey)
|
|
111
|
+
const parsed = JSON.parse(body) as SopRulesResponse
|
|
112
|
+
const rules = Array.isArray(parsed.rules) ? parsed.rules : []
|
|
113
|
+
this.rules = rules
|
|
114
|
+
this.lastFetchAt = Date.now()
|
|
115
|
+
log.info({ action: 'policy_refreshed', ruleCount: rules.length }, 'SOP rules refreshed')
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Minimal HTTP/HTTPS GET helper (avoids fetch / node-fetch dep). */
|
|
120
|
+
function httpGet(url: string, apiKey: string): Promise<string> {
|
|
121
|
+
return new Promise((resolve, reject) => {
|
|
122
|
+
const parsed = new URL(url)
|
|
123
|
+
const lib = parsed.protocol === 'https:' ? node_https : node_http
|
|
124
|
+
const req = lib.get(
|
|
125
|
+
url,
|
|
126
|
+
{
|
|
127
|
+
headers: {
|
|
128
|
+
Authorization: `Bearer ${apiKey}`,
|
|
129
|
+
Accept: 'application/json',
|
|
130
|
+
},
|
|
131
|
+
timeout: 5000,
|
|
132
|
+
},
|
|
133
|
+
(res) => {
|
|
134
|
+
const chunks: Buffer[] = []
|
|
135
|
+
res.on('data', (c: Buffer) => chunks.push(c))
|
|
136
|
+
res.on('end', () => {
|
|
137
|
+
const body = Buffer.concat(chunks).toString('utf-8')
|
|
138
|
+
if ((res.statusCode ?? 0) >= 400) {
|
|
139
|
+
reject(new Error(`HTTP ${res.statusCode ?? 'unknown'}: ${body}`))
|
|
140
|
+
} else {
|
|
141
|
+
resolve(body)
|
|
142
|
+
}
|
|
143
|
+
})
|
|
144
|
+
}
|
|
145
|
+
)
|
|
146
|
+
req.on('error', reject)
|
|
147
|
+
req.on('timeout', () => {
|
|
148
|
+
req.destroy()
|
|
149
|
+
reject(new Error('Policy fetch timed out'))
|
|
150
|
+
})
|
|
151
|
+
})
|
|
152
|
+
}
|
package/src/proxy.ts
ADDED
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* proxy.ts — McpGovernanceProxy: transparent stdio MCP proxy with governance interception.
|
|
3
|
+
*
|
|
4
|
+
* Architecture (proxy mode):
|
|
5
|
+
* Harness stdin → [McpGovernanceProxy] → real MCP server stdin
|
|
6
|
+
* Real MCP server stdout → [McpGovernanceProxy] → Harness stdout
|
|
7
|
+
*
|
|
8
|
+
* Architecture (standalone mode — the `intutic` harness entry):
|
|
9
|
+
* Harness ↔ [McpGovernanceProxy as MCP Server] ↔ Control Plane REST API
|
|
10
|
+
* Exposes governance tools: intutic_governance_status, intutic_list_sops, intutic_list_incidents.
|
|
11
|
+
*
|
|
12
|
+
* CRITICAL: Never write to process.stdout except for valid JSON-RPC frames.
|
|
13
|
+
* All logging MUST go to process.stderr via @intutic/logger.
|
|
14
|
+
*
|
|
15
|
+
* JSON-RPC framing: newline-delimited JSON (one JSON object per line).
|
|
16
|
+
*
|
|
17
|
+
* @module
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import * as node_child from 'node:child_process'
|
|
21
|
+
import * as node_readline from 'node:readline'
|
|
22
|
+
import { createStderrLogger as createLogger } from './stderrLog.js'
|
|
23
|
+
import type { ProxyConfig } from './config.js'
|
|
24
|
+
import { PolicyClient } from './policy.js'
|
|
25
|
+
import { GovernanceEmitter } from './emitter.js'
|
|
26
|
+
import { ToolCallInterceptor } from './interceptor.js'
|
|
27
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
|
28
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
|
|
29
|
+
import { z } from 'zod'
|
|
30
|
+
|
|
31
|
+
const log = createLogger('mcp-governance-proxy')
|
|
32
|
+
|
|
33
|
+
interface JsonRpcRequest {
|
|
34
|
+
jsonrpc: '2.0'
|
|
35
|
+
id: string | number | null
|
|
36
|
+
method: string
|
|
37
|
+
params?: unknown
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface JsonRpcResponse {
|
|
41
|
+
jsonrpc: '2.0'
|
|
42
|
+
id: string | number | null
|
|
43
|
+
result?: unknown
|
|
44
|
+
error?: { code: number; message: string; data?: unknown }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface McpToolsCallParams {
|
|
48
|
+
name: string
|
|
49
|
+
arguments?: Record<string, unknown>
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Build a JSON-RPC 2.0 error response for a blocked tool call.
|
|
54
|
+
*/
|
|
55
|
+
function buildBlockResponse(id: string | number | null, reason: string): JsonRpcResponse {
|
|
56
|
+
return {
|
|
57
|
+
jsonrpc: '2.0',
|
|
58
|
+
id,
|
|
59
|
+
error: {
|
|
60
|
+
code: -32603, // Internal error (closest standard code to "blocked")
|
|
61
|
+
message: `[Intutic Governance] Tool call blocked: ${reason}`,
|
|
62
|
+
},
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Write a JSON-RPC frame to stdout (the ONLY valid place to write in a stdio MCP proxy).
|
|
68
|
+
*/
|
|
69
|
+
function writeFrame(frame: unknown): void {
|
|
70
|
+
process.stdout.write(JSON.stringify(frame) + '\n')
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export class McpGovernanceProxy {
|
|
74
|
+
private readonly policy: PolicyClient
|
|
75
|
+
private readonly emitter: GovernanceEmitter
|
|
76
|
+
private readonly interceptor: ToolCallInterceptor
|
|
77
|
+
private realServer: node_child.ChildProcess | null = null
|
|
78
|
+
|
|
79
|
+
constructor(private readonly config: ProxyConfig) {
|
|
80
|
+
this.policy = new PolicyClient(
|
|
81
|
+
config.controlPlaneUrl,
|
|
82
|
+
config.apiKey,
|
|
83
|
+
config.workspaceId,
|
|
84
|
+
config.policyTtlMs,
|
|
85
|
+
config.mcpProxyMode
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
this.emitter = new GovernanceEmitter(
|
|
89
|
+
config.controlPlaneUrl,
|
|
90
|
+
config.apiKey,
|
|
91
|
+
config.eventsFilePath,
|
|
92
|
+
config.workspaceId,
|
|
93
|
+
config.mcpProxyMode
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
this.interceptor = new ToolCallInterceptor(this.policy, this.emitter, config.failOpen)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Start the proxy. Dispatches to standalone or proxy mode based on config.
|
|
101
|
+
*/
|
|
102
|
+
async run(): Promise<void> {
|
|
103
|
+
if (this.config.standalone) {
|
|
104
|
+
return this.runStandalone()
|
|
105
|
+
}
|
|
106
|
+
return this.runProxy()
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ─── Standalone MCP Server Mode ──────────────────────────────────────────────
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Run as a standalone MCP server (the `intutic` harness entry).
|
|
113
|
+
* Exposes Intutic governance tools directly to the harness.
|
|
114
|
+
* Gracefully degrades when the control plane is unreachable.
|
|
115
|
+
*/
|
|
116
|
+
private async runStandalone(): Promise<void> {
|
|
117
|
+
log.info(
|
|
118
|
+
{ action: 'standalone_start', workspaceId: this.config.workspaceId },
|
|
119
|
+
'Starting Intutic MCP governance server (standalone mode)'
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
const server = new McpServer({
|
|
123
|
+
name: 'intutic',
|
|
124
|
+
version: '0.1.0',
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
const cpUrl = this.config.controlPlaneUrl
|
|
128
|
+
const apiKey = this.config.apiKey
|
|
129
|
+
const workspaceId = this.config.workspaceId
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Helper: call control plane REST API.
|
|
133
|
+
* Returns null on any error (graceful degradation — never crashes the MCP server).
|
|
134
|
+
*/
|
|
135
|
+
async function callControlPlane(path: string): Promise<unknown> {
|
|
136
|
+
try {
|
|
137
|
+
const res = await fetch(`${cpUrl}${path}`, {
|
|
138
|
+
headers: {
|
|
139
|
+
'Authorization': `Bearer ${apiKey}`,
|
|
140
|
+
'x-workspace-id': workspaceId,
|
|
141
|
+
},
|
|
142
|
+
signal: AbortSignal.timeout(5000),
|
|
143
|
+
})
|
|
144
|
+
if (!res.ok) return null
|
|
145
|
+
return res.json()
|
|
146
|
+
} catch {
|
|
147
|
+
return null
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Tool: intutic_governance_status
|
|
152
|
+
server.tool(
|
|
153
|
+
'intutic_governance_status',
|
|
154
|
+
'Returns the current governance status and health of the Intutic control plane for this workspace.',
|
|
155
|
+
{},
|
|
156
|
+
async () => {
|
|
157
|
+
const health = await callControlPlane('/healthz') as Record<string, unknown> | null
|
|
158
|
+
const status = health
|
|
159
|
+
? { connected: true, controlPlane: cpUrl, workspaceId, ...health }
|
|
160
|
+
: { connected: false, controlPlane: cpUrl, workspaceId, error: 'Control plane unreachable' }
|
|
161
|
+
|
|
162
|
+
return {
|
|
163
|
+
content: [{ type: 'text' as const, text: JSON.stringify(status, null, 2) }],
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
// Tool: intutic_list_sops
|
|
169
|
+
server.tool(
|
|
170
|
+
'intutic_list_sops',
|
|
171
|
+
'Lists Standard Operating Procedures (SOPs) active in this workspace.',
|
|
172
|
+
{ limit: z.number().int().min(1).max(50).default(10).describe('Number of SOPs to return (1–50)') },
|
|
173
|
+
async ({ limit }) => {
|
|
174
|
+
const data = await callControlPlane(
|
|
175
|
+
`/api/v1/sops?workspaceId=${workspaceId}&limit=${limit}`
|
|
176
|
+
) as Record<string, unknown> | null
|
|
177
|
+
const text = data
|
|
178
|
+
? JSON.stringify(data, null, 2)
|
|
179
|
+
: 'Could not reach control plane to list SOPs.'
|
|
180
|
+
|
|
181
|
+
return {
|
|
182
|
+
content: [{ type: 'text' as const, text }],
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
// Tool: intutic_list_incidents
|
|
188
|
+
server.tool(
|
|
189
|
+
'intutic_list_incidents',
|
|
190
|
+
'Lists recent governance incidents (policy violations, blocked tool calls) in this workspace.',
|
|
191
|
+
{ limit: z.number().int().min(1).max(50).default(10).describe('Number of incidents to return (1–50)') },
|
|
192
|
+
async ({ limit }) => {
|
|
193
|
+
const data = await callControlPlane(
|
|
194
|
+
`/api/v1/incidents?workspaceId=${workspaceId}&limit=${limit}`
|
|
195
|
+
) as Record<string, unknown> | null
|
|
196
|
+
const text = data
|
|
197
|
+
? JSON.stringify(data, null, 2)
|
|
198
|
+
: 'Could not reach control plane to list incidents.'
|
|
199
|
+
|
|
200
|
+
return {
|
|
201
|
+
content: [{ type: 'text' as const, text }],
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
const transport = new StdioServerTransport()
|
|
207
|
+
await server.connect(transport)
|
|
208
|
+
log.info({ action: 'standalone_ready' }, 'Intutic MCP server ready')
|
|
209
|
+
|
|
210
|
+
// Keep alive until stdin closes (harness disconnects)
|
|
211
|
+
await new Promise<void>((resolve) => {
|
|
212
|
+
process.stdin.on('close', resolve)
|
|
213
|
+
process.on('SIGINT', resolve)
|
|
214
|
+
process.on('SIGTERM', resolve)
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
await server.close()
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// ─── Proxy Mode ───────────────────────────────────────────────────────────────
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Start the proxy. Spawns the real MCP server and begins proxying stdin/stdout.
|
|
224
|
+
* Returns a promise that resolves when the real server exits.
|
|
225
|
+
*/
|
|
226
|
+
private async runProxy(): Promise<void> {
|
|
227
|
+
const [cmd, ...args] = this.config.realServerCommand
|
|
228
|
+
|
|
229
|
+
log.info(
|
|
230
|
+
{ action: 'proxy_start', cmd, args, workspaceId: this.config.workspaceId },
|
|
231
|
+
'Starting MCP governance proxy'
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
// Start background policy refresh
|
|
235
|
+
this.policy.start()
|
|
236
|
+
|
|
237
|
+
// Spawn the real MCP server
|
|
238
|
+
const realServer = node_child.spawn(cmd!, args, {
|
|
239
|
+
stdio: ['pipe', 'pipe', 'inherit'], // inherit stderr so real server logs appear normally
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
this.realServer = realServer
|
|
243
|
+
|
|
244
|
+
// Handle graceful shutdown
|
|
245
|
+
const shutdown = (signal: string) => {
|
|
246
|
+
log.info({ action: 'proxy_shutdown', signal }, 'Shutting down MCP governance proxy')
|
|
247
|
+
this.policy.stop()
|
|
248
|
+
if (!realServer.killed) {
|
|
249
|
+
realServer.kill()
|
|
250
|
+
}
|
|
251
|
+
process.exit(0)
|
|
252
|
+
}
|
|
253
|
+
process.on('SIGINT', () => shutdown('SIGINT'))
|
|
254
|
+
process.on('SIGTERM', () => shutdown('SIGTERM'))
|
|
255
|
+
|
|
256
|
+
// Real server stdout → harness stdout (pass through)
|
|
257
|
+
realServer.stdout!.on('data', (chunk: Buffer) => {
|
|
258
|
+
process.stdout.write(chunk)
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
// Harness stdin → governance interceptor → real server stdin
|
|
262
|
+
const rl = node_readline.createInterface({ input: process.stdin, terminal: false })
|
|
263
|
+
|
|
264
|
+
rl.on('line', (line) => {
|
|
265
|
+
const trimmed = line.trim()
|
|
266
|
+
if (!trimmed) return
|
|
267
|
+
|
|
268
|
+
let msg: JsonRpcRequest
|
|
269
|
+
try {
|
|
270
|
+
msg = JSON.parse(trimmed) as JsonRpcRequest
|
|
271
|
+
} catch {
|
|
272
|
+
log.warn({ action: 'parse_error', line: trimmed.slice(0, 100) }, 'Failed to parse JSON-RPC line')
|
|
273
|
+
return
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Intercept tools/call
|
|
277
|
+
if (msg.method === 'tools/call') {
|
|
278
|
+
const params = msg.params as McpToolsCallParams | undefined
|
|
279
|
+
const toolName = params?.name ?? '<unknown>'
|
|
280
|
+
const toolInput = params?.arguments ?? {}
|
|
281
|
+
|
|
282
|
+
// Run governance check asynchronously
|
|
283
|
+
this.interceptor.decide(toolName, toolInput).then((decision) => {
|
|
284
|
+
if (decision.action === 'block') {
|
|
285
|
+
log.warn(
|
|
286
|
+
{ action: 'tool_blocked', toolName, reason: decision.reason },
|
|
287
|
+
'Tool call blocked by governance proxy'
|
|
288
|
+
)
|
|
289
|
+
writeFrame(buildBlockResponse(msg.id, decision.reason))
|
|
290
|
+
} else {
|
|
291
|
+
// Allow: forward to real server
|
|
292
|
+
realServer.stdin!.write(line + '\n')
|
|
293
|
+
}
|
|
294
|
+
}).catch((err) => {
|
|
295
|
+
// Governance check failed — fail-open: forward to real server
|
|
296
|
+
log.error({ action: 'interceptor_error', err: (err as Error).message }, 'Interceptor error — failing open')
|
|
297
|
+
realServer.stdin!.write(line + '\n')
|
|
298
|
+
})
|
|
299
|
+
} else {
|
|
300
|
+
// Non-tools/call messages pass through immediately
|
|
301
|
+
realServer.stdin!.write(line + '\n')
|
|
302
|
+
}
|
|
303
|
+
})
|
|
304
|
+
|
|
305
|
+
rl.on('close', () => {
|
|
306
|
+
log.info({ action: 'stdin_closed' }, 'Harness stdin closed — shutting down')
|
|
307
|
+
this.policy.stop()
|
|
308
|
+
if (!realServer.killed) {
|
|
309
|
+
realServer.kill()
|
|
310
|
+
}
|
|
311
|
+
})
|
|
312
|
+
|
|
313
|
+
// Wait for the real server to exit
|
|
314
|
+
return new Promise((resolve, reject) => {
|
|
315
|
+
realServer.on('exit', (code, signal) => {
|
|
316
|
+
log.info({ action: 'real_server_exit', code, signal }, 'Real MCP server exited')
|
|
317
|
+
this.policy.stop()
|
|
318
|
+
resolve()
|
|
319
|
+
})
|
|
320
|
+
realServer.on('error', (err) => {
|
|
321
|
+
log.error({ action: 'real_server_error', err: err.message }, 'Real MCP server process error')
|
|
322
|
+
this.policy.stop()
|
|
323
|
+
reject(err)
|
|
324
|
+
})
|
|
325
|
+
})
|
|
326
|
+
}
|
|
327
|
+
}
|
package/src/stderrLog.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* stderrLog.ts — Lightweight structured logger for the MCP governance proxy.
|
|
3
|
+
*
|
|
4
|
+
* CRITICAL: In an MCP stdio server, stdout is exclusively for JSON-RPC frames.
|
|
5
|
+
* This module bypasses pino (which defaults to stdout) and writes structured
|
|
6
|
+
* JSON logs directly to process.stderr (fd 2).
|
|
7
|
+
*
|
|
8
|
+
* Do NOT use @intutic/logger anywhere in packages/mcp-proxy — it routes to
|
|
9
|
+
* stdout unless PINO_DEST=stderr is set, which is fragile across harnesses.
|
|
10
|
+
*
|
|
11
|
+
* @module
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
type LogLevel = 'debug' | 'info' | 'warn' | 'error'
|
|
15
|
+
|
|
16
|
+
interface LogBindings {
|
|
17
|
+
[key: string]: unknown
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* A stderr-only structured logger that matches the @intutic/logger call surface.
|
|
22
|
+
*/
|
|
23
|
+
export class StderrLogger {
|
|
24
|
+
private readonly name: string
|
|
25
|
+
private readonly staticBindings: LogBindings
|
|
26
|
+
|
|
27
|
+
constructor(name: string, bindings: LogBindings = {}) {
|
|
28
|
+
this.name = name
|
|
29
|
+
this.staticBindings = bindings
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
private write(level: LogLevel, bindings: LogBindings, msg: string): void {
|
|
33
|
+
const entry = {
|
|
34
|
+
level,
|
|
35
|
+
time: new Date().toISOString(),
|
|
36
|
+
name: this.name,
|
|
37
|
+
...this.staticBindings,
|
|
38
|
+
...bindings,
|
|
39
|
+
msg,
|
|
40
|
+
}
|
|
41
|
+
process.stderr.write(JSON.stringify(entry) + '\n')
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
debug(bindings: LogBindings, msg: string): void { this.write('debug', bindings, msg) }
|
|
45
|
+
info(bindings: LogBindings, msg: string): void { this.write('info', bindings, msg) }
|
|
46
|
+
warn(bindings: LogBindings, msg: string): void { this.write('warn', bindings, msg) }
|
|
47
|
+
error(bindings: LogBindings, msg: string): void { this.write('error', bindings, msg) }
|
|
48
|
+
|
|
49
|
+
child(bindings: LogBindings): StderrLogger {
|
|
50
|
+
return new StderrLogger(this.name, { ...this.staticBindings, ...bindings })
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function createStderrLogger(name: string, bindings?: LogBindings): StderrLogger {
|
|
55
|
+
return new StderrLogger(name, bindings)
|
|
56
|
+
}
|