@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 +19 -55
- package/dist/index.js +25 -213
- package/package.json +1 -1
- package/src/index.ts +27 -227
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
|
-
|
|
7
|
+
## Two ways to connect
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
### Option 1: Hosted MCP Server (recommended)
|
|
10
10
|
|
|
11
|
-
|
|
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
|
-
|
|
13
|
+
**Claude Desktop / Claude Code / Cursor:**
|
|
14
14
|
|
|
15
15
|
```json
|
|
16
16
|
{
|
|
17
17
|
"mcpServers": {
|
|
18
18
|
"centrali": {
|
|
19
|
-
"
|
|
20
|
-
"
|
|
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
|
-
|
|
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:
|
|
28
|
+
### Option 2: Stdio with Service Account (CI/automation)
|
|
35
29
|
|
|
36
|
-
For
|
|
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
|
-
"
|
|
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
|
-
|
|
49
|
+
### Migrating from OAuth stdio to hosted
|
|
55
50
|
|
|
56
|
-
|
|
51
|
+
If you were previously using `CENTRALI_OAUTH_CLIENT_ID` with the stdio package, switch to the hosted URL:
|
|
57
52
|
|
|
58
|
-
|
|
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
|
-
|
|
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
|
-
| `
|
|
84
|
-
| `
|
|
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
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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,
|
|
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
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
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
|
|
55
|
+
const clientId = getRequiredEnv("CENTRALI_CLIENT_ID");
|
|
56
|
+
const clientSecret = getRequiredEnv("CENTRALI_CLIENT_SECRET");
|
|
238
57
|
|
|
239
|
-
|
|
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,
|
|
78
|
+
registerServiceAccountTools(server, sdk, baseUrl, workspaceId, clientId);
|
|
279
79
|
registerDescribeTools(server);
|
|
280
80
|
|
|
281
81
|
// Register resources
|