@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/policy.ts DELETED
@@ -1,152 +0,0 @@
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 DELETED
@@ -1,327 +0,0 @@
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 DELETED
@@ -1,56 +0,0 @@
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
- }
package/tsconfig.json DELETED
@@ -1,10 +0,0 @@
1
- {
2
- "extends": "../../tsconfig.base.json",
3
- "compilerOptions": {
4
- "rootDir": "./src",
5
- "outDir": "./dist",
6
- "composite": true
7
- },
8
- "include": ["src/**/*"],
9
- "exclude": ["node_modules", "dist", "**/__tests__/**"]
10
- }