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
3 changes: 3 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@ test/fmsg-docker.e2e.test.ts real two-host run, gated by FMSG_E2E=1
- Every tool returns concise Markdown in `content[0].text` **and** `structuredContent` matching its
`outputSchema`. Failures are `isError: true` results built by `src/errors.ts`, never thrown past
the handler.
- Output schemas stay open because hosts cache them: build every output object with `outputObject`,
type values that may grow with `openEnum` (never `z.enum`/`z.literal`), add new fields as optional,
and never remove or rename one. `test/output-schemas.test.ts` enforces this.
- Irreversible send-type tools carry `destructiveHint: true`; reversible reactions use
`destructiveHint: false` and `idempotentHint: true`. Read tools use `readOnlyHint: true`.
- Outbound bodies/topics and every error string pass through `redactSecrets`. Never log an API key;
Expand Down
23 changes: 20 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,24 @@

## Unreleased

- 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,
transports, resolutions, delivery `status` and `via`, skip reasons, `authentication`) are strings
with their current values in the description instead of enums; and only fields present since
0.2.5 are required, so fields added later are optional in the schema though still returned.
Schemas published by 0.2.6 and earlier were closed, so hosts that cached one reject results with
newer fields ("must NOT have additional properties") until the connector is reconnected once;
no server change can fix an already-cached closed schema.
- `whoami` again matches its 0.2.5 shape: `transport` is `stdio` or `http` (0.2.6 reported
`streamable-http`; the text still says Streamable HTTP) and `directory_names` is always present,
empty when there is no directory. Hosts holding 0.2.5 schemas validate `whoami`,
`resolve_address`, `mark_read` and `react` again. Hosts that cached 0.2.6 schemas validate every
tool except `whoami` over HTTP, which needs the one-time reconnect.
- Tests check that no published output schema is closed or uses `enum`/`const`, that required
fields match 0.2.5, and that representative results of every tool validate against the
published schemas and against the 0.2.5 schemas apart from unknown properties.

