0nmcp 3.2.1 → 4.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/crm/index.js CHANGED
@@ -11,8 +11,18 @@
11
11
  // crm/index.js — This file (wires everything together)
12
12
  // ============================================================
13
13
 
14
- import { registerTools, crmHeaders, CRM_API_BASE, API_VERSION } from "./helpers.js";
14
+ import { registerTools, crmHeaders, CRM_API_BASE, API_VERSION, setTokenResolver } from "./helpers.js";
15
15
  import { registerAuthTools } from "./auth.js";
16
+ import { registerOAuthStoreTools, resolveToken } from "./oauth-store.js";
17
+ import { registerCourseTools } from "./course-generator.js";
18
+ import { registerUserContextTools } from "./user-context.js";
19
+ import { registerBillingTools } from "./billing.js";
20
+ import { registerEmailCampaignTools } from "./email-campaigns.js";
21
+ import { registerMarketplaceBillingTools } from "./marketplace-billing.js";
22
+ import { registerMediaTools } from "./media.js";
23
+ import { registerSaasManagementTools } from "./saas-management.js";
24
+ import { registerSurveyFormTools } from "./surveys-forms.js";
25
+ import { registerAgentBuilderTools } from "./agent-builder.js";
16
26
 
17
27
  // Category modules — each exports a default array of tool definitions
18
28
  import contacts from "./contacts.js";
@@ -30,6 +40,7 @@ import knowledgeBase from "./knowledge-base.js";
30
40
  import voiceAi from "./voice-ai.js";
31
41
  import saas from "./saas.js";
32
42
  import funnels from "./funnels.js"; // includes forms, surveys, associations, snapshots
43
+ import phoneSystem from "./phone-system.js";
33
44
  import agentStudio from "./agent-studio.js";
34
45
 
35
46
  // Re-export definitions + helpers for external consumers (e.g. CRM bridges)
