@casys/mcp-server 0.14.0 → 0.15.1

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 CHANGED
@@ -41,13 +41,35 @@ stack.
41
41
  ## Install
42
42
 
43
43
  ```bash
44
- # npm
45
- npm install @casys/mcp-server
46
-
47
- # Deno
44
+ # Deno (primary target — JSR)
48
45
  deno add jsr:@casys/mcp-server
46
+
47
+ # Node (secondary — npm, via build-node compilation)
48
+ npm install @casys/mcp-server
49
49
  ```
50
50
 
51
+ ## Runtime targets
52
+
53
+ `@casys/mcp-server` is **Deno-first**. The canonical deployment path is Deno 2.x
54
+ running on [Deno Deploy](https://deno.com/deploy) or self-hosted Deno, with a
55
+ Node 20+ distribution as a secondary target via `scripts/build-node.sh` (which
56
+ swaps the HTTP runtime adapter and remaps `@std/*` imports to their npm
57
+ equivalents).
58
+
59
+ | Runtime | Status |
60
+ | --------------------------------------------- | :--------------: |
61
+ | **Deno 2.x** (Deno Deploy, self-hosted) | ✅ Primary |
62
+ | **Node.js 20+** (Express, Hono-on-Node, bare) | ✅ Secondary |
63
+ | **Cloudflare Workers / workerd** | ❌ Not supported |
64
+ | **Browser / WebContainer** | ❌ Not supported |
65
+
66
+ If you need to target Cloudflare Workers or the browser, use
67
+ [`@modelcontextprotocol/server`](https://www.npmjs.com/package/@modelcontextprotocol/server)
68
+ directly with its workerd / browser shims — that package focuses on the protocol
69
+ and runtime portability, while `@casys/mcp-server` focuses on the production
70
+ stack (auth, middleware, observability, multi-tenant, MCP Apps helpers) for Deno
71
+ deployments.
72
+
51
73
  ---
52
74
 
53
75
  ## Quick Start
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@casys/mcp-server",
3
- "version": "0.14.0",
3
+ "version": "0.15.1",
4
4
  "description": "Production-ready MCP server framework with concurrency control, auth, and observability",
5
5
  "type": "module",
6
6
  "main": "mod.ts",
@@ -42,6 +42,17 @@ export interface AuthConfig {
42
42
  jwksUri?: string;
43
43
  /** Supported scopes */
44
44
  scopesSupported?: string[];
45
+ /**
46
+ * Absolute HTTP(S) URL where the `/.well-known/oauth-protected-resource`
47
+ * metadata document is served publicly (RFC 9728 § 3).
48
+ *
49
+ * Required when `resource` is an opaque URI (e.g., an OIDC project ID used
50
+ * as JWT audience per RFC 9728 § 2) — otherwise `JwtAuthProvider` will
51
+ * throw at construction. When `resource` is itself an HTTP(S) URL, this
52
+ * field is optional and auto-derived by the factory. Mirrors
53
+ * `JwtAuthProviderOptions.resourceMetadataUrl`.
54
+ */
55
+ resourceMetadataUrl?: string;
45
56
  }
46
57
 
47
58
  /**
@@ -56,6 +67,7 @@ interface ConfigFile {
56
67
  issuer?: string;
57
68
  jwksUri?: string;
58
69
  scopesSupported?: string[];
70
+ resourceMetadataUrl?: string;
59
71
  };
60
72
  }
61
73
 
@@ -75,6 +87,7 @@ interface ConfigFile {
75
87
  * - MCP_AUTH_ISSUER → auth.issuer
76
88
  * - MCP_AUTH_JWKS_URI → auth.jwksUri
77
89
  * - MCP_AUTH_SCOPES → auth.scopesSupported (space-separated)
90
+ * - MCP_AUTH_RESOURCE_METADATA_URL → auth.resourceMetadataUrl
78
91
  *
79
92
  * @param configPath - Path to YAML config file. Defaults to "mcp-server.yaml" in cwd.
80
93
  * @returns AuthConfig or null if no auth configured
@@ -94,6 +107,7 @@ export async function loadAuthConfig(
94
107
  const envIssuer = env("MCP_AUTH_ISSUER");
95
108
  const envJwksUri = env("MCP_AUTH_JWKS_URI");
96
109
  const envScopes = env("MCP_AUTH_SCOPES");
110
+ const envResourceMetadataUrl = env("MCP_AUTH_RESOURCE_METADATA_URL");
97
111
 
98
112
  // 3. Merge: env overrides YAML
99
113
  const provider = envProvider ?? yamlAuth?.provider;
@@ -135,6 +149,8 @@ export async function loadAuthConfig(
135
149
  scopesSupported: envScopes
136
150
  ? envScopes.split(" ").filter(Boolean)
137
151
  : yamlAuth?.scopesSupported,
152
+ resourceMetadataUrl: envResourceMetadataUrl ??
153
+ yamlAuth?.resourceMetadataUrl,
138
154
  };
139
155
 
140
156
  // Provider-specific validation (fail-fast)
@@ -165,6 +181,7 @@ export function createAuthProviderFromConfig(config: AuthConfig): AuthProvider {
165
181
  audience: config.audience,
166
182
  resource: config.resource,
167
183
  scopesSupported: config.scopesSupported,
184
+ resourceMetadataUrl: config.resourceMetadataUrl,
168
185
  };
169
186
 
170
187
  switch (config.provider) {
@@ -225,5 +242,8 @@ async function loadYamlAuth(
225
242
  typeof s === "string"
226
243
  )
227
244
  : undefined,
245
+ resourceMetadataUrl: typeof auth.resourceMetadataUrl === "string"
246
+ ? auth.resourceMetadataUrl
247
+ : undefined,
228
248
  };
229
249
  }
@@ -28,6 +28,23 @@ export interface JwtAuthProviderOptions {
28
28
  authorizationServers: string[];
29
29
  /** Scopes supported by this server */
30
30
  scopesSupported?: string[];
31
+ /**
32
+ * Absolute HTTP(S) URL where the `/.well-known/oauth-protected-resource`
33
+ * metadata document is served publicly. Used to populate the
34
+ * `resource_metadata` parameter of the WWW-Authenticate challenge
35
+ * (RFC 9728 § 5) and the `resource_metadata_url` field of
36
+ * `ProtectedResourceMetadata`.
37
+ *
38
+ * - When omitted AND `resource` is an HTTP(S) URL, the factory
39
+ * auto-derives `${resource}/.well-known/oauth-protected-resource`.
40
+ * - When omitted AND `resource` is an opaque URI (e.g., an OIDC project
41
+ * ID used as JWT audience — valid per RFC 9728 § 2), the factory
42
+ * throws at construction. Set this option explicitly in that case
43
+ * (fail-fast, no silent broken header).
44
+ *
45
+ * @example "https://my-mcp.example.com/.well-known/oauth-protected-resource"
46
+ */
47
+ resourceMetadataUrl?: string;
31
48
  }
32
49
 
33
50
  /**
@@ -51,9 +68,54 @@ interface CachedAuth {
51
68
  expiresAt: number; // ms timestamp
52
69
  }
53
70
 
71
+ /**
72
+ * Validate that a string is a parseable absolute HTTP(S) URL. Used by
73
+ * `JwtAuthProvider`'s constructor to enforce the RFC 9728 § 3 invariant
74
+ * at construction time: the `resource_metadata_url` placed in the
75
+ * `WWW-Authenticate` challenge MUST be a URL clients can fetch. Before
76
+ * 0.15.1 the constructor stored whatever string the caller passed and
77
+ * trusted the type system, which silently produced broken headers when
78
+ * the value was empty / a relative path / unparseable / contained
79
+ * trailing whitespace — the exact class of bug that 0.15.0 was meant to
80
+ * eliminate (see postmortem phase8-deployment.md § 7 and F.1).
81
+ *
82
+ * Throws with a clear error message pointing at the offending value
83
+ * (`JSON.stringify`-ed so quotes/newlines/specials don't break log
84
+ * parsing) and suggesting a concrete fix. The `source` parameter names
85
+ * which input produced the bad value so operators reading the error know
86
+ * which config key to correct.
87
+ *
88
+ * Returns the normalized URL string (via `URL.toString()`), which
89
+ * lowercases the scheme and applies minor canonicalization — so a
90
+ * caller passing `HTTPS://foo.com/` receives `https://foo.com/` back.
91
+ */
92
+ function validateAbsoluteHttpUrl(raw: string, source: string): string {
93
+ let parsed: URL;
94
+ try {
95
+ parsed = new URL(raw);
96
+ } catch {
97
+ throw new Error(
98
+ `[JwtAuthProvider] ${source} is not a parseable URL: ${
99
+ JSON.stringify(raw)
100
+ }. Expected an absolute HTTP(S) URL like ` +
101
+ `"https://my-mcp.example.com/.well-known/oauth-protected-resource".`,
102
+ );
103
+ }
104
+ if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
105
+ throw new Error(
106
+ `[JwtAuthProvider] ${source} must use http:// or https:// scheme, ` +
107
+ `got ${JSON.stringify(parsed.protocol)} in ${JSON.stringify(raw)}. ` +
108
+ `Per RFC 9728 § 3, the protected resource metadata document must ` +
109
+ `be served over HTTP(S).`,
110
+ );
111
+ }
112
+ return parsed.toString();
113
+ }
114
+
54
115
  export class JwtAuthProvider extends AuthProvider {
55
116
  private jwks: ReturnType<typeof createRemoteJWKSet>;
56
117
  private options: JwtAuthProviderOptions;
118
+ private readonly resourceMetadataUrl: string;
57
119
 
58
120
  // Token verification cache: hash(token) → AuthInfo with TTL
59
121
  // Prevents redundant JWKS fetches (network round-trip per tool call)
@@ -79,7 +141,63 @@ export class JwtAuthProvider extends AuthProvider {
79
141
  );
80
142
  }
