@remit/mcp-server 0.0.0-stage → 0.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/dist/app-entry.d.ts +10 -0
- package/dist/app-entry.js +9 -0
- package/dist/app.d.ts +11 -0
- package/dist/app.js +261 -0
- package/dist/auth/authorization-server.d.ts +6835 -0
- package/dist/auth/authorization-server.js +592 -0
- package/dist/auth/database.d.ts +7 -0
- package/dist/auth/database.js +33 -0
- package/dist/auth/node-auth.d.ts +3 -0
- package/dist/auth/node-auth.js +17 -0
- package/dist/auth/remit-password.d.ts +8 -0
- package/dist/auth/remit-password.js +18 -0
- package/dist/auth/scopes.d.ts +6 -0
- package/dist/auth/scopes.js +13 -0
- package/dist/auth/token-verifier.d.ts +11 -0
- package/dist/auth/token-verifier.js +1 -0
- package/dist/cli/device-login.d.ts +16 -0
- package/dist/cli/device-login.js +111 -0
- package/dist/cli/mcp-login.d.ts +1 -0
- package/dist/cli/mcp-login.js +21 -0
- package/dist/config.d.ts +9 -0
- package/dist/config.js +41 -0
- package/dist/deps.d.ts +33 -0
- package/dist/deps.js +14 -0
- package/dist/filter-tools.d.ts +3 -0
- package/dist/filter-tools.js +155 -0
- package/dist/folder-tools.d.ts +3 -0
- package/dist/folder-tools.js +125 -0
- package/dist/main.d.ts +1 -0
- package/dist/main.js +53 -0
- package/dist/pages/assets.d.ts +8 -0
- package/dist/pages/assets.js +14 -0
- package/dist/pages/geist-cyrillic-ext-wght-normal.woff2 +0 -0
- package/dist/pages/geist-cyrillic-wght-normal.woff2 +0 -0
- package/dist/pages/geist-latin-ext-wght-normal.woff2 +0 -0
- package/dist/pages/geist-latin-wght-normal.woff2 +0 -0
- package/dist/pages/geist-vietnamese-wght-normal.woff2 +0 -0
- package/dist/pages/hanken-grotesk-cyrillic-ext-wght-normal.woff2 +0 -0
- package/dist/pages/hanken-grotesk-latin-ext-wght-normal.woff2 +0 -0
- package/dist/pages/hanken-grotesk-latin-wght-normal.woff2 +0 -0
- package/dist/pages/hanken-grotesk-vietnamese-wght-normal.woff2 +0 -0
- package/dist/pages/inter-cyrillic-ext-wght-normal.woff2 +0 -0
- package/dist/pages/inter-cyrillic-wght-normal.woff2 +0 -0
- package/dist/pages/inter-greek-ext-wght-normal.woff2 +0 -0
- package/dist/pages/inter-greek-wght-normal.woff2 +0 -0
- package/dist/pages/inter-latin-ext-wght-normal.woff2 +0 -0
- package/dist/pages/inter-latin-wght-normal.woff2 +0 -0
- package/dist/pages/inter-vietnamese-wght-normal.woff2 +0 -0
- package/dist/pages/load-assets.d.ts +2 -0
- package/dist/pages/load-assets.js +23 -0
- package/dist/pages/pages.css +1 -0
- package/dist/pages/render.d.ts +6 -0
- package/dist/pages/render.js +34 -0
- package/dist/pages/views.d.ts +11 -0
- package/dist/pages/views.js +20 -0
- package/dist/read-tools.d.ts +4 -0
- package/dist/read-tools.js +205 -0
- package/dist/server.d.ts +7 -0
- package/dist/server.js +32 -0
- package/dist/tool-result.d.ts +5 -0
- package/dist/tool-result.js +16 -0
- package/package.json +66 -5
- package/README.md +0 -3
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { McpPermission } from "@remit/ui/mcp-pages";
|
|
2
|
+
export declare const MCP_READ_SCOPE = "mail:read";
|
|
3
|
+
export declare const MCP_RULES_SCOPE = "mail:rules";
|
|
4
|
+
export declare const OFFLINE_ACCESS_SCOPE = "offline_access";
|
|
5
|
+
export declare const MCP_SCOPES: readonly ["mail:read", "mail:rules"];
|
|
6
|
+
export declare const describeScopes: (scopes: readonly string[]) => McpPermission[];
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export const MCP_READ_SCOPE = "mail:read";
|
|
2
|
+
export const MCP_RULES_SCOPE = "mail:rules";
|
|
3
|
+
export const OFFLINE_ACCESS_SCOPE = "offline_access";
|
|
4
|
+
export const MCP_SCOPES = [MCP_READ_SCOPE, MCP_RULES_SCOPE];
|
|
5
|
+
const SCOPE_DESCRIPTIONS = {
|
|
6
|
+
[MCP_READ_SCOPE]: "Read your mail",
|
|
7
|
+
[MCP_RULES_SCOPE]: "Create and change folders and filters",
|
|
8
|
+
[OFFLINE_ACCESS_SCOPE]: "Stay connected until you disconnect it",
|
|
9
|
+
};
|
|
10
|
+
export const describeScopes = (scopes) => scopes.map((scope) => ({
|
|
11
|
+
scope,
|
|
12
|
+
description: SCOPE_DESCRIPTIONS[scope] ?? `Use the "${scope}" permission`,
|
|
13
|
+
}));
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { McpPrincipal } from "../server.js";
|
|
2
|
+
export type TokenVerification = {
|
|
3
|
+
ok: true;
|
|
4
|
+
principal: McpPrincipal;
|
|
5
|
+
} | {
|
|
6
|
+
ok: false;
|
|
7
|
+
reason: string;
|
|
8
|
+
};
|
|
9
|
+
export interface TokenVerifier {
|
|
10
|
+
verify(bearer: string): Promise<TokenVerification>;
|
|
11
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export interface DeviceLoginIo {
|
|
2
|
+
fetch: typeof fetch;
|
|
3
|
+
print: (line: string) => void;
|
|
4
|
+
sleep: (ms: number) => Promise<void>;
|
|
5
|
+
}
|
|
6
|
+
export type DeviceLoginResult = {
|
|
7
|
+
ok: true;
|
|
8
|
+
accessToken: string;
|
|
9
|
+
command: string;
|
|
10
|
+
} | {
|
|
11
|
+
ok: false;
|
|
12
|
+
error: string;
|
|
13
|
+
description: string;
|
|
14
|
+
};
|
|
15
|
+
export declare const claudeMcpAddCommand: (origin: string, accessToken: string) => string;
|
|
16
|
+
export declare const deviceLogin: (origin: string, io: DeviceLoginIo) => Promise<DeviceLoginResult>;
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { DEVICE_CODE_GRANT_TYPE } from "@better-auth/oauth-provider";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { MCP_SCOPES } from "../auth/scopes.js";
|
|
4
|
+
const CLIENT_NAME = "remit mcp login";
|
|
5
|
+
const SLOW_DOWN_STEP_SECONDS = 5;
|
|
6
|
+
const metadataSchema = z.object({
|
|
7
|
+
registration_endpoint: z.string(),
|
|
8
|
+
device_authorization_endpoint: z.string(),
|
|
9
|
+
token_endpoint: z.string(),
|
|
10
|
+
});
|
|
11
|
+
const registrationSchema = z.object({ client_id: z.string() });
|
|
12
|
+
const deviceAuthorizationSchema = z.object({
|
|
13
|
+
device_code: z.string(),
|
|
14
|
+
user_code: z.string(),
|
|
15
|
+
verification_uri: z.string(),
|
|
16
|
+
verification_uri_complete: z.string(),
|
|
17
|
+
expires_in: z.number(),
|
|
18
|
+
interval: z.number(),
|
|
19
|
+
});
|
|
20
|
+
const tokenSchema = z.object({ access_token: z.string() });
|
|
21
|
+
const errorSchema = z.object({
|
|
22
|
+
error: z.string(),
|
|
23
|
+
error_description: z.string().default(""),
|
|
24
|
+
});
|
|
25
|
+
const read = async (response, schema, step) => {
|
|
26
|
+
const body = await response.json();
|
|
27
|
+
if (response.ok) {
|
|
28
|
+
const parsed = schema.safeParse(body);
|
|
29
|
+
if (parsed.success)
|
|
30
|
+
return { ok: true, value: parsed.data };
|
|
31
|
+
return {
|
|
32
|
+
ok: false,
|
|
33
|
+
error: "invalid_response",
|
|
34
|
+
description: `${step} answered with an unexpected body`,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
const failure = errorSchema.safeParse(body);
|
|
38
|
+
return failure.success
|
|
39
|
+
? {
|
|
40
|
+
ok: false,
|
|
41
|
+
error: failure.data.error,
|
|
42
|
+
description: failure.data.error_description,
|
|
43
|
+
}
|
|
44
|
+
: {
|
|
45
|
+
ok: false,
|
|
46
|
+
error: "http_error",
|
|
47
|
+
description: `${step} answered ${response.status}`,
|
|
48
|
+
};
|
|
49
|
+
};
|
|
50
|
+
export const claudeMcpAddCommand = (origin, accessToken) => `claude mcp add --transport http --header "Authorization: Bearer ${accessToken}" remit ${origin}/mcp`;
|
|
51
|
+
export const deviceLogin = async (origin, io) => {
|
|
52
|
+
const metadata = await read(await io.fetch(`${origin}/.well-known/oauth-authorization-server`), metadataSchema, "The authorization server metadata");
|
|
53
|
+
if (!metadata.ok)
|
|
54
|
+
return metadata;
|
|
55
|
+
const registration = await read(await io.fetch(metadata.value.registration_endpoint, {
|
|
56
|
+
method: "POST",
|
|
57
|
+
headers: { "content-type": "application/json" },
|
|
58
|
+
body: JSON.stringify({
|
|
59
|
+
client_name: CLIENT_NAME,
|
|
60
|
+
grant_types: [DEVICE_CODE_GRANT_TYPE],
|
|
61
|
+
token_endpoint_auth_method: "none",
|
|
62
|
+
}),
|
|
63
|
+
}), registrationSchema, "Client registration");
|
|
64
|
+
if (!registration.ok)
|
|
65
|
+
return registration;
|
|
66
|
+
const clientId = registration.value.client_id;
|
|
67
|
+
const resource = `${origin}/mcp`;
|
|
68
|
+
const authorization = await read(await io.fetch(metadata.value.device_authorization_endpoint, {
|
|
69
|
+
method: "POST",
|
|
70
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
71
|
+
body: new URLSearchParams({
|
|
72
|
+
client_id: clientId,
|
|
73
|
+
scope: MCP_SCOPES.join(" "),
|
|
74
|
+
resource,
|
|
75
|
+
}),
|
|
76
|
+
}), deviceAuthorizationSchema, "The device authorization request");
|
|
77
|
+
if (!authorization.ok)
|
|
78
|
+
return authorization;
|
|
79
|
+
const grant = authorization.value;
|
|
80
|
+
io.print(`Open ${grant.verification_uri} and enter the code ${grant.user_code}`);
|
|
81
|
+
io.print(`or go straight to ${grant.verification_uri_complete}`);
|
|
82
|
+
io.print(`The code expires in ${Math.round(grant.expires_in / 60)} minutes.`);
|
|
83
|
+
let intervalSeconds = grant.interval;
|
|
84
|
+
for (;;) {
|
|
85
|
+
await io.sleep(intervalSeconds * 1000);
|
|
86
|
+
const polled = await read(await io.fetch(metadata.value.token_endpoint, {
|
|
87
|
+
method: "POST",
|
|
88
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
89
|
+
body: new URLSearchParams({
|
|
90
|
+
grant_type: DEVICE_CODE_GRANT_TYPE,
|
|
91
|
+
device_code: grant.device_code,
|
|
92
|
+
client_id: clientId,
|
|
93
|
+
resource,
|
|
94
|
+
}),
|
|
95
|
+
}), tokenSchema, "The token endpoint");
|
|
96
|
+
if (polled.ok) {
|
|
97
|
+
return {
|
|
98
|
+
ok: true,
|
|
99
|
+
accessToken: polled.value.access_token,
|
|
100
|
+
command: claudeMcpAddCommand(origin, polled.value.access_token),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
if (polled.error === "authorization_pending")
|
|
104
|
+
continue;
|
|
105
|
+
if (polled.error === "slow_down") {
|
|
106
|
+
intervalSeconds += SLOW_DOWN_STEP_SECONDS;
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
return polled;
|
|
110
|
+
}
|
|
111
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { setTimeout } from "node:timers/promises";
|
|
2
|
+
import { deviceLogin } from "./device-login.js";
|
|
3
|
+
const origin = process.env.MCP_PUBLIC_ORIGIN;
|
|
4
|
+
if (!origin) {
|
|
5
|
+
process.stderr.write("Missing required env var: MCP_PUBLIC_ORIGIN\n");
|
|
6
|
+
process.exit(2);
|
|
7
|
+
}
|
|
8
|
+
const result = await deviceLogin(new URL(origin).origin, {
|
|
9
|
+
fetch,
|
|
10
|
+
print: (line) => process.stdout.write(`${line}\n`),
|
|
11
|
+
sleep: (ms) => setTimeout(ms),
|
|
12
|
+
});
|
|
13
|
+
if (!result.ok) {
|
|
14
|
+
const reasons = {
|
|
15
|
+
access_denied: "The request was denied on the approval page.",
|
|
16
|
+
expired_token: "The code expired before it was approved. Run this again.",
|
|
17
|
+
};
|
|
18
|
+
process.stderr.write(`Signing in failed: ${reasons[result.error] ?? `${result.error} ${result.description}`}\n`);
|
|
19
|
+
process.exit(1);
|
|
20
|
+
}
|
|
21
|
+
process.stdout.write(`\nSigned in. Add remit to Claude Code with:\n\n${result.command}\n`);
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { type SqliteDatabasePaths } from "@remit/drizzle-service";
|
|
2
|
+
export interface McpServerConfig {
|
|
3
|
+
origin: string;
|
|
4
|
+
secret: string;
|
|
5
|
+
databases: SqliteDatabasePaths;
|
|
6
|
+
port: number;
|
|
7
|
+
pageAssetsDir: string;
|
|
8
|
+
}
|
|
9
|
+
export declare const resolveMcpServerConfig: () => McpServerConfig;
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { sqliteDatabasePathsFromEnv, } from "@remit/drizzle-service";
|
|
2
|
+
const MIN_SECRET_LENGTH = 32;
|
|
3
|
+
const DEFAULT_PORT = 8790;
|
|
4
|
+
const required = (name) => {
|
|
5
|
+
const value = process.env[name];
|
|
6
|
+
if (!value)
|
|
7
|
+
throw new Error(`Missing required MCP server env var: ${name}`);
|
|
8
|
+
return value;
|
|
9
|
+
};
|
|
10
|
+
const parseOrigin = (raw) => {
|
|
11
|
+
if (!URL.canParse(raw)) {
|
|
12
|
+
throw new Error(`MCP_PUBLIC_ORIGIN is not a URL: ${raw}`);
|
|
13
|
+
}
|
|
14
|
+
const url = new URL(raw);
|
|
15
|
+
if (url.origin !== raw.replace(/\/$/, "")) {
|
|
16
|
+
throw new Error(`MCP_PUBLIC_ORIGIN must be a bare origin such as https://mcp.example.com, got ${raw}`);
|
|
17
|
+
}
|
|
18
|
+
return url.origin;
|
|
19
|
+
};
|
|
20
|
+
const parsePort = (raw) => {
|
|
21
|
+
if (!raw)
|
|
22
|
+
return DEFAULT_PORT;
|
|
23
|
+
const port = Number(raw);
|
|
24
|
+
if (!Number.isInteger(port) || port <= 0 || port > 65535) {
|
|
25
|
+
throw new Error(`MCP_PORT is not a port number: ${raw}`);
|
|
26
|
+
}
|
|
27
|
+
return port;
|
|
28
|
+
};
|
|
29
|
+
export const resolveMcpServerConfig = () => {
|
|
30
|
+
const secret = required("MCP_AUTH_SECRET");
|
|
31
|
+
if (secret.length < MIN_SECRET_LENGTH) {
|
|
32
|
+
throw new Error(`MCP_AUTH_SECRET must be at least ${MIN_SECRET_LENGTH} characters`);
|
|
33
|
+
}
|
|
34
|
+
return {
|
|
35
|
+
origin: parseOrigin(required("MCP_PUBLIC_ORIGIN")),
|
|
36
|
+
secret,
|
|
37
|
+
databases: sqliteDatabasePathsFromEnv(),
|
|
38
|
+
port: parsePort(process.env.MCP_PORT),
|
|
39
|
+
pageAssetsDir: required("MCP_PAGE_ASSETS_DIR"),
|
|
40
|
+
};
|
|
41
|
+
};
|
package/dist/deps.d.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { RemitClient } from "@remit/backend/client";
|
|
2
|
+
import type { IAccountRepository, IFilterRepository, IMailboxRepository, IMessageRepository, IOrganizeJobRequestRepository, IThreadMessageRepository, OrganizeJobEvent, QueueProducer } from "@remit/data-ports";
|
|
3
|
+
import type { BodyPartMaterializer, BodySyncCue, MailboxQueueService, MaterializeLogger, OrganizeJobLogger } from "@remit/mailbox-service";
|
|
4
|
+
import type { StorageService } from "@remit/storage-service";
|
|
5
|
+
export interface FolderToolDeps {
|
|
6
|
+
mailboxQueue: Pick<MailboxQueueService, "createMailbox" | "renameMailbox" | "dismissMailboxIntent">;
|
|
7
|
+
settleTimeoutMs: number;
|
|
8
|
+
pollIntervalMs: number;
|
|
9
|
+
sleep: (ms: number) => Promise<void>;
|
|
10
|
+
}
|
|
11
|
+
export interface FilterToolDeps {
|
|
12
|
+
organizeQueue: QueueProducer<OrganizeJobEvent>;
|
|
13
|
+
logger: OrganizeJobLogger;
|
|
14
|
+
}
|
|
15
|
+
export interface McpDeps {
|
|
16
|
+
account: Pick<IAccountRepository, "get" | "listAllByAccountConfig">;
|
|
17
|
+
mailbox: Pick<IMailboxRepository, "get" | "listAllByAccount" | "findByPath" | "resolveAccountId">;
|
|
18
|
+
message: Pick<IMessageRepository, "get" | "describe">;
|
|
19
|
+
threadMessage: Pick<IThreadMessageRepository, "listByMailbox" | "listByThread" | "searchByDate">;
|
|
20
|
+
filter: Pick<IFilterRepository, "listPageByAccountConfig" | "refreshExpiry" | "create" | "get" | "update">;
|
|
21
|
+
organizeJobRequest: Pick<IOrganizeJobRequestRepository, "create">;
|
|
22
|
+
storage: Pick<StorageService, "retrieveBodyPart">;
|
|
23
|
+
bodySync: BodyPartMaterializer;
|
|
24
|
+
bodySyncQueue: BodySyncCue;
|
|
25
|
+
logger: MaterializeLogger;
|
|
26
|
+
folders: FolderToolDeps;
|
|
27
|
+
filters: FilterToolDeps;
|
|
28
|
+
}
|
|
29
|
+
export interface McpDepsExtras {
|
|
30
|
+
logger: MaterializeLogger & OrganizeJobLogger;
|
|
31
|
+
settle: Omit<FolderToolDeps, "mailboxQueue">;
|
|
32
|
+
}
|
|
33
|
+
export declare const mcpDepsOf: (client: RemitClient, { logger, settle }: McpDepsExtras) => McpDeps;
|
package/dist/deps.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export const mcpDepsOf = (client, { logger, settle }) => ({
|
|
2
|
+
account: client.account,
|
|
3
|
+
mailbox: client.mailbox,
|
|
4
|
+
message: client.message,
|
|
5
|
+
threadMessage: client.threadMessage,
|
|
6
|
+
filter: client.filter,
|
|
7
|
+
organizeJobRequest: client.organizeJobRequest,
|
|
8
|
+
storage: client.storage,
|
|
9
|
+
bodySync: client.bodySync,
|
|
10
|
+
bodySyncQueue: client.bodySyncQueue,
|
|
11
|
+
logger,
|
|
12
|
+
folders: { mailboxQueue: client.mailboxQueue, ...settle },
|
|
13
|
+
filters: { organizeQueue: client.queues.accountFanout, logger },
|
|
14
|
+
});
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { FILTER_NO_ACTION } from "@remit/data-ports";
|
|
2
|
+
import { isNotFoundError } from "@remit/data-ports/errors";
|
|
3
|
+
import { deriveAccountConfigId } from "@remit/data-ports/id";
|
|
4
|
+
import { FilterClauseField, FilterMatchOperator, FilterScope, FilterState, } from "@remit/domain-enums";
|
|
5
|
+
import { createFilter, listFilters, queueOrganizeJob, refuse, updateFilter, } from "@remit/mailbox-service";
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
import { asJson, refused } from "./tool-result.js";
|
|
8
|
+
const FILTER_WRITE = {
|
|
9
|
+
readOnlyHint: false,
|
|
10
|
+
destructiveHint: false,
|
|
11
|
+
idempotentHint: false,
|
|
12
|
+
openWorldHint: false,
|
|
13
|
+
};
|
|
14
|
+
const toFilter = (item) => ({
|
|
15
|
+
filterId: item.filterId,
|
|
16
|
+
name: item.name,
|
|
17
|
+
scope: item.scope,
|
|
18
|
+
expiresAt: item.expiresAt,
|
|
19
|
+
state: item.state,
|
|
20
|
+
disabledReason: item.disabledReason,
|
|
21
|
+
matchOperator: item.matchOperator,
|
|
22
|
+
literalClauses: item.literalClauses,
|
|
23
|
+
actionLabelId: item.actionLabelId,
|
|
24
|
+
actionMailboxId: item.actionMailboxId,
|
|
25
|
+
ruleChangedAt: item.ruleChangedAt,
|
|
26
|
+
createdAt: item.createdAt,
|
|
27
|
+
updatedAt: item.updatedAt,
|
|
28
|
+
});
|
|
29
|
+
const notFound = (error) => {
|
|
30
|
+
if (!isNotFoundError(error))
|
|
31
|
+
throw error;
|
|
32
|
+
const message = error instanceof Error ? error.message : "Not found";
|
|
33
|
+
return refuse({
|
|
34
|
+
kind: "NotFound",
|
|
35
|
+
message: `${message}. Check the filterId against list_filters.`,
|
|
36
|
+
});
|
|
37
|
+
};
|
|
38
|
+
const accountId = z.string().min(1).describe("Account from list_mailboxes");
|
|
39
|
+
const literalClauses = z
|
|
40
|
+
.array(z.object({
|
|
41
|
+
field: z
|
|
42
|
+
.enum(FilterClauseField)
|
|
43
|
+
.describe("Message attribute the clause matches"),
|
|
44
|
+
value: z.string().min(1).max(256).describe("Literal text to match"),
|
|
45
|
+
}))
|
|
46
|
+
.min(1);
|
|
47
|
+
const scope = z
|
|
48
|
+
.enum(FilterScope)
|
|
49
|
+
.describe("Standing runs until changed; Temporary stops at expiresAt");
|
|
50
|
+
const expiresAt = z
|
|
51
|
+
.string()
|
|
52
|
+
.describe("ISO 8601 date-time with zone offset; only for a Temporary filter, e.g. 2026-12-31T23:59:59+01:00");
|
|
53
|
+
const matchOperator = z
|
|
54
|
+
.enum(FilterMatchOperator)
|
|
55
|
+
.describe("And: every clause matches; Or: any clause matches");
|
|
56
|
+
const actionMailboxId = z
|
|
57
|
+
.string()
|
|
58
|
+
.min(1)
|
|
59
|
+
.describe(`Folder from list_mailboxes to move matching mail into, or "${FILTER_NO_ACTION}"`);
|
|
60
|
+
const actionLabelId = z
|
|
61
|
+
.string()
|
|
62
|
+
.min(1)
|
|
63
|
+
.describe(`Label to apply to matching mail, or "${FILTER_NO_ACTION}"`);
|
|
64
|
+
export const registerFilterTools = (server, deps, userId) => {
|
|
65
|
+
const accountConfigId = deriveAccountConfigId(userId);
|
|
66
|
+
const organizeDeps = {
|
|
67
|
+
organizeJobRequest: deps.organizeJobRequest,
|
|
68
|
+
queue: deps.filters.organizeQueue,
|
|
69
|
+
logger: deps.filters.logger,
|
|
70
|
+
};
|
|
71
|
+
server.registerTool("list_filters", {
|
|
72
|
+
description: "List the user's mail filters: what each matches, where it moves or labels mail, and whether it is on. A page at a time.",
|
|
73
|
+
inputSchema: {
|
|
74
|
+
accountId,
|
|
75
|
+
continuationToken: z
|
|
76
|
+
.string()
|
|
77
|
+
.optional()
|
|
78
|
+
.describe("Opaque cursor from a previous response"),
|
|
79
|
+
},
|
|
80
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
81
|
+
}, async (args) => {
|
|
82
|
+
const page = await listFilters(deps, accountConfigId, {
|
|
83
|
+
accountId: args.accountId,
|
|
84
|
+
continuationToken: args.continuationToken,
|
|
85
|
+
}).catch(notFound);
|
|
86
|
+
if (!page.ok)
|
|
87
|
+
return refused(page.refusal);
|
|
88
|
+
return asJson({
|
|
89
|
+
items: page.value.items.map(toFilter),
|
|
90
|
+
continuationToken: page.value.continuationToken,
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
server.registerTool("create_filter", {
|
|
94
|
+
description: "Create a filter that sorts new mail, and queue a job that applies it to existing mail. Returns the filter and the organizeJobId of that job. A target folder must be confirmed by the mail server first.",
|
|
95
|
+
inputSchema: {
|
|
96
|
+
accountId,
|
|
97
|
+
name: z.string().min(1).max(256).describe("Name for the filter"),
|
|
98
|
+
scope: scope.default(FilterScope.Standing),
|
|
99
|
+
expiresAt: expiresAt.optional(),
|
|
100
|
+
matchOperator: matchOperator.default(FilterMatchOperator.And),
|
|
101
|
+
literalClauses,
|
|
102
|
+
actionMailboxId: actionMailboxId.default(FILTER_NO_ACTION),
|
|
103
|
+
actionLabelId: actionLabelId.default(FILTER_NO_ACTION),
|
|
104
|
+
},
|
|
105
|
+
annotations: FILTER_WRITE,
|
|
106
|
+
}, async ({ accountId, ...rule }) => {
|
|
107
|
+
const created = await createFilter({ ...deps, ...organizeDeps }, { accountConfigId, userId }, { accountId, filter: rule }).catch(notFound);
|
|
108
|
+
if (!created.ok)
|
|
109
|
+
return refused(created.refusal);
|
|
110
|
+
return asJson({
|
|
111
|
+
filter: toFilter(created.value.filter),
|
|
112
|
+
organizeJobId: created.value.organizeJob.organizeJobId,
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
server.registerTool("update_filter", {
|
|
116
|
+
description: "Change a filter: its name, clauses, target folder or label, scope, expiry, or turn it on or off. Only the fields given change. Set applyToExisting to also queue a job that applies the filter to existing mail; its organizeJobId is returned.",
|
|
117
|
+
inputSchema: {
|
|
118
|
+
accountId,
|
|
119
|
+
filterId: z.string().min(1).describe("Filter from list_filters"),
|
|
120
|
+
name: z.string().min(1).max(256).optional(),
|
|
121
|
+
scope: scope.optional(),
|
|
122
|
+
expiresAt: expiresAt.optional(),
|
|
123
|
+
matchOperator: matchOperator.optional(),
|
|
124
|
+
literalClauses: literalClauses.optional(),
|
|
125
|
+
actionMailboxId: actionMailboxId.optional(),
|
|
126
|
+
actionLabelId: actionLabelId.optional(),
|
|
127
|
+
state: z
|
|
128
|
+
.enum(FilterState)
|
|
129
|
+
.optional()
|
|
130
|
+
.describe("Active turns the filter on, Disabled turns it off"),
|
|
131
|
+
applyToExisting: z
|
|
132
|
+
.boolean()
|
|
133
|
+
.optional()
|
|
134
|
+
.describe("Also apply the filter to existing mail"),
|
|
135
|
+
},
|
|
136
|
+
annotations: FILTER_WRITE,
|
|
137
|
+
}, async ({ accountId, filterId, applyToExisting, ...patch }) => {
|
|
138
|
+
const updated = await updateFilter(deps, accountConfigId, {
|
|
139
|
+
accountId,
|
|
140
|
+
filterId,
|
|
141
|
+
patch,
|
|
142
|
+
}).catch(notFound);
|
|
143
|
+
if (!updated.ok)
|
|
144
|
+
return refused(updated.refusal);
|
|
145
|
+
const filter = toFilter(updated.value);
|
|
146
|
+
if (!applyToExisting)
|
|
147
|
+
return asJson({ filter });
|
|
148
|
+
const job = await queueOrganizeJob(organizeDeps, {
|
|
149
|
+
accountConfigId,
|
|
150
|
+
userId,
|
|
151
|
+
filterId,
|
|
152
|
+
});
|
|
153
|
+
return asJson({ filter, organizeJobId: job.organizeJobId });
|
|
154
|
+
});
|
|
155
|
+
};
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { isNotFoundError } from "@remit/data-ports/errors";
|
|
2
|
+
import { MailboxSyncStatus } from "@remit/domain-enums";
|
|
3
|
+
import { changeFolderPath, createFolder, settledFolder, } from "@remit/mailbox-service";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { asJson, refused, toolError } from "./tool-result.js";
|
|
6
|
+
const CREATE_DELIMITER = "/";
|
|
7
|
+
const FOLDER_WRITE = {
|
|
8
|
+
readOnlyHint: false,
|
|
9
|
+
destructiveHint: false,
|
|
10
|
+
idempotentHint: true,
|
|
11
|
+
openWorldHint: false,
|
|
12
|
+
};
|
|
13
|
+
const awaitSettled = async (deps, folders, accountId, mailboxId) => {
|
|
14
|
+
const deadline = Date.now() + folders.settleTimeoutMs;
|
|
15
|
+
for (;;) {
|
|
16
|
+
const folder = await deps.mailbox
|
|
17
|
+
.get(accountId, mailboxId)
|
|
18
|
+
.catch((error) => {
|
|
19
|
+
if (isNotFoundError(error))
|
|
20
|
+
return undefined;
|
|
21
|
+
throw error;
|
|
22
|
+
});
|
|
23
|
+
if (!folder)
|
|
24
|
+
return { state: "Gone" };
|
|
25
|
+
if (folder.syncStatus !== MailboxSyncStatus.pending ||
|
|
26
|
+
Date.now() >= deadline) {
|
|
27
|
+
return { state: "Present", folder };
|
|
28
|
+
}
|
|
29
|
+
await folders.sleep(folders.pollIntervalMs);
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
const folderState = (folder) => ({
|
|
33
|
+
accountId: folder.accountId,
|
|
34
|
+
mailboxId: folder.mailboxId,
|
|
35
|
+
fullPath: folder.fullPath,
|
|
36
|
+
syncStatus: folder.syncStatus,
|
|
37
|
+
});
|
|
38
|
+
const pendingResult = (folder, retry) => asJson({
|
|
39
|
+
...folderState(folder),
|
|
40
|
+
...(folder.pendingPath ? { pendingPath: folder.pendingPath } : {}),
|
|
41
|
+
message: `The mail server has not confirmed this yet. ${retry}`,
|
|
42
|
+
});
|
|
43
|
+
const settledOrRefused = (folder) => {
|
|
44
|
+
const settled = settledFolder(folder);
|
|
45
|
+
if (!settled.ok)
|
|
46
|
+
return refused(settled.refusal);
|
|
47
|
+
return asJson(folderState(folder));
|
|
48
|
+
};
|
|
49
|
+
const createOutcome = (watched, requested) => {
|
|
50
|
+
if (watched.state === "Gone") {
|
|
51
|
+
return toolError(`The mail server already holds “${requested}” under another path. Call list_mailboxes to find it.`);
|
|
52
|
+
}
|
|
53
|
+
const { folder } = watched;
|
|
54
|
+
switch (folder.syncStatus) {
|
|
55
|
+
case MailboxSyncStatus.synced:
|
|
56
|
+
return asJson(folderState(folder));
|
|
57
|
+
case MailboxSyncStatus.pending:
|
|
58
|
+
return pendingResult(folder, "Call create_folder again with the same arguments to keep waiting; it will not create a second folder.");
|
|
59
|
+
case MailboxSyncStatus.failed:
|
|
60
|
+
return toolError(`The mail server refused to create “${folder.fullPath}”: ${folder.syncFailureReason || "no reason given"}. Pick another name.`);
|
|
61
|
+
default:
|
|
62
|
+
return settledOrRefused(folder);
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
const renameOutcome = (watched, requested) => {
|
|
66
|
+
if (watched.state === "Gone") {
|
|
67
|
+
return toolError(`The folder was removed before the rename to “${requested}” settled. Call list_mailboxes to see what is there now.`);
|
|
68
|
+
}
|
|
69
|
+
const { folder } = watched;
|
|
70
|
+
switch (folder.syncStatus) {
|
|
71
|
+
case MailboxSyncStatus.synced:
|
|
72
|
+
return asJson(folderState(folder));
|
|
73
|
+
case MailboxSyncStatus.pending:
|
|
74
|
+
return pendingResult(folder, "Call rename_folder again with the same arguments to keep waiting; it will not rename twice.");
|
|
75
|
+
case MailboxSyncStatus.failed:
|
|
76
|
+
return toolError(`The mail server refused to rename “${folder.fullPath}” to “${requested}”: ${folder.syncFailureReason || "no reason given"}. Call rename_folder again to retry, or pass the current path “${folder.fullPath}” to drop the failed rename.`);
|
|
77
|
+
default:
|
|
78
|
+
return settledOrRefused(folder);
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
export const registerFolderTools = (server, deps, accountConfigId) => {
|
|
82
|
+
server.registerTool("create_folder", {
|
|
83
|
+
description: "Create a folder in one email account and wait until the mail server confirms it. Returns the confirmed path, or the pending state if the server is slow; calling again with the same arguments resumes the wait.",
|
|
84
|
+
inputSchema: {
|
|
85
|
+
accountId: z.string().min(1).describe("Account from list_mailboxes"),
|
|
86
|
+
fullPath: z
|
|
87
|
+
.string()
|
|
88
|
+
.min(1)
|
|
89
|
+
.describe(`Full folder path, levels separated by "${CREATE_DELIMITER}", e.g. "Projects/Acme"`),
|
|
90
|
+
},
|
|
91
|
+
annotations: FOLDER_WRITE,
|
|
92
|
+
}, async ({ accountId, fullPath }) => {
|
|
93
|
+
const { folders } = deps;
|
|
94
|
+
const created = await createFolder({
|
|
95
|
+
account: deps.account,
|
|
96
|
+
mailbox: deps.mailbox,
|
|
97
|
+
mailboxQueue: folders.mailboxQueue,
|
|
98
|
+
}, accountConfigId, { accountId, namespaceType: "personal", fullPath });
|
|
99
|
+
if (!created.ok)
|
|
100
|
+
return refused(created.refusal);
|
|
101
|
+
return createOutcome(await awaitSettled(deps, folders, accountId, created.value.mailbox.mailboxId), fullPath);
|
|
102
|
+
});
|
|
103
|
+
server.registerTool("rename_folder", {
|
|
104
|
+
description: "Rename a folder, or move it under another parent by giving a new full path, and wait until the mail server confirms it. Subfolders move with it. Returns the confirmed path, or the pending state if the server is slow; calling again with the same arguments resumes the wait.",
|
|
105
|
+
inputSchema: {
|
|
106
|
+
accountId: z.string().min(1).describe("Account from list_mailboxes"),
|
|
107
|
+
mailboxId: z.string().min(1).describe("Folder from list_mailboxes"),
|
|
108
|
+
fullPath: z
|
|
109
|
+
.string()
|
|
110
|
+
.min(1)
|
|
111
|
+
.describe('New full path, e.g. "Archive/2025" to move "2025" under "Archive"'),
|
|
112
|
+
},
|
|
113
|
+
annotations: FOLDER_WRITE,
|
|
114
|
+
}, async ({ accountId, mailboxId, fullPath }) => {
|
|
115
|
+
const { folders } = deps;
|
|
116
|
+
const changed = await changeFolderPath({
|
|
117
|
+
account: deps.account,
|
|
118
|
+
mailbox: deps.mailbox,
|
|
119
|
+
mailboxQueue: folders.mailboxQueue,
|
|
120
|
+
}, accountConfigId, { accountId, mailboxId, fullPath });
|
|
121
|
+
if (!changed.ok)
|
|
122
|
+
return refused(changed.refusal);
|
|
123
|
+
return renameOutcome(await awaitSettled(deps, folders, accountId, mailboxId), fullPath);
|
|
124
|
+
});
|
|
125
|
+
};
|
package/dist/main.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/main.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { setTimeout as sleep } from "node:timers/promises";
|
|
2
|
+
import { serve } from "@hono/node-server";
|
|
3
|
+
import { resolveSignInEnabled } from "@remit/auth-service/verifier";
|
|
4
|
+
import { getClient } from "@remit/backend/client";
|
|
5
|
+
import { logger } from "@remit/logger";
|
|
6
|
+
import { installNodeLogger } from "@remit/logger-lambda/node";
|
|
7
|
+
import { createMcpApp } from "./app.js";
|
|
8
|
+
import { createBetterAuthTokenVerifier } from "./auth/authorization-server.js";
|
|
9
|
+
import { openMcpAuthDatabase } from "./auth/database.js";
|
|
10
|
+
import { createSqliteMcpAuth } from "./auth/node-auth.js";
|
|
11
|
+
import { resolveMcpServerConfig } from "./config.js";
|
|
12
|
+
import { mcpDepsOf } from "./deps.js";
|
|
13
|
+
import { ASSETS_PATH_PREFIX, serveAsset } from "./pages/assets.js";
|
|
14
|
+
import { loadPageAssets } from "./pages/load-assets.js";
|
|
15
|
+
installNodeLogger();
|
|
16
|
+
const FOLDER_SETTLE_TIMEOUT_MS = 20_000;
|
|
17
|
+
const FOLDER_POLL_INTERVAL_MS = 500;
|
|
18
|
+
const config = resolveMcpServerConfig();
|
|
19
|
+
const client = await getClient();
|
|
20
|
+
const deps = mcpDepsOf(client, {
|
|
21
|
+
logger,
|
|
22
|
+
settle: {
|
|
23
|
+
settleTimeoutMs: FOLDER_SETTLE_TIMEOUT_MS,
|
|
24
|
+
pollIntervalMs: FOLDER_POLL_INTERVAL_MS,
|
|
25
|
+
sleep,
|
|
26
|
+
},
|
|
27
|
+
});
|
|
28
|
+
const authDb = openMcpAuthDatabase(config.databases.app).db;
|
|
29
|
+
const { auth, routes } = createSqliteMcpAuth({
|
|
30
|
+
origin: config.origin,
|
|
31
|
+
secret: config.secret,
|
|
32
|
+
signInEnabled: resolveSignInEnabled(),
|
|
33
|
+
}, authDb);
|
|
34
|
+
const app = createMcpApp({
|
|
35
|
+
origin: config.origin,
|
|
36
|
+
authRoutes: routes,
|
|
37
|
+
verifier: createBetterAuthTokenVerifier(auth, config.origin),
|
|
38
|
+
deps,
|
|
39
|
+
});
|
|
40
|
+
const pageAssets = loadPageAssets(config.pageAssetsDir);
|
|
41
|
+
serve({
|
|
42
|
+
fetch: (request) => {
|
|
43
|
+
const { pathname } = new URL(request.url);
|
|
44
|
+
if (pathname.startsWith(ASSETS_PATH_PREFIX)) {
|
|
45
|
+
return serveAsset(pageAssets, pathname);
|
|
46
|
+
}
|
|
47
|
+
return app(request);
|
|
48
|
+
},
|
|
49
|
+
hostname: "0.0.0.0",
|
|
50
|
+
port: config.port,
|
|
51
|
+
});
|
|
52
|
+
// biome-ignore lint/plugin/no-logger-info: one boot line names the origin the server answers on
|
|
53
|
+
logger.info({ origin: config.origin, port: config.port }, "mcp server listening");
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export declare const ASSETS_PATH_PREFIX = "/assets/";
|
|
2
|
+
export declare const STYLESHEET_PATH = "/assets/pages.css";
|
|
3
|
+
export interface PageAsset {
|
|
4
|
+
body: Uint8Array<ArrayBuffer>;
|
|
5
|
+
contentType: string;
|
|
6
|
+
}
|
|
7
|
+
export type PageAssets = ReadonlyMap<string, PageAsset>;
|
|
8
|
+
export declare const serveAsset: (assets: PageAssets, pathname: string) => Response;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export const ASSETS_PATH_PREFIX = "/assets/";
|
|
2
|
+
export const STYLESHEET_PATH = `${ASSETS_PATH_PREFIX}pages.css`;
|
|
3
|
+
export const serveAsset = (assets, pathname) => {
|
|
4
|
+
const asset = assets.get(pathname);
|
|
5
|
+
if (!asset)
|
|
6
|
+
return new Response("Not found", { status: 404 });
|
|
7
|
+
return new Response(asset.body, {
|
|
8
|
+
headers: {
|
|
9
|
+
"content-type": asset.contentType,
|
|
10
|
+
"cache-control": "public, max-age=3600",
|
|
11
|
+
"x-content-type-options": "nosniff",
|
|
12
|
+
},
|
|
13
|
+
});
|
|
14
|
+
};
|
|
Binary file
|