@markmnl/fmsg-mcp 1.2.3 → 1.4.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/dist/upload.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { AttachmentChecksumError } from "./client/client.js";
1
2
  import { normalizeMessageId } from "./client/message-id.js";
2
3
  import { FILENAME } from "./tools/attachments.js";
3
4
  export const UPLOAD_PATH = "/mcp/drafts";
@@ -93,3 +94,82 @@ export function uploadEncoding(header) {
93
94
  throw new Error(`unsupported Content-Encoding ${JSON.stringify(header)}; use ${UPLOAD_ENCODINGS.join(" or ")}, or none`);
94
95
  return value;
95
96
  }
97
+ // ── Uploads bound to one file's size and SHA-256 (presigned links) ──
98
+ const refuse = (status, error) => Response.json({ error }, { status });
99
+ /** The body as a stream that fails unless it is exactly `size` bytes. */
100
+ function exactly(body, size) {
101
+ const reader = body.getReader();
102
+ let read = 0;
103
+ return new ReadableStream({
104
+ async pull(controller) {
105
+ const chunk = await reader.read();
106
+ if (chunk.done) {
107
+ if (read !== size)
108
+ throw new SizeError(400, `the upload ended after ${read} bytes, but the file's declared size is ${size} bytes; it was not kept`);
109
+ controller.close();
110
+ return;
111
+ }
112
+ read += chunk.value.byteLength;
113
+ if (read > size)
114
+ throw new SizeError(413, `the upload is larger than the file's declared size of ${size} bytes; it was not kept`);
115
+ controller.enqueue(chunk.value);
116
+ },
117
+ cancel: (reason) => reader.cancel(reason),
118
+ });
119
+ }
120
+ class SizeError extends UploadBodyError {
121
+ status;
122
+ constructor(status, message) {
123
+ super(message);
124
+ this.status = status;
125
+ }
126
+ }
127
+ /**
128
+ * Stream one upload into a draft, refusing any body that is not exactly the slot's size and SHA-256, and a second
129
+ * upload after one succeeded. `noun` names the link in messages ("target", "link").
130
+ */
131
+ export async function streamIntoDraft(tracker, caller, slot, request, encoding, signal, noun) {
132
+ const declared = request.headers.get("content-length");
133
+ if (!encoding && declared !== null && Number(declared) !== slot.size) {
134
+ return refuse(Number(declared) > slot.size ? 413 : 400, `Content-Length is ${declared}, but the file's declared size is ${slot.size} bytes`);
135
+ }
136
+ const key = `${slot.draft}/${slot.filename}`;
137
+ if (tracker.verifiedSha256(key))
138
+ return refuse(409, `this upload ${noun} was already used; each ${noun} takes one upload`);
139
+ if (!tracker.begin(key))
140
+ return refuse(409, `an upload to this ${noun} is already in progress`);
141
+ const { stream, drain } = drainableBody(request.body ?? new ReadableStream({ start: (c) => c.close() }), UPLOAD_DRAIN_IDLE_MS);
142
+ try {
143
+ // The draft is the record of what arrived, also for uploads another instance took (shared key).
144
+ const draft = await caller.client.getMessage(slot.draft, signal);
145
+ if (draft.time !== null && draft.time !== undefined) {
146
+ await drain();
147
+ return refuse(409, `the draft this upload ${noun} belongs to was already sent`);
148
+ }
149
+ if ((draft.attachments ?? []).some((a) => a.filename === slot.filename)) {
150
+ await drain();
151
+ return refuse(409, `this upload ${noun} was already used; each ${noun} takes one upload`);
152
+ }
153
+ const body = exactly(decodedUploadBody(stream, encoding), slot.size);
154
+ const uploaded = await caller.client.uploadAttachmentStream(slot.draft, { filename: slot.filename, stream: body, contentType: slot.mimeType, expectedSha256: slot.sha256 }, signal);
155
+ tracker.recordVerified(key, uploaded.sha256);
156
+ return Response.json({ ...(slot.fileId !== undefined ? { fileId: slot.fileId } : {}), filename: uploaded.filename, size: uploaded.size, sha256: uploaded.sha256 }, { status: 201 });
157
+ }
158
+ catch (error) {
159
+ const drained = await drain();
160
+ if (error instanceof SizeError)
161
+ return refuse(error.status, error.message);
162
+ if (error instanceof AttachmentChecksumError) {
163
+ // A FileInput (fileId) declares its digest as base64url; other uploads give hex.
164
+ const shown = (hex) => slot.fileId !== undefined ? Buffer.from(hex, "hex").toString("base64url") : hex;
165
+ return refuse(422, `the uploaded bytes do not match the ${slot.fileId !== undefined ? "file's declared digest" : "declared sha256"} ${shown(slot.sha256)} ` +
166
+ `(got ${shown(error.actual)}); it was not kept`);
167
+ }
168
+ if (!drained)
169
+ throw Object.assign(error instanceof Error ? error : new Error(String(error)), { undrained: true });
170
+ throw error;
171
+ }
172
+ finally {
173
+ tracker.end(key);
174
+ }
175
+ }
package/dist/version.d.ts CHANGED
@@ -1,2 +1,17 @@
1
+ /** What `npm run build` records about the source it built (scripts/build-info.mjs); absent when not built that way. */
2
+ export type BuildInfo = {
3
+ /** Release version from a v-tag at or before the commit, or a version given to the build. */
4
+ version?: string;
5
+ /** Full git commit hash. */
6
+ commit?: string;
7
+ /** `git describe` output, such as v1.2.3-4-g80e0418 or v1.2.3-dirty. */
8
+ describe?: string;
9
+ };
1
10
  export declare const PACKAGE_NAME: string;
