-
-
Notifications
You must be signed in to change notification settings - Fork 79
Expand file tree
/
Copy pathplugin-example.ts
More file actions
365 lines (337 loc) · 10.8 KB
/
plugin-example.ts
File metadata and controls
365 lines (337 loc) · 10.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
/**
* Example plugins for the appium-mcp/core plugin API.
*
* These examples are illustrative only. They show how a custom server can add
* business-specific MCP capabilities and wrap the default Appium MCP tools.
*
* Run the custom server:
* npx ts-node examples/plugin-example.ts
*/
import {
createAppiumMcpServer,
type AppiumMcpCore,
type AppiumMcpPlugin,
type McpRegistry,
type PluginContext,
type ToolCallContext,
type ToolCallResult,
} from '../dist/core.js';
import { z } from 'zod';
const text = (value: string) => ({ type: 'text' as const, text: value });
const checkoutSummaryParameters = z.object({
orderId: z.string().describe('The order ID to look for on screen'),
});
const activeSessionPlatformParameters = z.object({
platform: z.enum(['Android', 'iOS']),
});
type PageSourceDriver = {
getPageSource(): Promise<string>;
};
// ---------------------------------------------------------------------------
// Example 1: Register custom tools and use AppiumMcpCore
// ---------------------------------------------------------------------------
class CheckoutPlugin implements AppiumMcpPlugin {
readonly name = 'checkout-plugin';
readonly version = '1.0.0';
register(registry: McpRegistry, core: AppiumMcpCore): void {
registry.addTool({
name: 'assert_checkout_summary',
description:
'Assert that the checkout summary screen shows the expected order ID.',
parameters: checkoutSummaryParameters,
execute: async (args) => {
const { orderId } = checkoutSummaryParameters.parse(args);
const driver = core.getDriver() as PageSourceDriver | null;
if (!driver) {
return {
isError: true,
content: [
text(
'No active Appium session. Create or attach to a session first.'
),
],
};
}
const pageSource = await driver.getPageSource();
if (!pageSource.includes(orderId)) {
return {
isError: true,
content: [text(`Order ${orderId} not found on screen`)],
};
}
return {
content: [text(`Checkout summary correct for ${orderId}`)],
};
}
});
registry.addTools([
{
name: 'list_business_sessions',
description: 'Return Appium session IDs with simple business metadata.',
parameters: z.object({}),
execute: async () => {
const sessions = core.listSessions();
const summary =
sessions.length === 0
? 'No active Appium sessions.'
: sessions
.map(
(session) =>
`${session.sessionId}: ${session.platform ?? 'unknown'} / ${
session.deviceName ?? 'unknown device'
}${session.isActive ? ' (active)' : ''}`
)
.join('\n');
return { content: [text(summary)] };
},
},
{
name: 'assert_active_session_platform',
description:
'Assert that the active Appium session is on the expected platform.',
parameters: activeSessionPlatformParameters,
execute: async (args) => {
const { platform } = activeSessionPlatformParameters.parse(args);
const activeSession = core
.listSessions()
.find((session) => session.isActive);
if (!activeSession) {
return {
isError: true,
content: [text('No active Appium session.')],
};
}
if (activeSession.platform !== platform) {
return {
isError: true,
content: [
text(
`Expected ${platform}, but active session is ${
activeSession.platform ?? 'unknown'
}.`
),
],
};
}
return {
content: [text(`Active session is ${platform}.`)],
};
},
},
]);
}
}
// ---------------------------------------------------------------------------
// Example 2: Register prompts, resources, and resource templates
// ---------------------------------------------------------------------------
class TestAssetsPlugin implements AppiumMcpPlugin {
readonly name = 'test-assets';
readonly version = '1.0.0';
register(registry: McpRegistry): void {
registry.addPrompt({
name: 'mobile-bug-report',
description: 'Create a concise mobile automation bug report.',
arguments: [
{
name: 'screen',
description: 'Screen or feature where the bug was observed',
required: true,
},
{
name: 'symptom',
description: 'Observed failure or unexpected behavior',
required: true,
},
],
load: async ({ screen, symptom }) =>
[
`Write a concise mobile bug report for the ${screen} screen.`,
`Observed symptom: ${symptom}.`,
'Include expected behavior, actual behavior, reproduction steps, and useful Appium artifacts.',
].join('\n'),
});
registry.addPrompts([
{
name: 'screen-model',
description: 'Generate a simple screen model from observed controls.',
arguments: [
{
name: 'platform',
description: 'Mobile platform',
required: true,
enum: ['Android', 'iOS'],
},
],
load: async ({ platform }) =>
`Create a ${platform} screen model with stable locators and high-level actions.`,
},
]);
registry.addResource({
uri: 'business://policies/checkout',
name: 'Checkout Automation Policy',
description: 'Business rules for checkout automation.',
mimeType: 'text/markdown',
load: async () => ({
text: [
'# Checkout Automation Policy',
'',
'- Prefer accessibility id locators.',
'- Confirm the order ID before completing payment.',
'- Capture a screenshot whenever checkout assertions fail.',
].join('\n'),
}),
});
registry.addResources([
{
uri: 'business://test-data/users',
name: 'Example Test Users',
description: 'Example user roles for app-specific tests.',
mimeType: 'application/json',
load: async () => ({
text: JSON.stringify(
{
users: [
{ role: 'guest', username: 'guest@example.test' },
{ role: 'member', username: 'member@example.test' },
],
},
null,
2
),
}),
},
]);
registry.addResourceTemplate({
uriTemplate: 'business://screens/{screen}',
name: 'Screen Playbook',
description: 'Screen-specific automation guidance.',
mimeType: 'text/markdown',
arguments: [
{
name: 'screen',
description: 'Screen name',
required: true,
complete: async (value) => ({
values: ['login', 'checkout', 'settings'].filter((screen) =>
screen.startsWith(value)
),
}),
},
],
load: async ({ screen }) => ({
text: [
`# ${screen} Screen Playbook`,
'',
'- Inspect the page source before choosing fallback locators.',
'- Prefer high-level plugin tools when they exist.',
'- Use appium_gesture for taps, swipes, and scrolls.',
].join('\n'),
}),
});
}
}
// ---------------------------------------------------------------------------
// Example 3: Wrap existing tools with beforeCall / afterCall
// ---------------------------------------------------------------------------
class LoginGuardPlugin implements AppiumMcpPlugin {
readonly name = 'login-guard';
readonly version = '1.0.0';
async beforeCall(ctx: ToolCallContext): Promise<ToolCallResult | void> {
if (
ctx.toolName === 'appium_gesture' &&
(ctx.args as { action?: string }).action === 'tap'
) {
const sessionInfo = ctx.session.getSessionInfo();
console.error(
`[login-guard] Pre-tap check passed for session ${sessionInfo?.sessionId ?? 'none'}`
);
}
if (
ctx.toolName === 'mobile_clear_app' &&
process.env.ALLOW_CLEAR_APP !== 'true'
) {
return {
isError: true,
content: [
text(
'Blocked mobile_clear_app. Set ALLOW_CLEAR_APP=true to allow destructive app cleanup.'
),
],
};
}
}
async afterCall(
ctx: ToolCallContext,
result: ToolCallResult
): Promise<ToolCallResult | void> {
if (!result.isError) {
return;
}
const sessionId = ctx.session.getSessionId();
return {
...result,
content: [
...result.content,
text(
`[login-guard] ${ctx.toolName} failed for session ${
sessionId ?? 'none'
}. Capture artifacts here in a real plugin.`
),
],
};
}
}
// ---------------------------------------------------------------------------
// Example 4: Async initialization and teardown
// ---------------------------------------------------------------------------
class ArtifactPipelinePlugin implements AppiumMcpPlugin {
readonly name = 'artifact-pipeline';
readonly version = '1.0.0';
private connected = false;
async initialize(ctx: PluginContext): Promise<void> {
this.connected = true;
console.error(
`[artifact-pipeline] Connected. Loaded plugins: ${Array.from(
ctx.plugins.keys()
).join(', ')}`
);
}
async afterCall(
ctx: ToolCallContext,
result: ToolCallResult
): Promise<ToolCallResult | void> {
if (this.connected && result.isError) {
console.error(
`[artifact-pipeline] Upload failure artifacts for ${ctx.toolName}`
);
}
}
async destroy(): Promise<void> {
if (this.connected) {
console.error('[artifact-pipeline] Disconnected from artifact storage.');
this.connected = false;
}
}
}
// ---------------------------------------------------------------------------
// Wire everything together
// ---------------------------------------------------------------------------
const server = createAppiumMcpServer({
plugins: [
new CheckoutPlugin(),
new TestAssetsPlugin(),
new LoginGuardPlugin(),
new ArtifactPipelinePlugin(),
],
additionalInstructions: [
'Custom checkout policies, screen playbooks, and artifact hooks are active.',
'Use plugin tools for business-level assertions when they match the task.',
].join('\n'),
});
const args = process.argv.slice(2);
void server.start({
transportType: args.includes('--httpStream') ? 'httpStream' : 'stdio',
...(args.includes('--httpStream')
? { httpStream: { endpoint: '/sse', port: 8080 } }
: {}),
});