keryx 0.22.0 → 0.23.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.
@@ -13,5 +13,9 @@ export const configServerMcp = {
13
13
  60 * 60 * 24 * 30,
14
14
  ), // 30 days, in seconds
15
15
  oauthCodeTtl: await loadFromEnvIfSet("MCP_OAUTH_CODE_TTL", 300), // 5 minutes, in seconds
16
+ oauthRefreshTtl: await loadFromEnvIfSet(
17
+ "MCP_OAUTH_REFRESH_TTL",
18
+ 60 * 60 * 24 * 30,
19
+ ), // 30 days, in seconds
16
20
  markdownDepthLimit: await loadFromEnvIfSet("MCP_MARKDOWN_DEPTH_LIMIT", 5),
17
21
  };
@@ -10,10 +10,14 @@ import {
10
10
  import {
11
11
  handleAuthorizeGet,
12
12
  handleAuthorizePost,
13
+ handleIntrospect,
13
14
  handleMetadata,
14
15
  handleProtectedResourceMetadata,
15
16
  handleRegister,
17
+ handleRevoke,
16
18
  handleToken,
19
+ OAUTH_PATHS,
20
+ OAUTH_WELL_KNOWN_PATHS,
17
21
  type TokenData,
18
22
  } from "../util/oauthHandlers";
19
23
  import {
@@ -70,7 +74,7 @@ export class OAuthInitializer extends Initializer {
70
74
  return new Response(null, { status: 204, headers: corsHeaders });
71
75
  }
72
76
 
73
- const prmPrefix = "/.well-known/oauth-protected-resource";
77
+ const prmPrefix = OAUTH_WELL_KNOWN_PATHS.protectedResource;
74
78
  if (path.startsWith(prmPrefix) && method === "GET") {
75
79
  const resourcePath = path.slice(prmPrefix.length) || "";
76
80
  return appendHeaders(
@@ -79,7 +83,7 @@ export class OAuthInitializer extends Initializer {
79
83
  );
80
84
  }
81
85
  if (
82
- path === "/.well-known/oauth-authorization-server" &&
86
+ path === OAUTH_WELL_KNOWN_PATHS.authorizationServer &&
83
87
  method === "GET"
84
88
  ) {
85
89
  return appendHeaders(handleMetadata(origin), corsHeaders);
@@ -89,13 +93,15 @@ export class OAuthInitializer extends Initializer {
89
93
  if (
90
94
  config.rateLimit.enabled &&
91
95
  ip &&
92
- (path === "/oauth/register" ||
93
- path === "/oauth/authorize" ||
94
- path === "/oauth/token")
96
+ (path === OAUTH_PATHS.register ||
97
+ path === OAUTH_PATHS.authorize ||
98
+ path === OAUTH_PATHS.token ||
99
+ path === OAUTH_PATHS.introspect ||
100
+ path === OAUTH_PATHS.revoke)
95
101
  ) {
96
102
  // /oauth/register gets a stricter, dedicated rate limit
97
103
  const overrides =
98
- path === "/oauth/register"
104
+ path === OAUTH_PATHS.register
99
105
  ? {
100
106
  limit: config.rateLimit.oauthRegisterLimit,
101
107
  windowMs: config.rateLimit.oauthRegisterWindowMs,
@@ -123,18 +129,24 @@ export class OAuthInitializer extends Initializer {
123
129
  }
124
130
  }
125
131
 
126
- if (path === "/oauth/register" && method === "POST") {
132
+ if (path === OAUTH_PATHS.register && method === "POST") {
127
133
  return appendHeaders(await handleRegister(req), corsHeaders);
128
134
  }
129
- if (path === "/oauth/authorize" && method === "GET") {
135
+ if (path === OAUTH_PATHS.authorize && method === "GET") {
130
136
  return handleAuthorizeGet(url, templates);
131
137
  }
132
- if (path === "/oauth/authorize" && method === "POST") {
138
+ if (path === OAUTH_PATHS.authorize && method === "POST") {
133
139
  return handleAuthorizePost(req, templates);
134
140
  }
135
- if (path === "/oauth/token" && method === "POST") {
141
+ if (path === OAUTH_PATHS.token && method === "POST") {
136
142
  return appendHeaders(await handleToken(req), corsHeaders);
137
143
  }
144
+ if (path === OAUTH_PATHS.introspect && method === "POST") {
145
+ return appendHeaders(await handleIntrospect(req), corsHeaders);
146
+ }
147
+ if (path === OAUTH_PATHS.revoke && method === "POST") {
148
+ return appendHeaders(await handleRevoke(req), corsHeaders);
149
+ }
138
150
 
139
151
  return null;
140
152
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "keryx",
3
- "version": "0.22.0",
3
+ "version": "0.23.0",
4
4
  "module": "index.ts",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/testing/index.ts CHANGED
@@ -2,6 +2,14 @@ import { afterAll, beforeAll } from "bun:test";
2
2
  import { api } from "../api";
3
3
  import type { WebServer } from "../servers/web";
4
4
 
5
+ export {
6
+ buildWebSocket,
7
+ createSession,
8
+ createUser,
9
+ subscribeToChannel,
10
+ waitForBroadcastMessages,
11
+ } from "./websocket";
12
+
5
13
  /**
6
14
  * Generous lifecycle hook timeout (15s) for `beforeAll` / `afterAll`.
7
15
  *
@@ -0,0 +1,171 @@
1
+ import { expect } from "bun:test";
2
+ import { api } from "../api";
3
+ import type { WebServer } from "../servers/web";
4
+
5
+ const wsUrl = () => {
6
+ const web = api.servers.servers.find(
7
+ (s: { name: string }) => s.name === "web",
8
+ ) as WebServer | undefined;
9
+ return (web?.url || "")
10
+ .replace("https://", "wss://")
11
+ .replace("http://", "ws://");
12
+ };
13
+
14
+ /**
15
+ * Open a WebSocket against the running test server and return the socket along
16
+ * with a mutable array that accumulates every `message` event as it arrives.
17
+ *
18
+ * The promise resolves once the socket's `open` event fires, so callers can
19
+ * immediately send actions without an additional readiness check.
20
+ *
21
+ * @param options.headers - Request headers to include in the WebSocket upgrade
22
+ * (for example a session cookie). Optional.
23
+ * @returns An object with the open `socket` and the live `messages` array that
24
+ * every subsequent handler populates.
25
+ */
26
+ export const buildWebSocket = async (
27
+ options: { headers?: Record<string, string> } = {},
28
+ ) => {
29
+ const socket = new WebSocket(wsUrl(), { headers: options.headers });
30
+ const messages: MessageEvent[] = [];
31
+ socket.addEventListener("message", (event) => {
32
+ messages.push(event);
33
+ });
34
+ socket.addEventListener("error", (event) => {
35
+ console.error(event);
36
+ });
37
+ await new Promise((resolve) => {
38
+ socket.addEventListener("open", resolve);
39
+ });
40
+ return { socket, messages };
41
+ };
42
+
43
+ /**
44
+ * Send a `user:create` action over the given WebSocket and return the created
45
+ * user from the server's response.
46
+ *
47
+ * Assumes this is the first action sent on the socket — it reads `messages[0]`.
48
+ *
49
+ * @throws {Error} If the server responds with an error payload.
50
+ */
51
+ export const createUser = async (
52
+ socket: WebSocket,
53
+ messages: MessageEvent[],
54
+ name: string,
55
+ email: string,
56
+ password: string,
57
+ ) => {
58
+ socket.send(
59
+ JSON.stringify({
60
+ messageType: "action",
61
+ action: "user:create",
62
+ messageId: 1,
63
+ params: { name, email, password },
64
+ }),
65
+ );
66
+
67
+ while (messages.length === 0) await Bun.sleep(10);
68
+ const response = JSON.parse(messages[0].data);
69
+
70
+ if (response.error) {
71
+ throw new Error(`User creation failed: ${response.error.message}`);
72
+ }
73
+
74
+ return response.response.user;
75
+ };
76
+
77
+ /**
78
+ * Send a `session:create` action over the given WebSocket and return the
79
+ * response payload (user + session).
80
+ *
81
+ * Assumes `createUser` was invoked first — it reads `messages[1]`.
82
+ *
83
+ * @throws {Error} If the server responds with an error payload.
84
+ */
85
+ export const createSession = async (
86
+ socket: WebSocket,
87
+ messages: MessageEvent[],
88
+ email: string,
89
+ password: string,
90
+ ) => {
91
+ socket.send(
92
+ JSON.stringify({
93
+ messageType: "action",
94
+ action: "session:create",
95
+ messageId: 2,
96
+ params: { email, password },
97
+ }),
98
+ );
99
+
100
+ while (messages.length < 2) await Bun.sleep(10);
101
+ const response = JSON.parse(messages[1].data);
102
+
103
+ if (response.error) {
104
+ throw new Error(`Session creation failed: ${response.error.message}`);
105
+ }
106
+
107
+ return response.response;
108
+ };
109
+
110
+ /**
111
+ * Subscribe the socket to a channel and wait for the server's subscribe
112
+ * confirmation.
113
+ *
114
+ * Matches the confirmation by content rather than index, because presence
115
+ * broadcast events (join/leave) delivered via Redis pub/sub can arrive before
116
+ * the subscribe confirmation and shift message indices.
117
+ */
118
+ export const subscribeToChannel = async (
119
+ socket: WebSocket,
120
+ messages: MessageEvent[],
121
+ channel: string,
122
+ ) => {
123
+ socket.send(JSON.stringify({ messageType: "subscribe", channel }));
124
+
125
+ let response: Record<string, any> | undefined;
126
+ while (!response) {
127
+ for (const m of messages) {
128
+ const parsed = JSON.parse(m.data);
129
+ if (parsed.subscribed?.channel === channel) {
130
+ response = parsed;
131
+ break;
132
+ }
133
+ }
134
+ if (!response) await Bun.sleep(10);
135
+ }
136
+ return response;
137
+ };
138
+
139
+ /**
140
+ * Wait briefly and return all broadcast (non-action-reply) messages received on
141
+ * the socket so far, asserting the expected count.
142
+ *
143
+ * Broadcasts are distinguished from action replies by the absence of a
144
+ * `messageId` field. Uses `expect()` internally so callers see a readable
145
+ * failure with the raw broadcast payload dumped to stderr on mismatch.
146
+ *
147
+ * @throws {Error} When the observed broadcast count does not equal
148
+ * `expectedCount`.
149
+ */
150
+ export const waitForBroadcastMessages = async (
151
+ messages: MessageEvent[],
152
+ expectedCount: number,
153
+ ) => {
154
+ await Bun.sleep(100);
155
+
156
+ const broadcastMessages: Record<string, any>[] = [];
157
+ for (const message of messages) {
158
+ const parsedMessage = JSON.parse(message.data);
159
+ if (!parsedMessage.messageId) {
160
+ broadcastMessages.push(parsedMessage);
161
+ }
162
+ }
163
+
164
+ try {
165
+ expect(broadcastMessages.length).toBe(expectedCount);
166
+ } catch (e) {
167
+ console.error(JSON.stringify(broadcastMessages, null, 2));
168
+ throw e;
169
+ }
170
+ return broadcastMessages;
171
+ };
@@ -0,0 +1,170 @@
1
+ import { randomUUID } from "crypto";
2
+ import { api } from "../../api";
3
+ import type { Action, OAuthActionResponse } from "../../classes/Action";
4
+ import { Connection } from "../../classes/Connection";
5
+ import { config } from "../../config";
6
+ import { redirectUrisMatch } from "../oauth";
7
+ import {
8
+ type AuthPageParams,
9
+ type OAuthTemplates,
10
+ renderAuthPage,
11
+ renderSuccessPage,
12
+ } from "../oauthTemplates";
13
+ import { clientKey, codeKey } from "./keys";
14
+ import type { AuthCode, OAuthClient } from "./types";
15
+
16
+ /** OAuth protocol fields that should not be forwarded to login/signup actions. */
17
+ const OAUTH_FIELDS = new Set([
18
+ "mode",
19
+ "client_id",
20
+ "redirect_uri",
21
+ "code_challenge",
22
+ "code_challenge_method",
23
+ "response_type",
24
+ "state",
25
+ ]);
26
+
27
+ function findAuthActions() {
28
+ return {
29
+ loginAction: api.actions.actions.find((a: Action) => a.mcp?.isLoginAction),
30
+ signupAction: api.actions.actions.find(
31
+ (a: Action) => a.mcp?.isSignupAction,
32
+ ),
33
+ };
34
+ }
35
+
36
+ /** Render the OAuth authorize page (GET). */
37
+ export function handleAuthorizeGet(
38
+ url: URL,
39
+ templates: OAuthTemplates,
40
+ ): Response {
41
+ const params: AuthPageParams = {
42
+ clientId: url.searchParams.get("client_id") ?? "",
43
+ redirectUri: url.searchParams.get("redirect_uri") ?? "",
44
+ codeChallenge: url.searchParams.get("code_challenge") ?? "",
45
+ codeChallengeMethod: url.searchParams.get("code_challenge_method") ?? "",
46
+ responseType: url.searchParams.get("response_type") ?? "",
47
+ state: url.searchParams.get("state") ?? "",
48
+ error: "",
49
+ };
50
+
51
+ return renderAuthPage(params, templates, findAuthActions());
52
+ }
53
+
54
+ /**
55
+ * Run the registered login or signup action inside an ephemeral OAuth
56
+ * connection and return the authenticated user id. Returns an error string if
57
+ * the action is not configured or the action itself returned an error.
58
+ */
59
+ async function runAuthAction(
60
+ mode: "signup" | "login",
61
+ fields: Record<string, string>,
62
+ ): Promise<{ userId: number } | { error: string }> {
63
+ const isSignup = mode === "signup";
64
+ const action = api.actions.actions.find((a: Action) =>
65
+ isSignup ? a.mcp?.isSignupAction : a.mcp?.isLoginAction,
66
+ );
67
+ if (!action) {
68
+ return {
69
+ error: isSignup
70
+ ? "No signup action configured"
71
+ : "No login action configured",
72
+ };
73
+ }
74
+
75
+ const actionParams: Record<string, unknown> = {};
76
+ for (const [key, value] of Object.entries(fields)) {
77
+ if (!OAUTH_FIELDS.has(key)) actionParams[key] = value;
78
+ }
79
+
80
+ const connection = new Connection(
81
+ "oauth",
82
+ isSignup ? "oauth-signup" : "oauth-login",
83
+ );
84
+ try {
85
+ const { response, error } = await connection.act(action.name, actionParams);
86
+ if (error) return { error: error.message };
87
+ return { userId: (response as OAuthActionResponse).user.id };
88
+ } finally {
89
+ connection.destroy();
90
+ }
91
+ }
92
+
93
+ /** Handle the OAuth authorize form POST (signin/signup). */
94
+ export async function handleAuthorizePost(
95
+ req: Request,
96
+ templates: OAuthTemplates,
97
+ ): Promise<Response> {
98
+ let fields: Record<string, string>;
99
+ try {
100
+ const contentType = req.headers.get("content-type") ?? "";
101
+ if (contentType.includes("application/x-www-form-urlencoded")) {
102
+ const text = await req.text();
103
+ const params = new URLSearchParams(text);
104
+ fields = Object.fromEntries(params.entries());
105
+ } else {
106
+ const form = await req.formData();
107
+ fields = {};
108
+ form.forEach((value, key) => {
109
+ fields[key] = String(value);
110
+ });
111
+ }
112
+ } catch {
113
+ return new Response("Bad Request", { status: 400 });
114
+ }
115
+
116
+ const oauthParams: AuthPageParams = {
117
+ clientId: fields.client_id ?? "",
118
+ redirectUri: fields.redirect_uri ?? "",
119
+ codeChallenge: fields.code_challenge ?? "",
120
+ codeChallengeMethod: fields.code_challenge_method ?? "",
121
+ responseType: fields.response_type ?? "",
122
+ state: fields.state ?? "",
123
+ error: "",
124
+ };
125
+ const authActions = findAuthActions();
126
+
127
+ const renderError = (error: string) => {
128
+ oauthParams.error = error;
129
+ return renderAuthPage(oauthParams, templates, authActions);
130
+ };
131
+
132
+ const clientRaw = await api.redis.redis.get(clientKey(oauthParams.clientId));
133
+ if (!clientRaw) return renderError("Unknown client");
134
+ const client = JSON.parse(clientRaw) as OAuthClient;
135
+
136
+ const uriMatch = client.redirect_uris.some((registered) =>
137
+ redirectUrisMatch(registered, oauthParams.redirectUri),
138
+ );
139
+ if (!uriMatch) return renderError("Invalid redirect URI");
140
+
141
+ if (oauthParams.codeChallengeMethod !== "S256") {
142
+ return renderError("code_challenge_method must be S256");
143
+ }
144
+
145
+ const mode = fields.mode === "signup" ? "signup" : "login";
146
+ const result = await runAuthAction(mode, fields);
147
+ if ("error" in result) return renderError(result.error);
148
+
149
+ const code = randomUUID();
150
+ const codeData: AuthCode = {
151
+ clientId: oauthParams.clientId,
152
+ userId: result.userId,
153
+ codeChallenge: oauthParams.codeChallenge,
154
+ redirectUri: oauthParams.redirectUri,
155
+ };
156
+
157
+ await api.redis.redis.set(
158
+ codeKey(code),
159
+ JSON.stringify(codeData),
160
+ "EX",
161
+ config.server.mcp.oauthCodeTtl,
162
+ );
163
+
164
+ const redirectUrl = new URL(oauthParams.redirectUri);
165
+ redirectUrl.searchParams.set("code", code);
166
+ if (oauthParams.state)
167
+ redirectUrl.searchParams.set("state", oauthParams.state);
168
+
169
+ return renderSuccessPage(redirectUrl.toString(), templates);
170
+ }
@@ -0,0 +1,17 @@
1
+ export { handleAuthorizeGet, handleAuthorizePost } from "./authorize";
2
+ export { handleIntrospect } from "./introspect";
3
+ export {
4
+ handleMetadata,
5
+ handleProtectedResourceMetadata,
6
+ } from "./metadata";
7
+ export { handleRegister } from "./register";
8
+ export { handleRevoke } from "./revoke";
9
+ export { handleToken } from "./token";
10
+ export {
11
+ type AuthCode,
12
+ OAUTH_PATHS,
13
+ OAUTH_WELL_KNOWN_PATHS,
14
+ type OAuthClient,
15
+ type RefreshTokenData,
16
+ type TokenData,
17
+ } from "./types";
@@ -0,0 +1,59 @@
1
+ import { api } from "../../api";
2
+ import { clientKey, tokenLookupOrder } from "./keys";
3
+ import { jsonNoStoreResponse, oauthError, parseFormBody } from "./responses";
4
+ import type { RefreshTokenData, TokenData } from "./types";
5
+
6
+ const NOSTORE_HEADER = { "Cache-Control": "no-store" };
7
+
8
+ /**
9
+ * OAuth 2.0 Token Introspection endpoint (RFC 7662).
10
+ *
11
+ * Requires a registered `client_id` in the form body; unknown clients get 401
12
+ * so random callers cannot probe token state. Looks up `token` in both the
13
+ * access-token and refresh-token keyspaces (honoring `token_type_hint` for
14
+ * lookup order). Returns `{ active: false }` for any miss so we do not leak
15
+ * whether a token existed and expired versus never existed.
16
+ */
17
+ export async function handleIntrospect(req: Request): Promise<Response> {
18
+ const body = await parseFormBody(req);
19
+ const token = body.get("token");
20
+ const hint = body.get("token_type_hint");
21
+ const clientId = body.get("client_id");
22
+
23
+ if (!clientId) {
24
+ return oauthError(
25
+ "invalid_client",
26
+ "client_id is required",
27
+ 401,
28
+ NOSTORE_HEADER,
29
+ );
30
+ }
31
+
32
+ const clientRaw = await api.redis.redis.get(clientKey(clientId));
33
+ if (!clientRaw) {
34
+ return oauthError("invalid_client", "Unknown client", 401, NOSTORE_HEADER);
35
+ }
36
+
37
+ if (!token) return jsonNoStoreResponse({ active: false });
38
+
39
+ for (const key of tokenLookupOrder(token, hint)) {
40
+ const raw = await api.redis.redis.get(key);
41
+ if (!raw) continue;
42
+
43
+ const data = JSON.parse(raw) as TokenData | RefreshTokenData;
44
+ const ttlSeconds = await api.redis.redis.ttl(key);
45
+ const nowSeconds = Math.floor(Date.now() / 1000);
46
+ const exp = ttlSeconds > 0 ? nowSeconds + ttlSeconds : undefined;
47
+
48
+ return jsonNoStoreResponse({
49
+ active: true,
50
+ client_id: data.clientId,
51
+ scope: data.scopes.join(" "),
52
+ token_type: "Bearer",
53
+ exp,
54
+ sub: String(data.userId),
55
+ });
56
+ }
57
+
58
+ return jsonNoStoreResponse({ active: false });
59
+ }
@@ -0,0 +1,19 @@
1
+ /** Redis key builders for OAuth records. */
2
+ export const clientKey = (id: string) => `oauth:client:${id}`;
3
+ export const codeKey = (code: string) => `oauth:code:${code}`;
4
+ export const accessKey = (token: string) => `oauth:token:${token}`;
5
+ export const refreshKey = (token: string) => `oauth:refresh:${token}`;
6
+
7
+ /**
8
+ * When looking up an unknown bearer token (introspect/revoke), the caller may
9
+ * pass `token_type_hint` to hint at which keyspace to try first. Tokens are
10
+ * UUIDs so there is no collision risk; the hint is purely an optimization.
11
+ */
12
+ export function tokenLookupOrder(
13
+ token: string,
14
+ hint: string | null,
15
+ ): [string, string] {
16
+ return hint === "refresh_token"
17
+ ? [refreshKey(token), accessKey(token)]
18
+ : [accessKey(token), refreshKey(token)];
19
+ }
@@ -0,0 +1,38 @@
1
+ import { jsonResponse } from "./responses";
2
+ import { OAUTH_PATHS } from "./types";
3
+
4
+ /**
5
+ * RFC 9728 — Protected Resource Metadata.
6
+ * MCP clients fetch this first to discover the authorization server.
7
+ */
8
+ export function handleProtectedResourceMetadata(
9
+ origin: string,
10
+ resourcePath: string,
11
+ ): Response {
12
+ const resource = resourcePath ? `${origin}${resourcePath}` : origin;
13
+ return jsonResponse({
14
+ resource,
15
+ authorization_servers: [origin],
16
+ scopes_supported: ["mcp"],
17
+ });
18
+ }
19
+
20
+ /** OAuth 2.1 authorization server metadata endpoint (RFC 8414). */
21
+ export function handleMetadata(origin: string): Response {
22
+ const issuer = origin;
23
+ return jsonResponse({
24
+ issuer,
25
+ authorization_endpoint: `${issuer}${OAUTH_PATHS.authorize}`,
26
+ token_endpoint: `${issuer}${OAUTH_PATHS.token}`,
27
+ registration_endpoint: `${issuer}${OAUTH_PATHS.register}`,
28
+ introspection_endpoint: `${issuer}${OAUTH_PATHS.introspect}`,
29
+ revocation_endpoint: `${issuer}${OAUTH_PATHS.revoke}`,
30
+ response_types_supported: ["code"],
31
+ grant_types_supported: ["authorization_code", "refresh_token"],
32
+ code_challenge_methods_supported: ["S256"],
33
+ token_endpoint_auth_methods_supported: ["none"],
34
+ introspection_endpoint_auth_methods_supported: ["none"],
35
+ revocation_endpoint_auth_methods_supported: ["none"],
36
+ client_id_metadata_document_supported: false,
37
+ });
38
+ }
@@ -0,0 +1,58 @@
1
+ import { randomUUID } from "crypto";
2
+ import { api } from "../../api";
3
+ import { config } from "../../config";
4
+ import { validateRedirectUri } from "../oauth";
5
+ import { clientKey } from "./keys";
6
+ import { jsonResponse, oauthError } from "./responses";
7
+ import type { OAuthClient } from "./types";
8
+
9
+ /** Dynamic client registration endpoint (RFC 7591). */
10
+ export async function handleRegister(req: Request): Promise<Response> {
11
+ let body: any;
12
+ try {
13
+ body = await req.json();
14
+ } catch {
15
+ return oauthError("invalid_request", "Invalid JSON body");
16
+ }
17
+
18
+ if (
19
+ !body.redirect_uris ||
20
+ !Array.isArray(body.redirect_uris) ||
21
+ body.redirect_uris.length === 0
22
+ ) {
23
+ return oauthError("invalid_request", "redirect_uris is required");
24
+ }
25
+
26
+ for (const uri of body.redirect_uris) {
27
+ if (typeof uri !== "string") {
28
+ return oauthError(
29
+ "invalid_request",
30
+ "Each redirect_uri must be a string",
31
+ );
32
+ }
33
+ const validation = validateRedirectUri(uri);
34
+ if (!validation.valid) {
35
+ // `error` is always populated when `valid` is false; see oauth.ts
36
+ return oauthError("invalid_request", validation.error!);
37
+ }
38
+ }
39
+
40
+ const clientId = randomUUID();
41
+ const client: OAuthClient = {
42
+ client_id: clientId,
43
+ redirect_uris: body.redirect_uris,
44
+ client_name: body.client_name,
45
+ grant_types: body.grant_types ?? ["authorization_code"],
46
+ response_types: body.response_types ?? ["code"],
47
+ token_endpoint_auth_method: body.token_endpoint_auth_method ?? "none",
48
+ };
49
+
50
+ await api.redis.redis.set(
51
+ clientKey(clientId),
52
+ JSON.stringify(client),
53
+ "EX",
54
+ config.server.mcp.oauthClientTtl,
55
+ );
56
+
57
+ return jsonResponse(client, 201);
58
+ }