Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,46 @@

## Unreleased

- Graceful shutdown. On SIGINT/SIGTERM or `HttpServerHandle.close()`, in-flight `wait_for_message`
calls return at once with the new status `interrupted`, the caller's `after_id` and a `next`
step ("call again with the same after_id; no messages are lost") instead of hanging until the
client's own timeout. New requests get `503` with `Retry-After`; other in-flight requests get a
5-second grace period, then a `503` if they have not answered. The process exits within a bounded
time, and a second signal exits immediately. `close()` accepts `{ graceMs }`.
- Fix truncated bodies of deflate-compressed messages: `size` is the compressed wire size, so
`short_text` (a preview of the decoded body) was taken as complete whenever the body compressed
below it. Such messages now always fetch the full body. `get_message`'s `body_bytes` and
`get_thread`'s new per-message `body_bytes` report the decoded length when the body was read.
- Structured results that carry other parties' words (`list_messages`, `list_sent`,
`get_message`, `get_thread`, `wait_for_message`, `download_attachment`,
`get_attachment_download_url`, `save_attachment`) include `untrusted_content_notice`, so the
safety framing survives hosts that show `structuredContent` instead of the text.
`wait_for_message` and `get_thread` add a `next` step, and `get_attachment_download_url` a
`fallback`.
- `wait_for_message` `skipped` entries add `from`, and for reactions `emoji` and `reaction_to`;
skipped reactions are also listed in the text. Results add `thread_topic`, read from the thread
lookup the wait already makes. The description explains that a timeout's `after_id` is never
older than the inbox's newest message (`"0"` only for an empty inbox) and that each wait is a
model turn in chat hosts.
- `add_recipients` takes `recipients`, matching `reply`; `add_to` stays as a deprecated alias
(pass one or the other). Results add `recipients` alongside `add_to`.
- `get_thread` adds `thread_topic`, the root's topic (replies carry none).
- Attachment types are consistent: a missing or generic `application/octet-stream` type is
inferred from the filename extension, as the download route does. List items, `get_message`,
send results and download links now include `type`.
- `delivery_status` and `list_sent` describe response codes accurately (`200` accepted, other fmsg
codes rejections, `null` when not recorded) and add `code_meaning`. Negative host-local codes
count as pending rather than failed.
- List items add `reaction` (the emoji when the item is itself a reaction). Hidden reactions never
count as unread; `mark_read` accepts listed ones.
- `whoami` reports `transport` as `stdio` or `streamable-http` (was `http`), says "connected over
Streamable HTTP", and no longer states the token expiry in its text; `token_expires_at` stays in
the structured result, described as internal. `directory_names` is omitted when empty.
`resolve_address`'s description names only the resolution steps the operator configured.
- Descriptions and errors mention `save_attachment` and `get_attachment_download_url` only where
those tools are registered. The server instructions now state that other fmsg tools or local
credentials may act as a different address, instead of directing the model away from them.

- Add `FMSG_DEFAULT_DOMAIN=caller` (case-insensitive): short names resolve on the domain of the
address the server acts as for each request, so a deployment serving callers on several domains
resolves `bob` to `@bob@example.org` for `@mark@example.org` and `@bob@example.net` for a caller
Expand Down
28 changes: 17 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,32 +119,37 @@ For loopback binds, loopback browser origins on any port work by default, includ
at `http://localhost:6274`. Setting an explicit origin list replaces that loopback default.
Allowed preflights need no credentials; actual MCP requests always require authentication.
`wait_for_message` holds a request open for up to
`FMSG_MCP_WAIT_MAX_SECONDS` (230), so give the proxy an idle timeout of at least 240 s.
`FMSG_MCP_WAIT_MAX_SECONDS` (230), so give the proxy an idle timeout of at least 240 s. On SIGTERM
the server ends open waits with an `interrupted` result that clients resume from, gives other
requests 5 seconds to finish, and exits, well within a service manager's stop timeout.
See the [TLS reverse-proxy example](docs/http-deployment.md) for a loopback deployment with Caddy.

