linktest-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/dist/index.d.ts +14 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +804 -0
- package/dist/index.js.map +1 -0
- package/package.json +29 -0
- package/src/index.ts +948 -0
- package/tsconfig.json +20 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,804 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* LinkTest MCP Server
|
|
4
|
+
*
|
|
5
|
+
* Enables AI assistants to automate Google Play closed testing.
|
|
6
|
+
* Users can place orders, check status, view screenshots, and manage billing
|
|
7
|
+
* directly through Claude or any MCP-compatible AI.
|
|
8
|
+
*
|
|
9
|
+
* Environment variables:
|
|
10
|
+
* LINKTEST_API_TOKEN — Required. Your LinkTest API token (from linktest.dev/settings)
|
|
11
|
+
* LINKTEST_API_URL — Optional. API base URL (default: http://localhost:3001)
|
|
12
|
+
*/
|
|
13
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
14
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
15
|
+
import { z } from "zod";
|
|
16
|
+
import axios, { AxiosError } from "axios";
|
|
17
|
+
import * as fs from "fs";
|
|
18
|
+
import * as path from "path";
|
|
19
|
+
import * as os from "os";
|
|
20
|
+
// ─── Constants ───────────────────────────────────────────────────────────────
|
|
21
|
+
const CHARACTER_LIMIT = 25000;
|
|
22
|
+
// ─── API Client ──────────────────────────────────────────────────────────────
|
|
23
|
+
function getApiClient() {
|
|
24
|
+
const token = process.env.LINKTEST_API_TOKEN;
|
|
25
|
+
const baseURL = process.env.LINKTEST_API_URL || "https://api-p2mrc3yq4a-du.a.run.app";
|
|
26
|
+
if (!token) {
|
|
27
|
+
throw new Error("LINKTEST_API_TOKEN environment variable is not set. " +
|
|
28
|
+
"Get your token from https://linktest.dev/settings");
|
|
29
|
+
}
|
|
30
|
+
return axios.create({
|
|
31
|
+
baseURL,
|
|
32
|
+
timeout: 30000,
|
|
33
|
+
headers: {
|
|
34
|
+
"Content-Type": "application/json",
|
|
35
|
+
"Authorization": `Bearer ${token}`,
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
async function apiRequest(method, path, data, params) {
|
|
40
|
+
const client = getApiClient();
|
|
41
|
+
const response = await client.request({ method, url: path, data, params });
|
|
42
|
+
return response.data;
|
|
43
|
+
}
|
|
44
|
+
// ─── Error Handling ───────────────────────────────────────────────────────────
|
|
45
|
+
function handleError(error) {
|
|
46
|
+
if (error instanceof AxiosError) {
|
|
47
|
+
if (error.response) {
|
|
48
|
+
const status = error.response.status;
|
|
49
|
+
const msg = error.response.data?.error;
|
|
50
|
+
switch (status) {
|
|
51
|
+
case 400:
|
|
52
|
+
return `Error: Bad request — ${msg || "check your input parameters"}`;
|
|
53
|
+
case 401:
|
|
54
|
+
return "Error: Invalid API token. Get a new one at https://linktest.dev/settings";
|
|
55
|
+
case 403:
|
|
56
|
+
return "Error: Access denied. This resource belongs to another account.";
|
|
57
|
+
case 404:
|
|
58
|
+
return `Error: Not found — ${msg || "the requested resource does not exist"}`;
|
|
59
|
+
case 409:
|
|
60
|
+
return `Error: Conflict — ${msg || "an order for this app may already exist"}`;
|
|
61
|
+
case 429:
|
|
62
|
+
return "Error: Rate limit exceeded. Please wait a moment and try again.";
|
|
63
|
+
default:
|
|
64
|
+
return `Error: API error (${status}) — ${msg || "unexpected error"}`;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
else if (error.code === "ECONNABORTED") {
|
|
68
|
+
return "Error: Request timed out. Check your internet connection and try again.";
|
|
69
|
+
}
|
|
70
|
+
else if (error.code === "ECONNREFUSED") {
|
|
71
|
+
return "Error: Cannot connect to LinkTest API. The service may be temporarily unavailable.";
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
if (error instanceof Error && error.message.startsWith("LINKTEST_API_TOKEN")) {
|
|
75
|
+
return `Error: ${error.message}`;
|
|
76
|
+
}
|
|
77
|
+
return `Error: Unexpected error — ${error instanceof Error ? error.message : String(error)}`;
|
|
78
|
+
}
|
|
79
|
+
// ─── Formatters ───────────────────────────────────────────────────────────────
|
|
80
|
+
const STATUS_LABELS = {
|
|
81
|
+
pending: "Pending Payment",
|
|
82
|
+
active: "Testing in Progress",
|
|
83
|
+
completed: "Completed",
|
|
84
|
+
expired: "Expired",
|
|
85
|
+
payment_failed: "Payment Failed",
|
|
86
|
+
failed: "Failed — Tester Not Registered",
|
|
87
|
+
};
|
|
88
|
+
function formatDate(iso) {
|
|
89
|
+
return new Date(iso).toLocaleDateString("en-US", {
|
|
90
|
+
year: "numeric", month: "short", day: "numeric",
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
function formatOrder(order) {
|
|
94
|
+
const status = STATUS_LABELS[order.status] ?? String(order.status);
|
|
95
|
+
const lines = [
|
|
96
|
+
`**Package**: ${order.packageName}`,
|
|
97
|
+
`**Status**: ${status}`,
|
|
98
|
+
`**Order ID**: ${order.id}`,
|
|
99
|
+
];
|
|
100
|
+
if (order.failReason) {
|
|
101
|
+
lines.push(`**Fail Reason**: ${order.failReason === "tester_not_registered"
|
|
102
|
+
? "Tester emails not registered — add LinkTest emails to your closed testing track"
|
|
103
|
+
: String(order.failReason)}`);
|
|
104
|
+
}
|
|
105
|
+
if (order.installedCount !== undefined) {
|
|
106
|
+
lines.push(`**Devices**: ${order.installedCount} installed`);
|
|
107
|
+
}
|
|
108
|
+
if (order.daysLeft !== undefined && order.status === "active") {
|
|
109
|
+
lines.push(`**Days Left**: ${order.daysLeft} days remaining`);
|
|
110
|
+
}
|
|
111
|
+
if (order.startDate)
|
|
112
|
+
lines.push(`**Started**: ${formatDate(String(order.startDate))}`);
|
|
113
|
+
if (order.expireDate)
|
|
114
|
+
lines.push(`**Expires**: ${formatDate(String(order.expireDate))}`);
|
|
115
|
+
if (order.optInLink)
|
|
116
|
+
lines.push(`**Opt-in Link**: ${order.optInLink}`);
|
|
117
|
+
return lines.join("\n");
|
|
118
|
+
}
|
|
119
|
+
// ─── MCP Server ───────────────────────────────────────────────────────────────
|
|
120
|
+
const server = new McpServer({
|
|
121
|
+
name: "linktest-mcp-server",
|
|
122
|
+
version: "1.0.0",
|
|
123
|
+
});
|
|
124
|
+
// ─── Tool: linktest_validate ──────────────────────────────────────────────────
|
|
125
|
+
server.registerTool("linktest_validate", {
|
|
126
|
+
title: "Validate Opt-in Link",
|
|
127
|
+
description: `Validate a Google Play closed testing opt-in link before placing an order.
|
|
128
|
+
|
|
129
|
+
Checks that the link is correctly formatted and accessible on the Play Store.
|
|
130
|
+
Always validate the link first before calling linktest_order to catch issues early.
|
|
131
|
+
|
|
132
|
+
Args:
|
|
133
|
+
- opt_in_link (string): The closed testing opt-in URL from Google Play Console.
|
|
134
|
+
Format: https://play.google.com/apps/testing/{packageName}
|
|
135
|
+
- package_name (string): The app's package name (e.g., com.example.myapp)
|
|
136
|
+
|
|
137
|
+
Returns:
|
|
138
|
+
A message indicating whether the link is valid or the reason it failed.
|
|
139
|
+
|
|
140
|
+
Examples:
|
|
141
|
+
- Use when: User provides a Play Store testing link and wants to check it's correct
|
|
142
|
+
- Use when: Before calling linktest_order to ensure the order will succeed
|
|
143
|
+
|
|
144
|
+
Error Handling:
|
|
145
|
+
- "invalid_format": Link URL doesn't match expected Play Store pattern
|
|
146
|
+
- "package_mismatch": Package name in URL doesn't match provided package_name
|
|
147
|
+
- "not_accessible": Play Store returned an error for this link`,
|
|
148
|
+
inputSchema: z.object({
|
|
149
|
+
opt_in_link: z.string()
|
|
150
|
+
.url("Must be a valid URL")
|
|
151
|
+
.describe("Google Play closed testing opt-in URL (e.g. https://play.google.com/apps/testing/com.example.app)"),
|
|
152
|
+
package_name: z.string()
|
|
153
|
+
.min(3)
|
|
154
|
+
.regex(/^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$/, "Must be a valid Android package name")
|
|
155
|
+
.describe("App package name (e.g. com.example.myapp)"),
|
|
156
|
+
}).strict(),
|
|
157
|
+
annotations: {
|
|
158
|
+
readOnlyHint: true,
|
|
159
|
+
destructiveHint: false,
|
|
160
|
+
idempotentHint: true,
|
|
161
|
+
openWorldHint: true,
|
|
162
|
+
},
|
|
163
|
+
}, async (params) => {
|
|
164
|
+
try {
|
|
165
|
+
const data = await apiRequest("POST", "/api/validate/optin-link", { optInLink: params.opt_in_link, packageName: params.package_name });
|
|
166
|
+
if (data.valid) {
|
|
167
|
+
return {
|
|
168
|
+
content: [{
|
|
169
|
+
type: "text",
|
|
170
|
+
text: `✅ Valid! ${data.reason || "Opt-in link is accessible and correctly formatted."}\n\nYou can now place an order with linktest_order.`,
|
|
171
|
+
}],
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
else {
|
|
175
|
+
return {
|
|
176
|
+
content: [{
|
|
177
|
+
type: "text",
|
|
178
|
+
text: `❌ Invalid opt-in link: ${data.reason}\n\nFix the link in Google Play Console → Testing → Closed Testing → Copy link`,
|
|
179
|
+
}],
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
catch (error) {
|
|
184
|
+
return { content: [{ type: "text", text: handleError(error) }] };
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
// ─── Tool: linktest_verify ───────────────────────────────────────────────────
|
|
188
|
+
server.registerTool("linktest_verify", {
|
|
189
|
+
title: "Verify Tester Access",
|
|
190
|
+
description: `Verify that LinkTest devices can actually access and install the user's app.
|
|
191
|
+
|
|
192
|
+
This triggers a real device to attempt accessing the app on Google Play Store.
|
|
193
|
+
It checks whether the tester emails are properly registered in the user's closed testing track.
|
|
194
|
+
|
|
195
|
+
IMPORTANT: After calling this tool, wait 30 seconds, then call it again with the verify_id
|
|
196
|
+
to check the result. Do NOT proceed with linktest_order until verification passes.
|
|
197
|
+
|
|
198
|
+
Workflow:
|
|
199
|
+
1. Call with package_name + opt_in_link → returns verify_id (status: pending)
|
|
200
|
+
2. Wait 30 seconds
|
|
201
|
+
3. Call with verify_id → returns result (passed or failed)
|
|
202
|
+
4. If passed → proceed with linktest_order
|
|
203
|
+
5. If failed → tell user to check tester registration in Play Console
|
|
204
|
+
|
|
205
|
+
Args (to start verification):
|
|
206
|
+
- package_name (string): Android package name
|
|
207
|
+
- opt_in_link (string): Google Play closed testing opt-in URL
|
|
208
|
+
|
|
209
|
+
Args (to check result):
|
|
210
|
+
- verify_id (string): The verification ID returned from step 1`,
|
|
211
|
+
inputSchema: z.object({
|
|
212
|
+
package_name: z.string()
|
|
213
|
+
.optional()
|
|
214
|
+
.describe("Android package name (for starting verification)"),
|
|
215
|
+
opt_in_link: z.string()
|
|
216
|
+
.optional()
|
|
217
|
+
.describe("Google Play closed testing opt-in URL (for starting verification)"),
|
|
218
|
+
verify_id: z.string()
|
|
219
|
+
.optional()
|
|
220
|
+
.describe("Verification ID to check result (from previous call)"),
|
|
221
|
+
}).strict(),
|
|
222
|
+
annotations: {
|
|
223
|
+
readOnlyHint: false,
|
|
224
|
+
destructiveHint: false,
|
|
225
|
+
idempotentHint: false,
|
|
226
|
+
openWorldHint: true,
|
|
227
|
+
},
|
|
228
|
+
}, async (params) => {
|
|
229
|
+
try {
|
|
230
|
+
// 결과 확인 모드
|
|
231
|
+
if (params.verify_id) {
|
|
232
|
+
const data = await apiRequest("GET", `/api/verify/${params.verify_id}`);
|
|
233
|
+
if (data.status === "pending") {
|
|
234
|
+
return {
|
|
235
|
+
content: [{
|
|
236
|
+
type: "text",
|
|
237
|
+
text: `⏳ Verification still in progress (ID: ${data.verifyId}).\n\nPlease wait another 15-30 seconds and check again.`,
|
|
238
|
+
}],
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
if (data.status === "passed") {
|
|
242
|
+
return {
|
|
243
|
+
content: [{
|
|
244
|
+
type: "text",
|
|
245
|
+
text: `✅ **Verification passed!** Tester access confirmed for \`${data.packageName}\`.\n\n` +
|
|
246
|
+
`Devices can install this app. You can now proceed with linktest_order.`,
|
|
247
|
+
}],
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
// failed
|
|
251
|
+
const reasonMsg = data.failReason === "tester_not_registered"
|
|
252
|
+
? "Tester emails are NOT registered in your closed testing track."
|
|
253
|
+
: `Reason: ${data.failReason}`;
|
|
254
|
+
return {
|
|
255
|
+
content: [{
|
|
256
|
+
type: "text",
|
|
257
|
+
text: `❌ **Verification failed** for \`${data.packageName}\`.\n\n${reasonMsg}\n\n` +
|
|
258
|
+
`**How to fix:**\n` +
|
|
259
|
+
`1. Open Google Play Console → your app\n` +
|
|
260
|
+
`2. Testing → Closed Testing → Manage track\n` +
|
|
261
|
+
`3. "Testers" tab → Add the "LinkTest" email list\n` +
|
|
262
|
+
`4. Click "Save changes" → "Send changes for review"\n\n` +
|
|
263
|
+
`After fixing, run linktest_verify again to re-check.`,
|
|
264
|
+
}],
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
// 새 검증 요청 모드
|
|
268
|
+
if (!params.package_name || !params.opt_in_link) {
|
|
269
|
+
return {
|
|
270
|
+
content: [{
|
|
271
|
+
type: "text",
|
|
272
|
+
text: "Error: package_name and opt_in_link are required to start verification.",
|
|
273
|
+
}],
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
const data = await apiRequest("POST", "/api/verify/tester-access", { packageName: params.package_name, optInLink: params.opt_in_link });
|
|
277
|
+
return {
|
|
278
|
+
content: [{
|
|
279
|
+
type: "text",
|
|
280
|
+
text: `🔍 **Verification started** (ID: ${data.verifyId})\n\n` +
|
|
281
|
+
`A real device is now checking if it can access \`${params.package_name}\` on Google Play.\n\n` +
|
|
282
|
+
`⏳ **Wait 30 seconds**, then call:\n` +
|
|
283
|
+
`linktest_verify(verify_id="${data.verifyId}")`,
|
|
284
|
+
}],
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
catch (error) {
|
|
288
|
+
return { content: [{ type: "text", text: handleError(error) }] };
|
|
289
|
+
}
|
|
290
|
+
});
|
|
291
|
+
// ─── Tool: linktest_order ─────────────────────────────────────────────────────
|
|
292
|
+
server.registerTool("linktest_order", {
|
|
293
|
+
title: "Place a Closed Testing Order",
|
|
294
|
+
description: `Place a new closed testing order for a Google Play app.
|
|
295
|
+
|
|
296
|
+
This creates an order and returns a payment checkout URL. The user must complete
|
|
297
|
+
the $5 payment before testing begins. After payment, LinkTest installs the app
|
|
298
|
+
on 12+ real Android devices and runs it daily for 14+ days.
|
|
299
|
+
|
|
300
|
+
IMPORTANT — Before placing an order, you MUST follow this workflow:
|
|
301
|
+
|
|
302
|
+
Step 1. Call linktest_testers to get the email list.
|
|
303
|
+
- Confirm the user has registered those emails in Play Console.
|
|
304
|
+
- Confirm the user has a closed testing release deployed.
|
|
305
|
+
- Confirm the user has clicked "변경사항 전송" (Send changes) in Play Console.
|
|
306
|
+
- Only AFTER all three are done does the opt-in link become available.
|
|
307
|
+
- If any step is missing → guide them to complete it before asking for the link.
|
|
308
|
+
|
|
309
|
+
Step 2. Call linktest_validate to check the opt-in link format.
|
|
310
|
+
|
|
311
|
+
Step 3. Call linktest_verify to confirm devices can actually install the app.
|
|
312
|
+
- Wait 30 seconds, then check the result.
|
|
313
|
+
- If failed → tell user to fix tester registration. Do NOT proceed.
|
|
314
|
+
|
|
315
|
+
Step 4. Only after verification passes, call this tool to create the order.
|
|
316
|
+
|
|
317
|
+
Step 5. Share the checkout_url with the user so they can complete payment.
|
|
318
|
+
|
|
319
|
+
Step 6. After payment, call linktest_status to monitor progress.
|
|
320
|
+
|
|
321
|
+
Args:
|
|
322
|
+
- package_name (string): Android package name (e.g., com.example.myapp)
|
|
323
|
+
- opt_in_link (string): Google Play closed testing opt-in URL
|
|
324
|
+
|
|
325
|
+
Returns (JSON):
|
|
326
|
+
{
|
|
327
|
+
"order_id": string, // Unique order ID for tracking
|
|
328
|
+
"status": "pending", // Initial status (awaiting payment)
|
|
329
|
+
"checkout_url": string, // Paddle payment URL — share with user
|
|
330
|
+
"message": string // Human-readable next step
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
Examples:
|
|
334
|
+
- Use when: User says "start closed testing for my app"
|
|
335
|
+
- Use when: User provides a package name and wants to begin testing
|
|
336
|
+
- Don't use when: An active order already exists for the same package (check with linktest_status)
|
|
337
|
+
|
|
338
|
+
Error Handling:
|
|
339
|
+
- 409 Conflict: An order for this package may already exist — check with linktest_status`,
|
|
340
|
+
inputSchema: z.object({
|
|
341
|
+
package_name: z.string()
|
|
342
|
+
.min(3)
|
|
343
|
+
.regex(/^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$/, "Must be a valid Android package name")
|
|
344
|
+
.describe("Android package name (e.g., com.example.myapp)"),
|
|
345
|
+
opt_in_link: z.string()
|
|
346
|
+
.url("Must be a valid URL")
|
|
347
|
+
.describe("Google Play closed testing opt-in URL (from Google Play Console → Testing → Closed Testing)"),
|
|
348
|
+
}).strict(),
|
|
349
|
+
annotations: {
|
|
350
|
+
readOnlyHint: false,
|
|
351
|
+
destructiveHint: false,
|
|
352
|
+
idempotentHint: false,
|
|
353
|
+
openWorldHint: true,
|
|
354
|
+
},
|
|
355
|
+
}, async (params) => {
|
|
356
|
+
try {
|
|
357
|
+
// Step 1: Create order
|
|
358
|
+
const order = await apiRequest("POST", "/api/orders", { packageName: params.package_name, optInLink: params.opt_in_link });
|
|
359
|
+
// Step 2: Create checkout session
|
|
360
|
+
const checkout = await apiRequest("POST", "/api/payment/checkout", { orderId: order.orderId });
|
|
361
|
+
const result = {
|
|
362
|
+
order_id: order.orderId,
|
|
363
|
+
status: "pending",
|
|
364
|
+
checkout_url: checkout.checkoutUrl,
|
|
365
|
+
message: "Order created! Complete the $5 payment to start testing.",
|
|
366
|
+
};
|
|
367
|
+
return {
|
|
368
|
+
content: [{
|
|
369
|
+
type: "text",
|
|
370
|
+
text: `## Order Created ✅\n\n` +
|
|
371
|
+
`**Order ID**: ${result.order_id}\n` +
|
|
372
|
+
`**Package**: ${params.package_name}\n` +
|
|
373
|
+
`**Status**: Pending Payment\n\n` +
|
|
374
|
+
`💳 **Payment required**: ${checkout.checkoutUrl}\n\n` +
|
|
375
|
+
`After payment, testing begins automatically on 12+ real Android devices.\n` +
|
|
376
|
+
`Check progress with: linktest_status(order_id="${result.order_id}")`,
|
|
377
|
+
}],
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
catch (error) {
|
|
381
|
+
return { content: [{ type: "text", text: handleError(error) }] };
|
|
382
|
+
}
|
|
383
|
+
});
|
|
384
|
+
// ─── Tool: linktest_status ────────────────────────────────────────────────────
|
|
385
|
+
server.registerTool("linktest_status", {
|
|
386
|
+
title: "Check Order Status",
|
|
387
|
+
description: `Check the status of one or all LinkTest orders.
|
|
388
|
+
|
|
389
|
+
If order_id is provided, returns detailed info about that specific order.
|
|
390
|
+
If omitted, returns a summary list of all orders.
|
|
391
|
+
|
|
392
|
+
Args:
|
|
393
|
+
- order_id (string, optional): Specific order ID to look up. If omitted, lists all orders.
|
|
394
|
+
|
|
395
|
+
Returns (with order_id):
|
|
396
|
+
Detailed order info including:
|
|
397
|
+
- Package name, status, days remaining
|
|
398
|
+
- Number of devices installed (out of 20)
|
|
399
|
+
- Start date, expiry date
|
|
400
|
+
- Opt-in link
|
|
401
|
+
|
|
402
|
+
Returns (without order_id):
|
|
403
|
+
List of all orders with:
|
|
404
|
+
- Order ID, package name, status, days left, device count
|
|
405
|
+
|
|
406
|
+
Status values:
|
|
407
|
+
- pending: Awaiting payment
|
|
408
|
+
- active: Testing in progress on devices
|
|
409
|
+
- completed: Test period finished
|
|
410
|
+
- expired: Expired without completing
|
|
411
|
+
- payment_failed: Payment was not completed
|
|
412
|
+
- failed: Tester emails not registered — devices could not install the app
|
|
413
|
+
|
|
414
|
+
Examples:
|
|
415
|
+
- Use when: "How's my testing going?"
|
|
416
|
+
- Use when: "Check status for com.example.app"
|
|
417
|
+
- Use when: "Show all my orders"`,
|
|
418
|
+
inputSchema: z.object({
|
|
419
|
+
order_id: z.string()
|
|
420
|
+
.optional()
|
|
421
|
+
.describe("Specific order ID to look up. Omit to list all orders."),
|
|
422
|
+
}).strict(),
|
|
423
|
+
annotations: {
|
|
424
|
+
readOnlyHint: true,
|
|
425
|
+
destructiveHint: false,
|
|
426
|
+
idempotentHint: true,
|
|
427
|
+
openWorldHint: true,
|
|
428
|
+
},
|
|
429
|
+
}, async (params) => {
|
|
430
|
+
try {
|
|
431
|
+
if (params.order_id) {
|
|
432
|
+
// Single order
|
|
433
|
+
const data = await apiRequest("GET", `/api/orders/${params.order_id}`);
|
|
434
|
+
const order = data.order;
|
|
435
|
+
const status = String(order.status);
|
|
436
|
+
let text = `## Order Status\n\n${formatOrder(order)}`;
|
|
437
|
+
if (status === "active") {
|
|
438
|
+
const installed = Number(order.installedCount ?? 0);
|
|
439
|
+
const pct = Math.round((installed / 20) * 100);
|
|
440
|
+
const bar = "█".repeat(Math.floor(pct / 5)) + "░".repeat(20 - Math.floor(pct / 5));
|
|
441
|
+
text += `\n\n**Install Progress**: [${bar}] ${pct}%`;
|
|
442
|
+
}
|
|
443
|
+
if (status === "pending") {
|
|
444
|
+
text += `\n\n⚠️ Payment is required to start testing. Use the checkout URL from your order.`;
|
|
445
|
+
}
|
|
446
|
+
if (status === "failed") {
|
|
447
|
+
text += `\n\n❌ **Testing failed**: Our devices could not access your app.\n` +
|
|
448
|
+
`Please check that all LinkTest tester emails are registered in your Google Play Console:\n` +
|
|
449
|
+
`Play Console → Testing → Closed Testing → Testers → Add LinkTest email list\n\n` +
|
|
450
|
+
`After fixing, create a new order with linktest_order.`;
|
|
451
|
+
}
|
|
452
|
+
if (status === "active" && Number(order.daysLeft) <= 2) {
|
|
453
|
+
text += `\n\n⚠️ Testing ends soon! Only ${order.daysLeft} days remaining.`;
|
|
454
|
+
}
|
|
455
|
+
return { content: [{ type: "text", text }] };
|
|
456
|
+
}
|
|
457
|
+
else {
|
|
458
|
+
// All orders
|
|
459
|
+
const data = await apiRequest("GET", "/api/orders");
|
|
460
|
+
const orders = data.orders ?? [];
|
|
461
|
+
if (orders.length === 0) {
|
|
462
|
+
return {
|
|
463
|
+
content: [{
|
|
464
|
+
type: "text",
|
|
465
|
+
text: "No orders yet.\n\nStart your first test with linktest_order.",
|
|
466
|
+
}],
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
const lines = [`## My LinkTest Orders (${orders.length} total)\n`];
|
|
470
|
+
for (const order of orders) {
|
|
471
|
+
const status = STATUS_LABELS[String(order.status)] ?? String(order.status);
|
|
472
|
+
lines.push(`### ${order.packageName}`);
|
|
473
|
+
lines.push(`- **Status**: ${status}`);
|
|
474
|
+
lines.push(`- **ID**: ${order.id}`);
|
|
475
|
+
if (order.status === "active") {
|
|
476
|
+
lines.push(`- **Devices**: ${order.installedCount} · ${order.daysLeft} days left`);
|
|
477
|
+
}
|
|
478
|
+
lines.push("");
|
|
479
|
+
}
|
|
480
|
+
const result = lines.join("\n");
|
|
481
|
+
return {
|
|
482
|
+
content: [{
|
|
483
|
+
type: "text",
|
|
484
|
+
text: result.length > CHARACTER_LIMIT
|
|
485
|
+
? result.slice(0, CHARACTER_LIMIT) + "\n\n[Response truncated. Use order_id to get details for a specific order.]"
|
|
486
|
+
: result,
|
|
487
|
+
}],
|
|
488
|
+
};
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
catch (error) {
|
|
492
|
+
return { content: [{ type: "text", text: handleError(error) }] };
|
|
493
|
+
}
|
|
494
|
+
});
|
|
495
|
+
// ─── Tool: linktest_screenshots ───────────────────────────────────────────────
|
|
496
|
+
server.registerTool("linktest_screenshots", {
|
|
497
|
+
title: "Get Order Screenshots",
|
|
498
|
+
description: `Retrieve screenshot URLs taken by LinkTest devices for an order.
|
|
499
|
+
|
|
500
|
+
Screenshots are captured daily from each Android device running the app.
|
|
501
|
+
Returns a list of URLs that can be opened in a browser to verify the app is running correctly.
|
|
502
|
+
|
|
503
|
+
Args:
|
|
504
|
+
- order_id (string): The order ID to get screenshots for
|
|
505
|
+
|
|
506
|
+
Returns:
|
|
507
|
+
List of screenshots with:
|
|
508
|
+
- device_id: Which device took the screenshot
|
|
509
|
+
- url: Direct URL to the screenshot image
|
|
510
|
+
- taken_at: When the screenshot was captured
|
|
511
|
+
|
|
512
|
+
Note: Screenshots are only available for active or completed orders.
|
|
513
|
+
Pending or expired orders will have no screenshots.
|
|
514
|
+
|
|
515
|
+
Examples:
|
|
516
|
+
- Use when: "Show me screenshots from my test"
|
|
517
|
+
- Use when: "Is my app running correctly on the devices?"`,
|
|
518
|
+
inputSchema: z.object({
|
|
519
|
+
order_id: z.string()
|
|
520
|
+
.min(1, "Order ID is required")
|
|
521
|
+
.describe("The order ID to fetch screenshots for"),
|
|
522
|
+
}).strict(),
|
|
523
|
+
annotations: {
|
|
524
|
+
readOnlyHint: true,
|
|
525
|
+
destructiveHint: false,
|
|
526
|
+
idempotentHint: true,
|
|
527
|
+
openWorldHint: true,
|
|
528
|
+
},
|
|
529
|
+
}, async (params) => {
|
|
530
|
+
try {
|
|
531
|
+
const data = await apiRequest("GET", `/api/orders/${params.order_id}/screenshots`);
|
|
532
|
+
const screenshots = data.screenshots ?? [];
|
|
533
|
+
if (screenshots.length === 0) {
|
|
534
|
+
return {
|
|
535
|
+
content: [{
|
|
536
|
+
type: "text",
|
|
537
|
+
text: "No screenshots yet for this order.\n\nScreenshots appear after the first daily run (within 24 hours of payment).",
|
|
538
|
+
}],
|
|
539
|
+
};
|
|
540
|
+
}
|
|
541
|
+
const lines = [
|
|
542
|
+
`## Screenshots for Order ${params.order_id}`,
|
|
543
|
+
`${screenshots.length} screenshot(s) found\n`,
|
|
544
|
+
];
|
|
545
|
+
for (const ss of screenshots) {
|
|
546
|
+
const date = new Date(ss.takenAt).toLocaleString("en-US", {
|
|
547
|
+
month: "short", day: "numeric", hour: "2-digit", minute: "2-digit",
|
|
548
|
+
});
|
|
549
|
+
lines.push(`**Device ${ss.deviceId}** — ${date}`);
|
|
550
|
+
lines.push(ss.url);
|
|
551
|
+
lines.push("");
|
|
552
|
+
}
|
|
553
|
+
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
554
|
+
}
|
|
555
|
+
catch (error) {
|
|
556
|
+
return { content: [{ type: "text", text: handleError(error) }] };
|
|
557
|
+
}
|
|
558
|
+
});
|
|
559
|
+
// ─── Tool: linktest_cancel ────────────────────────────────────────────────────
|
|
560
|
+
server.registerTool("linktest_cancel", {
|
|
561
|
+
title: "Cancel or Complete an Order",
|
|
562
|
+
description: `Cancel a pending order or mark a completed order as done.
|
|
563
|
+
|
|
564
|
+
Only orders in 'pending' or 'payment_failed' status can be cancelled.
|
|
565
|
+
Active testing orders cannot be cancelled (testing continues until the period ends).
|
|
566
|
+
|
|
567
|
+
Args:
|
|
568
|
+
- order_id (string): The order ID to cancel
|
|
569
|
+
|
|
570
|
+
Returns:
|
|
571
|
+
Confirmation message or error if the order cannot be cancelled.
|
|
572
|
+
|
|
573
|
+
When to use:
|
|
574
|
+
- User placed an order by mistake and hasn't paid yet
|
|
575
|
+
- User wants to clean up a failed payment order
|
|
576
|
+
- User wants to manually mark an order as completed
|
|
577
|
+
|
|
578
|
+
When NOT to use:
|
|
579
|
+
- Order is 'active' — testing is already running, cannot stop it
|
|
580
|
+
- Order is already 'completed' or 'expired'`,
|
|
581
|
+
inputSchema: z.object({
|
|
582
|
+
order_id: z.string()
|
|
583
|
+
.min(1, "Order ID is required")
|
|
584
|
+
.describe("The order ID to cancel"),
|
|
585
|
+
}).strict(),
|
|
586
|
+
annotations: {
|
|
587
|
+
readOnlyHint: false,
|
|
588
|
+
destructiveHint: true,
|
|
589
|
+
idempotentHint: false,
|
|
590
|
+
openWorldHint: true,
|
|
591
|
+
},
|
|
592
|
+
}, async (params) => {
|
|
593
|
+
try {
|
|
594
|
+
await apiRequest("DELETE", `/api/orders/${params.order_id}`);
|
|
595
|
+
return {
|
|
596
|
+
content: [{
|
|
597
|
+
type: "text",
|
|
598
|
+
text: `✅ Order ${params.order_id} has been cancelled successfully.`,
|
|
599
|
+
}],
|
|
600
|
+
};
|
|
601
|
+
}
|
|
602
|
+
catch (error) {
|
|
603
|
+
return { content: [{ type: "text", text: handleError(error) }] };
|
|
604
|
+
}
|
|
605
|
+
});
|
|
606
|
+
// ─── Tool: linktest_billing ───────────────────────────────────────────────────
|
|
607
|
+
server.registerTool("linktest_billing", {
|
|
608
|
+
title: "Get Billing History",
|
|
609
|
+
description: `View payment history and get receipt details.
|
|
610
|
+
|
|
611
|
+
Returns a list of all payments made, including amounts, dates, and order associations.
|
|
612
|
+
Optionally provide a payment_id to get a specific receipt.
|
|
613
|
+
|
|
614
|
+
Args:
|
|
615
|
+
- payment_id (string, optional): Specific payment ID for receipt details. Omit for full history.
|
|
616
|
+
|
|
617
|
+
Returns (without payment_id):
|
|
618
|
+
{
|
|
619
|
+
"total_spent": string, // Total amount spent (formatted, e.g. "$15.00")
|
|
620
|
+
"total_orders": number, // Number of completed payments
|
|
621
|
+
"payments": [
|
|
622
|
+
{
|
|
623
|
+
"id": string, // Payment ID
|
|
624
|
+
"order_id": string, // Associated order
|
|
625
|
+
"amount": string, // Formatted amount (e.g. "$5.00")
|
|
626
|
+
"date": string, // Payment date
|
|
627
|
+
"status": string // "completed" or "failed"
|
|
628
|
+
}
|
|
629
|
+
]
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
Returns (with payment_id):
|
|
633
|
+
Detailed receipt info including transaction ID for tax/accounting purposes.
|
|
634
|
+
|
|
635
|
+
Examples:
|
|
636
|
+
- Use when: "How much have I spent on LinkTest?"
|
|
637
|
+
- Use when: "Show me my payment history"
|
|
638
|
+
- Use when: "Get a receipt for my last payment"`,
|
|
639
|
+
inputSchema: z.object({
|
|
640
|
+
payment_id: z.string()
|
|
641
|
+
.optional()
|
|
642
|
+
.describe("Specific payment ID for receipt lookup. Omit to get full billing history."),
|
|
643
|
+
}).strict(),
|
|
644
|
+
annotations: {
|
|
645
|
+
readOnlyHint: true,
|
|
646
|
+
destructiveHint: false,
|
|
647
|
+
idempotentHint: true,
|
|
648
|
+
openWorldHint: true,
|
|
649
|
+
},
|
|
650
|
+
}, async (params) => {
|
|
651
|
+
try {
|
|
652
|
+
if (params.payment_id) {
|
|
653
|
+
const data = await apiRequest("GET", `/api/billing/${params.payment_id}`);
|
|
654
|
+
const receipt = data.receipt ?? data;
|
|
655
|
+
const r = receipt;
|
|
656
|
+
const lines = [
|
|
657
|
+
`## Receipt`,
|
|
658
|
+
`**Payment ID**: ${r.paymentId ?? params.payment_id}`,
|
|
659
|
+
`**Amount**: $${Number(r.amount ?? 0).toFixed(2)} ${r.currency ?? "USD"}`,
|
|
660
|
+
`**Date**: ${formatDate(String(r.createdAt ?? ""))}`,
|
|
661
|
+
`**Status**: ${r.status}`,
|
|
662
|
+
];
|
|
663
|
+
if (r.orderId)
|
|
664
|
+
lines.push(`**Order ID**: ${r.orderId}`);
|
|
665
|
+
if (r.paddleTransactionId)
|
|
666
|
+
lines.push(`**Transaction ID**: ${r.paddleTransactionId}`);
|
|
667
|
+
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
668
|
+
}
|
|
669
|
+
else {
|
|
670
|
+
const data = await apiRequest("GET", "/api/billing");
|
|
671
|
+
const payments = data.payments ?? [];
|
|
672
|
+
const completed = payments.filter(p => p.status === "completed");
|
|
673
|
+
const totalSpent = completed.reduce((sum, p) => sum + p.amount, 0);
|
|
674
|
+
if (payments.length === 0) {
|
|
675
|
+
return {
|
|
676
|
+
content: [{
|
|
677
|
+
type: "text",
|
|
678
|
+
text: "No payment history yet.\n\nPlace an order with linktest_order to get started.",
|
|
679
|
+
}],
|
|
680
|
+
};
|
|
681
|
+
}
|
|
682
|
+
const lines = [
|
|
683
|
+
`## Billing History`,
|
|
684
|
+
`**Total Spent**: $${totalSpent.toFixed(2)} · **Orders**: ${completed.length}\n`,
|
|
685
|
+
];
|
|
686
|
+
for (const p of payments) {
|
|
687
|
+
const date = formatDate(p.createdAt);
|
|
688
|
+
const status = p.status === "completed" ? "✅" : "❌";
|
|
689
|
+
lines.push(`${status} **$${p.amount.toFixed(2)}** — ${date} (Order: ${p.orderId.slice(-8)})`);
|
|
690
|
+
}
|
|
691
|
+
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
catch (error) {
|
|
695
|
+
return { content: [{ type: "text", text: handleError(error) }] };
|
|
696
|
+
}
|
|
697
|
+
});
|
|
698
|
+
// ─── Tool: linktest_testers ───────────────────────────────────────────────────
|
|
699
|
+
server.registerTool("linktest_testers", {
|
|
700
|
+
title: "Get Tester Email List",
|
|
701
|
+
description: `Get the LinkTest tester email list for Google Play closed testing registration.
|
|
702
|
+
|
|
703
|
+
**ALWAYS call this tool FIRST before anything else** — registering these emails
|
|
704
|
+
in Google Play Console is a required prerequisite before an opt-in link even exists.
|
|
705
|
+
|
|
706
|
+
The workflow is:
|
|
707
|
+
1. Call THIS TOOL → get the tester email list
|
|
708
|
+
2. User registers the emails in Play Console (see instructions below)
|
|
709
|
+
3. User deploys a closed testing release in Play Console
|
|
710
|
+
4. User sends changes ("변경사항 전송") in Play Console
|
|
711
|
+
5. ONLY THEN does the opt-in link become available
|
|
712
|
+
6. User copies opt-in link → call linktest_order
|
|
713
|
+
|
|
714
|
+
If a user asks "what should I do first?" or "how do I start?" or "링크테스트 시작",
|
|
715
|
+
ALWAYS call this tool first to show them the tester emails and registration steps.
|
|
716
|
+
Do NOT ask for an opt-in link before confirming tester registration is complete.
|
|
717
|
+
|
|
718
|
+
Returns:
|
|
719
|
+
{
|
|
720
|
+
"groupName": string, // Name of the tester group
|
|
721
|
+
"emails": string[], // List of tester email addresses
|
|
722
|
+
"count": number // Total number of testers
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
Examples:
|
|
726
|
+
- Use when: User says "링크테스트 시작해줘" or "how do I start?"
|
|
727
|
+
- Use when: "What emails do I need to add to closed testing?"
|
|
728
|
+
- Use when: User asks what the first step is
|
|
729
|
+
- Use when: Before calling linktest_order or linktest_validate`,
|
|
730
|
+
inputSchema: z.object({}).strict(),
|
|
731
|
+
annotations: {
|
|
732
|
+
readOnlyHint: true,
|
|
733
|
+
destructiveHint: false,
|
|
734
|
+
idempotentHint: true,
|
|
735
|
+
openWorldHint: false,
|
|
736
|
+
},
|
|
737
|
+
}, async () => {
|
|
738
|
+
try {
|
|
739
|
+
const data = await apiRequest("GET", "/api/testers");
|
|
740
|
+
// CSV 파일 로컬에 다운로드
|
|
741
|
+
const csvResponse = await apiRequest("GET", "/api/testers/csv");
|
|
742
|
+
const csvPath = path.join(os.homedir(), "Downloads", "linktest-testers.csv");
|
|
743
|
+
fs.writeFileSync(csvPath, typeof csvResponse === "string" ? csvResponse : data.emails.join("\n"));
|
|
744
|
+
const lines = [
|
|
745
|
+
`## LinkTest 테스터 그룹: ${data.groupName}`,
|
|
746
|
+
`총 ${data.count}개 계정\n`,
|
|
747
|
+
`---`,
|
|
748
|
+
`### 방법 1: AI가 자동 등록 (권장)`,
|
|
749
|
+
`Google Play Developer API를 사용해 AI가 직접 테스터를 등록합니다.`,
|
|
750
|
+
``,
|
|
751
|
+
`**사전 설정 (한 번만):**`,
|
|
752
|
+
`1. [Google Cloud Console](https://console.cloud.google.com) → 프로젝트 생성`,
|
|
753
|
+
`2. "API 및 서비스" → "Google Play Android Developer API" 활성화`,
|
|
754
|
+
`3. "IAM → 서비스 계정" → 서비스 계정 생성 → JSON 키 다운로드`,
|
|
755
|
+
`4. [Play Console](https://play.google.com/console) → "Users and permissions" → "Invite new users"`,
|
|
756
|
+
`5. 서비스 계정 이메일(JSON의 client_email) 입력 → "Release apps to testing tracks" 권한 부여`,
|
|
757
|
+
`6. JSON 키 내용을 AI에게 전달하면 자동 등록 완료`,
|
|
758
|
+
``,
|
|
759
|
+
`---`,
|
|
760
|
+
`### 방법 2: CSV 파일로 직접 등록`,
|
|
761
|
+
`아래 CSV 파일을 다운로드 후 Play Console에 업로드하세요.`,
|
|
762
|
+
``,
|
|
763
|
+
`**📥 CSV 다운로드 완료:** ${csvPath}`,
|
|
764
|
+
``,
|
|
765
|
+
`**처음 등록 시:**`,
|
|
766
|
+
`1. Play Console → 앱 선택`,
|
|
767
|
+
`2. 테스트 → 비공개 테스트 → 트랙 관리`,
|
|
768
|
+
`3. "테스터" 탭 → "이메일 목록 만들기"`,
|
|
769
|
+
`4. "CSV 파일 업로드" → 다운로드한 파일 선택 → 목록 이름: "LinkTest"`,
|
|
770
|
+
`5. 게시 개요 탭에서 "변경사항 전송"`,
|
|
771
|
+
``,
|
|
772
|
+
`**이후 다른 앱 등록 시 (CSV 재업로드 불필요):**`,
|
|
773
|
+
`1. Play Console → 새 앱 선택`,
|
|
774
|
+
`2. 테스트 → 비공개 테스트 → 트랙 관리 → "테스터" 탭`,
|
|
775
|
+
`3. 기존 "LinkTest" 목록 체크 → 변경사항 전송`,
|
|
776
|
+
``,
|
|
777
|
+
`---`,
|
|
778
|
+
`### 테스터 이메일 목록`,
|
|
779
|
+
];
|
|
780
|
+
data.emails.forEach((email, i) => {
|
|
781
|
+
lines.push(`${i + 1}. ${email}`);
|
|
782
|
+
});
|
|
783
|
+
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
784
|
+
}
|
|
785
|
+
catch (error) {
|
|
786
|
+
return { content: [{ type: "text", text: handleError(error) }] };
|
|
787
|
+
}
|
|
788
|
+
});
|
|
789
|
+
// ─── Main ─────────────────────────────────────────────────────────────────────
|
|
790
|
+
async function main() {
|
|
791
|
+
if (!process.env.LINKTEST_API_TOKEN) {
|
|
792
|
+
console.error("ERROR: LINKTEST_API_TOKEN environment variable is required.");
|
|
793
|
+
console.error("Get your token at https://linktest.dev/settings");
|
|
794
|
+
process.exit(1);
|
|
795
|
+
}
|
|
796
|
+
const transport = new StdioServerTransport();
|
|
797
|
+
await server.connect(transport);
|
|
798
|
+
console.error("LinkTest MCP server running via stdio");
|
|
799
|
+
}
|
|
800
|
+
main().catch((error) => {
|
|
801
|
+
console.error("Server error:", error);
|
|
802
|
+
process.exit(1);
|
|
803
|
+
});
|
|
804
|
+
//# sourceMappingURL=index.js.map
|