agent402-mcp 0.13.12 → 0.13.13
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/discovery.js +62 -0
- package/index.js +55 -21
- package/output-schemas.js +65 -1
- package/package.json +2 -1
package/discovery.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// Discovery reads (the catalog, /api/find, skill packs, leaderboards, a
|
|
2
|
+
// proof-of-work challenge) settle nothing, so a 503 the server sheds under
|
|
3
|
+
// load is safe to repeat. It is retried only when it names its own delay
|
|
4
|
+
// (a Retry-After header or a retryAfterSeconds body field), at most
|
|
5
|
+
// DISCOVERY_ATTEMPTS tries in all, each wait capped at DISCOVERY_MAX_WAIT_MS.
|
|
6
|
+
// A paid or priced call never goes through here: a repeat of one could settle
|
|
7
|
+
// twice.
|
|
8
|
+
export const DISCOVERY_ATTEMPTS = 3;
|
|
9
|
+
export const DISCOVERY_MAX_WAIT_MS = 5000;
|
|
10
|
+
|
|
11
|
+
const sleepMs = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
12
|
+
|
|
13
|
+
/** The wait (ms) a shed 503 asks for, or null when `r` is not one. */
|
|
14
|
+
export async function shedRetryWaitMs(r, maxWaitMs = DISCOVERY_MAX_WAIT_MS) {
|
|
15
|
+
if (!r || r.status !== 503) return null;
|
|
16
|
+
const header = r.headers && typeof r.headers.get === "function" ? r.headers.get("retry-after") : null;
|
|
17
|
+
let seconds = header != null && String(header).trim() !== "" ? Number(header) : NaN;
|
|
18
|
+
if (!Number.isFinite(seconds)) {
|
|
19
|
+
try {
|
|
20
|
+
const body = await (typeof r.clone === "function" ? r.clone() : r).json();
|
|
21
|
+
seconds = Number(body && body.retryAfterSeconds);
|
|
22
|
+
} catch { seconds = NaN; }
|
|
23
|
+
}
|
|
24
|
+
if (!Number.isFinite(seconds) || seconds < 0) return null;
|
|
25
|
+
return Math.min(seconds * 1000, maxWaitMs);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Fetch a discovery surface, retrying a shed 503. Resolves with the last
|
|
30
|
+
* response (2xx or not) and how many attempts it took; the caller decides how
|
|
31
|
+
* to report a failure, naming the surface and `res.status`.
|
|
32
|
+
*/
|
|
33
|
+
export async function fetchDiscovery(url, init, { fetchImpl = globalThis.fetch, sleep = sleepMs, attempts: max = DISCOVERY_ATTEMPTS } = {}) {
|
|
34
|
+
let res;
|
|
35
|
+
let attempts = 0;
|
|
36
|
+
for (;;) {
|
|
37
|
+
attempts++;
|
|
38
|
+
res = await fetchImpl(url, init);
|
|
39
|
+
if (res.ok || attempts >= max) return { res, attempts };
|
|
40
|
+
const wait = await shedRetryWaitMs(res);
|
|
41
|
+
if (wait == null) return { res, attempts };
|
|
42
|
+
await sleep(wait);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** "HTTP 503 from /api/find after 3 attempts": the tail of a discovery failure. */
|
|
47
|
+
export function discoveryFailure(url, res, attempts) {
|
|
48
|
+
const path = (() => { try { return new URL(String(url)).pathname; } catch { return String(url).split("?")[0]; } })();
|
|
49
|
+
return `HTTP ${res.status} from ${path}${attempts > 1 ? ` after ${attempts} attempts` : ""}`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** GET JSON from a discovery surface; throws `${label} failed: HTTP <status> from <path>[ after N attempts]`. */
|
|
53
|
+
export async function getDiscoveryJson(label, url, init, opts) {
|
|
54
|
+
const { res, attempts } = await fetchDiscovery(url, init, opts);
|
|
55
|
+
if (!res.ok) {
|
|
56
|
+
const err = new Error(`${label} failed: ${discoveryFailure(url, res, attempts)}`);
|
|
57
|
+
err.status = res.status;
|
|
58
|
+
err.attempts = attempts;
|
|
59
|
+
throw err;
|
|
60
|
+
}
|
|
61
|
+
return res.json();
|
|
62
|
+
}
|
package/index.js
CHANGED
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
import { createHash } from "node:crypto";
|
|
26
26
|
import { readFileSync } from "node:fs";
|
|
27
27
|
import { parseNetworkPrefs, withNetworkPreference } from "./networks.js";
|
|
28
|
+
import { fetchDiscovery, discoveryFailure, getDiscoveryJson } from "./discovery.js";
|
|
28
29
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
29
30
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
30
31
|
import {
|
|
@@ -32,6 +33,8 @@ import {
|
|
|
32
33
|
ListToolsRequestSchema,
|
|
33
34
|
ListPromptsRequestSchema,
|
|
34
35
|
GetPromptRequestSchema,
|
|
36
|
+
McpError,
|
|
37
|
+
ErrorCode,
|
|
35
38
|
} from "@modelcontextprotocol/sdk/types.js";
|
|
36
39
|
import {
|
|
37
40
|
FLAGSHIP_MCP_NAMES,
|
|
@@ -44,6 +47,7 @@ import {
|
|
|
44
47
|
mcpJsonResult,
|
|
45
48
|
outputSchemaFromExample,
|
|
46
49
|
mcpInitializeInstructions,
|
|
50
|
+
toolInputBoundRefusal,
|
|
47
51
|
} from "./output-schemas.js";
|
|
48
52
|
|
|
49
53
|
const BASE = (process.env.AGENT402_URL || "https://agent402.tools").replace(/\/$/, "");
|
|
@@ -91,12 +95,16 @@ const catalog = new Map(); // slug -> { slug, method, path, price, description,
|
|
|
91
95
|
// prompts/list will just return an empty array in that case.
|
|
92
96
|
let skillPacks = [];
|
|
93
97
|
|
|
98
|
+
// Each surface retries a shed 503 (./discovery.js). A failed pricing or
|
|
99
|
+
// openapi read throws, so the catalog is never built from an error body; the
|
|
100
|
+
// skill packs are optional and read as empty.
|
|
94
101
|
async function loadCatalog() {
|
|
95
102
|
const [pricing, openapi, packs] = await Promise.all([
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
103
|
+
getDiscoveryJson("catalog load", `${BASE}/api/pricing`),
|
|
104
|
+
getDiscoveryJson("catalog load", `${BASE}/openapi.json`),
|
|
105
|
+
getDiscoveryJson("skill packs", `${BASE}/api/skill-packs.json`).catch(() => ({ packs: [] })),
|
|
99
106
|
]);
|
|
107
|
+
if (!Array.isArray(pricing?.endpoints)) throw new Error(`catalog load failed: ${BASE}/api/pricing carried no endpoints list`);
|
|
100
108
|
skillPacks = Array.isArray(packs?.packs) ? packs.packs : [];
|
|
101
109
|
for (const e of pricing.endpoints) {
|
|
102
110
|
const slug = e.slug ?? e.docs?.split("/tools/").pop();
|
|
@@ -345,7 +353,7 @@ async function callEndpoint(tool, args = {}) {
|
|
|
345
353
|
} else if (tool.computePayable) {
|
|
346
354
|
// No wallet: pay with compute up front, solving before the call skips the
|
|
347
355
|
// 402 round-trip entirely (challenges are single-use and tool-scoped).
|
|
348
|
-
const challenge = await (
|
|
356
|
+
const challenge = await getDiscoveryJson(`proof-of-work challenge for "${tool.slug}"`, `${BASE}/api/pow/challenge?slug=${encodeURIComponent(tool.slug)}`);
|
|
349
357
|
const nonce = solvePow(challenge);
|
|
350
358
|
res = await fetch(url, { ...init, headers: { ...init.headers, "X-Pow-Solution": `${challenge.token}:${nonce}` } });
|
|
351
359
|
} else {
|
|
@@ -506,15 +514,34 @@ server.setRequestHandler(GetPromptRequestSchema, async (req) => {
|
|
|
506
514
|
for (const [k, v] of Object.entries(args || {})) {
|
|
507
515
|
if (v !== undefined && v !== null && v !== "") url.searchParams.set(k, String(v));
|
|
508
516
|
}
|
|
509
|
-
const res = await
|
|
510
|
-
if (!res.ok) throw new Error(`Failed to render prompt "${name}" from ${BASE}:
|
|
517
|
+
const { res, attempts } = await fetchDiscovery(url);
|
|
518
|
+
if (!res.ok) throw new Error(`Failed to render prompt "${name}" from ${BASE}: ${discoveryFailure(url, res, attempts)}`);
|
|
511
519
|
return await res.json();
|
|
512
520
|
});
|
|
513
521
|
|
|
514
522
|
let curated = [];
|
|
515
523
|
let pricingInfo = null;
|
|
516
524
|
|
|
517
|
-
|
|
525
|
+
// A catalog that could not load at boot (a shed or unreachable service) is
|
|
526
|
+
// read again on the next tools/list or tools/call, at most once per
|
|
527
|
+
// CATALOG_RETRY_MS, so a session that started during a busy minute recovers
|
|
528
|
+
// without a restart.
|
|
529
|
+
const CATALOG_RETRY_MS = 30_000;
|
|
530
|
+
let catalogRetryAt = 0;
|
|
531
|
+
async function ensureCatalog() {
|
|
532
|
+
if (catalog.size > 0 || Date.now() < catalogRetryAt) return;
|
|
533
|
+
catalogRetryAt = Date.now() + CATALOG_RETRY_MS;
|
|
534
|
+
try {
|
|
535
|
+
pricingInfo = await loadCatalog();
|
|
536
|
+
curated = requested.map((slug) => catalog.get(slug)).filter(Boolean);
|
|
537
|
+
log(`catalog: ${catalog.size} tools from ${BASE} on a later read; ${curated.length} first-class`);
|
|
538
|
+
} catch (err) {
|
|
539
|
+
log(`Could not load the catalog from ${BASE}: ${err.message}: next read in ${CATALOG_RETRY_MS / 1000}s`);
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
const withCatalog = (handler) => async (req) => { await ensureCatalog(); return handler(req); };
|
|
543
|
+
|
|
544
|
+
server.setRequestHandler(ListToolsRequestSchema, withCatalog(async () => {
|
|
518
545
|
const SAFE = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false };
|
|
519
546
|
const OPEN = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true };
|
|
520
547
|
const WRITE = { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false };
|
|
@@ -633,7 +660,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
633
660
|
properties: {
|
|
634
661
|
task: { type: "string", description: "Plain-language task, e.g. 'crypto news headlines' or 'sentiment of a tweet'. Resolved to a proven external seller." },
|
|
635
662
|
params: { type: "object", description: "Input parameters for the resolved external tool (optional; the seller decides its own schema)." },
|
|
636
|
-
maxUsd: { type: "number", description: "Max underlying seller price in USD. Default 0.005 (the $0.01 fee tier);
|
|
663
|
+
maxUsd: { type: "number", description: "Max underlying seller price in USD. Default 0.005 (the $0.01 fee tier); above 0.005 and up to 0.04 uses the $0.05 tier; above 0.04 the $0.55 tier (seller up to $0.50)." },
|
|
637
664
|
},
|
|
638
665
|
required: ["task"],
|
|
639
666
|
},
|
|
@@ -641,20 +668,26 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
641
668
|
}
|
|
642
669
|
);
|
|
643
670
|
return { tools };
|
|
644
|
-
});
|
|
671
|
+
}));
|
|
645
672
|
|
|
646
|
-
server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
673
|
+
server.setRequestHandler(CallToolRequestSchema, withCatalog(async (req) => {
|
|
647
674
|
const { name: rawName, arguments: args = {} } = req.params;
|
|
648
675
|
const name = resolveListedName(rawName);
|
|
676
|
+
const overBound = toolInputBoundRefusal(rawName, args);
|
|
677
|
+
if (overBound) throw new McpError(ErrorCode.InvalidParams, overBound);
|
|
649
678
|
try {
|
|
650
679
|
if (name === "catalog.search") {
|
|
651
|
-
const q = args.query ?? "";
|
|
680
|
+
const q = String(args.query ?? "").trim();
|
|
652
681
|
const results = searchTools(q, args.limit ?? 10);
|
|
653
682
|
const workflows = rankWorkflows(q, 2);
|
|
654
683
|
if (!results.length && !workflows.length) {
|
|
684
|
+
// A caller that passed no query is told which argument to pass, not
|
|
685
|
+
// shown an empty string in quotes.
|
|
655
686
|
return mcpJsonResult({
|
|
656
687
|
results: [],
|
|
657
|
-
message:
|
|
688
|
+
message: q
|
|
689
|
+
? `No tools matched "${q}". Browse the catalog at ${BASE}/tools or ${BASE}/api/pricing.`
|
|
690
|
+
: `catalog.search needs a "query" argument: what the tool should do (for example "convert miles to kilometers"). Browse the catalog at ${BASE}/tools or ${BASE}/api/pricing.`,
|
|
658
691
|
});
|
|
659
692
|
}
|
|
660
693
|
return mcpJsonResult({
|
|
@@ -672,9 +705,9 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
|
672
705
|
const url = new URL(`${BASE}/api/find`);
|
|
673
706
|
url.searchParams.set("q", task);
|
|
674
707
|
url.searchParams.set("k", String(limit));
|
|
675
|
-
const res = await
|
|
708
|
+
const { res, attempts } = await fetchDiscovery(url);
|
|
676
709
|
if (!res.ok) {
|
|
677
|
-
return { content: [{ type: "text", text: `catalog.find failed against ${BASE}/api/find:
|
|
710
|
+
return { content: [{ type: "text", text: `catalog.find failed against ${BASE}/api/find: ${discoveryFailure(url, res, attempts)}` }], isError: true };
|
|
678
711
|
}
|
|
679
712
|
const body = await res.json();
|
|
680
713
|
const matches = (body.results || []).map((t) => ({
|
|
@@ -770,9 +803,9 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
|
770
803
|
const limit = Math.min(Math.max(parseInt(args.limit, 10) || 10, 1), 50);
|
|
771
804
|
const sort = args.sort === "usd" ? "usd" : "calls";
|
|
772
805
|
const include = args.include === "all" ? "all" : "external";
|
|
773
|
-
const res = await
|
|
806
|
+
const { res, attempts } = await fetchDiscovery(`${BASE}/api/mpp-leaderboard`);
|
|
774
807
|
if (!res.ok) {
|
|
775
|
-
return { content: [{ type: "text", text: `Failed to fetch the MPP leaderboard from ${BASE}:
|
|
808
|
+
return { content: [{ type: "text", text: `Failed to fetch the MPP leaderboard from ${BASE}: ${discoveryFailure(`${BASE}/api/mpp-leaderboard`, res, attempts)}` }], isError: true };
|
|
776
809
|
}
|
|
777
810
|
const lb = await res.json();
|
|
778
811
|
let board = (Array.isArray(lb.rows) ? lb.rows : []).filter((r) => r.transfers > 0 || (r.d30?.transfers || 0) > 0);
|
|
@@ -821,9 +854,9 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
|
821
854
|
url.searchParams.set("top", String(limit));
|
|
822
855
|
url.searchParams.set("sort", sort);
|
|
823
856
|
url.searchParams.set("include", include);
|
|
824
|
-
const res = await
|
|
857
|
+
const { res, attempts } = await fetchDiscovery(url);
|
|
825
858
|
if (!res.ok) {
|
|
826
|
-
return { content: [{ type: "text", text: `Failed to fetch leaderboard from ${BASE}:
|
|
859
|
+
return { content: [{ type: "text", text: `Failed to fetch leaderboard from ${BASE}: ${discoveryFailure(url, res, attempts)}` }], isError: true };
|
|
827
860
|
}
|
|
828
861
|
const snap = await res.json();
|
|
829
862
|
// Trim to the same compact row shape the hosted MCP connector returns,
|
|
@@ -896,7 +929,7 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
|
896
929
|
} catch (err) {
|
|
897
930
|
return { content: [{ type: "text", text: `Agent402 call failed: ${err.message}` }], isError: true };
|
|
898
931
|
}
|
|
899
|
-
});
|
|
932
|
+
}));
|
|
900
933
|
|
|
901
934
|
// ---------------------------------------------------------------------------
|
|
902
935
|
try {
|
|
@@ -905,9 +938,10 @@ try {
|
|
|
905
938
|
// Don't hard-exit: starting with an empty catalog still lets the server
|
|
906
939
|
// connect and answer introspection (tools/list), required to pass directory
|
|
907
940
|
// health checks (e.g. Glama) and more resilient if the catalog endpoint is
|
|
908
|
-
// briefly unreachable.
|
|
909
|
-
//
|
|
941
|
+
// briefly unreachable. The next tools/list or tools/call reads it again
|
|
942
|
+
// (ensureCatalog).
|
|
910
943
|
log(`Could not load the catalog from ${BASE}: ${err.message}: starting with an empty catalog`);
|
|
944
|
+
catalogRetryAt = Date.now() + CATALOG_RETRY_MS;
|
|
911
945
|
}
|
|
912
946
|
const requested = (process.env.AGENT402_TOOLS || DEFAULT_CURATED.join(","))
|
|
913
947
|
.split(",").map((s) => s.trim()).filter(Boolean);
|
package/output-schemas.js
CHANGED
|
@@ -85,10 +85,27 @@ export const MCP_CALL_ALIASES = {
|
|
|
85
85
|
route_and_execute: "router.execute",
|
|
86
86
|
};
|
|
87
87
|
|
|
88
|
-
/**
|
|
88
|
+
/** Every listed dotted name (flagships, meta tools, the router). */
|
|
89
|
+
const LISTED_DOTTED = new Set([
|
|
90
|
+
...Object.values(FLAGSHIP_MCP_NAMES),
|
|
91
|
+
...Object.values(META_MCP_NAMES),
|
|
92
|
+
...Object.values(MCP_CALL_ALIASES),
|
|
93
|
+
]);
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Resolve any CallTool name to the canonical listed dotted name: a prior
|
|
97
|
+
* public alias (MCP_CALL_ALIASES), or the snake form of a listed dotted name
|
|
98
|
+
* (catalog_search, catalog_find, payment_info, web_search), which a client
|
|
99
|
+
* that cannot send a dot in a tool name produces. Anything else is returned as
|
|
100
|
+
* given, so a raw catalog slug still reaches the slug lookup.
|
|
101
|
+
*/
|
|
89
102
|
export function resolveListedName(name) {
|
|
90
103
|
if (!name) return name;
|
|
91
104
|
if (MCP_CALL_ALIASES[name]) return MCP_CALL_ALIASES[name];
|
|
105
|
+
if (typeof name === "string" && name.includes("_") && !name.includes(".")) {
|
|
106
|
+
const dotted = name.replace(/_/g, ".");
|
|
107
|
+
if (LISTED_DOTTED.has(dotted)) return dotted;
|
|
108
|
+
}
|
|
92
109
|
return name;
|
|
93
110
|
}
|
|
94
111
|
|
|
@@ -528,3 +545,50 @@ export function mcpInitializeInstructions(baseUrl, { decide = false, mpp = false
|
|
|
528
545
|
`Docs: ${baseUrl}/llms.txt · ${baseUrl}/api/find?q=… · status ${baseUrl}/status`,
|
|
529
546
|
].join("\n");
|
|
530
547
|
}
|
|
548
|
+
|
|
549
|
+
// Tool input element bound. The SDK's maxToolInputElements option lives on
|
|
550
|
+
// McpServer and is checked inside McpServer's own tools/call handler; both
|
|
551
|
+
// connectors use the low-level Server with their own CallTool handler, where
|
|
552
|
+
// that option is never read. So the same check runs here, with the SDK's
|
|
553
|
+
// counting rule (every array element and every own object member, at any
|
|
554
|
+
// depth) and its InvalidParams message.
|
|
555
|
+
//
|
|
556
|
+
// The bound: a JSON element costs at least two bytes ("0,"), so the hosted
|
|
557
|
+
// connector's 100kb body parser admits at most 51,200 of them. A bound at that
|
|
558
|
+
// figure never refuses a body the parser accepts, and the largest
|
|
559
|
+
// handler-capped input (three 10,000-value series) sits well under it. On the
|
|
560
|
+
// stdio package, which reads lines with no byte cap, an argument tree past it
|
|
561
|
+
// is refused before it is serialized and sent to a server that would 413 it.
|
|
562
|
+
export const MCP_MAX_TOOL_INPUT_ELEMENTS = 51_200;
|
|
563
|
+
|
|
564
|
+
// Counts array elements and object members in `value`, stopping once the
|
|
565
|
+
// count passes `max` (so a huge tree costs at most max + 1 steps).
|
|
566
|
+
export function toolInputElementCount(value, max = MCP_MAX_TOOL_INPUT_ELEMENTS) {
|
|
567
|
+
let count = 0;
|
|
568
|
+
const stack = [value];
|
|
569
|
+
while (stack.length > 0) {
|
|
570
|
+
const node = stack.pop();
|
|
571
|
+
if (node === null || typeof node !== "object") continue;
|
|
572
|
+
if (Array.isArray(node)) {
|
|
573
|
+
for (const child of node) {
|
|
574
|
+
if (++count > max) return count;
|
|
575
|
+
if (child !== null && typeof child === "object") stack.push(child);
|
|
576
|
+
}
|
|
577
|
+
} else {
|
|
578
|
+
for (const key in node) {
|
|
579
|
+
if (!Object.prototype.hasOwnProperty.call(node, key)) continue;
|
|
580
|
+
if (++count > max) return count;
|
|
581
|
+
const child = node[key];
|
|
582
|
+
if (child !== null && typeof child === "object") stack.push(child);
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
return count;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// The refusal text for an over-bound call, or null when the input is within
|
|
590
|
+
// the bound. Same wording as the SDK's McpServer refusal.
|
|
591
|
+
export function toolInputBoundRefusal(toolName, args, max = MCP_MAX_TOOL_INPUT_ELEMENTS) {
|
|
592
|
+
if (toolInputElementCount(args, max) <= max) return null;
|
|
593
|
+
return `Invalid arguments for tool ${toolName}: arguments contain more than the maximum of ${max} elements`;
|
|
594
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent402-mcp",
|
|
3
|
-
"version": "0.13.
|
|
3
|
+
"version": "0.13.13",
|
|
4
4
|
"mcpName": "io.github.MikeyPetrillo/agent402",
|
|
5
5
|
"author": "Havok Holdings LLC <mike@agent402.tools> (https://agent402.tools)",
|
|
6
6
|
"homepage": "https://agent402.tools",
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
"files": [
|
|
18
18
|
"index.js",
|
|
19
19
|
"networks.js",
|
|
20
|
+
"discovery.js",
|
|
20
21
|
"output-schemas.js",
|
|
21
22
|
"README.md"
|
|
22
23
|
],
|