@qulib/mcp 0.2.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -7,7 +7,7 @@
7
7
  Tools:
8
8
 
9
9
  - **`explore_auth(url, timeoutMs?)`** — list all sign-in paths (OAuth, unknown SSO heuristics, forms, magic link) and what the agent must collect before `analyze_app`. Prefer this on unfamiliar apps.
10
- - **`analyze_app(url, auth?)`** — full quality scan (optional form-login or storage-state auth).
10
+ - **`analyze_app`** — quality scan (optional form-login or storage-state auth). **Default payload is summary-first:** `summary`, `topGaps`, `costIntelligenceSummary`, `nextDeterministicChecks`, small previews. Set **`includeFullReport: true`** for the full `analyzeApp` result (all scenarios). Optional harness overrides: **`llmMaxOutputTokensPerCall`**, **`llmTokenBudget`** (legacy), **`testGenerationLimit`**, **`enableLlmScenarios`** (default true when omitted).
11
11
  - **`detect_auth(url, timeoutMs?)`** — single-pattern auth guess with a short recommendation (lighter than `explore_auth`).
12
12
 
13
13
  Returns from `analyze_app`:
@@ -61,6 +61,30 @@ When the model sees **`unrecognizedButtons`**, it can ask the user to register a
61
61
 
62
62
  `qulib auth providers add --id <kebab-id> --label "..." --pattern "..."` — patterns are saved under **`~/.qulib/providers.json`** and merged with the built-in list on the next `explore_auth` / `explore-auth`. Nothing is auto-written without an explicit `providers add`.
63
63
 
64
+ ## Compact vs full `analyze_app` response
65
+
66
+ | | Default (`includeFullReport` omitted or false) | `includeFullReport: true` |
67
+ |--|--|--|
68
+ | Size | Small: top gaps, cost summary, next checks | Full `gapAnalysis` with every scenario |
69
+ | When to use | Routine agent turns, chat context limits | Deep dives, exporting full scenario JSON |
70
+
71
+ Example (full):
72
+
73
+ ```json
74
+ { "url": "https://example.com", "includeFullReport": true }
75
+ ```
76
+
77
+ Example (tighter LLM envelope from MCP):
78
+
79
+ ```json
80
+ {
81
+ "url": "https://example.com",
82
+ "llmMaxOutputTokensPerCall": 2048,
83
+ "testGenerationLimit": 5,
84
+ "enableLlmScenarios": true
85
+ }
86
+ ```
87
+
64
88
  ## Example usage
65
89
 
66
90
  Ask Claude:
