-
Notifications
You must be signed in to change notification settings - Fork 32
Expand file tree
/
Copy pathprotectedResource.ts
More file actions
97 lines (87 loc) · 3.68 KB
/
Copy pathprotectedResource.ts
File metadata and controls
97 lines (87 loc) · 3.68 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
// src/auth/protectedResource.ts
import type { UpstreamAsMetadata } from './metadata.ts';
const RESOURCE_DOCUMENTATION =
'https://github.com/ProfessionalWiki/MediaWiki-MCP-Server/blob/master/docs/configuration.md#oauth';
export interface ProtectedResourceInput {
wikis: Record<string, { oauth2ClientId?: string | null }>;
metadatas: readonly UpstreamAsMetadata[];
requestHost: string | undefined;
requestProto: 'http' | 'https' | undefined;
/**
* When the hosted OAuth proxy is enabled, this server is itself the
* authorization server. Pass the proxy issuer(s) here to advertise self
* instead of the per-wiki upstream issuers derived from `metadatas`.
*/
// The authorization servers this document names. Required: only the hosted
// proxy makes this server an authorization server, and it names itself. There is
// no per-wiki fallback — naming the wikis' own issuers is what steered clients
// into minting tokens this server must not accept.
authorizationServers: readonly string[];
}
export interface ProtectedResourceDoc {
resource: string;
authorization_servers: string[];
bearer_methods_supported: string[];
scopes_supported?: string[];
resource_documentation?: string;
}
function anyWikiHasOAuth(wikis: Record<string, { oauth2ClientId?: string | null }>): boolean {
return Object.values(wikis).some(
(w) => typeof w.oauth2ClientId === 'string' && w.oauth2ClientId.length > 0,
);
}
/**
* Resolves the public base URL of the MCP server with a guaranteed trailing
* slash. Honours `MCP_PUBLIC_URL` when set; otherwise builds from request
* proto+host. Used for both the protected-resource doc's `resource` field and
* the WWW-Authenticate `resource_metadata` URL so the two stay aligned.
*/
export function resolvePublicBase(
requestHost: string | undefined,
requestProto: 'http' | 'https' | undefined,
): string {
const fromEnv = process.env.MCP_PUBLIC_URL;
let base: string;
if (typeof fromEnv === 'string' && fromEnv.length > 0) {
// Canonicalize through new URL() — the same normalization resolveProxyConfig
// applies to the issuer — so the protected-resource `resource`, the AS
// `issuer`, and the JWT audience all derive from one form (e.g. host case).
// Fall back to the raw value if somehow unparseable (config is validated at boot).
try {
base = new URL(fromEnv).toString();
} catch {
base = fromEnv;
}
} else if (requestHost !== undefined && requestProto !== undefined) {
base = `${requestProto}://${requestHost}/`;
} else {
base = 'https://localhost/';
}
return base.endsWith('/') ? base : `${base}/`;
}
export function buildProtectedResource(
input: ProtectedResourceInput,
): ProtectedResourceDoc | undefined {
if (!anyWikiHasOAuth(input.wikis) || input.metadatas.length === 0) {
return undefined;
}
// The RFC 8707/9728 resource identifier must equal the canonical MCP server
// URL the client connects to — slash-free, matching the AS `issuer`. Real MCP
// clients (e.g. the SDK) compare it verbatim against their configured server
// URL, so a trailing slash here fails auth ("Protected resource .../mcp/ does
// not match expected .../mcp"). resolvePublicBase keeps its trailing slash for
// building the resource_metadata URL; strip it for the identifier only.
const resource = resolvePublicBase(input.requestHost, input.requestProto).replace(/\/+$/, '');
const issuers = [...input.authorizationServers];
const scopes = [...new Set(input.metadatas.flatMap((m) => m.scopes_supported ?? []))];
const doc: ProtectedResourceDoc = {
resource,
authorization_servers: issuers,
bearer_methods_supported: ['header'],
resource_documentation: RESOURCE_DOCUMENTATION,
};
if (scopes.length > 0) {
doc.scopes_supported = scopes;
}
return doc;
}