@markmnl/fmsg-mcp 1.0.0 → 1.1.1
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 +35 -0
- package/README.md +35 -4
- package/dist/client/client.d.ts +38 -7
- package/dist/client/client.js +185 -30
- package/dist/client/index.d.ts +1 -1
- package/dist/client/index.js +1 -1
- package/dist/client/types.d.ts +21 -0
- package/dist/config.d.ts +2 -0
- package/dist/config.js +1 -0
- package/dist/http.d.ts +2 -0
- package/dist/http.js +117 -23
- package/dist/instructions.d.ts +3 -0
- package/dist/instructions.js +11 -2
- package/dist/oauth/errors.d.ts +1 -0
- package/dist/oauth/errors.js +1 -0
- package/dist/oauth/scopes.js +1 -0
- package/dist/prompts.d.ts +2 -1
- package/dist/prompts.js +47 -1
- package/dist/server.js +5 -1
- package/dist/tools/attachments.d.ts +66 -0
- package/dist/tools/attachments.js +165 -0
- package/dist/tools/draft.d.ts +2 -0
- package/dist/tools/draft.js +151 -0
- package/dist/tools/read.js +6 -2
- package/dist/tools/send.d.ts +85 -1
- package/dist/tools/send.js +69 -60
- package/dist/upload.d.ts +28 -0
- package/dist/upload.js +95 -0
- package/docs/http-deployment.md +36 -3
- package/package.json +1 -1
- package/server.json +2 -2
package/dist/http.js
CHANGED
|
@@ -4,10 +4,14 @@ import { createMcpHandler, hostHeaderValidationResponse, localhostAllowedHostnam
|
|
|
4
4
|
import { ApiKeyCallerProvider, FMSG_SCOPE } from "./auth.js";
|
|
5
5
|
import { createFmsgMcpServer } from "./server.js";
|
|
6
6
|
import { OAuthCallerProvider } from "./oauth/provider.js";
|
|
7
|
-
import { invalidToken, insufficientScope, OAuthRequestError, oauthErrorResponse, oauthRequestState } from "./oauth/errors.js";
|
|
8
|
-
import { MESSAGING_SCOPES, READ_SCOPE, requestScopes } from "./oauth/scopes.js";
|
|
7
|
+
import { invalidToken, insufficientScope, missingToken, OAuthRequestError, oauthErrorResponse, oauthRequestState } from "./oauth/errors.js";
|
|
8
|
+
import { MESSAGING_SCOPES, READ_SCOPE, WRITE_SCOPE, requestScopes } from "./oauth/scopes.js";
|
|
9
9
|
import { DOWNLOAD_PATH, downloadBaseUrl, downloadHeaders, parseDownloadPath } from "./download.js";
|
|
10
|
+
import { CHECKSUM_HEADER, UPLOAD_DRAIN_IDLE_MS, UPLOAD_PATH, UploadBodyError, decodedUploadBody, drainableBody, parseUploadPath, uploadEncoding } from "./upload.js";
|
|
11
|
+
import { AttachmentChecksumError, UploadInterruptedError } from "./client/client.js";
|
|
10
12
|
import { FmsgHttpError } from "./client/errors.js";
|
|
13
|
+
import { sameAddress } from "./address.js";
|
|
14
|
+
import { attachmentType } from "./render.js";
|
|
11
15
|
import { describeError } from "./errors.js";
|
|
12
16
|
import { VERSION } from "./version.js";
|
|
13
17
|
import { safeErrorMessage } from "./client/redact.js";
|
|
@@ -16,7 +20,10 @@ export const MCP_PATH = "/mcp";
|
|
|
16
20
|
/** How long close() lets in-flight requests finish (waits end at once) before cutting them off. */
|
|
17
21
|
export const SHUTDOWN_GRACE_MS = 5000;
|
|
18
22
|
const CORS_METHODS = ["POST", "GET", "DELETE"];
|
|
19
|
-
const CORS_HEADERS = ["authorization", "content-type", "accept", "mcp-protocol-version", "mcp-method", "mcp-name", "mcp-session-id", "last-event-id"
|
|
23
|
+
const CORS_HEADERS = ["authorization", "content-type", "accept", "mcp-protocol-version", "mcp-method", "mcp-name", "mcp-session-id", "last-event-id",
|
|
24
|
+
"content-encoding", CHECKSUM_HEADER];
|
|
25
|
+
/** How long a request other than an attachment upload may take to arrive in full. Uploads use an idle budget instead. */
|
|
26
|
+
export const REQUEST_BODY_TIMEOUT_MS = 60_000;
|
|
20
27
|
// Combined mode routes only fmsg API keys to API-key authentication; everything else is OAuth.
|
|
21
28
|
const API_KEY_BEARER = /^Bearer fmsgk_/iu;
|
|
22
29
|
/** Convert a Node request into a web-standard Request for the MCP handler. */
|
|
@@ -98,6 +105,18 @@ export async function sendWebResponse(res, response, failure) {
|
|
|
98
105
|
res.end();
|
|
99
106
|
}
|
|
100
107
|
}
|
|
108
|
+
/**
|
|
109
|
+
* After the host cut off an upload, the reason it gave is lost; say why when the draft shows it. A failed lookup
|
|
110
|
+
* throws the host's own error, such as a draft the caller cannot see.
|
|
111
|
+
*/
|
|
112
|
+
async function draftRefusal(caller, id, signal) {
|
|
113
|
+
const draft = await caller.client.getMessage(id, signal);
|
|
114
|
+
if (draft.time !== null && draft.time !== undefined)
|
|
115
|
+
return `message ${id} was already sent; attachments can be added only to drafts`;
|
|
116
|
+
if (!sameAddress(draft.from, caller.address))
|
|
117
|
+
return `message ${id} is not your draft; only its sender can add attachments`;
|
|
118
|
+
return undefined;
|
|
119
|
+
}
|
|
101
120
|
export function createHttpServer(config, log = (l) => console.error(l)) {
|
|
102
121
|
const allowedHosts = config.http.allowedHosts.length
|
|
103
122
|
? config.http.allowedHosts
|
|
@@ -135,8 +154,16 @@ export function createHttpServer(config, log = (l) => console.error(l)) {
|
|
|
135
154
|
const abort = () => controller.abort();
|
|
136
155
|
req.once("aborted", abort);
|
|
137
156
|
res.once("close", abort);
|
|
157
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
158
|
+
const isUpload = url.pathname.startsWith(`${UPLOAD_PATH}/`);
|
|
159
|
+
// A progressing upload may take longer than any fixed budget; the upload stream has an idle timeout instead.
|
|
160
|
+
// Closing the connection afterwards stops Node reading the rest of an upload refused before authentication;
|
|
161
|
+
// an authenticated caller's failed upload is drained first so its answer arrives (see serveUpload).
|
|
162
|
+
const bodyTimer = isUpload ? undefined : setTimeout(() => { if (!req.complete)
|
|
163
|
+
req.destroy(); }, REQUEST_BODY_TIMEOUT_MS).unref();
|
|
164
|
+
if (isUpload)
|
|
165
|
+
res.setHeader("connection", "close");
|
|
138
166
|
const done = (async () => {
|
|
139
|
-
const url = new URL(req.url ?? "/", "http://localhost");
|
|
140
167
|
const isDownload = url.pathname.startsWith(`${DOWNLOAD_PATH}/`);
|
|
141
168
|
if (url.pathname === "/healthz") {
|
|
142
169
|
res.writeHead(200, { "content-type": "application/json" });
|
|
@@ -156,7 +183,7 @@ export function createHttpServer(config, log = (l) => console.error(l)) {
|
|
|
156
183
|
authorization_servers: [config.oauth.issuerUrl], scopes_supported: MESSAGING_SCOPES,
|
|
157
184
|
bearer_methods_supported: ["header"], resource_name: "fmsg messaging", }, { headers }));
|
|
158
185
|
}
|
|
159
|
-
if (url.pathname !== MCP_PATH && !isDownload) {
|
|
186
|
+
if (url.pathname !== MCP_PATH && !isDownload && !isUpload) {
|
|
160
187
|
res.writeHead(404, { "content-type": "text/plain" });
|
|
161
188
|
res.end("not found; the MCP endpoint is /mcp");
|
|
162
189
|
return;
|
|
@@ -188,7 +215,7 @@ export function createHttpServer(config, log = (l) => console.error(l)) {
|
|
|
188
215
|
if (req.method === "OPTIONS") {
|
|
189
216
|
const method = request.headers.get("access-control-request-method") ?? "";
|
|
190
217
|
const headers = (request.headers.get("access-control-request-headers") ?? "").split(",").map((v) => v.trim().toLowerCase()).filter(Boolean);
|
|
191
|
-
const methods = isDownload ? ["GET"] : CORS_METHODS;
|
|
218
|
+
const methods = isDownload ? ["GET"] : isUpload ? ["PUT"] : CORS_METHODS;
|
|
192
219
|
if (!origin || !methods.includes(method) || headers.some((h) => !CORS_HEADERS.includes(h))) {
|
|
193
220
|
return sendWebResponse(res, new Response("preflight not allowed", { status: 403 }));
|
|
194
221
|
}
|
|
@@ -210,20 +237,81 @@ export function createHttpServer(config, log = (l) => console.error(l)) {
|
|
|
210
237
|
return sendWebResponse(res, new Response("invalid attachment download path", { status: 400 }));
|
|
211
238
|
}
|
|
212
239
|
}
|
|
213
|
-
|
|
214
|
-
|
|
240
|
+
let upload;
|
|
241
|
+
if (isUpload) {
|
|
242
|
+
// Refusals are JSON, like the errors from the upload itself.
|
|
243
|
+
const uploadRefusal = (error, status, headers = {}) => sendWebResponse(res, Response.json({ error }, { status, headers }));
|
|
244
|
+
if (req.method !== "PUT")
|
|
245
|
+
return uploadRefusal("use PUT to upload attachments", 405, { allow: "PUT, OPTIONS" });
|
|
246
|
+
if (url.search)
|
|
247
|
+
return uploadRefusal("attachment URLs must not contain query parameters", 400);
|
|
215
248
|
try {
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
249
|
+
upload = { ...parseUploadPath(url.pathname), contentType: "" };
|
|
250
|
+
}
|
|
251
|
+
catch {
|
|
252
|
+
return uploadRefusal("invalid attachment upload path; use /mcp/drafts/{id}/attachments/{filename}", 400);
|
|
253
|
+
}
|
|
254
|
+
try {
|
|
255
|
+
upload.encoding = uploadEncoding(request.headers.get("content-encoding"));
|
|
256
|
+
}
|
|
257
|
+
catch (error) {
|
|
258
|
+
return uploadRefusal(error.message, 415);
|
|
259
|
+
}
|
|
260
|
+
const sha256 = request.headers.get(CHECKSUM_HEADER)?.trim();
|
|
261
|
+
if (sha256 !== undefined) {
|
|
262
|
+
if (!/^[0-9A-Fa-f]{64}$/u.test(sha256))
|
|
263
|
+
return uploadRefusal(`${CHECKSUM_HEADER} must be 64 hexadecimal characters`, 400);
|
|
264
|
+
upload.sha256 = sha256.toLowerCase();
|
|
265
|
+
}
|
|
266
|
+
// curl and similar tools label raw bodies as form data by default; that says nothing about the file.
|
|
267
|
+
const declared = request.headers.get("content-type")?.trim();
|
|
268
|
+
if (declared && !/^[\x20-\x7e]+$/u.test(declared))
|
|
269
|
+
return uploadRefusal("Content-Type must be printable ASCII", 400);
|
|
270
|
+
upload.contentType = declared && !/^application\/x-www-form-urlencoded\b/iu.test(declared)
|
|
271
|
+
? attachmentType(upload.filename, declared) : attachmentType(upload.filename);
|
|
272
|
+
}
|
|
273
|
+
const serveDownload = (provider, auth) => serveTransfer(provider, auth, READ_SCOPE, async (caller) => {
|
|
274
|
+
const { stream, contentType } = await caller.client.streamAttachment(download.id, download.filename, controller.signal);
|
|
275
|
+
let response;
|
|
276
|
+
try {
|
|
277
|
+
response = new Response(stream, { headers: downloadHeaders(download.filename, contentType) });
|
|
278
|
+
}
|
|
279
|
+
catch (error) {
|
|
280
|
+
await stream.cancel().catch(() => undefined);
|
|
281
|
+
throw error;
|
|
282
|
+
}
|
|
283
|
+
return sendWebResponse(res, response);
|
|
284
|
+
});
|
|
285
|
+
const serveUpload = (provider, auth) => serveTransfer(provider, auth, WRITE_SCOPE, async (caller) => {
|
|
286
|
+
const { stream, drain } = drainableBody(request.body ?? new ReadableStream({ start: (c) => c.close() }), UPLOAD_DRAIN_IDLE_MS);
|
|
287
|
+
const body = decodedUploadBody(stream, upload.encoding);
|
|
288
|
+
let uploaded;
|
|
289
|
+
try {
|
|
290
|
+
uploaded = await caller.client.uploadAttachmentStream(upload.id, {
|
|
291
|
+
filename: upload.filename, stream: body, contentType: upload.contentType, ...(upload.sha256 ? { expectedSha256: upload.sha256 } : {}),
|
|
292
|
+
}, controller.signal);
|
|
293
|
+
}
|
|
294
|
+
catch (error) {
|
|
295
|
+
// Answering while the client is still sending would close the connection with unread data, resetting it
|
|
296
|
+
// and losing the answer; the caller is authenticated and could send these bytes to its own draft anyway.
|
|
297
|
+
if (!(await drain())) {
|
|
298
|
+
res.destroy();
|
|
299
|
+
return;
|
|
221
300
|
}
|
|
222
|
-
|
|
223
|
-
await stream.cancel().catch(() => undefined);
|
|
301
|
+
if (!(error instanceof UploadInterruptedError))
|
|
224
302
|
throw error;
|
|
225
|
-
}
|
|
226
|
-
|
|
303
|
+
const refusal = await draftRefusal(caller, upload.id, controller.signal).catch((lookup) => { throw lookup instanceof FmsgHttpError ? lookup : error; });
|
|
304
|
+
if (refusal === undefined)
|
|
305
|
+
throw error;
|
|
306
|
+
return sendWebResponse(res, Response.json({ error: refusal }, { status: 403 }));
|
|
307
|
+
}
|
|
308
|
+
return sendWebResponse(res, Response.json({ id: upload.id, ...uploaded, type: upload.contentType }, { status: 201 }));
|
|
309
|
+
});
|
|
310
|
+
const serveTransfer = async (provider, auth, scope, transfer) => {
|
|
311
|
+
let caller;
|
|
312
|
+
try {
|
|
313
|
+
caller = await provider.forRequest(auth);
|
|
314
|
+
return await transfer(caller);
|
|
227
315
|
}
|
|
228
316
|
catch (error) {
|
|
229
317
|
const rejectedAuth = (error instanceof OAuthRequestError && error.status === 401) ||
|
|
@@ -242,10 +330,11 @@ export function createHttpServer(config, log = (l) => console.error(l)) {
|
|
|
242
330
|
if (rejectedAuth)
|
|
243
331
|
return sendWebResponse(res, oauthErrorResponse(invalidToken(), metadataUrl));
|
|
244
332
|
if (error instanceof FmsgHttpError && error.insufficientScope) {
|
|
245
|
-
return sendWebResponse(res, oauthErrorResponse(new OAuthRequestError(403, "insufficient_scope", error.message, [
|
|
333
|
+
return sendWebResponse(res, oauthErrorResponse(new OAuthRequestError(403, "insufficient_scope", error.message, [scope]), metadataUrl));
|
|
246
334
|
}
|
|
247
335
|
}
|
|
248
|
-
const status = rejectedAuth ? 401 : error instanceof
|
|
336
|
+
const status = rejectedAuth ? 401 : error instanceof AttachmentChecksumError ? 422 : error instanceof UploadBodyError ? 400
|
|
337
|
+
: error instanceof FmsgHttpError && error.status >= 400 && error.status <= 599 ? error.status : 502;
|
|
249
338
|
const challenge = `Bearer error="invalid_token"${resource ? `, resource_metadata=${JSON.stringify(metadataUrl)}` : ""}`;
|
|
250
339
|
return sendWebResponse(res, Response.json({ error: describeError(error) }, {
|
|
251
340
|
status, headers: status === 401 ? { "www-authenticate": challenge } : {},
|
|
@@ -257,7 +346,7 @@ export function createHttpServer(config, log = (l) => console.error(l)) {
|
|
|
257
346
|
try {
|
|
258
347
|
const bearer = /^Bearer ([A-Za-z0-9._~+\/-]+=*)$/iu.exec(authorization);
|
|
259
348
|
if (!bearer)
|
|
260
|
-
throw invalidToken();
|
|
349
|
+
throw authorization ? invalidToken() : missingToken();
|
|
261
350
|
const authenticated = await oauthProvider.verifyAccessToken(bearer[1]);
|
|
262
351
|
release = () => oauthProvider.release(authenticated);
|
|
263
352
|
let parsedBody;
|
|
@@ -271,11 +360,13 @@ export function createHttpServer(config, log = (l) => console.error(l)) {
|
|
|
271
360
|
if (Array.isArray(parsedBody))
|
|
272
361
|
return sendWebResponse(res, new Response("MCP batch requests are not supported", { status: 400 }));
|
|
273
362
|
}
|
|
274
|
-
const scopes = download ? [READ_SCOPE] : requestScopes(parsedBody);
|
|
363
|
+
const scopes = download ? [READ_SCOPE] : upload ? [WRITE_SCOPE] : requestScopes(parsedBody);
|
|
275
364
|
if (scopes.some(scope => !authenticated.scopes.includes(scope)))
|
|
276
365
|
throw insufficientScope(scopes);
|
|
277
366
|
if (download)
|
|
278
367
|
return await serveDownload(oauthProvider, authenticated);
|
|
368
|
+
if (upload)
|
|
369
|
+
return await serveUpload(oauthProvider, authenticated);
|
|
279
370
|
// Discover/list operations only need the incoming JWT. Resolve an
|
|
280
371
|
// exchanged credential before starting any protected tool response.
|
|
281
372
|
if (scopes.length) {
|
|
@@ -314,6 +405,8 @@ export function createHttpServer(config, log = (l) => console.error(l)) {
|
|
|
314
405
|
return sendWebResponse(res, auth);
|
|
315
406
|
if (download)
|
|
316
407
|
return serveDownload(apiKeyProvider, auth);
|
|
408
|
+
if (upload)
|
|
409
|
+
return serveUpload(apiKeyProvider, auth);
|
|
317
410
|
return sendWebResponse(res, await apiKeyHandler.fetch(request, { authInfo: auth }));
|
|
318
411
|
})().catch((error) => {
|
|
319
412
|
safeLog(`request failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
@@ -326,6 +419,7 @@ export function createHttpServer(config, log = (l) => console.error(l)) {
|
|
|
326
419
|
res.writeHead(500, { "content-type": "text/plain" });
|
|
327
420
|
res.end("internal error");
|
|
328
421
|
}).finally(() => {
|
|
422
|
+
clearTimeout(bodyTimer);
|
|
329
423
|
release?.();
|
|
330
424
|
active.delete(controller);
|
|
331
425
|
req.off("aborted", abort);
|
|
@@ -333,8 +427,8 @@ export function createHttpServer(config, log = (l) => console.error(l)) {
|
|
|
333
427
|
});
|
|
334
428
|
active.set(controller, { res, done });
|
|
335
429
|
});
|
|
336
|
-
//
|
|
337
|
-
server.requestTimeout =
|
|
430
|
+
// Request bodies are bounded per request above (REQUEST_BODY_TIMEOUT_MS, or the upload idle budget).
|
|
431
|
+
server.requestTimeout = 0;
|
|
338
432
|
server.headersTimeout = 60_000;
|
|
339
433
|
server.keepAliveTimeout = 65_000;
|
|
340
434
|
let closing;
|
package/dist/instructions.d.ts
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
|
+
import type { FileRoutes } from "./tools/attachments.js";
|
|
1
2
|
export type InstructionsContext = {
|
|
2
3
|
/** The address this server acts as, when already known (HTTP callers; stdio after a token exchange). */
|
|
3
4
|
address?: string;
|
|
4
5
|
/** FMSG_DEFAULT_DOMAIN: a domain, or CALLER_DOMAIN for the caller's own domain. */
|
|
5
6
|
defaultDomain?: string;
|
|
7
|
+
/** Large-file routes this server offers; none when omitted. */
|
|
8
|
+
files?: FileRoutes;
|
|
6
9
|
};
|
|
7
10
|
export declare function buildInstructions(ctx?: InstructionsContext): string;
|
package/dist/instructions.js
CHANGED
|
@@ -2,11 +2,19 @@
|
|
|
2
2
|
* Server instructions: returned in the MCP `initialize` result and folded into
|
|
3
3
|
* the model's system prompt by hosts that support them (some chat hosts do not, so
|
|
4
4
|
* nothing a tool needs may live only here). Rules that span tools, each stated once:
|
|
5
|
-
* which address this server acts as, the fmsg terms, the inbox routine,
|
|
6
|
-
* irreversible-send rule and untrusted content. Per-tool detail lives in the tool
|
|
5
|
+
* which address this server acts as, the fmsg terms, the inbox routine, how to
|
|
6
|
+
* send files, the irreversible-send rule and untrusted content. Per-tool detail lives in the tool
|
|
7
7
|
* descriptions; situational guidance in results.
|
|
8
8
|
*/
|
|
9
9
|
import { CALLER_DOMAIN, effectiveDefaultDomain } from "./address.js";
|
|
10
|
+
/** How to send files, naming only the routes this server offers. */
|
|
11
|
+
function filesParagraph(files) {
|
|
12
|
+
const large = [
|
|
13
|
+
...(files?.uploadUrl ? ["create_draft, PUT each file to its upload URL, then send_draft"] : []),
|
|
14
|
+
...(files?.uploadFolder ? ["give path, a file in the upload folder"] : []),
|
|
15
|
+
];
|
|
16
|
+
return "Files: data_text for text, data_base64 for small files." + (large.length ? ` Large files: ${large.join("; or ")}.` : "");
|
|
17
|
+
}
|
|
10
18
|
export function buildInstructions(ctx = {}) {
|
|
11
19
|
const identity = ctx.address
|
|
12
20
|
? `you are acting as ${ctx.address}`
|
|
@@ -27,6 +35,7 @@ export function buildInstructions(ctx = {}) {
|
|
|
27
35
|
"To check the inbox: list_messages with unread_only, get_thread on what matters, act or reply, then mark_read " +
|
|
28
36
|
"what you have handled; reading does not mark a message read. There is no search: to find a thread by name, " +
|
|
29
37
|
"find its first message by topic in list_messages, then get_thread on a later message for the conversation.",
|
|
38
|
+
filesParagraph(ctx.files),
|
|
30
39
|
"Carry out the user's requested messaging task or authorized automation without repeatedly asking for " +
|
|
31
40
|
"confirmation. Sending is immediate and sent messages cannot be edited or recalled. Ask the user only " +
|
|
32
41
|
"when a decision is needed to resolve unclear intent, recipients or content.",
|
package/dist/oauth/errors.d.ts
CHANGED
|
@@ -14,5 +14,6 @@ export declare class OAuthRequestError extends Error {
|
|
|
14
14
|
constructor(status: 401 | 403 | 500 | 503, code: string, message: string, scopes?: string[]);
|
|
15
15
|
}
|
|
16
16
|
export declare const invalidToken: () => OAuthRequestError;
|
|
17
|
+
export declare const missingToken: () => OAuthRequestError;
|
|
17
18
|
export declare const insufficientScope: (scopes: string[]) => OAuthRequestError;
|
|
18
19
|
export declare function oauthErrorResponse(error: OAuthRequestError, metadataUrl: string): Response;
|
package/dist/oauth/errors.js
CHANGED
|
@@ -27,6 +27,7 @@ export class OAuthRequestError extends Error {
|
|
|
27
27
|
}
|
|
28
28
|
}
|
|
29
29
|
export const invalidToken = () => new OAuthRequestError(401, "invalid_token", "The OAuth connection must be refreshed or reconnected.");
|
|
30
|
+
export const missingToken = () => new OAuthRequestError(401, "invalid_token", "No access token was sent; send this MCP connection's Authorization header.");
|
|
30
31
|
export const insufficientScope = (scopes) => new OAuthRequestError(403, "insufficient_scope", "The OAuth connection does not authorize this operation.", scopes);
|
|
31
32
|
export function oauthErrorResponse(error, metadataUrl) {
|
|
32
33
|
const headers = new Headers({ "cache-control": "no-store" });
|
package/dist/oauth/scopes.js
CHANGED
|
@@ -10,6 +10,7 @@ export const TOOL_SCOPES = {
|
|
|
10
10
|
save_attachment: [READ_SCOPE], get_attachment_download_url: [READ_SCOPE], wait_for_message: [READ_SCOPE],
|
|
11
11
|
send_message: [WRITE_SCOPE], reply: MESSAGING_SCOPES,
|
|
12
12
|
mark_read: [WRITE_SCOPE], add_recipients: [WRITE_SCOPE], react: [WRITE_SCOPE],
|
|
13
|
+
create_draft: MESSAGING_SCOPES, send_draft: MESSAGING_SCOPES, delete_draft: MESSAGING_SCOPES,
|
|
13
14
|
};
|
|
14
15
|
export function parseScopes(value) {
|
|
15
16
|
if (typeof value !== "string")
|
package/dist/prompts.d.ts
CHANGED
|
@@ -1,2 +1,3 @@
|
|
|
1
1
|
import type { McpServer } from "@modelcontextprotocol/server";
|
|
2
|
-
|
|
2
|
+
import type { FileRoutes } from "./tools/attachments.js";
|
|
3
|
+
export declare function registerPrompts(server: McpServer, routes: FileRoutes): void;
|
package/dist/prompts.js
CHANGED
|
@@ -1,5 +1,51 @@
|
|
|
1
1
|
import * as z from "zod/v4";
|
|
2
|
-
|
|
2
|
+
/** Steps for sending files, using the best route this server offers. */
|
|
3
|
+
function sendFileSteps(routes, to, replyTo) {
|
|
4
|
+
const recipients = to ? `to ${to}, with a short topic` : "to the recipients I name (ask me if I have not)";
|
|
5
|
+
const draft = replyTo ? `with reply_to "${replyTo}"${to ? ` and to: ${to}` : ""}` : recipients;
|
|
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, each with its sha256 when you can compute it";
|
|
8
|
+
if (routes.uploadUrl) {
|
|
9
|
+
return [
|
|
10
|
+
`1. Call create_draft ${draft}; ${inline}.`,
|
|
11
|
+
"2. For each larger file, PUT its raw bytes to the draft's upload URL with your host's authenticated HTTP tools, " +
|
|
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, recreate the draft with the file as data_base64 instead; never look for or copy credentials.",
|
|
14
|
+
"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
|
+
];
|
|
16
|
+
}
|
|
17
|
+
if (routes.uploadFolder) {
|
|
18
|
+
return [
|
|
19
|
+
`1. Call ${direct}; ${inline}.`,
|
|
20
|
+
"2. Give each larger file as path, relative to the upload folder, instead of its data. A file outside the folder must be " +
|
|
21
|
+
"copied into it first; if you cannot, ask me to.",
|
|
22
|
+
"3. Tell me what was sent.",
|
|
23
|
+
];
|
|
24
|
+
}
|
|
25
|
+
return [
|
|
26
|
+
`1. Call ${direct}; ${inline}.`,
|
|
27
|
+
"2. This server has no upload route, so every file is written inline: tell me first if a file is too large for that to be practical.",
|
|
28
|
+
"3. Tell me what was sent.",
|
|
29
|
+
];
|
|
30
|
+
}
|
|
31
|
+
export function registerPrompts(server, routes) {
|
|
32
|
+
server.registerPrompt("send_file", {
|
|
33
|
+
title: "Send files over fmsg",
|
|
34
|
+
description: "Send files to fmsg recipients or as a reply, using the most efficient way this server accepts them.",
|
|
35
|
+
argsSchema: z.object({
|
|
36
|
+
files: z.string().describe("the files to send: paths, or a description of them"),
|
|
37
|
+
to: z.string().optional().describe("recipients, separated by commas"),
|
|
38
|
+
reply_to: z.string().optional().describe("message id to reply to instead of starting a thread"),
|
|
39
|
+
message: z.string().optional().describe("what the message should say"),
|
|
40
|
+
}),
|
|
41
|
+
}, ({ files, to, reply_to, message }) => {
|
|
42
|
+
const text = [
|
|
43
|
+
`Send these files over fmsg: ${files}.${message ? ` The message should say: ${message}` : " Write a short message saying what they are."}`,
|
|
44
|
+
"Send the files as they are, without zipping or re-encoding them; fmsg compresses them in transit.",
|
|
45
|
+
...sendFileSteps(routes, to, reply_to),
|
|
46
|
+
].join("\n");
|
|
47
|
+
return { messages: [{ role: "user", content: { type: "text", text } }] };
|
|
48
|
+
});
|
|
3
49
|
server.registerPrompt("chat", {
|
|
4
50
|
title: "Chat over fmsg",
|
|
5
51
|
description: "Wait for incoming fmsg messages and reply on the user's behalf, once or continuously within a thread.",
|
package/dist/server.js
CHANGED
|
@@ -2,10 +2,12 @@ import { McpServer } from "@modelcontextprotocol/server";
|
|
|
2
2
|
import { buildInstructions } from "./instructions.js";
|
|
3
3
|
import { registerPrompts } from "./prompts.js";
|
|
4
4
|
import { registerResources } from "./resources.js";
|
|
5
|
+
import { fileRoutes } from "./tools/attachments.js";
|
|
5
6
|
import { registerIdentityTools } from "./tools/identity.js";
|
|
6
7
|
import { registerListTools } from "./tools/list.js";
|
|
7
8
|
import { registerReadTools } from "./tools/read.js";
|
|
8
9
|
import { registerSendTools } from "./tools/send.js";
|
|
10
|
+
import { registerDraftTools } from "./tools/draft.js";
|
|
9
11
|
import { registerWaitTools } from "./tools/wait.js";
|
|
10
12
|
import { registerSaveTool } from "./tools/save.js";
|
|
11
13
|
import { registerDownloadTool } from "./tools/download.js";
|
|
@@ -19,6 +21,7 @@ export function createFmsgMcpServer(provider, config, options = {}) {
|
|
|
19
21
|
const instructions = buildInstructions({
|
|
20
22
|
...(options.address ? { address: options.address } : {}),
|
|
21
23
|
...(config.defaultDomain ? { defaultDomain: config.defaultDomain } : {}),
|
|
24
|
+
files: fileRoutes(config),
|
|
22
25
|
});
|
|
23
26
|
const server = new McpServer({ name: SERVER_NAME, title: "fmsg", version: VERSION }, { instructions });
|
|
24
27
|
const deps = { provider, config, ...(options.shutdown ? { shutdown: options.shutdown } : {}) };
|
|
@@ -28,8 +31,9 @@ export function createFmsgMcpServer(provider, config, options = {}) {
|
|
|
28
31
|
registerSaveTool(server, deps);
|
|
29
32
|
registerDownloadTool(server, deps);
|
|
30
33
|
registerSendTools(server, deps);
|
|
34
|
+
registerDraftTools(server, deps);
|
|
31
35
|
registerWaitTools(server, deps);
|
|
32
36
|
registerResources(server, deps);
|
|
33
|
-
registerPrompts(server);
|
|
37
|
+
registerPrompts(server, fileRoutes(config));
|
|
34
38
|
return server;
|
|
35
39
|
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import * as z from "zod/v4";
|
|
2
|
+
import type { FmsgClient } from "../client/client.js";
|
|
3
|
+
import type { Attachment, DraftInput, DraftResult } from "../client/types.js";
|
|
4
|
+
import type { Config } from "../config.js";
|
|
5
|
+
export declare const FILENAME: RegExp;
|
|
6
|
+
/** Ways this server can take a file without the model writing its bytes. */
|
|
7
|
+
export type FileRoutes = {
|
|
8
|
+
/** create_draft returns an HTTP upload URL (HTTP with a public MCP URL). */
|
|
9
|
+
uploadUrl: boolean;
|
|
10
|
+
/** Attachments may name a file in FMSG_MCP_UPLOAD_DIR (stdio). */
|
|
11
|
+
uploadFolder: boolean;
|
|
12
|
+
};
|
|
13
|
+
export declare function fileRoutes(config: Config): FileRoutes;
|
|
14
|
+
/** A hint for the model after it wrote a large file as base64 although this server offers a route that avoids it. */
|
|
15
|
+
export declare function base64Hint(items: AttachmentArgs[] | undefined, routes: FileRoutes): string | undefined;
|
|
16
|
+
/** One outbound attachment as a tool argument: a filename and exactly one source of bytes. */
|
|
17
|
+
export declare function attachmentInput(config: Config): z.ZodObject<{
|
|
18
|
+
content_type: z.ZodOptional<z.ZodString>;
|
|
19
|
+
sha256: z.ZodOptional<z.ZodString>;
|
|
20
|
+
path?: z.ZodOptional<z.ZodString> | undefined;
|
|
21
|
+
filename: z.ZodString;
|
|
22
|
+
data_base64: z.ZodOptional<z.ZodString>;
|
|
23
|
+
data_text: z.ZodOptional<z.ZodString>;
|
|
24
|
+
}, z.core.$strict>;
|
|
25
|
+
/** Parsed attachment arguments; `path` is a string whenever the schema registered it. */
|
|
26
|
+
export type AttachmentArgs = {
|
|
27
|
+
filename: string;
|
|
28
|
+
data_base64?: string;
|
|
29
|
+
data_text?: string;
|
|
30
|
+
path?: unknown;
|
|
31
|
+
content_type?: string;
|
|
32
|
+
sha256?: string;
|
|
33
|
+
};
|
|
34
|
+
/** A validated attachment ready to upload: bytes in memory, or a file to stream. */
|
|
35
|
+
export type PreparedAttachment = {
|
|
36
|
+
filename: string;
|
|
37
|
+
contentType: string;
|
|
38
|
+
sha256?: string;
|
|
39
|
+
} & ({
|
|
40
|
+
data: Uint8Array;
|
|
41
|
+
} | {
|
|
42
|
+
file: string;
|
|
43
|
+
});
|
|
44
|
+
/**
|
|
45
|
+
* Decode base64 strictly: one alphabet (standard, or URL-safe with - and _) plus whitespace, `=` padding only at the
|
|
46
|
+
* end, a valid length, and no stray trailing bits. Node's decoder skips anything else silently, which would send a
|
|
47
|
+
* corrupted file.
|
|
48
|
+
*/
|
|
49
|
+
export declare function decodeBase64Strict(filename: string, input: string): Buffer;
|
|
50
|
+
/** Validate every attachment before anything is created upstream, so a bad one never leaves a draft behind. */
|
|
51
|
+
export declare function prepareAttachments(items: AttachmentArgs[] | undefined, config: Config): Promise<PreparedAttachment[]>;
|
|
52
|
+
/** Upload prepared attachments into a draft; files stream from disk and are checked against their sha256. */
|
|
53
|
+
export declare function uploadPrepared(client: FmsgClient, draftId: string, prepared: PreparedAttachment[], signal: AbortSignal): Promise<Attachment[]>;
|
|
54
|
+
/** Create a draft and upload its attachments, deleting the draft if any upload fails. */
|
|
55
|
+
export declare function draftWithAttachments(client: FmsgClient, input: DraftInput, prepared: PreparedAttachment[], signal: AbortSignal): Promise<DraftResult & {
|
|
56
|
+
attachments: Attachment[];
|
|
57
|
+
}>;
|
|
58
|
+
/** Draft, attach and send in one step; the draft is deleted if any step fails. */
|
|
59
|
+
export declare function sendWithAttachments(client: FmsgClient, input: DraftInput, prepared: PreparedAttachment[], signal: AbortSignal): Promise<{
|
|
60
|
+
id: string;
|
|
61
|
+
time: number | null;
|
|
62
|
+
redactions: number;
|
|
63
|
+
redacted: string[];
|
|
64
|
+
topic: string;
|
|
65
|
+
attachments: Attachment[];
|
|
66
|
+
}>;
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { createReadStream } from "node:fs";
|
|
3
|
+
import { realpath, stat } from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { Readable } from "node:stream";
|
|
6
|
+
import * as z from "zod/v4";
|
|
7
|
+
import { downloadBaseUrl } from "../download.js";
|
|
8
|
+
import { attachmentType } from "../render.js";
|
|
9
|
+
export const FILENAME = /^[A-Za-z0-9._-]+$/u;
|
|
10
|
+
const SHA256 = /^[0-9A-Fa-f]{64}$/u;
|
|
11
|
+
export function fileRoutes(config) {
|
|
12
|
+
return { uploadUrl: Boolean(downloadBaseUrl(config)), uploadFolder: Boolean(config.uploadDir) };
|
|
13
|
+
}
|
|
14
|
+
/** 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
|
+
const BASE64_HINT_CHARS = 64 * 1024;
|
|
16
|
+
/** A hint for the model after it wrote a large file as base64 although this server offers a route that avoids it. */
|
|
17
|
+
export function base64Hint(items, routes) {
|
|
18
|
+
const large = (items ?? []).filter((a) => (a.data_base64?.length ?? 0) >= BASE64_HINT_CHARS).map((a) => a.filename);
|
|
19
|
+
const better = [
|
|
20
|
+
...(routes.uploadUrl ? ["call create_draft, PUT them to its upload URL, then call send_draft"] : []),
|
|
21
|
+
...(routes.uploadFolder ? ["give their path in the upload folder instead of data_base64"] : []),
|
|
22
|
+
];
|
|
23
|
+
if (!large.length || !better.length)
|
|
24
|
+
return undefined;
|
|
25
|
+
return `${large.join(", ")} went inline as base64. Next time, send files this large without writing their bytes: ${better.join(", or ")}.`;
|
|
26
|
+
}
|
|
27
|
+
/** The attachment sources this server accepts; `path` only over stdio with an upload folder. */
|
|
28
|
+
function sources(config) {
|
|
29
|
+
return ["data_base64", "data_text", ...(config.uploadDir ? ["path"] : [])];
|
|
30
|
+
}
|
|
31
|
+
/** One outbound attachment as a tool argument: a filename and exactly one source of bytes. */
|
|
32
|
+
export function attachmentInput(config) {
|
|
33
|
+
const names = sources(config);
|
|
34
|
+
return z.strictObject({
|
|
35
|
+
filename: z.string().regex(FILENAME, "letters, digits, dot, underscore, hyphen only").describe("file name"),
|
|
36
|
+
data_base64: z.string().optional().describe(`base64 file contents, for small files${config.uploadDir ? "; give large files as path" : downloadBaseUrl(config) ? "; send large files with create_draft's upload URL" : ""}. The host sets the size limit`),
|
|
37
|
+
data_text: z.string().optional().describe("text file contents, sent as UTF-8 bytes; use instead of data_base64 for text"),
|
|
38
|
+
...(config.uploadDir ? {
|
|
39
|
+
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
|
+
} : {}),
|
|
41
|
+
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. Give it when you can compute it"),
|
|
43
|
+
}).superRefine((a, ctx) => {
|
|
44
|
+
const given = names.filter((name) => a[name] !== undefined);
|
|
45
|
+
if (given.length !== 1)
|
|
46
|
+
ctx.addIssue({ code: "custom", message: `attachment ${a.filename}: give exactly one of ${names.join(", ")}` });
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Decode base64 strictly: one alphabet (standard, or URL-safe with - and _) plus whitespace, `=` padding only at the
|
|
51
|
+
* end, a valid length, and no stray trailing bits. Node's decoder skips anything else silently, which would send a
|
|
52
|
+
* corrupted file.
|
|
53
|
+
*/
|
|
54
|
+
export function decodeBase64Strict(filename, input) {
|
|
55
|
+
const bad = /[^A-Za-z0-9+/\-_=\s]/u.exec(input);
|
|
56
|
+
if (bad)
|
|
57
|
+
throw new Error(`attachment ${filename}: invalid base64 character ${JSON.stringify(bad[0])} at position ${bad.index}`);
|
|
58
|
+
const standard = /[+/]/u.exec(input);
|
|
59
|
+
const urlSafe = /[-_]/u.exec(input);
|
|
60
|
+
if (standard && urlSafe) {
|
|
61
|
+
const [first, second] = standard.index < urlSafe.index ? [standard, urlSafe] : [urlSafe, standard];
|
|
62
|
+
throw new Error(`attachment ${filename}: base64 mixes the standard and URL-safe alphabets (${JSON.stringify(first[0])} at position ${first.index}, ` +
|
|
63
|
+
`${JSON.stringify(second[0])} at position ${second.index}); part of the data may be corrupt`);
|
|
64
|
+
}
|
|
65
|
+
const compact = input.replace(/\s+/gu, "").replace(/-/gu, "+").replace(/_/gu, "/");
|
|
66
|
+
const padding = compact.indexOf("=");
|
|
67
|
+
const unpadded = padding < 0 ? compact : compact.slice(0, padding);
|
|
68
|
+
if ((padding >= 0 && (!/^={1,2}$/u.test(compact.slice(padding)) || compact.length % 4 !== 0)) || unpadded.length % 4 === 1) {
|
|
69
|
+
throw new Error(`attachment ${filename}: invalid base64 length or padding; part of the data may be missing`);
|
|
70
|
+
}
|
|
71
|
+
const data = Buffer.from(compact, "base64");
|
|
72
|
+
if (data.toString("base64").replace(/=+$/u, "") !== unpadded)
|
|
73
|
+
throw new Error(`attachment ${filename}: invalid base64 ending; part of the data may be missing`);
|
|
74
|
+
return data;
|
|
75
|
+
}
|
|
76
|
+
/** Resolve a path inside the operator's upload folder; refuse anything that leaves it, including through symlinks. */
|
|
77
|
+
async function uploadFile(directory, filename, relative) {
|
|
78
|
+
if (path.isAbsolute(relative) || relative.includes("\0"))
|
|
79
|
+
throw new Error(`attachment ${filename}: path must be relative to the upload folder`);
|
|
80
|
+
const root = await realpath(directory);
|
|
81
|
+
let target;
|
|
82
|
+
try {
|
|
83
|
+
target = await realpath(path.join(root, relative));
|
|
84
|
+
}
|
|
85
|
+
catch (error) {
|
|
86
|
+
if (error.code === "ENOENT")
|
|
87
|
+
throw new Error(`attachment ${filename}: ${relative} was not found in the upload folder`);
|
|
88
|
+
throw error;
|
|
89
|
+
}
|
|
90
|
+
const inside = path.relative(root, target);
|
|
91
|
+
if (!inside || inside.startsWith("..") || path.isAbsolute(inside))
|
|
92
|
+
throw new Error(`attachment ${filename}: path must stay inside the upload folder`);
|
|
93
|
+
if (!(await stat(target)).isFile())
|
|
94
|
+
throw new Error(`attachment ${filename}: ${relative} is not a regular file`);
|
|
95
|
+
return target;
|
|
96
|
+
}
|
|
97
|
+
function checksum(filename, expected, data) {
|
|
98
|
+
if (!expected)
|
|
99
|
+
return;
|
|
100
|
+
const actual = createHash("sha256").update(data).digest("hex");
|
|
101
|
+
if (actual !== expected.toLowerCase())
|
|
102
|
+
throw new Error(`attachment ${filename}: sha256 mismatch (expected ${expected.toLowerCase()}, got ${actual}); nothing was sent`);
|
|
103
|
+
}
|
|
104
|
+
/** Validate every attachment before anything is created upstream, so a bad one never leaves a draft behind. */
|
|
105
|
+
export async function prepareAttachments(items, config) {
|
|
106
|
+
const prepared = [];
|
|
107
|
+
for (const a of items ?? []) {
|
|
108
|
+
const sha256 = a.sha256?.toLowerCase();
|
|
109
|
+
const base = { filename: a.filename, ...(sha256 ? { sha256 } : {}) };
|
|
110
|
+
if (typeof a.path === "string") {
|
|
111
|
+
if (!config.uploadDir)
|
|
112
|
+
throw new Error(`attachment ${a.filename}: path needs an upload folder (FMSG_MCP_UPLOAD_DIR, stdio only)`);
|
|
113
|
+
prepared.push({ ...base, contentType: a.content_type ?? attachmentType(a.filename), file: await uploadFile(config.uploadDir, a.filename, a.path) });
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
const text = a.data_text !== undefined;
|
|
117
|
+
const data = text ? Buffer.from(a.data_text, "utf8") : decodeBase64Strict(a.filename, a.data_base64 ?? "");
|
|
118
|
+
if (data.byteLength === 0)
|
|
119
|
+
throw new Error(`attachment ${a.filename} is empty`);
|
|
120
|
+
checksum(a.filename, sha256, data);
|
|
121
|
+
const inferred = attachmentType(a.filename);
|
|
122
|
+
const contentType = a.content_type ?? (text && inferred === "application/octet-stream" ? "text/plain; charset=utf-8" : inferred);
|
|
123
|
+
prepared.push({ ...base, contentType, data: new Uint8Array(data) });
|
|
124
|
+
}
|
|
125
|
+
return prepared;
|
|
126
|
+
}
|
|
127
|
+
/** Upload prepared attachments into a draft; files stream from disk and are checked against their sha256. */
|
|
128
|
+
export async function uploadPrepared(client, draftId, prepared, signal) {
|
|
129
|
+
const uploaded = [];
|
|
130
|
+
for (const a of prepared) {
|
|
131
|
+
if ("data" in a) {
|
|
132
|
+
uploaded.push(await client.uploadAttachment(draftId, { filename: a.filename, data: a.data, contentType: a.contentType }, signal));
|
|
133
|
+
}
|
|
134
|
+
else {
|
|
135
|
+
const stream = Readable.toWeb(createReadStream(a.file));
|
|
136
|
+
const { filename, size } = await client.uploadAttachmentStream(draftId, {
|
|
137
|
+
filename: a.filename, stream, contentType: a.contentType, ...(a.sha256 ? { expectedSha256: a.sha256 } : {}),
|
|
138
|
+
}, signal);
|
|
139
|
+
uploaded.push({ filename, size });
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return uploaded;
|
|
143
|
+
}
|
|
144
|
+
/** Create a draft and upload its attachments, deleting the draft if any upload fails. */
|
|
145
|
+
export async function draftWithAttachments(client, input, prepared, signal) {
|
|
146
|
+
const draft = await client.createDraft({ ...input, signal });
|
|
147
|
+
try {
|
|
148
|
+
return { ...draft, attachments: await uploadPrepared(client, draft.id, prepared, signal) };
|
|
149
|
+
}
|
|
150
|
+
catch (error) {
|
|
151
|
+
await client.deleteMessage(draft.id).catch(() => undefined);
|
|
152
|
+
throw error;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
/** Draft, attach and send in one step; the draft is deleted if any step fails. */
|
|
156
|
+
export async function sendWithAttachments(client, input, prepared, signal) {
|
|
157
|
+
const draft = await draftWithAttachments(client, input, prepared, signal);
|
|
158
|
+
try {
|
|
159
|
+
return { ...draft, ...(await client.sendDraft(draft.id, signal)) };
|
|
160
|
+
}
|
|
161
|
+
catch (error) {
|
|
162
|
+
await client.deleteMessage(draft.id).catch(() => undefined);
|
|
163
|
+
throw error;
|
|
164
|
+
}
|
|
165
|
+
}
|