@@ -0,0 +1,144 @@
1
+ import type { AnalyzeResult } from '@qulib/core';
2
+ export declare function buildCompactAnalyzePayload(result: AnalyzeResult, includeFullReport: boolean): AnalyzeResult | {
3
+ includeFullReport: boolean;
4
+ note: string;
5
+ detectedAuth?: {
6
+ type: "unknown" | "form-login" | "none" | "oauth" | "magic-link";
7
+ loginUrl: string | null;
8
+ hasAuth: boolean;
9
+ provider: string | null;
10
+ observedSelectors: {
11
+ usernameSelector: string | null;
12
+ passwordSelector: string | null;
13
+ submitSelector: string | null;
14
+ } | null;
15
+ oauthButtons: {
16
+ provider: string;
17
+ text: string;
18
+ }[];
19
+ recommendation: string;
20
+ } | undefined;
21
+ summary: {
22
+ status: import("@qulib/core").AnalyzeStatus;
23
+ coverageScore: number | null;
24
+ releaseConfidence: number | null;
25
+ mode: "url-only" | "url-repo" | "auth-required";
26
+ coveragePagesScanned: number;
27
+ coverageBudgetExceeded: boolean;
28
+ coverageWarning: "auth-required" | "budget-exceeded" | "low-coverage" | "navigation-failures" | null;
29
+ gapCount: number;
30
+ scenarioCount: number;
31
+ generatedTestCount: number;
32
+ publicSurface: {
33
+ pageCount: number;
34
+ gapCount: number;
35
+ accessibilityViolationCount: number;
36
+ brokenLinkCount: number;
37
+ } | null;
38
+ };
39
+ topGaps: {
40
+ path: string;
41
+ category: "untested-route" | "a11y" | "console-error" | "broken-link" | "auth-surface" | "coverage";
42
+ severity: "high" | "medium" | "low" | "critical";
43
+ reason: string;
44
+ }[];
45
+ costIntelligenceSummary: {
46
+ maxOutputTokensPerLlmCall: number;
47
+ usageDataQuality: "none" | "actual" | "estimated" | "mixed";
48
+ totalInputTokens: number;
49
+ totalOutputTokens: number;
50
+ budgetWarningCount: number;
51
+ maturityLevel: number;
52
+ maturityLabel: string;
53
+ } | null;
54
+ costIntelligence: {
55
+ maxOutputTokensPerLlmCall: number;
56
+ budgetRole: "max-output-tokens-per-llm-call";
57
+ records: {
58
+ provider: string;
59
+ model: string;
60
+ inputTokens: number;
61
+ outputTokens: number;
62
+ operationType: "scenario-generation";
63
+ timestamp: string;
64
+ dataQuality: "none" | "actual" | "estimated" | "mixed";
65
+ estimatedCostUsd?: number | undefined;
66
+ promptHash?: string | undefined;
67
+ resultHash?: string | undefined;
68
+ notes?: string | undefined;
69
+ }[];
70
+ budgetWarnings: string[];
71
+ usageSummary: {
72
+ dataQuality: "none" | "actual" | "estimated" | "mixed";
73
+ totalInputTokens: number;
74
+ totalOutputTokens: number;
75
+ };
76
+ repeatedOperations: {
77
+ recommendation: string;
78
+ promptHash: string;
79
+ count: number;
80
+ }[];
81
+ deterministicMaturity: {
82
+ label: string;
83
+ level: number;
84
+ rationale: string;
85
+ ceilingNote?: string | undefined;
86
+ };
87
+ conversionRecommendations: string[];
88
+ } | null;
89
+ nextDeterministicChecks: string[];
90
+ gapAnalysisPreview: {
91
+ analyzedAt: string;
92
+ gapsSample: {
93
+ path: string;
94
+ id: string;
95
+ severity: "high" | "medium" | "low" | "critical";
96
+ reason: string;
97
+ category: "untested-route" | "a11y" | "console-error" | "broken-link" | "auth-surface" | "coverage";
98
+ recommendation?: string | undefined;
99
+ description?: string | undefined;
100
+ }[];
101
+ scenariosOmitted: number;
102
+ generatedTestsOmitted: number;
103
+ };
104
+ routeInventorySummary: {
105
+ scannedAt: string;
106
+ baseUrl: string;
107
+ routeCount: number;
108
+ pagesSkipped: number;
109
+ budgetExceeded: boolean;
110
+ };
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
+ };
144
+ //# sourceMappingURL=compact-analyze-payload.d.ts.map
@@ -0,0 +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;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;4BA0E0igB,CAAC;sBAA4C,CAAC;sBAA4C,CAAC;iBAAuC,CAAC;;;;;;;;;;;;;;;;;uBAAghB,CAAC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;qBAAt7e,CAAC;2BAA6C,CAAC;0BAA4C,CAAC;yBAA2C,CAAC;;;;;;;;;;EAD3+C"}
@@ -0,0 +1,87 @@
1
+ const severityOrder = { critical: 0, high: 1, medium: 2, low: 3 };
2
+ function topGapsBySeverity(gaps, limit) {
3
+ return [...gaps].sort((a, b) => severityOrder[a.severity] - severityOrder[b.severity]).slice(0, limit);
4
+ }
5
+ function nextDeterministicChecks(gaps, conversion) {
6
+ const out = [];
7
+ const byCat = new Map();
8
+ for (const g of gaps) {
9
+ byCat.set(g.category, (byCat.get(g.category) ?? 0) + 1);
10
+ }
11
+ for (const [cat, n] of [...byCat.entries()].sort((a, b) => b[1] - a[1]).slice(0, 3)) {
12
+ out.push(`Add or tighten deterministic coverage for **${cat}** (${n} gap(s) in this scan).`);
13
+ }
14
+ out.push(...conversion.slice(0, 2));
15
+ return out.slice(0, 5);
16
+ }
17
+ export function buildCompactAnalyzePayload(result, includeFullReport) {
18
+ if (includeFullReport) {
19
+ return result;
20
+ }
21
+ const g = result.gapAnalysis;
22
+ const ci = g.costIntelligence;
23
+ const top = topGapsBySeverity(result.gaps, 5);
24
+ const costSummary = ci
25
+ ? {
26
+ maxOutputTokensPerLlmCall: ci.maxOutputTokensPerLlmCall,
27
+ usageDataQuality: ci.usageSummary.dataQuality,
28
+ totalInputTokens: ci.usageSummary.totalInputTokens,
29
+ totalOutputTokens: ci.usageSummary.totalOutputTokens,
30
+ budgetWarningCount: ci.budgetWarnings.length,
31
+ maturityLevel: ci.deterministicMaturity.level,
32
+ maturityLabel: ci.deterministicMaturity.label,
33
+ }
34
+ : null;
35
+ const ps = result.publicSurface;
36
+ return {
37
+ summary: {
38
+ status: result.status,
39
+ coverageScore: result.coverageScore,
40
+ releaseConfidence: g.releaseConfidence,
41
+ mode: g.mode,
42
+ coveragePagesScanned: g.coveragePagesScanned,
43
+ coverageBudgetExceeded: g.coverageBudgetExceeded,
44
+ coverageWarning: g.coverageWarning ?? null,
45
+ gapCount: g.gaps.length,
46
+ scenarioCount: g.scenarios.length,
47
+ generatedTestCount: g.generatedTests.length,
48
+ publicSurface: ps === null
49
+ ? null
50
+ : {
51
+ pageCount: ps.pages.length,
52
+ gapCount: ps.gaps.length,
53
+ accessibilityViolationCount: ps.accessibilityViolations.length,
54
+ brokenLinkCount: ps.brokenLinks.length,
55
+ },
56
+ },
57
+ topGaps: top.map((x) => ({
58
+ path: x.path,
59
+ category: x.category,
60
+ severity: x.severity,
61
+ reason: x.reason,
62
+ })),
63
+ costIntelligenceSummary: costSummary,
64
+ costIntelligence: ci ?? null,
65
+ nextDeterministicChecks: ci
66
+ ? nextDeterministicChecks(result.gaps, ci.conversionRecommendations)
67
+ : nextDeterministicChecks(result.gaps, []),
68
+ gapAnalysisPreview: {
69
+ analyzedAt: g.analyzedAt,
70
+ gapsSample: g.gaps.slice(0, 8),
71
+ scenariosOmitted: g.scenarios.length,
72
+ generatedTestsOmitted: g.generatedTests.length,
73
+ },
74
+ routeInventorySummary: {
75
+ scannedAt: result.routeInventory.scannedAt,
76
+ baseUrl: result.routeInventory.baseUrl,
77
+ routeCount: result.routeInventory.routes.length,
78
+ pagesSkipped: result.routeInventory.pagesSkipped,
79
+ budgetExceeded: result.routeInventory.budgetExceeded,
80
+ },
81
+ repoInventory: result.repoInventory,
82
+ decisionLogPreview: result.decisionLog.slice(-8),
83
+ ...(result.detectedAuth !== undefined && { detectedAuth: result.detectedAuth }),
84
+ includeFullReport: false,
85
+ note: 'Summary-first payload. Pass includeFullReport: true for full gapAnalysis (all scenarios and generatedTests).',
86
+ };
87
+ }
package/dist/index.js CHANGED
@@ -4,6 +4,14 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
4
4
  import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
