@markmnl/fmsg-mcp 0.2.3 → 0.2.5

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,17 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ - Add `FMSG_DEFAULT_DOMAIN=caller` (case-insensitive): short names resolve on the domain of the
6
+ address the server acts as for each request, so a deployment serving callers on several domains
7
+ resolves `bob` to `@bob@example.org` for `@mark@example.org` and `@bob@example.net` for a caller
8
+ on `example.net`. `whoami`'s `default_domain` and the server instructions show the caller's
9
+ domain. Over stdio, `resolve_address` first looks up the configured key's address. Directory
10
+ entries still take precedence; a fixed or unset default domain is unchanged.
11
+ - Add HTTP `FMSG_MCP_AUTH_MODE=oauth+api-key`, serving OAuth and API-key callers on one endpoint.
12
+ `Bearer fmsgk_...` requests use API-key authentication; all others, including unauthenticated
13
+ requests, use OAuth with its discovery challenge and scope checks. Providers and caches stay
14
+ separate. Rejected API keys are also challenged with the protected-resource metadata URL.
15
+ `api-key` and `oauth` modes are unchanged. `HttpServerHandle.providers` exposes both providers.
5
16
  - Add optional `FMSG_API_PUBLIC_URL`: the Web API URL `whoami` reports to users, while requests
6
17
  keep using `FMSG_API_URL`. It defaults to `FMSG_API_URL`, except that HTTP mode no longer
7
18
  reports a cleartext (internal or loopback) upstream URL. `whoami`'s `api_url` is therefore
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
 
@@ -155,17 +156,17 @@ attach resources; prompts `chat` and `reply` script the wait → reply loop and
155
156
  | `FMSG_API_URL` | — | Base URL of the fmsg Web API (required) |
156
157
  | `FMSG_API_KEY` | — | `fmsgk_…` key; stdio mode only |
157
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` |
158
- | `FMSG_MCP_AUTH_MODE` | `api-key` | HTTP authentication: `api-key` or `oauth`; see [OAuth settings](docs/oauth.md#operator-configuration) |
159
- | `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` |
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` |
160
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 |
161
- | `FMSG_DEFAULT_DOMAIN` | — | Lets short names resolve: `bob` → `@bob@<domain>` |
162
+ | `FMSG_DEFAULT_DOMAIN` | — | Lets short names resolve: `bob` → `@bob@<domain>`. `caller` resolves them on the domain of the address the server acts as for each request, so `@mark@example.org` gets `@bob@example.org`; directory entries still take precedence |
162
163
  | `FMSG_DIRECTORY` | — | JSON file mapping short names to full addresses |
163
164
  | `FMSG_MCP_DOWNLOAD_DIR` | — | Enable `save_attachment` in stdio; folder for new files named from message ID and filename |
164
165
  | `FMSG_MCP_WAIT_MAX_SECONDS` | `230` | Cap on one `wait_for_message` call |
165
166
  | `FMSG_MCP_HOST` / `FMSG_MCP_PORT` | `127.0.0.1` / `8765` | HTTP bind address (or `--http host:port`) |
166
167
  | `FMSG_MCP_ALLOWED_HOSTS` | loopback names | Comma-separated `Host` header allowlist; required for non-loopback binds |
