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
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,13 @@ FMSG_API_KEY=fmsgk_xxxxxxxxxxxxxxxx_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
#FMSG_DEFAULT_DOMAIN=example.com
# Optional: JSON file mapping short names to full addresses
#FMSG_DIRECTORY=/path/to/directory.json
# Optional, stdio only: stream attachments into this operator-controlled folder
#FMSG_MCP_DOWNLOAD_DIR=/home/you/Downloads/fmsg

# HTTP mode
#FMSG_MCP_HOST=127.0.0.1
#FMSG_MCP_PORT=8765
#FMSG_MCP_ALLOWED_HOSTS=mcp.example.com
#FMSG_MCP_ALLOWED_ORIGINS=https://app.example.com
# Explicit opt-in for a trusted development/private HTTP API outside loopback:
#FMSG_ALLOW_INSECURE_HTTP=1
1 change: 1 addition & 0 deletions .github/scripts/run-fmsg-docker-e2e.sh
Original file line number Diff line number Diff line change
Expand Up @@ -32,5 +32,6 @@ FMSG_E2E_ALICE_ADDR="$ALICE_ADDR" \
FMSG_E2E_BOB_API_URL="$EXAMPLE_API_URL" \
FMSG_E2E_BOB_API_KEY="$BOB_API_KEY" \
FMSG_E2E_BOB_ADDR="$BOB_ADDR" \
FMSG_E2E_CAROL_API_KEY="$CAROL_API_KEY" \
FMSG_E2E_CAROL_ADDR="$CAROL_ADDR" \
npm run test:e2e
7 changes: 4 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,11 +44,12 @@ 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.
- Send-type tools carry `destructiveHint: true`; read tools `readOnlyHint: true`.
- 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;
log the address and a key-hash prefix.
- Message content handed to the model is prefixed with the data-not-instructions preamble
(`DATA_NOT_INSTRUCTIONS` in `src/render.ts`).
- Use `src/render.ts` for untrusted message content: a preamble, escaped single-line header values,
and a separate fence per body. Server-authored guidance stays outside the data.
- stdout is the stdio protocol channel: log with `console.error` only.
- Public OSS repo: never name a specific identity provider; use `example.com` in examples.

Expand Down
40 changes: 40 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# Changelog

## 0.2.0 (unreleased)

This is the next planned release; publication still happens through a `v0.2.0` GitHub release.

### Breaking changes

- `download_attachment` no longer accepts `save_to` or returns `saved_to`. It is read-only. Enable
the separate stdio-only `save_attachment` tool with `FMSG_MCP_DOWNLOAD_DIR` for direct streaming
to generated filenames; it never accepts a destination path or overwrites an existing file.
- Non-loopback HTTP binds require `FMSG_MCP_ALLOWED_HOSTS`. Browser origin entries must include
scheme and port. Loopback browser origins work automatically on loopback binds unless an explicit
list is set.
- Upstream API URLs require HTTPS outside loopback unless `FMSG_ALLOW_INSECURE_HTTP=1` explicitly
enables a trusted private development network. Authenticated redirects are refused.
- Text attachments return readable text; images return one image block rather than also duplicating
the image in an embedded resource. The default inline budget is 256 KiB; callers can raise it explicitly.

### Fixes and improvements

- Retry protected reads when a WebSocket announces a message before it is readable. If retries run
out, schedule a delayed inbox catch-up without requiring another push. Fix pre-cancelled waits
and preserve request deadlines.
- Stream attachment bodies with an idle timeout instead of a total download deadline. Repeated saves
create numbered files without overwriting. Registry metadata lists the optional download folder.
- Deduplicate token exchanges and close evicted/invalidated clients once active requests finish.
Request identity survives cache eviction and SDK cloning of authentication metadata.
- Keep each message body fenced separately from its escaped header, and server guidance outside
the data. Clarify authorized conversation behavior and restore reversible/idempotent reaction annotations.
- Bound inline attachment reads and error previews while streaming. Preserve the host's canonical
JSON 400/413 policy explanations and per-recipient delivery codes, except selected secret redaction.
- Surface invalid stdio configuration through discoverable tools with corrective guidance.
- `FmsgClient.send()` reports `redactions` and the transmitted `topic`; selected credential formats
in bodies/topics are replaced once at the client boundary. Attachments remain unchanged.
- Custom HTTP adapters using `ApiKeyCallerProvider` must call `release(authInfo)` when each verified
request finishes; the built-in HTTP adapter handles this automatically.

