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
32 changes: 32 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,38 @@

## Unreleased

- `wait_for_message` no longer lets a model step past an unread message silently. Messages on other
threads that arrive while a batch settles are not returned and `after_id` moves past them, as
before; now `next` (and the text) names each one first ("Also new and not included here: message
40 in another thread … read it with get_thread (or get_message) "40" … before waiting again"),
and results add `pending_ids`. The cursor is not held back, which would replay the returned batch.
- `get_thread` says it returns one lineage: results add `scope: "lineage"`, the description and
`next` say other replies in the thread (siblings and other branches) are not included and that
`list_messages` shows newer messages, and `complete` is described as the lineage being complete.
The Web API's thread routes return only the lineage, so other replies are not listed.
- Thread messages add `no_reply`, `terminal`, `important`, `added` and `compressed`, and results add
the target's `no_reply`. `next` never suggests replying to a terminal message (no replies are
possible) or a no-reply one (the sender asked for none; `reply` refuses unless `allow_no_reply`).
- Addresses in text results are code spans instead of Markdown-escaped text, so a model copying
`@bob_mcp@example.com` no longer gets `@bob\_mcp@example.com`. Address validation follows the
fmsg specification's user-part rule (letters and digits with single `.`, `_` or `-` between them)
and rejects backslashes, spaces and control characters, with a hint about Markdown escaping.
- `size` is described as the size on the wire (the compressed length when `compressed` is true), and
message items add `compressed`.
- Redaction covers more credential formats: cloud access key ids (`AKIA…`, `ASIA…`, including the
widely published documentation example) and 40-character secret access keys next to their usual
label, `gho_`/`ghs_`/`ghu_` and other source-hosting tokens (`glpat-…`), chat-platform tokens
(`xox…`, `xapp-…`), `AIza…` API keys, payment-platform keys (`sk_live_…`, `rk_live_…`,
`sk_test_…`, `whsec_…`) and `PGP PRIVATE KEY BLOCK`s. Ordinary text is left alone.
- `download_attachment` and `save_attachment` add `type` (the same value as `content_type`). A
filename the message does not have now says so and lists the message's attachments instead of
suggesting the message may not be visible.
- Attachments are listed in one order (by filename) by every tool, including send results.
- `delivery_status` describes received messages (the host's own receipt records, often with code
null) and what `via` means.
- `get_message` adds `thread_topic` (one thread lookup, only for replies) and shows it in the text;
`send_message` and `reply` results add `thread_topic` when it is known without another request.