11
+ export declare const BUILD: BuildInfo;
12
+ /** The release this server is: the build's version when recorded, otherwise package.json's. */
2
13
  export declare const VERSION: string;
14
+ /** The short commit hash, when the build recorded one. */
15
+ export declare const COMMIT_SHORT: string | undefined;
16
+ /** One line naming exactly which build this is, such as `1.2.3 (v1.2.3-4-g80e0418)` or `1.2.3 (commit 80e0418)`. */
17
+ export declare const VERSION_LABEL: string;
package/dist/version.js CHANGED
@@ -1,5 +1,31 @@
1
+ import { readFileSync } from "node:fs";
1
2
  import { createRequire } from "node:module";
2
3
  const require = createRequire(import.meta.url);
3
4
  const pkg = require("../package.json");
5
+ function readBuildInfo() {
6
+ let info = {};
7
+ try {
8
+ const raw = JSON.parse(readFileSync(new URL("./build-info.json", import.meta.url), "utf8"));
9
+ for (const key of ["version", "commit", "describe"]) {
10
+ const value = raw[key];
11
+ if (typeof value === "string" && /^[\x21-\x7e]{1,100}$/u.test(value))
12
+ info = { ...info, [key]: value };
13
+ }
14
+ }
15
+ catch { /* not built by `npm run build`, such as under the test runner */ }
16
+ // A deploy may name the commit it runs when the build could not see git.
17
+ const commit = process.env.FMSG_MCP_COMMIT?.trim();
18
+ if (commit && /^[0-9a-f]{7,64}$/iu.test(commit))
19
+ info = { ...info, commit: commit.toLowerCase() };
20
+ return info;
21
+ }
4
22
  export const PACKAGE_NAME = pkg.name;
5
- export const VERSION = pkg.version;
23
+ export const BUILD = readBuildInfo();
24
+ /** The release this server is: the build's version when recorded, otherwise package.json's. */
25
+ export const VERSION = BUILD.version ?? pkg.version;
26
+ /** The short commit hash, when the build recorded one. */
27
+ export const COMMIT_SHORT = BUILD.commit?.slice(0, 12);
28
+ /** One line naming exactly which build this is, such as `1.2.3 (v1.2.3-4-g80e0418)` or `1.2.3 (commit 80e0418)`. */
29
+ export const VERSION_LABEL = BUILD.describe && BUILD.describe !== `v${VERSION}`
30
+ ? `${VERSION} (${BUILD.describe})`
31
+ : COMMIT_SHORT ? `${VERSION} (commit ${COMMIT_SHORT})` : VERSION;
@@ -127,6 +127,21 @@ The server does the proxying to the fmsg host itself: a TLS proxy that forwards
127
127
  Caddy example above, needs no upload-specific settings. Proxies that cap request bodies by default