81
143
 
82
- this.options = options;
144
+ // Resolve resource_metadata_url: explicit > auto-derive (if URL) > throw.
145
+ // Per RFC 9728 § 2, `resource` is an URI identifier and MAY be opaque
146
+ // (e.g., an OIDC project ID used as JWT audience). The metadata document
147
+ // URL is a separate concept — always an HTTP(S) URL. We used to derive
148
+ // it from `resource` by string concatenation, which produced a broken
149
+ // URL when `resource` was not itself an HTTP(S) URL. 0.15.0+ requires
150
+ // the caller to provide the URL explicitly whenever `resource` is opaque.
151
+ //
152
+ // 0.15.1 hardening: both branches now run through `validateAbsoluteHttpUrl`
153
+ // which `new URL()`-parses the result and rejects non-HTTP(S) schemes.
154
+ // Empty / whitespace-only `resourceMetadataUrl` is treated as absent
155
+ // (so a YAML key with no value falls through to the derivation branch
156
+ // instead of silently producing `"://host"`). The scheme regex is now
157
+ // case-insensitive — `HTTPS://foo.com` is a valid URL per RFC 3986
158
+ // and we accept it (normalization happens in validateAbsoluteHttpUrl).
159
+ // `options.resource` is `.trim()`-ed before derivation so trailing
160
+ // whitespace doesn't produce an unparseable URL.
161
+ const explicitUrl = options.resourceMetadataUrl?.trim();
162
+ if (explicitUrl) {
163
+ this.resourceMetadataUrl = validateAbsoluteHttpUrl(
164
+ explicitUrl,
165
+ "options.resourceMetadataUrl",
166
+ );
167
+ } else {
168
+ const isUrl = /^https?:\/\//i.test(options.resource);
169
+ if (!isUrl) {
170
+ throw new Error(
171
+ `[JwtAuthProvider] resourceMetadataUrl is required when 'resource' ` +
172
+ `is not an HTTP(S) URL (got resource=${
173
+ JSON.stringify(options.resource)
174
+ }). Per RFC 9728 § 2, 'resource' is an URI identifier that can ` +
175
+ `be opaque (e.g., an OIDC project ID used as JWT audience). The ` +
176
+ `metadata document URL is a separate concept. Set 'resourceMetadataUrl' ` +
177
+ `to the HTTPS URL where your /.well-known/oauth-protected-resource ` +
178
+ `endpoint is served publicly (e.g., ` +
179
+ `"https://my-mcp.example.com/.well-known/oauth-protected-resource").`,
180
+ );
181
+ }
182
+ const base = options.resource.trim().replace(/\/$/, "");
183
+ const derived = `${base}/.well-known/oauth-protected-resource`;
184
+ this.resourceMetadataUrl = validateAbsoluteHttpUrl(
185
+ derived,
186
+ "derived from options.resource",
187
+ );
188
+ }
189
+
190
+ // Normalize `resource` at store time so `getResourceMetadata()` returns
191
+ // the trimmed value in the RFC 9728 Protected Resource Metadata JSON
192
+ // payload. Prior to 0.15.1 the derivation branch trimmed `resource` for
193
+ // URL construction BUT `this.options` kept the raw value, so clients
194
+ // fetching `/.well-known/oauth-protected-resource` would see
195
+ // `"resource": "https://api.example.com "` with trailing whitespace
196
+ // in the JSON body. The WWW-Authenticate header was always clean
197
+ // (middleware only reads `resource_metadata_url` which went through
198
+ // `validateAbsoluteHttpUrl` + `new URL().toString()` normalization),
199
+ // so runtime was never broken — just the PRM doc body was polluted.
200
+ this.options = { ...options, resource: options.resource.trim() };
83
201
  const jwksUri = options.jwksUri ??
