@camscanner/tracking-mcp-server 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/README.md ADDED
@@ -0,0 +1,57 @@
1
+ # @camscanner/tracking-mcp-server
2
+
3
+ MCP Server for querying Intsig Data Studio tracking/burial point documentation.
4
+
5
+ ## Features
6
+
7
+ - SSO authentication via browser (QR code scan)
8
+ - List products, product types, and versions
9
+ - Search tracking records by developer, product, keyword, PageID, ActionID
10
+ - View tracking screenshots/attachments as images
11
+ - Credential persistence (7-day validity)
12
+
13
+ ## Installation
14
+
15
+ ```bash
16
+ npx -y @camscanner/tracking-mcp-server@latest
17
+ ```
18
+
19
+ ## MCP Configuration
20
+
21
+ Add to your `.mcp.json`:
22
+
23
+ ```json
24
+ {
25
+ "tracking": {
26
+ "command": "npx",
27
+ "args": ["-y", "@camscanner/tracking-mcp-server@latest"],
28
+ "env": {
29
+ "TRACKING_BASE_URL": "https://bigdata.intsig.net",
30
+ "SSO_LOGIN_URL": "https://web-sso.intsig.net/login",
31
+ "SSO_PLATFORM_ID": "FEOXFe858GRbG9aLymrNDLyApKcBWtrw",
32
+ "SSO_REDIRECT_URL": "https://ids.intsig.net/trackManage/develop"
33
+ }
34
+ }
35
+ }
36
+ ```
37
+
38
+ ## Available Tools
39
+
40
+ | Tool | Description |
41
+ |------|-------------|
42
+ | `tracking-auth` | SSO login via browser |
43
+ | `tracking-logout` | Clear credentials |
44
+ | `list_products` | List all product names |
45
+ | `list_product_types` | Get platform types for a product |
46
+ | `list_product_versions` | Get version list for a product |
47
+ | `get_developer_name` | Get current user's display name |
48
+ | `search_tracking` | Search tracking records (paginated) |
49
+ | `get_tracking_screenshot` | Download tracking screenshot by UUID |
50
+
51
+ ## Development
52
+
53
+ ```bash
54
+ npm install
55
+ npm run build
56
+ npm start
57
+ ```
package/dist/auth.d.ts ADDED
@@ -0,0 +1,22 @@
1
+ export interface SsoConfig {
2
+ trackingBaseUrl: string;
3
+ ssoLoginUrl: string;
4
+ ssoPlatformId: string;
5
+ redirectUrl: string;
6
+ }
7
+ export interface Credentials {
8
+ token: string;
9
+ uid: string;
10
+ nickName: string;
11
+ userName: string;
12
+ expiresAt: number;
13
+ }
14
+ export declare function loadCredentials(): Promise<Credentials | null>;
15
+ export declare function saveCredentials(creds: Credentials): Promise<void>;
16
+ export declare function clearCredentials(): Promise<void>;
17
+ /**
18
+ * Launch browser for SSO login.
19
+ * The SSO redirects to ids.intsig.net/trackManage/develop?token=JWT_TOKEN
20
+ * We capture that JWT token and use it to call the bigdata API to get user info.
21
+ */
22
+ export declare function startSsoLogin(config: SsoConfig): Promise<Credentials>;
package/dist/auth.js ADDED
@@ -0,0 +1,213 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.loadCredentials = loadCredentials;
7
+ exports.saveCredentials = saveCredentials;
8
+ exports.clearCredentials = clearCredentials;
9
+ exports.startSsoLogin = startSsoLogin;
10
+ const fs_1 = __importDefault(require("fs"));
11
+ const path_1 = __importDefault(require("path"));
12
+ const os_1 = __importDefault(require("os"));
13
+ const http_1 = __importDefault(require("http"));
14
+ const https_1 = __importDefault(require("https"));
15
+ const url_1 = require("url");
16
+ const playwright_core_1 = require("playwright-core");
17
+ // --- Credentials persistence ---
18
+ const CREDS_DIR = path_1.default.join(os_1.default.homedir(), ".tracking-mcp");
19
+ const CREDS_FILE = path_1.default.join(CREDS_DIR, "credentials.json");
20
+ const BROWSER_DATA_DIR = path_1.default.join(CREDS_DIR, "browser-data");
21
+ async function loadCredentials() {
22
+ try {
23
+ if (!fs_1.default.existsSync(CREDS_FILE))
24
+ return null;
25
+ const data = JSON.parse(fs_1.default.readFileSync(CREDS_FILE, "utf-8"));
26
+ if (data && data.expiresAt > Date.now())
27
+ return data;
28
+ return null;
29
+ }
30
+ catch {
31
+ return null;
32
+ }
33
+ }
34
+ async function saveCredentials(creds) {
35
+ if (!fs_1.default.existsSync(CREDS_DIR))
36
+ fs_1.default.mkdirSync(CREDS_DIR, { recursive: true });
37
+ fs_1.default.writeFileSync(CREDS_FILE, JSON.stringify(creds, null, 2));
38
+ }
39
+ async function clearCredentials() {
40
+ try {
41
+ fs_1.default.unlinkSync(CREDS_FILE);
42
+ }
43
+ catch { /* ignore */ }
44
+ }
45
+ // --- Find system Chromium installed by Playwright ---
46
+ function findChromium() {
47
+ const cacheDir = path_1.default.join(os_1.default.homedir(), "Library", "Caches", "ms-playwright");
48
+ if (!fs_1.default.existsSync(cacheDir))
49
+ return undefined;
50
+ const dirs = fs_1.default.readdirSync(cacheDir)
51
+ .filter(d => d.startsWith("chromium-"))
52
+ .sort()
53
+ .reverse();
54
+ for (const dir of dirs) {
55
+ const candidates = [
56
+ path_1.default.join(cacheDir, dir, "chrome-mac-arm64", "Google Chrome for Testing.app", "Contents", "MacOS", "Google Chrome for Testing"),
57
+ path_1.default.join(cacheDir, dir, "chrome-mac", "Google Chrome for Testing.app", "Contents", "MacOS", "Google Chrome for Testing"),
58
+ path_1.default.join(cacheDir, dir, "chrome-mac-arm64", "Chromium.app", "Contents", "MacOS", "Chromium"),
59
+ path_1.default.join(cacheDir, dir, "chrome-mac", "Chromium.app", "Contents", "MacOS", "Chromium"),
60
+ path_1.default.join(cacheDir, dir, "chrome-linux", "chrome"),
61
+ ];
62
+ for (const c of candidates) {
63
+ if (fs_1.default.existsSync(c))
64
+ return c;
65
+ }
66
+ }
67
+ return undefined;
68
+ }
69
+ /**
70
+ * Launch browser for SSO login.
71
+ * The SSO redirects to ids.intsig.net/trackManage/develop?token=JWT_TOKEN
72
+ * We capture that JWT token and use it to call the bigdata API to get user info.
73
+ */
74
+ async function startSsoLogin(config) {
75
+ const execPath = findChromium();
76
+ if (!execPath) {
77
+ throw new Error("Cannot find Chromium. Please install Playwright browsers: npx playwright install chromium");
78
+ }
79
+ if (!fs_1.default.existsSync(BROWSER_DATA_DIR))
80
+ fs_1.default.mkdirSync(BROWSER_DATA_DIR, { recursive: true });
81
+ const ssoUrl = `${config.ssoLoginUrl}?platform_id=${config.ssoPlatformId}&redirect=${encodeURIComponent(config.redirectUrl)}&isLoginOut=1`;
82
+ console.error("[Auth] Launching browser for SSO login...");
83
+ const context = await playwright_core_1.chromium.launchPersistentContext(BROWSER_DATA_DIR, {
84
+ headless: false,
85
+ executablePath: execPath,
86
+ ignoreHTTPSErrors: true,
87
+ });
88
+ try {
89
+ const page = context.pages()[0] || await context.newPage();
90
+ const tokenPromise = new Promise((resolve, reject) => {
91
+ const timeout = setTimeout(() => reject(new Error("SSO login timed out (180s)")), 180000);
92
+ // Watch for redirect to trackManage with token param
93
+ page.on("framenavigated", (frame) => {
94
+ if (frame !== page.mainFrame())
95
+ return;
96
+ const url = frame.url();
97
+ if (url.includes("trackManage") && url.includes("token=")) {
98
+ try {
99
+ const parsed = new url_1.URL(url);
100
+ const token = parsed.searchParams.get("token");
101
+ if (token) {
102
+ clearTimeout(timeout);
103
+ resolve(token);
104
+ }
105
+ }
106
+ catch { /* ignore */ }
107
+ }
108
+ });
109
+ // Also monitor URL changes via response
110
+ page.on("response", (response) => {
111
+ const url = response.url();
112
+ if (url.includes("trackManage") && url.includes("token=")) {
113
+ try {
114
+ const parsed = new url_1.URL(url);
115
+ const token = parsed.searchParams.get("token");
116
+ if (token) {
117
+ clearTimeout(timeout);
118
+ resolve(token);
119
+ }
120
+ }
121
+ catch { /* ignore */ }
122
+ }
123
+ });
124
+ });
125
+ await page.goto(ssoUrl, { waitUntil: "domcontentloaded", timeout: 15000 });
126
+ // Check if already logged in (redirected directly with token)
127
+ const currentUrl = page.url();
128
+ if (currentUrl.includes("trackManage") && currentUrl.includes("token=")) {
129
+ const parsed = new url_1.URL(currentUrl);
130
+ const token = parsed.searchParams.get("token");
131
+ if (token) {
132
+ console.error("[Auth] Already logged in, extracting token...");
133
+ const creds = await exchangeToken(config.trackingBaseUrl, token);
134
+ await context.close();
135
+ return creds;
136
+ }
137
+ }
138
+ console.error("[Auth] Waiting for user to complete SSO login (up to 180s)...");
139
+ const token = await tokenPromise;
140
+ console.error("[Auth] Token captured, fetching user info...");
141
+ const creds = await exchangeToken(config.trackingBaseUrl, token);
142
+ console.error("[Auth] Authentication successful!");
143
+ return creds;
144
+ }
145
+ finally {
146
+ await context.close();
147
+ }
148
+ }
149
+ /** Decode JWT payload without verification to extract the sub (uid) claim */
150
+ function decodeJwtPayload(token) {
151
+ const parts = token.split(".");
152
+ if (parts.length !== 3)
153
+ throw new Error("Invalid JWT format");
154
+ const payload = Buffer.from(parts[1], "base64url").toString("utf-8");
155
+ return JSON.parse(payload);
156
+ }
157
+ /**
158
+ * Use the JWT token to call the SSO login API and get user info (nickName, uid, etc.)
159
+ */
160
+ function exchangeToken(baseUrl, token) {
161
+ return new Promise((resolve, reject) => {
162
+ // Decode JWT to get uid (sub field) — required as query param
163
+ let uid;
164
+ try {
165
+ const payload = decodeJwtPayload(token);
166
+ uid = payload.sub;
167
+ if (!uid)
168
+ throw new Error("No sub field in JWT");
169
+ }
170
+ catch (err) {
171
+ reject(new Error(`Failed to decode JWT: ${err.message}`));
172
+ return;
173
+ }
174
+ // Call: GET /api/bigdata/component/auth/sso/login?u={uid} with x-token header
175
+ const url = new url_1.URL(`${baseUrl}/api/bigdata/component/auth/sso/login?u=${uid}`);
176
+ const mod = url.protocol === "https:" ? https_1.default : http_1.default;
177
+ const options = {
178
+ timeout: 10000,
179
+ headers: {
180
+ "x-token": token,
181
+ "Accept": "application/json",
182
+ },
183
+ };
184
+ const req = mod.get(url.toString(), options, (res) => {
185
+ let body = "";
186
+ res.on("data", (chunk) => (body += chunk));
187
+ res.on("end", () => {
188
+ try {
189
+ const data = JSON.parse(body);
190
+ if (data.code !== 0 || !data.data) {
191
+ reject(new Error(`SSO login API failed: ${data.msg || body.substring(0, 200)}`));
192
+ return;
193
+ }
194
+ resolve({
195
+ token,
196
+ uid: data.data.uid,
197
+ nickName: data.data.nickName,
198
+ userName: data.data.userName,
199
+ expiresAt: Date.now() + 7 * 24 * 60 * 60 * 1000,
200
+ });
201
+ }
202
+ catch {
203
+ reject(new Error(`Invalid JSON from SSO login: ${body.substring(0, 200)}`));
204
+ }
205
+ });
206
+ });
207
+ req.on("error", reject);
208
+ req.on("timeout", () => {
209
+ req.destroy();
210
+ reject(new Error("SSO login request timed out"));
211
+ });
212
+ });
213
+ }
@@ -0,0 +1,10 @@
1
+ /** Format product names list */
2
+ export declare function formatProductNames(data: string[]): string;
3
+ /** Format product types */
4
+ export declare function formatProductTypes(data: any[]): string;
5
+ /** Format product versions */
6
+ export declare function formatProductVersions(data: any[]): string;
7
+ /** Format a single tracking record */
8
+ export declare function formatTrackingRecord(record: any): string;
9
+ /** Format tracking list response */
10
+ export declare function formatTrackingList(data: any): string;
@@ -0,0 +1,106 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.formatProductNames = formatProductNames;
4
+ exports.formatProductTypes = formatProductTypes;
5
+ exports.formatProductVersions = formatProductVersions;
6
+ exports.formatTrackingRecord = formatTrackingRecord;
7
+ exports.formatTrackingList = formatTrackingList;
8
+ /** Format product names list */
9
+ function formatProductNames(data) {
10
+ if (!Array.isArray(data) || data.length === 0)
11
+ return "No products found.";
12
+ return `Products (${data.length})\n\n${data.map((name, i) => `${i + 1}. ${name}`).join("\n")}`;
13
+ }
14
+ /** Format product types */
15
+ function formatProductTypes(data) {
16
+ if (!Array.isArray(data) || data.length === 0)
17
+ return "No product types found.";
18
+ return `Product Types\n\n${data.map((item) => `- ${typeof item === "string" ? item : JSON.stringify(item)}`).join("\n")}`;
19
+ }
20
+ /** Format product versions */
21
+ function formatProductVersions(data) {
22
+ if (!Array.isArray(data) || data.length === 0)
23
+ return "No versions found.";
24
+ return `Versions (${data.length})\n\n${data.map((v) => `- ${typeof v === "string" ? v : JSON.stringify(v)}`).join("\n")}`;
25
+ }
26
+ /** Format a single tracking record */
27
+ function formatTrackingRecord(record) {
28
+ const lines = [];
29
+ lines.push(`${record.pageIdZh || record.pageId || "Unknown"} — ${record.actionIdZh || record.actionId || "(no action)"}`);
30
+ lines.push(`- ID: ${record.id}`);
31
+ lines.push(`- Product: ${record.product || "?"}`);
32
+ lines.push(`- Platform: ${record.reportPlatform || "?"} / ${record.devPlatform || "?"}`);
33
+ lines.push(`- Type: ${record.operation || "?"}`);
34
+ lines.push(`- PageID: ${record.pageId || "—"} (${record.pageIdZh || "—"})`);
35
+ if (record.actionId)
36
+ lines.push(`- ActionID: ${record.actionId} (${record.actionIdZh || "—"})`);
37
+ if (record.traceId)
38
+ lines.push(`- TraceID: ${record.traceId} (${record.traceIdZh || "—"})`);
39
+ if (record.description)
40
+ lines.push(`- Description: ${record.description}`);
41
+ lines.push(`- Created: ${record.createTime || "?"} by ${record.createBy || "?"}`);
42
+ lines.push(`- Updated: ${record.updateTime || "?"} by ${record.updateBy || "?"}`);
43
+ if (record.reportCount != null)
44
+ lines.push(`- Report Count: ${record.reportCount}`);
45
+ if (record.usageCount != null)
46
+ lines.push(`- Usage Count: ${record.usageCount}`);
47
+ if (record.attchUuid)
48
+ lines.push(`- Attachment UUID: ${record.attchUuid}`);
49
+ // Format attributes
50
+ if (record.attributes && record.attributes.length > 0) {
51
+ lines.push(`\nAttributes (${record.attributes.length})`);
52
+ for (const attr of record.attributes) {
53
+ lines.push(`\n${attr.extKey}: ${attr.extValue}`);
54
+ if (attr.extKeyDesc)
55
+ lines.push(` - Key Desc: ${attr.extKeyDesc}`);
56
+ if (attr.extValueDesc)
57
+ lines.push(` - Value Desc: ${attr.extValueDesc}`);
58
+ if (attr.extKeyType)
59
+ lines.push(` - Type: ${attr.extKeyType}`);
60
+ if (attr.tag)
61
+ lines.push(` - Tag: ${attr.tag}`);
62
+ if (attr.tapd)
63
+ lines.push(` - TAPD: ${attr.tapd}`);
64
+ if (attr.proVersion)
65
+ lines.push(` - Version: ${attr.proVersion}`);
66
+ if (attr.developer)
67
+ lines.push(` - Developer: ${attr.developer}`);
68
+ if (attr.tester)
69
+ lines.push(` - Tester: ${attr.tester}`);
70
+ const statusLabel = getStatusLabel(attr.statusCode);
71
+ lines.push(` - Status: ${statusLabel} (${attr.statusCode})`);
72
+ }
73
+ }
74
+ return lines.join("\n");
75
+ }
76
+ /** Format tracking list response */
77
+ function formatTrackingList(data) {
78
+ if (!data)
79
+ return "No data returned.";
80
+ const total = data.total || 0;
81
+ const pageNum = data.pageNum || 1;
82
+ const pageSize = data.pageSize || 50;
83
+ const pages = data.pages || 1;
84
+ const records = data.data || [];
85
+ const lines = [];
86
+ lines.push(`Tracking List (Total: ${total}, Page: ${pageNum}/${pages})\n`);
87
+ if (records.length === 0) {
88
+ lines.push("No tracking records found.");
89
+ return lines.join("\n");
90
+ }
91
+ for (const record of records) {
92
+ lines.push(formatTrackingRecord(record));
93
+ lines.push("\n---\n");
94
+ }
95
+ return lines.join("\n");
96
+ }
97
+ function getStatusLabel(code) {
98
+ switch (code) {
99
+ case 0: return "Draft";
100
+ case 5: return "Pending Review";
101
+ case 10: return "Approved";
102
+ case 20: return "Rejected";
103
+ case 30: return "Deprecated";
104
+ default: return "Unknown";
105
+ }
106
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,192 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ var __importDefault = (this && this.__importDefault) || function (mod) {
4
+ return (mod && mod.__esModule) ? mod : { "default": mod };
5
+ };
6
+ Object.defineProperty(exports, "__esModule", { value: true });
7
+ const mcp_js_1 = require("@modelcontextprotocol/sdk/server/mcp.js");
8
+ const stdio_js_1 = require("@modelcontextprotocol/sdk/server/stdio.js");
9
+ const v3_1 = require("zod/v3");
10
+ const fs_1 = __importDefault(require("fs"));
11
+ const path_1 = __importDefault(require("path"));
12
+ const os_1 = __importDefault(require("os"));
13
+ const tracking_client_js_1 = require("./tracking-client.js");
14
+ const auth_js_1 = require("./auth.js");
15
+ const formatters_js_1 = require("./formatters.js");
16
+ // --- Config from env ---
17
+ const TRACKING_BASE_URL = process.env.TRACKING_BASE_URL || "https://bigdata.intsig.net";
18
+ const SSO_LOGIN_URL = process.env.SSO_LOGIN_URL || "https://web-sso.intsig.net/login";
19
+ const SSO_PLATFORM_ID = process.env.SSO_PLATFORM_ID || "FEOXFe858GRbG9aLymrNDLyApKcBWtrw";
20
+ const SSO_REDIRECT_URL = process.env.SSO_REDIRECT_URL || "https://ids.intsig.net/trackManage/develop";
21
+ const ssoConfig = {
22
+ trackingBaseUrl: TRACKING_BASE_URL,
23
+ ssoLoginUrl: SSO_LOGIN_URL,
24
+ ssoPlatformId: SSO_PLATFORM_ID,
25
+ redirectUrl: SSO_REDIRECT_URL,
26
+ };
27
+ const client = new tracking_client_js_1.TrackingClient(TRACKING_BASE_URL);
28
+ // --- Helper: check auth before API call ---
29
+ async function requireAuth() {
30
+ if (!client.isAuthenticated()) {
31
+ const savedCreds = await (0, auth_js_1.loadCredentials)();
32
+ if (savedCreds) {
33
+ client.setCredentials(savedCreds);
34
+ console.error("Restored saved credentials (valid until " + new Date(savedCreds.expiresAt).toLocaleString() + ")");
35
+ }
36
+ }
37
+ if (!client.isAuthenticated()) {
38
+ return "Not authenticated. Please call the 'tracking-auth' tool first to login via SSO.";
39
+ }
40
+ return null;
41
+ }
42
+ // --- MCP Server ---
43
+ const server = new mcp_js_1.McpServer({
44
+ name: "tracking",
45
+ version: "1.0.0",
46
+ });
47
+ // Tool: tracking-auth
48
+ server.tool("tracking-auth", "Login to Intsig Data Studio (tracking platform) via SSO. Opens browser for authentication.", {}, async () => {
49
+ if (client.isAuthenticated()) {
50
+ const creds = client.getCredentials();
51
+ return { content: [{ type: "text", text: `Already authenticated as ${creds?.nickName} (${creds?.userName}). Use 'tracking-logout' to re-authenticate.` }] };
52
+ }
53
+ try {
54
+ const creds = await (0, auth_js_1.startSsoLogin)(ssoConfig);
55
+ client.setCredentials(creds);
56
+ await (0, auth_js_1.saveCredentials)(creds);
57
+ return { content: [{ type: "text", text: `Authentication successful! Logged in as ${creds.nickName} (${creds.userName}).` }] };
58
+ }
59
+ catch (err) {
60
+ if (err.message?.includes("pre-verification")) {
61
+ return {
62
+ content: [{
63
+ type: "text",
64
+ text: "SSO pre-verification completed. Please call 'tracking-auth' again to complete authentication.",
65
+ }],
66
+ };
67
+ }
68
+ return { content: [{ type: "text", text: `Authentication failed: ${err.message}` }] };
69
+ }
70
+ });
71
+ // Tool: tracking-logout
72
+ server.tool("tracking-logout", "Clear saved tracking platform credentials and logout.", {}, async () => {
73
+ await (0, auth_js_1.clearCredentials)();
74
+ client.setCredentials(null);
75
+ return { content: [{ type: "text", text: "Logged out. Call 'tracking-auth' to login again." }] };
76
+ });
77
+ // Tool: list_products
78
+ server.tool("list_products", "List all available product names in the tracking platform", {}, async () => {
79
+ const authErr = await requireAuth();
80
+ if (authErr)
81
+ return { content: [{ type: "text", text: authErr }] };
82
+ const res = await client.getProductNames();
83
+ if (res.code !== 0) {
84
+ return { content: [{ type: "text", text: `Error: ${res.msg}` }] };
85
+ }
86
+ return { content: [{ type: "text", text: (0, formatters_js_1.formatProductNames)(res.data) }] };
87
+ });
88
+ // Tool: list_product_types
89
+ server.tool("list_product_types", "List platform types (e.g. iOS, Android, Web) for a specific product", {
90
+ product_name: v3_1.z.string().describe("Product name (from list_products)"),
91
+ }, async ({ product_name }) => {
92
+ const authErr = await requireAuth();
93
+ if (authErr)
94
+ return { content: [{ type: "text", text: authErr }] };
95
+ const res = await client.getProductTypes(product_name);
96
+ if (res.code !== 0) {
97
+ return { content: [{ type: "text", text: `Error: ${res.msg}` }] };
98
+ }
99
+ return { content: [{ type: "text", text: (0, formatters_js_1.formatProductTypes)(res.data) }] };
100
+ });
101
+ // Tool: list_product_versions
102
+ server.tool("list_product_versions", "List available versions for a product", {
103
+ product_name: v3_1.z.string().describe("Product name"),
104
+ product_type: v3_1.z.string().optional().describe("Platform type filter (e.g. iOS, Android, Web)"),
105
+ }, async ({ product_name, product_type }) => {
106
+ const authErr = await requireAuth();
107
+ if (authErr)
108
+ return { content: [{ type: "text", text: authErr }] };
109
+ const res = await client.getProductVersions(product_name, product_type || "");
110
+ if (res.code !== 0) {
111
+ return { content: [{ type: "text", text: `Error: ${res.msg}` }] };
112
+ }
113
+ return { content: [{ type: "text", text: (0, formatters_js_1.formatProductVersions)(res.data) }] };
114
+ });
115
+ // Tool: get_developer_name
116
+ server.tool("get_developer_name", "Get the current logged-in developer's display name (nickName), used for querying tracking list", {}, async () => {
117
+ const authErr = await requireAuth();
118
+ if (authErr)
119
+ return { content: [{ type: "text", text: authErr }] };
120
+ const creds = client.getCredentials();
121
+ return {
122
+ content: [{
123
+ type: "text",
124
+ text: `Developer: ${creds?.nickName} (${creds?.userName})\nUse the nickName "${creds?.nickName}" as the developer parameter when querying tracking list.`,
125
+ }],
126
+ };
127
+ });
128
+ // Tool: search_tracking
129
+ server.tool("search_tracking", "Search tracking/burial point records by developer name and product. Returns paginated list with details including PageID, ActionID, attributes, and status.", {
130
+ developer: v3_1.z.string().describe("Developer display name (nickName, e.g. '吴钰杰'). Use get_developer_name to get your own name."),
131
+ product: v3_1.z.string().describe("Product name (e.g. '扫描全能王'). Use list_products to see available products."),
132
+ keyword: v3_1.z.string().optional().describe("Search keyword to filter by pageId/actionId/description"),
133
+ page_id: v3_1.z.string().optional().describe("Filter by specific PageID"),
134
+ action_id: v3_1.z.string().optional().describe("Filter by specific ActionID"),
135
+ base_keyword: v3_1.z.string().optional().describe("Filter by base tracking keyword"),
136
+ attr_keyword: v3_1.z.string().optional().describe("Filter by attribute keyword"),
137
+ page_num: v3_1.z.number().optional().describe("Page number (default: 1)"),
138
+ page_size: v3_1.z.number().optional().describe("Page size (default: 50)"),
139
+ }, async ({ developer, product, keyword, page_id, action_id, base_keyword, attr_keyword, page_num, page_size }) => {
140
+ const authErr = await requireAuth();
141
+ if (authErr)
142
+ return { content: [{ type: "text", text: authErr }] };
143
+ const res = await client.getTrackingList({
144
+ developer,
145
+ product,
146
+ keyWord: keyword,
147
+ pageId: page_id,
148
+ actionId: action_id,
149
+ baseKeyword: base_keyword,
150
+ attrKeyword: attr_keyword,
151
+ pageNum: page_num,
152
+ pageSize: page_size,
153
+ });
154
+ if (res.code !== 0) {
155
+ return { content: [{ type: "text", text: `Error: ${res.msg}` }] };
156
+ }
157
+ return { content: [{ type: "text", text: (0, formatters_js_1.formatTrackingList)(res.data) }] };
158
+ });
159
+ // Tool: get_tracking_screenshot
160
+ server.tool("get_tracking_screenshot", "Get the screenshot/attachment image for a tracking record. Saves the image to a temp file and returns the file path for viewing.", {
161
+ attach_uuid: v3_1.z.string().describe("Attachment UUID from the tracking record's attchUuid field"),
162
+ }, async ({ attach_uuid }) => {
163
+ const authErr = await requireAuth();
164
+ if (authErr)
165
+ return { content: [{ type: "text", text: authErr }] };
166
+ const result = await client.downloadFile(attach_uuid);
167
+ if (!result) {
168
+ return { content: [{ type: "text", text: "Failed to download screenshot. The attachment may not exist or the UUID is invalid." }] };
169
+ }
170
+ // 保存图片到临时文件,避免 base64 数据污染上下文
171
+ const ext = result.contentType.includes("png") ? ".png" : result.contentType.includes("jpeg") || result.contentType.includes("jpg") ? ".jpg" : ".png";
172
+ const tmpDir = path_1.default.join(os_1.default.tmpdir(), "tracking-mcp-screenshots");
173
+ fs_1.default.mkdirSync(tmpDir, { recursive: true });
174
+ const filePath = path_1.default.join(tmpDir, `${attach_uuid}${ext}`);
175
+ fs_1.default.writeFileSync(filePath, Buffer.from(result.base64, "base64"));
176
+ return {
177
+ content: [{
178
+ type: "text",
179
+ text: `Screenshot saved to: ${filePath}\nUse the Read tool to view this image file.`,
180
+ }],
181
+ };
182
+ });
183
+ // --- Start ---
184
+ async function main() {
185
+ const transport = new stdio_js_1.StdioServerTransport();
186
+ await server.connect(transport);
187
+ console.error("Tracking MCP Server running on stdio");
188
+ }
189
+ main().catch((err) => {
190
+ console.error("Fatal error:", err);
191
+ process.exit(1);
192
+ });
@@ -0,0 +1,48 @@
1
+ import type { Credentials } from "./auth.js";
2
+ export interface TrackingResponse<T = any> {
3
+ code: number;
4
+ msg: string;
5
+ data: T;
6
+ }
7
+ export declare class TrackingClient {
8
+ private baseUrl;
9
+ private credentials;
10
+ constructor(baseUrl: string);
11
+ setCredentials(creds: Credentials): void;
12
+ isAuthenticated(): boolean;
13
+ getCredentials(): Credentials | null;
14
+ private getOnce;
15
+ private get;
16
+ private postOnce;
17
+ private post;
18
+ /**
19
+ * Download a file/image as base64.
20
+ * Returns { base64, contentType } or null if failed.
21
+ */
22
+ downloadFile(uuid: string): Promise<{
23
+ base64: string;
24
+ contentType: string;
25
+ } | null>;
26
+ /** Get user nicknames list */
27
+ getUserNickNames(): Promise<TrackingResponse>;
28
+ /** Get product names list */
29
+ getProductNames(): Promise<TrackingResponse>;
30
+ /** Get product types for a product */
31
+ getProductTypes(productName: string): Promise<TrackingResponse>;
32
+ /** Get product versions */
33
+ getProductVersions(productName: string, productType?: string): Promise<TrackingResponse>;
34
+ /** Get tracking list (paginated) */
35
+ getTrackingList(params: {
36
+ developer: string;
37
+ product: string;
38
+ keyWord?: string;
39
+ pageId?: string;
40
+ actionId?: string;
41
+ baseKeyword?: string;
42
+ attrKeyword?: string;
43
+ pageNum?: number;
44
+ pageSize?: number;
45
+ }): Promise<TrackingResponse>;
46
+ /** Get review count for a product */
47
+ getReviewCount(product: string): Promise<TrackingResponse>;
48
+ }
@@ -0,0 +1,207 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.TrackingClient = void 0;
7
+ const http_1 = __importDefault(require("http"));
8
+ const https_1 = __importDefault(require("https"));
9
+ const url_1 = require("url");
10
+ class TrackingClient {
11
+ constructor(baseUrl) {
12
+ this.credentials = null;
13
+ this.baseUrl = baseUrl.replace(/\/+$/, "");
14
+ }
15
+ setCredentials(creds) {
16
+ this.credentials = creds;
17
+ }
18
+ isAuthenticated() {
19
+ return !!(this.credentials && Date.now() < this.credentials.expiresAt);
20
+ }
21
+ getCredentials() {
22
+ return this.credentials;
23
+ }
24
+ getOnce(apiPath, params = {}) {
25
+ return new Promise((resolve, reject) => {
26
+ if (!this.credentials) {
27
+ reject(new Error("Not authenticated."));
28
+ return;
29
+ }
30
+ const url = new url_1.URL(this.baseUrl + apiPath);
31
+ url.searchParams.set("u", this.credentials.uid);
32
+ for (const [key, value] of Object.entries(params)) {
33
+ url.searchParams.set(key, value);
34
+ }
35
+ const mod = url.protocol === "https:" ? https_1.default : http_1.default;
36
+ const options = {
37
+ timeout: 30000,
38
+ headers: {
39
+ "x-token": this.credentials.token,
40
+ "Accept": "application/json",
41
+ },
42
+ };
43
+ const req = mod.get(url.toString(), options, (res) => {
44
+ let body = "";
45
+ res.on("data", (chunk) => (body += chunk));
46
+ res.on("end", () => {
47
+ try {
48
+ resolve(JSON.parse(body));
49
+ }
50
+ catch {
51
+ reject(new Error(`Invalid JSON response from ${apiPath}`));
52
+ }
53
+ });
54
+ });
55
+ req.on("error", reject);
56
+ req.on("timeout", () => {
57
+ req.destroy();
58
+ reject(new Error(`Request timeout: ${apiPath}`));
59
+ });
60
+ });
61
+ }
62
+ async get(apiPath, params = {}, retries = 2) {
63
+ for (let i = 0; i <= retries; i++) {
64
+ try {
65
+ return await this.getOnce(apiPath, params);
66
+ }
67
+ catch (err) {
68
+ if (i === retries || !err.message?.includes("timeout"))
69
+ throw err;
70
+ console.error(`[Tracking] Retry ${i + 1}/${retries} for ${apiPath}`);
71
+ }
72
+ }
73
+ throw new Error(`Request failed after ${retries} retries: ${apiPath}`);
74
+ }
75
+ postOnce(apiPath, body) {
76
+ return new Promise((resolve, reject) => {
77
+ if (!this.credentials) {
78
+ reject(new Error("Not authenticated."));
79
+ return;
80
+ }
81
+ const url = new url_1.URL(this.baseUrl + apiPath);
82
+ url.searchParams.set("u", this.credentials.uid);
83
+ const mod = url.protocol === "https:" ? https_1.default : http_1.default;
84
+ const data = JSON.stringify(body);
85
+ const options = {
86
+ method: "POST",
87
+ timeout: 30000,
88
+ headers: {
89
+ "x-token": this.credentials.token,
90
+ "Content-Type": "application/json",
91
+ "Content-Length": Buffer.byteLength(data),
92
+ "Accept": "application/json",
93
+ },
94
+ };
95
+ const req = mod.request(url.toString(), options, (res) => {
96
+ let respBody = "";
97
+ res.on("data", (chunk) => (respBody += chunk));
98
+ res.on("end", () => {
99
+ try {
100
+ resolve(JSON.parse(respBody));
101
+ }
102
+ catch {
103
+ reject(new Error(`Invalid JSON response from ${apiPath}: ${respBody.substring(0, 200)}`));
104
+ }
105
+ });
106
+ });
107
+ req.on("error", reject);
108
+ req.on("timeout", () => {
109
+ req.destroy();
110
+ reject(new Error(`Request timeout: ${apiPath}`));
111
+ });
112
+ req.write(data);
113
+ req.end();
114
+ });
115
+ }
116
+ async post(apiPath, body, retries = 2) {
117
+ for (let i = 0; i <= retries; i++) {
118
+ try {
119
+ return await this.postOnce(apiPath, body);
120
+ }
121
+ catch (err) {
122
+ if (i === retries || !err.message?.includes("timeout"))
123
+ throw err;
124
+ console.error(`[Tracking] Retry ${i + 1}/${retries} for ${apiPath}`);
125
+ }
126
+ }
127
+ throw new Error(`Request failed after ${retries} retries: ${apiPath}`);
128
+ }
129
+ /**
130
+ * Download a file/image as base64.
131
+ * Returns { base64, contentType } or null if failed.
132
+ */
133
+ downloadFile(uuid) {
134
+ return new Promise((resolve) => {
135
+ if (!this.credentials) {
136
+ resolve(null);
137
+ return;
138
+ }
139
+ const url = new url_1.URL(`${this.baseUrl}/api/business/burial/attachment/view`);
140
+ url.searchParams.set("uuid", uuid);
141
+ url.searchParams.set("u", this.credentials.uid);
142
+ const mod = url.protocol === "https:" ? https_1.default : http_1.default;
143
+ const options = {
144
+ timeout: 30000,
145
+ headers: {
146
+ "x-token": this.credentials.token,
147
+ "Accept": "*/*",
148
+ },
149
+ };
150
+ const req = mod.get(url.toString(), options, (res) => {
151
+ if (res.statusCode !== 200) {
152
+ resolve(null);
153
+ return;
154
+ }
155
+ const chunks = [];
156
+ res.on("data", (chunk) => chunks.push(Buffer.from(chunk)));
157
+ res.on("end", () => {
158
+ const buffer = Buffer.concat(chunks);
159
+ if (buffer.length === 0) {
160
+ resolve(null);
161
+ return;
162
+ }
163
+ const contentType = res.headers["content-type"] || "image/png";
164
+ resolve({ base64: buffer.toString("base64"), contentType });
165
+ });
166
+ });
167
+ req.on("error", () => resolve(null));
168
+ req.on("timeout", () => { req.destroy(); resolve(null); });
169
+ });
170
+ }
171
+ // --- Business API methods ---
172
+ /** Get user nicknames list */
173
+ async getUserNickNames() {
174
+ return this.get("/api/bigdata/component/system/user/nickNames");
175
+ }
176
+ /** Get product names list */
177
+ async getProductNames() {
178
+ return this.post("/api/business/burial/product/names", {});
179
+ }
180
+ /** Get product types for a product */
181
+ async getProductTypes(productName) {
182
+ return this.post("/api/business/burial/product/types", { productName });
183
+ }
184
+ /** Get product versions */
185
+ async getProductVersions(productName, productType = "") {
186
+ return this.post("/api/business/burial/product/versions", { productName, productType });
187
+ }
188
+ /** Get tracking list (paginated) */
189
+ async getTrackingList(params) {
190
+ return this.post("/api/business/burial/develop/page/v2", {
191
+ developer: params.developer,
192
+ product: params.product,
193
+ keyWord: params.keyWord || "",
194
+ pageId: params.pageId || "",
195
+ actionId: params.actionId || "",
196
+ baseKeyword: params.baseKeyword || "",
197
+ attrKeyword: params.attrKeyword || "",
198
+ pageNum: params.pageNum || 1,
199
+ pageSize: params.pageSize || 50,
200
+ });
201
+ }
202
+ /** Get review count for a product */
203
+ async getReviewCount(product) {
204
+ return this.post("/api/business/burial/review/count", { product });
205
+ }
206
+ }
207
+ exports.TrackingClient = TrackingClient;
package/package.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "@camscanner/tracking-mcp-server",
3
+ "version": "1.0.0",
4
+ "description": "MCP Server for querying Intsig Data Studio tracking/burial point documentation",
5
+ "main": "dist/index.js",
6
+ "bin": {
7
+ "tracking-mcp-server": "./dist/index.js"
8
+ },
9
+ "files": [
10
+ "dist",
11
+ "plugins",
12
+ "README.md"
13
+ ],
14
+ "publishConfig": {
15
+ "access": "public"
16
+ },
17
+ "scripts": {
18
+ "build": "tsc",
19
+ "start": "node dist/index.js"
20
+ },
21
+ "dependencies": {
22
+ "@modelcontextprotocol/sdk": "^1.12.1",
23
+ "playwright-core": "^1.59.1",
24
+ "zod": "^4.3.6"
25
+ },
26
+ "devDependencies": {
27
+ "@types/node": "^20.11.0",
28
+ "typescript": "^5.3.0"
29
+ }
30
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "name": "tracking",
3
+ "description": "Intsig Data Studio tracking/burial point management plugin",
4
+ "version": "1.0.0",
5
+ "author": { "name": "CamScanner" }
6
+ }
@@ -0,0 +1,12 @@
1
+ {
2
+ "tracking": {
3
+ "command": "npx",
4
+ "args": ["-y", "@camscanner/tracking-mcp-server@latest"],
5
+ "env": {
6
+ "TRACKING_BASE_URL": "https://bigdata.intsig.net",
7
+ "SSO_LOGIN_URL": "https://web-sso.intsig.net/login",
8
+ "SSO_PLATFORM_ID": "FEOXFe858GRbG9aLymrNDLyApKcBWtrw",
9
+ "SSO_REDIRECT_URL": "https://ids.intsig.net/trackManage/develop"
10
+ }
11
+ }
12
+ }
@@ -0,0 +1,70 @@
1
+ # 埋点管理助手
2
+
3
+ 帮助用户通过 Tracking MCP Server 查询和管理埋点(burial point)文档。
4
+
5
+ ## 可用 MCP 工具
6
+
7
+ 来自 tracking MCP server:
8
+
9
+ 1. **tracking-auth** — SSO 登录(首次使用需要扫码认证)
10
+ 2. **tracking-logout** — 退出登录,清除凭证
11
+ 3. **list_products** — 列出所有可用产品名称
12
+ 4. **list_product_types** — 获取产品的平台类型(iOS/Android/Web 等)
13
+ 5. **list_product_versions** — 获取产品的版本列表
14
+ 6. **get_developer_name** — 获取当前登录开发者的显示名称
15
+ 7. **search_tracking** — 按开发者和产品搜索埋点列表(支持关键词、PageID、ActionID 过滤)
16
+ 8. **get_tracking_screenshot** — 获取埋点的截图/附件图片
17
+
18
+ ## 工作流程
19
+
20
+ 当用户请求查询埋点信息时,**按以下步骤执行**:
21
+
22
+ ### 第 1 步:认证检查
23
+
24
+ 如果尚未认证,先调用 `tracking-auth` 进行登录。
25
+
26
+ ### 第 2 步:获取开发者信息
27
+
28
+ 调用 `get_developer_name` 获取当前用户的显示名(nickName),后续查询需要用到。
29
+
30
+ ### 第 3 步:确定产品
31
+
32
+ - 如果用户未指定产品,使用 `list_products` 列出可用产品让用户选择
33
+ - 默认使用 "扫描全能王"
34
+
35
+ ### 第 4 步:查询埋点
36
+
37
+ 根据用户需求选择合适的工具:
38
+
39
+ - **搜索埋点列表**: 使用 `search_tracking`,提供 developer(nickName)和 product
40
+ - **按关键词搜索**: 使用 `search_tracking` 的 keyword 参数
41
+ - **按 PageID 搜索**: 使用 `search_tracking` 的 page_id 参数
42
+ - **查看埋点截图**: 使用 `get_tracking_screenshot`,提供 attchUuid
43
+
44
+ ### 第 5 步:展示结果
45
+
46
+ - 以清晰的格式展示埋点信息
47
+ - 包含 PageID、ActionID、属性参数、开发者、状态等
48
+ - 如果有截图,可以获取并展示
49
+
50
+ ## 埋点数据结构说明
51
+
52
+ 每条埋点记录包含:
53
+ - **id**: 唯一标识
54
+ - **product**: 所属产品
55
+ - **reportPlatform / devPlatform**: 上报平台 / 开发平台
56
+ - **operation**: 埋点类型 (pageview / actionlog)
57
+ - **pageId / pageIdZh**: 页面标识和中文名
58
+ - **actionId / actionIdZh**: 操作标识和中文名
59
+ - **traceId / traceIdZh**: 链路标识和中文名
60
+ - **description**: 描述信息
61
+ - **attchUuid**: 截图附件 UUID
62
+ - **attributes**: 附加属性列表,每个属性包含:
63
+ - extKey / extKeyDesc: 参数名和描述
64
+ - extValue / extValueDesc: 参数值和描述
65
+ - extKeyType: 参数类型
66
+ - tag: 需求标签
67
+ - tapd: TAPD 链接
68
+ - proVersion: 版本
69
+ - developer / tester: 开发者 / 测试者
70
+ - statusCode: 状态码 (0=草稿, 5=待审核, 10=已通过, 20=已驳回, 30=已废弃)