@markmnl/fmsg-mcp 0.2.3 → 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 +5 -0
- package/README.md +4 -3
- package/SECURITY.md +1 -1
- package/dist/http.d.ts +6 -0
- package/dist/http.js +36 -22
- package/dist/index.js +2 -1
- package/dist/oauth/config.d.ts +2 -0
- package/dist/oauth/config.js +5 -3
- package/docs/http-deployment.md +4 -4
- package/docs/oauth.md +24 -3
- package/package.json +1 -1
- package/server.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,11 @@
|
|
|
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.
|
|
5
10
|
- Add optional `FMSG_API_PUBLIC_URL`: the Web API URL `whoami` reports to users, while requests
|
|
6
11
|
keep using `FMSG_API_URL`. It defaults to `FMSG_API_URL`, except that HTTP mode no longer
|
|
7
12
|
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,8 +156,8 @@ 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
|
|
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;
|
|
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
162
|
| `FMSG_DEFAULT_DOMAIN` | — | Lets short names resolve: `bob` → `@bob@<domain>` |
|
|
162
163
|
| `FMSG_DIRECTORY` | — | JSON file mapping short names to full addresses |
|
|
@@ -165,7 +166,7 @@ attach resources; prompts `chat` and `reply` script the wait → reply loop and
|
|
|
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
|
|
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/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
|
-
|
|
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
|
|
114
|
-
|
|
115
|
-
|
|
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
|
|
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":
|
|
242
|
+
status, headers: status === 401 ? { "www-authenticate": challenge } : {},
|
|
237
243
|
}));
|
|
238
244
|
}
|
|
239
245
|
};
|
|
240
|
-
|
|
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(
|
|
249
|
+
const bearer = /^Bearer ([A-Za-z0-9._~+\/-]+=*)$/iu.exec(authorization);
|
|
243
250
|
if (!bearer)
|
|
244
251
|
throw invalidToken();
|
|
245
|
-
authenticated = await
|
|
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
|
|
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
|
|
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
|
|
294
|
+
const authenticated = await apiKeyProvider.verifyAccessToken(token);
|
|
295
|
+
release = () => apiKeyProvider.release(authenticated);
|
|
285
296
|
return authenticated;
|
|
286
297
|
} },
|
|
287
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
321
|
-
|
|
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
|
|
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
|
package/dist/oauth/config.d.ts
CHANGED
|
@@ -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;
|
package/dist/oauth/config.js
CHANGED
|
@@ -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
|
}
|
package/docs/http-deployment.md
CHANGED
|
@@ -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
|
|
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 \
|
|
@@ -54,8 +54,8 @@ allow response idle time beyond `FMSG_MCP_WAIT_MAX_SECONDS` with assembly headro
|
|
|
54
54
|
allowed and denied Host/Origin requests, unauthenticated 401 responses, CORS preflight, a read,
|
|
55
55
|
and cancellation through the actual deployed proxy before advertising that deployment.
|
|
56
56
|
|
|
57
|
-
The metadata routes are public in
|
|
58
|
-
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
|
|
59
59
|
the public same-origin value, so explicitly listing that origin is optional.
|
|
60
60
|
|
|
61
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).
|
|
5
|
-
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
17
|
+
"version": "0.2.4",
|
|
18
18
|
"transport": {
|
|
19
19
|
"type": "stdio"
|
|
20
20
|
},
|