84
202
  `${options.issuer}/.well-known/jwks.json`;
85
203
  this.jwks = createRemoteJWKSet(new URL(jwksUri));
@@ -153,6 +271,7 @@ export class JwtAuthProvider extends AuthProvider {
153
271
  getResourceMetadata(): ProtectedResourceMetadata {
154
272
  return {
155
273
  resource: this.options.resource,
274
+ resource_metadata_url: this.resourceMetadataUrl,
156
275
  authorization_servers: this.options.authorizationServers,
157
276
  scopes_supported: this.options.scopesSupported,
158
277
  bearer_methods_supported: ["header"],
@@ -123,10 +123,10 @@ export function createAuthMiddleware(provider: AuthProvider): Middleware {
123
123
  return next();
124
124
  }
125
125
 
126
- const metadata = provider.getResourceMetadata();
127
- const resource = metadata.resource;
128
- const base = resource.endsWith("/") ? resource.slice(0, -1) : resource;
129
- const metadataUrl = `${base}/.well-known/oauth-protected-resource`;
126
+ // 0.15.0+: provider's getResourceMetadata() always returns a valid
127
+ // absolute URL in resource_metadata_url (type system guarantees it).
128
+ // No derivation needed at the middleware level anymore.
129
+ const metadataUrl = provider.getResourceMetadata().resource_metadata_url;
130
130
 
131
131
  const token = extractBearerToken(ctx.request);
132
132
  if (!token) {
@@ -23,6 +23,12 @@ export interface PresetOptions {
23
23
  resource: string;
24
24
  /** Scopes supported by this server */
25
25
  scopesSupported?: string[];
26
+ /**
27
+ * Absolute HTTP(S) URL where the `/.well-known/oauth-protected-resource`
28
+ * metadata document is served publicly (RFC 9728 § 3). Required when
29
+ * `resource` is an opaque URI. See `JwtAuthProviderOptions.resourceMetadataUrl`.
30
+ */
31
+ resourceMetadataUrl?: string;
26
32
  }
27
33
 
28
34
  /**
@@ -48,6 +54,7 @@ export function createGitHubAuthProvider(
48
54
  resource: options.resource,
49
55
  authorizationServers: ["https://token.actions.githubusercontent.com"],
50
56
  scopesSupported: options.scopesSupported,
57
+ resourceMetadataUrl: options.resourceMetadataUrl,
51
58
  });
52
59
  }
53
60
 
@@ -75,6 +82,7 @@ export function createGoogleAuthProvider(
75
82
  authorizationServers: ["https://accounts.google.com"],
76
83
  jwksUri: "https://www.googleapis.com/oauth2/v3/certs",
77
84
  scopesSupported: options.scopesSupported,
85
+ resourceMetadataUrl: options.resourceMetadataUrl,
78
86
  });
79
87
  }
80
88
 
@@ -104,6 +112,7 @@ export function createAuth0AuthProvider(
104
112
  authorizationServers: [issuer],
105
113
  jwksUri: `${issuer}.well-known/jwks.json`,
106
114
  scopesSupported: options.scopesSupported,
115
+ resourceMetadataUrl: options.resourceMetadataUrl,
107
116
  });
108
117
  }
109
118
 
@@ -24,6 +24,8 @@ import type { AuthInfo, ProtectedResourceMetadata } from "./types.js";
24
24
  * getResourceMetadata(): ProtectedResourceMetadata {
25
25
  * return {
26
26
  * resource: "https://my-mcp.example.com",
27
+ * resource_metadata_url:
28
+ * "https://my-mcp.example.com/.well-known/oauth-protected-resource",
27
29
  * authorization_servers: ["https://auth.example.com"],
28
30
  * bearer_methods_supported: ["header"],
29
31
  * };
package/src/auth/types.ts CHANGED
@@ -66,9 +66,28 @@ import type { AuthProvider } from "./provider.js";
66
66
  * @see https://datatracker.ietf.org/doc/html/rfc9728
67
67
  */
68
68
  export interface ProtectedResourceMetadata {
69
- /** Resource identifier (URL of this MCP server) */
69
+ /**
70
+ * RFC 9728 § 2 resource identifier. URI that identifies the protected
71
+ * resource — used as the JWT `aud` claim. Can be an HTTP(S) URL OR an
72
+ * opaque URI (e.g., an OIDC project ID). Do NOT assume it's an URL.
73
+ */
70
74
  resource: string;
71
75
 
76
+ /**
77
+ * Absolute HTTP(S) URL where this metadata document is served publicly.
78
+ * Per RFC 9728 § 3, this is the URL a client discovers via the
79
+ * WWW-Authenticate challenge. Always an HTTP(S) URL, regardless of
80
+ * whether `resource` itself is an URL or an opaque URI.
81
+ *
82
+ * REQUIRED as of 0.15.0 — previously derived at the middleware level
83
+ * from `resource`, which produced a broken URL when `resource` was not
84
+ * itself an HTTP(S) URL. Callers using the `createOIDCAuthProvider`
85
+ * factory or `JwtAuthProvider` can omit the explicit value when their
86
+ * `resource` IS an HTTP(S) URL (the factory auto-derives). Custom
87
+ * `AuthProvider` subclasses must always set it explicitly.
88
+ */
89
+ resource_metadata_url: string;
90
+
72
91
  /** Authorization servers that can issue valid tokens */
73
92
  authorization_servers: string[];
74
93
 
package/src/mcp-app.ts CHANGED
@@ -1159,15 +1159,16 @@ export class McpApp {
1159
1159
  return c.json(this.authProvider.getResourceMetadata());
1160
1160
  });
1161
1161
 
1162
- // Helper: build resource metadata URL safely (avoid double slash)
1163
- const buildMetadataUrl = (resource: string): string => {
1164
- const base = resource.endsWith("/") ? resource.slice(0, -1) : resource;
1165
- return `${base}/.well-known/oauth-protected-resource`;
1166
- };
1167
-
1168
1162
  // Auth verification helper for HTTP endpoints.
1169
1163
  // Returns an error Response if auth is required but token is missing/invalid.
1170
1164
  // Returns null if auth passes or is not configured.
1165
+ //
1166
+ // 0.15.0+: provider's getResourceMetadata() always returns a valid
1167
+ // absolute URL in resource_metadata_url (type-enforced). We used to
1168
+ // derive this URL from `metadata.resource` by string concatenation
1169
+ // (see `buildMetadataUrl` helper, removed 0.15.0), which produced a
1170
+ // broken URL when `resource` was an opaque URI per RFC 9728 § 2
1171
+ // (e.g., an OIDC project ID used as JWT audience).
1171
1172
  const verifyHttpAuth = async (
1172
1173
  request: Request,
1173
1174
  ): Promise<Response | null> => {
@@ -1177,7 +1178,7 @@ export class McpApp {
1177
1178
  if (!token) {
1178
1179
  const metadata = this.authProvider.getResourceMetadata();
1179
1180
  return createUnauthorizedResponse(
1180
- buildMetadataUrl(metadata.resource),
1181
+ metadata.resource_metadata_url,
1181
1182
  "missing_token",
1182
1183
  "Authorization header with Bearer token required",
1183
1184
  );
@@ -1187,7 +1188,7 @@ export class McpApp {
1187
1188
  if (!authInfo) {
1188
1189
  const metadata = this.authProvider.getResourceMetadata();
1189
1190
  return createUnauthorizedResponse(
1190
- buildMetadataUrl(metadata.resource),
1191
+ metadata.resource_metadata_url,
1191
1192
  "invalid_token",
1192
1193
  "Invalid or expired token",
1193
1194
  );
@@ -1779,8 +1780,17 @@ export class McpApp {
1779
1780
  /**
1780
1781
  * Build the HTTP middleware stack and return its fetch handler without
1781
1782
  * binding a port. Use this when you want to mount the MCP HTTP layer
1782
- * inside another HTTP framework (Fresh, Hono, Express, Cloudflare Workers,
1783
- * etc.) instead of giving up port ownership to {@link startHttp}.
1783
+ * inside another HTTP framework on Deno (Fresh, Hono, `Deno.serve`) or
1784
+ * Node (Express, Hono-on-Node) instead of giving up port ownership to
1785
+ * {@link startHttp}.
1786
+ *
1787
+ * **Runtime targets:** `@casys/mcp-server` is Deno-first — the canonical
1788
+ * deployment path is Deno 2.x on Deno Deploy or self-hosted Deno, with
1789
+ * a Node 20+ distribution via `scripts/build-node.sh` as a secondary
1790
+ * target. Cloudflare Workers, workerd, and browser runtimes are not
1791
+ * supported — if you target those, use
1792
+ * [`@modelcontextprotocol/server`](https://www.npmjs.com/package/@modelcontextprotocol/server)
1793
+ * directly instead.
1784
1794
  *
1785
1795
  * The returned handler accepts a Web Standard {@link Request} and returns
1786
1796
  * a Web Standard {@link Response}. It exposes the same routes as