@qulib/mcp 0.3.1 → 0.4.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/README.md +22 -0
- package/dist/compact-analyze-payload.d.ts +88 -37
- package/dist/compact-analyze-payload.d.ts.map +1 -1
- package/dist/compact-analyze-payload.js +8 -0
- package/dist/index.js +186 -169
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -2,6 +2,28 @@
|
|
|
2
2
|
|
|
3
3
|
**@qulib/mcp** is an MCP server that exposes Qulib so AI clients can analyze a deployed URL for release confidence, accessibility, broken links, console noise, and prioritized gaps (CLI entry `qulib-mcp`).
|
|
4
4
|
|
|
5
|
+
## Setup
|
|
6
|
+
|
|
7
|
+
To enable LLM-powered scenario generation, add your Anthropic API key to the
|
|
8
|
+
`env` block in your MCP host config (Claude Desktop, Claude Code, Cursor, etc.):
|
|
9
|
+
|
|
10
|
+
```json
|
|
11
|
+
{
|
|
12
|
+
"mcpServers": {
|
|
13
|
+
"qulib": {
|
|
14
|
+
"command": "npx",
|
|
15
|
+
"args": ["@qulib/mcp"],
|
|
16
|
+
"env": {
|
|
17
|
+
"ANTHROPIC_API_KEY": "sk-ant-..."
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Without this key, qulib still runs but uses built-in template scenarios only.
|
|
25
|
+
Your key is never stored by qulib — it is read from your local config at runtime.
|
|
26
|
+
|
|
5
27
|
## What it does
|
|
6
28
|
|
|
7
29
|
Tools:
|
|
@@ -3,20 +3,103 @@ export declare function buildCompactAnalyzePayload(result: AnalyzeResult, includ
|
|
|
3
3
|
includeFullReport: boolean;
|
|
4
4
|
note: string;
|
|
5
5
|
detectedAuth?: {
|
|
6
|
-
type: "unknown" | "form-login" | "
|
|
6
|
+
type: "unknown" | "form-login" | "oauth" | "magic-link" | "none";
|
|
7
7
|
loginUrl: string | null;
|
|
8
|
-
hasAuth: boolean;
|
|
9
8
|
provider: string | null;
|
|
9
|
+
hasAuth: boolean;
|
|
10
10
|
observedSelectors: {
|
|
11
11
|
usernameSelector: string | null;
|
|
12
12
|
passwordSelector: string | null;
|
|
13
13
|
submitSelector: string | null;
|
|
14
14
|
} | null;
|
|
15
15
|
oauthButtons: {
|
|
16
|
-
provider: string;
|
|
17
16
|
text: string;
|
|
17
|
+
provider: string;
|
|
18
18
|
}[];
|
|
19
19
|
recommendation: string;
|
|
20
|
+
authOptions?: {
|
|
21
|
+
type: "unknown" | "form-login" | "oauth" | "oauth-unknown" | "form-multi" | "magic-link";
|
|
22
|
+
label: string;
|
|
23
|
+
id: string;
|
|
24
|
+
provider: string | null;
|
|
25
|
+
source: "built-in" | "user-local" | "heuristic";
|
|
26
|
+
automatable: boolean;
|
|
27
|
+
confidence: "high" | "medium" | "low";
|
|
28
|
+
requirements: {
|
|
29
|
+
method: "storage-state";
|
|
30
|
+
instruction: string;
|
|
31
|
+
} | {
|
|
32
|
+
method: "credentials";
|
|
33
|
+
fields: {
|
|
34
|
+
type: "password" | "text" | "email" | "select" | "checkbox";
|
|
35
|
+
name: string;
|
|
36
|
+
label: string;
|
|
37
|
+
observedOptions: string[];
|
|
38
|
+
}[];
|
|
39
|
+
} | {
|
|
40
|
+
method: "unknown";
|
|
41
|
+
instruction: string;
|
|
42
|
+
};
|
|
43
|
+
}[] | undefined;
|
|
44
|
+
} | undefined;
|
|
45
|
+
repoInventory: {
|
|
46
|
+
scannedAt: string;
|
|
47
|
+
routes: {
|
|
48
|
+
path: string;
|
|
49
|
+
method: "unknown" | "GET" | "POST" | "PUT" | "DELETE" | "PATCH";
|
|
50
|
+
file: string;
|
|
51
|
+
}[];
|
|
52
|
+
repoPath: string;
|
|
53
|
+
testFiles: {
|
|
54
|
+
type: "playwright" | "cypress-e2e" | "cypress-component" | "jest" | "vitest" | "other";
|
|
55
|
+
file: string;
|
|
56
|
+
coveredPaths: string[];
|
|
57
|
+
}[];
|
|
58
|
+
missingTestIds: string[];
|
|
59
|
+
cypressStructure: {
|
|
60
|
+
detected: boolean;
|
|
61
|
+
hasCommandsFile: boolean;
|
|
62
|
+
existingE2eFiles: string[];
|
|
63
|
+
existingComponentFiles: string[];
|
|
64
|
+
e2eFolder?: string | undefined;
|
|
65
|
+
componentFolder?: string | undefined;
|
|
66
|
+
fixturesFolder?: string | undefined;
|
|
67
|
+
supportFolder?: string | undefined;
|
|
68
|
+
};
|
|
69
|
+
framework?: {
|
|
70
|
+
confidence: "high" | "medium" | "low";
|
|
71
|
+
evidence: string[];
|
|
72
|
+
primary: "unknown" | "nextjs-app-router" | "nextjs-pages-router" | "express" | "remix" | "nuxt" | "sveltekit" | "astro" | "vite";
|
|
73
|
+
testFrameworks: ("playwright" | "cypress-e2e" | "cypress-component" | "jest" | "vitest" | "other")[];
|
|
74
|
+
} | undefined;
|
|
75
|
+
automationMaturity?: {
|
|
76
|
+
label: string;
|
|
77
|
+
level: number;
|
|
78
|
+
computedAt: string;
|
|
79
|
+
repoPath: string;
|
|
80
|
+
overallScore: number;
|
|
81
|
+
dimensions: {
|
|
82
|
+
recommendations: string[];
|
|
83
|
+
dimension: "test-coverage-breadth" | "framework-adoption" | "test-id-hygiene" | "ci-integration" | "auth-test-coverage" | "component-test-ratio";
|
|
84
|
+
score: number;
|
|
85
|
+
weight: number;
|
|
86
|
+
evidence: string[];
|
|
87
|
+
}[];
|
|
88
|
+
topRecommendations: string[];
|
|
89
|
+
} | undefined;
|
|
90
|
+
} | null;
|
|
91
|
+
decisionLogPreview: {
|
|
92
|
+
timestamp: string;
|
|
93
|
+
reason: string;
|
|
94
|
+
phase: "observe" | "think" | "act" | "harness";
|
|
95
|
+
decision: string;
|
|
96
|
+
metadata?: Record<string, unknown> | undefined;
|
|
97
|
+
}[];
|
|
98
|
+
automationMaturitySummary?: {
|
|
99
|
+
overallScore: number;
|
|
100
|
+
level: number;
|
|
101
|
+
label: string;
|
|
102
|
+
topRecommendations: string[];
|
|
20
103
|
} | undefined;
|
|
21
104
|
summary: {
|
|
22
105
|
status: import("@qulib/core").AnalyzeStatus;
|
|
@@ -39,7 +122,7 @@ export declare function buildCompactAnalyzePayload(result: AnalyzeResult, includ
|
|
|
39
122
|
topGaps: {
|
|
40
123
|
path: string;
|
|
41
124
|
category: "untested-route" | "a11y" | "console-error" | "broken-link" | "auth-surface" | "coverage";
|
|
42
|
-
severity: "
|
|
125
|
+
severity: "critical" | "high" | "medium" | "low";
|
|
43
126
|
reason: string;
|
|
44
127
|
}[];
|
|
45
128
|
costIntelligenceSummary: {
|
|
@@ -92,7 +175,7 @@ export declare function buildCompactAnalyzePayload(result: AnalyzeResult, includ
|
|
|
92
175
|
gapsSample: {
|
|
93
176
|
path: string;
|
|
94
177
|
id: string;
|
|
95
|
-
severity: "
|
|
178
|
+
severity: "critical" | "high" | "medium" | "low";
|
|
96
179
|
reason: string;
|
|
97
180
|
category: "untested-route" | "a11y" | "console-error" | "broken-link" | "auth-surface" | "coverage";
|
|
98
181
|
recommendation?: string | undefined;
|
|
@@ -108,37 +191,5 @@ export declare function buildCompactAnalyzePayload(result: AnalyzeResult, includ
|
|
|
108
191
|
pagesSkipped: number;
|
|
109
192
|
budgetExceeded: boolean;
|
|
110
193
|
};
|
|
111
|
-
repoInventory: {
|
|
112
|
-
scannedAt: string;
|
|
113
|
-
routes: {
|
|
114
|
-
path: string;
|
|
115
|
-
method: "unknown" | "GET" | "POST" | "PUT" | "DELETE" | "PATCH";
|
|
116
|
-
file: string;
|
|
117
|
-
}[];
|
|
118
|
-
repoPath: string;
|
|
119
|
-
testFiles: {
|
|
120
|
-
type: "playwright" | "cypress-e2e" | "cypress-component" | "jest" | "vitest" | "other";
|
|
121
|
-
file: string;
|
|
122
|
-
coveredPaths: string[];
|
|
123
|
-
}[];
|
|
124
|
-
missingTestIds: string[];
|
|
125
|
-
cypressStructure: {
|
|
126
|
-
detected: boolean;
|
|
127
|
-
hasCommandsFile: boolean;
|
|
128
|
-
existingE2eFiles: string[];
|
|
129
|
-
existingComponentFiles: string[];
|
|
130
|
-
e2eFolder?: string | undefined;
|
|
131
|
-
componentFolder?: string | undefined;
|
|
132
|
-
fixturesFolder?: string | undefined;
|
|
133
|
-
supportFolder?: string | undefined;
|
|
134
|
-
};
|
|
135
|
-
} | null;
|
|
136
|
-
decisionLogPreview: {
|
|
137
|
-
timestamp: string;
|
|
138
|
-
reason: string;
|
|
139
|
-
phase: "observe" | "think" | "act" | "harness";
|
|
140
|
-
decision: string;
|
|
141
|
-
metadata?: Record<string, unknown> | undefined;
|
|
142
|
-
}[];
|
|
143
194
|
};
|
|
144
195
|
//# sourceMappingURL=compact-analyze-payload.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"compact-analyze-payload.d.ts","sourceRoot":"","sources":["../src/compact-analyze-payload.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AAqBjD,wBAAgB,0BAA0B,CAAC,MAAM,EAAE,aAAa,EAAE,iBAAiB,EAAE,OAAO
|
|
1
|
+
{"version":3,"file":"compact-analyze-payload.d.ts","sourceRoot":"","sources":["../src/compact-analyze-payload.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AAqBjD,wBAAgB,0BAA0B,CAAC,MAAM,EAAE,aAAa,EAAE,iBAAiB,EAAE,OAAO;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;qBAkFs3L,CAAC;2BAA6C,CAAC;0BAA4C,CAAC;yBAA2C,CAAC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;4BAAkqT,CAAC;sBAA4C,CAAC;sBAA4C,CAAC;iBAAuC,CAAC;;;;;;;;;;;;;;;;;uBAAghB,CAAC;;;;;;;;;;;;;;;;;;;;;;;;;;EAD/4gB"}
|
|
@@ -78,6 +78,14 @@ export function buildCompactAnalyzePayload(result, includeFullReport) {
|
|
|
78
78
|
pagesSkipped: result.routeInventory.pagesSkipped,
|
|
79
79
|
budgetExceeded: result.routeInventory.budgetExceeded,
|
|
80
80
|
},
|
|
81
|
+
...(result.repoInventory?.automationMaturity && {
|
|
82
|
+
automationMaturitySummary: {
|
|
83
|
+
overallScore: result.repoInventory.automationMaturity.overallScore,
|
|
84
|
+
level: result.repoInventory.automationMaturity.level,
|
|
85
|
+
label: result.repoInventory.automationMaturity.label,
|
|
86
|
+
topRecommendations: result.repoInventory.automationMaturity.topRecommendations,
|
|
87
|
+
},
|
|
88
|
+
}),
|
|
81
89
|
repoInventory: result.repoInventory,
|
|
82
90
|
decisionLogPreview: result.decisionLog.slice(-8),
|
|
83
91
|
...(result.detectedAuth !== undefined && { detectedAuth: result.detectedAuth }),
|
package/dist/index.js
CHANGED
|
@@ -1,11 +1,38 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
|
|
2
|
+
// Naming convention: qulib_{verb}_{noun}. Existing 3 tools predate this convention and retain their names for backwards compatibility.
|
|
3
|
+
//
|
|
4
|
+
// TODO(@qulib/mcp): When tool count exceeds ~10, evaluate MCP resource types and
|
|
5
|
+
// prompt templates as complementary surfaces. Tool explosion is an MCP anti-pattern.
|
|
6
|
+
// Prefer composable tools over one-tool-per-capability.
|
|
7
|
+
//
|
|
8
|
+
// TODO(@qulib/mcp): Evaluate tool-level permission modeling when MCP spec stabilizes.
|
|
9
|
+
// Today: all tools are equally trusted. Future: read-only tools (detect_auth, explore_auth)
|
|
10
|
+
// vs. write-capable tools (analyze_app with writeArtifacts) should carry different trust levels.
|
|
11
|
+
import { isAbsolute, normalize, resolve } from 'node:path';
|
|
12
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
3
13
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
4
|
-
import {
|
|
5
|
-
import { analyzeApp, detectAuth, exploreAuth } from '@qulib/core';
|
|
14
|
+
import { analyzeApp, detectAuth, exploreAuth, scanRepo, computeAutomationMaturity, } from '@qulib/core';
|
|
6
15
|
import { z } from 'zod';
|
|
7
16
|
import { buildCompactAnalyzePayload } from './compact-analyze-payload.js';
|
|
8
17
|
import { log } from './logger.js';
|
|
18
|
+
function toolError(code, message, detail) {
|
|
19
|
+
return {
|
|
20
|
+
content: [
|
|
21
|
+
{
|
|
22
|
+
type: 'text',
|
|
23
|
+
text: JSON.stringify({ error: { code, message, detail: detail ?? null } }, null, 2),
|
|
24
|
+
},
|
|
25
|
+
],
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
function stderrTelemetrySink() {
|
|
29
|
+
return {
|
|
30
|
+
emit(event) {
|
|
31
|
+
process.stderr.write(`${JSON.stringify(event)}\n`);
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
const telemetrySink = process.env.QULIB_TELEMETRY_STDERR === '1' ? stderrTelemetrySink() : undefined;
|
|
9
36
|
const mcpProgressLog = {
|
|
10
37
|
info: (message) => log.info(message),
|
|
11
38
|
warn: (message) => log.warn(message),
|
|
@@ -37,115 +64,51 @@ const AnalyzeInputSchema = z.object({
|
|
|
37
64
|
testGenerationLimit: z.number().int().positive().max(50).optional(),
|
|
38
65
|
enableLlmScenarios: z.boolean().optional(),
|
|
39
66
|
});
|
|
40
|
-
const
|
|
67
|
+
const ScoreAutomationInputSchema = z.object({
|
|
68
|
+
repoPath: z.string().describe('Absolute path to the automation repository on the MCP host filesystem'),
|
|
69
|
+
includeFullDimensions: z
|
|
70
|
+
.boolean()
|
|
71
|
+
.optional()
|
|
72
|
+
.describe('When true, includes all dimension detail. Default false returns top recommendations only.'),
|
|
73
|
+
});
|
|
74
|
+
function validateAbsoluteRepoPath(repoPath) {
|
|
75
|
+
const norm = normalize(repoPath.trim());
|
|
76
|
+
if (!norm || norm === '.' || norm.includes('..')) {
|
|
77
|
+
throw new Error('repoPath must be absolute and must not contain path traversal segments');
|
|
78
|
+
}
|
|
79
|
+
if (!isAbsolute(norm)) {
|
|
80
|
+
throw new Error('repoPath must be an absolute path on the MCP host');
|
|
81
|
+
}
|
|
82
|
+
return resolve(norm);
|
|
83
|
+
}
|
|
84
|
+
const mcpServer = new McpServer({
|
|
41
85
|
name: 'qulib-mcp',
|
|
42
|
-
version: '0.1
|
|
86
|
+
version: '0.4.1',
|
|
87
|
+
description: 'Qulib QA intelligence platform — gap analysis, auth exploration, and quality scoring for deployed web applications',
|
|
43
88
|
}, {
|
|
44
89
|
capabilities: {
|
|
45
90
|
tools: {},
|
|
46
91
|
},
|
|
47
92
|
});
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
properties: {
|
|
68
|
-
url: { type: 'string', description: 'Full URL of the deployed app' },
|
|
69
|
-
maxPagesToScan: { type: 'number', description: 'Max pages to crawl (default 10)' },
|
|
70
|
-
timeoutMs: { type: 'number', description: 'Per-page timeout in milliseconds (default 30000)' },
|
|
71
|
-
auth: {
|
|
72
|
-
description: 'Optional auth: form-login credentials or path to a storage state JSON from `qulib auth init`',
|
|
73
|
-
oneOf: [
|
|
74
|
-
{
|
|
75
|
-
type: 'object',
|
|
76
|
-
properties: {
|
|
77
|
-
type: { type: 'string', enum: ['form-login'] },
|
|
78
|
-
loginUrl: { type: 'string' },
|
|
79
|
-
username: { type: 'string' },
|
|
80
|
-
password: { type: 'string' },
|
|
81
|
-
usernameSelector: { type: 'string' },
|
|
82
|
-
passwordSelector: { type: 'string' },
|
|
83
|
-
submitSelector: { type: 'string' },
|
|
84
|
-
successUrlContains: { type: 'string' },
|
|
85
|
-
},
|
|
86
|
-
required: [
|
|
87
|
-
'type',
|
|
88
|
-
'loginUrl',
|
|
89
|
-
'username',
|
|
90
|
-
'password',
|
|
91
|
-
'usernameSelector',
|
|
92
|
-
'passwordSelector',
|
|
93
|
-
'submitSelector',
|
|
94
|
-
],
|
|
95
|
-
},
|
|
96
|
-
{
|
|
97
|
-
type: 'object',
|
|
98
|
-
properties: {
|
|
99
|
-
type: { type: 'string', enum: ['storage-state'] },
|
|
100
|
-
path: { type: 'string', description: 'Absolute path to storage state JSON on the MCP host' },
|
|
101
|
-
},
|
|
102
|
-
required: ['type', 'path'],
|
|
103
|
-
},
|
|
104
|
-
],
|
|
105
|
-
},
|
|
106
|
-
includeFullReport: {
|
|
107
|
-
type: 'boolean',
|
|
108
|
-
description: 'When true, returns the full analyzeApp payload including all scenarios. Default false returns a summary-first shape.',
|
|
109
|
-
},
|
|
110
|
-
llmTokenBudget: {
|
|
111
|
-
type: 'number',
|
|
112
|
-
description: 'Legacy per-completion max output tokens (same as HarnessConfig.llmTokenBudget). Prefer llmMaxOutputTokensPerCall when both are set.',
|
|
113
|
-
},
|
|
114
|
-
llmMaxOutputTokensPerCall: {
|
|
115
|
-
type: 'number',
|
|
116
|
-
description: 'Optional override for per-completion max output tokens (maps to HarnessConfig.llmMaxOutputTokensPerCall).',
|
|
117
|
-
},
|
|
118
|
-
testGenerationLimit: { type: 'number', description: 'Max gaps fed into scenario generation (default 5).' },
|
|
119
|
-
enableLlmScenarios: {
|
|
120
|
-
type: 'boolean',
|
|
121
|
-
description: 'When false, never calls an LLM for scenarios (default true when omitted).',
|
|
122
|
-
},
|
|
123
|
-
},
|
|
124
|
-
required: ['url'],
|
|
125
|
-
},
|
|
126
|
-
},
|
|
127
|
-
{
|
|
128
|
-
name: 'detect_auth',
|
|
129
|
-
description: 'Detect the authentication pattern used by a deployed web app. Returns the auth type (form-login, oauth, magic-link, none, or unknown) and a recommendation for how to configure qulib to scan past it.',
|
|
130
|
-
inputSchema: {
|
|
131
|
-
type: 'object',
|
|
132
|
-
properties: {
|
|
133
|
-
url: { type: 'string', description: 'Full URL of the deployed app or login page' },
|
|
134
|
-
timeoutMs: { type: 'number', description: 'Page load timeout in milliseconds (default 15000)' },
|
|
135
|
-
},
|
|
136
|
-
required: ['url'],
|
|
137
|
-
},
|
|
138
|
-
},
|
|
139
|
-
],
|
|
140
|
-
}));
|
|
141
|
-
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
142
|
-
if (request.params.name === 'explore_auth') {
|
|
143
|
-
const { url, timeoutMs } = z
|
|
144
|
-
.object({
|
|
145
|
-
url: z.string().url(),
|
|
146
|
-
timeoutMs: z.number().int().positive().optional(),
|
|
147
|
-
})
|
|
148
|
-
.parse(request.params.arguments ?? {});
|
|
93
|
+
if (!process.env.ANTHROPIC_API_KEY) {
|
|
94
|
+
process.stderr.write('[qulib] WARN ANTHROPIC_API_KEY is not set.\n' +
|
|
95
|
+
' LLM scenario generation will be skipped — only template scenarios will run.\n' +
|
|
96
|
+
' Add your key to the env block in your MCP host config.\n' +
|
|
97
|
+
' See: https://github.com/TapeshN/qulib#setup\n');
|
|
98
|
+
}
|
|
99
|
+
const ExploreAuthToolInputSchema = z.object({
|
|
100
|
+
url: z.string().url().describe('Full URL of the deployed app or login page'),
|
|
101
|
+
timeoutMs: z.number().int().positive().optional().describe('Navigation timeout in milliseconds (default 20000)'),
|
|
102
|
+
});
|
|
103
|
+
const DetectAuthToolInputSchema = z.object({
|
|
104
|
+
url: z.string().url().describe('Full URL of the deployed app or login page'),
|
|
105
|
+
timeoutMs: z.number().int().positive().optional().describe('Page load timeout in milliseconds (default 15000)'),
|
|
106
|
+
});
|
|
107
|
+
mcpServer.registerTool('explore_auth', {
|
|
108
|
+
description: 'Use this BEFORE analyze_app when scanning unfamiliar apps. Returns all detected sign-in paths with per-path requirements describing what credentials or actions the agent must collect from the user before calling analyze_app. Combines built-in OAuth/SSO labels, user-local patterns from ~/.qulib/providers.json, and heuristic unknown buttons.',
|
|
109
|
+
inputSchema: ExploreAuthToolInputSchema,
|
|
110
|
+
}, async ({ url, timeoutMs }) => {
|
|
111
|
+
try {
|
|
149
112
|
log.info(`explore_auth tool url=${url} timeoutMs=${timeoutMs ?? 20000}`);
|
|
150
113
|
const result = await exploreAuth(url, timeoutMs, mcpProgressLog);
|
|
151
114
|
log.info(`explore_auth tool done authRequired=${result.authRequired} paths=${result.authPaths.length}`);
|
|
@@ -153,13 +116,17 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
153
116
|
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
154
117
|
};
|
|
155
118
|
}
|
|
156
|
-
|
|
157
|
-
const
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
119
|
+
catch (err) {
|
|
120
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
121
|
+
log.error(`explore_auth failed: ${msg}`);
|
|
122
|
+
return toolError('QULIB_AUTH_EXPLORE_FAILED', msg, err instanceof Error ? err.stack : undefined);
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
mcpServer.registerTool('detect_auth', {
|
|
126
|
+
description: 'Detect the authentication pattern used by a deployed web app. Returns the auth type (form-login, oauth, magic-link, none, or unknown) and a recommendation for how to configure qulib to scan past it.',
|
|
127
|
+
inputSchema: DetectAuthToolInputSchema,
|
|
128
|
+
}, async ({ url, timeoutMs }) => {
|
|
129
|
+
try {
|
|
163
130
|
log.info(`detect_auth tool url=${url} timeoutMs=${timeoutMs ?? 15000}`);
|
|
164
131
|
const result = await detectAuth(url, timeoutMs, mcpProgressLog);
|
|
165
132
|
const providerSummary = result.oauthButtons.length > 0
|
|
@@ -170,63 +137,113 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
170
137
|
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
171
138
|
};
|
|
172
139
|
}
|
|
173
|
-
|
|
174
|
-
|
|
140
|
+
catch (err) {
|
|
141
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
142
|
+
log.error(`detect_auth failed: ${msg}`);
|
|
143
|
+
return toolError('QULIB_AUTH_DETECT_FAILED', msg, err instanceof Error ? err.stack : undefined);
|
|
175
144
|
}
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
145
|
+
});
|
|
146
|
+
mcpServer.registerTool('analyze_app', {
|
|
147
|
+
description: 'Analyze a deployed web app for quality gaps. Default response is summary-first (top gaps, cost summary, next checks). Set includeFullReport for the full gapAnalysis. Optional llmMaxOutputTokensPerCall / llmTokenBudget (legacy), testGenerationLimit, enableLlmScenarios align with @qulib/core HarnessConfig.',
|
|
148
|
+
inputSchema: AnalyzeInputSchema,
|
|
149
|
+
}, async (input) => {
|
|
150
|
+
try {
|
|
151
|
+
const successIndicator = input.auth?.type === 'form-login' &&
|
|
152
|
+
input.auth.successUrlContains !== undefined &&
|
|
153
|
+
input.auth.successUrlContains !== ''
|
|
154
|
+
? { urlContains: input.auth.successUrlContains }
|
|
155
|
+
: {};
|
|
156
|
+
const authConfig = input.auth?.type === 'form-login'
|
|
157
|
+
? {
|
|
158
|
+
type: 'form-login',
|
|
159
|
+
loginUrl: input.auth.loginUrl,
|
|
160
|
+
credentials: { username: input.auth.username, password: input.auth.password },
|
|
161
|
+
selectors: {
|
|
162
|
+
username: input.auth.usernameSelector,
|
|
163
|
+
password: input.auth.passwordSelector,
|
|
164
|
+
submit: input.auth.submitSelector,
|
|
165
|
+
},
|
|
166
|
+
successIndicator,
|
|
167
|
+
}
|
|
168
|
+
: input.auth?.type === 'storage-state'
|
|
169
|
+
? { type: 'storage-state', path: input.auth.path }
|
|
170
|
+
: undefined;
|
|
171
|
+
const harnessConfig = {
|
|
172
|
+
maxPagesToScan: input.maxPagesToScan ?? 10,
|
|
173
|
+
maxDepth: 3,
|
|
174
|
+
minPagesForConfidence: 3,
|
|
175
|
+
timeoutMs: input.timeoutMs ?? 30000,
|
|
176
|
+
retryCount: 0,
|
|
177
|
+
llmTokenBudget: input.llmTokenBudget ?? input.llmMaxOutputTokensPerCall ?? 4096,
|
|
178
|
+
llmMaxOutputTokensPerCall: input.llmMaxOutputTokensPerCall,
|
|
179
|
+
testGenerationLimit: input.testGenerationLimit ?? 5,
|
|
180
|
+
enableLlmScenarios: input.enableLlmScenarios !== false,
|
|
181
|
+
readOnlyMode: true,
|
|
182
|
+
requireHumanReview: false,
|
|
183
|
+
failOnConsoleError: false,
|
|
184
|
+
explorer: 'playwright',
|
|
185
|
+
defaultAdapter: 'playwright',
|
|
186
|
+
adapters: ['playwright'],
|
|
187
|
+
...(authConfig && { auth: authConfig }),
|
|
188
|
+
};
|
|
189
|
+
const result = await analyzeApp({
|
|
190
|
+
url: input.url,
|
|
191
|
+
writeArtifacts: false,
|
|
192
|
+
config: harnessConfig,
|
|
193
|
+
progressLog: mcpProgressLog,
|
|
194
|
+
telemetry: telemetrySink,
|
|
195
|
+
});
|
|
196
|
+
const payload = buildCompactAnalyzePayload(result, input.includeFullReport === true);
|
|
197
|
+
return {
|
|
198
|
+
content: [
|
|
199
|
+
{
|
|
200
|
+
type: 'text',
|
|
201
|
+
text: JSON.stringify(payload, null, 2),
|
|
202
|
+
},
|
|
203
|
+
],
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
catch (err) {
|
|
207
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
208
|
+
log.error(`analyze_app failed: ${msg}`);
|
|
209
|
+
return toolError('QULIB_SCAN_FAILED', msg, err instanceof Error ? err.stack : undefined);
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
mcpServer.registerTool('qulib_score_automation', {
|
|
213
|
+
description: 'Score an automation repository for QA maturity across six dimensions: test coverage breadth, framework adoption, test-id hygiene, CI integration, auth test coverage, and component test ratio. Returns an overall score (0–100), maturity level (L1–L5), and prioritized recommendations.',
|
|
214
|
+
inputSchema: ScoreAutomationInputSchema,
|
|
215
|
+
}, async ({ repoPath, includeFullDimensions }) => {
|
|
216
|
+
try {
|
|
217
|
+
// Security: repoPath is an absolute path on the MCP host. We validate it is absolute
|
|
218
|
+
// and does not contain path traversal sequences. The MCP host is responsible for ensuring
|
|
219
|
+
// the path is within an allowed directory. We do not enforce a sandbox here — that is a
|
|
220
|
+
// host-level concern.
|
|
221
|
+
const abs = validateAbsoluteRepoPath(repoPath);
|
|
222
|
+
log.info(`qulib_score_automation repoPath=${abs}`);
|
|
223
|
+
const repo = await scanRepo(abs);
|
|
224
|
+
const maturity = computeAutomationMaturity(repo);
|
|
225
|
+
const payload = includeFullDimensions === true
|
|
226
|
+
? maturity
|
|
227
|
+
: {
|
|
228
|
+
overallScore: maturity.overallScore,
|
|
229
|
+
level: maturity.level,
|
|
230
|
+
label: maturity.label,
|
|
231
|
+
topRecommendations: maturity.topRecommendations,
|
|
232
|
+
repoPath: maturity.repoPath,
|
|
233
|
+
computedAt: maturity.computedAt,
|
|
234
|
+
};
|
|
235
|
+
return {
|
|
236
|
+
content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }],
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
catch (err) {
|
|
240
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
241
|
+
if (msg.includes('repoPath must')) {
|
|
242
|
+
return toolError('QULIB_INPUT_INVALID', msg, undefined);
|
|
193
243
|
}
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
const harnessConfig = {
|
|
198
|
-
maxPagesToScan: input.maxPagesToScan ?? 10,
|
|
199
|
-
maxDepth: 3,
|
|
200
|
-
minPagesForConfidence: 3,
|
|
201
|
-
timeoutMs: input.timeoutMs ?? 30000,
|
|
202
|
-
retryCount: 0,
|
|
203
|
-
llmTokenBudget: input.llmTokenBudget ?? input.llmMaxOutputTokensPerCall ?? 4096,
|
|
204
|
-
llmMaxOutputTokensPerCall: input.llmMaxOutputTokensPerCall,
|
|
205
|
-
testGenerationLimit: input.testGenerationLimit ?? 5,
|
|
206
|
-
enableLlmScenarios: input.enableLlmScenarios !== false,
|
|
207
|
-
readOnlyMode: true,
|
|
208
|
-
requireHumanReview: false,
|
|
209
|
-
failOnConsoleError: false,
|
|
210
|
-
explorer: 'playwright',
|
|
211
|
-
defaultAdapter: 'playwright',
|
|
212
|
-
adapters: ['playwright'],
|
|
213
|
-
...(authConfig && { auth: authConfig }),
|
|
214
|
-
};
|
|
215
|
-
const result = await analyzeApp({
|
|
216
|
-
url: input.url,
|
|
217
|
-
writeArtifacts: false,
|
|
218
|
-
config: harnessConfig,
|
|
219
|
-
progressLog: mcpProgressLog,
|
|
220
|
-
});
|
|
221
|
-
const payload = buildCompactAnalyzePayload(result, input.includeFullReport === true);
|
|
222
|
-
return {
|
|
223
|
-
content: [
|
|
224
|
-
{
|
|
225
|
-
type: 'text',
|
|
226
|
-
text: JSON.stringify(payload, null, 2),
|
|
227
|
-
},
|
|
228
|
-
],
|
|
229
|
-
};
|
|
244
|
+
log.error(`qulib_score_automation failed: ${msg}`);
|
|
245
|
+
return toolError('QULIB_REPO_SCORE_FAILED', msg, err instanceof Error ? err.stack : undefined);
|
|
246
|
+
}
|
|
230
247
|
});
|
|
231
248
|
const transport = new StdioServerTransport();
|
|
232
|
-
await
|
|
249
|
+
await mcpServer.connect(transport);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@qulib/mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"description": "MCP server for Qulib — AI-callable QA gap analysis",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Tapesh Nagarwal",
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
35
|
"@modelcontextprotocol/sdk": "^1.0.0",
|
|
36
|
-
"@qulib/core": "0.
|
|
36
|
+
"@qulib/core": "0.4.1",
|
|
37
37
|
"zod": "^3.23.0"
|
|
38
38
|
},
|
|
39
39
|
"devDependencies": {
|