@hafla/intelligence-mcp-bridge 1.0.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/CHANGELOG.md +16 -0
- package/LICENSE +21 -0
- package/README.md +139 -0
- package/package.json +57 -0
- package/src/index.js +533 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to `@hafla/intelligence-mcp-bridge` will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
|
+
|
|
7
|
+
## [1.0.0] — 2026-05-16
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- Initial public release. Extracted from the private monorepo's `hafla-intelligence/mcp-gateway/scripts/mcp-gateway-bridge.js`.
|
|
12
|
+
- stdio↔HTTPS bridge with 60-minute Google ID token minting via `gcloud auth print-identity-token`, in-memory cache with 5-min refresh-ahead window, and 401-triggered cache invalidation.
|
|
13
|
+
- Pre-flight checks: gcloud installed, an `@hafla.com` account is the active one.
|
|
14
|
+
- Runtime diagnostic banners for the four most common failure modes (gcloud not found, wrong-domain account, 401 audience mismatch, 403 employee_inactive).
|
|
15
|
+
- Cross-platform support — Windows is handled by a `GCLOUD_BIN` constant that picks `gcloud.cmd` over `gcloud` based on `process.platform`.
|
|
16
|
+
- Zero npm runtime dependencies — Node ≥20 stdlib only.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Hafla (Evinops Limited)
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# @hafla/intelligence-mcp-bridge
|
|
2
|
+
|
|
3
|
+
A small stdio↔HTTPS shim that lets Claude Code, Claude Desktop, Cursor, and Gemini CLI reach the **Hafla MCP Gateway** at `mcp.hafla.com`.
|
|
4
|
+
|
|
5
|
+
The bridge mints a fresh 60-minute Google ID token via your own `gcloud` session, caches it, refreshes it ~55 minutes before expiry, and forwards every JSON-RPC request to the gateway with a `Bearer` header. No shared secret, no per-user token to issue or rotate — authorisation is your Google Workspace identity.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Prerequisites
|
|
10
|
+
|
|
11
|
+
Ops must have done two one-time things for you:
|
|
12
|
+
|
|
13
|
+
1. Added you to the `team@hafla.com` Google Workspace group.
|
|
14
|
+
2. Flagged your account `isEmployeeActive=true` in `haflaCore.OpsUsers`.
|
|
15
|
+
|
|
16
|
+
If you have not received confirmation from Ops, the bridge will start, but the gateway will return `403 employee_inactive` on the first request.
|
|
17
|
+
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
## Install — 3 steps
|
|
21
|
+
|
|
22
|
+
### 1. Install the Google Cloud SDK (one-time, skip if you already have `gcloud`)
|
|
23
|
+
|
|
24
|
+
| OS | Command |
|
|
25
|
+
| ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
26
|
+
| **macOS** | `brew install --cask google-cloud-sdk` |
|
|
27
|
+
| **Windows** | `winget install Google.CloudSDK` _(or)_ `choco install gcloudsdk` _(or)_ download the installer at [cloud.google.com/sdk/docs/install#windows](https://cloud.google.com/sdk/docs/install#windows) |
|
|
28
|
+
| **Linux** | Follow [cloud.google.com/sdk/docs/install#linux](https://cloud.google.com/sdk/docs/install#linux) |
|
|
29
|
+
|
|
30
|
+
### 2. Sign in with your `@hafla.com` account
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
gcloud auth login # browser opens → log in with @hafla.com
|
|
34
|
+
gcloud config set account <you>@hafla.com # only needed if you have other gcloud accounts
|
|
35
|
+
gcloud auth list # confirm @hafla.com row shows "*"
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
The bridge's pre-flight rejects any non-`@hafla.com` active account before sending traffic, so the `gcloud config set account` step matters if you already use `gcloud` for personal projects.
|
|
39
|
+
|
|
40
|
+
### 3. Add this MCP server block to your client config
|
|
41
|
+
|
|
42
|
+
The JSON block is identical on every OS — only the file path differs.
|
|
43
|
+
|
|
44
|
+
| Client | Config file (macOS / Linux) | Config file (Windows) |
|
|
45
|
+
| --------------- | ------------------------------- | ------------------------------------------- |
|
|
46
|
+
| Claude Code | `~/.claude.json` | `%USERPROFILE%\.claude.json` |
|
|
47
|
+
| Claude Code (project-scoped) | `<project>/.mcp.json` | `<project>\.mcp.json` |
|
|
48
|
+
| Claude Desktop | `~/Library/Application Support/Claude/claude_desktop_config.json` | `%APPDATA%\Claude\claude_desktop_config.json` |
|
|
49
|
+
| Gemini CLI | `~/.gemini/settings.json` | `%USERPROFILE%\.gemini\settings.json` |
|
|
50
|
+
|
|
51
|
+
Add (or replace) the `hafla-evwa-idl-gateway` block under `mcpServers`:
|
|
52
|
+
|
|
53
|
+
```json
|
|
54
|
+
{
|
|
55
|
+
"mcpServers": {
|
|
56
|
+
"hafla-evwa-idl-gateway": {
|
|
57
|
+
"command": "npx",
|
|
58
|
+
"args": ["-y", "@hafla/intelligence-mcp-bridge@1.0.0"]
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Restart the MCP client. Done.
|
|
65
|
+
|
|
66
|
+
---
|
|
67
|
+
|
|
68
|
+
## What tools you get
|
|
69
|
+
|
|
70
|
+
Five read-only tools, all backed by Hafla's data lakes + identity layer (live at `mcp.hafla.com`):
|
|
71
|
+
|
|
72
|
+
| Tool | What it does |
|
|
73
|
+
| --------------------------- | ------------------------------------------------------------------ |
|
|
74
|
+
| `safe_sql_sandbox` | Parameterised read-only AlloyDB SQL across all lakes |
|
|
75
|
+
| `safe_cypher_sandbox` | Parameterised read-only Neo4j Cypher over the identity graph |
|
|
76
|
+
| `analyze_identity_graph` | Cross-lake identity resolution — one unified profile per person |
|
|
77
|
+
| `get_ticket_360` | Full Zendesk ticket with linked WhatsApp chats and Slack threads |
|
|
78
|
+
| `search_internal_knowledge` | Semantic search over the WhatsApp / Slack conversation corpus |
|
|
79
|
+
|
|
80
|
+
All five are read-only at the database layer — the bridge cannot write.
|
|
81
|
+
|
|
82
|
+
---
|
|
83
|
+
|
|
84
|
+
## Verify
|
|
85
|
+
|
|
86
|
+
Restart your client and ask it:
|
|
87
|
+
|
|
88
|
+
> Run `safe_sql_sandbox` with `SELECT COUNT(*) FROM "haflaCore"."OpsUsers"`.
|
|
89
|
+
|
|
90
|
+
A row count comes back, you're done. The very first request takes ~1–2 s longer while the bridge mints your first Google ID token; subsequent calls reuse the cached token.
|
|
91
|
+
|
|
92
|
+
---
|
|
93
|
+
|
|
94
|
+
## Troubleshooting
|
|
95
|
+
|
|
96
|
+
The bridge writes actionable diagnostic banners to stderr. Where you see "MCP client logs" below, that's:
|
|
97
|
+
|
|
98
|
+
- **Claude Code / Claude Desktop:** the client's own log file (varies by OS — check the client's documentation)
|
|
99
|
+
- **Gemini CLI:** the terminal where you launched the client
|
|
100
|
+
|
|
101
|
+
| Symptom / banner | Cause | Fix |
|
|
102
|
+
| --------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
103
|
+
| `gcloud CLI not found` | Google Cloud SDK isn't installed (or not on PATH) | Re-run the install command from Step 1. On Windows, may need a restart for PATH to take effect. |
|
|
104
|
+
| `Active gcloud account is X — must be an @hafla.com account` | You're logged into `gcloud` with a personal account | `gcloud config set account <you>@hafla.com` — then `gcloud auth list` to confirm the `*` is on your @hafla.com row. If your @hafla.com account is missing, `gcloud auth login` first. |
|
|
105
|
+
| `gateway returned 401 — token audience likely mismatched` | Cloud Run edge rejected the token | Most common cause: you're not yet in the `team@hafla.com` Google Group — ping Ops. Less common: the gateway deploy is missing `https://mcp.hafla.com` in `customAudiences` — that's an Ops fix. Sometimes a cached token pre-dates your group add — re-run `gcloud auth login` to mint a fresh one. |
|
|
106
|
+
| `gateway returned 403 employee_inactive` | You're in the group but not active in OpsUsers | Ping Ops to set `isEmployeeActive=true` on your `haflaCore.OpsUsers` row. |
|
|
107
|
+
| `Failed to mint Google ID token` | `gcloud` could not produce an identity token | Usual causes (the banner lists them): (1) credentials expired → `gcloud auth login`; (2) wrong active project → `gcloud config get-value project`; (3) older gcloud SDK → `gcloud components update`. |
|
|
108
|
+
| Silent failure / no response in the client | MCP client cannot spawn `npx` or `node` | Verify `node` is on PATH (`node --version`) and is **20.0 or newer**. To get more diagnostics, add `"env": { "DEBUG": "1" }` to the server block and tail the MCP client's log. |
|
|
109
|
+
| **(Windows)** `gcloud CLI not found` even though gcloud is installed | gcloud's `bin/` isn't on PATH (the installer doesn't always add it) | In PowerShell: `where.exe gcloud`. If empty, add `%LOCALAPPDATA%\Google\Cloud SDK\google-cloud-sdk\bin\` to PATH via **System → Environment Variables**, then restart the MCP client. |
|
|
110
|
+
| **(Windows)** MCP client fails to spawn the bridge — no stderr at all | `npx.cmd` not on PATH, or Node ≥20 not available | In PowerShell: `where.exe npx` and `node --version`. Under nvm-windows, run `nvm use <version>` first; the bridge requires Node ≥20. |
|
|
111
|
+
|
|
112
|
+
---
|
|
113
|
+
|
|
114
|
+
## Upgrading
|
|
115
|
+
|
|
116
|
+
Because `npx -y @hafla/intelligence-mcp-bridge@1.0.0` is **pinned to an exact version**, npm caches that specifier under `~/.npm/_npx/<hash>/` and keeps re-using the cached copy across restarts. New versions ship via three steps:
|
|
117
|
+
|
|
118
|
+
1. Edit the `args` in your `.mcp.json` / `settings.json` to the new version, e.g. `"@hafla/intelligence-mcp-bridge@1.0.1"`.
|
|
119
|
+
2. Restart the MCP client.
|
|
120
|
+
3. `npx` pulls the new tarball on first invocation; the previous cache entry stays put under the old hash.
|
|
121
|
+
|
|
122
|
+
Optional: `npx clear-npx-cache` or remove `~/.npm/_npx/<hash>/` if you want to force a re-download for the same version.
|
|
123
|
+
|
|
124
|
+
**Do not switch to `@latest`** — pinning is the supply-chain hygiene boundary. Ops announces every version bump in Slack so the team can update on their own cadence.
|
|
125
|
+
|
|
126
|
+
---
|
|
127
|
+
|
|
128
|
+
## What this bridge does NOT do
|
|
129
|
+
|
|
130
|
+
- Store credentials of any kind. It runs as your user, uses your gcloud session.
|
|
131
|
+
- Open inbound ports. It's stdio↔HTTPS, the client launches it on demand.
|
|
132
|
+
- Write to anything. Authorisation at the gateway is read-only; SQL/Cypher writes are rejected at the database layer.
|
|
133
|
+
- Contact a server other than `https://mcp.hafla.com` (unless you override `GATEWAY_URL` for local dev).
|
|
134
|
+
|
|
135
|
+
---
|
|
136
|
+
|
|
137
|
+
## License
|
|
138
|
+
|
|
139
|
+
MIT — see [LICENSE](./LICENSE).
|
package/package.json
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@hafla/intelligence-mcp-bridge",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "stdio↔HTTPS bridge to the Hafla MCP Gateway at mcp.hafla.com, with gcloud-minted Google ID token auth.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"intelligence-mcp-bridge": "src/index.js"
|
|
8
|
+
},
|
|
9
|
+
"main": "src/index.js",
|
|
10
|
+
"exports": {
|
|
11
|
+
".": "./src/index.js"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"src/",
|
|
15
|
+
"README.md",
|
|
16
|
+
"LICENSE",
|
|
17
|
+
"CHANGELOG.md"
|
|
18
|
+
],
|
|
19
|
+
"scripts": {
|
|
20
|
+
"test": "node --test tests/index.test.js"
|
|
21
|
+
},
|
|
22
|
+
"repository": {
|
|
23
|
+
"type": "git",
|
|
24
|
+
"url": "git+https://github.com/evinops-hafla/hafla-intelligence-gateway.git",
|
|
25
|
+
"directory": "packages/intelligence-mcp-bridge"
|
|
26
|
+
},
|
|
27
|
+
"homepage": "https://github.com/evinops-hafla/hafla-intelligence-gateway/tree/main/packages/intelligence-mcp-bridge#readme",
|
|
28
|
+
"bugs": {
|
|
29
|
+
"url": "https://github.com/evinops-hafla/hafla-intelligence-gateway/issues"
|
|
30
|
+
},
|
|
31
|
+
"keywords": [
|
|
32
|
+
"mcp",
|
|
33
|
+
"model-context-protocol",
|
|
34
|
+
"claude",
|
|
35
|
+
"claude-code",
|
|
36
|
+
"gemini-cli",
|
|
37
|
+
"stdio",
|
|
38
|
+
"bridge",
|
|
39
|
+
"cloud-run",
|
|
40
|
+
"google-oidc",
|
|
41
|
+
"hafla"
|
|
42
|
+
],
|
|
43
|
+
"author": "Hafla (Evinops Limited)",
|
|
44
|
+
"license": "MIT",
|
|
45
|
+
"engines": {
|
|
46
|
+
"node": ">=20"
|
|
47
|
+
},
|
|
48
|
+
"os": [
|
|
49
|
+
"darwin",
|
|
50
|
+
"linux",
|
|
51
|
+
"win32"
|
|
52
|
+
],
|
|
53
|
+
"sideEffects": false,
|
|
54
|
+
"publishConfig": {
|
|
55
|
+
"access": "public"
|
|
56
|
+
}
|
|
57
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,533 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* MCP stdio bridge for the Hafla Intelligence Gateway at mcp.hafla.com.
|
|
4
|
+
*
|
|
5
|
+
* stdio↔HTTPS forwarder with Google ID token minting + caching + diagnostics,
|
|
6
|
+
* used by Claude Code / Claude Desktop / Cursor / Gemini CLI to reach the
|
|
7
|
+
* Cloud Run IAM-gated production service.
|
|
8
|
+
*
|
|
9
|
+
* Why this exists:
|
|
10
|
+
* MCP HTTP clients take only static headers in their config. Cloud Run IAM
|
|
11
|
+
* requires a fresh 60-min Google ID token. The bridge runs as a long-lived
|
|
12
|
+
* stdio child process, mints + caches the token via gcloud, refreshes ~55 min
|
|
13
|
+
* before expiry, and forwards JSON-RPC over HTTPS with a fresh Bearer
|
|
14
|
+
* header on every request.
|
|
15
|
+
*
|
|
16
|
+
* Pre-flight diagnostics (run once at startup):
|
|
17
|
+
* (a) gcloud CLI installed + at least one ACTIVE account
|
|
18
|
+
* (b) active account is on the required Workspace domain (default: hafla.com)
|
|
19
|
+
* Runtime diagnostics (on 401 / 403 from gateway):
|
|
20
|
+
* (c) 401 = likely audience mismatch — points to --add-custom-audiences
|
|
21
|
+
* (d) 403 employee_inactive = OpsUsers.isEmployeeActive=false — contact ops
|
|
22
|
+
*
|
|
23
|
+
* Environment:
|
|
24
|
+
* - GATEWAY_URL: gateway base URL (default https://mcp.hafla.com)
|
|
25
|
+
* - GATEWAY_PATH: MCP endpoint path (default /mcp)
|
|
26
|
+
* - GATEWAY_AUDIENCE: JWT aud claim (default GATEWAY_URL)
|
|
27
|
+
* - REQUIRED_DOMAIN: required Workspace domain for active gcloud account (default hafla.com)
|
|
28
|
+
* - REQUEST_TIMEOUT_MS: HTTP request timeout in ms (default 30000)
|
|
29
|
+
* - TOKEN_REFRESH_BEFORE_MS: refresh-ahead window in ms (default 300000 = 5 min)
|
|
30
|
+
* - DEBUG: set to "1" for verbose logging
|
|
31
|
+
*
|
|
32
|
+
* The script exits non-zero with an actionable diagnostic banner on any
|
|
33
|
+
* pre-flight failure or on a hard runtime error. The diagnostic banner
|
|
34
|
+
* is printed to stderr (stdout is reserved for MCP JSON-RPC traffic).
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
import { request as httpsRequest } from 'node:https';
|
|
38
|
+
import { execFile } from 'node:child_process';
|
|
39
|
+
import { promisify } from 'node:util';
|
|
40
|
+
import { pipeline } from 'node:stream/promises';
|
|
41
|
+
import { Transform } from 'node:stream';
|
|
42
|
+
|
|
43
|
+
const execFileAsync = promisify(execFile);
|
|
44
|
+
|
|
45
|
+
// Windows ships gcloud as gcloud.cmd; Node's execFile doesn't apply PATHEXT.
|
|
46
|
+
const GCLOUD_BIN = process.platform === 'win32' ? 'gcloud.cmd' : 'gcloud';
|
|
47
|
+
|
|
48
|
+
// ── Configuration ────────────────────────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
const rawGatewayUrl = process.env.GATEWAY_URL || 'https://mcp.hafla.com';
|
|
51
|
+
|
|
52
|
+
// HTTPS enforcement — the bridge mints Google ID tokens; sending them over
|
|
53
|
+
// plaintext HTTP would expose them on the wire. Allow http:// only for local
|
|
54
|
+
// dev hostnames (localhost, 127.0.0.1) where there's no network exposure.
|
|
55
|
+
{
|
|
56
|
+
const u = (() => {
|
|
57
|
+
try {
|
|
58
|
+
return new URL(rawGatewayUrl);
|
|
59
|
+
} catch {
|
|
60
|
+
process.stderr.write(
|
|
61
|
+
`\n┌── intelligence-mcp-bridge: invalid GATEWAY_URL\n│ Could not parse: ${rawGatewayUrl}\n└──\n\n`
|
|
62
|
+
);
|
|
63
|
+
process.exit(1);
|
|
64
|
+
}
|
|
65
|
+
})();
|
|
66
|
+
const isLocalDev =
|
|
67
|
+
u.hostname === 'localhost' ||
|
|
68
|
+
u.hostname === '127.0.0.1' ||
|
|
69
|
+
u.hostname === '0.0.0.0';
|
|
70
|
+
if (u.protocol !== 'https:' && !isLocalDev) {
|
|
71
|
+
process.stderr.write(
|
|
72
|
+
`\n┌── intelligence-mcp-bridge: refusing plaintext HTTP for non-local GATEWAY_URL\n│ Got: ${rawGatewayUrl}\n│ Fix: set GATEWAY_URL to https:// (or localhost for dev)\n└──\n\n`
|
|
73
|
+
);
|
|
74
|
+
process.exit(1);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const config = {
|
|
79
|
+
gatewayUrl: rawGatewayUrl,
|
|
80
|
+
gatewayPath: process.env.GATEWAY_PATH || '/mcp',
|
|
81
|
+
audience:
|
|
82
|
+
process.env.GATEWAY_AUDIENCE ||
|
|
83
|
+
process.env.GATEWAY_URL ||
|
|
84
|
+
'https://mcp.hafla.com',
|
|
85
|
+
requiredDomain: process.env.REQUIRED_DOMAIN || 'hafla.com',
|
|
86
|
+
requestTimeoutMs: Number.parseInt(
|
|
87
|
+
process.env.REQUEST_TIMEOUT_MS || '30000',
|
|
88
|
+
10
|
|
89
|
+
),
|
|
90
|
+
tokenRefreshBeforeMs: Number.parseInt(
|
|
91
|
+
process.env.TOKEN_REFRESH_BEFORE_MS || '300000', // 5 min
|
|
92
|
+
10
|
|
93
|
+
),
|
|
94
|
+
// Google ID tokens have a fixed 60-min lifetime.
|
|
95
|
+
tokenLifetimeMs: 60 * 60_000,
|
|
96
|
+
debug: process.env.DEBUG === '1'
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
// ── Logging (stderr only — stdout is the MCP channel) ───────────────────────
|
|
100
|
+
|
|
101
|
+
const log = {
|
|
102
|
+
info: (msg, data) =>
|
|
103
|
+
process.stderr.write(
|
|
104
|
+
JSON.stringify({ level: 'info', msg, ...data }) + '\n'
|
|
105
|
+
),
|
|
106
|
+
warn: (msg, data) =>
|
|
107
|
+
process.stderr.write(
|
|
108
|
+
JSON.stringify({ level: 'warn', msg, ...data }) + '\n'
|
|
109
|
+
),
|
|
110
|
+
error: (msg, data) =>
|
|
111
|
+
process.stderr.write(
|
|
112
|
+
JSON.stringify({ level: 'error', msg, ...data }) + '\n'
|
|
113
|
+
),
|
|
114
|
+
debug: (msg, data) => {
|
|
115
|
+
if (config.debug) {
|
|
116
|
+
process.stderr.write(
|
|
117
|
+
JSON.stringify({ level: 'debug', msg, ...data }) + '\n'
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
// ── Diagnostic banner — actionable failure messages to stderr ───────────────
|
|
124
|
+
|
|
125
|
+
export function diagnosticBanner(title, ...lines) {
|
|
126
|
+
process.stderr.write(`\n┌── intelligence-mcp-bridge: ${title}\n`);
|
|
127
|
+
for (const l of lines) process.stderr.write(`│ ${l}\n`);
|
|
128
|
+
process.stderr.write(`└──\n\n`);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function fail(title, ...lines) {
|
|
132
|
+
diagnosticBanner(title, ...lines);
|
|
133
|
+
process.exit(1);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ── gcloud invocation — injectable for tests ────────────────────────────────
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Execute a gcloud command. Returns trimmed stdout.
|
|
140
|
+
* Throws {message, stderr, code} on failure.
|
|
141
|
+
*
|
|
142
|
+
* Exported for unit-test injection; tests replace it with a stub.
|
|
143
|
+
*/
|
|
144
|
+
export async function execGcloud(args, { execFn = execFileAsync } = {}) {
|
|
145
|
+
try {
|
|
146
|
+
const { stdout } = await execFn(GCLOUD_BIN, args, { timeout: 15_000 });
|
|
147
|
+
return stdout.toString().trim();
|
|
148
|
+
} catch (err) {
|
|
149
|
+
const stderr = err.stderr?.toString?.() ?? '';
|
|
150
|
+
const wrapped = new Error(err.message);
|
|
151
|
+
wrapped.stderr = stderr;
|
|
152
|
+
wrapped.code = err.code;
|
|
153
|
+
throw wrapped;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ── Pre-flight diagnostics ───────────────────────────────────────────────────
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Verify gcloud is installed, an account is active, and that account
|
|
161
|
+
* is on the required Workspace domain. Exits non-zero with a banner on
|
|
162
|
+
* any failure. Returns the active account email on success.
|
|
163
|
+
*/
|
|
164
|
+
export async function preFlight({ execGcloudFn = execGcloud } = {}) {
|
|
165
|
+
// (a) gcloud installed + an active account exists
|
|
166
|
+
let activeAccount;
|
|
167
|
+
try {
|
|
168
|
+
activeAccount = await execGcloudFn([
|
|
169
|
+
'auth',
|
|
170
|
+
'list',
|
|
171
|
+
'--filter=status:ACTIVE',
|
|
172
|
+
'--format=value(account)'
|
|
173
|
+
]);
|
|
174
|
+
} catch (err) {
|
|
175
|
+
if (err.code === 'ENOENT' || /not found|not installed/i.test(err.stderr)) {
|
|
176
|
+
fail(
|
|
177
|
+
'gcloud CLI not found.',
|
|
178
|
+
'Install Google Cloud SDK: https://cloud.google.com/sdk/docs/install',
|
|
179
|
+
'Then run: gcloud auth login'
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
fail(`gcloud auth check failed: ${err.message}`, 'Try: gcloud auth login');
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (!activeAccount) {
|
|
186
|
+
fail(
|
|
187
|
+
'No active gcloud account.',
|
|
188
|
+
'Run: gcloud auth login',
|
|
189
|
+
`Then ensure your @${config.requiredDomain} account is the active one.`
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// (b) active account must be on the required Workspace domain
|
|
194
|
+
if (!activeAccount.endsWith(`@${config.requiredDomain}`)) {
|
|
195
|
+
fail(
|
|
196
|
+
`Active gcloud account is "${activeAccount}" — must be an @${config.requiredDomain} account.`,
|
|
197
|
+
`Run: gcloud config set account <your-${config.requiredDomain}-email>`,
|
|
198
|
+
`(If you have not yet logged in with your ${config.requiredDomain} account:`,
|
|
199
|
+
` gcloud auth login)`
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
log.info('Pre-flight OK', {
|
|
204
|
+
account: activeAccount,
|
|
205
|
+
audience: config.audience,
|
|
206
|
+
domain: config.requiredDomain
|
|
207
|
+
});
|
|
208
|
+
return activeAccount;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// ── Token cache + minting ────────────────────────────────────────────────────
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Internal token cache state. Exposed via a factory so tests can reset
|
|
215
|
+
* between cases.
|
|
216
|
+
*/
|
|
217
|
+
export function createTokenCache({
|
|
218
|
+
execGcloudFn = execGcloud,
|
|
219
|
+
audience = config.audience,
|
|
220
|
+
lifetimeMs = config.tokenLifetimeMs,
|
|
221
|
+
refreshBeforeMs = config.tokenRefreshBeforeMs,
|
|
222
|
+
now = () => Date.now()
|
|
223
|
+
} = {}) {
|
|
224
|
+
let cachedToken = null;
|
|
225
|
+
let mintedAt = 0;
|
|
226
|
+
|
|
227
|
+
return {
|
|
228
|
+
/** Returns a fresh token, minting + caching as needed. */
|
|
229
|
+
async getToken() {
|
|
230
|
+
const t = now();
|
|
231
|
+
if (cachedToken && t - mintedAt < lifetimeMs - refreshBeforeMs) {
|
|
232
|
+
return cachedToken;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
let token;
|
|
236
|
+
try {
|
|
237
|
+
token = await execGcloudFn([
|
|
238
|
+
'auth',
|
|
239
|
+
'print-identity-token',
|
|
240
|
+
`--audiences=${audience}`
|
|
241
|
+
]);
|
|
242
|
+
} catch (err) {
|
|
243
|
+
log.error('Failed to mint identity token', {
|
|
244
|
+
error: err.message,
|
|
245
|
+
stderr: err.stderr
|
|
246
|
+
});
|
|
247
|
+
fail(
|
|
248
|
+
`Failed to mint Google ID token: ${err.message}`,
|
|
249
|
+
'Common causes:',
|
|
250
|
+
` 1. Expired credentials — run: gcloud auth login`,
|
|
251
|
+
` 2. Wrong active project — check: gcloud config get-value project`,
|
|
252
|
+
` 3. Missing --audiences support — older gcloud SDK; upgrade: gcloud components update`
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
cachedToken = token;
|
|
257
|
+
mintedAt = t;
|
|
258
|
+
log.info('Identity token minted', {
|
|
259
|
+
ttlMs: lifetimeMs,
|
|
260
|
+
nextRefreshInMs: lifetimeMs - refreshBeforeMs
|
|
261
|
+
});
|
|
262
|
+
return cachedToken;
|
|
263
|
+
},
|
|
264
|
+
|
|
265
|
+
/** Force-invalidate the cache (used after a 401 from the gateway). */
|
|
266
|
+
invalidate() {
|
|
267
|
+
cachedToken = null;
|
|
268
|
+
mintedAt = 0;
|
|
269
|
+
},
|
|
270
|
+
|
|
271
|
+
/** Test/diagnostic accessor. */
|
|
272
|
+
_state() {
|
|
273
|
+
return { cached: !!cachedToken, mintedAt };
|
|
274
|
+
}
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// ── HTTP forwarder ───────────────────────────────────────────────────────────
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Forward a single JSON-RPC message to the gateway. Returns the parsed
|
|
282
|
+
* JSON-RPC response object. Handles 401/403 with diagnostic banners.
|
|
283
|
+
*
|
|
284
|
+
* Exported with injectable cache + httpRequest for testing.
|
|
285
|
+
*/
|
|
286
|
+
export async function forwardRequest(
|
|
287
|
+
message,
|
|
288
|
+
{
|
|
289
|
+
tokenCache,
|
|
290
|
+
httpRequestFn = httpsRequest,
|
|
291
|
+
gatewayUrl = config.gatewayUrl,
|
|
292
|
+
gatewayPath = config.gatewayPath,
|
|
293
|
+
requestTimeoutMs = config.requestTimeoutMs
|
|
294
|
+
}
|
|
295
|
+
) {
|
|
296
|
+
if (!message || typeof message !== 'object') {
|
|
297
|
+
return {
|
|
298
|
+
jsonrpc: '2.0',
|
|
299
|
+
error: { code: -32600, message: 'Invalid Request' },
|
|
300
|
+
id: message?.id ?? null
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const token = await tokenCache.getToken();
|
|
305
|
+
const messageStr = JSON.stringify(message);
|
|
306
|
+
const url = new URL(gatewayPath, gatewayUrl);
|
|
307
|
+
|
|
308
|
+
return new Promise((resolve) => {
|
|
309
|
+
const timeoutId = setTimeout(() => {
|
|
310
|
+
req.destroy();
|
|
311
|
+
log.warn('Request timeout', {
|
|
312
|
+
method: message.method,
|
|
313
|
+
id: message.id,
|
|
314
|
+
timeoutMs: requestTimeoutMs
|
|
315
|
+
});
|
|
316
|
+
resolve({
|
|
317
|
+
jsonrpc: '2.0',
|
|
318
|
+
error: {
|
|
319
|
+
code: -32000,
|
|
320
|
+
message: `Request timeout after ${requestTimeoutMs}ms`
|
|
321
|
+
},
|
|
322
|
+
id: message.id ?? null
|
|
323
|
+
});
|
|
324
|
+
}, requestTimeoutMs);
|
|
325
|
+
|
|
326
|
+
const options = {
|
|
327
|
+
hostname: url.hostname,
|
|
328
|
+
port: url.port || (url.protocol === 'https:' ? 443 : 80),
|
|
329
|
+
path: url.pathname,
|
|
330
|
+
method: 'POST',
|
|
331
|
+
headers: {
|
|
332
|
+
'Content-Type': 'application/json',
|
|
333
|
+
'Content-Length': Buffer.byteLength(messageStr),
|
|
334
|
+
Authorization: `Bearer ${token}`,
|
|
335
|
+
Accept: 'application/json, text/event-stream',
|
|
336
|
+
'User-Agent': 'intelligence-mcp-bridge/1.0'
|
|
337
|
+
}
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
const req = httpRequestFn(options, (res) => {
|
|
341
|
+
let body = '';
|
|
342
|
+
res.on('data', (chunk) => {
|
|
343
|
+
body += chunk.toString();
|
|
344
|
+
});
|
|
345
|
+
res.on('end', () => {
|
|
346
|
+
clearTimeout(timeoutId);
|
|
347
|
+
|
|
348
|
+
// 401 — likely audience mismatch (Cloud Run rejected the token).
|
|
349
|
+
if (res.statusCode === 401) {
|
|
350
|
+
diagnosticBanner(
|
|
351
|
+
'gateway returned 401 — token audience likely mismatched',
|
|
352
|
+
`The gateway expects tokens whose "aud" claim matches a configured custom-audience.`,
|
|
353
|
+
`Confirm the service has https://mcp.hafla.com in customAudiences:`,
|
|
354
|
+
` gcloud run services describe mcp-gateway --format='value(spec.template.metadata.annotations,spec.customAudiences)'`,
|
|
355
|
+
`If absent, the operator must redeploy with:`,
|
|
356
|
+
` --add-custom-audiences=https://mcp.hafla.com`,
|
|
357
|
+
`Bridge will invalidate cached token and retry on the next request.`
|
|
358
|
+
);
|
|
359
|
+
tokenCache.invalidate();
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// 403 employee_inactive — OpsUsers.isEmployeeActive=false.
|
|
363
|
+
if (res.statusCode === 403 && /employee_inactive/.test(body)) {
|
|
364
|
+
diagnosticBanner(
|
|
365
|
+
'gateway returned 403 employee_inactive',
|
|
366
|
+
`Your account is not flagged as an active Hafla employee in OpsUsers.`,
|
|
367
|
+
`Contact ops to verify your isEmployeeActive flag in haflaCore.OpsUsers.`
|
|
368
|
+
);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
if (res.statusCode !== 200) {
|
|
372
|
+
log.warn('Gateway non-200', {
|
|
373
|
+
statusCode: res.statusCode,
|
|
374
|
+
id: message.id,
|
|
375
|
+
bodyPreview: body.slice(0, 200)
|
|
376
|
+
});
|
|
377
|
+
resolve({
|
|
378
|
+
jsonrpc: '2.0',
|
|
379
|
+
error: {
|
|
380
|
+
code: -32000,
|
|
381
|
+
message: `Gateway returned ${res.statusCode}`
|
|
382
|
+
},
|
|
383
|
+
id: message.id ?? null
|
|
384
|
+
});
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
try {
|
|
389
|
+
resolve(JSON.parse(body));
|
|
390
|
+
} catch (e) {
|
|
391
|
+
log.error('Failed to parse gateway response', {
|
|
392
|
+
parseError: e.message,
|
|
393
|
+
id: message.id,
|
|
394
|
+
bodyLength: body.length
|
|
395
|
+
});
|
|
396
|
+
resolve({
|
|
397
|
+
jsonrpc: '2.0',
|
|
398
|
+
error: { code: -32700, message: 'Parse error' },
|
|
399
|
+
id: message.id ?? null
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
});
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
req.on('error', (err) => {
|
|
406
|
+
clearTimeout(timeoutId);
|
|
407
|
+
log.error('Gateway request failed', {
|
|
408
|
+
error: err.message,
|
|
409
|
+
code: err.code,
|
|
410
|
+
id: message.id,
|
|
411
|
+
host: url.hostname
|
|
412
|
+
});
|
|
413
|
+
resolve({
|
|
414
|
+
jsonrpc: '2.0',
|
|
415
|
+
error: {
|
|
416
|
+
code: -32603,
|
|
417
|
+
message: `Connection failed: ${err.code || err.message}`
|
|
418
|
+
},
|
|
419
|
+
id: message.id ?? null
|
|
420
|
+
});
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
req.write(messageStr);
|
|
424
|
+
req.end();
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// ── Main pipeline (line-based stdio) ─────────────────────────────────────────
|
|
429
|
+
|
|
430
|
+
async function main() {
|
|
431
|
+
await preFlight();
|
|
432
|
+
const tokenCache = createTokenCache();
|
|
433
|
+
|
|
434
|
+
log.info('intelligence-mcp-bridge started', {
|
|
435
|
+
gatewayUrl: config.gatewayUrl,
|
|
436
|
+
gatewayPath: config.gatewayPath,
|
|
437
|
+
audience: config.audience,
|
|
438
|
+
requestTimeoutMs: config.requestTimeoutMs
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
const lineSplitter = new Transform({
|
|
442
|
+
transform(chunk, encoding, callback) {
|
|
443
|
+
const str = (this.lastLine || '') + chunk.toString();
|
|
444
|
+
this.lastLine = '';
|
|
445
|
+
const lines = str.split('\n');
|
|
446
|
+
this.lastLine = lines.pop();
|
|
447
|
+
for (const line of lines) {
|
|
448
|
+
if (line.trim()) this.push(line + '\n');
|
|
449
|
+
}
|
|
450
|
+
callback();
|
|
451
|
+
},
|
|
452
|
+
flush(callback) {
|
|
453
|
+
if (this.lastLine?.trim()) {
|
|
454
|
+
this.push(this.lastLine + '\n');
|
|
455
|
+
}
|
|
456
|
+
callback();
|
|
457
|
+
}
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
const lineTransform = new Transform({
|
|
461
|
+
transform(chunk, encoding, callback) {
|
|
462
|
+
const line = chunk.toString();
|
|
463
|
+
if (!line.trim()) {
|
|
464
|
+
callback();
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
let message;
|
|
468
|
+
try {
|
|
469
|
+
message = JSON.parse(line);
|
|
470
|
+
} catch (parseErr) {
|
|
471
|
+
log.error('Failed to parse stdin line', {
|
|
472
|
+
error: parseErr.message,
|
|
473
|
+
line: line.slice(0, 100)
|
|
474
|
+
});
|
|
475
|
+
const errorResponse = {
|
|
476
|
+
jsonrpc: '2.0',
|
|
477
|
+
error: { code: -32700, message: 'Parse error' },
|
|
478
|
+
id: null
|
|
479
|
+
};
|
|
480
|
+
callback(null, JSON.stringify(errorResponse) + '\n');
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
forwardRequest(message, { tokenCache })
|
|
485
|
+
.then((response) => {
|
|
486
|
+
callback(null, JSON.stringify(response) + '\n');
|
|
487
|
+
})
|
|
488
|
+
.catch((err) => {
|
|
489
|
+
log.error('Unexpected forwardRequest error', { error: err.message });
|
|
490
|
+
callback(
|
|
491
|
+
null,
|
|
492
|
+
JSON.stringify({
|
|
493
|
+
jsonrpc: '2.0',
|
|
494
|
+
error: { code: -32603, message: 'Internal error' },
|
|
495
|
+
id: message.id ?? null
|
|
496
|
+
}) + '\n'
|
|
497
|
+
);
|
|
498
|
+
});
|
|
499
|
+
}
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
try {
|
|
503
|
+
await pipeline(process.stdin, lineSplitter, lineTransform, process.stdout);
|
|
504
|
+
log.info('Pipeline closed normally');
|
|
505
|
+
process.exit(0);
|
|
506
|
+
} catch (err) {
|
|
507
|
+
log.error('Pipeline error', { error: err.message });
|
|
508
|
+
process.exit(1);
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// ── Graceful shutdown ────────────────────────────────────────────────────────
|
|
513
|
+
|
|
514
|
+
process.on('SIGTERM', () => {
|
|
515
|
+
log.info('SIGTERM received');
|
|
516
|
+
process.exit(0);
|
|
517
|
+
});
|
|
518
|
+
process.on('SIGINT', () => {
|
|
519
|
+
log.info('SIGINT received');
|
|
520
|
+
process.exit(0);
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
// ── Execution guard — skip when imported as a module by tests ───────────────
|
|
524
|
+
|
|
525
|
+
const isMainModule = import.meta.url === `file://${process.argv[1]}`;
|
|
526
|
+
if (isMainModule) {
|
|
527
|
+
try {
|
|
528
|
+
await main();
|
|
529
|
+
} catch (err) {
|
|
530
|
+
log.error('Uncaught error', { error: err.message, stack: err.stack });
|
|
531
|
+
process.exit(1);
|
|
532
|
+
}
|
|
533
|
+
}
|