knock 0.1.0 → 1.1.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/dist/index.js ADDED
@@ -0,0 +1,278 @@
1
+ /**
2
+ * knock — subdomain enumeration via Certificate Transparency logs and
3
+ * wordlist DNS brute forcing, with wildcard DNS detection.
4
+ */
5
+ import { Resolver } from 'node:dns/promises';
6
+ import { readFile } from 'node:fs/promises';
7
+ import { randomBytes } from 'node:crypto';
8
+ import { fileURLToPath } from 'node:url';
9
+ import { loadPublicSuffixList, getRegistrableDomain } from './psl.js';
10
+ /** Absolute paths to the wordlists bundled with the package. */
11
+ export const wordlists = {
12
+ subs: fileURLToPath(new URL('../lists/subs.txt', import.meta.url)),
13
+ org: fileURLToPath(new URL('../lists/org.txt', import.meta.url)),
14
+ };
15
+ // DNS answers that simply mean "nothing there", as opposed to a lookup failure.
16
+ const NEGATIVE_CODES = new Set(['ENOTFOUND', 'ENODATA', 'NXDOMAIN']);
17
+ const HOSTNAME_PATTERN = /^[a-z0-9_][a-z0-9_.-]*$/;
18
+ // Dotted-quad IPv4 literals have no registrable domain; skip PSL reduction.
19
+ const IPV4_PATTERN = /^\d{1,3}(?:\.\d{1,3}){3}$/;
20
+ const defaults = {
21
+ wordlist: wordlists.subs,
22
+ concurrency: 64,
23
+ timeout: 5000,
24
+ tries: 2,
25
+ family: 4,
26
+ wildcardTests: 3,
27
+ ct: true,
28
+ ctTimeout: 15000,
29
+ verify: true,
30
+ web: false,
31
+ webTimeout: 5000,
32
+ baseDomainOnly: true,
33
+ icannOnly: false,
34
+ };
35
+ /**
36
+ * Reduce user input like "HTTPS://www.Example.com/path" to a bare hostname.
37
+ * Throws TypeError when no usable hostname can be extracted.
38
+ */
39
+ export function normalizeDomain(input) {
40
+ const raw = String(input ?? '').trim();
41
+ if (raw === '')
42
+ throw new TypeError('domain is required');
43
+ const url = /^[a-z][a-z0-9+.-]*:\/\//i.test(raw) ? raw : `http://${raw}`;
44
+ let hostname;
45
+ try {
46
+ hostname = new URL(url).hostname;
47
+ }
48
+ catch {
49
+ throw new TypeError(`invalid domain: ${raw}`);
50
+ }
51
+ hostname = hostname.toLowerCase().replace(/\.+$/, '');
52
+ if (hostname === '' || hostname.startsWith('[')) {
53
+ throw new TypeError(`invalid domain: ${raw}`);
54
+ }
55
+ return hostname;
56
+ }
57
+ /** Parse wordlist text into unique, lowercased labels. Blank lines and #comments are skipped. */
58
+ export function parseWordlist(text) {
59
+ const words = new Set();
60
+ for (const line of String(text).split(/\r?\n/)) {
61
+ const word = line.trim().toLowerCase();
62
+ if (word === '' || word.startsWith('#'))
63
+ continue;
64
+ words.add(word);
65
+ }
66
+ return [...words];
67
+ }
68
+ /** Read and parse a wordlist file. */
69
+ export async function loadWordlist(path) {
70
+ return parseWordlist(await readFile(path, 'utf8'));
71
+ }
72
+ /**
73
+ * Query Certificate Transparency logs (via crt.sh) for names certified under
74
+ * `domain`. Purely passive: one HTTPS request, no DNS traffic to the target.
75
+ * Wildcard entries like "*.dev.example.com" are reported as "dev.example.com".
76
+ * Resolves to a sorted array of hostnames, the apex excluded.
77
+ */
78
+ export async function certSubdomains(domain, options = {}) {
79
+ const target = normalizeDomain(domain);
80
+ const { timeout = defaults.ctTimeout, signal, fetch: fetchImpl = globalThis.fetch } = options;
81
+ const url = `https://crt.sh/?q=${encodeURIComponent(`%.${target}`)}&output=json`;
82
+ const signals = [AbortSignal.timeout(timeout)];
83
+ if (signal)
84
+ signals.push(signal);
85
+ const response = await fetchImpl(url, {
86
+ signal: AbortSignal.any(signals),
87
+ headers: { accept: 'application/json' },
88
+ });
89
+ if (!response.ok)
90
+ throw new Error(`crt.sh responded with HTTP ${response.status}`);
91
+ const entries = await response.json();
92
+ const names = new Set();
93
+ for (const entry of Array.isArray(entries) ? entries : []) {
94
+ const record = entry;
95
+ const candidates = [record?.common_name, ...String(record?.name_value ?? '').split('\n')];
96
+ for (const candidate of candidates) {
97
+ if (!candidate)
98
+ continue;
99
+ let name = String(candidate).trim().toLowerCase().replace(/\.+$/, '');
100
+ if (name.startsWith('*.'))
101
+ name = name.slice(2);
102
+ if (name === target || !name.endsWith(`.${target}`))
103
+ continue;
104
+ if (!HOSTNAME_PATTERN.test(name))
105
+ continue;
106
+ names.add(name);
107
+ }
108
+ }
109
+ return [...names].sort();
110
+ }
111
+ function makeResolver({ timeout, tries, servers, }) {
112
+ const resolver = new Resolver({ timeout, tries });
113
+ if (servers && servers.length > 0)
114
+ resolver.setServers(servers);
115
+ return resolver;
116
+ }
117
+ async function lookupName(resolver, name, family) {
118
+ const addresses = [];
119
+ let error;
120
+ const families = family === 'any' ? [4, 6] : [family];
121
+ for (const fam of families) {
122
+ try {
123
+ const found = fam === 6 ? await resolver.resolve6(name) : await resolver.resolve4(name);
124
+ addresses.push(...found);
125
+ }
126
+ catch (err) {
127
+ const code = err?.code;
128
+ if (!NEGATIVE_CODES.has(code ?? ''))
129
+ error = err;
130
+ }
131
+ }
132
+ return { addresses, error };
133
+ }
134
+ // Resolve a few random labels that cannot exist; any answers reveal a wildcard record.
135
+ async function detectWildcard(resolver, domain, { tests, family }) {
136
+ const addresses = new Set();
137
+ for (let i = 0; i < tests; i += 1) {
138
+ const name = `knock-${randomBytes(8).toString('hex')}.${domain}`;
139
+ const { addresses: found } = await lookupName(resolver, name, family);
140
+ for (const address of found)
141
+ addresses.add(address);
142
+ }
143
+ return addresses;
144
+ }
145
+ async function probeWeb(name, timeout) {
146
+ const statuses = { https: null, http: null };
147
+ await Promise.all(['https', 'http'].map(async (protocol) => {
148
+ try {
149
+ const response = await fetch(`${protocol}://${name}/`, {
150
+ method: 'HEAD',
151
+ redirect: 'manual',
152
+ signal: AbortSignal.timeout(timeout),
153
+ });
154
+ statuses[protocol] = response.status;
155
+ }
156
+ catch {
157
+ statuses[protocol] = null;
158
+ }
159
+ }));
160
+ return statuses;
161
+ }
162
+ async function pool(items, worker, concurrency, signal) {
163
+ let index = 0;
164
+ const size = Math.max(1, Math.min(concurrency, items.length));
165
+ const runners = Array.from({ length: size }, async () => {
166
+ while (index < items.length && !signal?.aborted) {
167
+ const item = items[index];
168
+ index += 1;
169
+ await worker(item);
170
+ }
171
+ });
172
+ await Promise.all(runners);
173
+ }
174
+ /**
175
+ * Enumerate subdomains of `domain`.
176
+ *
177
+ * Two sources feed the candidate set: Certificate Transparency logs
178
+ * (passive, `ct` option) and a wordlist brute force over DNS. Candidates
179
+ * are resolved concurrently; hosts that only echo a wildcard DNS answer are
180
+ * suppressed unless Certificate Transparency vouches for them.
181
+ */
182
+ export async function knock(domain, options = {}) {
183
+ const opts = { ...defaults, ...options };
184
+ let target = normalizeDomain(domain);
185
+ if (opts.baseDomainOnly && !IPV4_PATTERN.test(target)) {
186
+ const rules = opts.psl ?? (await loadPublicSuffixList());
187
+ const base = getRegistrableDomain(target, rules, { icannOnly: opts.icannOnly });
188
+ if (base)
189
+ target = base;
190
+ }
191
+ const words = opts.words ?? (await loadWordlist(opts.wordlist));
192
+ const resolver = opts.resolver ?? makeResolver(opts);
193
+ const { signal } = opts;
194
+ signal?.throwIfAborted();
195
+ const started = Date.now();
196
+ const ct = { enabled: Boolean(opts.ct), names: 0, unresolved: [], error: null };
197
+ const candidates = new Map();
198
+ for (const word of words) {
199
+ candidates.set(`${word}.${target}`, new Set(['wordlist']));
200
+ }
201
+ if (opts.ct) {
202
+ try {
203
+ const names = await certSubdomains(target, {
204
+ timeout: opts.ctTimeout,
205
+ signal,
206
+ fetch: opts.fetch,
207
+ });
208
+ ct.names = names.length;
209
+ for (const name of names) {
210
+ const sources = candidates.get(name);
211
+ if (sources)
212
+ sources.add('ct');
213
+ else
214
+ candidates.set(name, new Set(['ct']));
215
+ }
216
+ }
217
+ catch (err) {
218
+ if (signal?.aborted)
219
+ throw err;
220
+ ct.error = err instanceof Error ? err.message : String(err);
221
+ }
222
+ }
223
+ const wildcardAddresses = words.length > 0 && opts.wildcardTests > 0
224
+ ? await detectWildcard(resolver, target, { tests: opts.wildcardTests, family: opts.family })
225
+ : new Set();
226
+ const results = [];
227
+ let queried = 0;
228
+ let errors = 0;
229
+ await pool([...candidates.entries()], async ([name, sources]) => {
230
+ const fromCt = sources.has('ct');
231
+ const emit = async (addresses) => {
232
+ const result = { name, addresses, sources: [...sources] };
233
+ if (opts.web)
234
+ result.web = await probeWeb(name, opts.webTimeout);
235
+ results.push(result);
236
+ opts.onResult?.(result);
237
+ };
238
+ if (fromCt && !sources.has('wordlist') && !opts.verify) {
239
+ await emit([]);
240
+ return;
241
+ }
242
+ const { addresses, error } = await lookupName(resolver, name, opts.family);
243
+ queried += 1;
244
+ if (addresses.length === 0) {
245
+ if (error)
246
+ errors += 1;
247
+ if (fromCt)
248
+ ct.unresolved.push(name);
249
+ return;
250
+ }
251
+ // A wordlist hit that only mirrors the wildcard answer is not a real
252
+ // discovery; a certificate in the CT logs is evidence on its own.
253
+ if (!fromCt &&
254
+ wildcardAddresses.size > 0 &&
255
+ addresses.every((address) => wildcardAddresses.has(address))) {
256
+ return;
257
+ }
258
+ await emit(addresses);
259
+ }, opts.concurrency, signal);
260
+ signal?.throwIfAborted();
261
+ ct.unresolved.sort();
262
+ return {
263
+ domain: target,
264
+ wildcard: { detected: wildcardAddresses.size > 0, addresses: [...wildcardAddresses] },
265
+ ct,
266
+ results,
267
+ stats: {
268
+ words: words.length,
269
+ candidates: candidates.size,
270
+ queried,
271
+ found: results.length,
272
+ errors,
273
+ durationMs: Date.now() - started,
274
+ },
275
+ };
276
+ }
277
+ export { publicSuffixListPath, parsePublicSuffixList, loadPublicSuffixList, getPublicSuffix, getRegistrableDomain, } from './psl.js';
278
+ export default knock;
package/dist/mcp.d.ts ADDED
@@ -0,0 +1,14 @@
1
+ import type { Readable, Writable } from 'node:stream';
2
+ import type { KnockOptions } from './index.ts';
3
+ /** Protocol revisions this server speaks, newest first. */
4
+ export declare const MCP_PROTOCOL_VERSIONS: string[];
5
+ export interface McpServerOptions {
6
+ input?: Readable;
7
+ output?: Writable;
8
+ /** Merged into every knock() / certSubdomains() call, mainly for testing (resolver, fetch, psl). */
9
+ knockOptions?: Pick<KnockOptions, 'resolver' | 'fetch' | 'psl'>;
10
+ }
11
+ /**
12
+ * Serve MCP over stdio (or the given streams). Resolves when the input ends.
13
+ */
14
+ export declare function serveMcp(options?: McpServerOptions): Promise<void>;
package/dist/mcp.js ADDED
@@ -0,0 +1,319 @@
1
+ /**
2
+ * knock as a Model Context Protocol server over stdio — newline-delimited
3
+ * JSON-RPC 2.0, implemented directly so the package stays dependency-free.
4
+ */
5
+ import { createInterface } from 'node:readline';
6
+ import { readFileSync } from 'node:fs';
7
+ import process from 'node:process';
8
+ import { knock, certSubdomains, normalizeDomain, wordlists, loadPublicSuffixList, getPublicSuffix, getRegistrableDomain, } from './index.js';
9
+ const { version } = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
10
+ /** Protocol revisions this server speaks, newest first. */
11
+ export const MCP_PROTOCOL_VERSIONS = ['2025-11-25', '2025-06-18', '2025-03-26', '2024-11-05'];
12
+ class InvalidParams extends Error {
13
+ }
14
+ const INSTRUCTIONS = `knock enumerates subdomains of a domain using Certificate Transparency logs (passive, via crt.sh) and a wordlist DNS brute force with wildcard detection.
15
+ Prefer knock_ct_lookup or knock_enumerate with passive: true for a quick, low-noise look; a full brute force sends ~31k DNS queries and takes roughly 30-90 seconds.
16
+ Only enumerate domains the user owns or is authorized to assess.`;
17
+ const domainProperty = {
18
+ type: 'string',
19
+ description: 'Domain, hostname or URL, e.g. "example.com" or "https://www.example.co.uk/path".',
20
+ };
21
+ const TOOLS = [
22
+ {
23
+ name: 'knock_enumerate',
24
+ title: 'Enumerate subdomains',
25
+ description: 'Find subdomains of a domain. Combines Certificate Transparency logs with a DNS brute force over a bundled wordlist, filters wildcard DNS, and verifies names with real lookups. Input is reduced to its registrable domain first (www.example.co.uk -> example.co.uk) unless fullHost is set. Returns the full report: results (name, addresses, sources), wildcard, ct (including unresolved CT names), and stats. A full run sends ~31k DNS queries; use passive: true for a CT-only lookup.',
26
+ inputSchema: {
27
+ type: 'object',
28
+ properties: {
29
+ domain: domainProperty,
30
+ passive: {
31
+ type: 'boolean',
32
+ description: 'Skip the wordlist brute force; Certificate Transparency only. Default false.',
33
+ },
34
+ ct: { type: 'boolean', description: 'Query Certificate Transparency logs. Default true.' },
35
+ verify: {
36
+ type: 'boolean',
37
+ description: 'Resolve CT-discovered names over DNS. Default true.',
38
+ },
39
+ fullHost: {
40
+ type: 'boolean',
41
+ description: 'Scan the exact host given instead of its registrable domain. Default false.',
42
+ },
43
+ wordlist: {
44
+ type: 'string',
45
+ enum: ['subs', 'org'],
46
+ description: 'Bundled wordlist: "subs" (~31k labels, default) or "org".',
47
+ },
48
+ words: {
49
+ type: 'array',
50
+ items: { type: 'string' },
51
+ description: 'Custom labels to try instead of a bundled wordlist, e.g. ["www", "api"].',
52
+ },
53
+ concurrency: {
54
+ type: 'integer',
55
+ minimum: 1,
56
+ maximum: 512,
57
+ description: 'Parallel DNS queries. Default 64.',
58
+ },
59
+ timeout: {
60
+ type: 'integer',
61
+ minimum: 1,
62
+ description: 'Per-query DNS timeout in milliseconds. Default 5000.',
63
+ },
64
+ servers: {
65
+ type: 'array',
66
+ items: { type: 'string' },
67
+ description: 'DNS server IPs to query instead of the system resolvers.',
68
+ },
69
+ ipv6: { type: 'boolean', description: 'Also resolve AAAA records. Default false.' },
70
+ web: {
71
+ type: 'boolean',
72
+ description: 'Probe http/https on each found host and report status codes. Default false.',
73
+ },
74
+ },
75
+ required: ['domain'],
76
+ additionalProperties: false,
77
+ },
78
+ annotations: { readOnlyHint: true, openWorldHint: true },
79
+ },
80
+ {
81
+ name: 'knock_ct_lookup',
82
+ title: 'Certificate Transparency lookup',
83
+ description: 'List hostnames under a domain that appear in Certificate Transparency logs (crt.sh). Passive: one HTTPS request to crt.sh, no DNS traffic to the target. Names are not verified and may be stale. The domain is used exactly as given (no registrable-domain reduction).',
84
+ inputSchema: {
85
+ type: 'object',
86
+ properties: {
87
+ domain: domainProperty,
88
+ timeout: {
89
+ type: 'integer',
90
+ minimum: 1,
91
+ description: 'Request timeout in milliseconds. Default 15000.',
92
+ },
93
+ },
94
+ required: ['domain'],
95
+ additionalProperties: false,
96
+ },
97
+ annotations: { readOnlyHint: true, openWorldHint: true },
98
+ },
99
+ {
100
+ name: 'knock_registrable_domain',
101
+ title: 'Registrable domain',
102
+ description: 'Reduce a hostname or URL to its public suffix and registrable domain using the bundled Public Suffix List, e.g. "a.b.example.co.uk" -> suffix "co.uk", registrable "example.co.uk". Offline; no network access.',
103
+ inputSchema: {
104
+ type: 'object',
105
+ properties: {
106
+ host: domainProperty,
107
+ icannOnly: {
108
+ type: 'boolean',
109
+ description: 'Ignore private suffixes like github.io. Default false.',
110
+ },
111
+ },
112
+ required: ['host'],
113
+ additionalProperties: false,
114
+ },
115
+ annotations: { readOnlyHint: true, openWorldHint: false },
116
+ },
117
+ ];
118
+ function optional(args, key, check, expected) {
119
+ const value = args[key];
120
+ if (value === undefined || value === null)
121
+ return undefined;
122
+ if (!check(value))
123
+ throw new InvalidParams(`"${key}" must be ${expected}`);
124
+ return value;
125
+ }
126
+ const isBoolean = (v) => typeof v === 'boolean';
127
+ const isString = (v) => typeof v === 'string';
128
+ const isPositiveInteger = (v) => Number.isInteger(v) && v > 0;
129
+ const isStringArray = (v) => Array.isArray(v) && v.every(isString);
130
+ function required(args, key) {
131
+ const value = optional(args, key, isString, 'a string');
132
+ if (!value)
133
+ throw new InvalidParams(`"${key}" is required`);
134
+ return value;
135
+ }
136
+ /**
137
+ * Serve MCP over stdio (or the given streams). Resolves when the input ends.
138
+ */
139
+ export async function serveMcp(options = {}) {
140
+ const { input = process.stdin, output = process.stdout, knockOptions = {} } = options;
141
+ const inFlight = new Map();
142
+ const send = (message) => {
143
+ output.write(`${JSON.stringify({ jsonrpc: '2.0', ...message })}\n`);
144
+ };
145
+ const reply = (id, result) => send({ id, result });
146
+ const replyError = (id, code, message) => send({ id, error: { code, message } });
147
+ const toolResult = (data, summary) => ({
148
+ content: [
149
+ ...(summary ? [{ type: 'text', text: summary }] : []),
150
+ { type: 'text', text: JSON.stringify(data) },
151
+ ],
152
+ structuredContent: data,
153
+ });
154
+ async function callTool(name, args, signal, progress) {
155
+ switch (name) {
156
+ case 'knock_enumerate': {
157
+ const domain = required(args, 'domain');
158
+ const passive = optional(args, 'passive', isBoolean, 'a boolean') ?? false;
159
+ const list = optional(args, 'wordlist', isString, 'a string');
160
+ if (list !== undefined && list !== 'subs' && list !== 'org') {
161
+ throw new InvalidParams('"wordlist" must be "subs" or "org"');
162
+ }
163
+ const run = {
164
+ ...knockOptions,
165
+ signal,
166
+ ct: optional(args, 'ct', isBoolean, 'a boolean') ?? true,
167
+ verify: optional(args, 'verify', isBoolean, 'a boolean') ?? true,
168
+ baseDomainOnly: !(optional(args, 'fullHost', isBoolean, 'a boolean') ?? false),
169
+ wordlist: wordlists[list ?? 'subs'],
170
+ words: passive ? [] : optional(args, 'words', isStringArray, 'an array of strings'),
171
+ concurrency: Math.min(optional(args, 'concurrency', isPositiveInteger, 'a positive integer') ?? 64, 512),
172
+ timeout: optional(args, 'timeout', isPositiveInteger, 'a positive integer'),
173
+ servers: optional(args, 'servers', isStringArray, 'an array of strings'),
174
+ family: optional(args, 'ipv6', isBoolean, 'a boolean') ? 'any' : 4,
175
+ web: optional(args, 'web', isBoolean, 'a boolean') ?? false,
176
+ };
177
+ if (passive && !run.ct)
178
+ throw new InvalidParams('passive with ct: false leaves nothing to do');
179
+ if (run.timeout === undefined)
180
+ delete run.timeout;
181
+ let found = 0;
182
+ run.onResult = (result) => {
183
+ found += 1;
184
+ progress(found, result.name);
185
+ };
186
+ const report = await knock(domain, run);
187
+ const { stats, wildcard, ct } = report;
188
+ const summary = `${report.domain}: ${stats.found} found from ${stats.candidates} candidates` +
189
+ (ct.enabled ? `, ${ct.names} CT names (${ct.unresolved.length} unresolved)` : '') +
190
+ (wildcard.detected ? `, wildcard DNS detected (${wildcard.addresses.join(', ')})` : '') +
191
+ (ct.error ? `, CT lookup failed: ${ct.error}` : '') +
192
+ ` in ${(stats.durationMs / 1000).toFixed(1)}s`;
193
+ return toolResult(report, summary);
194
+ }
195
+ case 'knock_ct_lookup': {
196
+ const domain = normalizeDomain(required(args, 'domain'));
197
+ const names = await certSubdomains(domain, {
198
+ signal,
199
+ fetch: knockOptions.fetch,
200
+ timeout: optional(args, 'timeout', isPositiveInteger, 'a positive integer'),
201
+ });
202
+ return toolResult({ domain, names }, `${names.length} CT names under ${domain}`);
203
+ }
204
+ case 'knock_registrable_domain': {
205
+ const host = normalizeDomain(required(args, 'host'));
206
+ const icannOnly = optional(args, 'icannOnly', isBoolean, 'a boolean') ?? false;
207
+ const rules = knockOptions.psl ?? (await loadPublicSuffixList());
208
+ return toolResult({
209
+ host,
210
+ publicSuffix: getPublicSuffix(host, rules, { icannOnly }),
211
+ registrableDomain: getRegistrableDomain(host, rules, { icannOnly }),
212
+ });
213
+ }
214
+ default:
215
+ throw new InvalidParams(`unknown tool: ${name}`);
216
+ }
217
+ }
218
+ async function handle(message) {
219
+ const { id, method } = message;
220
+ const params = (message.params ?? {});
221
+ const isRequest = typeof id === 'string' || typeof id === 'number';
222
+ if (typeof method !== 'string') {
223
+ // Responses to requests we never send; ignore. Malformed requests get an error.
224
+ if (isRequest && !('result' in message || 'error' in message)) {
225
+ replyError(id, -32600, 'invalid request');
226
+ }
227
+ return;
228
+ }
229
+ if (!isRequest) {
230
+ if (method === 'notifications/cancelled') {
231
+ inFlight.get(params.requestId)?.abort(new Error(String(params.reason ?? 'cancelled')));
232
+ }
233
+ return;
234
+ }
235
+ switch (method) {
236
+ case 'initialize': {
237
+ const requested = params.protocolVersion;
238
+ reply(id, {
239
+ protocolVersion: typeof requested === 'string' && MCP_PROTOCOL_VERSIONS.includes(requested)
240
+ ? requested
241
+ : MCP_PROTOCOL_VERSIONS[0],
242
+ capabilities: { tools: { listChanged: false } },
243
+ serverInfo: { name: 'knock', title: 'knock', version },
244
+ instructions: INSTRUCTIONS,
245
+ });
246
+ return;
247
+ }
248
+ case 'ping':
249
+ reply(id, {});
250
+ return;
251
+ case 'tools/list':
252
+ reply(id, { tools: TOOLS });
253
+ return;
254
+ case 'tools/call': {
255
+ const name = String(params.name ?? '');
256
+ const args = (params.arguments ?? {});
257
+ const progressToken = params._meta?.progressToken;
258
+ const controller = new AbortController();
259
+ inFlight.set(id, controller);
260
+ const progress = (count, text) => {
261
+ if (progressToken === undefined)
262
+ return;
263
+ send({
264
+ method: 'notifications/progress',
265
+ params: { progressToken, progress: count, message: `found ${text}` },
266
+ });
267
+ };
268
+ try {
269
+ reply(id, await callTool(name, args, controller.signal, progress));
270
+ }
271
+ catch (error) {
272
+ // A cancelled request gets no response, per the spec.
273
+ if (controller.signal.aborted)
274
+ return;
275
+ const text = error instanceof Error ? error.message : String(error);
276
+ if (error instanceof InvalidParams && text.startsWith('unknown tool')) {
277
+ replyError(id, -32602, text);
278
+ }
279
+ else {
280
+ reply(id, { content: [{ type: 'text', text }], isError: true });
281
+ }
282
+ }
283
+ finally {
284
+ inFlight.delete(id);
285
+ }
286
+ return;
287
+ }
288
+ default:
289
+ replyError(id, -32601, `method not found: ${method}`);
290
+ }
291
+ }
292
+ const lines = createInterface({ input, crlfDelay: Infinity });
293
+ const pending = new Set();
294
+ for await (const line of lines) {
295
+ if (line.trim() === '')
296
+ continue;
297
+ let message;
298
+ try {
299
+ message = JSON.parse(line);
300
+ }
301
+ catch {
302
+ replyError(null, -32700, 'parse error');
303
+ continue;
304
+ }
305
+ if (typeof message !== 'object' || message === null || Array.isArray(message)) {
306
+ replyError(null, -32600, 'invalid request');
307
+ continue;
308
+ }
309
+ // Requests run concurrently so pings and cancellations stay responsive during a scan.
310
+ const task = handle(message).catch((error) => {
311
+ process.stderr.write(`knock mcp: ${error instanceof Error ? error.stack : String(error)}\n`);
312
+ });
313
+ pending.add(task);
314
+ void task.finally(() => pending.delete(task));
315
+ }
316
+ for (const controller of inFlight.values())
317
+ controller.abort(new Error('input closed'));
318
+ await Promise.allSettled(pending);
319
+ }
package/dist/psl.d.ts ADDED
@@ -0,0 +1,36 @@
1
+ /** Absolute path to the vendored Public Suffix List. */
2
+ export declare const publicSuffixListPath: string;
3
+ /** A single Public Suffix List rule. */
4
+ export interface PublicSuffixRule {
5
+ /** The rule began with `!`, marking an exception to a wildcard rule. */
6
+ exception: boolean;
7
+ /** From the ICANN section rather than the PRIVATE section of the list. */
8
+ icann: boolean;
9
+ }
10
+ /** Parsed rule set: punycode rule string (without any leading `!`) → rule. */
11
+ export type PublicSuffixRules = Map<string, PublicSuffixRule>;
12
+ /** Options shared by the Public Suffix List lookups. */
13
+ export interface PublicSuffixOptions {
14
+ /** Ignore the PRIVATE section (github.io, herokuapp.com, …). Default false. */
15
+ icannOnly?: boolean;
16
+ }
17
+ /**
18
+ * Parse Public Suffix List text into a rule map. Each line is read only up to
19
+ * the first whitespace; `//` lines and blanks are skipped. The ICANN/PRIVATE
20
+ * section is tracked from the `===BEGIN …===` markers.
21
+ */
22
+ export declare function parsePublicSuffixList(text: string): PublicSuffixRules;
23
+ /** Read and parse a Public Suffix List file, memoized per path. */
24
+ export declare function loadPublicSuffixList(path?: string): Promise<PublicSuffixRules>;
25
+ /**
26
+ * The public suffix of `hostname` (e.g. "co.uk", "github.io"), or null when the
27
+ * hostname has fewer labels than the matching rule. `hostname` should already
28
+ * be a bare, lowercased, punycode host as produced by `normalizeDomain`.
29
+ */
30
+ export declare function getPublicSuffix(hostname: string, rules: PublicSuffixRules, options?: PublicSuffixOptions): string | null;
31
+ /**
32
+ * The registrable domain of `hostname` — its public suffix plus one label
33
+ * (e.g. "www.example.co.uk" → "example.co.uk"). Returns null when `hostname` is
34
+ * itself a public suffix (or shorter), i.e. has no registrable domain.
35
+ */
36
+ export declare function getRegistrableDomain(hostname: string, rules: PublicSuffixRules, options?: PublicSuffixOptions): string | null;