167
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 |
168
- | `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 |
169
170
 
170
171
  The API key is exchanged for a short-lived access token that the server renews automatically.
171
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
package/dist/address.d.ts CHANGED
@@ -8,10 +8,21 @@ export declare function normalizeFmsgAddress(value: string): string | undefined;
8
8
  /** Case-insensitive address equality. */
9
9
  export declare function sameAddress(a: string, b: string): boolean;
10
10
  export declare function isFmsgAddress(value: string): boolean;
11
+ /** Reserved FMSG_DEFAULT_DOMAIN value: short names resolve on the caller's own domain. */
12
+ export declare const CALLER_DOMAIN = "caller";
13
+ /**
14
+ * The domain short names resolve to: the configured default domain, or with
15
+ * `caller` the domain of the address the server acts as. Undefined when neither
16
+ * is known (no default domain, or `caller` before the caller address is known).
17
+ */
18
+ export declare function effectiveDefaultDomain(defaultDomain: string | undefined, callerAddress?: string): string | undefined;
11
19
  export type Resolution = "literal" | "directory" | "default_domain";
12
20
  export type AddressResolver = {
21
+ /** A domain, or CALLER_DOMAIN to use the domain of `callerAddress`. */
13
22
  defaultDomain?: string;
14
23
  directory?: Record<string, string>;
24
+ /** The address the server acts as, when known. */
25
+ callerAddress?: string;
15
26
  };
16
27
  export type ResolvedAddress = {
17
28
  address: string;
@@ -19,7 +30,8 @@ export type ResolvedAddress = {
19
30
  };
20
31
  /**
21
32
  * Resolve a full address or short name: literal `@user@domain` first, then a
22
- * configured directory entry, then `@name@<default domain>`.
33
+ * configured directory entry, then `@name@<default domain>` (the caller's own
34
+ * domain when the default domain is `caller`).
23
35
  */
24
36
  export declare function resolveAddress(name: string, resolver?: AddressResolver): ResolvedAddress;
25
37
  export declare function resolveAddresses(names: string[], resolver?: AddressResolver): string[];
package/dist/address.js CHANGED
@@ -19,9 +19,23 @@ export function sameAddress(a, b) {
19
19
  export function isFmsgAddress(value) {
20
20
  return normalizeFmsgAddress(value) !== undefined;
21
21
  }
22
+ /** Reserved FMSG_DEFAULT_DOMAIN value: short names resolve on the caller's own domain. */
23
+ export const CALLER_DOMAIN = "caller";
24
+ /**
25
+ * The domain short names resolve to: the configured default domain, or with
26
+ * `caller` the domain of the address the server acts as. Undefined when neither
27
+ * is known (no default domain, or `caller` before the caller address is known).
28
+ */
29
+ export function effectiveDefaultDomain(defaultDomain, callerAddress) {
30
+ if (defaultDomain !== CALLER_DOMAIN)
31
+ return defaultDomain;
32
+ const match = callerAddress ? ADDRESS.exec(callerAddress.trim()) : null;
33
+ return match ? match[2].toLowerCase() : undefined;
34
+ }
22
35
  /**
23
36
  * Resolve a full address or short name: literal `@user@domain` first, then a
24
- * configured directory entry, then `@name@<default domain>`.
37
+ * configured directory entry, then `@name@<default domain>` (the caller's own
38
+ * domain when the default domain is `caller`).
25
39
  */
26
40
  export function resolveAddress(name, resolver = {}) {
27
41
  const trimmed = name.trim();
@@ -41,8 +55,9 @@ export function resolveAddress(name, resolver = {}) {
41
55
  return { address, resolution: "directory" };
42
56
  }
43
57
  }
44
- if (resolver.defaultDomain) {
45
- const address = normalizeFmsgAddress(`@${trimmed}@${resolver.defaultDomain}`);
58
+ const domain = effectiveDefaultDomain(resolver.defaultDomain, resolver.callerAddress);
59
+ if (domain) {
60
+ const address = normalizeFmsgAddress(`@${trimmed}@${domain}`);
46
61
  if (address)
47
62
  return { address, resolution: "default_domain" };
48
63
  }
package/dist/config.d.ts CHANGED
@@ -25,6 +25,7 @@ export type Config = {
25
25
  allowInsecureHttp?: boolean;
26
26
  /** Only set in stdio mode. */
27
27
  apiKey?: string;
28
+ /** Domain for short names, or CALLER_DOMAIN for each caller's own domain. */
28
29
  defaultDomain?: string;
29
30
  directory?: Record<string, string>;
30
31
  /** Trusted local destination; enables save_attachment over stdio only. */
package/dist/config.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { readFileSync } from "node:fs";
2
- import { normalizeFmsgAddress } from "./address.js";
2
+ import { CALLER_DOMAIN, normalizeFmsgAddress } from "./address.js";
3
3
  import { normalizeApiUrl, normalizeOrigin } from "./client/url.js";
4
4
  import { loadOAuthConfig, oauthUrl } from "./oauth/config.js";
5
5
  export const DEFAULT_HTTP_PORT = 8765;
@@ -74,7 +74,8 @@ export function loadConfig(env, transport, overrides = {}, options = {}) {
74
74
  if (transport === "http" && apiKey) {
75
75
  throw new Error("FMSG_API_KEY must not be set in HTTP mode: each client supplies its own bearer credential");
76
76
  }
77
- const defaultDomain = env.FMSG_DEFAULT_DOMAIN?.trim().replace(/^@/u, "") || undefined;
77
+ const rawDefaultDomain = env.FMSG_DEFAULT_DOMAIN?.trim().replace(/^@/u, "") || undefined;
78
+ const defaultDomain = rawDefaultDomain?.toLowerCase() === CALLER_DOMAIN ? CALLER_DOMAIN : rawDefaultDomain;
78
79
  const directoryPath = env.FMSG_DIRECTORY?.trim();
79
80
  const port = overrides.port ?? intEnv(env, "FMSG_MCP_PORT", DEFAULT_HTTP_PORT, 0);
80
81
  if (!Number.isInteger(port) || port < 0 || port > 65535)
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,11 +24,13 @@ 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
31
- FMSG_DEFAULT_DOMAIN lets short names resolve: bob -> @bob@<domain>
32
+ FMSG_DEFAULT_DOMAIN lets short names resolve: bob -> @bob@<domain>; caller
33
+ uses the domain of each caller's own address
32
34
  FMSG_DIRECTORY JSON file mapping short names to @user@domain
33
35
  FMSG_MCP_DOWNLOAD_DIR enables save_attachment to this local folder (stdio)
34
36
  FMSG_MCP_WAIT_MAX_SECONDS cap on one wait_for_message call (default 230)
@@ -1,12 +1,7 @@
1
- /**
2
- * Server instructions: returned in the MCP `initialize` result and folded into
3
- * the model's system prompt by hosts. Three jobs only: precedence over other
4
- * fmsg access paths, the irreversible-send rule, and the usage facts a model
5
- * otherwise gets wrong. Per-tool detail lives in the tool descriptions.
6
- */
7
1
  export type InstructionsContext = {
8
2
  /** The address this server acts as, when already known (HTTP callers; stdio after a token exchange). */
9
3
  address?: string;
4
+ /** FMSG_DEFAULT_DOMAIN: a domain, or CALLER_DOMAIN for the caller's own domain. */
10
5
  defaultDomain?: string;
11
6
  };
12
7
  export declare function buildInstructions(ctx?: InstructionsContext): string;
@@ -1,10 +1,20 @@
1
+ /**
2
+ * Server instructions: returned in the MCP `initialize` result and folded into
3
+ * the model's system prompt by hosts. Three jobs only: precedence over other
4
+ * fmsg access paths, the irreversible-send rule, and the usage facts a model
5
+ * otherwise gets wrong. Per-tool detail lives in the tool descriptions.
6
+ */
7
+ import { CALLER_DOMAIN, effectiveDefaultDomain } from "./address.js";
1
8
  export function buildInstructions(ctx = {}) {
2
9
  const identity = ctx.address
3
10
  ? `you are acting as ${ctx.address}`
4
11
  : "call whoami to see which";
5
- const shortNames = ctx.defaultDomain
6
- ? `; short names resolve to @name@${ctx.defaultDomain}`
7
- : "";
12
+ const domain = effectiveDefaultDomain(ctx.defaultDomain, ctx.address);
13
+ const shortNames = domain
14
+ ? `; short names resolve to @name@${domain}`
15
+ : ctx.defaultDomain === CALLER_DOMAIN
16
+ ? "; short names resolve to @name@<your domain>, the domain of the address you act as"
17
+ : "";
8
18
  return [
9
19
  `This server sends and receives fmsg messages as one fmsg address: ${identity}. ` +
10
20
  "Use its tools for everything fmsg: inbox, threads, attachments, sending, replying, reactions, " +
@@ -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
  }
@@ -1,5 +1,6 @@
1
1
  import type { CallToolResult, McpServer, ServerContext, ToolAnnotations } from "@modelcontextprotocol/server";
2
2
  import * as z from "zod/v4";
3
+ import type { AddressResolver } from "../address.js";
3
4
  import type { FmsgMessage } from "../client/types.js";
4
5
  import type { Config } from "../config.js";
5
6
  import { type Caller, type CallerProvider } from "../context.js";
@@ -57,6 +58,8 @@ export type MessageItem = z.infer<typeof messageItem>;
57
58
  export declare function toItem(m: FmsgMessage, self: string): MessageItem;
58
59
  export declare function deliveryOf(m: FmsgMessage): z.infer<typeof deliveryItem>[];
59
60
  export declare function ok(text: string, structured: Record<string, unknown>): CallToolResult;
61
+ /** Short-name resolution for this caller: the configured directory and default domain. */
62
+ export declare function resolverFor(deps: ToolDeps, caller: Caller): AddressResolver;
60
63
  /** Resolve the caller and run a tool body, turning any failure into an `isError` result. */
61
64
  export declare function withCaller(deps: ToolDeps, ctx: ServerContext, body: (caller: Caller, signal: AbortSignal) => Promise<CallToolResult>): Promise<CallToolResult>;
62
65
  export type Register = (server: McpServer, deps: ToolDeps) => void;
@@ -72,6 +72,10 @@ export function deliveryOf(m) {
72
72
  export function ok(text, structured) {
73
73
  return { content: [{ type: "text", text }], structuredContent: structured };
74
74
  }
75
+ /** Short-name resolution for this caller: the configured directory and default domain. */
76
+ export function resolverFor(deps, caller) {
77
+ return { ...deps.config, callerAddress: caller.address };
78
+ }
75
79
  /** Resolve the caller and run a tool body, turning any failure into an `isError` result. */
76
80
  export async function withCaller(deps, ctx, body) {
77
81
  let caller;
@@ -1,7 +1,7 @@
1
1
  import * as z from "zod/v4";
2
- import { resolveAddress } from "../address.js";
2
+ import { CALLER_DOMAIN, effectiveDefaultDomain, resolveAddress } from "../address.js";
3
3
  import { isoTime } from "../render.js";
4
- import { READ_ONLY, ok, withCaller } from "./common.js";
4
+ import { READ_ONLY, ok, resolverFor, withCaller } from "./common.js";
5
5
  import { toolError } from "../errors.js";
6
6
  export const registerIdentityTools = (server, deps) => {
7
7
  server.registerTool("whoami", {
@@ -21,12 +21,13 @@ export const registerIdentityTools = (server, deps) => {
21
21
  annotations: { ...READ_ONLY, openWorldHint: false },
22
22
  }, async (ctx) => withCaller(deps, ctx, async (caller) => {
23
23
  const expires = isoTime((await caller.tokenExpiresAt()) / 1000);
24
+ const defaultDomain = effectiveDefaultDomain(deps.config.defaultDomain, caller.address);
24
25
  const structured = {
25
26
  address: caller.address,
26
27
  api_url: deps.config.apiPublicUrl ?? null,
27
28
  token_expires_at: expires,
28
29
  transport: deps.config.transport,
29
- default_domain: deps.config.defaultDomain ?? null,
30
+ default_domain: defaultDomain ?? null,
30
31
  directory_names: Object.keys(deps.config.directory ?? {}),
31
32
  };
32
33
  const lines = [
@@ -35,8 +36,8 @@ export const registerIdentityTools = (server, deps) => {
35
36
  : `You are **${caller.address}** (${deps.config.transport}).`,
36
37
  `Access token expires ${expires ?? "unknown"} and is renewed automatically.`,
37
38
  ];
38
- if (deps.config.defaultDomain)
39
- lines.push(`Short names resolve to @name@${deps.config.defaultDomain}.`);
39
+ if (defaultDomain)
40
+ lines.push(`Short names resolve to @name@${defaultDomain}.`);
40
41
  if (structured.directory_names.length)
41
42
  lines.push(`Directory names: ${structured.directory_names.join(", ")}.`);
42
43
  return ok(lines.join("\n"), structured);
@@ -49,13 +50,19 @@ export const registerIdentityTools = (server, deps) => {
49
50
  inputSchema: z.object({ name: z.string().describe("Full fmsg address (@user@domain) or a short name") }),
50
51
  outputSchema: z.object({ address: z.string(), resolution: z.enum(["literal", "directory", "default_domain"]) }),
51
52
  annotations: { ...READ_ONLY, openWorldHint: false },
52
- }, async ({ name }) => {
53
- try {
54
- const resolved = resolveAddress(name, deps.config);
55
- return ok(`${name} → ${resolved.address} (${resolved.resolution})`, resolved);
56
- }
57
- catch (error) {
58
- return toolError(error instanceof Error ? error.message : String(error));
59
- }
53
+ }, async ({ name }, ctx) => {
54
+ const resolve = (resolver) => {
55
+ try {
56
+ const resolved = resolveAddress(name, resolver);
57
+ return ok(`${name} → ${resolved.address} (${resolved.resolution})`, resolved);
58
+ }
59
+ catch (error) {
60
+ return toolError(error instanceof Error ? error.message : String(error));
61
+ }
62
+ };
63
+ // Only the caller's own domain needs the caller; otherwise resolution is local.
64
+ if (deps.config.defaultDomain !== CALLER_DOMAIN)
65
+ return resolve(deps.config);
66
+ return withCaller(deps, ctx, async (caller) => resolve(resolverFor(deps, caller)));
60
67
  });
61
68
  };
@@ -2,7 +2,7 @@ import * as z from "zod/v4";
2
2
  import { resolveAddresses, sameAddress } from "../address.js";
3
3
  import { toolError } from "../errors.js";
4
4
  import { isoTime, participantsOf } from "../render.js";
5
- import { READ_ONLY, SENDS, idSchema, ok, withCaller } from "./common.js";
5
+ import { READ_ONLY, SENDS, idSchema, ok, resolverFor, withCaller } from "./common.js";
6
6
  const IMMUTABLE = "fmsg messages are immutable: once sent they cannot be edited or recalled. Send within the user's requested task or authorized automation.";
7
7
  const attachmentInput = z.object({
8
8
  filename: z.string().regex(/^[A-Za-z0-9._-]+$/u, "letters, digits, dot, underscore, hyphen only"),
@@ -47,7 +47,7 @@ export const registerSendTools = (server, deps) => {
47
47
  outputSchema: sentOutput,
48
48
  annotations: SENDS,
49
49
  }, async ({ to, topic, body, type, important, no_reply, attachments }, ctx) => withCaller(deps, ctx, async (caller, signal) => {
50
- const recipients = resolveAddresses(to, deps.config);
50
+ const recipients = resolveAddresses(to, resolverFor(deps, caller));
51
51
  const sent = await caller.client.send({
52
52
  to: recipients,
53
53
  topic,
@@ -101,7 +101,7 @@ export const registerSendTools = (server, deps) => {
101
101
  }
102
102
  const warnings = [];
103
103
  const to = recipients?.length
104
- ? resolveAddresses(recipients, deps.config)
104
+ ? resolveAddresses(recipients, resolverFor(deps, caller))
105
105
  : participantsOf(parent).filter((a) => !sameAddress(a, caller.address));
106
106
  if (to.length === 0)
107
107
  return toolError(`message ${id} has no other participants to reply to; pass recipients`);
@@ -141,7 +141,7 @@ export const registerSendTools = (server, deps) => {
141
141
  outputSchema: z.object({ id: z.string(), added: z.number(), add_to: z.array(z.string()) }),
142
142
  annotations: { ...SENDS, idempotentHint: true },
143
143
  }, async ({ id, add_to }, ctx) => withCaller(deps, ctx, async (caller, signal) => {
144
- const addresses = resolveAddresses(add_to, deps.config);
144
+ const addresses = resolveAddresses(add_to, resolverFor(deps, caller));
145
145
  const result = await caller.client.addRecipients(id, addresses, signal);
146
146
  return ok(`Added ${result.added} recipient(s) to message ${id}: ${addresses.join(", ")}`, { ...result, add_to: addresses });
147
147
  }));
@@ -3,7 +3,7 @@ import { resolveAddress } from "../address.js";
3
3
  import { assembleThread, renderThread } from "../thread.js";
4
4
  import { DATA_NOT_INSTRUCTIONS, fence, headerValue, messageData, messageLine } from "../render.js";
5
5
  import { waitForMessage } from "../wait.js";
6
- import { READ_ONLY, idSchema, messageItem, ok, toItem, withCaller } from "./common.js";
6
+ import { READ_ONLY, idSchema, messageItem, ok, resolverFor, toItem, withCaller } from "./common.js";
7
7
  export const registerWaitTools = (server, deps) => {
8
8
  const maxWait = deps.config.waitMaxSeconds;
9
9
  server.registerTool("wait_for_message", {
@@ -41,7 +41,7 @@ export const registerWaitTools = (server, deps) => {
41
41
  const result = await waitForMessage(caller.client, caller.address, {
42
42
  ...(after_id !== undefined ? { afterId: after_id } : {}),
43
43
  ...(thread_of !== undefined ? { threadOf: thread_of } : {}),
44
- ...(from !== undefined ? { from: resolveAddress(from, deps.config).address } : {}),
44
+ ...(from !== undefined ? { from: resolveAddress(from, resolverFor(deps, caller)).address } : {}),
45
45
  timeoutMs: timeout_seconds * 1000,
46
46
  settleMs: settle_seconds * 1000,
47
47
  onTick: (elapsed) => {
@@ -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 \
@@ -15,6 +15,11 @@ npx -y @markmnl/fmsg-mcp --http 127.0.0.1:8765
15
15
 
16
16
  `FMSG_API_KEY` must be unset in HTTP mode. Include only browser origins you use.
17
17
 
18
+ When callers have addresses on several domains, set `FMSG_DEFAULT_DOMAIN=caller` rather than one
19
+ fixed domain: short names then resolve on each caller's own domain, so `bob` is `@bob@example.org`
20
+ for `@mark@example.org` and `@bob@example.net` for a caller on `example.net`. `whoami` and the
21
+ server instructions show that caller's domain. `FMSG_DIRECTORY` entries still take precedence.
22
+
18
23
  If the server reaches the Web API at an internal address instead, for example
19
24
  `FMSG_API_URL=http://10.0.0.5:8000` with `FMSG_ALLOW_INSECURE_HTTP=1` on a private network, also set
20
25
  `FMSG_API_PUBLIC_URL=https://api.example.com` so `whoami` reports the URL users know. Without it,
@@ -54,8 +59,8 @@ allow response idle time beyond `FMSG_MCP_WAIT_MAX_SECONDS` with assembly headro
54
59
  allowed and denied Host/Origin requests, unauthenticated 401 responses, CORS preflight, a read,
55
60
  and cancellation through the actual deployed proxy before advertising that deployment.
56
61
 
57
- The metadata routes are public in OAuth mode and must reach the server for MCP authorization
58
- discovery. In API-key mode they return 404. When OAuth is configured, its resource URL supplies
62
+ The metadata routes are public in `oauth` and `oauth+api-key` modes and must reach the server for
63
+ MCP authorization discovery. In API-key mode they return 404. When OAuth is configured, its resource URL supplies
59
64
  the public same-origin value, so explicitly listing that origin is optional.
60
65
 
61
66
  ## 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.3",
4
+ "version": "0.2.5",
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.3",
11
+ "version": "0.2.5",
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.3",
17
+ "version": "0.2.5",
18
18
  "transport": {
19
19
  "type": "stdio"
20
20
  },