appium-mcp 1.84.2 → 1.85.1
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/CHANGELOG.md +16 -0
- package/README.md +30 -2
- package/dist/core.d.ts +2 -0
- package/dist/core.d.ts.map +1 -1
- package/dist/core.js +2 -0
- package/dist/core.js.map +1 -1
- package/dist/create-server.d.ts +3 -3
- package/dist/create-server.d.ts.map +1 -1
- package/dist/create-server.js +7 -3
- package/dist/create-server.js.map +1 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +1 -1
- package/dist/server.js.map +1 -1
- package/dist/session-store.d.ts +2 -2
- package/dist/session-store.d.ts.map +1 -1
- package/dist/session-store.js +5 -13
- package/dist/session-store.js.map +1 -1
- package/dist/telemetry/attributes.d.ts +46 -0
- package/dist/telemetry/attributes.d.ts.map +1 -0
- package/dist/telemetry/attributes.js +90 -0
- package/dist/telemetry/attributes.js.map +1 -0
- package/dist/telemetry/init.d.ts +19 -0
- package/dist/telemetry/init.d.ts.map +1 -0
- package/dist/telemetry/init.js +68 -0
- package/dist/telemetry/init.js.map +1 -0
- package/dist/telemetry/tracer.d.ts +31 -0
- package/dist/telemetry/tracer.d.ts.map +1 -0
- package/dist/telemetry/tracer.js +59 -0
- package/dist/telemetry/tracer.js.map +1 -0
- package/dist/telemetry/wrapOperations.d.ts +30 -0
- package/dist/telemetry/wrapOperations.d.ts.map +1 -0
- package/dist/telemetry/wrapOperations.js +156 -0
- package/dist/telemetry/wrapOperations.js.map +1 -0
- package/dist/tools/index.d.ts.map +1 -1
- package/dist/tools/index.js +2 -12
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/session/session.d.ts.map +1 -1
- package/dist/tools/session/session.js +11 -10
- package/dist/tools/session/session.js.map +1 -1
- package/dist/utils/env.d.ts +2 -0
- package/dist/utils/env.d.ts.map +1 -0
- package/dist/utils/env.js +5 -0
- package/dist/utils/env.js.map +1 -0
- package/dist/utils/sensitive.d.ts +8 -0
- package/dist/utils/sensitive.d.ts.map +1 -0
- package/dist/utils/sensitive.js +27 -0
- package/dist/utils/sensitive.js.map +1 -0
- package/package.json +10 -1
- package/server.json +2 -2
- package/src/core.ts +2 -0
- package/src/create-server.ts +8 -4
- package/src/server.ts +1 -1
- package/src/session-store.ts +6 -20
- package/src/telemetry/attributes.ts +104 -0
- package/src/telemetry/init.ts +77 -0
- package/src/telemetry/tracer.ts +75 -0
- package/src/telemetry/wrapOperations.ts +245 -0
- package/src/tools/index.ts +2 -14
- package/src/tools/session/session.ts +14 -9
- package/src/utils/env.ts +5 -0
- package/src/utils/sensitive.ts +30 -0
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Helpers for deciding whether telemetry is enabled and for building safe span
|
|
3
|
+
* attributes. This file intentionally exposes only low-cardinality metadata and
|
|
4
|
+
* filtered input names so spans do not capture secrets, screenshots, page
|
|
5
|
+
* source XML, prompts, or other user payloads.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { isTruthyEnvValue } from '../utils/env.js';
|
|
9
|
+
import { isSensitiveKey } from '../utils/sensitive.js';
|
|
10
|
+
import { getSessionId } from '../session-store.js';
|
|
11
|
+
|
|
12
|
+
const MAX_ATTRIBUTE_VALUE_LENGTH = 2048;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Determines whether telemetry is enabled based on the APPIUM_MCP_OTEL_ENABLED environment variable.
|
|
16
|
+
* Recognizes "1", "true", "yes", and "on" (case-insensitive) as true values.
|
|
17
|
+
* Defaults to false if the variable is not set or has an unrecognized value.
|
|
18
|
+
* @returns True if telemetry is enabled, false otherwise.
|
|
19
|
+
*/
|
|
20
|
+
export function isTelemetryEnabled(): boolean {
|
|
21
|
+
return isTruthyEnvValue(process.env.APPIUM_MCP_OTEL_ENABLED);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Determines whether including argument values in telemetry attributes is enabled based on the APPIUM_MCP_OTEL_INCLUDE_ARGUMENT_VALUES environment variable.
|
|
26
|
+
* Recognizes "1", "true", "yes", and "on" (case-insensitive) as true values.
|
|
27
|
+
* Defaults to false if the variable is not set or has an unrecognized value.
|
|
28
|
+
* When false, argument values will not be included in telemetry attributes, even for non-sensitive keys.
|
|
29
|
+
* When true, non-sensitive argument values will be included in telemetry attributes, while sensitive keys will still be excluded.
|
|
30
|
+
* @see safeInputValueAttributes for how argument values are included in attributes when this is enabled.
|
|
31
|
+
* @returns
|
|
32
|
+
*/
|
|
33
|
+
export function isArgumentValueTelemetryEnabled(): boolean {
|
|
34
|
+
return isTruthyEnvValue(process.env.APPIUM_MCP_OTEL_INCLUDE_ARGUMENT_VALUES);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Safely converts a value to a string, number, or boolean for use as a telemetry attribute.
|
|
39
|
+
* If the value is already a string, number, or boolean, it is returned as-is.
|
|
40
|
+
* If the value is null or undefined, an empty string is returned.
|
|
41
|
+
* Otherwise, the value is converted to a string.
|
|
42
|
+
* @param value The value to convert.
|
|
43
|
+
* @returns The safe attribute value.
|
|
44
|
+
*/
|
|
45
|
+
export function safeAttributeValue(value: unknown): string | number | boolean {
|
|
46
|
+
if (
|
|
47
|
+
typeof value === 'string' ||
|
|
48
|
+
typeof value === 'number' ||
|
|
49
|
+
typeof value === 'boolean'
|
|
50
|
+
) {
|
|
51
|
+
return value;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (value == null) {
|
|
55
|
+
return '';
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
try {
|
|
59
|
+
const serialized = JSON.stringify(value, (key, val) =>
|
|
60
|
+
key && isSensitiveKey(key) ? '[REDACTED]' : val
|
|
61
|
+
);
|
|
62
|
+
return truncateAttributeValue(serialized ?? String(value));
|
|
63
|
+
} catch {
|
|
64
|
+
return truncateAttributeValue(String(value));
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Safely extracts the session ID from the given arguments.
|
|
70
|
+
* Falls back to the active session ID when no string argument sessionId exists.
|
|
71
|
+
* @param args The arguments object potentially containing a sessionId.
|
|
72
|
+
* @returns The session ID if present and valid, otherwise undefined.
|
|
73
|
+
*/
|
|
74
|
+
export function safeSessionId(args: unknown): string | undefined {
|
|
75
|
+
if (args && typeof args === 'object' && 'sessionId' in args) {
|
|
76
|
+
const sessionId = (args as { sessionId?: unknown }).sessionId;
|
|
77
|
+
if (typeof sessionId === 'string' && sessionId.length > 0) {
|
|
78
|
+
return sessionId;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return getSessionId() ?? undefined;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Safely extracts the input keys from the given arguments, excluding sensitive keys.
|
|
87
|
+
* @param args The arguments object potentially containing input keys.
|
|
88
|
+
* @returns An array of safe input keys.
|
|
89
|
+
*/
|
|
90
|
+
export function safeInputKeys(args: unknown): string[] {
|
|
91
|
+
if (!args || typeof args !== 'object' || Array.isArray(args)) {
|
|
92
|
+
return [];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return Object.keys(args)
|
|
96
|
+
.filter((key) => !isSensitiveKey(key))
|
|
97
|
+
.sort();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function truncateAttributeValue(value: string): string {
|
|
101
|
+
return value.length > MAX_ATTRIBUTE_VALUE_LENGTH
|
|
102
|
+
? `${value.slice(0, MAX_ATTRIBUTE_VALUE_LENGTH)}...`
|
|
103
|
+
: value;
|
|
104
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenTelemetry SDK bootstrap for the appium-mcp CLI. Initialization is opt-in
|
|
3
|
+
* via APPIUM_MCP_OTEL_ENABLED so normal server startup keeps the default no-op
|
|
4
|
+
* OpenTelemetry provider and avoids exporter setup unless tracing is requested.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import log from '../logger.js';
|
|
8
|
+
import { isTelemetryEnabled } from './attributes.js';
|
|
9
|
+
|
|
10
|
+
let sdkStarted = false;
|
|
11
|
+
let shutdownRegistered = false;
|
|
12
|
+
let sdk: { start(): void; shutdown(): Promise<void> } | undefined;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Initializes the OpenTelemetry SDK if telemetry is enabled and not already started.
|
|
16
|
+
* This function is idempotent and safe to call multiple times; the SDK will only
|
|
17
|
+
* be initialized once.
|
|
18
|
+
* @returns
|
|
19
|
+
*/
|
|
20
|
+
export async function initializeOpenTelemetry(): Promise<void> {
|
|
21
|
+
if (!isTelemetryEnabled() || sdkStarted) {
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const [{ NodeSDK }, { OTLPTraceExporter }] = await Promise.all([
|
|
26
|
+
import('@opentelemetry/sdk-node'),
|
|
27
|
+
import('@opentelemetry/exporter-trace-otlp-http'),
|
|
28
|
+
]);
|
|
29
|
+
|
|
30
|
+
const nodeSdk = new NodeSDK({
|
|
31
|
+
traceExporter: new OTLPTraceExporter(),
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
sdk = nodeSdk;
|
|
35
|
+
nodeSdk.start();
|
|
36
|
+
sdkStarted = true;
|
|
37
|
+
registerShutdown();
|
|
38
|
+
log.info('OpenTelemetry tracing enabled for appium-mcp.');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Shuts down the OpenTelemetry SDK if it was started. This is typically called during process shutdown to ensure
|
|
43
|
+
* that all telemetry data is flushed properly. This function is idempotent and safe to call multiple times.
|
|
44
|
+
* @returns
|
|
45
|
+
*/
|
|
46
|
+
export async function shutdownOpenTelemetry(): Promise<void> {
|
|
47
|
+
if (!sdkStarted || !sdk) {
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
await sdk.shutdown();
|
|
53
|
+
} finally {
|
|
54
|
+
sdk = undefined;
|
|
55
|
+
sdkStarted = false;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function registerShutdown(): void {
|
|
60
|
+
if (shutdownRegistered) {
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
shutdownRegistered = true;
|
|
65
|
+
|
|
66
|
+
const shutdown = async () => {
|
|
67
|
+
try {
|
|
68
|
+
await shutdownOpenTelemetry();
|
|
69
|
+
} catch (error) {
|
|
70
|
+
log.error('Error shutting down OpenTelemetry SDK:', error);
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
process.once('beforeExit', () => {
|
|
75
|
+
void shutdown();
|
|
76
|
+
});
|
|
77
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared tracer helpers for appium-mcp instrumentation. Tool, prompt, and
|
|
3
|
+
* resource wrappers use this module so span creation, exception recording, and
|
|
4
|
+
* disabled-telemetry behavior stay consistent across MCP operation types.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
context,
|
|
9
|
+
SpanKind,
|
|
10
|
+
SpanStatusCode,
|
|
11
|
+
trace,
|
|
12
|
+
type Attributes,
|
|
13
|
+
} from '@opentelemetry/api';
|
|
14
|
+
|
|
15
|
+
import { isTelemetryEnabled } from './attributes.js';
|
|
16
|
+
|
|
17
|
+
const TRACER_NAME = 'appium-mcp';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Gets the OpenTelemetry tracer for appium-mcp. This is used by tool, prompt,
|
|
21
|
+
* and resource wrappers to create spans with a consistent name and configuration.
|
|
22
|
+
* @returns The OpenTelemetry tracer for appium-mcp.
|
|
23
|
+
*/
|
|
24
|
+
export function getAppiumMcpTracer() {
|
|
25
|
+
return trace.getTracer(TRACER_NAME);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Gets the currently active OpenTelemetry span from the context. This is used by
|
|
30
|
+
* tool, prompt, and resource wrappers to add attributes or record exceptions on the
|
|
31
|
+
* active span without needing to pass the span object through multiple layers of calls.
|
|
32
|
+
* @returns The currently active OpenTelemetry span, or undefined if there is no active span.
|
|
33
|
+
*/
|
|
34
|
+
export function getActiveSpan() {
|
|
35
|
+
return trace.getActiveSpan();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Runs the given asynchronous operation within a new OpenTelemetry span with the specified name and attributes.
|
|
40
|
+
* If telemetry is not enabled, the operation is run without creating a span.
|
|
41
|
+
* If the operation throws an error, the error is recorded on the span and re-thrown.
|
|
42
|
+
* @param name The name of the span.
|
|
43
|
+
* @param attributes The attributes to set on the span.
|
|
44
|
+
* @param operation The asynchronous operation to run within the span.
|
|
45
|
+
* @returns The result of the asynchronous operation.
|
|
46
|
+
*/
|
|
47
|
+
export async function withSpan<T>(
|
|
48
|
+
name: string,
|
|
49
|
+
attributes: Attributes,
|
|
50
|
+
operation: () => Promise<T>
|
|
51
|
+
): Promise<T> {
|
|
52
|
+
if (!isTelemetryEnabled()) {
|
|
53
|
+
return operation();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const span = getAppiumMcpTracer().startSpan(name, {
|
|
57
|
+
attributes,
|
|
58
|
+
kind: SpanKind.INTERNAL,
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
return await context.with(trace.setSpan(context.active(), span), operation);
|
|
63
|
+
} catch (error) {
|
|
64
|
+
span.recordException(error as Error);
|
|
65
|
+
span.setStatus({
|
|
66
|
+
code: SpanStatusCode.ERROR,
|
|
67
|
+
message: error instanceof Error ? error.message : String(error),
|
|
68
|
+
});
|
|
69
|
+
throw error;
|
|
70
|
+
} finally {
|
|
71
|
+
span.end();
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export { SpanStatusCode };
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FastMCP operation wrappers that create spans around registered MCP handlers.
|
|
3
|
+
* Installing these wrappers before built-in and plugin registration covers
|
|
4
|
+
* tools, prompts, resources, and resource templates without changing handler
|
|
5
|
+
* return values or recording sensitive request/response payloads.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { FastMCP } from 'fastmcp';
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
isArgumentValueTelemetryEnabled,
|
|
12
|
+
safeAttributeValue,
|
|
13
|
+
safeInputKeys,
|
|
14
|
+
safeSessionId,
|
|
15
|
+
} from './attributes.js';
|
|
16
|
+
import { getActiveSpan, SpanStatusCode, withSpan } from './tracer.js';
|
|
17
|
+
import { isSensitiveKey } from '../utils/sensitive.js';
|
|
18
|
+
|
|
19
|
+
type ToolDef = Parameters<FastMCP['addTool']>[0];
|
|
20
|
+
type PromptDef = Parameters<FastMCP['addPrompt']>[0];
|
|
21
|
+
type ResourceDef = Parameters<FastMCP['addResource']>[0];
|
|
22
|
+
type ResourceTemplateDef = Parameters<FastMCP['addResourceTemplate']>[0];
|
|
23
|
+
|
|
24
|
+
type ToolExecute = NonNullable<ToolDef['execute']>;
|
|
25
|
+
type PromptLoad = NonNullable<PromptDef['load']>;
|
|
26
|
+
type ResourceLoad = NonNullable<ResourceDef['load']>;
|
|
27
|
+
type ResourceTemplateLoad = NonNullable<ResourceTemplateDef['load']>;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Installs telemetry wrappers on the given FastMCP server instance. This should be
|
|
31
|
+
* called early in the server setup process, before registering any tools, prompts,
|
|
32
|
+
* or resources, to ensure that all operations are wrapped with OpenTelemetry spans.
|
|
33
|
+
* Each span will be named according to the operation type and include attributes
|
|
34
|
+
* such as tool name, prompt name, resource URI, session ID, and input keys, while
|
|
35
|
+
* avoiding any sensitive information.
|
|
36
|
+
* @param server The FastMCP server instance on which to install telemetry wrappers.
|
|
37
|
+
*/
|
|
38
|
+
export function installTelemetryWrappers(server: FastMCP): void {
|
|
39
|
+
const originalAddTool = server.addTool.bind(server) as FastMCP['addTool'];
|
|
40
|
+
|
|
41
|
+
server.addTool = ((toolDef: ToolDef) =>
|
|
42
|
+
originalAddTool(wrapToolWithTelemetry(toolDef))) as FastMCP['addTool'];
|
|
43
|
+
|
|
44
|
+
if (typeof server.addPrompt === 'function') {
|
|
45
|
+
const originalAddPrompt = server.addPrompt.bind(
|
|
46
|
+
server
|
|
47
|
+
) as FastMCP['addPrompt'];
|
|
48
|
+
server.addPrompt = ((promptDef: PromptDef) =>
|
|
49
|
+
originalAddPrompt(
|
|
50
|
+
wrapPromptWithTelemetry(promptDef)
|
|
51
|
+
)) as FastMCP['addPrompt'];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (typeof server.addResource === 'function') {
|
|
55
|
+
const originalAddResource = server.addResource.bind(
|
|
56
|
+
server
|
|
57
|
+
) as FastMCP['addResource'];
|
|
58
|
+
server.addResource = ((resourceDef: ResourceDef) =>
|
|
59
|
+
originalAddResource(
|
|
60
|
+
wrapResourceWithTelemetry(resourceDef)
|
|
61
|
+
)) as FastMCP['addResource'];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (typeof server.addResourceTemplate === 'function') {
|
|
65
|
+
const originalAddResourceTemplate = server.addResourceTemplate.bind(
|
|
66
|
+
server
|
|
67
|
+
) as FastMCP['addResourceTemplate'];
|
|
68
|
+
server.addResourceTemplate = ((resourceTemplateDef: ResourceTemplateDef) =>
|
|
69
|
+
originalAddResourceTemplate(
|
|
70
|
+
wrapResourceTemplateWithTelemetry(resourceTemplateDef)
|
|
71
|
+
)) as FastMCP['addResourceTemplate'];
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Wraps a tool definition with telemetry spans around its execute function.
|
|
77
|
+
* The span will be named "tools/call {toolName}" and include attributes for the tool name,
|
|
78
|
+
* session ID (if available), and input keys. If the tool execution results in an error,
|
|
79
|
+
* the span status will be set to error and an attribute will indicate that the result is an error.
|
|
80
|
+
* @param toolDef The original tool definition to wrap.
|
|
81
|
+
* @returns A new tool definition with telemetry spans around the execute function.
|
|
82
|
+
*/
|
|
83
|
+
export function wrapToolWithTelemetry(toolDef: ToolDef): ToolDef {
|
|
84
|
+
const execute = toolDef.execute as ToolExecute | undefined;
|
|
85
|
+
if (!execute) {
|
|
86
|
+
return toolDef;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const toolName = toolDef.name ?? 'unknown_tool';
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
...toolDef,
|
|
93
|
+
execute: async (args, context) =>
|
|
94
|
+
withSpan(
|
|
95
|
+
`tools/call ${toolName}`,
|
|
96
|
+
toolAttributes(toolName, args),
|
|
97
|
+
async () => {
|
|
98
|
+
const result = await execute(args, context);
|
|
99
|
+
if (isErrorResult(result)) {
|
|
100
|
+
getActiveSpan()?.setStatus({ code: SpanStatusCode.ERROR });
|
|
101
|
+
getActiveSpan()?.setAttribute('mcp.tool.result.is_error', true);
|
|
102
|
+
}
|
|
103
|
+
return result;
|
|
104
|
+
}
|
|
105
|
+
),
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function safeInputValueAttributes(
|
|
110
|
+
args: unknown
|
|
111
|
+
): Record<string, string | number | boolean | string[]> {
|
|
112
|
+
if (!isArgumentValueTelemetryEnabled()) {
|
|
113
|
+
return {};
|
|
114
|
+
}
|
|
115
|
+
if (!args || typeof args !== 'object' || Array.isArray(args)) {
|
|
116
|
+
return {};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const attributes: Record<string, string | number | boolean | string[]> = {};
|
|
120
|
+
|
|
121
|
+
for (const [key, value] of Object.entries(args)) {
|
|
122
|
+
// do not include sensitive keys as attributes, and avoid logging large strings or buffers
|
|
123
|
+
if (isSensitiveKey(key)) {
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
attributes[`mcp.input.value.${key}`] = safeAttributeValue(value);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return attributes;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Wraps a prompt definition with telemetry spans around its load function.
|
|
134
|
+
* The span will be named "prompts/get {promptName}" and include attributes for the prompt name
|
|
135
|
+
* and input keys. If the prompt load results in an error, the span status will be set to error
|
|
136
|
+
* and an attribute will indicate that the result is an error.
|
|
137
|
+
* @param promptDef The original prompt definition to wrap.
|
|
138
|
+
* @returns A new prompt definition with telemetry spans around the load function.
|
|
139
|
+
*/
|
|
140
|
+
function wrapPromptWithTelemetry(promptDef: PromptDef): PromptDef {
|
|
141
|
+
const load = promptDef.load as PromptLoad | undefined;
|
|
142
|
+
if (!load) {
|
|
143
|
+
return promptDef;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const promptName = promptDef.name ?? 'unknown_prompt';
|
|
147
|
+
|
|
148
|
+
return {
|
|
149
|
+
...promptDef,
|
|
150
|
+
load: async (args, auth) =>
|
|
151
|
+
withSpan(
|
|
152
|
+
`prompts/get ${promptName}`,
|
|
153
|
+
{
|
|
154
|
+
'mcp.prompt.name': promptName,
|
|
155
|
+
...inputAttributes(args),
|
|
156
|
+
},
|
|
157
|
+
() => load(args, auth)
|
|
158
|
+
),
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function wrapResourceWithTelemetry(resourceDef: ResourceDef): ResourceDef {
|
|
163
|
+
const load = resourceDef.load as ResourceLoad | undefined;
|
|
164
|
+
if (!load) {
|
|
165
|
+
return resourceDef;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const uri = resourceDef.uri ?? 'unknown_resource';
|
|
169
|
+
|
|
170
|
+
return {
|
|
171
|
+
...resourceDef,
|
|
172
|
+
load: async () =>
|
|
173
|
+
withSpan(
|
|
174
|
+
'resources/read',
|
|
175
|
+
{
|
|
176
|
+
'mcp.resource.uri': uri,
|
|
177
|
+
},
|
|
178
|
+
() => load()
|
|
179
|
+
),
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function wrapResourceTemplateWithTelemetry(
|
|
184
|
+
resourceTemplateDef: ResourceTemplateDef
|
|
185
|
+
): ResourceTemplateDef {
|
|
186
|
+
const load = resourceTemplateDef.load as ResourceTemplateLoad | undefined;
|
|
187
|
+
if (!load) {
|
|
188
|
+
return resourceTemplateDef;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const uriTemplate =
|
|
192
|
+
resourceTemplateDef.uriTemplate?.toString() ?? 'unknown_resource_template';
|
|
193
|
+
|
|
194
|
+
return {
|
|
195
|
+
...resourceTemplateDef,
|
|
196
|
+
load: async (args, auth) =>
|
|
197
|
+
withSpan(
|
|
198
|
+
'resources/read',
|
|
199
|
+
{
|
|
200
|
+
'mcp.resource.uri_template': uriTemplate,
|
|
201
|
+
...inputAttributes(args),
|
|
202
|
+
},
|
|
203
|
+
() => load(args, auth)
|
|
204
|
+
),
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function toolAttributes(toolName: string, args: unknown) {
|
|
209
|
+
const attributes: Record<string, string | string[]> = {
|
|
210
|
+
'mcp.tool.name': toolName,
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
const sessionId = safeSessionId(args);
|
|
214
|
+
if (sessionId) {
|
|
215
|
+
attributes['appium.session.id'] = sessionId;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return {
|
|
219
|
+
...attributes,
|
|
220
|
+
...inputAttributes(args),
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function inputAttributes(
|
|
225
|
+
args: unknown
|
|
226
|
+
): Record<string, string | number | boolean | string[]> {
|
|
227
|
+
return {
|
|
228
|
+
...inputKeyAttributes(args),
|
|
229
|
+
...safeInputValueAttributes(args),
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function inputKeyAttributes(args: unknown): Record<string, string[]> {
|
|
234
|
+
const inputKeys = safeInputKeys(args);
|
|
235
|
+
return inputKeys.length > 0 ? { 'mcp.input.keys': inputKeys } : {};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function isErrorResult(result: unknown): boolean {
|
|
239
|
+
return (
|
|
240
|
+
!!result &&
|
|
241
|
+
typeof result === 'object' &&
|
|
242
|
+
'isError' in result &&
|
|
243
|
+
(result as { isError?: unknown }).isError === true
|
|
244
|
+
);
|
|
245
|
+
}
|
package/src/tools/index.ts
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
*/
|
|
15
15
|
import type { ContentResult, FastMCP } from 'fastmcp';
|
|
16
16
|
import log from '../logger.js';
|
|
17
|
+
import { isSensitiveKey } from '../utils/sensitive.js';
|
|
17
18
|
import session from './session/session.js';
|
|
18
19
|
import generateLocators from './test-generation/locators.js';
|
|
19
20
|
import selectDevice from './session/select-device.js';
|
|
@@ -59,16 +60,6 @@ export default function registerTools(server: FastMCP): void {
|
|
|
59
60
|
if (typeof originalExecute !== 'function') {
|
|
60
61
|
return originalAddTool(toolDef);
|
|
61
62
|
}
|
|
62
|
-
const SENSITIVE_KEYS = [
|
|
63
|
-
'password',
|
|
64
|
-
'token',
|
|
65
|
-
'accesstoken',
|
|
66
|
-
'authorization',
|
|
67
|
-
'apikey',
|
|
68
|
-
'secret',
|
|
69
|
-
'clientsecret',
|
|
70
|
-
'remoteserverurl',
|
|
71
|
-
];
|
|
72
63
|
const redactArgs = (obj: unknown): unknown => {
|
|
73
64
|
if (obj === undefined || obj === null) {
|
|
74
65
|
return obj;
|
|
@@ -76,10 +67,7 @@ export default function registerTools(server: FastMCP): void {
|
|
|
76
67
|
try {
|
|
77
68
|
return JSON.parse(
|
|
78
69
|
JSON.stringify(obj, (key, value) => {
|
|
79
|
-
if (
|
|
80
|
-
key &&
|
|
81
|
-
SENSITIVE_KEYS.some((k) => key.toLowerCase().includes(k))
|
|
82
|
-
) {
|
|
70
|
+
if (key && isSensitiveKey(key)) {
|
|
83
71
|
return '[REDACTED]';
|
|
84
72
|
}
|
|
85
73
|
// Avoid logging extremely large buffers/strings
|
|
@@ -93,16 +93,21 @@ export default function session(server: FastMCP): void {
|
|
|
93
93
|
execute: async (args: z.infer<typeof schema>): Promise<any> => {
|
|
94
94
|
try {
|
|
95
95
|
// Parse capabilities: some LLMs (e.g. Gemini) pass a JSON string instead of an object.
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
96
|
+
let parsedCapabilities: Record<string, any> | undefined;
|
|
97
|
+
if (typeof args.capabilities === 'string') {
|
|
98
|
+
try {
|
|
99
|
+
parsedCapabilities = JSON.parse(args.capabilities) as Record<
|
|
100
|
+
string,
|
|
101
|
+
any
|
|
102
|
+
>;
|
|
103
|
+
} catch (err: unknown) {
|
|
104
|
+
return errorResult(
|
|
105
|
+
`Invalid capabilities JSON: ${toolErrorMessage(err)}`
|
|
106
|
+
);
|
|
103
107
|
}
|
|
104
|
-
|
|
105
|
-
|
|
108
|
+
} else {
|
|
109
|
+
parsedCapabilities = args.capabilities;
|
|
110
|
+
}
|
|
106
111
|
|
|
107
112
|
if (args.action === 'create') {
|
|
108
113
|
if (!args.platform) {
|
package/src/utils/env.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
const SENSITIVE_KEY_PARTS = [
|
|
2
|
+
'api_key',
|
|
3
|
+
'apikey',
|
|
4
|
+
'authorization',
|
|
5
|
+
'client_secret',
|
|
6
|
+
'clientsecret',
|
|
7
|
+
'credential',
|
|
8
|
+
'password',
|
|
9
|
+
'remote_server_url',
|
|
10
|
+
'remoteserverurl',
|
|
11
|
+
'secret',
|
|
12
|
+
'token',
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Determines if a given key is considered sensitive based on whether it includes any of the defined sensitive key parts.
|
|
17
|
+
* The check is case-insensitive and ignores non-alphanumeric characters, so keys like "API-Key", "client secret", or "remote_server_url" would all be correctly identified as sensitive.
|
|
18
|
+
* @param key The key to check for sensitivity.
|
|
19
|
+
* @returns True if the key is considered sensitive, false otherwise.
|
|
20
|
+
*/
|
|
21
|
+
export function isSensitiveKey(key: string): boolean {
|
|
22
|
+
const normalized = normalizeKey(key);
|
|
23
|
+
return SENSITIVE_KEY_PARTS.some((part) =>
|
|
24
|
+
normalized.includes(normalizeKey(part))
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function normalizeKey(key: string): string {
|
|
29
|
+
return key.replace(/[^a-zA-Z0-9]/g, '').toLowerCase();
|
|
30
|
+
}
|