@centrali-io/centrali-mcp 4.6.0 → 5.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/README.md CHANGED
@@ -4,36 +4,30 @@ 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`. Supports three auth methods: **OAuth client credentials** (recommended), **OAuth browser login** (Authorization Code + PKCE), and **service account** (RBAC).
7
+ ## Two ways to connect
8
8
 
9
- ## Setup
9
+ ### Option 1: Hosted MCP Server (recommended)
10
10
 
11
- ### Option 1: OAuth Client Credentials (recommended)
11
+ The easiest way to connect any AI client to Centrali. Add a single URL — authentication happens in the browser via OAuth.
12
12
 
13
- Create an OAuth app in the Console under **Settings > OAuth Apps**, select the scopes you need, then configure:
13
+ **Claude Desktop / Claude Code / Cursor:**
14
14
 
15
15
  ```json
16
16
  {
17
17
  "mcpServers": {
18
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
- }
19
+ "type": "url",
20
+ "url": "https://mcp.centrali.io"
27
21
  }
28
22
  }
29
23
  }
30
24
  ```
31
25
 
32
- OAuth clients use **scoped access** the MCP can only access what the client's scopes allow.
26
+ On first use, your AI client opens a browser for login and workspace selection. No client ID, secret, or workspace slug needed.
33
27
 
34
- ### Option 2: OAuth Browser Login (Authorization Code + PKCE)
28
+ ### Option 2: Stdio with Service Account (CI/automation)
35
29
 
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:
30
+ For headless environments (CI pipelines, automation scripts, cron jobs) where browser login isn't possible. Uses service account credentials with RBAC permissions.
37
31
 
38
32
  ```json
39
33
  {
@@ -43,7 +37,8 @@ For local development where you want the agent to act as **you** (not a bot). Cr
43
37
  "args": ["@centrali-io/centrali-mcp"],
44
38
  "env": {
45
39
  "CENTRALI_URL": "https://centrali.io",
46
- "CENTRALI_OAUTH_CLIENT_ID": "<oauth-client-id>",
40
+ "CENTRALI_CLIENT_ID": "<service-account-client-id>",
41
+ "CENTRALI_CLIENT_SECRET": "<service-account-secret>",
47
42
  "CENTRALI_WORKSPACE": "my-workspace"
48
43
  }
49
44
  }
@@ -51,53 +46,22 @@ For local development where you want the agent to act as **you** (not a bot). Cr
51
46
  }
52
47
  ```
53
48
 
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.
49
+ ### Migrating from OAuth stdio to hosted
55
50
 
56
- ### Option 3: Service Account (RBAC)
51
+ If you were previously using `CENTRALI_OAUTH_CLIENT_ID` with the stdio package, switch to the hosted URL:
57
52
 
58
- Service accounts use **RBAC permissions** via groups and roles. You must assign the service account to a group before it can access anything.
53
+ 1. Remove the old `command`/`args`/`env` configuration
54
+ 2. Replace with: `{ "type": "url", "url": "https://mcp.centrali.io" }`
55
+ 3. On next connection, authenticate in the browser
59
56
 
60
- ```json
61
- {
62
- "mcpServers": {
63
- "centrali": {
64
- "command": "npx",
65
- "args": ["@centrali-io/centrali-mcp"],
66
- "env": {
67
- "CENTRALI_URL": "https://centrali.io",
68
- "CENTRALI_CLIENT_ID": "<service-account-client-id>",
69
- "CENTRALI_CLIENT_SECRET": "<service-account-secret>",
70
- "CENTRALI_WORKSPACE": "my-workspace"
71
- }
72
- }
73
- }
74
- }
75
- ```
76
-
77
- ### Environment Variables
57
+ ### Environment Variables (stdio mode only)
78
58
 
79
59
  | Variable | Required | Description |
80
60
  |----------|----------|-------------|
81
61
  | `CENTRALI_URL` | Yes | Centrali instance URL (e.g., `https://centrali.io`) |
