@jrpool/kilotest 51.1.0 → 55.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/.devin/config.local.json +4 -1
- package/.github/workflows/ci.yml +5 -2
- package/.github/workflows/periodic-smoke-tests.yml +2 -2
- package/.github/workflows/publish-mcp.yml +1 -1
- package/README.md +5 -5
- package/api/requestFeature.test.ts +36 -0
- package/api/requestFeature.ts +26 -4
- package/api/requestRetest.test.ts +18 -0
- package/api/requestRetest.ts +4 -0
- package/api/requestTest.test.ts +50 -0
- package/api/requestTest.ts +25 -1
- package/capability.md +2 -2
- package/docs/AI-TOOLS.md +3 -3
- package/docs/SERVICE.md +23 -2
- package/env.example +32 -0
- package/index.test.ts +126 -0
- package/index.ts +74 -23
- package/llms-full.txt +1 -1
- package/llms.txt +1 -1
- package/mcp.test.ts +41 -4
- package/mcp.ts +67 -17
- package/openapi.yaml +5 -5
- package/package.json +2 -1
- package/scripts/generate-openapi.ts +1 -1
- package/test/setup.ts +5 -0
- package/util.test.ts +429 -0
- package/util.ts +182 -3
- package/web/requestRetest/index.ts +9 -0
- package/web/requestRetest/requestRetest.test.ts +14 -0
- package/web/requestTest/index.ts +9 -0
- package/web/requestTest/requestTest.test.ts +16 -0
- package/web/tutorialAI/index.ts +25 -3
- package/web/tutorialAI/tutorialAI.test.ts +32 -0
- package/web/tutorialWeb/index.ts +25 -3
- package/web/tutorialWeb/tutorialWeb.test.ts +32 -0
package/index.test.ts
CHANGED
|
@@ -19,6 +19,10 @@ process.env.FEATURE_REQUESTS_PATH = path.join(testCommentsDir, 'featureRequests.
|
|
|
19
19
|
process.env.TESTARO_WORKERS = JSON.stringify({
|
|
20
20
|
worker1: {secret: 'secret1', name: 'Worker One'}
|
|
21
21
|
});
|
|
22
|
+
// Allow internal targets by default, so tests that submit an ordinary https://example.com/...
|
|
23
|
+
// URL do not depend on real DNS/network access to pass the resolution check that
|
|
24
|
+
// isAllowedTarget performs. Tests of the check itself (below) override this per test.
|
|
25
|
+
process.env.ALLOW_INTERNAL_TARGETS = 'true';
|
|
22
26
|
// Blank the alert configuration unconditionally, so this file sends no real alert
|
|
23
27
|
// emails (e.g. from the requestTest/requestRetest/tutorial-comment tests below) even
|
|
24
28
|
// when run directly (e.g. `npx tsx --test index.test.ts`) rather than via `npm test`,
|
|
@@ -905,6 +909,21 @@ test('POST /requestTest.html with non-https URL returns an error', async () => {
|
|
|
905
909
|
assert.ok(res.body.includes('Invalid test request'));
|
|
906
910
|
});
|
|
907
911
|
|
|
912
|
+
test('POST /requestTest.html with a private-address URL returns an error unless internal targets are allowed', async () => {
|
|
913
|
+
delete process.env.ALLOW_INTERNAL_TARGETS;
|
|
914
|
+
try {
|
|
915
|
+
const res = await formRequest('POST', '/requestTest.html', {
|
|
916
|
+
description: 'Internal Page',
|
|
917
|
+
url: 'https://192.168.1.1/page',
|
|
918
|
+
why: 'Because accessibility matters'
|
|
919
|
+
});
|
|
920
|
+
assert.equal(res.statusCode, 400);
|
|
921
|
+
assert.ok(res.body.includes('Invalid test request'));
|
|
922
|
+
} finally {
|
|
923
|
+
process.env.ALLOW_INTERNAL_TARGETS = 'true';
|
|
924
|
+
}
|
|
925
|
+
});
|
|
926
|
+
|
|
908
927
|
test('POST /requestRetest.html/260202T0000/new with missing why returns an error', async () => {
|
|
909
928
|
const res = await formRequest('POST', '/requestRetest.html/260202T0000/new', {
|
|
910
929
|
why: ''
|
|
@@ -1147,6 +1166,57 @@ test('POST /worker/job with a queued job assigns it to the worker', {timeout: 50
|
|
|
1147
1166
|
await fs.unlink(path.join(claimedDir, jobFile)).catch(() => {});
|
|
1148
1167
|
});
|
|
1149
1168
|
|
|
1169
|
+
test('POST /worker/job with a queued job whose host now redirects to a disallowed target rejects it without assigning it', {timeout: 500}, async (t) => {
|
|
1170
|
+
delete process.env.ALLOW_INTERNAL_TARGETS;
|
|
1171
|
+
// Clean up claimed, queue, and failed directories.
|
|
1172
|
+
const claimedDir = path.join(fixtureDBDir, 'jobs', 'claimed');
|
|
1173
|
+
const queueDir = path.join(fixtureDBDir, 'jobs', 'queue');
|
|
1174
|
+
const failedDir = path.join(fixtureDBDir, 'jobs', 'failed');
|
|
1175
|
+
for (const dir of [claimedDir, queueDir]) {
|
|
1176
|
+
await fs.mkdir(dir, {recursive: true});
|
|
1177
|
+
const files = await fs.readdir(dir).catch(() => []);
|
|
1178
|
+
for (const file of files) {
|
|
1179
|
+
await fs.unlink(path.join(dir, file)).catch(() => {});
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1182
|
+
const jobFile = '260101T0000-mix.json';
|
|
1183
|
+
const failedJobPath = path.join(failedDir, jobFile);
|
|
1184
|
+
await fs.unlink(failedJobPath).catch(() => {});
|
|
1185
|
+
// The job's host still resolves publicly, but by the time it is dequeued for
|
|
1186
|
+
// assignment it has been reconfigured (e.g. by an attacker) to redirect to a
|
|
1187
|
+
// private address, which only a fetch-based check, not a DNS-only one, can see.
|
|
1188
|
+
t.mock.method(globalThis, 'fetch', async () => ({url: 'https://192.168.1.1/test'}) as Response);
|
|
1189
|
+
try {
|
|
1190
|
+
await fs.writeFile(path.join(queueDir, jobFile), JSON.stringify({
|
|
1191
|
+
id: '260101T0000-mix',
|
|
1192
|
+
target: {what: 'Test Page', url: 'https://example.com/test'},
|
|
1193
|
+
sources: {}
|
|
1194
|
+
}));
|
|
1195
|
+
const auth = Buffer.from('worker1:secret1').toString('base64');
|
|
1196
|
+
const res = await request('POST', '/worker/job', {}, {
|
|
1197
|
+
authorization: `Basic ${auth}`
|
|
1198
|
+
});
|
|
1199
|
+
// The worker should be told there is no job, not given the disallowed job.
|
|
1200
|
+
assert.equal(res.statusCode, 200);
|
|
1201
|
+
const body = jsonBody(res);
|
|
1202
|
+
assert.equal(body.id, undefined);
|
|
1203
|
+
// Wait for the async rename to complete.
|
|
1204
|
+
await new Promise<void>(resolve => setTimeout(resolve, 100));
|
|
1205
|
+
// The job should have been moved from queue to failed, never to claimed.
|
|
1206
|
+
const queueExists = await fs.access(path.join(queueDir, jobFile)).then(() => true).catch(() => false);
|
|
1207
|
+
assert.equal(queueExists, false, 'Job should be removed from queue');
|
|
1208
|
+
const claimedExists = await fs.access(path.join(claimedDir, jobFile)).then(() => true).catch(() => false);
|
|
1209
|
+
assert.equal(claimedExists, false, 'Job should not be moved to claimed directory');
|
|
1210
|
+
const failedExists = await fs.access(failedJobPath).then(() => true).catch(() => false);
|
|
1211
|
+
assert.ok(failedExists, 'Job should be moved to the failed directory');
|
|
1212
|
+
} finally {
|
|
1213
|
+
t.mock.reset();
|
|
1214
|
+
process.env.ALLOW_INTERNAL_TARGETS = 'true';
|
|
1215
|
+
// Clean up.
|
|
1216
|
+
await fs.unlink(failedJobPath).catch(() => {});
|
|
1217
|
+
}
|
|
1218
|
+
});
|
|
1219
|
+
|
|
1150
1220
|
// TESTS: worker/report valid submission
|
|
1151
1221
|
|
|
1152
1222
|
test('POST /worker/report with valid authentication and valid claimed job processes the report', {timeout: 500}, async () => {
|
|
@@ -1227,6 +1297,62 @@ test('POST /worker/report with a claimed job but an unusable report returns an e
|
|
|
1227
1297
|
await fs.unlink(failedJobPath).catch(() => {});
|
|
1228
1298
|
});
|
|
1229
1299
|
|
|
1300
|
+
test('POST /worker/report with a report on a disallowed target rejects it and reclassifies the job', {timeout: 500}, async () => {
|
|
1301
|
+
delete process.env.ALLOW_INTERNAL_TARGETS;
|
|
1302
|
+
// Use a unique job ID that does not conflict with existing fixtures.
|
|
1303
|
+
const jobID = '990101T0002-ssr';
|
|
1304
|
+
const reportPath = path.join(fixtureDBDir, 'reports', `${jobID}.json`);
|
|
1305
|
+
const claimedDir = path.join(fixtureDBDir, 'jobs', 'claimed');
|
|
1306
|
+
const failedDir = path.join(fixtureDBDir, 'jobs', 'failed');
|
|
1307
|
+
const jobFile = `${jobID}.json`;
|
|
1308
|
+
const claimedJobPath = path.join(claimedDir, jobFile);
|
|
1309
|
+
const failedJobPath = path.join(failedDir, jobFile);
|
|
1310
|
+
try {
|
|
1311
|
+
// Clean up any leftover files.
|
|
1312
|
+
await fs.unlink(reportPath).catch(() => {});
|
|
1313
|
+
await fs.unlink(failedJobPath).catch(() => {});
|
|
1314
|
+
// Create a claimed job assigned to Worker One.
|
|
1315
|
+
await fs.writeFile(claimedJobPath, JSON.stringify({
|
|
1316
|
+
id: jobID,
|
|
1317
|
+
target: {what: 'Test', url: 'https://example.com/test'},
|
|
1318
|
+
sources: {worker: 'Worker One'}
|
|
1319
|
+
}));
|
|
1320
|
+
const auth = Buffer.from('worker1:secret1').toString('base64');
|
|
1321
|
+
// A report that is otherwise usable, but whose test act was redirected (actualURL)
|
|
1322
|
+
// to a private address that this deployment does not allow as a testing target.
|
|
1323
|
+
const report = {
|
|
1324
|
+
id: jobID,
|
|
1325
|
+
target: {what: 'Test', url: 'https://example.com/test'},
|
|
1326
|
+
acts: [{
|
|
1327
|
+
type: 'test', which: 'axe', actualURL: 'https://192.168.1.1/test',
|
|
1328
|
+
result: {standardResult: {instances: []}}
|
|
1329
|
+
}],
|
|
1330
|
+
jobData: {endTime: '26-01-01T00:00'},
|
|
1331
|
+
catalog: {}
|
|
1332
|
+
};
|
|
1333
|
+
const res = await request('POST', '/worker/report', {report}, {
|
|
1334
|
+
authorization: `Basic ${auth}`
|
|
1335
|
+
});
|
|
1336
|
+
assert.equal(res.statusCode, 400);
|
|
1337
|
+
const body = jsonBody(res);
|
|
1338
|
+
assert.ok(body.error.message.includes('disallowed target'));
|
|
1339
|
+
// The report should not have been recorded.
|
|
1340
|
+
const reportExists = await fs.access(reportPath).then(() => true).catch(() => false);
|
|
1341
|
+
assert.equal(reportExists, false, 'Report on a disallowed target should not be saved');
|
|
1342
|
+
// Wait for the async rename to complete.
|
|
1343
|
+
await new Promise<void>(resolve => setTimeout(resolve, 100));
|
|
1344
|
+
// The job should have been reclassified as failed rather than left claimed.
|
|
1345
|
+
const failedExists = await fs.access(failedJobPath).then(() => true).catch(() => false);
|
|
1346
|
+
assert.ok(failedExists, 'Job should be moved to the failed directory');
|
|
1347
|
+
const claimedExists = await fs.access(claimedJobPath).then(() => true).catch(() => false);
|
|
1348
|
+
assert.equal(claimedExists, false, 'Job should be removed from the claimed directory');
|
|
1349
|
+
} finally {
|
|
1350
|
+
process.env.ALLOW_INTERNAL_TARGETS = 'true';
|
|
1351
|
+
// Clean up.
|
|
1352
|
+
await fs.unlink(failedJobPath).catch(() => {});
|
|
1353
|
+
}
|
|
1354
|
+
});
|
|
1355
|
+
|
|
1230
1356
|
// TESTS: remaining error branches and web pages
|
|
1231
1357
|
|
|
1232
1358
|
test('GET /style.css with a read error returns an error page', async () => {
|
package/index.ts
CHANGED
|
@@ -17,6 +17,9 @@ import {
|
|
|
17
17
|
getObject,
|
|
18
18
|
getPOSTData,
|
|
19
19
|
getReport,
|
|
20
|
+
isAllowedRedirectTarget,
|
|
21
|
+
isAllowedReport,
|
|
22
|
+
isAllowedTarget,
|
|
20
23
|
isReportError,
|
|
21
24
|
getReportPath,
|
|
22
25
|
hiddenReportsPath,
|
|
@@ -482,26 +485,54 @@ const processJobRequest = async (request: IncomingMessage, response: ServerRespo
|
|
|
482
485
|
if (queuedJobNames.length) {
|
|
483
486
|
const oldestJobName = queuedJobNames[0]!;
|
|
484
487
|
// Get the first one.
|
|
485
|
-
const firstJob = await getObject(path.join(queuePath(), oldestJobName)) as {id: string, sources: {worker: string}, target: {what: string}};
|
|
486
|
-
//
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
//
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
488
|
+
const firstJob = await getObject(path.join(queuePath(), oldestJobName)) as {id: string, sources: {worker: string}, target: {what: string, url: string}};
|
|
489
|
+
// If the job's target is no longer allowed, whether because its host now resolves
|
|
490
|
+
// to a disallowed address or, more likely, because the host now redirects to one
|
|
491
|
+
// (both possible if the target host was reconfigured, e.g. by an attacker, after
|
|
492
|
+
// the request was accepted; a fetch-based check is used, rather than the cheaper
|
|
493
|
+
// DNS-only isAllowedTarget, because this runs only when a job is actually queued,
|
|
494
|
+
// which is rare, and a redirect introduced after acceptance is the likelier attack):
|
|
495
|
+
if (!(await isAllowedRedirectTarget(firstJob.target.url))) {
|
|
496
|
+
console.error(`ERROR: Queued job ${firstJob.id} is on a target this deployment does not allow`);
|
|
497
|
+
// Alert a manager, since this indicates an attempted or accidental SSRF
|
|
498
|
+
// rather than an ordinary usability problem with the target.
|
|
499
|
+
await sendAlert(
|
|
500
|
+
'Kilotest: queued job on disallowed target rejected',
|
|
501
|
+
`Job ${firstJob.id} (${firstJob.target.what}) was never assigned to a worker because its target no longer resolves, directly or via a redirect, to an address this deployment allows (e.g. a private, loopback, or link-local address). The job was reclassified as failed.`
|
|
502
|
+
);
|
|
503
|
+
// Reclassify the job as failed, instead of assigning it or leaving it queued.
|
|
504
|
+
await fs.rename(
|
|
505
|
+
path.join(queuePath(), oldestJobName), path.join(failedPath(), oldestJobName)
|
|
506
|
+
);
|
|
507
|
+
// Respond as if no job were queued, rather than revealing anything about the rejected job.
|
|
508
|
+
response.writeHead(200, {
|
|
509
|
+
'content-type': 'application/json; charset=utf-8'
|
|
510
|
+
});
|
|
511
|
+
response.end(JSON.stringify({}));
|
|
512
|
+
console.log(`${messageStart}but the queued job was on a disallowed target`);
|
|
513
|
+
}
|
|
514
|
+
// Otherwise, i.e. if the job's target is still allowed:
|
|
515
|
+
else {
|
|
516
|
+
// Add the public worker name to the job, in a property Testaro does not read or alter.
|
|
517
|
+
firstJob.sources.worker = workerName;
|
|
518
|
+
console.log(
|
|
519
|
+
`Job ${firstJob.id} (${firstJob.target.what}) is being sent to the worker.`
|
|
520
|
+
);
|
|
521
|
+
// Assign the job to the worker.
|
|
522
|
+
response.writeHead(200, {
|
|
523
|
+
'content-type': 'application/json; charset=utf-8'
|
|
524
|
+
});
|
|
525
|
+
response.end(JSON.stringify(firstJob));
|
|
526
|
+
const messageEnd
|
|
527
|
+
= `and job ${firstJob.id} (${firstJob.target.what}) was assigned to the worker`;
|
|
528
|
+
console.log(`${messageStart}${messageEnd}`);
|
|
529
|
+
// Save the job in the claimed-jobs directory.
|
|
530
|
+
await fs.writeFile(
|
|
531
|
+
path.join(claimedPath(), oldestJobName), getJSON(firstJob)
|
|
532
|
+
);
|
|
533
|
+
// Delete it from the queue.
|
|
534
|
+
await fs.unlink(path.join(queuePath(), oldestJobName));
|
|
535
|
+
}
|
|
505
536
|
}
|
|
506
537
|
// Otherwise, i.e. if no jobs are queued:
|
|
507
538
|
else {
|
|
@@ -899,7 +930,7 @@ const handleRequest = async (request: IncomingMessage, response: ServerResponse)
|
|
|
899
930
|
else if (pageName === 'requestTest.html') {
|
|
900
931
|
const {description, url, why} = postData as {description?: string; url: string; why?: string};
|
|
901
932
|
// If the request is valid:
|
|
902
|
-
if (description && isURL(url) && why) {
|
|
933
|
+
if (description && isURL(url) && why && await isAllowedTarget(url)) {
|
|
903
934
|
// If a report on the page is already available:
|
|
904
935
|
if (await isReportAvailable(description, url)) {
|
|
905
936
|
// Report the error.
|
|
@@ -1103,8 +1134,28 @@ const handleRequest = async (request: IncomingMessage, response: ServerResponse)
|
|
|
1103
1134
|
}
|
|
1104
1135
|
// If the job was actually assigned to this worker:
|
|
1105
1136
|
if (typeof claimedJob === 'object' && claimedJob !== null && (claimedJob as {sources?: {worker?: string}}).sources?.worker === workerName) {
|
|
1106
|
-
// If the report is
|
|
1107
|
-
|
|
1137
|
+
// If the report is on a target this deployment allows (a redirect during
|
|
1138
|
+
// navigation, possibly via DNS rebinding, can take a worker to a target that
|
|
1139
|
+
// was disallowed even though the originally submitted URL passed that check
|
|
1140
|
+
// at submission time):
|
|
1141
|
+
if (!(await isAllowedReport(reportObj))) {
|
|
1142
|
+
console.error(`ERROR: Report ${id} from worker ${workerName} is on a disallowed target`);
|
|
1143
|
+
// Alert a manager, since this indicates an attempted or accidental SSRF
|
|
1144
|
+
// rather than an ordinary usability problem with the report.
|
|
1145
|
+
await sendAlert(
|
|
1146
|
+
'Kilotest: report on disallowed target rejected',
|
|
1147
|
+
`Job ${id} from worker ${workerName} resulted in a visit to a target this deployment does not allow (e.g. a private, loopback, or link-local address), possibly via a redirect. The report was discarded and the job was reclassified as failed.`
|
|
1148
|
+
);
|
|
1149
|
+
// Reclassify the job as failed, instead of recording the disallowed report or
|
|
1150
|
+
// leaving the job claimed indefinitely.
|
|
1151
|
+
await fs.rename(
|
|
1152
|
+
path.join(claimedPath(), `${id}.json`), path.join(failedPath(), `${id}.json`)
|
|
1153
|
+
);
|
|
1154
|
+
// Report the error.
|
|
1155
|
+
await serveError({message: `ERROR: Report ${id} is on a disallowed target`}, response, false);
|
|
1156
|
+
}
|
|
1157
|
+
// Otherwise, if the report is usable by Kilotest (as any report of an assigned job should be):
|
|
1158
|
+
else if (isUsableReport(reportObj)) {
|
|
1108
1159
|
console.log(`Testaro report ${id} was received from worker ${workerName}`);
|
|
1109
1160
|
// Add the public worker name to the report.
|
|
1110
1161
|
report!.sources = {...report!.sources, worker: workerName};
|
package/llms-full.txt
CHANGED
|
@@ -6,7 +6,7 @@ Kilotest is an application that performs ensemble testing of web pages for front
|
|
|
6
6
|
|
|
7
7
|
## What Kilotest does for language models
|
|
8
8
|
|
|
9
|
-
Kilotest is deployed on the public Internet as a service at `https://kilotest.com`. Kilotest can test only web pages that can be accessed
|
|
9
|
+
Kilotest is deployed on the public Internet as a service at `https://kilotest.com`. That deployment of Kilotest can test only web pages that can be accessed by its Testaro worker with a URL. Generally those are pages on the public Internet that are not protected by authentication or other access controls.
|
|
10
10
|
|
|
11
11
|
Without calling any tool, an LLM can, under some conditions, load web pages and use its knowledge to assess successes and failures of front-end quality. But such an assessment is almost always:
|
|
12
12
|
|
package/llms.txt
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Kilotest
|
|
2
2
|
|
|
3
|
-
Kilotest helps language models and AI platforms to give thorough, accurate and truthful answers when users ask about the front-end quality (i.e. accessibility, usability, and standards conformity) of web pages. Kilotest integrates an ensemble of twelve independent rule engines to test
|
|
3
|
+
Kilotest helps language models and AI platforms to give thorough, accurate and truthful answers when users ask about the front-end quality (i.e. accessibility, usability, and standards conformity) of web pages. Kilotest integrates an ensemble of twelve independent rule engines to test a web page. Kilotest creates a structured report of the results and saves the report for future reference. Kilotest API endpoints and MCP tools can provide multipage, single-page, and element-level test results.
|
|
4
4
|
|
|
5
5
|
## API
|
|
6
6
|
|
package/mcp.test.ts
CHANGED
|
@@ -11,6 +11,8 @@ import http from 'node:http';
|
|
|
11
11
|
import {mcpPath, createMCPServer, handleMCP} from './mcp.ts';
|
|
12
12
|
import {metricsPath} from './util.ts';
|
|
13
13
|
import fs from 'node:fs';
|
|
14
|
+
import {Client} from '@modelcontextprotocol/sdk/client/index.js';
|
|
15
|
+
import {InMemoryTransport} from '@modelcontextprotocol/sdk/inMemory.js';
|
|
14
16
|
|
|
15
17
|
// CONSTANTS
|
|
16
18
|
|
|
@@ -35,11 +37,12 @@ test('mcpPath is /mcp', () => {
|
|
|
35
37
|
assert.equal(mcpPath, '/mcp');
|
|
36
38
|
});
|
|
37
39
|
|
|
38
|
-
test('createMCPServer registers all
|
|
40
|
+
test('createMCPServer registers all 9 tools', () => {
|
|
39
41
|
const server = createMCPServer();
|
|
40
42
|
const toolNames = Object.keys((server as any)._registeredTools);
|
|
41
|
-
assert.equal(toolNames.length,
|
|
43
|
+
assert.equal(toolNames.length, 9);
|
|
42
44
|
assert.deepEqual(toolNames, [
|
|
45
|
+
'getKilotestOverview',
|
|
43
46
|
'listReports',
|
|
44
47
|
'listIssues',
|
|
45
48
|
'listViolators',
|
|
@@ -56,10 +59,41 @@ test('each tool has a description and a handler function', () => {
|
|
|
56
59
|
const tools = (server as any)._registeredTools;
|
|
57
60
|
for (const [name, tool] of Object.entries(tools) as [string, any][]) {
|
|
58
61
|
assert.ok(tool.description, `${name} has a description`);
|
|
62
|
+
assert.ok(
|
|
63
|
+
tool.description.includes('front-end quality'),
|
|
64
|
+
`${name} description includes the shared domain context`
|
|
65
|
+
);
|
|
59
66
|
assert.equal(typeof tool.handler, 'function', `${name} has a handler function`);
|
|
60
67
|
}
|
|
61
68
|
});
|
|
62
69
|
|
|
70
|
+
test('getKilotestOverview handler returns overview text and records a metric', async () => {
|
|
71
|
+
const server = createMCPServer();
|
|
72
|
+
const countBefore = getToolCallCount('getKilotestOverview');
|
|
73
|
+
const result = await (server as any)._registeredTools.getKilotestOverview.handler({});
|
|
74
|
+
assert.ok(result.content);
|
|
75
|
+
assert.equal(result.content[0].type, 'text');
|
|
76
|
+
assert.ok(result.content[0].text.includes('front-end quality'));
|
|
77
|
+
assert.equal(getToolCallCount('getKilotestOverview'), countBefore + 1);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('client receives instructions, server description, and the overview resource', async () => {
|
|
81
|
+
const server = createMCPServer();
|
|
82
|
+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
83
|
+
await server.connect(serverTransport);
|
|
84
|
+
const client = new Client({name: 'test-client', version: '1.0.0'});
|
|
85
|
+
await client.connect(clientTransport);
|
|
86
|
+
assert.ok(client.getInstructions()?.includes('front-end quality'));
|
|
87
|
+
assert.ok(client.getServerVersion()?.description?.includes('front-end quality'));
|
|
88
|
+
const resources = await client.listResources();
|
|
89
|
+
assert.deepEqual(resources.resources.map((r: any) => r.uri), ['docs://kilotest/overview']);
|
|
90
|
+
const read = await client.readResource({uri: 'docs://kilotest/overview'});
|
|
91
|
+
assert.equal(read.contents[0]?.mimeType, 'text/plain');
|
|
92
|
+
assert.ok((read.contents[0] as any).text.includes('front-end quality'));
|
|
93
|
+
await client.close();
|
|
94
|
+
await server.close();
|
|
95
|
+
});
|
|
96
|
+
|
|
63
97
|
test('listReports handler returns content and structuredContent', async () => {
|
|
64
98
|
const server = createMCPServer();
|
|
65
99
|
const countBefore = getToolCallCount('listReports');
|
|
@@ -258,6 +292,8 @@ test('handleMCP responds to initialize with server info', async () => {
|
|
|
258
292
|
assert.equal(res.statusCode, 200);
|
|
259
293
|
const result = parseSSEResult(res.body);
|
|
260
294
|
assert.equal(result.result.serverInfo.name, 'Kilotest');
|
|
295
|
+
assert.ok(result.result.serverInfo.description.includes('front-end quality'));
|
|
296
|
+
assert.ok(result.result.instructions.includes('front-end quality'));
|
|
261
297
|
assert.equal(result.result.protocolVersion, '2025-06-18');
|
|
262
298
|
}
|
|
263
299
|
finally {
|
|
@@ -265,7 +301,7 @@ test('handleMCP responds to initialize with server info', async () => {
|
|
|
265
301
|
}
|
|
266
302
|
});
|
|
267
303
|
|
|
268
|
-
test('handleMCP lists all
|
|
304
|
+
test('handleMCP lists all 9 tools via tools/list', async () => {
|
|
269
305
|
const server = await startMCPServer();
|
|
270
306
|
try {
|
|
271
307
|
const port = server.address().port;
|
|
@@ -273,8 +309,9 @@ test('handleMCP lists all 8 tools via tools/list', async () => {
|
|
|
273
309
|
assert.equal(res.statusCode, 200);
|
|
274
310
|
const result = parseSSEResult(res.body);
|
|
275
311
|
const toolNames = result.result.tools.map((t: any) => t.name);
|
|
276
|
-
assert.equal(toolNames.length,
|
|
312
|
+
assert.equal(toolNames.length, 9);
|
|
277
313
|
assert.deepEqual(toolNames, [
|
|
314
|
+
'getKilotestOverview',
|
|
278
315
|
'listReports',
|
|
279
316
|
'listIssues',
|
|
280
317
|
'listViolators',
|
package/mcp.ts
CHANGED
|
@@ -41,8 +41,28 @@ import {
|
|
|
41
41
|
|
|
42
42
|
export const mcpPath = '/mcp';
|
|
43
43
|
|
|
44
|
+
// Shared statement of the Kilotest domain and data hierarchy, repeated in every tool
|
|
45
|
+
// description, in the server instructions, and in the getKilotestOverview tool description,
|
|
46
|
+
// because some MCP clients do not propagate server-level instructions or the server
|
|
47
|
+
// description to their models.
|
|
48
|
+
const sharedContext = 'Kilotest tests web pages for front-end quality (accessibility, usability, and standards conformity); results are organized as report, then issue, then violator element, then diagnosis.';
|
|
49
|
+
|
|
50
|
+
// Detailed overview of the Kilotest domain, data hierarchy, and tool workflow, returned by
|
|
51
|
+
// the getKilotestOverview tool and served as the docs://kilotest/overview resource.
|
|
52
|
+
const kilotestOverview = [
|
|
53
|
+
'Kilotest tests web pages for front-end quality: accessibility, usability, and standards conformity.',
|
|
54
|
+
'Kilotest integrates an ensemble of twelve independent rule engines to test a web page and stores the results as a structured report.',
|
|
55
|
+
'Results are organized as a hierarchy. A report contains issues. An issue has violators: elements of the tested page reported as exhibiting the issue. A violator has diagnoses: explanations from rule engines of how the element exhibited the issue.',
|
|
56
|
+
'Typical workflow: call the listReports tool to learn whether a report about the page already exists. If it does, drill down with the listIssues, listViolators, and listDiagnoses tools, or retrieve the entire report with the getReport tool. If no report exists, request a test with the requestTest tool. If the latest report is obsolete, request a retest with the requestRetest tool.',
|
|
57
|
+
'More documentation is available at https://kilotest.com/llms.txt and https://kilotest.com/llms-full.txt.'
|
|
58
|
+
].join('\n\n');
|
|
59
|
+
|
|
44
60
|
// FUNCTIONS
|
|
45
61
|
|
|
62
|
+
// Returns a tool description consisting of the tool-specific summary followed by the shared
|
|
63
|
+
// Kilotest domain context.
|
|
64
|
+
const toolDoc = (summary: string): string => `${summary} ${sharedContext}`;
|
|
65
|
+
|
|
46
66
|
// Creates and returns an McpServer with Kilotest tools registered.
|
|
47
67
|
export const createMCPServer = (): McpServer => {
|
|
48
68
|
const server = new McpServer({
|
|
@@ -51,16 +71,34 @@ export const createMCPServer = (): McpServer => {
|
|
|
51
71
|
description: 'Tools that test web pages for front-end quality (accessibility, usability, and standards conformity) and make test results available'
|
|
52
72
|
},
|
|
53
73
|
{
|
|
54
|
-
instructions:
|
|
74
|
+
instructions: `${sharedContext} Use the listReports tool to start. If it shows that there is a report available about the page you want facts about, drill down with the listIssues, listViolators, and listDiagnoses tools. If not, use the requestTest tool to request that the page be tested. If the latest report about the page is obsolete, use the requestRetest tool to request that the page be retested. For detailed documentation, call the getKilotestOverview tool.`
|
|
55
75
|
});
|
|
76
|
+
server.registerTool(
|
|
77
|
+
'getKilotestOverview',
|
|
78
|
+
{
|
|
79
|
+
description: toolDoc('Explain what Kilotest is, what its tools do, and how its results are structured.'),
|
|
80
|
+
inputSchema: {},
|
|
81
|
+
annotations: {
|
|
82
|
+
title: toolDoc('Explain what Kilotest is, what its tools do, and how its results are structured.'),
|
|
83
|
+
readOnlyHint: true,
|
|
84
|
+
idempotentHint: true,
|
|
85
|
+
destructiveHint: false,
|
|
86
|
+
openWorldHint: false
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
async () => {
|
|
90
|
+
await recordMetric('mcpToolCalls', 'getKilotestOverview');
|
|
91
|
+
return {content: [{type: 'text', text: kilotestOverview}]};
|
|
92
|
+
}
|
|
93
|
+
);
|
|
56
94
|
server.registerTool(
|
|
57
95
|
'listReports',
|
|
58
96
|
{
|
|
59
|
-
description: 'Provide basics about all available reports.',
|
|
97
|
+
description: toolDoc('Provide basics about all available reports.'),
|
|
60
98
|
inputSchema: {},
|
|
61
99
|
outputSchema: listReportsResponseSchema,
|
|
62
100
|
annotations: {
|
|
63
|
-
title: 'Provide basics about all available reports.',
|
|
101
|
+
title: toolDoc('Provide basics about all available reports.'),
|
|
64
102
|
readOnlyHint: true,
|
|
65
103
|
idempotentHint: true,
|
|
66
104
|
destructiveHint: false,
|
|
@@ -76,11 +114,11 @@ export const createMCPServer = (): McpServer => {
|
|
|
76
114
|
server.registerTool(
|
|
77
115
|
'listIssues',
|
|
78
116
|
{
|
|
79
|
-
description: 'Provide details about one report, including basics about the issues reported in it.',
|
|
117
|
+
description: toolDoc('Provide details about one report, including basics about the issues reported in it.'),
|
|
80
118
|
inputSchema: listIssuesSchema,
|
|
81
119
|
outputSchema: listIssuesResponseSchema,
|
|
82
120
|
annotations: {
|
|
83
|
-
title: 'Provide details about one report, including basics about the issues reported in it.',
|
|
121
|
+
title: toolDoc('Provide details about one report, including basics about the issues reported in it.'),
|
|
84
122
|
readOnlyHint: true,
|
|
85
123
|
idempotentHint: true,
|
|
86
124
|
destructiveHint: false,
|
|
@@ -96,11 +134,11 @@ export const createMCPServer = (): McpServer => {
|
|
|
96
134
|
server.registerTool(
|
|
97
135
|
'listViolators',
|
|
98
136
|
{
|
|
99
|
-
description: 'Provide details about one issue in one report, including basics about the elements of the tested page that were reported as exhibiting the issue.',
|
|
137
|
+
description: toolDoc('Provide details about one issue in one report, including basics about the elements of the tested page that were reported as exhibiting the issue.'),
|
|
100
138
|
inputSchema: listViolatorsSchema,
|
|
101
139
|
outputSchema: listViolatorsResponseSchema,
|
|
102
140
|
annotations: {
|
|
103
|
-
title: 'Provide details about one issue in one report, including basics about the elements of the tested page that were reported as exhibiting the issue.',
|
|
141
|
+
title: toolDoc('Provide details about one issue in one report, including basics about the elements of the tested page that were reported as exhibiting the issue.'),
|
|
104
142
|
readOnlyHint: true,
|
|
105
143
|
idempotentHint: true,
|
|
106
144
|
destructiveHint: false,
|
|
@@ -116,11 +154,11 @@ export const createMCPServer = (): McpServer => {
|
|
|
116
154
|
server.registerTool(
|
|
117
155
|
'listDiagnoses',
|
|
118
156
|
{
|
|
119
|
-
description: 'Provide details about one element reported as exhibiting one issue in one report, including the diagnoses provided by rule engines about how the element exhibited the issue.',
|
|
157
|
+
description: toolDoc('Provide details about one element reported as exhibiting one issue in one report, including the diagnoses provided by rule engines about how the element exhibited the issue.'),
|
|
120
158
|
inputSchema: listDiagnosesSchema,
|
|
121
159
|
outputSchema: listDiagnosesResponseSchema,
|
|
122
160
|
annotations: {
|
|
123
|
-
title: 'Provide details about one element reported as exhibiting one issue in one report, including the diagnoses provided by rule engines about how the element exhibited the issue.',
|
|
161
|
+
title: toolDoc('Provide details about one element reported as exhibiting one issue in one report, including the diagnoses provided by rule engines about how the element exhibited the issue.'),
|
|
124
162
|
readOnlyHint: true,
|
|
125
163
|
idempotentHint: true,
|
|
126
164
|
destructiveHint: false,
|
|
@@ -136,11 +174,11 @@ export const createMCPServer = (): McpServer => {
|
|
|
136
174
|
server.registerTool(
|
|
137
175
|
'getReport',
|
|
138
176
|
{
|
|
139
|
-
description: 'Get one full report in JSON.',
|
|
177
|
+
description: toolDoc('Get one full report in JSON.'),
|
|
140
178
|
inputSchema: getReportSchema,
|
|
141
179
|
outputSchema: getReportResponseSchema,
|
|
142
180
|
annotations: {
|
|
143
|
-
title: 'Get one full report in JSON.',
|
|
181
|
+
title: toolDoc('Get one full report in JSON.'),
|
|
144
182
|
readOnlyHint: true,
|
|
145
183
|
idempotentHint: true,
|
|
146
184
|
destructiveHint: false,
|
|
@@ -156,11 +194,11 @@ export const createMCPServer = (): McpServer => {
|
|
|
156
194
|
server.registerTool(
|
|
157
195
|
'requestTest',
|
|
158
196
|
{
|
|
159
|
-
description: 'Process my request to test a page about which no report is available yet.',
|
|
197
|
+
description: toolDoc('Process my request to test a page about which no report is available yet.'),
|
|
160
198
|
inputSchema: requestTestSchema,
|
|
161
199
|
outputSchema: requestTestResponseSchema,
|
|
162
200
|
annotations: {
|
|
163
|
-
title: 'Process my request to test a page about which no report is available yet.',
|
|
201
|
+
title: toolDoc('Process my request to test a page about which no report is available yet.'),
|
|
164
202
|
readOnlyHint: false,
|
|
165
203
|
idempotentHint: false,
|
|
166
204
|
destructiveHint: false,
|
|
@@ -176,11 +214,11 @@ export const createMCPServer = (): McpServer => {
|
|
|
176
214
|
server.registerTool(
|
|
177
215
|
'requestRetest',
|
|
178
216
|
{
|
|
179
|
-
description: 'Process my request to retest a page about which a report is available.',
|
|
217
|
+
description: toolDoc('Process my request to retest a page about which a report is available.'),
|
|
180
218
|
inputSchema: requestRetestSchema,
|
|
181
219
|
outputSchema: requestRetestResponseSchema,
|
|
182
220
|
annotations: {
|
|
183
|
-
title: 'Process my request to retest a page about which a report is available.',
|
|
221
|
+
title: toolDoc('Process my request to retest a page about which a report is available.'),
|
|
184
222
|
readOnlyHint: false,
|
|
185
223
|
idempotentHint: false,
|
|
186
224
|
destructiveHint: false,
|
|
@@ -196,11 +234,11 @@ export const createMCPServer = (): McpServer => {
|
|
|
196
234
|
server.registerTool(
|
|
197
235
|
'requestFeature',
|
|
198
236
|
{
|
|
199
|
-
description: 'Process my request to add or improve a feature.',
|
|
237
|
+
description: toolDoc('Process my request to add or improve a feature.'),
|
|
200
238
|
inputSchema: requestFeatureSchema,
|
|
201
239
|
outputSchema: requestFeatureResponseSchema,
|
|
202
240
|
annotations: {
|
|
203
|
-
title: 'Process my request to add or improve a feature.',
|
|
241
|
+
title: toolDoc('Process my request to add or improve a feature.'),
|
|
204
242
|
readOnlyHint: false,
|
|
205
243
|
idempotentHint: false,
|
|
206
244
|
destructiveHint: false,
|
|
@@ -213,6 +251,18 @@ export const createMCPServer = (): McpServer => {
|
|
|
213
251
|
return {content: [{type: 'text', text: JSON.stringify(result)}], structuredContent: result};
|
|
214
252
|
}
|
|
215
253
|
);
|
|
254
|
+
server.registerResource(
|
|
255
|
+
'kilotest-overview',
|
|
256
|
+
'docs://kilotest/overview',
|
|
257
|
+
{
|
|
258
|
+
title: 'Kilotest overview',
|
|
259
|
+
description: `What Kilotest is and how its tools and results are organized. ${sharedContext}`,
|
|
260
|
+
mimeType: 'text/plain'
|
|
261
|
+
},
|
|
262
|
+
async () => ({
|
|
263
|
+
contents: [{uri: 'docs://kilotest/overview', mimeType: 'text/plain', text: kilotestOverview}]
|
|
264
|
+
})
|
|
265
|
+
);
|
|
216
266
|
return server;
|
|
217
267
|
};
|
|
218
268
|
// Handles an MCP request.
|
package/openapi.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
openapi: 3.1.0
|
|
2
2
|
info:
|
|
3
3
|
title: Kilotest API
|
|
4
|
-
description: 'Kilotest is an ensemble testing and reporting service for front-end web quality. It uses an ensemble of 12 rule engines to test
|
|
4
|
+
description: 'Kilotest is an ensemble testing and reporting service for front-end web quality. It uses an ensemble of 12 rule engines to test web pages for front-end quality (accessibility, usability, and standards conformity). Kilotest acts as a web server for human users, an MCP server for AI platforms, and an API for programmatic access. With this API you can request that a page be tested or retested, discover available reports, incrementally retrieve facts from a report: from what reports are available, to what issues were reported in one report, to what elements violated one issue, to what diagnoses rule engines gave for one violation.'
|
|
5
5
|
version: 2.1.0
|
|
6
6
|
license:
|
|
7
7
|
name: MIT
|
|
@@ -822,7 +822,7 @@ paths:
|
|
|
822
822
|
properties:
|
|
823
823
|
description:
|
|
824
824
|
type: string
|
|
825
|
-
description:
|
|
825
|
+
description: 1- to 100-character description of the page conforming to the naming convention used in the listReports output
|
|
826
826
|
URL:
|
|
827
827
|
type: string
|
|
828
828
|
description: 12- to 300-character URL of the page, including the https:// scheme and any query
|
|
@@ -860,7 +860,7 @@ paths:
|
|
|
860
860
|
properties:
|
|
861
861
|
description:
|
|
862
862
|
type: string
|
|
863
|
-
description:
|
|
863
|
+
description: 1- to 100-character description of the page conforming to the naming convention used in the listReports output
|
|
864
864
|
URL:
|
|
865
865
|
type: string
|
|
866
866
|
description: 12- to 300-character URL of the page, including the https:// scheme and any query
|
|
@@ -1099,7 +1099,7 @@ paths:
|
|
|
1099
1099
|
properties:
|
|
1100
1100
|
feature:
|
|
1101
1101
|
type: string
|
|
1102
|
-
description: description of requested feature improvement or new feature
|
|
1102
|
+
description: 20- to 1000-character description of requested feature improvement or new feature
|
|
1103
1103
|
required:
|
|
1104
1104
|
- feature
|
|
1105
1105
|
responses:
|
|
@@ -1129,7 +1129,7 @@ paths:
|
|
|
1129
1129
|
properties:
|
|
1130
1130
|
feature:
|
|
1131
1131
|
type: string
|
|
1132
|
-
description: description of requested feature improvement or new feature
|
|
1132
|
+
description: 20- to 1000-character description of requested feature improvement or new feature
|
|
1133
1133
|
required:
|
|
1134
1134
|
- feature
|
|
1135
1135
|
additionalProperties: false
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jrpool/kilotest",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "55.0.0",
|
|
4
4
|
"description": "Ensemble testing for front-end web quality",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.ts",
|
|
@@ -38,6 +38,7 @@
|
|
|
38
38
|
"dependencies": {
|
|
39
39
|
"@modelcontextprotocol/sdk": "*",
|
|
40
40
|
"dotenv": "*",
|
|
41
|
+
"ipaddr.js": "*",
|
|
41
42
|
"testaro-issues": "*",
|
|
42
43
|
"zod": "*"
|
|
43
44
|
},
|
|
@@ -58,7 +58,7 @@ const generate = () => {
|
|
|
58
58
|
openapi: '3.1.0',
|
|
59
59
|
info: {
|
|
60
60
|
title: 'Kilotest API',
|
|
61
|
-
description: 'Kilotest is an ensemble testing and reporting service for front-end web quality. It uses an ensemble of 12 rule engines to test
|
|
61
|
+
description: 'Kilotest is an ensemble testing and reporting service for front-end web quality. It uses an ensemble of 12 rule engines to test web pages for front-end quality (accessibility, usability, and standards conformity). Kilotest acts as a web server for human users, an MCP server for AI platforms, and an API for programmatic access. With this API you can request that a page be tested or retested, discover available reports, incrementally retrieve facts from a report: from what reports are available, to what issues were reported in one report, to what elements violated one issue, to what diagnoses rule engines gave for one violation.',
|
|
62
62
|
version,
|
|
63
63
|
license: {
|
|
64
64
|
name: license,
|