## Tools

| Tool | What it does |
|---|---|
| `whoami` | The address this server acts as, the public API URL (when known) and token expiry |
| `whoami` | The address this server acts as, the public API URL (when known), the transport (`stdio` or `streamable-http`) and short-name defaults |
| `resolve_address` | Turn a short name into `@user@domain` (directory, then default domain) |
| `list_messages` | Inbox, newest first, with previews; reactions hidden; optional unread filter |
| `list_messages` | Inbox, newest first, with previews and attachment types; reactions hidden and never counted as unread unless `include_reactions` |
| `list_sent` | Sent messages with per-recipient delivery state |
| `get_message` | One message with headers, full text body, attachments and reactions |
| `get_thread` | The lineage from the thread root to a message, with gaps for messages you cannot see |
| `get_thread` | The lineage from the thread root to a message, with gaps for messages you cannot see, and the root's `thread_topic` |
| `send_message` | Start a new thread; sends immediately (fmsg messages are immutable) |
| `reply` | Reply into a thread; reply-all by default, refuses terminal and no-reply parents |
| `add_recipients` | Add recipients to a sent message |
| `add_recipients` | Add `recipients` to a sent message (`add_to` is a deprecated alias) |
| `react` | Set or clear your emoji reaction |
| `mark_read` | Mark received messages read |
| `download_attachment` | Fetch a small attachment inline: text as text, images as image blocks, other files as base64 resources |
| `get_attachment_download_url` | Get a credential-free URL and resource link for an authenticated binary download; HTTP only, requires a public MCP URL |
| `get_attachment_download_url` | Get a credential-free URL and resource link for an authenticated binary download; HTTP only, requires a public MCP URL; hosts that cannot send the connection's Authorization header use `download_attachment` |
| `save_attachment` | Stream an attachment to the configured local folder; stdio only, enabled by `FMSG_MCP_DOWNLOAD_DIR` |
| `delivery_status` | Per-recipient delivery times and host response codes |
| `wait_for_message` | Block until the next inbound message (WebSocket push), batched per thread, with thread context |
| `delivery_status` | Per-recipient delivery times and fmsg response codes (`200` accepted; `null` when not recorded) |
| `wait_for_message` | Block until the next inbound message (WebSocket push), batched per thread, with thread context, skipped reactions and a `next` step |

Every tool returns readable Markdown plus `structuredContent`. Ids are decimal strings. Message
bodies are labelled as data from other parties, not instructions.
bodies are labelled as data from other parties, not instructions; structured results that carry
other parties' words repeat that in `untrusted_content_notice`, since some hosts show the
structured result instead of the text. Attachment types come from the host when it recorded one,
otherwise from the filename extension, so every tool reports the same type.

Resources `fmsg://message/{id}` and `fmsg://thread/{id}` expose the same content to hosts that
attach resources; prompts `chat` and `reply` script the wait → reply loop and a guided reply.
Expand Down Expand Up @@ -181,8 +186,9 @@ portable names; use the returned `saved_to` path. Streaming downloads can run lo
while making progress; a 60-second idle timeout detects stalled transfers.

Inline downloads default to 256 KiB to keep file content manageable for the model. Use
`save_attachment` for larger local files, or raise `max_inline_bytes` explicitly when your AI host
can handle more inline content.
`save_attachment` for larger local files (stdio), `get_attachment_download_url` for remote ones
(HTTP), or raise `max_inline_bytes` explicitly when your AI host can handle more inline content.
Tool descriptions name only the tools the running server registered.

