@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/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
- // Add the public worker name to the job, in a property Testaro does not read or alter.
487
- firstJob.sources.worker = workerName;
488
- console.log(
489
- `Job ${firstJob.id} (${firstJob.target.what}) is being sent to the worker.`
490
- );
491
- // Assign the job to the worker.
492
- response.writeHead(200, {
493
- 'content-type': 'application/json; charset=utf-8'
494
- });
495
- response.end(JSON.stringify(firstJob));
496
- const messageEnd
497
- = `and job ${firstJob.id} (${firstJob.target.what}) was assigned to the worker`;
498
- console.log(`${messageStart}${messageEnd}`);
499
- // Save the job in the claimed-jobs directory.
500
- await fs.writeFile(
501
- path.join(claimedPath(), oldestJobName), getJSON(firstJob)
502
- );
503
- // Delete it from the queue.
504
- await fs.unlink(path.join(queuePath(), oldestJobName));
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 usable by Kilotest (as any report of an assigned job should be):
1107
- if (isUsableReport(reportObj)) {
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 from the public Internet and that are not protected by authentication or other access controls. Kilotest has not implemented any mechanism for testing private, internal, password-protected, or otherwise restricted pages.
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 any public 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.
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 8 tools', () => {
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, 8);
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 8 tools via tools/list', async () => {
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, 8);
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: '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.'
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 public 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.'
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: 10- to 100-character description of the page conforming to the naming convention used in the listReports output
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: 10- to 100-character description of the page conforming to the naming convention used in the listReports output
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": "51.1.0",
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 public 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.',
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,