128
128
  (nginx's `client_max_body_size` is 1 MB) should not cap them below the fmsg host's attachment limit.
129
129
 
130
+ ### Presigned links
131
+
132
+ `get_attachment_download_url` with `presigned: true` and `get_attachment_upload_url` return links
133
+ under `/mcp/links/{token}` for hosts that cannot send the connection's Authorization header with their
134
+ own HTTP requests. Such a link carries a signed capability for one transfer:
135
+
136
+ - **GET** downloads one attachment.
137
+ - **PUT** uploads one file of the declared size and sha256 into one draft.
138
+
139
+ The link is single-use and valid for 5 minutes. It never contains the API key or an access token,
140
+ but anyone holding it can make that one transfer until it is used or expires, so hosts should treat
141
+ it as a secret. The caller a link acts for is kept in the process that issued it. Behind a load
142
+ balancer, the link therefore works only when the request reaches that instance; elsewhere the answer
143
+ is `404` with a message saying so. The proxy setup above already forwards these paths.
144
+
130
145
  Host and Origin validation applies to downloads and uploads too. Allowed browser clients can preflight GET
131
146
  with Authorization and read Content-Disposition. Hosts must attach the connection credential
132
147
  themselves; never ask the model to locate or copy tokens. Clients that cannot fetch authenticated
package/docs/oauth.md CHANGED
@@ -119,7 +119,7 @@ are refused.
119
119
  | Operations | Required scope |
120
120
  |---|---|
121
121
  | Identity/address lookup, inbox/sent, message/thread, delivery status, attachment download, wait, resource reads | `fmsg:read` |
122
- | Send a new message, add recipients, react, mark read | `fmsg:write` |
122
+ | Send a new message, add recipients, react, show an indicator (`indicate`), mark read | `fmsg:write` |
123
123
  | Reply (reads the parent before sending) | `fmsg:read fmsg:write` |
124
124
  | Protocol initialization, tool/resource/prompt discovery and prompt templates | Valid incoming token; no messaging scope needed |
125
125
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@markmnl/fmsg-mcp",
3
3
  "mcpName": "io.github.markmnl/fmsg-mcp",
4
- "version": "1.2.3",
4
+ "version": "1.4.0",
5
5
  "description": "MCP server for fmsg: send and receive federated messages from any AI agent via a deployed fmsg Web API",
6
6
  "type": "module",
7
7
  "license": "MIT",
@@ -41,6 +41,7 @@
41
41
  "files": [
42
42
  "dist/**/*.js",
43
43
  "dist/**/*.d.ts",
44
+ "dist/build-info.json",
44
45
  "README.md",
45
46
  "SECURITY.md",
46
47
  "CHANGELOG.md",
@@ -52,7 +53,7 @@
52
53
  "server.json"
53
54
  ],
54
55
  "scripts": {
55
- "build": "tsc -p tsconfig.build.json",
56
+ "build": "tsc -p tsconfig.build.json && node scripts/build-info.mjs",
56
57
  "clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"",
57
58
  "typecheck": "tsc -p tsconfig.json --noEmit",
58
59
  "test": "vitest run",
package/server.json CHANGED
@@ -8,13 +8,13 @@
8
8
  "url": "https://github.com/markmnl/fmsg-mcp",
9
9
  "source": "github"
10
10
  },
11
- "version": "1.2.3",
11
+ "version": "1.4.0",
12
12
  "packages": [
13
13
  {
14
14
  "registryType": "npm",
15
15
  "registryBaseUrl": "https://registry.npmjs.org",
16
16
  "identifier": "@markmnl/fmsg-mcp",
17
- "version": "1.2.3",
17
+ "version": "1.4.0",
18
18
  "transport": {
19
19
  "type": "stdio"
20
20
  },