@markmnl/fmsg-mcp 0.2.0 → 0.2.2
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 +9 -0
- package/README.md +52 -6
- package/SECURITY.md +6 -0
- package/dist/config.d.ts +2 -0
- package/dist/config.js +7 -1
- package/dist/download.d.ts +11 -0
- package/dist/download.js +34 -0
- package/dist/http.js +94 -11
- package/dist/instructions.js +3 -1
- package/dist/oauth/scopes.js +1 -1
- package/dist/server.js +2 -0
- package/dist/tools/download.d.ts +2 -0
- package/dist/tools/download.js +38 -0
- package/dist/tools/read.js +2 -2
- package/docs/http-deployment.md +29 -1
- package/docs/oauth.md +6 -0
- package/package.json +1 -1
- package/server.json +49 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## Unreleased
|
|
4
|
+
|
|
5
|
+
- Add HTTP-only `get_attachment_download_url` and authenticated binary attachment GETs. Links
|
|
6
|
+
contain no credentials; downloads reuse caller authentication and OAuth read scope, stream the
|
|
7
|
+
original bytes, and keep Web API access checks authoritative. API-key deployments configure
|
|
8
|
+
`FMSG_MCP_PUBLIC_URL`; OAuth reuses its resource URL. Inline downloads and stdio saves remain available.
|
|
9
|
+
- Abort failed response streams so a truncated download cannot appear successful.
|
|
10
|
+
- Close unused replacement connections on HTTP shutdown after cancelled downloads.
|
|
11
|
+
|
|
3
12
|
## 0.2.0 (unreleased)
|
|
4
13
|
|
|
5
14
|
This is the next planned release; publication still happens through a `v0.2.0` GitHub release.
|
package/README.md
CHANGED
|
@@ -10,23 +10,45 @@ attachments and wait for replies, through a deployed
|
|
|
10
10
|
[fmsg Web API](https://github.com/markmnl/fmsg-webapi). Connect through stdio in hosts such as
|
|
11
11
|
Claude Code, Claude Desktop, Cursor and VS Code, or through HTTP using API-key headers or configured OAuth.
|
|
12
12
|
|
|
13
|
+
Use your choice of fmsg hosting provider, or self-host. No hosting provider or identity provider
|
|
14
|
+
is built in.
|
|
15
|
+
|
|
13
16
|
- **stdio** for local hosts: one address per server process, configured by two environment variables.
|
|
14
17
|
- **Streamable HTTP** for shared or remote deployments: one endpoint serving many users, each
|
|
15
18
|
authenticated by their own fmsg API key or OAuth connection.
|
|
16
19
|
- The fmsg Web API client is exported for reuse: `import { FmsgClient } from "@markmnl/fmsg-mcp/client"`.
|
|
17
20
|
|
|
18
|
-
## 1.
|
|
21
|
+
## 1. Choose a connection
|
|
22
|
+
|
|
23
|
+
### OAuth: connect and sign in
|
|
19
24
|
|
|
20
|
-
|
|
21
|
-
|
|
25
|
+
Get an OAuth-enabled MCP URL from your fmsg hosting provider, add it to your AI host, and sign in.
|
|
26
|
+
You do not need an API key or a local installation for this connection. Your AI host must support
|
|
27
|
+
the authorization server's client registration method; see [OAuth onboarding](docs/oauth.md#discovery-and-client-onboarding).
|
|
28
|
+
Operators can enable this with [HTTP OAuth](docs/oauth.md).
|
|
29
|
+
|
|
30
|
+
### API key: use locally or over HTTP
|
|
22
31
|
|
|
23
32
|
You send as an fmsg address, authenticated by an API key (`fmsgk_…`) issued by your fmsg host:
|
|
24
33
|
|
|
25
|
-
- **
|
|
26
|
-
|
|
34
|
+
- **Using a hosting provider?** Create an account with an fmsg hosting provider and obtain an
|
|
35
|
+
API URL and API key for the address your agent will use.
|
|
27
36
|
- **Self-hosting?** Run the stack with [fmsg-docker](https://github.com/markmnl/fmsg-docker) and issue
|
|
28
37
|
a key with `fmsg-webapi api-key create`.
|
|
29
38
|
|
|
39
|
+
Use the API URL and key for local installation below, or use your provider's API-key MCP endpoint
|
|
40
|
+
with an `Authorization: Bearer fmsgk_...` header. The endpoint must use the fmsg Web API that accepts
|
|
41
|
+
your key.
|
|
42
|
+
|
|
43
|
+
### MCP Registry
|
|
44
|
+
|
|
45
|
+
Find `io.github.markmnl/fmsg-mcp` in the [official MCP Registry](https://registry.modelcontextprotocol.io/?q=io.github.markmnl%2Ffmsg-mcp).
|
|
46
|
+
The listing offers local npm installation and configurable remote connections for OAuth or API keys.
|
|
47
|
+
For a remote connection, supply the MCP endpoint from your fmsg hosting provider or your own deployment;
|
|
48
|
+
the registry input takes its hostname and path without the `https://` prefix, such as `mcp.example.com/mcp`.
|
|
49
|
+
OAuth sign-in is discovered from that endpoint; the API-key option additionally asks for your key.
|
|
50
|
+
If your AI host cannot configure registry URL templates, add the full MCP URL directly instead.
|
|
51
|
+
|
|
30
52
|
## 2. Install
|
|
31
53
|
|
|
32
54
|
Requires Node.js 22 or later.
|
|
@@ -115,6 +137,7 @@ See the [TLS reverse-proxy example](docs/http-deployment.md) for a loopback depl
|
|
|
115
137
|
| `react` | Set or clear your emoji reaction |
|
|
116
138
|
| `mark_read` | Mark received messages read |
|
|
117
139
|
| `download_attachment` | Fetch a small attachment inline: text as text, images as image blocks, other files as base64 resources |
|
|
140
|
+
| `get_attachment_download_url` | Get a credential-free URL and resource link for an authenticated binary download; HTTP only, requires a public MCP URL |
|
|
118
141
|
| `save_attachment` | Stream an attachment to the configured local folder; stdio only, enabled by `FMSG_MCP_DOWNLOAD_DIR` |
|
|
119
142
|
| `delivery_status` | Per-recipient delivery times and host response codes |
|
|
120
143
|
| `wait_for_message` | Block until the next inbound message (WebSocket push), batched per thread, with thread context |
|
|
@@ -132,6 +155,7 @@ attach resources; prompts `chat` and `reply` script the wait → reply loop and
|
|
|
132
155
|
| `FMSG_API_URL` | — | Base URL of the fmsg Web API (required) |
|
|
133
156
|
| `FMSG_API_KEY` | — | `fmsgk_…` key; stdio mode only |
|
|
134
157
|
| `FMSG_MCP_AUTH_MODE` | `api-key` | HTTP authentication: `api-key` or `oauth`; see [OAuth settings](docs/oauth.md#operator-configuration) |
|
|
158
|
+
| `FMSG_MCP_PUBLIC_URL` | OAuth resource URL, otherwise unset | Public MCP endpoint, including `/mcp`; enables the HTTP download-link tool. HTTPS required except loopback; in OAuth mode must equal `FMSG_MCP_OAUTH_RESOURCE_URL` |
|
|
135
159
|
| `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 |
|
|
136
160
|
| `FMSG_DEFAULT_DOMAIN` | — | Lets short names resolve: `bob` → `@bob@<domain>` |
|
|
137
161
|
| `FMSG_DIRECTORY` | — | JSON file mapping short names to full addresses |
|
|
@@ -156,7 +180,22 @@ while making progress; a 60-second idle timeout detects stalled transfers.
|
|
|
156
180
|
|
|
157
181
|
Inline downloads default to 256 KiB to keep file content manageable for the model. Use
|
|
158
182
|
`save_attachment` for larger local files, or raise `max_inline_bytes` explicitly when your AI host
|
|
159
|
-
can handle more inline content.
|
|
183
|
+
can handle more inline content.
|
|
184
|
+
|
|
185
|
+
For remote file downloads, call `get_attachment_download_url` with the message ID and filename.
|
|
186
|
+
It returns metadata and an HTTPS `resource_link`; your AI host downloads the original bytes using
|
|
187
|
+
the existing MCP connection's Authorization header. API-key operators enable the tool with
|
|
188
|
+
`FMSG_MCP_PUBLIC_URL=https://mcp.example.com/mcp`; OAuth deployments reuse their configured resource
|
|
189
|
+
URL automatically. The [reverse proxy must forward the attachment route](docs/http-deployment.md#binary-attachment-downloads).
|
|
190
|
+
Links contain no credentials and grant no access by themselves. The host must support authenticated
|
|
191
|
+
HTTP downloads; an ordinary browser click without the header returns 401. Use inline downloads when
|
|
192
|
+
that host capability is unavailable. Downloads stream without the inline size budget, using the
|
|
193
|
+
original Content-Type and download filename. No files are stored on the MCP server.
|
|
194
|
+
|
|
195
|
+
The Web API already transfers attachments as raw bytes. Base64 is used only when embedding binary
|
|
196
|
+
content in MCP's JSON results, as required by [MCP binary resources](https://modelcontextprotocol.io/specification/2026-07-28/server/resources#binary-content).
|
|
197
|
+
`save_attachment` and authenticated HTTP downloads avoid that encoding and keep file bytes out of
|
|
198
|
+
model context. See [issue #6](https://github.com/markmnl/fmsg-mcp/issues/6).
|
|
160
199
|
|
|
161
200
|
Over stdio, missing or invalid configuration still allows hosts to discover the tools. Tool calls
|
|
162
201
|
explain the configuration error and how to fix it; restart the MCP server after correcting settings.
|
|
@@ -213,4 +252,11 @@ bash .github/scripts/run-fmsg-docker-e2e.sh # end to end on
|
|
|
213
252
|
See [AGENTS.md](./AGENTS.md) for layout and conventions, [ROADMAP.md](./ROADMAP.md) for remaining
|
|
214
253
|
integration work, and [CHANGELOG.md](./CHANGELOG.md) for release notes.
|
|
215
254
|
|
|
255
|
+
### Releasing
|
|
256
|
+
|
|
257
|
+
Publish a stable GitHub release tagged `vX.Y.Z` to run the checks, publish the npm package, and
|
|
258
|
+
then update the MCP Registry with the same version. Both use OIDC authentication; no dedicated
|
|
259
|
+
registry secret is needed. Drafts and prereleases do not publish. Registry publishing runs as a
|
|
260
|
+
separate job, so if it fails after npm succeeds, use **Re-run failed jobs** on the release workflow.
|
|
261
|
+
|
|
216
262
|
[MIT licensed](./LICENSE)
|
package/SECURITY.md
CHANGED
|
@@ -37,6 +37,12 @@ rather than a public issue.
|
|
|
37
37
|
- Attachment transfers retain caller cancellation and client shutdown signals. They use a response
|
|
38
38
|
header deadline followed by a per-read idle timeout (60 seconds each by default), so a progressing
|
|
39
39
|
large download is not subject to a 60-second total duration limit.
|
|
40
|
+
- HTTP attachment links contain no credentials and do not confer access. Every GET authenticates
|
|
41
|
+
the caller and uses its upstream client; OAuth also requires `fmsg:read`. The Web API decides
|
|
42
|
+
attachment visibility. Public URLs come from operator configuration, never forwarded headers.
|
|
43
|
+
Downloads stream with backpressure and force attachment disposition, no-store caching and nosniff.
|
|
44
|
+
Failed streams terminate the connection so partial files cannot appear successfully completed.
|
|
45
|
+
Query parameters are refused. Authenticated download links require support in the AI host.
|
|
40
46
|
- Error previews are limited to 2 KiB while reading, except canonical JSON HTTP 400/413 responses:
|
|
41
47
|
those retain the host's acceptance/size-policy explanation. Selected credentials are still redacted;
|
|
42
48
|
oversized previews are explicitly marked as truncated.
|
package/dist/config.d.ts
CHANGED
|
@@ -3,6 +3,8 @@ export type Transport = "stdio" | "http";
|
|
|
3
3
|
export type HttpConfig = {
|
|
4
4
|
host: string;
|
|
5
5
|
port: number;
|
|
6
|
+
/** Canonical public MCP endpoint for credential-free attachment links. */
|
|
7
|
+
publicUrl?: string;
|
|
6
8
|
/** Hostnames accepted in the Host header. Empty means: derive from the bind address (loopback only). */
|
|
7
9
|
allowedHosts: string[];
|
|
8
10
|
/** Exact browser origins. Empty permits same-origin and, on loopback binds, loopback origins on any port. */
|
package/dist/config.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
2
|
import { normalizeFmsgAddress } from "./address.js";
|
|
3
3
|
import { normalizeApiUrl, normalizeOrigin } from "./client/url.js";
|
|
4
|
-
import { loadOAuthConfig } from "./oauth/config.js";
|
|
4
|
+
import { loadOAuthConfig, oauthUrl } from "./oauth/config.js";
|
|
5
5
|
export const DEFAULT_HTTP_PORT = 8765;
|
|
6
6
|
export const DEFAULT_WAIT_MAX_SECONDS = 230;
|
|
7
7
|
function intEnv(env, name, fallback, min = 1) {
|
|
@@ -63,6 +63,11 @@ export function loadConfig(env, transport, overrides = {}, options = {}) {
|
|
|
63
63
|
if (!Number.isInteger(port) || port < 0 || port > 65535)
|
|
64
64
|
throw new Error("FMSG_MCP_PORT must be between 0 and 65535");
|
|
65
65
|
const host = overrides.host ?? env.FMSG_MCP_HOST?.trim() ?? "127.0.0.1";
|
|
66
|
+
const publicUrl = transport === "http" ? env.FMSG_MCP_PUBLIC_URL?.trim() || oauth?.resourceUrl : undefined;
|
|
67
|
+
if (publicUrl)
|
|
68
|
+
oauthUrl(publicUrl, "FMSG_MCP_PUBLIC_URL");
|
|
69
|
+
if (oauth && publicUrl !== oauth.resourceUrl)
|
|
70
|
+
throw new Error("FMSG_MCP_PUBLIC_URL must match FMSG_MCP_OAUTH_RESOURCE_URL in OAuth mode");
|
|
66
71
|
return {
|
|
67
72
|
transport,
|
|
68
73
|
apiUrl: normalizedApiUrl,
|
|
@@ -76,6 +81,7 @@ export function loadConfig(env, transport, overrides = {}, options = {}) {
|
|
|
76
81
|
http: {
|
|
77
82
|
host,
|
|
78
83
|
port,
|
|
84
|
+
...(publicUrl ? { publicUrl } : {}),
|
|
79
85
|
allowedHosts: listEnv(env, "FMSG_MCP_ALLOWED_HOSTS"),
|
|
80
86
|
allowedOrigins: listEnv(env, "FMSG_MCP_ALLOWED_ORIGINS").map(normalizeOrigin),
|
|
81
87
|
keyCacheMax: intEnv(env, "FMSG_MCP_KEY_CACHE_MAX", 500),
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { Config } from "./config.js";
|
|
2
|
+
export declare const DOWNLOAD_PATH = "/mcp/attachments";
|
|
3
|
+
export declare function downloadBaseUrl(config: Config): string | undefined;
|
|
4
|
+
export declare function attachmentFilename(filename: string): string;
|
|
5
|
+
export declare function attachmentDownloadUrl(baseUrl: string, id: string, filename: string): string;
|
|
6
|
+
export declare function parseDownloadPath(pathname: string): {
|
|
7
|
+
id: string;
|
|
8
|
+
filename: string;
|
|
9
|
+
};
|
|
10
|
+
/** Always download untrusted files; preserve Unicode filenames without raw header characters. */
|
|
11
|
+
export declare function downloadHeaders(filename: string, contentType?: string): Headers;
|
package/dist/download.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { normalizeMessageId } from "./client/message-id.js";
|
|
2
|
+
export const DOWNLOAD_PATH = "/mcp/attachments";
|
|
3
|
+
export function downloadBaseUrl(config) {
|
|
4
|
+
return config.transport === "http" ? config.http.publicUrl ?? config.oauth?.resourceUrl : undefined;
|
|
5
|
+
}
|
|
6
|
+
export function attachmentFilename(filename) {
|
|
7
|
+
if (!filename || filename === "." || filename === ".." || /[/\\\x00-\x1f\x7f]/u.test(filename)) {
|
|
8
|
+
throw new Error("use an attachment filename without directory components or control characters");
|
|
9
|
+
}
|
|
10
|
+
// Also reject unpaired UTF-16 surrogates before building a URL/header.
|
|
11
|
+
encodeURIComponent(filename);
|
|
12
|
+
return filename;
|
|
13
|
+
}
|
|
14
|
+
export function attachmentDownloadUrl(baseUrl, id, filename) {
|
|
15
|
+
return `${baseUrl.replace(/\/$/u, "")}/attachments/${normalizeMessageId(id)}/${encodeURIComponent(attachmentFilename(filename))}`;
|
|
16
|
+
}
|
|
17
|
+
export function parseDownloadPath(pathname) {
|
|
18
|
+
const parts = pathname.slice(DOWNLOAD_PATH.length + 1).split("/");
|
|
19
|
+
if (parts.length !== 2)
|
|
20
|
+
throw new Error("invalid attachment download path");
|
|
21
|
+
return { id: normalizeMessageId(parts[0]), filename: attachmentFilename(decodeURIComponent(parts[1])) };
|
|
22
|
+
}
|
|
23
|
+
/** Always download untrusted files; preserve Unicode filenames without raw header characters. */
|
|
24
|
+
export function downloadHeaders(filename, contentType) {
|
|
25
|
+
const fallback = filename.replace(/[^A-Za-z0-9._-]/gu, "_");
|
|
26
|
+
const encoded = encodeURIComponent(filename).replace(/[!'()*]/gu, c => `%${c.charCodeAt(0).toString(16).toUpperCase()}`);
|
|
27
|
+
return new Headers({
|
|
28
|
+
"content-type": contentType ?? "application/octet-stream",
|
|
29
|
+
"content-disposition": `attachment; filename="${fallback}"; filename*=UTF-8''${encoded}`,
|
|
30
|
+
"cache-control": "no-store",
|
|
31
|
+
"x-content-type-options": "nosniff",
|
|
32
|
+
"content-security-policy": "sandbox",
|
|
33
|
+
});
|
|
34
|
+
}
|
package/dist/http.js
CHANGED
|
@@ -5,7 +5,10 @@ import { ApiKeyCallerProvider, FMSG_SCOPE } from "./auth.js";
|
|
|
5
5
|
import { createFmsgMcpServer } from "./server.js";
|
|
6
6
|
import { OAuthCallerProvider } from "./oauth/provider.js";
|
|
7
7
|
import { invalidToken, insufficientScope, OAuthRequestError, oauthErrorResponse, oauthRequestState } from "./oauth/errors.js";
|
|
8
|
-
import { MESSAGING_SCOPES, requestScopes } from "./oauth/scopes.js";
|
|
8
|
+
import { MESSAGING_SCOPES, READ_SCOPE, requestScopes } from "./oauth/scopes.js";
|
|
9
|
+
import { DOWNLOAD_PATH, downloadBaseUrl, downloadHeaders, parseDownloadPath } from "./download.js";
|
|
10
|
+
import { FmsgHttpError } from "./client/errors.js";
|
|
11
|
+
import { describeError } from "./errors.js";
|
|
9
12
|
import { VERSION } from "./version.js";
|
|
10
13
|
import { safeErrorMessage } from "./client/redact.js";
|
|
11
14
|
import { isLoopbackHost, normalizeOrigin } from "./client/url.js";
|
|
@@ -76,10 +79,18 @@ export async function sendWebResponse(res, response, failure) {
|
|
|
76
79
|
});
|
|
77
80
|
}
|
|
78
81
|
}
|
|
82
|
+
catch (error) {
|
|
83
|
+
// Once bytes have been sent, terminate the transfer so a truncated file is
|
|
84
|
+
// not reported as a successful download. Before headers, let the caller map the error.
|
|
85
|
+
if (res.headersSent)
|
|
86
|
+
res.destroy();
|
|
87
|
+
throw error;
|
|
88
|
+
}
|
|
79
89
|
finally {
|
|
80
90
|
res.off("close", abort);
|
|
81
91
|
await reader.cancel().catch(() => undefined);
|
|
82
|
-
res.
|
|
92
|
+
if (res.headersSent && !res.destroyed)
|
|
93
|
+
res.end();
|
|
83
94
|
}
|
|
84
95
|
}
|
|
85
96
|
export function createHttpServer(config, log = (l) => console.error(l)) {
|
|
@@ -96,6 +107,7 @@ export function createHttpServer(config, log = (l) => console.error(l)) {
|
|
|
96
107
|
const safeLog = (line) => log(safeErrorMessage(line));
|
|
97
108
|
const provider = config.oauth ? new OAuthCallerProvider(config, safeLog) : new ApiKeyCallerProvider(config, safeLog);
|
|
98
109
|
const resource = config.oauth ? new URL(config.oauth.resourceUrl) : undefined;
|
|
110
|
+
const publicOrigin = downloadBaseUrl(config) ? new URL(downloadBaseUrl(config)).origin : undefined;
|
|
99
111
|
const metadataPath = resource ? `/.well-known/oauth-protected-resource${resource.pathname === "/" ? "" : resource.pathname}` : undefined;
|
|
100
112
|
const metadataUrl = resource ? `${resource.origin}${metadataPath}` : "";
|
|
101
113
|
const handler = createMcpHandler(({ authInfo }) => createFmsgMcpServer(provider, config, authInfo ? {
|
|
@@ -111,6 +123,7 @@ export function createHttpServer(config, log = (l) => console.error(l)) {
|
|
|
111
123
|
res.once("close", abort);
|
|
112
124
|
void (async () => {
|
|
113
125
|
const url = new URL(req.url ?? "/", "http://localhost");
|
|
126
|
+
const isDownload = url.pathname.startsWith(`${DOWNLOAD_PATH}/`);
|
|
114
127
|
if (url.pathname === "/healthz") {
|
|
115
128
|
res.writeHead(200, { "content-type": "application/json" });
|
|
116
129
|
res.end(JSON.stringify({ ok: true, name: "fmsg-mcp", version: VERSION }));
|
|
@@ -129,7 +142,7 @@ export function createHttpServer(config, log = (l) => console.error(l)) {
|
|
|
129
142
|
authorization_servers: [config.oauth.issuerUrl], scopes_supported: MESSAGING_SCOPES,
|
|
130
143
|
bearer_methods_supported: ["header"], resource_name: "fmsg messaging", }, { headers }));
|
|
131
144
|
}
|
|
132
|
-
if (url.pathname !== MCP_PATH) {
|
|
145
|
+
if (url.pathname !== MCP_PATH && !isDownload) {
|
|
133
146
|
res.writeHead(404, { "content-type": "text/plain" });
|
|
134
147
|
res.end("not found; the MCP endpoint is /mcp");
|
|
135
148
|
return;
|
|
@@ -148,26 +161,82 @@ export function createHttpServer(config, log = (l) => console.error(l)) {
|
|
|
148
161
|
const localDevelopment = isLoopbackHost(config.http.host) && !allowedOrigins.length &&
|
|
149
162
|
/^https?:$/u.test(parsed.protocol) && isLoopbackHost(parsed.hostname);
|
|
150
163
|
valid = parsed.origin === origin &&
|
|
151
|
-
(localDevelopment || allowedOrigins.includes(origin) || origin === (resource?.origin ?? new URL(request.url).origin));
|
|
164
|
+
(localDevelopment || allowedOrigins.includes(origin) || origin === (publicOrigin ?? resource?.origin ?? new URL(request.url).origin));
|
|
152
165
|
}
|
|
153
166
|
catch { /* malformed origins are rejected */ }
|
|
154
167
|
if (!valid)
|
|
155
168
|
return sendWebResponse(res, new Response("origin not allowed", { status: 403 }));
|
|
156
169
|
res.setHeader("access-control-allow-origin", origin);
|
|
157
|
-
res.setHeader("access-control-expose-headers", "WWW-Authenticate, MCP-Session-Id, MCP-Protocol-Version, Retry-After");
|
|
170
|
+
res.setHeader("access-control-expose-headers", "WWW-Authenticate, MCP-Session-Id, MCP-Protocol-Version, Retry-After, Content-Disposition");
|
|
158
171
|
}
|
|
159
172
|
res.setHeader("vary", "Origin");
|
|
160
173
|
res.setHeader("cache-control", "no-store");
|
|
161
174
|
if (req.method === "OPTIONS") {
|
|
162
175
|
const method = request.headers.get("access-control-request-method") ?? "";
|
|
163
176
|
const headers = (request.headers.get("access-control-request-headers") ?? "").split(",").map((v) => v.trim().toLowerCase()).filter(Boolean);
|
|
164
|
-
|
|
177
|
+
const methods = isDownload ? ["GET"] : CORS_METHODS;
|
|
178
|
+
if (!origin || !methods.includes(method) || headers.some((h) => !CORS_HEADERS.includes(h))) {
|
|
165
179
|
return sendWebResponse(res, new Response("preflight not allowed", { status: 403 }));
|
|
166
180
|
}
|
|
167
|
-
res.setHeader("access-control-allow-methods",
|
|
181
|
+
res.setHeader("access-control-allow-methods", methods.join(", "));
|
|
168
182
|
res.setHeader("access-control-allow-headers", CORS_HEADERS.join(", "));
|
|
169
183
|
return sendWebResponse(res, new Response(null, { status: 204 }));
|
|
170
184
|
}
|
|
185
|
+
let download;
|
|
186
|
+
if (isDownload) {
|
|
187
|
+
if (req.method !== "GET")
|
|
188
|
+
return sendWebResponse(res, new Response("use GET to download attachments", { status: 405, headers: { allow: "GET, OPTIONS" } }));
|
|
189
|
+
// Authentication belongs only in the Authorization header, never the URL.
|
|
190
|
+
if (url.search)
|
|
191
|
+
return sendWebResponse(res, new Response("attachment URLs must not contain query parameters", { status: 400 }));
|
|
192
|
+
try {
|
|
193
|
+
download = parseDownloadPath(url.pathname);
|
|
194
|
+
}
|
|
195
|
+
catch {
|
|
196
|
+
return sendWebResponse(res, new Response("invalid attachment download path", { status: 400 }));
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
const serveDownload = async (auth) => {
|
|
200
|
+
let caller;
|
|
201
|
+
try {
|
|
202
|
+
caller = await provider.forRequest(auth);
|
|
203
|
+
const { stream, contentType } = await caller.client.streamAttachment(download.id, download.filename, controller.signal);
|
|
204
|
+
let response;
|
|
205
|
+
try {
|
|
206
|
+
response = new Response(stream, { headers: downloadHeaders(download.filename, contentType) });
|
|
207
|
+
}
|
|
208
|
+
catch (error) {
|
|
209
|
+
await stream.cancel().catch(() => undefined);
|
|
210
|
+
throw error;
|
|
211
|
+
}
|
|
212
|
+
return await sendWebResponse(res, response);
|
|
213
|
+
}
|
|
214
|
+
catch (error) {
|
|
215
|
+
const rejectedAuth = (error instanceof OAuthRequestError && error.status === 401) ||
|
|
216
|
+
(error instanceof FmsgHttpError && (error.status === 401 || (error.path === "/fmsg/token" && [400, 403].includes(error.status))));
|
|
217
|
+
if (caller && rejectedAuth)
|
|
218
|
+
provider.invalidate(caller);
|
|
219
|
+
if (res.destroyed || controller.signal.aborted)
|
|
220
|
+
return;
|
|
221
|
+
if (res.headersSent) {
|
|
222
|
+
res.destroy();
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
if (provider instanceof OAuthCallerProvider) {
|
|
226
|
+
if (error instanceof OAuthRequestError)
|
|
227
|
+
return sendWebResponse(res, oauthErrorResponse(error, metadataUrl));
|
|
228
|
+
if (rejectedAuth)
|
|
229
|
+
return sendWebResponse(res, oauthErrorResponse(invalidToken(), metadataUrl));
|
|
230
|
+
if (error instanceof FmsgHttpError && error.insufficientScope) {
|
|
231
|
+
return sendWebResponse(res, oauthErrorResponse(new OAuthRequestError(403, "insufficient_scope", error.message, [READ_SCOPE]), metadataUrl));
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
const status = rejectedAuth ? 401 : error instanceof FmsgHttpError && error.status >= 400 && error.status <= 599 ? error.status : 502;
|
|
235
|
+
return sendWebResponse(res, Response.json({ error: describeError(error) }, {
|
|
236
|
+
status, headers: status === 401 ? { "www-authenticate": 'Bearer error="invalid_token"' } : {},
|
|
237
|
+
}));
|
|
238
|
+
}
|
|
239
|
+
};
|
|
171
240
|
if (provider instanceof OAuthCallerProvider) {
|
|
172
241
|
try {
|
|
173
242
|
const bearer = /^Bearer ([A-Za-z0-9._~+\/-]+=*)$/iu.exec(request.headers.get("authorization") ?? "");
|
|
@@ -185,9 +254,11 @@ export function createHttpServer(config, log = (l) => console.error(l)) {
|
|
|
185
254
|
if (Array.isArray(parsedBody))
|
|
186
255
|
return sendWebResponse(res, new Response("MCP batch requests are not supported", { status: 400 }));
|
|
187
256
|
}
|
|
188
|
-
const scopes = requestScopes(parsedBody);
|
|
257
|
+
const scopes = download ? [READ_SCOPE] : requestScopes(parsedBody);
|
|
189
258
|
if (scopes.some(scope => !authenticated.scopes.includes(scope)))
|
|
190
259
|
throw insufficientScope(scopes);
|
|
260
|
+
if (download)
|
|
261
|
+
return await serveDownload(authenticated);
|
|
191
262
|
// Discover/list operations only need the incoming JWT. Resolve an
|
|
192
263
|
// exchanged credential before starting any protected tool response.
|
|
193
264
|
if (scopes.length) {
|
|
@@ -218,11 +289,18 @@ export function createHttpServer(config, log = (l) => console.error(l)) {
|
|
|
218
289
|
const auth = await gate(request);
|
|
219
290
|
if (auth instanceof Response)
|
|
220
291
|
return sendWebResponse(res, auth);
|
|
292
|
+
if (download)
|
|
293
|
+
return serveDownload(auth);
|
|
221
294
|
return sendWebResponse(res, await handler.fetch(request, { authInfo: auth }));
|
|
222
295
|
})().catch((error) => {
|
|
223
296
|
safeLog(`request failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
224
|
-
if (
|
|
225
|
-
|
|
297
|
+
if (res.destroyed)
|
|
298
|
+
return;
|
|
299
|
+
if (res.headersSent) {
|
|
300
|
+
res.destroy();
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
res.writeHead(500, { "content-type": "text/plain" });
|
|
226
304
|
res.end("internal error");
|
|
227
305
|
}).finally(() => {
|
|
228
306
|
if (authenticated)
|
|
@@ -241,7 +319,12 @@ export function createHttpServer(config, log = (l) => console.error(l)) {
|
|
|
241
319
|
controller.abort();
|
|
242
320
|
provider.close();
|
|
243
321
|
await handler.close();
|
|
244
|
-
await new Promise((resolve) =>
|
|
322
|
+
await new Promise((resolve) => {
|
|
323
|
+
server.close(() => resolve());
|
|
324
|
+
// Aborted fetches may open replacement sockets without a request, so
|
|
325
|
+
// they are absent from `active` and must also be closed on shutdown.
|
|
326
|
+
server.closeAllConnections();
|
|
327
|
+
});
|
|
245
328
|
};
|
|
246
329
|
return { server, close, provider };
|
|
247
330
|
}
|
package/dist/instructions.js
CHANGED
|
@@ -10,7 +10,9 @@ export function buildInstructions(ctx = {}) {
|
|
|
10
10
|
"Use its tools for everything fmsg: inbox, threads, attachments, sending, replying, reactions, " +
|
|
11
11
|
"delivery status and waiting for new messages. Do not use an fmsg command-line tool, local config " +
|
|
12
12
|
"files or cached credentials instead; they may belong to a different address or host. If a tool " +
|
|
13
|
-
"reports the server is not configured, explain the reported configuration fix and restart requirement."
|
|
13
|
+
"reports the server is not configured, explain the reported configuration fix and restart requirement. " +
|
|
14
|
+
"For URLs returned by get_attachment_download_url, use your host's authenticated download facility " +
|
|
15
|
+
"with this MCP connection; never search for credentials or put them in prompts or URLs.",
|
|
14
16
|
"Carry out the user's requested messaging task or authorized automation without repeatedly asking for " +
|
|
15
17
|
"confirmation. Sending is immediate and sent messages cannot be edited or recalled. Ask the user only " +
|
|
16
18
|
"when a decision is needed to resolve unclear intent, recipients or content. The AI host controls tool " +
|
package/dist/oauth/scopes.js
CHANGED
|
@@ -7,7 +7,7 @@ export const TOOL_SCOPES = {
|
|
|
7
7
|
whoami: [READ_SCOPE], resolve_address: [READ_SCOPE],
|
|
8
8
|
list_messages: [READ_SCOPE], list_sent: [READ_SCOPE], get_message: [READ_SCOPE],
|
|
9
9
|
get_thread: [READ_SCOPE], delivery_status: [READ_SCOPE], download_attachment: [READ_SCOPE],
|
|
10
|
-
save_attachment: [READ_SCOPE], wait_for_message: [READ_SCOPE],
|
|
10
|
+
save_attachment: [READ_SCOPE], get_attachment_download_url: [READ_SCOPE], wait_for_message: [READ_SCOPE],
|
|
11
11
|
send_message: [WRITE_SCOPE], reply: MESSAGING_SCOPES,
|
|
12
12
|
mark_read: [WRITE_SCOPE], add_recipients: [WRITE_SCOPE], react: [WRITE_SCOPE],
|
|
13
13
|
};
|
package/dist/server.js
CHANGED
|
@@ -8,6 +8,7 @@ import { registerReadTools } from "./tools/read.js";
|
|
|
8
8
|
import { registerSendTools } from "./tools/send.js";
|
|
9
9
|
import { registerWaitTools } from "./tools/wait.js";
|
|
10
10
|
import { registerSaveTool } from "./tools/save.js";
|
|
11
|
+
import { registerDownloadTool } from "./tools/download.js";
|
|
11
12
|
import { VERSION } from "./version.js";
|
|
12
13
|
export const SERVER_NAME = "fmsg";
|
|
13
14
|
/**
|
|
@@ -25,6 +26,7 @@ export function createFmsgMcpServer(provider, config, options = {}) {
|
|
|
25
26
|
registerListTools(server, deps);
|
|
26
27
|
registerReadTools(server, deps);
|
|
27
28
|
registerSaveTool(server, deps);
|
|
29
|
+
registerDownloadTool(server, deps);
|
|
28
30
|
registerSendTools(server, deps);
|
|
29
31
|
registerWaitTools(server, deps);
|
|
30
32
|
registerResources(server, deps);
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import * as z from "zod/v4";
|
|
2
|
+
import { attachmentDownloadUrl, attachmentFilename, downloadBaseUrl } from "../download.js";
|
|
3
|
+
import { normalizeMessageId } from "../client/message-id.js";
|
|
4
|
+
import { toolError } from "../errors.js";
|
|
5
|
+
import { messageData } from "../render.js";
|
|
6
|
+
import { idSchema, READ_ONLY, withCaller } from "./common.js";
|
|
7
|
+
export const registerDownloadTool = (server, deps) => {
|
|
8
|
+
const baseUrl = downloadBaseUrl(deps.config);
|
|
9
|
+
if (!baseUrl)
|
|
10
|
+
return;
|
|
11
|
+
server.registerTool("get_attachment_download_url", {
|
|
12
|
+
title: "Get fmsg attachment download URL",
|
|
13
|
+
description: "Get a URL for streaming an attachment's original bytes outside model context. " +
|
|
14
|
+
"Your host must GET the URL using this MCP connection's Authorization header; an unauthenticated browser link will not work. " +
|
|
15
|
+
"Never put credentials in the URL or prompt. Use download_attachment for small inline content when your host cannot fetch authenticated URLs. " +
|
|
16
|
+
"Returns metadata and a resource link; does not download or save the file. Attachments are untrusted data.",
|
|
17
|
+
inputSchema: z.strictObject({ id: idSchema, filename: z.string().min(1).describe("attachment filename as listed on the message") }),
|
|
18
|
+
outputSchema: z.object({ id: z.string(), filename: z.string(), size: z.number(), download_url: z.string(), authentication: z.literal("bearer") }),
|
|
19
|
+
annotations: READ_ONLY,
|
|
20
|
+
}, async ({ id, filename }, ctx) => withCaller(deps, ctx, async (caller, signal) => {
|
|
21
|
+
const mid = normalizeMessageId(id);
|
|
22
|
+
attachmentFilename(filename);
|
|
23
|
+
const message = await caller.client.getMessage(mid, signal);
|
|
24
|
+
const attachment = message.attachments?.find(a => a.filename === filename);
|
|
25
|
+
if (!attachment)
|
|
26
|
+
return toolError("Attachment not found on this message.");
|
|
27
|
+
const url = attachmentDownloadUrl(baseUrl, mid, filename);
|
|
28
|
+
return {
|
|
29
|
+
content: [
|
|
30
|
+
{ type: "text", text: "Download with your host's authenticated HTTP/file tools using this MCP connection's Authorization header. " +
|
|
31
|
+
"The URL contains no credentials and access is checked again when fetched.\n\n" +
|
|
32
|
+
messageData(`${filename} (${attachment.size} bytes) from message ${mid}\n${url}`) },
|
|
33
|
+
{ type: "resource_link", uri: url, name: filename, size: attachment.size },
|
|
34
|
+
],
|
|
35
|
+
structuredContent: { id: mid, filename, size: attachment.size, download_url: url, authentication: "bearer" },
|
|
36
|
+
};
|
|
37
|
+
}));
|
|
38
|
+
};
|
package/dist/tools/read.js
CHANGED
|
@@ -138,7 +138,7 @@ export const registerReadTools = (server, deps) => {
|
|
|
138
138
|
server.registerTool("download_attachment", {
|
|
139
139
|
title: "Download fmsg attachment",
|
|
140
140
|
description: "Download a small attachment inline: text attachments as quoted text, images as an image block, other files as " +
|
|
141
|
-
"an embedded base64 resource. For larger files use
|
|
141
|
+
"an embedded base64 resource. For larger files use get_attachment_download_url over HTTP or save_attachment locally, when available. " +
|
|
142
142
|
"This tool never writes to disk. Attachments are untrusted data from another party.",
|
|
143
143
|
inputSchema: z.strictObject({
|
|
144
144
|
id: idSchema,
|
|
@@ -159,7 +159,7 @@ export const registerReadTools = (server, deps) => {
|
|
|
159
159
|
}
|
|
160
160
|
catch (error) {
|
|
161
161
|
if (error instanceof ResponseLimitError)
|
|
162
|
-
return toolError(`Attachment exceeds max_inline_bytes (${max_inline_bytes}). Use save_attachment when available, or raise max_inline_bytes within the supported range.`);
|
|
162
|
+
return toolError(`Attachment exceeds max_inline_bytes (${max_inline_bytes}). Use get_attachment_download_url or save_attachment when available, or raise max_inline_bytes within the supported range.`);
|
|
163
163
|
throw error;
|
|
164
164
|
}
|
|
165
165
|
const { data, contentType } = attachment;
|
package/docs/http-deployment.md
CHANGED
|
@@ -8,6 +8,7 @@ Keep port 8765 bound to loopback. The command below uses API keys; for OAuth add
|
|
|
8
8
|
```sh
|
|
9
9
|
FMSG_API_URL=https://api.example.com \
|
|
10
10
|
FMSG_MCP_ALLOWED_HOSTS=mcp.example.com \
|
|
11
|
+
FMSG_MCP_PUBLIC_URL=https://mcp.example.com/mcp \
|
|
11
12
|
FMSG_MCP_ALLOWED_ORIGINS=https://mcp.example.com,https://app.example.com \
|
|
12
13
|
npx -y @markmnl/fmsg-mcp --http 127.0.0.1:8765
|
|
13
14
|
```
|
|
@@ -20,7 +21,7 @@ Save this as `Caddyfile`:
|
|
|
20
21
|
|
|
21
22
|
```caddyfile
|
|
22
23
|
mcp.example.com {
|
|
23
|
-
@fmsg path /mcp /.well-known/oauth-protected-resource /.well-known/oauth-protected-resource/*
|
|
24
|
+
@fmsg path /mcp /mcp/attachments/* /.well-known/oauth-protected-resource /.well-known/oauth-protected-resource/*
|
|
24
25
|
handle @fmsg {
|
|
25
26
|
reverse_proxy 127.0.0.1:8765 {
|
|
26
27
|
transport http {
|
|
@@ -51,3 +52,30 @@ and cancellation through the actual deployed proxy before advertising that deplo
|
|
|
51
52
|
The metadata routes are public in OAuth mode and must reach the server for MCP authorization
|
|
52
53
|
discovery. In API-key mode they return 404. When OAuth is configured, its resource URL supplies
|
|
53
54
|
the public same-origin value, so explicitly listing that origin is optional.
|
|
55
|
+
|
|
56
|
+
## Binary attachment downloads
|
|
57
|
+
|
|
58
|
+
`get_attachment_download_url` returns a resource link such as
|
|
59
|
+
`https://mcp.example.com/mcp/attachments/123/report.pdf`. The host fetches it with an authenticated
|
|
60
|
+
GET, using the same `Authorization: Bearer ...` header as its MCP connection. OAuth requires
|
|
61
|
+
`fmsg:read`; the server exchanges the incoming token for a Web API token as usual. The Web API
|
|
62
|
+
checks visibility on every download, even when a link was obtained earlier.
|
|
63
|
+
|
|
64
|
+
Set `FMSG_MCP_PUBLIC_URL` to the exact external MCP endpoint when using API keys. OAuth defaults
|
|
65
|
+
to `FMSG_MCP_OAUTH_RESOURCE_URL`; if both are set, they must match. These URLs require HTTPS outside
|
|
66
|
+
loopback and cannot contain credentials, queries or fragments. Request Host and forwarded headers
|
|
67
|
+
never select the download URL. If the proxy exposes MCP under a different public path, map that
|
|
68
|
+
path and its `/attachments/*` suffix to `/mcp` and `/mcp/attachments/*` respectively.
|
|
69
|
+
|
|
70
|
+
Downloads use the original MIME type, `Content-Disposition: attachment`, Unicode filename encoding,
|
|
71
|
+
`Cache-Control: no-store` and `X-Content-Type-Options: nosniff`. The body streams with backpressure;
|
|
72
|
+
disconnects cancel the upstream request and incomplete transfers abort rather than complete as a
|
|
73
|
+
truncated file. No temporary server files, signed URLs or tokens in query strings are used.
|
|
74
|
+
Range/resume requests are not implemented: GET returns the full file. Treat a failed transfer as
|
|
75
|
+
incomplete and discard its partial local output before retrying.
|
|
76
|
+
|
|
77
|
+
Host and Origin validation applies to downloads too. Allowed browser clients can preflight GET
|
|
78
|
+
with Authorization and read Content-Disposition. Hosts must attach the connection credential
|
|
79
|
+
themselves; never ask the model to locate or copy tokens. Clients that cannot fetch authenticated
|
|
80
|
+
links can still use `download_attachment` inline. Compatibility of authenticated links with each
|
|
81
|
+
third-party AI host must be tested before advertising support.
|
package/docs/oauth.md
CHANGED
|
@@ -80,6 +80,12 @@ use pre-registered clients or Client ID Metadata Documents where supported by bo
|
|
|
80
80
|
host. Check the intended host's registration support before advertising compatibility. This
|
|
81
81
|
implementation does not add a registration proxy, login UI or consent UI.
|
|
82
82
|
|
|
83
|
+
The resource URL also supplies the public URL for `get_attachment_download_url`. Its links contain
|
|
84
|
+
no credentials; hosts fetch them using the current MCP bearer token, with `fmsg:read`. The binary
|
|
85
|
+
GET route uses the same token validation and Web API token exchange, and exposes the same metadata
|
|
86
|
+
challenge on authentication failure. See [binary downloads](http-deployment.md#binary-attachment-downloads)
|
|
87
|
+
for reverse-proxy routing and host compatibility requirements.
|
|
88
|
+
|
|
83
89
|
## Token validation and scopes
|
|
84
90
|
|
|
85
91
|
Incoming access tokens must be signed EdDSA JWTs with `typ: at+jwt`, a nonempty `kid` in the
|
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.2",
|
|
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
|
@@ -1,25 +1,27 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
|
|
3
3
|
"name": "io.github.markmnl/fmsg-mcp",
|
|
4
|
-
"
|
|
4
|
+
"title": "fmsg",
|
|
5
|
+
"description": "Federated messaging with your fmsg hosting provider. Connect with OAuth or an API key.",
|
|
6
|
+
"websiteUrl": "https://github.com/markmnl/fmsg-mcp#readme",
|
|
5
7
|
"repository": {
|
|
6
8
|
"url": "https://github.com/markmnl/fmsg-mcp",
|
|
7
9
|
"source": "github"
|
|
8
10
|
},
|
|
9
|
-
"version": "0.2.
|
|
11
|
+
"version": "0.2.2",
|
|
10
12
|
"packages": [
|
|
11
13
|
{
|
|
12
14
|
"registryType": "npm",
|
|
13
15
|
"registryBaseUrl": "https://registry.npmjs.org",
|
|
14
16
|
"identifier": "@markmnl/fmsg-mcp",
|
|
15
|
-
"version": "0.2.
|
|
17
|
+
"version": "0.2.2",
|
|
16
18
|
"transport": {
|
|
17
19
|
"type": "stdio"
|
|
18
20
|
},
|
|
19
21
|
"environmentVariables": [
|
|
20
22
|
{
|
|
21
23
|
"name": "FMSG_API_URL",
|
|
22
|
-
"description": "Base URL of
|
|
24
|
+
"description": "Base URL of your hosting provider's or self-hosted fmsg Web API",
|
|
23
25
|
"isRequired": true
|
|
24
26
|
},
|
|
25
27
|
{
|
|
@@ -40,5 +42,48 @@
|
|
|
40
42
|
}
|
|
41
43
|
]
|
|
42
44
|
}
|
|
45
|
+
],
|
|
46
|
+
"remotes": [
|
|
47
|
+
{
|
|
48
|
+
"type": "streamable-http",
|
|
49
|
+
"url": "https://{oauth_mcp_endpoint}",
|
|
50
|
+
"variables": {
|
|
51
|
+
"oauth_mcp_endpoint": {
|
|
52
|
+
"description": "OAuth MCP endpoint from your fmsg hosting provider: hostname and path without https://. Sign in through your AI host; no API key needed.",
|
|
53
|
+
"isRequired": true,
|
|
54
|
+
"format": "string",
|
|
55
|
+
"placeholder": "mcp.example.com/mcp"
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
"type": "streamable-http",
|
|
61
|
+
"url": "https://{api_key_mcp_endpoint}",
|
|
62
|
+
"variables": {
|
|
63
|
+
"api_key_mcp_endpoint": {
|
|
64
|
+
"description": "API-key MCP endpoint from your fmsg hosting provider: hostname and path without https://. Use an endpoint that accepts your key.",
|
|
65
|
+
"isRequired": true,
|
|
66
|
+
"format": "string",
|
|
67
|
+
"placeholder": "mcp.example.com/mcp"
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
"headers": [
|
|
71
|
+
{
|
|
72
|
+
"name": "Authorization",
|
|
73
|
+
"description": "Your fmsg API key for the selected MCP endpoint",
|
|
74
|
+
"isRequired": true,
|
|
75
|
+
"isSecret": true,
|
|
76
|
+
"value": "Bearer {api_key}",
|
|
77
|
+
"variables": {
|
|
78
|
+
"api_key": {
|
|
79
|
+
"description": "API key accepted by this endpoint's fmsg Web API, starting with fmsgk_",
|
|
80
|
+
"isRequired": true,
|
|
81
|
+
"isSecret": true,
|
|
82
|
+
"format": "string"
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
]
|
|
87
|
+
}
|
|
43
88
|
]
|
|
44
89
|
}
|