5
5
  import { analyzeApp, detectAuth, exploreAuth } from '@qulib/core';
6
6
  import { z } from 'zod';
7
+ import { buildCompactAnalyzePayload } from './compact-analyze-payload.js';
8
+ import { log } from './logger.js';
9
+ const mcpProgressLog = {
10
+ info: (message) => log.info(message),
11
+ warn: (message) => log.warn(message),
12
+ error: (message) => log.error(message),
13
+ debug: (message) => log.debug(message),
14
+ };
7
15
  const FormLoginMcpAuthSchema = z.object({
8
16
  type: z.literal('form-login'),
9
17
  loginUrl: z.string().url(),
@@ -23,6 +31,11 @@ const AnalyzeInputSchema = z.object({
23
31
  maxPagesToScan: z.number().int().min(1).max(50).optional(),
24
32
  timeoutMs: z.number().int().positive().optional(),
25
33
  auth: z.discriminatedUnion('type', [FormLoginMcpAuthSchema, StorageStateMcpAuthSchema]).optional(),
34
+ includeFullReport: z.boolean().optional(),
35
+ llmTokenBudget: z.number().int().positive().optional(),
36
+ llmMaxOutputTokensPerCall: z.number().int().positive().optional(),
37
+ testGenerationLimit: z.number().int().positive().max(50).optional(),
38
+ enableLlmScenarios: z.boolean().optional(),
26
39
  });
27
40
  const server = new Server({
28
41
  name: 'qulib-mcp',
@@ -48,7 +61,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
48
61
  },
49
62
  {
50
63
  name: 'analyze_app',
51
- description: 'Analyze a deployed web app for quality gaps. Returns a release confidence score (0-100), accessibility violations, broken links, and prioritized risks. Supports optional form-login or storage-state (Playwright) authentication.',
64
+ 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.',
52
65
  inputSchema: {
53
66
  type: 'object',
54
67
  properties: {
@@ -90,6 +103,23 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
90
103
  },
91
104
  ],
92
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
+ },
93
123
  },
94
124
  required: ['url'],
95
125
  },
@@ -116,7 +146,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
116
146
  timeoutMs: z.number().int().positive().optional(),
117
147
  })
