@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 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` v4.4.7. Authenticates via service account client credentials.
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
- Add to your MCP client configuration (e.g., Claude Desktop, Cursor):
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 Service Accounts**
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
- This gives the MCP server full access to all workspace resources.
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 sdk = new centrali_sdk_1.CentraliSDK({
44
- baseUrl,
45
- workspaceId,
46
- clientId,
47
- clientSecret,
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, clientId);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@centrali-io/centrali-mcp",
3
- "version": "4.5.2",
3
+ "version": "4.6.0",
4
4
  "description": "Centrali MCP Server - AI assistant integration for Centrali workspaces",
5
5
  "main": "dist/index.js",
6
6
  "type": "commonjs",
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
- const sdk = new CentraliSDK({
36
- baseUrl,
37
- workspaceId,
38
- clientId,
39
- clientSecret,
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, clientId);
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