@softure-ai/mcp-access 0.1.6 → 0.1.8
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/CHANGELOG.md +30 -0
- package/README.md +146 -21
- package/adoption/move-app-tables.sql +100 -0
- package/dist/contract.d.ts +13 -1
- package/dist/contract.d.ts.map +1 -1
- package/dist/index.d.ts +58 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +26 -5
- package/dist/index.js.map +1 -1
- package/dist/messages/en.d.ts +28 -0
- package/dist/messages/en.d.ts.map +1 -1
- package/dist/messages/en.js +28 -0
- package/dist/messages/en.js.map +1 -1
- package/dist/messages/index.d.ts +56 -0
- package/dist/messages/index.d.ts.map +1 -1
- package/dist/messages/pl.d.ts.map +1 -1
- package/dist/messages/pl.js +28 -0
- package/dist/messages/pl.js.map +1 -1
- package/dist/next/actions.d.ts +3 -1
- package/dist/next/actions.d.ts.map +1 -1
- package/dist/next/actions.js +23 -2
- package/dist/next/actions.js.map +1 -1
- package/dist/next/context.d.ts +7 -1
- package/dist/next/context.d.ts.map +1 -1
- package/dist/next/context.js +12 -10
- package/dist/next/context.js.map +1 -1
- package/dist/next/index.d.ts +4 -1
- package/dist/next/index.d.ts.map +1 -1
- package/dist/next/index.js +5 -2
- package/dist/next/index.js.map +1 -1
- package/dist/next/oauth-page.d.ts +7 -0
- package/dist/next/oauth-page.d.ts.map +1 -0
- package/dist/next/oauth-page.js +36 -0
- package/dist/next/oauth-page.js.map +1 -0
- package/dist/next/oauth-routes.d.ts +19 -0
- package/dist/next/oauth-routes.d.ts.map +1 -0
- package/dist/next/oauth-routes.js +53 -0
- package/dist/next/oauth-routes.js.map +1 -0
- package/dist/next/pages.d.ts.map +1 -1
- package/dist/next/pages.js +13 -2
- package/dist/next/pages.js.map +1 -1
- package/dist/options.d.ts +40 -0
- package/dist/options.d.ts.map +1 -1
- package/dist/options.js +94 -1
- package/dist/options.js.map +1 -1
- package/dist/origins.d.ts +27 -0
- package/dist/origins.d.ts.map +1 -0
- package/dist/origins.js +32 -0
- package/dist/origins.js.map +1 -0
- package/dist/schema.d.ts +486 -0
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +45 -3
- package/dist/schema.js.map +1 -1
- package/dist/server/context.d.ts +4 -0
- package/dist/server/context.d.ts.map +1 -0
- package/dist/server/context.js +13 -0
- package/dist/server/context.js.map +1 -0
- package/dist/server/endpoint.d.ts +2 -4
- package/dist/server/endpoint.d.ts.map +1 -1
- package/dist/server/endpoint.js +14 -9
- package/dist/server/endpoint.js.map +1 -1
- package/dist/server/health.d.ts.map +1 -1
- package/dist/server/health.js +5 -3
- package/dist/server/health.js.map +1 -1
- package/dist/server/index.d.ts +8 -0
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +7 -0
- package/dist/server/index.js.map +1 -1
- package/dist/server/oauth-http.d.ts +127 -0
- package/dist/server/oauth-http.d.ts.map +1 -0
- package/dist/server/oauth-http.js +397 -0
- package/dist/server/oauth-http.js.map +1 -0
- package/dist/server/oauth-validation.d.ts +28 -0
- package/dist/server/oauth-validation.d.ts.map +1 -0
- package/dist/server/oauth-validation.js +88 -0
- package/dist/server/oauth-validation.js.map +1 -0
- package/dist/server/oauth.d.ts +106 -0
- package/dist/server/oauth.d.ts.map +1 -0
- package/dist/server/oauth.js +235 -0
- package/dist/server/oauth.js.map +1 -0
- package/dist/server/options.d.ts +11 -2
- package/dist/server/options.d.ts.map +1 -1
- package/dist/server/options.js +11 -4
- package/dist/server/options.js.map +1 -1
- package/dist/server/origins.d.ts +8 -0
- package/dist/server/origins.d.ts.map +1 -0
- package/dist/server/origins.js +15 -0
- package/dist/server/origins.js.map +1 -0
- package/dist/server/pkce.d.ts +7 -0
- package/dist/server/pkce.d.ts.map +1 -0
- package/dist/server/pkce.js +24 -0
- package/dist/server/pkce.js.map +1 -0
- package/dist/server/privacy.d.ts +7 -0
- package/dist/server/privacy.d.ts.map +1 -1
- package/dist/server/privacy.js +19 -9
- package/dist/server/privacy.js.map +1 -1
- package/dist/server/scopes.d.ts +5 -0
- package/dist/server/scopes.d.ts.map +1 -0
- package/dist/server/scopes.js +6 -0
- package/dist/server/scopes.js.map +1 -0
- package/dist/server/tokens.d.ts +18 -6
- package/dist/server/tokens.d.ts.map +1 -1
- package/dist/server/tokens.js +60 -25
- package/dist/server/tokens.js.map +1 -1
- package/dist/ui/consent-form.d.ts +31 -0
- package/dist/ui/consent-form.d.ts.map +1 -0
- package/dist/ui/consent-form.js +24 -0
- package/dist/ui/consent-form.js.map +1 -0
- package/dist/ui/index.d.ts +2 -1
- package/dist/ui/index.d.ts.map +1 -1
- package/dist/ui/index.js +1 -0
- package/dist/ui/index.js.map +1 -1
- package/dist/ui/token-manager.d.ts +16 -2
- package/dist/ui/token-manager.d.ts.map +1 -1
- package/dist/ui/token-manager.js +6 -2
- package/dist/ui/token-manager.js.map +1 -1
- package/migrations/0002_create_oauth_grants.sql +68 -0
- package/module.json +18 -4
- package/package.json +2 -1
- package/src/contract.ts +16 -1
- package/src/index.ts +33 -4
- package/src/messages/en.ts +28 -0
- package/src/messages/pl.ts +28 -0
- package/src/next/actions.ts +23 -3
- package/src/next/context.ts +15 -8
- package/src/next/index.ts +12 -2
- package/src/next/oauth-page.tsx +68 -0
- package/src/next/oauth-routes.ts +73 -0
- package/src/next/pages.tsx +16 -3
- package/src/options.ts +107 -1
- package/src/origins.ts +48 -0
- package/src/schema.ts +51 -3
- package/src/server/context.ts +14 -0
- package/src/server/endpoint.ts +14 -9
- package/src/server/health.ts +5 -3
- package/src/server/index.ts +56 -0
- package/src/server/oauth-http.ts +480 -0
- package/src/server/oauth-validation.ts +106 -0
- package/src/server/oauth.ts +315 -0
- package/src/server/options.ts +20 -4
- package/src/server/origins.ts +19 -0
- package/src/server/pkce.ts +27 -0
- package/src/server/privacy.ts +26 -9
- package/src/server/scopes.ts +6 -0
- package/src/server/tokens.ts +62 -25
- package/src/ui/consent-form.tsx +103 -0
- package/src/ui/index.ts +3 -0
- package/src/ui/token-manager.tsx +79 -3
package/src/next/pages.tsx
CHANGED
|
@@ -7,14 +7,15 @@ import { requireUser } from "@softure-ai/auth/next";
|
|
|
7
7
|
import { formatMessage, type SoftureConfig } from "@softure-ai/core";
|
|
8
8
|
import { getSoftureConfig } from "@softure-ai/core/next";
|
|
9
9
|
import { Card } from "@softure-ai/ui";
|
|
10
|
-
import type { AccessTokenStatus, AccessTokenView } from "../contract.js";
|
|
10
|
+
import type { AccessTokenStatus, AccessTokenView, OAuthGrantView } from "../contract.js";
|
|
11
11
|
import type { McpAccessMessages } from "../messages/index.js";
|
|
12
12
|
import type { McpAccessOptions } from "../options.js";
|
|
13
13
|
import { getLocalizedText, getMcpAccessMessages, getMcpAccessOptions, getMcpAccessRoutes } from "../server/options.js";
|
|
14
|
+
import { listOAuthGrants } from "../server/oauth.js";
|
|
14
15
|
import { listAccessTokens } from "../server/tokens.js";
|
|
15
16
|
import { getAccessTokenStatus } from "../token-status.js";
|
|
16
|
-
import { TokenManager, type TokenManagerRow, type TokenManagerTool } from "../ui/token-manager.js";
|
|
17
|
-
import { issueTokenAction, revokeTokenAction } from "./actions.js";
|
|
17
|
+
import { TokenManager, type TokenManagerGrant, type TokenManagerRow, type TokenManagerTool } from "../ui/token-manager.js";
|
|
18
|
+
import { issueTokenAction, revokeGrantAction, revokeTokenAction } from "./actions.js";
|
|
18
19
|
import { getMcpAccessContext } from "./context.js";
|
|
19
20
|
import { formatDate, formatDateTime } from "./format.js";
|
|
20
21
|
|
|
@@ -48,6 +49,16 @@ function toRow(token: AccessTokenView, now: Date, config: SoftureConfig, options
|
|
|
48
49
|
};
|
|
49
50
|
}
|
|
50
51
|
|
|
52
|
+
function toGrant(grant: OAuthGrantView, config: SoftureConfig, options: McpAccessOptions, messages: McpAccessMessages): TokenManagerGrant {
|
|
53
|
+
const lastUse = grant.lastUsedAt === null ? messages.list.neverUsed : formatMessage(messages.list.lastUsed, { date: formatDateTime(config, grant.lastUsedAt) });
|
|
54
|
+
return {
|
|
55
|
+
id: grant.id,
|
|
56
|
+
clientName: grant.clientName,
|
|
57
|
+
scopeText: grant.canWrite && options.allowWrites ? messages.list.readWrite : messages.list.readOnly,
|
|
58
|
+
details: [formatMessage(messages.grants.connectedOn, { date: formatDate(config, grant.createdAt) }), lastUse],
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
51
62
|
function toTool(tool: McpAccessOptions["tools"][number], config: SoftureConfig, options: McpAccessOptions, messages: McpAccessMessages): TokenManagerTool {
|
|
52
63
|
const accessText = tool.access === "read" ? messages.tools.read : options.allowWrites ? messages.tools.write : messages.tools.writeUnavailable;
|
|
53
64
|
return { name: tool.name, description: getLocalizedText(tool.description, config.locale), accessText };
|
|
@@ -61,6 +72,7 @@ export async function McpAccessPage() {
|
|
|
61
72
|
const ctx = await getMcpAccessContext(config);
|
|
62
73
|
const now = ctx.clock.now();
|
|
63
74
|
const tokens = await listAccessTokens(ctx, user.id);
|
|
75
|
+
const grants = options.oauth.enabled ? await listOAuthGrants(ctx, user.id) : null;
|
|
64
76
|
return (
|
|
65
77
|
<main className={LAYOUT_CLASS}>
|
|
66
78
|
<Card title={messages.page.title} subtitle={messages.page.lead}>
|
|
@@ -71,6 +83,7 @@ export async function McpAccessPage() {
|
|
|
71
83
|
maxTokens={options.maxTokensPerUser}
|
|
72
84
|
issueAction={issueTokenAction}
|
|
73
85
|
revokeAction={revokeTokenAction}
|
|
86
|
+
{...(grants === null ? {} : { grants: grants.map((grant) => toGrant(grant, config, options, messages)), revokeGrantAction })}
|
|
74
87
|
messages={messages}
|
|
75
88
|
locale={config.locale}
|
|
76
89
|
/>
|
package/src/options.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// The options an app passes to `mcpAccess({ ... })` in softure.config.ts, parsed at startup.
|
|
2
2
|
import { LOCALES } from "@softure-ai/core";
|
|
3
3
|
import { z } from "zod";
|
|
4
|
+
import type { McpOriginRequest, McpOrigins } from "./origins.js";
|
|
4
5
|
|
|
5
6
|
/** The name an MCP client lists the server under: no spaces, so a shell command needs no quotes. */
|
|
6
7
|
export const SERVER_NAME_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/;
|
|
@@ -11,6 +12,9 @@ export const TOOL_NAME_PATTERN = /^[A-Za-z0-9_.-]{1,128}$/;
|
|
|
11
12
|
/** The longest token name the table takes (`access_tokens.name`). */
|
|
12
13
|
export const MAX_TOKEN_NAME_LENGTH = 60;
|
|
13
14
|
|
|
15
|
+
/** The longest value the token check looks at: a longer header is refused before any pattern runs. */
|
|
16
|
+
export const MAX_PRESENTED_TOKEN_LENGTH = 512;
|
|
17
|
+
|
|
14
18
|
/** Copy per locale; a locale without its own text falls back to `en`. */
|
|
15
19
|
const localizedTextSchema = z.partialRecord(z.enum(LOCALES), z.string().trim().min(1).max(500));
|
|
16
20
|
|
|
@@ -26,6 +30,88 @@ const toolSchema = z.strictObject({
|
|
|
26
30
|
description: localizedTextSchema.refine((text) => text.en !== undefined, "needs at least an en text"),
|
|
27
31
|
});
|
|
28
32
|
|
|
33
|
+
/**
|
|
34
|
+
* Keys of the authorization server metadata the package generates. An extension cannot set them:
|
|
35
|
+
* they decide where a client sends a person and its secrets.
|
|
36
|
+
*/
|
|
37
|
+
export const GENERATED_AUTHORIZATION_SERVER_KEYS = [
|
|
38
|
+
"issuer",
|
|
39
|
+
"authorization_endpoint",
|
|
40
|
+
"token_endpoint",
|
|
41
|
+
"registration_endpoint",
|
|
42
|
+
"scopes_supported",
|
|
43
|
+
"response_types_supported",
|
|
44
|
+
"response_modes_supported",
|
|
45
|
+
"grant_types_supported",
|
|
46
|
+
"token_endpoint_auth_methods_supported",
|
|
47
|
+
"code_challenge_methods_supported",
|
|
48
|
+
"authorization_response_iss_parameter_supported",
|
|
49
|
+
] as const;
|
|
50
|
+
|
|
51
|
+
/** Keys of the protected resource metadata the package generates; `resource_name` is the app's to replace. */
|
|
52
|
+
export const GENERATED_PROTECTED_RESOURCE_KEYS = ["resource", "authorization_servers", "scopes_supported", "bearer_methods_supported"] as const;
|
|
53
|
+
|
|
54
|
+
/** Extra keys of a discovery document: a JSON object, or computed from the request's origins. */
|
|
55
|
+
export type McpMetadataExtension = Readonly<Record<string, unknown>> | ((origins: McpOrigins) => Readonly<Record<string, unknown>>);
|
|
56
|
+
|
|
57
|
+
/** The app origin of one request, or null for `appOrigin`. */
|
|
58
|
+
export type McpAppOriginResolver = (request: McpOriginRequest) => string | null;
|
|
59
|
+
|
|
60
|
+
function metadataExtensionSchema(generated: readonly string[]) {
|
|
61
|
+
return z.union([
|
|
62
|
+
z.custom<(origins: McpOrigins) => Readonly<Record<string, unknown>>>((value) => typeof value === "function"),
|
|
63
|
+
z.record(z.string(), z.unknown()).superRefine((record, context) => {
|
|
64
|
+
for (const key of generated) {
|
|
65
|
+
if (Object.hasOwn(record, key)) context.addIssue({ code: "custom", path: [key], message: `"${key}" is generated by the module and cannot be set` });
|
|
66
|
+
}
|
|
67
|
+
}),
|
|
68
|
+
], { error: "must be an object of extra keys or a function (origins) => object" });
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** A bare http(s) origin (no path, query, fragment or credentials), reduced to its origin. */
|
|
72
|
+
export function isBareOrigin(value: string): boolean {
|
|
73
|
+
if (!URL.canParse(value)) return false;
|
|
74
|
+
const url = new URL(value);
|
|
75
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") return false;
|
|
76
|
+
return url.username === "" && url.password === "" && (url.pathname === "/" || url.pathname === "") && !value.includes("?") && !value.includes("#");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const originSchema = z
|
|
80
|
+
.string()
|
|
81
|
+
.refine((value) => isBareOrigin(value), "must be an http(s) origin such as https://example.com, without a path, query or credentials")
|
|
82
|
+
.transform((value) => new URL(value).origin);
|
|
83
|
+
|
|
84
|
+
const oauthSchema = z.strictObject({
|
|
85
|
+
/**
|
|
86
|
+
* Makes the module an OAuth 2.1 authorization server for its endpoint: discovery metadata,
|
|
87
|
+
* dynamic client registration, a consent page and a token endpoint. The app mounts those routes.
|
|
88
|
+
*/
|
|
89
|
+
enabled: z.boolean().default(false),
|
|
90
|
+
/** How long an access token issued through OAuth works. Clients refresh it themselves. */
|
|
91
|
+
accessTokenLifetimeMinutes: z.int().min(5).max(1440).default(60),
|
|
92
|
+
/** How long a refresh token works, counted from the last refresh: a grant in use never expires. */
|
|
93
|
+
refreshTokenLifetimeDays: z.int().min(1).max(365).default(90),
|
|
94
|
+
/** How long an authorization code works; RFC 6749 §4.1.2 recommends at most ten minutes. */
|
|
95
|
+
authorizationCodeLifetimeMinutes: z.int().min(1).max(10).default(10),
|
|
96
|
+
/**
|
|
97
|
+
* Extra keys of the discovery documents (e.g. `jwks_uri`, `service_documentation`,
|
|
98
|
+
* `resource_documentation`, `agent_auth`). Generated keys win; the protected resource document
|
|
99
|
+
* takes the app's `resource_name`.
|
|
100
|
+
*/
|
|
101
|
+
metadata: z
|
|
102
|
+
.strictObject({
|
|
103
|
+
authorizationServer: metadataExtensionSchema(GENERATED_AUTHORIZATION_SERVER_KEYS).optional(),
|
|
104
|
+
protectedResource: metadataExtensionSchema(GENERATED_PROTECTED_RESOURCE_KEYS).optional(),
|
|
105
|
+
})
|
|
106
|
+
.prefault({}),
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
/** A pattern for tokens an app issued before adopting the module: anchored, and stateless. */
|
|
110
|
+
const legacyTokenPatternSchema = z
|
|
111
|
+
.instanceof(RegExp, { message: "must be a RegExp" })
|
|
112
|
+
.refine((pattern) => pattern.source.startsWith("^") && pattern.source.endsWith("$"), "must be anchored with ^ and $")
|
|
113
|
+
.refine((pattern) => !pattern.global && !pattern.sticky, "must not use the g or y flag");
|
|
114
|
+
|
|
29
115
|
export const mcpAccessOptionsSchema = z
|
|
30
116
|
.strictObject({
|
|
31
117
|
/** The name MCP clients list the server under, e.g. "acme". Used in the setup instructions. */
|
|
@@ -34,12 +120,31 @@ export const mcpAccessOptionsSchema = z
|
|
|
34
120
|
tools: z.array(toolSchema).default([]),
|
|
35
121
|
/** Lets write tokens be issued and used. Off, every token reads only, write tokens included. */
|
|
36
122
|
allowWrites: z.boolean().default(false),
|
|
37
|
-
/** How long a token works. A choice: longer than a session, never forever. */
|
|
123
|
+
/** How long a token issued on the token page works. A choice: longer than a session, never forever. */
|
|
38
124
|
tokenLifetimeDays: z.int().min(1).max(365).default(90),
|
|
39
125
|
/** How many unexpired tokens one account may hold. */
|
|
40
126
|
maxTokensPerUser: z.int().min(1).max(100).default(20),
|
|
41
127
|
/** From how many calendar days before its expiry the page warns about a token. */
|
|
42
128
|
expiryWarningDays: z.int().min(0).max(60).default(14),
|
|
129
|
+
/**
|
|
130
|
+
* Tokens an app issued before it adopted the module, e.g. `/^[0-9a-f]{64}$/`. Such a value is
|
|
131
|
+
* looked up by its sha256 like a token of the module's own shape; new tokens keep the prefix.
|
|
132
|
+
*/
|
|
133
|
+
legacyTokenPattern: legacyTokenPatternSchema.optional(),
|
|
134
|
+
/**
|
|
135
|
+
* The app origin of a request, when it is not the fixed `appOrigin`: an image built once and
|
|
136
|
+
* served under another origin, or a proxy whose `request.url` does not carry the public host.
|
|
137
|
+
* `readRequestOrigin` (`/server`) reads it from `Host`. Null keeps `appOrigin`.
|
|
138
|
+
*/
|
|
139
|
+
resolveAppOrigin: z.custom<McpAppOriginResolver>((value) => typeof value === "function", "must be a function (request) => string | null").optional(),
|
|
140
|
+
/**
|
|
141
|
+
* Other public hosts of the app, e.g. the apex when the product runs on `app.`: their root
|
|
142
|
+
* protected resource metadata names that host as `resource` (RFC 9728 §3.3), and the
|
|
143
|
+
* authorization and token endpoints accept it as `resource`.
|
|
144
|
+
*/
|
|
145
|
+
resourceOrigins: z.array(originSchema).max(16, "takes at most 16 origins").default([]),
|
|
146
|
+
/** OAuth 2.1 for MCP clients that connect without a pasted token (claude.ai, ChatGPT). */
|
|
147
|
+
oauth: oauthSchema.prefault({}),
|
|
43
148
|
})
|
|
44
149
|
.superRefine((options, context) => {
|
|
45
150
|
const names = new Set<string>();
|
|
@@ -56,3 +161,4 @@ export type McpAccessOptions = z.output<typeof mcpAccessOptionsSchema>;
|
|
|
56
161
|
export type McpToolDefinitionInput = z.input<typeof toolSchema>;
|
|
57
162
|
export type McpToolDefinition = z.output<typeof toolSchema>;
|
|
58
163
|
export type McpToolAccess = McpToolDefinition["access"];
|
|
164
|
+
export type McpOAuthOptions = z.output<typeof oauthSchema>;
|
package/src/origins.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// The origins one request is served under: the app origin every OAuth URL is built from, and the
|
|
2
|
+
// other public hosts whose root may be named as a protected resource. Pure: no config, no database.
|
|
3
|
+
|
|
4
|
+
/** What the origin helpers read from a request: a Web `Request`, or Next's `headers()` with a URL. */
|
|
5
|
+
export interface McpOriginRequest {
|
|
6
|
+
readonly url: string;
|
|
7
|
+
readonly headers: { get(name: string): string | null };
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface McpOrigins {
|
|
11
|
+
/** The issuer, and the origin of every endpoint, of `resource_metadata` and of the consent form. */
|
|
12
|
+
readonly appOrigin: string;
|
|
13
|
+
/** Other public hosts (`resourceOrigins`), each a bare origin. */
|
|
14
|
+
readonly resourceOrigins: readonly string[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function firstValue(header: string | null): string | null {
|
|
18
|
+
const value = header?.split(",")[0]?.trim().toLowerCase();
|
|
19
|
+
return value === undefined || value === "" ? null : value;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** The host the request was sent to: `Host`, else the URL's host. Behind a proxy the URL names the server's own. */
|
|
23
|
+
export function readRequestHost(request: McpOriginRequest): string {
|
|
24
|
+
return firstValue(request.headers.get("host")) ?? new URL(request.url).host;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The origin the request was sent to: the first `X-Forwarded-Proto` value when it is http or https
|
|
29
|
+
* (else the URL's scheme) and `Host` (else the URL's host). For `resolveAppOrigin` in an app whose
|
|
30
|
+
* proxy passes `Host` through and refuses hosts it does not serve.
|
|
31
|
+
*/
|
|
32
|
+
export function readRequestOrigin(request: McpOriginRequest): string {
|
|
33
|
+
const url = new URL(request.url);
|
|
34
|
+
const proto = firstValue(request.headers.get("x-forwarded-proto"));
|
|
35
|
+
const scheme = proto === "http" || proto === "https" ? proto : url.protocol.replace(/:$/, "");
|
|
36
|
+
return new URL(`${scheme}://${readRequestHost(request)}`).origin;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The resource origin the request was sent to, when its host is one of `resourceOrigins`; when two
|
|
41
|
+
* share the host, the one whose scheme `X-Forwarded-Proto` names wins, else the first. Null otherwise.
|
|
42
|
+
*/
|
|
43
|
+
export function findServedResourceOrigin(origins: McpOrigins, request: McpOriginRequest): string | null {
|
|
44
|
+
const host = readRequestHost(request);
|
|
45
|
+
const candidates = origins.resourceOrigins.filter((origin) => new URL(origin).host === host);
|
|
46
|
+
const proto = firstValue(request.headers.get("x-forwarded-proto"));
|
|
47
|
+
return candidates.find((origin) => new URL(origin).protocol === `${proto ?? ""}:`) ?? candidates[0] ?? null;
|
|
48
|
+
}
|
package/src/schema.ts
CHANGED
|
@@ -1,10 +1,54 @@
|
|
|
1
|
-
// Drizzle view of the module's
|
|
2
|
-
// the source of truth; this file only types the
|
|
1
|
+
// Drizzle view of the module's tables (migrations/0001_create_access_tokens.sql and
|
|
2
|
+
// 0002_create_oauth_grants.sql). The migrations are the source of truth; this file only types the
|
|
3
|
+
// queries.
|
|
3
4
|
import { users } from "@softure-ai/auth";
|
|
4
|
-
import { boolean, pgSchema, text, timestamp, uuid } from "drizzle-orm/pg-core";
|
|
5
|
+
import { boolean, jsonb, pgSchema, text, timestamp, uuid, type AnyPgColumn } from "drizzle-orm/pg-core";
|
|
5
6
|
|
|
6
7
|
export const mcpSchema = pgSchema("mcp");
|
|
7
8
|
|
|
9
|
+
export const oauthClients = mcpSchema.table("oauth_clients", {
|
|
10
|
+
id: uuid("id").primaryKey().defaultRandom(),
|
|
11
|
+
clientId: text("client_id").notNull().unique("oauth_clients_client_id_key"),
|
|
12
|
+
clientName: text("client_name").notNull(),
|
|
13
|
+
redirectUris: jsonb("redirect_uris").$type<string[]>().notNull(),
|
|
14
|
+
tokenEndpointAuthMethod: text("token_endpoint_auth_method").$type<"none" | "client_secret_post" | "client_secret_basic">().notNull(),
|
|
15
|
+
clientSecretHash: text("client_secret_hash"),
|
|
16
|
+
createdAt: timestamp("created_at", { withTimezone: true }).notNull(),
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
export const oauthAuthorizationCodes = mcpSchema.table("oauth_authorization_codes", {
|
|
20
|
+
id: uuid("id").primaryKey().defaultRandom(),
|
|
21
|
+
codeHash: text("code_hash").notNull().unique("oauth_authorization_codes_code_hash_key"),
|
|
22
|
+
clientId: uuid("client_id")
|
|
23
|
+
.notNull()
|
|
24
|
+
.references(() => oauthClients.id, { onDelete: "cascade" }),
|
|
25
|
+
userId: uuid("user_id")
|
|
26
|
+
.notNull()
|
|
27
|
+
.references(() => users.id, { onDelete: "cascade" }),
|
|
28
|
+
redirectUri: text("redirect_uri").notNull(),
|
|
29
|
+
codeChallenge: text("code_challenge").notNull(),
|
|
30
|
+
canWrite: boolean("can_write").notNull(),
|
|
31
|
+
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
|
|
32
|
+
usedAt: timestamp("used_at", { withTimezone: true }),
|
|
33
|
+
createdAt: timestamp("created_at", { withTimezone: true }).notNull(),
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
export const oauthGrants = mcpSchema.table("oauth_grants", {
|
|
37
|
+
id: uuid("id").primaryKey().defaultRandom(),
|
|
38
|
+
userId: uuid("user_id")
|
|
39
|
+
.notNull()
|
|
40
|
+
.references(() => users.id, { onDelete: "cascade" }),
|
|
41
|
+
clientId: uuid("client_id")
|
|
42
|
+
.notNull()
|
|
43
|
+
.references(() => oauthClients.id, { onDelete: "cascade" }),
|
|
44
|
+
canWrite: boolean("can_write").notNull(),
|
|
45
|
+
refreshTokenHash: text("refresh_token_hash").notNull().unique("oauth_grants_refresh_token_hash_key"),
|
|
46
|
+
previousRefreshTokenHash: text("previous_refresh_token_hash"),
|
|
47
|
+
refreshExpiresAt: timestamp("refresh_expires_at", { withTimezone: true }).notNull(),
|
|
48
|
+
lastUsedAt: timestamp("last_used_at", { withTimezone: true }),
|
|
49
|
+
createdAt: timestamp("created_at", { withTimezone: true }).notNull(),
|
|
50
|
+
});
|
|
51
|
+
|
|
8
52
|
export const accessTokens = mcpSchema.table("access_tokens", {
|
|
9
53
|
id: uuid("id").primaryKey().defaultRandom(),
|
|
10
54
|
userId: uuid("user_id")
|
|
@@ -16,4 +60,8 @@ export const accessTokens = mcpSchema.table("access_tokens", {
|
|
|
16
60
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull(),
|
|
17
61
|
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
|
|
18
62
|
lastUsedAt: timestamp("last_used_at", { withTimezone: true }),
|
|
63
|
+
grantId: uuid("grant_id").references((): AnyPgColumn => oauthGrants.id, { onDelete: "cascade" }),
|
|
19
64
|
});
|
|
65
|
+
|
|
66
|
+
export type OAuthClientRow = typeof oauthClients.$inferSelect;
|
|
67
|
+
export type OAuthTokenEndpointAuthMethod = OAuthClientRow["tokenEndpointAuthMethod"];
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// The module context (`{ db, clock, config }`) from the configuration: the process-wide database
|
|
2
|
+
// handle and the wall clock. For apps that compose their own routes from `/server`; the `/next`
|
|
3
|
+
// adapter builds its context with it.
|
|
4
|
+
import { systemClock, type SoftureConfig } from "@softure-ai/core";
|
|
5
|
+
import { getConfiguredDatabase } from "@softure-ai/db";
|
|
6
|
+
import type { McpAccessContext } from "./tokens.js";
|
|
7
|
+
|
|
8
|
+
export async function createMcpAccessContext(config: SoftureConfig): Promise<McpAccessContext> {
|
|
9
|
+
if (config.database === null) {
|
|
10
|
+
throw new Error("@softure-ai/mcp-access: softure.config.ts has no database; mcp-access needs one");
|
|
11
|
+
}
|
|
12
|
+
const { db } = await getConfiguredDatabase(config.database);
|
|
13
|
+
return { db, clock: systemClock, config };
|
|
14
|
+
}
|
package/src/server/endpoint.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// The MCP endpoint (
|
|
1
|
+
// The MCP endpoint (an adopting app's MCP route, generalised). It takes a Web
|
|
2
2
|
// `Request` and returns a `Response`, so it runs under any fetch-shaped host; the Next adapter only
|
|
3
3
|
// supplies the context. Order: identify the client, count the request, verify the Bearer token,
|
|
4
4
|
// then hand the request to a fresh server from the app's factory. Each refusal happens before the
|
|
@@ -15,12 +15,12 @@ import {
|
|
|
15
15
|
import { errorLogLabel, getModule } from "@softure-ai/core";
|
|
16
16
|
import { consumeRateLimit, identifyClient } from "@softure-ai/security/server";
|
|
17
17
|
import type { McpServerIdentity } from "../contract.js";
|
|
18
|
+
import { getProtectedResourceMetadataUrl, isOAuthEnabled } from "./oauth-http.js";
|
|
19
|
+
import { resolveMcpOrigins } from "./origins.js";
|
|
20
|
+
import { MCP_READ_SCOPE, MCP_WRITE_SCOPE } from "./scopes.js";
|
|
18
21
|
import { verifyAccessToken, type McpAccessContext } from "./tokens.js";
|
|
19
22
|
|
|
20
|
-
|
|
21
|
-
export const MCP_READ_SCOPE = "mcp:read";
|
|
22
|
-
/** A token that may write: issued for writes, and the app allows them. */
|
|
23
|
-
export const MCP_WRITE_SCOPE = "mcp:write";
|
|
23
|
+
export { MCP_READ_SCOPE, MCP_WRITE_SCOPE };
|
|
24
24
|
|
|
25
25
|
/** The security bucket the endpoint counts in; `MCP_RATE_LIMIT_BUCKETS` holds its default. */
|
|
26
26
|
export const MCP_RATE_LIMIT_BUCKET = "mcp";
|
|
@@ -80,7 +80,7 @@ export function createMcpEndpoint({ createServer }: McpEndpointOptions): McpEndp
|
|
|
80
80
|
);
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
-
const authInfo = await authenticate(ctx, request
|
|
83
|
+
const authInfo = await authenticate(ctx, request);
|
|
84
84
|
if (authInfo instanceof Response) return authInfo;
|
|
85
85
|
return handler.fetch(request, { authInfo });
|
|
86
86
|
};
|
|
@@ -89,10 +89,15 @@ export function createMcpEndpoint({ createServer }: McpEndpointOptions): McpEndp
|
|
|
89
89
|
/**
|
|
90
90
|
* The verified identity as the SDK's `AuthInfo`, or the challenge response. Unknown, revoked,
|
|
91
91
|
* expired and malformed tokens get the same `401 invalid_token`. Expiry is decided by the query on
|
|
92
|
-
* the module's clock, not by the SDK's own check on the wall clock.
|
|
92
|
+
* the module's clock, not by the SDK's own check on the wall clock. With OAuth on, the challenge
|
|
93
|
+
* names the protected resource metadata (RFC 9728 §5.1): that is how claude.ai and ChatGPT find
|
|
94
|
+
* the authorization server.
|
|
93
95
|
*/
|
|
94
|
-
async function authenticate(ctx: McpAccessContext,
|
|
95
|
-
const
|
|
96
|
+
async function authenticate(ctx: McpAccessContext, request: Request): Promise<AuthInfo | Response> {
|
|
97
|
+
const header = request.headers.get("authorization");
|
|
98
|
+
const challenge = isOAuthEnabled(ctx.config)
|
|
99
|
+
? { requiredScopes: [MCP_READ_SCOPE], resourceMetadataUrl: getProtectedResourceMetadataUrl(ctx.config, resolveMcpOrigins(ctx.config, request)) }
|
|
100
|
+
: { requiredScopes: [MCP_READ_SCOPE] };
|
|
96
101
|
if (header === null || !BEARER_PREFIX.test(header)) {
|
|
97
102
|
return bearerAuthChallengeResponse(new OAuthError(OAuthErrorCode.InvalidToken, "Missing Bearer token"), challenge);
|
|
98
103
|
}
|
package/src/server/health.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
// The module's readiness probe for `GET /api/health` of `@softure-ai/ops`: the tokens table
|
|
2
|
-
// and
|
|
1
|
+
// The module's readiness probe for `GET /api/health` of `@softure-ai/ops`: the tokens table (with
|
|
2
|
+
// its grant column) and the grants table exist and answer, i.e. `softure migrate` ran every file.
|
|
3
|
+
// It reads no rows.
|
|
3
4
|
import { ok, type HealthCheck } from "@softure-ai/core";
|
|
4
5
|
import type { Queryable } from "@softure-ai/db";
|
|
5
6
|
import { sql } from "drizzle-orm";
|
|
@@ -7,6 +8,7 @@ import { sql } from "drizzle-orm";
|
|
|
7
8
|
export const checkAccessTokensTable: HealthCheck = async (context) => {
|
|
8
9
|
// Core types `db` as unknown; the health route passes the @softure-ai/db handle.
|
|
9
10
|
const db = context.db as Queryable;
|
|
10
|
-
await db.execute(sql`select
|
|
11
|
+
await db.execute(sql`select grant_id from mcp.access_tokens limit 0`);
|
|
12
|
+
await db.execute(sql`select 1 from mcp.oauth_grants join mcp.oauth_clients on oauth_clients.id = oauth_grants.client_id limit 0`);
|
|
11
13
|
return ok();
|
|
12
14
|
};
|
package/src/server/index.ts
CHANGED
|
@@ -10,7 +10,63 @@ export {
|
|
|
10
10
|
type McpEndpointOptions,
|
|
11
11
|
type McpServerFactory,
|
|
12
12
|
} from "./endpoint.js";
|
|
13
|
+
export { createMcpAccessContext } from "./context.js";
|
|
13
14
|
export { checkAccessTokensTable } from "./health.js";
|
|
15
|
+
export {
|
|
16
|
+
CONSENT_CODE_PREFIX,
|
|
17
|
+
CLIENT_SECRET_PREFIX,
|
|
18
|
+
createAuthorizationCode,
|
|
19
|
+
exchangeAuthorizationCode,
|
|
20
|
+
findOAuthClient,
|
|
21
|
+
isClientSecretValid,
|
|
22
|
+
listOAuthGrants,
|
|
23
|
+
pruneOAuthRecords,
|
|
24
|
+
REFRESH_TOKEN_PREFIX,
|
|
25
|
+
refreshMcpGrant,
|
|
26
|
+
registerMcpClient,
|
|
27
|
+
revokeOAuthGrant,
|
|
28
|
+
type CreateAuthorizationCodeInput,
|
|
29
|
+
type ExchangeAuthorizationCodeInput,
|
|
30
|
+
type IssuedOAuthTokens,
|
|
31
|
+
type OAuthClientRegistration,
|
|
32
|
+
type PrunedOAuthRecords,
|
|
33
|
+
type RefreshOAuthGrantInput,
|
|
34
|
+
type RegisteredOAuthClient,
|
|
35
|
+
type RevokeOAuthGrantInput,
|
|
36
|
+
} from "./oauth.js";
|
|
37
|
+
export {
|
|
38
|
+
answerOAuthPreflight,
|
|
39
|
+
AUTHORIZATION_PARAMS,
|
|
40
|
+
AUTHORIZATION_SERVER_METADATA_PATH,
|
|
41
|
+
buildClientRedirect,
|
|
42
|
+
getAuthorizationServerMetadata,
|
|
43
|
+
getConsentPath,
|
|
44
|
+
getOAuthIssuer,
|
|
45
|
+
getProtectedResourceMetadata,
|
|
46
|
+
getProtectedResourceMetadataUrl,
|
|
47
|
+
getRootProtectedResourceMetadata,
|
|
48
|
+
handleAuthorizationDecision,
|
|
49
|
+
handleClientRegistration,
|
|
50
|
+
handleTokenRequest,
|
|
51
|
+
isAcceptableResource,
|
|
52
|
+
isOAuthEnabled,
|
|
53
|
+
isWriteScopeRequested,
|
|
54
|
+
MCP_OAUTH_RATE_LIMIT_BUCKET,
|
|
55
|
+
OAUTH_SCOPES,
|
|
56
|
+
PROTECTED_RESOURCE_METADATA_PATH,
|
|
57
|
+
readAuthorizationParams,
|
|
58
|
+
serveDiscoveryDocument,
|
|
59
|
+
validateAuthorizationRequest,
|
|
60
|
+
type AuthorizationOutcome,
|
|
61
|
+
type AuthorizationPageError,
|
|
62
|
+
type AuthorizationRequest,
|
|
63
|
+
type DiscoveryDocument,
|
|
64
|
+
} from "./oauth-http.js";
|
|
65
|
+
export { findServedResourceOrigin, readRequestHost, readRequestOrigin, type McpOriginRequest, type McpOrigins } from "../origins.js";
|
|
66
|
+
export { resolveMcpOrigins } from "./origins.js";
|
|
67
|
+
export type { OAuthClientRow, OAuthTokenEndpointAuthMethod } from "../schema.js";
|
|
68
|
+
export { describeRedirectUri, isAllowedRedirectUri, MAX_CLIENT_NAME_LENGTH, parseClientRegistration, type ClientRegistrationResult, type RegistrationError } from "./oauth-validation.js";
|
|
69
|
+
export { getCodeChallenge, isPkceVerifierValid, isValidCodeChallenge, PKCE_METHOD } from "./pkce.js";
|
|
14
70
|
export { getMcpAccessMessages, getMcpAccessOptions, getMcpAccessRoutes, getMcpEndpointUrl, type McpAccessRoutes } from "./options.js";
|
|
15
71
|
export {
|
|
16
72
|
deleteMcpAccessUserData,
|