@nimbus-dev/connectors 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +662 -0
- package/NOTICE +40 -0
- package/README.md +111 -0
- package/connectors/airflow/nimbus.extension.json +16 -0
- package/connectors/airflow/src/search-filter.ts +38 -0
- package/connectors/airflow/src/server.ts +91 -0
- package/connectors/apple/nimbus.extension.json +40 -0
- package/connectors/apple/src/apple-mail-core.ts +43 -0
- package/connectors/apple/src/caldav-core.ts +116 -0
- package/connectors/apple/src/calendar-tools.ts +278 -0
- package/connectors/apple/src/server.ts +541 -0
- package/connectors/apple/src/tools.ts +138 -0
- package/connectors/argocd/nimbus.extension.json +16 -0
- package/connectors/argocd/src/search-filter.ts +24 -0
- package/connectors/argocd/src/server.ts +158 -0
- package/connectors/athena/nimbus.extension.json +15 -0
- package/connectors/athena/src/server.ts +6 -0
- package/connectors/athena/src/tools.ts +181 -0
- package/connectors/aws/nimbus.extension.json +21 -0
- package/connectors/aws/src/server.ts +191 -0
- package/connectors/azure/nimbus.extension.json +15 -0
- package/connectors/azure/src/server.ts +139 -0
- package/connectors/bigeye/nimbus.extension.json +16 -0
- package/connectors/bigeye/src/search-filter.ts +22 -0
- package/connectors/bigeye/src/server.ts +161 -0
- package/connectors/bigquery/nimbus.extension.json +15 -0
- package/connectors/bigquery/src/server.ts +6 -0
- package/connectors/bigquery/src/tools.ts +175 -0
- package/connectors/bitbucket/nimbus.extension.json +15 -0
- package/connectors/bitbucket/src/server.ts +252 -0
- package/connectors/bitrise/nimbus.extension.json +15 -0
- package/connectors/bitrise/src/search-filter.ts +11 -0
- package/connectors/bitrise/src/server.ts +83 -0
- package/connectors/canva/nimbus.extension.json +15 -0
- package/connectors/canva/src/search-filter.ts +23 -0
- package/connectors/canva/src/server.ts +77 -0
- package/connectors/circleci/nimbus.extension.json +15 -0
- package/connectors/circleci/src/server.ts +184 -0
- package/connectors/cloud-logging/nimbus.extension.json +15 -0
- package/connectors/cloud-logging/src/server.ts +6 -0
- package/connectors/cloud-logging/src/tools.ts +115 -0
- package/connectors/cloudwatch/nimbus.extension.json +15 -0
- package/connectors/cloudwatch/src/server.ts +6 -0
- package/connectors/cloudwatch/src/tools.ts +144 -0
- package/connectors/codemagic/nimbus.extension.json +15 -0
- package/connectors/codemagic/src/search-filter.ts +11 -0
- package/connectors/codemagic/src/server.ts +74 -0
- package/connectors/confluence/nimbus.extension.json +15 -0
- package/connectors/confluence/src/kb-append.ts +25 -0
- package/connectors/confluence/src/server.ts +309 -0
- package/connectors/dagster/nimbus.extension.json +16 -0
- package/connectors/dagster/src/search-filter.ts +53 -0
- package/connectors/dagster/src/server.ts +218 -0
- package/connectors/databricks/nimbus.extension.json +16 -0
- package/connectors/databricks/src/search-filter.ts +28 -0
- package/connectors/databricks/src/server.ts +81 -0
- package/connectors/datadog/nimbus.extension.json +15 -0
- package/connectors/datadog/src/server.ts +56 -0
- package/connectors/dataprofile/nimbus.extension.json +16 -0
- package/connectors/dataprofile/src/profile.ts +298 -0
- package/connectors/dataprofile/src/server.ts +6 -0
- package/connectors/dataprofile/src/tools.ts +69 -0
- package/connectors/dbt/nimbus.extension.json +16 -0
- package/connectors/dbt/src/search-filter.ts +28 -0
- package/connectors/dbt/src/server.ts +96 -0
- package/connectors/dependencytrack/nimbus.extension.json +16 -0
- package/connectors/dependencytrack/src/search-filter.ts +24 -0
- package/connectors/dependencytrack/src/server.ts +80 -0
- package/connectors/discord/nimbus.extension.json +15 -0
- package/connectors/discord/src/server.ts +93 -0
- package/connectors/elasticsearch/nimbus.extension.json +16 -0
- package/connectors/elasticsearch/src/server.ts +6 -0
- package/connectors/elasticsearch/src/tools.ts +110 -0
- package/connectors/fastmail/nimbus.extension.json +16 -0
- package/connectors/fastmail/src/jmap-core.ts +86 -0
- package/connectors/fastmail/src/server.ts +194 -0
- package/connectors/fastmail/src/tools.ts +143 -0
- package/connectors/figma/nimbus.extension.json +15 -0
- package/connectors/figma/src/search-filter.ts +24 -0
- package/connectors/figma/src/server.ts +117 -0
- package/connectors/firebase/nimbus.extension.json +15 -0
- package/connectors/firebase/src/search-filter.ts +30 -0
- package/connectors/firebase/src/server.ts +94 -0
- package/connectors/firebase/src/token.ts +41 -0
- package/connectors/flagsmith/nimbus.extension.json +15 -0
- package/connectors/flagsmith/src/search-filter.ts +29 -0
- package/connectors/flagsmith/src/server.ts +102 -0
- package/connectors/flux/nimbus.extension.json +16 -0
- package/connectors/flux/src/search-filter.ts +42 -0
- package/connectors/flux/src/server.ts +182 -0
- package/connectors/gcp/nimbus.extension.json +15 -0
- package/connectors/gcp/src/server.ts +140 -0
- package/connectors/github/nimbus.extension.json +15 -0
- package/connectors/github/src/server.ts +347 -0
- package/connectors/github-actions/nimbus.extension.json +15 -0
- package/connectors/github-actions/src/server.ts +213 -0
- package/connectors/gitlab/nimbus.extension.json +15 -0
- package/connectors/gitlab/src/server.ts +392 -0
- package/connectors/gmail/nimbus.extension.json +15 -0
- package/connectors/gmail/src/server.ts +277 -0
- package/connectors/google-drive/nimbus.extension.json +15 -0
- package/connectors/google-drive/src/drive-query.ts +3 -0
- package/connectors/google-drive/src/server.ts +531 -0
- package/connectors/google-meet/nimbus.extension.json +15 -0
- package/connectors/google-meet/src/server.ts +89 -0
- package/connectors/google-photos/nimbus.extension.json +15 -0
- package/connectors/google-photos/src/server.ts +139 -0
- package/connectors/grafana/nimbus.extension.json +15 -0
- package/connectors/grafana/src/server.ts +61 -0
- package/connectors/great-expectations/nimbus.extension.json +16 -0
- package/connectors/great-expectations/src/gx-parse.ts +292 -0
- package/connectors/great-expectations/src/server.ts +6 -0
- package/connectors/great-expectations/src/tools.ts +91 -0
- package/connectors/greenhouse/nimbus.extension.json +15 -0
- package/connectors/greenhouse/src/search-filter.ts +65 -0
- package/connectors/greenhouse/src/server.ts +67 -0
- package/connectors/hubspot/nimbus.extension.json +15 -0
- package/connectors/hubspot/src/search-filter.ts +28 -0
- package/connectors/hubspot/src/server.ts +81 -0
- package/connectors/iac/nimbus.extension.json +14 -0
- package/connectors/iac/src/server.ts +145 -0
- package/connectors/imap/nimbus.extension.json +16 -0
- package/connectors/imap/src/imap-core.ts +110 -0
- package/connectors/imap/src/server.ts +232 -0
- package/connectors/imap/src/tools.ts +40 -0
- package/connectors/intercom/nimbus.extension.json +15 -0
- package/connectors/intercom/src/search-filter.ts +46 -0
- package/connectors/intercom/src/server.ts +65 -0
- package/connectors/jenkins/nimbus.extension.json +15 -0
- package/connectors/jenkins/src/jenkins-api.ts +156 -0
- package/connectors/jenkins/src/server.ts +223 -0
- package/connectors/jira/nimbus.extension.json +15 -0
- package/connectors/jira/src/server.ts +309 -0
- package/connectors/kubernetes/nimbus.extension.json +15 -0
- package/connectors/kubernetes/src/server.ts +163 -0
- package/connectors/launchdarkly/nimbus.extension.json +15 -0
- package/connectors/launchdarkly/src/search-filter.ts +31 -0
- package/connectors/launchdarkly/src/server.ts +79 -0
- package/connectors/lever/nimbus.extension.json +15 -0
- package/connectors/lever/src/search-filter.ts +35 -0
- package/connectors/lever/src/server.ts +64 -0
- package/connectors/linear/nimbus.extension.json +15 -0
- package/connectors/linear/src/server.ts +467 -0
- package/connectors/localdb/nimbus.extension.json +16 -0
- package/connectors/localdb/src/server.ts +6 -0
- package/connectors/localdb/src/sql-scan.ts +158 -0
- package/connectors/localdb/src/tools.ts +67 -0
- package/connectors/looker/nimbus.extension.json +16 -0
- package/connectors/looker/src/search-filter.ts +22 -0
- package/connectors/looker/src/server.ts +254 -0
- package/connectors/mendeley/nimbus.extension.json +15 -0
- package/connectors/mendeley/src/search-filter.ts +68 -0
- package/connectors/mendeley/src/server.ts +59 -0
- package/connectors/mercury/nimbus.extension.json +15 -0
- package/connectors/mercury/src/search-filter.ts +11 -0
- package/connectors/mercury/src/server.ts +61 -0
- package/connectors/metabase/nimbus.extension.json +16 -0
- package/connectors/metabase/src/search-filter.ts +22 -0
- package/connectors/metabase/src/server.ts +83 -0
- package/connectors/miro/nimbus.extension.json +15 -0
- package/connectors/miro/src/search-filter.ts +24 -0
- package/connectors/miro/src/server.ts +76 -0
- package/connectors/mlflow/nimbus.extension.json +16 -0
- package/connectors/mlflow/src/search-filter.ts +38 -0
- package/connectors/mlflow/src/server.ts +167 -0
- package/connectors/monte-carlo/nimbus.extension.json +16 -0
- package/connectors/monte-carlo/src/search-filter.ts +11 -0
- package/connectors/monte-carlo/src/server.ts +241 -0
- package/connectors/netlify/nimbus.extension.json +15 -0
- package/connectors/netlify/src/search-filter.ts +37 -0
- package/connectors/netlify/src/server.ts +64 -0
- package/connectors/newrelic/nimbus.extension.json +15 -0
- package/connectors/newrelic/src/server.ts +48 -0
- package/connectors/notion/nimbus.extension.json +15 -0
- package/connectors/notion/src/kb-append.ts +23 -0
- package/connectors/notion/src/server.ts +343 -0
- package/connectors/obsidian/nimbus.extension.json +19 -0
- package/connectors/obsidian/src/server.ts +433 -0
- package/connectors/onedrive/nimbus.extension.json +15 -0
- package/connectors/onedrive/src/server.ts +256 -0
- package/connectors/outlook/nimbus.extension.json +15 -0
- package/connectors/outlook/src/server.ts +358 -0
- package/connectors/outlook/src/tool-scope-policy.ts +53 -0
- package/connectors/pagerduty/nimbus.extension.json +15 -0
- package/connectors/pagerduty/src/server.ts +140 -0
- package/connectors/pipedrive/nimbus.extension.json +15 -0
- package/connectors/pipedrive/src/search-filter.ts +11 -0
- package/connectors/pipedrive/src/server.ts +62 -0
- package/connectors/powerbi/nimbus.extension.json +16 -0
- package/connectors/powerbi/src/search-filter.ts +22 -0
- package/connectors/powerbi/src/server.ts +249 -0
- package/connectors/prefect/nimbus.extension.json +16 -0
- package/connectors/prefect/src/search-filter.ts +40 -0
- package/connectors/prefect/src/server.ts +94 -0
- package/connectors/protonmail/nimbus.extension.json +16 -0
- package/connectors/protonmail/src/mail-core.ts +96 -0
- package/connectors/protonmail/src/server.ts +231 -0
- package/connectors/protonmail/src/tools.ts +45 -0
- package/connectors/raindrop/nimbus.extension.json +15 -0
- package/connectors/raindrop/src/search-filter.ts +20 -0
- package/connectors/raindrop/src/server.ts +107 -0
- package/connectors/ramp/nimbus.extension.json +15 -0
- package/connectors/ramp/src/search-filter.ts +37 -0
- package/connectors/ramp/src/server.ts +109 -0
- package/connectors/readwise/nimbus.extension.json +15 -0
- package/connectors/readwise/src/search-filter.ts +64 -0
- package/connectors/readwise/src/server.ts +92 -0
- package/connectors/sagemaker/nimbus.extension.json +15 -0
- package/connectors/sagemaker/src/server.ts +6 -0
- package/connectors/sagemaker/src/tools.ts +121 -0
- package/connectors/salesforce/nimbus.extension.json +15 -0
- package/connectors/salesforce/src/search-filter.ts +23 -0
- package/connectors/salesforce/src/server.ts +94 -0
- package/connectors/semgrep/nimbus.extension.json +15 -0
- package/connectors/semgrep/src/search-filter.ts +31 -0
- package/connectors/semgrep/src/server.ts +108 -0
- package/connectors/sentry/nimbus.extension.json +15 -0
- package/connectors/sentry/src/server.ts +66 -0
- package/connectors/slack/nimbus.extension.json +15 -0
- package/connectors/slack/src/server.ts +360 -0
- package/connectors/snowflake/nimbus.extension.json +16 -0
- package/connectors/snowflake/src/search-filter.ts +26 -0
- package/connectors/snowflake/src/server.ts +236 -0
- package/connectors/snyk/nimbus.extension.json +15 -0
- package/connectors/snyk/src/search-filter.ts +28 -0
- package/connectors/snyk/src/server.ts +144 -0
- package/connectors/sonarqube/nimbus.extension.json +15 -0
- package/connectors/sonarqube/src/search-filter.ts +28 -0
- package/connectors/sonarqube/src/server.ts +115 -0
- package/connectors/stackoverflow/nimbus.extension.json +15 -0
- package/connectors/stackoverflow/src/search-filter.ts +52 -0
- package/connectors/stackoverflow/src/server.ts +81 -0
- package/connectors/storybook/nimbus.extension.json +16 -0
- package/connectors/storybook/src/server.ts +6 -0
- package/connectors/storybook/src/storybook-parse.ts +67 -0
- package/connectors/storybook/src/tools.ts +68 -0
- package/connectors/stripe/nimbus.extension.json +15 -0
- package/connectors/stripe/src/search-filter.ts +19 -0
- package/connectors/stripe/src/server.ts +65 -0
- package/connectors/superset/nimbus.extension.json +16 -0
- package/connectors/superset/src/search-filter.ts +22 -0
- package/connectors/superset/src/server.ts +119 -0
- package/connectors/tableau/nimbus.extension.json +16 -0
- package/connectors/tableau/src/search-filter.ts +22 -0
- package/connectors/tableau/src/server.ts +248 -0
- package/connectors/teams/nimbus.extension.json +20 -0
- package/connectors/teams/src/bot-send.ts +72 -0
- package/connectors/teams/src/server.ts +257 -0
- package/connectors/testflight/nimbus.extension.json +15 -0
- package/connectors/testflight/src/jwt.ts +27 -0
- package/connectors/testflight/src/search-filter.ts +30 -0
- package/connectors/testflight/src/server.ts +74 -0
- package/connectors/vercel/nimbus.extension.json +15 -0
- package/connectors/vercel/src/search-filter.ts +34 -0
- package/connectors/vercel/src/server.ts +79 -0
- package/connectors/vertex-ai/nimbus.extension.json +15 -0
- package/connectors/vertex-ai/src/server.ts +6 -0
- package/connectors/vertex-ai/src/tools.ts +145 -0
- package/connectors/wiz/nimbus.extension.json +15 -0
- package/connectors/wiz/src/search-filter.ts +50 -0
- package/connectors/wiz/src/server.ts +192 -0
- package/connectors/workday/nimbus.extension.json +19 -0
- package/connectors/workday/src/search-filter.ts +5 -0
- package/connectors/workday/src/server.ts +78 -0
- package/connectors/workday/src/tools.ts +1 -0
- package/connectors/zendesk/nimbus.extension.json +16 -0
- package/connectors/zendesk/src/search-filter.ts +11 -0
- package/connectors/zendesk/src/server.ts +77 -0
- package/connectors/zoom/nimbus.extension.json +15 -0
- package/connectors/zoom/src/encode-meeting-path.ts +5 -0
- package/connectors/zoom/src/recordings-window.ts +34 -0
- package/connectors/zoom/src/search-filter.ts +42 -0
- package/connectors/zoom/src/server.ts +98 -0
- package/connectors/zotero/nimbus.extension.json +15 -0
- package/connectors/zotero/src/search-filter.ts +81 -0
- package/connectors/zotero/src/server.ts +82 -0
- package/package.json +152 -0
- package/shared/atlassian-json-fetch.ts +42 -0
- package/shared/audit-chain.ts +133 -0
- package/shared/connector-mode.ts +60 -0
- package/shared/consent-kit.ts +323 -0
- package/shared/fetch-bearer-json.ts +18 -0
- package/shared/header-safe.ts +28 -0
- package/shared/imap-bodystructure.ts +113 -0
- package/shared/imap-mail-core.ts +57 -0
- package/shared/imap-tool-kit.ts +293 -0
- package/shared/join-api-path.ts +18 -0
- package/shared/kb-markdown.ts +142 -0
- package/shared/mcp-search-tool.ts +37 -0
- package/shared/mcp-tool-kit.ts +35 -0
- package/shared/nimbus-spawn.ts +128 -0
- package/shared/rest-tool-kit.ts +18 -0
- package/shared/run-cli-json.ts +55 -0
- package/shared/run-read-only-mcp-connector.ts +59 -0
- package/shared/safe-cli-arg.ts +55 -0
- package/shared/search-filter.ts +140 -0
- package/shared/strip-trailing-slashes.ts +7 -0
- package/shared/write-scope.ts +58 -0
- package/standalone/src/bin.ts +19 -0
- package/standalone/src/launcher.ts +186 -0
- package/standalone/tsconfig.json +8 -0
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
3
|
+
import { type FetchMessageObject, type FetchQueryObject, ImapFlow } from "imapflow";
|
|
4
|
+
import { createTransport, type Transporter } from "nodemailer";
|
|
5
|
+
import {
|
|
6
|
+
type BodyStructureNode,
|
|
7
|
+
extractAttachments,
|
|
8
|
+
findTextPlainPart,
|
|
9
|
+
} from "../../../shared/imap-bodystructure.ts";
|
|
10
|
+
import { envInt, previewFromParts } from "../../../shared/imap-tool-kit.ts";
|
|
11
|
+
import { requireProcessEnv } from "../../../shared/mcp-tool-kit.ts";
|
|
12
|
+
import {
|
|
13
|
+
clampLimit,
|
|
14
|
+
type MailClient,
|
|
15
|
+
type MailEnvelope,
|
|
16
|
+
type MailListOptions,
|
|
17
|
+
type MailMessageMeta,
|
|
18
|
+
type MailSearchOptions,
|
|
19
|
+
PREVIEW_FETCH_BYTES,
|
|
20
|
+
type SendMailInput,
|
|
21
|
+
type SendMailResult,
|
|
22
|
+
type SmtpMailer,
|
|
23
|
+
} from "./mail-core.ts";
|
|
24
|
+
import { registerProtonmailTools } from "./tools.ts";
|
|
25
|
+
|
|
26
|
+
// ProtonMail Bridge loopback defaults.
|
|
27
|
+
const DEFAULT_IMAP_HOST = "127.0.0.1";
|
|
28
|
+
const DEFAULT_IMAP_PORT = 1143;
|
|
29
|
+
const DEFAULT_SMTP_HOST = "127.0.0.1";
|
|
30
|
+
const DEFAULT_SMTP_PORT = 1025;
|
|
31
|
+
const DEFAULT_MAILBOX = "INBOX";
|
|
32
|
+
|
|
33
|
+
function envStr(name: string, fallback: string): string {
|
|
34
|
+
const raw = process.env[name]?.trim();
|
|
35
|
+
return raw === undefined || raw === "" ? fallback : raw;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function envelopeFromImap(env: FetchMessageObject["envelope"]): MailEnvelope {
|
|
39
|
+
if (env === undefined) {
|
|
40
|
+
return {};
|
|
41
|
+
}
|
|
42
|
+
const toAddr = (a: { name?: string; address?: string }): { name?: string; address?: string } => {
|
|
43
|
+
const out: { name?: string; address?: string } = {};
|
|
44
|
+
if (a.name !== undefined) {
|
|
45
|
+
out.name = a.name;
|
|
46
|
+
}
|
|
47
|
+
if (a.address !== undefined) {
|
|
48
|
+
out.address = a.address;
|
|
49
|
+
}
|
|
50
|
+
return out;
|
|
51
|
+
};
|
|
52
|
+
return {
|
|
53
|
+
date: env.date ?? null,
|
|
54
|
+
subject: env.subject ?? null,
|
|
55
|
+
messageId: env.messageId ?? null,
|
|
56
|
+
from: (env.from ?? []).map(toAddr),
|
|
57
|
+
to: (env.to ?? []).map(toAddr),
|
|
58
|
+
cc: (env.cc ?? []).map(toAddr),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function toMessageMeta(
|
|
63
|
+
msg: FetchMessageObject,
|
|
64
|
+
mailbox: string,
|
|
65
|
+
uidValidity: string | null,
|
|
66
|
+
): MailMessageMeta {
|
|
67
|
+
const structure = (msg.bodyStructure ?? null) as BodyStructureNode | null;
|
|
68
|
+
const partKey = findTextPlainPart(structure);
|
|
69
|
+
return {
|
|
70
|
+
uid: msg.uid,
|
|
71
|
+
mailbox,
|
|
72
|
+
uidValidity,
|
|
73
|
+
envelope: envelopeFromImap(msg.envelope),
|
|
74
|
+
attachments: extractAttachments(structure),
|
|
75
|
+
preview: previewFromParts(msg.bodyParts, partKey),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function previewFetchQuery(): FetchQueryObject {
|
|
80
|
+
return {
|
|
81
|
+
uid: true,
|
|
82
|
+
envelope: true,
|
|
83
|
+
bodyStructure: true,
|
|
84
|
+
internalDate: true,
|
|
85
|
+
bodyParts: [
|
|
86
|
+
{ key: "1", start: 0, maxLength: PREVIEW_FETCH_BYTES },
|
|
87
|
+
{ key: "TEXT", start: 0, maxLength: PREVIEW_FETCH_BYTES },
|
|
88
|
+
],
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Real IMAP client over imapflow, configured for ProtonMail Bridge: a loopback
|
|
94
|
+
* STARTTLS endpoint with a Bridge-generated self-signed certificate, so
|
|
95
|
+
* `secure: false` + `tls.rejectUnauthorized: false` (the connection never
|
|
96
|
+
* leaves 127.0.0.1).
|
|
97
|
+
*/
|
|
98
|
+
class BridgeImapClient implements MailClient {
|
|
99
|
+
private readonly config: { host: string; port: number; user: string; pass: string };
|
|
100
|
+
|
|
101
|
+
constructor() {
|
|
102
|
+
this.config = {
|
|
103
|
+
host: envStr("PROTONMAIL_HOST", DEFAULT_IMAP_HOST),
|
|
104
|
+
port: envInt("PROTONMAIL_PORT", DEFAULT_IMAP_PORT),
|
|
105
|
+
user: requireProcessEnv("PROTONMAIL_USERNAME"),
|
|
106
|
+
pass: requireProcessEnv("PROTONMAIL_PASSWORD"),
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
private newClient(): ImapFlow {
|
|
111
|
+
return new ImapFlow({
|
|
112
|
+
host: this.config.host,
|
|
113
|
+
port: this.config.port,
|
|
114
|
+
secure: false,
|
|
115
|
+
auth: { user: this.config.user, pass: this.config.pass },
|
|
116
|
+
logger: false,
|
|
117
|
+
// ProtonMail Bridge listens on localhost with a self-signed certificate;
|
|
118
|
+
// disabling chain verification is required and safe for the loopback bridge.
|
|
119
|
+
tls: { rejectUnauthorized: false },
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
private async withMailbox<T>(
|
|
124
|
+
mailbox: string,
|
|
125
|
+
fn: (client: ImapFlow, uidValidity: string | null) => Promise<T>,
|
|
126
|
+
): Promise<T> {
|
|
127
|
+
const client = this.newClient();
|
|
128
|
+
await client.connect();
|
|
129
|
+
const lock = await client.getMailboxLock(mailbox);
|
|
130
|
+
try {
|
|
131
|
+
const mb = client.mailbox;
|
|
132
|
+
const uidValidity = mb === false ? null : String(mb.uidValidity);
|
|
133
|
+
return await fn(client, uidValidity);
|
|
134
|
+
} finally {
|
|
135
|
+
lock.release();
|
|
136
|
+
await client.logout();
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async list(options: MailListOptions): Promise<MailMessageMeta[]> {
|
|
141
|
+
const mailbox = options.mailbox ?? DEFAULT_MAILBOX;
|
|
142
|
+
const limit = clampLimit(options.limit);
|
|
143
|
+
return this.withMailbox(mailbox, async (client, uidValidity) => {
|
|
144
|
+
const out: MailMessageMeta[] = [];
|
|
145
|
+
const status = await client.status(mailbox, { messages: true });
|
|
146
|
+
const total = status.messages ?? 0;
|
|
147
|
+
if (total === 0) {
|
|
148
|
+
return out;
|
|
149
|
+
}
|
|
150
|
+
const start = Math.max(1, total - limit + 1);
|
|
151
|
+
const range = `${start}:${total}`;
|
|
152
|
+
for await (const msg of client.fetch(range, previewFetchQuery())) {
|
|
153
|
+
out.push(toMessageMeta(msg, mailbox, uidValidity));
|
|
154
|
+
}
|
|
155
|
+
out.sort((a, b) => b.uid - a.uid);
|
|
156
|
+
return out;
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async get(uid: number, mailbox?: string): Promise<MailMessageMeta | null> {
|
|
161
|
+
const box = mailbox ?? DEFAULT_MAILBOX;
|
|
162
|
+
return this.withMailbox(box, async (client, uidValidity) => {
|
|
163
|
+
const msg = await client.fetchOne(String(uid), previewFetchQuery(), { uid: true });
|
|
164
|
+
return msg === false ? null : toMessageMeta(msg, box, uidValidity);
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async search(options: MailSearchOptions): Promise<MailMessageMeta[]> {
|
|
169
|
+
const mailbox = options.mailbox ?? DEFAULT_MAILBOX;
|
|
170
|
+
const limit = clampLimit(options.limit);
|
|
171
|
+
const q = options.query;
|
|
172
|
+
return this.withMailbox(mailbox, async (client, uidValidity) => {
|
|
173
|
+
const uids = await client.search(
|
|
174
|
+
{ or: [{ subject: q }, { from: q }, { to: q }] },
|
|
175
|
+
{ uid: true },
|
|
176
|
+
);
|
|
177
|
+
if (uids === false || uids.length === 0) {
|
|
178
|
+
return [];
|
|
179
|
+
}
|
|
180
|
+
const picked = uids.slice(-limit).sort((a, b) => b - a);
|
|
181
|
+
const out: MailMessageMeta[] = [];
|
|
182
|
+
for await (const msg of client.fetch(picked, previewFetchQuery(), { uid: true })) {
|
|
183
|
+
out.push(toMessageMeta(msg, mailbox, uidValidity));
|
|
184
|
+
}
|
|
185
|
+
out.sort((a, b) => b.uid - a.uid);
|
|
186
|
+
return out;
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Real SMTP mailer over nodemailer, configured for the ProtonMail Bridge relay. */
|
|
192
|
+
class BridgeMailer implements SmtpMailer {
|
|
193
|
+
private readonly transport: Transporter;
|
|
194
|
+
private readonly from: string;
|
|
195
|
+
|
|
196
|
+
constructor() {
|
|
197
|
+
const host = envStr("PROTONMAIL_SMTP_HOST", DEFAULT_SMTP_HOST);
|
|
198
|
+
const port = envInt("PROTONMAIL_SMTP_PORT", DEFAULT_SMTP_PORT);
|
|
199
|
+
const user = requireProcessEnv("PROTONMAIL_SMTP_USERNAME");
|
|
200
|
+
const pass = requireProcessEnv("PROTONMAIL_SMTP_PASSWORD");
|
|
201
|
+
this.from = user;
|
|
202
|
+
this.transport = createTransport({
|
|
203
|
+
host,
|
|
204
|
+
port,
|
|
205
|
+
secure: false,
|
|
206
|
+
auth: { user, pass },
|
|
207
|
+
tls: { rejectUnauthorized: false },
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async send(input: SendMailInput): Promise<SendMailResult> {
|
|
212
|
+
const info = await this.transport.sendMail({
|
|
213
|
+
from: this.from,
|
|
214
|
+
to: input.to,
|
|
215
|
+
subject: input.subject,
|
|
216
|
+
text: input.body,
|
|
217
|
+
...(input.cc === undefined ? {} : { cc: input.cc }),
|
|
218
|
+
...(input.bcc === undefined ? {} : { bcc: input.bcc }),
|
|
219
|
+
});
|
|
220
|
+
return {
|
|
221
|
+
messageId: info.messageId ?? null,
|
|
222
|
+
accepted: (info.accepted ?? []).map(String),
|
|
223
|
+
rejected: (info.rejected ?? []).map(String),
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const server = new McpServer({ name: "nimbus-protonmail", version: "0.1.0" });
|
|
229
|
+
registerProtonmailTools(server, new BridgeImapClient(), new BridgeMailer());
|
|
230
|
+
|
|
231
|
+
await server.connect(new StdioServerTransport());
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { type ConsentServer, createWriteToolRegistrar } from "../../../shared/consent-kit.ts";
|
|
2
|
+
import { registerEmailConnectorTools } from "../../../shared/imap-tool-kit.ts";
|
|
3
|
+
import { formatAddress, type MailClient, type SmtpMailer } from "./mail-core.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Register the ProtonMail (Bridge) read tools + the SMTP send tool onto an MCP
|
|
7
|
+
* server. The IMAP client and SMTP mailer are injected so tests exercise the
|
|
8
|
+
* tool surface without opening real sockets.
|
|
9
|
+
*/
|
|
10
|
+
export function registerProtonmailTools(
|
|
11
|
+
// Widened: the consent kit needs the real server surface, not just the `.tool` shim.
|
|
12
|
+
server: ConsentServer & { tool: (...args: never) => unknown },
|
|
13
|
+
client: MailClient,
|
|
14
|
+
mailer: SmtpMailer,
|
|
15
|
+
): void {
|
|
16
|
+
const registerWriteTool = createWriteToolRegistrar(server, {
|
|
17
|
+
connector: "protonmail",
|
|
18
|
+
scopeEnv: "NIMBUS_MCP_PROTONMAIL_WRITE_SCOPE",
|
|
19
|
+
scopeKinds: ["recipient"],
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
registerEmailConnectorTools({
|
|
23
|
+
server,
|
|
24
|
+
registerWriteTool,
|
|
25
|
+
toolPrefix: "protonmail",
|
|
26
|
+
descriptions: {
|
|
27
|
+
list: "List recent ProtonMail messages (via Bridge) — HEADERS + attachment METADATA + a short capped text preview ONLY. Returns subject, from, to/cc, date, message-id, attachment {filename,size,mimetype}, and a <=2000-char plain-text body preview. NEVER returns attachment bytes or the full message body.",
|
|
28
|
+
get: "Fetch one ProtonMail message by uid — HEADERS + attachment METADATA + a short capped text preview ONLY. NEVER returns attachment bytes or the full message body.",
|
|
29
|
+
search:
|
|
30
|
+
"Substring search over message HEADERS (subject/from/to) — returns the same header + attachment-metadata + preview view as protonmail_list. Searches headers only; NEVER scans or returns document/body content beyond the capped preview.",
|
|
31
|
+
send: "Send a new email over the ProtonMail Bridge SMTP relay. Requires Gateway HITL email.send.",
|
|
32
|
+
},
|
|
33
|
+
client,
|
|
34
|
+
mailer,
|
|
35
|
+
formatAddr: formatAddress,
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Tool names exposed by this connector — for contract/introspection tests. */
|
|
40
|
+
export const PROTONMAIL_TOOL_NAMES = [
|
|
41
|
+
"protonmail_list",
|
|
42
|
+
"protonmail_get",
|
|
43
|
+
"protonmail_search",
|
|
44
|
+
"protonmail_mail_send",
|
|
45
|
+
] as const;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "com.nimbus.raindrop",
|
|
3
|
+
"displayName": "Raindrop",
|
|
4
|
+
"version": "0.1.0",
|
|
5
|
+
"description": "Raindrop connector (read-only). Surfaces the user's saved Raindrop.io bookmarks as `raindrop:bookmark` items in the local index — the bookmarked URL, title, the excerpt/description, the user's note, the domain, the item type (link/article/image/video/document/audio), tags, and the parent collection id — plus the collections themselves as `raindrop:collection` items (title, raindrop count, public flag, view, color, sort, parent id), so a bookmark's `collectionId` resolves to a real indexed record. Useful for answering bookmarking questions (\"what did I save about retries?\", \"which shelf is that bookmark on?\") without leaving Nimbus. Bearer API-token auth (`Authorization: Bearer <token>` — a Raindrop.io test token or OAuth access token; never logged). The SaaS host (api.raindrop.io) is fixed. Raindrop paginates the `/rest/v1/raindrops/0` endpoint by page number (0-based, page_size=50), returning the `{ result, items, count }` envelope; collections come from the two UNPAGINATED endpoints `/rest/v1/collections` (root) and `/rest/v1/collections/childrens` (nested). `created` / `lastUpdate` are ISO-8601 (converted to epoch-ms on index). Collection `external_id`s are namespaced `collection/<id>` because Raindrop numbers collections and raindrops in separate id spaces. Highlights and per-collection filtering of bookmarks remain deferred follow-ups.",
|
|
6
|
+
"author": "Nimbus",
|
|
7
|
+
"entrypoint": "dist/server.js",
|
|
8
|
+
"runtime": "bun",
|
|
9
|
+
"permissions": {
|
|
10
|
+
"network": ["api.raindrop.io"]
|
|
11
|
+
},
|
|
12
|
+
"hitlRequired": [],
|
|
13
|
+
"syncInterval": 600,
|
|
14
|
+
"minNimbusVersion": "0.2.0"
|
|
15
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import {
|
|
2
|
+
fieldsFromKeys,
|
|
3
|
+
makeQueryFilter,
|
|
4
|
+
type SearchMatchOptions,
|
|
5
|
+
} from "../../../shared/search-filter.ts";
|
|
6
|
+
|
|
7
|
+
export type RaindropSearchMatchOptions = SearchMatchOptions;
|
|
8
|
+
|
|
9
|
+
export const filterRaindropBookmarks = makeQueryFilter(
|
|
10
|
+
fieldsFromKeys(["title", "excerpt", "note", "domain", "link", "type"], { tags: true }),
|
|
11
|
+
);
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Collection haystack: the title and the two display fields. The Raindrop
|
|
15
|
+
* Collection object has no description, and `cover` / `access` / `collaborators`
|
|
16
|
+
* are noise, so the title carries almost all the signal.
|
|
17
|
+
*/
|
|
18
|
+
export const filterRaindropCollections = makeQueryFilter(
|
|
19
|
+
fieldsFromKeys(["title", "view", "color"]),
|
|
20
|
+
);
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { matchesResult, searchToolInputSchema } from "../../../shared/mcp-search-tool.ts";
|
|
3
|
+
import { mcpJsonResult as jsonResult } from "../../../shared/mcp-tool-kit.ts";
|
|
4
|
+
import { runReadOnlyMcpConnector } from "../../../shared/run-read-only-mcp-connector.ts";
|
|
5
|
+
import { filterRaindropBookmarks, filterRaindropCollections } from "./search-filter.ts";
|
|
6
|
+
|
|
7
|
+
const BASE = "https://api.raindrop.io";
|
|
8
|
+
|
|
9
|
+
function apiToken(): string {
|
|
10
|
+
const t = process.env["RAINDROP_TOKEN"]?.trim();
|
|
11
|
+
if (t === undefined || t === "") {
|
|
12
|
+
throw new Error("RAINDROP_TOKEN is not set");
|
|
13
|
+
}
|
|
14
|
+
return t;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function authHeader(): Record<string, string> {
|
|
18
|
+
return { Authorization: `Bearer ${apiToken()}`, Accept: "application/json" };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function raindropGet(path: string): Promise<unknown> {
|
|
22
|
+
const res = await fetch(`${BASE}${path}`, { headers: authHeader() });
|
|
23
|
+
const text = await res.text();
|
|
24
|
+
if (!res.ok) {
|
|
25
|
+
throw new Error(`Raindrop ${String(res.status)}: ${text.slice(0, 400)}`);
|
|
26
|
+
}
|
|
27
|
+
return JSON.parse(text) as unknown;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Raindrop splits collections across two unpaginated endpoints — `/collections`
|
|
32
|
+
* (root) and `/collections/childrens` (every nested one). "List my collections"
|
|
33
|
+
* means both, so drain both and concatenate the `items` arrays.
|
|
34
|
+
*/
|
|
35
|
+
async function allCollections(): Promise<unknown[]> {
|
|
36
|
+
const [root, children] = await Promise.all([
|
|
37
|
+
raindropGet(`/rest/v1/collections`),
|
|
38
|
+
raindropGet(`/rest/v1/collections/childrens`),
|
|
39
|
+
]);
|
|
40
|
+
const itemsOf = (envelope: unknown): unknown[] => {
|
|
41
|
+
const items = (envelope as { items?: unknown } | null)?.items;
|
|
42
|
+
return Array.isArray(items) ? items : [];
|
|
43
|
+
};
|
|
44
|
+
return [...itemsOf(root), ...itemsOf(children)];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
await runReadOnlyMcpConnector("nimbus-raindrop", (reg) => {
|
|
48
|
+
reg(
|
|
49
|
+
"raindrop_list",
|
|
50
|
+
'List the user\'s Raindrop bookmarks (`GET /rest/v1/raindrops/0?perpage=50` — collection id `0` is the special "all raindrops" collection). Returns the `{ result, items: [...], count }` envelope — `items` holds the bookmark objects.',
|
|
51
|
+
z.object({}),
|
|
52
|
+
async () => {
|
|
53
|
+
return jsonResult(await raindropGet(`/rest/v1/raindrops/0?perpage=50`));
|
|
54
|
+
},
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
reg(
|
|
58
|
+
"raindrop_get",
|
|
59
|
+
"Fetch one Raindrop bookmark by its id (`GET /rest/v1/raindrop/{id}` — note the SINGULAR `raindrop` in the get-by-id path). Returns the `{ result, item: {...} }` envelope. Throws when no match is found.",
|
|
60
|
+
z.object({
|
|
61
|
+
id: z.string().min(1),
|
|
62
|
+
}),
|
|
63
|
+
async (p) => {
|
|
64
|
+
return jsonResult(await raindropGet(`/rest/v1/raindrop/${encodeURIComponent(p.id)}`));
|
|
65
|
+
},
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
reg(
|
|
69
|
+
"raindrop_search",
|
|
70
|
+
"Substring search across the user's Raindrop bookmarks (first page only). Matches the query against the bookmark title, excerpt, note, domain, link, type, and tags (case-insensitive). Returns a `{ matches: [...] }` envelope.",
|
|
71
|
+
searchToolInputSchema(100),
|
|
72
|
+
async (p) => {
|
|
73
|
+
const root = await raindropGet(`/rest/v1/raindrops/0?perpage=50`);
|
|
74
|
+
const items = (root as { items?: unknown[] } | null)?.items;
|
|
75
|
+
return matchesResult(items, filterRaindropBookmarks, p);
|
|
76
|
+
},
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
reg(
|
|
80
|
+
"raindrop_collections_list",
|
|
81
|
+
"List ALL the user's Raindrop collections — the records a bookmark's `collectionId` points at. Raindrop splits them across two unpaginated endpoints, so this drains BOTH (`GET /rest/v1/collections` for root collections and `GET /rest/v1/collections/childrens` for every nested one) and returns their concatenated `items` as `{ items: [...] }`. A nested collection carries `parent.$id`; a root one does not. Note that collection id `0` — the special \"all raindrops\" collection the bookmark list reads — is NOT returned by either endpoint.",
|
|
82
|
+
z.object({}),
|
|
83
|
+
async () => {
|
|
84
|
+
return jsonResult({ items: await allCollections() });
|
|
85
|
+
},
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
reg(
|
|
89
|
+
"raindrop_collection_get",
|
|
90
|
+
"Fetch one Raindrop collection by its id (`GET /rest/v1/collection/{id}` — note the SINGULAR `collection` in the get-by-id path). The collection id space is SEPARATE from the bookmark id space: pass a bookmark's `collectionId`, not its `_id`. Returns the `{ result, item: {...} }` envelope. Throws when no match is found.",
|
|
91
|
+
z.object({
|
|
92
|
+
id: z.string().min(1),
|
|
93
|
+
}),
|
|
94
|
+
async (p) => {
|
|
95
|
+
return jsonResult(await raindropGet(`/rest/v1/collection/${encodeURIComponent(p.id)}`));
|
|
96
|
+
},
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
reg(
|
|
100
|
+
"raindrop_collections_search",
|
|
101
|
+
"Substring search across ALL the user's Raindrop collections (root + nested). Matches the query against the collection title, view, and color (case-insensitive) — the Raindrop Collection object has no description field. Returns a `{ matches: [...] }` envelope.",
|
|
102
|
+
searchToolInputSchema(100),
|
|
103
|
+
async (p) => {
|
|
104
|
+
return matchesResult(await allCollections(), filterRaindropCollections, p);
|
|
105
|
+
},
|
|
106
|
+
);
|
|
107
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "com.nimbus.ramp",
|
|
3
|
+
"displayName": "Ramp",
|
|
4
|
+
"version": "0.1.0",
|
|
5
|
+
"description": "Ramp connector (read-only). Surfaces the user's Ramp card transactions as ramp:transaction items in the local index — the merchant name, the amount + currency, the card holder's display name and department, the transaction state, the spend category, the transaction time, and a truncated memo. Useful for spend questions (\"what did I spend at AWS last month?\", \"find my transactions in the Engineering department\") without leaving Nimbus. OAuth2 client-credentials auth: the connector exchanges its client id + client secret for a bearer token at POST /developer/v1/token (HTTP Basic auth, transactions:read scope) and caches the token for the process, then calls the data endpoints with Authorization: Bearer. Two required secret credentials (a client id + a client secret) selecting the Ramp developer application. The SaaS host (api.ramp.com) is fixed. The /developer/v1/transactions endpoint paginates by a page.next cursor (a full URL to the next page, or null at the end); the connector walks a single forward pass per cycle, page-capped. No full card numbers or PANs are surfaced — Ramp's API does not return them and only the safe transaction fields are mapped.",
|
|
6
|
+
"author": "Nimbus",
|
|
7
|
+
"entrypoint": "dist/server.js",
|
|
8
|
+
"runtime": "bun",
|
|
9
|
+
"permissions": {
|
|
10
|
+
"network": ["api.ramp.com"]
|
|
11
|
+
},
|
|
12
|
+
"hitlRequired": [],
|
|
13
|
+
"syncInterval": 600,
|
|
14
|
+
"minNimbusVersion": "0.2.0"
|
|
15
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import {
|
|
2
|
+
asObjectish,
|
|
3
|
+
makeQueryFilter,
|
|
4
|
+
type SearchMatchOptions,
|
|
5
|
+
stringField,
|
|
6
|
+
} from "../../../shared/search-filter.ts";
|
|
7
|
+
|
|
8
|
+
export type RampSearchMatchOptions = SearchMatchOptions;
|
|
9
|
+
|
|
10
|
+
function cardHolderName(row: Record<string, unknown>): string {
|
|
11
|
+
const holder = asObjectish(row["card_holder"]);
|
|
12
|
+
if (holder === undefined) {
|
|
13
|
+
return "";
|
|
14
|
+
}
|
|
15
|
+
// stringField already returns "" for missing/non-string fields, so no `?? ""` is needed.
|
|
16
|
+
const first = stringField(holder, "first_name");
|
|
17
|
+
const last = stringField(holder, "last_name");
|
|
18
|
+
const dept = stringField(holder, "department_name");
|
|
19
|
+
return [first, last, dept].filter((p) => p !== "").join(" ");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function fieldsOf(item: unknown): readonly string[] | null {
|
|
23
|
+
const row = asObjectish(item);
|
|
24
|
+
if (row === undefined) {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
return [
|
|
28
|
+
stringField(row, "merchant_name"),
|
|
29
|
+
stringField(row, "sk_category_name"),
|
|
30
|
+
stringField(row, "state"),
|
|
31
|
+
stringField(row, "currency_code"),
|
|
32
|
+
stringField(row, "memo"),
|
|
33
|
+
cardHolderName(row),
|
|
34
|
+
];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export const filterRampTransactions = makeQueryFilter(fieldsOf);
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { searchToolInputSchema } from "../../../shared/mcp-search-tool.ts";
|
|
3
|
+
import { mcpJsonResult as jsonResult } from "../../../shared/mcp-tool-kit.ts";
|
|
4
|
+
import { runReadOnlyMcpConnector } from "../../../shared/run-read-only-mcp-connector.ts";
|
|
5
|
+
import { filterRampTransactions } from "./search-filter.ts";
|
|
6
|
+
|
|
7
|
+
const BASE = "https://api.ramp.com";
|
|
8
|
+
const TOKEN_PATH = "/developer/v1/token";
|
|
9
|
+
const TOKEN_SCOPE = "transactions:read";
|
|
10
|
+
|
|
11
|
+
function requiredEnv(name: string): string {
|
|
12
|
+
const v = process.env[name]?.trim();
|
|
13
|
+
if (v === undefined || v === "") {
|
|
14
|
+
throw new Error(`${name} is not set`);
|
|
15
|
+
}
|
|
16
|
+
return v;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
let cachedToken: string | null = null;
|
|
20
|
+
|
|
21
|
+
async function token(): Promise<string> {
|
|
22
|
+
if (cachedToken !== null) {
|
|
23
|
+
return cachedToken;
|
|
24
|
+
}
|
|
25
|
+
const clientId = requiredEnv("RAMP_CLIENT_ID");
|
|
26
|
+
const clientSecret = requiredEnv("RAMP_CLIENT_SECRET");
|
|
27
|
+
const basic = Buffer.from(`${clientId}:${clientSecret}`, "utf8").toString("base64");
|
|
28
|
+
const body = new URLSearchParams({ grant_type: "client_credentials", scope: TOKEN_SCOPE });
|
|
29
|
+
const res = await fetch(`${BASE}${TOKEN_PATH}`, {
|
|
30
|
+
method: "POST",
|
|
31
|
+
headers: {
|
|
32
|
+
Authorization: `Basic ${basic}`,
|
|
33
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
34
|
+
Accept: "application/json",
|
|
35
|
+
},
|
|
36
|
+
body: body.toString(),
|
|
37
|
+
});
|
|
38
|
+
const text = await res.text();
|
|
39
|
+
if (!res.ok) {
|
|
40
|
+
throw new Error(`Ramp token exchange ${String(res.status)}: ${text.slice(0, 400)}`);
|
|
41
|
+
}
|
|
42
|
+
let parsed: { access_token?: unknown };
|
|
43
|
+
try {
|
|
44
|
+
parsed = JSON.parse(text) as { access_token?: unknown };
|
|
45
|
+
} catch {
|
|
46
|
+
throw new Error("Ramp token exchange: invalid JSON response");
|
|
47
|
+
}
|
|
48
|
+
if (typeof parsed.access_token !== "string" || parsed.access_token === "") {
|
|
49
|
+
throw new Error("Ramp token exchange: no access_token in response");
|
|
50
|
+
}
|
|
51
|
+
cachedToken = parsed.access_token;
|
|
52
|
+
return cachedToken;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function rampGet(path: string): Promise<unknown> {
|
|
56
|
+
const t = await token();
|
|
57
|
+
const res = await fetch(`${BASE}${path}`, {
|
|
58
|
+
headers: { Authorization: `Bearer ${t}`, Accept: "application/json" },
|
|
59
|
+
});
|
|
60
|
+
const text = await res.text();
|
|
61
|
+
if (!res.ok) {
|
|
62
|
+
throw new Error(`Ramp ${String(res.status)}: ${text.slice(0, 400)}`);
|
|
63
|
+
}
|
|
64
|
+
return JSON.parse(text) as unknown;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function transactionsFrom(root: unknown): unknown[] {
|
|
68
|
+
const data = (root as { data?: unknown } | null)?.data;
|
|
69
|
+
return Array.isArray(data) ? data : [];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
await runReadOnlyMcpConnector("nimbus-ramp", (reg) => {
|
|
73
|
+
reg(
|
|
74
|
+
"ramp_list",
|
|
75
|
+
"List Ramp card transactions (GET /developer/v1/transactions?page_size=N). The connector exchanges its OAuth2 client-credentials for a bearer token (POST /developer/v1/token, HTTP Basic auth, transactions:read scope) and caches it for the process. `limit` (default 100, max 100) caps page_size of the single first-page request. Returns the `.data` array — each transaction is { id, amount, currency_code, merchant_name, card_holder, state, sk_category_name, user_transaction_time, memo, ... }.",
|
|
76
|
+
z.object({
|
|
77
|
+
limit: z.number().int().min(1).max(100).optional(),
|
|
78
|
+
}),
|
|
79
|
+
async (p) => {
|
|
80
|
+
const root = await rampGet(`/developer/v1/transactions?page_size=${String(p.limit ?? 100)}`);
|
|
81
|
+
return jsonResult(transactionsFrom(root));
|
|
82
|
+
},
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
reg(
|
|
86
|
+
"ramp_get",
|
|
87
|
+
"Fetch one Ramp transaction by its id (GET /developer/v1/transactions/{id}). Returns the transaction object directly. Throws when no transaction with that id exists.",
|
|
88
|
+
z.object({
|
|
89
|
+
id: z.string().min(1),
|
|
90
|
+
}),
|
|
91
|
+
async (p) => {
|
|
92
|
+
return jsonResult(await rampGet(`/developer/v1/transactions/${encodeURIComponent(p.id)}`));
|
|
93
|
+
},
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
reg(
|
|
97
|
+
"ramp_search",
|
|
98
|
+
"Substring search across the first page of Ramp transactions. Matches the query (case-insensitive) against merchant name, category, state, currency, memo, and the card holder name/department. Returns a `{ matches: [...] }` envelope.",
|
|
99
|
+
searchToolInputSchema(100),
|
|
100
|
+
async (p) => {
|
|
101
|
+
const root = await rampGet("/developer/v1/transactions?page_size=100");
|
|
102
|
+
const matches = filterRampTransactions(transactionsFrom(root), {
|
|
103
|
+
query: p.query,
|
|
104
|
+
limit: p.limit,
|
|
105
|
+
});
|
|
106
|
+
return jsonResult({ matches });
|
|
107
|
+
},
|
|
108
|
+
);
|
|
109
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "com.nimbus.readwise",
|
|
3
|
+
"displayName": "Readwise",
|
|
4
|
+
"version": "0.1.0",
|
|
5
|
+
"description": "Readwise connector (read-only). Surfaces the user's saved book/article highlights as `readwise:highlight` items in the local index — the highlighted excerpt text, the user's note, the parent book/article id, the location + location type, the color, tags, and the source article URL (for web highlights) — plus their parent books/articles as `readwise:book` items (title, author, category, source, highlight count, document note, tags, ASIN, source + Readwise book-review URLs), so a highlight's `book_id` resolves to a real indexed record. Useful for answering reading questions (\"what did I highlight about retries?\", \"which book was that note in?\") without leaving Nimbus. Token API-token auth (`Authorization: Token <token>` — Django-REST-Framework token auth, NOT Bearer). The SaaS host (readwise.io) is fixed. Readwise paginates both `/api/v2/highlights/` and `/api/v2/books/` by page number (DRF `{ count, next, previous, results }` envelope, page_size=1000). `highlighted_at` / `last_highlight_at` / `updated` are ISO-8601 (converted to epoch-ms on index). Book `external_id`s are namespaced `book/<id>` because Readwise numbers books and highlights in separate sequences. Documents / daily-review and the Reader v3 API remain deferred follow-ups.",
|
|
6
|
+
"author": "Nimbus",
|
|
7
|
+
"entrypoint": "dist/server.js",
|
|
8
|
+
"runtime": "bun",
|
|
9
|
+
"permissions": {
|
|
10
|
+
"network": ["readwise.io"]
|
|
11
|
+
},
|
|
12
|
+
"hitlRequired": [],
|
|
13
|
+
"syncInterval": 600,
|
|
14
|
+
"minNimbusVersion": "0.2.0"
|
|
15
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import {
|
|
2
|
+
asObjectish,
|
|
3
|
+
makeQueryFilter,
|
|
4
|
+
type SearchMatchOptions,
|
|
5
|
+
stringField,
|
|
6
|
+
} from "../../../shared/search-filter.ts";
|
|
7
|
+
|
|
8
|
+
export type ReadwiseSearchMatchOptions = SearchMatchOptions;
|
|
9
|
+
|
|
10
|
+
function tagNames(row: Record<string, unknown>): string {
|
|
11
|
+
const tags = row["tags"];
|
|
12
|
+
if (!Array.isArray(tags)) {
|
|
13
|
+
return "";
|
|
14
|
+
}
|
|
15
|
+
const names: string[] = [];
|
|
16
|
+
for (const t of tags) {
|
|
17
|
+
if (t !== null && typeof t === "object") {
|
|
18
|
+
const name = (t as Record<string, unknown>)["name"];
|
|
19
|
+
if (typeof name === "string") {
|
|
20
|
+
names.push(name);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return names.join(" ");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function fieldsOf(item: unknown): readonly string[] | null {
|
|
28
|
+
const row = asObjectish(item);
|
|
29
|
+
if (row === undefined) {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
return [
|
|
33
|
+
stringField(row, "text"),
|
|
34
|
+
stringField(row, "note"),
|
|
35
|
+
stringField(row, "color"),
|
|
36
|
+
stringField(row, "location_type"),
|
|
37
|
+
tagNames(row),
|
|
38
|
+
];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export const filterReadwiseHighlights = makeQueryFilter(fieldsOf);
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Book haystack: title, author, category, source, the user's document note, and
|
|
45
|
+
* tag names. `cover_image_url` / `highlights_url` / `source_url` are deliberately
|
|
46
|
+
* excluded — matching a URL substring is noise, and the highlight filter makes
|
|
47
|
+
* the same choice for its `url`.
|
|
48
|
+
*/
|
|
49
|
+
function bookFieldsOf(item: unknown): readonly string[] | null {
|
|
50
|
+
const row = asObjectish(item);
|
|
51
|
+
if (row === undefined) {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
return [
|
|
55
|
+
stringField(row, "title"),
|
|
56
|
+
stringField(row, "author"),
|
|
57
|
+
stringField(row, "category"),
|
|
58
|
+
stringField(row, "source"),
|
|
59
|
+
stringField(row, "document_note"),
|
|
60
|
+
tagNames(row),
|
|
61
|
+
];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export const filterReadwiseBooks = makeQueryFilter(bookFieldsOf);
|