For remote file downloads, call `get_attachment_download_url` with the message ID and filename.
It returns metadata and an HTTPS `resource_link`; your AI host downloads the original bytes using
Expand Down
4 changes: 3 additions & 1 deletion docs/http-deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,9 @@ the public same-origin value, so explicitly listing that origin is optional.
`https://mcp.example.com/mcp/attachments/123/report.pdf`. The host fetches it with an authenticated
GET, using the same `Authorization: Bearer ...` header as its MCP connection. OAuth requires
`fmsg:read`; the server exchanges the incoming token for a Web API token as usual. The Web API
checks visibility on every download, even when a link was obtained earlier.
checks visibility on every download, even when a link was obtained earlier. Hosts that cannot send
the connection's Authorization header with an arbitrary GET (many chat hosts cannot) should use
`download_attachment` instead; the tool's description and result say so.

Set `FMSG_MCP_PUBLIC_URL` to the exact external MCP endpoint when using API keys. OAuth defaults
to `FMSG_MCP_OAUTH_RESOURCE_URL`; if both are set, they must match. These URLs require HTTPS outside
Expand Down
6 changes: 5 additions & 1 deletion src/client/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,9 +209,13 @@ export class FmsgClient {
return { data: new Uint8Array(await response.arrayBuffer()), ...(contentType ? { contentType } : {}) };
}

/** Whether `short_text` already holds the complete body. */
/**
* Whether `short_text` already holds the complete body. `size` is the wire size,
* so a deflate-compressed message's preview is never treated as complete.
*/
static shortTextIsComplete(message: FmsgMessage): boolean {
if (typeof message.short_text !== "string") return false;
if (message.deflate === true) return false;
if (typeof message.size !== "number") return false;
return Buffer.byteLength(message.short_text, "utf8") >= message.size;
}
Expand Down
8 changes: 7 additions & 1 deletion src/client/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@ export type RecipientDelivery = {
addr: string;
/** RFC3339 UTC; null until delivered. */
time_delivered: string | null;
/** The receiving host's response code for the last delivery attempt; null if none yet. */
/**
* The receiving host's per-recipient fmsg response code for the last attempt when recorded
* (200 accepted; 100–105 recipient rejections; message-level codes such as 1–11); null when not recorded.
*/
response_code: number | null;
};

