@markmnl/fmsg-mcp 0.2.2 → 0.2.4

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 CHANGED
@@ -2,6 +2,15 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ - Add HTTP `FMSG_MCP_AUTH_MODE=oauth+api-key`, serving OAuth and API-key callers on one endpoint.
6
+ `Bearer fmsgk_...` requests use API-key authentication; all others, including unauthenticated
7
+ requests, use OAuth with its discovery challenge and scope checks. Providers and caches stay
8
+ separate. Rejected API keys are also challenged with the protected-resource metadata URL.
9
+ `api-key` and `oauth` modes are unchanged. `HttpServerHandle.providers` exposes both providers.
10
+ - Add optional `FMSG_API_PUBLIC_URL`: the Web API URL `whoami` reports to users, while requests
11
+ keep using `FMSG_API_URL`. It defaults to `FMSG_API_URL`, except that HTTP mode no longer
12
+ reports a cleartext (internal or loopback) upstream URL. `whoami`'s `api_url` is therefore
13
+ nullable and is `null` when no public URL is known.
5
14
  - Add HTTP-only `get_attachment_download_url` and authenticated binary attachment GETs. Links
6
15
  contain no credentials; downloads reuse caller authentication and OAuth read scope, stream the
7
16
  original bytes, and keep Web API access checks authoritative. API-key deployments configure
package/README.md CHANGED
@@ -103,6 +103,7 @@ configured `Authorization: Bearer fmsgk_...` header. Each caller supplies its ow
103
103
  header means a shared fmsg identity. For browser sign-in without user API keys, configure
104
104
  [HTTP OAuth](docs/oauth.md): the operator supplies an issuer, resource URI and exchange client.
105
105
  Users add the public MCP URL in a host supporting that issuer's client registration method.
106
+ `FMSG_MCP_AUTH_MODE=oauth+api-key` accepts both OAuth and API-key callers on the same URL.
106
107
 
