@ultimatelemon-eu/mcp-http 2.3.0 → 2.4.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
@@ -42,7 +42,7 @@ process.once('SIGTERM', () => {
42
42
  | `host` | `'0.0.0.0'` | `'127.0.0.1'` keeps it local |
43
43
  | `basePath` | `''` | Also serve MCP on `<basePath>` and `<basePath>/mcp`, and `<basePath>/healthz` |
44
44
  | `health` | always ok | Return `false` for a 503 on `/healthz` |
45
- | `apiKey` | off | Require the caller's key; `hint` is added to the 401 message |
45
+ | `apiKey` | off | Require the caller's key; `hint` is added to the 401, `verify` checks it |
46
46
  | `oauth` | off | Let callers sign in with the upstream service; see [OAuth](#oauth) |
47
47
  | `logger` | silent | Anything with `error(obj, msg)`, such as pino |
48
48
 
@@ -57,6 +57,15 @@ before building an MCP server. The key reaches `createMcpServer` as
57
57
  `apiKey`; it is never logged or stored. Other per-caller settings can travel
58
58
  as headers of their own, read from `headers` in the same context.
59
59
 
60
+ When the server hands out keys itself, `verify` checks one before any MCP
61
+ work: return `false` and the caller gets a 401 with `error="invalid_token"`.
62
+
63
+ ```ts
64
+ apiKey: {
65
+ verify: (key) => findUserByToken(key) !== undefined;
66
+ }
67
+ ```
68
+
60
69
  This suits public servers where each user brings their own credentials: no
61
70
  sessions, no database. In claude.ai, add the connector with
62
71
  "No sign-in" and the key as a request header.
package/dist/oauth.js CHANGED
@@ -9,7 +9,7 @@ const UPSTREAM_LEEWAY_MS = 5 * 60 * 1000;
9
9
  const COOKIE = 'mcp_oauth_state';
10
10
  function sealer(issuer) {
11
11
  return {
12
- seal(value, ttlMs) {
12
+ seal: (value, ttlMs) => {
13
13
  const sealed = {
14
14
  ...value,
15
15
  i: issuer,
@@ -17,7 +17,7 @@ function sealer(issuer) {
17
17
  };
18
18
  return encrypt(JSON.stringify(sealed));
19
19
  },
20
- open(value, type) {
20
+ open: (value, type) => {
21
21
  if (!value || !isEncrypted(value))
22
22
  return undefined;
23
23
  try {
@@ -274,7 +274,8 @@ export function createOAuthHandler(options, basePath) {
274
274
  u: tokens,
275
275
  ...(flow.s !== undefined ? { s: flow.s } : {})
276
276
  }, STATE_TTL_MS);
277
- return htmlResponse(settingsPage(form, `${origin}${paths.settings}`, pending), 200);
277
+ // Browsers apply form-action to the redirect after the POST, so the client's origin must be allowed.
278
+ return htmlResponse(settingsPage(form, `${origin}${paths.settings}`, pending), 200, [new URL(flow.r).origin]);
278
279
  }
279
280
  async function callback(request, url) {
280
281
  const failure = url.searchParams.get('error');
package/dist/pages.d.ts CHANGED
@@ -19,6 +19,6 @@ export interface SettingsForm {
19
19
  fields: SettingsField[];
20
20
  }
21
21
  export declare function escapeHtml(value: string): string;
22
- export declare function htmlResponse(body: string, status: number): Response;
22
+ export declare function htmlResponse(body: string, status: number, formTargets?: readonly string[]): Response;
23
23
  export declare function errorPage(title: string, detail: string): string;
24
24
  export declare function settingsPage(form: SettingsForm, action: string, pending: string): string;
package/dist/pages.js CHANGED
@@ -48,13 +48,14 @@ function page(title, body) {
48
48
  <body><main>${body}</main></body>
49
49
  </html>`;
50
50
  }
51
- export function htmlResponse(body, status) {
51
+ export function htmlResponse(body, status, formTargets = []) {
52
+ const formAction = ["'self'", ...formTargets].join(' ');
52
53
  return new Response(body, {
53
54
  status,
54
55
  headers: {
55
56
  'content-type': 'text/html; charset=utf-8',
56
57
  'cache-control': 'no-store',
57
- 'content-security-policy': "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; frame-ancestors 'none'",
58
+ 'content-security-policy': `default-src 'none'; style-src 'unsafe-inline'; form-action ${formAction}; frame-ancestors 'none'`,
58
59
  'x-frame-options': 'DENY'
59
60
  }
60
61
  });
package/dist/server.d.ts CHANGED
@@ -17,6 +17,8 @@ export interface RequestContext {
17
17
  export interface ApiKeyOptions {
18
18
  /** Added to the 401 message, e.g. where to get a key. */
19
19
  hint?: string;
20
+ /** For keys this server issued itself: return false to answer 401 before building a server. */
21
+ verify?: (key: string) => boolean | Promise<boolean>;
20
22
  }
21
23
  export interface McpHttpOptions {
22
24
  /** Builds a fresh server per request; the transport is stateless. */
package/dist/server.js CHANGED
@@ -175,14 +175,19 @@ export async function startMcpHttpServer(options) {
175
175
  return;
176
176
  }
177
177
  const apiKey = options.apiKey ? (headerKey(req) ?? bearer) : undefined;
178
+ const hint = options.apiKey?.hint ? ` ${options.apiKey.hint}` : '';
178
179
  if (!apiKey) {
179
- const hint = options.apiKey?.hint ? ` ${options.apiKey.hint}` : '';
180
180
  res.setHeader('www-authenticate', oauth ? oauth.challenge() : 'Bearer');
181
181
  jsonRpcError(res, 401, options.apiKey
182
182
  ? `Missing API key: send it in the X-Api-Key header or as Authorization: Bearer <key>.${hint}`
183
183
  : 'Sign in to use this server.');
184
184
  return;
185
185
  }
186
+ if (options.apiKey?.verify && !(await options.apiKey.verify(apiKey))) {
187
+ res.setHeader('www-authenticate', 'Bearer error="invalid_token"');
188
+ jsonRpcError(res, 401, `The API key is not valid.${hint}`);
189
+ return;
190
+ }
186
191
  await nodeHandler(Object.assign(req, {
187
192
  auth: { token: apiKey, clientId: 'api-key', scopes: [] }
188
193
  }), res);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimatelemon-eu/mcp-http",
3
- "version": "2.3.0",
3
+ "version": "2.4.0",
4
4
  "description": "Run an MCP server over stateless Streamable HTTP: /mcp, /healthz, a base path for proxies, the caller's own API key or a stateless OAuth sign-in, and errors that never leak a stack trace.",
5
5
  "license": "MIT",
6
6
  "repository": {