- Tool output schemas are open and additive, so hosts that cache a tool's `outputSchema` when the
connector is added keep validating results after upgrades. Every output object, at every depth,
accepts unknown properties (no `additionalProperties: false`); values that may grow (statuses,
Expand Down
23 changes: 15 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,26 +130,30 @@ See the [TLS reverse-proxy example](docs/http-deployment.md) for a loopback depl
|---|---|
| `whoami` | The address this server acts as, the public API URL (when known), the transport (`stdio` or `http`, which is 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 and attachment types; reactions hidden and never counted as unread unless `include_reactions` |
| `list_messages` | Inbox, newest first, with previews, attachment types and wire `size` (`compressed` marks a deflated body); 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, and the root's `thread_topic` |
| `get_message` | One message with headers, full text body, attachments, reactions and, for a reply, the root's `thread_topic` |
| `get_thread` | The lineage from the thread root to a message (`scope: "lineage"`; other replies in the thread are not included), with gaps for messages you cannot see, per-message flags and added recipients, the root's `thread_topic`, and a `next` step that never suggests replying to a terminal or no-reply message |
| `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_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 |
| `download_attachment` | Fetch a small attachment inline: text as text, images as image blocks, other files as base64 resources; a wrong filename lists the names the message has |
| `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 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 |
| `delivery_status` | Per-recipient delivery times and fmsg response codes (`200` accepted; `null` when not recorded); for a received message, the host's own receipt records; `via` is `to` or `add_to` (added later) |
| `wait_for_message` | Block until the next inbound message (WebSocket push), batched per thread, with thread context, skipped reactions and a `next` step that names messages from other threads (`pending_ids`) the cursor moved past |

Every tool returns readable Markdown plus `structuredContent`. Ids are decimal strings. Message
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.
otherwise from the filename extension, so every tool reports the same type, and every tool lists
attachments in the same order (by filename). Addresses in the text are code spans, so they copy
exactly (`@bob_mcp@example.com`, never with a Markdown escape); a backslash in an address is
rejected. A message's `size` is the body's size on the wire: the compressed length when
`compressed` is true.

### Output schema compatibility

Expand Down Expand Up @@ -228,7 +232,10 @@ explain the configuration error and how to fix it; restart the MCP server after
annotated `destructiveHint` to describe their effects. Approval behavior belongs to the AI host;
fmsg-mcp has no additional confirmation gate.
- Selected API-key/token formats are redacted from outbound bodies, topics and error text; the
send tools report the count. This is not general data-loss prevention or binary attachment scanning.
send tools report the count. They include fmsg API keys, JWTs, private key blocks, source-hosting
tokens (`ghp_…`, `github_pat_…`, `glpat-…`), cloud access key ids (`AKIA…`, `ASIA…`) and labelled
secret access keys, `AIza…` API keys, chat-platform tokens (`xox…`), payment-platform keys
(`sk_live_…`, `rk_live_…`, `sk_test_…`, `whsec_…`) and `sk-…` API keys. This is not general data-loss prevention or binary attachment scanning.
- Nothing about message size or acceptance is assumed: the fmsg host's own responses and delivery
codes are surfaced verbatim.
- The server publishes MCP `instructions` (shown to the model at session start) telling agents to use
Expand Down
3 changes: 2 additions & 1 deletion SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ rather than a public issue.
WebSocket announcements trigger protected message reads before their content reaches the host.
The upstream contract authenticates sockets at handshake; it does not promise immediate closure
of existing sockets on revocation. MCP does not infer ongoing authorization from a socket alone.
- Selected key/JWT/private-key patterns are redacted from outbound message bodies, topics and errors.
- Selected credential formats (API keys and tokens with distinctive prefixes, labelled secret access
keys, JWTs and private key blocks) are redacted from outbound message bodies, topics and errors.
This does not detect arbitrary sensitive information or scan binary attachments.
- Message bodies and upstream error text are fenced as untrusted data. Each message body has its own
fence; server-built headers stay outside it, with external header values escaped onto one line.
Expand Down
33 changes: 29 additions & 4 deletions src/address.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,36 @@
const ADDRESS = /^@([^@\s/]+)@([^@\s/]+)$/u;
/**
* fmsg specification, Addresses: the user part is Unicode letters and numbers, with `-`, `_` or `.`
* between them (never consecutive, never first or last).
*/
const USER_PART = /^[\p{L}\p{N}]+(?:[-_.][\p{L}\p{N}]+)*$/u;
/** Host names (including internationalised ones) and an optional port; no backslashes, spaces or controls. */
const DOMAIN_PART = /^[\p{L}\p{N}\p{M}](?:[\p{L}\p{N}\p{M}._:-]*[\p{L}\p{N}\p{M}])?$/u;
/** The specification limits a whole address to under 256 bytes. */
const MAX_ADDRESS_BYTES = 255;

/**
* Validate an fmsg address and normalise it to `@user@domain`. The user part keeps
* its case: the Web API compares `from` to the token's address byte for byte, and
* hosts may treat user names case-sensitively. Only the domain is lower-cased.
* Returns undefined when malformed.
* Returns undefined when malformed, including Markdown-escaped copies such as `@bob\_x@example.com`.
*/
export function normalizeFmsgAddress(value: string): string | undefined {
const trimmed = value.trim();
const match = ADDRESS.exec(trimmed);
if (!match) return undefined;
return `@${match[1]!}@${match[2]!.toLowerCase()}`;
if (!match || !USER_PART.test(match[1]!) || !DOMAIN_PART.test(match[2]!)) return undefined;
const address = `@${match[1]!}@${match[2]!.toLowerCase()}`;
return Buffer.byteLength(address, "utf8") <= MAX_ADDRESS_BYTES ? address : undefined;
}

/** Why a value shaped like `@user@domain` is still not a valid address. */
function invalidAddressReason(value: string): string | undefined {
const match = ADDRESS.exec(value);
if (!match) return /^@[^@]*@[^@]*$/u.test(value) ? "it contains spaces, slashes or control characters" : undefined;
if (value.includes("\\")) return "it contains a backslash; remove any Markdown escaping copied from formatted text";
if (!USER_PART.test(match[1]!)) return "the user part may contain only letters and digits, with single . _ or - between them";
if (!DOMAIN_PART.test(match[2]!)) return "the domain is not a valid host name";
return "it is longer than 255 bytes";
}

/** Case-insensitive address equality. */
Expand Down Expand Up @@ -57,7 +77,12 @@ export function resolveAddress(name: string, resolver: AddressResolver = {}): Re
const trimmed = name.trim();
const literal = normalizeFmsgAddress(trimmed);
if (literal) return { address: literal, resolution: "literal" };
if (trimmed === "" || /[@\s/]/u.test(trimmed)) {
const invalid = invalidAddressReason(trimmed);
if (invalid) throw new Error(`"${name}" is not a valid fmsg address: ${invalid}`);
if (trimmed.includes("\\")) {
throw new Error(`"${name}" contains a backslash, which no fmsg address or short name has; remove any Markdown escaping copied from formatted text`);
}
if (trimmed === "" || /[@\s/\\\p{Cc}]/u.test(trimmed)) {
throw new Error(`"${name}" is not an fmsg address (@user@domain) or a resolvable short name`);
}
const key = trimmed.toLowerCase();
Expand Down
31 changes: 26 additions & 5 deletions src/client/redact.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,31 @@
/** Patterns for secrets that must never leave the process in message bodies or logs. */
const PATTERNS: Array<[RegExp, string]> = [
/** Not preceded or followed by another key character, so a match is a whole token. */
const START = String.raw`(?<![A-Za-z0-9_-])`;
const END = String.raw`(?![A-Za-z0-9_-])`;

/**
* Patterns for secrets that must never leave the process in message bodies or logs. Each is a credential format
* with a distinctive prefix (or, for secret access keys, a distinctive label), so ordinary text is not touched.
*/
const PATTERNS: Array<[RegExp, string | ((match: string, ...groups: string[]) => string)]> = [
[/\bfmsgk_[A-Za-z0-9+/=._~-]{6,}\b/gu, "[REDACTED_FMSG_API_KEY]"],
[/\beyJ[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]{4,}\b/gu, "[REDACTED_JWT]"],
[/\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{36,}\b|\bgithub_pat_[A-Za-z0-9_]{22,}\b/gu, "[REDACTED_GITHUB_TOKEN]"],
[new RegExp(`${START}glpat-[A-Za-z0-9_-]{20,}${END}`, "gu"), "[REDACTED_ACCESS_TOKEN]"],
[/\bsk-[A-Za-z0-9_-]{20,}\b/gu, "[REDACTED_API_KEY]"],
[/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/gu, "[REDACTED_PRIVATE_KEY]"],
// Payment-platform secret, restricted and webhook-signing keys (sk_live_…, rk_test_…, whsec_…).
[/\b(?:sk|rk)_(?:live|test)_[A-Za-z0-9]{16,}\b|\bwhsec_[A-Za-z0-9+/=]{24,}/gu, "[REDACTED_API_KEY]"],
// Cloud API keys with the AIza prefix: 39 characters in all.
[new RegExp(`${START}AIza[0-9A-Za-z_-]{35}${END}`, "gu"), "[REDACTED_API_KEY]"],
// Chat-platform bot, user, app and refresh tokens (xoxb-…, xoxp-…, xapp-…).
[/\bxox[abposr]-[A-Za-z0-9-]{10,}|\bxapp-[0-9]-[A-Za-z0-9-]{10,}/gu, "[REDACTED_CHAT_TOKEN]"],
// Cloud access key ids: long-term (AKIA…) and temporary (ASIA…), always 20 upper-case letters and digits.
[/\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/gu, "[REDACTED_ACCESS_KEY_ID]"],
// A 40-character secret access key next to its usual label (aws_secret_access_key = …, "SecretAccessKey": "…").
[
/\b((?:aws_?)?secret_?access_?key|aws_?secret_?key)(["']?\s*[:=]\s*["']?)([A-Za-z0-9/+]{40})(?![A-Za-z0-9/+=])/giu,
(_match, label: string, separator: string) => `${label}${separator}[REDACTED_SECRET_ACCESS_KEY]`,
],
[/-----BEGIN [A-Z0-9 ]*PRIVATE KEY(?: BLOCK)?-----[\s\S]*?-----END [A-Z0-9 ]*PRIVATE KEY(?: BLOCK)?-----/gu, "[REDACTED_PRIVATE_KEY]"],
];

export type Redacted = { text: string; count: number };
Expand All @@ -14,9 +35,9 @@ export function redactSecrets(text: string): Redacted {
let count = 0;
let out = text;
for (const [pattern, replacement] of PATTERNS) {
out = out.replace(pattern, () => {
out = out.replace(pattern, (match: string, ...groups: string[]) => {
count += 1;
return replacement;
return typeof replacement === "string" ? replacement : replacement(match, ...groups);
});
}
return { text: out, count };
Expand Down
5 changes: 5 additions & 0 deletions src/client/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,12 +84,17 @@ export type ThreadMessage = {
visible: boolean;
version?: number;
pid?: string | null;
/** Flags are omitted when false. */
no_reply?: boolean;
important?: boolean;
terminal?: boolean;
from?: string;
to?: string[];
add_to?: AddToBatch[];
time?: number | null;
topic?: string;
type?: string;
/** Body size on the wire: the compressed size when `deflate` is true. */
size?: number;
deflate?: boolean;
message_sha256?: string;
Expand Down
3 changes: 2 additions & 1 deletion src/prompts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ export function registerPrompts(server: McpServer): void {
const text = [
`Chat over fmsg on my behalf (${mode === "keep" ? "keep replying within the thread" : "reply once to the next message"}).`,
`1. Call wait_for_message${thread ? ` with thread_of "${thread}"` : ""}${from ? ` and from "${from}"` : ""}. On status "timeout" call it again with the same arguments; stop after ${minutes} minutes with nothing arriving.`,
"2. When messages arrive, tell me in one line who wrote what, then compose a reply and send it with the reply tool to reply_target_id.",
"2. When messages arrive, tell me in one line who wrote what, then compose a reply and send it with the reply tool to reply_target_id. " +
"Follow the result's next step: it also names new messages on other threads (pending_ids) that the next wait will not return.",
mode === "keep"
? `3. Call wait_for_message again with the returned after_id and the same thread_of, and repeat. Stop after ${replies} replies, when I interrupt, or when the other party says goodbye.`
: "3. Then stop and report back.",
Expand Down
Loading
Loading