Expand Down Expand Up @@ -45,7 +48,9 @@ export type FmsgMessage = {
time?: number | null;
topic?: string;
type?: string;
/** Body size on the wire: the compressed size when `deflate` is true. */
size?: number;
/** Up to the host's preview limit of the decoded body; complete only when `deflate` is false and it covers `size`. */
short_text?: string;
read?: boolean;
time_read?: number | null;
Expand Down Expand Up @@ -86,6 +91,7 @@ export type ThreadMessage = {
topic?: string;
type?: string;
size?: number;
deflate?: boolean;
message_sha256?: string;
body?: ThreadBody;
attachments?: ThreadAttachment[];
Expand Down
57 changes: 41 additions & 16 deletions src/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ import { safeErrorMessage } from "./client/redact.js";
import { isLoopbackHost, normalizeOrigin } from "./client/url.js";

export const MCP_PATH = "/mcp";
/** How long close() lets in-flight requests finish (waits end at once) before cutting them off. */
export const SHUTDOWN_GRACE_MS = 5000;

const CORS_METHODS = ["POST", "GET", "DELETE"];
const CORS_HEADERS = ["authorization", "content-type", "accept", "mcp-protocol-version", "mcp-method", "mcp-name", "mcp-session-id", "last-event-id"];
Expand Down Expand Up @@ -70,7 +72,8 @@ export async function sendWebResponse(res: ServerResponse, response: Response, f
if (rejected) { await reader.cancel(); return await sendWebResponse(res, rejected); }
res.writeHead(response.status, headers);
}
if (done || res.destroyed) break;
// A shutdown may already have answered this request with a 503.
if (done || res.destroyed || res.writableEnded) break;
if (!res.write(value)) await new Promise<void>((resolve) => {
const finish = () => {
res.off("drain", finish);
Expand All @@ -90,13 +93,17 @@ export async function sendWebResponse(res: ServerResponse, response: Response, f
} finally {
res.off("close", abort);
await reader.cancel().catch(() => undefined);
if (res.headersSent && !res.destroyed) res.end();
if (res.headersSent && !res.destroyed && !res.writableEnded) res.end();
}
}

export type HttpServerHandle = {
server: Server;
close: () => Promise<void>;
/**
* Stop accepting requests, end in-flight waits with an "interrupted" result, give other
* requests `graceMs` to finish, then answer any still waiting with 503 and close.
*/
close: (options?: { graceMs?: number }) => Promise<void>;
/** The OAuth provider when OAuth is configured, otherwise the API-key provider. */
provider: ApiKeyCallerProvider | OAuthCallerProvider;
/** Each configured provider; both are set in combined `oauth+api-key` mode. */
Expand All @@ -121,22 +128,27 @@ export function createHttpServer(config: Config, log: (line: string) => void = (
const publicOrigin = downloadBaseUrl(config) ? new URL(downloadBaseUrl(config)!).origin : undefined;
const metadataPath = resource ? `/.well-known/oauth-protected-resource${resource.pathname === "/" ? "" : resource.pathname}` : undefined;
const metadataUrl = resource ? `${resource.origin}${metadataPath}` : "";
const shutdown = new AbortController();
const mcpHandler = (provider: CallerProvider, address: (authInfo: AuthInfo) => string) => createMcpHandler(({ authInfo }) =>
createFmsgMcpServer(provider, config, authInfo ? { address: address(authInfo) } : {}),
createFmsgMcpServer(provider, config, { ...(authInfo ? { address: address(authInfo) } : {}), shutdown: shutdown.signal }),
{ onerror: (error) => safeLog(`MCP transport failed: ${error instanceof Error ? error.message : String(error)}`) },
);
const oauthHandler = oauthProvider && mcpHandler(oauthProvider, (authInfo) => String(authInfo.extra?.address ?? ""));
const apiKeyHandler = apiKeyProvider && mcpHandler(apiKeyProvider, (authInfo) => authInfo.clientId);
const active = new Set<AbortController>();
const active = new Map<AbortController, { res: ServerResponse; done: Promise<void> }>();
const unavailable = (res: ServerResponse) => {
res.writeHead(503, { "content-type": "application/json", "retry-after": "5", connection: "close" });
res.end(JSON.stringify({ error: "fmsg-mcp is restarting; retry shortly" }));
};

const server = createServer((req, res) => {
if (shutdown.signal.aborted) return unavailable(res);
let release: (() => void) | undefined;
const controller = new AbortController();
active.add(controller);
const abort = () => controller.abort();
req.once("aborted", abort);
res.once("close", abort);
void (async () => {
const done = (async () => {
const url = new URL(req.url ?? "/", "http://localhost");
const isDownload = url.pathname.startsWith(`${DOWNLOAD_PATH}/`);
if (url.pathname === "/healthz") {
Expand Down Expand Up @@ -291,23 +303,36 @@ export function createHttpServer(config: Config, log: (line: string) => void = (
req.off("aborted", abort);
res.off("close", abort);
});
active.set(controller, { res, done });
});
// This bounds receiving the request body, not the duration of a wait response.
server.requestTimeout = 60_000;
server.headersTimeout = 60_000;
server.keepAliveTimeout = 65_000;

const close = async () => {
for (const controller of active) controller.abort();
let closing: Promise<void> | undefined;
const close = ({ graceMs = SHUTDOWN_GRACE_MS }: { graceMs?: number } = {}) => closing ??= (async () => {
// Waits answer "interrupted" with a resumable cursor; new requests get 503.
shutdown.abort();
const closed = new Promise<void>((resolve) => server.close(() => resolve()));
server.closeIdleConnections();
let timer: NodeJS.Timeout | undefined;
await Promise.race([
Promise.allSettled([...active.values()].map((a) => a.done)),
new Promise<void>((resolve) => { timer = setTimeout(resolve, graceMs); }),
]);
clearTimeout(timer);
for (const [controller, { res }] of active) {
if (!res.headersSent && !res.destroyed) unavailable(res);
controller.abort();
}
oauthProvider?.close();
apiKeyProvider?.close();
await Promise.all([oauthHandler?.close(), apiKeyHandler?.close()]);
await new Promise<void>((resolve) => {
server.close(() => resolve());
// Aborted fetches may open replacement sockets without a request, so
// they are absent from `active` and must also be closed on shutdown.
server.closeAllConnections();
});
};
// Aborted fetches may open replacement sockets without a request, so
// they are absent from `active` and must also be closed on shutdown.
server.closeAllConnections();
await closed;
})();
return { server, close, provider: (oauthProvider ?? apiKeyProvider)!, providers: { apiKey: apiKeyProvider, oauth: oauthProvider } };
}
33 changes: 23 additions & 10 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { FmsgClient } from "./client/client.js";
import { safeErrorMessage } from "./client/redact.js";
import { loadConfig, DEFAULT_HTTP_PORT, type ConfigOverrides } from "./config.js";
import { type CallerProvider, StaticCallerProvider, UnconfiguredCallerProvider } from "./context.js";
import { createHttpServer, MCP_PATH } from "./http.js";
import { createHttpServer, MCP_PATH, SHUTDOWN_GRACE_MS } from "./http.js";
import { createFmsgMcpServer } from "./server.js";
import { PACKAGE_NAME, VERSION } from "./version.js";

Expand Down Expand Up @@ -120,16 +120,18 @@ async function main(): Promise<void> {
const timeout = new Promise<undefined>((resolve) => setTimeout(() => resolve(undefined), 5000).unref());
return Promise.race([provider.forRequest(undefined).then((c) => c.address), timeout]).catch(() => undefined);
};
const shutdown = new AbortController();
const handle = serveStdio(async () => {
const address = await knownAddress();
return createFmsgMcpServer(provider, cfg, address ? { address } : {});
return createFmsgMcpServer(provider, cfg, { ...(address ? { address } : {}), shutdown: shutdown.signal });
});
const stop = () => {
onShutdown(async () => {
// Let an in-flight wait write its "interrupted" result before the transport closes.
shutdown.abort();
await new Promise((resolve) => setTimeout(resolve, 250));
provider.close?.();
void handle.close().finally(() => process.exit(0));
};
process.on("SIGINT", stop);
process.on("SIGTERM", stop);
await handle.close();
});
return;
}

Expand All @@ -141,9 +143,20 @@ async function main(): Promise<void> {
const addr = server.address();
const shown = typeof addr === "object" && addr ? `${addr.address}:${addr.port}` : `${config.http.host}:${config.http.port}`;
console.error(safeErrorMessage(`fmsg-mcp ${VERSION} serving Streamable HTTP at http://${shown}${MCP_PATH} for ${config.apiUrl}`));
const stop = () => void close().finally(() => process.exit(0));
process.on("SIGINT", stop);
process.on("SIGTERM", stop);
onShutdown(() => close());
}

/** Stop on SIGINT/SIGTERM within a bounded time; a second signal exits at once. */
function onShutdown(stop: () => Promise<void>): void {
let stopping = false;
const handler = () => {
if (stopping) process.exit(1);
stopping = true;
setTimeout(() => process.exit(1), SHUTDOWN_GRACE_MS + 5000).unref();
void stop().catch((error) => console.error(`fmsg-mcp: shutdown failed: ${safeErrorMessage(error)}`)).finally(() => process.exit(0));
};
process.on("SIGINT", handler);
process.on("SIGTERM", handler);
}

function invokedDirectly(): boolean {
Expand Down
Loading
Loading