@cci-labs/mode-mcp 0.0.0-4a844de
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/config.d.ts +11 -0
- package/dist/config.js +27 -0
- package/dist/file-utils.d.ts +7 -0
- package/dist/file-utils.js +47 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +29 -0
- package/dist/mode-client.d.ts +38 -0
- package/dist/mode-client.js +227 -0
- package/dist/tools/analytics.d.ts +4 -0
- package/dist/tools/analytics.js +741 -0
- package/dist/tools/datasets.d.ts +4 -0
- package/dist/tools/datasets.js +154 -0
- package/dist/tools/distribution.d.ts +4 -0
- package/dist/tools/distribution.js +175 -0
- package/dist/tools/management.d.ts +4 -0
- package/dist/tools/management.js +263 -0
- package/dist/truncate.d.ts +6 -0
- package/dist/truncate.js +16 -0
- package/dist/utils.d.ts +6 -0
- package/dist/utils.js +68 -0
- package/package.json +60 -0
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export interface ModeConfig {
|
|
2
|
+
workspace: string;
|
|
3
|
+
apiToken: string;
|
|
4
|
+
apiSecret: string;
|
|
5
|
+
baseUrl: string;
|
|
6
|
+
maxRowsDefault: number;
|
|
7
|
+
pollIntervalMs: number;
|
|
8
|
+
pollTimeoutMs: number;
|
|
9
|
+
maxOutputLength: number;
|
|
10
|
+
}
|
|
11
|
+
export declare function loadConfig(): ModeConfig;
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import dotenv from "dotenv";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { fileURLToPath } from "url";
|
|
4
|
+
// Load .env from project root
|
|
5
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
6
|
+
dotenv.config({ path: path.resolve(__dirname, "..", ".env"), quiet: true });
|
|
7
|
+
export function loadConfig() {
|
|
8
|
+
const workspace = process.env.MODE_WORKSPACE;
|
|
9
|
+
const apiToken = process.env.MODE_API_TOKEN;
|
|
10
|
+
const apiSecret = process.env.MODE_API_SECRET;
|
|
11
|
+
if (!workspace)
|
|
12
|
+
throw new Error("MODE_WORKSPACE environment variable is required");
|
|
13
|
+
if (!apiToken)
|
|
14
|
+
throw new Error("MODE_API_TOKEN environment variable is required");
|
|
15
|
+
if (!apiSecret)
|
|
16
|
+
throw new Error("MODE_API_SECRET environment variable is required");
|
|
17
|
+
return {
|
|
18
|
+
workspace,
|
|
19
|
+
apiToken,
|
|
20
|
+
apiSecret,
|
|
21
|
+
baseUrl: process.env.MODE_BASE_URL ?? "https://app.mode.com",
|
|
22
|
+
maxRowsDefault: parseInt(process.env.MODE_MAX_ROWS ?? "100", 10) || 100,
|
|
23
|
+
pollIntervalMs: parseInt(process.env.MODE_POLL_INTERVAL_MS ?? "2000", 10) || 2000,
|
|
24
|
+
pollTimeoutMs: parseInt(process.env.MODE_POLL_TIMEOUT_MS ?? "300000", 10) || 300000,
|
|
25
|
+
maxOutputLength: parseInt(process.env.MODE_MAX_OUTPUT_LENGTH ?? "50000", 10) || 50000,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export interface SaveResult {
|
|
2
|
+
saved: boolean;
|
|
3
|
+
path?: string;
|
|
4
|
+
size_bytes?: number;
|
|
5
|
+
}
|
|
6
|
+
export declare function slugify(name: string): string;
|
|
7
|
+
export declare function trySaveFile(content: Buffer | string, filename: string, outputDir?: string): Promise<SaveResult>;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { writeFile, access, mkdir, constants } from "fs/promises";
|
|
2
|
+
import path from "path";
|
|
3
|
+
function resolveDir(dir) {
|
|
4
|
+
if (dir.startsWith("~")) {
|
|
5
|
+
const home = process.env.HOME ?? process.env.USERPROFILE ?? "/tmp";
|
|
6
|
+
return path.join(home, dir.slice(1));
|
|
7
|
+
}
|
|
8
|
+
return path.resolve(dir);
|
|
9
|
+
}
|
|
10
|
+
async function isWritable(dir) {
|
|
11
|
+
try {
|
|
12
|
+
await access(dir, constants.W_OK);
|
|
13
|
+
return true;
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
export function slugify(name) {
|
|
20
|
+
return name
|
|
21
|
+
.toLowerCase()
|
|
22
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
23
|
+
.replace(/^-|-$/g, "");
|
|
24
|
+
}
|
|
25
|
+
export async function trySaveFile(content, filename, outputDir) {
|
|
26
|
+
if (!outputDir)
|
|
27
|
+
return { saved: false };
|
|
28
|
+
const resolved = resolveDir(outputDir);
|
|
29
|
+
// Try to create the directory if it doesn't exist
|
|
30
|
+
if (!(await isWritable(resolved))) {
|
|
31
|
+
try {
|
|
32
|
+
await mkdir(resolved, { recursive: true });
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return { saved: false };
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
const outputPath = path.join(resolved, path.basename(filename));
|
|
39
|
+
const buf = typeof content === "string" ? Buffer.from(content, "utf-8") : content;
|
|
40
|
+
try {
|
|
41
|
+
await writeFile(outputPath, buf);
|
|
42
|
+
return { saved: true, path: outputPath, size_bytes: buf.length };
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return { saved: false };
|
|
46
|
+
}
|
|
47
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
|
+
import { loadConfig } from "./config.js";
|
|
5
|
+
import { ModeClient } from "./mode-client.js";
|
|
6
|
+
import { setMaxOutputLength } from "./truncate.js";
|
|
7
|
+
import { registerAnalyticsTools } from "./tools/analytics.js";
|
|
8
|
+
import { registerManagementTools } from "./tools/management.js";
|
|
9
|
+
import { registerDistributionTools } from "./tools/distribution.js";
|
|
10
|
+
import { registerDatasetTools } from "./tools/datasets.js";
|
|
11
|
+
const config = loadConfig();
|
|
12
|
+
setMaxOutputLength(config.maxOutputLength);
|
|
13
|
+
const client = new ModeClient(config);
|
|
14
|
+
const server = new McpServer({
|
|
15
|
+
name: "mode-analytics",
|
|
16
|
+
version: "1.0.0",
|
|
17
|
+
});
|
|
18
|
+
registerAnalyticsTools(server, client, config);
|
|
19
|
+
registerManagementTools(server, client, config);
|
|
20
|
+
registerDistributionTools(server, client, config);
|
|
21
|
+
registerDatasetTools(server, client, config);
|
|
22
|
+
async function main() {
|
|
23
|
+
const transport = new StdioServerTransport();
|
|
24
|
+
await server.connect(transport);
|
|
25
|
+
}
|
|
26
|
+
main().catch((err) => {
|
|
27
|
+
console.error("Fatal error:", err);
|
|
28
|
+
process.exit(1);
|
|
29
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { ModeConfig } from "./config.js";
|
|
2
|
+
export interface ModeRequestOptions {
|
|
3
|
+
method?: string;
|
|
4
|
+
body?: Record<string, unknown>;
|
|
5
|
+
accept?: string;
|
|
6
|
+
params?: Record<string, string>;
|
|
7
|
+
}
|
|
8
|
+
export interface ModeErrorDetail {
|
|
9
|
+
id: string;
|
|
10
|
+
message: string;
|
|
11
|
+
}
|
|
12
|
+
export declare class ModeApiError extends Error {
|
|
13
|
+
readonly status: number;
|
|
14
|
+
readonly detail: ModeErrorDetail | string;
|
|
15
|
+
readonly endpoint: string;
|
|
16
|
+
constructor(status: number, detail: ModeErrorDetail | string, endpoint: string);
|
|
17
|
+
get suggestion(): string;
|
|
18
|
+
}
|
|
19
|
+
export declare class ModeClient {
|
|
20
|
+
private readonly authHeader;
|
|
21
|
+
private readonly baseApiUrl;
|
|
22
|
+
private readonly config;
|
|
23
|
+
private requestTimestamps;
|
|
24
|
+
private readonly maxRequests;
|
|
25
|
+
private readonly windowMs;
|
|
26
|
+
private rateLimitRemaining;
|
|
27
|
+
private rateLimitResetAt;
|
|
28
|
+
constructor(config: ModeConfig);
|
|
29
|
+
private parseRateLimitHeaders;
|
|
30
|
+
private rateLimit;
|
|
31
|
+
private calculateRetryWait;
|
|
32
|
+
request<T = unknown>(path: string, options?: ModeRequestOptions): Promise<T>;
|
|
33
|
+
get<T = unknown>(path: string, options?: Omit<ModeRequestOptions, "method">): Promise<T>;
|
|
34
|
+
post<T = unknown>(path: string, body?: Record<string, unknown>): Promise<T>;
|
|
35
|
+
getEmbedded<T>(response: Record<string, unknown>, key: string): T[];
|
|
36
|
+
getLink(response: Record<string, unknown>, rel: string): string | undefined;
|
|
37
|
+
pollUntilComplete<T extends Record<string, unknown>>(path: string, timeoutMs?: number, initialIntervalMs?: number): Promise<T>;
|
|
38
|
+
}
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
export class ModeApiError extends Error {
|
|
2
|
+
status;
|
|
3
|
+
detail;
|
|
4
|
+
endpoint;
|
|
5
|
+
constructor(status, detail, endpoint) {
|
|
6
|
+
const msg = typeof detail === "string" ? detail : detail.message;
|
|
7
|
+
super(`Mode API ${status} on ${endpoint}: ${msg}`);
|
|
8
|
+
this.status = status;
|
|
9
|
+
this.detail = detail;
|
|
10
|
+
this.endpoint = endpoint;
|
|
11
|
+
this.name = "ModeApiError";
|
|
12
|
+
}
|
|
13
|
+
get suggestion() {
|
|
14
|
+
if (this.status === 401) {
|
|
15
|
+
return "Check MODE_API_TOKEN and MODE_API_SECRET. Regenerate at app.mode.com/{workspace}/settings/api_tokens.";
|
|
16
|
+
}
|
|
17
|
+
if (this.status === 403) {
|
|
18
|
+
return "Your API token may lack permission for this operation. Workspace admin access may be required.";
|
|
19
|
+
}
|
|
20
|
+
if (this.status === 404) {
|
|
21
|
+
return "The resource was not found. Use mode_list_reports / mode_list_spaces to find valid tokens.";
|
|
22
|
+
}
|
|
23
|
+
if (this.status === 429) {
|
|
24
|
+
return "Rate limited. The server will automatically retry.";
|
|
25
|
+
}
|
|
26
|
+
if (this.status >= 500) {
|
|
27
|
+
return "Mode's API returned a server error. This is usually temporary — try again.";
|
|
28
|
+
}
|
|
29
|
+
return "";
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
export class ModeClient {
|
|
33
|
+
authHeader;
|
|
34
|
+
baseApiUrl;
|
|
35
|
+
config;
|
|
36
|
+
// Rate limiting state
|
|
37
|
+
requestTimestamps = [];
|
|
38
|
+
maxRequests = 35; // stay under Mode's 40/10s limit
|
|
39
|
+
windowMs = 10_000;
|
|
40
|
+
// Server-reported rate limit state
|
|
41
|
+
rateLimitRemaining = null;
|
|
42
|
+
rateLimitResetAt = null; // epoch ms
|
|
43
|
+
constructor(config) {
|
|
44
|
+
this.config = config;
|
|
45
|
+
const creds = Buffer.from(`${config.apiToken}:${config.apiSecret}`).toString("base64");
|
|
46
|
+
this.authHeader = `Basic ${creds}`;
|
|
47
|
+
this.baseApiUrl = `${config.baseUrl}/api/${config.workspace}`;
|
|
48
|
+
if (!config.apiToken.trim()) {
|
|
49
|
+
throw new Error("MODE_API_TOKEN is empty.");
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
parseRateLimitHeaders(headers) {
|
|
53
|
+
const remaining = headers.get("X-RateLimit-Remaining");
|
|
54
|
+
if (remaining !== null) {
|
|
55
|
+
this.rateLimitRemaining = parseInt(remaining, 10);
|
|
56
|
+
}
|
|
57
|
+
const reset = headers.get("X-RateLimit-Reset");
|
|
58
|
+
if (reset !== null) {
|
|
59
|
+
const resetValue = parseInt(reset, 10);
|
|
60
|
+
// If value looks like a unix timestamp (> year 2000 in seconds), convert to ms
|
|
61
|
+
this.rateLimitResetAt = resetValue > 1_000_000_000 ? resetValue * 1000 : Date.now() + resetValue * 1000;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
async rateLimit() {
|
|
65
|
+
const now = Date.now();
|
|
66
|
+
// If the server told us we're running low, proactively wait
|
|
67
|
+
if (this.rateLimitRemaining !== null &&
|
|
68
|
+
this.rateLimitRemaining < 5 &&
|
|
69
|
+
this.rateLimitResetAt !== null &&
|
|
70
|
+
this.rateLimitResetAt > now) {
|
|
71
|
+
const waitMs = this.rateLimitResetAt - now + 200;
|
|
72
|
+
if (waitMs > 0 && waitMs < 30_000) {
|
|
73
|
+
await new Promise((resolve) => setTimeout(resolve, waitMs));
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
// Local sliding window rate limiter
|
|
77
|
+
this.requestTimestamps = this.requestTimestamps.filter((t) => now - t < this.windowMs);
|
|
78
|
+
if (this.requestTimestamps.length >= this.maxRequests) {
|
|
79
|
+
const oldest = this.requestTimestamps[0];
|
|
80
|
+
const waitMs = this.windowMs - (now - oldest) + 100;
|
|
81
|
+
await new Promise((resolve) => setTimeout(resolve, waitMs));
|
|
82
|
+
}
|
|
83
|
+
this.requestTimestamps.push(Date.now());
|
|
84
|
+
}
|
|
85
|
+
calculateRetryWait(response) {
|
|
86
|
+
// Try to use server-provided reset time
|
|
87
|
+
const resetHeader = response.headers.get("X-RateLimit-Reset");
|
|
88
|
+
if (resetHeader) {
|
|
89
|
+
const resetValue = parseInt(resetHeader, 10);
|
|
90
|
+
const resetAt = resetValue > 1_000_000_000 ? resetValue * 1000 : Date.now() + resetValue * 1000;
|
|
91
|
+
const waitMs = resetAt - Date.now() + 500;
|
|
92
|
+
if (waitMs > 0 && waitMs < 60_000) {
|
|
93
|
+
return waitMs;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
// Try Retry-After header
|
|
97
|
+
const retryAfter = response.headers.get("Retry-After");
|
|
98
|
+
if (retryAfter) {
|
|
99
|
+
const seconds = parseInt(retryAfter, 10);
|
|
100
|
+
if (!isNaN(seconds) && seconds > 0 && seconds < 120) {
|
|
101
|
+
return seconds * 1000 + 500;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
// Default backoff
|
|
105
|
+
return 3000;
|
|
106
|
+
}
|
|
107
|
+
async request(path, options = {}) {
|
|
108
|
+
const { method = "GET", body, accept = "application/hal+json", params } = options;
|
|
109
|
+
await this.rateLimit();
|
|
110
|
+
let url;
|
|
111
|
+
if (path.startsWith("http")) {
|
|
112
|
+
url = path;
|
|
113
|
+
}
|
|
114
|
+
else {
|
|
115
|
+
url = `${this.baseApiUrl}${path.startsWith("/") ? "" : "/"}${path}`;
|
|
116
|
+
}
|
|
117
|
+
if (params) {
|
|
118
|
+
const searchParams = new URLSearchParams(params);
|
|
119
|
+
url += `?${searchParams.toString()}`;
|
|
120
|
+
}
|
|
121
|
+
const headers = {
|
|
122
|
+
Authorization: this.authHeader,
|
|
123
|
+
Accept: accept,
|
|
124
|
+
"Accept-Encoding": "gzip",
|
|
125
|
+
};
|
|
126
|
+
const fetchOptions = { method, headers };
|
|
127
|
+
if (body) {
|
|
128
|
+
headers["Content-Type"] = "application/json";
|
|
129
|
+
fetchOptions.body = JSON.stringify(body);
|
|
130
|
+
}
|
|
131
|
+
// Retry logic: up to 3 retries for transient errors
|
|
132
|
+
let lastError;
|
|
133
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
134
|
+
try {
|
|
135
|
+
const response = await fetch(url, fetchOptions);
|
|
136
|
+
// Always parse rate limit headers from every response
|
|
137
|
+
this.parseRateLimitHeaders(response.headers);
|
|
138
|
+
if (response.status === 429) {
|
|
139
|
+
// Rate limited — use server headers for exact wait time
|
|
140
|
+
const waitMs = this.calculateRetryWait(response);
|
|
141
|
+
await response.text().catch(() => { });
|
|
142
|
+
await new Promise((resolve) => setTimeout(resolve, waitMs));
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
if (response.status >= 500) {
|
|
146
|
+
// Server error — retry with backoff
|
|
147
|
+
await response.text().catch(() => { });
|
|
148
|
+
await new Promise((resolve) => setTimeout(resolve, 1000 * Math.pow(2, attempt)));
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
if (!response.ok) {
|
|
152
|
+
let detail;
|
|
153
|
+
try {
|
|
154
|
+
detail = (await response.json());
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
detail = await response.text();
|
|
158
|
+
}
|
|
159
|
+
throw new ModeApiError(response.status, detail, `${method} ${path}`);
|
|
160
|
+
}
|
|
161
|
+
// Handle different content types
|
|
162
|
+
const contentType = response.headers.get("content-type") ?? "";
|
|
163
|
+
if (contentType.includes("application/pdf") || accept === "application/pdf") {
|
|
164
|
+
return (await response.arrayBuffer());
|
|
165
|
+
}
|
|
166
|
+
if (contentType.includes("text/csv") || accept === "text/csv") {
|
|
167
|
+
return (await response.text());
|
|
168
|
+
}
|
|
169
|
+
if (contentType.includes("application/json") || contentType.includes("hal+json")) {
|
|
170
|
+
return (await response.json());
|
|
171
|
+
}
|
|
172
|
+
return (await response.text());
|
|
173
|
+
}
|
|
174
|
+
catch (e) {
|
|
175
|
+
if (e instanceof ModeApiError)
|
|
176
|
+
throw e;
|
|
177
|
+
lastError = e;
|
|
178
|
+
if (attempt < 2) {
|
|
179
|
+
await new Promise((resolve) => setTimeout(resolve, 1000 * Math.pow(2, attempt)));
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
throw lastError ?? new Error(`Failed to fetch ${method} ${path} after 3 attempts`);
|
|
184
|
+
}
|
|
185
|
+
async get(path, options) {
|
|
186
|
+
return this.request(path, { ...options, method: "GET" });
|
|
187
|
+
}
|
|
188
|
+
async post(path, body) {
|
|
189
|
+
return this.request(path, { method: "POST", body });
|
|
190
|
+
}
|
|
191
|
+
// HAL+JSON helpers
|
|
192
|
+
getEmbedded(response, key) {
|
|
193
|
+
const embedded = response._embedded;
|
|
194
|
+
return embedded?.[key] ?? [];
|
|
195
|
+
}
|
|
196
|
+
getLink(response, rel) {
|
|
197
|
+
const links = response._links;
|
|
198
|
+
return links?.[rel]?.href;
|
|
199
|
+
}
|
|
200
|
+
// Polling helper for report runs — uses progressive backoff
|
|
201
|
+
// Starts at initialIntervalMs (default 2s), increases by 1.5x each poll,
|
|
202
|
+
// capped at maxIntervalMs (default 15s).
|
|
203
|
+
async pollUntilComplete(path, timeoutMs, initialIntervalMs) {
|
|
204
|
+
const timeout = timeoutMs ?? this.config.pollTimeoutMs;
|
|
205
|
+
const startInterval = initialIntervalMs ?? this.config.pollIntervalMs;
|
|
206
|
+
const maxInterval = 15_000;
|
|
207
|
+
const deadline = Date.now() + timeout;
|
|
208
|
+
let currentInterval = startInterval;
|
|
209
|
+
while (Date.now() < deadline) {
|
|
210
|
+
const result = await this.get(path);
|
|
211
|
+
const state = result.state;
|
|
212
|
+
if (state === "succeeded" || state === "completed") {
|
|
213
|
+
return result;
|
|
214
|
+
}
|
|
215
|
+
if (state === "failed" || state === "cancelled") {
|
|
216
|
+
throw new Error(`Run failed with state: ${state}. Path: ${path}`);
|
|
217
|
+
}
|
|
218
|
+
// Wait with progressive backoff
|
|
219
|
+
const waitMs = Math.min(currentInterval, deadline - Date.now());
|
|
220
|
+
if (waitMs <= 0)
|
|
221
|
+
break;
|
|
222
|
+
await new Promise((resolve) => setTimeout(resolve, waitMs));
|
|
223
|
+
currentInterval = Math.min(currentInterval * 1.5, maxInterval);
|
|
224
|
+
}
|
|
225
|
+
throw new Error(`Polling timed out after ${timeout}ms for ${path}`);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { ModeClient } from "../mode-client.js";
|
|
3
|
+
import { ModeConfig } from "../config.js";
|
|
4
|
+
export declare function registerAnalyticsTools(server: McpServer, client: ModeClient, config: ModeConfig): void;
|