@@ -50,6 +61,7 @@ export {
50
61
  saas,
51
62
  funnels,
52
63
  agentStudio,
64
+ resolveToken,
53
65
  crmHeaders,
54
66
  CRM_API_BASE,
55
67
  API_VERSION,
@@ -63,9 +75,42 @@ export {
63
75
  * @param {import("../capability-proxy.js").CapabilityProxy} [proxy]
64
76
  */
65
77
  export function registerCrmTools(server, z, proxy) {
78
+ // 0. Wire up OAuth token auto-resolution
79
+ setTokenResolver(resolveToken);
80
+
66
81
  // 1. Auth & custom-logic tools (OAuth, snapshot deploy, workflow process)
67
82
  registerAuthTools(server, z);
68
83
 
84
+ // 1b. OAuth store tools (connect, status, resolve)
85
+ registerOAuthStoreTools(server, z);
86
+
87
+ // 1c. SDK-powered tools (courses, etc.)
88
+ registerCourseTools(server, z);
89
+
90
+ // 1d. User context decryption (marketplace app embeds)
91
+ registerUserContextTools(server, z);
92
+
93
+ // 1e. External billing (Stripe → CRM webhook) + payment config
94
+ registerBillingTools(server, z);
95
+
96
+ // 1f. Email campaign execution (templates, campaigns, warmup, send)
97
+ registerEmailCampaignTools(server, z);
98
+
99
+ // 1g. Marketplace billing (charge, rebilling, installer details)
100
+ registerMarketplaceBillingTools(server, z);
101
+
102
+ // 1h. Media library (upload, folders, browse, delete)
103
+ registerMediaTools(server, z);
104
+
105
+ // 1i. SaaS management (locations, plans, rebilling, payment links)
106
+ registerSaasManagementTools(server, z);
107
+
108
+ // 1j. Surveys & Forms (list, submissions, file upload)
109
+ registerSurveyFormTools(server, z);
110
+
111
+ // 1k. AI Agent Builder (create, list, execute AI Workflows)
112
+ registerAgentBuilderTools(server, z);
113
+
69
114
  // 2. Data-driven tools by category
70
115
  const categories = [
71
116
  { name: "Contacts", defs: contacts },
@@ -84,9 +129,10 @@ export function registerCrmTools(server, z, proxy) {
84
129
  { name: "SaaS & Billing", defs: saas },
85
130
  { name: "Funnels, Forms, Surveys & More", defs: funnels },
86
131
  { name: "Agent Studio", defs: agentStudio },
132
+ { name: "Phone System", defs: phoneSystem },
87
133
  ];
88
134
 
89
- let totalTools = 5; // auth tools count
135
+ let totalTools = 5 + 3 + 2 + 1 + 2 + 6 + 7 + 6 + 3 + 3 + 3; // auth(5) + oauth-store(3) + course(2) + user-context(1) + billing(2) + email-campaigns(6) + marketplace-billing(7) + media(6) + saas-mgmt(3) + surveys-forms(3) + agent-builder(3)
90
136
  for (const cat of categories) {
91
137
  registerTools(server, z, cat.defs, proxy);
92
138
  totalTools += cat.defs.length;
@@ -0,0 +1,162 @@
1
+ // ============================================================
2
+ // 0nMCP — CRM Marketplace Billing & Rebilling Tools
3
+ // ============================================================
4
+ // Full marketplace billing: charge locations, check funds,
5
+ // get installer details, rebilling config, uninstall.
6
+ //
7
+ // SDK methods: marketplace.charge, marketplace.getCharges,
8
+ // marketplace.hasFunds, marketplace.getInstallerDetails,
9
+ // marketplace.getSpecificCharge, marketplace.deleteCharge,
10
+ // marketplace.uninstallApplication
11
+ //
12
+ // REST: GET /marketplace/app/:appId/rebilling-config/location/:locationId
13
+ // ============================================================
14
+
15
+ import getSDK from "./sdk.js";
16
+ import { crmHeaders, CRM_API_BASE } from "./helpers.js";
17
+
18
+ export function registerMarketplaceBillingTools(server, z) {
19
+
20
+ server.tool(
21
+ "crm_marketplace_charge",
22
+ "Charge a location for an add-on or service through the CRM marketplace billing. The CRM handles payment collection from the sub-account owner.",
23
+ {
24
+ location_id: z.string().describe("CRM location ID to charge"),
25
+ amount: z.number().describe("Amount in cents"),
26
+ description: z.string().describe("Charge description"),
27
+ name: z.string().describe("Product/add-on name"),
28
+ },
29
+ async ({ location_id, amount, description, name }) => {
30
+ try {
31
+ const sdk = await getSDK();
32
+ const result = await sdk.marketplace.charge({
33
+ locationId: location_id,
34
+ amount,
35
+ description,
36
+ name,
37
+ });
38
+ return { content: [{ type: "text", text: JSON.stringify({ charged: true, data: result }, null, 2) }] };
39
+ } catch (err) {
40
+ return { content: [{ type: "text", text: JSON.stringify({ error: err.message }) }] };
41
+ }
42
+ }
43
+ );
44
+
45
+ server.tool(
46
+ "crm_marketplace_get_charges",
47
+ "List all marketplace charges for a location. Shows charge history, amounts, and status.",
48
+ {
49
+ location_id: z.string().describe("CRM location ID"),
50
+ },
51
+ async ({ location_id }) => {
52
+ try {
53
+ const sdk = await getSDK();
54
+ const result = await sdk.marketplace.getCharges({ locationId: location_id });
55
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
56
+ } catch (err) {
57
+ return { content: [{ type: "text", text: JSON.stringify({ error: err.message }) }] };
58
+ }
59
+ }
60
+ );
61
+
62
+ server.tool(
63
+ "crm_marketplace_has_funds",
64
+ "Check if a location has sufficient funds for a marketplace charge. Use before attempting to charge.",
65
+ {
66
+ location_id: z.string().describe("CRM location ID"),
67
+ amount: z.number().describe("Amount in cents to check"),
68
+ },
69
+ async ({ location_id, amount }) => {
70
+ try {
71
+ const sdk = await getSDK();
72
+ const result = await sdk.marketplace.hasFunds({ locationId: location_id, amount });
73
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
74
+ } catch (err) {
75
+ return { content: [{ type: "text", text: JSON.stringify({ error: err.message }) }] };
76
+ }
77
+ }
78
+ );
79
+
80
+ server.tool(
81
+ "crm_marketplace_installer_details",
82
+ "Get installer details for a marketplace app — which locations have it installed, installation status, and configuration.",
83
+ {
84
+ app_id: z.string().optional().describe("Marketplace app ID (default: 0nCORE app)"),
85
+ },
86
+ async ({ app_id }) => {
87
+ try {
88
+ const sdk = await getSDK();
89
+ const result = await sdk.marketplace.getInstallerDetails({
90
+ appId: app_id || "69c762225a31e1cd2f28dd4c",
91
+ });
92
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
93
+ } catch (err) {
94
+ return { content: [{ type: "text", text: JSON.stringify({ error: err.message }) }] };
95
+ }
96
+ }
97
+ );
98
+
99
+ server.tool(
100
+ "crm_marketplace_rebilling_config",
101
+ "Get the rebilling/subscription config for an app at a specific location. Shows pricing plans, usage limits, and subscription status.",
102
+ {
103
+ app_id: z.string().optional().describe("Marketplace app ID (default: 0nCORE app)"),
104
+ location_id: z.string().describe("CRM location ID"),
105
+ access_token: z.string().optional().describe("OAuth access token (auto-resolved)"),
106
+ },
107
+ async ({ app_id, location_id, access_token }) => {
108
+ try {
109
+ const appId = app_id || "69c762225a31e1cd2f28dd4c";
110
+ const token = access_token;
111
+ if (!token) {
112
+ return { content: [{ type: "text", text: JSON.stringify({ error: "access_token required for rebilling config" }) }] };
113
+ }
114
+ const headers = crmHeaders(token);
115
+ const res = await fetch(`${CRM_API_BASE}/marketplace/app/${appId}/rebilling-config/location/${location_id}`, { headers });
116
+ const data = await res.json();
117
+ return { content: [{ type: "text", text: JSON.stringify({ ok: res.ok, data }, null, 2) }] };
118
+ } catch (err) {
119
+ return { content: [{ type: "text", text: JSON.stringify({ error: err.message }) }] };
120
+ }
121
+ }
122
+ );
123
+
124
+ server.tool(
125
+ "crm_marketplace_delete_charge",
126
+ "Delete/cancel a specific marketplace charge.",
127
+ {
128
+ charge_id: z.string().describe("Charge ID to delete"),
129
+ location_id: z.string().describe("CRM location ID"),
130
+ },
131
+ async ({ charge_id, location_id }) => {
132
+ try {
133
+ const sdk = await getSDK();
134
+ const result = await sdk.marketplace.deleteCharge({ chargeId: charge_id, locationId: location_id });
135
+ return { content: [{ type: "text", text: JSON.stringify({ deleted: true, data: result }, null, 2) }] };
136
+ } catch (err) {
137
+ return { content: [{ type: "text", text: JSON.stringify({ error: err.message }) }] };
138
+ }
139
+ }
140
+ );
141
+
142
+ server.tool(
143
+ "crm_marketplace_uninstall",
144
+ "Uninstall a marketplace app from a location.",
145
+ {
146
+ app_id: z.string().optional().describe("Marketplace app ID"),
147
+ location_id: z.string().describe("CRM location ID"),
148
+ },
149
+ async ({ app_id, location_id }) => {
150
+ try {
151
+ const sdk = await getSDK();
152
+ const result = await sdk.marketplace.uninstallApplication({
153
+ appId: app_id || "69c762225a31e1cd2f28dd4c",
154
+ locationId: location_id,
155
+ });
156
+ return { content: [{ type: "text", text: JSON.stringify({ uninstalled: true, data: result }, null, 2) }] };
157
+ } catch (err) {
158
+ return { content: [{ type: "text", text: JSON.stringify({ error: err.message }) }] };
159
+ }
160
+ }
161
+ );
162
+ }
package/crm/media.js ADDED
@@ -0,0 +1,167 @@
1
+ // ============================================================
2
+ // 0nMCP — CRM Media Library Tools (SDK-powered)
3
+ // ============================================================
4
+ // Upload, organize, browse, folder management.
5
+ // SDK: medias.fetchMediaContent, uploadMediaContent,
6
+ // deleteMediaContent, updateMediaObject, createMediaFolder,
7
+ // bulkUpdateMediaObjects, bulkDeleteMediaObjects
8
+ // ============================================================
9
+
10
+ import getSDK from "./sdk.js";
11
+
12
+ export function registerMediaTools(server, z) {
13
+
14
+ server.tool(
15
+ "crm_media_list",
16
+ "List files and folders from the CRM media library. Supports pagination, search, and folder browsing.",
17
+ {
18
+ location_id: z.string().describe("CRM location ID"),
19
+ parent_id: z.string().optional().describe("Folder ID to browse into (omit for root)"),
20
+ type: z.string().optional().describe("Filter: file, folder, or all (default: all)"),
21
+ query: z.string().optional().describe("Search query"),
22
+ limit: z.number().optional().describe("Max results (default 50)"),
23
+ offset: z.number().optional().describe("Pagination offset"),
24
+ sort_by: z.string().optional().describe("Sort field (default: createdAt)"),
25
+ sort_order: z.string().optional().describe("asc or desc (default: desc)"),
26
+ },
27
+ async ({ location_id, parent_id, type, query, limit, offset, sort_by, sort_order }) => {
28
+ try {
29
+ const sdk = await getSDK();
30
+ const result = await sdk.medias.fetchMediaContent({
31
+ altType: "location",
32
+ altId: location_id,
33
+ type: type || "all",
34
+ sortBy: sort_by || "createdAt",
35
+ sortOrder: sort_order || "desc",
36
+ limit: String(limit || 50),
37
+ offset: String(offset || 0),
38
+ query: query || undefined,
39
+ parentId: parent_id || undefined,
40
+ });
41
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
42
+ } catch (err) {
43
+ return { content: [{ type: "text", text: JSON.stringify({ error: err.message }) }] };
44
+ }
45
+ }
46
+ );
47
+
48
+ server.tool(
49
+ "crm_media_upload",
50
+ "Upload a file to the CRM media library. Provide a hosted URL or file data. Max 25MB.",
51
+ {
52
+ location_id: z.string().describe("CRM location ID"),
53
+ file_url: z.string().describe("URL of the file to upload"),
54
+ name: z.string().describe("File name"),
55
+ folder_id: z.string().optional().describe("Parent folder ID"),
56
+ },
57
+ async ({ location_id, file_url, name, folder_id }) => {
58
+ try {
59
+ const sdk = await getSDK();
60
+ const body = {
61
+ hosted: true,
62
+ fileUrl: file_url,
63
+ name,
64
+ altType: "location",
65
+ altId: location_id,
66
+ parentId: folder_id || undefined,
67
+ };
68
+ const result = await sdk.medias.uploadMediaContent(body);
69
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
70
+ } catch (err) {
71
+ return { content: [{ type: "text", text: JSON.stringify({ error: err.message }) }] };
72
+ }
73
+ }
74
+ );
75
+
76
+ server.tool(
77
+ "crm_media_create_folder",
78
+ "Create a new folder in the CRM media library.",
79
+ {
80
+ location_id: z.string().describe("CRM location ID"),
81
+ name: z.string().describe("Folder name"),
82
+ parent_id: z.string().optional().describe("Parent folder ID (omit for root)"),
83
+ },
84
+ async ({ location_id, name, parent_id }) => {
85
+ try {
86
+ const sdk = await getSDK();
87
+ const result = await sdk.medias.createMediaFolder({
88
+ altType: "location",
89
+ altId: location_id,
90
+ name,
91
+ parentId: parent_id || undefined,
92
+ });
93
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
94
+ } catch (err) {
95
+ return { content: [{ type: "text", text: JSON.stringify({ error: err.message }) }] };
96
+ }
97
+ }
98
+ );
99
+
100
+ server.tool(
101
+ "crm_media_delete",
102
+ "Delete a file or folder from the CRM media library.",
103
+ {
104
+ location_id: z.string().describe("CRM location ID"),
105
+ id: z.string().describe("File or folder ID to delete"),
106
+ },
107
+ async ({ location_id, id }) => {
108
+ try {
109
+ const sdk = await getSDK();
110
+ const result = await sdk.medias.deleteMediaContent({
111
+ id,
112
+ altType: "location",
113
+ altId: location_id,
114
+ });
115
+ return { content: [{ type: "text", text: JSON.stringify({ deleted: true, data: result }, null, 2) }] };
116
+ } catch (err) {
117
+ return { content: [{ type: "text", text: JSON.stringify({ error: err.message }) }] };
118
+ }
119
+ }
120
+ );
121
+
122
+ server.tool(
123
+ "crm_media_rename",
124
+ "Rename a file or folder in the CRM media library.",
125
+ {
126
+ location_id: z.string().describe("CRM location ID"),
127
+ id: z.string().describe("File or folder ID"),
128
+ name: z.string().describe("New name"),
129
+ },
130
+ async ({ location_id, id, name }) => {
131
+ try {
132
+ const sdk = await getSDK();
133
+ const result = await sdk.medias.updateMediaObject(
134
+ { id },
135
+ { name, altType: "location", altId: location_id }
136
+ );
137
+ return { content: [{ type: "text", text: JSON.stringify({ renamed: true, data: result }, null, 2) }] };
138
+ } catch (err) {
139
+ return { content: [{ type: "text", text: JSON.stringify({ error: err.message }) }] };
140
+ }
141
+ }
142
+ );
143
+
144
+ server.tool(
145
+ "crm_media_bulk_delete",
146
+ "Bulk delete or trash multiple files and folders.",
147
+ {
148
+ location_id: z.string().describe("CRM location ID"),
149
+ ids: z.array(z.string()).describe("Array of file/folder IDs to delete"),
150
+ trash: z.boolean().optional().describe("Soft delete (trash) instead of permanent (default: true)"),
151
+ },
152
+ async ({ location_id, ids, trash }) => {
153
+ try {
154
+ const sdk = await getSDK();
155
+ const result = await sdk.medias.bulkDeleteMediaObjects({
156
+ altType: "location",
157
+ altId: location_id,
158
+ filesToBeDeleted: ids.map(id => ({ _id: id })),
159
+ status: trash !== false ? "trashed" : "deleted",
160
+ });
161
+ return { content: [{ type: "text", text: JSON.stringify({ deleted: ids.length, data: result }, null, 2) }] };
162
+ } catch (err) {
163
+ return { content: [{ type: "text", text: JSON.stringify({ error: err.message }) }] };
164
+ }
165
+ }
166
+ );
167
+ }
@@ -0,0 +1,262 @@
1
+ // ============================================================
2
+ // 0nMCP — CRM OAuth Token Store
3
+ // ============================================================
4
+ // Stores and auto-refreshes OAuth tokens per CRM location.
5
+ // Tokens stored in ~/.0n/connections/crm-oauth-{locationId}.0n
6
+ // Falls back to PIT from crm.0n if no OAuth token exists.
7
+ //
8
+ // Usage in tool factory:
9
+ // const token = await oauthStore.resolveToken(locationId)
10
+ // // token is always a valid access_token — OAuth or PIT
11
+ // ============================================================
12
+
13
+ import { readFile, writeFile, mkdir } from "fs/promises";
14
+ import { existsSync } from "fs";
15
+ import { join } from "path";
16
+ import { homedir } from "os";
17
+
18
+ const CONNECTIONS_DIR = join(homedir(), ".0n", "connections");
19
+ const TOKEN_URL = "https://services.leadconnectorhq.com/oauth/token";
20
+ const REFRESH_BUFFER_MS = 5 * 60 * 1000; // refresh 5 min before expiry
21
+
22
+ // Default marketplace app credentials (0nCORE app — 140+ scopes)
23
+ const DEFAULT_CLIENT_ID = "69c762225a31e1cd2f28dd4c-mnu5pazi";
24
+ const DEFAULT_CLIENT_SECRET = "92cb8fc3-2b23-40bf-adce-6b6afe9b8445";
25
+
26
+ /**
27
+ * @typedef {Object} StoredToken
28
+ * @property {string} access_token
29
+ * @property {string} refresh_token
30
+ * @property {number} expires_at - Unix timestamp in ms
31
+ * @property {string} location_id
32
+ * @property {string} [company_id]
33
+ * @property {string} [client_id]
34
+ * @property {string} [client_secret]
35
+ */
36
+
37
+ function tokenPath(locationId) {
38
+ return join(CONNECTIONS_DIR, `crm-oauth-${locationId}.0n`);
39
+ }
40
+
41
+ /**
42
+ * Load stored OAuth token for a location.
43
+ * @param {string} locationId
44
+ * @returns {Promise<StoredToken|null>}
45
+ */
46
+ export async function loadToken(locationId) {
47
+ const path = tokenPath(locationId);
48
+ if (!existsSync(path)) return null;
49
+ try {
50
+ const raw = await readFile(path, "utf-8");
51
+ const data = JSON.parse(raw);
52
+ return data.oauth || null;
53
+ } catch {
54
+ return null;
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Save OAuth token for a location.
60
+ * @param {string} locationId
61
+ * @param {Object} tokens - { access_token, refresh_token, expires_in, companyId }
62
+ * @param {string} [clientId]
63
+ * @param {string} [clientSecret]
64
+ */
65
+ export async function saveToken(locationId, tokens, clientId, clientSecret) {
66
+ await mkdir(CONNECTIONS_DIR, { recursive: true });
67
+ const path = tokenPath(locationId);
68
+ const data = {
69
+ "$0n": {
70
+ type: "connection",
71
+ name: `CRM OAuth — ${locationId}`,
72
+ version: "1.0.0",
73
+ created: new Date().toISOString(),
74
+ updated: new Date().toISOString(),
75
+ },
76
+ service: "crm",
77
+ auth: { type: "oauth" },
78
+ oauth: {
79
+ access_token: tokens.access_token,
80
+ refresh_token: tokens.refresh_token,
81
+ expires_at: Date.now() + (tokens.expires_in || 86400) * 1000,
82
+ location_id: locationId,
83
+ company_id: tokens.companyId || tokens.company_id || null,
84
+ client_id: clientId || DEFAULT_CLIENT_ID,
85
+ client_secret: clientSecret || DEFAULT_CLIENT_SECRET,
86
+ },
87
+ };
88
+ await writeFile(path, JSON.stringify(data, null, 2));
89
+ console.error(`[oauth-store] Saved token for location ${locationId}`);
90
+ }
91
+
92
+ /**
93
+ * Refresh an expired token.
94
+ * @param {StoredToken} stored
95
+ * @returns {Promise<StoredToken>}
96
+ */
97
+ async function refreshToken(stored) {
98
+ const clientId = stored.client_id || DEFAULT_CLIENT_ID;
99
+ const clientSecret = stored.client_secret || DEFAULT_CLIENT_SECRET;
100
+
101
+ console.error(`[oauth-store] Refreshing token for ${stored.location_id}...`);
102
+
103
+ const res = await fetch(TOKEN_URL, {
104
+ method: "POST",
105
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
106
+ body: new URLSearchParams({
107
+ client_id: clientId,
108
+ client_secret: clientSecret,
109
+ grant_type: "refresh_token",
110
+ refresh_token: stored.refresh_token,
111
+ }),
112
+ });
113
+
114
+ if (!res.ok) {
115
+ const err = await res.text();
116
+ console.error(`[oauth-store] Refresh failed for ${stored.location_id}:`, err);
117
+ throw new Error(`OAuth refresh failed: ${res.status}`);
118
+ }
119
+
120
+ const data = await res.json();
121
+ const updated = {
122
+ access_token: data.access_token,
123
+ refresh_token: data.refresh_token || stored.refresh_token,
124
+ expires_at: Date.now() + (data.expires_in || 86400) * 1000,
125
+ location_id: stored.location_id,
126
+ company_id: data.companyId || stored.company_id,
127
+ client_id: clientId,
128
+ client_secret: clientSecret,
129
+ };
130
+
131
+ await saveToken(stored.location_id, {
132
+ access_token: updated.access_token,
133
+ refresh_token: updated.refresh_token,
134
+ expires_in: data.expires_in || 86400,
135
+ companyId: updated.company_id,
136
+ }, clientId, clientSecret);
137
+
138
+ return updated;
139
+ }
140
+
141
+ /**
142
+ * Resolve a valid access token for a location.
143
+ * Auto-refreshes if expired. Falls back to PIT if no OAuth token.
144
+ * @param {string} locationId
145
+ * @returns {Promise<string>} - Valid access_token
146
+ */
147
+ export async function resolveToken(locationId) {
148
+ let stored = await loadToken(locationId);
149
+
150
+ if (stored) {
151
+ if (Date.now() >= stored.expires_at - REFRESH_BUFFER_MS) {
152
+ stored = await refreshToken(stored);
153
+ }
154
+ return stored.access_token;
155
+ }
156
+
157
+ // Fall back to PIT from crm.0n
158
+ const pitPath = join(CONNECTIONS_DIR, "crm.0n");
159
+ if (existsSync(pitPath)) {
160
+ try {
161
+ const raw = await readFile(pitPath, "utf-8");
162
+ const data = JSON.parse(raw);
163
+ const pit = data.auth?.credentials?.access_token;
164
+ if (pit) {
165
+ console.error(`[oauth-store] No OAuth for ${locationId}, falling back to PIT`);
166
+ return pit;
167
+ }
168
+ } catch {}
169
+ }
170
+
171
+ throw new Error(`No OAuth or PIT token available for location ${locationId}`);
172
+ }
173
+
174
+ /**
175
+ * List all locations with stored OAuth tokens.
176
+ * @returns {Promise<Array<{location_id: string, expires_at: number, expired: boolean}>>}
177
+ */
178
+ export async function listConnectedLocations() {
179
+ if (!existsSync(CONNECTIONS_DIR)) return [];
180
+ const { readdir } = await import("fs/promises");
181
+ const files = await readdir(CONNECTIONS_DIR);
182
+ const locations = [];
183
+ for (const f of files) {
184
+ if (f.startsWith("crm-oauth-") && f.endsWith(".0n")) {
185
+ try {
186
+ const raw = await readFile(join(CONNECTIONS_DIR, f), "utf-8");
187
+ const data = JSON.parse(raw);
188
+ if (data.oauth) {
189
+ locations.push({
190
+ location_id: data.oauth.location_id,
191
+ company_id: data.oauth.company_id,
192
+ expires_at: data.oauth.expires_at,
193
+ expired: Date.now() >= data.oauth.expires_at,
194
+ file: f,
195
+ });
196
+ }
197
+ } catch {}
198
+ }
199
+ }
200
+ return locations;
201
+ }
202
+
203
+ /**
204
+ * Register OAuth store MCP tools.
205
+ */
206
+ export function registerOAuthStoreTools(server, z) {
207
+ server.tool(
208
+ "crm_oauth_connect",
209
+ "Store OAuth tokens for a CRM location after OAuth callback. This enables auto-auth for all CRM tools on that location.",
210
+ {
211
+ location_id: z.string().describe("CRM location ID"),
212
+ access_token: z.string().describe("OAuth access token"),
213
+ refresh_token: z.string().describe("OAuth refresh token"),
214
+ expires_in: z.number().optional().describe("Token TTL in seconds (default 86400)"),
215
+ company_id: z.string().optional().describe("Company ID"),
216
+ client_id: z.string().optional().describe("Marketplace app client ID (default: 0nCORE app)"),
217
+ client_secret: z.string().optional().describe("Marketplace app client secret (default: 0nCORE app)"),
218
+ },
219
+ async ({ location_id, access_token, refresh_token, expires_in, company_id, client_id, client_secret }) => {
220
+ await saveToken(location_id, { access_token, refresh_token, expires_in: expires_in || 86400, companyId: company_id }, client_id, client_secret);
221
+ return { content: [{ type: "text", text: JSON.stringify({ status: "connected", location_id, expires_in: expires_in || 86400 }) }] };
222
+ }
223
+ );
224
+
225
+ server.tool(
226
+ "crm_oauth_status",
227
+ "Show OAuth connection status for all CRM locations. Shows which have OAuth tokens and which are using PIT fallback.",
228
+ {},
229
+ async () => {
230
+ const locations = await listConnectedLocations();
231
+ return { content: [{ type: "text", text: JSON.stringify({ connected_locations: locations, count: locations.length }, null, 2) }] };
232
+ }
233
+ );
234
+
235
+ server.tool(
236
+ "crm_oauth_resolve",
237
+ "Resolve a working access token for a location. Auto-refreshes if expired, falls back to PIT if no OAuth. Use this to test auth before making API calls.",
238
+ {
239
+ location_id: z.string().describe("CRM location ID"),
240
+ },
241
+ async ({ location_id }) => {
242
+ try {
243
+ const token = await resolveToken(location_id);
244
+ const stored = await loadToken(location_id);
245
+ return {
246
+ content: [{
247
+ type: "text",
248
+ text: JSON.stringify({
249
+ status: "ok",
250
+ location_id,
251
+ auth_type: stored ? "oauth" : "pit",
252
+ token_preview: token.slice(0, 20) + "...",
253
+ expires_at: stored?.expires_at ? new Date(stored.expires_at).toISOString() : null,
254
+ }, null, 2),
255
+ }],
256
+ };
257
+ } catch (err) {
258
+ return { content: [{ type: "text", text: JSON.stringify({ status: "error", message: err.message }) }] };
259
+ }
260
+ }
261
+ );
262
+ }