@markmnl/fmsg-mcp 1.2.2 → 1.3.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/CHANGELOG.md +19 -0
- package/README.md +20 -5
- package/dist/build-info.json +5 -0
- package/dist/client/client.js +6 -1
- package/dist/client/types.d.ts +11 -0
- package/dist/config.d.ts +5 -0
- package/dist/config.js +10 -0
- package/dist/http.js +112 -9
- package/dist/index.js +3 -3
- package/dist/instructions.js +2 -2
- package/dist/links.d.ts +63 -0
- package/dist/links.js +104 -0
- package/dist/oauth/errors.d.ts +2 -0
- package/dist/oauth/errors.js +4 -0
- package/dist/oauth/scopes.js +1 -0
- package/dist/prompts.js +4 -2
- package/dist/server.d.ts +3 -0
- package/dist/server.js +3 -2
- package/dist/tools/attachments.d.ts +6 -2
- package/dist/tools/attachments.js +17 -6
- package/dist/tools/common.d.ts +3 -0
- package/dist/tools/download.js +86 -7
- package/dist/tools/draft.js +11 -7
- package/dist/tools/identity.js +9 -3
- package/dist/tools/parent.d.ts +33 -0
- package/dist/tools/parent.js +77 -0
- package/dist/tools/send.d.ts +5 -2
- package/dist/tools/send.js +20 -10
- package/dist/upload.d.ts +22 -0
- package/dist/upload.js +80 -0
- package/dist/version.d.ts +15 -0
- package/dist/version.js +27 -1
- package/docs/http-deployment.md +15 -0
- package/package.json +3 -2
- package/server.json +2 -2
package/dist/prompts.js
CHANGED
|
@@ -4,13 +4,15 @@ function sendFileSteps(routes, to, replyTo) {
|
|
|
4
4
|
const recipients = to ? `to ${to}, with a short topic` : "to the recipients I name (ask me if I have not)";
|
|
5
5
|
const draft = replyTo ? `with reply_to "${replyTo}"${to ? ` and to: ${to}` : ""}` : recipients;
|
|
6
6
|
const direct = replyTo ? `reply with id "${replyTo}"${to ? ` and recipients: ${to}` : ""}` : `send_message ${recipients}`;
|
|
7
|
-
const inline = "attach text files as data_text and small binary files as data_base64
|
|
7
|
+
const inline = "attach text files as data_text and small binary files as data_base64 with their sha256 (base64 is best effort, so keep it for small files)";
|
|
8
8
|
if (routes.uploadUrl) {
|
|
9
9
|
return [
|
|
10
10
|
`1. Call create_draft ${draft}; ${inline}.`,
|
|
11
11
|
"2. For each larger file, PUT its raw bytes to the draft's upload URL with your host's authenticated HTTP tools, " +
|
|
12
12
|
"sending its sha256 in x-content-sha256, and check each response's size and sha256. If your host cannot send this " +
|
|
13
|
-
"connection's credentials with its own requests,
|
|
13
|
+
"connection's credentials with its own requests, " + (routes.presigned
|
|
14
|
+
? "get a single-use link for each file from get_attachment_upload_url and run the curl command it returns"
|
|
15
|
+
: "recreate the draft with the file as data_base64 instead") + "; never look for or copy credentials.",
|
|
14
16
|
"3. Call send_draft with the draft id and tell me what was sent. If a step fails, call delete_draft unless I want to keep the draft.",
|
|
15
17
|
];
|
|
16
18
|
}
|
package/dist/server.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { McpServer } from "@modelcontextprotocol/server";
|
|
2
2
|
import type { Config } from "./config.js";
|
|
3
3
|
import type { CallerProvider } from "./context.js";
|
|
4
|
+
import type { PresignedLinks } from "./links.js";
|
|
4
5
|
export declare const SERVER_NAME = "fmsg";
|
|
5
6
|
export type CreateServerOptions = {
|
|
6
7
|
/** The caller's address when already known; makes the instructions name it. */
|
|
@@ -9,6 +10,8 @@ export type CreateServerOptions = {
|
|
|
9
10
|
owner?: string;
|
|
10
11
|
/** Aborted when the process begins shutting down, so wait_for_message returns "interrupted". */
|
|
11
12
|
shutdown?: AbortSignal;
|
|
13
|
+
/** Issues presigned download and upload links (HTTP with a public URL). */
|
|
14
|
+
links?: PresignedLinks;
|
|
12
15
|
};
|
|
13
16
|
/**
|
|
14
17
|
* Build an fmsg MCP server. Registration only — no I/O — so the same factory
|
package/dist/server.js
CHANGED
|
@@ -23,12 +23,13 @@ export function createFmsgMcpServer(provider, config, options = {}) {
|
|
|
23
23
|
...(options.address ? { address: options.address } : {}),
|
|
24
24
|
...(options.owner ? { owner: options.owner } : {}),
|
|
25
25
|
...(config.defaultDomain ? { defaultDomain: config.defaultDomain } : {}),
|
|
26
|
-
files: fileRoutes(config),
|
|
26
|
+
files: fileRoutes(config, Boolean(options.links)),
|
|
27
27
|
});
|
|
28
28
|
const server = new McpServer({ name: SERVER_NAME, title: "fmsg", version: VERSION }, { instructions });
|
|
29
29
|
const includeOwner = includeOwnerSchema(options.address, options.owner);
|
|
30
30
|
const deps = {
|
|
31
31
|
provider, config, ...(includeOwner ? { includeOwner } : {}), ...(options.shutdown ? { shutdown: options.shutdown } : {}),
|
|
32
|
+
...(options.links ? { links: options.links } : {}),
|
|
32
33
|
};
|
|
33
34
|
registerIdentityTools(server, deps);
|
|
34
35
|
registerListTools(server, deps);
|
|
@@ -39,6 +40,6 @@ export function createFmsgMcpServer(provider, config, options = {}) {
|
|
|
39
40
|
registerDraftTools(server, deps);
|
|
40
41
|
registerWaitTools(server, deps);
|
|
41
42
|
registerResources(server, deps);
|
|
42
|
-
registerPrompts(server, fileRoutes(config));
|
|
43
|
+
registerPrompts(server, fileRoutes(config, Boolean(options.links)));
|
|
43
44
|
return server;
|
|
44
45
|
}
|
|
@@ -9,12 +9,16 @@ export type FileRoutes = {
|
|
|
9
9
|
uploadUrl: boolean;
|
|
10
10
|
/** Attachments may name a file in FMSG_MCP_UPLOAD_DIR (stdio). */
|
|
11
11
|
uploadFolder: boolean;
|
|
12
|
+
/** get_attachment_upload_url issues single-use upload links that need no credentials (HTTP). */
|
|
13
|
+
presigned: boolean;
|
|
12
14
|
};
|
|
13
|
-
export declare function fileRoutes(config: Config): FileRoutes;
|
|
15
|
+
export declare function fileRoutes(config: Config, presigned?: boolean): FileRoutes;
|
|
16
|
+
/** The routes for files too large for base64, in words for a model; empty when there are none. */
|
|
17
|
+
export declare function largeFileRoutes(routes: FileRoutes): string[];
|
|
14
18
|
/** A hint for the model after it wrote a large file as base64 although this server offers a route that avoids it. */
|
|
15
19
|
export declare function base64Hint(items: AttachmentArgs[] | undefined, routes: FileRoutes): string | undefined;
|
|
16
20
|
/** One outbound attachment as a tool argument: a filename and exactly one source of bytes. */
|
|
17
|
-
export declare function attachmentInput(config: Config): z.ZodObject<{
|
|
21
|
+
export declare function attachmentInput(config: Config, routes?: FileRoutes): z.ZodObject<{
|
|
18
22
|
content_type: z.ZodOptional<z.ZodString>;
|
|
19
23
|
sha256: z.ZodOptional<z.ZodString>;
|
|
20
24
|
path?: z.ZodOptional<z.ZodString> | undefined;
|
|
@@ -8,8 +8,16 @@ import { downloadBaseUrl } from "../download.js";
|
|
|
8
8
|
import { attachmentType } from "../render.js";
|
|
9
9
|
export const FILENAME = /^[A-Za-z0-9._-]+$/u;
|
|
10
10
|
const SHA256 = /^[0-9A-Fa-f]{64}$/u;
|
|
11
|
-
export function fileRoutes(config) {
|
|
12
|
-
|
|
11
|
+
export function fileRoutes(config, presigned = false) {
|
|
12
|
+
const uploadUrl = Boolean(downloadBaseUrl(config));
|
|
13
|
+
return { uploadUrl, uploadFolder: Boolean(config.uploadDir), presigned: uploadUrl && presigned };
|
|
14
|
+
}
|
|
15
|
+
/** The routes for files too large for base64, in words for a model; empty when there are none. */
|
|
16
|
+
export function largeFileRoutes(routes) {
|
|
17
|
+
return [
|
|
18
|
+
...(routes.uploadFolder ? ["path, a file in the upload folder"] : []),
|
|
19
|
+
...(routes.uploadUrl ? [`create_draft's upload URL${routes.presigned ? " (or a single-use link from get_attachment_upload_url)" : ""}`] : []),
|
|
20
|
+
];
|
|
13
21
|
}
|
|
14
22
|
/** Inline base64 this long (about 48 KB of file) earns a hint about a better route next time. A hint only, never a limit. */
|
|
15
23
|
const BASE64_HINT_CHARS = 64 * 1024;
|
|
@@ -17,7 +25,7 @@ const BASE64_HINT_CHARS = 64 * 1024;
|
|
|
17
25
|
export function base64Hint(items, routes) {
|
|
18
26
|
const large = (items ?? []).filter((a) => (a.data_base64?.length ?? 0) >= BASE64_HINT_CHARS).map((a) => a.filename);
|
|
19
27
|
const better = [
|
|
20
|
-
...(routes.uploadUrl ? [
|
|
28
|
+
...(routes.uploadUrl ? [`call create_draft, PUT them to its upload URL${routes.presigned ? " or to single-use links from get_attachment_upload_url" : ""}, then call send_draft`] : []),
|
|
21
29
|
...(routes.uploadFolder ? ["give their path in the upload folder instead of data_base64"] : []),
|
|
22
30
|
];
|
|
23
31
|
if (!large.length || !better.length)
|
|
@@ -29,17 +37,20 @@ function sources(config) {
|
|
|
29
37
|
return ["data_base64", "data_text", ...(config.uploadDir ? ["path"] : [])];
|
|
30
38
|
}
|
|
31
39
|
/** One outbound attachment as a tool argument: a filename and exactly one source of bytes. */
|
|
32
|
-
export function attachmentInput(config) {
|
|
40
|
+
export function attachmentInput(config, routes = fileRoutes(config)) {
|
|
33
41
|
const names = sources(config);
|
|
42
|
+
const large = largeFileRoutes(routes);
|
|
34
43
|
return z.strictObject({
|
|
35
44
|
filename: z.string().regex(FILENAME, "letters, digits, dot, underscore, hyphen only").describe("file name"),
|
|
36
|
-
data_base64: z.string().optional().describe(
|
|
45
|
+
data_base64: z.string().optional().describe("base64 file contents. You write the base64, so it is best effort: fine for " +
|
|
46
|
+
`small files (checked exact up to 16 KB). Always give sha256 with it${large.length ? `. For anything larger use ${large.join(", or ")}` : ""}. ` +
|
|
47
|
+
"The host sets the size limit"),
|
|
37
48
|
data_text: z.string().optional().describe("text file contents, sent as UTF-8 bytes; use instead of data_base64 for text"),
|
|
38
49
|
...(config.uploadDir ? {
|
|
39
50
|
path: z.string().min(1).optional().describe("file in the configured upload folder, relative to it; the server reads it so its bytes skip model context"),
|
|
40
51
|
} : {}),
|
|
41
52
|
content_type: z.string().optional().describe("inferred from the filename when omitted"),
|
|
42
|
-
sha256: z.string().regex(SHA256, "64 hexadecimal characters").optional().describe("hex sha256 of the file; the attachment is refused, and nothing sent, if the bytes differ.
|
|
53
|
+
sha256: z.string().regex(SHA256, "64 hexadecimal characters").optional().describe("hex sha256 of the file; the attachment is refused, and nothing sent, if the bytes differ. Always give it with data_base64"),
|
|
43
54
|
}).superRefine((a, ctx) => {
|
|
44
55
|
const given = names.filter((name) => a[name] !== undefined);
|
|
45
56
|
if (given.length !== 1)
|
package/dist/tools/common.d.ts
CHANGED
|
@@ -3,6 +3,7 @@ import * as z from "zod/v4";
|
|
|
3
3
|
import type { AddressResolver } from "../address.js";
|
|
4
4
|
import type { FmsgMessage } from "../client/types.js";
|
|
5
5
|
import type { Config } from "../config.js";
|
|
6
|
+
import type { PresignedLinks } from "../links.js";
|
|
6
7
|
import { type Caller, type CallerProvider } from "../context.js";
|
|
7
8
|
export type ToolDeps = {
|
|
8
9
|
provider: CallerProvider;
|
|
@@ -11,6 +12,8 @@ export type ToolDeps = {
|
|
|
11
12
|
includeOwner?: z.ZodDefault<z.ZodBoolean>;
|
|
12
13
|
/** Aborted when the server begins shutting down; long waits then return early. */
|
|
13
14
|
shutdown?: AbortSignal;
|
|
15
|
+
/** Presigned links, when this server has a public URL. */
|
|
16
|
+
links?: PresignedLinks;
|
|
14
17
|
};
|
|
15
18
|
export declare const READ_ONLY: ToolAnnotations;
|
|
16
19
|
export declare const SENDS: ToolAnnotations;
|
package/dist/tools/download.js
CHANGED
|
@@ -1,20 +1,40 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
1
2
|
import * as z from "zod/v4";
|
|
2
3
|
import { attachmentDownloadUrl, attachmentFilename, downloadBaseUrl } from "../download.js";
|
|
3
4
|
import { normalizeMessageId } from "../client/message-id.js";
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
5
|
+
import { toolError } from "../errors.js";
|
|
6
|
+
import { localFileName, shellQuote } from "../links.js";
|
|
7
|
+
import { attachmentType, isoTime, messageData } from "../render.js";
|
|
8
|
+
import { FILENAME } from "./attachments.js";
|
|
9
|
+
import { SENDS, attachmentMissingFrom, idSchema, openEnum, outputObject, READ_ONLY, UNTRUSTED, untrustedNotice, withCaller } from "./common.js";
|
|
6
10
|
const FALLBACK = "If you cannot fetch URLs with this connection's authorization, use download_attachment instead.";
|
|
11
|
+
const PRESIGNED_FALLBACK = "If you cannot fetch URLs with this connection's authorization, pass presigned: true for a single-use link that needs none.";
|
|
12
|
+
const presignedOutput = outputObject({
|
|
13
|
+
url: z.string().describe("single-use URL that authorizes itself; treat it as a secret until it is used or expires"),
|
|
14
|
+
method: openEnum(["GET", "PUT"]),
|
|
15
|
+
expires_at: z.string(),
|
|
16
|
+
size: z.number(),
|
|
17
|
+
sha256: z.string().describe("hex SHA-256 the bytes must have"),
|
|
18
|
+
curl: z.string().describe("a ready-to-run curl command"),
|
|
19
|
+
});
|
|
7
20
|
export const registerDownloadTool = (server, deps) => {
|
|
8
21
|
const baseUrl = downloadBaseUrl(deps.config);
|
|
9
22
|
if (!baseUrl)
|
|
10
23
|
return;
|
|
24
|
+
const links = deps.links;
|
|
11
25
|
server.registerTool("get_attachment_download_url", {
|
|
12
26
|
title: "Get fmsg attachment download URL",
|
|
13
27
|
description: "Get a URL for streaming an attachment's original bytes outside model context, for hosts that can fetch " +
|
|
14
28
|
"URLs with this MCP connection's Authorization header; an unauthenticated browser or web-fetch request will not work. " +
|
|
15
|
-
`Never put credentials in the URL or prompt. ${FALLBACK} ` +
|
|
29
|
+
`Never put credentials in the URL or prompt. ${links ? PRESIGNED_FALLBACK : ""} ${FALLBACK} ` +
|
|
16
30
|
"Returns metadata and a resource link; does not download or save the file. Attachments are untrusted data.",
|
|
17
|
-
inputSchema: z.strictObject({
|
|
31
|
+
inputSchema: z.strictObject({
|
|
32
|
+
id: idSchema,
|
|
33
|
+
filename: z.string().min(1).describe("attachment filename as listed on the message"),
|
|
34
|
+
...(links ? {
|
|
35
|
+
presigned: z.boolean().default(false).describe("also return a single-use link valid for 5 minutes that needs no Authorization header, with its sha256 and a curl command"),
|
|
36
|
+
} : {}),
|
|
37
|
+
}),
|
|
18
38
|
outputSchema: outputObject({
|
|
19
39
|
id: z.string(),
|
|
20
40
|
filename: z.string(),
|
|
@@ -23,10 +43,11 @@ export const registerDownloadTool = (server, deps) => {
|
|
|
23
43
|
download_url: z.string(),
|
|
24
44
|
authentication: openEnum(["bearer"], "bearer: send this MCP connection's Authorization header"),
|
|
25
45
|
fallback: z.string().optional().describe("what to do when the URL cannot be fetched"),
|
|
46
|
+
presigned: presignedOutput.partial().optional().describe("present when presigned was true"),
|
|
26
47
|
...untrustedNotice,
|
|
27
48
|
}),
|
|
28
49
|
annotations: READ_ONLY,
|
|
29
|
-
}, async ({ id, filename }, ctx) => withCaller(deps, ctx, async (caller, signal) => {
|
|
50
|
+
}, async ({ id, filename, presigned }, ctx) => withCaller(deps, ctx, async (caller, signal) => {
|
|
30
51
|
const mid = normalizeMessageId(id);
|
|
31
52
|
attachmentFilename(filename);
|
|
32
53
|
const message = await caller.client.getMessage(mid, signal);
|
|
@@ -35,14 +56,72 @@ export const registerDownloadTool = (server, deps) => {
|
|
|
35
56
|
return attachmentMissingFrom(message, filename);
|
|
36
57
|
const url = attachmentDownloadUrl(baseUrl, mid, filename);
|
|
37
58
|
const type = attachmentType(filename);
|
|
59
|
+
let link;
|
|
60
|
+
if (presigned && links) {
|
|
61
|
+
// The link is bound to the bytes as they are now, so the client can check what it downloads.
|
|
62
|
+
const { stream } = await caller.client.streamAttachment(mid, filename, signal);
|
|
63
|
+
const hash = createHash("sha256");
|
|
64
|
+
let size = 0;
|
|
65
|
+
for await (const chunk of stream) {
|
|
66
|
+
hash.update(chunk);
|
|
67
|
+
size += chunk.byteLength;
|
|
68
|
+
}
|
|
69
|
+
const sha256 = hash.digest("hex");
|
|
70
|
+
const minted = links.mint(caller, { k: "get", m: mid, n: filename, s: size, h: sha256 });
|
|
71
|
+
link = {
|
|
72
|
+
url: minted.url, method: "GET", expires_at: isoTime(minted.expiresAt), size, sha256,
|
|
73
|
+
curl: `curl -fsS -o ${shellQuote(localFileName(filename))} ${shellQuote(minted.url)}`,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
const linkText = link
|
|
77
|
+
? `\n\nSingle-use link (GET, no Authorization header, expires ${link.expires_at}; sha256 ${link.sha256}):\n${link.curl}`
|
|
78
|
+
: "";
|
|
38
79
|
return {
|
|
39
80
|
content: [
|
|
40
81
|
{ type: "text", text: "Download with your host's authenticated HTTP/file tools using this MCP connection's Authorization header. " +
|
|
41
82
|
`The URL contains no credentials and access is checked again when fetched. ${FALLBACK}\n\n` +
|
|
42
|
-
messageData(`${filename} (${attachment.size} bytes, ${type}) from message ${mid}\n${url}`) },
|
|
83
|
+
messageData(`${filename} (${attachment.size} bytes, ${type}) from message ${mid}\n${url}`) + linkText },
|
|
43
84
|
{ type: "resource_link", uri: url, name: filename, size: attachment.size, mimeType: type },
|
|
44
85
|
],
|
|
45
|
-
structuredContent: {
|
|
86
|
+
structuredContent: {
|
|
87
|
+
id: mid, filename, size: attachment.size, type, download_url: url, authentication: "bearer", fallback: FALLBACK,
|
|
88
|
+
...(link ? { presigned: link } : {}), ...UNTRUSTED,
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
}));
|
|
92
|
+
if (!links)
|
|
93
|
+
return;
|
|
94
|
+
server.registerTool("get_attachment_upload_url", {
|
|
95
|
+
title: "Get fmsg draft upload URL",
|
|
96
|
+
description: "Get a single-use URL, valid for 5 minutes, that PUTs one file into a draft from create_draft without " +
|
|
97
|
+
"this connection's Authorization header, for hosts that cannot send it. The URL accepts only a file of exactly the " +
|
|
98
|
+
"given size and sha256; then call send_draft. Returns a ready-to-run curl command. The URL authorizes itself: keep it " +
|
|
99
|
+
"out of messages and share it with nobody.",
|
|
100
|
+
inputSchema: z.strictObject({
|
|
101
|
+
id: idSchema.describe("the draft"),
|
|
102
|
+
filename: z.string().regex(FILENAME, "letters, digits, dot, underscore, hyphen only").describe("attachment file name"),
|
|
103
|
+
size: z.number().int().nonnegative().describe("file size in bytes"),
|
|
104
|
+
sha256: z.string().regex(/^[0-9A-Fa-f]{64}$/u, "64 hexadecimal characters").describe("hex SHA-256 of the file"),
|
|
105
|
+
content_type: z.string().regex(/^[\x20-\x7e]+$/u).optional().describe("inferred from the filename when omitted"),
|
|
106
|
+
}),
|
|
107
|
+
outputSchema: outputObject({ draft_id: z.string(), filename: z.string(), ...presignedOutput.shape }),
|
|
108
|
+
annotations: { ...SENDS, destructiveHint: false },
|
|
109
|
+
}, async ({ id, filename, size, sha256, content_type }, ctx) => withCaller(deps, ctx, async (caller, signal) => {
|
|
110
|
+
const draft = await caller.client.getMessage(id, signal);
|
|
111
|
+
if (draft.time !== null && draft.time !== undefined)
|
|
112
|
+
return toolError(`message ${draft.id} was already sent; files can be added only to drafts`);
|
|
113
|
+
if (draft.from.toLowerCase() !== caller.address.toLowerCase())
|
|
114
|
+
return toolError(`message ${draft.id} is not your draft`);
|
|
115
|
+
if ((draft.attachments ?? []).some((a) => a.filename === filename))
|
|
116
|
+
return toolError(`draft ${draft.id} already has an attachment named ${filename}`);
|
|
117
|
+
const hex = sha256.toLowerCase();
|
|
118
|
+
const minted = links.mint(caller, { k: "put", m: draft.id, n: filename, s: size, h: hex, t: content_type ?? attachmentType(filename) });
|
|
119
|
+
const curl = `curl -fsS -X PUT --data-binary @${shellQuote(filename)} ${shellQuote(minted.url)}`;
|
|
120
|
+
const structured = { draft_id: draft.id, filename, url: minted.url, method: "PUT", expires_at: isoTime(minted.expiresAt), size, sha256: hex, curl };
|
|
121
|
+
return {
|
|
122
|
+
content: [{ type: "text", text: `Upload ${filename} (${size} bytes, sha256 ${hex}) into draft ${draft.id} before ${structured.expires_at}, ` +
|
|
123
|
+
`then call send_draft. The link takes one upload of exactly these bytes and needs no Authorization header:\n${curl}` }],
|
|
124
|
+
structuredContent: structured,
|
|
46
125
|
};
|
|
47
126
|
}));
|
|
48
127
|
};
|
package/dist/tools/draft.js
CHANGED
|
@@ -6,17 +6,18 @@ import { addressList, headerValue, isoTime } from "../render.js";
|
|
|
6
6
|
import { CHECKSUM_HEADER, UPLOAD_ENCODINGS, draftUploadUrlTemplate } from "../upload.js";
|
|
7
7
|
import { attachmentInput, base64Hint, draftWithAttachments, fileRoutes, prepareAttachments } from "./attachments.js";
|
|
8
8
|
import { SENDS, attachmentItem, idSchema, ok, openEnum, outputObject, resolverFor, withCaller } from "./common.js";
|
|
9
|
+
import { parentReference, replyParent } from "./parent.js";
|
|
9
10
|
import { rootTopic } from "./read.js";
|
|
10
|
-
import { IMMUTABLE, IMPORTANT, NO_REPLY,
|
|
11
|
+
import { IMMUTABLE, IMPORTANT, NO_REPLY, hintOutput, uploadSteps, redactionNote, replyRecipients, resolveRecipients, resolvedNote, resolvedOutput, sendWarnings, sentAttachments, sentOutput, withHint, } from "./send.js";
|
|
11
12
|
export const registerDraftTools = (server, deps) => {
|
|
12
13
|
const baseUrl = downloadBaseUrl(deps.config);
|
|
13
|
-
const routes = fileRoutes(deps.config);
|
|
14
|
+
const routes = fileRoutes(deps.config, Boolean(deps.links));
|
|
14
15
|
const includeOwner = deps.includeOwner ? { include_owner: deps.includeOwner } : {};
|
|
15
16
|
server.registerTool("create_draft", {
|
|
16
17
|
title: "Create fmsg draft",
|
|
17
18
|
description: "Create an unsent draft: a new message, or a reply when reply_to is given (reply rules and reply-all apply). " +
|
|
18
19
|
"Nothing is sent until send_draft; delete_draft discards it. The draft is visible in the user's other fmsg clients. " +
|
|
19
|
-
(routes.uploadUrl ? `Use it for large files: ${
|
|
20
|
+
(routes.uploadUrl ? `Use it for large files: ${uploadSteps(routes)}. ` : "") +
|
|
20
21
|
"Secrets in the body and topic are redacted.",
|
|
21
22
|
inputSchema: z.strictObject({
|
|
22
23
|
to: z.array(z.string()).min(1).optional().describe("recipient addresses (@user@domain) or short names; for a reply, overrides reply-all"),
|
|
@@ -59,14 +60,16 @@ export const registerDraftTools = (server, deps) => {
|
|
|
59
60
|
return withCaller(deps, ctx, async (caller, signal) => {
|
|
60
61
|
let recipients;
|
|
61
62
|
let resolved;
|
|
62
|
-
let
|
|
63
|
+
let parentRef;
|
|
63
64
|
if (reply_to) {
|
|
64
|
-
const parent = await caller
|
|
65
|
+
const parent = await replyParent(caller, reply_to, signal);
|
|
66
|
+
if ("content" in parent)
|
|
67
|
+
return parent;
|
|
65
68
|
const target = replyRecipients(deps, caller, parent, to, allow_no_reply);
|
|
66
69
|
if ("content" in target)
|
|
67
70
|
return target;
|
|
68
71
|
({ to: recipients, resolved } = target);
|
|
69
|
-
|
|
72
|
+
parentRef = parentReference(caller, parent);
|
|
70
73
|
}
|
|
71
74
|
else {
|
|
72
75
|
({ to: recipients, resolved } = resolveRecipients(to, resolverFor(deps, caller)));
|
|
@@ -75,8 +78,9 @@ export const registerDraftTools = (server, deps) => {
|
|
|
75
78
|
const outbound = await prepareAttachments(attachments, deps.config);
|
|
76
79
|
const hint = base64Hint(attachments, routes);
|
|
77
80
|
const draft = await draftWithAttachments(caller.client, {
|
|
78
|
-
to: recipients, body, type, important, noReply: no_reply, ...(
|
|
81
|
+
to: recipients, body, type, important, noReply: no_reply, ...(parentRef ?? { topic: topic ?? "" }),
|
|
79
82
|
}, outbound, signal);
|
|
83
|
+
const pid = parentRef?.pid;
|
|
80
84
|
const upload = baseUrl ? {
|
|
81
85
|
url_template: draftUploadUrlTemplate(baseUrl, draft.id),
|
|
82
86
|
method: "PUT",
|
package/dist/tools/identity.js
CHANGED
|
@@ -3,14 +3,14 @@ import { CALLER_DOMAIN, effectiveDefaultDomain, resolveAddress } from "../addres
|
|
|
3
3
|
import { addressText, isoTime } from "../render.js";
|
|
4
4
|
import { READ_ONLY, ok, openEnum, outputObject, resolverFor, withCaller } from "./common.js";
|
|
5
5
|
import { toolError } from "../errors.js";
|
|
6
|
-
import { VERSION } from "../version.js";
|
|
6
|
+
import { BUILD, VERSION, VERSION_LABEL } from "../version.js";
|
|
7
7
|
/** Structured output keeps the transport ids published since 0.2.5; only the text uses the friendlier label. */
|
|
8
8
|
const TRANSPORT_LABELS = { stdio: "stdio", http: "Streamable HTTP" };
|
|
9
9
|
export const registerIdentityTools = (server, deps) => {
|
|
10
10
|
server.registerTool("whoami", {
|
|
11
11
|
title: "Show fmsg identity",
|
|
12
12
|
description: "Report the fmsg address this server acts as, its owner if it is a sub-address, the fmsg Web API URL and how " +
|
|
13
|
-
"short names resolve
|
|
13
|
+
"short names resolve, and this server's version and build. Call this first if unsure who you are sending as.",
|
|
14
14
|
outputSchema: outputObject({
|
|
15
15
|
address: z.string(),
|
|
16
16
|
owner: z.string().optional().describe("owner of this sub-address, included as a recipient of messages you send unless include_owner is false; absent for an owner's own address"),
|
|
@@ -19,6 +19,9 @@ export const registerIdentityTools = (server, deps) => {
|
|
|
19
19
|
transport: openEnum(["stdio", "http"], "http is Streamable HTTP"),
|
|
20
20
|
default_domain: z.string().nullable(),
|
|
21
21
|
directory_names: z.array(z.string()).describe("short names in the operator-configured directory; empty when there are none"),
|
|
22
|
+
server_version: z.string().optional().describe("this fmsg-mcp server's release version"),
|
|
23
|
+
server_commit: z.string().optional().describe("the git commit this server was built from, when the build recorded it"),
|
|
24
|
+
server_build: z.string().optional().describe("git describe of the build, such as v1.2.3-4-g80e0418, when recorded"),
|
|
22
25
|
}),
|
|
23
26
|
annotations: { ...READ_ONLY, openWorldHint: false },
|
|
24
27
|
}, async (ctx) => withCaller(deps, ctx, async (caller) => {
|
|
@@ -34,12 +37,15 @@ export const registerIdentityTools = (server, deps) => {
|
|
|
34
37
|
transport: deps.config.transport,
|
|
35
38
|
default_domain: defaultDomain ?? null,
|
|
36
39
|
directory_names: directoryNames,
|
|
40
|
+
server_version: VERSION,
|
|
41
|
+
...(BUILD.commit ? { server_commit: BUILD.commit } : {}),
|
|
42
|
+
...(BUILD.describe ? { server_build: BUILD.describe } : {}),
|
|
37
43
|
};
|
|
38
44
|
const lines = [
|
|
39
45
|
deps.config.apiPublicUrl
|
|
40
46
|
? `You are ${addressText(caller.address)} on ${deps.config.apiPublicUrl}, connected over ${transport}.`
|
|
41
47
|
: `You are ${addressText(caller.address)}, connected over ${transport}.`,
|
|
42
|
-
`Access is renewed automatically. Server version ${
|
|
48
|
+
`Access is renewed automatically. Server version ${VERSION_LABEL}.`,
|
|
43
49
|
];
|
|
44
50
|
if (caller.owner)
|
|
45
51
|
lines.push(`Sub-address of ${addressText(caller.owner)}, who is a recipient of messages you send unless include_owner is false.`);
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The parent of a reply. A recipient added to a message after it was sent (add-to) is a participant of that
|
|
3
|
+
* message only through the batch that added them, so the Web API takes their reply only with that batch's hash
|
|
4
|
+
* as the parent. They are not a participant of the rest of the thread and cannot reply there.
|
|
5
|
+
*/
|
|
6
|
+
import type { CallToolResult } from "@modelcontextprotocol/server";
|
|
7
|
+
import type { FmsgMessage } from "../client/types.js";
|
|
8
|
+
import type { Caller } from "../context.js";
|
|
9
|
+
/** How a draft names its parent: the parent's id, plus the hash to reply through when the caller was added later. */
|
|
10
|
+
export type ParentReference = {
|
|
11
|
+
pid: string;
|
|
12
|
+
parentSha256?: string;
|
|
13
|
+
};
|
|
14
|
+
/** Whether the caller was added to `message` after it was sent, rather than being its sender or a recipient. */
|
|
15
|
+
export declare function addedOnly(caller: Pick<Caller, "address">, message: FmsgMessage): boolean;
|
|
16
|
+
/**
|
|
17
|
+
* The parent reference for a reply from `caller` to `parent`. An added recipient replies through the hash of the
|
|
18
|
+
* batch that added them. A host that gives no batch hash predates the rule, so the id alone is sent there.
|
|
19
|
+
*/
|
|
20
|
+
export declare function parentReference(caller: Pick<Caller, "address">, parent: FmsgMessage): ParentReference;
|
|
21
|
+
/**
|
|
22
|
+
* The message the caller was added to in the thread above `target`, when `target` is an earlier message there that
|
|
23
|
+
* the caller cannot reply to. Searches the caller's recent inbox for messages it was added to whose thread contains
|
|
24
|
+
* `target`. Returns undefined when none is found.
|
|
25
|
+
*/
|
|
26
|
+
export declare function addedMessageBelow(caller: Caller, target: string, signal: AbortSignal): Promise<string | undefined>;
|
|
27
|
+
/** The error for a reply to a message in a thread the caller can reply to only lower down, at `added`. */
|
|
28
|
+
export declare function notParticipantError(target: string, added: string): CallToolResult;
|
|
29
|
+
/**
|
|
30
|
+
* Fetch the message a reply goes to. When it cannot be read because the caller is not a participant of it, but the
|
|
31
|
+
* caller was added to a later message in its thread, return an error naming that message.
|
|
32
|
+
*/
|
|
33
|
+
export declare function replyParent(caller: Caller, id: string, signal: AbortSignal): Promise<FmsgMessage | CallToolResult>;
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { sameAddress } from "../address.js";
|
|
2
|
+
import { FmsgHttpError } from "../client/client.js";
|
|
3
|
+
import { toolError } from "../errors.js";
|
|
4
|
+
const SHA256_HEX = /^[0-9a-f]{64}$/iu;
|
|
5
|
+
/** Whether the caller was added to `message` after it was sent, rather than being its sender or a recipient. */
|
|
6
|
+
export function addedOnly(caller, message) {
|
|
7
|
+
if (sameAddress(message.from, caller.address) || message.to.some((a) => sameAddress(a, caller.address)))
|
|
8
|
+
return false;
|
|
9
|
+
return (message.add_to ?? []).some((b) => (b.to ?? []).some((a) => sameAddress(a, caller.address)));
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* The parent reference for a reply from `caller` to `parent`. An added recipient replies through the hash of the
|
|
13
|
+
* batch that added them. A host that gives no batch hash predates the rule, so the id alone is sent there.
|
|
14
|
+
*/
|
|
15
|
+
export function parentReference(caller, parent) {
|
|
16
|
+
if (!addedOnly(caller, parent))
|
|
17
|
+
return { pid: parent.id };
|
|
18
|
+
const batch = (parent.add_to ?? []).find((b) => (b.to ?? []).some((a) => sameAddress(a, caller.address)));
|
|
19
|
+
const hash = batch?.sha256;
|
|
20
|
+
return typeof hash === "string" && SHA256_HEX.test(hash) ? { pid: parent.id, parentSha256: hash.toLowerCase() } : { pid: parent.id };
|
|
21
|
+
}
|
|
22
|
+
/** Inbox pages searched for the message the caller was added to; only on the error path. */
|
|
23
|
+
const SEARCH_PAGES = 5;
|
|
24
|
+
const PAGE_SIZE = 100;
|
|
25
|
+
/** Threads fetched while searching, so a large inbox cannot turn one failed reply into many requests. */
|
|
26
|
+
const SEARCH_THREADS = 20;
|
|
27
|
+
/**
|
|
28
|
+
* The message the caller was added to in the thread above `target`, when `target` is an earlier message there that
|
|
29
|
+
* the caller cannot reply to. Searches the caller's recent inbox for messages it was added to whose thread contains
|
|
30
|
+
* `target`. Returns undefined when none is found.
|
|
31
|
+
*/
|
|
32
|
+
export async function addedMessageBelow(caller, target, signal) {
|
|
33
|
+
const candidates = [];
|
|
34
|
+
for (let page = 0; page < SEARCH_PAGES; page++) {
|
|
35
|
+
const items = await caller.client.listInbox(PAGE_SIZE, page * PAGE_SIZE, signal);
|
|
36
|
+
candidates.push(...items.filter((m) => m.id !== target && addedOnly(caller, m)));
|
|
37
|
+
if (items.length < PAGE_SIZE)
|
|
38
|
+
break;
|
|
39
|
+
}
|
|
40
|
+
const direct = candidates.find((m) => m.pid === target);
|
|
41
|
+
if (direct)
|
|
42
|
+
return direct.id;
|
|
43
|
+
for (const m of candidates.filter((c) => c.pid).slice(0, SEARCH_THREADS)) {
|
|
44
|
+
try {
|
|
45
|
+
const thread = await caller.client.getThreadMessages(m.id, signal);
|
|
46
|
+
if (thread.messages.some((x) => x.id === target))
|
|
47
|
+
return m.id;
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
if (!(error instanceof FmsgHttpError) || error.status === 401)
|
|
51
|
+
throw error;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
/** The error for a reply to a message in a thread the caller can reply to only lower down, at `added`. */
|
|
57
|
+
export function notParticipantError(target, added) {
|
|
58
|
+
return toolError(`You cannot reply to message ${target}: you are not a participant of it. You were added to message ${added} ` +
|
|
59
|
+
`in the same thread after it was sent, so you can reply to message ${added} instead (pass id ${added}).`);
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Fetch the message a reply goes to. When it cannot be read because the caller is not a participant of it, but the
|
|
63
|
+
* caller was added to a later message in its thread, return an error naming that message.
|
|
64
|
+
*/
|
|
65
|
+
export async function replyParent(caller, id, signal) {
|
|
66
|
+
try {
|
|
67
|
+
return await caller.client.getMessage(id, signal);
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
if (!(error instanceof FmsgHttpError) || ![403, 404].includes(error.status))
|
|
71
|
+
throw error;
|
|
72
|
+
const added = await addedMessageBelow(caller, id, signal).catch(() => undefined);
|
|
73
|
+
if (added)
|
|
74
|
+
return notParticipantError(id, added);
|
|
75
|
+
throw error;
|
|
76
|
+
}
|
|
77
|
+
}
|
package/dist/tools/send.d.ts
CHANGED
|
@@ -4,15 +4,18 @@ import { type AddressResolver } from "../address.js";
|
|
|
4
4
|
import type { FmsgMessage } from "../client/types.js";
|
|
5
5
|
import type { Config } from "../config.js";
|
|
6
6
|
import type { Caller } from "../context.js";
|
|
7
|
-
import { type PreparedAttachment } from "./attachments.js";
|
|
7
|
+
import { type FileRoutes, type PreparedAttachment } from "./attachments.js";
|
|
8
8
|
import { type Register, type ToolDeps } from "./common.js";
|
|
9
9
|
export declare const IMMUTABLE = "fmsg messages are immutable: once sent they cannot be edited or recalled. Send within the user's requested task or authorized automation.";
|
|
10
10
|
export declare const IMPORTANT = "mark as important for recipients";
|
|
11
11
|
export declare const NO_REPLY = "ask recipients (and their agents) not to reply";
|
|
12
12
|
/** The upload-URL route, stated in full wherever a model may read about it. */
|
|
13
13
|
export declare const UPLOAD_STEPS: string;
|
|
14
|
+
/** UPLOAD_STEPS where get_attachment_upload_url is registered. */
|
|
15
|
+
export declare const UPLOAD_STEPS_PRESIGNED: string;
|
|
16
|
+
export declare function uploadSteps(routes: FileRoutes): string;
|
|
14
17
|
/** How to send files without the model writing their bytes, naming only what this server registered. */
|
|
15
|
-
export declare function attachmentGuidance(config: Config): string;
|
|
18
|
+
export declare function attachmentGuidance(config: Config, routes?: FileRoutes): string;
|
|
16
19
|
/**
|
|
17
20
|
* Resolve recipients, dropping duplicates, and report each name that was not already a full address, so the model
|
|
18
21
|
* can confirm a guessed address with the user.
|
package/dist/tools/send.js
CHANGED
|
@@ -5,6 +5,7 @@ import { withOwner } from "../owner.js";
|
|
|
5
5
|
import { addressList, addressText, attachmentType, headerValue, isoTime, participantsOf, sortAttachments } from "../render.js";
|
|
6
6
|
import { attachmentInput, base64Hint, fileRoutes, prepareAttachments, sendWithAttachments } from "./attachments.js";
|
|
7
7
|
import { SENDS, attachmentItem, idSchema, ok, outputObject, resolverFor, withCaller } from "./common.js";
|
|
8
|
+
import { parentReference, replyParent } from "./parent.js";
|
|
8
9
|
import { rootTopic } from "./read.js";
|
|
9
10
|
export const IMMUTABLE = "fmsg messages are immutable: once sent they cannot be edited or recalled. Send within the user's requested task or authorized automation.";
|
|
10
11
|
export const IMPORTANT = "mark as important for recipients";
|
|
@@ -12,14 +13,20 @@ export const NO_REPLY = "ask recipients (and their agents) not to reply";
|
|
|
12
13
|
/** The upload-URL route, stated in full wherever a model may read about it. */
|
|
13
14
|
export const UPLOAD_STEPS = "PUT each file's raw bytes to the draft's upload URL with this connection's Authorization header, then call " +
|
|
14
15
|
"send_draft. If your host cannot send this connection's credentials, use data_base64; never look for or copy credentials";
|
|
16
|
+
/** UPLOAD_STEPS where get_attachment_upload_url is registered. */
|
|
17
|
+
export const UPLOAD_STEPS_PRESIGNED = "PUT each file's raw bytes to the draft's upload URL with this connection's Authorization header, then call " +
|
|
18
|
+
"send_draft. If your host cannot send this connection's credentials, get a single-use link per file from get_attachment_upload_url " +
|
|
19
|
+
"instead; never look for or copy credentials";
|
|
20
|
+
export function uploadSteps(routes) {
|
|
21
|
+
return routes.presigned ? UPLOAD_STEPS_PRESIGNED : UPLOAD_STEPS;
|
|
22
|
+
}
|
|
15
23
|
/** How to send files without the model writing their bytes, naming only what this server registered. */
|
|
16
|
-
export function attachmentGuidance(config) {
|
|
17
|
-
const routes = fileRoutes(config);
|
|
24
|
+
export function attachmentGuidance(config, routes = fileRoutes(config)) {
|
|
18
25
|
const large = [
|
|
19
|
-
...(routes.uploadUrl ? [`use create_draft instead: ${
|
|
26
|
+
...(routes.uploadUrl ? [`use create_draft instead: ${uploadSteps(routes)}`] : []),
|
|
20
27
|
...(routes.uploadFolder ? ["give path, a file in the upload folder"] : []),
|
|
21
28
|
];
|
|
22
|
-
return "Files: data_text for text
|
|
29
|
+
return "Files: data_text for text; data_base64 for small files, best effort, always with sha256. " +
|
|
23
30
|
(large.length ? `For large files, ${large.join(". Or ")}. ` : "") +
|
|
24
31
|
"Send files as they are; fmsg compresses them.";
|
|
25
32
|
}
|
|
@@ -98,10 +105,10 @@ export function resolvedNote(resolved) {
|
|
|
98
105
|
return resolved.length ? ` Short names resolved: ${resolved.map((r) => `${r.input} → ${addressText(r.address)}`).join(", ")}.` : "";
|
|
99
106
|
}
|
|
100
107
|
export const registerSendTools = (server, deps) => {
|
|
101
|
-
const
|
|
102
|
-
const
|
|
108
|
+
const routes = fileRoutes(deps.config, Boolean(deps.links));
|
|
109
|
+
const attachments = z.array(attachmentInput(deps.config, routes)).optional().describe("files");
|
|
110
|
+
const guidance = attachmentGuidance(deps.config, routes);
|
|
103
111
|
const includeOwner = deps.includeOwner ? { include_owner: deps.includeOwner } : {};
|
|
104
|
-
const routes = fileRoutes(deps.config);
|
|
105
112
|
server.registerTool("send_message", {
|
|
106
113
|
title: "Send new fmsg message",
|
|
107
114
|
description: `Send a new message immediately, starting a new thread. ${IMMUTABLE} ` +
|
|
@@ -150,7 +157,8 @@ export const registerSendTools = (server, deps) => {
|
|
|
150
157
|
title: "Reply in fmsg thread",
|
|
151
158
|
description: `Send an immediate reply to a message (linking it into that thread). ${IMMUTABLE} ` +
|
|
152
159
|
"By default the reply goes to everyone on the parent message — its sender, recipients and anyone added later — " +
|
|
153
|
-
"except you; pass recipients to narrow or widen that.
|
|
160
|
+
"except you; pass recipients to narrow or widen that. If you were added to the message after it was sent, you can " +
|
|
161
|
+
"reply to it but not to earlier messages in its thread. Fails if the parent is terminal (no replies possible); " +
|
|
154
162
|
`a parent marked no-reply is refused unless allow_no_reply is true. Secrets are redacted. ${guidance}`,
|
|
155
163
|
inputSchema: z.strictObject({
|
|
156
164
|
id: idSchema.describe("message to reply to"),
|
|
@@ -166,7 +174,9 @@ export const registerSendTools = (server, deps) => {
|
|
|
166
174
|
outputSchema: sentOutput,
|
|
167
175
|
annotations: SENDS,
|
|
168
176
|
}, async ({ id, body, recipients, type, important, no_reply, allow_no_reply, include_owner, attachments }, ctx) => withCaller(deps, ctx, async (caller, signal) => {
|
|
169
|
-
const parent = await caller
|
|
177
|
+
const parent = await replyParent(caller, id, signal);
|
|
178
|
+
if ("content" in parent)
|
|
179
|
+
return parent;
|
|
170
180
|
const target = replyRecipients(deps, caller, parent, recipients, allow_no_reply);
|
|
171
181
|
if ("content" in target)
|
|
172
182
|
return target;
|
|
@@ -174,7 +184,7 @@ export const registerSendTools = (server, deps) => {
|
|
|
174
184
|
const to = withOwner(caller, target.to, include_owner);
|
|
175
185
|
const outbound = await prepareAttachments(attachments, deps.config);
|
|
176
186
|
const hint = base64Hint(attachments, routes);
|
|
177
|
-
const sent = await sendWithAttachments(caller.client, { to,
|
|
187
|
+
const sent = await sendWithAttachments(caller.client, { to, ...parentReference(caller, parent), body, type, important, noReply: no_reply }, outbound, signal);
|
|
178
188
|
const structured = {
|
|
179
189
|
id: sent.id,
|
|
180
190
|
time: isoTime(sent.time),
|
package/dist/upload.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { Caller } from "./context.js";
|
|
1
2
|
export declare const UPLOAD_PATH = "/mcp/drafts";
|
|
2
3
|
/** Content-Encoding values accepted on uploads; decoded before the bytes reach fmsg. */
|
|
3
4
|
export declare const UPLOAD_ENCODINGS: readonly ["gzip", "deflate"];
|
|
@@ -26,3 +27,24 @@ export declare function drainableBody(body: ReadableStream<Uint8Array>, idleMs:
|
|
|
26
27
|
export declare function decodedUploadBody(body: ReadableStream<Uint8Array>, encoding: string | undefined): ReadableStream<Uint8Array>;
|
|
27
28
|
/** The upload's Content-Encoding, lowercased; "identity" or none means undecoded. Throws for an unsupported one. */
|
|
28
29
|
export declare function uploadEncoding(header: string | null): string | undefined;
|
|
30
|
+
/** Who keeps track of uploads to one kind of link: one at a time per file, and which were verified. */
|
|
31
|
+
export type UploadTracker = {
|
|
32
|
+
begin(key: string): boolean;
|
|
33
|
+
end(key: string): void;
|
|
34
|
+
recordVerified(key: string, sha256: string): void;
|
|
35
|
+
verifiedSha256(key: string): string | undefined;
|
|
36
|
+
};
|
|
37
|
+
/** One file a link accepts: into which draft, under which name, and exactly which bytes. */
|
|
38
|
+
export type UploadSlot = {
|
|
39
|
+
draft: string;
|
|
40
|
+
filename: string;
|
|
41
|
+
size: number;
|
|
42
|
+
sha256: string;
|
|
43
|
+
mimeType: string;
|
|
44
|
+
fileId?: string;
|
|
45
|
+
};
|
|
46
|
+
/**
|
|
47
|
+
* Stream one upload into a draft, refusing any body that is not exactly the slot's size and SHA-256, and a second
|
|
48
|
+
* upload after one succeeded. `noun` names the link in messages ("target", "link").
|
|
49
|
+
*/
|
|
50
|
+
export declare function streamIntoDraft(tracker: UploadTracker, caller: Caller, slot: UploadSlot, request: Request, encoding: string | undefined, signal: AbortSignal, noun: string): Promise<Response>;
|