- 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
Expand Down Expand Up @@ -34,9 +52,8 @@
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.
- `whoami` 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.
`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
Expand Down
12 changes: 11 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ See the [TLS reverse-proxy example](docs/http-deployment.md) for a loopback depl

| Tool | What it does |
|---|---|
| `whoami` | The address this server acts as, the public API URL (when known), the transport (`stdio` or `streamable-http`) and short-name defaults |
| `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_sent` | Sent messages with per-recipient delivery state |
Expand All @@ -151,6 +151,16 @@ other parties' words repeat that in `untrusted_content_notice`, since some hosts
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.

### Output schema compatibility

Some MCP hosts cache each tool's `outputSchema` when the connector is added and validate every later
result against that copy. Output schemas are therefore open and additive: objects accept unknown
properties, values that may grow (statuses, transports, resolutions, delivery state) are strings
whose current values are listed in their descriptions, and only fields present since 0.2.5 are
required. Releases add fields and values; they do not remove or rename them. Schemas published by
0.2.6 and earlier were closed, so a host that cached one rejects results with newer fields (for
example "must NOT have additional properties"); remove and re-add the connector once to refresh it.

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
8 changes: 8 additions & 0 deletions docs/http-deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,14 @@ The metadata routes are public in `oauth` and `oauth+api-key` modes and must rea
MCP authorization discovery. In API-key mode they return 404. When OAuth is configured, its resource URL supplies
the public same-origin value, so explicitly listing that origin is optional.

## Upgrades and cached tool schemas

Chat hosts may cache each tool's `outputSchema` from when the connector was added and validate
results against it. Output schemas are open and additive (see "Output schema compatibility" in the
README), so later upgrades keep validating. Schemas published by 0.2.6 and earlier were closed: after
upgrading from those releases, users whose host reports that structured content does not match the
tool's output schema must reconnect (remove and re-add) the connector once.

## Binary attachment downloads

`get_attachment_download_url` returns a resource link such as
Expand Down
37 changes: 27 additions & 10 deletions src/tools/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,30 @@ export const SENDS: ToolAnnotations = { readOnlyHint: false, destructiveHint: tr

export const idSchema = z.string().regex(/^[0-9]+$/u, "fmsg message ids are decimal integers").describe("fmsg message id");

export const attachmentItem = z.object({
/**
* Output schemas must stay valid for hosts that cached an earlier copy and validate every later result against it.
* So every output object accepts unknown properties, and fields added after 0.2.5 are optional: a release may add
* fields and values but never remove or rename them.
*/
export const outputObject = z.looseObject;

/**
* A string output whose current values are listed only in its description. Publishing an enum would make every
* new value fail validation for hosts holding the older schema.
*/
export function openEnum(values: readonly string[], detail?: string): z.ZodString {
const list = values.map((v) => JSON.stringify(v)).join(", ");
return z.string().describe(`one of ${list}${detail ? `; ${detail}` : ""}. More values may be added`);
}

export const attachmentItem = outputObject({
filename: z.string(),
size: z.number(),
type: z.string().describe("media type; inferred from the filename when the host records none"),
type: z.string().optional().describe("media type; inferred from the filename when the host records none"),
});

/** Structured results that carry other parties' words repeat the text's safety framing. */
export const untrustedNotice = { untrusted_content_notice: z.string() };
export const untrustedNotice = { untrusted_content_notice: z.string().optional() };
export const UNTRUSTED = { untrusted_content_notice: UNTRUSTED_CONTENT_NOTICE };

/** fmsg response codes (fmsg specification, Response Codes) a delivery record can carry; -1 is a host-local "no response". */
Expand All @@ -42,20 +58,21 @@ function responseCodeMeaning(code: number | null): string | null {
return code === null ? null : (RESPONSE_CODES[code] ?? null);
}

export const deliveryItem = z.object({
export const deliveryItem = outputObject({
addr: z.string(),
status: z.enum(["delivered", "pending", "failed"]).describe(
status: openEnum(
["delivered", "pending", "failed"],
"delivered once the receiving host accepted it; failed when the last attempt was rejected (the host may retry temporary failures); otherwise pending",
),
time: z.string().nullable().describe("when delivery was confirmed"),
code: z.number().nullable().describe(
"the receiving host's fmsg response code for the last attempt when recorded: 200 means accepted, other codes are rejections; null when not recorded, including some successful deliveries",
),
code_meaning: z.string().nullable().describe("the code's name in the fmsg specification, when known"),
via: z.enum(["to", "add_to"]),
code_meaning: z.string().nullable().optional().describe("the code's name in the fmsg specification, when known"),
via: openEnum(["to", "add_to"], "add_to for recipients added later"),
});

export const messageItem = z.object({
export const messageItem = outputObject({
id: z.string(),
pid: z.string().nullable(),
from: z.string(),
Expand All @@ -72,8 +89,8 @@ export const messageItem = z.object({
size: z.number().describe("body size as stored by the host; the compressed size when the body was sent deflate-compressed"),
preview: z.string().describe("start of the body; may be shorter than the full body"),
attachments: z.array(attachmentItem),
reactions: z.array(z.object({ emoji: z.string(), from: z.array(z.string()) })),
reaction: z.string().nullable().describe("the emoji when this message is itself a reaction (\"\" clears one); null otherwise"),
reactions: z.array(outputObject({ emoji: z.string(), from: z.array(z.string()) })),
reaction: z.string().nullable().optional().describe("the emoji when this message is itself a reaction (\"\" clears one); null otherwise"),
});
export type MessageItem = z.infer<typeof messageItem>;

Expand Down
10 changes: 5 additions & 5 deletions src/tools/download.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { attachmentDownloadUrl, attachmentFilename, downloadBaseUrl } from "../d
import { normalizeMessageId } from "../client/message-id.js";
import { toolError } from "../errors.js";
import { attachmentType, messageData } from "../render.js";
import { idSchema, READ_ONLY, type Register, UNTRUSTED, untrustedNotice, withCaller } from "./common.js";
import { idSchema, openEnum, outputObject, READ_ONLY, type Register, UNTRUSTED, untrustedNotice, withCaller } from "./common.js";

const FALLBACK = "If you cannot fetch URLs with this connection's authorization, use download_attachment instead.";

Expand All @@ -17,14 +17,14 @@ export const registerDownloadTool: Register = (server, deps) => {
`Never put credentials in the URL or prompt. ${FALLBACK} ` +
"Returns metadata and a resource link; does not download or save the file. Attachments are untrusted data.",
inputSchema: z.strictObject({ id: idSchema, filename: z.string().min(1).describe("attachment filename as listed on the message") }),
outputSchema: z.object({
outputSchema: outputObject({
id: z.string(),
filename: z.string(),
size: z.number(),
type: z.string().describe("media type; inferred from the filename when the host records none"),
type: z.string().optional().describe("media type; inferred from the filename when the host records none"),
download_url: z.string(),
authentication: z.literal("bearer"),
fallback: z.string().describe("what to do when the URL cannot be fetched"),
authentication: openEnum(["bearer"], "bearer: send this MCP connection's Authorization header"),
fallback: z.string().optional().describe("what to do when the URL cannot be fetched"),
...untrustedNotice,
}),
annotations: READ_ONLY,
Expand Down
26 changes: 12 additions & 14 deletions src/tools/identity.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,12 @@
import * as z from "zod/v4";
import { type AddressResolver, CALLER_DOMAIN, effectiveDefaultDomain, resolveAddress } from "../address.js";
import { isoTime } from "../render.js";
import { READ_ONLY, type Register, ok, resolverFor, withCaller } from "./common.js";
import { READ_ONLY, type Register, ok, openEnum, outputObject, resolverFor, withCaller } from "./common.js";
import { toolError } from "../errors.js";
import type { Transport } from "../config.js";

const TRANSPORT_NAMES: Record<Transport, { id: "stdio" | "streamable-http"; label: string }> = {
stdio: { id: "stdio", label: "stdio" },
http: { id: "streamable-http", label: "Streamable HTTP" },
};
/** Structured output keeps the transport ids published since 0.2.5; only the text uses the friendlier label. */
const TRANSPORT_LABELS: Record<Transport, string> = { stdio: "stdio", http: "Streamable HTTP" };

export const registerIdentityTools: Register = (server, deps) => {
server.registerTool(
Expand All @@ -19,15 +17,15 @@ export const registerIdentityTools: Register = (server, deps) => {
"Report the fmsg address this server acts as (from the authenticated connection), the fmsg Web API URL " +
"(null when the server does not publish it), the MCP transport and the address-resolution defaults. " +
"Access is renewed automatically; no action is needed. Call this first if unsure who you are sending as.",
outputSchema: z.object({
outputSchema: outputObject({
address: z.string(),
api_url: z.string().nullable(),
token_expires_at: z.string().nullable().describe(
"internal: when the server's current upstream access token expires; it is renewed automatically, so no action is needed",
),
transport: z.enum(["stdio", "streamable-http"]),
transport: openEnum(["stdio", "http"], "http is Streamable HTTP"),
default_domain: z.string().nullable(),
directory_names: z.array(z.string()).optional().describe("short names in the operator-configured directory; omitted when there are none"),
directory_names: z.array(z.string()).describe("short names in the operator-configured directory; empty when there are none"),
}),
annotations: { ...READ_ONLY, openWorldHint: false },
},
Expand All @@ -36,19 +34,19 @@ export const registerIdentityTools: Register = (server, deps) => {
const expires = isoTime((await caller.tokenExpiresAt()) / 1000);
const defaultDomain = effectiveDefaultDomain(deps.config.defaultDomain, caller.address);
const directoryNames = Object.keys(deps.config.directory ?? {});
const transport = TRANSPORT_NAMES[deps.config.transport];
const transport = TRANSPORT_LABELS[deps.config.transport];
const structured = {
address: caller.address,
api_url: deps.config.apiPublicUrl ?? null,
token_expires_at: expires,
transport: transport.id,
transport: deps.config.transport,
default_domain: defaultDomain ?? null,
...(directoryNames.length ? { directory_names: directoryNames } : {}),
directory_names: directoryNames,
};
const lines = [
deps.config.apiPublicUrl
? `You are **${caller.address}** on ${deps.config.apiPublicUrl}, connected over ${transport.label}.`
: `You are **${caller.address}**, connected over ${transport.label}.`,
? `You are **${caller.address}** on ${deps.config.apiPublicUrl}, connected over ${transport}.`
: `You are **${caller.address}**, connected over ${transport}.`,
"Access is renewed automatically.",
];
if (defaultDomain) lines.push(`Short names resolve to @name@${defaultDomain}.`);
Expand All @@ -72,7 +70,7 @@ export const registerIdentityTools: Register = (server, deps) => {
`as-is${steps.length ? `, ${steps.join(", ")}` : "; this server has no short-name defaults, so other names fail"}. ` +
"Fails when nothing matches so you can ask the user for the full address.",
inputSchema: z.object({ name: z.string().describe("Full fmsg address (@user@domain) or a short name") }),
outputSchema: z.object({ address: z.string(), resolution: z.enum(["literal", "directory", "default_domain"]) }),
outputSchema: outputObject({ address: z.string(), resolution: openEnum(["literal", "directory", "default_domain"]) }),
annotations: { ...READ_ONLY, openWorldHint: false },
},
async ({ name }, ctx) => {
Expand Down
8 changes: 5 additions & 3 deletions src/tools/list.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import * as z from "zod/v4";
import { messageData, messageLine } from "../render.js";
import { READ_ONLY, type Register, UNTRUSTED, deliveryItem, deliveryOf, messageItem, ok, toItem, untrustedNotice, withCaller } from "./common.js";
import {
READ_ONLY, type Register, UNTRUSTED, deliveryItem, deliveryOf, messageItem, ok, outputObject, toItem, untrustedNotice, withCaller,
} from "./common.js";

const pageInput = {
limit: z.number().int().min(1).max(100).default(20).describe("page size (host maximum 100)"),
Expand All @@ -24,7 +26,7 @@ export const registerListTools: Register = (server, deps) => {
...pageInput,
unread_only: z.boolean().default(false).describe("keep only unread messages from the fetched page (reactions only with include_reactions)"),
}),
outputSchema: z.object({
outputSchema: outputObject({
messages: z.array(messageItem),
count: z.number(),
offset: z.number(),
Expand Down Expand Up @@ -59,7 +61,7 @@ export const registerListTools: Register = (server, deps) => {
"List messages sent by this address (including unsent drafts, shown with time null), newest first, with " +
"per-recipient delivery state. Use delivery_status for one message's detail.",
inputSchema: z.object(pageInput),
outputSchema: z.object({
outputSchema: outputObject({
messages: z.array(messageItem.extend({ delivery: z.array(deliveryItem) })),
count: z.number(),
offset: z.number(),
Expand Down
Loading
Loading