@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 +11 -0
- package/README.md +5 -4
- package/SECURITY.md +1 -1
- package/dist/address.d.ts +13 -1
- package/dist/address.js +18 -3
- package/dist/config.d.ts +1 -0
- package/dist/config.js +3 -2
- package/dist/http.d.ts +6 -0
- package/dist/http.js +36 -22
- package/dist/index.js +4 -2
- package/dist/instructions.d.ts +1 -6
- package/dist/instructions.js +13 -3
- package/dist/oauth/config.d.ts +2 -0
- package/dist/oauth/config.js +5 -3
- package/dist/tools/common.d.ts +3 -0
- package/dist/tools/common.js +4 -0
- package/dist/tools/identity.js +20 -13
- package/dist/tools/send.js +4 -4
- package/dist/tools/wait.js +2 -2
- package/docs/http-deployment.md +9 -4
- package/docs/oauth.md +24 -3
- package/package.json +1 -1
- package/server.json +2 -2
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
|
|
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
|
-
| `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
|
|
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
|
-
|
|
45
|
-
|
|
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
|
|
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
|
-
|
|
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,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
|
|
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)
|
package/dist/instructions.d.ts
CHANGED
|
@@ -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;
|
package/dist/instructions.js
CHANGED
|
@@ -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
|
|
6
|
-
|
|
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, " +
|
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/dist/tools/common.d.ts
CHANGED
|
@@ -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;
|
package/dist/tools/common.js
CHANGED
|
@@ -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;
|
package/dist/tools/identity.js
CHANGED
|
@@ -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:
|
|
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 (
|
|
39
|
-
lines.push(`Short names resolve to @name@${
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
};
|
package/dist/tools/send.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
}));
|
package/dist/tools/wait.js
CHANGED
|
@@ -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
|
|
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) => {
|
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 \
|
|
@@ -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
|
|
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).
|
|
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.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.
|
|
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.
|
|
17
|
+
"version": "0.2.5",
|
|
18
18
|
"transport": {
|
|
19
19
|
"type": "stdio"
|
|
20
20
|
},
|