107
108
  For [Claude Code over HTTP](https://code.claude.com/docs/en/mcp):
108
109
 
@@ -125,7 +126,7 @@ See the [TLS reverse-proxy example](docs/http-deployment.md) for a loopback depl
125
126
 
126
127
  | Tool | What it does |
127
128
  |---|---|
128
- | `whoami` | The address this server acts as, the API URL and token expiry |
129
+ | `whoami` | The address this server acts as, the public API URL (when known) and token expiry |
129
130
  | `resolve_address` | Turn a short name into `@user@domain` (directory, then default domain) |
130
131
  | `list_messages` | Inbox, newest first, with previews; reactions hidden; optional unread filter |
131
132
  | `list_sent` | Sent messages with per-recipient delivery state |
@@ -154,8 +155,9 @@ attach resources; prompts `chat` and `reply` script the wait → reply loop and
154
155
  |---|---|---|
155
156
  | `FMSG_API_URL` | — | Base URL of the fmsg Web API (required) |
156
157
  | `FMSG_API_KEY` | — | `fmsgk_…` key; stdio mode only |
157
- | `FMSG_MCP_AUTH_MODE` | `api-key` | HTTP authentication: `api-key` or `oauth`; see [OAuth settings](docs/oauth.md#operator-configuration) |
158
- | `FMSG_MCP_PUBLIC_URL` | OAuth resource URL, otherwise unset | Public MCP endpoint, including `/mcp`; enables the HTTP download-link tool. HTTPS required except loopback; in OAuth mode must equal `FMSG_MCP_OAUTH_RESOURCE_URL` |
158
+ | `FMSG_API_PUBLIC_URL` | `FMSG_API_URL` (HTTP mode: only if HTTPS) | Web API URL reported to users by `whoami`; never used for requests. Set it when the server reaches the Web API at an internal address. Without it, HTTP mode reports no URL for a cleartext `FMSG_API_URL`. Validated like `FMSG_API_URL` |
159
+ | `FMSG_MCP_AUTH_MODE` | `api-key` | HTTP authentication: `api-key`, `oauth`, or `oauth+api-key` to accept both on one endpoint; see [OAuth settings](docs/oauth.md#operator-configuration) and [combined mode](docs/oauth.md#combined-oauth-and-api-key-mode) |
160
+ | `FMSG_MCP_PUBLIC_URL` | OAuth resource URL, otherwise unset | Public MCP endpoint, including `/mcp`; enables the HTTP download-link tool. HTTPS required except loopback; when OAuth is enabled must equal `FMSG_MCP_OAUTH_RESOURCE_URL` |
159
161
  | `FMSG_ALLOW_INSECURE_HTTP` | disabled | Set to `1` only to permit cleartext API access on a trusted development/private network; loopback HTTP is allowed by default |
160
162
  | `FMSG_DEFAULT_DOMAIN` | — | Lets short names resolve: `bob` → `@bob@<domain>` |
161
163
  | `FMSG_DIRECTORY` | — | JSON file mapping short names to full addresses |
@@ -164,7 +166,7 @@ attach resources; prompts `chat` and `reply` script the wait → reply loop and
164
166
  | `FMSG_MCP_HOST` / `FMSG_MCP_PORT` | `127.0.0.1` / `8765` | HTTP bind address (or `--http host:port`) |
165
167
  | `FMSG_MCP_ALLOWED_HOSTS` | loopback names | Comma-separated `Host` header allowlist; required for non-loopback binds |
166
168
  | `FMSG_MCP_ALLOWED_ORIGINS` | same origin; loopback origins on loopback binds | Comma-separated browser origins including scheme and port; an explicit list replaces the loopback default; hostname-only values are rejected |
167
- | `FMSG_MCP_KEY_CACHE_MAX` / `FMSG_MCP_KEY_CACHE_TTL_SECONDS` | `500` / `1800` | HTTP client cache bound; TTL applies only to API-key mode |
169
+ | `FMSG_MCP_KEY_CACHE_MAX` / `FMSG_MCP_KEY_CACHE_TTL_SECONDS` | `500` / `1800` | HTTP client cache bound; TTL applies only to API-key callers |
168
170
 
169
171
  The API key is exchanged for a short-lived access token that the server renews automatically.
170
172
  API URLs must not contain credentials, query strings or fragments. Authenticated requests do not
package/SECURITY.md CHANGED
@@ -8,7 +8,7 @@ rather than a public issue.
8
8
 
9
9
  - Messaging authorization, grants, address status, quotas and acceptance remain the responsibility
10
10
  of fmsg-webapi and the host services. Each MCP request uses its caller's upstream identity.
11
- - Over stdio the API key comes from the environment. HTTP callers supply their own bearer keys or OAuth access tokens, according to the configured mode.
11
+ - Over stdio the API key comes from the environment. HTTP callers supply their own bearer keys or OAuth access tokens, according to the configured mode. Combined `oauth+api-key` mode routes `fmsgk_` keys to API-key authentication and all other credentials to OAuth, with separate caches.
12
12
  Keys and JWTs are retained in process memory for token renewal; hashes index the HTTP client cache.
13
13
  This server does not intentionally persist them. Idle entries expire on access and periodic sweeps
14
14
  (at most 30 seconds apart). Evicted or invalidated clients close immediately if idle; active requests
@@ -1,4 +1,4 @@
1
1
  /** Hosts for which cleartext loopback development is safe by default. */
2
2
  export declare function isLoopbackHost(host: string): boolean;
3
- export declare function normalizeApiUrl(value: string, allowInsecureHttp?: boolean): string;
3
+ export declare function normalizeApiUrl(value: string, allowInsecureHttp?: boolean, name?: string): string;
4
4
  export declare function normalizeOrigin(value: string): string;
@@ -2,21 +2,21 @@
2
2
  export function isLoopbackHost(host) {
3
3
  return host === "localhost" || host === "127.0.0.1" || host === "::1" || host === "[::1]";
4
4
  }
5
- export function normalizeApiUrl(value, allowInsecureHttp = false) {
5
+ export function normalizeApiUrl(value, allowInsecureHttp = false, name = "FMSG_API_URL") {
6
6
  let url;
7
7
  try {
8
8
  url = new URL(value);
9
9
  }
10
10
  catch {
11
- throw new Error("FMSG_API_URL must be an absolute http(s) URL");
11
+ throw new Error(`${name} must be an absolute http(s) URL`);
12
12
  }
13
13
  if (url.protocol !== "https:" && url.protocol !== "http:")
14
- throw new Error("FMSG_API_URL must be an http(s) URL");
14
+ throw new Error(`${name} must be an http(s) URL`);
15
15
  if (url.username || url.password || url.search || url.hash) {
16
- throw new Error("FMSG_API_URL must not contain credentials, a query string or a fragment");
16
+ throw new Error(`${name} must not contain credentials, a query string or a fragment`);
17
17
  }
18
18
  if (url.protocol === "http:" && !isLoopbackHost(url.hostname) && !allowInsecureHttp) {
19
- throw new Error("FMSG_API_URL must use HTTPS outside loopback; explicitly enable FMSG_ALLOW_INSECURE_HTTP=1 only for a trusted development/private network");
19
+ throw new Error(`${name} must use HTTPS outside loopback; explicitly enable FMSG_ALLOW_INSECURE_HTTP=1 only for a trusted development/private network`);
20
20
  }
21
21
  return url.href.replace(/\/+$/u, "");
22
22
  }
package/dist/config.d.ts CHANGED
@@ -15,6 +15,12 @@ export type HttpConfig = {
15
15
  export type Config = {
16
16
  transport: Transport;
17
17
  apiUrl: string;
18
+ /**
19
+ * The Web API URL shown to users (whoami). FMSG_API_PUBLIC_URL when set; otherwise FMSG_API_URL,
20
+ * except over HTTP where a cleartext upstream (typically a private or loopback address the
21
+ * server reaches internally) is not shown. Never used for requests.
22
+ */
23
+ apiPublicUrl?: string;
18
24
  /** Explicit opt-in for cleartext upstream traffic outside loopback. */
19
25
  allowInsecureHttp?: boolean;
20
26
  /** Only set in stdio mode. */
package/dist/config.js CHANGED
@@ -39,6 +39,22 @@ function loadDirectory(path) {
39
39
  }
40
40
  return out;
41
41
  }
42
+ /**
43
+ * Resolve the Web API URL shown to users. An explicit FMSG_API_PUBLIC_URL is validated like
44
+ * FMSG_API_URL. Without it, stdio shows FMSG_API_URL (the user's own configuration), while HTTP
45
+ * shows it only when it is HTTPS: a cleartext upstream on a hosted server is an internal address
46
+ * that means nothing to remote clients.
47
+ */
48
+ function resolveApiPublicUrl(env, transport, apiUrl, allowInsecureHttp) {
49
+ const explicit = env.FMSG_API_PUBLIC_URL?.trim();
50
+ if (explicit)
51
+ return normalizeApiUrl(explicit, allowInsecureHttp, "FMSG_API_PUBLIC_URL");
52
+ if (!apiUrl)
53
+ return undefined;
54
+ if (transport === "http" && !apiUrl.startsWith("https:"))
55
+ return undefined;
56
+ return apiUrl;
57
+ }
42
58
  export function loadConfig(env, transport, overrides = {}, options = {}) {
43
59
  const requireCredentials = options.requireCredentials ?? true;
44
60
  const oauth = loadOAuthConfig(env, transport);
@@ -48,6 +64,7 @@ export function loadConfig(env, transport, overrides = {}, options = {}) {
48
64
  }
49
65
  const allowInsecureHttp = env.FMSG_ALLOW_INSECURE_HTTP === "1";
50
66
  const normalizedApiUrl = apiUrl ? normalizeApiUrl(apiUrl, allowInsecureHttp) : "";
67
+ const apiPublicUrl = resolveApiPublicUrl(env, transport, normalizedApiUrl, allowInsecureHttp);
51
68
  const apiKey = env.FMSG_API_KEY?.trim();
52
69
  if (transport === "stdio" && apiKey && !apiKey.startsWith("fmsgk_"))
53
70
  throw new Error("FMSG_API_KEY must start with fmsgk_");
@@ -71,6 +88,7 @@ export function loadConfig(env, transport, overrides = {}, options = {}) {
71
88
  return {
72
89
  transport,
73
90
  apiUrl: normalizedApiUrl,
91
+ ...(apiPublicUrl ? { apiPublicUrl } : {}),
74
92
  ...(oauth ? { oauth } : {}),
75
93
  allowInsecureHttp,
76
94
  ...(transport === "stdio" && apiKey ? { apiKey } : {}),
package/dist/http.d.ts CHANGED
@@ -10,6 +10,12 @@ export declare function sendWebResponse(res: ServerResponse, response: Response,
10
10
  export type HttpServerHandle = {
11
11
  server: Server;
12
12
  close: () => Promise<void>;
13
+ /** The OAuth provider when OAuth is configured, otherwise the API-key provider. */
13
14
  provider: ApiKeyCallerProvider | OAuthCallerProvider;
15
+ /** Each configured provider; both are set in combined `oauth+api-key` mode. */
16
+ providers: {
17
+ apiKey?: ApiKeyCallerProvider;
18
+ oauth?: OAuthCallerProvider;
19
+ };
14
20
  };
15
21
  export declare function createHttpServer(config: Config, log?: (line: string) => void): HttpServerHandle;
package/dist/http.js CHANGED
@@ -15,6 +15,8 @@ import { isLoopbackHost, normalizeOrigin } from "./client/url.js";
15
15
  export const MCP_PATH = "/mcp";
16
16
  const CORS_METHODS = ["POST", "GET", "DELETE"];
17
17
  const CORS_HEADERS = ["authorization", "content-type", "accept", "mcp-protocol-version", "mcp-method", "mcp-name", "mcp-session-id", "last-event-id"];
18
+ // Combined mode routes only fmsg API keys to API-key authentication; everything else is OAuth.
19
+ const API_KEY_BEARER = /^Bearer fmsgk_/iu;
18
20
  /** Convert a Node request into a web-standard Request for the MCP handler. */
19
21
  export function toWebRequest(req, signal) {
20
22
  const host = req.headers.host ?? "localhost";
@@ -105,17 +107,20 @@ export function createHttpServer(config, log = (l) => console.error(l)) {
105
107
  throw new Error("FMSG_MCP_ALLOWED_HOSTS must contain explicit hostnames without wildcards, schemes or paths");
106
108
  const allowedOrigins = config.http.allowedOrigins.map(normalizeOrigin);
107
109
  const safeLog = (line) => log(safeErrorMessage(line));
108
- const provider = config.oauth ? new OAuthCallerProvider(config, safeLog) : new ApiKeyCallerProvider(config, safeLog);
110
+ // Combined mode keeps separate providers and MCP handlers, so a caller is only
111
+ // ever resolved by the provider that authenticated it.
112
+ const oauthProvider = config.oauth ? new OAuthCallerProvider(config, safeLog) : undefined;
113
+ const apiKeyProvider = !config.oauth || config.oauth.acceptApiKeys ? new ApiKeyCallerProvider(config, safeLog) : undefined;
109
114
  const resource = config.oauth ? new URL(config.oauth.resourceUrl) : undefined;
110
115
  const publicOrigin = downloadBaseUrl(config) ? new URL(downloadBaseUrl(config)).origin : undefined;
111
116
  const metadataPath = resource ? `/.well-known/oauth-protected-resource${resource.pathname === "/" ? "" : resource.pathname}` : undefined;
112
117
  const metadataUrl = resource ? `${resource.origin}${metadataPath}` : "";
113
- const handler = createMcpHandler(({ authInfo }) => createFmsgMcpServer(provider, config, authInfo ? {
114
- address: config.oauth ? String(authInfo.extra?.address ?? "") : authInfo.clientId,
115
- } : {}), { onerror: (error) => safeLog(`MCP transport failed: ${error instanceof Error ? error.message : String(error)}`) });
118
+ const mcpHandler = (provider, address) => createMcpHandler(({ authInfo }) => createFmsgMcpServer(provider, config, authInfo ? { address: address(authInfo) } : {}), { onerror: (error) => safeLog(`MCP transport failed: ${error instanceof Error ? error.message : String(error)}`) });
119
+ const oauthHandler = oauthProvider && mcpHandler(oauthProvider, (authInfo) => String(authInfo.extra?.address ?? ""));
120
+ const apiKeyHandler = apiKeyProvider && mcpHandler(apiKeyProvider, (authInfo) => authInfo.clientId);
116
121
  const active = new Set();
117
122
  const server = createServer((req, res) => {
118
- let authenticated;
123
+ let release;
119
124
  const controller = new AbortController();
120
125
  active.add(controller);
121
126
  const abort = () => controller.abort();
@@ -196,7 +201,7 @@ export function createHttpServer(config, log = (l) => console.error(l)) {
196
201
  return sendWebResponse(res, new Response("invalid attachment download path", { status: 400 }));
197
202
  }
198
203
  }
199
- const serveDownload = async (auth) => {
204
+ const serveDownload = async (provider, auth) => {
200
205
  let caller;
201
206
  try {
202
207
  caller = await provider.forRequest(auth);
@@ -232,17 +237,20 @@ export function createHttpServer(config, log = (l) => console.error(l)) {
232
237
  }
233
238
  }
234
239
  const status = rejectedAuth ? 401 : error instanceof FmsgHttpError && error.status >= 400 && error.status <= 599 ? error.status : 502;
240
+ const challenge = `Bearer error="invalid_token"${resource ? `, resource_metadata=${JSON.stringify(metadataUrl)}` : ""}`;
235
241
  return sendWebResponse(res, Response.json({ error: describeError(error) }, {
236
- status, headers: status === 401 ? { "www-authenticate": 'Bearer error="invalid_token"' } : {},
242
+ status, headers: status === 401 ? { "www-authenticate": challenge } : {},
237
243
  }));
238
244
  }
239
245
  };
240
- if (provider instanceof OAuthCallerProvider) {
246
+ const authorization = request.headers.get("authorization") ?? "";
247
+ if (oauthProvider && oauthHandler && !(apiKeyProvider && API_KEY_BEARER.test(authorization))) {
241
248
  try {
242
- const bearer = /^Bearer ([A-Za-z0-9._~+\/-]+=*)$/iu.exec(request.headers.get("authorization") ?? "");
249
+ const bearer = /^Bearer ([A-Za-z0-9._~+\/-]+=*)$/iu.exec(authorization);
243
250
  if (!bearer)
244
251
  throw invalidToken();
245
- authenticated = await provider.verifyAccessToken(bearer[1]);
252
+ const authenticated = await oauthProvider.verifyAccessToken(bearer[1]);
253
+ release = () => oauthProvider.release(authenticated);
246
254
  let parsedBody;
247
255
  if (request.method === "POST") {
248
256
  try {
@@ -258,16 +266,16 @@ export function createHttpServer(config, log = (l) => console.error(l)) {
258
266
  if (scopes.some(scope => !authenticated.scopes.includes(scope)))
259
267
  throw insufficientScope(scopes);
260
268
  if (download)
261
- return await serveDownload(authenticated);
269
+ return await serveDownload(oauthProvider, authenticated);
262
270
  // Discover/list operations only need the incoming JWT. Resolve an
263
271
  // exchanged credential before starting any protected tool response.
264
272
  if (scopes.length) {
265
- const caller = await provider.forRequest(authenticated);
273
+ const caller = await oauthProvider.forRequest(authenticated);
266
274
  await caller.client.getToken(false, controller.signal);
267
275
  }
268
276
  const state = { scopes };
269
277
  return await oauthRequestState.run(state, async () => {
270
- const response = await handler.fetch(request, { authInfo: authenticated, parsedBody });
278
+ const response = await oauthHandler.fetch(request, { authInfo: authenticated, parsedBody });
271
279
  await sendWebResponse(res, response, () => state.error ? oauthErrorResponse(state.error, metadataUrl) : undefined);
272
280
  });
273
281
  }
@@ -277,21 +285,27 @@ export function createHttpServer(config, log = (l) => console.error(l)) {
277
285
  throw error;
278
286
  }
279
287
  }
288
+ if (!apiKeyProvider || !apiKeyHandler)
289
+ throw new Error("no authentication provider configured");
280
290
  // Capture the lease before middleware's expiry/scope checks, so finally also
281
291
  // releases requests rejected after the upstream identity was verified.
282
292
  const gate = requireBearerAuth({
283
293
  verifier: { verifyAccessToken: async (token) => {
284
- authenticated = await provider.verifyAccessToken(token);
294
+ const authenticated = await apiKeyProvider.verifyAccessToken(token);
295
+ release = () => apiKeyProvider.release(authenticated);
285
296
  return authenticated;
286
297
  } },
287
- requiredScopes: [FMSG_SCOPE],
298
+ // In combined mode, challenge with the OAuth metadata so hosts can discover
299
+ // sign-in. The internal API-key scope is omitted there: it is always granted
300
+ // and is not an authorization-server scope a host could request.
301
+ ...(resource ? { resourceMetadataUrl: metadataUrl } : { requiredScopes: [FMSG_SCOPE] }),
288
302
  });
289
303
  const auth = await gate(request);
290
304
  if (auth instanceof Response)
291
305
  return sendWebResponse(res, auth);
292
306
  if (download)
293
- return serveDownload(auth);
294
- return sendWebResponse(res, await handler.fetch(request, { authInfo: auth }));
307
+ return serveDownload(apiKeyProvider, auth);
308
+ return sendWebResponse(res, await apiKeyHandler.fetch(request, { authInfo: auth }));
295
309
  })().catch((error) => {
296
310
  safeLog(`request failed: ${error instanceof Error ? error.message : String(error)}`);
297
311
  if (res.destroyed)
@@ -303,8 +317,7 @@ export function createHttpServer(config, log = (l) => console.error(l)) {
303
317
  res.writeHead(500, { "content-type": "text/plain" });
304
318
  res.end("internal error");
305
319
  }).finally(() => {
306
- if (authenticated)
307
- provider.release(authenticated);
320
+ release?.();
308
321
  active.delete(controller);
309
322
  req.off("aborted", abort);
310
323
  res.off("close", abort);
@@ -317,8 +330,9 @@ export function createHttpServer(config, log = (l) => console.error(l)) {
317
330
  const close = async () => {
318
331
  for (const controller of active)
319
332
  controller.abort();
320
- provider.close();
321
- await handler.close();
333
+ oauthProvider?.close();
334
+ apiKeyProvider?.close();
335
+ await Promise.all([oauthHandler?.close(), apiKeyHandler?.close()]);
322
336
  await new Promise((resolve) => {
323
337
  server.close(() => resolve());
324
338
  // Aborted fetches may open replacement sockets without a request, so
@@ -326,5 +340,5 @@ export function createHttpServer(config, log = (l) => console.error(l)) {
326
340
  server.closeAllConnections();
327
341
  });
328
342
  };
329
- return { server, close, provider };
343
+ return { server, close, provider: (oauthProvider ?? apiKeyProvider), providers: { apiKey: apiKeyProvider, oauth: oauthProvider } };
330
344
  }
package/dist/index.js CHANGED
@@ -24,7 +24,8 @@ Options (HTTP mode):
24
24
  Environment:
25
25
  FMSG_API_URL base URL of the fmsg Web API (required)
26
26
  FMSG_API_KEY fmsgk_... key (stdio mode only)
27
- FMSG_MCP_AUTH_MODE api-key (default) or oauth (HTTP only)
27
+ FMSG_MCP_AUTH_MODE api-key (default), oauth, or oauth+api-key to accept
28
+ both on one endpoint (HTTP only)
28
29
  FMSG_MCP_OAUTH_* OAuth resource/issuer URLs, client ID/secret and exchange
29
30
  audience; see docs/oauth.md for required settings
30
31
  FMSG_ALLOW_INSECURE_HTTP 1 to allow a trusted private HTTP API outside loopback
@@ -5,6 +5,8 @@ export type OAuthConfig = {
5
5
  clientSecret: string;
6
6
  exchangeAudience: string;
7
7
  addressClaim: string;
8
+ /** Combined mode: `fmsgk_` bearer keys use API-key authentication on the same endpoint. */
9
+ acceptApiKeys?: boolean;
8
10
  };
9
11
  /** Preserve exact issuer/resource identifiers while checking their transport and shape. */
10
12
  export declare function oauthUrl(value: string, label: string, allowQuery?: boolean): string;
@@ -16,12 +16,13 @@ export function oauthUrl(value, label, allowQuery = false) {
16
16
  }
17
17
  export function loadOAuthConfig(env, transport) {
18
18
  const mode = env.FMSG_MCP_AUTH_MODE ?? "api-key";
19
- if (mode !== "api-key" && mode !== "oauth")
20
- throw new Error("FMSG_MCP_AUTH_MODE must be api-key or oauth");
19
+ if (mode !== "api-key" && mode !== "oauth" && mode !== "oauth+api-key") {
20
+ throw new Error("FMSG_MCP_AUTH_MODE must be api-key, oauth or oauth+api-key");
21
+ }
21
22
  const configured = Object.keys(env).some(key => key.startsWith("FMSG_MCP_OAUTH_") && env[key]);
22
23
  if (mode === "api-key") {
23
24
  if (configured)
24
- throw new Error("Set FMSG_MCP_AUTH_MODE=oauth to use FMSG_MCP_OAUTH_* settings");
25
+ throw new Error("Set FMSG_MCP_AUTH_MODE=oauth or oauth+api-key to use FMSG_MCP_OAUTH_* settings");
25
26
  return undefined;
26
27
  }
27
28
  if (transport !== "http")
@@ -42,5 +43,6 @@ export function loadOAuthConfig(env, transport) {
42
43
  resourceUrl, issuerUrl, exchangeAudience,
43
44
  clientId: required("CLIENT_ID"), clientSecret: required("CLIENT_SECRET"),
44
45
  addressClaim: env.FMSG_MCP_OAUTH_ADDRESS_CLAIM?.trim() || "sub",
46
+ ...(mode === "oauth+api-key" ? { acceptApiKeys: true } : {}),
45
47
  };
46
48
  }
@@ -6,12 +6,13 @@ import { toolError } from "../errors.js";
6
6
  export const registerIdentityTools = (server, deps) => {
7
7
  server.registerTool("whoami", {
8
8
  title: "Show fmsg identity",
9
- description: "Report the fmsg address this server acts as (from the authenticated connection), the fmsg Web API URL, " +
9
+ description: "Report the fmsg address this server acts as (from the authenticated connection), the fmsg Web API URL " +
10
+ "(null when the server does not publish it), " +
10
11
  "when the current access token expires (it is renewed automatically; no action needed), and the " +
11
12
  "address-resolution defaults. Call this first if unsure who you are sending as.",
12
13
  outputSchema: z.object({
13
14
  address: z.string(),
14
- api_url: z.string(),
15
+ api_url: z.string().nullable(),
15
16
  token_expires_at: z.string().nullable(),
16
17
  transport: z.enum(["stdio", "http"]),
17
18
  default_domain: z.string().nullable(),
@@ -22,14 +23,16 @@ export const registerIdentityTools = (server, deps) => {
22
23
  const expires = isoTime((await caller.tokenExpiresAt()) / 1000);
23
24
  const structured = {
24
25
  address: caller.address,
25
- api_url: caller.client.apiUrl,
26
+ api_url: deps.config.apiPublicUrl ?? null,
26
27
  token_expires_at: expires,
27
28
  transport: deps.config.transport,
28
29
  default_domain: deps.config.defaultDomain ?? null,
29
30
  directory_names: Object.keys(deps.config.directory ?? {}),
30
31
  };
31
32
  const lines = [
32
- `You are **${caller.address}** on ${caller.client.apiUrl} (${deps.config.transport}).`,
33
+ deps.config.apiPublicUrl
34
+ ? `You are **${caller.address}** on ${deps.config.apiPublicUrl} (${deps.config.transport}).`
35
+ : `You are **${caller.address}** (${deps.config.transport}).`,
33
36
  `Access token expires ${expires ?? "unknown"} and is renewed automatically.`,
34
37
  ];
35
38
  if (deps.config.defaultDomain)
@@ -2,8 +2,8 @@
2
2
 
3
3
  This example runs Caddy and fmsg-mcp on the same machine. Replace `mcp.example.com` with your DNS
4
4
  name pointing to that machine; Caddy needs access to ports 80/443 for automatic public TLS.
5
- Keep port 8765 bound to loopback. The command below uses API keys; for OAuth add the
6
- [OAuth configuration](oauth.md#operator-configuration).
5
+ Keep port 8765 bound to loopback. The command below uses API keys; for OAuth, or OAuth and API
6
+ keys on the same endpoint, add the [OAuth configuration](oauth.md#operator-configuration).
7
7
 
8
8
  ```sh
9
9
  FMSG_API_URL=https://api.example.com \
@@ -13,7 +13,12 @@ FMSG_MCP_ALLOWED_ORIGINS=https://mcp.example.com,https://app.example.com \
13
13
  npx -y @markmnl/fmsg-mcp --http 127.0.0.1:8765
14
14
  ```
15
15
 
16
- `FMSG_API_KEY` must be unset in HTTP mode. Include only browser origins you use. Behind TLS,
16
+ `FMSG_API_KEY` must be unset in HTTP mode. Include only browser origins you use.
17
+
18
+ If the server reaches the Web API at an internal address instead, for example
19
+ `FMSG_API_URL=http://10.0.0.5:8000` with `FMSG_ALLOW_INSECURE_HTTP=1` on a private network, also set
20
+ `FMSG_API_PUBLIC_URL=https://api.example.com` so `whoami` reports the URL users know. Without it,
21
+ `whoami` omits the API URL rather than show the internal address. Behind TLS,
17
22
  list the public HTTPS origin explicitly: the server sees the proxy's HTTP connection and does
18
23
  not trust forwarded headers to establish the request origin.
19
24
 
@@ -49,8 +54,8 @@ allow response idle time beyond `FMSG_MCP_WAIT_MAX_SECONDS` with assembly headro
49
54
  allowed and denied Host/Origin requests, unauthenticated 401 responses, CORS preflight, a read,
50
55
  and cancellation through the actual deployed proxy before advertising that deployment.
51
56
 
52
- The metadata routes are public in OAuth mode and must reach the server for MCP authorization
53
- discovery. In API-key mode they return 404. When OAuth is configured, its resource URL supplies
57
+ The metadata routes are public in `oauth` and `oauth+api-key` modes and must reach the server for
58
+ MCP authorization discovery. In API-key mode they return 404. When OAuth is configured, its resource URL supplies
54
59
  the public same-origin value, so explicitly listing that origin is optional.
55
60
 
56
61
  ## Binary attachment downloads
package/docs/oauth.md CHANGED
@@ -1,8 +1,9 @@
1
1
  # HTTP OAuth
2
2
 
3
3
  OAuth is an optional HTTP authentication mode. API-key HTTP and stdio remain available with
4
- `FMSG_MCP_AUTH_MODE=api-key` (the default). Each endpoint uses one mode; run separate instances
5
- if you need both. There is no API-key fallback in OAuth mode.
4
+ `FMSG_MCP_AUTH_MODE=api-key` (the default). `FMSG_MCP_AUTH_MODE=oauth` accepts only OAuth access
5
+ tokens; there is no API-key fallback. `FMSG_MCP_AUTH_MODE=oauth+api-key` serves both kinds of
6
+ caller on one endpoint; see [combined mode](#combined-oauth-and-api-key-mode).
6
7
 
7
8
  A user adds the public MCP URL to a compatible host, signs in with the configured authorization
8
9
  server, and consents to messaging scopes. The host handles authorization-code/PKCE and refresh;
@@ -57,6 +58,26 @@ Use the [TLS deployment recipe](http-deployment.md), including its metadata rout
57
58
  on other origins need `FMSG_MCP_ALLOWED_ORIGINS`. The configured resource origin is accepted as
58
59
  same-origin behind a TLS proxy; forwarded headers never select an issuer, resource or token endpoint.
59
60
 
61
+ ## Combined OAuth and API-key mode
62
+
63
+ `FMSG_MCP_AUTH_MODE=oauth+api-key` (HTTP only) accepts OAuth and API-key callers at the same
64
+ public MCP URL. It requires the complete OAuth configuration above. Each request is routed by its
65
+ `Authorization` header:
66
+
67
+ - `Bearer fmsgk_...` is handled exactly as in API-key mode. The key is exchanged at the Web API
68
+ and is never sent to the authorization server. OAuth scopes do not apply; the Web API decides
69
+ access as for any API-key caller.
70
+ - Any other bearer token, or a missing or malformed header, is handled exactly as in OAuth mode,
71
+ including scope checks. Unauthenticated requests receive the OAuth `401` challenge with
72
+ `resource_metadata`, so hosts can discover sign-in.
73
+
74
+ The two kinds of caller use separate providers and caches: a request is only ever served by the
75
+ mechanism that authenticated it, and a JWT is never treated as an API key. Protected-resource
76
+ metadata is served as in OAuth mode, and attachment downloads accept either credential. A rejected
77
+ API key returns `401` with `error="invalid_token"` and the same `resource_metadata`, without a
78
+ `scope` parameter, so a host can offer sign-in instead. API-key callers need a host that sends a
79
+ configured `Authorization` header; hosts that only support MCP authorization sign in with OAuth.
80
+
60
81
  ## Discovery and client onboarding
61
82
 
62
83
  Public [RFC 9728](https://www.rfc-editor.org/rfc/rfc9728.html) metadata is served at
@@ -126,7 +147,7 @@ Each incoming token has its own client and cache, even when two tokens name the
126
147
  The cache lifetime is the earliest of `expires_in`, exchanged JWT expiry, incoming JWT expiry
127
148
  and five minutes from the exchange request. Renewal normally starts halfway through that lifetime.
128
149
  `FMSG_MCP_KEY_CACHE_MAX` bounds retained client entries; idle OAuth entries are evicted after five
129
- minutes. `FMSG_MCP_KEY_CACHE_TTL_SECONDS` applies only to API-key mode and cannot extend OAuth
150
+ minutes. `FMSG_MCP_KEY_CACHE_TTL_SECONDS` applies only to API-key callers and cannot extend OAuth
130
151
  credentials. Active requests retain their own leases through cache eviction. No token is persisted.
131
152
 
132
153
  Web API `401` retries once after re-exchange; `403` never retries. An expiry timer closes an OAuth
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@markmnl/fmsg-mcp",
3
3
  "mcpName": "io.github.markmnl/fmsg-mcp",
4
- "version": "0.2.2",
4
+ "version": "0.2.4",
5
5
  "description": "MCP server for fmsg: send and receive federated messages from any AI agent via a deployed fmsg Web API",
6
6
  "type": "module",
7
7
  "license": "MIT",
package/server.json CHANGED
@@ -8,13 +8,13 @@
8
8
  "url": "https://github.com/markmnl/fmsg-mcp",
9
9
  "source": "github"
10
10
  },
11
- "version": "0.2.2",
11
+ "version": "0.2.4",
12
12
  "packages": [
13
13
  {
14
14
  "registryType": "npm",
15
15
  "registryBaseUrl": "https://registry.npmjs.org",
16
16
  "identifier": "@markmnl/fmsg-mcp",
17
- "version": "0.2.2",
17
+ "version": "0.2.4",
18
18
  "transport": {
19
19
  "type": "stdio"
20
20
  },