118
148
  .parse(request.params.arguments ?? {});
119
- const result = await exploreAuth(url, timeoutMs);
149
+ log.info(`explore_auth tool url=${url} timeoutMs=${timeoutMs ?? 20000}`);
150
+ const result = await exploreAuth(url, timeoutMs, mcpProgressLog);
151
+ log.info(`explore_auth tool done authRequired=${result.authRequired} paths=${result.authPaths.length}`);
120
152
  return {
121
153
  content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
122
154
  };
@@ -128,7 +160,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
128
160
  timeoutMs: z.number().int().positive().optional(),
129
161
  })
130
162
  .parse(request.params.arguments ?? {});
131
- const result = await detectAuth(url, timeoutMs);
163
+ log.info(`detect_auth tool url=${url} timeoutMs=${timeoutMs ?? 15000}`);
164
+ const result = await detectAuth(url, timeoutMs, mcpProgressLog);
165
+ const providerSummary = result.oauthButtons.length > 0
166
+ ? result.oauthButtons.map((b) => b.provider).join(', ')
167
+ : result.provider ?? 'none';
168
+ log.info(`detect_auth tool done type=${result.type} providers=${providerSummary} automatable=${result.type === 'form-login'}`);
132
169
  return {
133
170
  content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
134
171
  };
@@ -157,31 +194,36 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
157
194
  : input.auth?.type === 'storage-state'
158
195
  ? { type: 'storage-state', path: input.auth.path }
