@clavex/mcp-server 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +107 -0
- package/dist/client.d.ts +38 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +121 -0
- package/dist/client.js.map +1 -0
- package/dist/helpers.d.ts +14 -0
- package/dist/helpers.d.ts.map +1 -0
- package/dist/helpers.js +44 -0
- package/dist/helpers.js.map +1 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +59 -0
- package/dist/index.js.map +1 -0
- package/dist/tools/access_reviews.d.ts +3 -0
- package/dist/tools/access_reviews.d.ts.map +1 -0
- package/dist/tools/access_reviews.js +131 -0
- package/dist/tools/access_reviews.js.map +1 -0
- package/dist/tools/ai.d.ts +3 -0
- package/dist/tools/ai.d.ts.map +1 -0
- package/dist/tools/ai.js +443 -0
- package/dist/tools/ai.js.map +1 -0
- package/dist/tools/ciba.d.ts +3 -0
- package/dist/tools/ciba.d.ts.map +1 -0
- package/dist/tools/ciba.js +85 -0
- package/dist/tools/ciba.js.map +1 -0
- package/dist/tools/clients.d.ts +3 -0
- package/dist/tools/clients.d.ts.map +1 -0
- package/dist/tools/clients.js +124 -0
- package/dist/tools/clients.js.map +1 -0
- package/dist/tools/developer.d.ts +3 -0
- package/dist/tools/developer.d.ts.map +1 -0
- package/dist/tools/developer.js +580 -0
- package/dist/tools/developer.js.map +1 -0
- package/dist/tools/fga.d.ts +3 -0
- package/dist/tools/fga.d.ts.map +1 -0
- package/dist/tools/fga.js +126 -0
- package/dist/tools/fga.js.map +1 -0
- package/dist/tools/groups.d.ts +3 -0
- package/dist/tools/groups.d.ts.map +1 -0
- package/dist/tools/groups.js +135 -0
- package/dist/tools/groups.js.map +1 -0
- package/dist/tools/idps.d.ts +3 -0
- package/dist/tools/idps.d.ts.map +1 -0
- package/dist/tools/idps.js +98 -0
- package/dist/tools/idps.js.map +1 -0
- package/dist/tools/orgs.d.ts +3 -0
- package/dist/tools/orgs.d.ts.map +1 -0
- package/dist/tools/orgs.js +90 -0
- package/dist/tools/orgs.js.map +1 -0
- package/dist/tools/pam.d.ts +3 -0
- package/dist/tools/pam.d.ts.map +1 -0
- package/dist/tools/pam.js +238 -0
- package/dist/tools/pam.js.map +1 -0
- package/dist/tools/policies.d.ts +3 -0
- package/dist/tools/policies.d.ts.map +1 -0
- package/dist/tools/policies.js +173 -0
- package/dist/tools/policies.js.map +1 -0
- package/dist/tools/ssf.d.ts +3 -0
- package/dist/tools/ssf.d.ts.map +1 -0
- package/dist/tools/ssf.js +65 -0
- package/dist/tools/ssf.js.map +1 -0
- package/dist/tools/users.d.ts +3 -0
- package/dist/tools/users.d.ts.map +1 -0
- package/dist/tools/users.js +144 -0
- package/dist/tools/users.js.map +1 -0
- package/package.json +48 -0
- package/src/client.ts +148 -0
- package/src/helpers.ts +45 -0
- package/src/index.ts +63 -0
- package/src/tools/access_reviews.ts +163 -0
- package/src/tools/ai.ts +581 -0
- package/src/tools/ciba.ts +109 -0
- package/src/tools/clients.ts +168 -0
- package/src/tools/developer.ts +661 -0
- package/src/tools/fga.ts +148 -0
- package/src/tools/groups.ts +200 -0
- package/src/tools/idps.ts +137 -0
- package/src/tools/orgs.ts +119 -0
- package/src/tools/pam.ts +285 -0
- package/src/tools/policies.ts +233 -0
- package/src/tools/ssf.ts +82 -0
- package/src/tools/users.ts +202 -0
- package/tsconfig.json +18 -0
|
@@ -0,0 +1,661 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { getClient } from "../client.js";
|
|
4
|
+
import { handleError } from "../helpers.js";
|
|
5
|
+
|
|
6
|
+
// ── Well-known OIDC error codes and their plain-English explanations ───────────
|
|
7
|
+
|
|
8
|
+
const OIDC_ERRORS: Record<string, { cause: string; fix: string }> = {
|
|
9
|
+
invalid_client: {
|
|
10
|
+
cause: "The client_id or client_secret provided is wrong, or the client is not registered.",
|
|
11
|
+
fix: "Double-check CLAVEX_CLIENT_ID and CLAVEX_CLIENT_SECRET. In Clavex, go to Applications → your app → Credentials.",
|
|
12
|
+
},
|
|
13
|
+
invalid_grant: {
|
|
14
|
+
cause: "The authorization code, refresh token, or device code is expired, already used, or was issued to a different client.",
|
|
15
|
+
fix: "Ensure you exchange the code only once. Refresh tokens are rotated — store the new one after each use. Codes expire in 5 minutes.",
|
|
16
|
+
},
|
|
17
|
+
unauthorized_client: {
|
|
18
|
+
cause: "The client is not allowed to use the requested grant type.",
|
|
19
|
+
fix: "In Clavex, open the client registration and add the required grant type under 'Grant Types'.",
|
|
20
|
+
},
|
|
21
|
+
access_denied: {
|
|
22
|
+
cause: "The user or the authorization server denied the request.",
|
|
23
|
+
fix: "This can also happen when the scope requested is not allowed for the client. Check allowed scopes in the client registration.",
|
|
24
|
+
},
|
|
25
|
+
unsupported_response_type: {
|
|
26
|
+
cause: "The response_type in the authorization request is not supported.",
|
|
27
|
+
fix: "Use response_type=code (Authorization Code flow). implicit flow (token / id_token) is disabled by default.",
|
|
28
|
+
},
|
|
29
|
+
unsupported_grant_type: {
|
|
30
|
+
cause: "The grant_type in the token request is not supported by this client.",
|
|
31
|
+
fix: "Add the required grant type to the client registration in Clavex.",
|
|
32
|
+
},
|
|
33
|
+
invalid_scope: {
|
|
34
|
+
cause: "One or more scopes in the request are not registered or not allowed for this client.",
|
|
35
|
+
fix: "Add the required scopes to the client registration, or remove them from your request. Standard scopes: openid, profile, email, offline_access.",
|
|
36
|
+
},
|
|
37
|
+
invalid_request: {
|
|
38
|
+
cause: "A required parameter is missing, duplicated, or otherwise malformed.",
|
|
39
|
+
fix: "Check all request parameters. Common issues: missing redirect_uri, duplicate parameters, malformed PKCE code_challenge.",
|
|
40
|
+
},
|
|
41
|
+
redirect_uri_mismatch: {
|
|
42
|
+
cause: "The redirect_uri in the token request does not exactly match the one registered for this client.",
|
|
43
|
+
fix: "The URI must be byte-for-byte identical (scheme, host, path, trailing slash). Update the registered redirect_uri in Clavex if needed.",
|
|
44
|
+
},
|
|
45
|
+
login_required: {
|
|
46
|
+
cause: "The authorization request had prompt=none but the user is not authenticated.",
|
|
47
|
+
fix: "Use prompt=none only for silent token renewal inside a hidden iframe. For first login, use prompt=login or omit prompt.",
|
|
48
|
+
},
|
|
49
|
+
consent_required: {
|
|
50
|
+
cause: "The authorization request had prompt=none but user consent is required.",
|
|
51
|
+
fix: "Clavex requires explicit user consent for third-party clients. Remove prompt=none or handle the consent flow.",
|
|
52
|
+
},
|
|
53
|
+
interaction_required: {
|
|
54
|
+
cause: "User interaction is required but prompt=none was specified.",
|
|
55
|
+
fix: "Remove prompt=none and allow the user to interact with the authorization page.",
|
|
56
|
+
},
|
|
57
|
+
server_error: {
|
|
58
|
+
cause: "An unexpected internal error occurred in the authorization server.",
|
|
59
|
+
fix: "Check Clavex server logs. This is a server-side issue, not a client misconfiguration.",
|
|
60
|
+
},
|
|
61
|
+
temporarily_unavailable: {
|
|
62
|
+
cause: "The server is temporarily overloaded or undergoing maintenance.",
|
|
63
|
+
fix: "Retry with exponential back-off. Check Clavex service health.",
|
|
64
|
+
},
|
|
65
|
+
expired_token: {
|
|
66
|
+
cause: "The device code or token has expired.",
|
|
67
|
+
fix: "Device codes expire after 10 minutes. Restart the device authorization flow.",
|
|
68
|
+
},
|
|
69
|
+
authorization_pending: {
|
|
70
|
+
cause: "The device code has been issued but the user has not yet approved the request.",
|
|
71
|
+
fix: "Keep polling the token endpoint at the specified interval. This is a normal state.",
|
|
72
|
+
},
|
|
73
|
+
slow_down: {
|
|
74
|
+
cause: "The device is polling the token endpoint too frequently.",
|
|
75
|
+
fix: "Increase the polling interval by at least 5 seconds as required by RFC 8628 §3.5.",
|
|
76
|
+
},
|
|
77
|
+
pkce_required: {
|
|
78
|
+
cause: "This client requires PKCE (Proof Key for Code Exchange) but none was provided.",
|
|
79
|
+
fix: "Generate a random code_verifier (43-128 chars), compute code_challenge = BASE64URL(SHA256(verifier)), and include both in the flow.",
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
// ── Framework-specific integration snippets ────────────────────────────────────
|
|
84
|
+
|
|
85
|
+
function nextjsSnippet(p: {
|
|
86
|
+
baseUrl: string;
|
|
87
|
+
clientId: string;
|
|
88
|
+
clientSecret: string;
|
|
89
|
+
orgSlug: string;
|
|
90
|
+
redirectUri: string;
|
|
91
|
+
}): string {
|
|
92
|
+
return `## Next.js Integration (App Router + next-auth v5)
|
|
93
|
+
|
|
94
|
+
### 1. Install dependencies
|
|
95
|
+
\`\`\`bash
|
|
96
|
+
npm install next-auth@beta @clavex/nextauth-provider
|
|
97
|
+
\`\`\`
|
|
98
|
+
|
|
99
|
+
### 2. Create \`auth.ts\` (project root)
|
|
100
|
+
\`\`\`ts
|
|
101
|
+
import NextAuth from "next-auth";
|
|
102
|
+
import Clavex from "@clavex/nextauth-provider";
|
|
103
|
+
|
|
104
|
+
export const { handlers, auth, signIn, signOut } = NextAuth({
|
|
105
|
+
providers: [
|
|
106
|
+
Clavex({
|
|
107
|
+
issuer: "${p.baseUrl}/${p.orgSlug}",
|
|
108
|
+
clientId: "${p.clientId}",
|
|
109
|
+
clientSecret: "${p.clientSecret}",
|
|
110
|
+
}),
|
|
111
|
+
],
|
|
112
|
+
});
|
|
113
|
+
\`\`\`
|
|
114
|
+
|
|
115
|
+
### 3. Create \`app/api/auth/[...nextauth]/route.ts\`
|
|
116
|
+
\`\`\`ts
|
|
117
|
+
export { handlers as GET, handlers as POST } from "../../../../auth";
|
|
118
|
+
\`\`\`
|
|
119
|
+
|
|
120
|
+
### 4. Add environment variables (\`.env.local\`)
|
|
121
|
+
\`\`\`env
|
|
122
|
+
AUTH_SECRET=<run: openssl rand -hex 32>
|
|
123
|
+
AUTH_CLAVEX_ID=${p.clientId}
|
|
124
|
+
AUTH_CLAVEX_SECRET=${p.clientSecret}
|
|
125
|
+
CLAVEX_ISSUER=${p.baseUrl}/${p.orgSlug}
|
|
126
|
+
\`\`\`
|
|
127
|
+
|
|
128
|
+
### 5. Protect a route (\`middleware.ts\`)
|
|
129
|
+
\`\`\`ts
|
|
130
|
+
export { auth as middleware } from "./auth";
|
|
131
|
+
export const config = { matcher: ["/dashboard/:path*"] };
|
|
132
|
+
\`\`\`
|
|
133
|
+
|
|
134
|
+
### 6. Use in a Server Component
|
|
135
|
+
\`\`\`tsx
|
|
136
|
+
import { auth } from "@/auth";
|
|
137
|
+
|
|
138
|
+
export default async function Page() {
|
|
139
|
+
const session = await auth();
|
|
140
|
+
if (!session) return <p>Not signed in</p>;
|
|
141
|
+
return <p>Hello, {session.user?.name}</p>;
|
|
142
|
+
}
|
|
143
|
+
\`\`\`
|
|
144
|
+
|
|
145
|
+
> **Redirect URI registered in Clavex:** \`${p.redirectUri}\`
|
|
146
|
+
> If your dev server runs on a different port, add that URI to the client too.`;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function reactSpaSnippet(p: {
|
|
150
|
+
baseUrl: string;
|
|
151
|
+
clientId: string;
|
|
152
|
+
orgSlug: string;
|
|
153
|
+
redirectUri: string;
|
|
154
|
+
}): string {
|
|
155
|
+
return `## React SPA Integration (@clavex/react)
|
|
156
|
+
|
|
157
|
+
### 1. Install dependencies
|
|
158
|
+
\`\`\`bash
|
|
159
|
+
npm install @clavex/react
|
|
160
|
+
\`\`\`
|
|
161
|
+
|
|
162
|
+
### 2. Wrap your app (\`main.tsx\`)
|
|
163
|
+
\`\`\`tsx
|
|
164
|
+
import { ClavexProvider } from "@clavex/react";
|
|
165
|
+
import { StrictMode } from "react";
|
|
166
|
+
import { createRoot } from "react-dom/client";
|
|
167
|
+
import App from "./App";
|
|
168
|
+
|
|
169
|
+
createRoot(document.getElementById("root")!).render(
|
|
170
|
+
<StrictMode>
|
|
171
|
+
<ClavexProvider
|
|
172
|
+
issuer="${p.baseUrl}/${p.orgSlug}"
|
|
173
|
+
clientId="${p.clientId}"
|
|
174
|
+
redirectUri="${p.redirectUri}"
|
|
175
|
+
scopes={["openid", "profile", "email"]}
|
|
176
|
+
>
|
|
177
|
+
<App />
|
|
178
|
+
</ClavexProvider>
|
|
179
|
+
</StrictMode>
|
|
180
|
+
);
|
|
181
|
+
\`\`\`
|
|
182
|
+
|
|
183
|
+
### 3. Use the auth hook
|
|
184
|
+
\`\`\`tsx
|
|
185
|
+
import { useClavex } from "@clavex/react";
|
|
186
|
+
|
|
187
|
+
export function NavBar() {
|
|
188
|
+
const { user, isLoading, signIn, signOut } = useClavex();
|
|
189
|
+
if (isLoading) return <span>Loading…</span>;
|
|
190
|
+
return user
|
|
191
|
+
? <button onClick={signOut}>Sign out ({user.email})</button>
|
|
192
|
+
: <button onClick={signIn}>Sign in</button>;
|
|
193
|
+
}
|
|
194
|
+
\`\`\`
|
|
195
|
+
|
|
196
|
+
### 4. Add environment variables (\`.env\`)
|
|
197
|
+
\`\`\`env
|
|
198
|
+
VITE_CLAVEX_ISSUER=${p.baseUrl}/${p.orgSlug}
|
|
199
|
+
VITE_CLAVEX_CLIENT_ID=${p.clientId}
|
|
200
|
+
VITE_CLAVEX_REDIRECT_URI=${p.redirectUri}
|
|
201
|
+
\`\`\`
|
|
202
|
+
|
|
203
|
+
> **Registered redirect URI:** \`${p.redirectUri}\`
|
|
204
|
+
> This is a **public client** (no secret). PKCE is enforced automatically.`;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function nodeExpressSnippet(p: {
|
|
208
|
+
baseUrl: string;
|
|
209
|
+
clientId: string;
|
|
210
|
+
clientSecret: string;
|
|
211
|
+
orgSlug: string;
|
|
212
|
+
redirectUri: string;
|
|
213
|
+
}): string {
|
|
214
|
+
return `## Node.js / Express Integration (openid-client)
|
|
215
|
+
|
|
216
|
+
### 1. Install dependencies
|
|
217
|
+
\`\`\`bash
|
|
218
|
+
npm install openid-client express express-session
|
|
219
|
+
\`\`\`
|
|
220
|
+
|
|
221
|
+
### 2. Configure OIDC client (\`auth.mjs\`)
|
|
222
|
+
\`\`\`js
|
|
223
|
+
import { Issuer } from "openid-client";
|
|
224
|
+
|
|
225
|
+
const issuer = await Issuer.discover("${p.baseUrl}/${p.orgSlug}");
|
|
226
|
+
|
|
227
|
+
export const oidcClient = new issuer.Client({
|
|
228
|
+
client_id: "${p.clientId}",
|
|
229
|
+
client_secret: "${p.clientSecret}",
|
|
230
|
+
redirect_uris: ["${p.redirectUri}"],
|
|
231
|
+
response_types: ["code"],
|
|
232
|
+
});
|
|
233
|
+
\`\`\`
|
|
234
|
+
|
|
235
|
+
### 3. Add login/callback routes (\`routes/auth.mjs\`)
|
|
236
|
+
\`\`\`js
|
|
237
|
+
import { generators } from "openid-client";
|
|
238
|
+
import { oidcClient } from "../auth.mjs";
|
|
239
|
+
|
|
240
|
+
router.get("/login", (req, res) => {
|
|
241
|
+
const nonce = generators.nonce();
|
|
242
|
+
req.session.nonce = nonce;
|
|
243
|
+
res.redirect(oidcClient.authorizationUrl({
|
|
244
|
+
scope: "openid profile email",
|
|
245
|
+
nonce,
|
|
246
|
+
}));
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
router.get("/callback", async (req, res) => {
|
|
250
|
+
const params = oidcClient.callbackParams(req);
|
|
251
|
+
const tokens = await oidcClient.callback("${p.redirectUri}", params, {
|
|
252
|
+
nonce: req.session.nonce,
|
|
253
|
+
});
|
|
254
|
+
req.session.user = tokens.claims();
|
|
255
|
+
res.redirect("/dashboard");
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
router.get("/logout", (req, res) => {
|
|
259
|
+
req.session.destroy();
|
|
260
|
+
res.redirect(oidcClient.endSessionUrl({ post_logout_redirect_uri: "http://localhost:3000" }));
|
|
261
|
+
});
|
|
262
|
+
\`\`\`
|
|
263
|
+
|
|
264
|
+
> **Redirect URI registered:** \`${p.redirectUri}\``;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function goSnippet(p: {
|
|
268
|
+
baseUrl: string;
|
|
269
|
+
clientId: string;
|
|
270
|
+
clientSecret: string;
|
|
271
|
+
orgSlug: string;
|
|
272
|
+
redirectUri: string;
|
|
273
|
+
}): string {
|
|
274
|
+
return `## Go Integration (golang.org/x/oauth2 + coreos/go-oidc)
|
|
275
|
+
|
|
276
|
+
### 1. Install dependencies
|
|
277
|
+
\`\`\`bash
|
|
278
|
+
go get golang.org/x/oauth2 github.com/coreos/go-oidc/v3/oidc
|
|
279
|
+
\`\`\`
|
|
280
|
+
|
|
281
|
+
### 2. Configure provider (\`auth/oidc.go\`)
|
|
282
|
+
\`\`\`go
|
|
283
|
+
package auth
|
|
284
|
+
|
|
285
|
+
import (
|
|
286
|
+
"context"
|
|
287
|
+
"golang.org/x/oauth2"
|
|
288
|
+
gooidc "github.com/coreos/go-oidc/v3/oidc"
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
const issuer = "${p.baseUrl}/${p.orgSlug}"
|
|
292
|
+
|
|
293
|
+
func NewProvider(ctx context.Context) (*gooidc.Provider, *oauth2.Config, error) {
|
|
294
|
+
provider, err := gooidc.NewProvider(ctx, issuer)
|
|
295
|
+
if err != nil {
|
|
296
|
+
return nil, nil, err
|
|
297
|
+
}
|
|
298
|
+
cfg := &oauth2.Config{
|
|
299
|
+
ClientID: "${p.clientId}",
|
|
300
|
+
ClientSecret: "${p.clientSecret}",
|
|
301
|
+
RedirectURL: "${p.redirectUri}",
|
|
302
|
+
Endpoint: provider.Endpoint(),
|
|
303
|
+
Scopes: []string{gooidc.ScopeOpenID, "profile", "email"},
|
|
304
|
+
}
|
|
305
|
+
return provider, cfg, nil
|
|
306
|
+
}
|
|
307
|
+
\`\`\`
|
|
308
|
+
|
|
309
|
+
> **Redirect URI registered:** \`${p.redirectUri}\``;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// ── Tool registration ──────────────────────────────────────────────────────────
|
|
313
|
+
|
|
314
|
+
export function registerDeveloperTools(server: McpServer): void {
|
|
315
|
+
|
|
316
|
+
// ─── clavex_integrate_app ──────────────────────────────────────────────────
|
|
317
|
+
server.registerTool(
|
|
318
|
+
"clavex_integrate_app",
|
|
319
|
+
{
|
|
320
|
+
title: "Integrate App with Clavex Auth",
|
|
321
|
+
description: `One-shot developer integration tool. Given a framework and a running app URL,
|
|
322
|
+
this tool:
|
|
323
|
+
1. Creates (or reuses) an OIDC client registration in Clavex
|
|
324
|
+
2. Returns copy-paste integration code for the specified framework
|
|
325
|
+
|
|
326
|
+
Supported frameworks: nextjs, react, express, go
|
|
327
|
+
|
|
328
|
+
Use when a developer says:
|
|
329
|
+
"add auth to my Next.js app"
|
|
330
|
+
"integrate Clavex into my React SPA at http://localhost:5173"
|
|
331
|
+
"set up login for my Express server"
|
|
332
|
+
"add Clavex authentication to my Go app"
|
|
333
|
+
|
|
334
|
+
Args:
|
|
335
|
+
org_id — Organization UUID to register the client in
|
|
336
|
+
app_url — The base URL of the app (e.g. http://localhost:3000). Used to
|
|
337
|
+
auto-derive the redirect_uri and post_logout_redirect_uri.
|
|
338
|
+
framework — One of: nextjs | react | express | go
|
|
339
|
+
app_name — Human-readable name for the client registration (e.g. "My Dashboard")
|
|
340
|
+
client_id — (optional) Reuse an existing client_id instead of creating a new one`,
|
|
341
|
+
inputSchema: {
|
|
342
|
+
org_id: z.string().uuid().describe("Organization UUID"),
|
|
343
|
+
app_url: z.string().url().describe("Base URL of the app, e.g. http://localhost:3000"),
|
|
344
|
+
framework: z.enum(["nextjs", "react", "express", "go"]).describe("Target framework"),
|
|
345
|
+
app_name: z.string().min(1).describe("Human-readable app name"),
|
|
346
|
+
client_id: z.string().optional().describe("Reuse existing client_id (skip registration)"),
|
|
347
|
+
},
|
|
348
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false },
|
|
349
|
+
},
|
|
350
|
+
async ({ org_id, app_url, framework, app_name, client_id: existingClientId }) =>
|
|
351
|
+
handleError(async () => {
|
|
352
|
+
const api = getClient();
|
|
353
|
+
const baseUrl = process.env.CLAVEX_BASE_URL!.replace(/\/+$/, "");
|
|
354
|
+
|
|
355
|
+
// Derive redirect URI from app URL
|
|
356
|
+
const appBase = app_url.replace(/\/+$/, "");
|
|
357
|
+
const redirectUri = framework === "nextjs"
|
|
358
|
+
? `${appBase}/api/auth/callback/clavex`
|
|
359
|
+
: `${appBase}/callback`;
|
|
360
|
+
const postLogoutUri = appBase;
|
|
361
|
+
|
|
362
|
+
// Look up the org to get its slug
|
|
363
|
+
const org = await api.get<{ id: string; slug: string; name: string }>(
|
|
364
|
+
`/api/v1/organizations/${org_id}`,
|
|
365
|
+
);
|
|
366
|
+
const orgSlug = org.slug;
|
|
367
|
+
|
|
368
|
+
let clientId: string;
|
|
369
|
+
let clientSecret: string | undefined;
|
|
370
|
+
let isNew = false;
|
|
371
|
+
|
|
372
|
+
if (existingClientId) {
|
|
373
|
+
// Reuse existing — just add the redirect URI if missing
|
|
374
|
+
const existing = await api.get<{ client_id: string; redirect_uris: string[] }>(
|
|
375
|
+
api.orgPath(org_id, `/clients/${existingClientId}`),
|
|
376
|
+
);
|
|
377
|
+
clientId = existing.client_id;
|
|
378
|
+
if (!existing.redirect_uris.includes(redirectUri)) {
|
|
379
|
+
await api.patch(api.orgPath(org_id, `/clients/${existingClientId}`), {
|
|
380
|
+
redirect_uris: [...existing.redirect_uris, redirectUri],
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
} else {
|
|
384
|
+
// Create a new client
|
|
385
|
+
const isPublic = framework === "react";
|
|
386
|
+
const grantTypes = isPublic
|
|
387
|
+
? ["authorization_code"]
|
|
388
|
+
: ["authorization_code", "refresh_token"];
|
|
389
|
+
|
|
390
|
+
const created = await api.post<{ client_id: string; client_secret?: string }>(
|
|
391
|
+
api.orgPath(org_id, "/clients"),
|
|
392
|
+
{
|
|
393
|
+
name: app_name,
|
|
394
|
+
redirect_uris: [redirectUri],
|
|
395
|
+
post_logout_redirect_uris: [postLogoutUri],
|
|
396
|
+
grant_types: grantTypes,
|
|
397
|
+
response_types: ["code"],
|
|
398
|
+
scopes: ["openid", "profile", "email"],
|
|
399
|
+
is_public: isPublic,
|
|
400
|
+
},
|
|
401
|
+
);
|
|
402
|
+
clientId = created.client_id;
|
|
403
|
+
clientSecret = created.client_secret;
|
|
404
|
+
isNew = true;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
const params = { baseUrl, clientId, clientSecret: clientSecret ?? "YOUR_CLIENT_SECRET", orgSlug, redirectUri };
|
|
408
|
+
|
|
409
|
+
let snippet = "";
|
|
410
|
+
switch (framework) {
|
|
411
|
+
case "nextjs": snippet = nextjsSnippet(params); break;
|
|
412
|
+
case "react": snippet = reactSpaSnippet(params); break;
|
|
413
|
+
case "express": snippet = nodeExpressSnippet(params); break;
|
|
414
|
+
case "go": snippet = goSnippet(params); break;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
const secretWarning = (isNew && clientSecret)
|
|
418
|
+
? `\n⚠️ **Save the client_secret now** — it will not be shown again:\n\`${clientSecret}\`\n`
|
|
419
|
+
: "";
|
|
420
|
+
|
|
421
|
+
return `## Clavex integration ready for **${app_name}** (${framework})
|
|
422
|
+
${secretWarning}
|
|
423
|
+
**client_id:** \`${clientId}\`
|
|
424
|
+
**org:** \`${orgSlug}\` (${org_id})
|
|
425
|
+
**redirect_uri registered:** \`${redirectUri}\`
|
|
426
|
+
|
|
427
|
+
---
|
|
428
|
+
|
|
429
|
+
${snippet}`;
|
|
430
|
+
}),
|
|
431
|
+
);
|
|
432
|
+
|
|
433
|
+
// ─── clavex_explain_oidc_error ─────────────────────────────────────────────
|
|
434
|
+
server.registerTool(
|
|
435
|
+
"clavex_explain_oidc_error",
|
|
436
|
+
{
|
|
437
|
+
title: "Explain OIDC / OAuth2 Error",
|
|
438
|
+
description: `Explain an OAuth2 / OIDC error code in plain English with a specific fix for Clavex.
|
|
439
|
+
|
|
440
|
+
Use when a developer sees an error like:
|
|
441
|
+
"invalid_client", "invalid_grant", "redirect_uri_mismatch",
|
|
442
|
+
"unauthorized_client", "invalid_scope", "slow_down", "pkce_required"
|
|
443
|
+
or any OAuth2/OIDC error string.
|
|
444
|
+
|
|
445
|
+
Also handles Clavex-specific HTTP error bodies (pass the full JSON body as error_body).
|
|
446
|
+
|
|
447
|
+
Args:
|
|
448
|
+
error_code — The OAuth2 error code string (e.g. "invalid_grant")
|
|
449
|
+
error_body — (optional) Full JSON response body from the server for extra context`,
|
|
450
|
+
inputSchema: {
|
|
451
|
+
error_code: z.string().describe('OAuth2 error code, e.g. "invalid_grant"'),
|
|
452
|
+
error_body: z.string().optional().describe("Full JSON error response body (optional)"),
|
|
453
|
+
},
|
|
454
|
+
annotations: { readOnlyHint: true, destructiveHint: false },
|
|
455
|
+
},
|
|
456
|
+
async ({ error_code, error_body }) =>
|
|
457
|
+
handleError(async () => {
|
|
458
|
+
const code = error_code.trim().toLowerCase();
|
|
459
|
+
const known = OIDC_ERRORS[code];
|
|
460
|
+
|
|
461
|
+
let bodyContext = "";
|
|
462
|
+
if (error_body) {
|
|
463
|
+
try {
|
|
464
|
+
const parsed = JSON.parse(error_body) as Record<string, unknown>;
|
|
465
|
+
if (parsed.error_description) {
|
|
466
|
+
bodyContext = `\n**Server message:** ${parsed.error_description}`;
|
|
467
|
+
}
|
|
468
|
+
} catch {
|
|
469
|
+
// ignore malformed JSON
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
if (!known) {
|
|
474
|
+
return `**Unknown error code: \`${error_code}\`**${bodyContext}
|
|
475
|
+
|
|
476
|
+
This error code is not in the standard OAuth2/OIDC specification. It may be:
|
|
477
|
+
- A Clavex-specific error — check the full response body and Clavex server logs
|
|
478
|
+
- A typo — common codes are: invalid_client, invalid_grant, redirect_uri_mismatch, invalid_scope
|
|
479
|
+
|
|
480
|
+
To dig deeper, enable debug logging in your Clavex instance and check the request that triggered this error.`;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
return `## \`${error_code}\`${bodyContext}
|
|
484
|
+
|
|
485
|
+
**What it means:** ${known.cause}
|
|
486
|
+
|
|
487
|
+
**How to fix it in Clavex:** ${known.fix}
|
|
488
|
+
|
|
489
|
+
**Quick checklist:**
|
|
490
|
+
- Open the Clavex admin panel → your organization → Applications
|
|
491
|
+
- Find the affected client and verify its configuration matches your code
|
|
492
|
+
- Compare the exact redirect_uri, scopes, and grant_types in your request vs. what is registered`;
|
|
493
|
+
}),
|
|
494
|
+
);
|
|
495
|
+
|
|
496
|
+
// ─── clavex_get_integration_config ────────────────────────────────────────
|
|
497
|
+
server.registerTool(
|
|
498
|
+
"clavex_get_integration_config",
|
|
499
|
+
{
|
|
500
|
+
title: "Get Integration Config for Existing Client",
|
|
501
|
+
description: `Retrieve the OIDC configuration needed to integrate an existing Clavex client
|
|
502
|
+
into an application. Returns the issuer URL, endpoints, client_id, and recommended
|
|
503
|
+
environment variable names for common frameworks.
|
|
504
|
+
|
|
505
|
+
Use when a developer asks:
|
|
506
|
+
"what are the OIDC settings for client <id>?"
|
|
507
|
+
"give me the .env variables to connect my app to Clavex"
|
|
508
|
+
"what's the discovery URL for my org?"
|
|
509
|
+
|
|
510
|
+
Args:
|
|
511
|
+
org_id — Organization UUID
|
|
512
|
+
client_id — The OIDC client_id`,
|
|
513
|
+
inputSchema: {
|
|
514
|
+
org_id: z.string().uuid().describe("Organization UUID"),
|
|
515
|
+
client_id: z.string().describe("OIDC client_id"),
|
|
516
|
+
},
|
|
517
|
+
annotations: { readOnlyHint: true, destructiveHint: false },
|
|
518
|
+
},
|
|
519
|
+
async ({ org_id, client_id }) =>
|
|
520
|
+
handleError(async () => {
|
|
521
|
+
const api = getClient();
|
|
522
|
+
const baseUrl = process.env.CLAVEX_BASE_URL!.replace(/\/+$/, "");
|
|
523
|
+
|
|
524
|
+
const [org, client] = await Promise.all([
|
|
525
|
+
api.get<{ id: string; slug: string; name: string }>(`/api/v1/organizations/${org_id}`),
|
|
526
|
+
api.get<{ client_id: string; name: string; redirect_uris: string[]; grant_types: string[]; is_public: boolean }>(
|
|
527
|
+
api.orgPath(org_id, `/clients/${client_id}`),
|
|
528
|
+
),
|
|
529
|
+
]);
|
|
530
|
+
|
|
531
|
+
const issuer = `${baseUrl}/${org.slug}`;
|
|
532
|
+
const discovery = `${issuer}/.well-known/openid-configuration`;
|
|
533
|
+
|
|
534
|
+
return `## OIDC Integration Config — ${client.name}
|
|
535
|
+
|
|
536
|
+
**Issuer:** \`${issuer}\`
|
|
537
|
+
**Discovery URL:** ${discovery}
|
|
538
|
+
**client_id:** \`${client.client_id}\`
|
|
539
|
+
**Registered redirect URIs:** ${client.redirect_uris.map((u) => `\`${u}\``).join(", ")}
|
|
540
|
+
**Grant types:** ${client.grant_types.join(", ")}
|
|
541
|
+
**Public client (no secret):** ${client.is_public ? "Yes" : "No"}
|
|
542
|
+
|
|
543
|
+
---
|
|
544
|
+
|
|
545
|
+
### Environment variables
|
|
546
|
+
|
|
547
|
+
#### Next.js / next-auth
|
|
548
|
+
\`\`\`env
|
|
549
|
+
AUTH_CLAVEX_ID=${client.client_id}
|
|
550
|
+
AUTH_CLAVEX_SECRET=<your_client_secret>
|
|
551
|
+
CLAVEX_ISSUER=${issuer}
|
|
552
|
+
\`\`\`
|
|
553
|
+
|
|
554
|
+
#### React SPA (Vite)
|
|
555
|
+
\`\`\`env
|
|
556
|
+
VITE_CLAVEX_ISSUER=${issuer}
|
|
557
|
+
VITE_CLAVEX_CLIENT_ID=${client.client_id}
|
|
558
|
+
\`\`\`
|
|
559
|
+
|
|
560
|
+
#### Generic OIDC (openid-client / any)
|
|
561
|
+
\`\`\`env
|
|
562
|
+
OIDC_ISSUER=${issuer}
|
|
563
|
+
OIDC_CLIENT_ID=${client.client_id}
|
|
564
|
+
OIDC_CLIENT_SECRET=<your_client_secret>
|
|
565
|
+
OIDC_REDIRECT_URI=${client.redirect_uris[0] ?? "http://localhost:3000/callback"}
|
|
566
|
+
\`\`\`
|
|
567
|
+
|
|
568
|
+
#### Go (coreos/go-oidc)
|
|
569
|
+
\`\`\`env
|
|
570
|
+
CLAVEX_ISSUER=${issuer}
|
|
571
|
+
CLAVEX_CLIENT_ID=${client.client_id}
|
|
572
|
+
CLAVEX_CLIENT_SECRET=<your_client_secret>
|
|
573
|
+
CLAVEX_REDIRECT_URI=${client.redirect_uris[0] ?? "http://localhost:8080/callback"}
|
|
574
|
+
\`\`\``;
|
|
575
|
+
}),
|
|
576
|
+
);
|
|
577
|
+
|
|
578
|
+
// ─── clavex_add_redirect_uri ───────────────────────────────────────────────
|
|
579
|
+
server.registerTool(
|
|
580
|
+
"clavex_add_redirect_uri",
|
|
581
|
+
{
|
|
582
|
+
title: "Add Redirect URI to Client",
|
|
583
|
+
description: `Add one or more redirect URIs (or post-logout redirect URIs) to an existing
|
|
584
|
+
OIDC client without touching any other settings.
|
|
585
|
+
|
|
586
|
+
Use when a developer says:
|
|
587
|
+
"add http://localhost:3001/callback to my client"
|
|
588
|
+
"I changed my app port, update the redirect URI"
|
|
589
|
+
"allow https://staging.example.com/api/auth/callback/clavex as a redirect"
|
|
590
|
+
|
|
591
|
+
Args:
|
|
592
|
+
org_id — Organization UUID
|
|
593
|
+
client_id — The OIDC client_id
|
|
594
|
+
redirect_uris — URIs to add (merged with existing ones)
|
|
595
|
+
post_logout_redirect_uris — (optional) Post-logout URIs to add`,
|
|
596
|
+
inputSchema: {
|
|
597
|
+
org_id: z.string().uuid().describe("Organization UUID"),
|
|
598
|
+
client_id: z.string().describe("OIDC client_id"),
|
|
599
|
+
redirect_uris: z.array(z.string().url()).min(1).describe("Redirect URIs to add"),
|
|
600
|
+
post_logout_redirect_uris: z.array(z.string().url()).optional().describe("Post-logout URIs to add"),
|
|
601
|
+
},
|
|
602
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true },
|
|
603
|
+
},
|
|
604
|
+
async ({ org_id, client_id, redirect_uris, post_logout_redirect_uris }) =>
|
|
605
|
+
handleError(async () => {
|
|
606
|
+
const api = getClient();
|
|
607
|
+
const existing = await api.get<{
|
|
608
|
+
redirect_uris: string[];
|
|
609
|
+
post_logout_redirect_uris?: string[];
|
|
610
|
+
}>(api.orgPath(org_id, `/clients/${client_id}`));
|
|
611
|
+
|
|
612
|
+
const merged = [...new Set([...existing.redirect_uris, ...redirect_uris])];
|
|
613
|
+
const mergedPLO = post_logout_redirect_uris
|
|
614
|
+
? [...new Set([...(existing.post_logout_redirect_uris ?? []), ...post_logout_redirect_uris])]
|
|
615
|
+
: existing.post_logout_redirect_uris;
|
|
616
|
+
|
|
617
|
+
const patch: Record<string, unknown> = { redirect_uris: merged };
|
|
618
|
+
if (mergedPLO) patch.post_logout_redirect_uris = mergedPLO;
|
|
619
|
+
|
|
620
|
+
await api.patch(api.orgPath(org_id, `/clients/${client_id}`), patch);
|
|
621
|
+
|
|
622
|
+
const added = redirect_uris.filter((u) => !existing.redirect_uris.includes(u));
|
|
623
|
+
return `Updated redirect URIs for client \`${client_id}\`.
|
|
624
|
+
|
|
625
|
+
**Added:** ${added.length ? added.map((u) => `\`${u}\``).join(", ") : "none (already registered)"}
|
|
626
|
+
**All redirect URIs now:** ${merged.map((u) => `\`${u}\``).join(", ")}`;
|
|
627
|
+
}),
|
|
628
|
+
);
|
|
629
|
+
|
|
630
|
+
// ─── clavex_whoami ─────────────────────────────────────────────────────────
|
|
631
|
+
server.registerTool(
|
|
632
|
+
"clavex_whoami",
|
|
633
|
+
{
|
|
634
|
+
title: "Show Current Clavex Identity",
|
|
635
|
+
description: `Show who the MCP server is authenticated as and which Clavex instance it is
|
|
636
|
+
connected to. Useful for orientation at the start of a session.
|
|
637
|
+
|
|
638
|
+
Use when a developer asks:
|
|
639
|
+
"which Clavex am I connected to?"
|
|
640
|
+
"what org am I in?"
|
|
641
|
+
"show my MCP connection details"`,
|
|
642
|
+
inputSchema: {},
|
|
643
|
+
annotations: { readOnlyHint: true, destructiveHint: false },
|
|
644
|
+
},
|
|
645
|
+
async () =>
|
|
646
|
+
handleError(async () => {
|
|
647
|
+
const api = getClient();
|
|
648
|
+
const baseUrl = process.env.CLAVEX_BASE_URL ?? "(not set)";
|
|
649
|
+
try {
|
|
650
|
+
const me = await api.get<{ email?: string; org_slug?: string; roles?: string[] }>("/api/v1/auth/me");
|
|
651
|
+
return `**Clavex instance:** ${baseUrl}
|
|
652
|
+
**Authenticated as:** ${me.email ?? "unknown"}
|
|
653
|
+
**Org:** ${me.org_slug ?? "superadmin (no org)"}
|
|
654
|
+
**Roles:** ${me.roles?.join(", ") ?? "—"}`;
|
|
655
|
+
} catch {
|
|
656
|
+
return `**Clavex instance:** ${baseUrl}
|
|
657
|
+
Could not fetch identity — check CLAVEX_TOKEN or CLAVEX_EMAIL/CLAVEX_PASSWORD.`;
|
|
658
|
+
}
|
|
659
|
+
}),
|
|
660
|
+
);
|
|
661
|
+
}
|