@casys/mcp-server 0.14.0 → 0.15.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 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.0",
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
  /**
@@ -54,6 +71,7 @@ interface CachedAuth {
54
71
  export class JwtAuthProvider extends AuthProvider {
55
72
  private jwks: ReturnType<typeof createRemoteJWKSet>;
56
73
  private options: JwtAuthProviderOptions;
74
+ private readonly resourceMetadataUrl: string;
57
75
 
58
76
  // Token verification cache: hash(token) → AuthInfo with TTL
59
77
  // Prevents redundant JWKS fetches (network round-trip per tool call)
@@ -79,6 +97,32 @@ export class JwtAuthProvider extends AuthProvider {
79
97
  );
80
98
  }
81
99
 
100
+ // Resolve resource_metadata_url: explicit > auto-derive (if URL) > throw.
101
+ // Per RFC 9728 § 2, `resource` is an URI identifier and MAY be opaque
102
+ // (e.g., an OIDC project ID used as JWT audience). The metadata document
103
+ // URL is a separate concept — always an HTTP(S) URL. We used to derive
104
+ // it from `resource` by string concatenation, which produced a broken
105
+ // URL when `resource` was not itself an HTTP(S) URL. 0.15.0+ requires
106
+ // the caller to provide the URL explicitly whenever `resource` is opaque.
107
+ if (options.resourceMetadataUrl) {
108
+ this.resourceMetadataUrl = options.resourceMetadataUrl;
109
+ } else {
110
+ const isUrl = /^https?:\/\//.test(options.resource);
111
+ if (!isUrl) {
112
+ throw new Error(
113
+ `[JwtAuthProvider] resourceMetadataUrl is required when 'resource' ` +
114
+ `is not an HTTP(S) URL (got resource="${options.resource}"). ` +
115
+ `Per RFC 9728 § 2, 'resource' is an URI identifier that can be ` +
116
+ `opaque (e.g., an OIDC project ID used as JWT audience). The ` +
117
+ `metadata document URL is a separate concept. Set 'resourceMetadataUrl' ` +
118
+ `to the HTTPS URL where your /.well-known/oauth-protected-resource ` +
119
+ `endpoint is served publicly (e.g., "https://my-mcp.example.com/.well-known/oauth-protected-resource").`,
120
+ );
121
+ }
122
+ const base = options.resource.replace(/\/$/, "");
123
+ this.resourceMetadataUrl = `${base}/.well-known/oauth-protected-resource`;
124
+ }
125
+
82
126
  this.options = options;
83
127
  const jwksUri = options.jwksUri ??
84
128
  `${options.issuer}/.well-known/jwks.json`;
@@ -153,6 +197,7 @@ export class JwtAuthProvider extends AuthProvider {
153
197
  getResourceMetadata(): ProtectedResourceMetadata {
154
198
  return {
155
199
  resource: this.options.resource,
200
+ resource_metadata_url: this.resourceMetadataUrl,
156
201
  authorization_servers: this.options.authorizationServers,
157
202
  scopes_supported: this.options.scopesSupported,
158
203
  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