@markmnl/fmsg-mcp 0.1.0
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/LICENSE +21 -0
- package/README.md +155 -0
- package/dist/address.d.ts +18 -0
- package/dist/address.js +50 -0
- package/dist/auth.d.ts +21 -0
- package/dist/auth.js +84 -0
- package/dist/client/client.d.ts +83 -0
- package/dist/client/client.js +310 -0
- package/dist/client/index.d.ts +6 -0
- package/dist/client/index.js +5 -0
- package/dist/client/message-id.d.ts +19 -0
- package/dist/client/message-id.js +70 -0
- package/dist/client/redact.d.ts +8 -0
- package/dist/client/redact.js +25 -0
- package/dist/client/types.d.ts +126 -0
- package/dist/client/types.js +2 -0
- package/dist/client/ws.d.ts +6 -0
- package/dist/client/ws.js +25 -0
- package/dist/config.d.ts +31 -0
- package/dist/config.js +74 -0
- package/dist/context.d.ts +20 -0
- package/dist/context.js +17 -0
- package/dist/errors.d.ts +5 -0
- package/dist/errors.js +39 -0
- package/dist/http.d.ts +14 -0
- package/dist/http.js +112 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +137 -0
- package/dist/prompts.d.ts +2 -0
- package/dist/prompts.js +44 -0
- package/dist/public.d.ts +10 -0
- package/dist/public.js +10 -0
- package/dist/render.d.ts +27 -0
- package/dist/render.js +109 -0
- package/dist/resources.d.ts +3 -0
- package/dist/resources.js +37 -0
- package/dist/server.d.ts +9 -0
- package/dist/server.js +26 -0
- package/dist/thread.d.ts +42 -0
- package/dist/thread.js +176 -0
- package/dist/tools/common.d.ts +62 -0
- package/dist/tools/common.js +88 -0
- package/dist/tools/identity.d.ts +2 -0
- package/dist/tools/identity.js +58 -0
- package/dist/tools/list.d.ts +2 -0
- package/dist/tools/list.js +72 -0
- package/dist/tools/read.d.ts +2 -0
- package/dist/tools/read.js +202 -0
- package/dist/tools/send.d.ts +2 -0
- package/dist/tools/send.js +170 -0
- package/dist/tools/wait.d.ts +2 -0
- package/dist/tools/wait.js +96 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.js +5 -0
- package/dist/wait.d.ts +41 -0
- package/dist/wait.js +210 -0
- package/package.json +74 -0
- package/server.json +24 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Mark Mennell
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# fmsg-mcp
|
|
2
|
+
|
|
3
|
+
[](https://github.com/markmnl/fmsg-mcp/actions/workflows/tests.yml)
|
|
4
|
+
[](https://www.npmjs.com/package/@markmnl/fmsg-mcp)
|
|
5
|
+
[](./LICENSE)
|
|
6
|
+
|
|
7
|
+
An [MCP](https://modelcontextprotocol.io) server that gives any AI agent its own
|
|
8
|
+
[fmsg](https://github.com/markmnl/fmsg) address: send messages, follow threads, react, exchange
|
|
9
|
+
attachments and wait for replies, through a deployed
|
|
10
|
+
[fmsg Web API](https://github.com/markmnl/fmsg-webapi). Works with Claude Code, Claude Desktop,
|
|
11
|
+
Cursor, VS Code, claude.ai remote connectors and any other MCP host.
|
|
12
|
+
|
|
13
|
+
- **stdio** for local hosts: one address per server process, configured by two environment variables.
|
|
14
|
+
- **Streamable HTTP** for shared or remote deployments: one endpoint serving many users, each
|
|
15
|
+
authenticated by their own fmsg API key.
|
|
16
|
+
- The fmsg Web API client is exported for reuse: `import { FmsgClient } from "@markmnl/fmsg-mcp/client"`.
|
|
17
|
+
|
|
18
|
+
## 1. Get an fmsg address and API key
|
|
19
|
+
|
|
20
|
+
You send as an fmsg address, authenticated by an API key (`fmsgk_…`) issued by your fmsg host:
|
|
21
|
+
|
|
22
|
+
- **No host yet?** Create an account at a public fmsg host such as [fmsg.io](https://fmsg.io) and
|
|
23
|
+
add an agent (sub-account) to get an API URL and key.
|
|
24
|
+
- **Self-hosting?** Run the stack with [fmsg-docker](https://github.com/markmnl/fmsg-docker) and issue
|
|
25
|
+
a key with `fmsg-webapi api-key create`.
|
|
26
|
+
|
|
27
|
+
## 2. Install
|
|
28
|
+
|
|
29
|
+
Requires Node.js 22 or later.
|
|
30
|
+
|
|
31
|
+
### Claude Code
|
|
32
|
+
|
|
33
|
+
```sh
|
|
34
|
+
claude mcp add fmsg --scope user \
|
|
35
|
+
--env FMSG_API_URL=https://api.example.com \
|
|
36
|
+
--env FMSG_API_KEY=fmsgk_... \
|
|
37
|
+
-- npx -y @markmnl/fmsg-mcp
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Then in any session: *"Send @bob@example.com a note about the release"*, *"What's in my fmsg inbox?"*,
|
|
41
|
+
*"Wait for Bob's reply and answer it"*. `/fmsg:chat` and `/fmsg:reply` are available as prompts.
|
|
42
|
+
|
|
43
|
+
### Claude Desktop, Cursor, VS Code and other stdio hosts
|
|
44
|
+
|
|
45
|
+
Add a server entry with the same command; only the config file differs:
|
|
46
|
+
|
|
47
|
+
```json
|
|
48
|
+
{
|
|
49
|
+
"mcpServers": {
|
|
50
|
+
"fmsg": {
|
|
51
|
+
"command": "npx",
|
|
52
|
+
"args": ["-y", "@markmnl/fmsg-mcp"],
|
|
53
|
+
"env": { "FMSG_API_URL": "https://api.example.com", "FMSG_API_KEY": "fmsgk_..." }
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
(Claude Desktop: `claude_desktop_config.json`; Cursor: `.cursor/mcp.json`; VS Code: `.vscode/mcp.json`
|
|
60
|
+
under `"servers"` with `"type": "stdio"`.)
|
|
61
|
+
|
|
62
|
+
### Remote (Streamable HTTP) mode
|
|
63
|
+
|
|
64
|
+
Run one server for many users. Each client sends **its own** fmsg API key as a bearer token; the
|
|
65
|
+
server exchanges it at the fmsg host and acts as that address. `FMSG_API_KEY` must not be set.
|
|
66
|
+
|
|
67
|
+
```sh
|
|
68
|
+
FMSG_API_URL=https://api.example.com npx -y @markmnl/fmsg-mcp --http 0.0.0.0:8765
|
|
69
|
+
# or
|
|
70
|
+
docker build -t fmsg-mcp . && docker run -e FMSG_API_URL=https://api.example.com -p 8765:8765 fmsg-mcp
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
The MCP endpoint is `/mcp`; `/healthz` reports liveness. Point a host at it with
|
|
74
|
+
`Authorization: Bearer fmsgk_...` — for claude.ai, add a custom connector with that URL and header;
|
|
75
|
+
for Claude Code, `claude mcp add --transport http fmsg https://mcp.example.com/mcp --header "Authorization: Bearer fmsgk_..."`.
|
|
76
|
+
|
|
77
|
+
Deploy behind a TLS-terminating reverse proxy and set `FMSG_MCP_ALLOWED_HOSTS` to the public hostname
|
|
78
|
+
when binding to a non-loopback address. `wait_for_message` holds a request open for up to
|
|
79
|
+
`FMSG_MCP_WAIT_MAX_SECONDS` (230), so give the proxy an idle timeout of at least 240 s.
|
|
80
|
+
|
|
81
|
+
## Tools
|
|
82
|
+
|
|
83
|
+
| Tool | What it does |
|
|
84
|
+
|---|---|
|
|
85
|
+
| `whoami` | The address this server acts as, the API URL and token expiry |
|
|
86
|
+
| `resolve_address` | Turn a short name into `@user@domain` (directory, then default domain) |
|
|
87
|
+
| `list_messages` | Inbox, newest first, with previews; reactions hidden; optional unread filter |
|
|
88
|
+
| `list_sent` | Sent messages with per-recipient delivery state |
|
|
89
|
+
| `get_message` | One message with headers, full text body, attachments and reactions |
|
|
90
|
+
| `get_thread` | The lineage from the thread root to a message, with gaps for messages you cannot see |
|
|
91
|
+
| `send_message` | Start a new thread; sends immediately (fmsg messages are immutable) |
|
|
92
|
+
| `reply` | Reply into a thread; reply-all by default, refuses terminal and no-reply parents |
|
|
93
|
+
| `add_recipients` | Add recipients to a sent message |
|
|
94
|
+
| `react` | Set or clear your emoji reaction |
|
|
95
|
+
| `mark_read` | Mark received messages read |
|
|
96
|
+
| `download_attachment` | Fetch an attachment inline (base64, images as image blocks) or, over stdio, save it to disk |
|
|
97
|
+
| `delivery_status` | Per-recipient delivery times and host response codes |
|
|
98
|
+
| `wait_for_message` | Block until the next inbound message (WebSocket push), batched per thread, with thread context |
|
|
99
|
+
|
|
100
|
+
Every tool returns readable Markdown plus `structuredContent`. Ids are decimal strings. Message
|
|
101
|
+
bodies are labelled as data from other parties, not instructions.
|
|
102
|
+
|
|
103
|
+
Resources `fmsg://message/{id}` and `fmsg://thread/{id}` expose the same content to hosts that
|
|
104
|
+
attach resources; prompts `chat` and `reply` script the wait → reply loop and a guided reply.
|
|
105
|
+
|
|
106
|
+
## Configuration
|
|
107
|
+
|
|
108
|
+
| Variable | Default | Purpose |
|
|
109
|
+
|---|---|---|
|
|
110
|
+
| `FMSG_API_URL` | — | Base URL of the fmsg Web API (required) |
|
|
111
|
+
| `FMSG_API_KEY` | — | `fmsgk_…` key; stdio mode only |
|
|
112
|
+
| `FMSG_DEFAULT_DOMAIN` | — | Lets short names resolve: `bob` → `@bob@<domain>` |
|
|
113
|
+
| `FMSG_DIRECTORY` | — | JSON file mapping short names to full addresses |
|
|
114
|
+
| `FMSG_MCP_WAIT_MAX_SECONDS` | `230` | Cap on one `wait_for_message` call |
|
|
115
|
+
| `FMSG_MCP_DOWNLOAD_DIR` | — | Restrict `download_attachment` `save_to` to this directory (stdio) |
|
|
116
|
+
| `FMSG_MCP_HOST` / `FMSG_MCP_PORT` | `127.0.0.1` / `8765` | HTTP bind address (or `--http host:port`) |
|
|
117
|
+
| `FMSG_MCP_ALLOWED_HOSTS` | loopback names | Comma-separated `Host` header allowlist for HTTP mode |
|
|
118
|
+
| `FMSG_MCP_ALLOWED_ORIGINS` | same as hosts | `Origin` allowlist for browser-based callers |
|
|
119
|
+
| `FMSG_MCP_KEY_CACHE_MAX` / `FMSG_MCP_KEY_CACHE_TTL_SECONDS` | `500` / `1800` | HTTP mode per-key client cache |
|
|
120
|
+
|
|
121
|
+
The API key is exchanged for a short-lived access token that the server renews automatically.
|
|
122
|
+
|
|
123
|
+
## Safety
|
|
124
|
+
|
|
125
|
+
- Sent messages cannot be edited or recalled; send tools say so in their descriptions and are
|
|
126
|
+
annotated `destructiveHint` so hosts can ask for confirmation.
|
|
127
|
+
- API keys, tokens and other secret-shaped strings are redacted from outbound bodies, topics and
|
|
128
|
+
error text; the count of redactions is reported.
|
|
129
|
+
- Nothing about message size or acceptance is assumed: the fmsg host's own responses and delivery
|
|
130
|
+
codes are surfaced verbatim.
|
|
131
|
+
- See [SECURITY.md](./SECURITY.md).
|
|
132
|
+
|
|
133
|
+
## Using the client library
|
|
134
|
+
|
|
135
|
+
```ts
|
|
136
|
+
import { FmsgClient } from "@markmnl/fmsg-mcp/client";
|
|
137
|
+
|
|
138
|
+
const client = new FmsgClient("https://api.example.com", process.env.FMSG_API_KEY!);
|
|
139
|
+
console.log(await client.address());
|
|
140
|
+
const inbox = await client.listInbox(10);
|
|
141
|
+
await client.send({ to: ["@bob@example.com"], topic: "Hi", body: "Hello from code" });
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
## Development
|
|
145
|
+
|
|
146
|
+
```sh
|
|
147
|
+
npm ci
|
|
148
|
+
npm run typecheck && npm run build && npm test
|
|
149
|
+
npx @modelcontextprotocol/inspector node dist/index.js # stdio, with FMSG_API_URL/FMSG_API_KEY set
|
|
150
|
+
bash .github/scripts/run-fmsg-docker-e2e.sh # end to end on two real fmsg stacks
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
See [AGENTS.md](./AGENTS.md) for layout and conventions.
|
|
154
|
+
|
|
155
|
+
[MIT licensed](./LICENSE)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** Normalise an fmsg address to `@user@domain` (lower-cased). Returns undefined when malformed. */
|
|
2
|
+
export declare function normalizeFmsgAddress(value: string): string | undefined;
|
|
3
|
+
export declare function isFmsgAddress(value: string): boolean;
|
|
4
|
+
export type Resolution = "literal" | "directory" | "default_domain";
|
|
5
|
+
export type AddressResolver = {
|
|
6
|
+
defaultDomain?: string;
|
|
7
|
+
directory?: Record<string, string>;
|
|
8
|
+
};
|
|
9
|
+
export type ResolvedAddress = {
|
|
10
|
+
address: string;
|
|
11
|
+
resolution: Resolution;
|
|
12
|
+
};
|
|
13
|
+
/**
|
|
14
|
+
* Resolve a full address or short name: literal `@user@domain` first, then a
|
|
15
|
+
* configured directory entry, then `@name@<default domain>`.
|
|
16
|
+
*/
|
|
17
|
+
export declare function resolveAddress(name: string, resolver?: AddressResolver): ResolvedAddress;
|
|
18
|
+
export declare function resolveAddresses(names: string[], resolver?: AddressResolver): string[];
|
package/dist/address.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
const ADDRESS = /^@([^@\s/]+)@([^@\s/]+)$/u;
|
|
2
|
+
/** Normalise an fmsg address to `@user@domain` (lower-cased). Returns undefined when malformed. */
|
|
3
|
+
export function normalizeFmsgAddress(value) {
|
|
4
|
+
const trimmed = value.trim();
|
|
5
|
+
const match = ADDRESS.exec(trimmed);
|
|
6
|
+
if (!match)
|
|
7
|
+
return undefined;
|
|
8
|
+
return `@${match[1].toLowerCase()}@${match[2].toLowerCase()}`;
|
|
9
|
+
}
|
|
10
|
+
export function isFmsgAddress(value) {
|
|
11
|
+
return normalizeFmsgAddress(value) !== undefined;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Resolve a full address or short name: literal `@user@domain` first, then a
|
|
15
|
+
* configured directory entry, then `@name@<default domain>`.
|
|
16
|
+
*/
|
|
17
|
+
export function resolveAddress(name, resolver = {}) {
|
|
18
|
+
const trimmed = name.trim();
|
|
19
|
+
const literal = normalizeFmsgAddress(trimmed);
|
|
20
|
+
if (literal)
|
|
21
|
+
return { address: literal, resolution: "literal" };
|
|
22
|
+
if (trimmed === "" || /[@\s/]/u.test(trimmed)) {
|
|
23
|
+
throw new Error(`"${name}" is not an fmsg address (@user@domain) or a resolvable short name`);
|
|
24
|
+
}
|
|
25
|
+
const key = trimmed.toLowerCase();
|
|
26
|
+
const directory = resolver.directory ?? {};
|
|
27
|
+
for (const [entry, target] of Object.entries(directory)) {
|
|
28
|
+
if (entry.toLowerCase() === key) {
|
|
29
|
+
const address = normalizeFmsgAddress(target);
|
|
30
|
+
if (!address)
|
|
31
|
+
throw new Error(`directory entry "${entry}" maps to an invalid address "${target}"`);
|
|
32
|
+
return { address, resolution: "directory" };
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
if (resolver.defaultDomain) {
|
|
36
|
+
const address = normalizeFmsgAddress(`@${key}@${resolver.defaultDomain}`);
|
|
37
|
+
if (address)
|
|
38
|
+
return { address, resolution: "default_domain" };
|
|
39
|
+
}
|
|
40
|
+
throw new Error(`"${name}" is not a full fmsg address and no directory entry or default domain resolves it; ask for the full @user@domain address`);
|
|
41
|
+
}
|
|
42
|
+
export function resolveAddresses(names, resolver = {}) {
|
|
43
|
+
const out = [];
|
|
44
|
+
for (const name of names) {
|
|
45
|
+
const { address } = resolveAddress(name, resolver);
|
|
46
|
+
if (!out.includes(address))
|
|
47
|
+
out.push(address);
|
|
48
|
+
}
|
|
49
|
+
return out;
|
|
50
|
+
}
|
package/dist/auth.d.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { type AuthInfo, type OAuthTokenVerifier } from "@modelcontextprotocol/server";
|
|
2
|
+
import type { Config } from "./config.js";
|
|
3
|
+
import type { Caller, CallerProvider } from "./context.js";
|
|
4
|
+
export declare const FMSG_SCOPE = "fmsg";
|
|
5
|
+
/**
|
|
6
|
+
* HTTP mode: each request carries an fmsg API key as its bearer token. The key
|
|
7
|
+
* is exchanged once at the fmsg host, and the resulting client (which renews
|
|
8
|
+
* its own JWT) is cached by the key's hash. Raw keys are never stored or logged.
|
|
9
|
+
*/
|
|
10
|
+
export declare class ApiKeyCallerProvider implements CallerProvider, OAuthTokenVerifier {
|
|
11
|
+
private readonly config;
|
|
12
|
+
private readonly log;
|
|
13
|
+
private readonly entries;
|
|
14
|
+
constructor(config: Config, log?: (line: string) => void);
|
|
15
|
+
static cacheKey(apiKey: string): string;
|
|
16
|
+
private evict;
|
|
17
|
+
/** Bearer verifier for the MCP gate: exchange the key, return the caller's identity. */
|
|
18
|
+
verifyAccessToken(token: string): Promise<AuthInfo>;
|
|
19
|
+
forRequest(authInfo: AuthInfo | undefined): Promise<Caller>;
|
|
20
|
+
get size(): number;
|
|
21
|
+
}
|
package/dist/auth.js
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { OAuthError, OAuthErrorCode } from "@modelcontextprotocol/server";
|
|
3
|
+
import { FmsgClient, FmsgHttpError } from "./client/client.js";
|
|
4
|
+
export const FMSG_SCOPE = "fmsg";
|
|
5
|
+
/**
|
|
6
|
+
* HTTP mode: each request carries an fmsg API key as its bearer token. The key
|
|
7
|
+
* is exchanged once at the fmsg host, and the resulting client (which renews
|
|
8
|
+
* its own JWT) is cached by the key's hash. Raw keys are never stored or logged.
|
|
9
|
+
*/
|
|
10
|
+
export class ApiKeyCallerProvider {
|
|
11
|
+
config;
|
|
12
|
+
log;
|
|
13
|
+
entries = new Map();
|
|
14
|
+
constructor(config, log = () => undefined) {
|
|
15
|
+
this.config = config;
|
|
16
|
+
this.log = log;
|
|
17
|
+
}
|
|
18
|
+
static cacheKey(apiKey) {
|
|
19
|
+
return createHash("sha256").update(apiKey).digest("hex");
|
|
20
|
+
}
|
|
21
|
+
evict() {
|
|
22
|
+
const now = Date.now();
|
|
23
|
+
for (const [key, entry] of this.entries) {
|
|
24
|
+
if (now - entry.lastUsed > this.config.http.keyCacheTtlMs)
|
|
25
|
+
this.entries.delete(key);
|
|
26
|
+
}
|
|
27
|
+
while (this.entries.size > this.config.http.keyCacheMax) {
|
|
28
|
+
const oldest = [...this.entries.entries()].sort((a, b) => a[1].lastUsed - b[1].lastUsed)[0];
|
|
29
|
+
if (!oldest)
|
|
30
|
+
break;
|
|
31
|
+
this.entries.delete(oldest[0]);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/** Bearer verifier for the MCP gate: exchange the key, return the caller's identity. */
|
|
35
|
+
async verifyAccessToken(token) {
|
|
36
|
+
if (!token.startsWith("fmsgk_")) {
|
|
37
|
+
throw new OAuthError(OAuthErrorCode.InvalidToken, "bearer token must be an fmsg API key (fmsgk_...)");
|
|
38
|
+
}
|
|
39
|
+
const key = ApiKeyCallerProvider.cacheKey(token);
|
|
40
|
+
let entry = this.entries.get(key);
|
|
41
|
+
if (!entry) {
|
|
42
|
+
const client = new FmsgClient(this.config.apiUrl, token);
|
|
43
|
+
let address;
|
|
44
|
+
try {
|
|
45
|
+
address = await client.address();
|
|
46
|
+
}
|
|
47
|
+
catch (error) {
|
|
48
|
+
if (error instanceof FmsgHttpError && (error.status === 401 || error.status === 403 || error.status === 400)) {
|
|
49
|
+
this.log(`rejected api key ${key.slice(0, 8)}…: ${error.status} ${error.message}`);
|
|
50
|
+
throw new OAuthError(OAuthErrorCode.InvalidToken, `fmsg host rejected the API key: ${error.message}`);
|
|
51
|
+
}
|
|
52
|
+
this.log(`token exchange failed for ${key.slice(0, 8)}…: ${error instanceof Error ? error.message : String(error)}`);
|
|
53
|
+
throw new OAuthError(OAuthErrorCode.ServerError, "fmsg host unavailable for token exchange");
|
|
54
|
+
}
|
|
55
|
+
entry = {
|
|
56
|
+
caller: { client, address, tokenExpiresAt: async () => (await client.getToken()).expiresAtMs },
|
|
57
|
+
lastUsed: Date.now(),
|
|
58
|
+
};
|
|
59
|
+
this.entries.set(key, entry);
|
|
60
|
+
this.evict();
|
|
61
|
+
this.log(`authenticated ${address} (key ${key.slice(0, 8)}…)`);
|
|
62
|
+
}
|
|
63
|
+
entry.lastUsed = Date.now();
|
|
64
|
+
const expiresAtMs = await entry.caller.tokenExpiresAt();
|
|
65
|
+
return {
|
|
66
|
+
token: key,
|
|
67
|
+
clientId: entry.caller.address,
|
|
68
|
+
scopes: [FMSG_SCOPE],
|
|
69
|
+
expiresAt: Math.floor(expiresAtMs / 1000),
|
|
70
|
+
extra: { cacheKey: key },
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
async forRequest(authInfo) {
|
|
74
|
+
const key = typeof authInfo?.extra?.cacheKey === "string" ? authInfo.extra.cacheKey : authInfo?.token;
|
|
75
|
+
const entry = key ? this.entries.get(key) : undefined;
|
|
76
|
+
if (!entry)
|
|
77
|
+
throw new Error("not authenticated: send your fmsg API key as `Authorization: Bearer fmsgk_...`");
|
|
78
|
+
entry.lastUsed = Date.now();
|
|
79
|
+
return entry.caller;
|
|
80
|
+
}
|
|
81
|
+
get size() {
|
|
82
|
+
return this.entries.size;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import type { AccessToken, Attachment, FmsgMessage, ReactResult, SendInput, SendResult, Thread } from "./types.js";
|
|
2
|
+
export type FetchLike = typeof fetch;
|
|
3
|
+
export type FmsgClientOptions = {
|
|
4
|
+
fetch?: FetchLike;
|
|
5
|
+
/** Refresh the access token this long before it expires (default 5 minutes). */
|
|
6
|
+
refreshMarginMs?: number;
|
|
7
|
+
/** Per-request timeout (default 60 s). */
|
|
8
|
+
timeoutMs?: number;
|
|
9
|
+
};
|
|
10
|
+
/** An HTTP error from the fmsg Web API, with the status and the host's own error text. */
|
|
11
|
+
export declare class FmsgHttpError extends Error {
|
|
12
|
+
readonly status: number;
|
|
13
|
+
readonly method: string;
|
|
14
|
+
readonly path: string;
|
|
15
|
+
/** Machine-readable `code` from the body, when the host sends one (thread routes). */
|
|
16
|
+
readonly code?: string | undefined;
|
|
17
|
+
constructor(message: string, status: number, method: string, path: string,
|
|
18
|
+
/** Machine-readable `code` from the body, when the host sends one (thread routes). */
|
|
19
|
+
code?: string | undefined);
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Client for the fmsg Web API (FMSG-003). Exchanges an `fmsgk_` API key for a
|
|
23
|
+
* short-lived JWT, refreshes it ahead of expiry, and retries once on 401.
|
|
24
|
+
*/
|
|
25
|
+
export declare class FmsgClient {
|
|
26
|
+
private readonly apiKey;
|
|
27
|
+
private readonly options;
|
|
28
|
+
readonly apiUrl: string;
|
|
29
|
+
private token?;
|
|
30
|
+
private tokenPromise?;
|
|
31
|
+
constructor(apiUrl: string, apiKey: string, options?: FmsgClientOptions);
|
|
32
|
+
private get fetchImpl();
|
|
33
|
+
/** The address this client acts as (from the JWT `sub`), exchanging the key if needed. */
|
|
34
|
+
address(): Promise<string>;
|
|
35
|
+
getToken(force?: boolean): Promise<AccessToken>;
|
|
36
|
+
private exchangeToken;
|
|
37
|
+
private request;
|
|
38
|
+
private json;
|
|
39
|
+
listInbox(limit?: number, offset?: number, signal?: AbortSignal): Promise<FmsgMessage[]>;
|
|
40
|
+
listSent(limit?: number, offset?: number, signal?: AbortSignal): Promise<FmsgMessage[]>;
|
|
41
|
+
getMessage(id: string, signal?: AbortSignal): Promise<FmsgMessage>;
|
|
42
|
+
/** Raw message body bytes. */
|
|
43
|
+
getData(id: string, signal?: AbortSignal): Promise<{
|
|
44
|
+
data: Uint8Array;
|
|
45
|
+
contentType?: string;
|
|
46
|
+
}>;
|
|
47
|
+
/** Whether `short_text` already holds the complete body. */
|
|
48
|
+
static shortTextIsComplete(message: FmsgMessage): boolean;
|
|
49
|
+
static isText(message: {
|
|
50
|
+
type?: string;
|
|
51
|
+
}): boolean;
|
|
52
|
+
/** Full body text for text-like messages; null for binary bodies. */
|
|
53
|
+
getText(message: FmsgMessage, signal?: AbortSignal): Promise<string | null>;
|
|
54
|
+
getThreadMessages(id: string, signal?: AbortSignal): Promise<Thread>;
|
|
55
|
+
getThreadText(id: string, signal?: AbortSignal): Promise<string>;
|
|
56
|
+
/** Download by a `download` path returned from thread/messages (`/fmsg/...`). */
|
|
57
|
+
downloadPath(path: string, signal?: AbortSignal): Promise<{
|
|
58
|
+
data: Uint8Array;
|
|
59
|
+
contentType?: string;
|
|
60
|
+
}>;
|
|
61
|
+
markRead(id: string, signal?: AbortSignal): Promise<{
|
|
62
|
+
id: string;
|
|
63
|
+
time_read: number | null;
|
|
64
|
+
}>;
|
|
65
|
+
addRecipients(id: string, addTo: string[], signal?: AbortSignal): Promise<{
|
|
66
|
+
id: string;
|
|
67
|
+
added: number;
|
|
68
|
+
}>;
|
|
69
|
+
react(id: string, emoji: string | null, signal?: AbortSignal): Promise<ReactResult>;
|
|
70
|
+
downloadAttachment(id: string, filename: string, signal?: AbortSignal): Promise<{
|
|
71
|
+
data: Uint8Array;
|
|
72
|
+
contentType?: string;
|
|
73
|
+
}>;
|
|
74
|
+
deleteMessage(id: string, signal?: AbortSignal): Promise<void>;
|
|
75
|
+
private createDraft;
|
|
76
|
+
uploadAttachment(draftId: string, attachment: {
|
|
77
|
+
filename: string;
|
|
78
|
+
data: Uint8Array;
|
|
79
|
+
contentType?: string;
|
|
80
|
+
}, signal?: AbortSignal): Promise<Attachment>;
|
|
81
|
+
/** Draft → attach → send. The draft is deleted if any step after creation fails. */
|
|
82
|
+
send(input: SendInput): Promise<SendResult>;
|
|
83
|
+
}
|