82
62
  | `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 |
63
+ | `CENTRALI_CLIENT_ID` | Yes | Service account client ID |
64
+ | `CENTRALI_CLIENT_SECRET` | Yes | Service account client secret |
101
65
 
102
66
  ### Service Account Permissions
103
67
 
package/dist/index.js CHANGED
@@ -13,9 +13,6 @@ 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");
19
16
  const structures_js_1 = require("./tools/structures.js");
20
17
  const records_js_1 = require("./tools/records.js");
21
18
  const search_js_1 = require("./tools/search.js");
@@ -37,219 +34,34 @@ function getRequiredEnv(name) {
37
34
  }
38
35
  return value;
39
36
  }
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
- }
226
37
  function main() {
227
38
  return __awaiter(this, void 0, void 0, function* () {
39
+ // ── Migration check ──────────────────────────────────────────
40
+ // Browser OAuth and client_credentials flows have moved to the hosted MCP server.
41
+ // Detect old env vars and guide users to the new setup.
42
+ if (process.env.CENTRALI_OAUTH_CLIENT_ID) {
43
+ console.error("\n" +
44
+ "══════════════════════════════════════════════════════════════\n" +
45
+ " Browser OAuth has moved to the hosted MCP server.\n" +
46
+ "\n" +
47
+ " Replace this stdio configuration with a single URL:\n" +
48
+ "\n" +
49
+ ' { "type": "url", "url": "https://mcp.centrali.io" }\n' +
50
+ "\n" +
51
+ " Your AI client will open a browser for login automatically.\n" +
52
+ " No client ID, secret, or workspace slug needed.\n" +
53
+ "\n" +
54
+ " Guide: https://docs.centrali.io/sdk/mcp/\n" +
55
+ "══════════════════════════════════════════════════════════════\n");
56
+ process.exit(0);
57
+ }
58
+ // ── Service account mode (the only mode for stdio) ───────────
228
59
  const baseUrl = getRequiredEnv("CENTRALI_URL");
229
60
  const workspaceId = getRequiredEnv("CENTRALI_WORKSPACE");
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
- }
61
+ const clientId = getRequiredEnv("CENTRALI_CLIENT_ID");
62
+ const clientSecret = getRequiredEnv("CENTRALI_CLIENT_SECRET");
63
+ const sdk = new centrali_sdk_1.CentraliSDK({ baseUrl, workspaceId, clientId, clientSecret });
64
+ console.error(`[centrali-mcp] Ready service account (client: ${clientId})`);
253
65
  const server = new mcp_js_1.McpServer({
254
66
  name: "centrali",
255
67
  version: "1.0.0",
@@ -266,7 +78,7 @@ function main() {
266
78
  (0, validation_js_1.registerValidationTools)(server, sdk);
267
79
  (0, pages_js_1.registerPageTools)(server, sdk, baseUrl, workspaceId);
268
80
  (0, auth_providers_js_1.registerAuthProviderTools)(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 || ""));
81
+ (0, service_accounts_js_1.registerServiceAccountTools)(server, sdk, baseUrl, workspaceId, clientId);
270
82
  (0, describe_js_1.registerDescribeTools)(server);
271
83
  // Register resources
272
84
  (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.6.0",
3
+ "version": "5.0.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,9 +3,6 @@
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";
9
6
  import { registerStructureTools, registerCollectionTools } from "./tools/structures.js";
10
7
  import { registerRecordTools } from "./tools/records.js";
11
8
  import { registerSearchTools } from "./tools/search.js";
@@ -29,234 +26,37 @@ function getRequiredEnv(name: string): string {
29
26
  return value;
30
27
  }
31
28
 
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}`);
29
+ async function main() {
30
+ // ── Migration check ──────────────────────────────────────────
31
+ // Browser OAuth and client_credentials flows have moved to the hosted MCP server.
32
+ // Detect old env vars and guide users to the new setup.
33
+ if (process.env.CENTRALI_OAUTH_CLIENT_ID) {
34
+ console.error(
35
+ "\n" +
36
+ "══════════════════════════════════════════════════════════════\n" +
37
+ " Browser OAuth has moved to the hosted MCP server.\n" +
38
+ "\n" +
39
+ " Replace this stdio configuration with a single URL:\n" +
40
+ "\n" +
41
+ ' { "type": "url", "url": "https://mcp.centrali.io" }\n' +
42
+ "\n" +
43
+ " Your AI client will open a browser for login automatically.\n" +
44
+ " No client ID, secret, or workspace slug needed.\n" +
45
+ "\n" +
46
+ " Guide: https://docs.centrali.io/sdk/mcp/\n" +
47
+ "══════════════════════════════════════════════════════════════\n"
48
+ );
49
+ process.exit(0);
204
50
  }
205
51
 
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
-
234
- async function main() {
52
+ // ── Service account mode (the only mode for stdio) ───────────
235
53
  const baseUrl = getRequiredEnv("CENTRALI_URL");
236
54
  const workspaceId = getRequiredEnv("CENTRALI_WORKSPACE");
237
- const authMode = detectAuthMode();
55
+ const clientId = getRequiredEnv("CENTRALI_CLIENT_ID");
56
+ const clientSecret = getRequiredEnv("CENTRALI_CLIENT_SECRET");
238
57
 
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
- }
58
+ const sdk = new CentraliSDK({ baseUrl, workspaceId, clientId, clientSecret });
59
+ console.error(`[centrali-mcp] Ready — service account (client: ${clientId})`);
260
60
 
261
61
  const server = new McpServer({
262
62
  name: "centrali",
@@ -275,7 +75,7 @@ async function main() {
275
75
  registerValidationTools(server, sdk);
276
76
  registerPageTools(server, sdk, baseUrl, workspaceId);
277
77
  registerAuthProviderTools(server, sdk, baseUrl, workspaceId);
278
- registerServiceAccountTools(server, sdk, baseUrl, workspaceId, authMode === "service-account" ? getRequiredEnv("CENTRALI_CLIENT_ID") : (process.env.CENTRALI_OAUTH_CLIENT_ID || ""));
78
+ registerServiceAccountTools(server, sdk, baseUrl, workspaceId, clientId);
279
79
  registerDescribeTools(server);
280
80
 
281
81
  // Register resources