@centrali-io/centrali-mcp 4.5.2 → 4.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/README.md +69 -10
- package/dist/index.js +213 -9
- package/package.json +1 -1
- package/src/index.ts +228 -9
package/README.md
CHANGED
|
@@ -4,11 +4,58 @@ MCP (Model Context Protocol) server for the Centrali platform. Lets AI assistant
|
|
|
4
4
|
|
|
5
5
|
> **Full documentation:** [docs.centrali.io](https://docs.centrali.io) — SDK guide, API reference, compute functions, orchestrations, and more.
|
|
6
6
|
|
|
7
|
-
Built on `@centrali-io/centrali-sdk
|
|
7
|
+
Built on `@centrali-io/centrali-sdk`. Supports three auth methods: **OAuth client credentials** (recommended), **OAuth browser login** (Authorization Code + PKCE), and **service account** (RBAC).
|
|
8
8
|
|
|
9
9
|
## Setup
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
### Option 1: OAuth Client Credentials (recommended)
|
|
12
|
+
|
|
13
|
+
Create an OAuth app in the Console under **Settings > OAuth Apps**, select the scopes you need, then configure:
|
|
14
|
+
|
|
15
|
+
```json
|
|
16
|
+
{
|
|
17
|
+
"mcpServers": {
|
|
18
|
+
"centrali": {
|
|
19
|
+
"command": "npx",
|
|
20
|
+
"args": ["@centrali-io/centrali-mcp"],
|
|
21
|
+
"env": {
|
|
22
|
+
"CENTRALI_URL": "https://centrali.io",
|
|
23
|
+
"CENTRALI_OAUTH_CLIENT_ID": "<oauth-client-id>",
|
|
24
|
+
"CENTRALI_OAUTH_CLIENT_SECRET": "<oauth-client-secret>",
|
|
25
|
+
"CENTRALI_WORKSPACE": "my-workspace"
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
OAuth clients use **scoped access** — the MCP can only access what the client's scopes allow.
|
|
33
|
+
|
|
34
|
+
### Option 2: OAuth Browser Login (Authorization Code + PKCE)
|
|
35
|
+
|
|
36
|
+
For local development where you want the agent to act as **you** (not a bot). Create a **public** OAuth app (no secret needed), and the MCP will open your browser for login:
|
|
37
|
+
|
|
38
|
+
```json
|
|
39
|
+
{
|
|
40
|
+
"mcpServers": {
|
|
41
|
+
"centrali": {
|
|
42
|
+
"command": "npx",
|
|
43
|
+
"args": ["@centrali-io/centrali-mcp"],
|
|
44
|
+
"env": {
|
|
45
|
+
"CENTRALI_URL": "https://centrali.io",
|
|
46
|
+
"CENTRALI_OAUTH_CLIENT_ID": "<oauth-client-id>",
|
|
47
|
+
"CENTRALI_WORKSPACE": "my-workspace"
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
On first use, a browser window opens for login and consent. The token carries your user identity — audit logs show **you** performed the action, not a bot.
|
|
55
|
+
|
|
56
|
+
### Option 3: Service Account (RBAC)
|
|
57
|
+
|
|
58
|
+
Service accounts use **RBAC permissions** via groups and roles. You must assign the service account to a group before it can access anything.
|
|
12
59
|
|
|
13
60
|
```json
|
|
14
61
|
{
|
|
@@ -32,9 +79,25 @@ Add to your MCP client configuration (e.g., Claude Desktop, Cursor):
|
|
|
32
79
|
| Variable | Required | Description |
|
|
33
80
|
|----------|----------|-------------|
|
|
34
81
|
| `CENTRALI_URL` | Yes | Centrali instance URL (e.g., `https://centrali.io`) |
|
|
35
|
-
| `CENTRALI_CLIENT_ID` | Yes | Service account client ID |
|
|
36
|
-
| `CENTRALI_CLIENT_SECRET` | Yes | Service account client secret |
|
|
37
82
|
| `CENTRALI_WORKSPACE` | Yes | Workspace slug to operate in |
|
|
83
|
+
| `CENTRALI_OAUTH_CLIENT_ID` | OAuth | OAuth client ID (from Settings > OAuth Apps) |
|
|
84
|
+
| `CENTRALI_OAUTH_CLIENT_SECRET` | OAuth | OAuth client secret (omit for browser flow) |
|
|
85
|
+
| `CENTRALI_OAUTH_SCOPE` | No | Space-separated scopes (defaults to all allowed) |
|
|
86
|
+
| `CENTRALI_CLIENT_ID` | SA | Service account client ID |
|
|
87
|
+
| `CENTRALI_CLIENT_SECRET` | SA | Service account client secret |
|
|
88
|
+
|
|
89
|
+
Set either the `CENTRALI_OAUTH_*` pair or the `CENTRALI_CLIENT_*` pair, not both.
|
|
90
|
+
|
|
91
|
+
### OAuth Scopes
|
|
92
|
+
|
|
93
|
+
When using OAuth, select the scopes your MCP workflows need:
|
|
94
|
+
|
|
95
|
+
| Use case | Scopes |
|
|
96
|
+
|----------|--------|
|
|
97
|
+
| Read-only data access | `records:read records:list structures:list` |
|
|
98
|
+
| Full data management | `records:read records:write records:list structures:read structures:list` |
|
|
99
|
+
| Data + compute | Above + `compute:read compute:list compute:execute` |
|
|
100
|
+
| Everything | Select all scopes when creating the OAuth app |
|
|
38
101
|
|
|
39
102
|
### Service Account Permissions
|
|
40
103
|
|
|
@@ -42,16 +105,12 @@ A freshly created service account **has no permissions by default**. You must as
|
|
|
42
105
|
|
|
43
106
|
**Quickest setup — full admin access:**
|
|
44
107
|
|
|
45
|
-
1. In the Console, go to **Settings
|
|
108
|
+
1. In the Console, go to **Settings > Service Accounts**
|
|
46
109
|
2. Create your service account and save the client secret
|
|
47
110
|
3. Open the service account, go to the **Groups** tab
|
|
48
111
|
4. Add it to the **workspace_administrators** group
|
|
49
112
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
**Least-privilege setup — custom permissions:**
|
|
53
|
-
|
|
54
|
-
If you only want the MCP to manage specific resources (e.g., collections and records but not billing), create a custom group with a policy scoped to the resources you need, then add the service account to that group. See the [Policies and Permissions guide](https://docs.centrali.io/authentication/policies-and-permissions/) for details.
|
|
113
|
+
**Least-privilege setup:** Create a custom group with a policy scoped to the resources you need. See the [Policies and Permissions guide](https://docs.centrali.io/authentication/policies-and-permissions/) for details.
|
|
55
114
|
|
|
56
115
|
## Getting Started
|
|
57
116
|
|
package/dist/index.js
CHANGED
|
@@ -13,6 +13,9 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
13
13
|
const mcp_js_1 = require("@modelcontextprotocol/sdk/server/mcp.js");
|
|
14
14
|
const stdio_js_1 = require("@modelcontextprotocol/sdk/server/stdio.js");
|
|
15
15
|
const centrali_sdk_1 = require("@centrali-io/centrali-sdk");
|
|
16
|
+
const node_http_1 = require("node:http");
|
|
17
|
+
const node_crypto_1 = require("node:crypto");
|
|
18
|
+
const node_child_process_1 = require("node:child_process");
|
|
16
19
|
const structures_js_1 = require("./tools/structures.js");
|
|
17
20
|
const records_js_1 = require("./tools/records.js");
|
|
18
21
|
const search_js_1 = require("./tools/search.js");
|
|
@@ -34,18 +37,219 @@ function getRequiredEnv(name) {
|
|
|
34
37
|
}
|
|
35
38
|
return value;
|
|
36
39
|
}
|
|
40
|
+
/**
|
|
41
|
+
* Detect auth mode from environment variables.
|
|
42
|
+
*
|
|
43
|
+
* Browser mode: Set CENTRALI_OAUTH_CLIENT_ID (no secret)
|
|
44
|
+
* → Opens browser for Authorization Code + PKCE flow
|
|
45
|
+
*
|
|
46
|
+
* OAuth client_credentials mode: Set CENTRALI_OAUTH_CLIENT_ID + CENTRALI_OAUTH_CLIENT_SECRET
|
|
47
|
+
* → Uses POST /oauth/token with scoped access
|
|
48
|
+
*
|
|
49
|
+
* Service account mode: Set CENTRALI_CLIENT_ID + CENTRALI_CLIENT_SECRET
|
|
50
|
+
* → Uses OIDC client_credentials with RBAC (roles, groups, policies)
|
|
51
|
+
*/
|
|
52
|
+
function detectAuthMode() {
|
|
53
|
+
const oauthClientId = process.env.CENTRALI_OAUTH_CLIENT_ID;
|
|
54
|
+
const oauthClientSecret = process.env.CENTRALI_OAUTH_CLIENT_SECRET;
|
|
55
|
+
if (oauthClientId && !oauthClientSecret) {
|
|
56
|
+
return "browser";
|
|
57
|
+
}
|
|
58
|
+
if (oauthClientId && oauthClientSecret) {
|
|
59
|
+
return "oauth";
|
|
60
|
+
}
|
|
61
|
+
if (process.env.CENTRALI_CLIENT_ID && process.env.CENTRALI_CLIENT_SECRET) {
|
|
62
|
+
return "service-account";
|
|
63
|
+
}
|
|
64
|
+
console.error("Missing auth credentials. Set one of:\n" +
|
|
65
|
+
" CENTRALI_OAUTH_CLIENT_ID (browser OAuth — Authorization Code + PKCE)\n" +
|
|
66
|
+
" CENTRALI_OAUTH_CLIENT_ID + CENTRALI_OAUTH_CLIENT_SECRET (OAuth client_credentials)\n" +
|
|
67
|
+
" CENTRALI_CLIENT_ID + CENTRALI_CLIENT_SECRET (service account — RBAC permissions)");
|
|
68
|
+
process.exit(1);
|
|
69
|
+
}
|
|
70
|
+
/** Derive the IAM auth URL from the base URL. */
|
|
71
|
+
function deriveAuthUrl(baseUrl) {
|
|
72
|
+
// Direct localhost port (e.g., http://localhost:7060) — already pointing at IAM
|
|
73
|
+
if (/localhost:\d+/.test(baseUrl)) {
|
|
74
|
+
return baseUrl.replace(/\/data$/, "").replace(/\/iam$/, "");
|
|
75
|
+
}
|
|
76
|
+
// Traefik or production — prepend auth. subdomain
|
|
77
|
+
// centrali.localhost → auth.centrali.localhost
|
|
78
|
+
// centrali.io → auth.centrali.io
|
|
79
|
+
return baseUrl.replace(/^(https?:\/\/)/, "$1auth.");
|
|
80
|
+
}
|
|
81
|
+
/** Open a URL in the default browser (safe — no shell interpolation). */
|
|
82
|
+
function openBrowser(url) {
|
|
83
|
+
const cmd = process.platform === "darwin" ? "open"
|
|
84
|
+
: process.platform === "win32" ? "cmd" : "xdg-open";
|
|
85
|
+
const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
|
|
86
|
+
(0, node_child_process_1.execFile)(cmd, args, (err) => {
|
|
87
|
+
if (err)
|
|
88
|
+
console.error(`[centrali-mcp] Could not open browser: ${err.message}`);
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
/** Create a token fetcher for OAuth client_credentials flow. */
|
|
92
|
+
function createClientCredentialsFetcher(baseUrl, clientId, clientSecret, scope) {
|
|
93
|
+
const tokenEndpoint = `${deriveAuthUrl(baseUrl)}/oauth/token`;
|
|
94
|
+
let cachedToken = null;
|
|
95
|
+
let expiresAt = 0;
|
|
96
|
+
return () => __awaiter(this, void 0, void 0, function* () {
|
|
97
|
+
if (cachedToken && Date.now() < expiresAt - 60000)
|
|
98
|
+
return cachedToken;
|
|
99
|
+
const params = new URLSearchParams({ grant_type: "client_credentials", client_id: clientId, client_secret: clientSecret });
|
|
100
|
+
if (scope)
|
|
101
|
+
params.set("scope", scope);
|
|
102
|
+
const resp = yield fetch(tokenEndpoint, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: params.toString() });
|
|
103
|
+
if (!resp.ok) {
|
|
104
|
+
const error = yield resp.json().catch(() => ({}));
|
|
105
|
+
throw new Error(`OAuth token request failed: ${error.error_description || resp.statusText}`);
|
|
106
|
+
}
|
|
107
|
+
const data = yield resp.json();
|
|
108
|
+
cachedToken = data.access_token;
|
|
109
|
+
expiresAt = Date.now() + data.expires_in * 1000;
|
|
110
|
+
return cachedToken;
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Perform Authorization Code + PKCE flow via a local HTTP callback server.
|
|
115
|
+
*
|
|
116
|
+
* 1. Start a temporary HTTP server on a random port
|
|
117
|
+
* 2. Open the browser to /oauth/authorize with PKCE challenge
|
|
118
|
+
* 3. Receive the callback with the authorization code
|
|
119
|
+
* 4. Exchange the code for tokens
|
|
120
|
+
* 5. Return a getToken function that auto-refreshes via refresh_token
|
|
121
|
+
*/
|
|
122
|
+
function browserAuthFlow(baseUrl, clientId, scope) {
|
|
123
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
124
|
+
const authUrl = deriveAuthUrl(baseUrl);
|
|
125
|
+
const tokenEndpoint = `${authUrl}/oauth/token`;
|
|
126
|
+
const codeVerifier = (0, node_crypto_1.randomBytes)(32).toString("base64url");
|
|
127
|
+
const codeChallenge = (0, node_crypto_1.createHash)("sha256").update(codeVerifier).digest("base64url");
|
|
128
|
+
const state = (0, node_crypto_1.randomBytes)(16).toString("hex");
|
|
129
|
+
let accessToken = null;
|
|
130
|
+
let refreshToken = null;
|
|
131
|
+
let expiresAt = 0;
|
|
132
|
+
// Wait for authorization code via local callback server
|
|
133
|
+
const { code, redirectUri } = yield new Promise((resolve, reject) => {
|
|
134
|
+
const server = (0, node_http_1.createServer)((req, res) => {
|
|
135
|
+
const url = new URL(req.url, "http://localhost");
|
|
136
|
+
if (url.pathname !== "/callback") {
|
|
137
|
+
res.writeHead(404);
|
|
138
|
+
res.end();
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
const error = url.searchParams.get("error");
|
|
142
|
+
if (error) {
|
|
143
|
+
res.writeHead(200, { "Content-Type": "text/html" });
|
|
144
|
+
res.end(`<h2>Authorization Failed</h2><p>${url.searchParams.get("error_description") || error}</p>`);
|
|
145
|
+
server.close();
|
|
146
|
+
reject(new Error(`Authorization denied: ${error}`));
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
if (url.searchParams.get("state") !== state) {
|
|
150
|
+
res.writeHead(400, { "Content-Type": "text/html" });
|
|
151
|
+
res.end("<h2>State Mismatch</h2>");
|
|
152
|
+
server.close();
|
|
153
|
+
reject(new Error("State mismatch — possible CSRF"));
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
const authCode = url.searchParams.get("code");
|
|
157
|
+
if (!authCode) {
|
|
158
|
+
res.writeHead(400, { "Content-Type": "text/html" });
|
|
159
|
+
res.end("<h2>Missing Code</h2>");
|
|
160
|
+
server.close();
|
|
161
|
+
reject(new Error("No authorization code"));
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
res.writeHead(200, { "Content-Type": "text/html" });
|
|
165
|
+
res.end("<h2>Authorization Successful</h2><p>You can close this window.</p><script>window.close()</script>");
|
|
166
|
+
const addr = server.address();
|
|
167
|
+
server.close();
|
|
168
|
+
resolve({ code: authCode, redirectUri: `http://localhost:${addr.port}/callback` });
|
|
169
|
+
});
|
|
170
|
+
server.listen(0, "127.0.0.1", () => {
|
|
171
|
+
const addr = server.address();
|
|
172
|
+
const callbackUri = `http://localhost:${addr.port}/callback`;
|
|
173
|
+
const authParams = new URLSearchParams({
|
|
174
|
+
response_type: "code", client_id: clientId, redirect_uri: callbackUri,
|
|
175
|
+
scope: scope || "",
|
|
176
|
+
state, code_challenge: codeChallenge, code_challenge_method: "S256",
|
|
177
|
+
});
|
|
178
|
+
const authorizeUrl = `${authUrl}/oauth/authorize?${authParams.toString()}`;
|
|
179
|
+
console.error("[centrali-mcp] Opening browser for authorization...");
|
|
180
|
+
console.error(`[centrali-mcp] If the browser doesn't open, visit:\n ${authorizeUrl}`);
|
|
181
|
+
openBrowser(authorizeUrl);
|
|
182
|
+
setTimeout(() => { server.close(); reject(new Error("Authorization timed out (5 min)")); }, 300000);
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
// Exchange authorization code for tokens
|
|
186
|
+
console.error("[centrali-mcp] Exchanging authorization code for tokens...");
|
|
187
|
+
const exchangeResp = yield fetch(tokenEndpoint, {
|
|
188
|
+
method: "POST",
|
|
189
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
190
|
+
body: new URLSearchParams({
|
|
191
|
+
grant_type: "authorization_code", code, redirect_uri: redirectUri,
|
|
192
|
+
client_id: clientId, code_verifier: codeVerifier,
|
|
193
|
+
}).toString(),
|
|
194
|
+
});
|
|
195
|
+
if (!exchangeResp.ok) {
|
|
196
|
+
const err = yield exchangeResp.json().catch(() => ({}));
|
|
197
|
+
throw new Error(`Token exchange failed: ${err.error_description || exchangeResp.statusText}`);
|
|
198
|
+
}
|
|
199
|
+
const tokenData = yield exchangeResp.json();
|
|
200
|
+
accessToken = tokenData.access_token;
|
|
201
|
+
refreshToken = tokenData.refresh_token;
|
|
202
|
+
expiresAt = Date.now() + tokenData.expires_in * 1000;
|
|
203
|
+
console.error(`[centrali-mcp] Authorization successful (scope: ${tokenData.scope})`);
|
|
204
|
+
// Return a getToken function with auto-refresh
|
|
205
|
+
return () => __awaiter(this, void 0, void 0, function* () {
|
|
206
|
+
if (accessToken && Date.now() < expiresAt - 60000)
|
|
207
|
+
return accessToken;
|
|
208
|
+
if (refreshToken) {
|
|
209
|
+
const resp = yield fetch(tokenEndpoint, {
|
|
210
|
+
method: "POST",
|
|
211
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
212
|
+
body: new URLSearchParams({ grant_type: "refresh_token", refresh_token: refreshToken, client_id: clientId }).toString(),
|
|
213
|
+
});
|
|
214
|
+
if (resp.ok) {
|
|
215
|
+
const data = yield resp.json();
|
|
216
|
+
accessToken = data.access_token;
|
|
217
|
+
refreshToken = data.refresh_token || refreshToken;
|
|
218
|
+
expiresAt = Date.now() + data.expires_in * 1000;
|
|
219
|
+
return accessToken;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
throw new Error("Token expired and refresh failed — restart the MCP server to re-authorize.");
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
}
|
|
37
226
|
function main() {
|
|
38
227
|
return __awaiter(this, void 0, void 0, function* () {
|
|
39
228
|
const baseUrl = getRequiredEnv("CENTRALI_URL");
|
|
40
|
-
const clientId = getRequiredEnv("CENTRALI_CLIENT_ID");
|
|
41
|
-
const clientSecret = getRequiredEnv("CENTRALI_CLIENT_SECRET");
|
|
42
229
|
const workspaceId = getRequiredEnv("CENTRALI_WORKSPACE");
|
|
43
|
-
const
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
clientId
|
|
47
|
-
|
|
48
|
-
|
|
230
|
+
const authMode = detectAuthMode();
|
|
231
|
+
let sdk;
|
|
232
|
+
if (authMode === "browser") {
|
|
233
|
+
const clientId = getRequiredEnv("CENTRALI_OAUTH_CLIENT_ID");
|
|
234
|
+
const scope = process.env.CENTRALI_OAUTH_SCOPE;
|
|
235
|
+
const getToken = yield browserAuthFlow(baseUrl, clientId, scope);
|
|
236
|
+
sdk = new centrali_sdk_1.CentraliSDK({ baseUrl, workspaceId, getToken });
|
|
237
|
+
console.error(`[centrali-mcp] Ready — browser OAuth (client: ${clientId})`);
|
|
238
|
+
}
|
|
239
|
+
else if (authMode === "oauth") {
|
|
240
|
+
const clientId = getRequiredEnv("CENTRALI_OAUTH_CLIENT_ID");
|
|
241
|
+
const clientSecret = getRequiredEnv("CENTRALI_OAUTH_CLIENT_SECRET");
|
|
242
|
+
const scope = process.env.CENTRALI_OAUTH_SCOPE;
|
|
243
|
+
const getToken = createClientCredentialsFetcher(baseUrl, clientId, clientSecret, scope);
|
|
244
|
+
sdk = new centrali_sdk_1.CentraliSDK({ baseUrl, workspaceId, getToken });
|
|
245
|
+
console.error(`[centrali-mcp] Ready — OAuth client_credentials (client: ${clientId})`);
|
|
246
|
+
}
|
|
247
|
+
else {
|
|
248
|
+
const clientId = getRequiredEnv("CENTRALI_CLIENT_ID");
|
|
249
|
+
const clientSecret = getRequiredEnv("CENTRALI_CLIENT_SECRET");
|
|
250
|
+
sdk = new centrali_sdk_1.CentraliSDK({ baseUrl, workspaceId, clientId, clientSecret });
|
|
251
|
+
console.error(`[centrali-mcp] Ready — service account / RBAC (client: ${clientId})`);
|
|
252
|
+
}
|
|
49
253
|
const server = new mcp_js_1.McpServer({
|
|
50
254
|
name: "centrali",
|
|
51
255
|
version: "1.0.0",
|
|
@@ -62,7 +266,7 @@ function main() {
|
|
|
62
266
|
(0, validation_js_1.registerValidationTools)(server, sdk);
|
|
63
267
|
(0, pages_js_1.registerPageTools)(server, sdk, baseUrl, workspaceId);
|
|
64
268
|
(0, auth_providers_js_1.registerAuthProviderTools)(server, sdk, baseUrl, workspaceId);
|
|
65
|
-
(0, service_accounts_js_1.registerServiceAccountTools)(server, sdk, baseUrl, workspaceId,
|
|
269
|
+
(0, service_accounts_js_1.registerServiceAccountTools)(server, sdk, baseUrl, workspaceId, authMode === "service-account" ? getRequiredEnv("CENTRALI_CLIENT_ID") : (process.env.CENTRALI_OAUTH_CLIENT_ID || ""));
|
|
66
270
|
(0, describe_js_1.registerDescribeTools)(server);
|
|
67
271
|
// Register resources
|
|
68
272
|
(0, structures_js_2.registerCollectionResources)(server, sdk);
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -3,6 +3,9 @@
|
|
|
3
3
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
4
4
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
5
5
|
import { CentraliSDK } from "@centrali-io/centrali-sdk";
|
|
6
|
+
import { createServer } from "node:http";
|
|
7
|
+
import { randomBytes, createHash } from "node:crypto";
|
|
8
|
+
import { execFile } from "node:child_process";
|
|
6
9
|
import { registerStructureTools, registerCollectionTools } from "./tools/structures.js";
|
|
7
10
|
import { registerRecordTools } from "./tools/records.js";
|
|
8
11
|
import { registerSearchTools } from "./tools/search.js";
|
|
@@ -26,18 +29,234 @@ function getRequiredEnv(name: string): string {
|
|
|
26
29
|
return value;
|
|
27
30
|
}
|
|
28
31
|
|
|
32
|
+
/**
|
|
33
|
+
* Detect auth mode from environment variables.
|
|
34
|
+
*
|
|
35
|
+
* Browser mode: Set CENTRALI_OAUTH_CLIENT_ID (no secret)
|
|
36
|
+
* → Opens browser for Authorization Code + PKCE flow
|
|
37
|
+
*
|
|
38
|
+
* OAuth client_credentials mode: Set CENTRALI_OAUTH_CLIENT_ID + CENTRALI_OAUTH_CLIENT_SECRET
|
|
39
|
+
* → Uses POST /oauth/token with scoped access
|
|
40
|
+
*
|
|
41
|
+
* Service account mode: Set CENTRALI_CLIENT_ID + CENTRALI_CLIENT_SECRET
|
|
42
|
+
* → Uses OIDC client_credentials with RBAC (roles, groups, policies)
|
|
43
|
+
*/
|
|
44
|
+
function detectAuthMode(): "browser" | "oauth" | "service-account" {
|
|
45
|
+
const oauthClientId = process.env.CENTRALI_OAUTH_CLIENT_ID;
|
|
46
|
+
const oauthClientSecret = process.env.CENTRALI_OAUTH_CLIENT_SECRET;
|
|
47
|
+
|
|
48
|
+
if (oauthClientId && !oauthClientSecret) {
|
|
49
|
+
return "browser";
|
|
50
|
+
}
|
|
51
|
+
if (oauthClientId && oauthClientSecret) {
|
|
52
|
+
return "oauth";
|
|
53
|
+
}
|
|
54
|
+
if (process.env.CENTRALI_CLIENT_ID && process.env.CENTRALI_CLIENT_SECRET) {
|
|
55
|
+
return "service-account";
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
console.error(
|
|
59
|
+
"Missing auth credentials. Set one of:\n" +
|
|
60
|
+
" CENTRALI_OAUTH_CLIENT_ID (browser OAuth — Authorization Code + PKCE)\n" +
|
|
61
|
+
" CENTRALI_OAUTH_CLIENT_ID + CENTRALI_OAUTH_CLIENT_SECRET (OAuth client_credentials)\n" +
|
|
62
|
+
" CENTRALI_CLIENT_ID + CENTRALI_CLIENT_SECRET (service account — RBAC permissions)"
|
|
63
|
+
);
|
|
64
|
+
process.exit(1);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Derive the IAM auth URL from the base URL. */
|
|
68
|
+
function deriveAuthUrl(baseUrl: string): string {
|
|
69
|
+
// Direct localhost port (e.g., http://localhost:7060) — already pointing at IAM
|
|
70
|
+
if (/localhost:\d+/.test(baseUrl)) {
|
|
71
|
+
return baseUrl.replace(/\/data$/, "").replace(/\/iam$/, "");
|
|
72
|
+
}
|
|
73
|
+
// Traefik or production — prepend auth. subdomain
|
|
74
|
+
// centrali.localhost → auth.centrali.localhost
|
|
75
|
+
// centrali.io → auth.centrali.io
|
|
76
|
+
return baseUrl.replace(/^(https?:\/\/)/, "$1auth.");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Open a URL in the default browser (safe — no shell interpolation). */
|
|
80
|
+
function openBrowser(url: string): void {
|
|
81
|
+
const cmd = process.platform === "darwin" ? "open"
|
|
82
|
+
: process.platform === "win32" ? "cmd" : "xdg-open";
|
|
83
|
+
const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
|
|
84
|
+
execFile(cmd, args, (err) => {
|
|
85
|
+
if (err) console.error(`[centrali-mcp] Could not open browser: ${err.message}`);
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Create a token fetcher for OAuth client_credentials flow. */
|
|
90
|
+
function createClientCredentialsFetcher(baseUrl: string, clientId: string, clientSecret: string, scope?: string) {
|
|
91
|
+
const tokenEndpoint = `${deriveAuthUrl(baseUrl)}/oauth/token`;
|
|
92
|
+
let cachedToken: string | null = null;
|
|
93
|
+
let expiresAt = 0;
|
|
94
|
+
|
|
95
|
+
return async (): Promise<string> => {
|
|
96
|
+
if (cachedToken && Date.now() < expiresAt - 60_000) return cachedToken;
|
|
97
|
+
|
|
98
|
+
const params = new URLSearchParams({ grant_type: "client_credentials", client_id: clientId, client_secret: clientSecret });
|
|
99
|
+
if (scope) params.set("scope", scope);
|
|
100
|
+
|
|
101
|
+
const resp = await fetch(tokenEndpoint, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: params.toString() });
|
|
102
|
+
if (!resp.ok) {
|
|
103
|
+
const error = await resp.json().catch(() => ({}));
|
|
104
|
+
throw new Error(`OAuth token request failed: ${error.error_description || resp.statusText}`);
|
|
105
|
+
}
|
|
106
|
+
const data = await resp.json();
|
|
107
|
+
cachedToken = data.access_token;
|
|
108
|
+
expiresAt = Date.now() + data.expires_in * 1000;
|
|
109
|
+
return cachedToken!;
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Perform Authorization Code + PKCE flow via a local HTTP callback server.
|
|
115
|
+
*
|
|
116
|
+
* 1. Start a temporary HTTP server on a random port
|
|
117
|
+
* 2. Open the browser to /oauth/authorize with PKCE challenge
|
|
118
|
+
* 3. Receive the callback with the authorization code
|
|
119
|
+
* 4. Exchange the code for tokens
|
|
120
|
+
* 5. Return a getToken function that auto-refreshes via refresh_token
|
|
121
|
+
*/
|
|
122
|
+
async function browserAuthFlow(baseUrl: string, clientId: string, scope?: string): Promise<() => Promise<string>> {
|
|
123
|
+
const authUrl = deriveAuthUrl(baseUrl);
|
|
124
|
+
const tokenEndpoint = `${authUrl}/oauth/token`;
|
|
125
|
+
|
|
126
|
+
const codeVerifier = randomBytes(32).toString("base64url");
|
|
127
|
+
const codeChallenge = createHash("sha256").update(codeVerifier).digest("base64url");
|
|
128
|
+
const state = randomBytes(16).toString("hex");
|
|
129
|
+
|
|
130
|
+
let accessToken: string | null = null;
|
|
131
|
+
let refreshToken: string | null = null;
|
|
132
|
+
let expiresAt = 0;
|
|
133
|
+
|
|
134
|
+
// Wait for authorization code via local callback server
|
|
135
|
+
const { code, redirectUri } = await new Promise<{ code: string; redirectUri: string }>((resolve, reject) => {
|
|
136
|
+
const server = createServer((req, res) => {
|
|
137
|
+
const url = new URL(req.url!, "http://localhost");
|
|
138
|
+
if (url.pathname !== "/callback") { res.writeHead(404); res.end(); return; }
|
|
139
|
+
|
|
140
|
+
const error = url.searchParams.get("error");
|
|
141
|
+
if (error) {
|
|
142
|
+
res.writeHead(200, { "Content-Type": "text/html" });
|
|
143
|
+
res.end(`<h2>Authorization Failed</h2><p>${url.searchParams.get("error_description") || error}</p>`);
|
|
144
|
+
server.close();
|
|
145
|
+
reject(new Error(`Authorization denied: ${error}`));
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (url.searchParams.get("state") !== state) {
|
|
150
|
+
res.writeHead(400, { "Content-Type": "text/html" });
|
|
151
|
+
res.end("<h2>State Mismatch</h2>");
|
|
152
|
+
server.close();
|
|
153
|
+
reject(new Error("State mismatch — possible CSRF"));
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const authCode = url.searchParams.get("code");
|
|
158
|
+
if (!authCode) {
|
|
159
|
+
res.writeHead(400, { "Content-Type": "text/html" });
|
|
160
|
+
res.end("<h2>Missing Code</h2>");
|
|
161
|
+
server.close();
|
|
162
|
+
reject(new Error("No authorization code"));
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
res.writeHead(200, { "Content-Type": "text/html" });
|
|
167
|
+
res.end("<h2>Authorization Successful</h2><p>You can close this window.</p><script>window.close()</script>");
|
|
168
|
+
|
|
169
|
+
const addr = server.address() as { port: number };
|
|
170
|
+
server.close();
|
|
171
|
+
resolve({ code: authCode, redirectUri: `http://localhost:${addr.port}/callback` });
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
server.listen(0, "127.0.0.1", () => {
|
|
175
|
+
const addr = server.address() as { port: number };
|
|
176
|
+
const callbackUri = `http://localhost:${addr.port}/callback`;
|
|
177
|
+
const authParams = new URLSearchParams({
|
|
178
|
+
response_type: "code", client_id: clientId, redirect_uri: callbackUri,
|
|
179
|
+
scope: scope || "",
|
|
180
|
+
state, code_challenge: codeChallenge, code_challenge_method: "S256",
|
|
181
|
+
});
|
|
182
|
+
const authorizeUrl = `${authUrl}/oauth/authorize?${authParams.toString()}`;
|
|
183
|
+
console.error("[centrali-mcp] Opening browser for authorization...");
|
|
184
|
+
console.error(`[centrali-mcp] If the browser doesn't open, visit:\n ${authorizeUrl}`);
|
|
185
|
+
openBrowser(authorizeUrl);
|
|
186
|
+
setTimeout(() => { server.close(); reject(new Error("Authorization timed out (5 min)")); }, 300_000);
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
// Exchange authorization code for tokens
|
|
191
|
+
console.error("[centrali-mcp] Exchanging authorization code for tokens...");
|
|
192
|
+
const exchangeResp = await fetch(tokenEndpoint, {
|
|
193
|
+
method: "POST",
|
|
194
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
195
|
+
body: new URLSearchParams({
|
|
196
|
+
grant_type: "authorization_code", code, redirect_uri: redirectUri,
|
|
197
|
+
client_id: clientId, code_verifier: codeVerifier,
|
|
198
|
+
}).toString(),
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
if (!exchangeResp.ok) {
|
|
202
|
+
const err = await exchangeResp.json().catch(() => ({}));
|
|
203
|
+
throw new Error(`Token exchange failed: ${err.error_description || exchangeResp.statusText}`);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const tokenData = await exchangeResp.json();
|
|
207
|
+
accessToken = tokenData.access_token;
|
|
208
|
+
refreshToken = tokenData.refresh_token;
|
|
209
|
+
expiresAt = Date.now() + tokenData.expires_in * 1000;
|
|
210
|
+
console.error(`[centrali-mcp] Authorization successful (scope: ${tokenData.scope})`);
|
|
211
|
+
|
|
212
|
+
// Return a getToken function with auto-refresh
|
|
213
|
+
return async (): Promise<string> => {
|
|
214
|
+
if (accessToken && Date.now() < expiresAt - 60_000) return accessToken;
|
|
215
|
+
|
|
216
|
+
if (refreshToken) {
|
|
217
|
+
const resp = await fetch(tokenEndpoint, {
|
|
218
|
+
method: "POST",
|
|
219
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
220
|
+
body: new URLSearchParams({ grant_type: "refresh_token", refresh_token: refreshToken!, client_id: clientId }).toString(),
|
|
221
|
+
});
|
|
222
|
+
if (resp.ok) {
|
|
223
|
+
const data = await resp.json();
|
|
224
|
+
accessToken = data.access_token;
|
|
225
|
+
refreshToken = data.refresh_token || refreshToken;
|
|
226
|
+
expiresAt = Date.now() + data.expires_in * 1000;
|
|
227
|
+
return accessToken!;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
throw new Error("Token expired and refresh failed — restart the MCP server to re-authorize.");
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
29
234
|
async function main() {
|
|
30
235
|
const baseUrl = getRequiredEnv("CENTRALI_URL");
|
|
31
|
-
const clientId = getRequiredEnv("CENTRALI_CLIENT_ID");
|
|
32
|
-
const clientSecret = getRequiredEnv("CENTRALI_CLIENT_SECRET");
|
|
33
236
|
const workspaceId = getRequiredEnv("CENTRALI_WORKSPACE");
|
|
237
|
+
const authMode = detectAuthMode();
|
|
34
238
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
clientId
|
|
39
|
-
|
|
40
|
-
|
|
239
|
+
let sdk: CentraliSDK;
|
|
240
|
+
|
|
241
|
+
if (authMode === "browser") {
|
|
242
|
+
const clientId = getRequiredEnv("CENTRALI_OAUTH_CLIENT_ID");
|
|
243
|
+
const scope = process.env.CENTRALI_OAUTH_SCOPE;
|
|
244
|
+
const getToken = await browserAuthFlow(baseUrl, clientId, scope);
|
|
245
|
+
sdk = new CentraliSDK({ baseUrl, workspaceId, getToken });
|
|
246
|
+
console.error(`[centrali-mcp] Ready — browser OAuth (client: ${clientId})`);
|
|
247
|
+
} else if (authMode === "oauth") {
|
|
248
|
+
const clientId = getRequiredEnv("CENTRALI_OAUTH_CLIENT_ID");
|
|
249
|
+
const clientSecret = getRequiredEnv("CENTRALI_OAUTH_CLIENT_SECRET");
|
|
250
|
+
const scope = process.env.CENTRALI_OAUTH_SCOPE;
|
|
251
|
+
const getToken = createClientCredentialsFetcher(baseUrl, clientId, clientSecret, scope);
|
|
252
|
+
sdk = new CentraliSDK({ baseUrl, workspaceId, getToken });
|
|
253
|
+
console.error(`[centrali-mcp] Ready — OAuth client_credentials (client: ${clientId})`);
|
|
254
|
+
} else {
|
|
255
|
+
const clientId = getRequiredEnv("CENTRALI_CLIENT_ID");
|
|
256
|
+
const clientSecret = getRequiredEnv("CENTRALI_CLIENT_SECRET");
|
|
257
|
+
sdk = new CentraliSDK({ baseUrl, workspaceId, clientId, clientSecret });
|
|
258
|
+
console.error(`[centrali-mcp] Ready — service account / RBAC (client: ${clientId})`);
|
|
259
|
+
}
|
|
41
260
|
|
|
42
261
|
const server = new McpServer({
|
|
43
262
|
name: "centrali",
|
|
@@ -56,7 +275,7 @@ async function main() {
|
|
|
56
275
|
registerValidationTools(server, sdk);
|
|
57
276
|
registerPageTools(server, sdk, baseUrl, workspaceId);
|
|
58
277
|
registerAuthProviderTools(server, sdk, baseUrl, workspaceId);
|
|
59
|
-
registerServiceAccountTools(server, sdk, baseUrl, workspaceId,
|
|
278
|
+
registerServiceAccountTools(server, sdk, baseUrl, workspaceId, authMode === "service-account" ? getRequiredEnv("CENTRALI_CLIENT_ID") : (process.env.CENTRALI_OAUTH_CLIENT_ID || ""));
|
|
60
279
|
registerDescribeTools(server);
|
|
61
280
|
|
|
62
281
|
// Register resources
|