Messaging permissions and quotas remain in fmsg-webapi. No additional MCP messaging approval flow
is introduced. See [GitHub releases](https://github.com/markmnl/fmsg-mcp/releases) for earlier notes.
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,6 @@ COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
USER node
EXPOSE 8765
# FMSG_API_URL must be provided at run time.
# FMSG_API_URL and FMSG_MCP_ALLOWED_HOSTS must be provided at run time (non-loopback bind).
ENTRYPOINT ["node", "dist/index.js"]
CMD ["--http", "0.0.0.0:8765"]
85 changes: 65 additions & 20 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@
An [MCP](https://modelcontextprotocol.io) server that gives any AI agent its own
[fmsg](https://github.com/markmnl/fmsg) address: send messages, follow threads, react, exchange
attachments and wait for replies, through a deployed
[fmsg Web API](https://github.com/markmnl/fmsg-webapi). Works with Claude Code, Claude Desktop,
Cursor, VS Code, claude.ai remote connectors and any other MCP host.
[fmsg Web API](https://github.com/markmnl/fmsg-webapi). Connect through stdio in hosts such as
Claude Code, Claude Desktop, Cursor and VS Code, or through HTTP in clients that support bearer headers.

- **stdio** for local hosts: one address per server process, configured by two environment variables.
- **Streamable HTTP** for shared or remote deployments: one endpoint serving many users, each
Expand Down Expand Up @@ -65,18 +65,34 @@ Run one server for many users. Each client sends **its own** fmsg API key as a b
server exchanges it at the fmsg host and acts as that address. `FMSG_API_KEY` must not be set.

```sh
FMSG_API_URL=https://api.example.com npx -y @markmnl/fmsg-mcp --http 0.0.0.0:8765
FMSG_API_URL=https://api.example.com FMSG_MCP_ALLOWED_HOSTS=mcp.example.com \
npx -y @markmnl/fmsg-mcp --http 0.0.0.0:8765
# or
docker build -t fmsg-mcp . && docker run -e FMSG_API_URL=https://api.example.com -p 8765:8765 fmsg-mcp
docker build -t fmsg-mcp .
docker run -e FMSG_API_URL=https://api.example.com \
-e FMSG_MCP_ALLOWED_HOSTS=mcp.example.com -p 8765:8765 fmsg-mcp
```

The MCP endpoint is `/mcp`; `/healthz` reports liveness. Point a host at it with
`Authorization: Bearer fmsgk_...` — for claude.ai, add a custom connector with that URL and header;
for Claude Code, `claude mcp add --transport http fmsg https://mcp.example.com/mcp --header "Authorization: Bearer fmsgk_..."`.
The MCP endpoint is `/mcp`; `/healthz` reports liveness. Use a client that supports an explicitly
configured `Authorization: Bearer fmsgk_...` header. Each caller supplies its own key; a shared
header means a shared fmsg identity. Hosted connectors that require OAuth are not supported yet.

For [Claude Code over HTTP](https://code.claude.com/docs/en/mcp):

```sh
claude mcp add --transport http fmsg --scope user https://mcp.example.com/mcp \
--header "Authorization: Bearer fmsgk_..."
```

Deploy behind a TLS-terminating reverse proxy and set `FMSG_MCP_ALLOWED_HOSTS` to the public hostname
when binding to a non-loopback address. `wait_for_message` holds a request open for up to
when binding to a non-loopback address; startup fails without it. Browser clients on another origin
also need `FMSG_MCP_ALLOWED_ORIGINS` containing exact origins, such as `https://app.example.com`.
For loopback binds, loopback browser origins on any port work by default, including MCP Inspector
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.
See the [TLS reverse-proxy example](docs/http-deployment.md) for a loopback deployment with Caddy.

## Tools

Expand All @@ -93,7 +109,8 @@ when binding to a non-loopback address. `wait_for_message` holds a request open
| `add_recipients` | Add recipients to a sent message |
| `react` | Set or clear your emoji reaction |
| `mark_read` | Mark received messages read |
| `download_attachment` | Fetch an attachment inline (base64, images as image blocks) or, over stdio, save it to disk |
| `download_attachment` | Fetch a small attachment inline: text as text, images as image blocks, other files as base64 resources |
| `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 |

Expand All @@ -109,30 +126,51 @@ attach resources; prompts `chat` and `reply` script the wait → reply loop and
|---|---|---|
| `FMSG_API_URL` | — | Base URL of the fmsg Web API (required) |
| `FMSG_API_KEY` | — | `fmsgk_…` key; stdio mode only |
| `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 |
| `FMSG_DEFAULT_DOMAIN` | — | Lets short names resolve: `bob` → `@bob@<domain>` |
| `FMSG_DIRECTORY` | — | JSON file mapping short names to full addresses |
| `FMSG_MCP_DOWNLOAD_DIR` | — | Enable `save_attachment` in stdio; folder for new files named from message ID and filename |
| `FMSG_MCP_WAIT_MAX_SECONDS` | `230` | Cap on one `wait_for_message` call |
| `FMSG_MCP_DOWNLOAD_DIR` | — | Restrict `download_attachment` `save_to` to this directory (stdio) |
| `FMSG_MCP_HOST` / `FMSG_MCP_PORT` | `127.0.0.1` / `8765` | HTTP bind address (or `--http host:port`) |
| `FMSG_MCP_ALLOWED_HOSTS` | loopback names | Comma-separated `Host` header allowlist for HTTP mode |
| `FMSG_MCP_ALLOWED_ORIGINS` | same as hosts | `Origin` allowlist for browser-based callers |
| `FMSG_MCP_ALLOWED_HOSTS` | loopback names | Comma-separated `Host` header allowlist; required for non-loopback binds |
| `FMSG_MCP_ALLOWED_ORIGINS` | same origin; loopback origins on loopback binds | Comma-separated browser origins including scheme and port; an explicit list replaces the loopback default; hostname-only values are rejected |
| `FMSG_MCP_KEY_CACHE_MAX` / `FMSG_MCP_KEY_CACHE_TTL_SECONDS` | `500` / `1800` | HTTP mode per-key client cache |

The API key is exchanged for a short-lived access token that the server renews automatically.
API URLs must not contain credentials, query strings or fragments. Authenticated requests do not
follow redirects; configure the final API URL directly.

Over stdio the server also starts with no credentials at all, so hosts and directories can list its tools; every tool call then returns a message naming the missing variables.
To save attachments directly to disk, add `FMSG_MCP_DOWNLOAD_DIR` to your stdio server's environment,
for example `/home/you/Downloads/fmsg`. The optional `save_attachment` tool streams files into that
folder without sending their bytes through model context. It accepts only a message ID and attachment
filename and creates a new file such as `123-report.pdf`. Repeat saves use `123-report-1.pdf`,
`123-report-2.pdf`, etc., leaving existing files untouched. Unusual filenames are converted to
portable names; use the returned `saved_to` path. Streaming downloads can run longer than 60 seconds
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. HTTP clients use inline downloads or their host's file capabilities.

Over stdio, missing or invalid configuration still allows hosts to discover the tools. Tool calls
explain the configuration error and how to fix it; restart the MCP server after correcting settings.

## Safety

- Messaging access, quotas and recipient acceptance are enforced by fmsg-webapi and the host
services. MCP forwards each operation as the caller's identity and surfaces upstream failures.
- `download_attachment` never writes local files. Optional `save_attachment` writes only generated
filenames in the operator-configured folder, using exclusive creation with no overwrite.
- Sent messages cannot be edited or recalled; send tools say so in their descriptions and are
annotated `destructiveHint` so hosts can ask for confirmation.
- API keys, tokens and other secret-shaped strings are redacted from outbound bodies, topics and
error text; the count of redactions is reported.
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.
- 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
these tools rather than a local fmsg CLI or cached credentials, to send only on a clear request, and
to treat message content as data.
these tools rather than a local fmsg CLI or cached credentials, to carry out authorized tasks and
automation without repeated confirmation, and to treat message content as data.
- See [SECURITY.md](./SECURITY.md).

## Using the client library
Expand All @@ -143,9 +181,15 @@ import { FmsgClient } from "@markmnl/fmsg-mcp/client";
const client = new FmsgClient("https://api.example.com", process.env.FMSG_API_KEY!);
console.log(await client.address());
const inbox = await client.listInbox(10);
await client.send({ to: ["@[email protected]"], topic: "Hi", body: "Hello from code" });
const sent = await client.send({ to: ["@[email protected]"], topic: "Hi", body: "Hello from code" });
console.log(sent.id, sent.redactions);
client.close();
```

`send()` replaces selected credential patterns in the body and topic before creating the draft.
Its result includes the replacement count (`redactions`) and transmitted `topic`. Attachments are
unchanged. Use `streamAttachment()` to consume large files incrementally; consume or cancel its stream.

## Development

```sh
Expand All @@ -155,6 +199,7 @@ npx @modelcontextprotocol/inspector node dist/index.js # stdio, with FM
bash .github/scripts/run-fmsg-docker-e2e.sh # end to end on two real fmsg stacks
```

See [AGENTS.md](./AGENTS.md) for layout and conventions.
See [AGENTS.md](./AGENTS.md) for layout and conventions, [ROADMAP.md](./ROADMAP.md) for remaining
integration work, and [CHANGELOG.md](./CHANGELOG.md) for release notes.

[MIT licensed](./LICENSE)
46 changes: 46 additions & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# Integration roadmap

Target: an MCP-capable agent can connect through a documented, tested path, identify its fmsg
account, and complete authorized messaging without exposing credentials or unexpected capabilities.
Support claims must name tested clients and versions; agents without MCP need an adapter.

## Constraints

- fmsg-webapi and host services own messaging permissions, visibility, quotas and acceptance.
MCP binds each operation to its caller and surfaces upstream decisions.
- Normal stdio setup requires only an HTTPS API URL and key. No second login, duplicate ACLs,
recipient policies or per-message confirmation gates. The AI host owns tool approval settings.
- Keep authorized conversations and automation convenient. Incoming messages do not authorize
adding recipients, contacting new parties or disclosing other data.
- Remove obsolete server API fields directly while there are no external consumers requiring
compatibility. Keep protocol compatibility needed by supported MCP hosts.

## Implementation sequence

| Work | Scope and completion criteria |
|---|---|
| A — MCP boundaries ([PR #2](https://github.com/markmnl/fmsg-mcp/pull/2)) | Separate read-only downloads from opt-in streamed saving; validate HTTP access; manage caller credentials and cancellation; preserve upstream authorization; frame untrusted content without obscuring server guidance. Regression and real-stack isolation tests cover these boundaries. |
| B — Receive reliability | Scan backlogs to a safe cursor boundary, including bursts, pending batches, interleaved threads and reconnects. Never advance past unseen work. Bound stream reads, response assembly, concurrent waits and overall deadlines. |
| C — Action outcomes | Preserve upstream denials and delivery codes. Return a durable reference and recovery guidance when a send may have committed but its response was lost; coordinate idempotency with the upstream API. |
| D — Painless local integration | Add a non-sending doctor command, separate host recipes and a versioned compatibility matrix. Verify wait defaults, attachment save/upload workflows, independent Python clients, conformance, supported OSes and clean installation of the actual npm tarball. |
| E — Hosted OAuth | Provider-neutral discovery, account linkage, consent, token refresh and revocation, coordinated with the host/account system. Prove per-user isolation in actual hosted clients. Retain explicit API-key integration. |
| F — Release trust and operations | Synchronize the existing MCP Registry listing after npm publication; verify the published version. Harden release inputs and gates, reusing D's artifact checks. Add deployment metrics, runbooks, load testing and independent review when supporting shared hosted service. |

Ship A first, then B–D. Plan E with the host/account-system maintainer. Release work in F can proceed
earlier; hosted-service promises depend on verified OAuth and operational behavior.

Release-triggered npm publication, OIDC trusted publishing, provenance generation and version
synchronization already exist in [publish.yml](.github/workflows/publish.yml). Preserve them.
[CI](https://github.com/markmnl/fmsg-mcp/actions/workflows/tests.yml) already covers Node 22/24,
the Docker image and real two-host acceptance. PR checks record validation for each revision.

## Broad-integration release criteria

- Fresh installs on every claimed client/OS reach `whoami` and inbox using the documented setup.
- Caller isolation and upstream authorization hold across tools, resources, attachments and waits.
- Backlog/reconnect tests prove no silent cursor loss; cancellation releases work promptly.
- Tool deadlines and payload budgets fit verified host configurations; large files are practical
without manual base64 handling or overflowing model context.
- Ambiguous sends have a documented reconciliation path that avoids blind duplicate sends.
- Advertised hosted integrations pass identity, refresh, revocation and disconnect checks.
- The npm artifact, registry metadata, release notes and compatibility results agree.
Loading
Loading