159
196
  : undefined;
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
+ };
160
215
  const result = await analyzeApp({
161
216
  url: input.url,
162
217
  writeArtifacts: false,
163
- config: {
164
- maxPagesToScan: input.maxPagesToScan ?? 10,
165
- maxDepth: 3,
166
- minPagesForConfidence: 3,
167
- timeoutMs: input.timeoutMs ?? 30000,
168
- retryCount: 0,
169
- llmTokenBudget: 1,
170
- testGenerationLimit: 1,
171
- readOnlyMode: true,
172
- requireHumanReview: false,
173
- failOnConsoleError: false,
174
- explorer: 'playwright',
175
- defaultAdapter: 'playwright',
176
- adapters: ['playwright'],
177
- ...(authConfig && { auth: authConfig }),
178
- },
218
+ config: harnessConfig,
219
+ progressLog: mcpProgressLog,
179
220
  });
221
+ const payload = buildCompactAnalyzePayload(result, input.includeFullReport === true);
180
222
  return {
181
223
  content: [
182
224
  {
183
225
  type: 'text',
184
- text: JSON.stringify(result, null, 2),
226
+ text: JSON.stringify(payload, null, 2),
185
227
  },
186
228
  ],
187
229
  };
@@ -0,0 +1,7 @@
1
+ export declare const log: {
2
+ info(message: string): void;
3
+ warn(message: string): void;
4
+ error(message: string): void;
5
+ debug(message: string): void;
6
+ };
7
+ //# sourceMappingURL=logger.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"logger.d.ts","sourceRoot":"","sources":["../src/logger.ts"],"names":[],"mappings":"AAiBA,eAAO,MAAM,GAAG;kBACA,MAAM,GAAG,IAAI;kBAGb,MAAM,GAAG,IAAI;mBAGZ,MAAM,GAAG,IAAI;mBAGb,MAAM,GAAG,IAAI;CAK7B,CAAC"}
package/dist/logger.js ADDED
@@ -0,0 +1,29 @@
1
+ function pad2(n) {
2
+ return String(n).padStart(2, '0');
3
+ }
4
+ function wallTime() {
5
+ const d = new Date();
6
+ return `${pad2(d.getHours())}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())}`;
7
+ }
8
+ function isDebugEnabled() {
9
+ return process.env.QULIB_DEBUG === '1';
10
+ }
11
+ function emit(level, message) {
12
+ process.stderr.write(`[qulib ${wallTime()}] ${level} ${message}\n`);
13
+ }
14
+ export const log = {
15
+ info(message) {
16
+ emit('INFO', message);
17
+ },
18
+ warn(message) {
19
+ emit('WARN', message);
20
+ },
21
+ error(message) {
22
+ emit('ERROR', message);
23
+ },
24
+ debug(message) {
25
+ if (isDebugEnabled()) {
26
+ emit('DEBUG', message);
27
+ }
28
+ },
29
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@qulib/mcp",
3
- "version": "0.2.2",
3
+ "version": "0.3.0",
4
4
  "description": "MCP server for Qulib — AI-callable QA gap analysis",
5
5
  "license": "MIT",
6
6
  "author": "Tapesh Nagarwal",
@@ -27,12 +27,13 @@
27
27
  "README.md"
28
28
  ],
29
29
  "scripts": {
30
- "build": "tsc && chmod +x dist/index.js",
31
- "dev": "tsx src/index.ts"
30
+ "build": "npm --prefix ../.. run build -w @qulib/core && tsc && chmod +x dist/index.js",
31
+ "dev": "tsx src/index.ts",
32
+ "test": "node --import tsx/esm --test src/compact-analyze-payload.test.ts"
32
33
  },
33
34
  "dependencies": {
34
- "@qulib/core": "0.2.2",
35
35
  "@modelcontextprotocol/sdk": "^1.0.0",
36
+ "@qulib/core": "0.3.0",
36
37
  "zod": "^3.23.0"
37
38
  },
38
39
  "devDependencies": {