daeda-mcp 1.0.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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Daeda Technologies PTE Ltd
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,250 @@
1
+ # Daeda MCP
2
+
3
+ An MCP server that syncs your HubSpot CRM to a local encrypted database, enabling AI assistants to query your contacts, companies, and deals instantly.
4
+
5
+ ## Why Daeda?
6
+
7
+ Querying HubSpot through the API is slow and rate-limited. Daeda solves this by:
8
+
9
+ - **Syncing your entire CRM locally** - Contacts, companies, deals, and all associations
10
+ - **Encrypted storage** - Your data is encrypted at rest using your HubSpot token
11
+ - **Instant queries** - AI assistants can run SQL queries against your local database
12
+ - **Works offline** - Once synced, no internet required for queries
13
+
14
+ ## Features
15
+
16
+ - **Full CRM Sync** - Exports all contacts, companies, and deals with all properties
17
+ - **Association Support** - Contact-company, deal-contact, and deal-company relationships
18
+ - **Smart Seeding** - Quick preview of ~1,000 recent deals available in seconds while full sync runs
19
+ - **Resumable Sync** - Interrupted syncs resume automatically on restart
20
+ - **Read-Only Queries** - AI can only SELECT data, never modify your CRM
21
+
22
+ ## Use Cases
23
+
24
+ - "Show me all deals closing this month over $50k"
25
+ - "Find contacts at companies in the healthcare industry"
26
+ - "Which deals have no associated contacts?"
27
+ - "List all contacts with a @gmail.com email"
28
+ - "What's the total pipeline value by deal stage?"
29
+
30
+ ## Prerequisites
31
+
32
+ ### HubSpot Private App Setup
33
+
34
+ 1. Go to your HubSpot account → Settings → Integrations → Private Apps
35
+ 2. Create a new private app
36
+ 3. Under "Scopes", enable these permissions:
37
+ - `crm.export` (required for bulk data export)
38
+ - `crm.objects.contacts.read`
39
+ - `crm.objects.companies.read`
40
+ - `crm.objects.deals.read`
41
+ 4. Create the app and copy your access token (starts with `pat-`)
42
+
43
+ ## Installation
44
+
45
+ ### npm (recommended)
46
+
47
+ ```bash
48
+ npm install -g daeda-mcp
49
+ ```
50
+
51
+ ### From source
52
+
53
+ ```bash
54
+ git clone https://github.com/daeda-tech/daeda-mcp.git
55
+ cd daeda-mcp
56
+ npm install
57
+ npm run build
58
+ ```
59
+
60
+ ## Configuration
61
+
62
+ ### Claude Desktop
63
+
64
+ Add to your Claude Desktop config (`~/Library/Application Support/Claude/claude_desktop_config.json` on macOS):
65
+
66
+ ```json
67
+ {
68
+ "mcpServers": {
69
+ "daeda": {
70
+ "command": "npx",
71
+ "args": ["-y", "daeda-mcp"],
72
+ "env": {
73
+ "HS_PRIVATE_TOKEN": "pat-xx-xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
74
+ }
75
+ }
76
+ }
77
+ }
78
+ ```
79
+
80
+ ### Claude Desktop (from source)
81
+
82
+ ```json
83
+ {
84
+ "mcpServers": {
85
+ "daeda": {
86
+ "command": "node",
87
+ "args": ["/path/to/daeda-mcp/dist/index.js"],
88
+ "env": {
89
+ "HS_PRIVATE_TOKEN": "pat-xx-xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
90
+ }
91
+ }
92
+ }
93
+ }
94
+ ```
95
+
96
+ ### Cursor
97
+
98
+ Add to your Cursor MCP settings (`.cursor/mcp.json` in your project or global config):
99
+
100
+ ```json
101
+ {
102
+ "mcpServers": {
103
+ "daeda": {
104
+ "command": "npx",
105
+ "args": ["-y", "daeda-mcp"],
106
+ "env": {
107
+ "HS_PRIVATE_TOKEN": "pat-xx-xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
108
+ }
109
+ }
110
+ }
111
+ }
112
+ ```
113
+
114
+ ### Windsurf
115
+
116
+ Add to your Windsurf MCP config (`~/.windsurf/mcp.json`):
117
+
118
+ ```json
119
+ {
120
+ "mcpServers": {
121
+ "daeda": {
122
+ "command": "npx",
123
+ "args": ["-y", "daeda-mcp"],
124
+ "env": {
125
+ "HS_PRIVATE_TOKEN": "pat-xx-xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
126
+ }
127
+ }
128
+ }
129
+ }
130
+ ```
131
+
132
+ ### VS Code with MCP Extension
133
+
134
+ Add to your VS Code settings or MCP extension config:
135
+
136
+ ```json
137
+ {
138
+ "mcp.servers": {
139
+ "daeda": {
140
+ "command": "npx",
141
+ "args": ["-y", "daeda-mcp"],
142
+ "env": {
143
+ "HS_PRIVATE_TOKEN": "pat-xx-xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
144
+ }
145
+ }
146
+ }
147
+ }
148
+ ```
149
+
150
+ ### Cline
151
+
152
+ Add to your Cline MCP settings:
153
+
154
+ ```json
155
+ {
156
+ "mcpServers": {
157
+ "daeda": {
158
+ "command": "npx",
159
+ "args": ["-y", "daeda-mcp"],
160
+ "env": {
161
+ "HS_PRIVATE_TOKEN": "pat-xx-xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
162
+ }
163
+ }
164
+ }
165
+ }
166
+ ```
167
+
168
+ ### Using with Bun (development)
169
+
170
+ If you prefer Bun for faster startup:
171
+
172
+ ```json
173
+ {
174
+ "mcpServers": {
175
+ "daeda": {
176
+ "command": "bun",
177
+ "args": ["run", "/path/to/daeda-mcp/src/index.ts"],
178
+ "env": {
179
+ "HS_PRIVATE_TOKEN": "pat-xx-xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
180
+ }
181
+ }
182
+ }
183
+ }
184
+ ```
185
+
186
+ ## How It Works
187
+
188
+ 1. **First Launch** - Daeda requests bulk exports from HubSpot's Export API
189
+ 2. **Quick Seed** - While exports process, ~1,000 recent deals are fetched via Search API for immediate use
190
+ 3. **Full Sync** - Export CSVs are downloaded and streamed into an encrypted SQLite database
191
+ 4. **Ready** - AI assistants can now query your full CRM instantly
192
+
193
+ The **quick seed completes in 2-5 minutes**, giving you immediate access to your 1,000 most recent deals and their associated contacts and companies.
194
+
195
+ The **full sync** runs in the background and duration depends on your CRM size:
196
+ - Small CRM (10k records): ~5-10 minutes
197
+ - Medium CRM (100k records): ~30-60 minutes
198
+ - Large CRM (1M+ records): up to 5 hours
199
+
200
+ Progress is shown via the `db_status` tool. You can start querying immediately after the quick seed completes.
201
+
202
+ ## Available Tools
203
+
204
+ | Tool | Description |
205
+ |------|-------------|
206
+ | `db_status` | Check sync progress and database health |
207
+ | `get_raw_sql` | Execute SELECT queries against your CRM data |
208
+
209
+ ## Database Schema
210
+
211
+ The local database contains:
212
+
213
+ - **contacts** - All contacts with email and full properties as JSON
214
+ - **companies** - All companies with domain and full properties as JSON
215
+ - **deals** - All deals with name and full properties as JSON
216
+ - **contact_company** - Contact to company associations
217
+ - **deal_contact** - Deal to contact associations
218
+ - **deal_company** - Deal to company associations
219
+
220
+ Query any HubSpot property using `json_extract()`:
221
+
222
+ ```sql
223
+ SELECT
224
+ json_extract(properties, '$.firstname') as first_name,
225
+ json_extract(properties, '$.lastname') as last_name,
226
+ email
227
+ FROM contacts
228
+ WHERE json_extract(properties, '$.lifecyclestage') = 'customer'
229
+ LIMIT 10
230
+ ```
231
+
232
+ ## Data Storage
233
+
234
+ Your CRM data is stored locally at:
235
+ - **macOS/Linux**: `~/.daeda-mcp/data/`
236
+ - **Windows**: `%APPDATA%\daeda-mcp\data\`
237
+
238
+ The database is encrypted using your HubSpot token as the encryption key. If you change tokens, the database will be re-initialized automatically.
239
+
240
+ ## Security
241
+
242
+ - All data stays on your machine
243
+ - Database is encrypted at rest
244
+ - Only SELECT queries are allowed
245
+ - Dangerous SQL keywords are blocked
246
+ - Your HubSpot token is never stored (only used for encryption)
247
+
248
+ ## License
249
+
250
+ MIT - see [LICENSE](LICENSE) for details.
@@ -0,0 +1,12 @@
1
+ export interface PortalConfig {
2
+ portalId: string;
3
+ accessToken: string;
4
+ name?: string;
5
+ createdAt: string;
6
+ lastSyncAt?: string;
7
+ }
8
+ export declare function getPortal(portalId: string): PortalConfig | null;
9
+ export declare function listPortals(): PortalConfig[];
10
+ export declare function setPortal(portal: PortalConfig): void;
11
+ export declare function updatePortalLastSync(portalId: string): void;
12
+ export declare function deletePortal(portalId: string): boolean;
@@ -0,0 +1,53 @@
1
+ // Portal configuration storage
2
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
3
+ import { join, dirname } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ const __dirname = dirname(fileURLToPath(import.meta.url));
6
+ const DATA_DIR = join(__dirname, '../../data');
7
+ const CONFIG_FILE = join(DATA_DIR, 'config.json');
8
+ function ensureDataDir() {
9
+ if (!existsSync(DATA_DIR)) {
10
+ mkdirSync(DATA_DIR, { recursive: true });
11
+ }
12
+ }
13
+ function loadConfig() {
14
+ ensureDataDir();
15
+ if (!existsSync(CONFIG_FILE)) {
16
+ return { portals: {} };
17
+ }
18
+ const content = readFileSync(CONFIG_FILE, 'utf-8');
19
+ return JSON.parse(content);
20
+ }
21
+ function saveConfig(config) {
22
+ ensureDataDir();
23
+ writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2));
24
+ }
25
+ export function getPortal(portalId) {
26
+ const config = loadConfig();
27
+ return config.portals[portalId] || null;
28
+ }
29
+ export function listPortals() {
30
+ const config = loadConfig();
31
+ return Object.values(config.portals);
32
+ }
33
+ export function setPortal(portal) {
34
+ const config = loadConfig();
35
+ config.portals[portal.portalId] = portal;
36
+ saveConfig(config);
37
+ }
38
+ export function updatePortalLastSync(portalId) {
39
+ const config = loadConfig();
40
+ if (config.portals[portalId]) {
41
+ config.portals[portalId].lastSyncAt = new Date().toISOString();
42
+ saveConfig(config);
43
+ }
44
+ }
45
+ export function deletePortal(portalId) {
46
+ const config = loadConfig();
47
+ if (config.portals[portalId]) {
48
+ delete config.portals[portalId];
49
+ saveConfig(config);
50
+ return true;
51
+ }
52
+ return false;
53
+ }
@@ -0,0 +1,2 @@
1
+ export declare function getHubSpotToken(): string | null;
2
+ export declare function getDbEncryptionKey(): Promise<string>;
@@ -0,0 +1,11 @@
1
+ const ENV_VAR_NAME = "HS_PRIVATE_TOKEN";
2
+ export function getHubSpotToken() {
3
+ return process.env[ENV_VAR_NAME] || null;
4
+ }
5
+ export async function getDbEncryptionKey() {
6
+ const key = process.env[ENV_VAR_NAME];
7
+ if (!key) {
8
+ throw new Error(`${ENV_VAR_NAME} environment variable is required for database encryption.`);
9
+ }
10
+ return key;
11
+ }
@@ -0,0 +1,10 @@
1
+ export declare const SCHEMA_SQL = "\n-- Contacts table (id, human-readable identifier, last_synced, all properties as JSON)\nCREATE TABLE IF NOT EXISTS contacts (\n id TEXT PRIMARY KEY,\n email TEXT,\n last_synced TEXT,\n properties TEXT\n);\n\nCREATE INDEX IF NOT EXISTS idx_contacts_email ON contacts(email);\n\n-- Companies table\nCREATE TABLE IF NOT EXISTS companies (\n id TEXT PRIMARY KEY,\n domain TEXT,\n last_synced TEXT,\n properties TEXT\n);\n\nCREATE INDEX IF NOT EXISTS idx_companies_domain ON companies(domain);\n\n-- Deals table\nCREATE TABLE IF NOT EXISTS deals (\n id TEXT PRIMARY KEY,\n dealname TEXT,\n last_synced TEXT,\n properties TEXT\n);\n\nCREATE INDEX IF NOT EXISTS idx_deals_dealname ON deals(dealname);\n\n-- Association tables\nCREATE TABLE IF NOT EXISTS contact_company (\n contact_id TEXT,\n company_id TEXT,\n PRIMARY KEY (contact_id, company_id)\n);\n\nCREATE INDEX IF NOT EXISTS idx_cc_contact ON contact_company(contact_id);\nCREATE INDEX IF NOT EXISTS idx_cc_company ON contact_company(company_id);\n\nCREATE TABLE IF NOT EXISTS deal_contact (\n deal_id TEXT,\n contact_id TEXT,\n PRIMARY KEY (deal_id, contact_id)\n);\n\nCREATE INDEX IF NOT EXISTS idx_dc_deal ON deal_contact(deal_id);\nCREATE INDEX IF NOT EXISTS idx_dc_contact ON deal_contact(contact_id);\n\nCREATE TABLE IF NOT EXISTS deal_company (\n deal_id TEXT,\n company_id TEXT,\n PRIMARY KEY (deal_id, company_id)\n);\n\nCREATE INDEX IF NOT EXISTS idx_dco_deal ON deal_company(deal_id);\nCREATE INDEX IF NOT EXISTS idx_dco_company ON deal_company(company_id);\n\n-- Sync metadata table\nCREATE TABLE IF NOT EXISTS sync_metadata (\n key TEXT PRIMARY KEY,\n value TEXT\n);\n";
2
+ export type ObjectType = "contacts" | "companies" | "deals";
3
+ export type AssociationType = "contact_company" | "deal_contact" | "deal_company";
4
+ export interface SyncMetadata {
5
+ initialized_at: string;
6
+ last_synced: string;
7
+ contacts_count: number;
8
+ companies_count: number;
9
+ deals_count: number;
10
+ }
@@ -0,0 +1,65 @@
1
+ export const SCHEMA_SQL = `
2
+ -- Contacts table (id, human-readable identifier, last_synced, all properties as JSON)
3
+ CREATE TABLE IF NOT EXISTS contacts (
4
+ id TEXT PRIMARY KEY,
5
+ email TEXT,
6
+ last_synced TEXT,
7
+ properties TEXT
8
+ );
9
+
10
+ CREATE INDEX IF NOT EXISTS idx_contacts_email ON contacts(email);
11
+
12
+ -- Companies table
13
+ CREATE TABLE IF NOT EXISTS companies (
14
+ id TEXT PRIMARY KEY,
15
+ domain TEXT,
16
+ last_synced TEXT,
17
+ properties TEXT
18
+ );
19
+
20
+ CREATE INDEX IF NOT EXISTS idx_companies_domain ON companies(domain);
21
+
22
+ -- Deals table
23
+ CREATE TABLE IF NOT EXISTS deals (
24
+ id TEXT PRIMARY KEY,
25
+ dealname TEXT,
26
+ last_synced TEXT,
27
+ properties TEXT
28
+ );
29
+
30
+ CREATE INDEX IF NOT EXISTS idx_deals_dealname ON deals(dealname);
31
+
32
+ -- Association tables
33
+ CREATE TABLE IF NOT EXISTS contact_company (
34
+ contact_id TEXT,
35
+ company_id TEXT,
36
+ PRIMARY KEY (contact_id, company_id)
37
+ );
38
+
39
+ CREATE INDEX IF NOT EXISTS idx_cc_contact ON contact_company(contact_id);
40
+ CREATE INDEX IF NOT EXISTS idx_cc_company ON contact_company(company_id);
41
+
42
+ CREATE TABLE IF NOT EXISTS deal_contact (
43
+ deal_id TEXT,
44
+ contact_id TEXT,
45
+ PRIMARY KEY (deal_id, contact_id)
46
+ );
47
+
48
+ CREATE INDEX IF NOT EXISTS idx_dc_deal ON deal_contact(deal_id);
49
+ CREATE INDEX IF NOT EXISTS idx_dc_contact ON deal_contact(contact_id);
50
+
51
+ CREATE TABLE IF NOT EXISTS deal_company (
52
+ deal_id TEXT,
53
+ company_id TEXT,
54
+ PRIMARY KEY (deal_id, company_id)
55
+ );
56
+
57
+ CREATE INDEX IF NOT EXISTS idx_dco_deal ON deal_company(deal_id);
58
+ CREATE INDEX IF NOT EXISTS idx_dco_company ON deal_company(company_id);
59
+
60
+ -- Sync metadata table
61
+ CREATE TABLE IF NOT EXISTS sync_metadata (
62
+ key TEXT PRIMARY KEY,
63
+ value TEXT
64
+ );
65
+ `;
@@ -0,0 +1,43 @@
1
+ import { type Client } from "@libsql/client";
2
+ import { type ObjectType, type AssociationType } from "./schema.js";
3
+ export declare function getDbPath(): string;
4
+ export declare function dbExists(): Promise<boolean>;
5
+ export declare function isDbHealthy(): Promise<boolean>;
6
+ export declare function getDb(): Promise<Client>;
7
+ export declare function closeDb(): Promise<void>;
8
+ export declare function clearTable(table: ObjectType | AssociationType): Promise<void>;
9
+ export declare function insertContact(id: string, email: string | null, properties: Record<string, unknown>): Promise<void>;
10
+ export declare function insertCompany(id: string, domain: string | null, properties: Record<string, unknown>): Promise<void>;
11
+ export declare function insertDeal(id: string, dealname: string | null, properties: Record<string, unknown>): Promise<void>;
12
+ export declare function insertAssociation(associationType: AssociationType, fromId: string, toId: string): Promise<void>;
13
+ export declare function setMetadata(key: string, value: string): Promise<void>;
14
+ export interface ContactRow {
15
+ id: string;
16
+ email: string | null;
17
+ properties: Record<string, unknown>;
18
+ }
19
+ export interface CompanyRow {
20
+ id: string;
21
+ domain: string | null;
22
+ properties: Record<string, unknown>;
23
+ }
24
+ export interface DealRow {
25
+ id: string;
26
+ dealname: string | null;
27
+ properties: Record<string, unknown>;
28
+ }
29
+ export interface AssociationRow {
30
+ fromId: string;
31
+ toId: string;
32
+ }
33
+ export declare function batchInsertContacts(rows: ContactRow[]): Promise<void>;
34
+ export declare function batchInsertCompanies(rows: CompanyRow[]): Promise<void>;
35
+ export declare function batchInsertDeals(rows: DealRow[]): Promise<void>;
36
+ export declare function batchInsertAssociations(associationType: AssociationType, rows: AssociationRow[]): Promise<void>;
37
+ export declare function getMetadata(key: string): Promise<string | null>;
38
+ export declare function getAllMetadata(): Promise<Record<string, string>>;
39
+ export declare function getRecordCount(objectType: ObjectType): Promise<number>;
40
+ export declare function executeQuery(sql: string): Promise<{
41
+ columns: string[];
42
+ rows: Record<string, unknown>[];
43
+ }>;